"""MouseClicker — AI-powered mouse click on correct CSCA exam answer.

Workflow:
  1. Press hotkey (default: Ctrl+Shift+M)
  2. Screenshot taken of the current screen
  3. Vision AI identifies the correct answer (A/B/C/D) and its pixel coordinates
  4. Mouse moves smoothly (human-like) to the answer option
  5. Left click

The Vision AI returns pixel coordinates relative to the screenshot image.
These are scaled back to real screen coordinates before clicking.

Platform support:
  - Windows: ctypes SetCursorPos + mouse_event (SEB/CSCA compatible)
  - macOS:   pyautogui (fallback)
"""

from __future__ import annotations

import logging
import random
import sys
import threading
import time
from typing import TYPE_CHECKING

from .vision_pipeline import (
    MOUSE_PROMPT,
    analyze_screenshot,
    beep_result,
    beep_start,
    capture_screenshot,
)

if TYPE_CHECKING:
    from ..config import Config

log = logging.getLogger(__name__)


class MouseClicker:
    """Screenshot → Vision AI → human-like mouse move → click on correct answer."""

    def __init__(self, config: Config) -> None:
        self._config = config
        self._running = False
        self._lock = threading.Lock()

    # ------------------------------------------------------------------ #
    # Public entry point
    # ------------------------------------------------------------------ #

    def on_hotkey(self) -> None:
        """Called when hotkey is pressed. Starts a background worker thread."""
        with self._lock:
            if self._running:
                log.info("[mouse] Already running — ignoring repeat press")
                return
            self._running = True
        threading.Thread(target=self._run, daemon=True, name="mouse-clicker").start()

    # ------------------------------------------------------------------ #
    # Main pipeline
    # ------------------------------------------------------------------ #

    def _run(self) -> None:
        try:
            # Step 1: take screenshot (shared pipeline with cache)
            img_b64, orig_w, orig_h, res_w, res_h = capture_screenshot(
                self._config, "mouse_clicker",
            )
            if not img_b64:
                log.error("[mouse] Screenshot failed — aborting")
                return

            # Step 2: ask Vision AI for correct answer + coordinates
            result = analyze_screenshot(
                self._config, img_b64, MOUSE_PROMPT,
                config_prefix="mouse_clicker",
                max_tokens=128,
                required_key="found",
            )
            if result is None:
                log.error("[mouse] Vision AI failed — aborting")
                return

            if not result.get("found"):
                log.info("[mouse] No multiple-choice question found on screen")
                return

            answer = str(result.get("answer") or "").strip().upper()
            y_pct = float(result.get("y_pct") or 0)
            x_pct = float(result.get("x_pct") or 0)

            if not answer:
                log.info("[mouse] No answer from AI")
                return

            # If AI returned percentage coords — use them directly
            if y_pct > 0.01:
                click_x = int(orig_w * max(0.05, min(x_pct, 0.95)))
                click_y = int(orig_h * max(0.05, min(y_pct, 0.95)))
                log.info(
                    "[mouse] AI position: x_pct=%.2f y_pct=%.2f → (%d, %d)",
                    x_pct, y_pct, click_x, click_y,
                )
            else:
                # Fallback: derive from answer letter (A=1, B=2, C=3, D=4)
                letter = answer[0] if answer else ""
                index = ord(letter) - ord("A") + 1 if len(letter) == 1 and letter in "ABCD" else 0
                if index <= 0:
                    log.warning("[mouse] Cannot determine option position")
                    return
                click_x, click_y = self._coords_from_index(
                    index, 4, orig_w, orig_h,
                )
                log.info(
                    "[mouse] Fallback index=%d → (%d, %d)",
                    index, click_x, click_y,
                )

            # Sanity check: clamp to screen bounds
            click_x = max(10, min(click_x, orig_w - 10))
            click_y = max(10, min(click_y, orig_h - 10))

            log.info(
                "[mouse] Answer=%s screen=(%d,%d)",
                answer, click_x, click_y,
            )

            # Step 3: move and click
            self._move_and_click(click_x, click_y)

        except Exception as exc:
            log.error("[mouse] Unexpected error: %s", exc, exc_info=True)
        finally:
            with self._lock:
                self._running = False

    # ------------------------------------------------------------------ #
    # Coordinate calculation from option index
    # ------------------------------------------------------------------ #

    @staticmethod
    def _coords_from_index(
        index: int, total: int, screen_w: int, screen_h: int,
    ) -> tuple[int, int]:
        """Calculate click coordinates based on answer option index (1-based).

        Typical CSCA/exam layout:
        - Options are in the left-center area (~25% from left for radio buttons)
        - Options are vertically distributed in the middle area of the screen
        - For 4 options on a full-page question:
          they span roughly from 35% to 70% of screen height
        """
        total = max(total, 2)  # at least 2 options

        # Horizontal: radio buttons / option letters are ~25% from left
        x = int(screen_w * 0.25)

        # Vertical: options span from ~35% to ~70% of screen height
        y_start = 0.35
        y_end = 0.70
        # Evenly distribute options within this range
        step = (y_end - y_start) / max(total - 1, 1)
        y_frac = y_start + step * (index - 1)
        y = int(screen_h * y_frac)

        log.info("[mouse] Coords from index=%d/%d: (%d, %d) [%.0f%%, %.0f%%]",
                 index, total, x, y, x / screen_w * 100, y / screen_h * 100)
        return x, y

    # ------------------------------------------------------------------ #
    # Bézier curve helpers
    # ------------------------------------------------------------------ #

    @staticmethod
    def _bezier_point(t: float, p0: tuple[float, float], p1: tuple[float, float],
                      p2: tuple[float, float], p3: tuple[float, float]) -> tuple[float, float]:
        """Cubic Bézier curve point at parameter t ∈ [0, 1]."""
        u = 1.0 - t
        x = u**3 * p0[0] + 3 * u**2 * t * p1[0] + 3 * u * t**2 * p2[0] + t**3 * p3[0]
        y = u**3 * p0[1] + 3 * u**2 * t * p1[1] + 3 * u * t**2 * p2[1] + t**3 * p3[1]
        return x, y

    @staticmethod
    def _random_control_points(
        sx: float, sy: float, ex: float, ey: float,
    ) -> tuple[tuple[float, float], tuple[float, float]]:
        """Generate two random Bézier control points that curve naturally."""
        dx, dy = ex - sx, ey - sy
        dist = (dx**2 + dy**2) ** 0.5 or 1.0
        # Control points offset perpendicular to the line, with randomness
        spread = dist * random.uniform(0.15, 0.45)
        # Perpendicular direction
        nx, ny = -dy / dist, dx / dist
        # First control point: ~30% along path, offset to one side
        t1 = random.uniform(0.2, 0.4)
        off1 = random.uniform(-spread, spread)
        cp1 = (sx + dx * t1 + nx * off1, sy + dy * t1 + ny * off1)
        # Second control point: ~70% along path, offset (possibly other side)
        t2 = random.uniform(0.6, 0.8)
        off2 = random.uniform(-spread, spread)
        cp2 = (sx + dx * t2 + nx * off2, sy + dy * t2 + ny * off2)
        return cp1, cp2

    # ------------------------------------------------------------------ #
    # Human-like timing helpers
    # ------------------------------------------------------------------ #

    @staticmethod
    def _human_speed_factor(t: float) -> float:
        """Variable speed: slow start, fast middle, slow approach at end."""
        # Quintic ease-in-out for more natural acceleration
        if t < 0.5:
            return 16 * t**5
        return 1.0 - (-2 * t + 2) ** 5 / 2

    @staticmethod
    def _human_delay(base: float) -> float:
        """Add slight randomness to delay to mimic human imprecision."""
        return base * random.uniform(0.7, 1.4)

    # ------------------------------------------------------------------ #
    # Get current cursor position (cross-platform)
    # ------------------------------------------------------------------ #

    @staticmethod
    def _get_cursor_pos() -> tuple[int, int]:
        """Get current mouse cursor position."""
        if sys.platform == "win32":
            import ctypes
            import ctypes.wintypes
            pt = ctypes.wintypes.POINT()
            ctypes.windll.user32.GetCursorPos(ctypes.byref(pt))
            return pt.x, pt.y
        else:
            try:
                import Quartz
                event = Quartz.CGEventCreate(None)
                loc = Quartz.CGEventGetLocation(event)
                return int(loc.x), int(loc.y)
            except Exception:
                import pyautogui
                return pyautogui.position()

    @staticmethod
    def _set_cursor_pos(x: int, y: int) -> None:
        """Set cursor position (cross-platform)."""
        if sys.platform == "win32":
            import ctypes
            ctypes.windll.user32.SetCursorPos(x, y)
        else:
            try:
                import Quartz
                Quartz.CGWarpMouseCursorPosition(Quartz.CGPoint(x, y))
                # Suppress the "mouse moved" coalescing that can fight back
                Quartz.CGAssociateMouseAndMouseCursorPosition(True)
            except Exception:
                import pyautogui
                pyautogui.moveTo(x, y, _pause=False)

    # ------------------------------------------------------------------ #
    # Mouse movement and click (with anti-interference)
    # ------------------------------------------------------------------ #

    def _move_and_click(self, x: int, y: int) -> None:
        """Move mouse to (x, y) with human-like Bézier path, then click.

        Anti-interference: if user jerks the mouse during movement,
        the cursor is forcefully re-acquired and driven to the target.
        Retries up to max_retries times to ensure the click lands.
        """
        duration = float(self._config.get("mouse_clicker.move_duration", 0.2))
        click_delay = float(self._config.get("mouse_clicker.click_delay", 0.08))
        max_retries = int(self._config.get("mouse_clicker.max_retries", 5))
        tolerance = int(self._config.get("mouse_clicker.tolerance_px", 6))

        for attempt in range(1, max_retries + 1):
            # Move with human-like path
            self._human_move(x, y, duration)

            # Small human-like pause before click
            time.sleep(self._human_delay(click_delay))

            # Check if cursor is still on target (user may have jerked it)
            cur_x, cur_y = self._get_cursor_pos()
            dist = ((cur_x - x) ** 2 + (cur_y - y) ** 2) ** 0.5

            if dist <= tolerance:
                # On target — click!
                self._do_click(x, y)
                log.info("[mouse] Clicked at (%d, %d) on attempt %d", x, y, attempt)
                return

            log.info(
                "[mouse] Cursor drifted to (%d, %d), target (%d, %d), "
                "dist=%.0f — retrying (attempt %d/%d)",
                cur_x, cur_y, x, y, dist, attempt, max_retries,
            )
            # Shorten duration on retries — more aggressive re-acquisition
            duration = max(0.1, duration * 0.6)

        # Final forced attempt — snap and click immediately
        log.warning("[mouse] Max retries reached — force-clicking at (%d, %d)", x, y)
        self._set_cursor_pos(x, y)
        time.sleep(0.02)
        self._do_click(x, y)

    def _human_move(self, x: int, y: int, duration: float) -> None:
        """Move cursor along a randomized Bézier curve with human-like timing."""
        sx, sy = self._get_cursor_pos()

        # Generate Bézier path
        cp1, cp2 = self._random_control_points(sx, sy, x, y)
        p0, p3 = (float(sx), float(sy)), (float(x), float(y))

        # Number of steps — more for longer distances
        dist = ((x - sx) ** 2 + (y - sy) ** 2) ** 0.5
        steps = max(8, min(40, int(dist / 8)))
        step_delay = duration / steps

        for i in range(1, steps + 1):
            # Human speed curve
            t = self._human_speed_factor(i / steps)

            bx, by = self._bezier_point(t, p0, cp1, cp2, p3)

            # Add micro-jitter (decreasing near target for precision)
            jitter_scale = max(0, 1.0 - (i / steps) ** 2) * 2.0
            jx = random.gauss(0, jitter_scale) if i < steps else 0
            jy = random.gauss(0, jitter_scale) if i < steps else 0

            self._set_cursor_pos(int(bx + jx), int(by + jy))
            time.sleep(self._human_delay(step_delay))

            # Occasional micro-pause (human hesitation, ~5% chance)
            if random.random() < 0.05 and i < steps - 2:
                time.sleep(random.uniform(0.02, 0.08))

        # Final exact position
        self._set_cursor_pos(x, y)

    def _do_click(self, x: int, y: int) -> None:
        """Perform a single left click at (x, y) — platform dispatch."""
        if sys.platform == "win32":
            self._click_win32(x, y)
        else:
            self._click_mac(x, y)

    # ---- Windows (ctypes — works inside SEB/CSCA) ----

    def _click_win32(self, x: int, y: int) -> None:
        """Left click at (x, y) on Windows via ctypes mouse_event."""
        try:
            import ctypes

            MOUSEEVENTF_LEFTDOWN = 0x0002
            MOUSEEVENTF_LEFTUP = 0x0004

            ctypes.windll.user32.SetCursorPos(x, y)
            ctypes.windll.user32.mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0)
            time.sleep(0.05 + random.uniform(0, 0.04))
            ctypes.windll.user32.mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, 0)

        except Exception as exc:
            log.error("[mouse] Win32 click failed: %s", exc)

    # ---- macOS (Quartz CGEvent — precise, no pyautogui needed) ----

    def _click_mac(self, x: int, y: int) -> None:
        """Left click on macOS via Quartz CGEvent."""
        try:
            import Quartz

            point = Quartz.CGPoint(x, y)
            # Move to exact position first
            Quartz.CGWarpMouseCursorPosition(point)
            time.sleep(0.01)

            # Mouse down
            evt_down = Quartz.CGEventCreateMouseEvent(
                None, Quartz.kCGEventLeftMouseDown, point, Quartz.kCGMouseButtonLeft,
            )
            Quartz.CGEventPost(Quartz.kCGHIDEventTap, evt_down)

            time.sleep(0.05 + random.uniform(0, 0.04))

            # Mouse up
            evt_up = Quartz.CGEventCreateMouseEvent(
                None, Quartz.kCGEventLeftMouseUp, point, Quartz.kCGMouseButtonLeft,
            )
            Quartz.CGEventPost(Quartz.kCGHIDEventTap, evt_up)
            log.info("[mouse] Clicked at (%d, %d)", x, y)

        except Exception as exc:
            log.error("[mouse] macOS click failed: %s — falling back to pyautogui", exc)
            try:
                import pyautogui
                pyautogui.click(x, y)
            except Exception as exc2:
                log.error("[mouse] pyautogui click also failed: %s", exc2)
