master
Colby Knox Merge PR #353: kicad-lane: 1.0.89, animate_view, lint_library on the standard's rules, engine 0.21.1 be88db0 11d ago
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477
//! Lint group, ported from `handlers/kicad_cli_lint.py` and `handlers/kicad_parse_check.py`.
//!
//! Headless pre-checks that run before any GUI automation: a magic-byte sniff of the
//! file, DRC or ERC through `kicad-cli` with a JSON report, a structural check and
//! inventory of symbol and footprint libraries, KiCad's OWN verdict on a `.kicad_sym`
//! (`kicad-cli sym export svg`, the one read-only command that parses a symbol
//! library), and the `kicad-cli <kind> upgrade` runner.
//!
//! Everything here is OS-neutral. The only way out to the OS is `cli::run`, which
//! goes through `kicad_platform::native()` for the quiet child process. The Python
//! used regexes for the inventory; there is no regex crate in this workspace, so the
//! same patterns are small scanners below, each named after the pattern it replaces.

use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};

use serde_json::{json, Value};

use crate::cli;

/// Expected root atom of a board file.
pub const PCB: &str = "kicad_pcb";
/// Expected root atom of a schematic file.
pub const SCH: &str = "kicad_sch";

/// Python `_run_kicad_cli_json` and `handle_format_upgrade` deadline.
pub const CLI_TIMEOUT: Duration = Duration::from_secs(120);
/// Python `kicad_parse_check.verdict_file` deadline, per file.
pub const VERDICT_TIMEOUT: Duration = Duration::from_secs(60);

// ───────────────────────── text helpers (the Python regexes) ─────────────────────────

/// `s` cut to at most `max` bytes, on a char boundary.
fn head_of(s: &str, max: usize) -> &str {
    if s.len() <= max {
        return s;
    }
    let mut i = max;
    while !s.is_char_boundary(i) {
        i -= 1;
    }
    &s[..i]
}

/// Python `f"{x!r}"` for a short str: single-quoted, backslashes and quotes escaped,
/// newlines as `\n`. Only used inside error messages.
fn py_repr(s: &str) -> String {
    let mut out = String::from("'");
    for c in s.chars() {
        match c {
            '\\' => out.push_str("\\\\"),
            '\'' => out.push_str("\\'"),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            c => out.push(c),
        }
    }
    out.push('\'');
    out
}

/// After `(kw` there must be at least one whitespace char; returns the text after it.
fn after_keyword<'a>(s: &'a str, kw: &str) -> Option<(&'a str, &'a str)> {
    let needle = format!("({kw}");
    let i = s.find(&needle)?;
    let after = &s[i + needle.len()..];
    let t = after.trim_start();
    if t.len() == after.len() {
        return None; // `(version)` or `(versionx`: no whitespace, not a match; caller advances
    }
    Some((t, after))
}

/// `\(version\s+(\d+)\)`: the first file-format version in `s`.
pub fn file_version(s: &str) -> Option<u64> {
    let mut rest = s;
    while let Some(i) = rest.find("(version") {
        let after = &rest[i + "(version".len()..];
        let t = after.trim_start();
        if t.len() < after.len() {
            let n = t.bytes().take_while(u8::is_ascii_digit).count();
            if n > 0 && t[n..].starts_with(')') {
                return t[..n].parse().ok();
            }
        }
        rest = after;
    }
    None
}

/// `\(<kw>\s+(?:"([^"]+)"|(\S+))\)`: every value of a quoted-or-bare keyword, in order.
/// Callers that only want the file head pass `head_of(text, 500)`.
fn keyword_values(s: &str, kw: &str) -> Vec<String> {
    let mut out = Vec::new();
    let mut rest = s;
    let needle = format!("({kw}");
    while let Some(i) = rest.find(&needle) {
        let after = &rest[i + needle.len()..];
        if let Some((t, _)) = after_keyword(&rest[i..], kw) {
            if let Some(q) = t.strip_prefix('"') {
                if let Some(end) = q.find('"') {
                    if end > 0 && q[end + 1..].starts_with(')') {
                        out.push(q[..end].to_string());
                    }
                }
            } else if let Some(end) = t.find(')') {
                let tok = &t[..end];
                if !tok.is_empty() && !tok.chars().any(char::is_whitespace) {
                    out.push(tok.to_string());
                }
            }
        }
        rest = after;
    }
    out
}

/// `\(generator\s+(?:"([^"]+)"|(\S+))\)` in the head of the file.
pub fn generator(s: &str) -> Option<String> {
    keyword_values(s, "generator").into_iter().next()
}

/// Count of `(<kw>` followed by whitespace (and, when `word` is set, a word char):
/// `\(pin\s+\w+` and `\(pad\s`.
fn count_keyword(s: &str, kw: &str, word: bool) -> usize {
    let needle = format!("({kw}");
    let mut n = 0;
    let mut rest = s;
    while let Some(i) = rest.find(&needle) {
        let after = &rest[i + needle.len()..];
        let mut it = after.chars();
        match it.next() {
            Some(c) if c.is_whitespace() => {
                if word {
                    let t = after.trim_start();
                    if t.chars().next().map(|c| c.is_alphanumeric() || c == '_').unwrap_or(false) {
                        n += 1;
                    }
                } else {
                    n += 1;
                }
            }
            _ => {}
        }
        rest = after;
    }
    n
}

/// `(symbol "NAME"` with `NAME` = `[^"]+`, starting at byte `at` of `s` (which must
/// point at the `(`). Returns the name.
fn symbol_name_at(s: &str, at: usize) -> Option<&str> {
    let t = s[at..].strip_prefix("(symbol")?;
    let t2 = t.trim_start();
    if t2.len() == t.len() {
        return None;
    }
    let q = t2.strip_prefix('"')?;
    let end = q.find('"')?;
    if end == 0 {
        return None;
    }
    Some(&q[..end])
}

/// `_\d+_\d+$`: nested unit variants like `R_0_1`.
fn ends_with_unit_suffix(name: &str) -> bool {
    let b = name.as_bytes();
    let mut i = b.len();
    let mut d2 = 0;
    while i > 0 && b[i - 1].is_ascii_digit() {
        i -= 1;
        d2 += 1;
    }
    if d2 == 0 || i == 0 || b[i - 1] != b'_' {
        return false;
    }
    i -= 1;
    let mut d1 = 0;
    while i > 0 && b[i - 1].is_ascii_digit() {
        i -= 1;
        d1 += 1;
    }
    d1 > 0 && i > 0 && b[i - 1] == b'_'
}

/// For `name` = `<parent>_<N>_<M>` returns `N` as text.
fn unit_of<'a>(name: &'a str, parent: &str) -> Option<&'a str> {
    let rest = name.strip_prefix(parent)?.strip_prefix('_')?;
    let n = rest.bytes().take_while(u8::is_ascii_digit).count();
    if n == 0 {
        return None;
    }
    let tail = rest[n..].strip_prefix('_')?;
    if tail.is_empty() || !tail.bytes().all(|c| c.is_ascii_digit()) {
        return None;
    }
    Some(&rest[..n])
}

// ───────────────────────── file format sniff ─────────────────────────

/// Python `_file_format_sniff` on the first bytes of the file. `expected` is `PCB` or
/// `SCH`. Returns the Python dict verbatim: `{ok, expected, magic, fileVersion, _note}`
/// or `{ok:false, expected?, actual?, _error, _hint}`.
pub fn sniff_head(head: &str, expected: &str) -> Value {
    let head_text = head.trim_start();
    let magic = format!("({expected}");
    if !head_text.starts_with(&magic) {
        let wrong_type = if expected == PCB && head_text.starts_with("(kicad_sch") {
            Some(SCH)
        } else if expected == SCH && head_text.starts_with("(kicad_pcb") {
            Some(PCB)
        } else {
            None
        };
        let hint = match wrong_type {
            Some(w) => format!(
                "You passed what looks like a {w} file to a verb expecting {expected}. Use the matching verb instead \
                 (kicad_open_board for .kicad_pcb, kicad_open_schematic / kicad_lint_schematic for .kicad_sch)."
            ),
            None => format!(
                "The file doesn't start with the expected {expected} S-expression header. \
                 It may be corrupted, truncated, or not a KiCad file at all."
            ),
        };
        return json!({
            "ok": false,
            "expected": expected,
            "actual": wrong_type.unwrap_or("non-kicad"),
            "_error": format!("file is not a {expected} (first bytes: {})", py_repr(head_of(head_text, 80))),
            "_hint": hint,
        });
    }
    json!({
        "ok": true,
        "expected": expected,
        "magic": "matched",
        "fileVersion": file_version(head_text),
        "_note": "magic + format sniff only; kicad-cli does the real validation",
    })
}

/// Python `_file_format_sniff(path, expected)`: existence, is-a-file, then `sniff_head`
/// on the first 512 bytes.
pub fn file_format_sniff(path: &Path, expected: &str) -> Value {
    if !path.exists() {
        return json!({
            "ok": false,
            "_error": format!("file not found: {}", path.display()),
            "_hint": "Verify the path. If the AI is on Docker, the path must exist on the Windows host (use push_file to copy from Docker first).",
        });
    }
    if !path.is_file() {
        return json!({"ok": false, "_error": format!("not a file: {}", path.display())});
    }
    let head = match read_head(path, 512) {
        Ok(h) => h,
        Err(e) => return json!({"ok": false, "_error": format!("read failed: {e}")}),
    };
    sniff_head(&head, expected)
}

fn read_head(path: &Path, n: usize) -> std::io::Result<String> {
    use std::io::Read;
    let mut f = std::fs::File::open(path)?;
    let mut buf = vec![0u8; n];
    let mut got = 0;
    while got < n {
        let k = f.read(&mut buf[got..])?;
        if k == 0 {
            break;
        }
        got += k;
    }
    buf.truncate(got);
    Ok(String::from_utf8_lossy(&buf).to_string())
}

// ───────────────────────── kicad-cli with a JSON report ─────────────────────────

/// Python `_run_kicad_cli_json`: `{ok, exitCode, stdout, stderr, data?, _error?, _hint?}`.
pub struct CliJson {
    pub ok: bool,
    pub exit_code: i32,
    pub stdout: String,
    pub stderr: String,
    pub elapsed_ms: u64,
    pub timed_out: bool,
    /// The parsed `--output` report, when kicad-cli wrote one.
    pub data: Option<Value>,
    pub error: Option<String>,
    pub hint: Option<String>,
}

fn scratch_path(prefix: &str, suffix: &str) -> PathBuf {
    let nanos = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.subsec_nanos())
        .unwrap_or(0);
    std::env::temp_dir().join(format!("{prefix}-{}-{nanos}{suffix}", std::process::id()))
}

/// Run kicad-cli with `--output <tempfile>` appended and read the JSON it wrote there.
/// `pcb drc` and `sch erc` put the structured report in that file; stdout and stderr
/// carry status text only.
///
/// Deviation from the Python: a non-zero exit that wrote no report is `ok: false`
/// (the Python returned `ok: true, data: None`, which the lint verbs then reported as a
/// clean file with zero violations).
pub fn run_cli_json(cli: &Path, args: &[&str], timeout: Duration) -> CliJson {
    let report = scratch_path("adom-kicad-lint", ".json");
    let report_s = report.to_string_lossy().to_string();
    let mut argv: Vec<&str> = args.to_vec();
    argv.push("--output");
    argv.push(&report_s);
    let r = cli::run(cli, &argv, timeout);
    let raw = std::fs::read(&report).ok().filter(|b| !b.is_empty());
    let _ = std::fs::remove_file(&report);
    let mut out = CliJson {
        ok: true,
        exit_code: r.exit_code,
        stdout: r.stdout,
        stderr: r.stderr,
        elapsed_ms: r.elapsed_ms,
        timed_out: r.timed_out,
        data: None,
        error: None,
        hint: None,
    };
    if r.timed_out {
        out.ok = false;
        out.error = Some(format!("kicad-cli timed out after {}s", timeout.as_secs()));
        out.hint = Some("Bump the timeout, or the board/schematic may be pathologically large.".into());
        return out;
    }
    if out.exit_code == -1 {
        out.ok = false;
        out.error = Some(format!("kicad-cli invocation failed: {}", out.stderr.trim()));
        return out;
    }
    match raw {
        Some(bytes) => match serde_json::from_slice::<Value>(&bytes) {
            Ok(v) => out.data = Some(v),
            Err(e) => {
                out.ok = false;
                out.error = Some(format!("could not parse kicad-cli JSON output: {e}"));
            }
        },
        None if out.exit_code != 0 => {
            out.ok = false;
            let first = out.stderr.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or("");
            out.error = Some(format!("kicad-cli exited {} and wrote no report: {first}", out.exit_code));
        }
        None => {}
    }
    out
}

// ───────────────────────── violation classification ─────────────────────────

/// Python `_parse_violations`: one row per violation, `{type, severity, description,
/// items:[{description, pos}]}`, plus counts by severity (always carrying the four
/// keys error, warning, info, exclusion; other severities are added as seen).
///
/// Buckets walked: `violations`, `unconnected_items` (DRC), and, beyond the Python,
/// `schematic_parity` (DRC with `--schematic-parity`) and `sheets[].violations` (the
/// ERC report of KiCad 8 and later nests everything under sheets; the Python saw none
/// of it and reported every schematic clean).
pub fn parse_violations(data: Option<&Value>) -> (Vec<Value>, BTreeMap<String, u64>) {
    let mut counts: BTreeMap<String, u64> = BTreeMap::new();
    for k in ["error", "warning", "info", "exclusion"] {
        counts.insert(k.into(), 0);
    }
    let mut out = Vec::new();
    let Some(data) = data.filter(|d| d.is_object()) else {
        return (out, counts);
    };
    let mut walk = |bucket: Option<&Value>, label: Option<&str>| {
        let Some(items) = bucket.and_then(Value::as_array) else { return };
        for v in items {
            let sev = v.get("severity").and_then(Value::as_str).unwrap_or("warning").to_string();
            let items: Vec<Value> = v
                .get("items")
                .and_then(Value::as_array)
                .map(|a| {
                    a.iter()
                        .map(|i| {
                            json!({
                                "description": i.get("description").cloned().unwrap_or(json!("")),
                                "pos": i.get("pos").cloned().unwrap_or(json!({})),
                            })
                        })
                        .collect()
                })
                .unwrap_or_default();
            out.push(json!({
                "type": v.get("type").cloned().unwrap_or_else(|| json!(label.unwrap_or("unknown"))),
                "severity": sev,
                "description": v.get("description").cloned().unwrap_or(json!("")),
                "items": items,
            }));
            *counts.entry(sev).or_insert(0) += 1;
        }
    };
    walk(data.get("violations"), None);
    walk(data.get("unconnected_items"), Some("unconnected_items"));
    walk(data.get("schematic_parity"), Some("schematic_parity"));
    if let Some(sheets) = data.get("sheets").and_then(Value::as_array) {
        for s in sheets {
            walk(s.get("violations"), None);
        }
    }
    (out, counts)
}

/// Sorted, de-duplicated `type` of every error-severity violation, first five.
pub fn top_error_types(violations: &[Value]) -> Vec<String> {
    let set: BTreeSet<String> = violations
        .iter()
        .filter(|v| v.get("severity").and_then(Value::as_str) == Some("error"))
        .filter_map(|v| v.get("type").and_then(Value::as_str).map(str::to_string))
        .collect();
    set.into_iter().take(5).collect()
}

fn types_str(violations: &[Value]) -> String {
    let t = top_error_types(violations);
    if t.is_empty() {
        "various".into()
    } else {
        t.join(", ")
    }
}

/// The tiered `_hint` of `kicad_lint_board`.
pub fn board_hint(violations: &[Value], errors: u64, warnings: u64) -> String {
    if errors == 0 && warnings == 0 {
        "✅ DRC clean. Safe to open kicad_open_board, generate gerbers via export, or hand off to fabrication. No human intervention needed.".into()
    } else if errors == 0 {
        format!(
            "⚠️  {warnings} DRC warning(s), 0 errors. The board is fabricable as-is but the warnings may \
             indicate sloppy design (silkscreen overlapping pads, courtyard violations, etc.). \
             Review violations[].description and decide if any need fixing. Safe to proceed with kicad_open_board."
        )
    } else {
        format!(
            "❌ {errors} DRC error(s) + {warnings} warning(s). Top error types: {}. \
             DO NOT proceed to fabrication. Open kicad_open_board and resolve these in the PCB Editor, \
             OR fix the underlying schematic if these are schematic-parity errors (e.g. nets connected on the schematic \
             but unrouted on the board). Re-run kicad_lint_board after fixing to confirm clean.",
            types_str(violations)
        )
    }
}

/// The tiered `_hint` of `kicad_lint_schematic`.
pub fn schematic_hint(violations: &[Value], errors: u64, warnings: u64) -> String {
    if errors == 0 && warnings == 0 {
        "✅ ERC clean. Safe to open kicad_open_schematic or move on to PCB layout.".into()
    } else if errors == 0 {
        format!(
            "⚠️  {warnings} ERC warning(s), 0 errors. Common warnings: pin-type mismatch on connections, \
             missing power flags. Inspect violations[].description and decide. Schematic is functionally OK."
        )
    } else {
        format!(
            "❌ {errors} ERC error(s) + {warnings} warning(s). Top error types: {}. \
             Common causes: unconnected pins (add NoConnect flag), duplicate references (run Annotate Schematic), \
             hierarchical-label mismatches, no-driver on a net (e.g. an input pin with no source). \
             Fix in eeschema via kicad_open_schematic, then re-run kicad_lint_schematic to confirm.",
            types_str(violations)
        )
    }
}

/// `handle_lint_board` / `handle_lint_schematic` after the arg checks: run kicad-cli,
/// classify, build the Python response. `expected` is `PCB` or `SCH`; `file_version`
/// comes from the sniff; `schematic_parity` only matters for boards.
pub fn lint_document(cli: &Path, path: &Path, expected: &str, file_version: Option<u64>, schematic_parity: bool, timeout: Duration) -> Value {
    let is_pcb = expected == PCB;
    let path_s = path.to_string_lossy().to_string();
    let mut argv: Vec<&str> = if is_pcb {
        vec!["pcb", "drc", "--format", "json", "--severity-all", "--all-track-errors"]
    } else {
        vec!["sch", "erc", "--format", "json", "--severity-all"]
    };
    if is_pcb && schematic_parity {
        // Only meaningful if the matching .kicad_sch exists in the project dir.
        argv.push("--schematic-parity");
    }
    argv.push(&path_s);
    let r = run_cli_json(cli, &argv, timeout);
    let file_format = json!({"expected": expected, "fileVersion": file_version});
    if !r.ok {
        let hint = if is_pcb {
            "If exitCode is nonzero and stderr mentions 'older format', the file needs upgrading: \
             `kicad-cli pcb upgrade` (or just opening it in KiCad and saving) does this. \
             If schematic-parity failed because no matching .kicad_sch was found in the project dir, \
             retry with schematicParity:false."
        } else {
            "If exitCode is nonzero and stderr mentions an older format, run `kicad-cli sch upgrade` (or open + save in eeschema)."
        };
        return json!({
            "success": false,
            "errorCode": if r.timed_out { "cli_timeout" } else { "cli_failed" },
            "error": r.error.clone().unwrap_or_else(|| "kicad-cli failed".into()),
            "exitCode": r.exit_code,
            "stdout": r.stdout,
            "stderr": r.stderr,
            "fileFormat": file_format,
            "cliElapsedMs": r.elapsed_ms,
            "_hint": r.hint.unwrap_or_else(|| hint.into()),
        });
    }
    let (violations, counts) = parse_violations(r.data.as_ref());
    let total = violations.len();
    let errors = counts.get("error").copied().unwrap_or(0);
    let warnings = counts.get("warning").copied().unwrap_or(0);
    let info = counts.get("info").copied().unwrap_or(0);
    let hint = if is_pcb { board_hint(&violations, errors, warnings) } else { schematic_hint(&violations, errors, warnings) };
    let mut data = json!({
        "violations": violations,
        "summary": {"total": total, "errors": errors, "warnings": warnings, "info": info},
        "fileFormat": file_format,
        "source_file": path_s,
    });
    if is_pcb {
        data["schematicParityChecked"] = json!(schematic_parity);
    }
    json!({
        "success": true,
        "output": format!("Lint complete: {errors} error(s), {warnings} warning(s), {total} total"),
        "data": data,
        "exitCode": r.exit_code,
        "cliElapsedMs": r.elapsed_ms,
        "_hint": hint,
    })
}

// ───────────────────────── library structural check and inventory ─────────────────────────

/// Python `_LIB_MAGIC`.
pub fn sym_magic_ok(head: &str) -> bool {
    head.trim_start().starts_with("(kicad_symbol_lib")
}
pub fn mod_magic_ok(head: &str) -> bool {
    let h = head.trim_start();
    h.starts_with("(footprint") || h.starts_with("(module") // KiCad <= 6 used "module"
}

/// Python `adom_library._quote_aware_children`: the FIRST top-level s-expression list
/// in `text`. Returns `(root_open, root_close, children)` where children are the byte
/// spans of the root's direct child lists. Errs on any imbalance (an extra `)` names
/// its offset, a never-closed list says so) and on an unterminated string.
pub fn quote_aware_children(text: &str) -> Result<(usize, usize, Vec<(usize, usize)>), String> {
    let (mut in_str, mut esc) = (false, false);
    let mut depth: i64 = 0;
    let (mut root_open, mut root_close) = (None, None);
    let mut child_start: Option<usize> = None;
    let mut children = Vec::new();
    for (i, ch) in text.bytes().enumerate() {
        if in_str {
            if esc {
                esc = false;
            } else if ch == b'\\' {
                esc = true;
            } else if ch == b'"' {
                in_str = false;
            }
            continue;
        }
        match ch {
            b'"' => in_str = true,
            b'(' => {
                depth += 1;
                if depth == 1 {
                    root_open = Some(i);
                } else if depth == 2 && child_start.is_none() {
                    child_start = Some(i);
                }
            }
            b')' => {
                depth -= 1;
                if depth == 1 {
                    if let Some(cs) = child_start.take() {
                        children.push((cs, i + 1));
                    }
                } else if depth == 0 {
                    root_close = Some(i);
                    break;
                } else if depth < 0 {
                    return Err(format!("unbalanced s-expression: extra ')' at offset {i}"));
                }
            }
            _ => {}
        }
    }
    if in_str {
        return Err("unterminated quoted string".into());
    }
    let Some(ro) = root_open else { return Err("no s-expression found".into()) };
    let Some(rc) = root_close else { return Err("unbalanced s-expression: the top-level list never closes".into()) };
    Ok((ro, rc, children))
}

/// Python `adom_library._parse_library_nodes` minus the node list: balanced, root is
/// `kicad_symbol_lib`, nothing after the root's closer.
pub fn check_symbol_lib_structure(text: &str) -> Result<(), String> {
    let (ro, rc, _) = quote_aware_children(text)?;
    let head = if text[ro + 1..].trim().is_empty() {
        ""
    } else {
        head_of(&text[ro + 1..], 39).split_whitespace().next().unwrap_or("")
    };
    if head != "kicad_symbol_lib" {
        return Err(format!("not a symbol library (top-level list is '{head}')"));
    }
    let tail = text[rc + 1..].trim();
    if !tail.is_empty() {
        return Err(format!("trailing content after the library's closing paren: {}", py_repr(head_of(tail, 40))));
    }
    Ok(())
}

/// The footprint structural check `handle_lint_library` runs: balanced, and nothing
/// after the footprint's closer.
pub fn check_footprint_structure(text: &str) -> Result<(), String> {
    let (_, rc, _) = quote_aware_children(text)?;
    let tail = text[rc + 1..].trim();
    if !tail.is_empty() {
        return Err(format!("trailing content after the footprint's closing paren: {}", py_repr(head_of(tail, 40))));
    }
    Ok(())
}

/// Python `_parse_kicad_sym` on the file text: `{symbolCount, symbols:[{name, pinCount,
/// unitCount}], fileVersion, generator, _warnings}`. Regex-style inventory, same
/// heuristics as the Python (a 5000-char window per symbol for the pin count).
pub fn parse_kicad_sym(text: &str) -> Value {
    let head = head_of(text, 500);
    // Every `(symbol "NAME"` anywhere, for the unit lookup.
    let mut all_names: Vec<&str> = Vec::new();
    let mut rest = 0;
    while let Some(i) = text[rest..].find("(symbol") {
        let at = rest + i;
        if let Some(n) = symbol_name_at(text, at) {
            all_names.push(n);
        }
        rest = at + "(symbol".len();
    }
    let mut symbols = Vec::new();
    let mut offset = 0;
    for line in text.split_inclusive('\n') {
        let lead = line.len() - line.trim_start().len();
        let at = offset + lead;
        offset += line.len();
        if !line[lead..].starts_with("(symbol") {
            continue;
        }
        let Some(name) = symbol_name_at(text, at) else { continue };
        if ends_with_unit_suffix(name) {
            continue; // nested unit variant like "R_0_0"
        }
        let window = head_of(&text[at..], 5000);
        let pin_count = count_keyword(window, "pin", true);
        let units: BTreeSet<&str> = all_names.iter().filter_map(|n| unit_of(n, name)).collect();
        symbols.push(json!({"name": name, "pinCount": pin_count, "unitCount": units.len().max(1)}));
    }
    json!({
        "symbolCount": symbols.len(),
        "symbols": symbols,
        "fileVersion": file_version(head),
        "generator": generator(head),
        "_warnings": [],
    })
}

/// Python `_parse_kicad_mod`: `{name, padCount, layers, fileVersion, generator, _warnings}`.
pub fn parse_kicad_mod(text: &str) -> Value {
    let head = head_of(text, 500);
    let name: Option<String> = match after_keyword(head, "footprint") {
        Some((t, _)) if t.starts_with('"') => t[1..].find('"').filter(|&e| e > 0).map(|e| t[1..1 + e].to_string()),
        _ => after_keyword(head, "module").and_then(|(t, _)| {
            let tok: String = t.chars().take_while(|c| !c.is_whitespace()).collect();
            if tok.is_empty() { None } else { Some(tok) }
        }),
    };
    let layers: BTreeSet<String> = keyword_values(text, "layer").into_iter().collect();
    let layers: Vec<String> = layers.into_iter().take(10).collect();
    json!({
        "name": name,
        "padCount": count_keyword(text, "pad", false),
        "layers": layers,
        "fileVersion": file_version(head),
        "generator": generator(head),
        "_warnings": [],
    })
}

// ───────────────────────── KiCad's own verdict (kicad_parse_check.py) ─────────────────────────

/// `Plotting symbol 'NAME' unit N` lines from `kicad-cli sym export svg`: unique symbol
/// names in order, and the number of units plotted.
pub fn parse_plot_lines(text: &str) -> (Vec<String>, usize) {
    let mut names: Vec<String> = Vec::new();
    let mut units = 0;
    for line in text.lines() {
        if let Some(name) = plot_line_name(line) {
            units += 1;
            if !names.iter().any(|n| n == name) {
                names.push(name.to_string());
            }
        }
    }
    (names, units)
}

fn plot_line_name(line: &str) -> Option<&str> {
    const P: &str = "Plotting symbol '";
    let i = line.find(P)?;
    let rest = &line[i + P.len()..];
    let end = rest.find('\'')?;
    if end == 0 {
        return None;
    }
    let after = rest[end + 1..].strip_prefix(" unit ")?;
    if after.bytes().next().map(|c| c.is_ascii_digit()).unwrap_or(false) {
        Some(&rest[..end])
    } else {
        None
    }
}

/// `kicad_parse_check.cli_exe`: the recorded kicad-cli if it exists, else the two
/// spellings next to it in the bin dir.
pub fn cli_exe(kicad_cli: &str, bin_dir: &str) -> Option<PathBuf> {
    if !kicad_cli.is_empty() && Path::new(kicad_cli).exists() {
        return Some(PathBuf::from(kicad_cli));
    }
    if !bin_dir.is_empty() {
        for name in ["kicad-cli.exe", "kicad-cli"] {
            let c = Path::new(bin_dir).join(name);
            if c.exists() {
                return Some(c);
            }
        }
    }
    None
}

/// `kicad_parse_check.verdict_file`: KiCad's verdict on an existing `.kicad_sym`.
/// `{available, ok, symbols[], units, error, exitCode, durationMs}`. `available: false`
/// means kicad-cli was not found: no verdict either way, and callers must SAY that
/// rather than treat it as a pass.
pub fn verdict_file(cli: Option<&Path>, lib_path: &Path, timeout: Duration) -> Value {
    let Some(cli) = cli else {
        return json!({"available": false, "ok": null, "symbols": [], "units": 0,
                      "error": "kicad-cli not found; KiCad's parser could not be asked"});
    };
    if !lib_path.exists() {
        return json!({"available": true, "ok": false, "symbols": [], "units": 0,
                      "error": format!("file not found: {}", lib_path.display())});
    }
    let out_dir = scratch_path("adom-kicad-parse", "");
    if let Err(e) = std::fs::create_dir_all(&out_dir) {
        return json!({"available": true, "ok": false, "symbols": [], "units": 0,
                      "error": head_of(&format!("scratch dir failed: {e}"), 300)});
    }
    let t0 = Instant::now();
    let out_s = out_dir.to_string_lossy().to_string();
    let lib_s = lib_path.to_string_lossy().to_string();
    let r = cli::run(cli, &["sym", "export", "svg", "-o", &out_s, &lib_s], timeout);
    let _ = std::fs::remove_dir_all(&out_dir);
    if r.timed_out {
        // no answer is not a refusal: KiCad's verdict on this file is unknown
        return json!({"available": true, "ok": null, "timedOut": true, "symbols": [], "units": 0,
                      "error": format!("kicad-cli did not answer within {}s", timeout.as_secs())});
    }
    if r.exit_code == -1 {
        return json!({"available": true, "ok": false, "symbols": [], "units": 0,
                      "error": head_of(&r.stderr, 300)});
    }
    let text = format!("{}\n{}", r.stdout, r.stderr);
    let (names, units) = parse_plot_lines(&text);
    let ok = r.exit_code == 0;
    let err = if ok {
        String::new()
    } else {
        let lines: Vec<&str> = text.lines().map(str::trim).filter(|l| !l.is_empty() && plot_line_name(l).is_none()).take(3).collect();
        let joined = head_of(&lines.join(" | "), 400).to_string();
        if joined.is_empty() { format!("kicad-cli exit {}", r.exit_code) } else { joined }
    };
    json!({
        "available": true, "ok": ok, "symbols": names, "units": units, "error": err,
        "exitCode": r.exit_code, "durationMs": t0.elapsed().as_millis() as u64,
    })
}

// ───────────────────────── kicad_lint_library ─────────────────────────

fn ext_lower(p: &Path) -> String {
    p.extension().map(|e| format!(".{}", e.to_string_lossy().to_lowercase())).unwrap_or_default()
}

/// Files in `dir` with the given (lower-case, dotted) extension, sorted by path.
fn glob_ext(dir: &Path, ext: &str) -> Vec<PathBuf> {
    let mut v: Vec<PathBuf> = std::fs::read_dir(dir)
        .map(|rd| rd.flatten().map(|e| e.path()).filter(|p| p.is_file() && ext_lower(p) == ext).collect())
        .unwrap_or_default();
    v.sort();
    v
}

/// What `handle_lint_library` found on disk.
#[derive(Debug)]
pub struct LibraryInput {
    pub kind: &'static str,
    pub sym_files: Vec<PathBuf>,
    pub mod_files: Vec<PathBuf>,
}

/// Discover what kind of input a library path is. Errors are the Python's
/// `(error, _hint)` pairs.
pub fn discover_library(path: &Path) -> Result<LibraryInput, (String, String)> {
    if !path.exists() {
        return Err((
            format!("path not found: {}", path.display()),
            "The path may exist on Docker but not on the Windows host. Use push_file to copy from Docker first.".into(),
        ));
    }
    let mut li = LibraryInput { kind: "", sym_files: Vec::new(), mod_files: Vec::new() };
    if path.is_file() {
        match ext_lower(path).as_str() {
            ".kicad_sym" => {
                li.sym_files.push(path.to_path_buf());
                li.kind = "symbol_lib_file";
            }
            ".kicad_mod" => {
                li.mod_files.push(path.to_path_buf());
                li.kind = "footprint_file";
            }
            ext => {
                return Err((
                    format!("unsupported file extension: {ext}"),
                    "Pass a .kicad_sym, .kicad_mod, or a directory containing them (.pretty for footprint libraries).".into(),
                ))
            }
        }
    } else if path.is_dir() {
        let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
        if ext_lower(path) == ".pretty" || name.ends_with(".pretty") {
            li.kind = "footprint_lib_dir";
            li.mod_files = glob_ext(path, ".kicad_mod");
        } else {
            li.kind = "mixed_dir";
            li.sym_files = glob_ext(path, ".kicad_sym");
            li.mod_files = glob_ext(path, ".kicad_mod");
        }
        if li.sym_files.is_empty() && li.mod_files.is_empty() {
            return Err((
                format!("directory contains no .kicad_sym or .kicad_mod files: {}", path.display()),
                "Symbol libraries are single .kicad_sym files. Footprint libraries are .pretty/ directories containing .kicad_mod files.".into(),
            ));
        }
    } else {
        return Err((format!("not a file or directory: {}", path.display()), "Pass a .kicad_sym, .kicad_mod or .pretty directory.".into()));
    }
    Ok(li)
}

/// Python list repr of sorted versions, e.g. `[20211014, 20231120]`.
fn versions_repr(v: &[u64]) -> String {
    format!("[{}]", v.iter().map(u64::to_string).collect::<Vec<_>>().join(", "))
}

fn file_name(p: &str) -> String {
    Path::new(p).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| p.to_string())
}

/// The tiered `_hint` of `kicad_lint_library`. Pure, so it is testable without files.
pub fn library_hint(
    kicad_refused: &[Value],
    sym_file_errors: usize,
    mod_file_errors: usize,
    symbol_count: u64,
    footprint_count: u64,
    versions: &[u64],
    kicad_verdicts: &[Value],
) -> String {
    if !kicad_refused.is_empty() {
        let names: Vec<String> = kicad_refused
            .iter()
            .take(3)
            .map(|v| format!("{}: {}", file_name(v.get("path").and_then(Value::as_str).unwrap_or("")), v.get("error").and_then(Value::as_str).unwrap_or("")))
            .collect();
        return format!(
            "❌ KICAD REFUSES {} symbol file(s) that the bridge's structural check passed: {}. \
             KiCad lists such a library EMPTY, and one malformed symbol does that to the whole file. \
             DO NOT install. Fix the file until `kicad-cli sym export svg` accepts it \
             (common: bare `hide yes` inside (effects ...) instead of `(hide yes)`).",
            kicad_refused.len(),
            names.join("; ")
        );
    }
    if sym_file_errors > 0 || mod_file_errors > 0 {
        return format!(
            "❌ {sym_file_errors} symbol file(s) + {mod_file_errors} footprint file(s) \
             failed to parse. Inspect _errors[] for specifics. Common causes: file truncated mid-write, \
             wrong file type renamed with .kicad_sym extension, file from a much newer KiCad than installed. \
             DO NOT install via kicad_install_library / kicad_install_symbol / kicad_install_footprint: \
             the install will land broken files."
        );
    }
    // Outdated format. KiCad 10 uses YYYYMMDD versions like 20240108. Anything below
    // 20200101 is KiCad 5-era; 2023xxxx is KiCad 7-era and warrants a kicad_format_upgrade first.
    let outdated: Vec<u64> = versions.iter().copied().filter(|&v| v < 20231101).collect();
    if !outdated.is_empty() {
        return format!(
            "⚠️  {symbol_count} symbol(s) + {footprint_count} footprint(s) parsed cleanly, \
             BUT file-format version(s) {} are pre-KiCad-7. KiCad 10 can read these but \
             will silently upgrade them on first save. To migrate proactively, call \
             kicad_format_upgrade (or just run `kicad-cli sym/fp upgrade` manually). \
             OK to proceed with install but expect a one-time format change.",
            versions_repr(&outdated)
        );
    }
    let versions_s = if versions.is_empty() { "unspecified".to_string() } else { versions_repr(versions) };
    let available = |v: &Value| v.get("available").and_then(Value::as_bool).unwrap_or(false);
    let kp_ok: Vec<&Value> = kicad_verdicts.iter().filter(|v| available(v) && v.get("ok").and_then(Value::as_bool).unwrap_or(false)).collect();
    let kp_syms: usize = kp_ok.iter().map(|v| v.get("symbols").and_then(Value::as_array).map(|a| a.len()).unwrap_or(0)).sum();
    if !kicad_verdicts.is_empty() && kicad_verdicts.iter().all(available) {
        format!(
            "✅ KiCad's own parser loads {kp_syms} symbol(s) from {} file(s) (kicadParser); \
             the bridge's structural check counts {symbol_count} symbol(s) + {footprint_count} footprint(s), \
             format version(s) {versions_s}. Safe to install via \
             kicad_install_library / kicad_install_symbol / kicad_install_footprint.",
            kp_ok.len()
        )
    } else if kicad_verdicts.is_empty() && footprint_count > 0 && symbol_count == 0 {
        // wiki #103: footprints alone. kicad-cli lints SYMBOL libraries; it has no footprint
        // verdict, so saying "kicad-cli not found" blames a missing tool for a gap that is by
        // design, and sends the caller hunting for an executable that is right there.
        format!(
            "✅ {footprint_count} footprint(s) balanced by the BRIDGE's reader, \
             format version(s) {versions_s}. KiCad's own parser has no footprint verdict to give \
             (kicad-cli checks symbol libraries), so footprints are a structural check by design, \
             not a missing tool. Safe to install via kicad_install_footprint."
        )
    } else {
        format!(
            "✅ {symbol_count} symbol(s) + {footprint_count} footprint(s) balanced by the BRIDGE's reader, \
             format version(s) {versions_s}. KiCad's own parser was \
             NOT consulted (kicad-cli not found), so this is a structural check only, not KiCad's verdict."
        )
    }
}

/// `handle_lint_library` after the arg check. `cli` is None when kicad-cli is not on
/// this box; the structural check still runs and the hint says KiCad was not asked.
pub fn lint_library(path: &Path, cli: Option<&Path>, verdict_timeout: Duration) -> Value {
    let li = match discover_library(path) {
        Ok(li) => li,
        Err((error, hint)) => return json!({"success": false, "errorCode": "bad_input", "error": error, "_hint": hint}),
    };

    let mut parsed_sym: Vec<Value> = Vec::new();
    for f in &li.sym_files {
        let p = f.to_string_lossy().to_string();
        let text = match std::fs::read(f) {
            Ok(b) => String::from_utf8_lossy(&b).to_string(),
            Err(e) => {
                parsed_sym.push(json!({"path": p, "_error": format!("read failed: {e}")}));
                continue;
            }
        };
        if !sym_magic_ok(head_of(&text, 200)) {
            parsed_sym.push(json!({"path": p, "_error": "not a kicad_symbol_lib (bad magic)"}));
            continue;
        }
        // Structural check (#54): unbalanced parens, trailing garbage after the root
        // closer, or a non-library root are hard errors; a regex inventory cannot see them.
        if let Err(e) = check_symbol_lib_structure(&text) {
            parsed_sym.push(json!({"path": p, "_error": format!("structural: {e}")}));
            continue;
        }
        let mut info = parse_kicad_sym(&text);
        info["path"] = json!(p);
        info["structuralCheck"] = json!("balanced");
        parsed_sym.push(info);
    }

    let mut parsed_mod: Vec<Value> = Vec::new();
    for f in &li.mod_files {
        let p = f.to_string_lossy().to_string();
        let text = match std::fs::read(f) {
            Ok(b) => String::from_utf8_lossy(&b).to_string(),
            Err(e) => {
                parsed_mod.push(json!({"path": p, "_error": format!("read failed: {e}")}));
                continue;
            }
        };
        if !mod_magic_ok(head_of(&text, 200)) {
            parsed_mod.push(json!({"path": p, "_error": "not a kicad_mod (bad magic)"}));
            continue;
        }
        if let Err(e) = check_footprint_structure(&text) {
            parsed_mod.push(json!({"path": p, "_error": format!("structural: {e}")}));
            continue;
        }
        let mut info = parse_kicad_mod(&text);
        info["path"] = json!(p);
        parsed_mod.push(info);
    }

    let has_err = |v: &Value| v.get("_error").is_some();
    let symbol_count: u64 = parsed_sym.iter().filter(|s| !has_err(s)).map(|s| s.get("symbolCount").and_then(Value::as_u64).unwrap_or(0)).sum();
    let footprint_count: u64 = parsed_mod.iter().filter(|m| !has_err(m)).count() as u64;
    let sym_file_errors = parsed_sym.iter().filter(|s| has_err(s)).count();
    let mod_file_errors = parsed_mod.iter().filter(|m| has_err(m)).count();
    let versions: Vec<u64> = parsed_sym
        .iter()
        .chain(parsed_mod.iter())
        .filter_map(|s| s.get("fileVersion").and_then(Value::as_u64))
        .filter(|&v| v != 0)
        .collect::<BTreeSet<u64>>()
        .into_iter()
        .collect();

    // wiki #71: the parse above is the BRIDGE's reader. Ask KiCad for every .kicad_sym
    // (read-only export) and let its verdict override the checkmark.
    let mut kicad_verdicts: Vec<Value> = Vec::new();
    let mut kicad_refused: Vec<Value> = Vec::new();
    for f in &li.sym_files {
        let mut v = verdict_file(cli, f, verdict_timeout);
        v["path"] = json!(f.to_string_lossy().to_string());
        let available = v.get("available").and_then(Value::as_bool).unwrap_or(false);
        let ok = v.get("ok").and_then(Value::as_bool).unwrap_or(false);
        if available && !ok && v.get("timedOut") != Some(&json!(true)) {
            kicad_refused.push(v.clone());
        }
        kicad_verdicts.push(v);
    }

    let hint = library_hint(&kicad_refused, sym_file_errors, mod_file_errors, symbol_count, footprint_count, &versions, &kicad_verdicts);
    let ok = kicad_refused.is_empty() && sym_file_errors == 0 && mod_file_errors == 0;
    let kicad_parser = match kicad_verdicts.len() {
        0 => Value::Null,
        1 => kicad_verdicts[0].clone(),
        _ => json!(kicad_verdicts),
    };
    let mut output = format!(
        "Lint: {symbol_count} symbol(s), {footprint_count} footprint(s), {} file error(s)",
        sym_file_errors + mod_file_errors
    );
    if !kicad_refused.is_empty() {
        output.push_str(&format!(", KiCad refuses {} file(s)", kicad_refused.len()));
    }
    let not_asked = kicad_verdicts.iter().filter(|v| v.get("timedOut") == Some(&json!(true))).count();
    if not_asked > 0 {
        output.push_str(&format!(", KiCad did not answer in time for {not_asked} file(s) (its verdict is unknown, not a refusal)"));
    }
    json!({
        "success": true,
        "ok": ok,
        "kicadParser": kicad_parser,
        "output": output,
        "data": {
            "kind": li.kind,
            "source_path": path.to_string_lossy().to_string(),
            "summary": {
                "symbolCount": symbol_count,
                "footprintCount": footprint_count,
                "symFileErrorCount": sym_file_errors,
                "modFileErrorCount": mod_file_errors,
                "fileFormatVersions": versions,
            },
            "symbols": parsed_sym,
            "footprints": parsed_mod,
        },
        "_hint": hint,
    })
}

// ───────────────────────── kicad_format_upgrade ─────────────────────────

/// The four `kicad-cli <kind> upgrade` kinds.
pub const UPGRADE_KINDS: [&str; 4] = ["pcb", "sch", "sym", "fp"];

/// A caller-supplied `kind`, lower-cased and checked. Empty means auto-detect.
pub fn normalize_kind(kind: &str) -> Option<&'static str> {
    let k = kind.trim().to_lowercase();
    UPGRADE_KINDS.iter().copied().find(|&u| u == k)
}

/// Python auto-detect: by extension for a file (case-sensitive, as the Python was), by
/// contents for a directory (`.pretty` or any `.kicad_mod` means fp, any `.kicad_sym`
/// means sym).
pub fn detect_kind(path: &Path) -> Option<&'static str> {
    if path.is_file() {
        let ext = path.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default();
        return match ext.as_str() {
            "kicad_pcb" => Some("pcb"),
            "kicad_sch" => Some("sch"),
            "kicad_sym" => Some("sym"),
            "kicad_mod" => Some("fp"),
            _ => None,
        };
    }
    if path.is_dir() {
        if ext_lower(path) == ".pretty" || !glob_ext(path, ".kicad_mod").is_empty() {
            return Some("fp");
        }
        if !glob_ext(path, ".kicad_sym").is_empty() {
            return Some("sym");
        }
    }
    None
}

/// The lint verb to re-run after an upgrade of `kind`. (The Python compared `kind`
/// against "board"/"schematic", which never occur, so it always said lint_library.)
pub fn lint_verb_for(kind: &str) -> &'static str {
    match kind {
        "pcb" => "kicad_lint_board",
        "sch" => "kicad_lint_schematic",
        _ => "kicad_lint_library",
    }
}

/// The `_hint` of `kicad_format_upgrade`.
pub fn upgrade_hint(kind: &str, success: bool, exit_code: i32) -> String {
    if success {
        format!(
            "✅ kicad-cli {kind} upgrade completed. File(s) rewritten to current KiCad format. \
             Any open KiCad GUI windows with this file loaded should be closed + reopened; they \
             have the OLD in-memory state. Re-run {} to confirm the post-upgrade file is clean.",
            lint_verb_for(kind)
        )
    } else {
        format!(
            "❌ kicad-cli {kind} upgrade failed with exitCode={exit_code}. \
             stderr likely names the offending file or syntax issue. If it's a 'file format too new' \
             error, the user's KiCad install is OLDER than the source file; surface to user. \
             If 'file already up to date', pass force:true to resave anyway."
        )
    }
}

/// `handle_format_upgrade` after the arg checks: `kicad-cli <kind> upgrade [--force] <path>`.
/// WARNING: this MUTATES the file in place (kicad-cli has no --dry-run).
pub fn format_upgrade(cli: &Path, path: &Path, kind: &str, force: bool, timeout: Duration) -> Value {
    let path_s = path.to_string_lossy().to_string();
    let mut argv: Vec<&str> = vec![kind, "upgrade"];
    if force {
        argv.push("--force");
    }
    argv.push(&path_s);
    let r = cli::run(cli, &argv, timeout);
    if r.timed_out {
        return json!({
            "success": false,
            "errorCode": "cli_timeout",
            "error": format!("kicad-cli upgrade timed out after {}s", timeout.as_secs()),
            "_hint": "Likely a huge directory of footprints. Re-run with a single file at a time.",
        });
    }
    if r.exit_code == -1 {
        return json!({"success": false, "errorCode": "cli_failed", "error": r.stderr.trim(), "_hint": "kicad-cli could not be started; check kicad_list_versions."});
    }
    let success = r.exit_code == 0;
    json!({
        "success": success,
        "exitCode": r.exit_code,
        "kind": kind,
        "stdout": r.stdout,
        "stderr": r.stderr,
        "source_path": path_s,
        "cliElapsedMs": r.elapsed_ms,
        "_hint": upgrade_hint(kind, success, r.exit_code),
    })
}

// ───────────────────────── tests ─────────────────────────

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

    #[test]
    fn version_and_generator() {
        let head = "(kicad_symbol_lib\n\t(version 20231120)\n\t(generator \"kicad_symbol_editor\")\n";
        assert_eq!(file_version(head), Some(20231120));
        assert_eq!(generator(head), Some("kicad_symbol_editor".into()));
        assert_eq!(generator("(generator eeschema)"), Some("eeschema".into()));
        assert_eq!(file_version("(version)"), None);
        assert_eq!(file_version("(version_x 1)"), None);
        assert_eq!(file_version("(generator_version \"8.0\") (version 20240108)"), Some(20240108));
    }

    #[test]
    fn sniff_pcb_ok() {
        let v = sniff_head("(kicad_pcb (version 20240108) (generator \"pcbnew\")", PCB);
        assert_eq!(v["ok"], json!(true));
        assert_eq!(v["magic"], json!("matched"));
        assert_eq!(v["fileVersion"], json!(20240108));
        assert_eq!(v["expected"], json!("kicad_pcb"));
    }

    #[test]
    fn sniff_wrong_type_and_garbage() {
        let v = sniff_head("  (kicad_sch (version 20231120)", PCB);
        assert_eq!(v["ok"], json!(false));
        assert_eq!(v["actual"], json!("kicad_sch"));
        assert!(v["_hint"].as_str().unwrap().contains("kicad_lint_schematic"));
        assert!(v["_error"].as_str().unwrap().starts_with("file is not a kicad_pcb (first bytes: '(kicad_sch"));
        let g = sniff_head("PK\u{3}\u{4}zip", SCH);
        assert_eq!(g["actual"], json!("non-kicad"));
        assert!(g["_hint"].as_str().unwrap().contains("corrupted"));
        let ok = sniff_head("(kicad_sch (version 20231120)", SCH);
        assert_eq!(ok["ok"], json!(true));
    }

    const DRC: &str = r#"{
      "$schema": "https://schemas.kicad.org/drc.v1.json",
      "kicad_version": "10.0.5",
      "source": "board.kicad_pcb",
      "violations": [
        {"type": "clearance", "severity": "error", "description": "Clearance violation",
         "items": [{"description": "Track [GND]", "pos": {"x": 1.0, "y": 2.0}}]},
        {"type": "silk_over_copper", "severity": "warning", "description": "Silk over pad", "items": []},
        {"type": "courtyards_overlap", "severity": "error", "description": "Courtyards overlap", "items": []}
      ],
      "unconnected_items": [
        {"type": "unconnected_items", "severity": "error", "description": "Missing connection", "items": [{"description": "Pad 1"}]}
      ],
      "schematic_parity": [
        {"type": "net_conflict", "severity": "warning", "description": "Net mismatch", "items": []}
      ]
    }"#;

    #[test]
    fn parse_drc_report() {
        let data: Value = serde_json::from_str(DRC).unwrap();
        let (v, c) = parse_violations(Some(&data));
        assert_eq!(v.len(), 5);
        assert_eq!(c["error"], 3);
        assert_eq!(c["warning"], 2);
        assert_eq!(c["info"], 0);
        assert_eq!(v[0]["items"][0]["pos"]["x"], json!(1.0));
        assert_eq!(v[3]["type"], json!("unconnected_items"));
        assert_eq!(v[3]["items"][0]["pos"], json!({}));
        assert_eq!(top_error_types(&v), vec!["clearance", "courtyards_overlap", "unconnected_items"]);
        let hint = board_hint(&v, 3, 2);
        assert!(hint.starts_with("❌ 3 DRC error(s) + 2 warning(s). Top error types: clearance, courtyards_overlap, unconnected_items."));
        assert!(board_hint(&[], 0, 0).contains("DRC clean"));
        assert!(board_hint(&[], 0, 4).starts_with("⚠️  4 DRC warning(s), 0 errors."));
    }

    const ERC: &str = r#"{
      "$schema": "https://schemas.kicad.org/erc.v1.json",
      "kicad_version": "10.0.5",
      "source": "board.kicad_sch",
      "sheets": [
        {"path": "/", "uuid_path": "/abc", "violations": [
          {"type": "pin_not_connected", "severity": "error", "description": "Pin not connected", "items": [{"description": "U1 pin 3", "pos": {"x": 5, "y": 6}}]},
          {"type": "power_pin_not_driven", "severity": "warning", "description": "Not driven", "items": []}
        ]},
        {"path": "/sub", "uuid_path": "/abc/def", "violations": [
          {"type": "lib_symbol_mismatch", "description": "no severity given", "items": []}
        ]}
      ]
    }"#;

    #[test]
    fn parse_erc_report_walks_sheets() {
        let data: Value = serde_json::from_str(ERC).unwrap();
        let (v, c) = parse_violations(Some(&data));
        assert_eq!(v.len(), 3);
        assert_eq!(c["error"], 1);
        assert_eq!(c["warning"], 2, "missing severity defaults to warning");
        assert!(schematic_hint(&v, 1, 2).starts_with("❌ 1 ERC error(s) + 2 warning(s). Top error types: pin_not_connected."));
        assert!(schematic_hint(&[], 0, 0).contains("ERC clean"));
        assert!(schematic_hint(&[], 0, 1).starts_with("⚠️  1 ERC warning(s), 0 errors."));
        let (none, c0) = parse_violations(None);
        assert!(none.is_empty());
        assert_eq!(c0.len(), 4);
        let (arr, _) = parse_violations(Some(&json!([1, 2])));
        assert!(arr.is_empty());
    }

    #[test]
    fn structure_checks() {
        let good = "(kicad_symbol_lib (version 20231120)\n  (symbol \"R\" (property \"a\" \"x)\"))\n)\n";
        assert!(check_symbol_lib_structure(good).is_ok());
        let (ro, rc, kids) = quote_aware_children(good).unwrap();
        assert_eq!(ro, 0);
        assert_eq!(rc, good.len() - 2);
        assert_eq!(kids.len(), 2);
        assert_eq!(quote_aware_children("(a (b)").unwrap_err(), "unbalanced s-expression: the top-level list never closes");
        assert_eq!(quote_aware_children(") (a)").unwrap_err(), "unbalanced s-expression: extra ')' at offset 0");
        assert!(quote_aware_children("(a) )").is_ok(), "the scan stops at the root's closer; trailing text is the structure check's job");
        assert_eq!(quote_aware_children("(a \"open").unwrap_err(), "unterminated quoted string");
        assert_eq!(quote_aware_children("nothing").unwrap_err(), "no s-expression found");
        assert_eq!(check_symbol_lib_structure("(kicad_symbol_lib (a))\n(junk)").unwrap_err(), "trailing content after the library's closing paren: '(junk)'");
        assert_eq!(check_symbol_lib_structure("(kicad_pcb (a))").unwrap_err(), "not a symbol library (top-level list is 'kicad_pcb')");
        assert!(check_footprint_structure("(footprint \"X\" (pad \"1\" smd rect))").is_ok());
        assert!(check_footprint_structure("(footprint \"X\") extra").unwrap_err().starts_with("trailing content after the footprint's closing paren"));
    }

    #[test]
    fn inventory_of_symbol_lib() {
        let text = r#"(kicad_symbol_lib
	(version 20231120)
	(generator "kicad_symbol_editor")
	(symbol "R" (pin_numbers hide)
		(property "Reference" "R")
		(symbol "R_0_1" (rectangle (start 0 0) (end 1 1)))
		(symbol "R_1_1"
			(pin passive line (at 0 3.81 270) (length 1.27))
			(pin passive line (at 0 -3.81 90) (length 1.27))
		)
	)
	(symbol "OpAmp_Dual"
		(symbol "OpAmp_Dual_1_1" (pin input line (at 0 0 0)))
		(symbol "OpAmp_Dual_2_1" (pin input line (at 0 0 0)))
		(symbol "OpAmp_Dual_3_1" (pin power_in line (at 0 0 0)))
	)
)
"#;
        let v = parse_kicad_sym(text);
        assert_eq!(v["symbolCount"], json!(2));
        assert_eq!(v["fileVersion"], json!(20231120));
        assert_eq!(v["generator"], json!("kicad_symbol_editor"));
        assert_eq!(v["symbols"][0]["name"], json!("R"));
        // The Python counts pins in a 5000-char window from the symbol, which here runs
        // into OpAmp_Dual's three pins: 2 + 3. Same over-count, on purpose.
        assert_eq!(v["symbols"][0]["pinCount"], json!(5));
        assert_eq!(v["symbols"][0]["unitCount"], json!(2), "units 0 and 1, as the Python's _N_M set counts them");
        assert_eq!(v["symbols"][1]["name"], json!("OpAmp_Dual"));
        assert_eq!(v["symbols"][1]["pinCount"], json!(3));
        assert_eq!(v["symbols"][1]["unitCount"], json!(3));
        assert!(ends_with_unit_suffix("R_0_1"));
        assert!(!ends_with_unit_suffix("R_0"));
        assert!(!ends_with_unit_suffix("LM358"));
        assert!(sym_magic_ok("\n(kicad_symbol_lib"));
        assert!(!sym_magic_ok("(kicad_pcb"));
    }

    #[test]
    fn inventory_of_footprint() {
        let text = r#"(footprint "R_0603_1608Metric" (version 20240108) (generator "pcbnew")
	(layer "F.Cu")
	(fp_line (start -1 -1) (end 1 1) (layer "F.SilkS"))
	(pad "1" smd roundrect (at -0.8 0) (size 0.9 0.95) (layers "F.Cu" "F.Paste" "F.Mask"))
	(pad "2" smd roundrect (at 0.8 0) (size 0.9 0.95) (layers "F.Cu" "F.Paste" "F.Mask"))
)
"#;
        let v = parse_kicad_mod(text);
        assert_eq!(v["name"], json!("R_0603_1608Metric"));
        assert_eq!(v["padCount"], json!(2));
        assert_eq!(v["layers"], json!(["F.Cu", "F.SilkS"]));
        assert_eq!(v["fileVersion"], json!(20240108));
        assert_eq!(v["generator"], json!("pcbnew"));
        let legacy = parse_kicad_mod("(module R_0805 (layer F.Cu) (tedit 5A1)\n  (pad 1 smd rect (at 0 0))\n)");
        assert_eq!(legacy["name"], json!("R_0805"));
        assert_eq!(legacy["padCount"], json!(1));
        assert_eq!(legacy["layers"], json!(["F.Cu"]));
        assert_eq!(legacy["fileVersion"], Value::Null);
        assert!(mod_magic_ok("(module X"));
        assert!(!mod_magic_ok("(kicad_symbol_lib"));
    }

    #[test]
    fn plot_lines_from_sym_export() {
        let text = "Plotting symbol 'R' unit 1\nPlotting symbol 'OpAmp' unit 1\nPlotting symbol 'OpAmp' unit 2\nDone.\n";
        let (names, units) = parse_plot_lines(text);
        assert_eq!(names, vec!["R", "OpAmp"]);
        assert_eq!(units, 3);
        assert_eq!(plot_line_name("Plotting symbol 'X' unit x"), None);
        assert_eq!(parse_plot_lines("Unable to load library").0.len(), 0);
    }

    #[test]
    fn library_hint_tiers() {
        let refused = vec![json!({"path": "C:/libs/Adom.kicad_sym", "error": "Unable to load library", "available": true, "ok": false})];
        let h = library_hint(&refused, 0, 0, 5, 0, &[20231120], &refused);
        assert!(h.starts_with("❌ KICAD REFUSES 1 symbol file(s) that the bridge's structural check passed: Adom.kicad_sym: Unable to load library."));
        let h = library_hint(&[], 1, 2, 0, 0, &[], &[]);
        assert!(h.starts_with("❌ 1 symbol file(s) + 2 footprint file(s) failed to parse."));
        let h = library_hint(&[], 0, 0, 3, 1, &[20211014, 20231120], &[]);
        assert!(h.starts_with("⚠️  3 symbol(s) + 1 footprint(s) parsed cleanly, BUT file-format version(s) [20211014] are pre-KiCad-7."));
        let ok = vec![json!({"available": true, "ok": true, "symbols": ["A", "B"]})];
        let h = library_hint(&[], 0, 0, 2, 0, &[20231120], &ok);
        assert!(h.starts_with("✅ KiCad's own parser loads 2 symbol(s) from 1 file(s) (kicadParser); the bridge's structural check counts 2 symbol(s) + 0 footprint(s), format version(s) [20231120]."));
        let na = vec![json!({"available": false, "ok": null, "symbols": []})];
        let h = library_hint(&[], 0, 0, 2, 0, &[], &na);
        assert!(h.contains("format version(s) unspecified. KiCad's own parser was NOT consulted"));
        // wiki #103: footprints alone are not a missing kicad-cli. kicad-cli lints symbol
        // libraries and has no footprint verdict, so say that instead of sending the caller
        // hunting for an executable that is installed and working.
        let h = library_hint(&[], 0, 0, 0, 3, &[20260206], &[]);
        assert!(h.starts_with("✅ 3 footprint(s) balanced by the BRIDGE's reader"), "{h}");
        assert!(h.contains("no footprint verdict to give"), "{h}");
        assert!(!h.contains("kicad-cli not found"), "a working kicad-cli must not be blamed: {h}");
        // a symbol library with no verdict still reads as a missing tool
        let h = library_hint(&[], 0, 0, 1, 0, &[20231120], &[]);
        assert!(h.contains("NOT consulted (kicad-cli not found)"), "{h}");
    }

    #[test]
    fn upgrade_kind_and_hint() {
        assert_eq!(normalize_kind("PCB"), Some("pcb"));
        assert_eq!(normalize_kind(" fp "), Some("fp"));
        assert_eq!(normalize_kind("board"), None);
        assert_eq!(normalize_kind(""), None);
        assert!(upgrade_hint("pcb", true, 0).contains("Re-run kicad_lint_board to confirm"));
        assert!(upgrade_hint("sch", true, 0).contains("Re-run kicad_lint_schematic to confirm"));
        assert!(upgrade_hint("sym", true, 0).contains("Re-run kicad_lint_library to confirm"));
        assert!(upgrade_hint("fp", false, 2).starts_with("❌ kicad-cli fp upgrade failed with exitCode=2."));
    }

    #[test]
    fn detect_kind_and_discover_on_disk() {
        let dir = std::env::temp_dir().join(format!("kicad-lint-test-{}", std::process::id()));
        let pretty = dir.join("Test.pretty");
        std::fs::create_dir_all(&pretty).unwrap();
        std::fs::write(pretty.join("R.kicad_mod"), "(footprint \"R\" (version 20240108) (pad \"1\" smd rect))\n").unwrap();
        std::fs::write(dir.join("Lib.kicad_sym"), "(kicad_symbol_lib (version 20231120)\n  (symbol \"R\" (pin passive line))\n)\n").unwrap();
        std::fs::write(dir.join("board.kicad_pcb"), "(kicad_pcb (version 20240108))\n").unwrap();
        std::fs::write(dir.join("notes.txt"), "hello").unwrap();

        assert_eq!(detect_kind(&pretty), Some("fp"));
        assert_eq!(detect_kind(&dir), Some("sym"));
        assert_eq!(detect_kind(&dir.join("board.kicad_pcb")), Some("pcb"));
        assert_eq!(detect_kind(&dir.join("Lib.kicad_sym")), Some("sym"));
        assert_eq!(detect_kind(&dir.join("notes.txt")), None);

        let li = discover_library(&pretty).unwrap();
        assert_eq!(li.kind, "footprint_lib_dir");
        assert_eq!(li.mod_files.len(), 1);
        let li = discover_library(&dir).unwrap();
        assert_eq!(li.kind, "mixed_dir");
        assert_eq!((li.sym_files.len(), li.mod_files.len()), (1, 0));
        assert_eq!(discover_library(&dir.join("Lib.kicad_sym")).unwrap().kind, "symbol_lib_file");
        assert!(discover_library(&dir.join("notes.txt")).unwrap_err().0.starts_with("unsupported file extension: .txt"));
        assert!(discover_library(&dir.join("missing")).unwrap_err().0.starts_with("path not found"));

        // Full library lint with no kicad-cli: structural verdict only, hint says KiCad was not asked.
        let v = lint_library(&dir, None, Duration::from_secs(1));
        assert_eq!(v["success"], json!(true));
        assert_eq!(v["ok"], json!(true));
        assert_eq!(v["data"]["summary"]["symbolCount"], json!(1));
        assert_eq!(v["data"]["summary"]["fileFormatVersions"], json!([20231120]));
        assert_eq!(v["kicadParser"]["available"], json!(false));
        assert!(v["_hint"].as_str().unwrap().contains("NOT consulted"));
        assert_eq!(v["data"]["symbols"][0]["structuralCheck"], json!("balanced"));

        // A broken library is a file error, not a pass.
        std::fs::write(dir.join("Bad.kicad_sym"), "(kicad_symbol_lib (symbol \"R\")\n").unwrap();
        let v = lint_library(&dir.join("Bad.kicad_sym"), None, Duration::from_secs(1));
        assert_eq!(v["ok"], json!(false));
        assert_eq!(v["data"]["summary"]["symFileErrorCount"], json!(1));
        assert!(v["data"]["symbols"][0]["_error"].as_str().unwrap().starts_with("structural: unbalanced"));
        assert!(v["_hint"].as_str().unwrap().starts_with("❌ 1 symbol file(s) + 0 footprint file(s) failed to parse."));

        // Sniff on disk.
        let s = file_format_sniff(&dir.join("board.kicad_pcb"), PCB);
        assert_eq!(s["ok"], json!(true));
        assert_eq!(file_format_sniff(&dir.join("nope.kicad_pcb"), PCB)["ok"], json!(false));
        assert_eq!(file_format_sniff(&dir, PCB)["_error"], json!(format!("not a file: {}", dir.display())));

        let _ = std::fs::remove_dir_all(&dir);
    }
}