use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::Path;

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BuildingTypeSourceKind {
    Bca,
    Bty,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BuildingTypeSourceFile {
    pub file_name: String,
    pub raw_stem: String,
    pub canonical_stem: String,
    pub source_kind: BuildingTypeSourceKind,
    #[serde(default)]
    pub byte_len: Option<usize>,
    #[serde(default)]
    pub bca_selector_probe: Option<BuildingTypeBcaSelectorProbe>,
    #[serde(default)]
    pub bty_header_probe: Option<BuildingTypeBtyHeaderProbe>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BuildingTypeSourceEntry {
    pub canonical_stem: String,
    pub raw_stems: Vec<String>,
    pub source_kinds: Vec<BuildingTypeSourceKind>,
    pub file_names: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BuildingTypeBcaSelectorProbe {
    pub byte_0xb8: u8,
    pub byte_0xb8_hex: String,
    pub byte_0xb9: u8,
    pub byte_0xb9_hex: String,
    pub byte_0xba: u8,
    pub byte_0xba_hex: String,
    pub byte_0xbb: u8,
    pub byte_0xbb_hex: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BuildingTypeBtyHeaderProbe {
    pub type_id: u32,
    pub type_id_hex: String,
    pub name_0x04: String,
    pub name_0x22: String,
    pub name_0x40: String,
    pub name_0x5e: String,
    pub name_0x7c: String,
    pub name_0x9a: String,
    pub byte_0xb8: u8,
    pub byte_0xb8_hex: String,
    pub byte_0xb9: u8,
    pub byte_0xb9_hex: String,
    pub byte_0xba: u8,
    pub byte_0xba_hex: String,
    pub dword_0xbb: u32,
    pub dword_0xbb_hex: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BuildingTypeBcaSelectorPatternSummary {
    pub byte_len: usize,
    pub byte_0xb8_hex: String,
    pub byte_0xb9_hex: String,
    pub byte_0xba_hex: String,
    pub byte_0xbb_hex: String,
    pub file_count: usize,
    pub sample_file_names: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BuildingTypeNamedBindingComparison {
    pub bindings_path: String,
    pub named_binding_count: usize,
    pub shared_canonical_stem_count: usize,
    pub binding_only_canonical_stems: Vec<String>,
    pub source_only_canonical_stems: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BuildingTypeRecoveredTableSummary {
    pub recovered_style_themes: Vec<String>,
    pub recovered_source_kinds: Vec<String>,
    pub present_style_station_entries: Vec<String>,
    pub present_standalone_entries: Vec<String>,
    pub bare_port_warehouse_files: Vec<String>,
    pub nonzero_bty_header_dword_summaries: Vec<BuildingTypeBtyHeaderDwordSummary>,
    pub nonzero_bty_header_name_0x40_summaries: Vec<BuildingTypeBtyHeaderNameSummary>,
    pub nonzero_bty_header_name_0x5e_summaries: Vec<BuildingTypeBtyHeaderNameSummary>,
    pub nonzero_bty_header_name_0x7c_summaries: Vec<BuildingTypeBtyHeaderNameSummary>,
    pub bty_header_name_0x5e_dword_summaries: Vec<BuildingTypeBtyHeaderNameDwordSummary>,
    pub bty_name_0x5e_bca_selector_summaries: Vec<BuildingTypeBtyNameBcaSelectorSummary>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BuildingTypeBtyHeaderDwordSummary {
    pub dword_0xbb: u32,
    pub dword_0xbb_hex: String,
    pub file_count: usize,
    pub sample_file_names: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BuildingTypeBtyHeaderNameSummary {
    pub header_offset_hex: String,
    pub header_value: String,
    pub file_count: usize,
    pub sample_file_names: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BuildingTypeBtyHeaderNameDwordSummary {
    pub header_offset_hex: String,
    pub header_value: String,
    pub dword_0xbb: u32,
    pub dword_0xbb_hex: String,
    pub file_count: usize,
    pub sample_file_names: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BuildingTypeBtyNameBcaSelectorSummary {
    pub header_offset_hex: String,
    pub header_value: String,
    pub dword_0xbb: u32,
    pub dword_0xbb_hex: String,
    pub byte_0xba_hex: String,
    pub byte_0xbb_hex: String,
    pub file_count: usize,
    pub sample_file_names: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BuildingTypeSourceReport {
    pub directory_path: String,
    pub bca_file_count: usize,
    pub bty_file_count: usize,
    pub unique_canonical_stem_count: usize,
    pub bca_selector_pattern_count: usize,
    #[serde(default)]
    pub named_binding_comparison: Option<BuildingTypeNamedBindingComparison>,
    pub recovered_table_summary: BuildingTypeRecoveredTableSummary,
    pub notes: Vec<String>,
    pub bca_selector_patterns: Vec<BuildingTypeBcaSelectorPatternSummary>,
    pub files: Vec<BuildingTypeSourceFile>,
    pub entries: Vec<BuildingTypeSourceEntry>,
}

pub fn inspect_building_types_dir(
    path: &Path,
) -> Result<BuildingTypeSourceReport, Box<dyn std::error::Error>> {
    inspect_building_types_dir_with_bindings(path, None)
}

pub fn inspect_building_types_dir_with_bindings(
    path: &Path,
    bindings_path: Option<&Path>,
) -> Result<BuildingTypeSourceReport, Box<dyn std::error::Error>> {
    let mut files = Vec::new();
    for entry in fs::read_dir(path)? {
        let entry = entry?;
        if !entry.file_type()?.is_file() {
            continue;
        }
        let file_name = entry.file_name().to_string_lossy().into_owned();
        let Some(extension) = Path::new(&file_name)
            .extension()
            .and_then(|extension| extension.to_str())
            .map(|extension| extension.to_ascii_lowercase())
        else {
            continue;
        };
        let source_kind = match extension.as_str() {
            "bca" => BuildingTypeSourceKind::Bca,
            "bty" => BuildingTypeSourceKind::Bty,
            _ => continue,
        };
        let bytes = fs::read(entry.path())?;
        let raw_stem = Path::new(&file_name)
            .file_stem()
            .and_then(|stem| stem.to_str())
            .unwrap_or("")
            .to_string();
        if raw_stem.is_empty() {
            continue;
        }
        files.push(BuildingTypeSourceFile {
            file_name,
            canonical_stem: canonicalize_building_stem(&raw_stem),
            raw_stem,
            source_kind: source_kind.clone(),
            byte_len: Some(bytes.len()),
            bca_selector_probe: match source_kind {
                BuildingTypeSourceKind::Bca => Some(probe_bca_selector_bytes(&bytes)),
                BuildingTypeSourceKind::Bty => None,
            },
            bty_header_probe: match source_kind {
                BuildingTypeSourceKind::Bca => None,
                BuildingTypeSourceKind::Bty => Some(probe_bty_header(&bytes)),
            },
        });
    }

    files.sort_by(|left, right| {
        left.canonical_stem
            .cmp(&right.canonical_stem)
            .then_with(|| left.file_name.cmp(&right.file_name))
    });

    let mut grouped = BTreeMap::<String, Vec<&BuildingTypeSourceFile>>::new();
    for file in &files {
        grouped
            .entry(file.canonical_stem.clone())
            .or_default()
            .push(file);
    }

    let entries = grouped
        .into_iter()
        .map(|(canonical_stem, group)| BuildingTypeSourceEntry {
            canonical_stem,
            raw_stems: group
                .iter()
                .map(|file| file.raw_stem.clone())
                .collect::<BTreeSet<_>>()
                .into_iter()
                .collect(),
            source_kinds: group
                .iter()
                .map(|file| file.source_kind.clone())
                .collect::<BTreeSet<_>>()
                .into_iter()
                .collect(),
            file_names: group
                .iter()
                .map(|file| file.file_name.clone())
                .collect::<BTreeSet<_>>()
                .into_iter()
                .collect(),
        })
        .collect::<Vec<_>>();

    let bca_file_count = files
        .iter()
        .filter(|file| matches!(file.source_kind, BuildingTypeSourceKind::Bca))
        .count();
    let bty_file_count = files
        .iter()
        .filter(|file| matches!(file.source_kind, BuildingTypeSourceKind::Bty))
        .count();
    let mut grouped_selector_patterns =
        BTreeMap::<(usize, String, String, String, String), Vec<String>>::new();
    for file in &files {
        let Some(probe) = &file.bca_selector_probe else {
            continue;
        };
        grouped_selector_patterns
            .entry((
                file.byte_len.unwrap_or_default(),
                probe.byte_0xb8_hex.clone(),
                probe.byte_0xb9_hex.clone(),
                probe.byte_0xba_hex.clone(),
                probe.byte_0xbb_hex.clone(),
            ))
            .or_default()
            .push(file.file_name.clone());
    }
    let bca_selector_patterns = grouped_selector_patterns
        .into_iter()
        .map(
            |(
                (byte_len, byte_0xb8_hex, byte_0xb9_hex, byte_0xba_hex, byte_0xbb_hex),
                file_names,
            )| BuildingTypeBcaSelectorPatternSummary {
                byte_len,
                byte_0xb8_hex,
                byte_0xb9_hex,
                byte_0xba_hex,
                byte_0xbb_hex,
                file_count: file_names.len(),
                sample_file_names: file_names.into_iter().take(12).collect(),
            },
        )
        .collect::<Vec<_>>();

    let notes = vec![
        "BuildingTypes sources are grouped by a canonical stem that lowercases and strips spaces, underscores, and hyphens so paired .bca/.bty variants collapse onto one asset token.".to_string(),
        "This report is an offline asset-pool view only; it does not by itself assign live candidate ids or prove scenario candidate-table availability.".to_string(),
        "For .bca files, the report also exposes the narrow selector-byte window at offsets 0xb8..0xbb used by the grounded aux-candidate and live-candidate stream decoders.".to_string(),
        "The recovered stock table above the Tier-2 building seam combines one style/theme subset with one source-kind table; this report now surfaces the matching on-disk filename families directly.".to_string(),
    ];

    let named_binding_comparison = if let Some(bindings_path) = bindings_path {
        Some(load_named_binding_comparison(bindings_path, &entries)?)
    } else {
        None
    };
    let recovered_table_summary = summarize_recovered_table_families(&entries, &files);

    Ok(BuildingTypeSourceReport {
        directory_path: path.display().to_string(),
        bca_file_count,
        bty_file_count,
        unique_canonical_stem_count: entries.len(),
        bca_selector_pattern_count: bca_selector_patterns.len(),
        named_binding_comparison,
        recovered_table_summary,
        notes,
        bca_selector_patterns,
        files,
        entries,
    })
}

fn probe_bca_selector_bytes(bytes: &[u8]) -> BuildingTypeBcaSelectorProbe {
    let byte_0xb8 = bytes.get(0xb8).copied().unwrap_or(0);
    let byte_0xb9 = bytes.get(0xb9).copied().unwrap_or(0);
    let byte_0xba = bytes.get(0xba).copied().unwrap_or(0);
    let byte_0xbb = bytes.get(0xbb).copied().unwrap_or(0);
    BuildingTypeBcaSelectorProbe {
        byte_0xb8,
        byte_0xb8_hex: format!("0x{byte_0xb8:02x}"),
        byte_0xb9,
        byte_0xb9_hex: format!("0x{byte_0xb9:02x}"),
        byte_0xba,
        byte_0xba_hex: format!("0x{byte_0xba:02x}"),
        byte_0xbb,
        byte_0xbb_hex: format!("0x{byte_0xbb:02x}"),
    }
}

fn probe_bty_header(bytes: &[u8]) -> BuildingTypeBtyHeaderProbe {
    let type_id = read_u32_le(bytes, 0x00);
    let byte_0xb8 = bytes.get(0xb8).copied().unwrap_or(0);
    let byte_0xb9 = bytes.get(0xb9).copied().unwrap_or(0);
    let byte_0xba = bytes.get(0xba).copied().unwrap_or(0);
    let dword_0xbb = read_u32_le(bytes, 0xbb);
    BuildingTypeBtyHeaderProbe {
        type_id,
        type_id_hex: format!("0x{type_id:08x}"),
        name_0x04: read_c_string(bytes, 0x04, 0x1e),
        name_0x22: read_c_string(bytes, 0x22, 0x1e),
        name_0x40: read_c_string(bytes, 0x40, 0x1e),
        name_0x5e: read_c_string(bytes, 0x5e, 0x1e),
        name_0x7c: read_c_string(bytes, 0x7c, 0x1e),
        name_0x9a: read_c_string(bytes, 0x9a, 0x1e),
        byte_0xb8,
        byte_0xb8_hex: format!("0x{byte_0xb8:02x}"),
        byte_0xb9,
        byte_0xb9_hex: format!("0x{byte_0xb9:02x}"),
        byte_0xba,
        byte_0xba_hex: format!("0x{byte_0xba:02x}"),
        dword_0xbb,
        dword_0xbb_hex: format!("0x{dword_0xbb:08x}"),
    }
}

fn read_u32_le(bytes: &[u8], offset: usize) -> u32 {
    bytes
        .get(offset..offset + 4)
        .and_then(|slice| <[u8; 4]>::try_from(slice).ok())
        .map(u32::from_le_bytes)
        .unwrap_or(0)
}

fn read_c_string(bytes: &[u8], offset: usize, max_len: usize) -> String {
    let Some(slice) = bytes.get(offset..offset.saturating_add(max_len)) else {
        return String::new();
    };
    let end = slice
        .iter()
        .position(|byte| *byte == 0)
        .unwrap_or(slice.len());
    String::from_utf8_lossy(&slice[..end]).into_owned()
}

fn load_named_binding_comparison(
    bindings_path: &Path,
    entries: &[BuildingTypeSourceEntry],
) -> Result<BuildingTypeNamedBindingComparison, Box<dyn std::error::Error>> {
    let artifact =
        serde_json::from_str::<BuildingBindingArtifact>(&fs::read_to_string(bindings_path)?)?;
    let named_binding_stems = artifact
        .bindings
        .into_iter()
        .filter_map(|binding| binding.candidate_name)
        .map(|candidate_name| canonicalize_building_stem(&candidate_name))
        .collect::<BTreeSet<_>>();
    let source_stems = entries
        .iter()
        .map(|entry| entry.canonical_stem.clone())
        .collect::<BTreeSet<_>>();

    Ok(BuildingTypeNamedBindingComparison {
        bindings_path: bindings_path.display().to_string(),
        named_binding_count: named_binding_stems.len(),
        shared_canonical_stem_count: named_binding_stems.intersection(&source_stems).count(),
        binding_only_canonical_stems: named_binding_stems
            .difference(&source_stems)
            .cloned()
            .collect(),
        source_only_canonical_stems: source_stems
            .difference(&named_binding_stems)
            .cloned()
            .collect(),
    })
}

fn canonicalize_building_stem(stem: &str) -> String {
    stem.chars()
        .filter(|ch| !matches!(ch, ' ' | '_' | '-'))
        .flat_map(|ch| ch.to_lowercase())
        .collect()
}

fn summarize_recovered_table_families(
    entries: &[BuildingTypeSourceEntry],
    files: &[BuildingTypeSourceFile],
) -> BuildingTypeRecoveredTableSummary {
    const RECOVERED_STYLE_THEMES: [&str; 6] =
        ["Victorian", "Tudor", "SoWest", "Persian", "Kyoto", "ClpBrd"];
    const RECOVERED_SOURCE_KINDS: [&str; 5] = [
        "StationSml",
        "StationMed",
        "StationLrg",
        "ServiceTower",
        "Maintenance",
    ];

    let entry_by_canonical = entries
        .iter()
        .map(|entry| (entry.canonical_stem.clone(), entry))
        .collect::<BTreeMap<_, _>>();

    let mut present_style_station_entries = Vec::new();
    for style in RECOVERED_STYLE_THEMES {
        for source_kind in ["StationSml", "StationMed", "StationLrg"] {
            let canonical = canonicalize_building_stem(&format!("{style}{source_kind}"));
            if let Some(entry) = entry_by_canonical.get(&canonical) {
                if let Some(raw_stem) = entry.raw_stems.first() {
                    present_style_station_entries.push(raw_stem.clone());
                }
            }
        }
    }
    present_style_station_entries.sort();
    present_style_station_entries.dedup();

    let mut present_standalone_entries = Vec::new();
    for raw_name in ["ServiceTower", "Maintenance"] {
        let canonical = canonicalize_building_stem(raw_name);
        if let Some(entry) = entry_by_canonical.get(&canonical) {
            if let Some(raw_stem) = entry.raw_stems.first() {
                present_standalone_entries.push(raw_stem.clone());
            }
        }
    }
    present_standalone_entries.sort();
    present_standalone_entries.dedup();

    let mut bare_port_warehouse_files = files
        .iter()
        .filter(|file| matches!(file.canonical_stem.as_str(), "port" | "warehouse"))
        .map(|file| file.file_name.clone())
        .collect::<Vec<_>>();
    bare_port_warehouse_files.sort();
    bare_port_warehouse_files.dedup();

    let mut nonzero_bty_header_dword_groups = BTreeMap::<u32, Vec<String>>::new();
    for file in files {
        let Some(probe) = &file.bty_header_probe else {
            continue;
        };
        if probe.dword_0xbb == 0 {
            continue;
        }
        nonzero_bty_header_dword_groups
            .entry(probe.dword_0xbb)
            .or_default()
            .push(file.file_name.clone());
    }
    let nonzero_bty_header_dword_summaries = nonzero_bty_header_dword_groups
        .into_iter()
        .map(|(dword_0xbb, mut file_names)| {
            file_names.sort();
            file_names.dedup();
            BuildingTypeBtyHeaderDwordSummary {
                dword_0xbb,
                dword_0xbb_hex: format!("0x{dword_0xbb:08x}"),
                file_count: file_names.len(),
                sample_file_names: file_names.into_iter().take(24).collect(),
            }
        })
        .collect();

    let nonzero_bty_header_name_0x40_summaries =
        summarize_nonzero_bty_header_name_lane(files, 0x40, |probe| &probe.name_0x40);
    let nonzero_bty_header_name_0x5e_summaries =
        summarize_nonzero_bty_header_name_lane(files, 0x5e, |probe| &probe.name_0x5e);
    let nonzero_bty_header_name_0x7c_summaries =
        summarize_nonzero_bty_header_name_lane(files, 0x7c, |probe| &probe.name_0x7c);
    let bty_header_name_0x5e_dword_summaries =
        summarize_bty_header_name_lane_by_dword(files, 0x5e, |probe| &probe.name_0x5e);
    let bty_name_0x5e_bca_selector_summaries =
        summarize_bty_name_0x5e_bca_selector_patterns(entries, files);

    BuildingTypeRecoveredTableSummary {
        recovered_style_themes: RECOVERED_STYLE_THEMES
            .into_iter()
            .map(str::to_string)
            .collect(),
        recovered_source_kinds: RECOVERED_SOURCE_KINDS
            .into_iter()
            .map(str::to_string)
            .collect(),
        present_style_station_entries,
        present_standalone_entries,
        bare_port_warehouse_files,
        nonzero_bty_header_dword_summaries,
        nonzero_bty_header_name_0x40_summaries,
        nonzero_bty_header_name_0x5e_summaries,
        nonzero_bty_header_name_0x7c_summaries,
        bty_header_name_0x5e_dword_summaries,
        bty_name_0x5e_bca_selector_summaries,
    }
}

fn summarize_nonzero_bty_header_name_lane(
    files: &[BuildingTypeSourceFile],
    offset: u32,
    selector: impl Fn(&BuildingTypeBtyHeaderProbe) -> &String,
) -> Vec<BuildingTypeBtyHeaderNameSummary> {
    let mut groups = BTreeMap::<String, Vec<String>>::new();
    for file in files {
        let Some(probe) = &file.bty_header_probe else {
            continue;
        };
        if probe.dword_0xbb == 0 {
            continue;
        }
        let header_value = selector(probe).trim();
        if header_value.is_empty() {
            continue;
        }
        groups
            .entry(header_value.to_string())
            .or_default()
            .push(file.file_name.clone());
    }

    let mut summaries = groups
        .into_iter()
        .map(|(header_value, mut file_names)| {
            file_names.sort();
            file_names.dedup();
            BuildingTypeBtyHeaderNameSummary {
                header_offset_hex: format!("0x{offset:02x}"),
                header_value,
                file_count: file_names.len(),
                sample_file_names: file_names.into_iter().take(24).collect(),
            }
        })
        .collect::<Vec<_>>();
    summaries.sort_by(|left, right| {
        right
            .file_count
            .cmp(&left.file_count)
            .then_with(|| left.header_offset_hex.cmp(&right.header_offset_hex))
            .then_with(|| left.header_value.cmp(&right.header_value))
    });
    summaries
}

fn summarize_bty_header_name_lane_by_dword(
    files: &[BuildingTypeSourceFile],
    offset: u32,
    selector: impl Fn(&BuildingTypeBtyHeaderProbe) -> &String,
) -> Vec<BuildingTypeBtyHeaderNameDwordSummary> {
    let mut groups = BTreeMap::<(String, u32), Vec<String>>::new();
    for file in files {
        let Some(probe) = &file.bty_header_probe else {
            continue;
        };
        let header_value = selector(probe).trim();
        if header_value.is_empty() {
            continue;
        }
        groups
            .entry((header_value.to_string(), probe.dword_0xbb))
            .or_default()
            .push(file.file_name.clone());
    }

    let mut summaries = groups
        .into_iter()
        .map(|((header_value, dword_0xbb), mut file_names)| {
            file_names.sort();
            file_names.dedup();
            BuildingTypeBtyHeaderNameDwordSummary {
                header_offset_hex: format!("0x{offset:02x}"),
                header_value,
                dword_0xbb,
                dword_0xbb_hex: format!("0x{dword_0xbb:08x}"),
                file_count: file_names.len(),
                sample_file_names: file_names.into_iter().take(24).collect(),
            }
        })
        .collect::<Vec<_>>();
    summaries.sort_by(|left, right| {
        right
            .file_count
            .cmp(&left.file_count)
            .then_with(|| left.dword_0xbb.cmp(&right.dword_0xbb))
            .then_with(|| left.header_offset_hex.cmp(&right.header_offset_hex))
            .then_with(|| left.header_value.cmp(&right.header_value))
    });
    summaries
}

fn summarize_bty_name_0x5e_bca_selector_patterns(
    entries: &[BuildingTypeSourceEntry],
    files: &[BuildingTypeSourceFile],
) -> Vec<BuildingTypeBtyNameBcaSelectorSummary> {
    let file_by_name = files
        .iter()
        .map(|file| (file.file_name.as_str(), file))
        .collect::<BTreeMap<_, _>>();
    let mut groups = BTreeMap::<(String, u32, String, String), Vec<String>>::new();

    for entry in entries {
        let bty_file = entry
            .file_names
            .iter()
            .filter_map(|name| file_by_name.get(name.as_str()))
            .find(|file| matches!(file.source_kind, BuildingTypeSourceKind::Bty));
        let bca_file = entry
            .file_names
            .iter()
            .filter_map(|name| file_by_name.get(name.as_str()))
            .find(|file| matches!(file.source_kind, BuildingTypeSourceKind::Bca));
        let (Some(bty_file), Some(bca_file)) = (bty_file, bca_file) else {
            continue;
        };
        let (Some(bty_probe), Some(bca_probe)) =
            (&bty_file.bty_header_probe, &bca_file.bca_selector_probe)
        else {
            continue;
        };
        let header_value = bty_probe.name_0x5e.trim();
        if header_value.is_empty() {
            continue;
        }
        groups
            .entry((
                header_value.to_string(),
                bty_probe.dword_0xbb,
                bca_probe.byte_0xba_hex.clone(),
                bca_probe.byte_0xbb_hex.clone(),
            ))
            .or_default()
            .push(bty_file.file_name.clone());
    }

    let mut summaries = groups
        .into_iter()
        .map(
            |((header_value, dword_0xbb, byte_0xba_hex, byte_0xbb_hex), mut file_names)| {
                file_names.sort();
                file_names.dedup();
                BuildingTypeBtyNameBcaSelectorSummary {
                    header_offset_hex: "0x5e".to_string(),
                    header_value,
                    dword_0xbb,
                    dword_0xbb_hex: format!("0x{dword_0xbb:08x}"),
                    byte_0xba_hex,
                    byte_0xbb_hex,
                    file_count: file_names.len(),
                    sample_file_names: file_names.into_iter().take(24).collect(),
                }
            },
        )
        .collect::<Vec<_>>();
    summaries.sort_by(|left, right| {
        right
            .file_count
            .cmp(&left.file_count)
            .then_with(|| left.dword_0xbb.cmp(&right.dword_0xbb))
            .then_with(|| left.header_value.cmp(&right.header_value))
            .then_with(|| left.byte_0xba_hex.cmp(&right.byte_0xba_hex))
            .then_with(|| left.byte_0xbb_hex.cmp(&right.byte_0xbb_hex))
    });
    summaries
}

#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct BuildingBindingArtifact {
    bindings: Vec<BuildingBindingRow>,
}

#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct BuildingBindingRow {
    #[serde(default)]
    candidate_name: Option<String>,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn probes_bca_selector_bytes_from_fixed_offsets() {
        let mut bytes = vec![0u8; 0xbc + 1];
        bytes[0xb8] = 0x12;
        bytes[0xb9] = 0x34;
        bytes[0xba] = 0x56;
        bytes[0xbb] = 0x78;
        let probe = probe_bca_selector_bytes(&bytes);
        assert_eq!(probe.byte_0xb8, 0x12);
        assert_eq!(probe.byte_0xb9, 0x34);
        assert_eq!(probe.byte_0xba, 0x56);
        assert_eq!(probe.byte_0xbb, 0x78);
        assert_eq!(probe.byte_0xb8_hex, "0x12");
        assert_eq!(probe.byte_0xbb_hex, "0x78");
    }

    #[test]
    fn probes_bty_header_from_fixed_offsets() {
        let mut bytes = vec![0u8; 0xc0];
        bytes[0x00..0x04].copy_from_slice(&0x03ebu32.to_le_bytes());
        bytes[0x04..0x04 + 5].copy_from_slice(b"Port\0");
        bytes[0x22..0x22 + 7].copy_from_slice(b"Cargo\0\0");
        bytes[0x40..0x40 + 6].copy_from_slice(b"Dock\0\0");
        bytes[0x5e..0x5e + 5].copy_from_slice(b"Sea\0\0");
        bytes[0x7c..0x7c + 6].copy_from_slice(b"Coast\0");
        bytes[0x9a..0x9a + 5].copy_from_slice(b"Port\0");
        bytes[0xb8] = 0x12;
        bytes[0xb9] = 0x34;
        bytes[0xba] = 0x56;
        bytes[0xbb..0xbf].copy_from_slice(&0x89abcdefu32.to_le_bytes());

        let probe = probe_bty_header(&bytes);
        assert_eq!(probe.type_id, 0x03eb);
        assert_eq!(probe.type_id_hex, "0x000003eb");
        assert_eq!(probe.name_0x04, "Port");
        assert_eq!(probe.name_0x22, "Cargo");
        assert_eq!(probe.name_0x40, "Dock");
        assert_eq!(probe.name_0x5e, "Sea");
        assert_eq!(probe.name_0x7c, "Coast");
        assert_eq!(probe.name_0x9a, "Port");
        assert_eq!(probe.byte_0xb8_hex, "0x12");
        assert_eq!(probe.byte_0xb9_hex, "0x34");
        assert_eq!(probe.byte_0xba_hex, "0x56");
        assert_eq!(probe.dword_0xbb_hex, "0x89abcdef");
    }

    #[test]
    fn summarizes_recovered_table_families_from_entries_and_files() {
        let entries = vec![
            BuildingTypeSourceEntry {
                canonical_stem: canonicalize_building_stem("VictorianStationSml"),
                raw_stems: vec!["VictorianStationSml".to_string()],
                source_kinds: vec![BuildingTypeSourceKind::Bty],
                file_names: vec!["VictorianStationSml.bty".to_string()],
            },
            BuildingTypeSourceEntry {
                canonical_stem: canonicalize_building_stem("ClpBrdStationLrg"),
                raw_stems: vec!["ClpbrdStationLrg".to_string()],
                source_kinds: vec![BuildingTypeSourceKind::Bty],
                file_names: vec!["ClpbrdStationLrg.bty".to_string()],
            },
            BuildingTypeSourceEntry {
                canonical_stem: canonicalize_building_stem("Maintenance"),
                raw_stems: vec!["Maintenance".to_string()],
                source_kinds: vec![BuildingTypeSourceKind::Bty],
                file_names: vec!["Maintenance.bty".to_string()],
            },
            BuildingTypeSourceEntry {
                canonical_stem: canonicalize_building_stem("ServiceTower"),
                raw_stems: vec!["ServiceTower".to_string()],
                source_kinds: vec![BuildingTypeSourceKind::Bty],
                file_names: vec!["ServiceTower.bty".to_string()],
            },
            BuildingTypeSourceEntry {
                canonical_stem: canonicalize_building_stem("Port"),
                raw_stems: vec!["Port".to_string()],
                source_kinds: vec![BuildingTypeSourceKind::Bca, BuildingTypeSourceKind::Bty],
                file_names: vec!["Port.bca".to_string(), "Port.bty".to_string()],
            },
        ];
        let files = vec![
            BuildingTypeSourceFile {
                file_name: "Port.bty".to_string(),
                raw_stem: "Port".to_string(),
                canonical_stem: canonicalize_building_stem("Port"),
                source_kind: BuildingTypeSourceKind::Bty,
                byte_len: None,
                bca_selector_probe: None,
                bty_header_probe: Some(BuildingTypeBtyHeaderProbe {
                    type_id: 0x03ec,
                    type_id_hex: "0x000003ec".to_string(),
                    name_0x04: "Port".to_string(),
                    name_0x22: "Port".to_string(),
                    name_0x40: "Port".to_string(),
                    name_0x5e: "TextileMill".to_string(),
                    name_0x7c: "Port".to_string(),
                    name_0x9a: "Port".to_string(),
                    byte_0xb8: 0x06,
                    byte_0xb8_hex: "0x06".to_string(),
                    byte_0xb9: 0x06,
                    byte_0xb9_hex: "0x06".to_string(),
                    byte_0xba: 0x30,
                    byte_0xba_hex: "0x30".to_string(),
                    dword_0xbb: 0x01f4,
                    dword_0xbb_hex: "0x000001f4".to_string(),
                }),
            },
            BuildingTypeSourceFile {
                file_name: "Port.bca".to_string(),
                raw_stem: "Port".to_string(),
                canonical_stem: canonicalize_building_stem("Port"),
                source_kind: BuildingTypeSourceKind::Bca,
                byte_len: None,
                bca_selector_probe: Some(BuildingTypeBcaSelectorProbe {
                    byte_0xb8: 0x00,
                    byte_0xb8_hex: "0x00".to_string(),
                    byte_0xb9: 0x00,
                    byte_0xb9_hex: "0x00".to_string(),
                    byte_0xba: 0x00,
                    byte_0xba_hex: "0x00".to_string(),
                    byte_0xbb: 0x00,
                    byte_0xbb_hex: "0x00".to_string(),
                }),
                bty_header_probe: None,
            },
        ];

        let summary = summarize_recovered_table_families(&entries, &files);
        assert!(
            summary
                .present_style_station_entries
                .contains(&"VictorianStationSml".to_string())
        );
        assert!(
            summary
                .present_style_station_entries
                .contains(&"ClpbrdStationLrg".to_string())
        );
        assert_eq!(
            summary.present_standalone_entries,
            vec!["Maintenance".to_string(), "ServiceTower".to_string()]
        );
        assert_eq!(
            summary.bare_port_warehouse_files,
            vec!["Port.bca".to_string(), "Port.bty".to_string()]
        );
        assert_eq!(summary.nonzero_bty_header_dword_summaries.len(), 1);
        assert_eq!(
            summary.nonzero_bty_header_dword_summaries[0].dword_0xbb_hex,
            "0x000001f4"
        );
        assert_eq!(
            summary.nonzero_bty_header_dword_summaries[0].sample_file_names,
            vec!["Port.bty".to_string()]
        );
        assert_eq!(
            summary.nonzero_bty_header_name_0x40_summaries,
            vec![BuildingTypeBtyHeaderNameSummary {
                header_offset_hex: "0x40".to_string(),
                header_value: "Port".to_string(),
                file_count: 1,
                sample_file_names: vec!["Port.bty".to_string()],
            }]
        );
        assert_eq!(
            summary.nonzero_bty_header_name_0x5e_summaries,
            vec![BuildingTypeBtyHeaderNameSummary {
                header_offset_hex: "0x5e".to_string(),
                header_value: "TextileMill".to_string(),
                file_count: 1,
                sample_file_names: vec!["Port.bty".to_string()],
            }]
        );
        assert_eq!(
            summary.nonzero_bty_header_name_0x7c_summaries,
            vec![BuildingTypeBtyHeaderNameSummary {
                header_offset_hex: "0x7c".to_string(),
                header_value: "Port".to_string(),
                file_count: 1,
                sample_file_names: vec!["Port.bty".to_string()],
            }]
        );
        assert_eq!(
            summary.bty_header_name_0x5e_dword_summaries,
            vec![BuildingTypeBtyHeaderNameDwordSummary {
                header_offset_hex: "0x5e".to_string(),
                header_value: "TextileMill".to_string(),
                dword_0xbb: 0x01f4,
                dword_0xbb_hex: "0x000001f4".to_string(),
                file_count: 1,
                sample_file_names: vec!["Port.bty".to_string()],
            }]
        );
        assert_eq!(
            summary.bty_name_0x5e_bca_selector_summaries,
            vec![BuildingTypeBtyNameBcaSelectorSummary {
                header_offset_hex: "0x5e".to_string(),
                header_value: "TextileMill".to_string(),
                dword_0xbb: 0x01f4,
                dword_0xbb_hex: "0x000001f4".to_string(),
                byte_0xba_hex: "0x00".to_string(),
                byte_0xbb_hex: "0x00".to_string(),
                file_count: 1,
                sample_file_names: vec!["Port.bty".to_string()],
            }]
        );
    }
}
