"""adom_bridge.py - Phase 2 reverse bridge for adom-desktop's KiCad v2.

Runs INSIDE every KiCad GUI process (kicad.exe, pcbnew.exe, eeschema.exe,
kicad-cli.exe) thanks to the usercustomize.py loader at KiCad USER_SITE.

What this does
==============
1. Starts a tiny HTTP server on 127.0.0.1:<port> where port is
   8870 + exe_offset (kicad=0, pcbnew=1, eeschema=2, kicad-cli=3).
2. Writes a discovery JSON to %TEMP%\\adom-kicad-bridge-<exe>.json so
   adom-desktop's Python kicad-bridge can find which port to talk to
   for each KiCad process.
3. Accepts JSON-RPC-style requests over POST /rpc and dispatches
   them onto KiCad's main UI thread via `wx.CallAfter`. Results return
   in the HTTP response body.

Why HTTP instead of WebSocket
=============================
KiCad 10's bundled Python doesn't ship `websockets`, `aiohttp`, or
`tornado`. Plain `http.server` is in stdlib though. For MVP we don't
need server-push - every command is request-response. If a future
phase needs streaming (e.g. DRC progress events), we can add Server-
Sent Events on a side endpoint, still stdlib-only.

MVP methods (this iteration):
  * ping             - returns version + pid + exe + uptime_ms.
  * list_frames      - returns wx.GetTopLevelWindows() summary
                       (frame class, title, hwnd, menubarItemCount).
  * get_menu_ids     - returns the full menu walk for every frame.
  * wm_command       - posts a wx.EVT_MENU to a target frame so menu
                       commands fire on the UI thread.
  * shutdown         - stops accepting connections (does NOT kill KiCad).

Threading model
===============
The HTTP server runs on a dedicated daemon thread. Each incoming
request is handled in its own thread (`ThreadingHTTPServer`). When a
request needs wx state, it submits a callable to wx's main thread via
`wx.CallAfter` and waits on a `threading.Event` for the result.

Safety
======
* Listens on 127.0.0.1 only - never bound to a public interface.
* Honors ADOM_KICAD_BRIDGE_DISABLE=1 env var as kill-switch.
* Wraps EVERYTHING in try/except - a bridge failure cannot crash KiCad.
* Daemon thread - dies with the host process, no leaked sockets.
"""

from __future__ import annotations

import json
import os
import socket
import sys
import threading
import time
import traceback
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any, Callable, Optional

__version__ = "0.8.0"

# Exe-name -> port offset. The KiCad bridge in adom-desktop knows this
# table too. New exes get appended; never renumber existing entries or
# the kicad-bridge's per-exe routing breaks.
_EXE_PORT_OFFSET = {
    "kicad": 0,
    "pcbnew": 1,
    "eeschema": 2,
    "kicad-cli": 3,
    "python": 9,  # bundled python.exe (rare, for headless tests)
}
_BASE_PORT = 8870

_OUT_DIR = Path(os.environ.get("TEMP") or os.environ.get("TMP") or "/tmp")
_STARTED_AT_MS = int(time.time() * 1000)

# Module-level mutable state. The HTTP handler reads these.
_state: dict[str, Any] = {
    "exe_name": (Path(sys.executable).stem or "unknown").lower(),
    "pid": os.getpid(),
    "port": None,
    "running": False,
    "request_count": 0,
}


# --- discovery breadcrumb ---------------------------------------------------

def _discovery_path() -> Path:
    """Per-PID discovery file (Phase 2.x).

    Naming: adom-kicad-bridge-<exe>-<pid>.json so multiple instances of
    the same exe can run side-by-side (e.g. two pcbnew windows on two
    different boards) without overwriting each other. The kicad-bridge
    enumerates all matching files and picks the right plugin for the
    caller. v1.7.5 used <exe>.json which lost multi-instance support."""
    return _OUT_DIR / f"adom-kicad-bridge-{_state['exe_name']}-{_state['pid']}.json"


def _write_discovery():
    try:
        _discovery_path().write_text(json.dumps({
            "version": __version__,
            "exeName": _state["exe_name"],
            "pid": _state["pid"],
            "port": _state["port"],
            "startedAt": datetime.utcfromtimestamp(_STARTED_AT_MS / 1000).isoformat() + "Z",
            "host": "127.0.0.1",
            "endpoint": f"http://127.0.0.1:{_state['port']}/rpc",
            "transport": "http",
        }, indent=2), encoding="utf-8")
    except OSError:
        pass


def _remove_discovery():
    try:
        p = _discovery_path()
        if p.exists():
            p.unlink()
        # Also clean any v1.7.5-style exe-only path this process may have
        # written (the legacy bridge_client may write to both paths during
        # transition). Belt-and-suspenders cleanup.
        legacy = _OUT_DIR / f"adom-kicad-bridge-{_state['exe_name']}.json"
        if legacy.exists():
            try:
                payload = json.loads(legacy.read_text(encoding="utf-8"))
                if payload.get("pid") == _state["pid"]:
                    legacy.unlink()
            except (OSError, ValueError):
                pass
    except OSError:
        pass


# --- wx marshalling helpers -------------------------------------------------

def _call_on_ui_thread(fn: Callable[[], Any], timeout: float = 5.0) -> Any:
    """Run `fn` on wx's UI thread; return its result. Re-raises exceptions.

    The HTTP handler runs on a worker thread. wx state can only be
    touched from the main thread that owns the wx.App. wx.CallAfter
    posts onto that thread's idle queue. We hand-roll a result-shuttle
    using a threading.Event so the worker can wait for the UI return.
    """
    import wx  # type: ignore[import-not-found]

    holder: dict[str, Any] = {"ok": False, "result": None, "err": None}
    event = threading.Event()

    def _runner():
        try:
            holder["result"] = fn()
            holder["ok"] = True
        except Exception as e:  # pylint: disable=broad-except
            holder["err"] = (type(e).__name__, str(e), traceback.format_exc())
        finally:
            event.set()

    wx.CallAfter(_runner)
    if not event.wait(timeout):
        raise TimeoutError(f"wx UI thread did not respond within {timeout:.1f}s")
    if not holder["ok"]:
        kind, msg, tb = holder["err"]
        raise RuntimeError(f"UI-thread error: {kind}: {msg}\n{tb}")
    return holder["result"]


# --- frame + menu introspection (run on UI thread) -------------------------

def _ui_list_frames() -> list[dict]:
    """Return wx.GetTopLevelWindows() summary. Must run on UI thread."""
    import wx  # type: ignore[import-not-found]
    out = []
    for win in wx.GetTopLevelWindows():
        try:
            mb = win.GetMenuBar() if hasattr(win, "GetMenuBar") else None
            mb_count = 0
            if mb is not None:
                try:
                    mb_count = sum(
                        mb.GetMenu(i).GetMenuItemCount()
                        for i in range(mb.GetMenuCount())
                    )
                except Exception:  # pylint: disable=broad-except
                    mb_count = -1
            out.append({
                "frameClass": type(win).__name__,
                "frameTitle": win.GetTitle() if hasattr(win, "GetTitle") else "",
                "hwnd": int(win.GetHandle()) if hasattr(win, "GetHandle") else None,
                "menubarTopCount": mb.GetMenuCount() if mb is not None else 0,
                "menubarTotalItems": mb_count,
                "isShown": bool(win.IsShown()) if hasattr(win, "IsShown") else None,
            })
        except Exception as e:  # pylint: disable=broad-except
            out.append({"_frameError": f"{type(e).__name__}: {e}"})
    return out


def _walk_menu(menu, top_label: str, out: list[dict]):
    for item in menu.GetMenuItems():
        try:
            out.append({
                "topMenu": top_label,
                "label": item.GetItemLabelText(),
                "id": item.GetId(),
                "kind": str(item.GetKind()),
                "accelerator": item.GetItemLabel(),
                "helpString": item.GetHelp() or "",
            })
            if item.IsSubMenu():
                sub = item.GetSubMenu()
                if sub is not None:
                    _walk_menu(sub, f"{top_label} > {item.GetItemLabelText()}", out)
        except Exception as e:  # pylint: disable=broad-except
            out.append({"topMenu": top_label, "_itemError": f"{type(e).__name__}: {e}"})


def _ui_get_menu_ids() -> list[dict]:
    import wx  # type: ignore[import-not-found]
    out = []
    for win in wx.GetTopLevelWindows():
        try:
            mb = win.GetMenuBar() if hasattr(win, "GetMenuBar") else None
            menus: list[dict] = []
            if mb is not None:
                for i in range(mb.GetMenuCount()):
                    _walk_menu(mb.GetMenu(i), mb.GetMenuLabel(i), menus)
            out.append({
                "frameClass": type(win).__name__,
                "frameTitle": win.GetTitle() if hasattr(win, "GetTitle") else "",
                "menuItems": menus,
            })
        except Exception as e:  # pylint: disable=broad-except
            out.append({"_frameError": f"{type(e).__name__}: {e}"})
    return out


_BOARD_TITLE_NEEDLES = ("pcb editor", "footprint editor")
_SCHEMATIC_TITLE_NEEDLES = ("schematic editor", "symbol editor")


def _ui_get_board_info() -> dict:
    """Return PCB board statistics. Only meaningful inside pcbnew.exe
    (the only KiCad GUI exe that exposes a usable `pcbnew` Python
    module). Returns hasBoard:False if called from a different process
    or if pcbnew has no board loaded.
    """
    try:
        import pcbnew  # type: ignore[import-not-found]
    except ImportError:
        return {"hasBoard": False, "_error": "pcbnew not importable (wrong process — must be inside pcbnew.exe)"}
    try:
        board = pcbnew.GetBoard()
    except Exception as e:  # pylint: disable=broad-except
        return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}
    if board is None:
        return {"hasBoard": False}
    try:
        bb = board.GetBoundingBox()
        return {
            "hasBoard": True,
            "fileName": board.GetFileName(),
            "trackCount": len(board.GetTracks()),
            "footprintCount": len(board.GetFootprints()),
            "drawingsCount": len(board.GetDrawings()),
            "netCount": board.GetNetCount(),
            "copperLayerCount": board.GetCopperLayerCount(),
            "designSettingsLayerCount": board.GetCopperLayerCount(),
            "boundingBoxMm": {
                "widthMm": bb.GetWidth() / 1_000_000,
                "heightMm": bb.GetHeight() / 1_000_000,
                "centerXMm": bb.GetCenter().x / 1_000_000,
                "centerYMm": bb.GetCenter().y / 1_000_000,
            },
        }
    except Exception as e:  # pylint: disable=broad-except
        return {"hasBoard": True, "_error": f"{type(e).__name__}: {e}"}


def _ui_get_schematic_info() -> dict:
    """Return schematic info — eeschema doesn't expose its sheet/symbol
    state as a Python module like pcbnew does, so this is a best-effort
    introspection via wx frame walk.

    Returns the schematic editor's wx frame title (which encodes the
    filename + dirty state, e.g. "design.kicad_sch — Schematic Editor"
    or "untitled [Unsaved] — Schematic Editor"). Phase 2.y will replace
    this with a real eeschema Python binding once KiCad 11 ships its
    IPC API (`kipy`).
    """
    try:
        import wx  # type: ignore[import-not-found]
    except ImportError:
        return {"available": False, "_error": "wx not loaded — must be inside eeschema.exe"}
    try:
        frames = []
        for win in wx.GetTopLevelWindows():
            title = ""
            if hasattr(win, "GetTitle"):
                try:
                    title = win.GetTitle()
                except Exception:  # pylint: disable=broad-except
                    pass
            tl = title.lower()
            if any(n in tl for n in _SCHEMATIC_TITLE_NEEDLES):
                # Parse filename from title. Format examples:
                #   "untitled [Unsaved] — Schematic Editor"
                #   "design [Unsaved] — Schematic Editor"
                #   "C:\\path\\design.kicad_sch — Schematic Editor"
                #   "design.kicad_sch [Unsaved] — Schematic Editor"
                hwnd = int(win.GetHandle()) if hasattr(win, "GetHandle") else None
                fr = {
                    "frameClass": type(win).__name__,
                    "frameTitle": title,
                    "hwnd": hwnd,
                    "isShown": bool(win.IsShown()) if hasattr(win, "IsShown") else None,
                }
                # Decode the title shape
                parts = title.rsplit("—", 1)
                if len(parts) == 2:
                    file_part = parts[0].strip()
                    editor_part = parts[1].strip()
                    fr["editorKind"] = editor_part  # "Schematic Editor" / "Symbol Editor"
                    # Strip trailing " [Unsaved]" if present
                    dirty = "[Unsaved]" in file_part
                    file_part = file_part.replace("[Unsaved]", "").strip()
                    fr["fileName"] = file_part or None
                    fr["isUnsaved"] = dirty
                    fr["isUntitled"] = file_part.lower() in ("untitled", "")
                frames.append(fr)
        return {
            "available": True,
            "frames": frames,
            "frameCount": len(frames),
        }
    except Exception as e:  # pylint: disable=broad-except
        return {"available": False, "_error": f"{type(e).__name__}: {e}"}


def _ui_get_open_editors() -> dict:
    """Cross-process best-effort: list every wx top-level window in THIS
    process and classify it.

    Each bridge process only sees its own frames. The kicad-bridge
    (server.py) calls this on every plugin instance to build a
    cross-process picture — see `handle_open_editors` in bridge_client.py.
    """
    try:
        import wx  # type: ignore[import-not-found]
    except ImportError:
        return {"available": False, "_error": "wx not loaded"}
    try:
        editors: list[dict] = []
        for win in wx.GetTopLevelWindows():
            title = ""
            if hasattr(win, "GetTitle"):
                try:
                    title = win.GetTitle()
                except Exception:  # pylint: disable=broad-except
                    pass
            cls = type(win).__name__
            hwnd = int(win.GetHandle()) if hasattr(win, "GetHandle") else None
            tl = title.lower()
            kind = "unknown"
            if "schematic editor" in tl:
                kind = "schematic_editor"
            elif "symbol editor" in tl:
                kind = "symbol_editor"
            elif "pcb editor" in tl:
                kind = "pcb_editor"
            elif "footprint editor" in tl:
                kind = "footprint_editor"
            elif "kicad" in tl and "editor" not in tl:
                kind = "project_manager"
            editors.append({
                "kind": kind,
                "frameClass": cls,
                "frameTitle": title,
                "hwnd": hwnd,
                "isShown": bool(win.IsShown()) if hasattr(win, "IsShown") else None,
            })
        return {
            "available": True,
            "exeName": _state["exe_name"],
            "pid": _state["pid"],
            "editors": editors,
            "editorCount": len(editors),
        }
    except Exception as e:  # pylint: disable=broad-except
        return {"available": False, "_error": f"{type(e).__name__}: {e}"}


def _ui_get_pcb_footprints() -> dict:
    """Return every footprint on the board with position, rotation, layer,
    reference, value, and footprint library ID. Only meaningful inside
    pcbnew.exe with a board loaded.

    Coordinates are in millimeters (KiCad internally uses nm; we divide
    by 1_000_000). Rotation is in degrees.
    """
    try:
        import pcbnew  # type: ignore[import-not-found]
    except ImportError:
        return {"hasBoard": False, "_error": "pcbnew not importable (wrong process)"}
    try:
        board = pcbnew.GetBoard()
    except Exception as e:  # pylint: disable=broad-except
        return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}
    if board is None:
        return {"hasBoard": False}
    try:
        footprints = []
        for fp in board.GetFootprints():
            try:
                pos = fp.GetPosition()
                fpid = fp.GetFPID()
                # FPID is "library:name" — split for clarity
                lib_name = ""
                item_name = ""
                try:
                    lib_name = fpid.GetLibNickname().wx_str()
                    item_name = fpid.GetLibItemName().wx_str()
                except Exception:  # pylint: disable=broad-except
                    pass
                # Bounding box (excludes courtyard, includes drawn shapes)
                bb = fp.GetBoundingBox()
                pad_count = fp.GetPadCount() if hasattr(fp, "GetPadCount") else len(fp.Pads())
                footprints.append({
                    "ref": fp.GetReference(),
                    "value": fp.GetValue(),
                    "libNickname": lib_name,
                    "itemName": item_name,
                    "fpid": f"{lib_name}:{item_name}" if lib_name else item_name,
                    "x_mm": pos.x / 1_000_000,
                    "y_mm": pos.y / 1_000_000,
                    "rotationDeg": fp.GetOrientation().AsDegrees(),
                    "layer": fp.GetLayerName(),
                    "isFlipped": bool(fp.IsFlipped()),
                    "isLocked": bool(fp.IsLocked()),
                    "padCount": pad_count,
                    "boundingBoxMm": {
                        "minXMm": bb.GetX() / 1_000_000,
                        "minYMm": bb.GetY() / 1_000_000,
                        "widthMm": bb.GetWidth() / 1_000_000,
                        "heightMm": bb.GetHeight() / 1_000_000,
                    },
                })
            except Exception as e:  # pylint: disable=broad-except
                footprints.append({"_error": f"{type(e).__name__}: {e}"})
        return {
            "hasBoard": True,
            "fileName": board.GetFileName(),
            "footprintCount": len(footprints),
            "footprints": footprints,
        }
    except Exception as e:  # pylint: disable=broad-except
        return {"hasBoard": True, "_error": f"{type(e).__name__}: {e}"}


def _ui_get_pcb_layers() -> dict:
    """Return the active layer stackup: every enabled copper + technical
    layer with its name, layer ID, and type (copper / dielectric / mask /
    silk / paste / fab / other). pcbnew only.
    """
    try:
        import pcbnew  # type: ignore[import-not-found]
    except ImportError:
        return {"hasBoard": False, "_error": "pcbnew not importable"}
    try:
        board = pcbnew.GetBoard()
    except Exception as e:  # pylint: disable=broad-except
        return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}
    if board is None:
        return {"hasBoard": False}
    try:
        # Enumerate every layer the board enables. Layer IDs are in
        # PCB_LAYER_ID enum but we'll skip naming the enum and use the
        # board's own canonical names.
        layers = []
        for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
            if not board.IsLayerEnabled(layer_id):
                continue
            name = board.GetLayerName(layer_id)
            # Classify. `pcbnew.IsCopperLayer(id)` is the authoritative
            # source for copper (works across KiCad 9/10 layer-ID
            # rearrangements). Fall back to name-pattern classification
            # for the rest. The first iteration of this code used
            # `layer_id < 32` which over-counted because KiCad interleaves
            # mask/silk into the low-ID range.
            kind = "other"
            lname = name.lower()
            is_copper = False
            try:
                is_copper = bool(pcbnew.IsCopperLayer(layer_id))
            except Exception:  # pylint: disable=broad-except
                pass
            if is_copper:
                kind = "copper"
            elif "mask" in lname:
                kind = "mask"
            elif "silkscreen" in lname or "silk" in lname:
                kind = "silk"
            elif "paste" in lname:
                kind = "paste"
            elif "courtyard" in lname:
                kind = "courtyard"
            elif "adhesive" in lname or "adhes" in lname:
                kind = "adhesive"
            elif "edge" in lname:
                kind = "edge"
            elif "fab" in lname:
                kind = "fab"
            elif "comments" in lname or "comment" in lname:
                kind = "comments"
            elif "user" in lname or "drawings" in lname:
                kind = "user"
            elif "margin" in lname:
                kind = "margin"
            layers.append({
                "id": layer_id,
                "name": name,
                "kind": kind,
                "isCopper": is_copper,
            })
        return {
            "hasBoard": True,
            "fileName": board.GetFileName(),
            "copperLayerCount": board.GetCopperLayerCount(),
            "totalEnabledLayers": len(layers),
            "layers": layers,
        }
    except Exception as e:  # pylint: disable=broad-except
        return {"hasBoard": True, "_error": f"{type(e).__name__}: {e}"}


def _ui_get_pcb_nets() -> dict:
    """Return every net on the board with name, code, pad count, and
    total track length (in mm). pcbnew only."""
    try:
        import pcbnew  # type: ignore[import-not-found]
    except ImportError:
        return {"hasBoard": False, "_error": "pcbnew not importable"}
    try:
        board = pcbnew.GetBoard()
    except Exception as e:  # pylint: disable=broad-except
        return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}
    if board is None:
        return {"hasBoard": False}
    try:
        # Walk all tracks once, building a per-net length map.
        # GetLength() is in nm.
        net_lengths_nm: dict[int, int] = {}
        for tr in board.GetTracks():
            try:
                netcode = tr.GetNetCode()
                length = tr.GetLength() if hasattr(tr, "GetLength") else 0
                net_lengths_nm[netcode] = net_lengths_nm.get(netcode, 0) + length
            except Exception:  # pylint: disable=broad-except
                pass

        # Walk pads to count pads per net.
        net_pad_counts: dict[int, int] = {}
        for fp in board.GetFootprints():
            for pad in fp.Pads():
                try:
                    netcode = pad.GetNetCode()
                    net_pad_counts[netcode] = net_pad_counts.get(netcode, 0) + 1
                except Exception:  # pylint: disable=broad-except
                    pass

        # Get nets from board.GetNetInfo().
        nets = []
        try:
            netinfo = board.GetNetInfo()
            for netcode in range(board.GetNetCount()):
                try:
                    net = netinfo.GetNetItem(netcode)
                    if net is None:
                        continue
                    name = net.GetNetname() if hasattr(net, "GetNetname") else ""
                    nets.append({
                        "code": netcode,
                        "name": name,
                        "padCount": net_pad_counts.get(netcode, 0),
                        "trackLengthMm": net_lengths_nm.get(netcode, 0) / 1_000_000,
                    })
                except Exception as e:  # pylint: disable=broad-except
                    nets.append({"code": netcode, "_error": f"{type(e).__name__}: {e}"})
        except Exception as e:  # pylint: disable=broad-except
            return {"hasBoard": True, "_error": f"{type(e).__name__}: {e}"}

        return {
            "hasBoard": True,
            "fileName": board.GetFileName(),
            "netCount": len(nets),
            "totalTrackLengthMm": sum(n.get("trackLengthMm", 0) for n in nets if isinstance(n.get("trackLengthMm"), (int, float))),
            "nets": nets,
        }
    except Exception as e:  # pylint: disable=broad-except
        return {"hasBoard": True, "_error": f"{type(e).__name__}: {e}"}


def _ui_get_pcb_design_rules() -> dict:
    """Return the board's design rules: minimum track width, via dimensions,
    clearances. pcbnew only.

    These come from board.GetDesignSettings() and BOARD_DESIGN_SETTINGS.
    Units in millimeters."""
    try:
        import pcbnew  # type: ignore[import-not-found]
    except ImportError:
        return {"hasBoard": False, "_error": "pcbnew not importable"}
    try:
        board = pcbnew.GetBoard()
    except Exception as e:  # pylint: disable=broad-except
        return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}
    if board is None:
        return {"hasBoard": False}
    try:
        ds = board.GetDesignSettings()
        # Best-effort field extraction — different KiCad versions expose
        # different attrs. Wrap each in its own try/except.
        rules: dict = {"hasBoard": True, "fileName": board.GetFileName()}

        def _safe_attr(name, getter, divisor=1_000_000):
            try:
                val = getter()
                rules[name] = val / divisor if divisor else val
            except Exception as e:  # pylint: disable=broad-except
                rules[name] = None
                rules.setdefault("_errors", {})[name] = f"{type(e).__name__}: {e}"

        # Track widths (the first entry is the "default" / "current" track width)
        try:
            tw = list(ds.m_TrackWidthList) if hasattr(ds, "m_TrackWidthList") else []
            rules["trackWidthsMm"] = [w / 1_000_000 for w in tw]
        except Exception as e:  # pylint: disable=broad-except
            rules["trackWidthsMm"] = None
            rules.setdefault("_errors", {})["trackWidthsMm"] = f"{type(e).__name__}: {e}"

        # Via dimensions
        try:
            vds = list(ds.m_ViasDimensionsList) if hasattr(ds, "m_ViasDimensionsList") else []
            rules["viaDimensions"] = [
                {"diameterMm": v.m_Diameter / 1_000_000, "drillMm": v.m_Drill / 1_000_000}
                for v in vds
            ]
        except Exception as e:  # pylint: disable=broad-except
            rules["viaDimensions"] = None
            rules.setdefault("_errors", {})["viaDimensions"] = f"{type(e).__name__}: {e}"

        # Min clearance
        _safe_attr("minClearanceMm", lambda: ds.GetSmallestClearanceValue() if hasattr(ds, "GetSmallestClearanceValue") else ds.m_MinClearance)

        # Hole sizes
        _safe_attr("minThroughHoleMm", lambda: ds.m_MinThroughDrill if hasattr(ds, "m_MinThroughDrill") else 0)
        _safe_attr("minViaDiameterMm", lambda: ds.m_ViasMinSize)

        return rules
    except Exception as e:  # pylint: disable=broad-except
        return {"hasBoard": True, "_error": f"{type(e).__name__}: {e}"}


# ── Track / pad / via / drawing introspection ─────────────────────────

def _ui_get_pcb_tracks() -> dict:
    """Return every track (segment, arc, or via) with position, layer,
    width, and net info. pcbnew only.

    PCB_TRACK covers normal segment tracks; PCB_ARC tracks; PCB_VIA via
    objects. We classify each by type so callers can filter."""
    try:
        import pcbnew  # type: ignore[import-not-found]
    except ImportError:
        return {"hasBoard": False, "_error": "pcbnew not importable"}
    try:
        board = pcbnew.GetBoard()
    except Exception as e:  # pylint: disable=broad-except
        return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}
    if board is None:
        return {"hasBoard": False}

    try:
        tracks: list[dict] = []
        # Build a netcode → netname map so each track row carries the name
        try:
            netinfo = board.GetNetInfo()
            netname_for: dict[int, str] = {}
            for code in range(board.GetNetCount()):
                try:
                    n = netinfo.GetNetItem(code)
                    if n is not None and hasattr(n, "GetNetname"):
                        netname_for[code] = n.GetNetname()
                except Exception:  # pylint: disable=broad-except
                    pass
        except Exception:  # pylint: disable=broad-except
            netname_for = {}

        for tr in board.GetTracks():
            try:
                cls = type(tr).__name__
                start = tr.GetStart() if hasattr(tr, "GetStart") else None
                end = tr.GetEnd() if hasattr(tr, "GetEnd") else None
                width = tr.GetWidth() if hasattr(tr, "GetWidth") else 0
                netcode = tr.GetNetCode() if hasattr(tr, "GetNetCode") else 0
                layer_id = tr.GetLayer() if hasattr(tr, "GetLayer") else -1
                row = {
                    "type": cls,
                    "layer": layer_id,
                    "layerName": board.GetLayerName(layer_id) if layer_id >= 0 else None,
                    "widthMm": width / 1_000_000 if width else None,
                    "netCode": netcode,
                    "netName": netname_for.get(netcode, ""),
                    "lengthMm": (tr.GetLength() / 1_000_000) if hasattr(tr, "GetLength") else None,
                }
                if start is not None:
                    row["startMm"] = {"x": start.x / 1_000_000, "y": start.y / 1_000_000}
                if end is not None:
                    row["endMm"] = {"x": end.x / 1_000_000, "y": end.y / 1_000_000}
                tracks.append(row)
            except Exception as e:  # pylint: disable=broad-except
                tracks.append({"_error": f"{type(e).__name__}: {e}"})

        return {
            "hasBoard": True,
            "fileName": board.GetFileName(),
            "trackCount": len(tracks),
            "tracks": tracks,
        }
    except Exception as e:  # pylint: disable=broad-except
        return {"hasBoard": True, "_error": f"{type(e).__name__}: {e}"}


def _ui_get_pcb_vias() -> dict:
    """Return every via (PCB_VIA in pcbnew's track list).

    Differentiates through-hole / blind / buried via type. pcbnew only.
    """
    try:
        import pcbnew  # type: ignore[import-not-found]
    except ImportError:
        return {"hasBoard": False, "_error": "pcbnew not importable"}
    try:
        board = pcbnew.GetBoard()
    except Exception as e:  # pylint: disable=broad-except
        return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}
    if board is None:
        return {"hasBoard": False}

    try:
        # Net name lookup (same pattern as get_pcb_tracks)
        try:
            netinfo = board.GetNetInfo()
            netname_for: dict[int, str] = {}
            for code in range(board.GetNetCount()):
                n = netinfo.GetNetItem(code)
                if n is not None and hasattr(n, "GetNetname"):
                    netname_for[code] = n.GetNetname()
        except Exception:  # pylint: disable=broad-except
            netname_for = {}

        # Map via-type enum → string. KiCad pcbnew.VIATYPE_THROUGH etc.
        type_names = {}
        for attr in ("VIATYPE_THROUGH", "VIATYPE_BLIND_BURIED", "VIATYPE_MICROVIA"):
            try:
                type_names[getattr(pcbnew, attr)] = attr.replace("VIATYPE_", "").lower()
            except AttributeError:
                pass

        vias: list[dict] = []
        for tr in board.GetTracks():
            if type(tr).__name__ != "PCB_VIA":
                continue
            try:
                pos = tr.GetPosition()
                width = tr.GetWidth()
                drill = tr.GetDrillValue() if hasattr(tr, "GetDrillValue") else 0
                vtype = tr.GetViaType() if hasattr(tr, "GetViaType") else None
                netcode = tr.GetNetCode()
                top_layer = tr.TopLayer() if hasattr(tr, "TopLayer") else None
                bottom_layer = tr.BottomLayer() if hasattr(tr, "BottomLayer") else None
                vias.append({
                    "positionMm": {"x": pos.x / 1_000_000, "y": pos.y / 1_000_000},
                    "diameterMm": width / 1_000_000,
                    "drillMm": drill / 1_000_000,
                    "viaType": type_names.get(vtype, str(vtype)) if vtype is not None else None,
                    "topLayer": top_layer,
                    "topLayerName": board.GetLayerName(top_layer) if top_layer is not None else None,
                    "bottomLayer": bottom_layer,
                    "bottomLayerName": board.GetLayerName(bottom_layer) if bottom_layer is not None else None,
                    "netCode": netcode,
                    "netName": netname_for.get(netcode, ""),
                })
            except Exception as e:  # pylint: disable=broad-except
                vias.append({"_error": f"{type(e).__name__}: {e}"})

        return {
            "hasBoard": True,
            "fileName": board.GetFileName(),
            "viaCount": len(vias),
            "vias": vias,
        }
    except Exception as e:  # pylint: disable=broad-except
        return {"hasBoard": True, "_error": f"{type(e).__name__}: {e}"}


def _ui_get_pcb_pads() -> dict:
    """Return every pad across every footprint. pcbnew only.

    Includes pad name (e.g. "1", "GND"), parent footprint reference,
    absolute position (post-rotation), drill (if THT), layers mask.
    """
    try:
        import pcbnew  # type: ignore[import-not-found]
    except ImportError:
        return {"hasBoard": False, "_error": "pcbnew not importable"}
    try:
        board = pcbnew.GetBoard()
    except Exception as e:  # pylint: disable=broad-except
        return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}
    if board is None:
        return {"hasBoard": False}

    try:
        # Net name lookup
        try:
            netinfo = board.GetNetInfo()
            netname_for: dict[int, str] = {}
            for code in range(board.GetNetCount()):
                n = netinfo.GetNetItem(code)
                if n is not None and hasattr(n, "GetNetname"):
                    netname_for[code] = n.GetNetname()
        except Exception:  # pylint: disable=broad-except
            netname_for = {}

        pads: list[dict] = []
        for fp in board.GetFootprints():
            try:
                fp_ref = fp.GetReference()
                for pad in fp.Pads():
                    try:
                        pos = pad.GetPosition()
                        size = pad.GetSize() if hasattr(pad, "GetSize") else None
                        drill = pad.GetDrillSize() if hasattr(pad, "GetDrillSize") else None
                        netcode = pad.GetNetCode() if hasattr(pad, "GetNetCode") else 0
                        # GetShape returns an enum; stringify
                        try:
                            shape_id = pad.GetShape()
                        except Exception:  # pylint: disable=broad-except
                            shape_id = None
                        row = {
                            "footprintRef": fp_ref,
                            "padName": pad.GetName() if hasattr(pad, "GetName") else "",
                            "positionMm": {"x": pos.x / 1_000_000, "y": pos.y / 1_000_000},
                            "shapeId": shape_id,
                            "netCode": netcode,
                            "netName": netname_for.get(netcode, ""),
                        }
                        if size is not None:
                            try:
                                row["sizeMm"] = {"x": size.x / 1_000_000, "y": size.y / 1_000_000}
                            except Exception:  # pylint: disable=broad-except
                                pass
                        if drill is not None:
                            try:
                                row["drillMm"] = {"x": drill.x / 1_000_000, "y": drill.y / 1_000_000}
                            except Exception:  # pylint: disable=broad-except
                                pass
                        # Pad-stack attribute: THT, SMD, or NPTH
                        try:
                            row["attribute"] = pad.GetAttribute()
                        except Exception:  # pylint: disable=broad-except
                            pass
                        pads.append(row)
                    except Exception as e:  # pylint: disable=broad-except
                        pads.append({"footprintRef": fp_ref, "_error": f"{type(e).__name__}: {e}"})
            except Exception as e:  # pylint: disable=broad-except
                pads.append({"_error": f"footprint walk: {type(e).__name__}: {e}"})

        return {
            "hasBoard": True,
            "fileName": board.GetFileName(),
            "padCount": len(pads),
            "pads": pads,
        }
    except Exception as e:  # pylint: disable=broad-except
        return {"hasBoard": True, "_error": f"{type(e).__name__}: {e}"}


def _ui_get_pcb_drawings() -> dict:
    """Return every drawing on the board (silk text, edge cuts, dimensions,
    user-drawn lines). pcbnew only.

    Includes type, layer, position. For text, includes the text content.
    For shapes, includes start/end/center/radius as applicable."""
    try:
        import pcbnew  # type: ignore[import-not-found]
    except ImportError:
        return {"hasBoard": False, "_error": "pcbnew not importable"}
    try:
        board = pcbnew.GetBoard()
    except Exception as e:  # pylint: disable=broad-except
        return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}
    if board is None:
        return {"hasBoard": False}

    try:
        drawings: list[dict] = []
        for dr in board.GetDrawings():
            try:
                cls = type(dr).__name__
                layer_id = dr.GetLayer() if hasattr(dr, "GetLayer") else -1
                row = {
                    "type": cls,
                    "layer": layer_id,
                    "layerName": board.GetLayerName(layer_id) if layer_id >= 0 else None,
                }
                # Text drawings expose GetText() / GetTextHeight() / GetTextWidth()
                if hasattr(dr, "GetText"):
                    try:
                        row["text"] = dr.GetText()
                    except Exception:  # pylint: disable=broad-except
                        pass
                # Position (works for text + most shapes)
                if hasattr(dr, "GetPosition"):
                    try:
                        pos = dr.GetPosition()
                        row["positionMm"] = {"x": pos.x / 1_000_000, "y": pos.y / 1_000_000}
                    except Exception:  # pylint: disable=broad-except
                        pass
                # Shape start/end (lines, arcs, rects)
                for name, getter in (("startMm", "GetStart"), ("endMm", "GetEnd"), ("centerMm", "GetCenter")):
                    if hasattr(dr, getter):
                        try:
                            pt = getattr(dr, getter)()
                            row[name] = {"x": pt.x / 1_000_000, "y": pt.y / 1_000_000}
                        except Exception:  # pylint: disable=broad-except
                            pass
                # Arc radius / shape kind
                if hasattr(dr, "GetRadius"):
                    try:
                        r = dr.GetRadius()
                        row["radiusMm"] = r / 1_000_000
                    except Exception:  # pylint: disable=broad-except
                        pass
                drawings.append(row)
            except Exception as e:  # pylint: disable=broad-except
                drawings.append({"_error": f"{type(e).__name__}: {e}"})

        return {
            "hasBoard": True,
            "fileName": board.GetFileName(),
            "drawingCount": len(drawings),
            "drawings": drawings,
        }
    except Exception as e:  # pylint: disable=broad-except
        return {"hasBoard": True, "_error": f"{type(e).__name__}: {e}"}


def _ui_get_net_topology(net_name: str) -> dict:
    """Graph traversal from a named net outward through the components
    that pin it. pcbnew only. Phase 2.ac+.

    Returns the full footprint-level topology for one net:
      - Direct pads (footprint refs + pad names on this net)
      - Reachable components (every footprint that has a pad on this
        net) with their OTHER nets — i.e. "if I trace VCC, what
        components touch it, and what other signals do they bridge?"

    Example output for the demo board's VCC net:
      {
        "netName": "VCC",
        "padCount": 6,             # 6 pads share this net
        "componentCount": 3,       # spread across 3 footprints
        "components": [
          {"ref": "R1", "value": "10k", "padsOnNet": ["1"],
           "otherPads": [{"padName": "2", "netName": "OUT"}]},
          ...
        ],
        "bridgedNets": ["OUT", "GND"]  # nets reachable in 1 hop
      }
    """
    try:
        import pcbnew  # type: ignore[import-not-found]
    except ImportError:
        return {"found": False, "_error": "pcbnew not importable"}
    try:
        board = pcbnew.GetBoard()
    except Exception as e:  # pylint: disable=broad-except
        return {"found": False, "_error": f"{type(e).__name__}: {e}"}
    if board is None:
        return {"found": False, "_error": "no board loaded"}

    try:
        # Net code lookup
        target_netcode = None
        netname_for: dict[int, str] = {}
        netinfo = board.GetNetInfo()
        for code in range(board.GetNetCount()):
            n = netinfo.GetNetItem(code)
            if n is not None and hasattr(n, "GetNetname"):
                nm = n.GetNetname()
                netname_for[code] = nm
                if nm == net_name:
                    target_netcode = code

        if target_netcode is None:
            return {
                "found": False,
                "netName": net_name,
                "_error": f"net {net_name!r} not found",
                "_hint": "Call get_pcb_nets to see the list of net names on this board. Net names are case-sensitive.",
            }

        # Walk every footprint's pads. For each pad on the target net,
        # record the parent footprint. For each footprint that has at
        # least one pad on the target net, also list its OTHER pads
        # (so the caller can see what other signals that component
        # bridges).
        components: dict[str, dict] = {}  # ref → {value, padsOnNet, otherPads}
        bridged_netcodes: set[int] = set()

        for fp in board.GetFootprints():
            try:
                fp_ref = fp.GetReference()
                pads_on_net = []
                other_pads = []
                for pad in fp.Pads():
                    nc = pad.GetNetCode() if hasattr(pad, "GetNetCode") else 0
                    pad_name = pad.GetName() if hasattr(pad, "GetName") else ""
                    if nc == target_netcode:
                        pads_on_net.append(pad_name)
                    elif nc != 0:
                        other_pads.append({
                            "padName": pad_name,
                            "netCode": nc,
                            "netName": netname_for.get(nc, ""),
                        })
                if pads_on_net:
                    components[fp_ref] = {
                        "ref": fp_ref,
                        "value": fp.GetValue() if hasattr(fp, "GetValue") else "",
                        "footprintId": str(fp.GetFPID().GetLibItemName().wx_str()) if hasattr(fp, "GetFPID") else "",
                        "padsOnNet": pads_on_net,
                        "otherPads": other_pads,
                    }
                    for op in other_pads:
                        bridged_netcodes.add(op["netCode"])
            except Exception as e:  # pylint: disable=broad-except
                components[f"_error_{len(components)}"] = {"_error": f"{type(e).__name__}: {e}"}

        # Total pad count (sum across components)
        pad_count = sum(len(c.get("padsOnNet", [])) for c in components.values())

        # Bridged net names (the ones component-co-located with our target net)
        bridged_nets = sorted({netname_for.get(nc, "") for nc in bridged_netcodes if netname_for.get(nc)})

        return {
            "found": True,
            "netName": net_name,
            "netCode": target_netcode,
            "padCount": pad_count,
            "componentCount": len(components),
            "components": sorted(components.values(), key=lambda c: c.get("ref", "")),
            "bridgedNets": bridged_nets,
            "_hint": (
                f"Net {net_name!r} reaches {pad_count} pad(s) across {len(components)} "
                f"component(s). Components co-located with this net bridge to: "
                f"{', '.join(bridged_nets) if bridged_nets else '(no other nets — net is isolated)'}. "
                "For length-matching on each branch, call get_track_paths_by_net "
                f"{{\"netName\":{net_name!r}}}. For pad-level details (positions, sizes), "
                "filter get_pcb_pads to this footprintRef list."
            ),
        }
    except Exception as e:  # pylint: disable=broad-except
        return {"found": False, "_error": f"{type(e).__name__}: {e}"}


def _ui_get_pcb_zones() -> dict:
    """Return every copper zone / keepout on the board.

    Each zone: {netCode, netName, layer, layerName, priority, isFilled,
    isKeepout, clearanceMm, minWidthMm, hatchStyle, polygonVertexCount,
    boundingBoxMm}.
    """
    try:
        import pcbnew  # type: ignore[import-not-found]
    except ImportError:
        return {"hasBoard": False, "_error": "pcbnew not importable"}
    try:
        board = pcbnew.GetBoard()
    except Exception as e:  # pylint: disable=broad-except
        return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}
    if board is None:
        return {"hasBoard": False}

    try:
        # Net-name lookup
        try:
            netinfo = board.GetNetInfo()
            netname_for: dict[int, str] = {}
            for code in range(board.GetNetCount()):
                n = netinfo.GetNetItem(code)
                if n is not None and hasattr(n, "GetNetname"):
                    netname_for[code] = n.GetNetname()
        except Exception:  # pylint: disable=broad-except
            netname_for = {}

        zones: list[dict] = []
        # board.Zones() returns ZONE objects (PCB_ZONE). Each can span
        # multiple layers in KiCad 7+ but we report per-layer where useful.
        try:
            zone_iter = list(board.Zones())
        except Exception as e:  # pylint: disable=broad-except
            return {"hasBoard": True, "_error": f"board.Zones() failed: {type(e).__name__}: {e}"}

        for z in zone_iter:
            try:
                # Layer set — KiCad 7+ zones can be multi-layer
                layer_ids: list[int] = []
                try:
                    lset = z.GetLayerSet()
                    # LayerSet has Seq() returning vector of layer IDs
                    for lid in lset.Seq():
                        layer_ids.append(int(lid))
                except Exception:  # pylint: disable=broad-except
                    # Fallback to single-layer
                    try:
                        layer_ids = [z.GetLayer()]
                    except Exception:  # pylint: disable=broad-except
                        pass
                netcode = z.GetNetCode() if hasattr(z, "GetNetCode") else 0
                # Bounding box
                try:
                    bb = z.GetBoundingBox()
                    bbox_mm = {
                        "minXMm": bb.GetX() / 1_000_000,
                        "minYMm": bb.GetY() / 1_000_000,
                        "widthMm": bb.GetWidth() / 1_000_000,
                        "heightMm": bb.GetHeight() / 1_000_000,
                    }
                except Exception:  # pylint: disable=broad-except
                    bbox_mm = None
                # Polygon vertex count (one polygon per outline)
                vertex_count = 0
                try:
                    # GetOutline() returns SHAPE_POLY_SET
                    poly = z.Outline()
                    vertex_count = poly.FullPointCount() if hasattr(poly, "FullPointCount") else 0
                except Exception:  # pylint: disable=broad-except
                    pass
                zones.append({
                    "netCode": netcode,
                    "netName": netname_for.get(netcode, ""),
                    "layers": layer_ids,
                    "layerNames": [board.GetLayerName(lid) for lid in layer_ids],
                    "priority": z.GetAssignedPriority() if hasattr(z, "GetAssignedPriority") else None,
                    "isFilled": bool(z.IsFilled()) if hasattr(z, "IsFilled") else None,
                    "isKeepout": bool(z.GetIsRuleArea()) if hasattr(z, "GetIsRuleArea") else None,
                    "clearanceMm": (z.GetLocalClearance() / 1_000_000) if hasattr(z, "GetLocalClearance") else None,
                    "minWidthMm": (z.GetMinThickness() / 1_000_000) if hasattr(z, "GetMinThickness") else None,
                    "polygonVertexCount": vertex_count,
                    "boundingBoxMm": bbox_mm,
                })
            except Exception as e:  # pylint: disable=broad-except
                zones.append({"_error": f"{type(e).__name__}: {e}"})

        return {
            "hasBoard": True,
            "fileName": board.GetFileName(),
            "zoneCount": len(zones),
            "zones": zones,
        }
    except Exception as e:  # pylint: disable=broad-except
        return {"hasBoard": True, "_error": f"{type(e).__name__}: {e}"}


def _ui_get_footprint_connections(ref: str) -> dict:
    """For a given footprint reference (e.g. "U1"), return its pin/pad-level
    connectivity. Each pad with a non-empty net reports which OTHER pads
    (on other footprints) share that net.

    Returns: {found, ref, pads:[{padName, netCode, netName,
                                  connectedTo:[{ref, padName}]}]}
    """
    try:
        import pcbnew  # type: ignore[import-not-found]
    except ImportError:
        return {"found": False, "_error": "pcbnew not importable"}
    try:
        board = pcbnew.GetBoard()
    except Exception as e:  # pylint: disable=broad-except
        return {"found": False, "_error": f"{type(e).__name__}: {e}"}
    if board is None:
        return {"found": False, "_error": "no board loaded"}

    try:
        # Build the per-net pad index once: netcode → [(ref, padName)]
        per_net: dict[int, list[tuple[str, str]]] = {}
        target_fp = None
        for fp in board.GetFootprints():
            try:
                fp_ref = fp.GetReference()
                if fp_ref == ref:
                    target_fp = fp
                for pad in fp.Pads():
                    nc = pad.GetNetCode() if hasattr(pad, "GetNetCode") else 0
                    nm = pad.GetName() if hasattr(pad, "GetName") else ""
                    if nc:
                        per_net.setdefault(nc, []).append((fp_ref, nm))
            except Exception:  # pylint: disable=broad-except
                continue

        if target_fp is None:
            return {
                "found": False,
                "ref": ref,
                "error": f"footprint with reference {ref!r} not found",
                "_hint": "Call get_pcb_footprints to see the full list of refs on the board.",
            }

        # Net name lookup
        try:
            netinfo = board.GetNetInfo()
            netname_for: dict[int, str] = {}
            for code in range(board.GetNetCount()):
                n = netinfo.GetNetItem(code)
                if n is not None and hasattr(n, "GetNetname"):
                    netname_for[code] = n.GetNetname()
        except Exception:  # pylint: disable=broad-except
            netname_for = {}

        pads_out: list[dict] = []
        for pad in target_fp.Pads():
            try:
                nc = pad.GetNetCode() if hasattr(pad, "GetNetCode") else 0
                pad_name = pad.GetName() if hasattr(pad, "GetName") else ""
                row: dict = {
                    "padName": pad_name,
                    "netCode": nc,
                    "netName": netname_for.get(nc, ""),
                    "connectedTo": [],
                }
                if nc:
                    # Other pads on the same net (excluding self)
                    for (other_ref, other_pad) in per_net.get(nc, []):
                        if other_ref == ref and other_pad == pad_name:
                            continue
                        row["connectedTo"].append({"ref": other_ref, "padName": other_pad})
                pads_out.append(row)
            except Exception as e:  # pylint: disable=broad-except
                pads_out.append({"_error": f"{type(e).__name__}: {e}"})

        return {
            "found": True,
            "ref": ref,
            "value": target_fp.GetValue() if hasattr(target_fp, "GetValue") else "",
            "padCount": len(pads_out),
            "pads": pads_out,
        }
    except Exception as e:  # pylint: disable=broad-except
        return {"found": False, "_error": f"{type(e).__name__}: {e}"}


def _ui_run_drc_inprocess() -> dict:
    """Trigger DRC inside the running pcbnew via the DRC menu item, wait
    a short while, then read get_drc_markers. Replaces shelling out to
    `kicad-cli pcb drc` (which takes ~3-5s subprocess startup).

    NOTE: pcbnew's full DRC_ENGINE isn't exposed in KiCad 10's SWIG
    bindings (DRC_ENGINE.RunTests is C++-only). The cleanest available
    path is to POST the DRC menu command (menu_id 20088 on the PCB
    Editor frame), which opens the DRC dialog and triggers a run. We
    then poll get_drc_markers until markers appear or timeout.

    Trade-off: this opens the DRC dialog visually (the user sees it
    flash). True invisible DRC needs KiCad 11's kipy / a future
    DRC_ENGINE Python binding.

    Returns: {hasBoard, dialogTriggered, markers:[{...}],
              markerCount, bySeverityCounts, _hint}.
    """
    try:
        import pcbnew  # type: ignore[import-not-found]
        import wx  # type: ignore[import-not-found]
    except ImportError:
        return {"hasBoard": False, "_error": "pcbnew/wx not importable"}
    try:
        board = pcbnew.GetBoard()
    except Exception as e:  # pylint: disable=broad-except
        return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}
    if board is None:
        return {"hasBoard": False, "_error": "no board loaded"}

    # Find PCB Editor frame
    target_frame = None
    for win in wx.GetTopLevelWindows():
        try:
            title = win.GetTitle() if hasattr(win, "GetTitle") else ""
        except Exception:  # pylint: disable=broad-except
            title = ""
        if "pcb editor" in title.lower():
            target_frame = win
            break
    if target_frame is None:
        return {
            "hasBoard": True,
            "dialogTriggered": False,
            "_error": "PCB Editor frame not found",
            "_hint": "Open pcbnew with a board first via kicad_open_board.",
        }

    # Post WM_COMMAND 20088 (DRC) via Win32 PostMessage — proven path
    # for posting menu commands to KiCad. wx.PostEvent fires the event
    # but KiCad's TOOL_DISPATCHER doesn't pick wx events up; only Win32
    # WM_COMMAND.
    try:
        import ctypes
        import ctypes.wintypes as wt
        WM_COMMAND = 0x0111
        DRC_MENU_ID = 20088
        hwnd = int(target_frame.GetHandle())
        user32 = ctypes.windll.user32
        PostMessageW = user32.PostMessageW
        PostMessageW.argtypes = [wt.HWND, ctypes.c_uint, wt.WPARAM, wt.LPARAM]
        PostMessageW.restype = wt.BOOL
        ok = PostMessageW(hwnd, WM_COMMAND, DRC_MENU_ID, 0)
    except Exception as e:  # pylint: disable=broad-except
        return {
            "hasBoard": True,
            "dialogTriggered": False,
            "_error": f"PostMessage failed: {type(e).__name__}: {e}",
        }

    return {
        "hasBoard": True,
        "fileName": board.GetFileName(),
        "dialogTriggered": bool(ok),
        "menuIdPosted": DRC_MENU_ID,
        "_hint": (
            "DRC menu command posted to pcbnew. The DRC dialog will appear "
            "and KiCad will run the rule check. Wait ~2-5s (depending on "
            "board complexity), then call get_drc_markers to read the "
            "results. Note: this opens the DRC dialog visually — the user "
            "sees it flash. For invisible DRC, fall back to "
            "`kicad-cli pcb drc --output drc.json` (current behavior of "
            "kicad_run_drc) until KiCad 11's kipy IPC API ships a true "
            "in-process trigger."
        ),
    }


def _ui_get_track_paths_by_net(net_name: str) -> dict:
    """Group all tracks belonging to a net into connected polyline paths.

    A "path" is a chain of track segments where consecutive segments share
    an endpoint. Returns one entry per path with start/end + total length.
    Useful for length-matching analysis (DDR data lines, RF traces, etc.)
    where you want each branch of the net measured independently.

    Returns: {hasBoard, netName, pathCount, paths:[{
        segmentCount, totalLengthMm,
        startMm:{x,y}, endMm:{x,y},
        layers:[layerName],
        segments:[{startMm, endMm, widthMm, layer}]
    }]}
    """
    try:
        import pcbnew  # type: ignore[import-not-found]
    except ImportError:
        return {"hasBoard": False, "_error": "pcbnew not importable"}
    try:
        board = pcbnew.GetBoard()
    except Exception as e:  # pylint: disable=broad-except
        return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}
    if board is None:
        return {"hasBoard": False}

    try:
        # Resolve netcode for the requested net
        netcode = None
        try:
            netinfo = board.GetNetInfo()
            for code in range(board.GetNetCount()):
                n = netinfo.GetNetItem(code)
                if n is not None and hasattr(n, "GetNetname"):
                    if n.GetNetname() == net_name:
                        netcode = code
                        break
        except Exception:  # pylint: disable=broad-except
            pass

        if netcode is None:
            return {
                "hasBoard": True,
                "found": False,
                "netName": net_name,
                "_error": f"net {net_name!r} not found",
                "_hint": "Call get_pcb_nets to see the list of net names on this board.",
            }

        # Collect every segment-like track on this net. Skip vias (they're
        # zero-length points; user can correlate via get_pcb_vias on the
        # same net).
        segs = []
        for tr in board.GetTracks():
            try:
                if tr.GetNetCode() != netcode:
                    continue
                cls = type(tr).__name__
                if cls == "PCB_VIA":
                    continue
                start = tr.GetStart()
                end = tr.GetEnd()
                width = tr.GetWidth()
                layer_id = tr.GetLayer()
                segs.append({
                    "startMm": (start.x / 1_000_000, start.y / 1_000_000),
                    "endMm": (end.x / 1_000_000, end.y / 1_000_000),
                    "widthMm": width / 1_000_000,
                    "layer": layer_id,
                    "layerName": board.GetLayerName(layer_id),
                    "lengthMm": (tr.GetLength() / 1_000_000) if hasattr(tr, "GetLength") else 0,
                })
            except Exception:  # pylint: disable=broad-except
                continue

        if not segs:
            return {
                "hasBoard": True,
                "found": True,
                "netName": net_name,
                "pathCount": 0,
                "paths": [],
                "_hint": f"Net {net_name!r} exists but has 0 routed track segments. "
                         "It's still defined (pads on it) but unrouted.",
            }

        # Build adjacency by endpoint match. Two segments share an endpoint
        # when the (rounded-to-µm) coordinates match. Use 0.0001mm = 100nm
        # tolerance to handle floating-point quirks.
        def _key(pt):
            return (round(pt[0], 4), round(pt[1], 4))

        # endpoint -> set of segment indices touching it
        endpoint_to_segs: dict = {}
        for i, s in enumerate(segs):
            for ep in (_key(s["startMm"]), _key(s["endMm"])):
                endpoint_to_segs.setdefault(ep, []).append(i)

        # Union-find on segment indices: two segs are in the same component
        # if they share any endpoint.
        parent = list(range(len(segs)))
        def find(i):
            while parent[i] != i:
                parent[i] = parent[parent[i]]
                i = parent[i]
            return i
        def union(a, b):
            ra, rb = find(a), find(b)
            if ra != rb:
                parent[ra] = rb

        for seg_list in endpoint_to_segs.values():
            for j in range(1, len(seg_list)):
                union(seg_list[0], seg_list[j])

        # Group segs by component root
        components: dict = {}
        for i in range(len(segs)):
            components.setdefault(find(i), []).append(i)

        paths = []
        for _root, seg_ids in components.items():
            comp_segs = [segs[i] for i in seg_ids]
            total_length = sum(s["lengthMm"] for s in comp_segs)
            # Pick "start" / "end" as the two endpoints that appear in only
            # ONE segment of this component (the dangling ends). If the
            # component is a loop, just pick the first segment's endpoints.
            endpoint_count: dict = {}
            for s in comp_segs:
                for ep in (_key(s["startMm"]), _key(s["endMm"])):
                    endpoint_count[ep] = endpoint_count.get(ep, 0) + 1
            ends = [ep for ep, c in endpoint_count.items() if c == 1]
            if len(ends) >= 2:
                start_ep = {"x": ends[0][0], "y": ends[0][1]}
                end_ep = {"x": ends[1][0], "y": ends[1][1]}
            else:
                start_ep = {"x": comp_segs[0]["startMm"][0], "y": comp_segs[0]["startMm"][1]}
                end_ep = {"x": comp_segs[0]["endMm"][0], "y": comp_segs[0]["endMm"][1]}
            layers = sorted(set(s["layerName"] for s in comp_segs))
            paths.append({
                "segmentCount": len(comp_segs),
                "totalLengthMm": total_length,
                "startMm": start_ep,
                "endMm": end_ep,
                "layers": layers,
                "segments": [
                    {
                        "startMm": {"x": s["startMm"][0], "y": s["startMm"][1]},
                        "endMm": {"x": s["endMm"][0], "y": s["endMm"][1]},
                        "widthMm": s["widthMm"],
                        "layer": s["layer"],
                        "layerName": s["layerName"],
                        "lengthMm": s["lengthMm"],
                    }
                    for s in comp_segs
                ],
            })

        # Sort paths longest-first (typical use is "which is the longest
        # branch on this DDR data line?")
        paths.sort(key=lambda p: p["totalLengthMm"], reverse=True)

        return {
            "hasBoard": True,
            "found": True,
            "netName": net_name,
            "pathCount": len(paths),
            "paths": paths,
            "_hint": (
                "Each `path` is a connected chain of track segments sharing "
                "endpoints. Paths are sorted longest-first. For length-"
                "matching (DDR/RF), compare `totalLengthMm` across nets — "
                "e.g. fetch the same path-count for each DDR data line and "
                "diff their totals. Vias are excluded (they have zero "
                "horizontal length); for via inventory call get_pcb_vias "
                "filtered to this net's code."
            ),
        }
    except Exception as e:  # pylint: disable=broad-except
        return {"hasBoard": True, "_error": f"{type(e).__name__}: {e}"}


def _ui_get_drc_markers() -> dict:
    """Read existing DRC markers from the board.

    Markers are produced by the most recent DRC run (manual or kicad-cli).
    This method does NOT trigger DRC; for that, use the legacy kicad_run_drc
    verb (which shells out to kicad-cli). A future 2.ab method will trigger
    in-process via pcbnew's DRC_ENGINE.

    Returns: {hasBoard, markerCount, markers:[{severity, type, message,
                                                positionMm, layer?, ...}]}
    """
    try:
        import pcbnew  # type: ignore[import-not-found]
    except ImportError:
        return {"hasBoard": False, "_error": "pcbnew not importable"}
    try:
        board = pcbnew.GetBoard()
    except Exception as e:  # pylint: disable=broad-except
        return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}
    if board is None:
        return {"hasBoard": False}

    try:
        # Markers live in board.GetItems() but we want only PCB_MARKER_T.
        # GetMarkers() returns a vector<PCB_MARKER*>.
        try:
            marker_iter = list(board.Markers()) if hasattr(board, "Markers") else []
        except Exception:  # pylint: disable=broad-except
            marker_iter = []
        # Fallback: walk board.GetItems(PCB_MARKER_T) — older SWIG bindings
        if not marker_iter and hasattr(pcbnew, "PCB_MARKER_T"):
            try:
                # Some SWIG bindings expose this; many don't. Best-effort.
                pass
            except Exception:  # pylint: disable=broad-except
                pass

        # Build severity / type enum lookup
        severity_names: dict[int, str] = {}
        for attr, label in (
            ("SEVERITY_ERROR", "error"),
            ("SEVERITY_WARNING", "warning"),
            ("SEVERITY_ACTION", "action"),
            ("SEVERITY_INFO", "info"),
            ("SEVERITY_EXCLUSION", "exclusion"),
            ("SEVERITY_IGNORE", "ignore"),
        ):
            try:
                severity_names[getattr(pcbnew, attr)] = label
            except AttributeError:
                pass

        markers: list[dict] = []
        for m in marker_iter:
            try:
                row: dict = {}
                # Severity
                if hasattr(m, "GetSeverity"):
                    try:
                        sev = m.GetSeverity()
                        row["severity"] = severity_names.get(sev, str(sev))
                        row["severityCode"] = sev
                    except Exception:  # pylint: disable=broad-except
                        pass
                # The reporter holds the message
                if hasattr(m, "GetReporter"):
                    try:
                        rep = m.GetReporter()
                        if rep is not None:
                            if hasattr(rep, "GetErrorMessage"):
                                row["message"] = rep.GetErrorMessage()
                            if hasattr(rep, "GetErrorText"):
                                row["errorText"] = rep.GetErrorText()
                    except Exception as e:  # pylint: disable=broad-except
                        row.setdefault("_errors", {})["reporter"] = f"{type(e).__name__}: {e}"
                # Layer
                if hasattr(m, "GetLayer"):
                    try:
                        lid = m.GetLayer()
                        row["layer"] = lid
                        row["layerName"] = board.GetLayerName(lid) if lid >= 0 else None
                    except Exception:  # pylint: disable=broad-except
                        pass
                # Position
                if hasattr(m, "GetPosition"):
                    try:
                        p = m.GetPosition()
                        row["positionMm"] = {"x": p.x / 1_000_000, "y": p.y / 1_000_000}
                    except Exception:  # pylint: disable=broad-except
                        pass
                # Marker class
                row["markerClass"] = type(m).__name__
                markers.append(row)
            except Exception as e:  # pylint: disable=broad-except
                markers.append({"_error": f"{type(e).__name__}: {e}"})

        # Group by severity for the AI's summary
        by_severity: dict[str, int] = {}
        for r in markers:
            by_severity[r.get("severity", "unknown")] = by_severity.get(r.get("severity", "unknown"), 0) + 1

        return {
            "hasBoard": True,
            "fileName": board.GetFileName(),
            "markerCount": len(markers),
            "bySeverityCounts": by_severity,
            "markers": markers,
            "_note": "Markers reflect the LAST DRC run (manual or kicad-cli). To trigger a fresh DRC, use kicad_run_drc; this method only READS existing markers.",
        }
    except Exception as e:  # pylint: disable=broad-except
        return {"hasBoard": True, "_error": f"{type(e).__name__}: {e}"}


# ── PCB export (in-process via pcbnew.PLOT_CONTROLLER) ────────────────

def _ui_export_pcb_plot(fmt_label: str, params: dict) -> dict:
    """Export PCB to SVG or PDF using pcbnew.PLOT_CONTROLLER. In-process —
    no kicad-cli subprocess startup cost (~3-5s per call savings).

    fmt_label: "svg" or "pdf"

    params (all optional):
      outputDir:   destination dir (default: %TEMP%/adom-kicad-export/)
      layers:      list of layer names to plot (default: copper+silk)
                   accepts canonical names like "F.Cu", "B.SilkS", "Edge.Cuts"
      mirror:      bool, mirror the plot (default false)
      plotFrameRef:bool, plot the page border (default false)
      useAuxOrigin:bool, use aux origin instead of page origin (default false)
      subtractMaskFromSilk: bool (default false)
      drillMarks:  one of "none", "small", "full" (default "small")
      filenamePrefix: stem prepended to each layer's filename (default: board basename)
    """
    try:
        import pcbnew  # type: ignore[import-not-found]
    except ImportError:
        return {"hasBoard": False, "_error": "pcbnew not importable"}
    try:
        board = pcbnew.GetBoard()
    except Exception as e:  # pylint: disable=broad-except
        return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}
    if board is None:
        return {"hasBoard": False, "_error": "no board loaded"}

    # Resolve format
    fmt = fmt_label.lower()
    if fmt == "svg":
        plot_format = pcbnew.PLOT_FORMAT_SVG
    elif fmt == "pdf":
        plot_format = pcbnew.PLOT_FORMAT_PDF
    else:
        return {"hasBoard": True, "_error": f"unsupported export format: {fmt_label}"}

    # Output directory
    try:
        out_dir_str = params.get("outputDir") or os.path.join(
            os.environ.get("TEMP") or "/tmp",
            "adom-kicad-export",
        )
        out_dir = Path(out_dir_str)
        out_dir.mkdir(parents=True, exist_ok=True)
    except OSError as e:
        return {"hasBoard": True, "_error": f"could not create output dir: {e}"}

    # Layers to plot
    requested_layer_names = params.get("layers")
    layer_ids: list[int] = []
    if requested_layer_names:
        # User-specified list
        for name in requested_layer_names:
            for lid in range(pcbnew.PCB_LAYER_ID_COUNT):
                if board.IsLayerEnabled(lid) and board.GetLayerName(lid) == name:
                    layer_ids.append(lid)
                    break
            else:
                # Couldn't match — record but continue
                pass
    else:
        # Default: every enabled copper layer + F.SilkS + B.SilkS + Edge.Cuts
        for lid in range(pcbnew.PCB_LAYER_ID_COUNT):
            if not board.IsLayerEnabled(lid):
                continue
            name = board.GetLayerName(lid)
            try:
                is_copper = bool(pcbnew.IsCopperLayer(lid))
            except Exception:  # pylint: disable=broad-except
                is_copper = False
            if is_copper or name in ("F.Silkscreen", "B.Silkscreen", "F.SilkS", "B.SilkS", "Edge.Cuts"):
                layer_ids.append(lid)

    # Configure plot controller
    try:
        plot_controller = pcbnew.PLOT_CONTROLLER(board)
    except AttributeError:
        try:
            plot_controller = pcbnew.PCB_PLOT_CONTROLLER(board)
        except AttributeError:
            return {"hasBoard": True, "_error": "PLOT_CONTROLLER not available in this pcbnew SWIG binding"}

    plot_options = plot_controller.GetPlotOptions()
    plot_options.SetFormat(plot_format)
    plot_options.SetOutputDirectory(str(out_dir))
    plot_options.SetPlotFrameRef(bool(params.get("plotFrameRef", False)))
    plot_options.SetMirror(bool(params.get("mirror", False)))
    plot_options.SetUseAuxOrigin(bool(params.get("useAuxOrigin", False)))
    try:
        plot_options.SetSubtractMaskFromSilk(bool(params.get("subtractMaskFromSilk", False)))
    except Exception:  # pylint: disable=broad-except
        pass
    # Drill marks
    drill_marks = (params.get("drillMarks") or "small").lower()
    try:
        # KiCad's PCB_PLOT_PARAMS exposes the enum as nested constants
        drill_enum = {
            "none": pcbnew.DRILL_MARKS_NO_DRILL_SHAPE,
            "small": pcbnew.DRILL_MARKS_SMALL_DRILL_SHAPE,
            "full": pcbnew.DRILL_MARKS_FULL_DRILL_SHAPE,
        }.get(drill_marks)
        if drill_enum is not None and hasattr(plot_options, "SetDrillMarksType"):
            plot_options.SetDrillMarksType(drill_enum)
    except Exception:  # pylint: disable=broad-except
        pass

    # Filename prefix. KiCad's PLOT_CONTROLLER always prepends the board
    # basename to the OpenPlotfile sheet name. So:
    #   - Default (no prefix): sheet_name=<layer>           → "<basename>-<layer>.svg"
    #   - With prefix:         sheet_name=<prefix>-<layer>  → "<basename>-<prefix>-<layer>.svg"
    # The previous default used basename as the prefix, producing the
    # ugly "<basename>-<basename>-<layer>.svg" doubling.
    board_path = board.GetFileName()
    board_basename = Path(board_path).stem if board_path else "board"  # noqa: F841 — kept for caller refs
    prefix = params.get("filenamePrefix")

    # Plot each layer
    written: list[dict] = []
    plotted_count = 0
    try:
        for lid in layer_ids:
            try:
                plot_controller.SetLayer(lid)
                layer_name = board.GetLayerName(lid)
                # OpenPlotfile takes (sheetName, format, sheetDescription).
                # For multi-layer SVG/PDF this opens a new file per layer.
                # KiCad always prepends the board basename to the output
                # filename, so the actual path is:
                #   <output_dir>/<board_basename>-<sheetName>.<ext>
                sheet_name = f"{prefix}-{layer_name}" if prefix else layer_name
                plot_controller.OpenPlotfile(sheet_name, plot_format, "")
                plot_controller.PlotLayer()
                plot_controller.ClosePlot()
                plotted_count += 1
                # Compute the path KiCad wrote to. Different KiCad versions
                # name it slightly differently; we search the output dir
                # after the fact and capture whatever .svg/.pdf was just
                # created.
                # First attempt: predictable naming.
                ext = "svg" if fmt == "svg" else "pdf"
                # KiCad's output filename is usually <prefix>-<safe_layer_name>.<ext>
                # where safe_layer_name replaces dots with underscores.
                # Just sample the dir for the most recently modified matching file.
                candidates = sorted(out_dir.glob(f"*-{layer_name.replace('.', '_')}.{ext}"),
                                    key=lambda p: p.stat().st_mtime, reverse=True)
                if not candidates:
                    candidates = sorted(out_dir.glob(f"*.{ext}"),
                                        key=lambda p: p.stat().st_mtime, reverse=True)
                actual_path = str(candidates[0]) if candidates else None
                written.append({
                    "layerId": lid,
                    "layerName": layer_name,
                    "filePath": actual_path,
                    "fileSize": candidates[0].stat().st_size if candidates else None,
                })
            except Exception as e:  # pylint: disable=broad-except
                written.append({
                    "layerId": lid,
                    "layerName": board.GetLayerName(lid) if lid >= 0 else None,
                    "_error": f"{type(e).__name__}: {e}",
                })
    finally:
        try:
            plot_controller.ClosePlot()
        except Exception:  # pylint: disable=broad-except
            pass

    return {
        "hasBoard": True,
        "fileName": board.GetFileName(),
        "format": fmt,
        "outputDir": str(out_dir),
        "plottedLayerCount": plotted_count,
        "files": written,
    }


# ── Library navigation (Symbol Editor / Footprint Editor search-box) ──

def _find_search_ctrl_recursive(window) -> object:
    """Recursively find a wx.SearchCtrl (or wx.TextCtrl with placeholder
    containing 'search') under `window`. Returns the control or None.

    wx widget trees are arbitrary deep — Symbol Editor's library panel
    has the search box nested inside a wxAuiManager pane inside a
    wxSplitter inside the main frame. Walk every descendant."""
    try:
        import wx  # type: ignore[import-not-found]
    except ImportError:
        return None
    try:
        # SearchCtrl is the obvious target
        if isinstance(window, wx.SearchCtrl):
            return window
        # KiCad sometimes uses a plain TextCtrl with a search hint
        if isinstance(window, wx.TextCtrl):
            try:
                hint = window.GetHint() if hasattr(window, "GetHint") else ""
                if hint and "search" in hint.lower():
                    return window
            except Exception:  # pylint: disable=broad-except
                pass
        for child in window.GetChildren():
            found = _find_search_ctrl_recursive(child)
            if found is not None:
                return found
    except Exception:  # pylint: disable=broad-except
        pass
    return None


def _ui_navigate_in_editor(editor_kind: str, query: str) -> dict:
    """Generic library-search navigation. Finds the editor's wx frame,
    locates its search control, sets the value, and posts a text-changed
    event so the live filter activates.

    editor_kind: "symbol" or "footprint" (matched against frame title)
    query: text to type into the search box (substring match in KiCad's
           library filter)

    Returns {found, searched, frameTitle, searchCtrlClass, error?}.

    NOTE: This is a NON-DESTRUCTIVE filter. It does NOT click "load symbol"
    or open the editor with the result selected — it just sets the search
    text so the user (or a follow-up wm_command) can act on the filtered list.
    """
    try:
        import wx  # type: ignore[import-not-found]
    except ImportError:
        return {"found": False, "_error": "wx not loaded"}

    needle = f"{editor_kind} editor".lower()
    target_frame = None
    for win in wx.GetTopLevelWindows():
        try:
            title = win.GetTitle() if hasattr(win, "GetTitle") else ""
        except Exception:  # pylint: disable=broad-except
            title = ""
        if needle in title.lower():
            target_frame = win
            break

    if target_frame is None:
        return {
            "found": False,
            "error": f"no '{editor_kind} editor' frame is open",
            "_hint": f"call kicad_open_{editor_kind}_editor first to launch the editor.",
        }

    sctrl = _find_search_ctrl_recursive(target_frame)
    if sctrl is None:
        return {
            "found": False,
            "frameTitle": target_frame.GetTitle(),
            "error": "could not locate search control inside the editor frame",
            "_hint": "the wx widget hierarchy may have changed in this KiCad version. "
                     "Report the KiCad version and try the legacy PowerShell SendKeys path.",
        }

    try:
        sctrl.SetValue(query)
        # Post EVT_TEXT so the live filter activates
        try:
            evt = wx.CommandEvent(wx.EVT_TEXT.typeId, sctrl.GetId())
            evt.SetEventObject(sctrl)
            evt.SetString(query)
            wx.PostEvent(sctrl, evt)
        except Exception:  # pylint: disable=broad-except
            pass
        return {
            "found": True,
            "frameTitle": target_frame.GetTitle(),
            "searchCtrlClass": type(sctrl).__name__,
            "searched": query,
            "_hint": (
                f"Set the library search filter to {query!r}. "
                f"This is FILTER-ONLY — it does NOT load the {editor_kind}. "
                f"The user can now SEE the matching entries in the library "
                f"tree. To filter AND load the first match in one call, use "
                f"select_and_load_{editor_kind} instead (v0.6.0+, "
                f"keystroke-based, no longer segfaults). To revert the "
                f"filter, call navigate_{editor_kind} with an empty query."
            ),
        }
    except Exception as e:  # pylint: disable=broad-except
        return {
            "found": False,
            "frameTitle": target_frame.GetTitle(),
            "error": f"failed to set search value: {type(e).__name__}: {e}",
        }


def _ui_navigate_symbol(symbol_name: str) -> dict:
    return _ui_navigate_in_editor("symbol", symbol_name)


def _ui_navigate_footprint(footprint_name: str) -> dict:
    return _ui_navigate_in_editor("footprint", footprint_name)


def _find_dataview_ctrl_recursive(window) -> object:
    """Recursively find the wx.dataview.DataViewCtrl that holds the
    library tree in Symbol Editor / Footprint Editor. Returns the
    control or None."""
    try:
        import wx  # type: ignore[import-not-found]
        import wx.dataview  # type: ignore[import-not-found]
    except ImportError:
        return None
    try:
        if isinstance(window, wx.dataview.DataViewCtrl):
            return window
        for child in window.GetChildren():
            found = _find_dataview_ctrl_recursive(child)
            if found is not None:
                return found
    except Exception:  # pylint: disable=broad-except
        pass
    return None


def _find_treectrl_recursive(window) -> object:
    """Older KiCad versions or alternative panels may use a plain TreeCtrl.
    Fallback when DataViewCtrl isn't found."""
    try:
        import wx  # type: ignore[import-not-found]
    except ImportError:
        return None
    try:
        if isinstance(window, wx.TreeCtrl):
            return window
        for child in window.GetChildren():
            found = _find_treectrl_recursive(child)
            if found is not None:
                return found
    except Exception:  # pylint: disable=broad-except
        pass
    return None


def _ui_select_and_load(editor_kind: str, name: str, use_send_input: bool = False) -> dict:
    """Filter the editor's library tree by `name`, then KEYSTROKE-SIMULATE
    "first match selected + activated" to load it.

    v0.8.0 (Phase 2.ad): with `use_send_input=True`, foreground
    acquisition uses `_ui_robust_focus` — the AttachThreadInput dance
    PLUS a SendInput LEFT-CLICK at the search ctrl's screen center.
    Previously v0.7.0 used a naive `SetForegroundWindow` which Windows
    frequently downgraded to a taskbar flash, causing SendInput
    keystrokes to land in the terminal/VS Code instead of KiCad. Now
    the click guarantees foreground + child focus; cursor pos is
    snapshotted + restored.

    v0.7.0 (Phase 2.ac): added `use_send_input` opt-in. When True,
    after setting the search filter we focus the editor frame +
    inject real Win32 keystrokes via SendInput (vs the default
    wx.PostEvent which may not propagate to KiCad's
    TOOL_DISPATCHER). SendInput is more reliable but briefly steals
    foreground focus.

    v0.6.0 rewrite: previous impl walked the wx.dataview.DataViewModel
    to find the first leaf, then posted EVT_DATAVIEW_ITEM_ACTIVATED on
    the picked item. That worked on small libraries but SEGFAULTED
    eeschema on real KiCad symbol libraries.

    Returns: {found, loaded?, frameTitle, searchedFor, transport,
              keystrokeMode, error?, _hint?}.
    """
    try:
        import wx  # type: ignore[import-not-found]
    except ImportError:
        return {"found": False, "_error": "wx not loaded"}

    # Step 1+2: navigate (sets the search filter via _ui_navigate_in_editor)
    nav = _ui_navigate_in_editor(editor_kind, name)
    if not nav.get("found"):
        return {**nav, "loaded": False}

    # Step 3: re-resolve the editor frame and the search control to send
    # keystrokes to.
    needle = f"{editor_kind} editor".lower()
    target_frame = None
    for win in wx.GetTopLevelWindows():
        try:
            title = win.GetTitle() if hasattr(win, "GetTitle") else ""
        except Exception:  # pylint: disable=broad-except
            title = ""
        if needle in title.lower():
            target_frame = win
            break
    if target_frame is None:
        return {"found": True, "loaded": False, "searchedFor": name,
                "error": "editor frame disappeared between navigate and activate"}

    sctrl = _find_search_ctrl_recursive(target_frame)
    if sctrl is None:
        return {
            "found": True,
            "loaded": False,
            "searchedFor": name,
            "frameTitle": target_frame.GetTitle(),
            "error": "search control not found",
            "_hint": "navigate_in_editor returned found=True but the search ctrl "
                     "vanished. Likely a wx widget hierarchy change in this "
                     "KiCad version. Fall back to the legacy PowerShell SendKeys "
                     "path via kicad_open_symbol_editor.",
        }

    # Step 4: dispatch the Down + Enter keystrokes.
    #
    # Two transports:
    #   - wx.PostEvent (default): enqueues wx.KeyEvent on wx's event
    #     loop. Doesn't steal foreground. BUT often doesn't propagate
    #     to KiCad's native Win32 / TOOL_DISPATCHER handler.
    #   - SendInput (opt-in via use_send_input): real Win32 keyboard
    #     events. Always reaches KiCad. BUT briefly steals foreground.
    keystroke_mode = "send_input" if use_send_input else "wx_post_event"

    try:
        # Focus the search ctrl regardless of transport — SendInput
        # needs the right window focused, wx.PostEvent benefits from it.
        try:
            sctrl.SetFocus()
        except Exception:  # pylint: disable=broad-except
            pass

        # Give wx one cycle to actually move focus (otherwise SendInput
        # injects into the previous foreground window — typically the
        # CLI terminal or VS Code).
        try:
            wx.YieldIfNeeded()
        except Exception:  # pylint: disable=broad-except
            pass

        if use_send_input:
            # v0.8.0 (Phase 2.ad): robust foreground acquisition before
            # the SendInput keystrokes. v0.7.0 used a naive
            # `SetForegroundWindow(hwnd)` + 150ms sleep, which Windows
            # frequently downgraded to a taskbar flash because the
            # bridge thread didn't have recent user-input context —
            # SendInput keystrokes then landed in whatever window WAS
            # foreground (terminal, VS Code, etc.) instead of KiCad,
            # and verify_loaded always reported hasLoaded:false.
            #
            # _ui_robust_focus does TWO things together:
            #   1. The same keybd_event(0,0) + AttachThreadInput +
            #      BringWindowToTop + SetForegroundWindow dance Tauri's
            #      screenshot.rs uses — documented Win32 pattern.
            #   2. A SendInput LEFT-CLICK at the center of the search
            #      ctrl's screen rect — mouse clicks bypass the
            #      foreground restriction unconditionally AND set
            #      keyboard focus inside the child control. Matches
            #      the legacy navigate_symbol.ps1 mouse_event path.
            #   3. Cursor position is snapshotted + restored so the
            #      user's mouse doesn't end up in a surprising spot.
            try:
                hwnd = int(target_frame.GetHandle())
            except Exception:  # pylint: disable=broad-except
                hwnd = 0

            # Compute the search ctrl's screen center.
            click_x: int | None = None
            click_y: int | None = None
            try:
                screen_rect = sctrl.GetScreenRect()
                click_x = screen_rect.x + screen_rect.width // 2
                click_y = screen_rect.y + screen_rect.height // 2
            except Exception:  # pylint: disable=broad-except
                pass

            focus_detail = _ui_robust_focus(hwnd, click_x, click_y)

            # Send the PROVEN keystroke sequence that the legacy
            # navigate_symbol.ps1 / navigate_footprint.ps1 PowerShell
            # path uses: ENTER to commit the search filter, then
            # CTRL+SHIFT+E to load the highlighted entry. WXK_DOWN
            # alone isn't enough — KiCad's library tree needs the
            # explicit "load symbol" shortcut.
            VK_RETURN = 0x0D
            VK_CONTROL = 0x11
            VK_SHIFT = 0x10
            VK_E = 0x45
            si = _ui_send_input_keystrokes([
                VK_RETURN,                               # commit search
                (VK_CONTROL, VK_SHIFT, VK_E),            # Ctrl+Shift+E
            ])
            keys_sent = ["SendInput VK_RETURN", "SendInput Ctrl+Shift+E"] if si.get("ok") else [f"SendInput failed: {si}"]
            transport_detail = {"sendInput": si, "robustFocus": focus_detail}
        else:
            keys_sent = []
            for keycode, label in ((wx.WXK_DOWN, "WXK_DOWN"), (wx.WXK_RETURN, "WXK_RETURN")):
                try:
                    key_down = wx.KeyEvent(wx.wxEVT_KEY_DOWN)
                    key_down.SetEventObject(sctrl)
                    key_down.SetKeyCode(keycode)
                    wx.PostEvent(sctrl, key_down)

                    key_up = wx.KeyEvent(wx.wxEVT_KEY_UP)
                    key_up.SetEventObject(sctrl)
                    key_up.SetKeyCode(keycode)
                    wx.PostEvent(sctrl, key_up)
                    keys_sent.append(label)
                except Exception as e:  # pylint: disable=broad-except
                    keys_sent.append(f"{label}:error={type(e).__name__}")
            transport_detail = None

        return {
            "found": True,
            # "unverified" — caller must verify_loaded to confirm. SendInput
            # is more reliable but neither is guaranteed; the editor frame
            # title is the ground truth.
            "loaded": "unverified",
            "searchedFor": name,
            "frameTitle": target_frame.GetTitle(),
            "searchCtrlClass": type(sctrl).__name__,
            "keystrokeMode": keystroke_mode,
            "keysSent": keys_sent,
            "transportDetail": transport_detail,
            "_hint": (
                "Keystrokes sent. To CONFIRM the symbol/footprint actually "
                "loaded, call `kicad_bridge_call verify_loaded "
                f"{{\"kind\":\"{editor_kind}\",\"expectedName\":{name!r}}}` after "
                "~500ms. If verify_loaded reports hasLoaded:false, the keystroke "
                "didn't dispatch — retry with useSendInput:true (real Win32 "
                "keyboard input, briefly steals foreground; more reliable but "
                "noisier) or fall back to the legacy path via "
                f"`kicad_open_{editor_kind}_editor '{{\"{editor_kind}Name\":\"...\"}}'`."
                if not use_send_input else
                "Keystrokes sent via Win32 SendInput (real keyboard input). "
                f"Foreground was briefly stolen to {target_frame.GetTitle()!r}. "
                "Call `kicad_bridge_call verify_loaded "
                f"{{\"kind\":\"{editor_kind}\",\"expectedName\":{name!r}}}` to "
                "confirm. If still hasLoaded:false, the search returned zero "
                "matches — refine the query."
            ),
        }
    except Exception as e:  # pylint: disable=broad-except
        return {
            "found": True,
            "loaded": False,
            "searchedFor": name,
            "frameTitle": target_frame.GetTitle(),
            "_error": f"{type(e).__name__}: {e}",
        }


def _ui_dataview_activate_first(dvc, frame, searched_for: str) -> dict:
    """Walk a wx.dataview.DataViewCtrl's model, find the first leaf,
    select it, and post EVT_DATAVIEW_ITEM_ACTIVATED to trigger load."""
    try:
        import wx  # type: ignore[import-not-found]
        import wx.dataview  # type: ignore[import-not-found]
    except ImportError:
        return {"loaded": False, "_error": "wx.dataview unavailable"}

    try:
        model = dvc.GetModel()
        if model is None:
            return {
                "found": True, "loaded": False, "searchedFor": searched_for,
                "frameTitle": frame.GetTitle(),
                "error": "DataViewCtrl has no model",
            }
        # Walk the model: roots → children → leaves. KiCad's library tree
        # model has libraries at the root, symbols/footprints as children.
        # After the filter, hidden items aren't in IsEnabled but are still
        # walkable via GetChildren. We want the first item that IS rendered
        # (i.e. matches the filter).
        #
        # wxPython's DataViewItemArray is list-like — use len() + [i],
        # NOT the C++ GetCount()/Item() which aren't exposed in SWIG.
        children_root = wx.dataview.DataViewItemArray()
        model.GetChildren(wx.dataview.DataViewItem(0), children_root)

        first_leaf = None
        # Recurse: for each root, expand and look for leaf children.
        def _walk(item, depth=0):
            nonlocal first_leaf
            if first_leaf is not None or depth > 8:
                return
            kids = wx.dataview.DataViewItemArray()
            model.GetChildren(item, kids)
            if len(kids) == 0:
                # This is a leaf — pick it
                first_leaf = item
                return
            for i in range(len(kids)):
                _walk(kids[i], depth + 1)

        # Walk each top-level (library) until we find a leaf
        for i in range(len(children_root)):
            _walk(children_root[i])
            if first_leaf is not None:
                break

        if first_leaf is None:
            return {
                "found": True, "loaded": False, "searchedFor": searched_for,
                "frameTitle": frame.GetTitle(),
                "error": "no leaf items in (filtered) library tree",
                "_hint": "The search filter may have hidden all entries. Refine the query or check spelling.",
            }

        # Select + ensure visible
        try:
            dvc.UnselectAll()
            dvc.Select(first_leaf)
            dvc.EnsureVisible(first_leaf)
        except Exception:  # pylint: disable=broad-except
            pass

        # Get a human label for the activated item
        activated_label = ""
        try:
            value = model.GetValue(first_leaf, 0)
            activated_label = str(value) if value is not None else ""
        except Exception:  # pylint: disable=broad-except
            pass

        # Post EVT_DATAVIEW_ITEM_ACTIVATED — same effect as double-clicking
        try:
            evt_type = wx.dataview.EVT_DATAVIEW_ITEM_ACTIVATED.typeId
            evt = wx.dataview.DataViewEvent(evt_type, dvc, first_leaf)
            evt.SetEventObject(dvc)
            wx.PostEvent(dvc, evt)
            posted = True
        except Exception as e:  # pylint: disable=broad-except
            posted = False
            post_err = f"{type(e).__name__}: {e}"

        result = {
            "found": True,
            "loaded": posted,
            "searchedFor": searched_for,
            "frameTitle": frame.GetTitle(),
            "activatedItem": activated_label,
            "treeControlClass": "DataViewCtrl",
        }
        if not posted:
            result["error"] = "PostEvent failed for EVT_DATAVIEW_ITEM_ACTIVATED"
            result["_postError"] = post_err
        return result
    except Exception as e:  # pylint: disable=broad-except
        return {
            "found": True, "loaded": False, "searchedFor": searched_for,
            "frameTitle": frame.GetTitle(),
            "_error": f"{type(e).__name__}: {e}",
        }


def _ui_treectrl_activate_first(tree, frame, searched_for: str) -> dict:
    """Fallback: classic wx.TreeCtrl. Walk to first leaf + post
    EVT_TREE_ITEM_ACTIVATED."""
    try:
        import wx  # type: ignore[import-not-found]
    except ImportError:
        return {"loaded": False, "_error": "wx unavailable"}
    try:
        root = tree.GetRootItem()
        if not root.IsOk():
            return {
                "found": True, "loaded": False, "searchedFor": searched_for,
                "frameTitle": frame.GetTitle(),
                "error": "TreeCtrl has no root",
            }

        def _walk_tree(item):
            # If item has no children, it's a leaf.
            cookie = 0
            try:
                child, cookie = tree.GetFirstChild(item)
            except Exception:  # pylint: disable=broad-except
                child = None
            if not child or not child.IsOk():
                return item
            while child and child.IsOk():
                found = _walk_tree(child)
                if found is not None:
                    return found
                try:
                    child, cookie = tree.GetNextChild(item, cookie)
                except Exception:  # pylint: disable=broad-except
                    break
            return None

        leaf = None
        # If the root is hidden, walk its children
        if tree.HasFlag(wx.TR_HIDE_ROOT):
            cookie = 0
            try:
                child, cookie = tree.GetFirstChild(root)
            except Exception:  # pylint: disable=broad-except
                child = None
            while child and child.IsOk() and leaf is None:
                leaf = _walk_tree(child)
                try:
                    child, cookie = tree.GetNextChild(root, cookie)
                except Exception:  # pylint: disable=broad-except
                    break
        else:
            leaf = _walk_tree(root)

        if leaf is None:
            return {
                "found": True, "loaded": False, "searchedFor": searched_for,
                "frameTitle": frame.GetTitle(),
                "error": "no leaf in (filtered) TreeCtrl",
            }

        tree.SelectItem(leaf)
        tree.EnsureVisible(leaf)
        label = tree.GetItemText(leaf)
        # Post EVT_TREE_ITEM_ACTIVATED
        try:
            evt = wx.TreeEvent(wx.EVT_TREE_ITEM_ACTIVATED.typeId, tree, leaf)
            evt.SetEventObject(tree)
            wx.PostEvent(tree, evt)
            posted = True
        except Exception:  # pylint: disable=broad-except
            posted = False
        return {
            "found": True,
            "loaded": posted,
            "searchedFor": searched_for,
            "frameTitle": frame.GetTitle(),
            "activatedItem": label,
            "treeControlClass": "TreeCtrl",
        }
    except Exception as e:  # pylint: disable=broad-except
        return {"found": True, "loaded": False, "searchedFor": searched_for,
                "frameTitle": frame.GetTitle(),
                "_error": f"{type(e).__name__}: {e}"}


def _ui_select_and_load_symbol(symbol_name: str, use_send_input: bool = False) -> dict:
    return _ui_select_and_load("symbol", symbol_name, use_send_input=use_send_input)


def _ui_select_and_load_footprint(footprint_name: str, use_send_input: bool = False) -> dict:
    return _ui_select_and_load("footprint", footprint_name, use_send_input=use_send_input)


# ── verify_loaded + SendInput fallback (Phase 2.ac) ──────────────────

# KiCad's editor frame titles when nothing is loaded. Used by both
# verify_loaded and select_and_load to confirm load success.
_NO_LOAD_MARKERS = {
    "symbol": ("[no symbol loaded]",),
    "footprint": ("[no footprint loaded]",),
}


def _ui_verify_loaded(editor_kind: str, expected_name: str = "") -> dict:
    """Read the editor frame title and report whether the editor has a
    symbol/footprint loaded. Phase 2.ac+.

    Args:
      editor_kind:   "symbol" or "footprint"
      expected_name: optional — if non-empty, check the title CONTAINS
                     this substring. Use to confirm a specific symbol/
                     footprint was loaded by a prior select_and_load_*.

    Returns:
      {found: bool,           — was the editor frame found at all?
       frameTitle: str,
       hasLoaded: bool,       — is something loaded? (title doesn't contain
                                "[no <kind> loaded]" marker)
       loadedName: str?,      — best-effort: extracted symbol/footprint name
       matchesExpected: bool? — only present if expected_name was passed
       _hint}
    """
    try:
        import wx  # type: ignore[import-not-found]
    except ImportError:
        return {"found": False, "_error": "wx not loaded"}

    needle = f"{editor_kind} editor".lower()
    target_frame = None
    for win in wx.GetTopLevelWindows():
        try:
            title = win.GetTitle() if hasattr(win, "GetTitle") else ""
        except Exception:  # pylint: disable=broad-except
            title = ""
        if needle in title.lower():
            target_frame = win
            break
    if target_frame is None:
        return {
            "found": False,
            "_hint": f"No '{editor_kind} editor' frame is currently open. "
                     f"Call kicad_open_{editor_kind}_editor first, then retry.",
        }

    title = target_frame.GetTitle()
    no_load_markers = _NO_LOAD_MARKERS.get(editor_kind, ())
    has_loaded = not any(marker in title for marker in no_load_markers)

    # Extract the loaded name from the title. KiCad's editor title format:
    #   "Library:Name — Symbol Editor"        (saved/loaded item, no edits)
    #   "Library:Name * — Symbol Editor"      (loaded but modified)
    #   "[no symbol loaded] — Symbol Editor"  (nothing loaded)
    # The "— Symbol Editor" / "— Footprint Editor" suffix is constant; we
    # split on it to isolate the prefix.
    loaded_name = None
    if has_loaded:
        # Try em-dash separator first (KiCad uses U+2014)
        for sep in ("—", "-"):
            if sep in title:
                prefix = title.split(sep, 1)[0].strip()
                # Strip trailing modification marker
                prefix = prefix.rstrip(" *")
                if prefix:
                    loaded_name = prefix
                break

    result: dict = {
        "found": True,
        "frameTitle": title,
        "hasLoaded": has_loaded,
        "loadedName": loaded_name,
    }

    if expected_name:
        if has_loaded and loaded_name:
            # Allow substring match — "R_US" matches "Device:R_US",
            # "RP2040" matches "Adom:RP2040", etc.
            result["matchesExpected"] = expected_name.lower() in loaded_name.lower()
        else:
            result["matchesExpected"] = False
        result["expectedName"] = expected_name

    if not has_loaded:
        result["_hint"] = (
            f"The {editor_kind} editor is OPEN but nothing is loaded yet. "
            f"If you just called select_and_load_{editor_kind}, the keystroke "
            f"may not have dispatched (wx KeyEvent doesn't always reach KiCad's "
            f"TOOL_DISPATCHER). Retry with useSendInput:true for real Win32 "
            f"keyboard input, OR fall back to "
            f"kicad_open_{editor_kind}_editor '{{\"{editor_kind}Name\":\"...\"}}' "
            f"which uses the proven PowerShell SendKeys path."
        )
    elif expected_name and not result["matchesExpected"]:
        result["_hint"] = (
            f"The {editor_kind} editor has {loaded_name!r} loaded, NOT "
            f"{expected_name!r}. The search filter may have matched a different "
            f"item first. Refine your query (e.g. include the library prefix: "
            f"\"Device:R_US\" instead of just \"R\"), or use "
            f"kicad_open_{editor_kind}_editor with the exact name."
        )
    else:
        # Loaded + (no expected or matches)
        result["_hint"] = (
            f"✅ {editor_kind} editor has {loaded_name!r} loaded. "
            f"Safe to drive further edits via the editor's GUI (or wait for "
            f"future Phase 2.ad bridge methods to manipulate the loaded item in-place)."
        )

    return result


def _ui_send_input_keystrokes(keys) -> dict:
    """Inject real Win32 keystrokes via SendInput. Each item in `keys` is
    either:
      - a virtual key code (int) for a tap (down + up), e.g. VK_RETURN
      - a tuple of VK ints to chord (e.g. (VK_CONTROL, VK_SHIFT, 0x45)
        for Ctrl+Shift+E — held down in order, then released in reverse).

    Trade-off vs wx.PostEvent: SendInput sends keystrokes at the OS
    level — they reach whatever window has foreground focus. So:
      1. The caller must ensure the right window has focus FIRST
         (we do via SetForegroundWindow on the editor frame).
      2. SendInput briefly STEALS foreground from whatever was active.

    Returns: {ok: bool, totalEvents: int, _error?}.
    """
    try:
        import ctypes
        import ctypes.wintypes as wt
    except ImportError:
        return {"ok": False, "_error": "ctypes not available"}

    INPUT_KEYBOARD = 1
    KEYEVENTF_KEYUP = 0x0002

    class KEYBDINPUT(ctypes.Structure):
        _fields_ = [
            ("wVk", wt.WORD),
            ("wScan", wt.WORD),
            ("dwFlags", wt.DWORD),
            ("time", wt.DWORD),
            ("dwExtraInfo", ctypes.POINTER(wt.ULONG)),
        ]

    class _INPUT_UNION(ctypes.Union):
        _fields_ = [("ki", KEYBDINPUT), ("padding", ctypes.c_byte * 32)]

    class INPUT(ctypes.Structure):
        _anonymous_ = ("u",)
        _fields_ = [("type", wt.DWORD), ("u", _INPUT_UNION)]

    SendInput = ctypes.windll.user32.SendInput
    SendInput.argtypes = [wt.UINT, ctypes.POINTER(INPUT), ctypes.c_int]
    SendInput.restype = wt.UINT

    def _add_event(inputs_list, vk, key_up):
        inp = INPUT()
        inp.type = INPUT_KEYBOARD
        inp.ki = KEYBDINPUT(
            wVk=vk,
            wScan=0,
            dwFlags=KEYEVENTF_KEYUP if key_up else 0,
            time=0,
            dwExtraInfo=ctypes.cast(ctypes.pointer(wt.ULONG(0)),
                                    ctypes.POINTER(wt.ULONG)),
        )
        inputs_list.append(inp)

    inputs: list[INPUT] = []
    for item in keys:
        if isinstance(item, (tuple, list)):
            # Chord: press each VK down in order, then release in reverse
            for vk in item:
                _add_event(inputs, vk, False)
            for vk in reversed(item):
                _add_event(inputs, vk, True)
        else:
            # Tap: down + up
            _add_event(inputs, item, False)
            _add_event(inputs, item, True)

    arr_type = INPUT * len(inputs)
    arr = arr_type(*inputs)
    sent = SendInput(len(inputs), arr, ctypes.sizeof(INPUT))
    return {"ok": sent == len(inputs), "totalEvents": len(inputs)}


def _ui_robust_focus(hwnd: int,
                     click_x: int | None = None,
                     click_y: int | None = None,
                     restore_cursor: bool = True) -> dict:
    """Reliably bring a Win32 window to foreground AND set keyboard focus
    inside it.

    The naive `SetForegroundWindow(hwnd)` is unreliable on modern Windows:
    Microsoft restricts foreground-switching to processes that recently
    handled user input, so a bare call often just flashes the taskbar
    button instead of actually granting foreground. v0.7.0's
    `_ui_select_and_load(use_send_input=True)` did exactly the naive call
    and SendInput keystrokes frequently landed in the previously-focused
    window (terminal, VS Code, etc.) instead of KiCad.

    v0.8.0 (Phase 2.ad): two complementary techniques run together:
      1. The same `force_foreground` dance Tauri's screenshot.rs uses —
         send a phantom keybd_event to mark "this thread had user input,"
         AttachThreadInput to share the foreground thread's input queue,
         then BringWindowToTop + SetForegroundWindow. Documented Win32
         pattern; works reliably across UAC/elevation boundaries.
      2. If `click_x`/`click_y` are provided, a SendInput LEFT-CLICK at
         those screen coordinates. Mouse clicks are unconditionally
         treated as user input by Windows — they bring the owning window
         to foreground AND, for clicks inside an edit control, set
         keyboard focus there. This matches the legacy PowerShell
         `navigate_symbol.ps1` `mouse_event` path that historically
         worked for KiCad's symbol-chooser search ctrl. `restore_cursor`
         (default True) snapshots cursor pos before the click and
         restores it after — avoids surprising the user with a cursor
         jump on a different monitor.

    Returns: {ok, hwnd, foregroundOk, clicked, restoreCursorOk, _error?}.
    """
    try:
        import ctypes
        import ctypes.wintypes as wt
        import time
    except ImportError:
        return {"ok": False, "_error": "ctypes not available"}

    user32 = ctypes.windll.user32
    kernel32 = ctypes.windll.kernel32

    # ── Step 1: AttachThreadInput dance to acquire real foreground. ──
    foreground_ok = False
    try:
        # Phantom keybd_event(0, 0, 0, 0): VK=0 is invalid, no real
        # keystroke fires, but Windows flips an internal bit saying
        # "this thread just had user input" — which is the gate that
        # allows SetForegroundWindow to actually grant foreground.
        user32.keybd_event(0, 0, 0, 0)
        user32.keybd_event(0, 0, 0x0002, 0)  # KEYEVENTF_KEYUP

        target_tid = user32.GetWindowThreadProcessId(hwnd, None)
        our_tid = kernel32.GetCurrentThreadId()
        attached = False
        if target_tid != 0 and target_tid != our_tid:
            attached = bool(user32.AttachThreadInput(our_tid, target_tid, True))

        # Un-iconify if minimized.
        if user32.IsIconic(hwnd):
            user32.ShowWindow(hwnd, 9)  # SW_RESTORE

        user32.BringWindowToTop(hwnd)
        foreground_ok = bool(user32.SetForegroundWindow(hwnd))

        if attached:
            user32.AttachThreadInput(our_tid, target_tid, False)
    except Exception as e:  # pylint: disable=broad-except
        return {"ok": False, "hwnd": hwnd,
                "_error": f"foreground dance failed: {type(e).__name__}: {e}"}

    # ── Step 2: optional left-click at (click_x, click_y). ──
    clicked = False
    restore_cursor_ok = False
    saved_pt = None
    if click_x is not None and click_y is not None:
        try:
            if restore_cursor:
                class POINT(ctypes.Structure):
                    _fields_ = [("x", wt.LONG), ("y", wt.LONG)]
                saved_pt = POINT()
                if user32.GetCursorPos(ctypes.byref(saved_pt)):
                    pass
                else:
                    saved_pt = None

            # Move cursor to target coords (absolute pixels).
            user32.SetCursorPos(int(click_x), int(click_y))
            # Brief settle — too fast and Windows may merge events.
            time.sleep(0.05)

            # SendInput left-click. We use mouse_event for simplicity
            # (it's still supported on modern Windows; the SendInput
            # path requires building a MOUSEINPUT structure and we'd
            # otherwise share no code with the keyboard helper).
            MOUSEEVENTF_LEFTDOWN = 0x0002
            MOUSEEVENTF_LEFTUP = 0x0004
            user32.mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0)
            time.sleep(0.03)
            user32.mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, 0)
            clicked = True
            time.sleep(0.08)  # let the click propagate (focus change)

            # Restore cursor to where the user had it.
            if restore_cursor and saved_pt is not None:
                if user32.SetCursorPos(saved_pt.x, saved_pt.y):
                    restore_cursor_ok = True
        except Exception as e:  # pylint: disable=broad-except
            return {
                "ok": foreground_ok,  # foreground may have succeeded
                "hwnd": hwnd,
                "foregroundOk": foreground_ok,
                "clicked": clicked,
                "restoreCursorOk": restore_cursor_ok,
                "_error": f"mouse-click step failed: {type(e).__name__}: {e}",
            }

    return {
        "ok": foreground_ok,
        "hwnd": hwnd,
        "foregroundOk": foreground_ok,
        "clicked": clicked,
        "restoreCursorOk": restore_cursor_ok,
    }


def _ui_wm_command(frame_index: int, menu_id: int) -> dict:
    """Send a Win32 WM_COMMAND to the given top-level frame's HWND.

    NOTE: Initial impl used wx.PostEvent with EVT_MENU — events fired but
    KiCad's menu-handler chain (which lives in C++ on TOOL_ACTIONs) did
    NOT pick them up. The proven path is Win32 PostMessageW(WM_COMMAND),
    which is how the external Phase 4 probe-drive.py opened sub-editors
    successfully. The native menu-bar dispatch routes through KiCad's
    TOOL_DISPATCHER which DOES respond to Win32 messages.

    Trade-off: only works on Windows. Cross-platform port (Phase 2.x)
    would need to find the wx handler that KiCad bound at C++ level —
    likely needs a small C++ shim or a call into KiCad's TOOL_MANAGER
    via the pcbnew swig binding.
    """
    import wx  # type: ignore[import-not-found]
    import ctypes
    import ctypes.wintypes as wt

    windows = list(wx.GetTopLevelWindows())
    if not (0 <= frame_index < len(windows)):
        raise IndexError(f"frame_index {frame_index} out of range (have {len(windows)} frames)")
    win = windows[frame_index]
    hwnd = int(win.GetHandle()) if hasattr(win, "GetHandle") else 0
    if not hwnd:
        raise RuntimeError(f"frame[{frame_index}] has no HWND")

    WM_COMMAND = 0x0111
    user32 = ctypes.windll.user32
    PostMessageW = user32.PostMessageW
    PostMessageW.argtypes = [wt.HWND, ctypes.c_uint, wt.WPARAM, wt.LPARAM]
    PostMessageW.restype = wt.BOOL
    ok = PostMessageW(hwnd, WM_COMMAND, menu_id, 0)

    return {
        "frameClass": type(win).__name__,
        "frameTitle": win.GetTitle() if hasattr(win, "GetTitle") else "",
        "hwnd": hwnd,
        "menuId": menu_id,
        "transport": "win32-postmessage-wm-command",
        "posted": bool(ok),
    }


# --- request dispatcher ----------------------------------------------------

def _dispatch(method: str, params: dict) -> Any:
    if method == "ping":
        return {
            "version": __version__,
            "exeName": _state["exe_name"],
            "pid": _state["pid"],
            "uptimeMs": int(time.time() * 1000) - _STARTED_AT_MS,
            "requestCount": _state["request_count"],
        }
    if method == "list_frames":
        return {"frames": _call_on_ui_thread(_ui_list_frames)}
    if method == "get_menu_ids":
        return {"frames": _call_on_ui_thread(_ui_get_menu_ids)}
    if method == "wm_command":
        frame_index = int(params.get("frame_index", 0))
        menu_id = int(params["menu_id"])
        return _call_on_ui_thread(lambda: _ui_wm_command(frame_index, menu_id))
    if method == "get_board_info":
        return _call_on_ui_thread(_ui_get_board_info)
    if method == "get_schematic_info":
        return _call_on_ui_thread(_ui_get_schematic_info)
    if method == "get_open_editors":
        return _call_on_ui_thread(_ui_get_open_editors)
    if method == "get_pcb_footprints":
        return _call_on_ui_thread(_ui_get_pcb_footprints)
    if method == "get_pcb_layers":
        return _call_on_ui_thread(_ui_get_pcb_layers)
    if method == "get_pcb_nets":
        return _call_on_ui_thread(_ui_get_pcb_nets)
    if method == "get_pcb_design_rules":
        return _call_on_ui_thread(_ui_get_pcb_design_rules)
    if method == "navigate_symbol":
        query = params.get("symbolName") or params.get("query") or ""
        return _call_on_ui_thread(lambda: _ui_navigate_symbol(query))
    if method == "navigate_footprint":
        query = params.get("footprintName") or params.get("query") or ""
        return _call_on_ui_thread(lambda: _ui_navigate_footprint(query))
    if method == "get_pcb_tracks":
        return _call_on_ui_thread(_ui_get_pcb_tracks)
    if method == "get_pcb_vias":
        return _call_on_ui_thread(_ui_get_pcb_vias)
    if method == "get_pcb_pads":
        return _call_on_ui_thread(_ui_get_pcb_pads)
    if method == "get_pcb_drawings":
        return _call_on_ui_thread(_ui_get_pcb_drawings)
    if method == "export_svg":
        return _call_on_ui_thread(lambda: _ui_export_pcb_plot("svg", params), timeout=60.0)
    if method == "export_pdf":
        return _call_on_ui_thread(lambda: _ui_export_pcb_plot("pdf", params), timeout=60.0)
    if method == "get_pcb_zones":
        return _call_on_ui_thread(_ui_get_pcb_zones)
    if method == "get_footprint_connections":
        ref = params.get("ref") or ""
        if not ref:
            raise ValueError("get_footprint_connections requires params.ref (e.g. 'U1')")
        return _call_on_ui_thread(lambda: _ui_get_footprint_connections(ref))
    if method == "get_drc_markers":
        return _call_on_ui_thread(_ui_get_drc_markers)
    if method == "run_drc_inprocess":
        return _call_on_ui_thread(_ui_run_drc_inprocess, timeout=15.0)
    if method == "get_track_paths_by_net":
        net = params.get("netName") or params.get("net") or ""
        if not net:
            raise ValueError("get_track_paths_by_net requires params.netName (e.g. 'VCC')")
        return _call_on_ui_thread(lambda: _ui_get_track_paths_by_net(net))
    # Phase 2.ac (v0.7.0): keystroke-based, no more segfault. Optional
    # useSendInput:true escalates from wx.PostEvent (safe, may not
    # propagate) to real Win32 SendInput (reliable, briefly steals
    # foreground). Always pair with verify_loaded for confirmation.
    if method == "select_and_load_symbol":
        name = params.get("symbolName") or params.get("name") or ""
        use_si = bool(params.get("useSendInput"))
        return _call_on_ui_thread(lambda: _ui_select_and_load_symbol(name, use_si), timeout=30.0)
    if method == "select_and_load_footprint":
        name = params.get("footprintName") or params.get("name") or ""
        use_si = bool(params.get("useSendInput"))
        return _call_on_ui_thread(lambda: _ui_select_and_load_footprint(name, use_si), timeout=30.0)
    if method == "verify_loaded":
        kind = params.get("kind") or ""
        if kind not in ("symbol", "footprint"):
            raise ValueError("verify_loaded requires params.kind = 'symbol' or 'footprint'")
        expected = params.get("expectedName", "")
        return _call_on_ui_thread(lambda: _ui_verify_loaded(kind, expected))
    if method == "get_net_topology":
        net = params.get("netName") or params.get("net") or ""
        if not net:
            raise ValueError("get_net_topology requires params.netName (e.g. 'VCC')")
        return _call_on_ui_thread(lambda: _ui_get_net_topology(net))
    raise ValueError(f"unknown method: {method}")


# --- HTTP server -----------------------------------------------------------

class _RpcHandler(BaseHTTPRequestHandler):
    # Silence the default stderr request logging - it spams the KiCad
    # console window. If we ever need to debug, set
    # ADOM_KICAD_BRIDGE_VERBOSE=1 in the environment.
    def log_message(self, format, *args):  # noqa: A002 - matches stdlib sig
        if os.environ.get("ADOM_KICAD_BRIDGE_VERBOSE") == "1":
            super().log_message(format, *args)

    def _send_json(self, status: int, payload: dict):
        body = json.dumps(payload).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Access-Control-Allow-Origin", "*")
        self.end_headers()
        self.wfile.write(body)

    def do_GET(self):  # noqa: N802 - stdlib name
        if self.path == "/" or self.path == "/status":
            self._send_json(200, {
                "ok": True,
                "version": __version__,
                "exeName": _state["exe_name"],
                "pid": _state["pid"],
                "port": _state["port"],
                "requestCount": _state["request_count"],
                "uptimeMs": int(time.time() * 1000) - _STARTED_AT_MS,
            })
            return
        self._send_json(404, {"ok": False, "error": "not found"})

    def do_POST(self):  # noqa: N802 - stdlib name
        if self.path != "/rpc":
            self._send_json(404, {"ok": False, "error": "post to /rpc"})
            return
        _state["request_count"] += 1
        try:
            length = int(self.headers.get("Content-Length", "0"))
        except ValueError:
            length = 0
        try:
            raw = self.rfile.read(length) if length > 0 else b""
            req = json.loads(raw.decode("utf-8")) if raw else {}
        except json.JSONDecodeError as e:
            self._send_json(400, {"id": None, "error": {"code": -32700, "message": f"parse error: {e}"}})
            return

        req_id = req.get("id")
        method = req.get("method")
        params = req.get("params") or {}
        if not method:
            self._send_json(400, {"id": req_id, "error": {"code": -32600, "message": "missing 'method'"}})
            return
        try:
            result = _dispatch(method, params)
            self._send_json(200, {"id": req_id, "result": result})
        except (ValueError, KeyError, IndexError) as e:
            self._send_json(400, {"id": req_id, "error": {"code": -32602, "message": str(e)}})
        except TimeoutError as e:
            self._send_json(504, {"id": req_id, "error": {"code": -32001, "message": f"ui timeout: {e}"}})
        except Exception as e:  # pylint: disable=broad-except
            self._send_json(500, {"id": req_id, "error": {
                "code": -32000,
                "message": f"{type(e).__name__}: {e}",
                "data": traceback.format_exc(),
            }})


def _run_server(host: str, port: int):
    """Entry point for the bridge thread."""
    try:
        server = ThreadingHTTPServer((host, port), _RpcHandler)
        server.daemon_threads = True
        _state["running"] = True
        _state["port"] = port
        _write_discovery()
        try:
            server.serve_forever(poll_interval=0.5)
        finally:
            _state["running"] = False
            _remove_discovery()
    except Exception:  # pylint: disable=broad-except
        try:
            (_OUT_DIR / f"adom-kicad-bridge-{_state['exe_name']}.error.log").write_text(
                traceback.format_exc(), encoding="utf-8"
            )
        except OSError:
            pass


def _pick_port() -> Optional[int]:
    """Return the port this exe should listen on, or None if every
    attempt is taken."""
    offset = _EXE_PORT_OFFSET.get(_state["exe_name"])
    if offset is None:
        offset = max(_EXE_PORT_OFFSET.values()) + 1
    base = _BASE_PORT + offset
    for attempt in range(11):
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            try:
                s.bind(("127.0.0.1", base + attempt))
                return base + attempt
            except OSError:
                continue
    return None


_bridge_thread: Optional[threading.Thread] = None


def start():
    """Public entry point - called from usercustomize.py.

    Idempotent: a second call does nothing. Honors the kill-switch env
    var. Returns the port the bridge is listening on, or None if it
    didn't start.
    """
    global _bridge_thread  # pylint: disable=global-statement
    if os.environ.get("ADOM_KICAD_BRIDGE_DISABLE") == "1":
        return None
    if _bridge_thread is not None and _bridge_thread.is_alive():
        return _state.get("port")

    # Only start if wx is actually loaded. Headless python.exe
    # invocations have nothing to bridge.
    try:
        import wx  # type: ignore[import-not-found, unused-import]  # noqa: F401
    except ImportError:
        return None

    port = _pick_port()
    if port is None:
        return None

    _bridge_thread = threading.Thread(
        target=_run_server,
        args=("127.0.0.1", port),
        name=f"adom-kicad-bridge-{_state['exe_name']}",
        daemon=True,
    )
    _bridge_thread.start()
    # Give the HTTP server a beat to bind. If it failed, the discovery
    # file won't appear and the orchestrator will know.
    time.sleep(0.1)
    return port


def status() -> dict:
    return dict(_state)
