"""
test_hand_gestures.py — pytest tests for _hand_gestures.py (no ROS2 required).

Tests cover:
  - Landmark dataclass
  - HandGestureResult NamedTuple fields
  - WaveDetector — no wave / wave trigger / reset
  - _finger_up helpers
  - classify_hand:
      stop (open palm)
      point (index up, others curled) + direction
      disarm (fist)
      confirm (thumbs-up)
      follow_me (peace/victory)
      greeting (wave via WaveDetector)
      none (neutral / ambiguous pose)
  - classify_hand priority ordering
  - classify_hand: fewer than 21 landmarks → "none"
"""

from __future__ import annotations

import math
from typing import List

import pytest

from saltybot_hand_tracking._hand_gestures import (
    Landmark,
    HandGestureResult,
    WaveDetector,
    classify_hand,
    _finger_up,
    _count_fingers_up,
    _four_fingers_curled,
    _thumb_curled,
    _thumb_extended_up,
    _point_direction,
)


# ── Landmark factory helpers ──────────────────────────────────────────────────

def _lm(x: float, y: float, z: float = 0.0) -> Landmark:
    return Landmark(x, y, z)


def _flat_hand(n: int = 21) -> List[Landmark]:
    """Return n landmarks all at (0.5, 0.5)."""
    return [_lm(0.5, 0.5) for _ in range(n)]


def _make_hand(
    wrist_y: float = 0.8,
    # Index finger
    idx_mcp_y: float = 0.6, idx_pip_y: float = 0.5,
    idx_tip_y: float = 0.3, idx_mcp_x: float = 0.4, idx_tip_x: float = 0.4,
    # Middle finger
    mid_mcp_y: float = 0.6, mid_pip_y: float = 0.5, mid_tip_y: float = 0.4,
    # Ring finger
    rng_mcp_y: float = 0.6, rng_pip_y: float = 0.5, rng_tip_y: float = 0.55,
    # Pinky
    pnk_mcp_y: float = 0.6, pnk_pip_y: float = 0.5, pnk_tip_y: float = 0.55,
    # Thumb
    thm_cmc_y: float = 0.7, thm_mcp_y: float = 0.65, thm_tip_y: float = 0.55,
) -> List[Landmark]:
    """
    Build a 21-landmark array for testing.

    Layout (MediaPipe Hands indices):
      0  WRIST
      1  THUMB_CMC  2 THUMB_MCP  3 THUMB_IP   4 THUMB_TIP
      5  INDEX_MCP  6 INDEX_PIP  7 INDEX_DIP  8 INDEX_TIP
      9  MIDDLE_MCP 10 MIDDLE_PIP 11 MIDDLE_DIP 12 MIDDLE_TIP
      13 RING_MCP   14 RING_PIP   15 RING_DIP   16 RING_TIP
      17 PINKY_MCP  18 PINKY_PIP  19 PINKY_DIP  20 PINKY_TIP
    """
    lm = [_lm(0.5, 0.5)] * 21
    # WRIST
    lm[0]  = _lm(0.5,      wrist_y)
    # THUMB
    lm[1]  = _lm(0.35,     thm_cmc_y)   # CMC
    lm[2]  = _lm(0.33,     thm_mcp_y)   # MCP
    lm[3]  = _lm(0.31,     (thm_mcp_y + thm_tip_y) / 2)  # IP
    lm[4]  = _lm(0.30,     thm_tip_y)   # TIP
    # INDEX
    lm[5]  = _lm(idx_mcp_x, idx_mcp_y)  # MCP
    lm[6]  = _lm(idx_mcp_x, idx_pip_y)  # PIP
    lm[7]  = _lm(idx_mcp_x, (idx_pip_y + idx_tip_y) / 2)  # DIP
    lm[8]  = _lm(idx_tip_x, idx_tip_y)  # TIP
    # MIDDLE
    lm[9]  = _lm(0.5, mid_mcp_y)        # MCP
    lm[10] = _lm(0.5, mid_pip_y)        # PIP
    lm[11] = _lm(0.5, (mid_pip_y + mid_tip_y) / 2)
    lm[12] = _lm(0.5, mid_tip_y)        # TIP
    # RING
    lm[13] = _lm(0.6, rng_mcp_y)        # MCP
    lm[14] = _lm(0.6, rng_pip_y)        # PIP
    lm[15] = _lm(0.6, (rng_pip_y + rng_tip_y) / 2)
    lm[16] = _lm(0.6, rng_tip_y)        # TIP
    # PINKY
    lm[17] = _lm(0.65, pnk_mcp_y)       # MCP
    lm[18] = _lm(0.65, pnk_pip_y)       # PIP
    lm[19] = _lm(0.65, (pnk_pip_y + pnk_tip_y) / 2)
    lm[20] = _lm(0.65, pnk_tip_y)       # TIP
    return lm


# ── Prebuilt canonical poses ──────────────────────────────────────────────────

def _open_palm() -> List[Landmark]:
    """All 4 fingers extended (tips clearly above PIPs), thumb neutral."""
    return _make_hand(
        idx_mcp_y=0.60, idx_pip_y=0.50, idx_tip_y=0.25,
        mid_mcp_y=0.60, mid_pip_y=0.50, mid_tip_y=0.25,
        rng_mcp_y=0.60, rng_pip_y=0.50, rng_tip_y=0.25,
        pnk_mcp_y=0.60, pnk_pip_y=0.50, pnk_tip_y=0.25,
    )


def _point_up() -> List[Landmark]:
    """Index extended, middle/ring/pinky curled."""
    return _make_hand(
        idx_mcp_y=0.60, idx_pip_y=0.50, idx_tip_y=0.25,
        mid_mcp_y=0.60, mid_pip_y=0.55, mid_tip_y=0.62,   # curled
        rng_mcp_y=0.60, rng_pip_y=0.55, rng_tip_y=0.62,
        pnk_mcp_y=0.60, pnk_pip_y=0.55, pnk_tip_y=0.62,
    )


def _fist() -> List[Landmark]:
    """All fingers curled, thumb tip at/below thumb MCP."""
    return _make_hand(
        idx_mcp_y=0.60, idx_pip_y=0.58, idx_tip_y=0.65,   # tip below pip
        mid_mcp_y=0.60, mid_pip_y=0.58, mid_tip_y=0.65,
        rng_mcp_y=0.60, rng_pip_y=0.58, rng_tip_y=0.65,
        pnk_mcp_y=0.60, pnk_pip_y=0.58, pnk_tip_y=0.65,
        thm_cmc_y=0.70, thm_mcp_y=0.65, thm_tip_y=0.68,   # tip >= mcp → curled
    )


def _thumbs_up() -> List[Landmark]:
    """Thumb tip clearly above CMC, four fingers curled."""
    return _make_hand(
        thm_cmc_y=0.70, thm_mcp_y=0.65, thm_tip_y=0.30,  # tip well above CMC
        idx_mcp_y=0.60, idx_pip_y=0.58, idx_tip_y=0.65,
        mid_mcp_y=0.60, mid_pip_y=0.58, mid_tip_y=0.65,
        rng_mcp_y=0.60, rng_pip_y=0.58, rng_tip_y=0.65,
        pnk_mcp_y=0.60, pnk_pip_y=0.58, pnk_tip_y=0.65,
    )


def _peace() -> List[Landmark]:
    """Index + middle extended, ring + pinky curled."""
    return _make_hand(
        idx_mcp_y=0.60, idx_pip_y=0.50, idx_tip_y=0.25,
        mid_mcp_y=0.60, mid_pip_y=0.50, mid_tip_y=0.25,
        rng_mcp_y=0.60, rng_pip_y=0.58, rng_tip_y=0.65,   # curled
        pnk_mcp_y=0.60, pnk_pip_y=0.58, pnk_tip_y=0.65,
    )


# ── Landmark dataclass ────────────────────────────────────────────────────────

class TestLandmark:
    def test_fields(self):
        lm = Landmark(0.1, 0.2, 0.3)
        assert lm.x == pytest.approx(0.1)
        assert lm.y == pytest.approx(0.2)
        assert lm.z == pytest.approx(0.3)

    def test_default_z(self):
        lm = Landmark(0.5, 0.5)
        assert lm.z == 0.0

    def test_frozen(self):
        lm = Landmark(0.5, 0.5)
        with pytest.raises(Exception):
            lm.x = 0.9  # type: ignore[misc]


# ── HandGestureResult ─────────────────────────────────────────────────────────

class TestHandGestureResult:
    def test_fields(self):
        r = HandGestureResult("stop", 0.85, "", 0.5, 0.6)
        assert r.gesture == "stop"
        assert r.confidence == pytest.approx(0.85)
        assert r.direction == ""
        assert r.wrist_x == pytest.approx(0.5)
        assert r.wrist_y == pytest.approx(0.6)


# ── Low-level helpers ─────────────────────────────────────────────────────────

class TestFingerHelpers:
    def _two_lm(self, tip_y: float, pip_y: float) -> List[Landmark]:
        """Build a minimal 21-lm list where positions 0 and 1 are tip and pip."""
        lm = [_lm(0.5, 0.5)] * 21
        lm[0] = _lm(0.5, tip_y)
        lm[1] = _lm(0.5, pip_y)
        return lm

    def test_finger_up_true(self):
        """Tip above PIP (smaller y) → True."""
        lm = self._two_lm(tip_y=0.3, pip_y=0.6)
        assert _finger_up(lm, 0, 1) is True

    def test_finger_up_false(self):
        """Tip below PIP → False."""
        lm = self._two_lm(tip_y=0.7, pip_y=0.4)
        assert _finger_up(lm, 0, 1) is False

    def test_count_fingers_up_open_palm(self):
        lm = _open_palm()
        assert _count_fingers_up(lm) == 4

    def test_count_fingers_up_fist(self):
        lm = _fist()
        assert _count_fingers_up(lm) == 0

    def test_four_fingers_curled_fist(self):
        lm = _fist()
        assert _four_fingers_curled(lm) is True

    def test_four_fingers_curled_open_palm_false(self):
        lm = _open_palm()
        assert _four_fingers_curled(lm) is False

    def test_thumb_curled_fist(self):
        lm = _fist()
        assert _thumb_curled(lm) is True

    def test_thumb_extended_up_thumbs_up(self):
        lm = _thumbs_up()
        assert _thumb_extended_up(lm) is True

    def test_thumb_not_extended_fist(self):
        lm = _fist()
        assert _thumb_extended_up(lm) is False


# ── Point direction ───────────────────────────────────────────────────────────

class TestPointDirection:
    def _hand_with_index_vec(self, dx: float, dy: float) -> List[Landmark]:
        """Build hand where index MCP→TIP vector is (dx, dy)."""
        lm = _make_hand()
        lm[5] = _lm(0.5,       0.6)          # INDEX_MCP
        lm[8] = _lm(0.5 + dx,  0.6 + dy)     # INDEX_TIP
        return lm

    def test_pointing_up(self):
        # dy negative (tip above MCP) → up
        lm = self._hand_with_index_vec(0.0, -0.2)
        assert _point_direction(lm) == "up"

    def test_pointing_right(self):
        lm = self._hand_with_index_vec(0.2, 0.0)
        assert _point_direction(lm) == "right"

    def test_pointing_left(self):
        lm = self._hand_with_index_vec(-0.2, 0.0)
        assert _point_direction(lm) == "left"

    def test_pointing_upper_right(self):
        lm = self._hand_with_index_vec(0.15, -0.15)
        assert _point_direction(lm) == "upper_right"


# ── WaveDetector ──────────────────────────────────────────────────────────────

class TestWaveDetector:
    def test_too_few_samples_no_wave(self):
        wd = WaveDetector()
        for x in [0.3, 0.7, 0.3]:
            is_w, conf = wd.push(x)
        assert is_w is False
        assert conf == 0.0

    def test_wave_detected_after_oscillation(self):
        """Feed a sinusoidal wrist_x — should trigger wave."""
        wd = WaveDetector(history_len=20, min_reversals=2, min_amplitude=0.08)
        is_waving = False
        for i in range(20):
            x = 0.5 + 0.20 * math.sin(i * math.pi / 3)
            is_waving, conf = wd.push(x)
        assert is_waving is True
        assert conf > 0.0

    def test_no_wave_small_amplitude(self):
        """Very small oscillation should not trigger."""
        wd = WaveDetector(min_amplitude=0.10)
        for i in range(24):
            x = 0.5 + 0.01 * math.sin(i * math.pi / 3)
            wd.push(x)
        is_w, _ = wd.push(0.5)
        assert is_w is False

    def test_reset_clears_history(self):
        wd = WaveDetector()
        for i in range(24):
            wd.push(0.5 + 0.2 * math.sin(i * math.pi / 3))
        wd.reset()
        is_w, _ = wd.push(0.5)
        assert is_w is False


# ── classify_hand ─────────────────────────────────────────────────────────────

class TestClassifyHand:
    def test_too_few_landmarks(self):
        r = classify_hand([_lm(0.5, 0.5)] * 10)
        assert r.gesture == "none"

    def test_stop_open_palm(self):
        lm = _open_palm()
        r  = classify_hand(lm)
        assert r.gesture == "stop"
        assert r.confidence >= 0.60

    def test_point_up_gesture(self):
        lm = _point_up()
        r  = classify_hand(lm)
        assert r.gesture == "point"
        assert r.confidence >= 0.60

    def test_point_direction_populated(self):
        lm = _point_up()
        r  = classify_hand(lm)
        assert r.direction != ""

    def test_disarm_fist(self):
        lm = _fist()
        r  = classify_hand(lm)
        assert r.gesture == "disarm"
        assert r.confidence >= 0.60

    def test_confirm_thumbs_up(self):
        lm = _thumbs_up()
        r  = classify_hand(lm)
        assert r.gesture == "confirm"
        assert r.confidence >= 0.60

    def test_follow_me_peace(self):
        lm = _peace()
        r  = classify_hand(lm)
        assert r.gesture == "follow_me"
        assert r.confidence >= 0.60

    def test_greeting_wave(self):
        """Wave via WaveDetector should produce greeting gesture."""
        wd = WaveDetector(history_len=20, min_reversals=2, min_amplitude=0.08)
        lm = _open_palm()
        # Simulate 20 frames with oscillating wrist_x via different landmark sets
        r = HandGestureResult("none", 0.0, "", 0.5, 0.5)
        for i in range(20):
            # Rebuild landmark set with moving wrist x
            moving = list(lm)
            wx = 0.5 + 0.20 * math.sin(i * math.pi / 3)
            # WRIST is index 0 — move it
            moving[0] = Landmark(wx, moving[0].y)
            r = classify_hand(moving, is_right=True, wave_det=wd)
        assert r.gesture == "greeting"

    def test_flat_hand_no_crash(self):
        """A flat hand (all landmarks at 0.5, 0.5) has ambiguous geometry —
        verify it returns a valid gesture string without crashing."""
        _valid = {"stop", "point", "disarm", "confirm", "follow_me", "greeting", "none"}
        r = classify_hand(_flat_hand())
        assert r.gesture in _valid

    def test_wrist_position_in_result(self):
        lm = _open_palm()
        lm[0] = Landmark(0.3, 0.7)
        r = classify_hand(lm)
        assert r.wrist_x == pytest.approx(0.3)
        assert r.wrist_y == pytest.approx(0.7)

    def test_confirm_before_stop(self):
        """Thumbs-up should be classified as 'confirm', not 'stop'."""
        lm = _thumbs_up()
        r  = classify_hand(lm)
        assert r.gesture == "confirm"

    def test_follow_me_before_point(self):
        """Peace sign (2 fingers) should NOT be classified as 'point'."""
        lm = _peace()
        r  = classify_hand(lm)
        assert r.gesture == "follow_me"

    def test_wave_beats_static_gesture(self):
        """When wave is detected it should override any static gesture."""
        wd = WaveDetector(history_len=20, min_reversals=2, min_amplitude=0.08)
        # Pre-load enough waving frames
        for i in range(20):
            wx = 0.5 + 0.25 * math.sin(i * math.pi / 3)
            lm = _open_palm()
            lm[0] = Landmark(wx, lm[0].y)
            r = classify_hand(lm, wave_det=wd)
        # The open palm would normally be "stop" but wave has already triggered
        assert r.gesture == "greeting"

    def test_result_confidence_bounded(self):
        for lm_factory in [_open_palm, _point_up, _fist, _thumbs_up, _peace]:
            r = classify_hand(lm_factory())
            assert 0.0 <= r.confidence <= 1.0
