"""macOS implementations of the Fusion 360 UI-automation surface.

Mirrors handlers/fusion_ui.py's public API (screenshot_fusion_window,
screenshot_hwnd, click_fusion, send_key_to_fusion, close_window,
get_fusion_window_info) plus the primitives close_fusion.py /
dismiss_recovery.py / dialog_classify.py need, using:

- CGWindowListCopyWindowInfo (via ctypes on CoreGraphics) for window
  enumeration — the "hwnd" the bridge hands out on macOS is the CGWindowID.
- /usr/sbin/screencapture -l <windowid> for window capture (works while the
  window is backgrounded; needs the Screen Recording TCC permission).
- CGEvent (CGEventCreateMouseEvent / CGEventCreateKeyboardEvent) for input —
  synthetic input. Unicode-string key events cover arbitrary characters
  without a keycode table.
- System Events (osascript/JXA) for accessibility actions: raising a window,
  clicking a window's close button in the BACKGROUND (no focus steal),
  and app activation by pid. Needs the Accessibility TCC permission.

Response shapes match fusion_ui.py exactly so server.py and the callers see
one contract on both platforms. Every entry point is best-effort and returns
{"success": False, "error": ...} rather than raising.
"""

import ctypes
import os
import re
import subprocess
import sys
import time

IS_MACOS = sys.platform == "darwin"

SCREENSHOT_DIR = "/tmp/conduit-screenshots"
SCREENCAPTURE = "/usr/sbin/screencapture"
SIPS = "/usr/bin/sips"
OSASCRIPT = "/usr/bin/osascript"

# Marker every Fusion-on-mac process command line contains.
FUSION_PROC_PATTERN = r"Autodesk Fusion\.app/Contents/MacOS"

# ---------------------------------------------------------------------------
# CoreFoundation / CoreGraphics via ctypes
# ---------------------------------------------------------------------------

if IS_MACOS:
    _CG = ctypes.CDLL("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")
    _CF = ctypes.CDLL("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")
else:  # pragma: no cover - module only used on macOS
    _CG = None
    _CF = None

_kCFStringEncodingUTF8 = 0x08000100
_kCFNumberSInt64Type = 4
_kCFNumberFloat64Type = 6

_kCGWindowListOptionOnScreenOnly = 1 << 0
_kCGWindowListExcludeDesktopElements = 1 << 4
_kCGNullWindowID = 0

_kCGHIDEventTap = 0

_kCGEventLeftMouseDown = 1
_kCGEventLeftMouseUp = 2
_kCGEventMouseMoved = 5
_kCGMouseButtonLeft = 0

_kCGEventFlagMaskShift = 0x00020000
_kCGEventFlagMaskControl = 0x00040000
_kCGEventFlagMaskAlternate = 0x00080000  # option
_kCGEventFlagMaskCommand = 0x00100000


class _CGPoint(ctypes.Structure):
    _fields_ = [("x", ctypes.c_double), ("y", ctypes.c_double)]


class _CGRect(ctypes.Structure):
    _fields_ = [("x", ctypes.c_double), ("y", ctypes.c_double),
                ("w", ctypes.c_double), ("h", ctypes.c_double)]


if IS_MACOS:
    _CF.CFRelease.argtypes = [ctypes.c_void_p]
    _CF.CFArrayGetCount.restype = ctypes.c_long
    _CF.CFArrayGetCount.argtypes = [ctypes.c_void_p]
    _CF.CFArrayGetValueAtIndex.restype = ctypes.c_void_p
    _CF.CFArrayGetValueAtIndex.argtypes = [ctypes.c_void_p, ctypes.c_long]
    _CF.CFDictionaryGetValue.restype = ctypes.c_void_p
    _CF.CFDictionaryGetValue.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
    _CF.CFStringCreateWithCString.restype = ctypes.c_void_p
    _CF.CFStringCreateWithCString.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_uint32]
    _CF.CFStringGetCString.restype = ctypes.c_bool
    _CF.CFStringGetCString.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_long, ctypes.c_uint32]
    _CF.CFNumberGetValue.restype = ctypes.c_bool
    _CF.CFNumberGetValue.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_void_p]
    _CF.CFBooleanGetValue.restype = ctypes.c_bool
    _CF.CFBooleanGetValue.argtypes = [ctypes.c_void_p]

    _CG.CGWindowListCopyWindowInfo.restype = ctypes.c_void_p
    _CG.CGWindowListCopyWindowInfo.argtypes = [ctypes.c_uint32, ctypes.c_uint32]
    _CG.CGRectMakeWithDictionaryRepresentation.restype = ctypes.c_bool
    _CG.CGRectMakeWithDictionaryRepresentation.argtypes = [ctypes.c_void_p, ctypes.POINTER(_CGRect)]

    _CG.CGEventCreateMouseEvent.restype = ctypes.c_void_p
    _CG.CGEventCreateMouseEvent.argtypes = [ctypes.c_void_p, ctypes.c_uint32, _CGPoint, ctypes.c_uint32]
    _CG.CGEventCreateKeyboardEvent.restype = ctypes.c_void_p
    _CG.CGEventCreateKeyboardEvent.argtypes = [ctypes.c_void_p, ctypes.c_uint16, ctypes.c_bool]
    _CG.CGEventPost.argtypes = [ctypes.c_uint32, ctypes.c_void_p]
    _CG.CGEventSetFlags.argtypes = [ctypes.c_void_p, ctypes.c_uint64]
    _CG.CGEventKeyboardSetUnicodeString.argtypes = [
        ctypes.c_void_p, ctypes.c_long, ctypes.POINTER(ctypes.c_uint16)]
    try:
        _CG.CGPreflightScreenCaptureAccess.restype = ctypes.c_bool
    except Exception:
        pass

    # Pre-built CFString keys for the CGWindow dictionaries.
    def _cfstr(s: str):
        return _CF.CFStringCreateWithCString(None, s.encode("utf-8"), _kCFStringEncodingUTF8)

    _K_NUMBER = _cfstr("kCGWindowNumber")
    _K_PID = _cfstr("kCGWindowOwnerPID")
    _K_LAYER = _cfstr("kCGWindowLayer")
    _K_NAME = _cfstr("kCGWindowName")
    _K_OWNER = _cfstr("kCGWindowOwnerName")
    _K_BOUNDS = _cfstr("kCGWindowBounds")
    _K_ALPHA = _cfstr("kCGWindowAlpha")


def _cf_to_str(ref) -> str:
    if not ref:
        return ""
    buf = ctypes.create_string_buffer(2048)
    if _CF.CFStringGetCString(ref, buf, 2048, _kCFStringEncodingUTF8):
        return buf.value.decode("utf-8", "replace")
    return ""


def _cf_to_int(ref):
    if not ref:
        return None
    v = ctypes.c_int64(0)
    if _CF.CFNumberGetValue(ref, _kCFNumberSInt64Type, ctypes.byref(v)):
        return int(v.value)
    return None


def _cf_to_float(ref):
    if not ref:
        return None
    v = ctypes.c_double(0)
    if _CF.CFNumberGetValue(ref, _kCFNumberFloat64Type, ctypes.byref(v)):
        return float(v.value)
    return None


def list_windows() -> list:
    """Every on-screen, non-desktop window as a plain dict.

    Keys: id, pid, owner, title, layer, alpha, rect {left, top, right, bottom,
    width, height}. Coordinates are Quartz global points, top-left origin —
    the same space CGEvent input uses, so a rect can be clicked directly.
    """
    out = []
    arr = _CG.CGWindowListCopyWindowInfo(
        _kCGWindowListOptionOnScreenOnly | _kCGWindowListExcludeDesktopElements,
        _kCGNullWindowID)
    if not arr:
        return out
    try:
        n = _CF.CFArrayGetCount(arr)
        for i in range(n):
            d = _CF.CFArrayGetValueAtIndex(arr, i)
            if not d:
                continue
            rect = _CGRect()
            bounds = _CF.CFDictionaryGetValue(d, _K_BOUNDS)
            if not bounds or not _CG.CGRectMakeWithDictionaryRepresentation(bounds, ctypes.byref(rect)):
                continue
            alpha = _cf_to_float(_CF.CFDictionaryGetValue(d, _K_ALPHA))
            out.append({
                "id": _cf_to_int(_CF.CFDictionaryGetValue(d, _K_NUMBER)),
                "pid": _cf_to_int(_CF.CFDictionaryGetValue(d, _K_PID)),
                "owner": _cf_to_str(_CF.CFDictionaryGetValue(d, _K_OWNER)),
                "title": _cf_to_str(_CF.CFDictionaryGetValue(d, _K_NAME)),
                "layer": _cf_to_int(_CF.CFDictionaryGetValue(d, _K_LAYER)) or 0,
                "alpha": 1.0 if alpha is None else alpha,
                "rect": {
                    "left": int(rect.x), "top": int(rect.y),
                    "right": int(rect.x + rect.w), "bottom": int(rect.y + rect.h),
                    "width": int(rect.w), "height": int(rect.h),
                },
            })
    finally:
        _CF.CFRelease(arr)
    return out


def _window_by_id(winid):
    for w in list_windows():
        if w["id"] == int(winid):
            return w
    return None


def window_valid(winid) -> bool:
    return _window_by_id(winid) is not None


def get_window_rect(winid) -> tuple:
    w = _window_by_id(winid)
    if not w:
        return (0, 0, 0, 0)
    r = w["rect"]
    return (r["left"], r["top"], r["right"], r["bottom"])


# ---------------------------------------------------------------------------
# Fusion process + window discovery
# ---------------------------------------------------------------------------

def fusion_pids() -> list:
    try:
        r = subprocess.run(["pgrep", "-f", FUSION_PROC_PATTERN],
                           stdin=subprocess.DEVNULL, capture_output=True,
                           text=True, timeout=5)
        return [int(p) for p in r.stdout.split()] if r.returncode == 0 else []
    except Exception:
        return []


def has_fusion_process() -> bool:
    return bool(fusion_pids())


def _fusion_windows() -> list:
    pids = set(fusion_pids())
    if not pids:
        return []
    return [w for w in list_windows()
            if w["pid"] in pids and w["layer"] == 0 and w["alpha"] > 0.05]


def find_fusion_main_winid():
    """The main Fusion window's CGWindowID (or 0).

    macOS window titles don't always carry the app name, so prefer a title
    containing 'Autodesk Fusion' but fall back to the LARGEST normal-layer
    Fusion window.
    """
    wins = _fusion_windows()
    if not wins:
        return 0
    titled = [w for w in wins if "autodesk fusion" in (w["title"] or "").lower()]
    pool = titled or wins
    pool.sort(key=lambda w: w["rect"]["width"] * w["rect"]["height"], reverse=True)
    return pool[0]["id"]


def find_dialog_windows() -> list:
    """Fusion-owned windows that are NOT the main window — the dialog list.

    Mirrors fusion_ui._find_qt_dialog_windows' shape ({hwnd, title, className,
    rect}) and its small-panel filter: tiny auxiliary windows (<400x300 with no
    title) are skipped, but ANY titled window is kept (recovery prompts can be
    small).
    """
    main_id = find_fusion_main_winid()
    out = []
    for w in _fusion_windows():
        if w["id"] == main_id:
            continue
        r = w["rect"]
        titled = bool((w["title"] or "").strip())
        if not titled and (r["width"] < 400 or r["height"] < 300):
            continue
        # ASPECT GUARD (ported from the Windows fusion_ui, 2026-07-23): the size
        # test alone is not enough — Fusion's docked side panels (BROWSER,
        # Timeline, Comments) are TALL and NARROW and sail past 400x300, getting
        # reported as blocking dialogs on an idle Fusion. Real blocking dialogs
        # (startup picker, licensing, recovery) are landscape or roughly square,
        # so anything markedly taller than wide is a docked panel, not a dialog.
        if not titled and r["height"] > r["width"] * 1.5:
            continue
        out.append({
            "hwnd": w["id"],
            "title": w["title"],
            "className": "CGWindow",
            "rect": r,
        })
    return out


def find_windows_by_title(substring: str) -> list:
    """(winid, title) for every on-screen window whose title contains substring.

    Searches ALL processes (the dismiss/classify paths need AdskIdentityManager
    and launcher dialogs too, and mac titles often omit the app name).
    """
    sub = (substring or "").lower()
    return [(w["id"], w["title"]) for w in list_windows()
            if w["layer"] == 0 and sub in (w["title"] or "").lower()]


def enumerate_all_top_level() -> list:
    """(winid, title) for every visible, titled, normal-layer window."""
    return [(w["id"], w["title"]) for w in list_windows()
            if w["layer"] == 0 and (w["title"] or "").strip()]


# ---------------------------------------------------------------------------
# osascript / System Events helpers (Accessibility)
# ---------------------------------------------------------------------------

def _osascript(script: str, timeout: float = 10.0):
    try:
        r = subprocess.run([OSASCRIPT, "-e", script], stdin=subprocess.DEVNULL,
                           capture_output=True, text=True, timeout=timeout)
        return r.returncode == 0, (r.stdout or "").strip(), (r.stderr or "").strip()
    except Exception as e:
        return False, "", str(e)


def _activate_pid(pid) -> bool:
    ok, _, _ = _osascript(
        f'tell application "System Events" to set frontmost of '
        f'(first process whose unix id is {int(pid)}) to true')
    return ok


def activate_fusion() -> bool:
    pids = fusion_pids()
    return _activate_pid(pids[0]) if pids else False


def raise_window(winid) -> bool:
    """Best-effort AXRaise of the window (after activating its app)."""
    w = _window_by_id(winid)
    if not w:
        return False
    _activate_pid(w["pid"])
    title = (w["title"] or "").replace('"', '')
    if title:
        _osascript(
            f'tell application "System Events" to tell '
            f'(first process whose unix id is {w["pid"]}) to '
            f'perform action "AXRaise" of (first window whose name is "{title}")')
    time.sleep(0.15)
    return True


def _ax_close_window(winid) -> bool:
    """Click the window's close button via Accessibility — background-safe
    (no raise, no focus steal)."""
    w = _window_by_id(winid)
    if not w:
        return False
    title = (w["title"] or "").replace('"', '')
    pid = w["pid"]
    if title:
        ok, out, _ = _osascript(
            f'tell application "System Events" to tell '
            f'(first process whose unix id is {pid}) to '
            f'click (first button whose subrole is "AXCloseButton") of '
            f'(first window whose name is "{title}")')
        if ok:
            return True
    # Untitled (or name mismatch): try index-matching by position.
    r = w["rect"]
    ok, out, _ = _osascript(
        f'tell application "System Events" to tell '
        f'(first process whose unix id is {pid})\n'
        f'repeat with wnd in windows\n'
        f'  if (item 1 of (position of wnd)) is {r["left"]} and '
        f'(item 2 of (position of wnd)) is {r["top"]} then\n'
        f'    click (first button whose subrole is "AXCloseButton") of wnd\n'
        f'    return "closed"\n'
        f'  end if\n'
        f'end repeat\n'
        f'return "notfound"\n'
        f'end tell')
    return ok and out == "closed"


def close_window_bg(winid) -> bool:
    """Background close (never raises, never steals focus)."""
    try:
        return _ax_close_window(winid)
    except Exception:
        return False


def close_and_wait(winid, timeout: float = 15.0) -> bool:
    """Close a window and wait for it to disappear from the window list."""
    if not _ax_close_window(winid):
        # Fall back to raise + Cmd+W.
        raise_window(winid)
        send_named_key("w", flags=_kCGEventFlagMaskCommand)
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        time.sleep(0.3)
        if not window_valid(winid):
            return True
    return False


def quit_fusion_native() -> bool:
    """Ask Fusion to quit like a user would (Quit menu semantics)."""
    ok, _, _ = _osascript('tell application "Autodesk Fusion" to quit', timeout=20)
    return ok


def kill_fusion() -> int:
    """Force-kill every Fusion process. Returns 1 if anything was signalled."""
    try:
        r = subprocess.run(["pkill", "-9", "-f", FUSION_PROC_PATTERN],
                           stdin=subprocess.DEVNULL, capture_output=True, timeout=10)
        return 1 if r.returncode == 0 else 0
    except Exception:
        return 0


# ---------------------------------------------------------------------------
# Input synthesis (CGEvent)
# ---------------------------------------------------------------------------

# macOS virtual keycodes (kVK_*) for the same named keys VK_MAP supports.
KEYCODE_MAP = {
    "enter": 36, "return": 36,
    "escape": 53, "esc": 53,
    "tab": 48,
    "space": 49,
    "up": 126, "down": 125, "left": 123, "right": 124,
    "backspace": 51, "delete": 117,
    "home": 115, "end": 119,
    "pageup": 116, "pagedown": 121,
    "f1": 122, "f2": 120, "f3": 99, "f4": 118,
    "f5": 96, "f6": 97, "f7": 98, "f8": 100,
    "f9": 101, "f10": 109, "f11": 103, "f12": 111,
}

_MODIFIER_FLAGS = {
    "cmd": _kCGEventFlagMaskCommand, "command": _kCGEventFlagMaskCommand,
    "ctrl": _kCGEventFlagMaskControl, "control": _kCGEventFlagMaskControl,
    "alt": _kCGEventFlagMaskAlternate, "option": _kCGEventFlagMaskAlternate,
    "shift": _kCGEventFlagMaskShift,
}


def _post_key_event(keycode: int, down: bool, flags: int = 0, unicode_char: str = None):
    evt = _CG.CGEventCreateKeyboardEvent(None, keycode, down)
    if not evt:
        return
    try:
        if flags:
            _CG.CGEventSetFlags(evt, flags)
        if unicode_char:
            units = unicode_char.encode("utf-16-le")
            n = len(units) // 2
            buf = (ctypes.c_uint16 * n).from_buffer_copy(units)
            _CG.CGEventKeyboardSetUnicodeString(evt, n, buf)
        _CG.CGEventPost(_kCGHIDEventTap, evt)
    finally:
        _CF.CFRelease(evt)


def send_named_key(key: str, flags: int = 0) -> bool:
    keycode = KEYCODE_MAP.get(key.lower())
    if keycode is None:
        if len(key) != 1:
            return False
        _post_key_event(0, True, flags, unicode_char=key)
        time.sleep(0.02)
        _post_key_event(0, False, flags, unicode_char=key)
        return True
    _post_key_event(keycode, True, flags)
    time.sleep(0.02)
    _post_key_event(keycode, False, flags)
    return True


def _post_mouse(evt_type: int, x: float, y: float):
    pt = _CGPoint(float(x), float(y))
    evt = _CG.CGEventCreateMouseEvent(None, evt_type, pt, _kCGMouseButtonLeft)
    if not evt:
        return
    try:
        _CG.CGEventPost(_kCGHIDEventTap, evt)
    finally:
        _CF.CFRelease(evt)


# ---------------------------------------------------------------------------
# Public API — same shapes as handlers/fusion_ui.py
# ---------------------------------------------------------------------------

def _screen_recording_hint():
    try:
        if not bool(_CG.CGPreflightScreenCaptureAccess()):
            return ("macOS Screen Recording permission is missing for the bridge's host "
                    "process. Grant it in System Settings > Privacy & Security > Screen "
                    "Recording (for Hydrogen Desktop / the terminal that spawned the "
                    "bridge), then retry.")
    except Exception:
        pass
    return None


def _img_dimensions(path: str):
    try:
        r = subprocess.run([SIPS, "-g", "pixelWidth", "-g", "pixelHeight", path],
                           stdin=subprocess.DEVNULL, capture_output=True,
                           text=True, timeout=10)
        w = h = 0
        for line in r.stdout.splitlines():
            m = re.search(r"pixelWidth:\s*(\d+)", line)
            if m:
                w = int(m.group(1))
            m = re.search(r"pixelHeight:\s*(\d+)", line)
            if m:
                h = int(m.group(1))
        return w, h
    except Exception:
        return 0, 0


def _downscale(path: str, max_dim: int = 1568):
    w, h = _img_dimensions(path)
    if max(w, h) > max_dim:
        try:
            subprocess.run([SIPS, "-Z", str(max_dim), path],
                           stdin=subprocess.DEVNULL, capture_output=True, timeout=15)
            return _img_dimensions(path)
        except Exception:
            pass
    return w, h


def _capture(winid, label: str = "", region: dict = None) -> dict:
    os.makedirs(SCREENSHOT_DIR, exist_ok=True)
    timestamp = int(time.time() * 1000)
    suffix = f"-{label}" if label else ""
    path = os.path.join(SCREENSHOT_DIR, f"fusion{suffix}-{timestamp}.png")
    if region is not None:
        cmd = [SCREENCAPTURE, "-x",
               "-R", f'{region["left"]},{region["top"]},{region["width"]},{region["height"]}',
               path]
    else:
        cmd = [SCREENCAPTURE, "-x", "-o", "-l", str(int(winid)), path]
    try:
        r = subprocess.run(cmd, stdin=subprocess.DEVNULL, capture_output=True,
                           text=True, timeout=20)
    except Exception as e:
        return {"success": False, "error": f"screencapture failed: {e}"}
    if r.returncode != 0 or not os.path.exists(path) or os.path.getsize(path) == 0:
        err = (r.stderr or "").strip() or f"screencapture exited {r.returncode}"
        hint = _screen_recording_hint()
        resp = {"success": False, "error": f"screencapture failed: {err}"}
        if hint:
            resp["_hint"] = hint
        return resp
    w, h = _downscale(path)
    size_kb = os.path.getsize(path) / 1024
    return {
        "success": True,
        "savedTo": path,
        "sizeKB": round(size_kb, 1),
        "dimensions": {"width": w, "height": h},
    }


def screenshot_hwnd(hwnd, label: str = "") -> dict:
    """Capture any window by CGWindowID. Returns {success, savedTo, sizeKB}."""
    winid = int(hwnd)
    if not window_valid(winid):
        return {"success": False, "error": f"Window id {winid} is not a valid window"}
    return _capture(winid, label=label)


def screenshot_fusion_window(use_bitblt: bool = False) -> dict:
    """Capture the Fusion window.

    Default: window-scoped capture (screencapture -l) — works while
    backgrounded. use_bitblt=True mirrors the Windows BitBlt path: raise the
    window and grab its screen REGION, which also captures anything overlapping
    it (floating overlays that render as separate windows).
    """
    winid = find_fusion_main_winid()
    if not winid:
        return {
            "success": False,
            "error": "Fusion 360 window not found (no on-screen Fusion window).",
            "_hint": "Call fusion_start to start Fusion 360 and wait for the add-in to be ready, then retry.",
        }
    if use_bitblt:
        w = _window_by_id(winid)
        raise_window(winid)
        time.sleep(1.0)
        return _capture(winid, region=w["rect"])
    return _capture(winid)


def click_fusion(x, y, relative=True, hwnd=None) -> dict:
    """Click at coordinates in a window (Fusion main window or any dialog).

    Same convention as Windows: relative=True means x/y are 0.0-1.0 fractions
    of the window; relative=False means pixel (point) offsets from its top-left.
    """
    if hwnd:
        winid = int(hwnd)
        if not window_valid(winid):
            return {
                "success": False,
                "error": f"Window id {winid} is not a valid window.",
                "_hint": "Window ids change when windows close/reopen. Call fusion_get_window_info to get fresh ids, then retry.",
            }
    else:
        winid = find_fusion_main_winid()
        if not winid:
            return {
                "success": False,
                "error": "Fusion 360 window not found.",
                "_hint": "Call fusion_start to start Fusion 360, then retry.",
            }

    left, top, right, bottom = get_window_rect(winid)
    width, height = right - left, bottom - top

    raise_window(winid)
    time.sleep(0.1)

    if relative:
        screen_x = left + int(float(x) * width)
        screen_y = top + int(float(y) * height)
    else:
        screen_x = left + int(x)
        screen_y = top + int(y)

    _post_mouse(_kCGEventMouseMoved, screen_x, screen_y)
    time.sleep(0.05)
    _post_mouse(_kCGEventLeftMouseDown, screen_x, screen_y)
    time.sleep(0.03)
    _post_mouse(_kCGEventLeftMouseUp, screen_x, screen_y)

    return {
        "success": True,
        "clickedAt": {"screenX": screen_x, "screenY": screen_y},
        "windowOffset": {"x": screen_x - left, "y": screen_y - top},
        "relative": relative,
    }


def send_key_to_fusion(key: str, hwnd=None) -> dict:
    """Send a key to Fusion (or a specific window) via CGEvent.

    Accepts the same named keys as Windows (enter/escape/tab/arrows/f1-f12/...)
    or a single character. Also accepts mac-style combos like "cmd+w".
    """
    if hwnd:
        winid = int(hwnd)
        if not window_valid(winid):
            return {
                "success": False,
                "error": f"Window id {winid} is not a valid window.",
                "_hint": "Window ids change when windows close/reopen. Call fusion_get_window_info to get fresh ids, then retry.",
            }
        raise_window(winid)
    else:
        winid = find_fusion_main_winid()
        if not winid:
            return {
                "success": False,
                "error": "Fusion 360 window not found.",
                "_hint": "Call fusion_start to start Fusion 360, then retry.",
            }
        raise_window(winid)
    time.sleep(0.1)

    key_norm = key.strip()
    flags = 0
    while "+" in key_norm:
        mod, rest = key_norm.split("+", 1)
        f = _MODIFIER_FLAGS.get(mod.lower().strip())
        if f is None:
            break
        flags |= f
        key_norm = rest.strip()

    key_lower = key_norm.lower()
    keycode = KEYCODE_MAP.get(key_lower)
    if keycode is None and len(key_norm) != 1:
        return {"success": False,
                "error": f"Unknown key: '{key}'. Use a named key or single character."}

    ok = send_named_key(key_norm if keycode is None else key_lower, flags=flags)
    if not ok:
        return {"success": False, "error": f"Cannot send key '{key}'."}
    return {"success": True, "key": key_lower, "keycode": keycode,
            "modifiers": flags != 0}


def close_window(hwnd) -> dict:
    """Close a window via its Accessibility close button (background-safe).

    Background-safe: does not raise or focus the window. The window can still
    prompt for confirmation — this is equivalent to clicking the red close
    button.
    """
    winid = int(hwnd)
    if not window_valid(winid):
        return {"success": False, "error": f"Window id {winid} is not a valid window."}
    if _ax_close_window(winid):
        return {"success": True, "hwnd": winid, "action": "AX close-button clicked"}
    # Fall back to raise + Cmd+W.
    raise_window(winid)
    time.sleep(0.1)
    send_named_key("w", flags=_kCGEventFlagMaskCommand)
    return {"success": True, "hwnd": winid, "action": "Cmd+W sent (AX close button not found)"}


def _main_enabled(pid, dialog_count: int) -> bool:
    """A modal AX window means the main window is blocked — the mac analog of
    a disabled main window under a modal. Best-effort; defaults to True."""
    try:
        ok, out, _ = _osascript(
            f'tell application "System Events" to tell '
            f'(first process whose unix id is {int(pid)}) to '
            f'return count of (windows whose value of attribute "AXModal" is true)',
            timeout=6)
        if ok and out.isdigit():
            return int(out) == 0
    except Exception:
        pass
    return True


def get_fusion_window_info() -> dict:
    """Main Fusion window info + dialog windows. Same shape as Windows."""
    winid = find_fusion_main_winid()
    if not winid:
        return {
            "success": False,
            "error": "Fusion 360 window not found.",
            "_hint": "Call fusion_start to start Fusion 360, then retry.",
        }
    w = _window_by_id(winid)
    dialogs = find_dialog_windows()
    return {
        "success": True,
        "hwnd": winid,
        "title": w["title"],
        "mainEnabled": _main_enabled(w["pid"], len(dialogs)),
        "rect": w["rect"],
        "dialogs": dialogs,
    }
