"""Headless KiCad lint pre-checks via `kicad-cli`.

This module ships three top-level handlers — `handle_run_erc`,
`handle_lint_board`, `handle_lint_schematic` — that wrap `kicad-cli`
subprocess invocations to give AIs fast, structured validation
*before* attempting GUI automation. GUI automation is slow and fragile;
kicad-cli is fast (no wx, no event loop) and emits clean JSON.

Why lint before GUI:
  - Bad file? Parse error caught in 1s instead of a confusing
    "the board didn't open" 30 seconds later.
  - Schematic ↔ PCB parity broken? Surface the mismatch via DRC
    `--schematic-parity` flag BEFORE the AI thinks the board is fine.
  - File-format outdated? Tell the user to upgrade BEFORE opening
    triggers KiCad's modal "this file was made with an older version"
    dialog (which blocks the GUI and confuses screenshot-based recovery).

Each handler returns a `_hint` field that explicitly tells the calling
AI what to do next: "ok to proceed to kicad_open_board", "fix these
violations first", "ask user to upgrade", etc.

Shared concerns extracted into helpers:
  - File existence + extension check
  - Magic-byte sniff (first ~50 bytes, looking for `(kicad_pcb` etc.)
  - subprocess wrapper with timeout + JSON capture
  - Violation parser (DRC + ERC share JSON shape mostly)
"""

from __future__ import annotations

import json
import os
import subprocess
import tempfile
from pathlib import Path
from typing import Any


# Magic-byte sniff: KiCad files are S-expression with a known root atom.
# This catches "wrong file type" before kicad-cli even runs.
_PCB_MAGIC_TOKENS = ("(kicad_pcb", "(kicad_pcb (")
_SCH_MAGIC_TOKENS = ("(kicad_sch", "(kicad_sch (")


def _file_format_sniff(path: Path, expected: str) -> dict:
    """Return {ok, expected, magic, fileVersion?, _error?}.

    expected: "kicad_pcb" or "kicad_sch"
    """
    if not path.exists():
        return {
            "ok": False,
            "_error": f"file not found: {path}",
            "_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 not path.is_file():
        return {"ok": False, "_error": f"not a file: {path}"}

    try:
        with open(path, "rb") as f:
            head = f.read(512)
    except OSError as e:
        return {"ok": False, "_error": f"read failed: {e}"}

    head_text = head.decode("utf-8", errors="replace").lstrip()
    magic_tokens = _PCB_MAGIC_TOKENS if expected == "kicad_pcb" else _SCH_MAGIC_TOKENS
    magic_match = any(head_text.startswith(t) for t in magic_tokens)
    if not magic_match:
        # The file might be valid KiCad but a different type
        # (.kicad_sch passed where .kicad_pcb expected), or pure garbage.
        wrong_type = None
        if expected == "kicad_pcb" and head_text.startswith("(kicad_sch"):
            wrong_type = "kicad_sch"
        elif expected == "kicad_sch" and head_text.startswith("(kicad_pcb"):
            wrong_type = "kicad_pcb"
        return {
            "ok": False,
            "expected": expected,
            "actual": wrong_type or "non-kicad",
            "_error": f"file is not a {expected} (first bytes: {head_text[:80]!r})",
            "_hint": (
                f"You passed what looks like a {wrong_type} file to a verb "
                f"expecting {expected}. Use the matching verb instead "
                f"(kicad_open_board for .kicad_pcb, kicad_open_schematic / "
                f"kicad_lint_schematic for .kicad_sch)."
                if wrong_type else
                f"The file doesn't start with the expected {expected} S-expression header. "
                "It may be corrupted, truncated, or not a KiCad file at all."
            ),
        }

    # Try to pluck the file-format version. KiCad ≥6 embeds a `(version YYYYMMDD)`
    # near the top. We look for the first `(version ...)` in the first 512 bytes.
    file_version = None
    try:
        import re
        m = re.search(r"\(version\s+(\d+)\)", head_text)
        if m:
            file_version = int(m.group(1))
    except Exception:  # pylint: disable=broad-except
        pass

    return {
        "ok": True,
        "expected": expected,
        "magic": "matched",
        "fileVersion": file_version,
        "_note": "magic + format sniff only — kicad-cli will do the real validation",
    }


def _run_kicad_cli_json(cli_exe: str, args: list[str], timeout_secs: int = 120) -> dict:
    """Run kicad-cli and capture the JSON output it wrote to --output.

    Both `pcb drc` and `sch erc` write their structured report to a file
    path supplied via `--output`; stdout/stderr just carry status text.
    We make a tempfile, pass it in, parse it back, clean up.

    Returns {ok, exitCode, stdout, stderr, data?, _error?}.
    Caller adds higher-level `_hint`s based on what the data says.
    """
    fd, tmp_path = tempfile.mkstemp(suffix=".json")
    os.close(fd)
    full_args = list(args) + ["--output", tmp_path]
    try:
        proc = subprocess.run(
            [cli_exe] + full_args,
            capture_output=True,
            text=True,
            timeout=timeout_secs,
        )
        out_file = Path(tmp_path)
        data: Any = None
        if out_file.exists() and out_file.stat().st_size > 0:
            try:
                with open(tmp_path, "r", encoding="utf-8") as f:
                    data = json.load(f)
            except json.JSONDecodeError as e:
                return {
                    "ok": False,
                    "exitCode": proc.returncode,
                    "stdout": proc.stdout,
                    "stderr": proc.stderr,
                    "_error": f"could not parse kicad-cli JSON output: {e}",
                }
        return {
            "ok": True,
            "exitCode": proc.returncode,
            "stdout": proc.stdout,
            "stderr": proc.stderr,
            "data": data,
        }
    except subprocess.TimeoutExpired:
        return {
            "ok": False,
            "_error": f"kicad-cli timed out after {timeout_secs}s",
            "_hint": "Bump the timeout, or the board/schematic may be pathologically large.",
        }
    except Exception as e:  # pylint: disable=broad-except
        return {
            "ok": False,
            "_error": f"kicad-cli invocation failed: {type(e).__name__}: {e}",
        }
    finally:
        try:
            Path(tmp_path).unlink(missing_ok=True)
        except OSError:
            pass


def _parse_violations(data: dict | None) -> tuple[list[dict], dict]:
    """Extract violation list + per-severity counts from DRC/ERC JSON.

    Returns (violations[], counts{error,warning,info,exclusion}).
    """
    if not data or not isinstance(data, dict):
        return [], {"error": 0, "warning": 0, "info": 0, "exclusion": 0}
    out: list[dict] = []
    counts = {"error": 0, "warning": 0, "info": 0, "exclusion": 0}
    # `violations` is the standard list. `unconnected_items` is a DRC-only
    # sibling that surfaces ratlines kicad-cli reports separately.
    for bucket_key, bucket_label in (("violations", None), ("unconnected_items", "unconnected_items")):
        for v in data.get(bucket_key, []):
            row = {
                "type": v.get("type", bucket_label or "unknown"),
                "severity": v.get("severity", "warning"),
                "description": v.get("description", ""),
                "items": [],
            }
            for item in v.get("items", []):
                row["items"].append({
                    "description": item.get("description", ""),
                    "pos": item.get("pos", {}),
                })
            out.append(row)
            sev = row["severity"]
            counts[sev] = counts.get(sev, 0) + 1
    return out, counts


def _cli_exe_or_error(kicad_info: dict) -> tuple[str | None, dict | None]:
    cli = kicad_info.get("kicad_cli_exe")
    if not cli or not Path(cli).exists():
        return None, {
            "success": False,
            "error": "kicad-cli not found",
            "_hint": "Run kicad_list_versions to check the install. If kicad_cli_exe is missing, the user should reinstall KiCad — kicad-cli ships in the same Bin dir as kicad.exe.",
        }
    return cli, None


# ── handle_run_erc ──────────────────────────────────────────────────

def handle_run_erc(kicad_info: dict, args: dict) -> dict:
    """Run ERC on a .kicad_sch via `kicad-cli sch erc --format json`.

    Mirrors handle_run_drc. Args: {filePath}. Returns the same structured
    shape as run_drc: {success, output, data:{violations, summary,
    source_file}}.

    This was missing as a top-level verb prior to v1.7.12 — schematic
    designers had no equivalent of `kicad_run_drc` for catching wiring
    errors / missing labels / un-driven nets / etc. before opening the
    GUI.
    """
    if not kicad_info.get("installed"):
        return {"success": False, "error": "KiCad not installed",
                "_hint": "Ask the user to install KiCad from https://www.kicad.org/download/."}
    file_path = args.get("filePath", "")
    if not file_path:
        return {"success": False, "error": "filePath is required",
                "_hint": "Pass {\"filePath\":\"<absolute path to a .kicad_sch>\"}."}
    sch_path = Path(file_path)
    sniff = _file_format_sniff(sch_path, "kicad_sch")
    if not sniff.get("ok"):
        return {"success": False, **sniff}

    cli, err = _cli_exe_or_error(kicad_info)
    if err:
        return err

    res = _run_kicad_cli_json(cli, [
        "sch", "erc",
        "--format", "json",
        "--severity-all",
        str(sch_path),
    ])
    if not res.get("ok"):
        return {
            "success": False,
            "error": res.get("_error", "kicad-cli failed"),
            "stdout": res.get("stdout"),
            "stderr": res.get("stderr"),
            "_hint": "If exitCode is nonzero with no parsed data, KiCad's CLI couldn't even open the file — fix the file before retrying.",
        }
    violations, counts = _parse_violations(res.get("data"))
    total = len(violations)
    return {
        "success": True,
        "output": f"ERC complete: {counts.get('error', 0)} error(s), {counts.get('warning', 0)} warning(s), {total} total",
        "data": {
            "violations": violations,
            "summary": {
                "total": total,
                "errors": counts.get("error", 0),
                "warnings": counts.get("warning", 0),
                "info": counts.get("info", 0),
            },
            "fileFormat": {
                "expected": "kicad_sch",
                "fileVersion": sniff.get("fileVersion"),
            },
            "source_file": str(sch_path),
        },
        "_hint": (
            "ERC passed cleanly — safe to open this schematic in eeschema for further editing."
            if counts.get("error", 0) == 0
            else
            f"{counts.get('error', 0)} ERC error(s). Common causes: unconnected pins (add a NoConnect flag if intentional), "
            "duplicate references (run Annotate Schematic), hierarchical-label mismatches, mixed input/output drivers on the "
            "same net. Inspect violations[].description for specifics. Fix in eeschema (kicad_open_schematic) before running "
            "DRC on the corresponding board."
        ),
    }


# ── handle_lint_board ───────────────────────────────────────────────

def handle_lint_board(kicad_info: dict, args: dict) -> dict:
    """Comprehensive pre-flight lint for a .kicad_pcb. Combines:
      - File-existence + magic-byte check
      - DRC with --severity-all + --all-track-errors
      - Schematic parity (DRC --schematic-parity) — catches board-vs-
        schematic drift, a common bug class that's hard to spot from
        the PCB alone.

    Returns a single structured payload designed for AI consumption:
    `{success, fileFormat, drc:{violations, summary}, _hint}`.

    Distinct from `kicad_run_drc`:
      - run_drc is the existing minimal wrapper around `pcb drc`.
      - lint_board adds the file-format sanity layer, enables schematic-
        parity (which run_drc didn't), and returns a single AI-friendly
        hint string that EXPLICITLY tells the calling AI what to do
        next (proceed / fix violations / upgrade file / etc.).
    """
    if not kicad_info.get("installed"):
        return {"success": False, "error": "KiCad not installed",
                "_hint": "Ask the user to install KiCad from https://www.kicad.org/download/."}
    file_path = args.get("filePath", "")
    if not file_path:
        return {"success": False, "error": "filePath is required",
                "_hint": "Pass {\"filePath\":\"<absolute path to a .kicad_pcb>\"}."}
    pcb_path = Path(file_path)
    sniff = _file_format_sniff(pcb_path, "kicad_pcb")
    if not sniff.get("ok"):
        return {"success": False, **sniff}

    cli, err = _cli_exe_or_error(kicad_info)
    if err:
        return err

    schematic_parity = bool(args.get("schematicParity", True))
    cli_args = [
        "pcb", "drc",
        "--format", "json",
        "--severity-all",
        "--all-track-errors",
        str(pcb_path),
    ]
    if schematic_parity:
        # Only meaningful if the matching .kicad_sch exists in the project dir
        cli_args.insert(-1, "--schematic-parity")
    res = _run_kicad_cli_json(cli, cli_args)
    if not res.get("ok"):
        return {
            "success": False,
            "error": res.get("_error", "kicad-cli failed"),
            "stdout": res.get("stdout"),
            "stderr": res.get("stderr"),
            "fileFormat": {
                "expected": "kicad_pcb",
                "fileVersion": sniff.get("fileVersion"),
            },
            "_hint": (
                "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."
            ),
        }

    violations, counts = _parse_violations(res.get("data"))
    total = len(violations)
    errors = counts.get("error", 0)
    warnings = counts.get("warning", 0)

    # Build a tiered _hint so the AI knows what to do next
    if errors == 0 and warnings == 0:
        hint = "✅ DRC clean. Safe to open kicad_open_board, generate gerbers via export, or hand off to fabrication. No human intervention needed."
    elif errors == 0:
        hint = (f"⚠️  {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:
        # Group failure types for the hint
        types = sorted({v["type"] for v in violations if v["severity"] == "error"})[:5]
        types_str = ", ".join(types) if types else "various"
        hint = (f"❌ {errors} DRC error(s) + {warnings} warning(s). Top error types: {types_str}. "
                "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.")

    return {
        "success": True,
        "output": f"Lint complete: {errors} error(s), {warnings} warning(s), {total} total",
        "data": {
            "violations": violations,
            "summary": {
                "total": total,
                "errors": errors,
                "warnings": warnings,
                "info": counts.get("info", 0),
            },
            "fileFormat": {
                "expected": "kicad_pcb",
                "fileVersion": sniff.get("fileVersion"),
            },
            "schematicParityChecked": schematic_parity,
            "source_file": str(pcb_path),
        },
        "_hint": hint,
    }


# ── handle_lint_schematic ───────────────────────────────────────────

def handle_lint_schematic(kicad_info: dict, args: dict) -> dict:
    """Comprehensive pre-flight lint for a .kicad_sch.

    File-existence + magic-byte check + ERC with --severity-all. Same
    AI-friendly hint structure as kicad_lint_board.
    """
    if not kicad_info.get("installed"):
        return {"success": False, "error": "KiCad not installed",
                "_hint": "Ask the user to install KiCad from https://www.kicad.org/download/."}
    file_path = args.get("filePath", "")
    if not file_path:
        return {"success": False, "error": "filePath is required",
                "_hint": "Pass {\"filePath\":\"<absolute path to a .kicad_sch>\"}."}
    sch_path = Path(file_path)
    sniff = _file_format_sniff(sch_path, "kicad_sch")
    if not sniff.get("ok"):
        return {"success": False, **sniff}

    cli, err = _cli_exe_or_error(kicad_info)
    if err:
        return err

    res = _run_kicad_cli_json(cli, [
        "sch", "erc",
        "--format", "json",
        "--severity-all",
        str(sch_path),
    ])
    if not res.get("ok"):
        return {
            "success": False,
            "error": res.get("_error", "kicad-cli failed"),
            "stdout": res.get("stdout"),
            "stderr": res.get("stderr"),
            "fileFormat": {
                "expected": "kicad_sch",
                "fileVersion": sniff.get("fileVersion"),
            },
            "_hint": "If exitCode is nonzero and stderr mentions an older format, run `kicad-cli sch upgrade` (or open + save in eeschema).",
        }

    violations, counts = _parse_violations(res.get("data"))
    total = len(violations)
    errors = counts.get("error", 0)
    warnings = counts.get("warning", 0)

    if errors == 0 and warnings == 0:
        hint = "✅ ERC clean. Safe to open kicad_open_schematic or move on to PCB layout."
    elif errors == 0:
        hint = (f"⚠️  {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:
        types = sorted({v["type"] for v in violations if v["severity"] == "error"})[:5]
        types_str = ", ".join(types) if types else "various"
        hint = (f"❌ {errors} ERC error(s) + {warnings} warning(s). Top error types: {types_str}. "
                "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.")

    return {
        "success": True,
        "output": f"Lint complete: {errors} error(s), {warnings} warning(s), {total} total",
        "data": {
            "violations": violations,
            "summary": {
                "total": total,
                "errors": errors,
                "warnings": warnings,
                "info": counts.get("info", 0),
            },
            "fileFormat": {
                "expected": "kicad_sch",
                "fileVersion": sniff.get("fileVersion"),
            },
            "source_file": str(sch_path),
        },
        "_hint": hint,
    }


# ── handle_lint_library ─────────────────────────────────────────────

# Magic patterns for library files
_LIB_MAGIC = {
    "kicad_sym": ("(kicad_symbol_lib",),
    "kicad_mod": ("(footprint", "(module",),  # KiCad ≤6 used "module"
}


def _parse_kicad_sym(path: Path) -> dict:
    """Parse a .kicad_sym file. Returns:
        {symbolCount, symbols:[{name, hasPins, unitCount?, ...}],
         fileVersion, generator, _warnings:[...]}.

    Uses a regex over the file (s-expr parser would be cleaner but
    KiCad's symbol files have predictable top-level structure that's
    easy to grep — same approach kicad_detect.py uses for adomLibrary)."""
    import re
    out: dict = {
        "symbolCount": 0,
        "symbols": [],
        "fileVersion": None,
        "generator": None,
        "_warnings": [],
    }
    try:
        text = path.read_text(encoding="utf-8", errors="replace")
    except OSError as e:
        out["_warnings"].append(f"read failed: {e}")
        return out
    # File-format version
    m = re.search(r"\(version\s+(\d+)\)", text[:500])
    if m:
        out["fileVersion"] = int(m.group(1))
    m = re.search(r"\(generator\s+(?:\"([^\"]+)\"|(\S+))\)", text[:500])
    if m:
        out["generator"] = m.group(1) or m.group(2)

    # Top-level symbols: `(symbol "Name"` at the start of a line, NOT
    # nested unit variants (those have names like `R_0_0` or `R_1_1`).
    # The top-level `(symbol "Name"` lines have NO `_<digit>_<digit>` suffix.
    for sym_match in re.finditer(r'^\s*\(symbol\s+"([^"]+)"', text, re.MULTILINE):
        name = sym_match.group(1)
        # Skip nested unit variants like "R_0_0", "R_1_1"
        if re.search(r"_\d+_\d+$", name):
            continue
        # Snapshot a small window around this symbol to inspect pin count
        # and units. Find the matching closing paren — quick heuristic:
        # count up to ~5000 chars or until paren-balance reaches 0.
        start = sym_match.start()
        window = text[start:start + 5000]
        # Count pins (rough): `(pin <type> <shape>` lines belonging to
        # this symbol. May over-count if window slices into the next
        # symbol; for inventory purposes this is good enough.
        pin_count = len(re.findall(r"\(pin\s+\w+", window))
        # Unit count: look for `_<N>_<M>` subsymbol declarations
        units = set(re.findall(rf'\(symbol\s+"{re.escape(name)}_(\d+)_\d+"', text))
        out["symbols"].append({
            "name": name,
            "pinCount": pin_count,
            "unitCount": max(1, len(units)),
        })
    out["symbolCount"] = len(out["symbols"])
    return out


def _parse_kicad_mod(path: Path) -> dict:
    """Parse a .kicad_mod file. Returns:
        {name, padCount, layers, fileVersion, generator, _warnings:[...]}.
    """
    import re
    out: dict = {
        "name": None,
        "padCount": 0,
        "layers": [],
        "fileVersion": None,
        "generator": None,
        "_warnings": [],
    }
    try:
        text = path.read_text(encoding="utf-8", errors="replace")
    except OSError as e:
        out["_warnings"].append(f"read failed: {e}")
        return out

    # Name: `(footprint "Name"` (KiCad 7+) or `(module Name` (KiCad ≤6)
    m = re.search(r'\(footprint\s+"([^"]+)"', text[:500])
    if m:
        out["name"] = m.group(1)
    else:
        m = re.search(r"\(module\s+(\S+)", text[:500])
        if m:
            out["name"] = m.group(1)
    # Version + generator
    m = re.search(r"\(version\s+(\d+)\)", text[:500])
    if m:
        out["fileVersion"] = int(m.group(1))
    m = re.search(r"\(generator\s+(?:\"([^\"]+)\"|(\S+))\)", text[:500])
    if m:
        out["generator"] = m.group(1) or m.group(2)
    # Pad count
    out["padCount"] = len(re.findall(r"\(pad\s", text))
    # Top-layers used by pads / drawings (rough)
    layer_strs = set(re.findall(r'\(layer\s+(?:"([^"]+)"|(\S+))\)', text))
    out["layers"] = sorted({a or b for a, b in layer_strs})[:10]
    return out


def handle_lint_library(kicad_info: dict, args: dict) -> dict:
    """Validate a KiCad library file or directory. v1.7.13+.

    Inputs accepted:
      - Single `.kicad_sym` file → returns symbol inventory + file-version
      - Single `.kicad_mod` file → returns footprint metadata + version
      - `.pretty` directory → walks .kicad_mod files inside
      - Directory containing one or more `.kicad_sym` files → walks them

    Does NOT call kicad-cli (no --dry-run is available for sym/fp upgrade,
    and the upgrade commands actually rewrite files which we don't want
    during lint). Instead uses lightweight regex parsing — same approach
    as kicad_detect.py's adomLibrary inventory code.

    Returns a single AI-friendly structured response with a tiered `_hint`
    that tells the calling AI whether to proceed with install or fix the
    library first.
    """
    file_path = args.get("libraryPath", "") or args.get("filePath", "")
    if not file_path:
        return {
            "success": False,
            "error": "libraryPath is required",
            "_hint": "Pass {\"libraryPath\":\"<absolute path to .kicad_sym / .kicad_mod / .pretty dir>\"}.",
        }
    path = Path(file_path)
    if not path.exists():
        return {
            "success": False,
            "error": f"path not found: {file_path}",
            "_hint": "The path may exist on Docker but not on the Windows host. Use push_file to copy from Docker first.",
        }

    # Discover what kind of input this is
    files_found: dict = {"kicad_sym": [], "kicad_mod": []}
    kind = None
    if path.is_file():
        if path.suffix.lower() == ".kicad_sym":
            files_found["kicad_sym"].append(path)
            kind = "symbol_lib_file"
        elif path.suffix.lower() == ".kicad_mod":
            files_found["kicad_mod"].append(path)
            kind = "footprint_file"
        else:
            return {
                "success": False,
                "error": f"unsupported file extension: {path.suffix}",
                "_hint": "Pass a .kicad_sym, .kicad_mod, or a directory containing them (.pretty for footprint libraries).",
            }
    elif path.is_dir():
        if path.suffix.lower() == ".pretty" or path.name.endswith(".pretty"):
            kind = "footprint_lib_dir"
            files_found["kicad_mod"] = sorted(path.glob("*.kicad_mod"))
        else:
            kind = "mixed_dir"
            files_found["kicad_sym"] = sorted(path.glob("*.kicad_sym"))
            files_found["kicad_mod"] = sorted(path.glob("*.kicad_mod"))
        if not files_found["kicad_sym"] and not files_found["kicad_mod"]:
            return {
                "success": False,
                "error": f"directory contains no .kicad_sym or .kicad_mod files: {path}",
                "_hint": "Symbol libraries are single .kicad_sym files. Footprint libraries are .pretty/ directories containing .kicad_mod files.",
            }

    # Parse each file
    parsed_sym = []
    for f in files_found["kicad_sym"]:
        # Magic check
        try:
            with open(f, "rb") as fh:
                head = fh.read(200).decode("utf-8", errors="replace").lstrip()
            if not any(head.startswith(t) for t in _LIB_MAGIC["kicad_sym"]):
                parsed_sym.append({"path": str(f), "_error": "not a kicad_symbol_lib (bad magic)"})
                continue
        except OSError as e:
            parsed_sym.append({"path": str(f), "_error": f"read failed: {e}"})
            continue
        info = _parse_kicad_sym(f)
        info["path"] = str(f)
        parsed_sym.append(info)

    parsed_mod = []
    for f in files_found["kicad_mod"]:
        try:
            with open(f, "rb") as fh:
                head = fh.read(200).decode("utf-8", errors="replace").lstrip()
            if not any(head.startswith(t) for t in _LIB_MAGIC["kicad_mod"]):
                parsed_mod.append({"path": str(f), "_error": "not a kicad_mod (bad magic)"})
                continue
        except OSError as e:
            parsed_mod.append({"path": str(f), "_error": f"read failed: {e}"})
            continue
        info = _parse_kicad_mod(f)
        info["path"] = str(f)
        parsed_mod.append(info)

    # Summary numbers
    symbol_count = sum(s.get("symbolCount", 0) for s in parsed_sym if "_error" not in s)
    footprint_count = sum(1 for m in parsed_mod if "_error" not in m)
    sym_file_errors = [s for s in parsed_sym if "_error" in s]
    mod_file_errors = [m for m in parsed_mod if "_error" in m]

    # All file versions seen
    versions = set()
    for s in parsed_sym + parsed_mod:
        v = s.get("fileVersion")
        if v:
            versions.add(v)

    # Build tiered hint
    if sym_file_errors or mod_file_errors:
        hint = (
            f"❌ {len(sym_file_errors)} symbol file(s) + {len(mod_file_errors)} footprint file(s) "
            f"failed to parse. Inspect _errors[] for specifics. Common causes: file truncated mid-write, "
            f"wrong file type renamed with .kicad_sym extension, file from a much newer KiCad than installed. "
            f"DO NOT install via kicad_install_library / kicad_install_symbol / kicad_install_footprint — "
            f"the install will land broken files."
        )
    else:
        # Detect outdated format. KiCad 10 uses YYYYMMDD versions like
        # 20240108. Anything below 20200101 is KiCad 5-era, definitely
        # outdated. Versions in the 2023xxxx range are KiCad 7-era and
        # may still load but warrant a `kicad_format_upgrade` first.
        outdated = sorted([v for v in versions if v < 20231101])
        if outdated:
            hint = (
                f"⚠️  {symbol_count} symbol(s) + {footprint_count} footprint(s) parsed cleanly, "
                f"BUT file-format version(s) {outdated} are pre-KiCad-7. KiCad 10 can read these but "
                f"will silently upgrade them on first save. To migrate proactively, call "
                f"kicad_format_upgrade (or just run `kicad-cli sym/fp upgrade` manually). "
                f"OK to proceed with install but expect a one-time format change."
            )
        else:
            hint = (
                f"✅ {symbol_count} symbol(s) + {footprint_count} footprint(s) parsed cleanly, "
                f"format version(s) {sorted(versions) if versions else 'unspecified'}. Safe to install via "
                f"kicad_install_library / kicad_install_symbol / kicad_install_footprint."
            )

    return {
        "success": True,
        "output": f"Lint: {symbol_count} symbol(s), {footprint_count} footprint(s), "
                  f"{len(sym_file_errors) + len(mod_file_errors)} file error(s)",
        "data": {
            "kind": kind,
            "source_path": str(path),
            "summary": {
                "symbolCount": symbol_count,
                "footprintCount": footprint_count,
                "symFileErrorCount": len(sym_file_errors),
                "modFileErrorCount": len(mod_file_errors),
                "fileFormatVersions": sorted(versions),
            },
            "symbols": parsed_sym,
            "footprints": parsed_mod,
        },
        "_hint": hint,
    }


# ── handle_format_upgrade ───────────────────────────────────────────

def handle_format_upgrade(kicad_info: dict, args: dict) -> dict:
    """Wraps `kicad-cli {pcb,sch,sym,fp} upgrade` to migrate older
    KiCad files to the current format. v1.7.13+.

    Args:
      filePath: required — file or directory to upgrade
      kind:     optional — "pcb" | "sch" | "sym" | "fp". Auto-detected
                from extension or directory contents if omitted.
      force:    optional bool (default false) — passes `--force` to
                kicad-cli, which resaves the file even if it's already
                at the current version. Useful for round-tripping any
                quirks the importer fixed.

    WARNING: This MUTATES the file in place (kicad-cli has no --dry-run).
    Callers should expect the file to be rewritten. If you want
    inspection without write, call kicad_lint_library first.
    """
    if not kicad_info.get("installed"):
        return {"success": False, "error": "KiCad not installed",
                "_hint": "Ask the user to install KiCad."}
    file_path = args.get("filePath", "")
    if not file_path:
        return {"success": False, "error": "filePath is required",
                "_hint": "Pass {\"filePath\":\"<path>\", \"kind\":\"pcb|sch|sym|fp\"}."}
    path = Path(file_path)
    if not path.exists():
        return {"success": False, "error": f"path not found: {file_path}",
                "_hint": "Verify the path; use push_file from Docker if needed."}

    # Auto-detect kind if not given
    kind = (args.get("kind") or "").lower()
    if not kind:
        if path.is_file():
            if path.suffix == ".kicad_pcb":
                kind = "pcb"
            elif path.suffix == ".kicad_sch":
                kind = "sch"
            elif path.suffix == ".kicad_sym":
                kind = "sym"
            elif path.suffix == ".kicad_mod":
                kind = "fp"
        elif path.is_dir():
            # .pretty dir = footprint lib; dir with .kicad_sym = sym lib
            if path.suffix.lower() == ".pretty" or any(path.glob("*.kicad_mod")):
                kind = "fp"
            elif any(path.glob("*.kicad_sym")):
                kind = "sym"
    if kind not in ("pcb", "sch", "sym", "fp"):
        return {
            "success": False,
            "error": f"could not auto-detect kind for {file_path}",
            "_hint": "Pass kind explicitly: \"pcb\" / \"sch\" / \"sym\" / \"fp\".",
        }

    cli, err = _cli_exe_or_error(kicad_info)
    if err:
        return err

    cli_args = [kind, "upgrade"]
    if args.get("force"):
        cli_args.append("--force")
    cli_args.append(str(path))

    try:
        proc = subprocess.run(
            [cli] + cli_args,
            capture_output=True,
            text=True,
            timeout=120,
        )
    except subprocess.TimeoutExpired:
        return {"success": False, "error": "kicad-cli upgrade timed out after 120s",
                "_hint": "Likely a huge directory of footprints. Re-run with a single file at a time."}
    except Exception as e:  # pylint: disable=broad-except
        return {"success": False, "error": f"{type(e).__name__}: {e}"}

    success = proc.returncode == 0
    return {
        "success": success,
        "exitCode": proc.returncode,
        "kind": kind,
        "stdout": proc.stdout,
        "stderr": proc.stderr,
        "source_path": str(path),
        "_hint": (
            f"✅ kicad-cli {kind} upgrade completed. File(s) rewritten to current KiCad format. "
            f"Any open KiCad GUI windows with this file loaded should be closed + reopened — they "
            f"have the OLD in-memory state. Re-run kicad_lint_{kind if kind in ('board','schematic') else 'library'} "
            f"to confirm the post-upgrade file is clean."
            if success else
            f"❌ kicad-cli {kind} upgrade failed with exitCode={proc.returncode}. "
            f"stderr likely names the offending file or syntax issue. If it's a 'file format too new' "
            f"error, the user's KiCad install is OLDER than the source file — surface to user. "
            f"If 'file already up to date', pass force:true to resave anyway."
        ),
    }
