"""KiCad version check + upgrade via the OFFICIAL downloads.kicad.org installer.

Why not winget: winget's KiCad package lags the real KiCad release (often by a
minor version or more), and it can't pin a specific build. The official
`downloads.kicad.org` NSIS installer is the canonical, signed artifact — so the
bridge fetches THAT and installs it silently (`/S`).

- `check_for_updates` — read-only: current installed version vs latest STABLE
  (resolved from KiCad's GitLab tags, baked fallback on a network blip), plus the
  "your KiCad predates the IPC/kipy API — upgrade" nudge.
- `upgrade` — downloads the official installer and runs it silently. Needs
  elevation for Program Files, so the user may see (and approve) a UAC prompt.
"""

import json
import os
import re
import ssl
import sys
import tempfile
import urllib.error
import urllib.request
from pathlib import Path

import kicad_detect
import proc

# A CA bundle shipped WITH the bridge (certifi's cacert.pem). This is the durable
# trust source: Bridge's provisioned portable Python (Runtime contract v1.9.63+) has
# no usable system CA store, so create_default_context() verifies against nothing
# and the Windows cert enumeration proved unreliable on it — every https fetch
# died with CERTIFICATE_VERIFY_FAILED. Bundling the roots guarantees working TLS
# regardless of the interpreter. Verification stays ON (no MITM window).
_BUNDLED_CACERT = os.path.join(
    os.path.dirname(os.path.abspath(__file__)), "..", "certs", "cacert.pem"
)


def _ssl_context() -> ssl.SSLContext:
    """A verifying SSL context that works even on Bridge's certless portable Python."""
    ctx = ssl.create_default_context()
    # Primary: the bundled certifi roots (works on any interpreter).
    try:
        if os.path.exists(_BUNDLED_CACERT):
            ctx.load_verify_locations(cafile=_BUNDLED_CACERT)
    except Exception:
        pass
    # Secondary (belt-and-suspenders): the Windows system ROOT/CA store, if usable.
    if sys.platform == "win32":
        try:
            pem = "".join(
                ssl.DER_cert_to_PEM_cert(cert_der)
                for store in ("ROOT", "CA")
                for cert_der, enc, _trust in ssl.enum_certificates(store)
                if enc == "x509_asn"
            )
            if pem:
                ctx.load_verify_locations(cadata=pem)
        except Exception:
            pass
    return ctx

# Baked fallback if the GitLab tag query fails (network blip). Bump on new stable.
_FALLBACK_LATEST = "10.0.4"
_TAGS_URL = (
    "https://gitlab.com/api/v4/projects/kicad%2Fcode%2Fkicad/repository/tags?per_page=30"
)
_DL_URL = "https://downloads.kicad.org/kicad/windows/explore/stable/download/kicad-{v}-x86_64.exe"
_UA = "kicad-bridge"


def _is_stable(tag: str) -> bool:
    """True for a real stable tag like 10.0.4 / 9.0.9.1. Drops `-rcN` prereleases
    and KiCad's `X.99.*` nightly/development tags (minor component == 99)."""
    if not re.fullmatch(r"\d+(?:\.\d+)+", tag):
        return False
    parts = tag.split(".")
    return not (len(parts) >= 2 and parts[1] == "99")


def latest_stable_version() -> tuple[str, str]:
    """Return (version, source) where source is 'gitlab' or 'fallback'."""
    try:
        req = urllib.request.Request(_TAGS_URL, headers={"User-Agent": _UA})
        with urllib.request.urlopen(req, timeout=10, context=_ssl_context()) as r:
            tags = [t["name"] for t in json.loads(r.read().decode())]
        stables = sorted(
            (t for t in tags if _is_stable(t)),
            key=kicad_detect._version_tuple,
            reverse=True,
        )
        if stables:
            return stables[0], "gitlab"
    except Exception:
        pass
    return _FALLBACK_LATEST, "fallback"


def download_url(version: str) -> str:
    return _DL_URL.format(v=version)


def handle_check_for_updates(kicad_info: dict, args: dict) -> dict:
    """Read-only: is a newer KiCad available, and does the current one have the IPC API?"""
    current = kicad_info.get("version") if kicad_info else None
    latest, src = latest_stable_version()
    cur_t = kicad_detect._version_tuple(current) if current else (0,)
    lat_t = kicad_detect._version_tuple(latest)
    upgrade_available = current is None or lat_t > cur_t
    ipc = bool(kicad_info.get("ipcApiAvailable")) if kicad_info else False

    if current is None:
        rec = (
            f"KiCad isn't installed. Run kicad_upgrade to install {latest} "
            f"from the official downloads.kicad.org installer."
        )
    elif upgrade_available and not ipc:
        rec = (
            f"Your KiCad {current} predates the modern IPC / kipy automation API — "
            f"upgrade to {latest} to unlock it (plus the latest fixes). Run kicad_upgrade."
        )
    elif upgrade_available:
        rec = f"KiCad {latest} is available (you're on {current}). Run kicad_upgrade to update."
    else:
        rec = f"KiCad {current} is up to date (latest stable is {latest})."

    return {
        "success": True,
        "currentVersion": current,
        "latestVersion": latest,
        "latestSource": src,
        "upgradeAvailable": upgrade_available,
        "ipcApiAvailable": ipc,
        "downloadUrl": download_url(latest),
        "recommendation": rec,
        "_hint": (
            "Run kicad_upgrade '{}' to install the latest (downloads the OFFICIAL "
            "downloads.kicad.org installer + silent /S install — NOT winget, which lags). "
            'Pass {"version":"X.Y.Z"} to pin a specific build.'
        ),
    }


def _elevation_facts() -> dict:
    """Hard, verifiable elevation/UAC facts for THIS process — no guessing.

    Settles "is the bridge actually elevated, and why didn't a UAC prompt appear":
    - tokenElevationType: 1=Default (no split token — UAC disabled OR built-in
      Administrator), 2=Full (elevated via UAC), 3=Limited (normal user token).
    - tokenElevated: the token's TokenElevation flag (the ground truth).
    - uacEnableLUA: HKLM ...\\Policies\\System EnableLUA — 0 means UAC is OFF
      machine-wide, so admin accounts get a full token with NO prompt ever.
    """
    facts: dict = {"platform": sys.platform}
    if sys.platform != "win32":
        return facts
    import ctypes
    import ctypes.wintypes as wt
    import getpass
    facts["user"] = getpass.getuser()
    try:
        facts["isUserAnAdmin"] = bool(ctypes.windll.shell32.IsUserAnAdmin())
    except Exception as e:
        facts["isUserAnAdmin"] = f"error: {e}"
    try:
        advapi32 = ctypes.WinDLL("advapi32", use_last_error=True)
        kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
        TOKEN_QUERY = 0x0008
        token = wt.HANDLE()
        if not advapi32.OpenProcessToken(
            kernel32.GetCurrentProcess(), TOKEN_QUERY, ctypes.byref(token)
        ):
            raise OSError(f"OpenProcessToken failed: {ctypes.get_last_error()}")
        for name, cls in (("tokenElevationType", 18), ("tokenElevated", 20)):
            val = wt.DWORD()
            ret_len = wt.DWORD()
            ok = advapi32.GetTokenInformation(
                token, cls, ctypes.byref(val), ctypes.sizeof(val), ctypes.byref(ret_len)
            )
            facts[name] = int(val.value) if ok else f"error: {ctypes.get_last_error()}"
        kernel32.CloseHandle(token)
        facts["tokenElevationTypeMeaning"] = {
            1: "Default — no split token (UAC disabled machine-wide, or built-in Administrator): full admin, NO UAC prompt ever",
            2: "Full — elevated via an approved UAC prompt (or elevated parent)",
            3: "Limited — standard filtered token, NOT admin",
        }.get(facts.get("tokenElevationType"), "unknown")
    except Exception as e:
        facts["tokenError"] = str(e)
    try:
        import winreg
        with winreg.OpenKey(
            winreg.HKEY_LOCAL_MACHINE,
            r"SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System",
        ) as k:
            facts["uacEnableLUA"] = winreg.QueryValueEx(k, "EnableLUA")[0]
            # 0 = elevate silently WITHOUT a prompt (common on VMs) — explains
            # "I never clicked UAC" even when a process ends up elevated.
            try:
                facts["uacConsentPromptBehaviorAdmin"] = winreg.QueryValueEx(
                    k, "ConsentPromptBehaviorAdmin"
                )[0]
            except OSError:
                facts["uacConsentPromptBehaviorAdmin"] = "not set (default 5: prompt)"
    except Exception as e:
        facts["uacEnableLUA"] = f"error: {e}"
    return facts


def _remote_installer_size(url: str) -> int | None:
    """The server's Content-Length for the installer (follows redirects). Lets us
    verify a downloaded/cached file isn't silently TRUNCATED — a dropped
    connection reads as EOF, logs 'download complete', passes the MZ+50MB sanity
    checks, and then the NSIS integrity check fails instantly at install time."""
    try:
        req = urllib.request.Request(url, method="HEAD", headers={"User-Agent": _UA})
        with urllib.request.urlopen(req, timeout=30, context=_ssl_context()) as r:
            cl = r.headers.get("Content-Length")
            return int(cl) if cl else None
    except Exception:
        return None


def handle_upgrade(kicad_info: dict, args: dict) -> dict:
    """Download the official KiCad installer and run it silently (/S)."""
    if sys.platform != "win32":
        return {
            "success": False,
            "error": "kicad_upgrade is Windows-only (it downloads the .exe installer).",
            "errorCode": "unsupported_platform",
        }

    # diagnoseOnly: return the elevation/UAC ground truth + cached-installer state
    # WITHOUT downloading or installing anything. Used to settle "is this process
    # actually elevated / why did no UAC prompt appear / why rc=666660".
    if args.get("diagnoseOnly"):
        facts = _elevation_facts()
        t = args.get("version") or latest_stable_version()[0]
        cached = Path(tempfile.gettempdir()) / f"kicad-{t}-x86_64.exe"
        facts["cachedInstaller"] = str(cached) if cached.exists() else None
        facts["cachedInstallerBytes"] = cached.stat().st_size if cached.exists() else 0
        expected = _remote_installer_size(download_url(t))
        facts["serverInstallerBytes"] = expected
        facts["cachedInstallerTruncated"] = (
            bool(cached.exists() and expected and cached.stat().st_size != expected)
        )
        return {
            "success": True,
            "diagnoseOnly": True,
            **facts,
            "_hint": (
                "Read tokenElevationTypeMeaning + uacEnableLUA together: EnableLUA=0 means UAC is "
                "disabled machine-wide, so an admin account always runs with a full token and NO UAC "
                "prompt ever appears — IsUserAnAdmin=True without anyone clicking anything. "
                "tokenElevated=1 with an rc=666660 install failure means elevation is NOT the cause "
                "(look at SmartScreen/AV or the installer's silent flag instead)."
            ),
        }

    target = args.get("version") or latest_stable_version()[0]
    current = kicad_info.get("version") if kicad_info else None
    if (
        current
        and kicad_detect._version_tuple(current) >= kicad_detect._version_tuple(target)
        and not args.get("force")
    ):
        return {
            "success": True,
            "alreadyCurrent": True,
            "currentVersion": current,
            "targetVersion": target,
            "_hint": f'KiCad {current} >= {target}; nothing to do. Pass {{"force":true}} to reinstall.',
        }

    url = download_url(target)
    dest = Path(tempfile.gettempdir()) / f"kicad-{target}-x86_64.exe"

    # Reuse an already-downloaded installer. The KiCad installer is ~1 GB, and a
    # prior attempt that succeeded at DOWNLOAD but failed at INSTALL (e.g. UAC not
    # approved / Bridge not elevated) leaves a valid .exe in Temp. Skipping the
    # re-download makes a post-elevation retry near-instant.
    def _valid_installer(p: Path) -> bool:
        try:
            if not p.exists() or p.stat().st_size < 50 * 1024 * 1024:
                return False
            with open(p, "rb") as fh:
                return fh.read(2) == b"MZ"
        except OSError:
            return False

    # The server's true size — the anti-truncation ground truth. A dropped
    # connection reads as EOF ("download complete"), passes MZ+50MB checks, then
    # NSIS's integrity check fails instantly at install time (the rc=666660
    # failure mode). Compare every cached/downloaded file against this.
    expected_bytes = _remote_installer_size(url)
    print(f"[KiCad Bridge] kicad_upgrade: server reports installer size "
          f"{expected_bytes if expected_bytes else 'unknown'} bytes", flush=True)

    total = dest.stat().st_size if dest.exists() else 0
    cache_ok = (
        _valid_installer(dest)
        and not args.get("reDownload")
        and (expected_bytes is None or total == expected_bytes)
    )
    if _valid_installer(dest) and expected_bytes and total != expected_bytes:
        print(f"[KiCad Bridge] kicad_upgrade: cached installer is TRUNCATED "
              f"({total} of {expected_bytes} bytes) — discarding and re-downloading", flush=True)
    if cache_ok:
        print(f"[KiCad Bridge] kicad_upgrade: reusing cached installer {dest} "
              f"({total // (1024*1024)} MB, size verified) — pass reDownload:true to force a fresh fetch", flush=True)
    else:
        # Download — urllib follows the downloads.kicad.org -> s3.cern.ch redirect.
        # Progress is printed so `bridge_log_read` reveals where a slow/stuck
        # install is. timeout is the per-read blocking timeout (300s for slow VMs).
        print(f"[KiCad Bridge] kicad_upgrade: downloading {url}", flush=True)
        try:
            req = urllib.request.Request(url, headers={"User-Agent": _UA})
            total = 0
            next_log = 25 * 1024 * 1024
            with urllib.request.urlopen(req, timeout=300, context=_ssl_context()) as r, open(dest, "wb") as f:
                if expected_bytes is None:
                    cl = r.headers.get("Content-Length")
                    expected_bytes = int(cl) if cl else None
                while True:
                    chunk = r.read(1 << 20)
                    if not chunk:
                        break
                    f.write(chunk)
                    total += len(chunk)
                    if total >= next_log:
                        print(f"[KiCad Bridge] kicad_upgrade: downloaded {total // (1024*1024)} MB...", flush=True)
                        next_log += 25 * 1024 * 1024
            print(f"[KiCad Bridge] kicad_upgrade: download finished ({total} of "
                  f"{expected_bytes if expected_bytes else 'unknown'} bytes) -> {dest}", flush=True)
        except (urllib.error.URLError, OSError) as e:
            print(f"[KiCad Bridge] kicad_upgrade: DOWNLOAD FAILED: {e}", flush=True)
            return {
                "success": False,
                "error": f"download failed: {e}",
                "errorCode": "download_failed",
                "url": url,
            }
        if expected_bytes and total != expected_bytes:
            print(f"[KiCad Bridge] kicad_upgrade: TRUNCATED download ({total} of {expected_bytes}) — deleting", flush=True)
            try:
                dest.unlink()
            except OSError:
                pass
            return {
                "success": False,
                "error": f"download truncated: got {total} of {expected_bytes} bytes "
                         f"(connection dropped mid-transfer). The file was deleted.",
                "errorCode": "download_truncated",
                "url": url,
                "_hint": "Re-run kicad_upgrade — it will re-download. If this repeats, the mirror "
                         "or the VM's link is flaky; try again later.",
            }

    # Sanity: a real KiCad installer is a >50 MB Windows PE (MZ header).
    if total < 50 * 1024 * 1024:
        return {
            "success": False,
            "error": f"download too small ({total} bytes) — not a full installer",
            "errorCode": "download_incomplete",
            "url": url,
            "path": str(dest),
        }
    with open(dest, "rb") as f:
        if f.read(2) != b"MZ":
            return {
                "success": False,
                "error": "downloaded file is not a Windows executable",
                "errorCode": "download_corrupt",
                "path": str(dest),
            }

    # Silent install (NSIS /S). Installing into Program Files needs elevation, so
    # the user may get a UAC prompt to approve. Block until the installer exits.
    # If Bridge isn't elevated, this is where it STALLS (a UAC prompt no one approves)
    # until the timeout — the log line below is the last thing you'll see.
    # Is THIS process (hence Bridge, its parent) elevated? Definitive answer to
    # "did the elevated relaunch actually take" — the KiCad installer needs admin,
    # so if we're not elevated it exits fast with a failure code (no UAC prompt to
    # click) and nothing installs.
    def _is_admin():
        if sys.platform != "win32":
            return None
        try:
            import ctypes
            return bool(ctypes.windll.shell32.IsUserAnAdmin())
        except Exception:
            return None
    elevated = _is_admin()
    # KiCad 10's installer is built on NsisMultiUser: in SILENT mode it REQUIRES an
    # explicit install scope — bare "/S" is "invalid command-line parameters" and
    # exits instantly with rc=666660 (NsisMultiUser's documented error level; its
    # sibling 666661 = elevation restricted). THE root cause of the fast-fail.
    #   /allusers  /S  → per-machine (Program Files; needs elevation)
    #   /currentuser /S → per-user (%LOCALAPPDATA%\Programs\KiCad; NO admin, NO UAC)
    # scope arg: "machine" | "user" | default auto (machine when elevated, else the
    # zero-UAC per-user install — kicad_detect scans both locations).
    scope = args.get("scope") or ("machine" if elevated else "user")
    scope_flag = "/allusers" if scope == "machine" else "/currentuser"
    print(f"[KiCad Bridge] kicad_upgrade: elevated={elevated}; launching installer "
          f"{scope_flag} /S (scope={scope})...", flush=True)
    try:
        result = proc.run([str(dest), scope_flag, "/S"], timeout=900)
        rc = result.returncode
        print(f"[KiCad Bridge] kicad_upgrade: installer exited rc={rc}", flush=True)
    except Exception as e:
        print(f"[KiCad Bridge] kicad_upgrade: INSTALLER LAUNCH/RUN FAILED: {e}", flush=True)
        return {
            "success": False,
            "error": f"installer launch failed: {e}",
            "errorCode": "install_failed",
            "elevated": elevated,
            "path": str(dest),
            "_hint": "If a UAC prompt appeared, approve it; or run the downloaded installer manually.",
        }

    # Confirm by re-scanning installed versions.
    after = kicad_detect.detect_all_kicad_versions()
    installed = [v["version"] for v in after]
    now_has = any(
        kicad_detect._version_tuple(v) >= kicad_detect._version_tuple(target)
        for v in installed
    )
    return {
        "success": bool(now_has),
        "upgraded": bool(now_has),
        "targetVersion": target,
        "previousVersion": current,
        "installerExit": rc,
        "elevated": elevated,
        "installScope": scope,
        "installedVersions": installed,
        "downloadUrl": url,
        "_hint": (
            f"KiCad {target} installed ({scope} scope)."
            if now_has
            else (
                f"Installer ran (exit {rc}) but {target} wasn't detected. elevated={elevated}, scope={scope}. "
                + ("rc=666660 is NsisMultiUser 'invalid command-line parameters' and 666661 is 'elevation "
                   "restricted' — retry with the other scope: "
                   '{"scope":"user"} needs NO admin/UAC (installs to %LOCALAPPDATA%\\Programs\\KiCad); '
                   '{"scope":"machine"} needs elevation. '
                   if rc in (666660, 666661) else
                   "Check SmartScreen/AV blocking the .exe or a pending reboot. ")
                + f"Installed: {', '.join(installed) or 'none'}."
            )
        ),
    }