"""
wivexx_hotkeys.py — Customisable global hotkey manager  (v1.6.1)
═══════════════════════════════════════════════════════════════════════
All WIVEXX shortcuts live in wivexx_hotkeys.json and can be rebound from
the GUI (⌨ Shortcuts).  Multi-key combinations are supported, including
MOUSE buttons ("mouse4", "mouse5", "mouse3"/middle — e.g. "ctrl+mouse4"),
plus user-defined custom shortcuts that run any voice command.

Engine (v1.6.1): instead of installing a low-level keyboard hook (which
Windows silently drops and which fullscreen games like GTA bypass via raw
input — the reason push-to-talk used to die in-game), the engine POLLS the
asynchronous key state through the Win32 GetAsyncKeyState API every 20 ms.
Polling needs no hooks, sees keyboard AND mouse buttons, keeps working
regardless of which window has focus, and cannot be disconnected.

Built-in actions (wired up by wivexx_api.py):
    toggle_mic        — mute/unmute the microphone
    toggle_voice      — deafen: mute WIVEXX's voice only (mic stays live)
    toggle_pause      — pause everything (voice + mic)
    push_to_talk      — hold-to-talk (press handler; release is polled)
    toggle_call_mode  — wake word off, push-to-talk only

Public API (unchanged from v1.6):
    init(actions, run_command=None, logger=None) -> bool
    apply() / get_bindings() / set_binding(action, keys) / reset_defaults()
    get_customs() / add_custom(name, keys, command) / remove_custom(index)
    capture_combo(timeout=10.0) -> Optional[str]   # also captures mouse keys
    is_pressed(action) -> bool
    available() -> bool
"""

import ctypes
import json
import os
import threading
import time
from typing import Callable, Optional

ACTION_LABELS: dict[str, str] = {
    "toggle_mic":       "Mute / unmute microphone",
    "toggle_voice":     "Deafen (mute WIVEXX voice only)",
    "toggle_pause":     "Pause everything (voice + mic)",
    "push_to_talk":     "Push-to-talk (hold)",
    "toggle_call_mode": "Toggle call mode (push-to-talk only)",
    "toggle_goals_overlay": "Show / hide the goals overlay",
    "toggle_timers":    "Show / hide the timers on screen",
    "lightroom_import": "Import newest Downloads folder into Lightroom",
    "toggle_recording": "Start / stop action recording",
    "play_active":      "Play (or stop) the active recording",
    "eyedropper":       "Eyedropper — copy the pixel colour under the cursor",
    "toggle_overlay":   "Show / hide the performance overlay",
}

DEFAULT_BINDINGS: dict[str, str] = {
    "toggle_mic":       "ctrl+shift+m",
    "toggle_voice":     "ctrl+shift+d",
    "toggle_pause":     "ctrl+shift+v",
    "push_to_talk":     "ctrl+space",
    "toggle_call_mode": "ctrl+shift+c",
    "toggle_goals_overlay": "ctrl+shift+g",
    "toggle_timers":    "ctrl+shift+t",
    # UNBOUND by default: ctrl+shift+l is Lightroom Classic's own "Lights Out"
    # toggle, so a GLOBAL hotkey there hijacked it and fired a Downloads→Lightroom
    # import every time you used Lights Out while culling. Trigger the import by
    # voice ("import to Lightroom") instead, or bind a free key via ⌨ Shortcuts.
    "lightroom_import": "",
    # Start/stop the keyboard+mouse action recorder from anywhere — handy when a
    # fullscreen game has the screen and the control-room panel can't be clicked.
    "toggle_recording": "ctrl+shift+r",
    # Trigger a chosen saved recording from anywhere (set the "active" one + a key in
    # the action-recorder menu). Unbound by default so it never clashes with a game.
    "play_active":      "",
    # Hover over any colour anywhere on screen, press this → its hex is copied.
    "eyedropper":       "ctrl+alt+c",
    # v1.34: the transparent performance overlay, from anywhere (also in games).
    "toggle_overlay":   "ctrl+shift+o",
}

_POLL_INTERVAL    = 0.02   # engine poll period (20 ms)
_CAPTURE_INTERVAL = 0.01   # capture poll period (10 ms)

_HOTKEYS_FILE: str = os.path.join(
    os.path.dirname(os.path.abspath(__file__)), "wivexx_hotkeys.json"
)

# ══════════════════════════════════════════════════════════════════════════════
#  Virtual-key tables
#  Names map to one VK code, or a tuple of alternatives (any of them counts —
#  e.g. "windows" is either the left or the right Win key).
# ══════════════════════════════════════════════════════════════════════════════
_NAME_TO_VK: dict[str, tuple] = {
    # modifiers (generic VKs report either side)
    "ctrl":     (0x11,),
    "shift":    (0x10,),
    "alt":      (0x12,),
    "windows":  (0x5B, 0x5C),
    # mouse buttons — left/right click are deliberately NOT bindable
    "mouse3":   (0x04,),   # middle
    "mouse4":   (0x05,),   # side back  (XBUTTON1)
    "mouse5":   (0x06,),   # side front (XBUTTON2)
    # common keys
    "space": (0x20,), "tab": (0x09,), "enter": (0x0D,), "esc": (0x1B,),
    "backspace": (0x08,), "capslock": (0x14,), "printscreen": (0x2C,),
    "insert": (0x2D,), "delete": (0x2E,), "home": (0x24,), "end": (0x23,),
    "pageup": (0x21,), "pagedown": (0x22,),
    "up": (0x26,), "down": (0x28,), "left": (0x25,), "right": (0x27,),
    "numlock": (0x90,), "scrolllock": (0x91,), "pause": (0x13,),
    # OEM punctuation (US layout)
    "semicolon": (0xBA,), "plus": (0xBB,), "comma": (0xBC,), "minus": (0xBD,),
    "period": (0xBE,), "slash": (0xBF,), "backquote": (0xC0,),
    "lbracket": (0xDB,), "backslash": (0xDC,), "rbracket": (0xDD,),
    "quote": (0xDE,),
    # numpad
    "num*": (0x6A,), "num+": (0x6B,), "num-": (0x6D,), "num.": (0x6E,),
    "num/": (0x6F,),
}
for _i in range(10):                      # 0-9
    _NAME_TO_VK[str(_i)] = (0x30 + _i,)
    _NAME_TO_VK[f"num{_i}"] = (0x60 + _i,)
for _i in range(26):                      # a-z
    _NAME_TO_VK[chr(ord("a") + _i)] = (0x41 + _i,)
for _i in range(24):                      # f1-f24
    _NAME_TO_VK[f"f{_i + 1}"] = (0x70 + _i,)

# aliases accepted when parsing / capturing
_NAME_ALIASES: dict[str, str] = {
    "control": "ctrl", "escape": "esc", "return": "enter", "win": "windows",
    "cmd": "windows", "super": "windows", "caps lock": "capslock",
    "page up": "pageup", "page down": "pagedown", "print screen": "printscreen",
    "middle": "mouse3", "mouse-middle": "mouse3", "middle mouse": "mouse3",
    "x1": "mouse4", "xbutton1": "mouse4", "mouse back": "mouse4",
    "x2": "mouse5", "xbutton2": "mouse5", "mouse forward": "mouse5",
    "spacebar": "space", "del": "delete", "ins": "insert",
}

# Preferred display name per VK (built from the table; modifiers pinned)
_VK_TO_NAME: dict[int, str] = {}
for _n, _vks in _NAME_TO_VK.items():
    for _vk in _vks:
        _VK_TO_NAME.setdefault(_vk, _n)

# VKs scanned during capture_combo: everything above EXCEPT left/right click
# (binding plain clicks would make the mouse unusable).
_SCAN_VKS: list[int] = sorted(_VK_TO_NAME.keys())

_MOD_ORDER = ["ctrl", "shift", "alt", "windows"]


def _vk_down(vk: int) -> bool:
    """True if the key/button is physically held right now (async state)."""
    try:
        return bool(ctypes.windll.user32.GetAsyncKeyState(vk) & 0x8000)
    except Exception:
        return False


def _canon_key(name: str) -> str:
    n = name.lower().strip()
    for side in ("left ", "right "):
        if n.startswith(side):
            n = n[len(side):]
    return _NAME_ALIASES.get(n, n)


def _parse_combo(combo: str) -> Optional[list[tuple]]:
    """'ctrl+mouse4' → [(0x11,), (0x05,)].  None if any part is unknown."""
    groups: list[tuple] = []
    for part in combo.lower().split("+"):
        key = _canon_key(part)
        vks = _NAME_TO_VK.get(key)
        if vks is None:
            if key.startswith("vk") and key[2:].isdigit():     # raw escape hatch
                vks = (int(key[2:]),)
            else:
                return None
        groups.append(vks)
    return groups or None


def _combo_down(groups: list[tuple]) -> bool:
    return all(any(_vk_down(vk) for vk in grp) for grp in groups)


# ══════════════════════════════════════════════════════════════════════════════
#  Manager state
# ══════════════════════════════════════════════════════════════════════════════
_lock = threading.Lock()
_logger = None
_actions: dict[str, Callable] = {}
_run_command: Optional[Callable[[str], None]] = None
_bindings: dict[str, str] = dict(DEFAULT_BINDINGS)
_customs: list[dict] = []
_available = False
_capture_active = False
_engine_started = False

# compiled bindings: list of [groups, fire_fn, fire_inline, was_down]
_compiled: list[list] = []

_dispatch_q: list = []
_dispatch_evt = threading.Event()


def available() -> bool:
    return _available


# ── Persistence ────────────────────────────────────────────────────────────────
def _load_file() -> None:
    global _bindings, _customs
    try:
        if os.path.exists(_HOTKEYS_FILE):
            with open(_HOTKEYS_FILE, encoding="utf-8") as f:
                data = json.load(f)
            merged = dict(DEFAULT_BINDINGS)
            merged.update({k: v for k, v in data.get("bindings", {}).items()
                           if isinstance(v, str) and v.strip()})
            _bindings = merged
            _customs = [c for c in data.get("custom", [])
                        if isinstance(c, dict) and c.get("keys") and c.get("command")]
    except Exception as e:
        if _logger:
            _logger.warning("hotkeys: could not load %s: %s", _HOTKEYS_FILE, e)


def _save_file() -> None:
    try:
        with open(_HOTKEYS_FILE, "w", encoding="utf-8") as f:
            json.dump({"bindings": _bindings, "custom": _customs}, f, indent=2)
    except Exception as e:
        if _logger:
            _logger.warning("hotkeys: could not save %s: %s", _HOTKEYS_FILE, e)


# ── Dispatch worker — keeps the poll loop instant ──────────────────────────────
def _dispatch_worker() -> None:
    while True:
        _dispatch_evt.wait()
        _dispatch_evt.clear()
        while _dispatch_q:
            fn = _dispatch_q.pop(0)
            try:
                fn()
            except Exception as e:
                if _logger:
                    _logger.warning("hotkeys: action failed: %s", e)


def _enqueue(fn: Callable) -> None:
    _dispatch_q.append(fn)
    _dispatch_evt.set()


# ── Compilation ────────────────────────────────────────────────────────────────
def apply() -> None:
    """Recompile all bindings (thread-safe). Safe to call any time."""
    global _compiled
    new: list[list] = []
    for action, combo in _bindings.items():
        cb = _actions.get(action)
        if not cb or not combo:
            continue
        groups = _parse_combo(combo)
        if groups is None:
            if _logger:
                _logger.warning("hotkeys: unknown key in '%s' for %s — skipped",
                                combo, action)
            continue
        # push_to_talk must fire the instant the combo lands — no queue
        inline = (action == "push_to_talk")
        new.append([groups, cb, inline, False])

    for custom in _customs:
        combo   = custom.get("keys", "")
        command = custom.get("command", "")
        if not combo or not command or _run_command is None:
            continue
        groups = _parse_combo(combo)
        if groups is None:
            if _logger:
                _logger.warning("hotkeys: unknown key in custom '%s' — skipped", combo)
            continue
        new.append([groups, (lambda c=command: _run_command(c)), False, False])

    with _lock:
        _compiled = new


# ── Poll engine ────────────────────────────────────────────────────────────────
def _engine_loop() -> None:
    """Edge-detects every compiled combo via GetAsyncKeyState. No hooks, no
    watchdog needed — this cannot be disconnected by Windows or by games."""
    while True:
        time.sleep(_POLL_INTERVAL)
        if _capture_active:
            continue
        try:
            with _lock:
                bindings = list(_compiled)
            for entry in bindings:
                groups, fire, inline, was_down = entry
                down = _combo_down(groups)
                if down and not was_down:
                    if inline:
                        try:
                            fire()
                        except Exception as e:
                            if _logger:
                                _logger.warning("hotkeys: inline action failed: %s", e)
                    else:
                        _enqueue(fire)
                entry[3] = down
        except Exception as e:
            if _logger:
                _logger.debug("hotkeys: engine tick failed: %s", e)


# ── Public API ─────────────────────────────────────────────────────────────────
def init(actions: dict[str, Callable],
         run_command: Optional[Callable[[str], None]] = None,
         logger=None) -> bool:
    """Wire actions and start the polling engine. Windows-only (ctypes)."""
    global _actions, _run_command, _logger, _available, _engine_started
    _logger = logger
    _actions = dict(actions)
    _run_command = run_command

    if os.name != "nt":
        _available = False
        return False
    try:
        ctypes.windll.user32.GetAsyncKeyState(0x10)
    except Exception as e:
        _available = False
        if logger:
            logger.warning("hotkeys: GetAsyncKeyState unavailable: %s", e)
        return False

    _available = True
    _load_file()
    _save_file()   # write defaults on first run so users can find the file
    apply()

    if not _engine_started:
        _engine_started = True
        threading.Thread(target=_dispatch_worker, daemon=True,
                         name="wivexx-hotkey-dispatch").start()
        threading.Thread(target=_engine_loop, daemon=True,
                         name="wivexx-hotkey-engine").start()
    return True


def get_bindings() -> dict[str, str]:
    return dict(_bindings)


def set_binding(action: str, keys: str) -> None:
    if action not in ACTION_LABELS:
        return
    _bindings[action] = keys.strip().lower()
    _save_file()
    apply()


def reset_defaults() -> None:
    global _bindings
    _bindings = dict(DEFAULT_BINDINGS)
    _save_file()
    apply()


def get_customs() -> list[dict]:
    return [dict(c) for c in _customs]


def add_custom(name: str, keys: str, command: str) -> None:
    _customs.append({"name": name.strip() or command,
                     "keys": keys.strip().lower(),
                     "command": command.strip()})
    _save_file()
    apply()


def remove_custom(index: int) -> None:
    if 0 <= index < len(_customs):
        _customs.pop(index)
        _save_file()
        apply()


def is_pressed(action: str) -> bool:
    """True while the combo bound to *action* is held down (used for PTT).
    Works for keyboard keys and mouse buttons alike."""
    if not _available:
        return False
    groups = _parse_combo(_bindings.get(action, ""))
    if not groups:
        return False
    return _combo_down(groups)


# ── Public helpers for sibling modules (e.g. the auto-clicker) ──────────────────
def parse_combo(combo: str):
    """Public: 'ctrl+mouse4' → list of VK-groups, or None if unparseable."""
    return _parse_combo(combo or "")


def combo_down(groups) -> bool:
    """Public: True while every group in *groups* (from parse_combo) is held."""
    try:
        return bool(groups) and _combo_down(groups)
    except Exception:
        return False


def vk_for(name: str) -> Optional[int]:
    """Public: primary virtual-key code for a key name (e.g. 'space'→0x20),
    or None if unknown.  Used to synthesise key presses."""
    vks = _NAME_TO_VK.get(_canon_key(name or ""))
    return vks[0] if vks else None


def capturing() -> bool:
    """True while capture_combo() is actively listening (the engine is paused)."""
    return _capture_active


def capture_combo(timeout: float = 10.0) -> Optional[str]:
    """Block until the user presses (and releases) a key/button combination.
    Captures keyboard keys AND mouse buttons (side/middle — not plain clicks).
    Returns e.g. "ctrl+mouse4" or "shift+f9"; None on timeout.
    Run from a background thread — never from the GUI thread."""
    global _capture_active
    if not _available:
        return None

    _capture_active = True
    try:
        deadline = time.time() + timeout

        # Phase 0: wait for everything to be released first — the click that
        # pressed the "Rebind" button must not leak into the capture.
        while time.time() < deadline:
            if not any(_vk_down(vk) for vk in _SCAN_VKS):
                break
            time.sleep(_CAPTURE_INTERVAL)

        # Phase 1: accumulate everything pressed until full release
        captured: list[int] = []
        while time.time() < deadline:
            down_now = [vk for vk in _SCAN_VKS if _vk_down(vk)]
            for vk in down_now:
                if vk not in captured:
                    captured.append(vk)
            if captured and not down_now:
                break
            time.sleep(_CAPTURE_INTERVAL)
    finally:
        _capture_active = False

    if not captured:
        return None
    names: list[str] = []
    for vk in captured:
        n = _VK_TO_NAME.get(vk, f"vk{vk}")
        if n not in names:
            names.append(n)
    mods   = [n for n in _MOD_ORDER if n in names]
    others = [n for n in names if n not in _MOD_ORDER]
    return "+".join(mods + others) if (mods or others) else None
