123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742
"""Win32-based tools for interacting with Fusion 360's UI.

Provides screenshot, click, and keyboard input functions that work with
CEF (Chromium Embedded Framework) modal dialogs inside Fusion 360.
These dialogs can't be accessed via standard Win32 child window enumeration,
so we use screen-DC capture (BitBlt) instead of PrintWindow, and SendInput
instead of PostMessage for input.
"""

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

# Guard windll so the module imports on non-Windows hosts (the bridge must boot
# + serve /status anywhere); these Win32 paths are only reached on Windows.
_have_windll = hasattr(ctypes, "windll")
user32 = ctypes.windll.user32 if _have_windll else None
gdi32 = ctypes.windll.gdi32 if _have_windll else None
kernel32 = ctypes.windll.kernel32 if _have_windll else None

# ⚠️ HWND TRUNCATION BUG (fixed 2026-07-08, cost hours): with NO argtypes declared, ctypes
# defaults every function arg + return to 32-bit `c_int`, which TRUNCATES 64-bit HWND handles.
# So `_find_fusion_hwnd`'s GetWindowTextW/IsWindowVisible ran on a garbage handle and matched
# NOTHING - get_fusion_window_info returned mainHwnd:None even though Fusion's window ("Untitled
# - Autodesk Fusion") plainly existed, which blinded all background driving. (Verified live: the
# window's real hwnd via `(Get-Process Fusion360).MainWindowHandle` worked fine.) Declaring HWND
# argtypes/restypes makes ctypes marshal the full 64-bit handle. If the finder ever returns 0/None
# while Fusion IS running, the process-handle fallback in _find_fusion_hwnd covers it.
if _have_windll:
    _W = ctypes.wintypes
    for _fn, _arg, _ret in (
        ("IsWindowVisible",       [_W.HWND],                              ctypes.c_bool),
        ("IsWindowEnabled",       [_W.HWND],                              ctypes.c_bool),
        ("GetWindowTextLengthW",  [_W.HWND],                              ctypes.c_int),
        ("GetWindowTextW",        [_W.HWND, _W.LPWSTR, ctypes.c_int],     ctypes.c_int),
        ("GetClassNameW",         [_W.HWND, _W.LPWSTR, ctypes.c_int],     ctypes.c_int),
        ("GetWindow",             [_W.HWND, ctypes.c_uint],               _W.HWND),
        ("GetParent",             [_W.HWND],                              _W.HWND),
        ("IsWindow",              [_W.HWND],                              ctypes.c_bool),
    ):
        try:
            _f = getattr(user32, _fn); _f.argtypes = _arg; _f.restype = _ret
        except Exception:
            pass

# Enable DPI awareness so GetWindowRect returns physical pixel coordinates
# (not logical/scaled). Without this, screenshots are cropped on HiDPI displays.
try:
    ctypes.windll.shcore.SetProcessDpiAwareness(2)  # PROCESS_PER_MONITOR_DPI_AWARE
except Exception:
    try:
        user32.SetProcessDPIAware()  # Fallback for older Windows
    except Exception:
        pass

# --- Constants ---

SRCCOPY = 0x00CC0020
DIB_RGB_COLORS = 0
BI_RGB = 0
SM_CXVIRTUALSCREEN = 78
SM_CYVIRTUALSCREEN = 79

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

# Virtual key codes
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/conduit-screenshots"

# 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 finding ---

def _find_fusion_hwnd() -> int:
    """Find Fusion 360's main window by title containing 'Autodesk Fusion'."""
    result = [0]
    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 "Autodesk Fusion" in buf.value:
                    result[0] = hwnd
                    return False
        return True

    cb = WNDENUMPROC(callback)
    _callbacks.append(cb)
    user32.EnumWindows(cb, 0)
    _callbacks.remove(cb)
    return result[0]


def _find_qt_dialog_windows() -> list:
    """Find visible Qt windows that may be Fusion 360 dialogs.

    Returns list of dicts with hwnd, title, rect, className.
    Excludes the main Fusion window (which has 'Autodesk Fusion' in title).
    """
    results = []
    WNDENUMPROC = ctypes.WINFUNCTYPE(
        ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM
    )

    def callback(hwnd, _lparam):
        if user32.IsWindowVisible(hwnd):
            cls_buf = ctypes.create_unicode_buffer(256)
            user32.GetClassNameW(hwnd, cls_buf, 256)
            class_name = cls_buf.value

            length = user32.GetWindowTextLengthW(hwnd)
            title = ""
            if length > 0:
                buf = ctypes.create_unicode_buffer(length + 1)
                user32.GetWindowTextW(hwnd, buf, length + 1)
                title = buf.value

            # Qt windows belonging to Fusion (not the main window)
            if "Qt" in class_name and "Autodesk Fusion" not in title:
                # Some Fusion dialogs (including the "What do you want to
                # design?" startup picker) use Qt Tool window classes like
                # "Qt655QWindowToolSaveBits".  We used to skip ALL Tool
                # windows (they're usually docked panels — Browser, Timeline).
                # But that caused the startup picker to be invisible to
                # dialog detection. Now we only skip SMALL Tool windows
                # (docked panels are narrow sidebar panels, typically
                # <400px wide).  Large Tool windows (>400px wide AND >300px
                # tall) are kept — they're blocking dialogs like the picker.
                if "Tool" in class_name:
                    rect = ctypes.wintypes.RECT()
                    user32.GetWindowRect(hwnd, ctypes.byref(rect))
                    w = rect.right - rect.left
                    h = rect.bottom - rect.top
                    if w < 400 or h < 300:
                        return True  # small tool panel, skip

                rect = ctypes.wintypes.RECT()
                user32.GetWindowRect(hwnd, ctypes.byref(rect))
                results.append({
                    "hwnd": hwnd,
                    "title": title,
                    "className": class_name,
                    "rect": {
                        "left": rect.left, "top": rect.top,
                        "right": rect.right, "bottom": rect.bottom,
                        "width": rect.right - rect.left,
                        "height": rect.bottom - rect.top,
                    },
                })
        return True

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


def _get_window_rect(hwnd: int) -> tuple:
    """Get window rect as (left, top, right, bottom)."""
    rect = ctypes.wintypes.RECT()
    user32.GetWindowRect(hwnd, ctypes.byref(rect))
    return (rect.left, rect.top, rect.right, rect.bottom)


# --- 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 order).

    pixel_data must be width*height*4 bytes (32-bit BGRA from GetDIBits).
    We write a 24-bit BMP (strip alpha) for broad compatibility.
    """
    row_size_24 = (width * 3 + 3) & ~3  # rows padded to 4 bytes
    pixel_size_24 = row_size_24 * height
    file_size = 14 + 40 + pixel_size_24  # BMP header + DIB header + pixels

    with open(path, "wb") as f:
        # BMP file header (14 bytes)
        f.write(b"BM")
        f.write(struct.pack("<I", file_size))
        f.write(struct.pack("<HH", 0, 0))  # reserved
        f.write(struct.pack("<I", 14 + 40))  # offset to pixel data

        # DIB header (BITMAPINFOHEADER, 40 bytes)
        f.write(struct.pack("<I", 40))  # header size
        f.write(struct.pack("<i", width))
        f.write(struct.pack("<i", height))  # positive = bottom-up
        f.write(struct.pack("<HH", 1, 24))  # planes, bpp
        f.write(struct.pack("<I", BI_RGB))  # compression
        f.write(struct.pack("<I", pixel_size_24))
        f.write(struct.pack("<ii", 2835, 2835))  # pixels/meter (~72 DPI)
        f.write(struct.pack("<II", 0, 0))  # colors

        # Write pixel rows (convert 32-bit BGRA to 24-bit BGR, bottom-up)
        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]  # BGR (skip alpha)
            # Pad row to 4-byte boundary
            padding = row_size_24 - len(row_24)
            if padding > 0:
                row_24 += b"\x00" * padding
            f.write(bytes(row_24))


PW_RENDERFULLCONTENT = 0x00000002


def screenshot_hwnd(hwnd: int, label: str = "") -> dict:
    """Capture any window by HWND. Returns {success, savedTo, sizeKB}."""
    left, top, right, bottom = _get_window_rect(hwnd)
    width = right - left
    height = bottom - top

    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)
    suffix = f"-{label}" if label else ""

    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)
        try:
            path = os.path.join(SCREENSHOT_DIR, f"fusion{suffix}-{timestamp}.webp")
            img.save(path, "WEBP", lossless=True, quality=100)
        except Exception:
            path = os.path.join(SCREENSHOT_DIR, f"fusion{suffix}-{timestamp}.png")
            img.save(path, "PNG", optimize=True)
    except ImportError:
        path = os.path.join(SCREENSHOT_DIR, f"fusion{suffix}-{timestamp}.bmp")
        _write_bmp(path, width, height, pixel_buf.raw)

    size_kb = os.path.getsize(path) / 1024
    return {
        "success": True,
        "savedTo": path.replace("\\", "/"),
        "sizeKB": round(size_kb, 1),
    }


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

    By default uses PrintWindow with PW_RENDERFULLCONTENT (works without
    foreground, captures Qt content but NOT CEF modal overlays).

    If use_bitblt=True, brings window to foreground and uses BitBlt from
    screen DC (captures CEF overlays but requires foreground).

    Saves PNG to C:/tmp/conduit-screenshots/ and returns the path.
    """
    hwnd = _find_fusion_hwnd()
    if not hwnd:
        return {
            "success": False,
            "error": "Fusion 360 window not found (no window with 'Autodesk Fusion' in title).",
            "_hint": "Call fusion_start to start Fusion 360 and wait for the add-in to be ready, then retry.",
        }

    # Get window rect
    left, top, right, bottom = _get_window_rect(hwnd)
    width = right - left
    height = bottom - top

    if width <= 0 or height <= 0:
        return {"success": False, "error": f"Invalid window rect: {left},{top},{right},{bottom}"}

    if use_bitblt:
        # Screen DC capture — needs foreground
        SW_RESTORE = 9
        user32.ShowWindow(hwnd, SW_RESTORE)
        user32.SetForegroundWindow(hwnd)
        time.sleep(1.0)

        hdc_screen = user32.GetDC(0)
        hdc_mem = gdi32.CreateCompatibleDC(hdc_screen)
        hbmp = gdi32.CreateCompatibleBitmap(hdc_screen, width, height)
        old_bmp = gdi32.SelectObject(hdc_mem, hbmp)
        gdi32.BitBlt(hdc_mem, 0, 0, width, height, hdc_screen, left, top, SRCCOPY)
    else:
        # PrintWindow capture — works without foreground
        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)

    # Read pixel data via GetDIBits
    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  # positive = bottom-up (standard BMP order)
    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)

    # Clean up GDI resources
    gdi32.SelectObject(hdc_mem, old_bmp)
    gdi32.DeleteObject(hbmp)
    gdi32.DeleteDC(hdc_mem)
    if use_bitblt:
        user32.ReleaseDC(0, hdc_screen)
    else:
        user32.ReleaseDC(hwnd, hdc_screen)

    # Save to file as PNG (try PIL first, fall back to BMP)
    os.makedirs(SCREENSHOT_DIR, exist_ok=True)
    timestamp = int(time.time() * 1000)

    try:
        from PIL import Image
        # pixel_buf is BGRA bottom-up; convert to RGBA top-down
        img = Image.frombytes("RGBA", (width, height), pixel_buf.raw, "raw", "BGRA")
        img = img.transpose(Image.FLIP_TOP_BOTTOM)

        # Downscale to max 1568px on longest side (saves tokens for AI vision)
        MAX_DIM = 1568
        if max(width, height) > MAX_DIM:
            ratio = MAX_DIM / max(width, height)
            new_size = (int(width * ratio), int(height * ratio))
            img = img.resize(new_size, Image.LANCZOS)

        # Try WebP first (much smaller than PNG for screenshots)
        try:
            path = os.path.join(SCREENSHOT_DIR, f"fusion-{timestamp}.webp")
            img.save(path, "WEBP", lossless=True, quality=100)
        except Exception:
            # Fall back to PNG if WebP not available
            path = os.path.join(SCREENSHOT_DIR, f"fusion-{timestamp}.png")
            img.save(path, "PNG", optimize=True)
    except ImportError:
        # No PIL — save as BMP (readable by most tools, but not Claude Read)
        path = os.path.join(SCREENSHOT_DIR, f"fusion-{timestamp}.bmp")
        _write_bmp(path, width, height, pixel_buf.raw)

    size_kb = os.path.getsize(path) / 1024
    return {
        "success": True,
        "savedTo": path.replace("\\", "/"),
        "sizeKB": round(size_kb, 1),
        "dimensions": {"width": width, "height": height},
    }


# --- Click ---

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

    Args:
        x: X coordinate. If relative=True, a float 0.0-1.0 (percentage of window width).
           If relative=False, pixel offset from window's top-left corner.
        y: Y coordinate. Same convention as x but for height.
        relative: If True (default), x/y are percentages. If False, pixel offsets.
        hwnd: Optional HWND to click on. If omitted, finds the main Fusion window.
              Use this to click buttons inside Qt dialogs by passing the dialog's HWND.

    Uses SendInput (NOT PostMessage) because CEF doesn't respond to WM messages.
    """
    if hwnd:
        hwnd = int(hwnd)
        # Validate the HWND is a real window
        if not user32.IsWindow(hwnd):
            return {
                "success": False,
                "error": f"HWND {hwnd} is not a valid window.",
                "_hint": "HWNDs change when windows close/reopen. Call fusion_get_window_info to get fresh HWNDs, then retry.",
            }
    else:
        hwnd = _find_fusion_hwnd()
        if not hwnd:
            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(hwnd)
    width = right - left
    height = bottom - top

    # Bring to front
    user32.SetForegroundWindow(hwnd)
    time.sleep(0.1)

    # Calculate screen coordinates
    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)

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

    # Move mouse
    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

    # Click down
    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

    # Click up
    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,
        "clickedAt": {"screenX": screen_x, "screenY": screen_y},
        "windowOffset": {
            "x": screen_x - left,
            "y": screen_y - top,
        },
        "relative": relative,
    }


# --- Keyboard ---

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

    Args:
        key: One of: "enter", "escape", "tab", "space", "up", "down",
             "left", "right", "f1"-"f12", "backspace", "delete",
             "home", "end", "pageup", "pagedown", or a single character.
        hwnd: Optional HWND to target. If omitted, targets the main Fusion window.
    """
    if hwnd:
        hwnd = int(hwnd)
        if not user32.IsWindow(hwnd):
            return {
                "success": False,
                "error": f"HWND {hwnd} is not a valid window.",
                "_hint": "HWNDs change when windows close/reopen. Call fusion_get_window_info to get fresh HWNDs, then retry.",
            }
    else:
        hwnd = _find_fusion_hwnd()
        if not hwnd:
            return {
                "success": False,
                "error": "Fusion 360 window not found.",
                "_hint": "Call fusion_start to start Fusion 360, then retry.",
            }

    # Bring to front
    user32.SetForegroundWindow(hwnd)
    time.sleep(0.1)

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

    if vk is None:
        if len(key) == 1:
            # Single character: use VkKeyScanW to get the virtual key code
            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 = []
            # Press shift if needed
            if shift:
                s_down = INPUT()
                s_down.type = INPUT_KEYBOARD
                s_down.union.ki.wVk = 0x10  # VK_SHIFT
                inputs.append(s_down)

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

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

            # Release shift if needed
            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, "key": key, "vk": vk, "shift": bool(shift)}
        else:
            return {"success": False, "error": f"Unknown key: '{key}'. Use a named key or single character."}

    # Named key: simple down + up
    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, "key": key_lower, "vk": vk}


def close_window(hwnd: int) -> dict:
    """Close a window by sending WM_CLOSE via PostMessage.

    Unlike Escape (which many Fusion dialogs ignore — e.g. Recovered Documents),
    WM_CLOSE is the standard Windows mechanism for closing a window and is
    handled by virtually all Qt dialogs.

    This does NOT force-kill the window — the dialog can still prompt for
    confirmation. It's equivalent to clicking the X button in the title bar.

    Args:
        hwnd: HWND of the window to close.
    """
    hwnd = int(hwnd)
    if not user32.IsWindow(hwnd):
        return {
            "success": False,
            "error": f"HWND {hwnd} is not a valid window.",
        }

    WM_CLOSE = 0x0010
    user32.PostMessageW(hwnd, WM_CLOSE, 0, 0)
    return {"success": True, "hwnd": hwnd, "action": "WM_CLOSE sent"}


# --- Window info ---

def get_fusion_window_info() -> dict:
    """Get Fusion 360 window info: hwnd, title, rect, and any Qt dialog windows.

    Returns the main Fusion window details plus a list of other Qt windows
    that may be Fusion dialogs (file pickers, recovery prompts, etc.).
    """
    hwnd = _find_fusion_hwnd()
    if not hwnd:
        return {
            "success": False,
            "error": "Fusion 360 window not found.",
            "_hint": "Call fusion_start to start Fusion 360, then retry.",
        }

    # Get title
    length = user32.GetWindowTextLengthW(hwnd)
    title = ""
    if length > 0:
        buf = ctypes.create_unicode_buffer(length + 1)
        user32.GetWindowTextW(hwnd, buf, length + 1)
        title = buf.value

    left, top, right, bottom = _get_window_rect(hwnd)

    # Find potential dialog windows
    dialogs = _find_qt_dialog_windows()

    # A real modal dialog DISABLES its owner window; docked panels (Browser,
    # Timeline) leave it ENABLED. This is the clean signal that separates an
    # actual blocking modal from a tool window that merely shares the "Fusion360"
    # title and slips past the size filter. Callers use it to suppress
    # false-positive "dialogs" when nothing is actually blocking.
    try:
        main_enabled = bool(user32.IsWindowEnabled(hwnd))
    except Exception:
        main_enabled = True

    return {
        "success": True,
        "hwnd": hwnd,
        "title": title,
        "mainEnabled": main_enabled,
        "rect": {
            "left": left, "top": top,
            "right": right, "bottom": bottom,
            "width": right - left,
            "height": bottom - top,
        },
        "dialogs": dialogs,
    }