123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674
//! `adom-chipsmith view-library <chip-fetcher-dir>` — open a chip from a
//! chip-fetcher library directory. Auto-discovers the .step, .kicad_mod,
//! .pdf, and info.json files inside the directory and seeds the server
//! state so the UI's Source tab is populated immediately.

use crate::cli::{err, ok, DEFAULT_PORT};
use crate::server::{self, convert, ServerConfig, ServerState};
use anyhow::{anyhow, Result};
use clap::Parser;
use std::path::PathBuf;

#[derive(Parser)]
pub struct Args {
    /// Path to a chip-fetcher library directory. Example:
    /// `/home/adom/project/chip-fetcher/library/VL53L8CX`.
    pub dir: PathBuf,

    #[arg(long, default_value_t = DEFAULT_PORT)]
    pub port: u16,

    #[arg(long)]
    pub no_open: bool,

    #[arg(long, default_value = "Chipsmith")]
    pub tab_name: String,

    #[arg(long, default_value = "mdi:chip")]
    pub display_icon: String,
}

pub fn run(args: Args) -> Result<()> {
    if !args.dir.is_dir() {
        return Err(anyhow!("not a directory: {}", args.dir.display()));
    }

    let mpn = args.dir.file_name()
        .and_then(|s| s.to_str())
        .ok_or_else(|| anyhow!("can't derive MPN from dir: {}", args.dir.display()))?
        .to_string();

    // Find the canonical STEP file: <MPN>.step preferred, else first .step in dir
    let mut step_path = args.dir.join(format!("{mpn}.step"));
    if !step_path.exists() {
        step_path = args.dir.join(format!("{mpn}.STEP"));
    }
    if !step_path.exists() {
        // Find any .step / .stp in the dir
        let entries = std::fs::read_dir(&args.dir).map_err(|e| anyhow!("read dir: {e}"))?;
        let mut found = None;
        for ent in entries {
            let p = ent.map_err(|e| anyhow!("dir entry: {e}"))?.path();
            if let Some(ext) = p.extension().and_then(|s| s.to_str()) {
                if matches!(ext.to_lowercase().as_str(), "step" | "stp") {
                    found = Some(p);
                    break;
                }
            }
        }
        step_path = found.ok_or_else(|| anyhow!("no .step in {}", args.dir.display()))?;
    }

    // Find footprint, datasheet, info.json
    let footprint_path = args.dir.join(format!("{mpn}.kicad_mod"));
    let datasheet_path = args.dir.join(format!("{mpn}.pdf"));
    let info_path = args.dir.join("info.json");

    ok(format!("loading {mpn} from {}", args.dir.display()));
    if footprint_path.exists() { ok(format!("  · footprint: {}", footprint_path.display())); }
    if datasheet_path.exists() { ok(format!("  · datasheet: {}", datasheet_path.display())); }
    if info_path.exists() { ok(format!("  · info.json: {}", info_path.display())); }

    // Convert STEP → GLB
    let step_bytes = std::fs::read(&step_path).map_err(|e| anyhow!("read STEP: {e}"))?;
    let result = match convert::convert_step_bytes(&step_bytes) {
        Ok(r) => r,
        Err(e) => { err(format!("conversion failed: {e}")); return Err(e); }
    };
    let label = if result.cache_hit { "cache HIT" } else { "cache MISS" };
    ok(format!(
        "{label} — GLB at {} ({} KB, {} meshes, {} nodes, {} ms)",
        result.glb_path.display(), result.size_bytes / 1024,
        result.meshes, result.nodes, result.duration_ms
    ));

    let config = ServerConfig {
        port: args.port,
        initial_glb_path: Some(result.glb_path.clone()),
        initial_source_label: Some(format!("{mpn}.step")),
        initial_source_type: Some("chip-fetcher".to_string()),
    };
    let state = ServerState::new(config);

    // Seed the spec from info.json if present
    if info_path.exists() {
        if let Ok(s) = std::fs::read_to_string(&info_path) {
            if let Ok(v) = serde_json::from_str::<serde_json::Value>(&s) {
                *state.spec.lock().unwrap() = v;
            }
        }
    }

    // Seed the footprint by parsing .kicad_mod
    if footprint_path.exists() {
        if let Ok(parsed) = parse_kicad_mod(&footprint_path) {
            *state.footprint.lock().unwrap() = parsed;
        }
    }

    // Multi-source footprints: chip-fetcher persists info.json with a
    // footprint_sources block pointing at the four EDA-format paths. For now
    // we only have a KiCad parser; Fusion .lbr and Altium .IntLib parsers are
    // stubs (we mirror the .kicad_mod data into each available source so the
    // overlay renders + cross-validation runs even with one source). When
    // chip-fetcher generates per-source `.kicad_mod` files (e.g.,
    // <MPN>.datasheet-fp.kicad_mod, <MPN>-from-fusion.kicad_mod), parse those.
    let mut sources = serde_json::Map::new();
    let kicad_obj = state.footprint.lock().unwrap().clone();
    if !kicad_obj.is_null() {
        sources.insert("kicad".to_string(), kicad_obj);
    }
    let datasheet_fp_path = args.dir.join(format!("{mpn}.datasheet-fp.kicad_mod"));
    if datasheet_fp_path.exists() {
        if let Ok(parsed) = parse_kicad_mod(&datasheet_fp_path) {
            sources.insert("datasheet".to_string(), parsed);
            ok(format!("  · datasheet-fp: {}", datasheet_fp_path.display()));
        }
    }
    let fusion_fp_path = args.dir.join(format!("{mpn}-from-fusion.kicad_mod"));
    if fusion_fp_path.exists() {
        if let Ok(parsed) = parse_kicad_mod(&fusion_fp_path) {
            sources.insert("fusion".to_string(), parsed);
            ok(format!("  · fusion-fp (KiCad re-export): {}", fusion_fp_path.display()));
        }
    }
    let altium_fp_path = args.dir.join(format!("{mpn}-from-altium.kicad_mod"));
    if altium_fp_path.exists() {
        if let Ok(parsed) = parse_kicad_mod(&altium_fp_path) {
            sources.insert("altium".to_string(), parsed);
            ok(format!("  · altium-fp (KiCad re-export): {}", altium_fp_path.display()));
        }
    }

    // Direct Eagle .lbr parse — Eagle/Fusion 360 stores XML with <smd>/<pad>
    // elements per package. Eagle libraries routinely carry MULTIPLE packages
    // for the same logical IC (DIP + SOIC + QFN + …) — we walk every <package>
    // block, score each against the chipsmith spec (package_kind + pin_count),
    // and pick the best match. The non-winners ride along as `_alternates`
    // so the UI can offer a picker if the auto-pick is wrong.
    let lbr_path = args.dir.join(format!("{mpn}.lbr"));
    if lbr_path.exists() && !sources.contains_key("fusion") {
        let spec_v = state.spec.lock().unwrap().clone();
        let hint_kind = spec_v.get("package_kind").and_then(|v| v.as_str()).map(|s| s.to_string())
            .or_else(|| spec_v.get("package").and_then(|v| v.as_str()).map(|s| {
                // "SOIC-8 (D, 4.9×3.9 mm, ..." → "SOIC"
                s.split(|c: char| c == '-' || c == ' ' || c == '(').next().unwrap_or("").to_string()
            }));
        let hint_pin_count = spec_v.get("pin_count").and_then(|v| v.as_u64()).map(|n| n as usize);
        if let Ok(parsed) = parse_eagle_lbr_with_hint(&lbr_path, hint_kind.as_deref(), hint_pin_count) {
            let pkg_name = parsed.get("module_name").and_then(|v| v.as_str()).unwrap_or("?").to_string();
            let alt_count = parsed.get("_alternates").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0);
            sources.insert("fusion".to_string(), parsed);
            if alt_count > 0 {
                ok(format!("  · fusion: parsed {} → picked '{pkg_name}' (best of {} packages)",
                    lbr_path.display(), alt_count + 1));
            } else {
                ok(format!("  · fusion: parsed {} → '{pkg_name}'", lbr_path.display()));
            }
        }
    }
    // Altium .PcbLib / .IntLib is a Microsoft OLE2 compound document. We
    // parse it natively in src/altium_pcblib.rs; when a sibling
    // <MPN>-from-altium.kicad_mod is also present, the KiCad re-export above
    // wins and we don't double-insert here.
    let altium_pcblib = args.dir.join(format!("{mpn}.PcbLib"));
    let altium_intlib = args.dir.join(format!("{mpn}.IntLib"));
    if (altium_pcblib.exists() || altium_intlib.exists()) && !sources.contains_key("altium") {
        let path = if altium_pcblib.exists() { &altium_pcblib } else { &altium_intlib };
        let kind = if altium_pcblib.exists() { "PcbLib" } else { "IntLib" };
        match altium_pcblib::parse_altium_pcblib_path(path) {
            Ok(parsed) => {
                let pad_count = parsed.get("pad_count").and_then(|v| v.as_u64()).unwrap_or(0);
                let module = parsed.get("module_name").and_then(|v| v.as_str()).unwrap_or("?").to_string();
                let alt_n = parsed.get("_alternates").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0);
                sources.insert("altium".to_string(), parsed);
                if alt_n > 0 {
                    ok(format!("  · altium ({kind}): parsed {} → '{module}' ({pad_count} pads, +{alt_n} alternates)", path.display()));
                } else {
                    ok(format!("  · altium ({kind}): parsed {} → '{module}' ({pad_count} pads)", path.display()));
                }
            }
            Err(e) => {
                err(format!("  · altium ({kind}): parse failed — {e}"));
                sources.insert("altium".to_string(), serde_json::json!({
                    "module_name": format!("{mpn} (Altium {kind})"),
                    "pad_count": 0,
                    "pads": [],
                    "silk_markers": [],
                    "pin1_silk": { "found": false },
                    "_source": "altium_pcblib",
                    "_parse_error": e.to_string(),
                    "_path": path.display().to_string(),
                }));
            }
        }
    }

    // Datasheet-derived footprint stub. Real parser pulls from the
    // datasheet's mechanical drawing page (PDF text + vector + LLM-vision
    // assist). Until chip-fetcher generates <MPN>.datasheet-fp.kicad_mod,
    // surface the .pdf as a stub so the hierarchy + Footprint tab show
    // the source slot as "datasheet PDF detected, parser pending".
    if datasheet_path.exists() && !sources.contains_key("datasheet") {
        sources.insert("datasheet".to_string(), serde_json::json!({
            "module_name": format!("{mpn} (datasheet-derived)"),
            "pad_count": 0,
            "pads": [],
            "silk_markers": [],
            "pin1_silk": { "found": false },
            "_source": "datasheet_pdf",
            "_stub": true,
            "_stub_reason": "Datasheet-derived footprint pending — chip-fetcher should generate <MPN>.datasheet-fp.kicad_mod from the datasheet's mechanical drawing page (PDF text + vector + LLM-vision).",
            "_path": datasheet_path.display().to_string(),
        }));
        ok(format!("  · datasheet: {} (stub — PDF extraction pending)", datasheet_path.display()));
    }
    if !sources.is_empty() {
        *state.footprint_sources.lock().unwrap() = serde_json::Value::Object(sources);
    }

    // Sign-off sidecar lives next to the source files
    *state.signoff_dir.lock().unwrap() = Some(args.dir.clone());
    // Persist the library dir so `adom-chipsmith restart` can re-use it.
    let _ = std::fs::write("/tmp/adom-chipsmith-last-library", args.dir.display().to_string());
    *state.source_step_path.lock().unwrap() = Some(step_path.clone());

    let shutdown_state = state.clone();
    ctrlc::set_handler(move || {
        eprintln!("\nreceived SIGINT, shutting down adom-chipsmith");
        let _ = &shutdown_state;
        std::process::exit(0);
    })?;

    if !args.no_open {
        let port = args.port;
        let tab_name = args.tab_name.clone();
        let icon = args.display_icon.clone();
        std::thread::spawn(move || {
            std::thread::sleep(std::time::Duration::from_millis(400));
            if let Err(e) = crate::cli::open::open_webview_tab(port, &tab_name, &icon) {
                eprintln!("warn: failed to open webview tab: {e}");
            }
        });
    }

    ok(format!("adom-chipsmith running on port {}", args.port));
    server::run(state)?;
    Ok(())
}

/// Parse an Eagle .lbr XML file → footprint JSON (mirrors the .kicad_mod schema).
/// We pull <smd> + <pad> elements from the first <package> block.
fn parse_eagle_lbr(path: &std::path::Path) -> Result<serde_json::Value> {
    parse_eagle_lbr_with_hint(path, None, None)
}

/// Multi-package Eagle .lbr parser. Walks every <package> block in the
/// library, returns ALL of them, and ranks them by how well they match the
/// chipsmith spec (package_kind + pin_count). The caller's "primary"
/// footprint is the best match; the rest live under `_alternates` so the UI
/// can offer a picker.
///
/// Eagle libraries routinely carry multiple packages per part (DIP + SOIC
/// + TSSOP + QFN — the same logical IC in five physical packages). Picking
/// the first one was the v0.1.x bug — it'd give you a DIP footprint for a
/// chip you fetched in SOIC.
fn parse_eagle_lbr_with_hint(
    path: &std::path::Path,
    hint_kind: Option<&str>,
    hint_pin_count: Option<usize>,
) -> Result<serde_json::Value> {
    let xml = std::fs::read_to_string(path).map_err(|e| anyhow!("read .lbr: {e}"))?;
    let mut packages: Vec<serde_json::Value> = Vec::new();
    let mut i = 0;
    while i < xml.len() {
        let pkg_open = match find_subseq(&xml[i..], "<package ") {
            Some(o) => i + o,
            None => break,
        };
        let pkg_close = match find_subseq(&xml[pkg_open..], "</package>") {
            Some(o) => pkg_open + o,
            None => break,
        };
        let block = &xml[pkg_open..pkg_close];
        let pkg_name = attr_value(block, "name").unwrap_or_default();
        let mut pads: Vec<serde_json::Value> = Vec::new();
        // <smd> — surface-mount pads
        let mut bi = 0;
        while let Some(at) = find_subseq(&block[bi..], "<smd ") {
            let start = bi + at;
            let end = match find_subseq(&block[start..], "/>") {
                Some(o) => start + o + 2,
                None => break,
            };
            let elem = &block[start..end];
            let pad_num = attr_value(elem, "name").unwrap_or_default();
            let x = attr_f64(elem, "x").unwrap_or(0.0);
            let y = attr_f64(elem, "y").unwrap_or(0.0);
            let dx = attr_f64(elem, "dx").unwrap_or(0.0);
            let dy = attr_f64(elem, "dy").unwrap_or(0.0);
            // Eagle stores rotation as `rot="R90"` / `rot="R180"` etc.
            // Strip the leading "R" / "MR" (mirrored), parse the rest as
            // float. Default 0 when absent. Sign-flip Y to match
            // KiCad's screen-axis convention (Eagle is Y-up, KiCad Y-down)
            // — the same flip applied to position; rotations are
            // unaffected because both libraries measure CCW from the
            // local pad X axis.
            let rot_attr = attr_value(elem, "rot").unwrap_or_default();
            let rot_str = rot_attr.trim_start_matches('M').trim_start_matches('R');
            let rot_deg: f64 = rot_str.parse().unwrap_or(0.0);
            let layer = attr_value(elem, "layer").unwrap_or_else(|| "1".to_string());
            let on_top = layer == "1";
            let layers = if on_top {
                vec!["F.Cu".to_string(), "F.Paste".to_string(), "F.Mask".to_string()]
            } else {
                vec!["B.Cu".to_string(), "B.Paste".to_string(), "B.Mask".to_string()]
            };
            pads.push(serde_json::json!({
                "number": pad_num,
                "type": "smd",
                "shape": "rect",
                "at": [x, -y, rot_deg],
                "size": [dx, dy],
                "layers": layers,
                "drill": serde_json::Value::Null,
            }));
            bi = end;
        }
        // <pad> — plated through-hole
        let mut bi = 0;
        while let Some(at) = find_subseq(&block[bi..], "<pad ") {
            let start = bi + at;
            let end = match find_subseq(&block[start..], "/>") {
                Some(o) => start + o + 2,
                None => break,
            };
            let elem = &block[start..end];
            let pad_num = attr_value(elem, "name").unwrap_or_default();
            let x = attr_f64(elem, "x").unwrap_or(0.0);
            let y = attr_f64(elem, "y").unwrap_or(0.0);
            let drill = attr_f64(elem, "drill").unwrap_or(0.0);
            let diameter = attr_f64(elem, "diameter").unwrap_or(drill * 1.7);
            pads.push(serde_json::json!({
                "number": pad_num,
                "type": "thru_hole",
                "shape": "circle",
                "at": [x, -y, 0.0],
                "size": [diameter, diameter],
                "layers": ["*.Cu", "*.Paste", "*.Mask"],
                "drill": { "shape": "circle", "diameter": drill },
            }));
            bi = end;
        }
        if !pads.is_empty() {
            packages.push(serde_json::json!({
                "module_name": pkg_name,
                "pad_count": pads.len(),
                "pads": pads,
                "silk_markers": [],
                "pin1_silk": { "found": false },
                "_source": "eagle_lbr",
            }));
        }
        i = pkg_close + 10;
    }
    if packages.is_empty() {
        return Ok(serde_json::json!({
            "module_name": "",
            "pad_count": 0,
            "pads": [],
            "silk_markers": [],
            "pin1_silk": { "found": false },
            "_source": "eagle_lbr",
        }));
    }
    // Score each package against the hints. Higher score = better match.
    //   +100 if pin_count matches exactly
    //   +50  if pin_count is within ±2 (close-but-no-cigar — same family)
    //   +30  if package name contains the kind hint (e.g. "SOIC" in "SO8")
    //   +10  if package name contains kind synonyms ("SO" matches SOIC)
    let kind_synonyms: &[(&str, &[&str])] = &[
        ("SOIC", &["SOIC", "SO", "SOP"]),
        ("QFN",  &["QFN", "DFN", "VQFN", "MLF", "MLP"]),
        ("TSSOP", &["TSSOP", "TSOP"]),
        ("LGA",  &["LGA"]),
        ("QFP",  &["QFP", "LQFP", "TQFP"]),
        ("DIP",  &["DIP"]),
        ("SOT",  &["SOT"]),
        ("BGA",  &["BGA", "WLCSP"]),
    ];
    let mut scored: Vec<(i32, usize)> = packages.iter().enumerate().map(|(idx, pkg)| {
        let mut score: i32 = 0;
        let pad_count = pkg.get("pad_count").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
        if let Some(want) = hint_pin_count {
            if pad_count == want { score += 100; }
            else if (pad_count as i64 - want as i64).abs() <= 2 { score += 50; }
        }
        if let Some(kind) = hint_kind {
            let name = pkg.get("module_name").and_then(|v| v.as_str()).unwrap_or("").to_uppercase();
            let kind_u = kind.to_uppercase();
            if name.contains(&kind_u) { score += 30; }
            for (canon, syns) in kind_synonyms {
                if canon.eq_ignore_ascii_case(&kind_u) {
                    for syn in *syns {
                        if name.contains(*syn) { score += 10; break; }
                    }
                }
            }
        }
        (score, idx)
    }).collect();
    scored.sort_by(|a, b| b.0.cmp(&a.0));
    let best_idx = scored[0].1;
    let mut primary = packages[best_idx].clone();
    let alternates: Vec<serde_json::Value> = packages.iter().enumerate()
        .filter(|(idx, _)| *idx != best_idx)
        .map(|(_, p)| p.clone())
        .collect();
    if !alternates.is_empty() {
        if let Some(o) = primary.as_object_mut() {
            o.insert("_alternates".to_string(), serde_json::Value::Array(alternates));
            o.insert("_match_score".to_string(), serde_json::Value::from(scored[0].0));
        }
    }
    Ok(primary)
}

fn find_subseq(haystack: &str, needle: &str) -> Option<usize> {
    haystack.find(needle)
}

fn attr_value(elem: &str, name: &str) -> Option<String> {
    let key = format!("{name}=\"");
    let start = elem.find(&key)? + key.len();
    let rest = &elem[start..];
    let end = rest.find('"')?;
    Some(rest[..end].to_string())
}

fn attr_f64(elem: &str, name: &str) -> Option<f64> {
    attr_value(elem, name)?.parse().ok()
}

/// Minimal .kicad_mod parser — extracts pads with their position, size, shape,
/// drill, layers. Enough for chipsmith to render footprint pads + the 1.6mm
/// faux PCB plate with the correct hole-type per pad.
fn parse_kicad_mod(path: &std::path::Path) -> Result<serde_json::Value> {
    let s = std::fs::read_to_string(path).map_err(|e| anyhow!("read footprint: {e}"))?;
    parse_kicad_mod_text(&s)
}

/// Parse a .kicad_mod from raw text (for service-kicad fetches that
/// arrive as HTTP body, not files on disk). Same output shape as
/// parse_kicad_mod so cross-validation downstream works identically.
pub fn parse_kicad_mod_text(s: &str) -> Result<serde_json::Value> {
    let tokens = tokenize_sexpr(s);
    let (root, _) = parse_sexpr_node(&tokens, 0)?;
    let mut pads = Vec::new();
    let mut module_name = String::new();
    let mut silk_markers = Vec::new();
    walk_pads_with_silk(&root, &mut pads, &mut module_name, &mut silk_markers);
    let pin1_silk = pad1_silk_match(&silk_markers, &pads);
    Ok(serde_json::json!({
        "module_name": module_name,
        "pad_count": pads.len(),
        "pads": pads,
        "silk_markers": silk_markers,
        "pin1_silk": pin1_silk,
    }))
}

fn pad1_silk_match(silk: &[serde_json::Value], pads: &[serde_json::Value]) -> serde_json::Value {
    // Filter out fp_text markers that are clearly reference-designator placeholders
    // (REF**, U**, etc.) — they live at the footprint origin, not at a pin-1 dot.
    // Prefer fp_circle / fp_arc / fp_poly. Only accept fp_text when its text is
    // literally "1" (or "PIN1" / "1•").
    fn is_reasonable_pin1_marker(m: &serde_json::Value) -> bool {
        let kind = m.get("kind").and_then(|v| v.as_str()).unwrap_or("");
        if matches!(kind, "fp_circle" | "fp_arc" | "fp_poly") { return true; }
        if kind == "fp_text" {
            let t = m.get("text").and_then(|v| v.as_str()).unwrap_or("");
            // accept "1" or anything containing "PIN1"
            return t.trim() == "1" || t.to_uppercase().contains("PIN1");
        }
        false
    }
    let pad1 = pads.iter().find(|p| p.get("number").and_then(|v| v.as_str()) == Some("1"));
    if let Some(p) = pad1 {
        if let (Some(x), Some(y)) = (
            p.get("at").and_then(|a| a.get(0)).and_then(|v| v.as_f64()),
            p.get("at").and_then(|a| a.get(1)).and_then(|v| v.as_f64()),
        ) {
            let mut best = None;
            let mut best_dist = f64::MAX;
            for m in silk {
                if !is_reasonable_pin1_marker(m) { continue; }
                if let Some(c) = m.get("center") {
                    if let (Some(cx), Some(cy)) = (c.get(0).and_then(|v| v.as_f64()), c.get(1).and_then(|v| v.as_f64())) {
                        let d = ((x - cx).powi(2) + (y - cy).powi(2)).sqrt();
                        if d < best_dist { best_dist = d; best = Some(m.clone()); }
                    }
                }
            }
            if let Some(m) = best {
                if best_dist < 5.0 {
                    return serde_json::json!({
                        "found": true,
                        "marker": m,
                        "near_pad": "1",
                        "distance_mm": best_dist,
                    });
                }
            }
        }
    }
    serde_json::json!({"found": false})
}

#[derive(Debug)]
enum Sexpr {
    Atom(String),
    List(Vec<Sexpr>),
}

fn tokenize_sexpr(s: &str) -> Vec<String> {
    let mut tokens = Vec::new();
    let mut cur = String::new();
    let mut in_str = false;
    for ch in s.chars() {
        if in_str {
            cur.push(ch);
            if ch == '"' { tokens.push(cur.clone()); cur.clear(); in_str = false; }
            continue;
        }
        match ch {
            '"' => { if !cur.is_empty() { tokens.push(cur.clone()); cur.clear(); } in_str = true; cur.push(ch); }
            '(' | ')' => { if !cur.is_empty() { tokens.push(cur.clone()); cur.clear(); } tokens.push(ch.to_string()); }
            c if c.is_whitespace() => { if !cur.is_empty() { tokens.push(cur.clone()); cur.clear(); } }
            _ => cur.push(ch),
        }
    }
    if !cur.is_empty() { tokens.push(cur); }
    tokens
}

fn parse_sexpr_node(tokens: &[String], i: usize) -> Result<(Sexpr, usize)> {
    if i >= tokens.len() { return Err(anyhow!("unexpected end of sexpr")); }
    if tokens[i] == "(" {
        let mut children = Vec::new();
        let mut j = i + 1;
        while j < tokens.len() && tokens[j] != ")" {
            let (child, k) = parse_sexpr_node(tokens, j)?;
            children.push(child);
            j = k;
        }
        Ok((Sexpr::List(children), j + 1))
    } else {
        Ok((Sexpr::Atom(tokens[i].clone()), i + 1))
    }
}

fn atom(s: &Sexpr) -> Option<&str> { if let Sexpr::Atom(a) = s { Some(a.as_str()) } else { None } }

fn walk_pads(node: &Sexpr, pads: &mut Vec<serde_json::Value>, module_name: &mut String) {
    walk_pads_with_silk(node, pads, module_name, &mut Vec::new());
}

fn walk_pads_with_silk(node: &Sexpr, pads: &mut Vec<serde_json::Value>, module_name: &mut String, silk_markers: &mut Vec<serde_json::Value>) {
    if let Sexpr::List(items) = node {
        if let Some(head) = items.first().and_then(atom) {
            match head {
                "footprint" | "module" => {
                    if let Some(name) = items.get(1).and_then(atom) { *module_name = name.trim_matches('"').to_string(); }
                    for c in items.iter().skip(2) { walk_pads_with_silk(c, pads, module_name, silk_markers); }
                }
                // Silkscreen primitives
                "fp_circle" | "fp_arc" | "fp_poly" | "fp_text" => {
                    let mut on_silk = false;
                    let mut center = None;
                    let mut text = None;
                    for c in items.iter().skip(1) {
                        if let Sexpr::List(sub) = c {
                            match sub.first().and_then(atom) {
                                Some("layer") => {
                                    if sub.get(1).and_then(atom).map_or(false, |l| l.trim_matches('"').starts_with("F.SilkS")) { on_silk = true; }
                                }
                                Some("center") | Some("at") => {
                                    let x: f64 = sub.get(1).and_then(atom).and_then(|s| s.parse().ok()).unwrap_or(0.0);
                                    let y: f64 = sub.get(2).and_then(atom).and_then(|s| s.parse().ok()).unwrap_or(0.0);
                                    center = Some([x, y]);
                                }
                                _ => {}
                            }
                        } else if head == "fp_text" {
                            if let Some(s) = atom(c) {
                                let s = s.trim_matches('"').to_string();
                                if !s.is_empty() && !["reference","value","user"].contains(&s.as_str()) { text = Some(s); }
                            }
                        }
                    }
                    if on_silk {
                        silk_markers.push(serde_json::json!({
                            "kind": head,
                            "center": center,
                            "text": text,
                        }));
                    }
                }
                "pad" => {
                    let pad_num = items.get(1).and_then(atom).unwrap_or("?").trim_matches('"').to_string();
                    let pad_type = items.get(2).and_then(atom).unwrap_or("?").to_string();
                    let pad_shape = items.get(3).and_then(atom).unwrap_or("?").to_string();
                    let mut at = None;
                    let mut size = None;
                    let mut layers = Vec::new();
                    let mut drill: Option<serde_json::Value> = None;
                    for c in items.iter().skip(4) {
                        if let Sexpr::List(sub) = c {
                            match sub.first().and_then(atom) {
                                Some("at") => {
                                    let x: f64 = sub.get(1).and_then(atom).and_then(|s| s.parse().ok()).unwrap_or(0.0);
                                    let y: f64 = sub.get(2).and_then(atom).and_then(|s| s.parse().ok()).unwrap_or(0.0);
                                    let r: f64 = sub.get(3).and_then(atom).and_then(|s| s.parse().ok()).unwrap_or(0.0);
                                    at = Some(serde_json::json!([x, y, r]));
                                }
                                Some("size") => {
                                    let w: f64 = sub.get(1).and_then(atom).and_then(|s| s.parse().ok()).unwrap_or(0.0);
                                    let h: f64 = sub.get(2).and_then(atom).and_then(|s| s.parse().ok()).unwrap_or(0.0);
                                    size = Some(serde_json::json!([w, h]));
                                }
                                Some("layers") => {
                                    for l in sub.iter().skip(1) {
                                        if let Some(la) = atom(l) { layers.push(la.trim_matches('"').to_string()); }
                                    }
                                }
                                Some("drill") => {
                                    // (drill <Ø>) round, (drill oval <w> <h>) for slot
                                    let second = sub.get(1).and_then(atom).unwrap_or("");
                                    if second == "oval" {
                                        let w: f64 = sub.get(2).and_then(atom).and_then(|s| s.parse().ok()).unwrap_or(0.0);
                                        let h: f64 = sub.get(3).and_then(atom).and_then(|s| s.parse().ok()).unwrap_or(0.0);
                                        drill = Some(serde_json::json!({"shape": "oval", "size": [w, h]}));
                                    } else if let Ok(d) = second.parse::<f64>() {
                                        drill = Some(serde_json::json!({"shape": "circle", "diameter": d}));
                                    }
                                }
                                _ => {}
                            }
                        }
                    }
                    pads.push(serde_json::json!({
                        "number": pad_num,
                        "type": pad_type,
                        "shape": pad_shape,
                        "at": at.unwrap_or(serde_json::json!(null)),
                        "size": size.unwrap_or(serde_json::json!(null)),
                        "layers": layers,
                        "drill": drill.unwrap_or(serde_json::json!(null)),
                    }));
                }
                _ => for c in items.iter().skip(1) { walk_pads_with_silk(c, pads, module_name, silk_markers); },
            }
        }
    }
}