"""
wivexx_macro.py — Keyboard + mouse action recorder / looping player.  (v1.0)
═══════════════════════════════════════════════════════════════════════════════
Record everything you do — every key, every click, mouse movement and scroll, with
real timing — then replay it once or on a loop until you stop it.

RECORDING uses pynput's global listeners (passive — your input still works while
recording).  PLAYBACK synthesises the events back through Win32 SendInput (absolute
mouse moves so clicks land where you recorded them, scancode keystrokes for game
compatibility).  A panic key (default ESC) aborts a runaway loop instantly.

Recordings persist (named) to wivexx_macros.json next to this file, so you can keep
a library of macros and replay any of them later.

Public API
----------
    start(logger=None) -> bool
    available() -> bool
    status() -> dict                          # {state, name, events, elapsed, loops_done, total_loops}
    start_recording(mode="", name="") -> dict # name = save the take under this (set before starting)
    set_pending_name(name="") -> dict         # name the NEXT take ahead of a shortcut-driven start
    stop_recording(name="") -> dict           # saves; uses the pending name, else auto-names
    cancel_recording() -> dict
    list_recordings() -> list[dict]
    play(name, loops=0, speed=1.0) -> dict     # loops=0 → infinite until stopped
    stop_playback() -> dict
    delete_recording(name) -> dict
    rename_recording(old, new) -> dict
    set_panic_key(key) -> dict
    get_panic_key() -> str
"""

import ctypes
import json
import os
import threading
import time
import uuid
from ctypes import wintypes
from typing import Optional

# ── Tunables ────────────────────────────────────────────────────────────────────
_MOVE_MIN_DT = 0.012        # throttle recorded mouse-moves (~80/s max)
_MAX_EVENTS  = 200_000      # safety cap per recording
_DEFAULT_PANIC = "esc"

_CONFIG_FILE = os.path.join(
    os.path.dirname(os.path.abspath(__file__)), "wivexx_macros.json"
)

# ── Win32 SendInput plumbing (64-bit safe) ──────────────────────────────────────
_INPUT_MOUSE, _INPUT_KEYBOARD = 0, 1
_ME_MOVE, _ME_ABSOLUTE, _ME_VIRTUALDESK = 0x0001, 0x8000, 0x4000
_ME_LEFTDOWN, _ME_LEFTUP     = 0x0002, 0x0004
_ME_RIGHTDOWN, _ME_RIGHTUP   = 0x0008, 0x0010
_ME_MIDDLEDOWN, _ME_MIDDLEUP = 0x0020, 0x0040
_ME_WHEEL, _ME_HWHEEL        = 0x0800, 0x1000
_KE_EXTENDED, _KE_KEYUP, _KE_SCANCODE = 0x0001, 0x0002, 0x0008
_MAPVK_VK_TO_VSC = 0
_WHEEL_DELTA = 120

_SM_XVIRTUALSCREEN, _SM_YVIRTUALSCREEN = 76, 77
_SM_CXVIRTUALSCREEN, _SM_CYVIRTUALSCREEN = 78, 79

_BTN_FLAGS = {
    "left":   (_ME_LEFTDOWN,   _ME_LEFTUP),
    "right":  (_ME_RIGHTDOWN,  _ME_RIGHTUP),
    "middle": (_ME_MIDDLEDOWN, _ME_MIDDLEUP),
}
_EXTENDED_VKS = {0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28,
                 0x2D, 0x2E, 0x2C, 0x90, 0x6F, 0xA3, 0xA5}


class _MOUSEINPUT(ctypes.Structure):
    _fields_ = [("dx", wintypes.LONG), ("dy", wintypes.LONG),
                ("mouseData", wintypes.DWORD), ("dwFlags", wintypes.DWORD),
                ("time", wintypes.DWORD), ("dwExtraInfo", ctypes.c_void_p)]


class _KEYBDINPUT(ctypes.Structure):
    _fields_ = [("wVk", wintypes.WORD), ("wScan", wintypes.WORD),
                ("dwFlags", wintypes.DWORD), ("time", wintypes.DWORD),
                ("dwExtraInfo", ctypes.c_void_p)]


class _INPUT_U(ctypes.Union):
    _fields_ = [("mi", _MOUSEINPUT), ("ki", _KEYBDINPUT)]


class _INPUT(ctypes.Structure):
    _fields_ = [("type", wintypes.DWORD), ("u", _INPUT_U)]


_user32 = None
try:
    if os.name == "nt":
        # PRIVATE WinDLL instance (not the shared ctypes.windll.user32): the
        # auto-clicker also sets SendInput.argtypes with its own _INPUT struct;
        # separate handles keep the two from clobbering each other's argtypes.
        _user32 = ctypes.WinDLL("user32")
        _user32.SendInput.argtypes = (wintypes.UINT, ctypes.POINTER(_INPUT), ctypes.c_int)
        _user32.SendInput.restype = wintypes.UINT
except Exception:
    _user32 = None


def _send(inp: _INPUT) -> None:
    if _user32 is not None:
        _user32.SendInput(1, ctypes.byref(inp), ctypes.sizeof(_INPUT))


def _vk_down(vk: int) -> bool:
    try:
        return bool(_user32.GetAsyncKeyState(vk) & 0x8000)
    except Exception:
        return False


def _abs_xy(x: int, y: int):
    """Screen pixel → 0..65535 normalised over the whole virtual desktop."""
    try:
        gm = _user32.GetSystemMetrics
        vx, vy = gm(_SM_XVIRTUALSCREEN), gm(_SM_YVIRTUALSCREEN)
        vw, vh = gm(_SM_CXVIRTUALSCREEN), gm(_SM_CYVIRTUALSCREEN)
        nx = int((x - vx) * 65535 / max(1, vw - 1))
        ny = int((y - vy) * 65535 / max(1, vh - 1))
        return max(0, min(65535, nx)), max(0, min(65535, ny))
    except Exception:
        return 0, 0


def _do_move(x: int, y: int) -> None:
    nx, ny = _abs_xy(x, y)
    inp = _INPUT(type=_INPUT_MOUSE)
    inp.u.mi = _MOUSEINPUT(nx, ny, 0, _ME_MOVE | _ME_ABSOLUTE | _ME_VIRTUALDESK, 0, None)
    _send(inp)


def _do_click(button: str, pressed: bool) -> None:
    down, up = _BTN_FLAGS.get(button, _BTN_FLAGS["left"])
    inp = _INPUT(type=_INPUT_MOUSE)
    inp.u.mi = _MOUSEINPUT(0, 0, 0, down if pressed else up, 0, None)
    _send(inp)


def _do_scroll(dy: int) -> None:
    inp = _INPUT(type=_INPUT_MOUSE)
    inp.u.mi = _MOUSEINPUT(0, 0, dy * _WHEEL_DELTA, _ME_WHEEL, 0, None)
    _send(inp)


def _do_key(vk: int, pressed: bool) -> None:
    scan = 0
    try:
        scan = _user32.MapVirtualKeyW(vk, _MAPVK_VK_TO_VSC)
    except Exception:
        scan = 0
    up = 0 if pressed else _KE_KEYUP
    inp = _INPUT(type=_INPUT_KEYBOARD)
    if scan:
        ext = _KE_EXTENDED if vk in _EXTENDED_VKS else 0
        inp.u.ki = _KEYBDINPUT(0, scan, _KE_SCANCODE | ext | up, 0, None)
    else:
        inp.u.ki = _KEYBDINPUT(vk, 0, up, 0, None)
    _send(inp)


def _do_move_rel(dx: int, dy: int) -> None:
    """RELATIVE mouse move (no ABSOLUTE flag) — this is what drives mouse-look in
    fullscreen games (Minecraft etc.), which read raw/relative motion, not the
    cursor position. Scaled by _game_sensitivity: Windows runs injected relative
    motion through the pointer-speed/acceleration pipeline, so the raw deltas we
    recorded usually arrive SMALLER than the physical movement — the multiplier (and
    disabling acceleration during playback, see _push_no_accel) makes a recorded 360°
    spin replay as a full 360°. Sub-pixel remainder is carried so nothing is lost."""
    fx = dx * _game_sensitivity + _rel_rem[0]
    fy = dy * _game_sensitivity + _rel_rem[1]
    ix, iy = int(fx), int(fy)            # truncate toward zero; keep the fraction
    _rel_rem[0] = fx - ix
    _rel_rem[1] = fy - iy
    if ix or iy:
        inp = _INPUT(type=_INPUT_MOUSE)
        inp.u.mi = _MOUSEINPUT(ix, iy, 0, _ME_MOVE, 0, None)
        _send(inp)


# ── pointer acceleration override (so injected relative motion is LINEAR) ────────
# Windows' "Enhance pointer precision" applies a non-linear curve to mouse motion,
# which distorts replayed mouse-look. During playback we turn it off + normalise the
# pointer speed to 10 (the 1:1 setting), then restore the user's originals after.
_SPI_GETMOUSE, _SPI_SETMOUSE = 0x0003, 0x0004
_SPI_GETMOUSESPEED, _SPI_SETMOUSESPEED = 0x0070, 0x0071


def _push_no_accel():
    """Return a token to pass to _restore_accel(), or None if unavailable/failed."""
    if _user32 is None:
        return None
    try:
        _user32.SystemParametersInfoW.argtypes = (
            wintypes.UINT, wintypes.UINT, ctypes.c_void_p, wintypes.UINT)
        _user32.SystemParametersInfoW.restype = wintypes.BOOL
        accel = (ctypes.c_int * 3)()
        if not _user32.SystemParametersInfoW(_SPI_GETMOUSE, 0, ctypes.cast(accel, ctypes.c_void_p), 0):
            return None
        speed = ctypes.c_int(10)
        _user32.SystemParametersInfoW(_SPI_GETMOUSESPEED, 0, ctypes.byref(speed), 0)
        saved = ([accel[0], accel[1], accel[2]], int(speed.value))
        off = (ctypes.c_int * 3)(0, 0, 0)                      # thresholds + accel all off
        _user32.SystemParametersInfoW(_SPI_SETMOUSE, 0, ctypes.cast(off, ctypes.c_void_p), 0)
        _user32.SystemParametersInfoW(_SPI_SETMOUSESPEED, 0, ctypes.c_void_p(10), 0)
        return saved
    except Exception:
        return None


def _restore_accel(token) -> None:
    if _user32 is None or not token:
        return
    try:
        accel_vals, speed = token
        arr = (ctypes.c_int * 3)(*accel_vals)
        _user32.SystemParametersInfoW(_SPI_SETMOUSE, 0, ctypes.cast(arr, ctypes.c_void_p), 0)
        _user32.SystemParametersInfoW(_SPI_SETMOUSESPEED, 0, ctypes.c_void_p(int(speed)), 0)
    except Exception:
        pass


# ── cursor-lock detection (for AUTO capture mode) ────────────────────────────────
# A 3D/FPS game hides + clips the cursor while you mouse-look; the desktop and game
# menus show a free cursor. AUTO mode watches this so ONE recording can contain
# in-game look (recorded as raw RELATIVE deltas) AND desktop work like alt-tabbing
# through windows (recorded as ABSOLUTE cursor positions), switching on its own.
_CURSOR_SHOWING = 0x00000001


class _CURSORINFO(ctypes.Structure):
    _fields_ = [("cbSize", wintypes.DWORD), ("flags", wintypes.DWORD),
                ("hCursor", wintypes.HANDLE), ("ptScreenPos", wintypes.POINT)]


class _RECT(ctypes.Structure):
    _fields_ = [("left", wintypes.LONG), ("top", wintypes.LONG),
                ("right", wintypes.LONG), ("bottom", wintypes.LONG)]


def _cursor_locked() -> bool:
    """True when we look 'inside a game' — cursor HIDDEN, or CLIPPED to a sub-region
    of the virtual desktop. Either is the tell-tale of mouse-look capture; a free,
    visible cursor (desktop / menus) returns False."""
    if _user32 is None:
        return False
    try:
        ci = _CURSORINFO()
        ci.cbSize = ctypes.sizeof(_CURSORINFO)
        if _user32.GetCursorInfo(ctypes.byref(ci)) and not (ci.flags & _CURSOR_SHOWING):
            return True                              # cursor hidden → in-game look
        r = _RECT()
        if _user32.GetClipCursor(ctypes.byref(r)):
            gm = _user32.GetSystemMetrics
            vw, vh = gm(_SM_CXVIRTUALSCREEN), gm(_SM_CYVIRTUALSCREEN)
            cw, ch = r.right - r.left, r.bottom - r.top
            if vw and vh and (cw < vw * 0.6 or ch < vh * 0.6):
                return True                          # cursor confined to a region → locked
    except Exception:
        pass
    return False


# ══════════════════════════════════════════════════════════════════════════════
#  Raw Input (relative mouse) — captures TRUE physical motion even when a
#  fullscreen game has locked/​hidden the cursor.  A message-only window registers
#  for WM_INPUT mouse data on its own thread and reports relative deltas/buttons/
#  wheel.  This is the only reliable way to record mouse-look in games.
# ══════════════════════════════════════════════════════════════════════════════
_WM_INPUT = 0x00FF
_WM_QUIT = 0x0012
_RID_INPUT = 0x10000003
_RIDEV_INPUTSINK = 0x00000100
_RIDEV_REMOVE = 0x00000001
_RIM_TYPEMOUSE = 0
_HWND_MESSAGE = wintypes.HWND(-3)
_RI_BTNS = ((0x0001, 0x0002, "left"), (0x0004, 0x0008, "right"),
            (0x0010, 0x0020, "middle"))
_RI_WHEEL = 0x0400

_LRESULT = ctypes.c_ssize_t
_WPARAM = ctypes.c_size_t
_LPARAM = ctypes.c_ssize_t
_WNDPROCTYPE = ctypes.WINFUNCTYPE(_LRESULT, wintypes.HWND, wintypes.UINT, _WPARAM, _LPARAM)

_kernel32 = None
if os.name == "nt":
    try:
        _kernel32 = ctypes.windll.kernel32
    except Exception:
        _kernel32 = None


class _WNDCLASS(ctypes.Structure):
    _fields_ = [("style", wintypes.UINT), ("lpfnWndProc", _WNDPROCTYPE),
                ("cbClsExtra", ctypes.c_int), ("cbWndExtra", ctypes.c_int),
                ("hInstance", wintypes.HINSTANCE), ("hIcon", wintypes.HICON),
                ("hCursor", wintypes.HANDLE), ("hbrBackground", wintypes.HBRUSH),
                ("lpszMenuName", wintypes.LPCWSTR), ("lpszClassName", wintypes.LPCWSTR)]


class _RAWINPUTDEVICE(ctypes.Structure):
    _fields_ = [("usUsagePage", wintypes.USHORT), ("usUsage", wintypes.USHORT),
                ("dwFlags", wintypes.DWORD), ("hwndTarget", wintypes.HWND)]


class _RAWINPUTHEADER(ctypes.Structure):
    _fields_ = [("dwType", wintypes.DWORD), ("dwSize", wintypes.DWORD),
                ("hDevice", wintypes.HANDLE), ("wParam", _WPARAM)]


class _RAWMOUSE(ctypes.Structure):
    # NOTE the _pad: the button union is 4-byte aligned, so there are 2 bytes of
    # padding after usFlags — without it lLastX/lLastY land at the wrong offset.
    _fields_ = [("usFlags", wintypes.USHORT), ("_pad", wintypes.USHORT),
                ("usButtonFlags", wintypes.USHORT), ("usButtonData", wintypes.USHORT),
                ("ulRawButtons", wintypes.ULONG),
                ("lLastX", wintypes.LONG), ("lLastY", wintypes.LONG),
                ("ulExtraInformation", wintypes.ULONG)]


class _RAWINPUT(ctypes.Structure):
    _fields_ = [("header", _RAWINPUTHEADER), ("mouse", _RAWMOUSE)]


_protos_ready = False


def _setup_win32() -> bool:
    """Set 64-bit-safe argtypes/restypes once. Without explicit argtypes, ctypes
    infers param types from the Python values and mis-marshals pointer-sized
    handles (HINSTANCE/HWND) → 'int too long to convert'."""
    global _protos_ready
    if _protos_ready:
        return True
    if _kernel32 is None or _user32 is None:
        return False
    try:
        _kernel32.GetModuleHandleW.restype = wintypes.HMODULE
        _kernel32.GetModuleHandleW.argtypes = (wintypes.LPCWSTR,)
        _kernel32.GetCurrentThreadId.restype = wintypes.DWORD
        _user32.RegisterClassW.restype = wintypes.ATOM
        _user32.RegisterClassW.argtypes = (ctypes.POINTER(_WNDCLASS),)
        _user32.CreateWindowExW.restype = wintypes.HWND
        _user32.CreateWindowExW.argtypes = (
            wintypes.DWORD, wintypes.LPCWSTR, wintypes.LPCWSTR, wintypes.DWORD,
            ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int,
            wintypes.HWND, wintypes.HMENU, wintypes.HINSTANCE, wintypes.LPVOID)
        _user32.DestroyWindow.restype = wintypes.BOOL
        _user32.DestroyWindow.argtypes = (wintypes.HWND,)
        _user32.DefWindowProcW.restype = _LRESULT
        _user32.DefWindowProcW.argtypes = (wintypes.HWND, wintypes.UINT, _WPARAM, _LPARAM)
        _user32.GetMessageW.restype = ctypes.c_int
        _user32.GetMessageW.argtypes = (wintypes.LPMSG, wintypes.HWND, wintypes.UINT, wintypes.UINT)
        _user32.TranslateMessage.argtypes = (wintypes.LPMSG,)
        _user32.DispatchMessageW.restype = _LRESULT
        _user32.DispatchMessageW.argtypes = (wintypes.LPMSG,)
        _user32.PostThreadMessageW.argtypes = (wintypes.DWORD, wintypes.UINT, _WPARAM, _LPARAM)
        _user32.RegisterRawInputDevices.restype = wintypes.BOOL
        _user32.RegisterRawInputDevices.argtypes = (
            ctypes.POINTER(_RAWINPUTDEVICE), wintypes.UINT, wintypes.UINT)
        _user32.GetRawInputData.restype = wintypes.UINT
        _user32.GetRawInputData.argtypes = (
            wintypes.HANDLE, wintypes.UINT, wintypes.LPVOID,
            ctypes.POINTER(wintypes.UINT), wintypes.UINT)
        _protos_ready = True
        return True
    except Exception:
        return False


class _RawMouse:
    """Relative-mouse capture via Win32 Raw Input on a private message thread."""

    def __init__(self, on_move, on_button, on_wheel):
        self.on_move, self.on_button, self.on_wheel = on_move, on_button, on_wheel
        self._hwnd = None
        self._thread = None
        self._thread_id = 0
        self._failed = False
        self._proc = None
        self._cls = "WivexxRawMouse"

    def _wndproc(self, hwnd, msg, wparam, lparam):
        if msg == _WM_INPUT:
            try:
                self._read(lparam)
            except Exception:
                pass
        return _user32.DefWindowProcW(hwnd, msg, wparam, lparam)

    def _read(self, lparam):
        buf = (ctypes.c_ubyte * 64)()
        size = wintypes.UINT(64)
        got = _user32.GetRawInputData(ctypes.c_void_p(lparam), _RID_INPUT, buf,
                                      ctypes.byref(size), ctypes.sizeof(_RAWINPUTHEADER))
        if got in (0, 0xFFFFFFFF):
            return
        ri = ctypes.cast(buf, ctypes.POINTER(_RAWINPUT)).contents
        if ri.header.dwType != _RIM_TYPEMOUSE:
            return
        m = ri.mouse
        if m.lLastX or m.lLastY:
            self.on_move(int(m.lLastX), int(m.lLastY))
        bf = m.usButtonFlags
        if bf:
            for down, up, name in _RI_BTNS:
                if bf & down:
                    self.on_button(name, True)
                if bf & up:
                    self.on_button(name, False)
            if bf & _RI_WHEEL:
                d = m.usButtonData
                if d > 32767:
                    d -= 65536
                self.on_wheel(1 if d > 0 else -1)

    def _run(self):
        hinst = _kernel32.GetModuleHandleW(None)
        self._proc = _WNDPROCTYPE(self._wndproc)        # keep a ref or it gets GC'd
        wc = _WNDCLASS()
        wc.lpfnWndProc = self._proc
        wc.hInstance = hinst
        wc.lpszClassName = self._cls
        try:
            _user32.RegisterClassW(ctypes.byref(wc))    # harmless if already there
        except Exception:
            pass
        self._hwnd = _user32.CreateWindowExW(0, self._cls, self._cls, 0, 0, 0, 0, 0,
                                             _HWND_MESSAGE, None, hinst, None)
        if not self._hwnd:
            self._failed = True
            return
        rid = _RAWINPUTDEVICE(0x01, 0x02, _RIDEV_INPUTSINK, self._hwnd)
        if not _user32.RegisterRawInputDevices(ctypes.byref(rid), 1,
                                               ctypes.sizeof(_RAWINPUTDEVICE)):
            self._failed = True
            try:
                _user32.DestroyWindow(self._hwnd)
            except Exception:
                pass
            self._hwnd = None
            return
        self._thread_id = _kernel32.GetCurrentThreadId()
        msg = wintypes.MSG()
        try:
            while _user32.GetMessageW(ctypes.byref(msg), None, 0, 0) > 0:
                _user32.TranslateMessage(ctypes.byref(msg))
                _user32.DispatchMessageW(ctypes.byref(msg))
        finally:
            try:
                rid_rm = _RAWINPUTDEVICE(0x01, 0x02, _RIDEV_REMOVE, None)
                _user32.RegisterRawInputDevices(ctypes.byref(rid_rm), 1,
                                                ctypes.sizeof(_RAWINPUTDEVICE))
                if self._hwnd:
                    _user32.DestroyWindow(self._hwnd)
            except Exception:
                pass
            self._hwnd = None

    def start(self) -> bool:
        if _kernel32 is None or not _setup_win32():
            return False
        self._failed = False
        self._thread = threading.Thread(target=self._run, daemon=True,
                                        name="wivexx-rawmouse")
        self._thread.start()
        for _ in range(60):                              # wait up to ~0.3s for the hwnd
            if self._hwnd or self._failed:
                break
            time.sleep(0.005)
        return bool(self._hwnd) and not self._failed

    def stop(self) -> None:
        try:
            if self._thread_id:
                _user32.PostThreadMessageW(self._thread_id, _WM_QUIT, 0, 0)
        except Exception:
            pass
        t = self._thread
        if t is not None:
            t.join(timeout=1.0)
        self._thread = None
        self._thread_id = 0


# ══════════════════════════════════════════════════════════════════════════════
#  Manager state
# ══════════════════════════════════════════════════════════════════════════════
_lock = threading.Lock()
_logger = None
_available = False
_mascot = None             # optional callback(name) — drives the orb's recording/playback pose


def _set_mascot(name: str) -> None:
    if _mascot:
        try:
            _mascot(name)
        except Exception:
            pass

_state = "idle"            # idle | recording | playing
_recordings: dict = {}     # name -> {name, created, duration, mouse_mode, events:[...]}
_panic_key = _DEFAULT_PANIC
_mouse_mode = "auto"       # last-used capture: "auto" (switches) | "desktop" (abs) | "game" (rel)
_game_sensitivity = 1.0    # playback scale for in-game (relative) mouse-look — crank up if a
                           # recorded 360° spin replays as a tiny turn (SendInput relative motion
                           # is scaled by Windows' pointer pipeline, so it often needs >1×)
_active_name = ""          # the "active" recording a bound quick-play shortcut triggers
_rel_rem = [0.0, 0.0]      # carried fractional remainder so sub-pixel scaled deltas aren't lost

# recording runtime
_rec_events: list = []
_rec_start = 0.0
_rec_last_move = 0.0
_rec_mode = "auto"
_kb_listener = None
_ms_listener = None
_raw = None                # _RawMouse instance while recording in game / auto mode
_raw_acc = [0, 0]          # accumulated raw dx/dy between flushes
_raw_last_flush = 0.0
_pending_name = ""         # name chosen BEFORE recording → used when the take is saved
_locked = False            # AUTO mode: is the cursor currently game-locked (→ relative)?
_lock_thread = None        # background poller updating _locked while recording in auto
_lock_stop = None          # threading.Event to stop the poller

# playback runtime
_play_stop = threading.Event()
_play_name = ""
_play_loops = 0           # 0 = infinite
_play_done = 0
_play_thread = None


def available() -> bool:
    return _available


# ── Persistence ─────────────────────────────────────────────────────────────────
def _load_file() -> None:
    global _recordings, _panic_key, _mouse_mode, _game_sensitivity, _active_name
    try:
        if not os.path.exists(_CONFIG_FILE):
            return
        with open(_CONFIG_FILE, encoding="utf-8") as f:
            data = json.load(f)
        recs = {}
        for r in data.get("recordings", []):
            if isinstance(r, dict) and r.get("name") and isinstance(r.get("events"), list):
                recs[r["name"]] = {
                    "name": r["name"],
                    "created": r.get("created", ""),
                    "duration": float(r.get("duration", 0) or 0),
                    "mouse_mode": r.get("mouse_mode", "desktop"),
                    "events": r["events"],
                }
        _recordings = recs
        pk = str(data.get("panic_key", _DEFAULT_PANIC)).strip().lower()
        _panic_key = pk or _DEFAULT_PANIC
        mm = str(data.get("mouse_mode", "auto")).strip().lower()
        _mouse_mode = mm if mm in ("desktop", "game", "auto") else "auto"
        try:
            _game_sensitivity = max(0.1, min(20.0, float(data.get("game_sensitivity", 1.0) or 1.0)))
        except Exception:
            _game_sensitivity = 1.0
        _active_name = str(data.get("active_name", "") or "")
    except Exception as e:
        if _logger:
            _logger.warning("macro: could not load %s: %s", _CONFIG_FILE, e)


def _save_file() -> None:
    try:
        with open(_CONFIG_FILE, "w", encoding="utf-8") as f:
            json.dump({"panic_key": _panic_key, "mouse_mode": _mouse_mode,
                       "game_sensitivity": _game_sensitivity, "active_name": _active_name,
                       "recordings": list(_recordings.values())}, f)
    except Exception as e:
        if _logger:
            _logger.warning("macro: could not save %s: %s", _CONFIG_FILE, e)


# ── pynput key → virtual-key code ────────────────────────────────────────────────
def _key_to_vk(key) -> Optional[int]:
    try:
        vk = getattr(key, "vk", None)
        if vk is not None:
            return int(vk)
        val = getattr(key, "value", None)        # special Key.* → wraps a KeyCode
        if val is not None and getattr(val, "vk", None) is not None:
            return int(val.vk)
    except Exception:
        pass
    return None


# ── Recording ─────────────────────────────────────────────────────────────────
def _rec_append(ev: dict) -> None:
    if len(_rec_events) >= _MAX_EVENTS:
        return
    ev["t"] = round(time.perf_counter() - _rec_start, 4)
    _rec_events.append(ev)


def _on_press(key):
    vk = _key_to_vk(key)
    if vk is not None:
        _rec_append({"e": "kd", "vk": vk})


def _on_release(key):
    vk = _key_to_vk(key)
    if vk is not None:
        _rec_append({"e": "ku", "vk": vk})


def _on_move(x, y):
    global _rec_last_move
    if _rec_mode == "auto" and _locked:
        return                       # in-game look → the raw RELATIVE stream handles it
    now = time.perf_counter()
    if now - _rec_last_move < _MOVE_MIN_DT:
        return
    _rec_last_move = now
    _rec_append({"e": "mm", "x": int(x), "y": int(y)})


def _on_click(x, y, button, pressed):
    name = getattr(button, "name", "left")
    if _rec_mode == "auto" and _locked:
        _rec_append({"e": "mc", "b": name, "p": bool(pressed)})          # fire in place
    else:
        _rec_append({"e": "mc", "x": int(x), "y": int(y), "b": name, "p": bool(pressed)})


def _on_scroll(x, y, dx, dy):
    if _rec_mode == "auto" and _locked:
        _rec_append({"e": "ms", "dy": int(dy)})
    else:
        _rec_append({"e": "ms", "x": int(x), "y": int(y), "dy": int(dy)})


# ── game-mode (raw / relative) recording callbacks ──────────────────────────────
def _raw_on_move(dx, dy):
    """Accumulate raw deltas; flush a summed relative-move event on a throttle so
    the path is preserved without recording thousands of micro-events. In AUTO mode
    only the IN-GAME (cursor-locked) deltas are kept — out-of-game movement is logged
    as absolute positions by the pynput listener, so dropping raw here avoids doubles."""
    global _raw_last_flush
    if _rec_mode == "auto" and not _locked:
        _raw_acc[0] = _raw_acc[1] = 0            # desktop region → discard raw deltas
        return
    _raw_acc[0] += dx
    _raw_acc[1] += dy
    now = time.perf_counter()
    if now - _raw_last_flush >= _MOVE_MIN_DT and (_raw_acc[0] or _raw_acc[1]):
        _raw_last_flush = now
        _rec_append({"e": "mr", "dx": _raw_acc[0], "dy": _raw_acc[1]})
        _raw_acc[0] = _raw_acc[1] = 0


def _raw_on_button(name, pressed):
    if _rec_mode == "auto":
        return                                   # pynput's on_click covers buttons in auto
    _rec_append({"e": "mc", "b": name, "p": bool(pressed)})   # no position → fires in place


def _raw_on_wheel(notches):
    if _rec_mode == "auto":
        return                                   # pynput's on_scroll covers the wheel in auto
    _rec_append({"e": "ms", "dy": int(notches)})


def _lock_poll_loop(stop_ev) -> None:
    """While recording in AUTO mode, keep _locked current (~16×/s) so the move
    callbacks know whether to record relative (in-game) or absolute (desktop)."""
    global _locked
    while not stop_ev.is_set():
        try:
            _locked = _cursor_locked()
        except Exception:
            _locked = False
        stop_ev.wait(0.06)


def start_recording(mode: str = "", name: str = "") -> dict:
    """Begin capturing. mode="auto" (default) → records absolute cursor on the desktop
    AND raw RELATIVE mouse-look while a game has the cursor locked, switching on its
    own so ONE take can span in-game movement and out-of-game tab/window work.
    mode="desktop" → absolute only; mode="game" → raw relative only. Blank reuses the
    last-used mode. `name` (optional) sets what the take saves as — naming it BEFORE
    starting, the only way to name a take you start/stop from in-game via the shortcut.
    Blank keeps any pending name."""
    global _state, _rec_events, _rec_start, _rec_last_move, _rec_mode
    global _kb_listener, _ms_listener, _raw, _mouse_mode, _raw_last_flush, _pending_name
    global _locked, _lock_thread, _lock_stop
    if not _available:
        return {"ok": False, "error": "Recorder unavailable on this system."}
    nm = (name or "").strip()
    if nm:                                             # a blank start keeps the pending name
        _pending_name = nm
    # hardening: if a previous session left anything dangling, force it clean first
    # (this is what made it "work once then stop" — a stuck listener/state).
    if _state == "playing":
        return {"ok": False, "error": "Busy (playing)."}
    _stop_listeners()
    try:
        from pynput import keyboard as _kb, mouse as _ms
    except Exception as e:
        return {"ok": False, "error": f"pynput not installed: {e}"}
    mode = (mode or _mouse_mode or "auto").lower()
    if mode not in ("desktop", "game", "auto"):
        mode = "auto"
    _rec_events = []
    _rec_start = time.perf_counter()
    _rec_last_move = 0.0
    _raw_last_flush = 0.0
    _raw_acc[0] = _raw_acc[1] = 0
    _locked = False
    try:
        _kb_listener = _kb.Listener(on_press=_on_press, on_release=_on_release)
        _kb_listener.start()
        if mode in ("game", "auto"):                   # raw relative for mouse-look
            _raw = _RawMouse(_raw_on_move, _raw_on_button, _raw_on_wheel)
            if not _raw.start():                       # raw input unavailable → fall back
                _raw = None
                if mode == "game":
                    mode = "desktop"
                if _logger:
                    _logger.warning("macro: raw mouse unavailable — %s",
                                    "auto continues absolute-only" if mode == "auto" else "falling back to desktop")
        if mode in ("desktop", "auto"):                # pynput absolute cursor + clicks + scroll
            _ms_listener = _ms.Listener(on_move=_on_move, on_click=_on_click,
                                        on_scroll=_on_scroll)
            _ms_listener.start()
        if mode == "auto":                             # poll the cursor-lock state
            _lock_stop = threading.Event()
            _lock_thread = threading.Thread(target=_lock_poll_loop, args=(_lock_stop,),
                                            daemon=True, name="wivexx-macro-lock")
            _lock_thread.start()
    except Exception as e:
        _stop_listeners()
        return {"ok": False, "error": f"Could not start listeners: {e}"}
    _rec_mode = mode
    _mouse_mode = mode
    _state = "recording"
    _save_file()                                       # remember the last-used mode
    _set_mascot("recording")
    if _logger:
        _logger.info("macro: recording started (%s mode)", mode)
    return {"ok": True, "mode": mode}


def _stop_listeners() -> None:
    global _kb_listener, _ms_listener, _raw, _lock_thread, _lock_stop, _locked
    for lis in (_kb_listener, _ms_listener):
        try:
            if lis is not None:
                lis.stop()
        except Exception:
            pass
    _kb_listener = _ms_listener = None
    if _raw is not None:
        try:
            _raw.stop()
        except Exception:
            pass
        _raw = None
    if _lock_stop is not None:
        try:
            _lock_stop.set()
        except Exception:
            pass
    _lock_thread = None
    _lock_stop = None
    _locked = False


def set_pending_name(name: str = "") -> dict:
    """Set the name the NEXT recording will be saved under, BEFORE it starts. This is
    how you name a take you start/stop from in-game via the shortcut (you can't type a
    name after the fact). Consumed (and cleared) when a take is saved or cancelled."""
    global _pending_name
    _pending_name = (name or "").strip()
    return {"ok": True, "pending_name": _pending_name}


def cancel_recording() -> dict:
    global _state, _rec_events, _pending_name
    if _state != "recording":
        _stop_listeners()
        return {"ok": False, "error": "Not recording."}
    _stop_listeners()
    _rec_events = []
    _state = "idle"
    _pending_name = ""
    _set_mascot("")
    return {"ok": True}


def _auto_name() -> str:
    base = time.strftime("macro %b %d %H:%M")
    name, i = base, 2
    while name in _recordings:
        name = f"{base} ({i})"; i += 1
    return name


def _trim_tail_keys(events: list, secs: float = 0.45) -> list:
    """Drop trailing keyboard events within the last *secs* — they're the
    start/stop SHORTCUT being released (ctrl+shift+r etc.). Left in, playback
    would re-press them (and re-trigger the toggle) and could leave keys held."""
    if not events:
        return events
    last_t = events[-1].get("t", 0.0)
    cut = last_t - secs
    out = list(events)
    while out and out[-1].get("e") in ("kd", "ku") and out[-1].get("t", 0.0) >= cut:
        out.pop()
    return out


def stop_recording(name: str = "", trim_tail: bool = False) -> dict:
    global _state, _rec_events, _pending_name
    if _state != "recording":
        _stop_listeners()
        return {"ok": False, "error": "Not recording."}
    mode = _rec_mode
    _stop_listeners()
    events = list(_rec_events)
    _rec_events = []
    _state = "idle"
    _set_mascot("")
    if trim_tail:
        events = _trim_tail_keys(events)
    if not events:
        _pending_name = ""
        return {"ok": False, "error": "Nothing was recorded."}
    # explicit name (typed at stop) wins, else the name chosen before starting, else auto
    name = (name or "").strip() or _pending_name or _auto_name()
    _pending_name = ""
    duration = events[-1].get("t", 0.0)
    rec = {"name": name, "created": time.strftime("%Y-%m-%d %H:%M"),
           "duration": duration, "mouse_mode": mode, "events": events}
    _recordings[name] = rec
    _save_file()
    if _logger:
        _logger.info("macro: saved '%s' (%d events, %.1fs, %s mode)",
                     name, len(events), duration, mode)
    return {"ok": True, "name": name, "events": len(events),
            "duration": duration, "mode": mode}


def toggle(name: str = "", mode: str = "", trim_tail: bool = True) -> dict:
    """One call for a start/stop shortcut: stop+save if recording, else start.
    trim_tail strips the shortcut keys off the end when stopping. When STOPPING, the
    name set before starting (via the panel / set_pending_name) is used automatically."""
    if _state == "recording":
        return stop_recording(name, trim_tail=trim_tail)
    if _state == "playing":
        return {"ok": False, "error": "Busy (playing)."}
    return start_recording(mode=mode, name=name)


# ── Playback ─────────────────────────────────────────────────────────────────
def _dispatch(ev: dict) -> None:
    e = ev.get("e")
    if e == "mm":                                  # absolute move (desktop mode)
        _do_move(ev["x"], ev["y"])
    elif e == "mr":                                # relative move (game mode)
        _do_move_rel(ev.get("dx", 0), ev.get("dy", 0))
    elif e == "mc":                                # click — move to it first only if a position was recorded
        if "x" in ev and "y" in ev:
            _do_move(ev["x"], ev["y"])
        _do_click(ev.get("b", "left"), ev.get("p", True))
    elif e == "ms":
        if "x" in ev and "y" in ev:
            _do_move(ev["x"], ev["y"])
        _do_scroll(ev.get("dy", 0))
    elif e == "kd":
        _do_key(ev["vk"], True)
    elif e == "ku":
        _do_key(ev["vk"], False)


def _panic_groups():
    """Parse the panic binding into VK groups (supports ANY key or combo the user
    bound, e.g. 'f8', 'pause', 'ctrl+shift+q'). Falls back to ESC if unparseable."""
    try:
        import wivexx_hotkeys as hk
        return hk.parse_combo(_panic_key) or hk.parse_combo(_DEFAULT_PANIC)
    except Exception:
        return None


def _panic_held(groups) -> bool:
    """True while the whole panic binding is currently held down."""
    if not groups:
        return _vk_down(0x1B)               # ESC fallback if hotkeys unavailable
    try:
        import wivexx_hotkeys as hk
        return hk.combo_down(groups)
    except Exception:
        return False


def _panic_release_vks() -> list:
    """Primary VK of each group in the panic binding — lifted in _release_all so a
    held panic key/combo can never get stuck and block future playback."""
    groups = _panic_groups()
    vks = [g[0] for g in groups if g] if groups else []
    return vks or [0x1B]


def _wait_until(target: float, panic_groups, armed: list) -> bool:
    """Sleep until perf_counter()>=target. Return False to abort. The panic binding is
    only honoured once it's ARMED — i.e. it must be observed RELEASED at least once
    after playback starts. This way a panic key that's already stuck/held down (the
    bug that made every playback abort instantly) is ignored until a real, fresh
    press; the stop button (_play_stop) always aborts."""
    while True:
        remaining = target - time.perf_counter()
        if remaining <= 0:
            return True
        if _play_stop.is_set():
            return False
        if not _panic_held(panic_groups):
            armed[0] = True                 # released → now a fresh press counts
        elif armed[0]:
            return False                    # armed + down = the user hit panic
        time.sleep(min(remaining, 0.004))


def _play_loop(events: list, loops: int, speed: float) -> None:
    global _state, _play_done
    panic_groups = _panic_groups()
    # Arm immediately only if the panic binding is NOT already down; if it's stuck/held
    # at the start, stay disarmed until it's released so playback can still run.
    armed = [not _panic_held(panic_groups)]
    speed = max(0.1, min(10.0, speed))
    _play_done = 0
    aborted = False
    held_keys, held_btns = set(), set()      # track what the macro presses…
    _rel_rem[0] = _rel_rem[1] = 0.0          # fresh sub-pixel remainder for this run
    # turn OFF pointer acceleration for the duration if this take has in-game mouse-look,
    # so the scaled relative deltas replay linearly (restored in finally, even on abort)
    accel_token = _push_no_accel() if any(ev.get("e") == "mr" for ev in events) else None
    try:
        while not _play_stop.is_set():
            base = time.perf_counter()
            for ev in events:
                if not _wait_until(base + ev.get("t", 0.0) / speed, panic_groups, armed):
                    aborted = True
                    break
                e = ev.get("e")              # …so we can release exactly those at the end
                if e == "kd":
                    held_keys.add(ev.get("vk"))
                elif e == "ku":
                    held_keys.discard(ev.get("vk"))
                elif e == "mc":
                    (held_btns.add if ev.get("p", True) else held_btns.discard)(ev.get("b", "left"))
                try:
                    _dispatch(ev)
                except Exception:
                    pass
            if aborted:
                break
            _play_done += 1
            if loops and _play_done >= loops:
                break
    finally:
        # lift anything the macro left held (incl. on abort) so we never leak a
        # stuck key/button — that's what jammed the panic key in the first place.
        for vk in list(held_keys):
            try:
                _do_key(vk, False)
            except Exception:
                pass
        for b in list(held_btns):
            try:
                _do_click(b, False)
            except Exception:
                pass
        _release_all()
        _restore_accel(accel_token)          # always restore the user's pointer settings
        _state = "idle"
        _set_mascot("")
        if _logger:
            _logger.info("macro: playback finished (%d loop(s)%s)",
                         _play_done, ", aborted" if aborted else "")


def _release_all() -> None:
    """Safety: lift any buttons/keys a macro might have left held at the end."""
    for flag in (_ME_LEFTUP, _ME_RIGHTUP, _ME_MIDDLEUP):
        try:
            inp = _INPUT(type=_INPUT_MOUSE)
            inp.u.mi = _MOUSEINPUT(0, 0, 0, flag, 0, None)
            _send(inp)
        except Exception:
            pass
    # lift common modifiers + WASD + the panic binding in case a take/shortcut left
    # them down (a stuck panic key would block all future playback).
    vks = [0x11, 0x10, 0x12, 0x5B, 0x57, 0x41, 0x53, 0x44, 0x20]  # ctrl shift alt win w a s d space
    try:
        for pv in _panic_release_vks():
            if pv and pv not in vks:
                vks.append(pv)
    except Exception:
        pass
    for vk in vks:
        try:
            _do_key(vk, False)
        except Exception:
            pass


def play(name: str, loops: int = 0, speed: float = 1.0) -> dict:
    global _state, _play_stop, _play_name, _play_loops, _play_thread
    if not _available:
        return {"ok": False, "error": "Recorder unavailable."}
    if _state != "idle":
        return {"ok": False, "error": f"Busy ({_state})."}
    rec = _recordings.get(name)
    if not rec or not rec.get("events"):
        return {"ok": False, "error": "Recording not found."}
    try:
        loops = max(0, int(loops))
    except Exception:
        loops = 0
    _play_stop = threading.Event()
    _play_name = name
    _play_loops = loops
    _state = "playing"
    _play_thread = threading.Thread(
        target=_play_loop, args=(list(rec["events"]), loops, float(speed)),
        daemon=True, name="wivexx-macro-play")
    _play_thread.start()
    _set_mascot("playback")
    if _logger:
        _logger.info("macro: playing '%s' x%s", name, loops or "∞")
    return {"ok": True, "name": name, "loops": loops}


def stop_playback() -> dict:
    if _state != "playing":
        return {"ok": False, "error": "Not playing."}
    _play_stop.set()
    return {"ok": True}


# ── Library management ───────────────────────────────────────────────────────────
def list_recordings() -> list:
    out = []
    for r in _recordings.values():
        out.append({"name": r["name"], "created": r.get("created", ""),
                    "duration": round(r.get("duration", 0) or 0, 1),
                    "events": len(r.get("events", []))})
    out.sort(key=lambda x: x["created"], reverse=True)
    return out


def delete_recording(name: str) -> dict:
    if name in _recordings:
        _recordings.pop(name, None)
        _save_file()
        return {"ok": True}
    return {"ok": False, "error": "Not found."}


def rename_recording(old: str, new: str) -> dict:
    new = (new or "").strip()
    if old not in _recordings:
        return {"ok": False, "error": "Not found."}
    if not new:
        return {"ok": False, "error": "Name required."}
    if new in _recordings and new != old:
        return {"ok": False, "error": "Name already exists."}
    rec = _recordings.pop(old)
    rec["name"] = new
    _recordings[new] = rec
    _save_file()
    return {"ok": True}


def set_panic_key(key: str) -> dict:
    """Bind the panic abort to ANY key or combo (e.g. 'f8', 'pause', 'ctrl+shift+q')."""
    global _panic_key
    key = (key or "").strip().lower()
    try:
        import wivexx_hotkeys as hk
        if key and hk.parse_combo(key) is None:
            return {"ok": False, "error": f"Unknown key: {key}"}
    except Exception:
        pass
    _panic_key = key or _DEFAULT_PANIC
    _save_file()
    return {"ok": True, "panic_key": _panic_key}


def get_panic_key() -> str:
    return _panic_key


def set_game_sensitivity(mult) -> dict:
    """Scale in-game (relative) mouse-look on playback. 1.0 = as-recorded; raise it if a
    recorded 360° spin replays as a tiny turn (a common SendInput relative-motion issue)."""
    global _game_sensitivity
    try:
        _game_sensitivity = max(0.1, min(20.0, float(mult)))
    except Exception:
        return {"ok": False, "error": "bad value"}
    _save_file()
    return {"ok": True, "game_sensitivity": _game_sensitivity}


def get_game_sensitivity() -> float:
    return _game_sensitivity


def set_active(name: str = "") -> dict:
    """Designate the recording a bound quick-play shortcut will trigger (blank clears)."""
    global _active_name
    name = (name or "").strip()
    if name and name not in _recordings:
        return {"ok": False, "error": "Recording not found."}
    _active_name = name
    _save_file()
    return {"ok": True, "active_name": _active_name}


def get_active() -> str:
    return _active_name


def play_active(loops: int = 1, speed: float = 1.0) -> dict:
    """One call for a bound shortcut: STOP if already playing (toggle), else play the
    'active' recording. Lets you trigger a saved macro from anywhere — even in-game —
    without opening the panel."""
    if _state == "playing":
        return stop_playback()
    if _state == "recording":
        return {"ok": False, "error": "Busy (recording)."}
    if not _active_name or _active_name not in _recordings:
        return {"ok": False, "error": "No active recording set."}
    return play(_active_name, loops=loops, speed=speed)


def status() -> dict:
    elapsed = 0.0
    if _state == "recording":
        elapsed = round(time.perf_counter() - _rec_start, 1)
    return {
        "available": _available,
        "state": _state,
        "events": len(_rec_events) if _state == "recording" else 0,
        "elapsed": elapsed,
        "mode": _rec_mode if _state == "recording" else _mouse_mode,
        "play_name": _play_name if _state == "playing" else "",
        "loops_done": _play_done if _state == "playing" else 0,
        "total_loops": _play_loops if _state == "playing" else 0,
        "panic_key": _panic_key,
        "mouse_mode": _mouse_mode,
        "pending_name": _pending_name,
        "game_sensitivity": _game_sensitivity,
        "active_name": _active_name,
    }


def start(logger=None, mascot=None) -> bool:
    global _logger, _available, _mascot
    _logger = logger
    _mascot = mascot
    if os.name != "nt" or _user32 is None:
        _available = False
        return False
    try:
        import pynput  # noqa: F401
    except Exception as e:
        _available = False
        if logger:
            logger.warning("macro: pynput unavailable: %s", e)
        return False
    _available = True
    _load_file()
    return True
