"""Handlers for close_symbol_editor, close_footprint_editor, close_3d_viewer, and close_kicad.

Cross-platform: Windows uses Win32 (WM_CLOSE + taskkill), macOS uses
osascript (graceful quit) and pkill (force).

Window enumeration is process-based on Windows (find PIDs running from KiCad's
bin_dir, then enumerate top-level windows belonging to those PIDs). On macOS
we can't enumerate windows without Accessibility-framework permissions, so
window_info returns the running editor processes as a flat list instead.
"""

import ctypes
import ctypes.wintypes
import os
import subprocess
import sys
import time

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

WM_CLOSE = 0x0010
GW_OWNER = 4
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000

# ╔═══════════════════════════════════════════════════════════════════════════╗
# ║                              WINDOWS                                       ║
# ╚═══════════════════════════════════════════════════════════════════════════╝

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

if IS_WINDOWS:

    # KiCad sub-process names that may survive WM_CLOSE
    WIN_KICAD_PROCESS_NAMES = [
        "eeschema.exe",
        "pcbnew.exe",
        "bitmap2component.exe",
        "pcb_calculator.exe",
        "pl_editor.exe",
        "kicad.exe",
    ]

    _callbacks = []


    def _win_get_kicad_pids(kicad_info: dict) -> set:
        """Find PIDs of all running processes whose exe lives under KiCad's bin_dir."""
        bin_dir = kicad_info.get("bin_dir", "")
        if not bin_dir:
            return set()
        bin_dir_norm = os.path.normcase(os.path.normpath(bin_dir))

        pids = set()
        max_pids = 4096
        arr = (ctypes.wintypes.DWORD * max_pids)()
        bytes_returned = ctypes.wintypes.DWORD()
        psapi = ctypes.windll.psapi
        if not psapi.EnumProcesses(ctypes.byref(arr), ctypes.sizeof(arr), ctypes.byref(bytes_returned)):
            return pids

        count = bytes_returned.value // ctypes.sizeof(ctypes.wintypes.DWORD)
        buf = ctypes.create_unicode_buffer(1024)

        for i in range(count):
            pid = arr[i]
            if pid == 0:
                continue
            handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
            if not handle:
                continue
            try:
                size = ctypes.wintypes.DWORD(1024)
                if kernel32.QueryFullProcessImageNameW(handle, 0, buf, ctypes.byref(size)):
                    exe_path = os.path.normcase(os.path.normpath(buf.value))
                    if exe_path.startswith(bin_dir_norm):
                        pids.add(pid)
            finally:
                kernel32.CloseHandle(handle)

        return pids


    def _win_find_windows_by_pid(pids: set) -> list:
        """Find all visible top-level windows belonging to any of the given PIDs."""
        results = []
        WNDENUMPROC = ctypes.WINFUNCTYPE(
            ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM
        )

        def callback(hwnd, _lparam):
            if not user32.IsWindowVisible(hwnd):
                return True
            pid = ctypes.wintypes.DWORD()
            user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
            if pid.value in pids:
                length = user32.GetWindowTextLengthW(hwnd)
                title = ""
                if length > 0:
                    buf = ctypes.create_unicode_buffer(length + 1)
                    user32.GetWindowTextW(hwnd, buf, length + 1)
                    title = buf.value
                owner = user32.GetWindow(hwnd, GW_OWNER)
                results.append((hwnd, title, owner))
            return True

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


    def _win_find_all_kicad_windows(kicad_info: dict) -> list:
        pids = _win_get_kicad_pids(kicad_info)
        if not pids:
            return []
        return _win_find_windows_by_pid(pids)


    def _win_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 _win_get_dialog_body_text(hwnd: int) -> str:
        """Read a dialog's message text from its Static (label) child controls.

        Returns the concatenated body (e.g. "Insufficient permissions to save…")
        so the AI can read + handle an error dialog WITHOUT a screenshot round-trip.
        Only Static class (skips buttons), skips icon-only statics + consecutive
        dups. Best-effort: returns "" on any failure.
        """
        parts: list = []
        try:
            WNDENUMPROC = ctypes.WINFUNCTYPE(
                ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM
            )

            def child_cb(child, _lparam):
                cls = ctypes.create_unicode_buffer(64)
                user32.GetClassNameW(child, cls, 64)
                if cls.value == "Static":
                    length = user32.GetWindowTextLengthW(child)
                    if length > 0:
                        b = ctypes.create_unicode_buffer(length + 1)
                        user32.GetWindowTextW(child, b, length + 1)
                        t = b.value.strip()
                        if t and any(ord(c) >= 32 for c in t):
                            parts.append(t)
                return True

            cb = WNDENUMPROC(child_cb)
            _callbacks.append(cb)
            user32.EnumChildWindows(hwnd, cb, 0)
            _callbacks.remove(cb)
        except Exception:
            return ""
        out: list = []
        for p in parts:
            if not out or out[-1] != p:
                out.append(p)
        return "  ".join(out).strip()


    def _win_close_and_wait(hwnd: int, title: str, timeout: float = 5.0) -> bool:
        """Send WM_CLOSE and wait for the window to disappear."""
        user32.PostMessageW(hwnd, WM_CLOSE, 0, 0)

        deadline = time.monotonic() + timeout
        while time.monotonic() < deadline:
            time.sleep(0.3)
            if not user32.IsWindow(hwnd) or not user32.IsWindowVisible(hwnd):
                return True
        return False


    def _win_force_kill_kicad_processes() -> list:
        """Force-kill all KiCad sub-processes via taskkill. Returns list of killed names."""
        killed = []
        for name in WIN_KICAD_PROCESS_NAMES:
            try:
                result = subprocess.run(
                    ["taskkill", "/F", "/IM", name],
                    capture_output=True, text=True, timeout=5,
                    creationflags=0x08000000,  # CREATE_NO_WINDOW
                )
                if result.returncode == 0:
                    killed.append(name)
            except Exception:
                pass
        return killed


# ╔═══════════════════════════════════════════════════════════════════════════╗
# ║                                macOS                                       ║
# ╚═══════════════════════════════════════════════════════════════════════════╝

# Bundle ids / app names for AppleScript "tell application X to quit".
# Display names (left side) match the on-disk .app bundle name; AppleScript
# accepts these directly.
MAC_APP_NAMES = ["KiCad", "PCB Editor", "Schematic Editor", "Footprint Editor",
                 "Symbol Editor", "GerbView", "Image Converter", "PCB Calculator",
                 "Page Layout Editor"]

# Process-name patterns for pgrep / pkill — covers both binary names and the
# editor bundles whose Mach-O is named after the editor.
MAC_KICAD_PROCESS_NAMES = ["kicad", "pcbnew", "eeschema", "gerbview",
                           "bitmap2component", "pcb_calculator", "pl_editor"]


def _mac_pgrep(pattern: str) -> list:
    """Run `pgrep -f <pattern>` and return list of matching PIDs."""
    try:
        result = subprocess.run(
            ["pgrep", "-f", pattern],
            capture_output=True, text=True, timeout=3,
        )
        if result.returncode != 0:
            return []
        return [int(line) for line in result.stdout.split() if line.strip().isdigit()]
    except Exception:
        return []


def _mac_get_kicad_pids(kicad_info: dict) -> set:
    """Find PIDs of running KiCad processes by matching binary names.
    Filters by the install's base_dir so we don't hit a different KiCad install."""
    base_dir = kicad_info.get("base_dir", "")
    pids: set = set()
    # ps -A -o pid,comm,args is the most reliable way to filter by exe path
    try:
        result = subprocess.run(
            ["ps", "-A", "-o", "pid=,comm="],
            capture_output=True, text=True, timeout=3,
        )
        if result.returncode != 0:
            return pids
        for line in result.stdout.splitlines():
            line = line.strip()
            if not line:
                continue
            parts = line.split(None, 1)
            if len(parts) != 2:
                continue
            pid_str, comm = parts
            if not pid_str.isdigit():
                continue
            # comm is the full path to the executable on macOS
            # Filter to KiCad install if we have a base_dir; otherwise match by name
            if base_dir and base_dir in comm:
                pids.add(int(pid_str))
            elif not base_dir:
                bin_name = os.path.basename(comm).lower()
                if any(name in bin_name for name in MAC_KICAD_PROCESS_NAMES):
                    pids.add(int(pid_str))
    except Exception:
        pass
    return pids


def _mac_running_editors(kicad_info: dict) -> list:
    """Return a list of dicts describing currently running KiCad editor processes.
    Used as a stand-in for window enumeration on macOS (where we'd need
    Accessibility-framework permission to enumerate windows of other apps)."""
    pids = _mac_get_kicad_pids(kicad_info)
    out = []
    for pid in pids:
        try:
            result = subprocess.run(
                ["ps", "-o", "comm=", "-p", str(pid)],
                capture_output=True, text=True, timeout=2,
            )
            comm = result.stdout.strip()
            if not comm:
                continue
            bin_name = os.path.basename(comm)
            # Title is best-effort: use the binary name as the title surrogate
            out.append({
                "pid": pid,
                "title": bin_name,
                "binary": comm,
            })
        except Exception:
            pass
    return out


def _mac_quit_app(app_name: str, timeout: float = 5.0) -> bool:
    """Ask an app to quit gracefully via AppleScript.
    Returns True if the quit message was delivered (the app may still
    show a save-prompt dialog before fully quitting)."""
    try:
        subprocess.run(
            ["osascript", "-e", f'tell application "{app_name}" to quit'],
            capture_output=True, text=True, timeout=timeout,
        )
        # osascript returns 0 even if the app isn't running, so we always
        # claim success — caller should re-check process state.
        return True
    except Exception:
        return False


def _mac_force_kill_kicad_processes() -> list:
    """Force-kill all KiCad sub-processes via pkill. Returns list of killed names."""
    killed = []
    for name in MAC_KICAD_PROCESS_NAMES:
        try:
            result = subprocess.run(
                ["pkill", "-9", "-x", name],
                capture_output=True, text=True, timeout=3,
            )
            if result.returncode == 0:
                killed.append(name)
        except Exception:
            pass
    return killed


def _mac_wait_for_quit(name_substr: str, timeout: float = 5.0) -> bool:
    """Wait up to `timeout` for processes whose binary name contains `name_substr` to disappear."""
    deadline = time.monotonic() + timeout
    name_substr = name_substr.lower()
    while time.monotonic() < deadline:
        try:
            result = subprocess.run(
                ["ps", "-A", "-o", "comm="],
                capture_output=True, text=True, timeout=2,
            )
            running = any(name_substr in line.lower()
                          for line in result.stdout.splitlines())
            if not running:
                return True
        except Exception:
            pass
        time.sleep(0.3)
    return False


# ╔═══════════════════════════════════════════════════════════════════════════╗
# ║                          PUBLIC API (cross-platform)                       ║
# ╚═══════════════════════════════════════════════════════════════════════════╝

def _is_manager_window(title: str) -> bool:
    """The KiCad project manager's title is just 'KiCad <version>' or 'KiCad'
    — it doesn't contain 'Editor', 'Viewer', etc."""
    lower = title.lower()
    if lower.startswith("kicad") and "editor" not in lower and "viewer" not in lower:
        return True
    return False


def handle_window_info(kicad_info: dict, args: dict) -> dict:
    """Return all KiCad windows (editors, project manager, modal dialogs).

    Windows: enumerates real windows via Win32. Bundles modal dialogs separately.
    macOS: returns a flat list of running editor processes. Modal-dialog
    enumeration requires Accessibility-framework permission, which we don't
    request — handlers that need to dismiss dialogs should ask the user.
    """
    if IS_WINDOWS:
        all_found = _win_find_all_kicad_windows(kicad_info)
        editors = []
        dialogs = []
        manager = None
        for hwnd, title, owner in all_found:
            if owner != 0:
                # `body` = the dialog's message text (read from its Static child
                # controls) so the AI can read+handle the error WITHOUT a screenshot
                # round-trip (e.g. "Insufficient permissions to save...").
                dialogs.append({
                    "hwnd": hwnd,
                    "title": title,
                    "ownerHwnd": owner,
                    "body": _win_get_dialog_body_text(hwnd),
                })
            elif _is_manager_window(title):
                manager = {"hwnd": hwnd, "title": title}
            else:
                editors.append({"hwnd": hwnd, "title": title})
        data = {
            "projectManager": manager,
            "editors": editors,
            "modalDialogs": dialogs,
            "hasModalDialogs": len(dialogs) > 0,
        }
        if dialogs:
            # In-`data` hint (siblings the flag, so it's not missed): tells the AI
            # a blocking dialog is up and how to read + dismiss it.
            data["_dialogHint"] = (
                "hasModalDialogs is true — KiCad has a BLOCKING modal dialog. Read each "
                "modalDialogs[].body for the message text; if body is empty, screenshot "
                "modalDialogs[].hwnd via desktop_screenshot_window. Dismiss with "
                "kicad_send_key {hwnd, key:'return'} (OK/default) or 'escape' (cancel)."
            )
        return {
            "success": True,
            "output": f"{len(editors)} editor(s), {len(dialogs)} dialog(s)",
            "data": data,
        }

    if IS_MACOS:
        editors = _mac_running_editors(kicad_info)
        manager = None
        editor_list = []
        for e in editors:
            if _is_manager_window(e["title"]):
                manager = {"pid": e["pid"], "title": e["title"]}
            else:
                editor_list.append({"pid": e["pid"], "title": e["title"]})
        return {
            "success": True,
            "output": f"{len(editor_list)} editor(s) running",
            "data": {
                "projectManager": manager,
                "editors": editor_list,
                "modalDialogs": [],
                "hasModalDialogs": False,
                "_hint": ("macOS reports editors as running processes; modal dialog "
                          "enumeration requires Accessibility permission and is not "
                          "implemented. If a modal dialog is blocking, ask the user "
                          "to dismiss it manually."),
            },
        }

    return {
        "success": False,
        "error": f"window_info not implemented on platform {sys.platform!r}",
    }


def handle_close_symbol_editor(kicad_info: dict, args: dict) -> dict:
    """Close the KiCad Symbol Editor window."""
    if IS_WINDOWS:
        windows = _win_find_windows_by_title("Symbol Editor")
        sym_windows = [(h, t) for h, t, _ in windows if "symbol editor" in t.lower()]
        if not sym_windows:
            return {"success": True, "output": "Symbol Editor is not open."}
        closed, failed = [], []
        for hwnd, title in sym_windows:
            (closed if _win_close_and_wait(hwnd, title) else failed).append(title)
        if failed:
            return {
                "success": False,
                "error": f"Failed to close: {', '.join(failed)}",
                "_hint": "Call close_kicad with {\"force\": true} to force-kill the window, then retry.",
            }
        return {
            "success": True,
            "output": f"Symbol Editor closed: {closed[0]}" if len(closed) == 1
                      else f"Closed {len(closed)} Symbol Editor window(s).",
        }

    if IS_MACOS:
        if not _mac_pgrep("Symbol Editor"):
            return {"success": True, "output": "Symbol Editor is not open."}
        _mac_quit_app("Symbol Editor")
        if _mac_wait_for_quit("Symbol Editor"):
            return {"success": True, "output": "Symbol Editor closed."}
        return {
            "success": False,
            "error": "Symbol Editor did not close within 5s",
            "_hint": "Call close_kicad with {\"force\": true} to force-kill the editor, then retry.",
        }

    return {"success": False, "error": f"unsupported platform {sys.platform!r}"}


def handle_close_footprint_editor(kicad_info: dict, args: dict) -> dict:
    """Close the KiCad Footprint Editor window."""
    if IS_WINDOWS:
        windows = _win_find_windows_by_title("Footprint Editor")
        fp_windows = [(h, t) for h, t, _ in windows if "footprint editor" in t.lower()]
        if not fp_windows:
            return {"success": True, "output": "Footprint Editor is not open."}
        closed, failed = [], []
        for hwnd, title in fp_windows:
            (closed if _win_close_and_wait(hwnd, title) else failed).append(title)
        if failed:
            return {
                "success": False,
                "error": f"Failed to close: {', '.join(failed)}",
                "_hint": "Call close_kicad with {\"force\": true} to force-kill the window, then retry.",
            }
        return {
            "success": True,
            "output": f"Footprint Editor closed: {closed[0]}" if len(closed) == 1
                      else f"Closed {len(closed)} Footprint Editor window(s).",
        }

    if IS_MACOS:
        if not _mac_pgrep("Footprint Editor"):
            return {"success": True, "output": "Footprint Editor is not open."}
        _mac_quit_app("Footprint Editor")
        if _mac_wait_for_quit("Footprint Editor"):
            return {"success": True, "output": "Footprint Editor closed."}
        return {
            "success": False,
            "error": "Footprint Editor did not close within 5s",
            "_hint": "Call close_kicad with {\"force\": true} to force-kill the editor, then retry.",
        }

    return {"success": False, "error": f"unsupported platform {sys.platform!r}"}


def handle_close_3d_viewer(kicad_info: dict, args: dict) -> dict:
    """Close the KiCad 3D Viewer window."""
    if IS_WINDOWS:
        windows = _win_find_windows_by_title("3D Viewer")
        viewer_windows = [(h, t) for h, t, _ in windows if "3d viewer" in t.lower()]
        if not viewer_windows:
            return {"success": True, "output": "3D Viewer is not open."}
        closed, failed = [], []
        for hwnd, title in viewer_windows:
            (closed if _win_close_and_wait(hwnd, title) else failed).append(title)
        if failed:
            return {
                "success": False,
                "error": f"Failed to close: {', '.join(failed)}",
                "_hint": "Call close_kicad with {\"force\": true} to force-kill the window, then retry.",
            }
        return {"success": True, "output": "3D Viewer closed."}

    if IS_MACOS:
        # The 3D Viewer on macOS is a sub-window of PCB Editor; closing it
        # requires either Accessibility-driven UI scripting or a force-kill of
        # PCB Editor (which would also close the board). We surface the
        # limitation rather than killing the whole editor.
        return {
            "success": False,
            "error": "close_3d_viewer is not implemented on macOS",
            "_hint": ("On macOS the 3D Viewer is a child window of PCB Editor; "
                      "closing it requires Accessibility permission. Ask the user "
                      "to close it manually, or use close_kicad to close the whole editor."),
        }

    return {"success": False, "error": f"unsupported platform {sys.platform!r}"}


def handle_close_kicad(kicad_info: dict, args: dict) -> dict:
    """Close all KiCad windows (editors first, then the project manager).

    Args:
        kicad_info: KiCad detection info dict.
        args: Optional dict with:
            force (bool): If True, skip graceful close and go straight to kill.
                          Default False.
    """
    force = bool(args.get("force", False)) if args else False

    if IS_WINDOWS:
        if force:
            killed = _win_force_kill_kicad_processes()
            if killed:
                return {
                    "success": True,
                    "output": f"Force-killed: {', '.join(killed)}",
                    "data": {"forceKilled": killed},
                }
            return {"success": True, "output": "KiCad is not running (nothing to kill)."}

        all_windows = _win_find_all_kicad_windows(kicad_info)
        editors, manager = [], None
        for hwnd, title, owner in all_windows:
            if owner != 0:
                continue
            if _is_manager_window(title):
                manager = (hwnd, title)
            else:
                editors.append((hwnd, title))

        if not editors and not manager:
            return {"success": True, "output": "KiCad is not running."}

        closed, failed = [], []
        for hwnd, title in editors:
            (closed if _win_close_and_wait(hwnd, title) else failed).append(title)
        if manager:
            hwnd, title = manager
            (closed if _win_close_and_wait(hwnd, title) else failed).append(title)

        force_killed = []
        if failed:
            time.sleep(1)
            force_killed = _win_force_kill_kicad_processes()
            if force_killed:
                closed.extend(f"{name} (force-killed)" for name in force_killed)
                failed = []

        if failed:
            return {
                "success": False,
                "error": f"Failed to close: {', '.join(failed)}",
                "output": f"Closed: {', '.join(closed)}" if closed else "",
                "_hint": "Call close_kicad with {\"force\": true} to force-kill stuck windows.",
            }

        return {
            "success": True,
            "output": f"KiCad closed ({len(closed)} window{'s' if len(closed) != 1 else ''}).",
            "data": {"forceKilled": force_killed} if force_killed else {},
        }

    if IS_MACOS:
        if force:
            killed = _mac_force_kill_kicad_processes()
            if killed:
                return {
                    "success": True,
                    "output": f"Force-killed: {', '.join(killed)}",
                    "data": {"forceKilled": killed},
                }
            return {"success": True, "output": "KiCad is not running (nothing to kill)."}

        # Graceful: ask each editor app to quit
        editors = _mac_running_editors(kicad_info)
        if not editors:
            return {"success": True, "output": "KiCad is not running."}

        for app_name in MAC_APP_NAMES:
            _mac_quit_app(app_name)

        # Wait for each known process to disappear
        still_running = []
        for proc_name in MAC_KICAD_PROCESS_NAMES:
            if not _mac_wait_for_quit(proc_name, timeout=3.0):
                still_running.append(proc_name)

        force_killed = []
        if still_running:
            force_killed = _mac_force_kill_kicad_processes()

        if still_running and not force_killed:
            return {
                "success": False,
                "error": f"Failed to close: {', '.join(still_running)}",
                "_hint": "Call close_kicad with {\"force\": true} to force-kill stuck processes.",
            }

        return {
            "success": True,
            "output": f"KiCad closed ({len(editors)} process{'es' if len(editors) != 1 else ''}).",
            "data": {"forceKilled": force_killed} if force_killed else {},
        }

    return {"success": False, "error": f"unsupported platform {sys.platform!r}"}
