123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884
"""Auto-detect Fusion 360 installation on Windows."""

import ctypes
import ctypes.wintypes
import json
import os
import re
import subprocess
import threading
import time
import urllib.error
import urllib.request
from pathlib import Path

ADDIN_PORT = 8774

# Fusion 360 uses a web-deploy model — the launcher lives under
#   <base>\Autodesk\webdeploy\production\<hash>\FusionLauncher.exe
# where <base> is %LOCALAPPDATA% for a per-user install OR %ProgramFiles%
# (/%ProgramFiles(x86)%/%ProgramW6432%) for a SYSTEM-WIDE `--globalinstall` -
# which is exactly what the fusion-onboarding skill runs. We MUST check both, or
# an AI-installed (globalinstall) Fusion is invisible and onboarding "succeeds"
# yet the bridge still reports not-installed. (Found on the ADOMBASELINE test.)
_WEBDEPLOY_BASES = []
for _var in ("LOCALAPPDATA", "ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"):
    _root = os.environ.get(_var)
    if _root:
        _base = Path(_root) / "Autodesk" / "webdeploy" / "production"
        if _base not in _WEBDEPLOY_BASES:
            _WEBDEPLOY_BASES.append(_base)
# Back-compat alias (some callers import WEBDEPLOY_BASE directly).
WEBDEPLOY_BASE = _WEBDEPLOY_BASES[0] if _WEBDEPLOY_BASES else (
    Path(os.environ.get("LOCALAPPDATA", "")) / "Autodesk" / "webdeploy" / "production"
)

# EVERY known per-user add-in dir, newest convention first. Fusion MOVED this
# across versions and silently ignores the others (issue #63: an add-in in the
# legacy API\AddIns dir never loads on 2025+ Fusion, which scans FusionAddins -
# proven live on a fresh install). Track ALL of them; never assume one path.
ADDINS_DIR_CANDIDATES = [
    Path(os.environ.get("APPDATA", "")) / "Autodesk" / "FusionAddins",
    Path(os.environ.get("APPDATA", "")) / "Autodesk" / "Autodesk Fusion" / "API" / "AddIns",
    Path(os.environ.get("APPDATA", "")) / "Autodesk" / "Autodesk Fusion 360" / "API" / "AddIns",
]

# Keep callback references alive to prevent GC
_callbacks = []

# Guard windll so this module imports on non-Windows hosts (galliaApril smoke
# tests, macOS). The Win32-only functions below are never reached off-Windows —
# detect_fusion() short-circuits to installed:False — but the bridge must still
# boot + serve /status on any platform AD spawns it on.
user32 = ctypes.windll.user32 if hasattr(ctypes, "windll") else None


def detect_fusion() -> dict:
    """Detect Fusion 360 installation. Returns a dict with paths and status."""
    exe_path = _find_fusion_launcher()
    addins_dir = _find_addins_dir()
    addin_installed = _check_addin_installed(addins_dir) if addins_dir else False
    running = _is_fusion_running()

    if exe_path:
        return {
            "installed": True,
            "exe_path": str(exe_path),
            "addins_dir": str(addins_dir) if addins_dir else None,
            "addin_installed": addin_installed,
            "running": running,
        }

    return {
        "installed": False,
        "exe_path": None,
        "addins_dir": str(addins_dir) if addins_dir else None,
        "addin_installed": addin_installed,
        "running": running,
        "error": "Fusion 360 not found",
    }


# ---------------------------------------------------------------------------------------------------
# INSTALL-COMPLETENESS DETECTION  (root-caused live on a fresh Hyper-V VM, John 2026-07-14)
#
# WHAT A WEBDEPLOY INSTALL ACTUALLY LOOKS LIKE (verified on disk, do not assume):
#   %LOCALAPPDATA%\Autodesk\webdeploy\production\ holds one hash dir PER content-build. A COMPLETE
#   Fusion install is ~615 files (Fusion360.exe ~880 KB + hundreds of DLLs + Qt/ + Python/ + ...).
#   BUT Autodesk ALSO drops a tiny 8-file launcher STUB dir next to it: icons + FusionLauncher.exe
#   + a small FusionLauncher.exe.ini (~412 bytes). So at steady state there are TWO hash dirs.
#
# THE TRAP that broke the fresh VM (and the two earlier "fixes" that were WRONG):
#   * Keying "installed" off FusionLauncher.exe EXISTING -> true mid-stream (launcher lands early) ->
#     premature fusion_start -> "Error Launching Streamed Application ... .ini missing/incomplete".
#   * Keying off FusionLauncher.exe.ini existing / its SIZE -> ALSO wrong: the COMPLETE app dir has
#     NO .ini at all; only the throwaway stub carries a 412-byte .ini. A size gate would REJECT a
#     perfectly good install and ACCEPT nothing. (Measured it: complete app dir = 615 files, no .ini.)
#
# THE RELIABLE SIGNAL: the real application binary Fusion360.exe. It only exists (full size) when the
#   payload finished streaming. Gate "installed" on Fusion360.exe, NEVER on the launcher or its .ini.
# ---------------------------------------------------------------------------------------------------
_FUSION_APP_EXE = "Fusion360.exe"     # the ACTUAL app binary (~880 KB); the completeness marker
_MIN_APP_EXE_BYTES = 200_000          # guards against a 0-byte placeholder the streamer writes early


def _app_dir_complete(subdir: Path) -> bool:
    """A production hash dir is a COMPLETE Fusion install ONLY when it contains the real app binary
    Fusion360.exe at full size. This is process-independent and version-stable. Do NOT substitute
    FusionLauncher.exe (lands early, mid-stream) or FusionLauncher.exe.ini (absent from the complete
    app dir; only the launcher stub has one). See the block comment above for the on-disk anatomy."""
    exe = subdir / _FUSION_APP_EXE
    try:
        return exe.exists() and exe.stat().st_size >= _MIN_APP_EXE_BYTES
    except OSError:
        return False


def _any_app_complete() -> bool:
    """True if ANY webdeploy hash dir holds a complete Fusion (Fusion360.exe). This is the single
    source of truth for 'Fusion is installed'."""
    for base in _WEBDEPLOY_BASES:
        try:
            if base.exists():
                for subdir in base.iterdir():
                    if subdir.is_dir() and _app_dir_complete(subdir):
                        return True
        except OSError:
            continue
    return False


def _find_fusion_launcher() -> Path | None:
    """Return the FusionLauncher.exe to RUN, but only when Fusion is actually INSTALLED.

    TWO SEPARATE QUESTIONS (conflating them is what broke the launch, John 2026-07-14):
      1. Is Fusion installed?  ->  a hash dir contains the real app binary Fusion360.exe.
      2. Which launcher do I run?  ->  a FusionLauncher.exe that HAS its FusionLauncher.exe.ini
         BESIDE IT. Running a FusionLauncher.exe WITHOUT its .ini throws Fusion's own
         "Error Launching Streamed Application ... FusionLauncher.exe.ini is missing or incomplete".
         The complete APP dir (615 files, Fusion360.exe) frequently has the launcher but NO .ini; a
         separate tiny STUB dir carries FusionLauncher.exe + its ~412-byte .ini and knows how to load
         the app. So we must return the launcher-WITH-.ini, NOT the app dir's bare launcher.

    Because we gate on Fusion360.exe first, the stub's .ini is guaranteed complete by then (mid-stream
    there is no Fusion360.exe, so we return None and nothing launches into a half-written install)."""
    if not _any_app_complete():
        return None                                   # not installed (or still streaming) -> None

    runnable = []      # (mtime, launcher) - FusionLauncher.exe that has its .ini beside it
    bare = []          # (mtime, launcher) - FusionLauncher.exe with NO .ini (fallback only)
    app_exes = []      # (mtime, Fusion360.exe) - last-ditch fallback
    for base in _WEBDEPLOY_BASES:
        if not base.exists():
            continue
        try:
            subdirs = [d for d in base.iterdir() if d.is_dir()]
        except OSError:
            continue
        for subdir in subdirs:
            try:
                mtime = subdir.stat().st_mtime
            except OSError:
                mtime = 0.0
            launcher = subdir / "FusionLauncher.exe"
            if launcher.exists():
                if (subdir / "FusionLauncher.exe.ini").exists():
                    runnable.append((mtime, launcher))
                else:
                    bare.append((mtime, launcher))
            if _app_dir_complete(subdir):
                app_exes.append((mtime, subdir / _FUSION_APP_EXE))

    for pool in (runnable, bare, app_exes):            # prefer launcher+ini, then bare launcher, then app exe
        if pool:
            pool.sort(key=lambda c: c[0], reverse=True)
            return pool[0][1]
    return None


def _incomplete_webdeploy_present() -> bool:
    """Disk-state (process-INDEPENDENT) 'a fresh install is actively streaming' signal: some hash dir
    has FusionLauncher.exe but NO complete Fusion360.exe exists ANYWHERE yet. Why disk-state and not a
    tasklist check: the Autodesk streamer spawns short-lived per-chunk worker processes, so
    _installer_running() reads False most of a live multi-GB stream (flickered True only 3 of 25 polls
    during a real install, John 2026-07-14). Why gate on 'no complete app anywhere': once the app IS
    complete, the leftover launcher stub (FusionLauncher.exe, no Fusion360.exe) is NORMAL and must NOT
    read as 'still installing'. (An auto-UPDATE that streams a new build beside a complete old one is
    handled separately by fusion_update_in_progress(), not here.) Never raises."""
    if _any_app_complete():
        return False                                  # a complete install exists -> not installing
    for base in _WEBDEPLOY_BASES:
        try:
            if not base.exists():
                continue
            for subdir in base.iterdir():
                if subdir.is_dir() and (subdir / "FusionLauncher.exe").exists():
                    return True                       # launcher present, no complete app -> streaming
        except OSError:
            continue
    return False


def _clean_incomplete_webdeploy() -> list:
    """Remove the residue of an INTERRUPTED stream so the next fusion_install_fusion re-streams clean.
    Only ever called AFTER a launch failure (we already know something is corrupt).

    SAFETY GUARD: if a COMPLETE install (Fusion360.exe) exists anywhere, do NOTHING and return [] -
    the launcher-only stub dir beside a complete app is a LEGIT part of the install, not corruption,
    and nuking it would break a working Fusion. Only when NO complete app exists (a genuinely half-
    streamed state) do we remove the partial launcher-bearing dirs. Never raises. (John 2026-07-14:
    the old .ini-size test would have deleted the legit stub of a healthy install.)"""
    import shutil
    if _any_app_complete():
        return []                                     # healthy install present - never touch it
    removed = []
    for base in _WEBDEPLOY_BASES:
        if not base.exists():
            continue
        try:
            subdirs = [d for d in base.iterdir() if d.is_dir()]
        except OSError:
            continue
        for subdir in subdirs:
            if not (subdir / "FusionLauncher.exe").exists():
                continue
            if not _app_dir_complete(subdir):         # partial: launcher but no real app binary
                try:
                    shutil.rmtree(subdir, ignore_errors=True)
                    removed.append(subdir.name)
                except Exception:
                    pass
    return removed


def fusion_update_in_progress() -> bool:
    """Best-effort: is Fusion ACTIVELY applying an auto-update right now? While it is,
    Fusion crash-restarts ITSELF every ~30-60s, so verbs intermittently see 'not
    running' (observed live on a fresh VM 2026-07-05: hashes 441fa88... + 6a0c961...
    being streamed side by side). Callers surface a distinct non-fatal `updating`
    status instead of a fatal error so a driving AI waits/retries. Never raises.

    IMPORTANT (regression fix 2026-07-06, caught on a real laptop): a bare
    "2+ production dirs" count is NOT an update - Fusion NEVER prunes old webdeploy
    builds, so a normal machine accumulates many stale production dirs (the laptop had
    5, from April-June, Fusion not even running). That made the flag fire permanently
    and falsely. So require ACTIVE streaming: 2+ launcher builds AND at least one build
    dir TOUCHED in the last ~20 min (the streamer is writing into it). Stale leftover
    dirs have old mtimes and no longer trigger it."""
    import time as _t
    now = _t.time()
    RECENT_SECS = 20 * 60
    launcher_mtimes = []
    for base in _WEBDEPLOY_BASES:
        try:
            if not base.exists():
                continue
            for d in base.iterdir():
                try:
                    if d.is_dir() and (d / "FusionLauncher.exe").exists():
                        launcher_mtimes.append(d.stat().st_mtime)
                except OSError:
                    continue
        except OSError:
            continue
    if len(launcher_mtimes) < 2:
        return False
    return any((now - mt) < RECENT_SECS for mt in launcher_mtimes)


def _find_addins_dir() -> Path | None:
    """Find the Fusion 360 add-ins directory."""
    for candidate in ADDINS_DIR_CANDIDATES:
        if candidate.exists():
            return candidate
    # Return the first candidate even if it doesn't exist yet (for install_addin)
    return ADDINS_DIR_CANDIDATES[0] if ADDINS_DIR_CANDIDATES else None


def _check_addin_installed(addins_dir: Path | None) -> bool:
    """Check if the AdomBridge add-in is installed in ANY known add-in dir.

    Scans every ADDINS_DIR_CANDIDATES entry (not just the preferred one) - the
    copy that counts is whichever dir THIS Fusion version scans, and we can't
    know that a priori, so presence anywhere counts as installed."""
    for candidate in ADDINS_DIR_CANDIDATES:
        if (candidate / "AdomBridge" / "AdomBridge.py").exists():
            return True
    if addins_dir and (Path(addins_dir) / "AdomBridge" / "AdomBridge.py").exists():
        return True
    return False


def _is_fusion_running() -> bool:
    """Check if Fusion 360 is running (process check is authoritative).

    Uses tasklist instead of window title matching to avoid false positives
    from browser tabs that mention 'Autodesk Fusion' in their title.
    """
    return _has_fusion_process()


def _has_fusion_process() -> bool:
    """Check if any Fusion 360 process is running via tasklist."""
    try:
        # CREATE_NO_WINDOW (0x08000000) on Windows so the child cmd window doesn't
        # flash on screen for every poll. The Tauri side spawns the Python bridge
        # itself with CREATE_NO_WINDOW, but Python's subprocess.run/Popen creates
        # a fresh console window for each child by default — without this flag,
        # every fusion_running check would briefly show a black cmd window.
        creationflags = 0x08000000 if hasattr(subprocess, "CREATE_NO_WINDOW") else 0
        if hasattr(subprocess, "CREATE_NO_WINDOW"):
            creationflags = subprocess.CREATE_NO_WINDOW
        # stdin=DEVNULL is load-bearing: AD spawns the bridge console-less +
        # detached, so the inherited stdin handle is invalid. Without an explicit
        # DEVNULL, this child can block on a dead handle and deadlock the probe —
        # the exact failure mode that bit the KiCad bridge.
        output = subprocess.check_output(
            ["tasklist", "/FI", "IMAGENAME eq Fusion360.exe", "/FO", "CSV", "/NH"],
            stdin=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            text=True,
            timeout=5,
            creationflags=creationflags,
        )
        # tasklist returns "INFO: No tasks..." when no match
        return "Fusion360.exe" in output
    except Exception:
        return False


def _find_windows_by_title(substring: str) -> list:
    """Find all visible windows whose title contains the given substring."""
    results = []
    WNDENUMPROC = ctypes.WINFUNCTYPE(
        ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM
    )

    def callback(hwnd, _lparam):
        if user32.IsWindowVisible(hwnd):
            length = user32.GetWindowTextLengthW(hwnd)
            if length > 0:
                buf = ctypes.create_unicode_buffer(length + 1)
                user32.GetWindowTextW(hwnd, buf, length + 1)
                if substring in buf.value:
                    results.append((hwnd, buf.value))
        return True

    cb = WNDENUMPROC(callback)
    _callbacks.append(cb)
    user32.EnumWindows(cb, 0)
    _callbacks.remove(cb)
    return results


def is_fusion_running() -> bool:
    """Public API: check if Fusion 360 is currently running (live check)."""
    return _has_fusion_process()


def wait_for_addin(timeout: int = 90, dismiss_dialogs: bool = False) -> bool:
    """Wait for the AdomBridge add-in HTTP server to become responsive.

    Polls the add-in health endpoint once per second up to `timeout` seconds.
    Returns True if the add-in responded, False on timeout.

    If dismiss_dialogs is True, also checks for and dismisses blocking dialogs
    (startup picker, recovery dialog) every 5 seconds while waiting. This is
    critical at startup: dialogs block the add-in's main thread, so dismissing
    them is required for the add-in to become responsive.
    """
    for i in range(timeout):
        try:
            req = urllib.request.Request(
                f"http://127.0.0.1:{ADDIN_PORT}/health", method="GET"
            )
            with urllib.request.urlopen(req, timeout=1) as resp:
                return True
        except Exception:
            # Every 5 seconds during the wait, try to dismiss blocking dialogs.
            # Dialogs are the #1 reason the add-in can't respond at startup.
            if dismiss_dialogs and i >= 5 and i % 5 == 0:
                _dismiss_startup_dialogs()
            time.sleep(1)
    return False


def _dismiss_startup_dialogs():
    """Dismiss common Fusion startup dialogs (recovery, startup picker).

    Uses Win32 APIs directly — no add-in needed. Called during wait_for_addin
    to unblock the main thread so the add-in can become responsive.
    """
    try:
        from handlers.dismiss_recovery import dismiss_recovery_dialog
        dismiss_recovery_dialog(max_wait=2.0)
    except Exception:
        pass

    # Dismiss startup picker ("What do you want to design?") and other
    # Fusion dialogs by sending Escape to the Fusion main window.
    try:
        from handlers.fusion_ui import send_key_to_fusion
        send_key_to_fusion("escape")
    except Exception:
        pass


# ── Background launch: NEVER steal the user's foreground ──────────────────────
# The bridge launches Fusion for AUTOMATION, on a machine the user is actively
# working on. Fusion normally opens its splash + main window in the FOREGROUND,
# yanking focus off whatever the user is doing. That is unacceptable (John, 2026-
# 07-06: "make sure new windows ALWAYS only open in the background"). So we (1)
# start the process MINIMIZED + NOT ACTIVATED via STARTUPINFO, and (2) run a short
# focus-guard thread that, whenever a Fusion window steals the foreground during
# the noisy startup window, minimizes it WITHOUT activating - returning focus to
# the user's work. The add-in still loads fully while minimized. To SHOW Fusion to
# the user later, a verb explicitly restores+foregrounds it (the ONLY time we ever
# foreground). See the fusion-bridge-dev skill "Background-launch invariant".
_SW_SHOWMINNOACTIVE = 7  # show minimized, do NOT activate/steal focus


# Every process in the Autodesk launch family whose windows must NEVER steal the
# user's foreground: the main app (Fusion360.exe), the streamed-app launcher +
# splash ("Loading additional modules" - FusionLauncher.exe), and the identity/seat
# manager (the "Signing in" + "Active Sessions Exceeded" dialogs - AdskIdentityManager
# .exe). The 1.6.33 guard only knew Fusion360.exe, so the splash + seat dialog sailed
# straight past it and yanked focus (John, 2026-07-06). Match the whole family.
_FUSION_FAMILY_IMAGES = {
    "fusion360.exe", "fusionlauncher.exe", "adskidentitymanager.exe",
    "adsso.exe", "adcefwebbrowser.exe", "fusion360bootstrap.exe",
}


def _fusion_family_pids() -> set:
    """PIDs of every running Autodesk-launch-family process (via one tasklist CSV)."""
    try:
        cnw = subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0
        out = subprocess.check_output(
            ["tasklist", "/FO", "CSV", "/NH"],
            stdin=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=cnw,
        ).decode("utf-8", "replace")
    except Exception:
        return set()
    pids = set()
    for m in re.finditer(r'"([^"]+\.exe)","(\d+)"', out):
        if m.group(1).lower() in _FUSION_FAMILY_IMAGES:
            pids.add(int(m.group(2)))
    return pids


def _family_pid_images() -> dict:
    """{pid: image_name} for every running Autodesk-launch-family process."""
    try:
        cnw = subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0
        out = subprocess.check_output(
            ["tasklist", "/FO", "CSV", "/NH"],
            stdin=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=cnw,
        ).decode("utf-8", "replace")
    except Exception:
        return {}
    m = {}
    for mo in re.finditer(r'"([^"]+\.exe)","(\d+)"', out):
        img = mo.group(1).lower()
        if img in _FUSION_FAMILY_IMAGES:
            m[int(mo.group(2))] = img
    return m


def family_windows() -> list:
    """Visible top-level windows owned by the Autodesk launch family (Fusion360 /
    FusionLauncher / AdskIdentityManager). Each dict: {hwnd, title, pid, image, width,
    height, left, top}.

    This is how the launch state machine detects the SEAT/licensing dialog
    DETERMINISTICALLY - by OWNING PROCESS + size, NOT the "Fusion360" window TITLE that
    fooled the title-based classifier for a whole day (the "Active Sessions Exceeded"
    dialog's title is literally just "Fusion360"; the real text is CEF body content).
    Never raises."""
    results = []
    try:
        user32 = ctypes.windll.user32
        HWND = ctypes.wintypes.HWND
        user32.GetWindowThreadProcessId.argtypes = [HWND, ctypes.POINTER(ctypes.wintypes.DWORD)]
        user32.IsWindowVisible.argtypes = [HWND]
        # restype/argtypes MUST be set or the returned HWND is truncated to 32-bit on
        # 64-bit Windows and the owner handle comes back wrong (same class of bug that
        # silently broke the focus-guard, 2026-07-06).
        user32.GetWindow.restype = HWND
        user32.GetWindow.argtypes = [HWND, ctypes.c_uint]
        fam = _fusion_family_pids()
        if not fam:
            return results
        pid_img = _family_pid_images()
    except Exception:
        return results
    WNDENUMPROC = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM)

    def cb(hwnd, _l):
        try:
            if not user32.IsWindowVisible(hwnd):
                return True
            wpid = ctypes.wintypes.DWORD(0)
            user32.GetWindowThreadProcessId(hwnd, ctypes.byref(wpid))
            if wpid.value not in fam:
                return True
            n = user32.GetWindowTextLengthW(hwnd)
            title = ""
            if n:
                buf = ctypes.create_unicode_buffer(n + 1)
                user32.GetWindowTextW(hwnd, buf, n + 1)
                title = buf.value
            rect = ctypes.wintypes.RECT()
            user32.GetWindowRect(hwnd, ctypes.byref(rect))
            w = rect.right - rect.left
            h = rect.bottom - rect.top
            if w <= 0 or h <= 0:
                return True
            GW_OWNER = 4
            owner = user32.GetWindow(hwnd, GW_OWNER)
            results.append({
                "hwnd": int(hwnd), "title": title, "pid": int(wpid.value),
                "image": pid_img.get(int(wpid.value), ""),
                "width": w, "height": h, "left": rect.left, "top": rect.top,
                "owner": int(owner) if owner else 0,
            })
        except Exception:
            pass
        return True

    cb_c = WNDENUMPROC(cb)
    _callbacks.append(cb_c)
    try:
        user32.EnumWindows(cb_c, 0)
    except Exception:
        pass
    try:
        _callbacks.remove(cb_c)
    except ValueError:
        pass
    return results


def find_licensing_dialog():
    """The seat/licensing or sign-in modal ("Active Sessions Exceeded" / "Suspend Remote
    Session" / a sign-in prompt). Detected DETERMINISTICALLY by the OWNED-POPUP signal,
    NOT a fragile height window:

      A seat/sign-in modal is ALWAYS an owned popup of the main Fusion window (confirmed
      via desktop_screenshot_window's ownedPopupCount). The splash and the maximized main
      app are NEVER owned popups. So "family window that has an owner" is the reliable
      tell - it does not care about the exact dialog size or the "Fusion360" title.

    This replaced a size-only filter whose 320px height floor SILENTLY MISSED the
    823x262 "Suspend Remote Session" confirm variant, so readiness reported the dialog
    gone while it sat there blocking (John caught it live, 2026-07-06). A size-based
    fallback is kept for safety in case owner detection ever returns 0.
    Returns the window dict (with 'owner'), or None.

    SHAPE matters, not just ownership: Fusion's own side panels (Data Panel, browser, the
    comments rail) are ~300px-wide OWNED popups too, so an owned-only match false-fires the
    moment a document is open and reads a drivable Fusion as 'blocked' (caught live on winvm
    2026-07-06, right after this over-broad owned check shipped). The seat/sign-in modal is
    always WIDE (823x262 'Suspend Remote Session', 1235x527 'Active Sessions Exceeded') - far
    wider than any side panel - so we require dialog PROPORTIONS: width >= 480 (excludes the
    narrow panels), height >= 180 (excludes toolbar strips), and smaller than the maximized
    main app."""
    wins = family_windows()
    if not wins:
        return None
    # NEVER match the MAIN app window. A seat/sign-in modal is a smaller popup; the main Fusion
    # window is the LARGEST family window. The old no-owner fallback matched the main window whenever
    # it happened to fall inside the shape gate (e.g. a non-maximized 1300x820 window fits
    # 480<=w<=1500, 180<=h<=900) - so find_licensing_dialog false-fired on the drivable main app,
    # and once ad_client came online the seat resolver churned 8x on it and HUNG readiness (John,
    # live 2026-07-14). Excluding the largest window is size-independent and safe: a dialog is never
    # bigger than the app that owns it.
    main_hwnd = max(wins, key=lambda w: w["width"] * w["height"])["hwnd"]
    # Primary: an OWNED popup that is DIALOG-SHAPED (wide modal, not a narrow side panel).
    for w in wins:
        if w["hwnd"] == main_hwnd:
            continue
        ww, hh = w["width"], w["height"]
        if w.get("owner") and 480 <= ww <= 1500 and 180 <= hh <= 900:
            return w
    # Fallback (owner==0 for all - owner detection failed): same shape gate, still NOT the main window.
    for w in wins:
        if w["hwnd"] == main_hwnd:
            continue
        ww, hh = w["width"], w["height"]
        if 480 <= ww <= 1500 and 180 <= hh <= 900:
            return w
    return None


def _fusion_pids() -> set:
    """PIDs of running Fusion360.exe processes (via tasklist CSV). Empty on error."""
    try:
        cnw = subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0
        out = subprocess.check_output(
            ["tasklist", "/FI", "IMAGENAME eq Fusion360.exe", "/FO", "CSV", "/NH"],
            stdin=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=cnw,
        ).decode("utf-8", "replace")
    except Exception:
        return set()
    return {int(p) for p in re.findall(r'"Fusion360\.exe","(\d+)"', out)}


_SPI_GETFOREGROUNDLOCKTIMEOUT = 0x2000
_SPI_SETFOREGROUNDLOCKTIMEOUT = 0x2001
_SPI_GETANIMATION = 0x0048
_SPI_SETANIMATION = 0x0049


class _ANIMATIONINFO(ctypes.Structure):
    _fields_ = [("cbSize", ctypes.c_uint), ("iMinAnimate", ctypes.c_int)]


def _get_min_animate():
    """Current 'animate windows when minimizing/maximizing' setting (1=on), or None."""
    try:
        user32 = ctypes.windll.user32
        ai = _ANIMATIONINFO()
        ai.cbSize = ctypes.sizeof(_ANIMATIONINFO)
        user32.SystemParametersInfoW(_SPI_GETANIMATION, ai.cbSize, ctypes.byref(ai), 0)
        return int(ai.iMinAnimate)
    except Exception:
        return None


def _set_min_animate(val) -> bool:
    """Turn the minimize/maximize ANIMATION on(1)/off(0). We turn it OFF during launch
    so the focus-guard's minimize is an INSTANT single-frame vanish rather than the
    visible slide-down that read as a 'flash' (John 2026-07-06). Restored to the user's
    original value when the launch window ends. Best-effort; no persist/broadcast."""
    try:
        user32 = ctypes.windll.user32
        ai = _ANIMATIONINFO()
        ai.cbSize = ctypes.sizeof(_ANIMATIONINFO)
        ai.iMinAnimate = int(val)
        user32.SystemParametersInfoW(_SPI_SETANIMATION, ai.cbSize, ctypes.byref(ai), 0)
        return True
    except Exception:
        return False


def _get_fg_lock_timeout():
    """Current SystemParametersInfo foreground-lock timeout (ms), or None."""
    try:
        user32 = ctypes.windll.user32
        val = ctypes.wintypes.DWORD(0)
        user32.SystemParametersInfoW(_SPI_GETFOREGROUNDLOCKTIMEOUT, 0, ctypes.byref(val), 0)
        return int(val.value)
    except Exception:
        return None


def _set_fg_lock_timeout(ms) -> bool:
    """Set the foreground-lock timeout. A NONZERO value makes Windows DENY foreground
    changes from processes that don't own the foreground / have no recent user input -
    they get a taskbar flash instead of an actual foreground grab. We raise it right
    before launching Fusion so its splash/main/seat windows can't STEAL focus in the
    first place (proactive), rather than relying only on the reactive minimize-guard
    (which leaves a visible flash as the window appears-then-shrinks). Restored to the
    user's original value when the guard's launch window ends. Best-effort."""
    try:
        user32 = ctypes.windll.user32
        user32.SystemParametersInfoW.argtypes = [
            ctypes.c_uint, ctypes.c_uint, ctypes.c_void_p, ctypes.c_uint]
        user32.SystemParametersInfoW(
            _SPI_SETFOREGROUNDLOCKTIMEOUT, 0, ctypes.c_void_p(int(ms)), 0)
        return True
    except Exception:
        return False


def _popen_fusion_background(exe_path: str):
    """Launch the Fusion launcher MINIMIZED + NOT ACTIVATED, console-less."""
    cnw = subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0
    startupinfo = None
    try:
        startupinfo = subprocess.STARTUPINFO()
        startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
        startupinfo.wShowWindow = _SW_SHOWMINNOACTIVE
    except Exception:
        startupinfo = None
    return subprocess.Popen(
        [exe_path],
        stdin=subprocess.DEVNULL,
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
        creationflags=cnw,
        startupinfo=startupinfo,
    )


def _focus_guard(seconds: float = 150.0, restore_fg_lock=None, restore_min_anim=None):
    """For `seconds`, demote any Autodesk-FAMILY window that steals the foreground.

    SURGICAL: only acts when the CURRENT foreground window belongs to an Autodesk
    launch-family process (Fusion / launcher-splash / identity-seat manager) - i.e.
    something just grabbed focus off the user. It minimizes that window without
    activating anything, so focus falls back to whatever the user was in. It does
    NOT touch the family when the user is working in another app, so it never
    fights a user who deliberately foregrounds Fusion themselves. Best-effort;
    never raises."""
    try:
        user32 = ctypes.windll.user32
        # ⚠️ CRITICAL: set restype/argtypes or ctypes defaults every HANDLE to c_int
        # (32-bit) and TRUNCATES it on 64-bit Windows - GetForegroundWindow then hands
        # back a garbage HWND and ShowWindowAsync silently no-ops (the guard fires into
        # the void; the window is never minimized - the exact 1.6.34 failure, 2026-07-06).
        HWND = ctypes.wintypes.HWND
        user32.GetForegroundWindow.restype = HWND
        user32.GetForegroundWindow.argtypes = []
        user32.GetWindowThreadProcessId.argtypes = [HWND, ctypes.POINTER(ctypes.wintypes.DWORD)]
        user32.GetWindowThreadProcessId.restype = ctypes.wintypes.DWORD
        user32.ShowWindowAsync.argtypes = [HWND, ctypes.c_int]
        user32.ShowWindowAsync.restype = ctypes.wintypes.BOOL
    except Exception:
        return
    end = time.time() + seconds
    pids = set()
    last_refresh = 0.0
    while time.time() < end:
        now = time.time()
        if now - last_refresh > 2.0:
            pids = _fusion_family_pids()  # whole family, not just Fusion360.exe
            last_refresh = now
        try:
            fg = user32.GetForegroundWindow()
            if fg and pids:
                wpid = ctypes.wintypes.DWORD(0)
                user32.GetWindowThreadProcessId(fg, ctypes.byref(wpid))
                if wpid.value in pids:
                    # minimize the focus-stealing family window, no activation
                    user32.ShowWindowAsync(fg, _SW_SHOWMINNOACTIVE)
        except Exception:
            pass
        time.sleep(0.1)
    # Launch window over: restore the user's original foreground-lock + minimize
    # animation (we only changed them DURING Fusion's noisy startup).
    if restore_fg_lock is not None:
        _set_fg_lock_timeout(restore_fg_lock)
    if restore_min_anim is not None:
        _set_min_animate(restore_min_anim)


def _start_focus_guard(seconds: float = 150.0, restore_fg_lock=None, restore_min_anim=None):
    """Fire-and-forget the focus guard on a daemon thread. Never raises."""
    try:
        threading.Thread(
            target=_focus_guard, args=(seconds, restore_fg_lock, restore_min_anim),
            daemon=True,
        ).start()
    except Exception:
        pass


def ensure_fusion_running(fusion_info: dict, wait_addin: bool = False) -> dict | None:
    """Launch Fusion 360 if not already running.

    Uses a live process check (not the stale fusion_info["running"] flag).
    Returns None on success, or an error dict on failure.
    If wait_addin is True, also waits for the add-in to be responsive.
    """
    # Live check — ignore stale fusion_info["running"]
    if _has_fusion_process():
        if wait_addin:
            # Fusion is running but add-in might not be ready yet
            if not wait_for_addin(timeout=10):
                return {
                    "success": False,
                    "error": "Fusion 360 is running but AdomBridge add-in not responding.",
                    "errorCode": "fusion_addin_not_responding",
                    "_hint": "Fix it YOURSELF - never ask the user: (1) run install_addin() so the "
                             "add-in lands in EVERY Fusion add-in dir (Fusion moved it to "
                             "%APPDATA%\\Autodesk\\FusionAddins on 2025+ builds; an add-in in the "
                             "legacy API\\AddIns dir is silently ignored - issue #63), then "
                             "(2) restart Fusion via fusion_stop + fusion_start so it rescans.",
                }
        return None  # Already running (and add-in ready if requested)

    exe_path = fusion_info.get("exe_path")
    if not exe_path or not Path(exe_path).exists():
        return {"success": False, "error": "Fusion 360 executable not found"}

    try:
        # PROACTIVE: raise the foreground-lock timeout so Windows DENIES Fusion's
        # splash/main/seat windows the ability to steal focus in the first place (they
        # get a taskbar flash, not a real foreground grab). This is what stops the
        # visible FLICKER the reactive guard alone leaves. Restored by the guard at the
        # end of the launch window.
        _orig_fg_lock = _get_fg_lock_timeout()
        _set_fg_lock_timeout(300000)
        # Disable the minimize ANIMATION so the guard's minimize is an INSTANT vanish
        # (no visible slide-down = no perceptible flash). Restored by the guard.
        _orig_min_anim = _get_min_animate()
        _set_min_animate(0)
        # Launch MINIMIZED + NOT ACTIVATED (STARTUPINFO). DEVNULL on all three std
        # streams + CREATE_NO_WINDOW: the bridge is a detached, console-less child of
        # AD, so inherited handles are invalid.
        _popen_fusion_background(exe_path)
        # Reactive backstop: minimize any family window that still slips to foreground,
        # and restore the user's original foreground-lock + animation when done.
        _start_focus_guard(seconds=150.0, restore_fg_lock=_orig_fg_lock,
                           restore_min_anim=_orig_min_anim)
    except Exception as e:
        return {"success": False, "error": f"Failed to launch Fusion 360: {e}"}

    # Wait for Fusion process to appear (up to 30s for launcher to spawn main process)
    for _ in range(60):
        time.sleep(0.5)
        if _has_fusion_process():
            break
    else:
        # LAUNCH FAILED - do NOT return a blind "process not found". READ the dialog that is almost
        # certainly on screen. The classic fresh-box cause is an INCOMPLETE stream: "Error Launching
        # Streamed Application ... FusionLauncher.exe.ini is missing or incomplete" (John, live on a
        # fresh Hyper-V VM 2026-07-14). Detect + dismiss it, CLEAN the corrupt webdeploy dir(s), and
        # tell the caller to re-stream - instead of silently sitting on a broken install.
        launch_err = None
        try:
            from handlers.dialog_classify import classify_launch_dialogs, close_dialog_bg
            for d in classify_launch_dialogs():
                if d.get("category") == "launch_error" or "streamed application" in (d.get("title") or "").lower():
                    launch_err = d
                    close_dialog_bg(d.get("hwnd"))
        except Exception:
            pass

        # DISTINGUISH the two causes (John 2026-07-14 - conflating them mis-blamed a healthy install):
        #  (a) app is COMPLETE (Fusion360.exe present) but launch failed -> almost always we launched a
        #      FusionLauncher.exe WITHOUT its .ini, or a stale lock from a just-killed instance. The
        #      install is FINE; do NOT tell the user to re-stream. _find_fusion_launcher now prefers the
        #      launcher-with-.ini, so a retry usually succeeds.
        #  (b) app is NOT complete -> a genuinely half-streamed install; clean the partial dirs so the
        #      next fusion_install_fusion re-streams clean.
        if _any_app_complete():
            return {
                "success": False,
                "error": "Fusion is INSTALLED (Fusion360.exe present) but did not start within 30s"
                         + (" - a launch-error dialog was dismissed" if launch_err else "") + ".",
                "errorCode": "fusion_launch_failed",
                "installed": True,
                "dialog": (launch_err or {}).get("title"),
                "_hint": "The install is COMPLETE - do NOT re-stream. Retry fusion_start (it now picks the "
                         "FusionLauncher.exe that has its .ini beside it; a stale lock from a just-killed "
                         "instance also clears on a retry). If it keeps failing, fusion_kill then wait a "
                         "few seconds before fusion_start.",
            }
        removed = _clean_incomplete_webdeploy()
        return {
            "success": False,
            "error": "Fusion launch failed: the streamed install is INCOMPLETE (no Fusion360.exe yet).",
            "errorCode": "fusion_incomplete_install",
            "cleanedWebdeployDirs": removed,
            "dialog": (launch_err or {}).get("title"),
            "_hint": "AUTO-RECOVERED: dismissed the launch-error dialog and removed the partial webdeploy "
                     "dir(s). Re-run fusion_install_fusion, then WAIT for fusion_readiness to report "
                     "installed:true BEFORE fusion_start - starting mid-stream is what corrupts the install.",
        }

    # Give it time to finish initializing UI
    time.sleep(5.0)

    if wait_addin:
        # dismiss_dialogs=True: while waiting for the add-in, also check for
        # and dismiss blocking dialogs every 5s. This is critical because
        # startup dialogs (picker, recovery) block the add-in's main thread.
        if not wait_for_addin(timeout=60, dismiss_dialogs=True):
            return {
                "success": False,
                "error": "addin_timeout_after_launch",
                "message": "Fusion 360 launched but the AdomBridge add-in didn't respond "
                           "within 60s. A modal dialog (e.g. Document Recovery, update prompt) "
                           "may be blocking startup. Check Fusion and dismiss any dialogs, "
                           "then retry.",
            }

    return None


if __name__ == "__main__":
    info = detect_fusion()
    print(json.dumps(info, indent=2))