123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854
"""Handler for open_footprint_editor and open_3d_viewer commands.

Windows: clicks the launcher icon in the KiCad project manager (the launcher
buttons in KiCad 9 use dynamic WM_COMMAND IDs we can't rely on), then optionally
loads a footprint via PowerShell SendKeys against the search box.

macOS: launches the Footprint Editor app directly via `open -a`. Footprint-name
navigation requires Accessibility-framework permission and is not implemented.
"""

import ctypes
import ctypes.wintypes
import subprocess
import sys
import time
from pathlib import Path

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

# Win32 constants — cross-platform safe (just integers).
WM_CLOSE = 0x0010
SW_RESTORE = 9
GW_OWNER = 4
KEYEVENTF_KEYUP = 0x0002
VK_MENU = 0x12
MOUSEEVENTF_LEFTDOWN = 0x0002
MOUSEEVENTF_LEFTUP = 0x0004


class RECT(ctypes.Structure):
    _fields_ = [("left", ctypes.c_long), ("top", ctypes.c_long),
                ("right", ctypes.c_long), ("bottom", ctypes.c_long)]


if IS_WINDOWS:
    user32 = ctypes.windll.user32
    kernel32 = ctypes.windll.kernel32
else:
    user32 = None
    kernel32 = None

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

# Reuse the same navigate_symbol.ps1 script (works for both editors)
_SCRIPT_DIR = Path(__file__).resolve().parent.parent
_NAVIGATE_SCRIPT = _SCRIPT_DIR / "navigate_symbol.ps1"


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:
                    owner = user32.GetWindow(hwnd, GW_OWNER)
                    results.append((hwnd, buf.value, owner))
        return True

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


def _find_kicad_manager() -> int | None:
    """Find the KiCad project manager window handle."""
    windows = _find_windows_by_title("KiCad")
    for hwnd, title, owner in windows:
        lower = title.lower()
        if ("editor" not in lower
                and "viewer" not in lower
                and "calculator" not in lower
                and owner == 0):
            return hwnd
    return None


def _find_footprint_editor() -> int | None:
    """Find an open Footprint Editor window."""
    windows = _find_windows_by_title("Footprint Editor")
    for hwnd, title, _owner in windows:
        if "footprint editor" in title.lower():
            return hwnd
    return None


def _find_child_by_text(parent_hwnd: int, text: str) -> int | None:
    """Find a child window with exact text match."""
    result = [None]
    WNDENUMPROC = ctypes.WINFUNCTYPE(
        ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM
    )

    def callback(hwnd, _lparam):
        length = user32.GetWindowTextLengthW(hwnd)
        if length > 0:
            buf = ctypes.create_unicode_buffer(length + 1)
            user32.GetWindowTextW(hwnd, buf, length + 1)
            if buf.value == text:
                result[0] = hwnd
                return False
        return True

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


def _find_icon_panel_near(parent_hwnd: int, near_y: int) -> tuple | None:
    """Find a small (~51x51) wxWindowNR 'panel' child near the given y coordinate."""
    results = []
    WNDENUMPROC = ctypes.WINFUNCTYPE(
        ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM
    )

    def callback(hwnd, _lparam):
        cls = ctypes.create_unicode_buffer(256)
        user32.GetClassNameW(hwnd, cls, 256)
        if cls.value == "wxWindowNR":
            txt_len = user32.GetWindowTextLengthW(hwnd)
            txt = ""
            if txt_len > 0:
                buf = ctypes.create_unicode_buffer(txt_len + 1)
                user32.GetWindowTextW(hwnd, buf, txt_len + 1)
                txt = buf.value
            if txt == "panel":
                rect = RECT()
                user32.GetWindowRect(hwnd, ctypes.byref(rect))
                w = rect.right - rect.left
                h = rect.bottom - rect.top
                if 40 <= w <= 60 and 40 <= h <= 60:
                    if abs(rect.top - near_y) < 15:
                        results.append((hwnd, rect))
        return True

    cb = WNDENUMPROC(callback)
    _callbacks.append(cb)
    user32.EnumChildWindows(parent_hwnd, cb, 0)
    _callbacks.remove(cb)
    return results[0] if results else None


def _find_child_by_class_and_parent(parent_hwnd: int, child_class: str, parent_text: str) -> int | None:
    """Find a child window by class name and parent window text."""
    result = [None]
    WNDENUMPROC = ctypes.WINFUNCTYPE(
        ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM
    )

    def callback(hwnd, _lparam):
        cls_buf = ctypes.create_unicode_buffer(256)
        user32.GetClassNameW(hwnd, cls_buf, 256)
        if cls_buf.value == child_class:
            parent = user32.GetParent(hwnd)
            p_buf = ctypes.create_unicode_buffer(256)
            user32.GetWindowTextW(parent, p_buf, 256)
            if p_buf.value == parent_text:
                result[0] = hwnd
                return False
        return True

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


SW_SHOWNOACTIVATE = 4


def _force_foreground(hwnd: int):
    """DELIBERATELY does NOT foreground. The bridge must never steal the user's
    focus or flash a window in front of their work (John, 2026-07 — 'do your stuff
    in the background'). We only make the window VISIBLE without activating it
    (SW_SHOWNOACTIVATE); the plugin WM_COMMAND path drives it fine without focus,
    and the dispatcher restores the user's window afterward anyway. Name kept so
    the ~10 call sites don't churn."""
    try:
        user32.ShowWindow(hwnd, SW_SHOWNOACTIVATE)
    except Exception:
        pass


def _click_at(x: int, y: int):
    """Simulate a mouse click at screen coordinates."""
    user32.SetCursorPos(x, y)
    time.sleep(0.15)
    user32.mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, ctypes.POINTER(ctypes.c_ulong)())
    time.sleep(0.05)
    user32.mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, ctypes.POINTER(ctypes.c_ulong)())


def _open_footprint_editor_via_click(kicad_hwnd: int) -> bool:
    """Open the Footprint Editor by clicking its launcher icon in the project manager.

    Returns True if the Footprint Editor window appeared.
    """
    label_hwnd = _find_child_by_text(kicad_hwnd, "Footprint Editor")
    if not label_hwnd:
        return False

    label_rect = RECT()
    user32.GetWindowRect(label_hwnd, ctypes.byref(label_rect))

    # Find the icon panel (small ~51x51 wxWindowNR "panel" near the label's y position)
    icon_result = _find_icon_panel_near(kicad_hwnd, label_rect.top - 6)
    if icon_result:
        _, icon_rect = icon_result
        click_x = (icon_rect.left + icon_rect.right) // 2
        click_y = (icon_rect.top + icon_rect.bottom) // 2
    else:
        # Fallback: click just left of the label
        click_x = label_rect.left - 30
        click_y = (label_rect.top + label_rect.bottom) // 2

    # Bring KiCad to foreground and click
    _force_foreground(kicad_hwnd)
    time.sleep(0.5)
    _click_at(click_x, click_y)

    # Wait for the Footprint Editor to appear
    for _ in range(12):
        time.sleep(0.5)
        if _find_footprint_editor():
            return True

    return False


def _send_ctrl_shift_e(hwnd: int):
    """Send Ctrl+Shift+E to load the selected footprint in the Footprint Editor."""
    VK_CONTROL = 0x11
    VK_SHIFT = 0x10
    VK_E = 0x45
    _force_foreground(hwnd)
    time.sleep(0.3)
    user32.keybd_event(VK_CONTROL, 0, 0, 0)
    user32.keybd_event(VK_SHIFT, 0, 0, 0)
    user32.keybd_event(VK_E, 0, 0, 0)
    user32.keybd_event(VK_E, 0, KEYEVENTF_KEYUP, 0)
    user32.keybd_event(VK_SHIFT, 0, KEYEVENTF_KEYUP, 0)
    user32.keybd_event(VK_CONTROL, 0, KEYEVENTF_KEYUP, 0)


def _navigate_to_footprint(fp_hwnd: int, footprint_name: str, load_after: bool = True) -> dict:
    """Call the PowerShell script to navigate to a specific footprint.

    Uses the same navigate_symbol.ps1 script as the Symbol Editor handler.
    If load_after is True, sends Ctrl+Shift+E to load the footprint after
    searching.
    """
    if not _NAVIGATE_SCRIPT.exists():
        return {"status": "error", "error": f"Script not found: {_NAVIGATE_SCRIPT}"}

    search_edit = _find_child_by_class_and_parent(fp_hwnd, "Edit", "searchCtrl")
    if not search_edit:
        return {"status": "error", "error": "Search edit control not found in Footprint Editor"}

    try:
        subprocess.run(
            [
                "powershell", "-ExecutionPolicy", "Bypass",
                "-File", str(_NAVIGATE_SCRIPT),
                "-SymEditorHwnd", str(fp_hwnd),
                "-SearchEditHwnd", str(search_edit),
                "-SymbolName", footprint_name,
            ],
            capture_output=True, text=True, timeout=20,
        )
    except subprocess.TimeoutExpired:
        pass
    except Exception:
        pass

    time.sleep(0.5)

    windows = _find_windows_by_title("Footprint Editor")
    for _, title, _ in windows:
        if footprint_name in title:
            return {"status": "ok", "title": title}
        if "footprint editor" in title.lower() and "[no footprint loaded]" not in title.lower():
            return {"status": "partial", "title": title}

    # Footprint found in search but not loaded — try Ctrl+Shift+E
    if load_after:
        _send_ctrl_shift_e(fp_hwnd)
        time.sleep(1.5)

        windows = _find_windows_by_title("Footprint Editor")
        for _, title, _ in windows:
            if footprint_name in title:
                return {"status": "ok", "title": title}
            if "footprint editor" in title.lower() and "[no footprint loaded]" not in title.lower():
                return {"status": "partial", "title": title}

    return {"status": "noload", "title": "[no footprint loaded]"}


def _mac_open_footprint_editor(kicad_info: dict, args: dict) -> dict:
    """macOS: launch the Footprint Editor app directly. Footprint navigation
    is not implemented (requires Accessibility permission)."""
    parent = Path(kicad_info.get("base_dir", "")).parent
    fp_app = parent / "Footprint Editor.app"
    if not fp_app.exists():
        return {
            "success": False,
            "error": f"Footprint Editor.app not found at {fp_app}",
            "_hint": "Reinstall KiCad — the Footprint Editor app bundle is missing.",
        }
    try:
        subprocess.Popen(
            ["open", "-a", str(fp_app)],
            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
        )
    except Exception as e:
        return {"success": False, "error": f"Failed to launch Footprint Editor: {e}"}
    fp_name = args.get("footprintName", "") or args.get("footprint", "")
    lib_name = args.get("libraryName", "") or args.get("library", "")
    if fp_name or lib_name:
        return {
            "success": True,
            "output": "Footprint Editor opened. Navigation to a specific footprint is not implemented on macOS.",
            "_hint": "Ask the user to type the footprint name in the editor's search box.",
        }
    return {"success": True, "output": "Footprint Editor opened."}


def handle_open_footprint_editor(kicad_info: dict, args: dict) -> dict:
    """Open the KiCad Footprint Editor and optionally load a specific footprint.

    Args:
        kicad_info: KiCad detection info dict.
        args: Optional dict with:
            - footprintName: Name of footprint to load (e.g. "ADOM_R0805").
            - libraryName: Name of the library containing the footprint.
    """
    if not kicad_info.get("installed"):
        return {
            "success": False,
            "error": "KiCad not installed",
            "_hint": "Report to the user and ask them to install KiCad from https://www.kicad.org/download/, then retry.",
        }

    if IS_MACOS:
        return _mac_open_footprint_editor(kicad_info, args or {})
    if not IS_WINDOWS:
        return {"success": False, "error": f"open_footprint_editor not implemented on {sys.platform!r}"}

    footprint_name = args.get("footprintName", "") or args.get("footprint", "")
    library_name = args.get("libraryName", "") or args.get("library", "")

    # Check if the footprint is already loaded
    if footprint_name:
        fp_hwnd = _find_footprint_editor()
        if fp_hwnd:
            windows = _find_windows_by_title("Footprint Editor")
            for hwnd, title, _ in windows:
                if footprint_name in title:
                    _force_foreground(hwnd)
                    return {
                        "success": True,
                        "output": f"Footprint '{footprint_name}' is already loaded in the Footprint Editor.",
                    }

    # --- Phase 1: Ensure Footprint Editor is open ---

    fp_hwnd = _find_footprint_editor()
    pathways_tried: list[str] = []

    # Tier 0: open Footprint Editor from a RUNNING pcbnew (menu id 20572 —
    # "Footprint Editor", discovered via get_menu_ids on KiCad 10). The Footprint
    # Editor is hosted inside pcbnew, so when a board is open this is the reliable
    # path and it does NOT need the kicad.exe project manager (which may not be
    # running, and whose first-run "KiCad Setup" wizard otherwise blocks the icon
    # click). Mirrors how open_symbol_editor drives a live eeschema. (ADOMBASELINE 2026-07.)
    PCBNEW_FOOTPRINT_EDITOR_MENU_ID = 20572  # noqa: N806
    if not fp_hwnd:
        try:
            from handlers.bridge_client import try_wm_command_via_bridge
            tier0 = try_wm_command_via_bridge(
                exe_name="pcbnew", menu_id=PCBNEW_FOOTPRINT_EDITOR_MENU_ID, frame_index=0,
            )
            pathways_tried.append("pcbnew" if tier0.get("success") else f"pcbnew-failed:{tier0.get('errorCode','')}")
            if tier0.get("success"):
                for _ in range(12):
                    time.sleep(0.5)
                    fp_hwnd = _find_footprint_editor()
                    if fp_hwnd:
                        break
                if fp_hwnd:
                    _force_foreground(fp_hwnd)
                    if not footprint_name and not library_name:
                        return {
                            "success": True,
                            "output": "Footprint Editor opened successfully via a running pcbnew.",
                            "pathway": "pcbnew",
                            "pathwaysTried": pathways_tried,
                            "bridgePid": tier0.get("pid"),
                        }
                    # else fall through to navigate_footprint for footprint_name/library_name
        except Exception as e:  # pylint: disable=broad-except
            pathways_tried.append(f"pcbnew-import-error:{type(e).__name__}")

    # Phase 3 Tier 1: try the reverse bridge first if the kicad.exe
    # project manager has the plugin loaded. WM_COMMAND 20013 from the
    # Project Manager's Tools menu opens Footprint Editor. This sidesteps
    # the flaky icon-click fallback documented at the top of this file
    # (KiCad 9 launcher icons use dynamic WM_COMMAND IDs we can't rely on).
    KICAD_FOOTPRINT_EDITOR_CMD_ID = 20013  # noqa: N806
    if not fp_hwnd:
        try:
            from handlers.bridge_client import try_wm_command_via_bridge
            tier1 = try_wm_command_via_bridge(
                exe_name="kicad", menu_id=KICAD_FOOTPRINT_EDITOR_CMD_ID, frame_index=0,
            )
            pathways_tried.append("plugin" if tier1.get("success") else f"plugin-failed:{tier1.get('errorCode','')}")
            if tier1.get("success"):
                for _ in range(12):
                    time.sleep(0.5)
                    fp_hwnd = _find_footprint_editor()
                    if fp_hwnd:
                        break
                if fp_hwnd:
                    _force_foreground(fp_hwnd)
                    if not footprint_name and not library_name:
                        return {
                            "success": True,
                            "output": "Footprint Editor opened successfully via bridge.",
                            "pathway": "plugin",
                            "pathwaysTried": pathways_tried,
                            "bridgePid": tier1.get("pid"),
                        }
                    # Fall through to navigate_footprint for footprint_name/library_name
        except Exception as e:  # pylint: disable=broad-except
            pathways_tried.append(f"plugin-import-error:{type(e).__name__}")

    if not fp_hwnd:
        kicad_hwnd = _find_kicad_manager()

        if not kicad_hwnd:
            kicad_exe = kicad_info.get("kicad_exe")
            if not kicad_exe or not Path(kicad_exe).exists():
                return {
                    "success": False,
                    "error": "KiCad executable not found",
                    "_hint": "Report to the user and ask them to reinstall KiCad — the installation appears corrupted.",
                }

            try:
                _si = None
                try:
                    from handlers import win_focus
                    _si = win_focus.background_startupinfo()  # open the manager behind the user
                except Exception:
                    _si = None
                subprocess.Popen(
                    [kicad_exe],
                    startupinfo=_si,
                    stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL,
                )
            except Exception as e:
                return {
                    "success": False,
                    "error": f"Failed to launch KiCad: {e}",
                    "_hint": "Call close_kicad with {\"force\": true} to clear stuck processes, then retry.",
                }

            for _ in range(12):
                time.sleep(0.5)
                kicad_hwnd = _find_kicad_manager()
                if kicad_hwnd:
                    break

            if not kicad_hwnd:
                return {
                    "success": False,
                    "error": "KiCad launched but project manager window not found after 10s",
                    "_hint": "Call screenshot_all to see KiCad's current state, or call close_kicad with {\"force\": true} and retry.",
                }
            time.sleep(2.0)

        # Tier 1.5 — UIA background Invoke (the RELIABLE background fallback). KiCad
        # exposes an invokable UIA tree, so we can trigger the "Footprint Editor"
        # control PROGRAMMATICALLY: no foreground, no cursor move. Try a running
        # pcbnew first (its toolbar/Tools has the control), then the project manager.
        try:
            from handlers import uia
            uia_targets = [h for (h, _t, _o) in _find_windows_by_title("PCB Editor")]
            if kicad_hwnd:
                uia_targets.append(kicad_hwnd)
            for tgt in uia_targets:
                r = uia.uia_invoke(tgt, contains="Footprint Editor")
                if r.get("ok"):
                    pathways_tried.append("uia")
                    for _ in range(12):
                        time.sleep(0.5)
                        fp_hwnd = _find_footprint_editor()
                        if fp_hwnd:
                            _force_foreground(fp_hwnd)  # SW_SHOWNOACTIVATE — visible, not focused
                            if not footprint_name and not library_name:
                                return {
                                    "success": True,
                                    "output": "Footprint Editor opened via background UIA Invoke.",
                                    "pathway": "uia",
                                    "pathwaysTried": pathways_tried,
                                }
                            break
                    if fp_hwnd:
                        break
                else:
                    pathways_tried.append(f"uia-{r.get('reason','miss')}")
        except Exception as e:  # pylint: disable=broad-except
            pathways_tried.append(f"uia-error:{type(e).__name__}")

        # Tier 2: legacy launcher-icon click — DISABLED BY DEFAULT. It MOVES the
        # user's mouse cursor (SetCursorPos) and needs the manager foregrounded, so
        # it hijacks the desktop mid-work. The bridge must never do that (John,
        # 2026-07). We only run it if the caller EXPLICITLY opts in with
        # {"allowCursorClick": true}; otherwise we fail gracefully and point at the
        # background plugin path (which needs the reverse-bridge plugin loaded).
        opened = False
        if args.get("allowCursorClick"):
            for attempt in range(2):
                if _open_footprint_editor_via_click(kicad_hwnd):
                    opened = True
                    break
                _force_foreground(kicad_hwnd)
                time.sleep(1.0)
            pathways_tried.append("icon_click" if opened else "icon_click_failed")
        else:
            pathways_tried.append("icon_click_skipped_background_mode")

        if not opened:
            return {
                "success": False,
                "error": "Could not open the Footprint Editor via the background (plugin) path.",
                "pathwaysTried": pathways_tried,
                "_hint": "The background WM_COMMAND path needs the reverse-bridge plugin loaded in a running pcbnew/kicad — open a board first (kicad_open_board) so pcbnew loads the plugin, then retry; the pcbnew tier (menu 20572) opens the Footprint Editor with NO focus theft. The mouse-moving launcher-click fallback is disabled by default; pass {\"allowCursorClick\":true} only if you accept it will move the cursor + foreground KiCad.",
            }

        fp_hwnd = _find_footprint_editor()
        if not fp_hwnd:
            return {
                "success": False,
                "error": "Footprint Editor launcher clicked but window did not appear.",
                "_hint": "Call screenshot_all to check for a modal dialog, dismiss with send_key escape, then retry.",
            }

        time.sleep(1.5)

    # --- Phase 2: Navigate to specific footprint (if requested) ---

    if not footprint_name and not library_name:
        _force_foreground(fp_hwnd)
        return {"success": True, "output": "Footprint Editor opened successfully."}

    search_text = footprint_name or library_name

    nav_result = _navigate_to_footprint(fp_hwnd, search_text)

    if nav_result["status"] == "ok":
        return {
            "success": True,
            "output": f"Footprint '{search_text}' loaded in the Footprint Editor: {nav_result['title']}",
        }
    elif nav_result["status"] == "partial":
        return {
            "success": True,
            "output": f"Footprint Editor loaded: {nav_result['title']}",
        }
    elif nav_result["status"] == "noload":
        time.sleep(0.5)
        nav_result2 = _navigate_to_footprint(fp_hwnd, search_text)
        if nav_result2["status"] == "ok":
            return {
                "success": True,
                "output": f"Footprint '{search_text}' loaded in the Footprint Editor: {nav_result2['title']}",
            }
        elif nav_result2["status"] == "partial":
            return {
                "success": True,
                "output": f"Footprint Editor loaded: {nav_result2['title']}",
            }
        else:
            return {
                "success": True,
                "output": f"Footprint Editor opened and searched for '{search_text}'. "
                          f"Use Ctrl+Shift+E to load the footprint if it didn't auto-load.",
            }
    else:
        _force_foreground(fp_hwnd)
        return {
            "success": True,
            "output": f"Footprint Editor opened but navigation failed: {nav_result.get('error', 'unknown')}. "
                      f"Search for '{search_text}' manually.",
        }


def _find_pcb_editor() -> int | None:
    """Find an open PCB Editor (pcbnew) window."""
    windows = _find_windows_by_title("PCB Editor")
    for hwnd, title, _owner in windows:
        if "pcb editor" in title.lower():
            return hwnd
    return None


def handle_open_3d_viewer(kicad_info: dict, args: dict) -> dict:
    """Open the 3D Viewer from the Footprint Editor or the PCB Editor.

    Args:
        editor: "auto" (default), "fp", or "pcb". Picks which editor to open
            the 3D viewer from. v0.7.1+ — added to fix the bug where the FP
            editor was always tried first and blocked the PCB fallback when
            FP's WM_COMMAND silently failed (KiCad 10's command IDs differ
            from KiCad 9's, so the FP path can fail without opening a
            viewer AND without raising an error).

    Windows: sends WM_COMMAND 20500 to Footprint Editor (KiCad 9; may differ
    on 10), or Alt+3 keystroke / WM_COMMAND 20563 to PCB Editor.
    macOS: not implemented — invoking a menu item in another app requires
    Accessibility-framework permission.
    """
    if IS_MACOS:
        return {
            "success": False,
            "error": "open_3d_viewer is not implemented on macOS",
            "_hint": ("On macOS, ask the user to open the 3D Viewer from the editor's "
                      "View menu (or press Alt+3 in PCB Editor). UI scripting requires "
                      "Accessibility permission."),
        }
    if not IS_WINDOWS:
        return {"success": False, "error": f"open_3d_viewer not implemented on {sys.platform!r}"}

    # Editor selection. "auto" means: try FP if open AND PCB isn't (since FP
    # 3D viewer is per-footprint, PCB 3D viewer is per-board — caller usually
    # has a strong preference even on "auto" once one editor dominates the
    # screen). When BOTH are open, prefer PCB — that matches the common tour
    # flow (FP gets the chip in step 4; PCB gets the board in step 7).
    editor_choice = (args.get("editor") or "auto").lower()
    if editor_choice not in {"auto", "fp", "footprint", "pcb", "board"}:
        return {
            "success": False,
            "error": f"Unknown editor '{editor_choice}'. Use 'auto', 'fp', or 'pcb'.",
        }

    fp_hwnd = _find_footprint_editor()
    pcb_hwnd = _find_pcb_editor()
    if editor_choice in {"fp", "footprint"}:
        if not fp_hwnd:
            return {
                "success": False,
                "error": "Footprint Editor is not open.",
                "_hint": "Call open_footprint_editor first or pass editor='pcb'.",
            }
        pcb_hwnd = None  # skip pcb fallback when caller explicitly wants fp
    elif editor_choice in {"pcb", "board"}:
        if not pcb_hwnd:
            return {
                "success": False,
                "error": "PCB Editor is not open.",
                "_hint": "Call open_board first or pass editor='fp'.",
            }
        fp_hwnd = None  # skip fp attempt when caller explicitly wants pcb
    else:
        # auto: prefer PCB when both are open (tour-like flow)
        if pcb_hwnd and fp_hwnd:
            fp_hwnd = None

    # Try Footprint Editor (only when chosen / sole open editor)
    if fp_hwnd:
        WM_COMMAND = 0x0111
        fp_pathways: list[str] = []
        # BACKGROUND path (preferred): in KiCad 10 the Footprint Editor is a frame
        # INSIDE the pcbnew process, so the reverse-bridge plugin can drive it. Find
        # that frame's index (list_frames) and post the live-resolved "3D Viewer"
        # menu id to it (wm_command honors frame_index). No foreground, no keystroke.
        # (Replaces the old Alt+3 path, which needed focus we deliberately don't take
        # — and referenced VK_MENU as a bare global, raising UnboundLocalError because
        # the pcbnew branch below assigns VK_MENU, making it function-local.)
        try:
            from handlers.bridge_client import (resolve_frame_index_via_bridge,
                                                resolve_menu_id_via_bridge,
                                                try_wm_command_via_bridge)
            fp_fi = resolve_frame_index_via_bridge("pcbnew", "Footprint Editor")
            if fp_fi is not None:
                mid = resolve_menu_id_via_bridge("pcbnew", "3D Viewer", frame_index=fp_fi) or 20568
                fp_pathways.append(f"plugin(frame={fp_fi},menuid={mid})")
                r = try_wm_command_via_bridge(exe_name="pcbnew", menu_id=mid, frame_index=fp_fi)
                if r.get("success"):
                    time.sleep(3.0)
                    windows = _find_windows_by_title("3D Viewer")
                    if windows:
                        return {
                            "success": True,
                            "output": f"3D Viewer opened from Footprint Editor: {windows[0][1]}",
                            "pathway": "plugin",
                            "pathwaysTried": fp_pathways,
                        }
            else:
                fp_pathways.append("plugin-no-fp-frame")
        except Exception as e:  # pylint: disable=broad-except
            fp_pathways.append(f"plugin-error:{type(e).__name__}")

        # Opt-in disruptive fallback: Alt+3 keystroke (needs the FP window foregrounded,
        # so it steals focus) — only when the caller explicitly allows it.
        if args.get("allowFocusSteal"):
            VK_MENU_LOCAL = 0x12
            VK_3 = 0x33
            user32.SetForegroundWindow(fp_hwnd)
            time.sleep(0.2)
            try:
                user32.keybd_event(VK_MENU_LOCAL, 0, 0, 0)
                user32.keybd_event(VK_3, 0, 0, 0)
                user32.keybd_event(VK_3, 0, KEYEVENTF_KEYUP, 0)
            finally:
                user32.keybd_event(VK_MENU_LOCAL, 0, KEYEVENTF_KEYUP, 0)
            fp_pathways.append("alt3_focussteal")
            time.sleep(2.5)
            windows = _find_windows_by_title("3D Viewer")
            if windows:
                return {
                    "success": True,
                    "output": f"3D Viewer opened from Footprint Editor: {windows[0][1]}",
                    "pathway": "fp_alt3",
                    "pathwaysTried": fp_pathways,
                }

        # FP path failed (plugin frame absent AND no focus-steal fallback).
        # If caller asked for 'auto' AND a PCB editor is also open, fall through
        # to PCB attempt rather than dead-ending here.
        if editor_choice == "auto" and pcb_hwnd is None:
            # We nulled it above on auto+both; redo the lookup to recover.
            pcb_hwnd = _find_pcb_editor()
        if not pcb_hwnd:
            return {
                "success": False,
                "error": "3D Viewer did not open from Footprint Editor.",
                "_hint": "Call open_footprint_editor with a footprintName first, OR "
                         "pass editor='pcb' to target the board's 3D viewer instead.",
            }
        # else: fall through to PCB Editor attempt below

    # Try PCB Editor (pcbnew) — Phase 3 prefers tier-1 bridge wm_command
    # over the legacy Alt+3 keystroke. The bridge posts WM_COMMAND 20563
    # (3D Viewer menu id from PCB Editor's View menu) which doesn't
    # require foregrounding the window or stealing keyboard focus.
    pcb_hwnd = _find_pcb_editor()
    if pcb_hwnd:
        # Menu ids DRIFT across KiCad builds (seen 20563 on one, 20568 on another),
        # so RESOLVE the live "3D Viewer" id from the running pcbnew instead of trusting
        # a constant. Fall back to the legacy id only if the lookup can't reach the plugin.
        PCB_3D_VIEWER_CMD = 20563  # noqa: N806  legacy fallback (PCB Editor → View → 3D Viewer)
        pathways_tried: list[str] = []

        # Tier 1: bridge wm_command via pcbnew plugin
        try:
            from handlers.bridge_client import try_wm_command_via_bridge, resolve_menu_id_via_bridge
            _live_id = resolve_menu_id_via_bridge(exe_name="pcbnew", contains="3D Viewer", frame_index=0)
            _cmd_id = _live_id if _live_id else PCB_3D_VIEWER_CMD
            pathways_tried.append(f"menuid={_cmd_id}{'(live)' if _live_id else '(fallback)'}")
            tier1 = try_wm_command_via_bridge(
                exe_name="pcbnew", menu_id=_cmd_id, frame_index=0,
            )
            pathways_tried.append("plugin" if tier1.get("success") else f"plugin-failed:{tier1.get('errorCode','')}")
            if tier1.get("success"):
                time.sleep(3.0)
                windows = _find_windows_by_title("3D Viewer")
                if windows:
                    return {
                        "success": True,
                        "output": f"3D Viewer opened from PCB Editor: {windows[0][1]}",
                        "pathway": "plugin",
                        "pathwaysTried": pathways_tried,
                        "bridgePid": tier1.get("pid"),
                    }
        except Exception as e:  # pylint: disable=broad-except
            pathways_tried.append(f"plugin-import-error:{type(e).__name__}")

        # Tier 1.5 — UIA background Invoke of the "3D Viewer" control on pcbnew.
        # Programmatic (no foreground, no cursor), replaces the Alt+3 keystroke.
        try:
            from handlers import uia
            r = uia.uia_invoke(pcb_hwnd, contains="3D Viewer")
            if r.get("ok"):
                pathways_tried.append("uia")
                time.sleep(3.0)
                windows = _find_windows_by_title("3D Viewer")
                if windows:
                    return {
                        "success": True,
                        "output": f"3D Viewer opened via background UIA Invoke: {windows[0][1]}",
                        "pathway": "uia",
                        "pathwaysTried": pathways_tried,
                    }
            else:
                pathways_tried.append(f"uia-{r.get('reason','miss')}")
        except Exception as e:  # pylint: disable=broad-except
            pathways_tried.append(f"uia-error:{type(e).__name__}")

        # Tier 2 / legacy: Alt+3 keystroke — needs pcbnew FOREGROUNDED so the global
        # keystroke lands there (a global Alt+3 to the user's own window could fire
        # their app's Alt+3). That steals focus, so it's OPT-IN only
        # ({"allowFocusSteal": true}); by default we fail to the background path.
        if args.get("allowFocusSteal"):
            user32.SetForegroundWindow(pcb_hwnd)
            time.sleep(0.3)
            VK_MENU = 0x12   # Alt key
            VK_3 = 0x33      # '3' key
            KEYEVENTF_KEYUP = 0x0002
            user32.keybd_event(VK_MENU, 0, 0, 0)           # Alt down
            user32.keybd_event(VK_3, 0, 0, 0)               # 3 down
            user32.keybd_event(VK_3, 0, KEYEVENTF_KEYUP, 0) # 3 up
            user32.keybd_event(VK_MENU, 0, KEYEVENTF_KEYUP, 0) # Alt up
            pathways_tried.append("alt_keystroke")
            time.sleep(3.0)
            windows = _find_windows_by_title("3D Viewer")
            if windows:
                return {
                    "success": True,
                    "output": f"3D Viewer opened from PCB Editor: {windows[0][1]}",
                    "pathway": "alt_keystroke",
                    "pathwaysTried": pathways_tried,
                }
        else:
            pathways_tried.append("alt_keystroke_skipped_background_mode")

        return {
            "success": False,
            "error": "3D Viewer did not open via the background (plugin) path.",
            "pathwaysTried": pathways_tried,
            "_hint": "Call open_board {\"filePath\":\"...\"} to load a PCB in pcbnew first, then retry.",
        }

    return {
        "success": False,
        "error": "Neither Footprint Editor nor PCB Editor is open. Open one first with open_footprint_editor or open_pcb.",
        "_hint": "Call open_footprint_editor or open_board {\"filePath\":\"...\"} first, then retry.",
    }