"""
wivexx_autoclicker.py — Auto-clicker + multi-key auto-input (turbo) engine.  (v1.0)
═══════════════════════════════════════════════════════════════════════════════
Two related toys, one engine:

  • Auto-clicker  — rapidly clicks a chosen MOUSE button (left/right/middle) at a
    chosen clicks-per-second, toggled (or held) by a global shortcut.
  • Key "inputs"  — any number of independent auto-keyers.  Each rapidly presses
    (or single-presses) one KEYBOARD key at its own CPS, fired by its own global
    shortcut.  Add as many as you like, each with a different key + shortcut.

OUTPUT goes through the Win32 SendInput API (scancode-based for game compat).
TRIGGERS are polled through wivexx_hotkeys' combo parser (GetAsyncKeyState), so a
shortcut works no matter which window is focused — including fullscreen games —
exactly like the rest of Wivexx's hotkeys.

Config lives in wivexx_autoclicker.json next to this file and is editable from the
GUI (Settings → autoclicker & inputs).

Public API
----------
    start(announce=None, logger=None) -> bool       # init + start the engine
    available() -> bool
    get_config() -> dict                             # for the settings panel
    set_autoclicker(cps, button, hotkey, mode, enabled) -> dict
    add_input(name, key, cps, hotkey, mode, fire, enabled=True) -> dict
    update_input(input_id, **fields) -> dict
    delete_input(input_id) -> dict
    stop_all()                                       # panic: halt every active job
    KEY_CHOICES / BUTTON_CHOICES / MODE_CHOICES / FIRE_CHOICES
"""

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

# ── Tunables ────────────────────────────────────────────────────────────────────
_POLL_INTERVAL = 0.02     # trigger poll period (20 ms) — matches wivexx_hotkeys
CPS_MIN, CPS_MAX = 1, 200
# Hold the button/key DOWN this long before releasing. A zero-length press (down
# and up the same instant) is silently dropped by many browsers/games/CPS tests —
# they need a real, measurable press. Clamped below half the per-click interval so
# it never throttles the requested CPS noticeably.
_PRESS_HOLD = 0.018       # 18 ms — comfortably "real", still allows >25 cps cleanly

BUTTON_CHOICES = ["left", "right", "middle"]
MODE_CHOICES   = ["toggle", "hold"]    # toggle = press to start/stop · hold = while held
FIRE_CHOICES   = ["repeat", "single"]  # repeat = spam at CPS · single = one press per trigger

# Curated, friendly key names offered in the inputs dropdown (each must resolve to
# a single VK via wivexx_hotkeys.vk_for).  Mouse buttons are intentionally left to
# the auto-clicker, not the key inputs.
KEY_CHOICES = (
    [chr(ord("a") + i) for i in range(26)] +              # a-z
    [str(i) for i in range(10)] +                         # 0-9
    [f"f{i}" for i in range(1, 13)] +                     # f1-f12
    ["space", "enter", "tab", "esc", "backspace", "delete",
     "up", "down", "left", "right",
     "shift", "ctrl", "alt",
     "home", "end", "pageup", "pagedown", "insert",
     "minus", "plus", "comma", "period", "slash", "semicolon"]
)

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

# ── Win32 SendInput plumbing (64-bit safe) ──────────────────────────────────────
_INPUT_MOUSE    = 0
_INPUT_KEYBOARD = 1
_ME_LEFTDOWN, _ME_LEFTUP     = 0x0002, 0x0004
_ME_RIGHTDOWN, _ME_RIGHTUP   = 0x0008, 0x0010
_ME_MIDDLEDOWN, _ME_MIDDLEUP = 0x0020, 0x0040
_KE_EXTENDED = 0x0001
_KE_KEYUP    = 0x0002
_KE_SCANCODE = 0x0008
_MAPVK_VK_TO_VSC = 0

_BUTTON_FLAGS = {
    "left":   (_ME_LEFTDOWN,   _ME_LEFTUP),
    "right":  (_ME_RIGHTDOWN,  _ME_RIGHTUP),
    "middle": (_ME_MIDDLEDOWN, _ME_MIDDLEUP),
}

# VKs that need the EXTENDED flag so games/apps see the right (grey) key.
_EXTENDED_VKS = {
    0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28,   # pgup/pgdn/end/home/arrows
    0x2D, 0x2E, 0x2C, 0x90, 0x6F, 0xA3, 0xA5,         # ins/del/prtsc/numlock/div/rctrl/ralt
}


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
_winmm = None
try:
    if os.name == "nt":
        # IMPORTANT: a PRIVATE WinDLL instance, NOT the shared ctypes.windll.user32.
        # wivexx_macro.py also sets SendInput.argtypes with ITS own _INPUT class; on
        # the shared singleton the last import wins and the other module's SendInput
        # then fails an argtype check (silently → zero clicks). Separate handles =
        # independent argtypes, no collision.
        _user32 = ctypes.WinDLL("user32")
        _user32.SendInput.argtypes = (wintypes.UINT, ctypes.POINTER(_INPUT), ctypes.c_int)
        _user32.SendInput.restype = wintypes.UINT
        try:
            _winmm = ctypes.WinDLL("winmm")     # for timeBeginPeriod (accurate sleeps)
        except Exception:
            _winmm = None
except Exception:
    _user32 = None


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


def _mouse_flag(flag: int) -> None:
    inp = _INPUT(type=_INPUT_MOUSE)
    inp.u.mi = _MOUSEINPUT(0, 0, 0, flag, 0, None)
    _send(inp)


def _click(button: str, hold: float = _PRESS_HOLD) -> None:
    down, up = _BUTTON_FLAGS.get(button, _BUTTON_FLAGS["left"])
    # a 0-px MOVE first makes the window under the cursor the click target reliably
    _mouse_flag(0x0001)            # MOUSEEVENTF_MOVE (dx=dy=0)
    _mouse_flag(down)
    if hold > 0:
        time.sleep(hold)
    _mouse_flag(up)


# mouse-button VKs that should be sent as CLICKS, not keystrokes, inside a combo
_MOUSE_VK_FLAGS = {
    0x01: _BUTTON_FLAGS["left"], 0x02: _BUTTON_FLAGS["right"], 0x04: _BUTTON_FLAGS["middle"],
}


def _key_send(vk: int, down: bool) -> None:
    """Press or release one virtual key. Mouse VKs route to a button event."""
    if vk in _MOUSE_VK_FLAGS:
        flag = _MOUSE_VK_FLAGS[vk][0 if down else 1]
        inp = _INPUT(type=_INPUT_MOUSE)
        inp.u.mi = _MOUSEINPUT(0, 0, 0, flag, 0, None)
        _send(inp)
        return
    scan = 0
    try:
        scan = _user32.MapVirtualKeyW(vk, _MAPVK_VK_TO_VSC) if _user32 else 0
    except Exception:
        scan = 0
    up = _KE_KEYUP if not down else 0
    inp = _INPUT(type=_INPUT_KEYBOARD)
    if scan:                                   # preferred: scancode (DirectInput-friendly)
        ext = _KE_EXTENDED if vk in _EXTENDED_VKS else 0
        inp.u.ki = _KEYBDINPUT(0, scan, _KE_SCANCODE | ext | up, 0, None)
    else:                                      # fallback: virtual-key
        inp.u.ki = _KEYBDINPUT(vk, 0, up, 0, None)
    _send(inp)


def _tap_key(vk: int, hold: float = _PRESS_HOLD) -> None:
    _key_send(vk, True)
    if hold > 0:
        time.sleep(hold)
    _key_send(vk, False)


def _tap_combo(vks, hold: float = _PRESS_HOLD) -> None:
    """Press a COMBINATION as one chord: all keys down in order, held briefly, then
    up in reverse (e.g. ctrl+shift+a → ctrl↓ shift↓ a↓ … a↑ shift↑ ctrl↑)."""
    vks = [v for v in vks if v]
    if not vks:
        return
    if len(vks) == 1:
        _tap_key(vks[0], hold)
        return
    for v in vks:
        _key_send(v, True)
    if hold > 0:
        time.sleep(hold)
    for v in reversed(vks):
        _key_send(v, False)


# ══════════════════════════════════════════════════════════════════════════════
#  Manager state
# ══════════════════════════════════════════════════════════════════════════════
_lock = threading.Lock()
_logger = None
_announce: Optional[Callable[[str], None]] = None
_mascot: Optional[Callable[[str], None]] = None    # callback(name) → orb "clicker" pose
_mascot_state = ""
_available = False
_engine_started = False

_autoclicker: dict = {
    "enabled": True, "cps": 10, "button": "left",
    "hotkey": "", "mode": "toggle",
}
_inputs: list[dict] = []

# compiled runtime jobs (autoclicker + each input), rebuilt on every config change
_jobs: list[dict] = []


def available() -> bool:
    return _available


def _clamp_cps(v) -> int:
    try:
        return max(CPS_MIN, min(CPS_MAX, int(round(float(v)))))
    except Exception:
        return 10


def _clean_mode(v) -> str:
    v = (str(v) or "").strip().lower()
    return v if v in MODE_CHOICES else "toggle"


def _clean_fire(v) -> str:
    v = (str(v) or "").strip().lower()
    return v if v in FIRE_CHOICES else "repeat"


# ── Persistence ─────────────────────────────────────────────────────────────────
def _load_file() -> None:
    global _autoclicker, _inputs
    try:
        if not os.path.exists(_CONFIG_FILE):
            return
        with open(_CONFIG_FILE, encoding="utf-8") as f:
            data = json.load(f)
        ac = data.get("autoclicker", {}) or {}
        _autoclicker = {
            "enabled": bool(ac.get("enabled", True)),
            "cps": _clamp_cps(ac.get("cps", 10)),
            "button": ac.get("button", "left") if ac.get("button") in BUTTON_CHOICES else "left",
            "hotkey": str(ac.get("hotkey", "")).strip().lower(),
            "mode": _clean_mode(ac.get("mode", "toggle")),
        }
        cleaned: list[dict] = []
        for it in data.get("inputs", []):
            if not isinstance(it, dict):
                continue
            key = str(it.get("key", "")).strip().lower()
            if not key:
                continue
            cleaned.append({
                "id": str(it.get("id") or uuid.uuid4().hex[:8]),
                "name": str(it.get("name", "")).strip(),
                "key": key,
                "cps": _clamp_cps(it.get("cps", 10)),
                "hotkey": str(it.get("hotkey", "")).strip().lower(),
                "mode": _clean_mode(it.get("mode", "toggle")),
                "fire": _clean_fire(it.get("fire", "repeat")),
                "enabled": bool(it.get("enabled", True)),
            })
        _inputs = cleaned
    except Exception as e:
        if _logger:
            _logger.warning("autoclicker: 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({"autoclicker": _autoclicker, "inputs": _inputs}, f, indent=2)
    except Exception as e:
        if _logger:
            _logger.warning("autoclicker: could not save %s: %s", _CONFIG_FILE, e)


# ── Job (one active auto-fire task) ──────────────────────────────────────────────
def _make_job(job_id: str, label: str, kind: str, *, fire_fn, cps, hotkey,
              mode, fire, enabled) -> dict:
    try:
        import wivexx_hotkeys as hk
        groups = hk.parse_combo(hotkey) if hotkey else None
    except Exception:
        groups = None
    return {
        "id": job_id, "label": label, "kind": kind,
        "fire_fn": fire_fn, "cps": cps, "hotkey": hotkey, "groups": groups,
        "mode": mode, "fire": fire, "enabled": bool(enabled and groups),
        "_stop": threading.Event(), "_thread": None, "_active": False,
        "_was_down": False,
    }


def _rebuild_jobs() -> None:
    """Stop everything, then recompile the job list from current config."""
    stop_all()
    jobs: list[dict] = []
    ac = _autoclicker
    btn = ac.get("button", "left")
    jobs.append(_make_job(
        "autoclicker", f"Autoclicker ({btn})", "click",
        fire_fn=(lambda h, b=btn: _click(b, h)),
        cps=ac.get("cps", 10), hotkey=ac.get("hotkey", ""),
        mode=ac.get("mode", "toggle"), fire="repeat",
        enabled=ac.get("enabled", True)))
    try:
        import wivexx_hotkeys as hk
    except Exception:
        hk = None
    for it in _inputs:
        # the key field is now a captured combo string ("a" or "ctrl+shift+e");
        # parse it to the list of VKs we press together each fire.
        groups = hk.parse_combo(it["key"]) if hk else None
        if not groups:
            continue
        vks = [grp[0] for grp in groups]               # one vk per token
        label = it.get("name") or f"Press {it['key']}"
        jobs.append(_make_job(
            it["id"], label, "key",
            fire_fn=(lambda h, g=vks: _tap_combo(g, h)),
            cps=it.get("cps", 10), hotkey=it.get("hotkey", ""),
            mode=it.get("mode", "toggle"), fire=it.get("fire", "repeat"),
            enabled=it.get("enabled", True)))
    with _lock:
        _jobs[:] = jobs


def _job_runner(job: dict) -> None:
    interval = 1.0 / max(CPS_MIN, job["cps"])
    # hold long enough to register everywhere (>=5 ms), but never more than the
    # per-click budget so CPS is preserved (at extreme CPS the floor caps it a bit).
    hold = max(0.005, min(_PRESS_HOLD, interval * 0.45))
    fire = job["fire_fn"]
    stop = job["_stop"]
    while not stop.is_set():
        t0 = time.perf_counter()
        try:
            fire(hold)
        except Exception:
            pass
        wait = interval - (time.perf_counter() - t0)
        if stop.wait(max(0.0, wait)):
            break


def _timer_precision(on: bool) -> None:
    """Raise the system timer to 1 ms while clicking so the short hold + interval
    sleeps are accurate (otherwise Windows' ~15.6 ms granularity overshoots and the
    real CPS comes out well below what was set). begin/end calls are balanced."""
    if _winmm is None:
        return
    try:
        _winmm.timeBeginPeriod(1) if on else _winmm.timeEndPeriod(1)
    except Exception:
        pass


def _start_job(job: dict) -> None:
    if job["_active"]:
        return
    job["_active"] = True
    job["_stop"].clear()
    _timer_precision(True)
    t = threading.Thread(target=_job_runner, args=(job,), daemon=True,
                         name=f"wivexx-autofire-{job['id']}")
    job["_thread"] = t
    t.start()
    _notify(job, True)


def _stop_job(job: dict) -> None:
    if not job["_active"]:
        return
    job["_active"] = False
    job["_stop"].set()
    _timer_precision(False)
    _notify(job, False)


def _toggle_job(job: dict) -> None:
    _stop_job(job) if job["_active"] else _start_job(job)


def _fire_once(job: dict) -> None:
    try:
        job["fire_fn"](_PRESS_HOLD)
    except Exception:
        pass


def _notify(job: dict, active: bool) -> None:
    if _logger:
        _logger.info("autoclicker: %s %s", job["label"], "ON" if active else "OFF")
    if _announce and job["fire"] != "single":
        try:
            _announce(f"{job['label']} {'on' if active else 'off'}.")
        except Exception:
            pass
    _refresh_mascot()


def _refresh_mascot() -> None:
    """Show the orb's 'clicker' pose whenever ANY auto-fire job is active."""
    global _mascot_state
    any_active = any(j.get("_active") for j in _jobs)
    want = "clicker" if any_active else ""
    if want != _mascot_state:
        _mascot_state = want
        if _mascot:
            try:
                _mascot(want)
            except Exception:
                pass


def stop_all() -> None:
    """Panic stop — halt every running auto-fire job immediately."""
    with _lock:
        jobs = list(_jobs)
    for job in jobs:
        try:
            _stop_job(job)
        except Exception:
            pass


# ── Trigger poll engine ──────────────────────────────────────────────────────────
def _engine_loop() -> None:
    """Edge-detect each job's shortcut and start/stop/fire accordingly. Pauses
    while wivexx_hotkeys is capturing a combo so a rebind never fires a job."""
    try:
        import wivexx_hotkeys as hk
    except Exception:
        hk = None
    while True:
        time.sleep(_POLL_INTERVAL)
        if hk is None or hk.capturing():
            continue
        try:
            with _lock:
                jobs = list(_jobs)
            for job in jobs:
                if not job["enabled"] or not job["groups"]:
                    continue
                down = hk.combo_down(job["groups"])
                rising = down and not job["_was_down"]
                falling = (not down) and job["_was_down"]
                if job["fire"] == "single":
                    if rising:
                        _fire_once(job)
                elif job["mode"] == "hold":
                    if rising:
                        _start_job(job)
                    elif falling:
                        _stop_job(job)
                else:  # repeat + toggle
                    if rising:
                        _toggle_job(job)
                job["_was_down"] = down
        except Exception as e:
            if _logger:
                _logger.debug("autoclicker: engine tick failed: %s", e)


# ══════════════════════════════════════════════════════════════════════════════
#  Public API
# ══════════════════════════════════════════════════════════════════════════════
def start(announce: Optional[Callable[[str], None]] = None, logger=None,
          mascot: Optional[Callable[[str], None]] = None) -> bool:
    """Load config and start the trigger engine. Windows-only (Win32 SendInput)."""
    global _logger, _announce, _mascot, _available, _engine_started
    _logger = logger
    _announce = announce
    _mascot = mascot
    if os.name != "nt" or _user32 is None:
        _available = False
        return False
    _available = True
    _load_file()
    _save_file()    # write defaults on first run so the file is discoverable
    _rebuild_jobs()
    if not _engine_started:
        _engine_started = True
        threading.Thread(target=_engine_loop, daemon=True,
                         name="wivexx-autoclicker-engine").start()
    return True


def active_summary() -> dict:
    """Brief live status for the top-left activity overlay: whether any auto-fire job
    is running, how many, and the first one's label/cps (for a one-line detail)."""
    with _lock:
        act = [j for j in _jobs if j.get("_active")]
    if not act:
        return {"active": False, "count": 0, "label": "", "cps": 0}
    j = act[0]
    return {"active": True, "count": len(act),
            "label": j.get("label", ""), "cps": int(j.get("cps", 0) or 0)}


def get_config() -> dict:
    return {
        "available": _available,
        "autoclicker": dict(_autoclicker),
        "inputs": [dict(it) for it in _inputs],
        "key_choices": list(KEY_CHOICES),
        "button_choices": list(BUTTON_CHOICES),
        "mode_choices": list(MODE_CHOICES),
        "fire_choices": list(FIRE_CHOICES),
        "cps_min": CPS_MIN, "cps_max": CPS_MAX,
    }


def set_autoclicker(cps=None, button=None, hotkey=None,
                    mode=None, enabled=None) -> dict:
    global _autoclicker
    if cps is not None:
        _autoclicker["cps"] = _clamp_cps(cps)
    if button is not None and button in BUTTON_CHOICES:
        _autoclicker["button"] = button
    if hotkey is not None:
        _autoclicker["hotkey"] = str(hotkey).strip().lower()
    if mode is not None:
        _autoclicker["mode"] = _clean_mode(mode)
    if enabled is not None:
        _autoclicker["enabled"] = bool(enabled)
    _save_file()
    _rebuild_jobs()
    return {"ok": True, "autoclicker": dict(_autoclicker)}


def add_input(name="", key="", cps=10, hotkey="",
              mode="toggle", fire="repeat", enabled=True) -> dict:
    # key is a captured combo string now: "a", "f", "ctrl+shift+e", "shift+w"…
    key = str(key).strip().lower()
    try:
        import wivexx_hotkeys as hk
        valid = key and hk.parse_combo(key) is not None
    except Exception:
        valid = False
    if not valid:
        return {"ok": False, "error": f"Capture a key (or combo) first."}
    item = {
        "id": uuid.uuid4().hex[:8],
        "name": str(name).strip(),
        "key": key,
        "cps": _clamp_cps(cps),
        "hotkey": str(hotkey).strip().lower(),
        "mode": _clean_mode(mode),
        "fire": _clean_fire(fire),
        "enabled": bool(enabled),
    }
    _inputs.append(item)
    _save_file()
    _rebuild_jobs()
    return {"ok": True, "id": item["id"]}


def update_input(input_id: str, **fields) -> dict:
    for it in _inputs:
        if it["id"] != input_id:
            continue
        if "name" in fields:
            it["name"] = str(fields["name"]).strip()
        if "key" in fields:
            k = str(fields["key"]).strip().lower()
            try:
                import wivexx_hotkeys as hk
                if not k or hk.parse_combo(k) is None:
                    return {"ok": False, "error": f"Unrecognised key combo: {k}"}
            except Exception:
                pass
            it["key"] = k
        if "cps" in fields:
            it["cps"] = _clamp_cps(fields["cps"])
        if "hotkey" in fields:
            it["hotkey"] = str(fields["hotkey"]).strip().lower()
        if "mode" in fields:
            it["mode"] = _clean_mode(fields["mode"])
        if "fire" in fields:
            it["fire"] = _clean_fire(fields["fire"])
        if "enabled" in fields:
            it["enabled"] = bool(fields["enabled"])
        _save_file()
        _rebuild_jobs()
        return {"ok": True}
    return {"ok": False, "error": "not found"}


def delete_input(input_id: str) -> dict:
    global _inputs
    before = len(_inputs)
    _inputs = [it for it in _inputs if it["id"] != input_id]
    if len(_inputs) == before:
        return {"ok": False, "error": "not found"}
    _save_file()
    _rebuild_jobs()
    return {"ok": True}
