"""Global hotkeys.

Windows gets a native RegisterHotKey backend through ctypes: no third-party
package, no Python-version fragility, and the OS tells us honestly when a
combo is already taken by another app. (The `keyboard` package was the
first attempt — it is unmaintained since 2021 and dies on modern Pythons
with "'_KeyboardListener' object has no attribute 'blocking_hotkeys'".)

Elsewhere we try `keyboard` if it happens to be installed, and otherwise
report that the API bindings are the way — which is what a Linux user with
a compositor binding wants anyway.
"""
import ctypes
import os
import threading
import time

_MODS = {"ctrl": 0x0002, "control": 0x0002, "alt": 0x0001, "shift": 0x0004,
         "win": 0x0008, "super": 0x0008, "cmd": 0x0008}
MOD_NOREPEAT = 0x4000
WM_HOTKEY = 0x0312

_VK_NAMES = {
    "space": 0x20, "enter": 0x0D, "return": 0x0D, "tab": 0x09, "esc": 0x1B,
    "escape": 0x1B, "backspace": 0x08, "delete": 0x2E, "insert": 0x2D,
    "home": 0x24, "end": 0x23, "pageup": 0x21, "pagedown": 0x22,
    "left": 0x25, "up": 0x26, "right": 0x27, "down": 0x28,
    "printscreen": 0x2C, "pause": 0x13, "scrolllock": 0x91,
    "numpad0": 0x60, "numpad1": 0x61, "numpad2": 0x62, "numpad3": 0x63,
    "numpad4": 0x64, "numpad5": 0x65, "numpad6": 0x66, "numpad7": 0x67,
    "numpad8": 0x68, "numpad9": 0x69, "numpadplus": 0x6B, "numpadminus": 0x6D,
    "`": 0xC0, "-": 0xBD, "=": 0xBB, "[": 0xDB, "]": 0xDD, "\\": 0xDC,
    ";": 0xBA, "'": 0xDE, ",": 0xBC, ".": 0xBE, "/": 0xBF,
}


def parse(combo: str):
    """'ctrl+alt+c' -> (modifier mask, virtual key code). Raises ValueError."""
    parts = [p.strip().lower() for p in str(combo).split("+") if p.strip()]
    if not parts:
        raise ValueError("empty hotkey")
    mods, key = 0, None
    for p in parts:
        if p in _MODS:
            mods |= _MODS[p]
        elif key is None:
            key = p
        else:
            raise ValueError(f"more than one non-modifier key in '{combo}'")
    if key is None:
        raise ValueError(f"'{combo}' has no main key")
    if len(key) == 1 and (key.isalpha() or key.isdigit()):
        vk = ord(key.upper())
    elif key.startswith("f") and key[1:].isdigit() and 1 <= int(key[1:]) <= 24:
        vk = 0x70 + int(key[1:]) - 1
    elif key in _VK_NAMES:
        vk = _VK_NAMES[key]
    else:
        raise ValueError(f"unknown key '{key}'")
    if not mods:
        raise ValueError("a global hotkey needs at least one modifier "
                         "(ctrl/alt/shift/win) so it can't eat normal typing")
    return mods, vk


class _WindowsHotkeys:
    """RegisterHotKey must happen on the thread that pumps the messages, so
    one thread owns everything; rebinding restarts it."""

    def __init__(self):
        self._stop = threading.Event()
        self._thread = None
        self.active, self.errors = [], []

    def stop(self):
        if self._thread:
            self._stop.set()
            self._thread.join(timeout=3)
            self._thread = None

    def bind(self, bindings):
        self.stop()
        self._stop = threading.Event()
        self.active, self.errors = [], []
        pairs = [(c, fn) for c, fn in bindings if c]
        if not pairs:
            return
        ready = threading.Event()
        self._thread = threading.Thread(target=self._run, args=(pairs, ready),
                                        daemon=True)
        self._thread.start()
        ready.wait(timeout=5)

    def _run(self, pairs, ready):
        user32 = ctypes.windll.user32
        registered = {}
        for i, (combo, fn) in enumerate(pairs, start=1):
            try:
                mods, vk = parse(combo)
            except ValueError as e:
                self.errors.append(f"{combo}: {e}")
                continue
            if user32.RegisterHotKey(None, i, mods | MOD_NOREPEAT, vk):
                registered[i] = fn
                self.active.append(combo)
            else:
                err = ctypes.get_last_error()
                self.errors.append(
                    f"{combo}: already in use by another app"
                    if err == 1409 else f"{combo}: could not register (error {err})")
        ready.set()
        try:
            import ctypes.wintypes as wt
            msg = wt.MSG()
            while not self._stop.is_set():
                # PeekMessage + a short sleep instead of blocking GetMessage,
                # so stopping never hangs waiting for an input event
                if user32.PeekMessageW(ctypes.byref(msg), None, 0, 0, 1):
                    if msg.message == WM_HOTKEY:
                        fn = registered.get(msg.wParam)
                        if fn:
                            threading.Thread(target=fn, daemon=True).start()
                else:
                    time.sleep(0.03)
        finally:
            for i in registered:
                user32.UnregisterHotKey(None, i)


class _KeyboardPkgHotkeys:
    """Fallback for non-Windows if the `keyboard` package is installed."""

    def __init__(self):
        self.active, self.errors = [], []

    def stop(self):
        try:
            import keyboard
            keyboard.unhook_all_hotkeys()
        except Exception:
            pass

    def bind(self, bindings):
        self.active, self.errors = [], []
        try:
            import keyboard
        except ImportError:
            self.errors.append("no global-hotkey backend on this platform — "
                               "bind the API endpoints from your desktop instead")
            return
        self.stop()
        for combo, fn in bindings:
            if not combo:
                continue
            try:
                keyboard.add_hotkey(combo, fn, suppress=False)
                self.active.append(combo)
            except Exception as e:
                self.errors.append(f"{combo}: {type(e).__name__}: {e}")


_backend = _WindowsHotkeys() if os.name == "nt" else _KeyboardPkgHotkeys()


def bind(bindings) -> dict:
    """bindings: [(combo, callable), ...]. Returns {active, errors}."""
    try:
        _backend.bind(bindings)
    except Exception as e:
        return {"active": [], "errors": [f"{type(e).__name__}: {e}"]}
    return {"active": list(_backend.active), "errors": list(_backend.errors)}


def available() -> bool:
    if os.name == "nt":
        return True
    try:
        import keyboard  # noqa: F401
        return True
    except ImportError:
        return False
