//! Altium `.PcbLib` (and `.IntLib`'s embedded PcbLib stream) adapter.
//!
//! Wraps the shared `altium-pcblib` crate and adapts its rich pad JSON down
//! to concur's lean [`Part`] shape — concur only needs pad designators +
//! the footprint name to score consensus, so we drop position / size /
//! shape / drill on the floor.
//!
//! ## Multi-variant selection
//!
//! Many vendor `.PcbLib`s ship multiple footprint variants in one file —
//! e.g. DRV8316RRGFR carries `RGF0040E-IPC_A` (41-pad IPC standard, the
//! one that matches the chip's actual pinout) and `RGF0040E-MFG` (57-pad
//! manufacturing layout with extra test points and fiducials). The
//! upstream `altium-pcblib` crate picks one as "primary" by stream order
//! and shoves the rest into `_alternates`. That order isn't guaranteed
//! to give us the IPC variant, and getting the wrong one silently
//! breaks the consensus check (DRV8316 v0.4 → divergent because altium
//! reported 57 pads vs kicad+eagle's 41).
//!
//! [`parse_with_consensus_hint`] walks all the variants (primary +
//! alternates) and picks the one whose pad-count is closest to the
//! `consensus_pad_count` hint passed in by cli.rs (computed from the
//! other parsed sources). If no consensus is available yet, falls back
//! to the primary the upstream picked.

use anyhow::{Context as _, Result};
use std::path::Path;

use super::{Pad, PackageInfo, Part, Pin};

#[derive(Debug, Clone)]
struct AltiumVariant {
    module_name: Option<String>,
    pads: Vec<Pad>,
    parse_error: Option<String>,
}

/// Parse without any consensus hint (caller doesn't know what other sources
/// say yet). Returns the primary footprint as upstream chose it. Kept as
/// public for callers that don't have other sources to reason against.
#[allow(dead_code)]
pub fn parse(path: &Path) -> Result<Part> {
    parse_with_consensus_hint(path, None, None)
}

/// Read the Altium `.SchLib` symbol pins (number + name) via `altium-schlib`,
/// so the Altium source contributes to the symbol-side axes (pin count, pin
/// names, pin→pad map) — not just footprint pads. Best-effort: a missing or
/// unparseable `.SchLib` just yields no pins (footprint-only, as before).
pub fn parse_schlib_pins(schlib: &Path) -> Vec<Pin> {
    let Ok(sym) = altium_schlib::parse_altium_schlib_path(schlib) else { return Vec::new() };
    let Some(arr) = sym.get("pins").and_then(|p| p.as_array()) else { return Vec::new() };
    arr.iter()
        .filter_map(|p| {
            let number = p.get("number").and_then(|n| n.as_str())?.to_string();
            // Altium SchLib pins carry per-char overbar backslashes (C\S\); strip
            // them so names compare apples-to-apples with KiCad/EAGLE/ds2sf.
            let name = p.get("name").and_then(|n| n.as_str()).unwrap_or("").replace('\\', "");
            if number.is_empty() { return None; }
            let is_stub_name = name.is_empty() || name == number;
            Some(Pin { number, name, is_stub_name })
        })
        .collect()
}

/// Parse and pick the variant whose pad count is closest to
/// `consensus_pad_count`. When `Some`, walks primary + every alternate.
/// `schlib` (when present) supplies the symbol-side pins.
pub fn parse_with_consensus_hint(
    path: &Path,
    consensus_pad_count: Option<usize>,
    schlib: Option<&Path>,
) -> Result<Part> {
    let parsed = altium_pcblib::parse_altium_pcblib_path(path)
        .with_context(|| format!("altium-pcblib parsing {}", path.display()))?;

    let mut variants: Vec<AltiumVariant> = Vec::new();
    variants.push(extract_variant(&parsed));
    if let Some(alts) = parsed.get("_alternates").and_then(|v| v.as_array()) {
        for alt in alts {
            variants.push(extract_variant(alt));
        }
    }

    // Reject variants that didn't parse (parse_error AND no pads) — they
    // can't contribute to a consensus and would just add noise.
    variants.retain(|v| !v.pads.is_empty() || v.parse_error.is_none());

    if variants.is_empty() {
        anyhow::bail!("no parseable footprint variants in PcbLib");
    }

    let chosen = match consensus_pad_count {
        Some(target) => {
            // Pick the variant whose pad count is closest to target.
            // Tiebreak: prefer the one whose name doesn't scream "MFG" /
            // "DEBUG" / "TEST" / "FIDUCIAL" (manufacturing layouts with
            // extra non-pinout pads).
            variants
                .iter()
                .min_by_key(|v| {
                    let diff = v.pads.len().abs_diff(target);
                    let name_penalty = v
                        .module_name
                        .as_ref()
                        .map(|n| {
                            let upper = n.to_uppercase();
                            if upper.contains("-MFG") || upper.contains("_MFG")
                                || upper.contains("DEBUG") || upper.contains("FIDUCIAL")
                                || upper.contains("TEST")
                            {
                                100usize
                            } else {
                                0
                            }
                        })
                        .unwrap_or(0);
                    diff * 1000 + name_penalty
                })
                .expect("variants is non-empty")
                .clone()
        }
        None => variants[0].clone(),
    };

    if chosen.pads.is_empty() {
        if let Some(err_msg) = chosen.parse_error.as_ref() {
            anyhow::bail!("chosen variant failed to parse: {err_msg}");
        }
    }

    let package = chosen.module_name.clone().map(|raw_name| PackageInfo {
        raw_name,
        body_x_mm: None,
        body_y_mm: None,
    });

    let pins = schlib.map(parse_schlib_pins).unwrap_or_default();

    Ok(Part {
        source: "altium".to_string(),
        source_part_name: chosen.module_name,
        pads: chosen.pads,
        pins,
        package,
    })
}

fn extract_variant(v: &serde_json::Value) -> AltiumVariant {
    let module_name = v.get("module_name").and_then(|x| x.as_str()).map(String::from);
    let pads: Vec<Pad> = v
        .get("pads")
        .and_then(|x| x.as_array())
        .map(|arr| {
            arr.iter()
                .filter_map(|p| p.get("number").and_then(|n| n.as_str()).map(String::from))
                .map(|number| Pad { number })
                .collect()
        })
        .unwrap_or_default();
    let parse_error = v.get("_parse_error").and_then(|x| x.as_str()).map(String::from);
    AltiumVariant { module_name, pads, parse_error }
}