use super::bundle::{
    SmpContainerProfile, classify_container_profile, classify_header_variant_probe,
    classify_secondary_variant_probe, parse_shared_header, probe_early_content_layout,
};
use crate::inspect::smp::common::*;
use crate::inspect::smp::*;

mod model;

pub use model::*;

pub fn inspect_map_title_hint_file(
    path: &Path,
) -> Result<Option<SmpMapTitleHintProbe>, Box<dyn std::error::Error>> {
    let bytes = fs::read(path)?;
    let file_extension_hint = path
        .extension()
        .and_then(|extension| extension.to_str())
        .map(|extension| extension.to_ascii_lowercase());
    Ok(inspect_map_title_hint_bytes(
        &bytes,
        file_extension_hint.as_deref(),
    ))
}

pub fn inspect_map_title_hint_bytes(
    bytes: &[u8],
    file_extension_hint: Option<&str>,
) -> Option<SmpMapTitleHintProbe> {
    let shared_header = parse_shared_header(bytes);
    let header_variant_probe = shared_header.as_ref().map(classify_header_variant_probe);
    let first_ascii_run = find_first_ascii_run(bytes);
    let early_content_probe = first_ascii_run
        .as_ref()
        .and_then(|ascii_run| probe_early_content_layout(bytes, ascii_run));
    let secondary_variant_probe = early_content_probe
        .as_ref()
        .and_then(classify_secondary_variant_probe);
    let container_profile = classify_container_profile(
        file_extension_hint,
        header_variant_probe.as_ref(),
        secondary_variant_probe.as_ref(),
    );
    parse_map_title_hint_probe(bytes, file_extension_hint, container_profile.as_ref())
}

pub(super) fn parse_map_title_hint_probe(
    bytes: &[u8],
    file_extension_hint: Option<&str>,
    container_profile: Option<&SmpContainerProfile>,
) -> Option<SmpMapTitleHintProbe> {
    if file_extension_hint != Some("gmp") {
        return None;
    }

    let grounded_title_hits = POST_LOAD_SCENARIO_FIXUP_TITLE_SET
        .iter()
        .filter_map(|title| {
            let offset = find_first_subsequence_offset(bytes, title.as_bytes())?;
            Some(SmpMapTitleHintTitleHit {
                title: (*title).to_string(),
                earliest_offset: offset,
            })
        })
        .collect::<Vec<_>>();

    let embedded_map_references = find_ascii_fragment_occurrences_with_suffix(bytes, ".gmp")
        .into_iter()
        .map(|(offset, text)| SmpMapTitleHintMapReference { offset, text })
        .collect::<Vec<_>>();

    let adjacent_reference_title_pairs =
        build_map_title_hint_adjacent_pairs(&embedded_map_references, &grounded_title_hits);
    let strongest_same_stem_pair = adjacent_reference_title_pairs
        .iter()
        .find(|pair| pair.normalized_stem_match)
        .cloned();

    if grounded_title_hits.is_empty()
        && embedded_map_references.is_empty()
        && strongest_same_stem_pair.is_none()
    {
        return None;
    }

    Some(SmpMapTitleHintProbe {
        source_kind: "grounded-title-string-scan".to_string(),
        profile_family: container_profile.map(|profile| profile.profile_family.clone()),
        grounded_title_hits,
        embedded_map_references,
        adjacent_reference_title_pairs,
        strongest_same_stem_pair,
    })
}

pub(super) fn build_map_title_hint_adjacent_pairs(
    map_references: &[SmpMapTitleHintMapReference],
    title_hits: &[SmpMapTitleHintTitleHit],
) -> Vec<SmpMapTitleHintAdjacentPair> {
    let mut pairs = Vec::new();

    for map_reference in map_references {
        let mut best_pair: Option<SmpMapTitleHintAdjacentPair> = None;
        for title_hit in title_hits {
            let byte_distance = map_reference.offset.abs_diff(title_hit.earliest_offset);
            if byte_distance > MAP_TITLE_HINT_REFERENCE_PAIR_DISTANCE_LIMIT {
                continue;
            }
            let candidate = SmpMapTitleHintAdjacentPair {
                map_reference_offset: map_reference.offset,
                map_reference_text: map_reference.text.clone(),
                title_offset: title_hit.earliest_offset,
                title: title_hit.title.clone(),
                byte_distance,
                normalized_stem_match: normalize_map_title_hint_stem(&map_reference.text)
                    == normalize_map_title_hint_stem(&title_hit.title),
            };
            let replace = match &best_pair {
                Some(current) => {
                    (candidate.normalized_stem_match && !current.normalized_stem_match)
                        || (candidate.normalized_stem_match == current.normalized_stem_match
                            && candidate.byte_distance < current.byte_distance)
                }
                None => true,
            };
            if replace {
                best_pair = Some(candidate);
            }
        }
        if let Some(pair) = best_pair {
            pairs.push(pair);
        }
    }

    pairs.sort_by_key(|pair| {
        (
            !pair.normalized_stem_match,
            pair.byte_distance,
            pair.map_reference_offset,
        )
    });
    pairs
}

pub(super) fn normalize_map_title_hint_stem(text: &str) -> String {
    text.trim()
        .trim_end_matches(".gmp")
        .trim_end_matches(".GMP")
        .to_ascii_lowercase()
}

pub(super) fn find_first_subsequence_offset(bytes: &[u8], needle: &[u8]) -> Option<usize> {
    if needle.is_empty() || bytes.len() < needle.len() {
        return None;
    }
    bytes
        .windows(needle.len())
        .position(|window| window == needle)
}

pub(super) fn find_ascii_fragment_occurrences_with_suffix(
    bytes: &[u8],
    suffix: &str,
) -> Vec<(usize, String)> {
    let suffix_bytes = suffix.as_bytes();
    if suffix_bytes.is_empty() || bytes.len() < suffix_bytes.len() {
        return Vec::new();
    }

    let mut occurrences = Vec::new();
    let mut seen_offsets = BTreeSet::new();
    for offset in 0..=bytes.len() - suffix_bytes.len() {
        if &bytes[offset..offset + suffix_bytes.len()] != suffix_bytes {
            continue;
        }
        if let Some((start, text)) = extract_ascii_fragment_containing(bytes, offset) {
            if seen_offsets.insert(start) {
                occurrences.push((start, text));
            }
        }
    }
    occurrences
}

pub(super) fn extract_ascii_fragment_containing(
    bytes: &[u8],
    offset: usize,
) -> Option<(usize, String)> {
    if offset >= bytes.len() || !is_map_title_hint_ascii_fragment_byte(bytes[offset]) {
        return None;
    }

    let mut start = offset;
    while start > 0 && is_map_title_hint_ascii_fragment_byte(bytes[start - 1]) {
        start -= 1;
    }

    let mut end = offset;
    while end < bytes.len() && is_map_title_hint_ascii_fragment_byte(bytes[end]) {
        end += 1;
    }

    if end <= start {
        return None;
    }
    let len = (end - start).min(MAP_TITLE_HINT_ASCII_FRAGMENT_MAX_LEN);
    let text = String::from_utf8_lossy(&bytes[start..start + len])
        .trim()
        .to_string();
    if text.is_empty() {
        return None;
    }
    Some((start, text))
}

pub(super) fn is_map_title_hint_ascii_fragment_byte(byte: u8) -> bool {
    matches!(byte, b' ' | b'!' | b'-' | b'.' | b'/' | b'\\' | b':' | b'_' | b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z')
}

pub(super) fn build_ascii_preview(bytes: &[u8], start: usize, end: usize) -> SmpAsciiPreview {
    let byte_len = end - start;
    let preview_bytes = &bytes[start..end];
    let preview = String::from_utf8_lossy(
        &preview_bytes[..preview_bytes.len().min(ASCII_PREVIEW_CHAR_LIMIT)],
    )
    .into_owned();

    SmpAsciiPreview {
        offset: start,
        byte_len,
        truncated: byte_len > ASCII_PREVIEW_CHAR_LIMIT,
        preview,
    }
}

pub(super) fn is_ascii_preview_byte(byte: u8) -> bool {
    matches!(byte, b' ' | b'\t' | b'\n' | b'\r' | 0x21..=0x7e)
}
