123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809
"""Win32-based tools for interacting with KiCad's UI.

Windows: provides screenshot, click, and keyboard input against KiCad windows
(project manager, eeschema, pcbnew, footprint editor, symbol editor, 3D viewer,
and their dialogs). Mirrors the Fusion 360 UI handler at
plugins/fusion360/handlers/fusion_ui.py.

macOS: handlers return "not implemented" responses. Faithful UI scripting
against another app on macOS requires Accessibility-framework permission,
which we don't request. The screenshot path is handled by the Tauri app's
CoreGraphics implementation in src-tauri/src/screenshot/macos.rs instead.
"""

import ctypes
import ctypes.wintypes
import os
import struct
import sys
import time

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

# ctypes.Structure / Union / WINFUNCTYPE are cross-platform; only ctypes.windll
# is Windows-only. So the module-level INPUT/MOUSEINPUT/etc. structs below
# load fine on macOS — they just never get used because handlers branch
# at the entry point.
if IS_WINDOWS:
    user32 = ctypes.windll.user32
    gdi32 = ctypes.windll.gdi32

    # Enable DPI awareness so GetWindowRect returns physical pixel coordinates
    try:
        ctypes.windll.shcore.SetProcessDpiAwareness(2)
    except Exception:
        try:
            user32.SetProcessDPIAware()
        except Exception:
            pass
else:
    # Sentinels so module-level expressions referencing user32/gdi32 don't
    # NameError. Helper functions that touch these are guarded by IS_WINDOWS
    # at call time via the handler entry points.
    user32 = None
    gdi32 = None

# --- Constants ---

BI_RGB = 0
DIB_RGB_COLORS = 0
GW_OWNER = 4
PW_RENDERFULLCONTENT = 0x00000002

INPUT_MOUSE = 0
INPUT_KEYBOARD = 1
MOUSEEVENTF_ABSOLUTE = 0x8000
MOUSEEVENTF_MOVE = 0x0001
MOUSEEVENTF_LEFTDOWN = 0x0002
MOUSEEVENTF_LEFTUP = 0x0004
KEYEVENTF_KEYUP = 0x0002

VK_MAP = {
    "enter": 0x0D, "return": 0x0D,
    "escape": 0x1B, "esc": 0x1B,
    "tab": 0x09,
    "space": 0x20,
    "up": 0x26, "down": 0x28, "left": 0x25, "right": 0x27,
    "backspace": 0x08, "delete": 0x2E,
    "home": 0x24, "end": 0x23,
    "pageup": 0x21, "pagedown": 0x22,
    "f1": 0x70, "f2": 0x71, "f3": 0x72, "f4": 0x73,
    "f5": 0x74, "f6": 0x75, "f7": 0x76, "f8": 0x77,
    "f9": 0x78, "f10": 0x79, "f11": 0x7A, "f12": 0x7B,
}

SCREENSHOT_DIR = "C:/tmp/adom-desktop-screenshots"

# KiCad window title keywords (wxWidgets windows use class wxWindowNR)
KICAD_TITLE_KEYWORDS = [
    "KiCad", "Symbol Editor", "Footprint Editor", "3D Viewer",
    "eeschema", "pcbnew", "Schematic Editor", "PCB Editor",
]

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


# --- SendInput structures ---

class MOUSEINPUT(ctypes.Structure):
    _fields_ = [
        ("dx", ctypes.wintypes.LONG),
        ("dy", ctypes.wintypes.LONG),
        ("mouseData", ctypes.wintypes.DWORD),
        ("dwFlags", ctypes.wintypes.DWORD),
        ("time", ctypes.wintypes.DWORD),
        ("dwExtraInfo", ctypes.POINTER(ctypes.c_ulong)),
    ]


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


class _INPUT_UNION(ctypes.Union):
    _fields_ = [
        ("mi", MOUSEINPUT),
        ("ki", KEYBDINPUT),
    ]


class INPUT(ctypes.Structure):
    _fields_ = [
        ("type", ctypes.wintypes.DWORD),
        ("union", _INPUT_UNION),
    ]


def _send_input(*inputs):
    """Send one or more INPUT structures via SendInput."""
    n = len(inputs)
    arr = (INPUT * n)(*inputs)
    user32.SendInput(n, ctypes.pointer(arr), ctypes.sizeof(INPUT))


# --- Window enumeration ---

def _get_window_title(hwnd) -> str:
    length = user32.GetWindowTextLengthW(hwnd)
    if length <= 0:
        return ""
    buf = ctypes.create_unicode_buffer(length + 1)
    user32.GetWindowTextW(hwnd, buf, length + 1)
    return buf.value


def _get_class_name(hwnd) -> str:
    buf = ctypes.create_unicode_buffer(256)
    user32.GetClassNameW(hwnd, buf, 256)
    return buf.value


def _get_window_rect(hwnd) -> dict:
    rect = ctypes.wintypes.RECT()
    user32.GetWindowRect(hwnd, ctypes.byref(rect))
    return {
        "left": rect.left, "top": rect.top,
        "right": rect.right, "bottom": rect.bottom,
        "width": rect.right - rect.left,
        "height": rect.bottom - rect.top,
    }


def _find_all_kicad_windows() -> list:
    """Find all visible KiCad-related windows.

    Returns list of dicts with hwnd, title, className, rect, ownerHwnd.
    """
    results = []
    seen = set()
    WNDENUMPROC = ctypes.WINFUNCTYPE(
        ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM
    )

    def callback(hwnd, _lparam):
        if not user32.IsWindowVisible(hwnd):
            return True
        title = _get_window_title(hwnd)
        class_name = _get_class_name(hwnd)

        # Match KiCad windows by title keywords or wxWindowNR class with owner
        is_kicad = any(kw.lower() in title.lower() for kw in KICAD_TITLE_KEYWORDS)

        # Also catch #32770 (Win32 dialog) windows owned by KiCad
        if not is_kicad and class_name == "#32770":
            owner = user32.GetWindow(hwnd, GW_OWNER)
            if owner and owner not in seen:
                # Check if owner is a KiCad window
                owner_title = _get_window_title(owner)
                is_kicad = any(kw.lower() in owner_title.lower() for kw in KICAD_TITLE_KEYWORDS)

        if is_kicad and hwnd not in seen:
            seen.add(hwnd)
            owner = user32.GetWindow(hwnd, GW_OWNER)
            results.append({
                "hwnd": hwnd,
                "title": title,
                "className": class_name,
                "rect": _get_window_rect(hwnd),
                "ownerHwnd": owner if owner else 0,
            })
        return True

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


def _find_foreground_kicad_hwnd() -> int:
    """Find the foreground KiCad window, or the first KiCad window found."""
    fg = user32.GetForegroundWindow()
    if fg:
        title = _get_window_title(fg)
        if any(kw.lower() in title.lower() for kw in KICAD_TITLE_KEYWORDS):
            return fg

    # Fallback: first KiCad window
    windows = _find_all_kicad_windows()
    if windows:
        return windows[0]["hwnd"]
    return 0


# --- Screenshot ---

def _write_bmp(path: str, width: int, height: int, pixel_data: bytes):
    """Write a BMP file from raw BGR pixel data (bottom-up)."""
    row_size_24 = (width * 3 + 3) & ~3
    pixel_size_24 = row_size_24 * height
    file_size = 14 + 40 + pixel_size_24

    with open(path, "wb") as f:
        f.write(b"BM")
        f.write(struct.pack("<I", file_size))
        f.write(struct.pack("<HH", 0, 0))
        f.write(struct.pack("<I", 14 + 40))
        f.write(struct.pack("<I", 40))
        f.write(struct.pack("<i", width))
        f.write(struct.pack("<i", height))
        f.write(struct.pack("<HH", 1, 24))
        f.write(struct.pack("<I", BI_RGB))
        f.write(struct.pack("<I", pixel_size_24))
        f.write(struct.pack("<ii", 2835, 2835))
        f.write(struct.pack("<II", 0, 0))

        src_row_size = width * 4
        for y in range(height):
            row_start = y * src_row_size
            row_24 = bytearray()
            for x in range(width):
                px = row_start + x * 4
                row_24 += pixel_data[px:px + 3]
            padding = row_size_24 - len(row_24)
            if padding > 0:
                row_24 += b"\x00" * padding
            f.write(bytes(row_24))


def _screenshot_hwnd(hwnd: int, label: str = "") -> dict:
    """Capture a window by HWND using PrintWindow. Returns {success, savedTo, sizeKB}."""
    rect = _get_window_rect(hwnd)
    width = rect["width"]
    height = rect["height"]

    if width <= 0 or height <= 0:
        return {"success": False, "error": f"Invalid rect for hwnd {hwnd}"}

    hdc_screen = user32.GetDC(hwnd)
    hdc_mem = gdi32.CreateCompatibleDC(hdc_screen)
    hbmp = gdi32.CreateCompatibleBitmap(hdc_screen, width, height)
    old_bmp = gdi32.SelectObject(hdc_mem, hbmp)
    user32.PrintWindow(hwnd, hdc_mem, PW_RENDERFULLCONTENT)

    class BITMAPINFOHEADER(ctypes.Structure):
        _fields_ = [
            ("biSize", ctypes.wintypes.DWORD),
            ("biWidth", ctypes.wintypes.LONG),
            ("biHeight", ctypes.wintypes.LONG),
            ("biPlanes", ctypes.wintypes.WORD),
            ("biBitCount", ctypes.wintypes.WORD),
            ("biCompression", ctypes.wintypes.DWORD),
            ("biSizeImage", ctypes.wintypes.DWORD),
            ("biXPelsPerMeter", ctypes.wintypes.LONG),
            ("biYPelsPerMeter", ctypes.wintypes.LONG),
            ("biClrUsed", ctypes.wintypes.DWORD),
            ("biClrImportant", ctypes.wintypes.DWORD),
        ]

    bmi = BITMAPINFOHEADER()
    bmi.biSize = ctypes.sizeof(BITMAPINFOHEADER)
    bmi.biWidth = width
    bmi.biHeight = height
    bmi.biPlanes = 1
    bmi.biBitCount = 32
    bmi.biCompression = BI_RGB

    buf_size = width * height * 4
    pixel_buf = ctypes.create_string_buffer(buf_size)
    gdi32.GetDIBits(hdc_mem, hbmp, 0, height, pixel_buf, ctypes.byref(bmi), DIB_RGB_COLORS)

    gdi32.SelectObject(hdc_mem, old_bmp)
    gdi32.DeleteObject(hbmp)
    gdi32.DeleteDC(hdc_mem)
    user32.ReleaseDC(hwnd, hdc_screen)

    os.makedirs(SCREENSHOT_DIR, exist_ok=True)
    timestamp = int(time.time() * 1000)
    # Slugify the label so filenames have NO spaces/punctuation (clean for a
    # cloud caller to pull). v0.8.8: "pcb_editor pic_programmer Read Only" -> "pcb-editor-pic-programmer-read-only".
    slug = "".join(c if (c.isalnum() or c in "._-") else "-" for c in (label or "")).strip("-").lower()
    while "--" in slug:
        slug = slug.replace("--", "-")
    suffix = f"-{slug}" if slug else ""

    # v0.8.8: default to PNG (container-side callers want PNG; was lossless WEBP,
    # which forced a host-side pull + convert). WEBP only on explicit fmt="webp".
    try:
        from PIL import Image
        img = Image.frombytes("RGBA", (width, height), pixel_buf.raw, "raw", "BGRA")
        img = img.transpose(Image.FLIP_TOP_BOTTOM)
        MAX_DIM = 1568
        if max(width, height) > MAX_DIM:
            ratio = MAX_DIM / max(width, height)
            img = img.resize((int(width * ratio), int(height * ratio)), Image.LANCZOS)
        path = os.path.join(SCREENSHOT_DIR, f"kicad{suffix}-{timestamp}.png")
        img.save(path, "PNG", optimize=True)
    except ImportError:
        path = os.path.join(SCREENSHOT_DIR, f"kicad{suffix}-{timestamp}.bmp")
        _write_bmp(path, width, height, pixel_buf.raw)

    size_kb = os.path.getsize(path) / 1024
    posix = path.replace("\\", "/")
    return {
        "success": True,
        # `fullPath` is the field the CLI's universal screenshot auto-pull keys on —
        # it copies the host file to the cloud container's /tmp/ad-shots/ and adds
        # localFullPath, so a cloud caller can Read it directly (no pull_file hop).
        # `savedTo` kept for back-compat.
        "savedTo": posix,
        "fullPath": posix,
        "sizeKB": round(size_kb, 1),
    }


# --- Public handler functions ---

def handle_screenshot_all(kicad_info: dict, args: dict) -> dict:
    """Screenshot all KiCad windows (main + editors + dialogs).

    Windows: enumerates windows via Win32 and captures each via PrintWindow.
    macOS: not implemented at the plugin level. The Tauri app exposes
    desktop_screenshot_screen / desktop_screenshot_window via CoreGraphics —
    callers should use those instead.
    """
    if IS_MACOS:
        return {
            "success": False,
            "error": "kicad_screenshot_all is not implemented on macOS",
            "_hint": ("Use desktop_screenshot_screen or desktop_screenshot_window "
                      "(via the Tauri app) — those use CoreGraphics and capture "
                      "any visible window including KiCad."),
        }
    if not IS_WINDOWS:
        return {"success": False, "error": f"screenshot_all not implemented on {sys.platform!r}"}

    from handlers.close_windows import handle_window_info

    info = handle_window_info(kicad_info, {})
    data = info.get("data", {})

    # Collect windows in order: editors first, project manager, dialogs last
    windows = []
    for ed in data.get("editors", []):
        windows.append({"hwnd": ed["hwnd"], "title": ed["title"], "type": "editor"})
    pm = data.get("projectManager")
    if pm:
        windows.append({"hwnd": pm["hwnd"], "title": pm["title"], "type": "main"})
    for dlg in data.get("modalDialogs", []):
        windows.append({"hwnd": dlg["hwnd"], "title": dlg["title"], "type": "dialog"})

    if not windows:
        return {"success": False, "error": "No KiCad windows found."}

    screenshots = []
    for w in windows:
        hwnd = w["hwnd"]
        title = w["title"]
        win_type = w["type"]

        # Sanitize title for filename
        safe_title = "".join(c if c.isalnum() or c in "-_ " else "" for c in title)[:40].strip()
        label = f"{win_type}-{safe_title}" if safe_title else win_type

        result = _screenshot_hwnd(hwnd, label)
        entry = {
            "type": win_type,
            "title": title,
            "hwnd": hwnd,
        }
        if result.get("success"):
            entry["savedTo"] = result["savedTo"]
            entry["fullPath"] = result.get("fullPath", result["savedTo"])
            entry["sizeKB"] = result["sizeKB"]
        else:
            entry["error"] = result.get("error", "screenshot failed")

        screenshots.append(entry)

    return {
        "success": True,
        "output": f"Captured {len(screenshots)} KiCad window(s)",
        "data": {"screenshots": screenshots},
    }


# v0.7.1+: modifier VKs for combo parsing in handle_send_key. The four standard
# Windows modifiers; combos like "ctrl+shift+f10" or "alt+3" decompose into a
# list of these wrapping the base key.
_MODIFIER_VK = {
    "ctrl": 0x11, "control": 0x11,
    "alt": 0x12, "menu": 0x12,
    "shift": 0x10,
    "win": 0x5B, "super": 0x5B, "lwin": 0x5B,
}


def _parse_modifier_combo(key: str) -> tuple[list[int], str] | None:
    """Parse a 'ctrl+alt+f10' style combo.

    Returns (modifier_vks, base_key) where base_key is the trailing token after
    the last "+". Modifiers must come BEFORE the base key. Returns None if `key`
    isn't a combo (no "+" or only one token).
    """
    if "+" not in key:
        return None
    tokens = [t.strip().lower() for t in key.split("+") if t.strip()]
    if len(tokens) < 2:
        return None
    *mods, base = tokens
    mod_vks: list[int] = []
    for m in mods:
        vk = _MODIFIER_VK.get(m)
        if vk is None:
            # Not a known modifier — bail and let the caller treat the whole
            # thing as an unknown key.
            return None
        mod_vks.append(vk)
    return mod_vks, base



# ---------------------------------------------------------------------------
# BACKGROUND INPUT (John, 2026-07-20)
#
# SendInput/keybd_event/mouse_event are GLOBAL: they go to whatever window owns
# focus, so using them forces us to foreground KiCad — stealing the user's screen
# and, for clicks, physically moving their mouse. That is unacceptable while
# someone is working.
#
# PostMessage targets ONE window's message queue. It needs no focus, works while
# KiCad is buried behind other apps, and can never leak a keystroke into the
# user's active window. It is the default for every verb now.
#
# Trade-off (be honest about it): posted messages bypass the global keyboard
# state, so modifier CHORDS (Ctrl+Shift+E) are unreliable this way — the target
# checks GetKeyState() and sees no Ctrl held. For chords, prefer the embedded
# plugin's WM_COMMAND pathway (handlers/bridge_client.py), which is both
# deterministic AND background. Only if neither works should a caller opt into
# the focus path, and then it MUST warn the user first.
# ---------------------------------------------------------------------------
WM_KEYDOWN, WM_KEYUP, WM_CHAR = 0x0100, 0x0101, 0x0102
WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MOUSEMOVE = 0x0201, 0x0202, 0x0200


def _post_key(hwnd: int, vk: int, char: str | None = None) -> bool:
    """Deliver one key to `hwnd` without focus. True if posted."""
    try:
        user32.PostMessageW(hwnd, WM_KEYDOWN, vk, 0)
        if char:
            user32.PostMessageW(hwnd, WM_CHAR, ord(char), 0)
        user32.PostMessageW(hwnd, WM_KEYUP, vk, 0)
        return True
    except Exception:
        return False


def _post_click(hwnd: int, cx: int, cy: int) -> bool:
    """Click at CLIENT coords inside `hwnd` without focus and WITHOUT moving the
    user's physical mouse pointer."""
    try:
        lp = (cy << 16) | (cx & 0xFFFF)
        user32.PostMessageW(hwnd, WM_MOUSEMOVE, 0, lp)
        user32.PostMessageW(hwnd, WM_LBUTTONDOWN, 1, lp)
        user32.PostMessageW(hwnd, WM_LBUTTONUP, 0, lp)
        return True
    except Exception:
        return False


def _foreground_warning(action: str) -> dict:
    """The payload a verb returns when it genuinely cannot work in the background."""
    return {
        "success": False,
        "error": f"{action} needs the foreground, which would take over the user's screen",
        "errorCode": "foreground_required",
        "_hint": (
            "STOP and ask the user first: \"I need your screen for about 5 seconds to "
            f"{action} — ok?\" Send a desktop notification (adom-desktop notify_user) so "
            "they see it even if they're in another app, and only then retry with "
            "{\"allowForeground\": true}. Never take the screen silently — the user may be "
            "mid-sentence in another window. Prefer the background routes first: the "
            "embedded-plugin WM_COMMAND pathway (kicad_bridge_call) for menu actions, and "
            "kicad_send_key/kicad_click which post directly to the window."
        ),
    }


def handle_send_key(kicad_info: dict, args: dict) -> dict:
    """Send a keystroke to the foreground KiCad window.

    Supports modifier-combo syntax (v0.7.1+): "ctrl+s", "alt+3", "shift+f10",
    "ctrl+shift+s". Modifiers: ctrl/control, alt/menu, shift, win/super/lwin.
    The base key after the last "+" is parsed via the same VK_MAP / single-char
    path as standalone keys.

    Windows-only: macOS UI scripting against another app requires Accessibility
    permission, which we don't request. Ask the user to interact directly.
    """
    if not IS_WINDOWS:
        return {
            "success": False,
            "error": "kicad_send_key is not implemented on macOS",
            "_hint": ("On macOS, ask the user to press the key in the KiCad window "
                      "directly. UI scripting needs Accessibility permission."),
        }

    key = args.get("key", "").strip()
    if not key:
        return {"success": False, "error": "Missing required arg: key"}

    target_hwnd = args.get("hwnd")
    if target_hwnd:
        target_hwnd = int(target_hwnd)
    else:
        target_hwnd = _find_foreground_kicad_hwnd()

    if not target_hwnd:
        return {"success": False, "error": "No KiCad window found to send key to."}

    title = _get_window_title(target_hwnd)

    # NEVER steal the user's screen. Show the window without activating it
    # (SW_SHOWNOACTIVATE) and deliver input to THIS hwnd's own message queue.
    # (John, 2026-07-20 — the bridge was foregrounding KiCad and typing over him
    # while he worked.) Keys posted this way arrive even when KiCad is behind
    # other apps, and can never land in the user's active window by mistake.
    try:
        user32.ShowWindow(target_hwnd, 4)  # SW_SHOWNOACTIVATE
    except Exception:
        pass
    time.sleep(0.05)

    allow_fg = bool(args.get("allowForeground"))

    # DEFAULT PATH: post the key straight to KiCad's window — no focus, no screen
    # theft, works while the window is buried. Chords still need the focus path
    # (posted messages don't set global modifier state), so those are gated.
    combo_probe = _parse_modifier_combo(key)
    if not allow_fg:
        if combo_probe is not None:
            return _foreground_warning(f"send the key chord '{key}' to KiCad")
        vk = VK_MAP.get(key)
        ch = key if (vk is None and len(key) == 1) else None
        if vk is None and ch:
            vk = user32.VkKeyScanW(ord(ch)) & 0xFF
        if vk is None:
            return {"success": False, "error": f"Unknown key: {key}"}
        if _post_key(target_hwnd, vk, ch):
            return {"success": True, "output": f"Posted '{key}' to '{title}' (background)",
                    "data": {"hwnd": target_hwnd, "title": title, "method": "postmessage", "foreground": False},
                    "_hint": ("Delivered without touching the user's focus. If KiCad ignored it, "
                              "the control may need real input — prefer the plugin WM_COMMAND "
                              "pathway; only as a last resort warn the user and retry with "
                              "allowForeground:true.")}
        return {"success": False, "error": f"Could not post '{key}' to the window"}

    # v0.7.1+: detect modifier combos first. If "key" parses as one (e.g.
    # "alt+3"), wrap the base key in mod-down/up events. Falls through to the
    # original path if it's a single key or unknown-modifier combo.
    combo = _parse_modifier_combo(key)
    if combo is not None:
        mod_vks, base = combo
        # Resolve the base key to a VK via the same path as a standalone key.
        base_vk = VK_MAP.get(base)
        base_shift = False
        if base_vk is None and len(base) == 1:
            vk_scan = user32.VkKeyScanW(ord(base))
            base_vk = vk_scan & 0xFF
            base_shift = bool((vk_scan >> 8) & 0x01)
            if base_vk == 0xFF:
                return {"success": False, "error": f"Cannot map base character '{base}' to a virtual key."}
        if base_vk is None:
            return {"success": False, "error": f"Unknown base key '{base}' in combo '{key}'."}

        # Build the input sequence: ALL modifiers down, base down + up, modifiers up
        # (reverse order on the way up so the OS sees clean nesting).
        inputs: list[INPUT] = []
        # If the base char itself needs Shift AND Shift wasn't already in the combo,
        # add it. (Caller intent: "shift+s" already has shift; "alt+!" should add shift.)
        if base_shift and 0x10 not in mod_vks:
            mod_vks = mod_vks + [0x10]
        for vk in mod_vks:
            d = INPUT(); d.type = INPUT_KEYBOARD; d.union.ki.wVk = vk
            inputs.append(d)
        kd = INPUT(); kd.type = INPUT_KEYBOARD; kd.union.ki.wVk = base_vk
        inputs.append(kd)
        ku = INPUT(); ku.type = INPUT_KEYBOARD; ku.union.ki.wVk = base_vk
        ku.union.ki.dwFlags = KEYEVENTF_KEYUP
        inputs.append(ku)
        for vk in reversed(mod_vks):
            u = INPUT(); u.type = INPUT_KEYBOARD; u.union.ki.wVk = vk
            u.union.ki.dwFlags = KEYEVENTF_KEYUP
            inputs.append(u)

        _send_input(*inputs)
        return {
            "success": True,
            "output": f"Sent '{key}' to {title}",
            "data": {"key": key, "modifiers": mod_vks, "baseVk": base_vk,
                     "baseShift": base_shift, "hwnd": target_hwnd, "title": title},
            "_hint": ("Sent (back-compat chord path). For modifier chords prefer AD's "
                      "desktop_press_key — it owns full chord parsing and AD's focus-first "
                      "delivery. Keep kicad_send_key for single keys to an EXACT hwnd (e.g. "
                      "Enter/Escape to a specific dialog)."),
        }

    key_lower = key.lower().strip()
    vk = VK_MAP.get(key_lower)

    if vk is None:
        if len(key) == 1:
            vk_scan = user32.VkKeyScanW(ord(key))
            vk = vk_scan & 0xFF
            shift = (vk_scan >> 8) & 0x01
            if vk == 0xFF:
                return {"success": False, "error": f"Cannot map character '{key}' to a virtual key."}

            inputs = []
            if shift:
                s_down = INPUT()
                s_down.type = INPUT_KEYBOARD
                s_down.union.ki.wVk = 0x10
                inputs.append(s_down)

            kd = INPUT()
            kd.type = INPUT_KEYBOARD
            kd.union.ki.wVk = vk
            inputs.append(kd)

            ku = INPUT()
            ku.type = INPUT_KEYBOARD
            ku.union.ki.wVk = vk
            ku.union.ki.dwFlags = KEYEVENTF_KEYUP
            inputs.append(ku)

            if shift:
                s_up = INPUT()
                s_up.type = INPUT_KEYBOARD
                s_up.union.ki.wVk = 0x10
                s_up.union.ki.dwFlags = KEYEVENTF_KEYUP
                inputs.append(s_up)

            _send_input(*inputs)
            return {"success": True,
                    "output": f"Sent '{key}' to {title}",
                    "data": {"key": key, "vk": vk, "shift": bool(shift),
                             "hwnd": target_hwnd, "title": title}}
        else:
            return {"success": False, "error": f"Unknown key: '{key}'. Use a named key (enter/escape/tab/space/f1-f12), a single character, or a modifier combo like 'alt+3', 'ctrl+s', 'ctrl+shift+s' (modifiers: ctrl/control, alt/menu, shift, win/super)."}

    kd = INPUT()
    kd.type = INPUT_KEYBOARD
    kd.union.ki.wVk = vk

    ku = INPUT()
    ku.type = INPUT_KEYBOARD
    ku.union.ki.wVk = vk
    ku.union.ki.dwFlags = KEYEVENTF_KEYUP

    _send_input(kd, ku)
    return {"success": True,
            "output": f"Sent '{key_lower}' to {title}",
            "data": {"key": key_lower, "vk": vk,
                     "hwnd": target_hwnd, "title": title}}


def handle_click(kicad_info: dict, args: dict) -> dict:
    """Click at coordinates within a KiCad window.

    Windows-only: macOS UI scripting against another app requires Accessibility
    permission, which we don't request. Ask the user to click directly.
    """
    if not IS_WINDOWS:
        return {
            "success": False,
            "error": "kicad_click is not implemented on macOS",
            "_hint": ("On macOS, ask the user to click in the KiCad window directly. "
                      "UI scripting needs Accessibility permission."),
        }

    hwnd = args.get("hwnd")
    if not hwnd:
        return {"success": False, "error": "Missing required arg: hwnd"}
    hwnd = int(hwnd)

    x = args.get("x")
    y = args.get("y")
    if x is None or y is None:
        return {"success": False, "error": "Missing required args: x, y"}

    relative = args.get("relative", True)

    # Footgun guard: relative coords are FRACTIONS of the window (0.0–1.0). If a caller
    # leaves relative=True (the default) but passes pixel values (e.g. x=676), the math
    # below lands the click FAR off-screen — which at best misses and at worst hits a
    # random window's close/controls and can take KiCad down. Refuse clearly instead.
    if relative:
        try:
            if float(x) > 1.0 or float(y) > 1.0 or float(x) < 0 or float(y) < 0:
                return {"success": False,
                        "error": f"relative=True expects x/y as fractions 0.0–1.0 of the window, "
                                 f"but got x={x}, y={y}. Pass fractions, or set relative=false to use pixels.",
                        "_hint": "e.g. center = {\"x\":0.5,\"y\":0.5}; toolbar button ≈ {\"x\":0.35,\"y\":0.09}. "
                                 "For exact pixel coords add \"relative\": false."}
        except (TypeError, ValueError):
            return {"success": False, "error": f"x/y must be numbers, got x={x!r}, y={y!r}"}

    title = _get_window_title(hwnd)
    if not title:
        return {"success": False, "error": f"HWND {hwnd} is not a valid window."}

    rect = _get_window_rect(hwnd)
    width = rect["width"]
    height = rect["height"]

    # Background click: no SetForegroundWindow, and crucially no SetCursorPos —
    # moving the user's physical mouse is the rudest thing this bridge can do.
    try:
        user32.ShowWindow(hwnd, 4)  # SW_SHOWNOACTIVATE
    except Exception:
        pass
    time.sleep(0.05)

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

    # DEFAULT PATH: post the click to the window in CLIENT coords. The user's
    # physical pointer never moves.
    if not bool(args.get("allowForeground")):
        cx, cy = screen_x - rect["left"], screen_y - rect["top"]
        if _post_click(hwnd, cx, cy):
            return {"success": True,
                    "output": f"Posted click at ({cx},{cy}) in '{title}' (background)",
                    "data": {"hwnd": hwnd, "title": title, "client": [cx, cy],
                             "method": "postmessage", "foreground": False},
                    "_hint": ("Clicked without moving the user's mouse or taking focus. If the "
                              "canvas ignored it, that control may need real input — warn the "
                              "user you need the screen for ~5s, then retry allowForeground:true.")}
        return {"success": False, "error": "Could not post the click to the window"}

    # Convert to absolute coordinates for SendInput (0-65535 range)
    screen_w = user32.GetSystemMetrics(0)
    screen_h = user32.GetSystemMetrics(1)
    abs_x = int(screen_x * 65535 / screen_w)
    abs_y = int(screen_y * 65535 / screen_h)

    move = INPUT()
    move.type = INPUT_MOUSE
    move.union.mi.dx = abs_x
    move.union.mi.dy = abs_y
    move.union.mi.dwFlags = MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE

    down = INPUT()
    down.type = INPUT_MOUSE
    down.union.mi.dx = abs_x
    down.union.mi.dy = abs_y
    down.union.mi.dwFlags = MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE | MOUSEEVENTF_LEFTDOWN

    up = INPUT()
    up.type = INPUT_MOUSE
    up.union.mi.dx = abs_x
    up.union.mi.dy = abs_y
    up.union.mi.dwFlags = MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE | MOUSEEVENTF_LEFTUP

    _send_input(move, down, up)

    return {
        "success": True,
        "output": f"Clicked ({screen_x}, {screen_y}) on {title}",
        "data": {
            "clickedAt": {"screenX": screen_x, "screenY": screen_y},
            "windowOffset": {"x": screen_x - rect["left"], "y": screen_y - rect["top"]},
            "relative": relative,
            "hwnd": hwnd,
            "title": title,
        },
    }