123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934
#!/usr/bin/env python3
"""Adom KiCad Bridge Server — localhost HTTP server for KiCad integration.

Receives commands from the Adom Desktop (Tauri app) and controls
KiCad via kicad-cli, direct executable launch, and library table manipulation.

Usage:
    python server.py
    python server.py --port 8772
"""

import json
import os
import re
import subprocess
from proc import run as _safe_run
import sys
import traceback
from http.server import HTTPServer, ThreadingHTTPServer, BaseHTTPRequestHandler
from pathlib import Path

# Platform detection for v1.7.4+ KiCad-v2 enriched helpers (runningInstances etc.)
IS_WINDOWS = sys.platform == "win32"

# Ensure the plugin root is on the path
sys.path.insert(0, str(Path(__file__).parent))

from kicad_detect import detect_kicad, detect_all_kicad_versions
from capabilities import build_capability_report
from handlers.open_files import handle_open_board, handle_open_schematic
from handlers.open_symbol_editor import handle_open_symbol_editor
from handlers.open_footprint_editor import handle_open_footprint_editor, handle_open_3d_viewer
from handlers.software_opengl import handle_enable_software_opengl
from handlers.diagnostics import handle_diagnostics
from handlers.close_windows import (
    handle_close_symbol_editor, handle_close_footprint_editor,
    handle_close_3d_viewer, handle_close_kicad, handle_window_info, handle_dismiss_dialogs,
)
from handlers.install_library import handle_install_library
from handlers.install_symbol import handle_install_symbol
from handlers.install_footprint import handle_install_footprint
from handlers.place_footprint import handle_place_footprint
from handlers.run_drc import handle_run_drc
from handlers.fix_keyboard import handle_fix_keyboard
from handlers.kicad_ui import handle_screenshot_all, handle_send_key, handle_click
from handlers.bridge_client import handle_bridge_status, handle_bridge_call, handle_open_editors
from handlers.kicad_cli_lint import (
    handle_run_erc, handle_lint_board, handle_lint_schematic,
    handle_lint_library, handle_format_upgrade,
)
# v0.7.1+: export handlers (Gerber/PDF/SVG/STEP/BOM_CSV) via kicad-cli. The
# functions have existed in handlers/export.py for a while but were never wired
# into COMMAND_HANDLERS — kicad_export_gerbers/pdf/svg/step/bom_csv all returned
# "Unknown command" at runtime. Discovered while building the KiCad tour skill.
from handlers.export import (
    handle_export_gerber, handle_export_pdf, handle_export_svg,
    handle_export_step, handle_export_bom_csv,
)
# v0.9.61+: one-shot desktop half of the Adom molecule pipeline (STEP + silk
# renders + sibling sch/pro discovery + container next-steps manifest).
from handlers.export_molecule import handle_export_molecule
# v0.9.63+: first-class showcase tour (AD demos this bridge during HD install).
from handlers.demo import handle_demo
# v0.9.10+: check/upgrade KiCad via the OFFICIAL downloads.kicad.org installer
# (NOT winget, which lags the real release). check_for_updates is read-only;
# upgrade silent-installs the official signed NSIS build.
from handlers.upgrade import handle_check_for_updates, handle_upgrade
from adom_library import ensure_adom_library
import plugin_install

DEFAULT_PORT = 8772


def _read_bridge_version() -> str:
    """Our own bridge version, from the BRIDGE_VERSION file beside server.py.
    Reported in /health so AD can detect a STALE bridge process (one that
    survived an AD update) and restart it — the "not running latest bridge"
    footgun. Empty string if the file is missing (treated as stale by AD)."""
    try:
        p = os.path.join(os.path.dirname(os.path.abspath(__file__)), "BRIDGE_VERSION")
        with open(p, encoding="utf-8") as fh:
            return fh.read().strip()
    except OSError:
        return ""


BRIDGE_VERSION = _read_bridge_version()

# Populated at startup
kicad_info = None              # default kicad_info (newest installed)
all_kicad_versions: list = []  # every installed version, newest first

def _handle_adom_library_status(kicad_info: dict, args: dict) -> dict:
    """Return the status of the Adom symbol library."""
    result = ensure_adom_library(kicad_info)
    return {"success": True, **result}


def _handle_list_versions(kicad_info: dict, args: dict) -> dict:
    """List every installed KiCad version on this machine.

    Returns the full enriched per-version info from kicad_detect.detect_all_*
    plus a `default` flag per entry. v1.7.4+ (KiCad v2 Phase 1): broader
    surface — pythonVersion, pluginDir, adomPlugin{Installed,Version},
    ipcApiAvailable, ipcApiScope, binaries{}, libraryTablePaths{} all
    included per the integration plan. Back-compat: the legacy fields
    (version, base_dir, kicad_exe, default) are all still present.

    Returns: {success, versions:[full info dict + default flag], default,
              activeVersion, count, runningInstances, _hint}
    """
    if not all_kicad_versions:
        return {
            "success": True,
            "versions": [],
            "default": None,
            "activeVersion": None,
            "count": 0,
            "runningInstances": _detect_running_kicad_instances(),
            "_hint": "No KiCad installs found. KiCad is the user's host app, so AD won't silently auto-install it as a dependency/prewarm (v1.9.47) — but DON'T make the user install it by hand. Offer to do it for them, then run `kicad_upgrade '{}'`: the agent installs the latest stable from the OFFICIAL downloads.kicad.org installer (silent /S; may raise a UAC prompt to approve). kicad_upgrade installs from scratch, not just upgrades. Windows only.",
            "errorCode": "host_app_not_installed",
            "errorCodeLegacy": "kicad_not_installed",
            "hostApp": "KiCad",
        }
    default_version = all_kicad_versions[0]["version"]
    # Surface every field detect_all_kicad_versions returns + a `default`
    # flag. We deliberately don't hand-pick fields here — callers expecting
    # the v1.7.3 shape (version/base_dir/kicad_exe/default) still get them;
    # callers using the v1.7.4 enriched shape get everything additional.
    versions = []
    for v in all_kicad_versions:
        entry = dict(v)  # shallow copy so we don't mutate the cached info
        entry["default"] = (v["version"] == default_version)
        versions.append(entry)
    return {
        "success": True,
        "versions": versions,
        "default": default_version,             # legacy field — newest version
        "activeVersion": default_version,       # v1.7.4 alias for newest installed
        "count": len(all_kicad_versions),
        "runningInstances": _detect_running_kicad_instances(),
        "_hint": (
            f"Pass kicadVersion to any kicad_open_* command to pick a specific version "
            f"(e.g. {{\"kicadVersion\":\"{versions[-1]['version']}\"}}). "
            f"Omit to use the newest ({default_version}). "
            f"v1.7.4+: each entry now includes pythonVersion, pluginDir, adomPluginInstalled, "
            f"ipcApiAvailable, binaries{{}}, libraryTablePaths{{}} — see the integration plan."
        ),
    }


def _handle_readiness(kicad_info: dict, args: dict) -> dict:
    """Read-only readiness probe — 'is the KiCad host app present?'

    AD v1.9.47+: KiCad is the user's HOST APP. AD detects it, it never
    installs it. This verb answers that detection question with zero side
    effects: it reads the cached `all_kicad_versions` computed once at
    startup, does NOT install the reverse-bridge plugin, does NOT spawn
    subprocesses, and works even when KiCad is absent (reports
    detected:false). For full per-version detail use kicad_list_versions;
    for "what EDA tools do I have" across all bridges use the AD-level
    `bridge_readiness`.
    """
    detected = bool(all_kicad_versions)
    default_version = all_kicad_versions[0]["version"] if detected else None
    versions = [
        {
            "version": v["version"],
            "exePath": v.get("kicad_exe"),
            "default": (v["version"] == default_version),
        }
        for v in all_kicad_versions
    ]
    return {
        "success": True,
        "hostApp": "KiCad",
        "detected": detected,
        "ready": detected,
        "versionCount": len(all_kicad_versions),
        "defaultVersion": default_version,
        "versions": versions,
        "_hint": (
            "Read-only host-app detection (no install, no plugin load, no subprocess). "
            "AD won't silently auto-install KiCad as a dependency/prewarm (v1.9.47), but the agent "
            "still installs it FOR the user on request — never hand the user a manual download. "
            + ("KiCad is installed and ready." if detected
               else "KiCad not detected — offer to install it, then run `kicad_upgrade '{}'` (installs the "
                    "official build from scratch, silent /S, Windows only; may raise a UAC prompt).")
        ),
    }


# Public verb catalog for kicad_describe (Bridge SDK Verb contract + discussion
# #14 amendment). Keyed by the INTERNAL command name; the kicad_ prefix is added
# on output. Each entry carries summary + long(-running) + hint + related +
# pitfalls, which AD's Verbs tab renders inline. Aliases (export_gerbers) skipped.
_VERB_CATALOG = {
    "open_board": {"summary": "Open a .kicad_pcb in the PCB editor (pcbnew).", "long": True,
        "hint": "Pass an ABSOLUTE filePath; opens the board in pcbnew and returns once the window is up. Optional kicadVersion pins a specific install.",
        "related": ["kicad_open_3d_viewer", "kicad_run_drc", "kicad_screenshot_all", "kicad_close"],
        "pitfalls": ["filePath must be ABSOLUTE — a relative/project-dir path fails", "if the board is already open you get the existing window, not a fresh reload"]},
    "open_schematic": {"summary": "Open a .kicad_sch in the schematic editor (eeschema).", "long": True,
        "hint": "Absolute filePath to a .kicad_sch; opens eeschema. Optional kicadVersion.",
        "related": ["kicad_run_erc", "kicad_export_bom_csv", "kicad_screenshot_all", "kicad_close"],
        "pitfalls": ["filePath must be ABSOLUTE", "open the top-level .kicad_sch, not a sub-sheet, to load the whole design"]},
    "open_symbol_editor": {"summary": "Open the Symbol Editor (optionally at a symbol).", "long": True,
        "hint": "Opens the Symbol Editor; pass library/symbol to jump straight to one.",
        "related": ["kicad_install_symbol", "kicad_close_symbol_editor", "kicad_screenshot_all"],
        "pitfalls": ["the symbol must already be in an installed sym-lib-table library (install_symbol first)"]},
    "open_footprint_editor": {"summary": "Open the Footprint Editor (optionally at a footprint).", "long": True,
        "hint": "Opens the Footprint Editor; pass library/footprint to jump to one.",
        "related": ["kicad_install_footprint", "kicad_close_footprint_editor", "kicad_screenshot_all"],
        "pitfalls": ["the footprint must be in an installed fp-lib-table library (install_footprint first)"]},
    "open_3d_viewer": {"summary": "Open the 3D board viewer for a .kicad_pcb.", "long": True,
        "hint": "Absolute filePath to a board; opens the 3D viewer. Follow with screenshot_all to capture it.",
        "related": ["kicad_open_board", "kicad_screenshot_all", "kicad_close_3d_viewer"],
        "pitfalls": ["3D models only render if the footprints reference installed 3D model files", "the viewer takes a few seconds to render — wait before screenshotting"]},
    "close_symbol_editor": {"summary": "Close the Symbol Editor window.", "long": False,
        "hint": "Closes the Symbol Editor if open; no-op otherwise.",
        "related": ["kicad_open_symbol_editor", "kicad_close"], "pitfalls": ["unsaved edits may prompt — save first if you made changes"]},
    "close_footprint_editor": {"summary": "Close the Footprint Editor window.", "long": False,
        "hint": "Closes the Footprint Editor if open; no-op otherwise.",
        "related": ["kicad_open_footprint_editor", "kicad_close"], "pitfalls": ["unsaved edits may prompt — save first"]},
    "close_3d_viewer": {"summary": "Close the 3D viewer window.", "long": False,
        "hint": "Closes the 3D viewer if open; no-op otherwise.",
        "related": ["kicad_open_3d_viewer", "kicad_close"], "pitfalls": []},
    "close": {"summary": "Close KiCad (or a specific editor window).", "long": False,
        "hint": "Closes KiCad; pass a target to close just one editor. Read kicad-interaction skill before blind-closing.",
        "related": ["kicad_open_editors", "kicad_window_info"], "pitfalls": ["closing with unsaved work triggers a save dialog — handle it, don't blind-close"]},
    "enable_software_opengl": {"summary": "LAST-RESORT fallback: deploy Mesa llvmpipe software OpenGL into KiCad's bin so it renders on a GPU-less host (Hyper-V/RDP/headless). SLOW (CPU). Never suggest proactively.", "long": True,
        "hint": "Only when KiCad can't render at all AND there's no GPU. Drops Mesa opengl32/libgallium DLLs + .local redirection into KiCad bin (needs elevation to write Program Files, or use a per-user install). Close+reopen editors after.",
        "related": ["kicad_dismiss_dialogs", "kicad_upgrade"],
        "pitfalls": ["a real GPU / Hyper-V GPU-P passthrough is far better — this is CPU rendering, slow", "writing KiCad's bin needs an elevated AD"]},
    "dismiss_dialogs": {"summary": "Programmatically expire KiCad dialogs (auto-clears the benign OpenGL/software-render notice; {all:true} clears every dialog).", "long": False,
        "hint": "Read-only-ish self-heal: clicks each dialog's OK/default button. window_info also auto-expires benign dialogs; use this to force-clear a specific/all dialogs before retrying an open/screenshot.",
        "related": ["kicad_window_info", "kicad_screenshot_all", "kicad_open_board"],
        "pitfalls": ["benign-only by default — pass {\"all\":true} to also clear real dialogs you've already read", "a NON-benign dialog left up may indicate a real error — read its body first"]},
    "window_info": {"summary": "Report geometry/state for open KiCad windows (auto-expires benign OpenGL dialogs).", "long": False,
        "hint": "Read-only; returns each KiCad window's position/size/state. Use before click/send_key to target the right window.",
        "related": ["kicad_open_editors", "kicad_click", "kicad_send_key"], "pitfalls": ["coordinates are screen-absolute — re-read after a window moves/resizes"]},
    "install_library": {"summary": "Register a symbol/footprint library in the user's sym-/fp-lib-table.", "long": False,
        "hint": "Adds a library to the selected KiCad version's lib table so its parts are usable.",
        "related": ["kicad_install_symbol", "kicad_install_footprint", "kicad_list_versions"],
        "pitfalls": ["each KiCad version has its OWN lib tables — install under every version the user needs", "restart/reload KiCad to see a newly-added library"]},
    "install_symbol": {"summary": "Install a .kicad_sym into the user's sym-lib-table.", "long": False,
        "hint": "Absolute path to a .kicad_sym; registers it in the sym-lib-table.",
        "related": ["kicad_open_symbol_editor", "kicad_install_library", "kicad_install_footprint"],
        "pitfalls": ["writes to the SELECTED version's table only (pass kicadVersion for others)"]},
    "install_footprint": {"summary": "Install a footprint (.kicad_mod/.pretty) into the fp-lib-table.", "long": False,
        "hint": "Absolute path to a .kicad_mod or .pretty dir; registers it in the fp-lib-table.",
        "related": ["kicad_open_footprint_editor", "kicad_install_library", "kicad_install_symbol"],
        "pitfalls": [".pretty is a DIRECTORY of .kicad_mod files — point at the dir, not one file"]},
    "install_plugin": {"summary": "Install the reverse-bridge plugin into KiCad (idempotent).", "long": False,
        "hint": "Normally auto-runs on your first kicad_* call; call explicitly only to force/repair.",
        "related": ["kicad_bridge_status", "kicad_bridge_call"], "pitfalls": ["needs a restart of an already-running KiCad to load the freshly-installed plugin"]},
    "place_footprint": {"summary": "Deterministically place a footprint into a board preview.", "long": False,
        "hint": "Splices a footprint into a board via s-expr (no GUI); good for previews.",
        "related": ["kicad_open_board", "kicad_install_footprint"], "pitfalls": ["operates on the file — close the board in KiCad first or the edit races the GUI"]},
    "run_drc": {"summary": "Run DRC on a board via kicad-cli (read-only).", "long": False,
        "hint": "Absolute board path; runs kicad-cli DRC and returns violations. Doesn't need KiCad open.",
        "related": ["kicad_lint_board", "kicad_open_board", "kicad_run_erc"], "pitfalls": ["needs KiCad >= 7 (kicad-cli); older installs lack it"]},
    "run_erc": {"summary": "Run ERC on a schematic via kicad-cli (read-only).", "long": False,
        "hint": "Absolute schematic path; runs kicad-cli ERC and returns violations.",
        "related": ["kicad_lint_schematic", "kicad_open_schematic", "kicad_run_drc"], "pitfalls": ["needs kicad-cli (KiCad >= 7)"]},
    "lint_board": {"summary": "Lint a .kicad_pcb (DRC pre-check) via kicad-cli.", "long": False,
        "hint": "Fast DRC pre-check on a board file; use before opening in the GUI.",
        "related": ["kicad_run_drc", "kicad_open_board"], "pitfalls": ["needs kicad-cli (KiCad >= 7)"]},
    "lint_schematic": {"summary": "Lint a .kicad_sch (ERC pre-check) via kicad-cli.", "long": False,
        "hint": "Fast ERC pre-check on a schematic file.",
        "related": ["kicad_run_erc", "kicad_open_schematic"], "pitfalls": ["needs kicad-cli (KiCad >= 7)"]},
    "lint_library": {"summary": "Lint a symbol/footprint library via kicad-cli.", "long": False,
        "hint": "Checks a library for structural issues before you ship it.",
        "related": ["kicad_install_library"], "pitfalls": ["needs kicad-cli (KiCad >= 7)"]},
    "format_upgrade": {"summary": "Upgrade a file to the current KiCad file format.", "long": False,
        "hint": "Rewrites an older .kicad_pcb/.kicad_sch to the installed KiCad's format.",
        "related": ["kicad_open_board", "kicad_open_schematic"], "pitfalls": ["it MODIFIES the file — back it up first; the change is not reversible in-place"]},
    "fix_keyboard": {"summary": "Reset stuck modifier keys on a KiCad window.", "long": False,
        "hint": "Clears stuck Ctrl/Shift/Alt state after send_key sequences misbehave.",
        "related": ["kicad_send_key"], "pitfalls": []},
    "screenshot_all": {"summary": "Screenshot every open KiCad window.", "long": False,
        "hint": "Captures all KiCad windows; the go-to way to VERIFY what actually happened after an open/edit.",
        "related": ["kicad_open_editors", "kicad_window_info", "kicad_open_board"], "pitfalls": ["a window still rendering (e.g. 3D viewer) shows blank — wait a beat first"]},
    "send_key": {"summary": "Send a keystroke to an EXACT KiCad window by hwnd (SendMessage; not for chords).", "long": False,
        "hint": "For modifier CHORDS ('ctrl+s', 'alt+3', 'ctrl+shift+p') use AD's desktop_press_key — it owns full chord parsing; don't route chords here. Use this verb only for the case AD's focus-first model can't cover: a single key delivered to an EXACT hwnd (e.g. Enter/Escape to a specific dialog from window_info).",
        "related": ["kicad_window_info", "kicad_click", "kicad_fix_keyboard"], "pitfalls": ["not for chords — use desktop_press_key", "needs a target hwnd (from window_info)"]},
    "click": {"summary": "Click at coordinates inside a KiCad window.", "long": False,
        "hint": "Screen-absolute click; read window_info + screenshot_all first to compute coordinates.",
        "related": ["kicad_window_info", "kicad_screenshot_all", "kicad_send_key"], "pitfalls": ["coordinates are screen-absolute and break if the window moved — re-read window_info"]},
    "adom_library_status": {"summary": "Report Adom library install state across KiCad versions.", "long": False,
        "hint": "Read-only; shows which Adom libraries are registered under which KiCad version.",
        "related": ["kicad_install_library", "kicad_list_versions"], "pitfalls": []},
    "list_versions": {"summary": "Enumerate every installed KiCad version with paths (discovery).", "long": False,
        "hint": "Read-only; lists installs + the default (newest). Run first when unsure which version to target.",
        "related": ["kicad_readiness", "kicad_check_for_updates", "kicad_upgrade"], "pitfalls": ["returns empty when KiCad isn't installed — offer kicad_upgrade, don't punt to a manual download"]},
    "readiness": {"summary": "Read-only host-app detection — is KiCad installed/ready?", "long": False,
        "hint": "Zero side effects; returns {detected, ready, versionCount, defaultVersion}. Poll before driving.",
        "related": ["kicad_list_versions", "kicad_upgrade", "kicad_describe"], "pitfalls": ["there is NO prewarm — KiCad is a host app you install via kicad_upgrade, not a download AD warms"]},
    "describe": {"summary": "This catalog — every verb the bridge exposes.", "long": False,
        "hint": "Read-only full verb catalog; the completeness escape hatch. Per-call _hints carry you in practice.",
        "related": ["kicad_readiness"], "pitfalls": []},
    "diagnostics": {"summary": "Read-only bridge self-inspection (env, detection breakdown, processes, path probe).", "long": False,
        "hint": "Use INSTEAD of AD's approval-gated run_script/shell. Shows the env the BRIDGE sees (stripped LOCALAPPDATA explains an undetected per-user install), every path the detector scans + what it finds, running KiCad exes, and (with probePaths/readFile args) any path's stat/listing/small-file content.",
        "related": ["kicad_readiness", "kicad_list_versions", "kicad_bridge_status"], "pitfalls": ["read-only — never installs or changes anything"]},
    "bridge_status": {"summary": "Probe running KiCad plugin instances (multi-instance health).", "long": False,
        "hint": "Read-only; lists live in-process plugin instances + their ports/health. Bounded, non-blocking.",
        "related": ["kicad_bridge_call", "kicad_open_editors", "kicad_install_plugin"], "pitfalls": ["an instance shows only after KiCad has run a kicad_* call that loaded the plugin"]},
    "status": {"summary": "Bridge/host-app status — the verb AD polls after a non-terminal timeout.", "long": False,
        "hint": "Canonical statusVerb (see bridge.json). Read-only; poll this after a long verb returns {stillRunning:true} to check whether it finished.",
        "related": ["kicad_readiness", "kicad_bridge_status"], "pitfalls": ["reports bridge + plugin liveness, not per-export progress"]},
    "bridge_call": {"summary": "Call into a running KiCad plugin instance (in-process IPC).", "long": False,
        "hint": "Low-level IPC into a live pcbnew/eeschema; use bridge_status first to find a live instance.",
        "related": ["kicad_bridge_status", "kicad_install_plugin"], "pitfalls": ["fails if no plugin instance is live — open a board first"]},
    "open_editors": {"summary": "List currently open KiCad editor windows.", "long": False,
        "hint": "Read-only; which editors are open right now. Use to reuse a window instead of opening a duplicate.",
        "related": ["kicad_window_info", "kicad_screenshot_all", "kicad_close"], "pitfalls": []},
    "export_gerber": {"summary": "Export Gerbers from a board via kicad-cli.", "long": False,
        "hint": "Absolute board path + output dir; writes Gerber + drill files (JLCPCB/InstaPCB ready).",
        "related": ["kicad_export_step", "kicad_export_bom_csv", "kicad_run_drc"], "pitfalls": ["run kicad_run_drc first — don't ship Gerbers from a board with DRC violations"]},
    "export_pdf": {"summary": "Export a PDF plot from a board/schematic.", "long": False,
        "hint": "Absolute path + output; plots the doc to PDF via kicad-cli.",
        "related": ["kicad_export_svg", "kicad_export_gerber"], "pitfalls": ["needs kicad-cli (KiCad >= 7)"]},
    "export_svg": {"summary": "Export an SVG plot from a board/schematic.", "long": False,
        "hint": "Absolute path + output; plots to SVG via kicad-cli.",
        "related": ["kicad_export_pdf", "kicad_export_gerber"], "pitfalls": ["needs kicad-cli (KiCad >= 7)"]},
    "export_step": {"summary": "Export a STEP 3D model from a board.", "long": False,
        "hint": "Absolute board path + output; exports a STEP assembly via kicad-cli.",
        "related": ["kicad_open_3d_viewer", "kicad_export_gerber"], "pitfalls": ["only components with 3D models attached appear in the STEP"]},
    "demo": {"summary": "Run the bridge's showcase tour: symbol -> footprint -> 3D part -> schematic -> 2D board -> 3D board.", "long": True,
        "hint": "The demo verb — STAGED + RESUMABLE, same contract as fusion_demo (stage/done/narrate/screenshots/steps). Speak narrate, show screenshots[{label,path}] (captured for you), call data.nextCall until done:true. Generates its own parts + project (ships no sample files), installs them into the user's real KiCad, and opens each view. Every step returns `say` (speak it), `pointOut` (what's on screen) and `window` (match in kicad_screenshot_all). Pass {\"step\":\"symbol\"} to pace it one beat at a time; no args runs all six.",
        "related": ["kicad_screenshot_all", "kicad_close", "kicad_upgrade"], "pitfalls": ["if KiCad isn't installed it returns an actionable error — call kicad_upgrade, don't send the user to a download page", "windows open in the BACKGROUND; screenshot them instead of stealing focus", "a bulk run opens all six windows at once — step-by-step narrates better"]},
    "export_molecule": {"summary": "One-shot molecule export pack: STEP + silk renders + sch/pro discovery for the Adom molecule pipeline.", "long": True,
        "hint": "Absolute board path; exports STEP (+ silkscreen top/bottom PNGs when kicad-cli pcb render exists), finds the sibling .kicad_sch/.kicad_pro, and returns data.nextSteps — the exact container commands (step2glb → adom-molecule create) to finish.",
        "related": ["kicad_export_step", "kicad_export_svg"], "pitfalls": ["adom-molecule requires glb+pcb+sch+pro — check data.missing before running the container half", "silk renders need KiCad 9+ (kicad-cli pcb render); older KiCads skip them gracefully"]},
    "export_bom_csv": {"summary": "Export a BOM CSV from a schematic.", "long": False,
        "hint": "Absolute schematic path + output; writes a BOM CSV via kicad-cli.",
        "related": ["kicad_open_schematic", "kicad_export_gerber"], "pitfalls": ["needs kicad-cli (KiCad >= 7)"]},
    "check_for_updates": {"summary": "Read-only: installed KiCad vs latest stable.", "long": False,
        "hint": "Compares the installed KiCad to the latest stable on downloads.kicad.org; flags pre-IPC installs.",
        "related": ["kicad_upgrade", "kicad_list_versions"], "pitfalls": ["read-only — it does NOT install; run kicad_upgrade to apply"]},
    "upgrade": {"summary": "Install/upgrade KiCad via the official installer (agent installs for the user).", "long": True,
        "hint": "Downloads the official downloads.kicad.org installer + silent /S installs; installs from scratch too. The agent does this FOR the user — never hand them a manual download.",
        "related": ["kicad_check_for_updates", "kicad_readiness", "kicad_list_versions"],
        "pitfalls": ["Windows only", "may raise a UAC prompt the user must approve", "blocks until the installer finishes (can take minutes)"]},
}


def _handle_describe(kicad_info: dict, args: dict) -> dict:
    """Bridge SDK <prefix>_describe — the full machine-readable verb catalog.

    Read-only, zero side effects. AD caches it (~/.adom/bridge-verbs/kicad.json)
    and renders it in the GUI Verbs tab (name/summary/hint/related/pitfalls per
    verb — discussion #14). In practice the per-call `_hint`s carry the AI;
    describe is the completeness escape hatch.
    """
    prefix = "kicad_"
    verbs = []
    for cmd in COMMAND_HANDLERS:
        if cmd == "export_gerbers":  # alias of export_gerber
            continue
        meta = _VERB_CATALOG.get(cmd, {"summary": cmd.replace("_", " "), "long": False,
                                       "hint": "", "related": [], "pitfalls": []})
        entry = {
            "name": prefix + cmd,
            "summary": meta["summary"],
            "hint": meta.get("hint", ""),
            "related": meta.get("related", []),
            "pitfalls": meta.get("pitfalls", []),
            "longRunning": bool(meta.get("long")),
        }
        if meta.get("long"):
            entry["statusVerb"] = prefix + "status"
        verbs.append(entry)
    verbs.sort(key=lambda v: v["name"])
    return {
        "success": True,
        "prefix": prefix,
        "verbCount": len(verbs),
        "verbs": verbs,
        "_hint": (
            "Full verb catalog for the KiCad bridge — each entry carries hint/related/pitfalls. "
            "Every verb ALSO returns its own rich _hint when called; that's what to read in practice. "
            "For host-app readiness use kicad_readiness; for 'what EDA tools do I have' across bridges "
            "use AD's bridge_readiness."
        ),
    }


def _detect_running_kicad_instances() -> list[dict]:
    """v1.7.4+ (KiCad v2 Phase 1): enumerate currently-running KiCad processes
    via PowerShell. Returns one entry per kicad.exe process with pid + window
    title. Best-effort — empty list on probe failure (PowerShell missing,
    timeout, parse error).

    Doesn't include HWND in v1 — Get-Process doesn't expose it cleanly and the
    Win32 EnumWindows path would need ctypes + window-class matching to
    correlate windows back to pids. Add HWND in a follow-up when a caller
    actually needs it (probably Phase 3's tier-2 WM_COMMAND path).
    """
    if not IS_WINDOWS:
        return []
    try:
        result = _safe_run(
            [
                "powershell", "-NoProfile", "-NonInteractive", "-Command",
                "Get-Process kicad -ErrorAction SilentlyContinue | "
                "Select-Object Id, MainWindowTitle, Path | "
                "ConvertTo-Json -Compress -AsArray",
            ],
            capture_output=True, text=True, timeout=5,
        )
        if result.returncode != 0 or not result.stdout.strip():
            return []
        import json
        data = json.loads(result.stdout)
        if not isinstance(data, list):
            data = [data]
        out = []
        for proc in data:
            if not isinstance(proc, dict):
                continue
            # Try to infer version from the install path; fall back to None.
            install_path = proc.get("Path") or ""
            version = None
            m = re.search(r"KiCad[\\/](\d+(?:\.\d+)*)[\\/]", install_path)
            if m:
                version = m.group(1)
            out.append({
                "pid": proc.get("Id"),
                "title": proc.get("MainWindowTitle") or "",
                "exePath": install_path or None,
                "version": version,
            })
        return out
    except (subprocess.TimeoutExpired, OSError, ValueError):
        return []


COMMAND_HANDLERS = {
    "open_board": handle_open_board,
    "open_schematic": handle_open_schematic,
    "open_symbol_editor": handle_open_symbol_editor,
    "open_footprint_editor": handle_open_footprint_editor,
    "open_3d_viewer": handle_open_3d_viewer,
    "close_symbol_editor": handle_close_symbol_editor,
    "close_footprint_editor": handle_close_footprint_editor,
    "close_3d_viewer": handle_close_3d_viewer,
    "close": handle_close_kicad,
    "window_info": handle_window_info,
    "dismiss_dialogs": handle_dismiss_dialogs,
    "enable_software_opengl": handle_enable_software_opengl,
    "install_library": handle_install_library,
    "install_symbol": handle_install_symbol,
    "install_footprint": handle_install_footprint,
    "place_footprint": handle_place_footprint,
    "run_drc": handle_run_drc,
    "fix_keyboard": handle_fix_keyboard,
    "screenshot_all": handle_screenshot_all,
    "send_key": handle_send_key,
    "click": handle_click,
    "adom_library_status": _handle_adom_library_status,
    "list_versions": _handle_list_versions,
    "readiness": _handle_readiness,
    "describe": _handle_describe,
    "diagnostics": handle_diagnostics,
    "bridge_status": handle_bridge_status,
    # kicad_status: the canonical statusVerb AD polls after a non-terminal timeout
    # (bridge.json `statusVerb` + detect.hostAppOptionalVerbs "status"). Alias of
    # bridge_status; kicad_bridge_status stays for back-compat.
    "status": handle_bridge_status,
    "bridge_call": handle_bridge_call,
    "open_editors": handle_open_editors,
    "install_plugin": plugin_install.handle_install_plugin,
    # v1.7.12+: headless lint pre-checks via kicad-cli
    "run_erc": handle_run_erc,
    "lint_board": handle_lint_board,
    "lint_schematic": handle_lint_schematic,
    # v1.7.13+: library lint + file-format upgrade
    "lint_library": handle_lint_library,
    "format_upgrade": handle_format_upgrade,
    # v0.7.1+ (BRIDGE_VERSION): export verbs via kicad-cli. Aliases included for
    # the older `gerbers` plural form callers used.
    "export_gerber": handle_export_gerber,
    "export_gerbers": handle_export_gerber,
    "export_pdf": handle_export_pdf,
    "export_svg": handle_export_svg,
    "export_step": handle_export_step,
    "export_molecule": handle_export_molecule,
    "demo": handle_demo,
    "export_bom_csv": handle_export_bom_csv,
    # v0.9.10+: KiCad version check + upgrade via the official downloads.kicad.org
    # installer (read-only check; silent /S install). Exposed as kicad_check_for_updates
    # / kicad_upgrade through Adom Desktop's `kicad_` verb prefix.
    "check_for_updates": handle_check_for_updates,
    "upgrade": handle_upgrade,
}


def _resolve_kicad_info_for_command(args: dict) -> tuple[dict, str | None]:
    """Pick the kicad_info to pass to a handler based on the optional kicadVersion
    arg. Returns (info, error_dict). If error_dict is None, info is good to use.
    """
    requested = args.get("kicadVersion")
    if not requested:
        return kicad_info, None  # default = newest installed

    # Look up the requested version among all_kicad_versions
    available = [v["version"] for v in all_kicad_versions]
    for v in all_kicad_versions:
        if v["version"] == requested or v["version"].startswith(f"{requested}."):
            return v, None

    return None, {
        "success": False,
        "error": f"KiCad {requested} is not installed. Available: {', '.join(available) or '(none)'}.",
        "errorCode": "kicad_version_not_installed",
        "available_versions": available,
        "default_version": kicad_info.get("version") if kicad_info else None,
        "_hint": (
            f"Pass kicadVersion as one of: {', '.join(available) or '(none)'}, "
            f"or omit it to use the newest ({kicad_info.get('version') if kicad_info else 'none'}). "
            f"Run kicad_list_versions to see installed versions with their paths."
        ),
    }


def dispatch_command(command: str, args: dict) -> dict:
    # A successful `upgrade` reassigns these to refresh detection post-install.
    global all_kicad_versions, kicad_info
    handler = COMMAND_HANDLERS.get(command)
    # Be tolerant of how the host routes verbs: most strip the bridge's verb
    # prefix and send the bare command ("list_versions"), but some forward the
    # full prefixed verb ("kicad_list_versions" / "adomkicad_list_versions").
    # If the bare command isn't found, strip the leading "<prefix>_" once and
    # retry — so the bridge works no matter which name/prefix it's installed
    # under and no matter whether the host strips.
    if handler is None and "_" in command:
        tail = command.split("_", 1)[1]
        if tail in COMMAND_HANDLERS:
            command, handler = tail, COMMAND_HANDLERS[tail]
    if handler is None:
        return {
            "success": False,
            "error": f"Unknown command: {command}",
            "_hint": f"Valid KiCad commands: {', '.join(sorted(COMMAND_HANDLERS.keys()))}.",
        }

    # Verbs that MUST run even when KiCad is absent — return BEFORE the plugin
    # auto-install and the kicad-not-installed pre-check:
    #   readiness / describe   — pure read-only (detection probe + verb catalog)
    #   check_for_updates      — reports installed-vs-latest; valid answer is "none"
    #   diagnostics            — self-inspection; MOST needed when KiCad is undetected
    # None of these need the reverse-bridge plugin (there may be no KiCad to load
    # it into), so they also skip ensure_plugin_installed_once.
    if command in ("readiness", "describe", "check_for_updates", "diagnostics"):
        return handler(kicad_info, args)

    # upgrade INSTALLS KiCad from scratch — the whole point is it runs when KiCad
    # ISN'T there yet, so it too must skip the plugin auto-install + the
    # kicad-not-installed pre-check. (Bug fix: the pre-check below was blocking the
    # very verb that installs KiCad, so a fresh machine could never bootstrap it.)
    # On success, refresh the startup detection cache so subsequent verbs
    # (open_*, list_versions, readiness) see the newly-installed KiCad WITHOUT a
    # bridge restart (handle_upgrade only re-scanned for its own return value).
    if command == "upgrade":
        result = handler(kicad_info, args)
        if result.get("success") or result.get("upgraded"):
            all_kicad_versions = detect_all_kicad_versions()
            if all_kicad_versions:
                kicad_info = all_kicad_versions[0]
                # Seed the just-installed KiCad's config so the FIRST GUI open
                # doesn't hit the "Welcome to KiCad" first-run wizard (which blocks
                # automation). Idempotent: only writes files that don't exist.
                try:
                    import kicad_detect
                    kicad_detect.ensure_win_user_config(
                        kicad_info.get("version") or "",
                        kicad_info.get("base_dir"),
                    )
                except Exception:  # pylint: disable=broad-except
                    pass
        return result

    # Auto-install the Phase 2 reverse-bridge plugin on first call per
    # bridge boot. Idempotent — subsequent calls return the cached result
    # at near-zero cost. The pluginAutoInstall report is attached to the
    # FIRST response so the calling AI sees what happened (or what
    # failed). bridge_status / bridge_call always get the current state
    # attached via plugin_install.force_attach() inside this dispatcher.
    plugin_install.ensure_plugin_installed_once(all_kicad_versions)

    # list_versions doesn't need an installed KiCad — discovery verb.
    # Still gets the pluginAutoInstall attached via the normal once-per-
    # boot path (not force_attach), so the AI's very first kicad_* call
    # gets the install report, but a repeat list_versions doesn't.
    if command == "list_versions":
        result = handler(kicad_info, args)
        plugin_install.attach_to_response_if_first(result)
        return result

    # bridge_status / bridge_call / open_editors / install_plugin are
    # explicit diagnostic verbs that ALWAYS want to see the install
    # state — they exist for the AI to introspect plugin health.
    if command in ("bridge_status", "bridge_call", "open_editors", "install_plugin"):
        result = handler(kicad_info, args)
        plugin_install.force_attach(result)
        return result

    # Pre-check: if KiCad isn't installed at all, return a helpful error. KiCad
    # is the user's host app — AD won't silently auto-install it (v1.9.47) — but
    # the agent installs it FOR the user via kicad_upgrade rather than handing
    # them a manual download.
    #
    # SELF-HEAL: the startup detection cache goes STALE if KiCad was installed
    # AFTER the bridge booted (kicad_upgrade whose client call timed out, or an
    # external install) — every verb then wrongly reports "not installed" until a
    # manual bridge restart. So before trusting an empty cache, do ONE live
    # re-detect; if it now finds KiCad, refresh the cache and proceed. This makes
    # detection reliable without a respawn. (ADOMBASELINE 2026-07.)
    if not kicad_info or not kicad_info.get("installed"):
        try:
            fresh = detect_all_kicad_versions()
        except Exception:
            fresh = []
        if fresh:
            all_kicad_versions = fresh
            kicad_info = fresh[0]
        else:
            return {
                "success": False,
                "error": "KiCad is not installed.",
                "errorCode": "host_app_not_installed",
                "errorCodeLegacy": "kicad_not_installed",
                "hostApp": "KiCad",
                "_hint": "Don't make the user install it by hand. Offer to do it for them, then run "
                         "`kicad_upgrade '{}'` — the agent installs the latest stable from the official "
                         "downloads.kicad.org installer (silent /S, Windows only; may raise a UAC prompt), "
                         "then retry this command. kicad_upgrade installs from scratch, not just upgrades.",
            }

    # Resolve the version to use for this specific call (may differ from default)
    selected_info, err = _resolve_kicad_info_for_command(args)
    if err is not None:
        return err

    # Verb classes (defined before use — the capture below references the last one):
    #  _NO_GUI: headless kicad-cli verbs (no window) — skip the dialog sweep.
    #  _SPAWNS_WINDOW: verbs that may surface a dialog — get the ~3s settle-poll.
    #  _SPAWNS_WINDOW_FOCUS: verbs that RAISE a KiCad window — restore user focus.
    _NO_GUI = {"run_drc", "run_erc", "lint_board", "lint_schematic", "lint_library",
               "format_upgrade", "export_gerber", "export_gerbers", "export_pdf",
               "export_svg", "export_step", "export_bom_csv", "adom_library_status",
               "list_versions", "install_library"}
    # Only verbs that actually SPAWN a window get the ~3s settle-poll (they can
    # trigger an async dialog). Read-only reporters (open_editors, screenshot_all,
    # window_info) don't spawn windows → one cheap scan, no needless 3s poll.
    _SPAWNS_WINDOW = {"open_board", "open_schematic", "open_symbol_editor",
                      "open_footprint_editor", "open_3d_viewer",
                      "place_footprint", "install_symbol", "install_footprint",
                      "install_plugin", "upgrade"}
    _SPAWNS_WINDOW_FOCUS = {"open_board", "open_schematic", "open_symbol_editor",
                            "open_footprint_editor", "open_3d_viewer",
                            "place_footprint", "install_symbol", "install_footprint"}

    # BACKGROUND-OPEN RULE: capture the USER's foreground window BEFORE any verb
    # that spawns/raises a KiCad window, so we can hand focus back afterward. The
    # bridge must NEVER interrupt the user's work — KiCad opens behind whatever
    # they're doing. (win_focus.restore_foreground uses AttachThreadInput so the
    # cross-process restore actually sticks.) See the kicad-bridge-dev skill.
    # Capture before ANY GUI verb (opens, clicks, key sends, dialog sweeps — all can
    # steal focus), not just window-raisers. The headless kicad-cli verbs (_NO_GUI)
    # touch no window, so skip them.
    _user_fg = None
    try:
        from handlers import win_focus
        if command not in _NO_GUI:
            _user_fg = win_focus.capture_foreground()
    except Exception:
        _user_fg = None

    result = handler(selected_info, args)
    # Stamp the version actually used so callers can see which install ran
    if isinstance(result, dict):
        result.setdefault("kicadVersionUsed", selected_info.get("version"))
    # CONSTANT DIALOG COVERAGE: after EVERY GUI-affecting verb, sweep for KiCad
    # dialogs (KiCad throws modal error boxes constantly — "Cannot enumerate
    # ...Templates (Access denied)", save prompts, format warnings — that
    # otherwise silently block the NEXT verb and make an open/screenshot report a
    # misleading "did not open"). auto_sweep_dialogs screenshots each, reads its
    # body, expires it, and we attach the report so the AI ALWAYS sees what KiCad
    # popped. Skip the pure-CLI/no-GUI verbs (exports/lint/DRC run kicad-cli
    # headless — no window) to avoid needless EnumWindows churn; everything that
    # can spawn a window gets swept. The scan itself is cheap when nothing is up.
    if isinstance(result, dict) and command not in _NO_GUI:
        try:
            from handlers.close_windows import auto_sweep_dialogs
            _rounds = 5 if command in _SPAWNS_WINDOW else 1
            swept = auto_sweep_dialogs(selected_info, screenshot=True, settle_rounds=_rounds)
            if swept:
                result["_autoDialogs"] = swept
                if swept.get("hadError"):
                    titles = ", ".join(f'"{d["title"]}"' for d in swept["dialogs"] if not d["benign"])
                    result["_dialogHint"] = (
                        f"⚠ KiCad popped {swept['count']} dialog(s) during this verb "
                        f"(non-benign: {titles}). Each was screenshotted (see "
                        "_autoDialogs[].screenshot + .body) and auto-expired so you're "
                        "unblocked. If your verb 'failed'/'did not open', this dialog "
                        "is almost certainly why — read the body and act on it."
                    )
        except Exception:
            pass

    # BACKGROUND-OPEN RULE (part 2): hand focus back to the user's window that we
    # captured before the verb ran. KiCad raised + activated its own new window;
    # restoring the user's window here means the editor opened BEHIND their work —
    # it never stole their focus/keyboard. Done LAST, after the dialog sweep (which
    # may itself have surfaced windows). Reports whether it stuck.
    if _user_fg is not None:
        try:
            from handlers import win_focus
            restored = win_focus.restore_foreground(_user_fg)
            # A launched editor activates its window ASYNC — a beat after we return.
            # For window-spawning verbs, also keep the user on top for a few seconds
            # (background daemon thread — doesn't delay this response) so KiCad's late
            # self-activation gets pushed back down.
            if command in _SPAWNS_WINDOW:
                # The 3D viewer is an IN-PROCESS child window pcbnew creates and
                # then RAISES repeatedly while it renders the board (several
                # seconds) — STARTUPINFO can't touch it and a 6s restore expires
                # mid-render, so it needs a longer guard. Plain launched editors
                # only self-activate once, right after launch, so 6s covers them.
                _restore_secs = 16.0 if command in ("open_3d_viewer", "place_footprint") else 6.0
                win_focus.restore_foreground_persistent(_user_fg, seconds=_restore_secs)
            if isinstance(result, dict):
                result["_userFocusRestored"] = bool(restored)
        except Exception:
            pass

    # Attach plugin auto-install report ONCE per bridge boot (on the
    # first kicad_* command's response) so the AI knows whether the
    # Phase 2 reverse-bridge plugin was deployed / updated / already
    # current. Subsequent commands skip this to keep payloads small.
    plugin_install.attach_to_response_if_first(result)
    return result


class KiCadBridgeHandler(BaseHTTPRequestHandler):
    """HTTP request handler for the KiCad bridge server."""

    def do_GET(self):
        # /status is the manifest's declared healthEndpoint (bridge.json
        # spawn.healthEndpoint == "/status"). Through v0.8.9 the server only
        # answered /health, so adom-desktop's health probe got a 404, never
        # marked the bridge healthy, and every kicad_* verb blocked the full
        # 30s and timed out (bridgeRunning:false). We now answer BOTH paths
        # with 200 so the bridge actually comes up. /status additionally
        # carries the capability map (KiCad versions + which automations this
        # install supports) so callers can reason about upgrades.
        if self.path in ("/status", "/health"):
            self._respond(200, self._status_payload())
        else:
            self._respond(404, {"error": "Not found"})

    def _status_payload(self) -> dict:
        # Status chip (Bridge SDK "Health + status chip"): a 200 already makes the
        # chip green; we additionally report led/summary/tooltip truthfully so the
        # chip reflects host-app readiness (AD owns the gray "offline" state).
        detected = bool(all_kicad_versions)
        default_v = all_kicad_versions[0]["version"] if detected else None
        if detected:
            led = "green"
            summary = f"KiCad {default_v} ready"
            tooltip = (
                f"KiCad bridge up — {len(all_kicad_versions)} version(s) detected "
                f"(default {default_v})."
            )
        else:
            led = "yellow"
            summary = "KiCad not detected"
            tooltip = (
                "KiCad bridge is up, but no KiCad install was detected. "
                "Run kicad_upgrade to install it (the agent installs it for the user)."
            )
        return {
            "status": "ok",
            "led": led,
            "summary": summary,
            "tooltip": tooltip,
            "bridgeVersion": BRIDGE_VERSION,
            "kicad": kicad_info,
            "all_versions": [
                {"version": v["version"], "base_dir": v["base_dir"]}
                for v in all_kicad_versions
            ],
            "capabilities": build_capability_report(all_kicad_versions),
        }

    def do_POST(self):
        if self.path != "/command":
            self._respond(404, {"error": "Not found"})
            return

        content_length = int(self.headers.get("Content-Length", 0))
        body = self.rfile.read(content_length)

        try:
            request = json.loads(body)
        except json.JSONDecodeError as e:
            self._respond(400, {"success": False, "error": f"Invalid JSON: {e}"})
            return

        command = request.get("command", "")
        args = request.get("args", {})

        print(f"[KiCad Bridge] Command: {command} | Args: {json.dumps(args)}")

        try:
            result = dispatch_command(command, args)
            self._respond(200, result)
        except Exception as e:
            print(f"[KiCad Bridge] ERROR: {e}")
            traceback.print_exc()
            self._respond(500, {
                "success": False,
                "error": f"Internal error: {e}",
            })

    def _respond(self, status: int, data: dict):
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(json.dumps(data).encode("utf-8"))

    def log_message(self, format, *args):
        print(f"[KiCad Bridge] {args[0]} {args[1]} {args[2]}")


def main():
    global kicad_info, all_kicad_versions

    # Line-buffer stdout/stderr. When a supervisor (Adom Desktop) redirects our
    # output to a log FILE, Python block-buffers it by default — so a startup
    # hang shows a 0-byte log and is undebuggable. Line buffering makes every
    # startup step land in ~/.adom/bridge-logs/<name>.log in real time, which is
    # exactly what AD asked bridge authors to do (fatal errors → stdout/stderr).
    try:
        sys.stdout.reconfigure(line_buffering=True)
        sys.stderr.reconfigure(line_buffering=True)
    except Exception:
        pass

    port = DEFAULT_PORT
    if "--port" in sys.argv:
        idx = sys.argv.index("--port")
        if idx + 1 < len(sys.argv):
            port = int(sys.argv[idx + 1])

    print(f"[KiCad Bridge] starting v{BRIDGE_VERSION} on port {port} (pid {os.getpid()})", flush=True)

    # ── SINGLE-INSTANCE GUARD ────────────────────────────────────────────────
    # Bind the port FIRST, before the (slow ~9s) KiCad detection. A supervisor
    # that lazy-spawns the bridge on every verb call can otherwise fire several
    # instances near-simultaneously; if they all run detection before binding,
    # they contend (KiCad config/IPC probes) and HANG, none ever binds, and the
    # bridge never reports healthy — a spawn storm. Binding first makes exactly
    # ONE spawn win the port; every duplicate fails the bind and exits cleanly
    # (exit 0) instead of piling up. allow_reuse_address is forced False so the
    # second bind genuinely fails on Windows (SO_REUSEADDR would otherwise let
    # duplicates co-bind). This keeps the bridge robust no matter how aggressive
    # the host's spawn logic is.
    # ThreadingHTTPServer: handle each request in its own thread so a slow or
    # wedged handler (e.g. first-call plugin auto-install) never blocks the
    # health probe or other verbs — the single-threaded server otherwise serves
    # one request at a time, so AD's first verb could wedge the whole bridge and
    # make it look hung. daemon_threads so a stuck handler can't block shutdown.
    class _SingleInstanceHTTPServer(ThreadingHTTPServer):
        allow_reuse_address = False
        daemon_threads = True
    # Bind loopback via ADOM_BIND_HOST (Bridge SDK Runtime contract, AD v1.9.63+):
    # AD passes ADOM_BIND_HOST (always 127.0.0.1) to every spawned bridge. Binding
    # it (never 0.0.0.0) avoids the Windows Firewall "allow access?" prompt AD
    # guarantees users won't see. Default to 127.0.0.1 when spawned outside AD.
    bind_host = os.environ.get("ADOM_BIND_HOST", "127.0.0.1")
    try:
        server = _SingleInstanceHTTPServer((bind_host, port), KiCadBridgeHandler)
    except OSError as e:
        print(f"[KiCad Bridge] Port {port} already in use ({e}). "
              f"Another bridge instance owns it — exiting cleanly (single-instance guard).")
        return

    print("[KiCad Bridge] bound port; detecting KiCad installs...", flush=True)
    all_kicad_versions = detect_all_kicad_versions()
    kicad_info = all_kicad_versions[0] if all_kicad_versions else detect_kicad()
    print(f"[KiCad Bridge] detection done ({len(all_kicad_versions)} version(s))", flush=True)

    print(f"[KiCad Bridge] KiCad detection result:")
    print(f"  Versions found: {len(all_kicad_versions)}")
    for v in all_kicad_versions:
        marker = " (default)" if v["version"] == kicad_info.get("version") else ""
        print(f"    - {v['version']}{marker} -> {v['base_dir']}")
    if not all_kicad_versions:
        print(f"  WARNING: KiCad not found. Commands will fail until KiCad is installed.")
    else:
        print(f"  Default:   {kicad_info.get('version')}")
        print(f"  Config:    {kicad_info.get('config_dir')}")
        print(f"  User dir:  {kicad_info.get('user_dir')}")
        print(f"  Pass kicadVersion={{\"9.0\"|\"10.0\"|...}} to any open_* command to override.")

    print(f"[KiCad Bridge] Available commands: {', '.join(COMMAND_HANDLERS.keys())}")

    # Ensure the global Adom symbol library exists and is registered
    if kicad_info.get("installed"):
        print("[KiCad Bridge] ensuring Adom library...", flush=True)
        lib_result = ensure_adom_library(kicad_info)
        if lib_result.get("created"):
            print(f"[KiCad Bridge] Created Adom symbol library (Adom.kicad_sym)")
        if not lib_result.get("registered"):
            print(f"[KiCad Bridge] Registered Adom library in sym-lib-table")
        symbols = lib_result.get("symbols", [])
        print(f"[KiCad Bridge] Adom library: {len(symbols)} symbol(s) — {', '.join(symbols) if symbols else '(empty)'}")

    # server already bound above (single-instance guard); now serve.
    print(f"[KiCad Bridge] Listening on http://{bind_host}:{port} — serving now", flush=True)
    print(f"[KiCad Bridge] Health check: http://{bind_host}:{port}/status (and /health)")
    print(f"[KiCad Bridge] Press Ctrl+C to stop.")

    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\n[KiCad Bridge] Shutting down.")
        server.server_close()


if __name__ == "__main__":
    main()