123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916
//! Cross-source comparison engine.

use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;

use crate::parsers::Part;
use crate::result::Variant;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Verdict {
    Agree,
    /// A source is missing this axis (e.g. ds2sf-only had no kicad_sym to compare).
    /// Treated as informational, NOT a divergence.
    SourceMissing,
    Disagree,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AxisOutcome {
    pub axis: String,
    pub verdict: Verdict,
    /// Per-source value for this axis, as a stringified summary.
    pub values: BTreeMap<String, String>,
    /// Specific conflicts found (pin numbers, dim diffs, etc.).
    pub conflicts: Vec<String>,
    /// Severity 1..3. 1 = informational, 2 = soft (recoverable / non-blocking),
    /// 3 = hard (almost certainly bad data — the wrong variant landed in the lib).
    pub severity: u8,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComparisonReport {
    pub axes: Vec<AxisOutcome>,
    pub overall: Verdict,
    pub variants: Vec<Variant>,
}

/// Run all axis checks across the loaded parts. Sources with `pads.is_empty()`
/// AND `pins.is_empty()` AND no package — i.e. the parser found nothing — are
/// excluded from comparison. They're treated as "this format wasn't available
/// for this chip" rather than "this format disagreed."
pub fn compare(parts: &[Part]) -> ComparisonReport {
    let parts: Vec<&Part> = parts
        .iter()
        .filter(|p| !p.pins.is_empty() || !p.pads.is_empty() || p.package.is_some())
        .collect();

    let mut axes = Vec::new();
    axes.push(axis_pad_count(&parts));
    axes.push(axis_pad_numbers(&parts));
    axes.push(axis_symbol_pin_count(&parts));
    axes.push(axis_pin_names(&parts));
    axes.push(axis_pin_to_pad_map(&parts));
    axes.push(axis_package_family(&parts));
    axes.push(axis_body_dims(&parts));

    let overall = if axes.iter().any(|a| a.verdict == Verdict::Disagree) {
        Verdict::Disagree
    } else {
        Verdict::Agree
    };
    let variants = detect_variants(&parts, &axes);
    ComparisonReport { axes, overall, variants }
}

// ── variant detection ─────────────────────────────────────────────────────
//
// A variant is a known-benign explanation for one or more disagreeing axes.
// The status-determination logic in cli.rs uses Variant::covers() to decide
// whether every disagreeing axis is explained — if yes, status becomes
// `golden_with_variants` (exit 0); if not, `divergent` (exit 2).

fn detect_variants(parts: &[&Part], axes: &[AxisOutcome]) -> Vec<Variant> {
    let mut variants = Vec::new();

    let pad_count_disagree = axes.iter().any(|a| a.axis == "pad_count" && a.verdict == Verdict::Disagree);
    let package_family_disagree = axes.iter().any(|a| a.axis == "package_family" && a.verdict == Verdict::Disagree);
    let body_dims_disagree = axes.iter().any(|a| a.axis == "body_dims" && a.verdict == Verdict::Disagree);
    let pin_names_disagree = axes.iter().any(|a| a.axis == "pin_names" && a.verdict == Verdict::Disagree);

    // ── Variant 1: ExposedPad / ModuleExtraPads ───────────────────────────
    if pad_count_disagree {
        let parts_with_pads: Vec<&&Part> = parts.iter().filter(|p| !p.pads.is_empty()).collect();
        if parts_with_pads.len() >= 2 {
            let counts: Vec<u32> = parts_with_pads.iter().map(|p| p.pads.len() as u32).collect();
            let min_c = *counts.iter().min().unwrap();
            let max_c = *counts.iter().max().unwrap();
            let with_max: Vec<String> = parts_with_pads.iter()
                .filter(|p| p.pads.len() as u32 == max_c)
                .map(|p| p.source.clone()).collect();
            let with_min: Vec<String> = parts_with_pads.iter()
                .filter(|p| p.pads.len() as u32 == min_c)
                .map(|p| p.source.clone()).collect();

            if max_c - min_c == 1 {
                variants.push(Variant::ExposedPadVariant {
                    sources_with_ep: with_max,
                    sources_without_ep: with_min,
                });
            } else if max_c > min_c {
                // GUARD: a big pad-count gap is only a benign "module extras"
                // variant when the SMALLER-source's package name carries a
                // module marker. Otherwise — like an FPGA where ds2sf only
                // got half the pins — it's a real undercount divergence and
                // should NOT be auto-explained as a variant.
                //
                // Real-world catch: ICE40UP5K v0.3 saw ds2sf=24 pads, kicad=48
                // pads (FPGA, not a module). v0.3 wrongly fired module_extras
                // and reported golden_with_variants when it should have been
                // divergent. v0.4 only fires module_extras when the smaller-
                // source claims a module package name.
                let smaller_is_module = parts_with_pads
                    .iter()
                    .filter(|p| p.pads.len() as u32 == min_c)
                    .any(|p| p.package.as_ref()
                        .map(|pkg| is_module_package(&pkg.raw_name))
                        .unwrap_or(false));
                if smaller_is_module {
                    variants.push(Variant::ModuleExtraPads {
                        sources_with_extras: with_max,
                        sources_without_extras: with_min,
                        extra_count: max_c - min_c,
                    });
                }
                // else: leave the disagreement uncovered → status=divergent.
            }
        }
    }

    // ── Variant 2: LabelOnlyMismatch ──────────────────────────────────────
    // package_family disagrees but the underlying geometry agrees (or the
    // pad-count diff is already explained by ep/module-extras variants).
    let geometry_agrees = {
        let pad_count_agrees = axes.iter().any(|a| a.axis == "pad_count" && a.verdict == Verdict::Agree);
        let pad_numbers_agrees = axes.iter().any(|a| a.axis == "pad_numbers" && a.verdict == Verdict::Agree);
        let ep_or_module_explained = variants.iter().any(|v| matches!(
            v, Variant::ExposedPadVariant { .. } | Variant::ModuleExtraPads { .. }
        ));
        (pad_count_agrees && pad_numbers_agrees) || ep_or_module_explained
    };
    if package_family_disagree && geometry_agrees {
        let pf = axes.iter().find(|a| a.axis == "package_family").unwrap();
        let notes = pf
            .values
            .iter()
            .map(|(s, v)| format!("{s}: {v}"))
            .collect::<Vec<_>>()
            .join(" | ");
        variants.push(Variant::LabelOnlyMismatch { notes });
    }

    // ── Variant 3: StubSymbolPinNames ─────────────────────────────────────
    if pin_names_disagree {
        let mut stubbed = Vec::new();
        let mut canonical = Vec::new();
        for p in parts {
            if p.pins.is_empty() { continue; }
            let stub_count = p.pins.iter().filter(|pp| pp.is_stub_name).count();
            if stub_count > p.pins.len() / 2 {
                stubbed.push(p.source.clone());
            } else {
                canonical.push(p.source.clone());
            }
        }
        if !stubbed.is_empty() && !canonical.is_empty() {
            variants.push(Variant::StubSymbolPinNames {
                stub_sources: stubbed,
                canonical_sources: canonical,
            });
        }
    }

    // ── Variant 4: BodyDimMeasurementArtifact ─────────────────────────────
    // body_dims disagrees but geometry (pad layout) agrees — body dim is
    // courtyard-only, doesn't affect placement.
    if body_dims_disagree && geometry_agrees {
        let bd = axes.iter().find(|a| a.axis == "body_dims").unwrap();
        let notes = bd
            .values
            .iter()
            .map(|(s, v)| format!("{s}: {v}"))
            .collect::<Vec<_>>()
            .join(" | ");
        variants.push(Variant::BodyDimMeasurementArtifact { notes });
    }

    // ── Variant 5b: KelvinSenseRepresentation ────────────────────────────
    // 4-terminal Kelvin sense resistors (Vishay WSL, Bourns CSL, Susumu
    // PCS, etc) are sometimes represented as 4 pads (I+/V+/V-/I- — the
    // canonical precision-measurement form) and sometimes as 2 pads
    // (lossy non-precision form). Both are valid for routing; the
    // 4-pad form is just better for accurate current sensing. If one
    // source has 4 pads with Kelvin pin names and another has 2 pads,
    // explain the divergence as a representation choice rather than
    // ds2sf-misread or wrong-variant.
    if pad_count_disagree {
        if let Some(kvariant) = detect_kelvin_sense_split(parts) {
            variants.push(kvariant);
        }
    }

    // ── Variant 6: NamingConventionMismatch ──────────────────────────────
    // pin_names disagree but EVERY disagreeing pin pair is a benign naming-
    // convention difference (digit-position swap OR power-pin alias). If even
    // one pair has real-content disagreement, this variant doesn't fire and
    // the divergence bubbles up.
    if pin_names_disagree {
        if let Some(examples) = detect_naming_convention_only(parts) {
            variants.push(Variant::NamingConventionMismatch { examples });
        }
    }

    variants
}

/// Detect the Kelvin-sense-vs-2-terminal-resistor representation split.
/// Triggers when:
///   - Some source has exactly 4 pads with names matching the Kelvin set
///     (I+, I-, V+, V- in any order — slashes/dashes/Unicode-minus normalized)
///   - Another source has exactly 2 pads (numeric "1"/"2" or any pair)
///   - Body sizes are roughly comparable (both within a 2010/2512-class size)
fn detect_kelvin_sense_split(parts: &[&Part]) -> Option<crate::result::Variant> {
    use std::collections::HashSet;
    let kelvin_target: HashSet<&str> = ["I+", "I-", "V+", "V-"].into_iter().collect();

    let mut sources_with_kelvin: Vec<String> = Vec::new();
    let mut sources_without_kelvin: Vec<String> = Vec::new();
    let mut found_kelvin_names: Vec<String> = Vec::new();

    for p in parts {
        if p.pads.is_empty() && p.pins.is_empty() { continue; }
        // Kelvin source: 4 pads/pins AND the names match the Kelvin set.
        let has_4 = p.pads.len() == 4 || p.pins.len() == 4;
        let has_2 = p.pads.len() == 2 || p.pins.len() == 2;
        let names: HashSet<String> = if !p.pins.is_empty() {
            p.pins.iter().map(|pp| canon_kelvin_name(&pp.name)).collect()
        } else {
            // No symbol-side names available; we can't classify.
            continue;
        };
        let kelvin_match_count = names.iter()
            .filter(|n| kelvin_target.contains(n.as_str()))
            .count();

        if has_4 && kelvin_match_count >= 3 {
            sources_with_kelvin.push(p.source.clone());
            if found_kelvin_names.is_empty() {
                found_kelvin_names = p.pins.iter().map(|pp| pp.name.clone()).collect();
            }
        } else if has_2 {
            sources_without_kelvin.push(p.source.clone());
        }
    }

    if !sources_with_kelvin.is_empty() && !sources_without_kelvin.is_empty() {
        Some(crate::result::Variant::KelvinSenseRepresentation {
            sources_with_kelvin,
            sources_without_kelvin,
            kelvin_pin_names: found_kelvin_names,
        })
    } else {
        None
    }
}

/// Canonicalize a Kelvin pin name for set-membership checks.
/// Maps "I+", "I-", "V+", "V-" through dash/Unicode-minus variants.
fn canon_kelvin_name(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.trim().chars() {
        match c {
            '−' | '–' | '—' => out.push('-'),  // Unicode minus → ASCII minus
            '_' => out.push('-'),               // sometimes "I_" used for "I-"
            _ => out.push(c.to_ascii_uppercase()),
        }
    }
    out
}

/// Returns Some(examples) if every per-pin name disagreement between any two
/// sources is a benign naming-convention swap (digit-position OR power alias).
/// Returns None on the first real-content disagreement.
fn detect_naming_convention_only(parts: &[&Part]) -> Option<Vec<String>> {
    use std::collections::BTreeMap;

    let maps: Vec<(String, BTreeMap<String, String>)> = parts
        .iter()
        .filter(|p| {
            !p.pins.is_empty()
                && p.pins.iter().filter(|pp| pp.is_stub_name).count() <= p.pins.len() / 2
        })
        .map(|p| {
            let m: BTreeMap<String, String> = p
                .pins
                .iter()
                .map(|pp| (canon_pin_num(&pp.number), canon_pin_name(&pp.name)))
                .collect();
            (p.source.clone(), m)
        })
        .collect();

    if maps.len() < 2 { return None; }

    let mut examples: Vec<String> = Vec::new();
    let (first_src, first_map) = &maps[0];
    for (src, m) in &maps[1..] {
        for (num, name1) in first_map {
            let Some(name2) = m.get(num) else { continue; };
            if name1 == name2 { continue; }
            // Names differ — is it just a digit-position permutation?
            // Acceptable if any of: (a) digit-position permutation,
            // (b) both names are aliases of the same canonical power pin,
            // (c) names match after stripping all separators (SPI_CS1 ↔
            // SPICS1, MUX_SEL ↔ MUXSEL — common UL-vs-datasheet-vs-vendor
            // convention difference).
            if !same_digit_letter_multiset(name1, name2)
                && !same_power_pin_alias(name1, name2)
                && !same_with_separators_stripped(name1, name2)
                && !same_active_low_notation(name1, name2)
                && !same_thermal_pad(name1, name2)
            {
                return None;
            }
            if examples.len() < 4 {
                examples.push(format!("pin {num}: {first_src}={name1} ↔ {src}={name2}"));
            }
        }
    }
    if examples.is_empty() { None } else { Some(examples) }
}

/// True if `a` and `b` have the same multiset of digit-runs and letter-runs
/// when each is tokenized into maximal letter / digit / underscore-or-other
/// chunks. So `OUT1` (["OUT","1"]) matches `1OUT` (["1","OUT"]). `IN1_` and
/// `1IN_` also match. But `P108` and `P400` don't (different digit runs).
fn same_digit_letter_multiset(a: &str, b: &str) -> bool {
    let mut ta = tokenize_alnum(a);
    let mut tb = tokenize_alnum(b);
    ta.sort();
    tb.sort();
    ta == tb
}

/// True if `a` and `b` are both aliases of the same canonical power pin.
/// Handles the common chip-vendor-vs-library naming differences for supplies
/// and ground (single-supply convention vs dual-supply convention).
fn same_power_pin_alias(a: &str, b: &str) -> bool {
    let ca = canonical_power_alias(a);
    let cb = canonical_power_alias(b);
    ca.is_some() && ca == cb
}

/// True when `a` and `b` are equal after stripping all `_` separators.
/// e.g. `SPI_CS1` ↔ `SPICS1`, `MUX_SEL` ↔ `MUXSEL`. Underscore-vs-no-
/// underscore is a UL-bundle-vs-datasheet convention difference that
/// doesn't change pin identity.
fn same_with_separators_stripped(a: &str, b: &str) -> bool {
    let strip = |s: &str| -> String {
        s.chars().filter(|c| *c != '_').collect()
    };
    let sa = strip(a);
    let sb = strip(b);
    !sa.is_empty() && sa == sb
}

/// True when `a` and `b` are the same signal in different **active-low
/// notations**. Datasheets/ds2sf write the bare name (`CS`, `FAULT`) or an
/// `N`-prefix (`NFAULT`); KiCad/EAGLE/Altium libraries mark active-low with an
/// `_N`/`_B`/`#` suffix or an overbar (`~{CS}`, `!CS`, `C\S\`). On a same-pin-
/// number comparison these denote one pin, so the difference is a benign
/// convention, not a conflict. Conservative: only fires when one side carries
/// an explicit negation marker that, once removed, matches the other side
/// (optionally accounting for the bare `N`-prefix form).
fn same_active_low_notation(a: &str, b: &str) -> bool {
    // Strip overbar markup, then ONE trailing active-low suffix.
    let neg_strip = |s: &str| -> String {
        let mut t: String = s.chars().filter(|c| !matches!(c, '~' | '{' | '}' | '!' | '\\')).collect();
        for suf in ["_N", "_B", "#", "_L"] {
            if let Some(p) = t.strip_suffix(suf) { t = p.to_string(); break; }
        }
        t
    };
    if a == b { return false; }
    let (na, nb) = (neg_strip(a), neg_strip(b));
    // One side had an explicit marker and now matches the other.
    if na.len() >= 2 && na == nb { return true; }
    // `N`-prefix (NFAULT) vs `_N`-suffix base (FAULT_N → FAULT).
    let lead_n = |x: &str, base: &str| x.len() > 2 && x.starts_with('N') && &x[1..] == base && base.len() >= 2;
    lead_n(&na, &nb) || lead_n(&nb, &na)
}

/// True when both names denote the exposed/thermal pad — vendors, KiCad and
/// EAGLE/Altium each spell it differently (`PAD`, `THERMAL_PAD`, `EP`, `EPAD`…)
/// but it's the same physical pad, so the difference is benign.
fn same_thermal_pad(a: &str, b: &str) -> bool {
    let is_pad = |s: &str| {
        let u = s.replace('_', "");
        matches!(u.as_str(),
            "PAD" | "THERMALPAD" | "EP" | "EPAD" | "EXPPAD" | "EXPAD" | "GNDPAD"
            | "PPAD" | "POWERPAD" | "DAP" | "TAB" | "HEATSINK" | "THERMAL")
    };
    is_pad(a) && is_pad(b)
}

fn canonical_power_alias(name: &str) -> Option<&'static str> {
    match name {
        // Positive supplies — `V+` is the chip-vendor convention; VCC/VDD are
        // library/silkscreen conventions.
        "V+" | "VPLUS" | "V_PLUS" | "VCC" | "VDD" | "VPP" | "VSUP" => Some("VCC"),
        // Negative supplies / ground.
        "V_" | "V-" | "VMINUS" | "V_MINUS" | "GND" | "VSS" | "VEE" => Some("GND"),
        _ => None,
    }
}

/// Split into runs of letters and runs of digits. Non-alphanumeric chars
/// (`+`, `_`, `-`, `/`) are treated as separators and dropped — so `IN1+`
/// and `1IN+` tokenize identically to ["IN","1"] / ["1","IN"], which sort
/// to the same multiset.
fn tokenize_alnum(s: &str) -> Vec<String> {
    let mut out = Vec::new();
    let mut buf = String::new();
    let mut last_kind: Option<bool> = None;
    for c in s.chars() {
        if !c.is_ascii_alphanumeric() {
            if !buf.is_empty() {
                out.push(std::mem::take(&mut buf));
                last_kind = None;
            }
            continue;
        }
        let kind = c.is_ascii_digit();
        if last_kind != Some(kind) && !buf.is_empty() {
            out.push(std::mem::take(&mut buf));
        }
        buf.push(c);
        last_kind = Some(kind);
    }
    if !buf.is_empty() { out.push(buf); }
    out
}

// ── individual axes ────────────────────────────────────────────────────────

fn axis_pad_count(parts: &[&Part]) -> AxisOutcome {
    let mut values = BTreeMap::new();
    let mut counts: Vec<u32> = Vec::new();
    for p in parts {
        if !p.pads.is_empty() {
            values.insert(p.source.clone(), p.pads.len().to_string());
            counts.push(p.pads.len() as u32);
        } else {
            values.insert(p.source.clone(), "(no footprint loaded)".to_string());
        }
    }
    if counts.len() < 2 {
        return AxisOutcome {
            axis: "pad_count".into(),
            verdict: Verdict::SourceMissing,
            values, conflicts: vec![],
            severity: 1,
        };
    }
    let unique: std::collections::HashSet<_> = counts.iter().collect();
    let verdict = if unique.len() == 1 { Verdict::Agree } else { Verdict::Disagree };
    let conflicts = if verdict == Verdict::Disagree {
        vec![format!("Pad counts differ across sources: {:?}", counts)]
    } else { vec![] };
    AxisOutcome { axis: "pad_count".into(), verdict, values, conflicts, severity: 3 }
}

fn axis_pad_numbers(parts: &[&Part]) -> AxisOutcome {
    let mut values = BTreeMap::new();
    let mut sets: Vec<(String, std::collections::BTreeSet<String>)> = Vec::new();
    for p in parts {
        if p.pads.is_empty() { continue; }
        let s: std::collections::BTreeSet<String> =
            p.pads.iter().map(|p| canon_pin_num(&p.number)).collect();
        values.insert(p.source.clone(), summarize_set(&s));
        sets.push((p.source.clone(), s));
    }
    if sets.len() < 2 {
        return AxisOutcome {
            axis: "pad_numbers".into(),
            verdict: Verdict::SourceMissing,
            values, conflicts: vec![], severity: 1,
        };
    }
    // All sets equal?
    let first = &sets[0].1;
    let all_equal = sets.iter().skip(1).all(|(_, s)| s == first);
    let verdict = if all_equal { Verdict::Agree } else { Verdict::Disagree };
    let mut conflicts = Vec::new();
    if !all_equal {
        for (src, s) in &sets[1..] {
            let only_first: Vec<_> = first.difference(s).cloned().collect();
            let only_other: Vec<_> = s.difference(first).cloned().collect();
            if !only_first.is_empty() {
                conflicts.push(format!("{} has pads {} that {} is missing", sets[0].0, fmt_list(&only_first), src));
            }
            if !only_other.is_empty() {
                conflicts.push(format!("{} has pads {} that {} is missing", src, fmt_list(&only_other), sets[0].0));
            }
        }
    }
    AxisOutcome { axis: "pad_numbers".into(), verdict, values, conflicts, severity: 3 }
}

fn axis_symbol_pin_count(parts: &[&Part]) -> AxisOutcome {
    let mut values = BTreeMap::new();
    let mut counts: Vec<u32> = Vec::new();
    for p in parts {
        if !p.pins.is_empty() {
            values.insert(p.source.clone(), p.pins.len().to_string());
            counts.push(p.pins.len() as u32);
        } else {
            values.insert(p.source.clone(), "(no symbol loaded)".to_string());
        }
    }
    if counts.len() < 2 {
        return AxisOutcome {
            axis: "symbol_pin_count".into(),
            verdict: Verdict::SourceMissing,
            values, conflicts: vec![], severity: 1,
        };
    }
    let unique: std::collections::HashSet<_> = counts.iter().collect();
    let verdict = if unique.len() == 1 { Verdict::Agree } else { Verdict::Disagree };
    let conflicts = if verdict == Verdict::Disagree {
        vec![format!("Pin counts differ across sources: {:?}", counts)]
    } else { vec![] };
    AxisOutcome { axis: "symbol_pin_count".into(), verdict, values, conflicts, severity: 3 }
}

fn axis_pin_names(parts: &[&Part]) -> AxisOutcome {
    // Compare pin-name DICTIONARIES across sources that have non-stub names.
    let mut values = BTreeMap::new();
    let mut maps: Vec<(String, BTreeMap<String, String>)> = Vec::new();
    let mut stub_sources: Vec<String> = Vec::new();
    for p in parts {
        if p.pins.is_empty() {
            values.insert(p.source.clone(), "(no symbol loaded)".to_string());
            continue;
        }
        let stubbed = p.pins.iter().filter(|pp| pp.is_stub_name).count();
        if stubbed > p.pins.len() / 2 {
            // Mostly numbered placeholders (UL stub case) — exclude from name match.
            stub_sources.push(p.source.clone());
            values.insert(p.source.clone(), format!("(stub names: {} of {} are numeric)", stubbed, p.pins.len()));
            continue;
        }
        let m: BTreeMap<String, String> = p
            .pins
            .iter()
            .map(|pp| (canon_pin_num(&pp.number), canon_pin_name(&pp.name)))
            .collect();
        values.insert(p.source.clone(), format!("{} named pins", m.len()));
        maps.push((p.source.clone(), m));
    }
    let mut conflicts: Vec<String> = Vec::new();
    for src in &stub_sources {
        conflicts.push(format!("{} has stub-only pin names (UL bundle did not carry the part's real pin labels)", src));
    }
    if maps.len() < 2 {
        // Only one source has real names — cannot compare. Soft severity since
        // stub sources are still flagged above.
        let verdict = if conflicts.is_empty() { Verdict::SourceMissing } else { Verdict::Disagree };
        return AxisOutcome { axis: "pin_names".into(), verdict, values, conflicts, severity: 2 };
    }
    let (first_src, first_map) = &maps[0];
    for (src, m) in &maps[1..] {
        for (num, name) in first_map {
            if let Some(other) = m.get(num) {
                if other != name {
                    conflicts.push(format!("pin {}: {}={} but {}={}", num, first_src, name, src, other));
                }
            } else {
                conflicts.push(format!("pin {} present in {} ({}) but missing in {}", num, first_src, name, src));
            }
        }
        for (num, name) in m {
            if !first_map.contains_key(num) {
                conflicts.push(format!("pin {} present in {} ({}) but missing in {}", num, src, name, first_src));
            }
        }
    }
    let verdict = if conflicts.is_empty() { Verdict::Agree } else { Verdict::Disagree };
    AxisOutcome { axis: "pin_names".into(), verdict, values, conflicts, severity: 3 }
}

fn axis_pin_to_pad_map(parts: &[&Part]) -> AxisOutcome {
    // Some sources (EAGLE, ds2sf) explicitly map pin name → pad number. Cross-check.
    let mut values = BTreeMap::new();
    let mut maps: Vec<(String, BTreeMap<String, String>)> = Vec::new();
    for p in parts {
        if p.pins.is_empty() { continue; }
        let stubbed = p.pins.iter().filter(|pp| pp.is_stub_name).count();
        if stubbed > p.pins.len() / 2 { continue; }
        // Build {pin_name → pad_number} (pin_name is canonicalized).
        let m: BTreeMap<String, String> = p
            .pins
            .iter()
            .map(|pp| (canon_pin_name(&pp.name), canon_pin_num(&pp.number)))
            .collect();
        values.insert(p.source.clone(), format!("{} mappings", m.len()));
        maps.push((p.source.clone(), m));
    }
    if maps.len() < 2 {
        return AxisOutcome {
            axis: "pin_to_pad_map".into(),
            verdict: Verdict::SourceMissing,
            values, conflicts: vec![], severity: 1,
        };
    }
    let mut conflicts: Vec<String> = Vec::new();
    let (first_src, first_map) = &maps[0];
    for (src, m) in &maps[1..] {
        for (name, pad) in first_map {
            if let Some(other_pad) = m.get(name) {
                if other_pad != pad {
                    conflicts.push(format!("pin {}: {}=pad {} but {}=pad {}", name, first_src, pad, src, other_pad));
                }
            }
        }
    }
    let verdict = if conflicts.is_empty() { Verdict::Agree } else { Verdict::Disagree };
    AxisOutcome { axis: "pin_to_pad_map".into(), verdict, values, conflicts, severity: 3 }
}

fn axis_package_family(parts: &[&Part]) -> AxisOutcome {
    // Reduce each source's package name to a "family" token (SOIC, SOT, QFN, …) and compare.
    let mut values = BTreeMap::new();
    let mut families: Vec<(String, String)> = Vec::new();
    for p in parts {
        if let Some(pkg) = &p.package {
            let fam = package_family(&pkg.raw_name);
            values.insert(p.source.clone(), format!("{} → family={}", pkg.raw_name, fam));
            families.push((p.source.clone(), fam));
        } else {
            values.insert(p.source.clone(), "(no package loaded)".to_string());
        }
    }
    if families.len() < 2 {
        return AxisOutcome {
            axis: "package_family".into(),
            verdict: Verdict::SourceMissing,
            values, conflicts: vec![], severity: 1,
        };
    }
    let unique: std::collections::HashSet<&String> = families.iter().map(|(_, f)| f).collect();
    let verdict = if unique.len() == 1 { Verdict::Agree } else { Verdict::Disagree };
    let mut conflicts = Vec::new();
    if verdict == Verdict::Disagree {
        conflicts.push(format!(
            "Package families disagree across sources: {}",
            families.iter().map(|(s, f)| format!("{s}={f}")).collect::<Vec<_>>().join(", ")
        ));
    }
    AxisOutcome { axis: "package_family".into(), verdict, values, conflicts, severity: 3 }
}

fn axis_body_dims(parts: &[&Part]) -> AxisOutcome {
    // Compare body x/y across sources that report them. Tolerance: 0.5 mm
    // (manufacturer drawings vary; this axis is informational unless the diff
    // is enormous).
    let mut values = BTreeMap::new();
    let mut entries: Vec<(String, f64, f64)> = Vec::new();
    for p in parts {
        if let Some(pkg) = &p.package {
            if let (Some(x), Some(y)) = (pkg.body_x_mm, pkg.body_y_mm) {
                values.insert(p.source.clone(), format!("{:.2}×{:.2} mm", x, y));
                entries.push((p.source.clone(), x, y));
            }
        }
    }
    if entries.len() < 2 {
        return AxisOutcome {
            axis: "body_dims".into(),
            verdict: Verdict::SourceMissing,
            values, conflicts: vec![], severity: 1,
        };
    }
    let mut conflicts = Vec::new();
    let (_, fx, fy) = entries[0];
    for (src, x, y) in &entries[1..] {
        if (x - fx).abs() > 0.5 || (y - fy).abs() > 0.5 {
            conflicts.push(format!(
                "{} body {:.2}×{:.2} mm differs from {} body {:.2}×{:.2} mm by >0.5 mm",
                src, x, y, entries[0].0, fx, fy,
            ));
        }
    }
    let verdict = if conflicts.is_empty() { Verdict::Agree } else { Verdict::Disagree };
    AxisOutcome { axis: "body_dims".into(), verdict, values, conflicts, severity: 2 }
}

// ── helpers ────────────────────────────────────────────────────────────────

/// Canonicalize a pin number — uppercase, no surrounding whitespace.
fn canon_pin_num(s: &str) -> String {
    s.trim().to_uppercase()
}

/// Canonicalize a pin name for comparison: uppercase, replace slashes with
/// underscores (EAGLE convention) so "ALERT/RDY" matches "ALERT_RDY". Also
/// strip trailing `_<digits>` disambiguation suffixes — UL-bundle KiCad
/// symbols often disambiguate repeated pins as "GND_1"/"GND_2"/"GND_3" while
/// the datasheet calls all three just "GND".
fn canon_pin_name(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.trim().chars() {
        match c {
            // ASCII slash / dash AND Unicode minus (U+2212) / en-dash
            // (U+2013) / em-dash (U+2014) — datasheets routinely use the
            // Unicode forms (e.g. "V−", "IN1−", "ALERT/RDY").
            '/' | '-' | '−' | '–' | '—' => out.push('_'),
            _ => out.push(c.to_ascii_uppercase()),
        }
    }
    // Strip trailing _<digits>.
    if let Some(idx) = out.rfind('_') {
        let suffix = &out[idx + 1..];
        if !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit()) {
            out.truncate(idx);
        }
    }
    out
}

/// Reduce a package name like "VSSOP-10" / "DGS0010A" / "SOIC-8W_5.3x5.3mm" /
/// "SOT_ADS1115IDYNR_TEX" / "SOP65P640X120-20N" / "QFP50P1600X1600X170-100N"
/// to a comparable family token. LQFP and TQFP both map to LQFP (same
/// JEDEC outline). DSBGA/WLCSP/CSP/BGA all map to BGA.
fn package_family(raw: &str) -> String {
    let upper = raw.to_uppercase();

    // ── 1. TI JEDEC outline-code prefixes (single-letter PKG_DESIG codes) ──
    // TI prefixes their UL library names with these JEDEC codes; the TI
    // "Generic Package View" lookup table maps them to actual families.
    if upper.starts_with("DGS00") { return "VSSOP".into(); }   // VSSOP/MSOP-10
    if upper.starts_with("DCT00") { return "SOT".into(); }
    if upper.starts_with("DCK00") { return "SC70".into(); }
    if upper.starts_with("DCN00") { return "VSON".into(); }
    if upper.starts_with("DBV00") { return "SOT".into(); }     // SOT-23 family
    if upper.starts_with("DDC00") { return "SOT".into(); }     // SOT-23-6
    if upper.starts_with("DLC00") { return "WSON".into(); }    // TPS62840DLCR
    if upper.starts_with("YBG00") { return "BGA".into(); }     // DSBGA / TPS62840YBGR
    if upper.starts_with("RGE00") || upper.starts_with("RGZ00") { return "QFN".into(); }
    if upper.starts_with("RHB00") || upper.starts_with("RUG00") { return "VQFN".into(); }
    if upper.starts_with("RRG00") { return "VQFN".into(); }
    if upper.starts_with("RGF00") { return "VQFN".into(); }    // TI QFN-40
    if upper.starts_with("RTV00") { return "VQFN".into(); }    // TI QFN-32
    if upper.starts_with("RRY00") { return "VQFN".into(); }
    if upper.starts_with("RSM00") { return "VQFN".into(); }    // TI QFN-32 (RSM)
    if upper.starts_with("RQM00") { return "VQFN".into(); }    // TI QFN-29
    if upper.starts_with("PW")    { return "TSSOP".into(); }
    if upper.starts_with("DR")    { return "QFN".into(); }
    if upper.starts_with("PBS00") { return "TQFP".into(); }    // TI TQFP-32

    // ── 2. Single-letter / short JEDEC outline codes ──
    // KiCad UL sometimes labels parts with their bare JEDEC outline code.
    // SOIC-8: "D" / "D8" / "D_8" (TI's D = SOIC-8).
    // PDIP-8: "P" / "P8" (TI's P = PDIP).
    if upper == "D8" || upper == "D_8" || upper == "D" { return "SOIC".into(); }
    if upper.ends_with("_D8") { return "SOIC".into(); }
    if upper == "P8" || upper == "P_8" || upper == "PDIP" { return "DIP".into(); }
    if upper == "P14" || upper == "P_14" { return "DIP".into(); }
    if upper == "P16" || upper == "P_16" { return "DIP".into(); }
    if upper.ends_with("_P8") || upper.ends_with("_P14") || upper.ends_with("_P16") {
        return "DIP".into();
    }

    // ── 3. TI's UL library-namespace prefix convention ──
    // Many TI UL exports prefix every name with "SOT_" regardless of actual
    // package (it's a library-namespace convention, not a package claim).
    // Strip the prefix and re-inspect.
    if upper.starts_with("SOT_") {
        let stripped = &upper[4..];
        for fam in ["VSSOP", "TSSOP", "SSOP", "MSOP", "SOIC", "QFN", "QFP", "BGA"] {
            if stripped.contains(fam) { return fam.into(); }
        }
    }

    // ── 4. IPC-7351 generic naming: "SOP<pp>P<wid>X<hgt>-<n>N", "QFP<pp>P...",
    // "BGA<pp>P...", "DFN<pp>P..." ─────────────────────────────────────────
    // pp = pitch * 100 (e.g. SOP65 = 0.65 mm pitch). Pitch alone disambiguates
    // SOIC (1.27 mm) from TSSOP/SSOP (0.65 mm) from fine-pitch (0.5 mm).
    if let Some(fam) = parse_ipc7351(&upper) {
        return fam;
    }

    // ── 5. EAGLE / vendor outline aliases ─────────────────────────────────
    if upper.starts_with("DGS00") { return "VSSOP".into(); }
    if upper.contains("LGA") { return "LGA".into(); }

    // ── 6. Family-keyword scan ────────────────────────────────────────────
    // LQFP and TQFP map to the same family (same JEDEC outline; "L" is just
    // low-profile). DSBGA / WLCSP / CSP all collapse to BGA.
    for (token, family) in [
        ("VSSOP", "VSSOP"),
        ("TSSOP", "TSSOP"),
        ("SSOP",  "SSOP"),
        ("MSOP",  "VSSOP"),         // MSOP-10 ≡ VSSOP-10
        ("SOIC-8W",  "SOIC"),
        ("SOIC-16W", "SOIC"),
        ("SOIC",  "SOIC"),
        ("SOIJ",  "SOIC"),
        ("VQFN",  "VQFN"),
        ("QFN",   "QFN"),
        ("WSON",  "WSON"),
        ("DFN",   "DFN"),
        ("LQFP",  "LQFP"),
        ("TQFP",  "LQFP"),          // same JEDEC outline
        ("QFP",   "LQFP"),          // bare "QFP" too — concur treats 'em as one family
        ("DSBGA", "BGA"),
        ("WLCSP", "BGA"),
        ("CSP",   "BGA"),
        ("BGA",   "BGA"),
        ("OLGA",  "LGA"),
        ("LGA",   "LGA"),
        ("SOT-563", "SOT"),
        ("SOT-23-5","SOT"),
        ("SOT-23-6","SOT"),
        ("SOT-23",  "SOT"),
        ("SOT",     "SOT"),
        ("SC-70",   "SC70"),
        ("SC70",    "SC70"),
        ("DIP",     "DIP"),
        ("PDIP",    "DIP"),
        ("WROOM",   "MODULE"),
        ("WROVER",  "MODULE"),
        ("MODULE",  "MODULE"),
    ] {
        if upper.contains(token) {
            return family.into();
        }
    }
    "UNKNOWN".into()
}

/// True when the package name signals an SMD module (chip + supporting
/// passives + RF shield + PCB antenna packaged as a sub-assembly with
/// castellated edge pads) rather than a bare IC. Used by the
/// `module_extra_pads` variant detector to avoid false-classifying
/// undercounted-IC extractions as benign module variants.
fn is_module_package(raw: &str) -> bool {
    let upper = raw.to_uppercase();
    const MODULE_MARKERS: &[&str] = &[
        "MODULE", "WROOM", "WROVER", "WROOM-1", "WROOM-2",
        "NORA-", "NORA_", "ATWINC", "SARA-", "SARA_", "MIKROBUS",
        "FEATHER", "XBEE", "XPLAINED", "BREAKOUT",
        "CASTELLATED", "EDGE-PAD", "EDGE_PAD",
    ];
    MODULE_MARKERS.iter().any(|m| upper.contains(m))
}

/// Parse IPC-7351-style generic package names like:
///   SOP65P640X120-20N      → TSSOP (pitch 0.65 mm, narrow)
///   QFP50P1600X1600X170-100N → LQFP (pitch 0.5 mm, 100-pin)
///   BGA50P800X800-49N      → BGA
///   DFN65P200X300-8N       → DFN
fn parse_ipc7351(upper: &str) -> Option<String> {
    let bytes = upper.as_bytes();
    let prefixes: &[(&str, &str)] = &[
        // (prefix, default family — refined by pitch)
        ("SOP",  "SOIC"),
        ("QFP",  "LQFP"),
        ("BGA",  "BGA"),
        ("CSP",  "BGA"),
        ("DFN",  "DFN"),
        ("QFN",  "QFN"),
        ("SON",  "WSON"),
        ("WSON", "WSON"),
    ];
    for (prefix, default_family) in prefixes {
        if !upper.starts_with(prefix) { continue; }
        // Read pitch digits immediately after prefix.
        let after = &upper[prefix.len()..];
        let mut pitch_chars = String::new();
        for &b in after.as_bytes() {
            if b.is_ascii_digit() { pitch_chars.push(b as char); } else { break; }
        }
        if pitch_chars.is_empty() { continue; }
        // The next char should be 'P' (per IPC-7351). If not, this isn't
        // an IPC-7351 generic name — bail.
        let next_idx = prefix.len() + pitch_chars.len();
        if next_idx >= bytes.len() || bytes[next_idx] != b'P' { continue; }

        let pitch_pp: u32 = pitch_chars.parse().ok()?;
        let family = match (*prefix, pitch_pp) {
            // SOP family disambiguation by pitch.
            ("SOP", 127) => "SOIC",       // 1.27 mm = SOIC
            ("SOP", p) if p <= 50 => "TSSOP",
            ("SOP", p) if p <= 65 => "TSSOP",
            ("SOP", _) => "SSOP",
            // QFP: any pitch is LQFP.
            ("QFP", _) => "LQFP",
            _ => default_family,
        };
        return Some(family.into());
    }
    None
}

fn summarize_set(s: &std::collections::BTreeSet<String>) -> String {
    if s.len() <= 4 {
        s.iter().cloned().collect::<Vec<_>>().join(",")
    } else {
        format!("{} pads ({}…{})", s.len(), s.iter().next().unwrap_or(&"?".to_string()), s.iter().last().unwrap_or(&"?".to_string()))
    }
}

fn fmt_list(v: &[String]) -> String {
    if v.len() <= 4 { v.join(",") } else { format!("{} items", v.len()) }
}