123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857
"""Auto-detect KiCad installation on Windows and macOS.

Windows: globs C:/Program Files/KiCad/<version>/ for parallel installs.
macOS:   scans /Applications/ for KiCad.app bundles (KiCad.app, KiCad-nightly.app, etc.)
         and reads CFBundleShortVersionString from Info.plist for the version.

Both platforms return the same dict shape so handlers can stay platform-agnostic.
Returns a sorted list (newest first); callers default to the latest unless they
explicitly request an older version via the `kicadVersion` arg on commands.
"""

import os
import re
import subprocess
from proc import run as _safe_run
import sys
from pathlib import Path

IS_WINDOWS = sys.platform == "win32"
IS_MACOS = sys.platform == "darwin"

# ── Windows paths ────────────────────────────────────────────────────────────
# v1.6.0+: scan ALL the locations a Windows KiCad install might land. v1.5.x
# only checked C:/Program Files/KiCad — that missed at least one user whose
# install was elsewhere (winget per-user install, custom dir, etc.) on a
# fresh-Windows-machine deploy. We now also check %LOCALAPPDATA%/Programs/
# (winget --scope user target) and the Windows registry's KiCad install key.
WIN_KICAD_BASES = [
    Path("C:/Program Files/KiCad"),
    Path("C:/Program Files (x86)/KiCad"),
]
_local = os.environ.get("LOCALAPPDATA")
if _local:
    WIN_KICAD_BASES.append(Path(_local) / "Programs" / "KiCad")
# Also look directly in LOCALAPPDATA (rare, but seen in some manual installs)
if _local:
    WIN_KICAD_BASES.append(Path(_local) / "KiCad")
# Legacy single-base name kept for back-compat with any callers that import it
WIN_KICAD_BASE = WIN_KICAD_BASES[0]

# ── macOS paths ──────────────────────────────────────────────────────────────
# KiCad on macOS installs individual .app bundles into /Applications/KiCad/.
# The "main" bundle is KiCad.app; Schematic Editor.app, PCB Editor.app, etc.
# are siblings and share the same install version.
MAC_APPS_DIR = Path("/Applications")
MAC_KICAD_DIR = MAC_APPS_DIR / "KiCad"


def _version_tuple(version: str):
    """Parse '10.0' / '9.0' / '8.0.4' into a sortable tuple. Falls back to (0,) on parse error."""
    parts = []
    for piece in re.split(r"[.\-_]", str(version)):
        m = re.match(r"^(\d+)", piece)
        if m:
            parts.append(int(m.group(1)))
        else:
            parts.append(0)
    return tuple(parts) if parts else (0,)


# ╔═══════════════════════════════════════════════════════════════════════════╗
# ║                              WINDOWS                                       ║
# ╚═══════════════════════════════════════════════════════════════════════════╝

def _win_build_info(version: str, base_override: Path | None = None) -> dict | None:
    """Build the kicad_info dict for a Windows install version dir, or None
    if it doesn't look like a real install. v1.6.0+: accepts an explicit
    base override so we can describe installs in non-standard locations
    (LOCALAPPDATA/Programs, custom dirs from registry) the same way the
    Program Files install gets described.

    v1.7.4+ (KiCad v2 Phase 1): enriched with pythonVersion + pluginDir +
    adomPlugin status + IPC API probe + libraryTablePaths grouping. The
    raw fields stay (back-compat); the new fields are additive.
    """
    base = (base_override or WIN_KICAD_BASE) / version if base_override is None or not base_override.name == version else base_override
    # If caller passed a base that already names the version dir, base==base_override.
    # Otherwise we joined version onto a parent. Either way `base` is the version dir.
    bin_dir = base / "bin"
    kicad_exe = bin_dir / "kicad.exe"

    if not kicad_exe.exists():
        return None

    config_dir = _win_find_config_dir(version)
    user_dir = _win_find_user_dir(version)
    # v1.7.4+: also resolve the per-version plugin dir + Adom plugin status.
    plugin_dir = _win_find_plugin_dir(version)
    adom_plugin_state = _adom_plugin_state(plugin_dir)
    # v1.7.5+ (KiCad v2 Phase 2): the cross-process reverse bridge lives
    # at the KiCad embedded-Python USER_SITE, not the old pcbnew action-
    # plugin dir. usercustomize.py + adom_bridge.py land here and load
    # in every KiCad GUI process at startup.
    user_site_dir = _win_find_user_site_dir(version)
    bridge_plugin_state = _adom_bridge_state(user_site_dir)
    # Probe the bundled python for version + IPC API availability. Cheap
    # subprocess call — only runs once per detection pass.
    python_exe = bin_dir / "python.exe"
    py_version = _probe_python_version(python_exe)
    ipc_state = _probe_ipc_api(python_exe)
    # Adom library inventory — read the user's sym-lib-table + fp-lib-table
    # for entries whose name starts with "Adom" and count symbols/footprints.
    adom_lib_state = _adom_library_stats(
        config_dir / "sym-lib-table" if config_dir else None,
        config_dir / "fp-lib-table" if config_dir else None,
        version,
    )

    return {
        # ── Legacy fields (kept verbatim for back-compat with v1.7.3 callers) ──
        "installed": True,
        "platform": "windows",
        "version": version,
        "base_dir": str(base),
        "bin_dir": str(bin_dir),
        "kicad_exe": str(kicad_exe),
        "pcbnew_exe": str(bin_dir / "pcbnew.exe"),
        "eeschema_exe": str(bin_dir / "eeschema.exe"),
        "kicad_cli_exe": str(bin_dir / "kicad-cli.exe"),
        "python_exe": str(python_exe),
        "config_dir": str(config_dir) if config_dir else None,
        "user_dir": str(user_dir) if user_dir else None,
        "sym_lib_table": str(config_dir / "sym-lib-table") if config_dir else None,
        "fp_lib_table": str(config_dir / "fp-lib-table") if config_dir else None,
        "user_symbols_dir": str(user_dir / "symbols") if user_dir else None,
        "user_footprints_dir": str(user_dir / "footprints") if user_dir else None,
        "user_3dmodels_dir": str(user_dir / "3dmodels") if user_dir else None,
        # ── v1.7.4 enriched fields (KiCad v2 Phase 1) ──
        "installPath": str(base),                       # alias for base_dir, camelCase per PLAN
        "binaries": {                                   # grouped per PLAN.md
            "kicad": str(kicad_exe),
            "eeschema": str(bin_dir / "eeschema.exe"),
            "pcbnew": str(bin_dir / "pcbnew.exe"),
            "kicadCli": str(bin_dir / "kicad-cli.exe"),
            "python": str(python_exe),
        },
        "pythonVersion": py_version,                    # e.g. "3.11.5", or None if probe failed
        "pluginDir": str(plugin_dir) if plugin_dir else None,
        "adomPluginInstalled": adom_plugin_state["installed"],
        "adomPluginVersion": adom_plugin_state["version"],
        # v1.7.5+ KiCad v2 Phase 2: cross-process reverse bridge state
        "userSiteDir": str(user_site_dir) if user_site_dir else None,
        "adomBridgeInstalled": bridge_plugin_state["installed"],
        "adomBridgeVersion": bridge_plugin_state["version"],
        "adomBridgeLoaderInstalled": bridge_plugin_state["loaderInstalled"],
        "ipcApiAvailable": ipc_state["available"],      # False on KiCad ≤10; True if `kipy` importable
        "ipcApiScope": ipc_state["scope"],              # ["pcb"] etc.; [] when not available
        "libraryTablePaths": {                          # grouped per PLAN.md
            "symLibTable": str(config_dir / "sym-lib-table") if config_dir else None,
            "fpLibTable": str(config_dir / "fp-lib-table") if config_dir else None,
        },
        "adomLibrary": adom_lib_state,                  # symbol/footprint inventory
    }


def _win_find_config_dir(version: str):
    """Find KiCad config dir on Windows: %APPDATA%/kicad/<version>/"""
    appdata = os.environ.get("APPDATA", "")
    if not appdata:
        return None
    config = Path(appdata) / "kicad" / version
    return config if config.exists() else None


def _win_find_user_dir(version: str):
    """Find KiCad user dir on Windows: %USERPROFILE%/Documents/KiCad/<version>/"""
    userprofile = os.environ.get("USERPROFILE", "")
    if not userprofile:
        return None
    user = Path(userprofile) / "Documents" / "KiCad" / version
    return user if user.exists() else None


# v1.7.4+ (KiCad v2 Phase 1): enriched-discovery helpers.
# All return None / empty / False on failure — never raise. Detection has to be
# best-effort: the caller iterates over multiple installs and one buggy install
# shouldn't break the whole list.

def _win_find_user_site_dir(version: str) -> Path | None:
    """Find KiCad's embedded-Python USER_SITE on Windows.

    KiCad's bundled `sitecustomize.py` overrides `site.USER_SITE` to
    `%USERPROFILE%/Documents/KiCad/<version>/3rdparty/Python311/site-packages/`.
    This is where Phase 2's reverse-bridge plugin (adom_bridge.py +
    usercustomize.py) is deployed — usercustomize.py loads automatically
    for every KiCad GUI process at Python init.

    Returns the Path regardless of whether the dir exists. plugin_install
    handles mkdir at deploy time.
    """
    userprofile = os.environ.get("USERPROFILE", "")
    if not userprofile:
        return None
    return Path(userprofile) / "Documents" / "KiCad" / version / "3rdparty" / "Python311" / "site-packages"


def _adom_bridge_state(user_site_dir: Path | None) -> dict:
    """Check whether the Phase 2 reverse-bridge plugin is installed at
    the given USER_SITE.

    Returns {"installed": bool, "version": str|None, "loaderInstalled": bool}.

    "installed" = adom_bridge.py exists.
    "version"   = __version__ parsed from the first ~50 lines (regex).
    "loaderInstalled" = usercustomize.py exists (the loader that imports
                       adom_bridge at site init). Both files must be
                       present for the bridge to actually start.
    """
    if user_site_dir is None:
        return {"installed": False, "version": None, "loaderInstalled": False}
    bridge_path = user_site_dir / "adom_bridge.py"
    loader_path = user_site_dir / "usercustomize.py"
    if not bridge_path.exists():
        return {
            "installed": False,
            "version": None,
            "loaderInstalled": loader_path.exists(),
        }
    try:
        with open(bridge_path, "r", encoding="utf-8", errors="replace") as f:
            head = "".join(f.readline() for _ in range(50))
        m = re.search(r"""__version__\s*=\s*["']([^"']+)["']""", head)
        return {
            "installed": True,
            "version": m.group(1) if m else None,
            "loaderInstalled": loader_path.exists(),
        }
    except OSError:
        return {"installed": True, "version": None, "loaderInstalled": loader_path.exists()}


def _win_find_plugin_dir(version: str) -> Path | None:
    """Find the per-version KiCad scripting plugin dir:
    `%APPDATA%/KiCad/<version>/scripting/plugins/`.

    Returns the Path regardless of whether the dir exists yet — caller may want
    to install the Adom plugin INTO it, so a non-existent dir is still useful
    information (vs None which means we couldn't even resolve APPDATA).
    """
    appdata = os.environ.get("APPDATA", "")
    if not appdata:
        return None
    return Path(appdata) / "KiCad" / version / "scripting" / "plugins"


def _adom_plugin_state(plugin_dir: Path | None) -> dict:
    """Check whether the Adom bridge plugin (adom_bridge.py) is installed
    in the given KiCad scripting plugins dir, and what version it reports.

    Returns: {"installed": bool, "version": str|None}.

    Version is parsed from a `__version__ = "x.y.z"` line at the top of the
    plugin file. Falls back to None if the file exists but has no __version__.
    """
    if plugin_dir is None:
        return {"installed": False, "version": None}
    plugin_path = plugin_dir / "adom_bridge.py"
    if not plugin_path.exists():
        return {"installed": False, "version": None}
    # Read the first ~50 lines looking for `__version__`. Don't load/eval the
    # plugin code; just regex-extract the literal.
    try:
        with open(plugin_path, "r", encoding="utf-8", errors="replace") as f:
            head = "".join(f.readline() for _ in range(50))
        m = re.search(r"""__version__\s*=\s*["']([^"']+)["']""", head)
        return {"installed": True, "version": m.group(1) if m else None}
    except OSError:
        return {"installed": True, "version": None}


def _probe_python_version(python_exe: Path) -> str | None:
    """Run `<kicad>/bin/python.exe --version` and parse the version string.
    Returns "3.11.5" etc., or None if the probe fails (python.exe missing,
    spawn error, timeout). Subprocess call costs ~30-80 ms.
    """
    if not python_exe.exists():
        return None
    try:
        result = _safe_run(
            [str(python_exe), "--version"],
            capture_output=True, text=True, timeout=5,
        )
        # Python writes its version to stdout (>=3.4) or stderr (<3.4).
        out = (result.stdout or "") + (result.stderr or "")
        m = re.search(r"Python\s+(\d+\.\d+(?:\.\d+)?)", out)
        return m.group(1) if m else None
    except (subprocess.TimeoutExpired, OSError):
        return None


def _adom_library_stats(
    sym_lib_table: Path | None,
    fp_lib_table: Path | None,
    version: str,
) -> dict:
    """Read the user's sym-lib-table + fp-lib-table for entries whose name
    starts with "Adom" (case-insensitive — handles "Adom", "adom",
    "Adom_Symbols", "AdomCustom", etc.) and count the symbols/footprints
    inside each matching library.

    Returns:
        {
            "symbolsInstalled": bool,        # any Adom sym-lib entry exists
            "footprintsInstalled": bool,     # any Adom fp-lib entry exists
            "symbolCount": int,              # total count across all Adom symbol libs
            "footprintCount": int,           # total count across all Adom .pretty dirs
            "symbolLibraries": [{name, uri, count}],   # per-entry detail
            "footprintLibraries": [{name, uri, count}],
        }

    Best-effort. Returns the empty-shape on any IO / parse error so callers
    can rely on the keys always being present.
    """
    out = {
        "symbolsInstalled": False,
        "footprintsInstalled": False,
        "symbolCount": 0,
        "footprintCount": 0,
        "symbolLibraries": [],
        "footprintLibraries": [],
    }

    # ── Symbols ──
    if sym_lib_table is not None and sym_lib_table.exists():
        for entry in _parse_lib_table_entries(sym_lib_table):
            if not entry["name"].lower().startswith("adom"):
                continue
            resolved_uri = _resolve_kicad_var(entry["uri"], version)
            count = _count_symbols_in_kicad_sym(resolved_uri)
            out["symbolLibraries"].append({
                "name": entry["name"],
                "uri": str(resolved_uri),
                "count": count,
            })
            out["symbolCount"] += count
        out["symbolsInstalled"] = len(out["symbolLibraries"]) > 0

    # ── Footprints ──
    if fp_lib_table is not None and fp_lib_table.exists():
        for entry in _parse_lib_table_entries(fp_lib_table):
            if not entry["name"].lower().startswith("adom"):
                continue
            resolved_uri = _resolve_kicad_var(entry["uri"], version)
            count = _count_footprints_in_pretty_dir(resolved_uri)
            out["footprintLibraries"].append({
                "name": entry["name"],
                "uri": str(resolved_uri),
                "count": count,
            })
            out["footprintCount"] += count
        out["footprintsInstalled"] = len(out["footprintLibraries"]) > 0

    return out


def _parse_lib_table_entries(table_path: Path) -> list[dict]:
    """Parse a KiCad sym-lib-table or fp-lib-table file.

    Both formats are s-expressions like:
        (sym_lib_table
          (version 7)
          (lib (name "Adom")(type "KiCad")(uri "...")(options "")(descr "..."))
        )

    Returns one dict per (lib ...) entry: {name, type, uri, options, descr}.
    Best-effort regex parse — these files are small (~dozens of entries max)
    and the format is rigid enough that a full s-expr parser is overkill.
    """
    try:
        with open(table_path, "r", encoding="utf-8", errors="replace") as f:
            content = f.read()
    except OSError:
        return []

    entries = []
    # Each (lib ...) entry is on its own conceptual unit; match against the
    # five quoted-value fields. The whitespace between fields varies but
    # they always appear in this order.
    pattern = re.compile(
        r"""\(lib\s+
            \(name\s+"([^"]*)"\)\s*
            \(type\s+"([^"]*)"\)\s*
            \(uri\s+"([^"]*)"\)\s*
            \(options\s+"([^"]*)"\)\s*
            \(descr\s+"([^"]*)"\)\s*
            \)""",
        re.VERBOSE | re.DOTALL,
    )
    for m in pattern.finditer(content):
        entries.append({
            "name": m.group(1),
            "type": m.group(2),
            "uri": m.group(3),
            "options": m.group(4),
            "descr": m.group(5),
        })
    return entries


def _resolve_kicad_var(uri: str, version: str) -> Path:
    """Resolve KiCad path variables in a library URI. KiCad uses
    `${VAR_NAME}` syntax for things like `${KICAD_USER_TEMPLATE_DIR}`,
    `${KIPRJMOD}`, `${KICAD9_FOOTPRINT_DIR}`, etc.

    For the Adom-library case, the URIs in the user's tables we've seen
    are absolute paths (no vars). But we handle the common ones for
    robustness.
    """
    userprofile = os.environ.get("USERPROFILE", "")
    documents = Path(userprofile) / "Documents" if userprofile else None
    # Common KiCad path vars + their typical resolved values
    substitutions = {
        "KICAD_USER_TEMPLATE_DIR": str(documents / "KiCad" / version) if documents else "",
        "KICAD_3RD_PARTY": str(Path(os.environ.get("APPDATA", "")) / "kicad" / version / "3rdparty") if os.environ.get("APPDATA") else "",
        f"KICAD{version.split('.')[0]}_SYMBOL_DIR": str(Path("C:/Program Files/KiCad") / version / "share" / "kicad" / "symbols"),
        f"KICAD{version.split('.')[0]}_FOOTPRINT_DIR": str(Path("C:/Program Files/KiCad") / version / "share" / "kicad" / "footprints"),
        f"KICAD{version.split('.')[0]}_3DMODEL_DIR": str(Path("C:/Program Files/KiCad") / version / "share" / "kicad" / "3dmodels"),
    }
    resolved = uri
    for var, val in substitutions.items():
        resolved = resolved.replace(f"${{{var}}}", val)
    # KIPRJMOD = the project dir of the currently-open project; can't be
    # resolved at detection time. Leave it as-is; the count probe will fail
    # gracefully if it ever shows up in an Adom entry (unlikely — Adom libs
    # aren't project-scoped).
    return Path(resolved)


def _count_symbols_in_kicad_sym(sym_path: Path) -> int:
    """Count `(symbol "..."` entries in a .kicad_sym file. KiCad's symbol
    file format is s-expression with one or more `(symbol "Name" ...)` at
    the top level of `(kicad_symbol_lib ...)`.

    Returns 0 on any error (file missing, parse fail, etc.).
    """
    if not sym_path.exists():
        return 0
    try:
        with open(sym_path, "r", encoding="utf-8", errors="replace") as f:
            content = f.read()
    except OSError:
        return 0
    # Top-level `(symbol "Name"`. The kicad_sym format also has nested
    # `(symbol "Name_0_0" ...)` for unit/sub-symbol drawings — those start
    # with the parent symbol's name + underscore, so we filter them by
    # requiring the count to be at line-start indentation level (tab or 2
    # spaces). The simplest reliable approach: count `^\s*(symbol "..."` at
    # the beginning of a line. Top-level symbols typically have 1 leading
    # tab/2 spaces in pretty-printed output; nested ones have more.
    # Regex matches `(symbol "X"` where X is not just digits/underscore-only:
    matches = re.findall(r'^\s+\(symbol\s+"([^"]+)"', content, re.MULTILINE)
    # Filter out nested unit symbols (named like "ParentName_0_0" or
    # "ParentName_1_1"). These never appear as top-level entries in a
    # well-formed .kicad_sym, but Symbol Editor exports them with these
    # exact suffixes. Match: anything that ends with "_<digit>_<digit>".
    top_level = [n for n in matches if not re.search(r"_\d+_\d+$", n)]
    return len(top_level)


def _count_footprints_in_pretty_dir(pretty_dir: Path) -> int:
    """Count .kicad_mod files in a KiCad footprint library directory.
    KiCad footprint libraries are directories (suffixed `.pretty/`) with
    one `.kicad_mod` file per footprint.
    """
    if not pretty_dir.exists() or not pretty_dir.is_dir():
        return 0
    try:
        return sum(1 for entry in pretty_dir.iterdir() if entry.suffix == ".kicad_mod")
    except OSError:
        return 0


def _probe_ipc_api(python_exe: Path) -> dict:
    """Probe whether KiCad's IPC API (`kipy` module) is importable from the
    bundled python. KiCad 10 returns False everywhere; KiCad 11 (when it ships)
    is expected to bundle it. The probe is a one-shot subprocess that imports
    + prints a small structured result so we don't pollute stderr.

    Returns: {"available": bool, "scope": list[str]}.
    """
    if not python_exe.exists():
        return {"available": False, "scope": []}
    probe = (
        "import json,sys\n"
        "try:\n"
        "    import kipy\n"
        "    scope = []\n"
        "    for name in ('pcb', 'sch', 'sym', 'fp'):\n"
        "        if hasattr(kipy, name): scope.append(name)\n"
        "    json.dump({'available': True, 'scope': scope}, sys.stdout)\n"
        "except ImportError:\n"
        "    json.dump({'available': False, 'scope': []}, sys.stdout)\n"
    )
    try:
        result = _safe_run(
            [str(python_exe), "-c", probe],
            capture_output=True, text=True, timeout=5,
        )
        if result.returncode != 0 or not result.stdout:
            return {"available": False, "scope": []}
        import json
        data = json.loads(result.stdout)
        return {
            "available": bool(data.get("available")),
            "scope": list(data.get("scope") or []),
        }
    except (subprocess.TimeoutExpired, OSError, ValueError):
        return {"available": False, "scope": []}


def _win_registry_kicad_paths() -> list[Path]:
    """Read KiCad install paths from the Windows registry. Looks under
    HKLM\\SOFTWARE\\KiCad\\<version>\\InstallationPath (and the WOW6432Node
    mirror for 32-bit installs on 64-bit Windows). Returns version-dir
    Paths so the standard _win_build_info pipeline can take over."""
    if not IS_WINDOWS:
        return []
    try:
        import winreg  # stdlib on Windows; ImportError on others
    except ImportError:
        return []
    out: list[Path] = []
    for hive in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER):
        for subkey in (r"SOFTWARE\KiCad", r"SOFTWARE\WOW6432Node\KiCad"):
            try:
                with winreg.OpenKey(hive, subkey) as parent:
                    i = 0
                    while True:
                        try:
                            version = winreg.EnumKey(parent, i)
                        except OSError:
                            break
                        i += 1
                        try:
                            with winreg.OpenKey(parent, version) as ver_key:
                                # KiCad's installer writes "InstallationPath" or
                                # the unnamed default value. Try both.
                                for value_name in ("InstallationPath", "Install_Dir", ""):
                                    try:
                                        path_str, _ = winreg.QueryValueEx(ver_key, value_name)
                                        if path_str:
                                            out.append(Path(str(path_str).strip().strip('"')))
                                            break
                                    except OSError:
                                        continue
                        except OSError:
                            continue
            except OSError:
                continue
    return out


def _win_detect_all() -> list[dict]:
    found: list[dict] = []
    seen: set[str] = set()

    def consider(version_dir: Path):
        """version_dir is something like C:/Program Files/KiCad/9.0/."""
        try:
            resolved = str(version_dir.resolve())
        except OSError:
            resolved = str(version_dir)
        if resolved in seen:
            return
        seen.add(resolved)
        # Pull the version name from the dir if it looks like X.Y.Z, otherwise
        # try to read it from kicad-cli; bail if neither works.
        name = version_dir.name
        if re.match(r"^\d+(\.\d+)*$", name):
            info = _win_build_info(name, base_override=version_dir)
        else:
            # Non-standard layout (registry-pointed install with no version
            # in the path). Fall back: probe bin/kicad-cli.exe --version.
            cli = version_dir / "bin" / "kicad-cli.exe"
            if not cli.exists():
                return
            try:
                result = _safe_run([str(cli), "--version"], capture_output=True, text=True, timeout=5)
                m = re.search(r"(\d+\.\d+(?:\.\d+)?)", result.stdout + result.stderr)
                if not m:
                    return
                info = _win_build_info(m.group(1), base_override=version_dir.parent / m.group(1) if (version_dir.parent / m.group(1)).exists() else version_dir)
            except (subprocess.TimeoutExpired, OSError):
                return
        if info:
            found.append(info)

    # 1. Scan all known parent dirs for version subdirs.
    for parent in WIN_KICAD_BASES:
        if not parent.exists():
            continue
        try:
            for entry in parent.iterdir():
                if not entry.is_dir():
                    continue
                if not re.match(r"^\d+(\.\d+)*$", entry.name):
                    continue
                consider(entry)
        except (OSError, PermissionError):
            continue

    # 2. Scan registry for installs in non-standard locations.
    for reg_path in _win_registry_kicad_paths():
        if not reg_path.exists():
            continue
        # Registry value can point at the version dir directly (newer installs)
        # or at the parent (older installs). Handle both.
        if (reg_path / "bin" / "kicad.exe").exists():
            consider(reg_path)
        else:
            try:
                for entry in reg_path.iterdir():
                    if entry.is_dir() and re.match(r"^\d+(\.\d+)*$", entry.name):
                        consider(entry)
            except (OSError, PermissionError):
                continue

    found.sort(key=lambda info: _version_tuple(info["version"]), reverse=True)
    return found


# ╔═══════════════════════════════════════════════════════════════════════════╗
# ║                                macOS                                       ║
# ╚═══════════════════════════════════════════════════════════════════════════╝

def _mac_app_version(app_path: Path) -> str | None:
    """Read CFBundleShortVersionString from an app's Info.plist.
    Returns the version string ('9.0.0', '8.0.4') or None on failure."""
    plist = app_path / "Contents" / "Info.plist"
    if not plist.exists():
        return None
    try:
        result = _safe_run(
            ["/usr/libexec/PlistBuddy", "-c", "Print :CFBundleShortVersionString", str(plist)],
            capture_output=True, text=True, timeout=5,
        )
        if result.returncode == 0:
            return result.stdout.strip() or None
    except (subprocess.TimeoutExpired, FileNotFoundError):
        pass
    return None


def _mac_short_version(full_version: str) -> str:
    """'9.0.0' -> '9.0'  (KiCad's config/user dirs use major.minor only)."""
    parts = full_version.split(".")
    if len(parts) >= 2:
        return f"{parts[0]}.{parts[1]}"
    return full_version


def _mac_find_config_dir(short_version: str):
    """KiCad config dir on macOS: ~/Library/Preferences/kicad/<X.Y>/"""
    home = Path.home()
    config = home / "Library" / "Preferences" / "kicad" / short_version
    return config if config.exists() else None


def _mac_find_user_dir(short_version: str):
    """KiCad user dir on macOS: ~/Documents/KiCad/<X.Y>/"""
    home = Path.home()
    user = home / "Documents" / "KiCad" / short_version
    # We can't always stat ~/Documents under the macOS Privacy layer (TCC),
    # but the path is conventional. Return it unconditionally and let callers
    # handle missing dirs gracefully.
    return user


def _mac_build_info(app_path: Path) -> dict | None:
    """Build the kicad_info dict for a macOS KiCad.app bundle, or None
    if it doesn't look like a real install."""
    bin_dir = app_path / "Contents" / "MacOS"
    kicad_exe = bin_dir / "kicad"
    if not kicad_exe.exists():
        return None

    full_version = _mac_app_version(app_path)
    if not full_version:
        return None
    short_version = _mac_short_version(full_version)

    # Sibling editor apps next to the main bundle (PCB Editor.app, Schematic Editor.app, etc.).
    # KiCad ships these alongside KiCad.app under /Applications/KiCad/. The actual
    # binaries inside each bundle are Contents/MacOS/<name> — directly Popen-able
    # by handlers that were written for Windows .exe paths.
    parent = app_path.parent
    pcbnew_bin = parent / "PCB Editor.app" / "Contents" / "MacOS" / "pcbnew"
    eeschema_bin = parent / "Schematic Editor.app" / "Contents" / "MacOS" / "eeschema"

    config_dir = _mac_find_config_dir(short_version)
    user_dir = _mac_find_user_dir(short_version)

    # kicad-cli ships in the main bundle's MacOS dir on macOS
    kicad_cli = bin_dir / "kicad-cli"

    return {
        "installed": True,
        "platform": "macos",
        # Use major.minor for the version key so it matches the Windows convention
        # ('9.0' not '9.0.0') and lines up with config/user dir names.
        "version": short_version,
        "full_version": full_version,
        "base_dir": str(app_path),
        "bin_dir": str(bin_dir),
        # Direct path to the kicad project-manager binary
        "kicad_exe": str(kicad_exe),
        # Path to the editor binaries inside their bundles. Handlers can Popen
        # these directly (same shape as Windows .exe paths). Falls back to the
        # main kicad binary if the editor bundle is missing.
        "pcbnew_exe": str(pcbnew_bin) if pcbnew_bin.exists() else str(kicad_exe),
        "eeschema_exe": str(eeschema_bin) if eeschema_bin.exists() else str(kicad_exe),
        "kicad_cli_exe": str(kicad_cli) if kicad_cli.exists() else None,
        # macOS bundles ship their own embedded Python under Frameworks/Python.framework;
        # plugin code that needs to invoke it should call `kicad-cli` instead.
        "python_exe": None,
        "config_dir": str(config_dir) if config_dir else None,
        "user_dir": str(user_dir) if user_dir else None,
        "sym_lib_table": str(config_dir / "sym-lib-table") if config_dir else None,
        "fp_lib_table": str(config_dir / "fp-lib-table") if config_dir else None,
        "user_symbols_dir": str(user_dir / "symbols") if user_dir else None,
        "user_footprints_dir": str(user_dir / "footprints") if user_dir else None,
        "user_3dmodels_dir": str(user_dir / "3dmodels") if user_dir else None,
    }


def _mac_detect_all() -> list[dict]:
    """Discover every KiCad install on macOS.

    Looks first inside /Applications/KiCad/ (where the official installer puts
    the bundle), then falls back to scanning /Applications/ directly for
    KiCad*.app — covers users who drag the bundle straight into Applications.
    """
    found: list[dict] = []
    seen: set[str] = set()

    candidates: list[Path] = []
    if MAC_KICAD_DIR.exists():
        for entry in MAC_KICAD_DIR.iterdir():
            if entry.suffix == ".app" and entry.name.lower().startswith("kicad"):
                candidates.append(entry)
    if MAC_APPS_DIR.exists():
        try:
            for entry in MAC_APPS_DIR.iterdir():
                if entry.suffix == ".app" and entry.name.lower().startswith("kicad"):
                    candidates.append(entry)
        except PermissionError:
            pass

    for app in candidates:
        key = str(app.resolve())
        if key in seen:
            continue
        seen.add(key)
        info = _mac_build_info(app)
        if info:
            found.append(info)

    found.sort(key=lambda info: _version_tuple(info["version"]), reverse=True)
    return found


# ╔═══════════════════════════════════════════════════════════════════════════╗
# ║                          PUBLIC API (cross-platform)                       ║
# ╚═══════════════════════════════════════════════════════════════════════════╝

def detect_all_kicad_versions() -> list[dict]:
    """Discover every installed KiCad version on the current platform.
    Returns a list sorted newest first. Empty list if KiCad isn't installed."""
    if IS_WINDOWS:
        return _win_detect_all()
    if IS_MACOS:
        return _mac_detect_all()
    return []


def detect_kicad(version: str | None = None) -> dict:
    """Detect a specific KiCad install (by version) or the latest installed.

    - `version=None`: return the newest installed version
    - `version="9.0"`: return that exact version, or {installed:false} with a hint
      listing all available versions if not found.

    Backward-compatible with the original (no-arg) behavior — existing call sites
    that do `detect_kicad()` still get the latest install.
    """
    all_versions = detect_all_kicad_versions()

    if not all_versions:
        if IS_WINDOWS:
            scanned = ", ".join(str(p) for p in WIN_KICAD_BASES)
            err = (
                f"KiCad not found in any of: {scanned}, or in the Windows registry "
                f"(HKLM/HKCU\\SOFTWARE\\KiCad). Install from https://www.kicad.org/download/, "
                f"or run `adom-desktop desktop_install_kicad` for an unattended winget install (v1.6.0+)."
            )
            hint = (
                "Trigger an unattended install with `adom-desktop desktop_install_kicad '{}'`. "
                "It runs `winget install --id KiCad.KiCad --silent --accept-package-agreements "
                "--accept-source-agreements`. Requires winget (Windows 10 1809+ / Windows 11). "
                "Takes ~2-5 min depending on network. Re-run kicad_list_versions after to verify."
            )
        elif IS_MACOS:
            err = "KiCad not found in /Applications/. Install from https://www.kicad.org/download/macos/."
            hint = "On macOS the install is manual — download the KiCad installer DMG from kicad.org."
        else:
            err = f"KiCad detection is not implemented for platform {sys.platform!r}."
            hint = None
        result = {
            "installed": False,
            "platform": _current_platform(),
            "version": None,
            "available_versions": [],
            "error": err,
            "errorCode": "kicad_not_installed",
        }
        if hint:
            result["_hint"] = hint
        return result

    if version is None:
        return all_versions[0]

    # Try exact match, then prefix match (e.g. "9" matches "9.0")
    for info in all_versions:
        if info["version"] == version:
            return info
    for info in all_versions:
        if info["version"].startswith(f"{version}."):
            return info

    available = [v["version"] for v in all_versions]
    return {
        "installed": False,
        "platform": _current_platform(),
        "version": version,
        "available_versions": available,
        "error": f"KiCad {version} is not installed. Available versions: {', '.join(available)}.",
        "_hint": f"Pass kicadVersion as one of: {', '.join(available)}, or omit it to use the newest ({available[0]}).",
    }


def _current_platform() -> str:
    if IS_WINDOWS:
        return "windows"
    if IS_MACOS:
        return "macos"
    return sys.platform


if __name__ == "__main__":
    import json
    all_v = detect_all_kicad_versions()
    print(json.dumps({
        "platform": _current_platform(),
        "all_versions": all_v,
        "latest": all_v[0] if all_v else None,
    }, indent=2))