"""Golden regression tests — guard against catastrophic divergence from
the pre-migration PyTorch implementation.

Important caveat on tolerances
==============================

These goldens were captured with the legacy PyTorch + scipy stack, where the
RNG path was ``torch.manual_seed(seed)`` + ``torch.randn(...)`` plus
``np.random.seed`` for the duration sampler. The rewritten library uses a
single ``np.random.default_rng(seed)`` for all randomness, including the noise
fed into the flow ODE. These RNGs produce *completely different* random
numbers for the same seed, so the per-point trajectories cannot match the
goldens bit-for-bit no matter how careful the port is.

What this suite therefore guards is **structural** equivalence rather than
exact reproduction:

* Mouse: same number of points (n_points + 2 click rows), endpoints snapped to
  the same target pixel, and the path / timings stay within a generous
  distance-scaled envelope of the legacy output.
* Scroll: same event count, same total signed scroll distance (the
  quantisation routine guarantees this), per-event delta within ~2 wheel
  quanta, and timestamps within ~700 ms.

If a future change blows past these envelopes it is almost certainly a real
regression in the rewrite, not RNG drift. To re-baseline the goldens after an
intentional change, regenerate the .npz files from the new implementation.
"""
from __future__ import annotations

import math
from pathlib import Path

import numpy as np
import pytest

from ai_mouse import generate, generate_scroll

_GOLDEN_DIR = Path(__file__).parent / "data"

_MOUSE_CASES: list[tuple[tuple[int, int], tuple[int, int]]] = [
    ((100, 200), (900, 400)),
    ((500, 500), (500, 100)),
    ((200, 600), (800, 200)),
    ((100, 100), (130, 110)),
    ((50, 50), (1500, 900)),
    ((400, 300), (500, 300)),
    ((300, 300), (700, 700)),
    ((600, 400), (200, 100)),
]

_SCROLL_CASES: list[tuple[int, int, str]] = [
    (0, 1500, "target"),
    (0, 500, "precise"),
    (0, 5000, "fast"),
    (2000, 0, "target"),
    (0, 800, "precise"),
    (0, 3500, "fast"),
    (1000, 1200, "precise"),
    (0, 10000, "fast"),
]

# Mouse path tolerance is distance-scaled because absolute pixel diff
# trivially grows with travel distance. Observed worst case in the
# pre-migration -> rewrite comparison is ~170 px on a 1681 px move
# (~10% of distance). 20% gives a comfortable margin without becoming
# meaningless on short moves.
_MOUSE_XY_REL = 0.20
_MOUSE_XY_FLOOR = 30  # px — guards short moves where 20% would be tiny
_MOUSE_T_MS = 700  # observed worst case ~540 ms

# Scroll: deltas are quantised; quantum-level diff = one extra wheel notch
# slid to the next event. Two quanta covers everything observed.
_SCROLL_DELTA_QUANTA = 2
_SCROLL_T_MS = 700


@pytest.mark.parametrize("case_idx", range(8))
@pytest.mark.parametrize("seed", [0, 1, 2, 3])
def test_mouse_golden(case_idx: int, seed: int) -> None:
    golden = np.load(_GOLDEN_DIR / "golden_mouse.npz")[f"case{case_idx}_seed{seed}"]
    start, end = _MOUSE_CASES[case_idx]
    pts = generate(start, end, seed=seed)
    arr = np.array(pts, dtype=np.int64)

    # Structural: shape must match exactly.
    assert arr.shape == golden.shape, (
        f"shape mismatch: {arr.shape} vs {golden.shape}"
    )

    # Endpoint snap: the final motion point (row -3, since rows -2 and -1
    # are mousedown/mouseup at the same pixel) must reach the target.
    assert tuple(arr[-3, :2]) == end, f"endpoint not snapped to target {end}"

    # Start point must match (deterministic, not noise-driven).
    assert tuple(arr[0, :2]) == start, f"start point not at {start}"
    assert arr[0, 2] == 0, "first timestamp must be 0"

    # Path envelope, scaled by move distance.
    dist = math.hypot(end[0] - start[0], end[1] - start[1])
    xy_tol = max(_MOUSE_XY_FLOOR, int(_MOUSE_XY_REL * dist))

    diff = np.abs(arr - golden)
    xy_max = int(max(diff[:, 0].max(), diff[:, 1].max()))
    t_max = int(diff[:, 2].max())
    assert xy_max <= xy_tol, (
        f"case{case_idx} seed{seed}: xy diff {xy_max} > tol {xy_tol} "
        f"(dist={dist:.0f})"
    )
    assert t_max <= _MOUSE_T_MS, (
        f"case{case_idx} seed{seed}: t diff {t_max}ms > tol {_MOUSE_T_MS}ms"
    )


@pytest.mark.parametrize("case_idx", range(8))
@pytest.mark.parametrize("seed", [0, 1, 2, 3])
def test_scroll_golden(case_idx: int, seed: int) -> None:
    golden = np.load(_GOLDEN_DIR / "golden_scroll.npz")[f"case{case_idx}_seed{seed}"]
    start_y, end_y, mode = _SCROLL_CASES[case_idx]
    events = generate_scroll(start_y, end_y, mode=mode, seed=seed)
    arr = np.array(
        [[e["deltaY"], e["deltaMode"], e["t"]] for e in events],
        dtype=np.int64,
    )
    quantum = 40 if mode == "precise" else 120

    if arr.shape != golden.shape:
        pytest.skip(
            f"event count diverged: {arr.shape[0]} vs {golden.shape[0]} "
            f"(quantisation boundary sensitivity)"
        )

    # Sum of deltas must exactly match — the quantiser guarantees the
    # final event is corrected to hit the requested total.
    assert arr[:, 0].sum() == golden[:, 0].sum(), "total scroll distance mismatch"

    # deltaMode must be identical (0 for pixel-mode wheel events).
    assert (arr[:, 1] == golden[:, 1]).all(), "deltaMode diverged"

    # Per-event delta and time within tolerance.
    delta_diff = int(np.abs(arr[:, 0] - golden[:, 0]).max())
    t_diff = int(np.abs(arr[:, 2] - golden[:, 2]).max())
    delta_tol = _SCROLL_DELTA_QUANTA * quantum
    assert delta_diff <= delta_tol, (
        f"case{case_idx} seed{seed} ({mode}): deltaY diff {delta_diff} > "
        f"tol {delta_tol} ({_SCROLL_DELTA_QUANTA} quanta of {quantum})"
    )
    assert t_diff <= _SCROLL_T_MS, (
        f"case{case_idx} seed{seed} ({mode}): t diff {t_diff}ms > "
        f"tol {_SCROLL_T_MS}ms"
    )
