"""test_gesture_classifier.py — Unit tests for gesture_classifier (Issue #140).

Tests cover:
  - All 7 hand gestures with multiple landmark configurations
  - All 3 body-pose gestures
  - WaveDetector temporal detection
  - Entity extraction (direction for point/follow)
  - enabled allowlist filtering
  - Edge cases: too few landmarks, marginal values, near-misses

No ROS2 or MediaPipe runtime required.
"""

import pytest
from saltybot_social.gesture_classifier import (
    Landmark,
    ClassifiedGesture,
    WaveDetector,
    classify_hand,
    classify_pose,
    _finger_up,
    _thumb_up,
    _thumb_down,
    _four_fingers_curled,
    _count_fingers_up,
    _point_direction,
)


# ── Landmark builder helpers ──────────────────────────────────────────────────


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


def _make_hand_landmarks(
    fingers_up: list = None,   # True/False per [index, middle, ring, pinky]
    thumb_up: bool = False,
    thumb_down: bool = False,
    wrist_y: float = 0.8,
    palm_height: float = 0.2,
) -> list:
    """
    Build a synthetic 21-landmark hand with controllable finger states.

    fingers_up:  [index, middle, ring, pinky] — True = finger extended
    thumb_up:    thumb tip is above wrist
    thumb_down:  thumb tip is below wrist
    wrist_y:     y-coordinate of the wrist (image-space, 0=top, 1=bottom)
    palm_height: vertical extent of the palm
    """
    if fingers_up is None:
        fingers_up = [False, False, False, False]

    lm = [None] * 21

    mcp_y = wrist_y - palm_height * 0.5    # MCP knuckles above wrist
    pip_y = mcp_y - palm_height * 0.2      # PIP above MCP
    tip_y_up = pip_y - palm_height * 0.3   # TIP above PIP (extended)
    tip_y_dn = pip_y + palm_height * 0.1   # TIP below PIP (curled)

    # Wrist
    lm[0] = _lm(0.5, wrist_y)

    # Thumb (CMC → MCP → IP → TIP)
    lm[1] = _lm(0.45, wrist_y - palm_height * 0.05)   # CMC
    lm[2] = _lm(0.42, wrist_y - palm_height * 0.15)   # MCP
    lm[3] = _lm(0.38, wrist_y - palm_height * 0.25)   # IP
    if thumb_up:
        lm[4] = _lm(0.35, wrist_y - palm_height * 0.50)  # TIP high up
    elif thumb_down:
        lm[4] = _lm(0.48, wrist_y + 0.06)               # TIP below wrist
    else:
        lm[4] = _lm(0.40, wrist_y - palm_height * 0.10)  # tucked

    # Finger MCP, PIP, DIP, TIP — four fingers evenly spaced across x
    finger_x = [0.45, 0.50, 0.55, 0.60]
    finger_offsets = [  # (mcp, pip, dip, tip) indices
        (5, 6, 7, 8),
        (9, 10, 11, 12),
        (13, 14, 15, 16),
        (17, 18, 19, 20),
    ]
    for fi, (mcp_i, pip_i, dip_i, tip_i) in enumerate(finger_offsets):
        x = finger_x[fi]
        lm[mcp_i] = _lm(x, mcp_y)
        lm[pip_i] = _lm(x, pip_y)
        lm[dip_i] = _lm(x, (pip_y + tip_y_up) / 2 if fingers_up[fi] else pip_y - 0.01)
        lm[tip_i] = _lm(x, tip_y_up if fingers_up[fi] else tip_y_dn)

    return lm


def _make_pose_landmarks(
    arms_up: bool = False,
    arms_spread: bool = False,
    crouching: bool = False,
    shoulder_y: float = 0.30,
) -> list:
    """Build synthetic 33-landmark body pose."""
    lm = [_lm(0.5, 0.5)] * 33

    hip_y = 0.55 if not crouching else 0.75
    knee_y = hip_y + 0.15

    # Shoulders
    lm[11] = _lm(0.35, shoulder_y)   # L shoulder
    lm[12] = _lm(0.65, shoulder_y)   # R shoulder

    if arms_up:
        lm[15] = _lm(0.30, shoulder_y - 0.20)  # L wrist above L shoulder
        lm[16] = _lm(0.70, shoulder_y - 0.20)  # R wrist above R shoulder
    elif arms_spread:
        lm[13] = _lm(0.20, shoulder_y + 0.05)  # L elbow out
        lm[14] = _lm(0.80, shoulder_y + 0.05)  # R elbow out
        lm[15] = _lm(0.10, shoulder_y + 0.02)  # L wrist far left
        lm[16] = _lm(0.90, shoulder_y + 0.02)  # R wrist far right
    else:
        lm[15] = _lm(0.35, shoulder_y + 0.20)  # L wrist at side
        lm[16] = _lm(0.65, shoulder_y + 0.20)  # R wrist at side

    lm[23] = _lm(0.40, hip_y)   # L hip
    lm[24] = _lm(0.60, hip_y)   # R hip
    lm[25] = _lm(0.40, knee_y)  # L knee
    lm[26] = _lm(0.60, knee_y)  # R knee
    lm[27] = _lm(0.40, knee_y + 0.15)  # L ankle
    lm[28] = _lm(0.60, knee_y + 0.15)  # R ankle

    return lm


# ── Helper tests ──────────────────────────────────────────────────────────────


class TestFingerHelpers:
    def test_finger_up_when_tip_above_pip(self):
        lm = _make_hand_landmarks(fingers_up=[True, False, False, False])
        assert _finger_up(lm, 8, 6) is True   # index tip above pip

    def test_finger_down_when_tip_below_pip(self):
        lm = _make_hand_landmarks(fingers_up=[False, False, False, False])
        assert _finger_up(lm, 8, 6) is False

    def test_count_fingers_all_up(self):
        lm = _make_hand_landmarks(fingers_up=[True, True, True, True])
        assert _count_fingers_up(lm) == 4

    def test_count_fingers_none_up(self):
        lm = _make_hand_landmarks(fingers_up=[False, False, False, False])
        assert _count_fingers_up(lm) == 0

    def test_count_fingers_two_up(self):
        lm = _make_hand_landmarks(fingers_up=[True, True, False, False])
        assert _count_fingers_up(lm) == 2

    def test_four_fingers_curled_all_down(self):
        lm = _make_hand_landmarks(fingers_up=[False, False, False, False])
        assert _four_fingers_curled(lm) is True

    def test_four_fingers_not_curled_one_up(self):
        lm = _make_hand_landmarks(fingers_up=[True, False, False, False])
        assert _four_fingers_curled(lm) is False

    def test_thumb_up_when_tip_high(self):
        lm = _make_hand_landmarks(thumb_up=True)
        assert _thumb_up(lm, True) is True

    def test_thumb_not_up_when_tucked(self):
        lm = _make_hand_landmarks(thumb_up=False)
        assert _thumb_up(lm, True) is False

    def test_thumb_down_when_tip_below_wrist(self):
        lm = _make_hand_landmarks(thumb_down=True)
        assert _thumb_down(lm) is True

    def test_thumb_not_down_when_tucked(self):
        lm = _make_hand_landmarks(thumb_up=False, thumb_down=False)
        assert _thumb_down(lm) is False


# ── Hand gesture: stop_palm ───────────────────────────────────────────────────


class TestStopPalm:
    def test_all_fingers_up(self):
        lm = _make_hand_landmarks(fingers_up=[True, True, True, True])
        r = classify_hand(lm)
        assert r is not None
        assert r.gesture_type == "stop_palm"

    def test_confidence_range(self):
        lm = _make_hand_landmarks(fingers_up=[True, True, True, True])
        r = classify_hand(lm)
        assert 0.60 <= r.confidence <= 1.0

    def test_three_fingers_does_not_trigger(self):
        lm = _make_hand_landmarks(fingers_up=[True, True, True, False])
        r = classify_hand(lm)
        assert r is None or r.gesture_type != "stop_palm"

    def test_source_is_hand(self):
        lm = _make_hand_landmarks(fingers_up=[True, True, True, True])
        r = classify_hand(lm)
        assert r.source == "hand"


# ── Hand gesture: thumbs_up ───────────────────────────────────────────────────


class TestThumbsUp:
    def test_basic(self):
        lm = _make_hand_landmarks(
            fingers_up=[False, False, False, False], thumb_up=True
        )
        r = classify_hand(lm)
        assert r is not None
        assert r.gesture_type == "thumbs_up"

    def test_confidence_range(self):
        lm = _make_hand_landmarks(
            fingers_up=[False, False, False, False], thumb_up=True
        )
        r = classify_hand(lm)
        assert 0.60 <= r.confidence <= 1.0

    def test_thumb_up_with_fingers_open_not_thumbs_up(self):
        # If fingers are also open → stop_palm wins in priority
        lm = _make_hand_landmarks(
            fingers_up=[True, True, True, True], thumb_up=True
        )
        r = classify_hand(lm)
        assert r.gesture_type == "stop_palm"

    def test_right_hand_flag(self):
        lm = _make_hand_landmarks(
            fingers_up=[False, False, False, False], thumb_up=True
        )
        r = classify_hand(lm, is_right_hand=True)
        assert r.is_right_hand is True

    def test_left_hand_flag(self):
        lm = _make_hand_landmarks(
            fingers_up=[False, False, False, False], thumb_up=True
        )
        r = classify_hand(lm, is_right_hand=False)
        assert r.is_right_hand is False


# ── Hand gesture: thumbs_down ─────────────────────────────────────────────────


class TestThumbsDown:
    def test_basic(self):
        lm = _make_hand_landmarks(
            fingers_up=[False, False, False, False], thumb_down=True
        )
        r = classify_hand(lm)
        assert r is not None
        assert r.gesture_type == "thumbs_down"

    def test_confidence_range(self):
        lm = _make_hand_landmarks(
            fingers_up=[False, False, False, False], thumb_down=True
        )
        r = classify_hand(lm)
        assert 0.60 <= r.confidence <= 1.0

    def test_fingers_open_prevents_thumbs_down(self):
        lm = _make_hand_landmarks(
            fingers_up=[True, True, True, True], thumb_down=True
        )
        r = classify_hand(lm)
        # stop_palm takes priority
        assert r is None or r.gesture_type != "thumbs_down"


# ── Hand gesture: point ───────────────────────────────────────────────────────


class TestPoint:
    def test_index_only_up(self):
        lm = _make_hand_landmarks(fingers_up=[True, False, False, False])
        r = classify_hand(lm)
        assert r is not None
        assert r.gesture_type == "point"

    def test_direction_right(self):
        """Index tip to the right of MCP → direction='right'."""
        lm = _make_hand_landmarks(fingers_up=[True, False, False, False])
        # Override index tip to point right (TIP above PIP, dx=0.15 ≤ 0.20 threshold)
        lm[8] = _lm(lm[5].x + 0.15, lm[6].y - 0.02)
        r = classify_hand(lm)
        assert r is not None
        assert r.gesture_type == "point"
        assert r.direction == "right"

    def test_direction_up(self):
        """Index tip significantly above MCP → direction='up'."""
        lm = _make_hand_landmarks(fingers_up=[True, False, False, False])
        lm[8] = _lm(lm[5].x, lm[5].y - 0.20)  # far above MCP
        r = classify_hand(lm)
        assert r is not None
        assert r.direction == "up"

    def test_two_fingers_up_not_point(self):
        lm = _make_hand_landmarks(fingers_up=[True, True, False, False])
        r = classify_hand(lm)
        # With 2 fingers up, point classifier should reject
        assert r is None or r.gesture_type != "point"

    def test_confidence_range(self):
        lm = _make_hand_landmarks(fingers_up=[True, False, False, False])
        r = classify_hand(lm)
        assert 0.60 <= r.confidence <= 1.0


# ── Hand gesture: come_here ───────────────────────────────────────────────────


class TestComeHere:
    def _make_beckoning_hand(self) -> list:
        """Index MCP raised but TIP curled below PIP."""
        lm = _make_hand_landmarks(
            fingers_up=[False, False, False, False],
            wrist_y=0.85,
            palm_height=0.25,
        )
        # Index MCP should be above wrist
        assert lm[5].y < lm[0].y  # sanity: MCP.y < WRIST.y (above in image)
        # Force TIP to be BELOW MCP (clearly curled — satisfies MCP+0.03 threshold)
        lm[8] = _lm(lm[5].x, lm[5].y + 0.06)   # tip 0.06 below MCP
        return lm

    def test_beckoning_detected(self):
        lm = self._make_beckoning_hand()
        r = classify_hand(lm)
        assert r is not None
        assert r.gesture_type == "come_here"

    def test_confidence_range(self):
        lm = self._make_beckoning_hand()
        r = classify_hand(lm)
        assert 0.60 <= r.confidence <= 1.0

    def test_open_hand_not_come_here(self):
        lm = _make_hand_landmarks(fingers_up=[True, True, True, True])
        r = classify_hand(lm)
        assert r is None or r.gesture_type != "come_here"


# ── Hand gesture: follow ──────────────────────────────────────────────────────


class TestFollow:
    def _make_follow_hand(self, direction: str = "right") -> list:
        """Index pointing horizontally, others curled."""
        lm = _make_hand_landmarks(fingers_up=[True, False, False, False])
        # Move index TIP far to the right/left of MCP (dx=0.25 > 0.20 threshold → follow)
        # TIP above PIP so _finger_up returns True; dy=0.06 keeps dx >> dy
        if direction == "right":
            lm[8] = _lm(lm[5].x + 0.25, lm[6].y - 0.02)
        else:
            lm[8] = _lm(lm[5].x - 0.25, lm[6].y - 0.02)
        return lm

    def test_follow_right(self):
        lm = self._make_follow_hand("right")
        r = classify_hand(lm)
        assert r is not None
        assert r.gesture_type == "follow"
        assert r.direction == "right"

    def test_follow_left(self):
        lm = self._make_follow_hand("left")
        r = classify_hand(lm)
        assert r is not None
        assert r.gesture_type == "follow"
        assert r.direction == "left"

    def test_vertical_point_not_follow(self):
        lm = _make_hand_landmarks(fingers_up=[True, False, False, False])
        # TIP straight up — dy >> dx
        lm[8] = _lm(lm[5].x, lm[5].y - 0.30)
        r = classify_hand(lm)
        # Should be "point", not "follow"
        assert r is None or r.gesture_type == "point"

    def test_confidence_range(self):
        lm = self._make_follow_hand()
        r = classify_hand(lm)
        assert r is not None
        assert 0.60 <= r.confidence <= 1.0


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


class TestWaveDetector:
    def test_not_waving_insufficient_data(self):
        det = WaveDetector()
        for x in [0.4, 0.5]:
            waving, conf = det.push(x)
        assert waving is False
        assert conf == 0.0

    def test_wave_oscillation_detected(self):
        det = WaveDetector(history_len=20, min_reversals=2, min_amplitude=0.06)
        xs = [0.3, 0.5, 0.3, 0.5, 0.3, 0.5, 0.3, 0.5, 0.3, 0.5]
        for x in xs:
            waving, conf = det.push(x)
        assert waving is True
        assert conf > 0.0

    def test_small_amplitude_not_wave(self):
        det = WaveDetector(min_amplitude=0.10)
        # Tiny oscillation — amplitude < threshold
        xs = [0.50, 0.51, 0.50, 0.51, 0.50, 0.51, 0.50, 0.51, 0.50, 0.51]
        for x in xs:
            waving, conf = det.push(x)
        assert waving is False

    def test_confidence_increases_with_larger_amplitude(self):
        det_small = WaveDetector()
        det_large = WaveDetector()
        for x in [0.40, 0.60, 0.40, 0.60, 0.40, 0.60, 0.40, 0.60]:
            _, c_small = det_small.push(x)
        for x in [0.20, 0.80, 0.20, 0.80, 0.20, 0.80, 0.20, 0.80]:
            _, c_large = det_large.push(x)
        assert c_large >= c_small

    def test_reset_clears_history(self):
        det = WaveDetector()
        for x in [0.3, 0.7, 0.3, 0.7, 0.3, 0.7, 0.3, 0.7, 0.3, 0.7]:
            det.push(x)
        det.reset()
        waving, conf = det.push(0.5)
        assert waving is False

    def test_wave_via_classify_hand(self):
        """Wave detector triggered through classify_hand API."""
        det = WaveDetector(min_reversals=2, min_amplitude=0.06)
        xs = [0.3, 0.6, 0.3, 0.6, 0.3, 0.6, 0.3, 0.6, 0.3, 0.6]
        result = None
        for x in xs:
            lm = _make_hand_landmarks()
            lm[0] = _lm(x, lm[0].y)  # move wrist X
            result = classify_hand(lm, wave_det=det)
        assert result is not None
        assert result.gesture_type == "wave"


# ── Body-pose: arms_up ────────────────────────────────────────────────────────


class TestArmsUp:
    def test_both_arms_raised(self):
        lm = _make_pose_landmarks(arms_up=True)
        r = classify_pose(lm)
        assert r is not None
        assert r.gesture_type == "arms_up"

    def test_confidence_range(self):
        lm = _make_pose_landmarks(arms_up=True)
        r = classify_pose(lm)
        assert 0.60 <= r.confidence <= 1.0

    def test_arms_at_side_not_arms_up(self):
        lm = _make_pose_landmarks(arms_up=False, arms_spread=False)
        r = classify_pose(lm)
        assert r is None or r.gesture_type != "arms_up"

    def test_source_is_body_pose(self):
        lm = _make_pose_landmarks(arms_up=True)
        r = classify_pose(lm)
        assert r.source == "body_pose"


# ── Body-pose: arms_spread ────────────────────────────────────────────────────


class TestArmsSpread:
    def test_arms_extended_laterally(self):
        lm = _make_pose_landmarks(arms_spread=True)
        r = classify_pose(lm)
        assert r is not None
        assert r.gesture_type in ("arms_spread", "arms_up")

    def test_confidence_range(self):
        lm = _make_pose_landmarks(arms_spread=True)
        r = classify_pose(lm)
        if r and r.gesture_type == "arms_spread":
            assert 0.55 <= r.confidence <= 1.0

    def test_arms_at_rest_not_spread(self):
        lm = _make_pose_landmarks()
        r = classify_pose(lm)
        assert r is None or r.gesture_type not in ("arms_spread", "arms_up")


# ── Body-pose: crouch ─────────────────────────────────────────────────────────


class TestCrouch:
    def test_crouching_detected(self):
        lm = _make_pose_landmarks(crouching=True)
        r = classify_pose(lm)
        assert r is not None
        assert r.gesture_type == "crouch"

    def test_standing_not_crouch(self):
        lm = _make_pose_landmarks(crouching=False)
        r = classify_pose(lm)
        assert r is None or r.gesture_type != "crouch"

    def test_confidence_range(self):
        lm = _make_pose_landmarks(crouching=True)
        r = classify_pose(lm)
        assert 0.55 <= r.confidence <= 1.0

    def test_source_is_body_pose(self):
        lm = _make_pose_landmarks(crouching=True)
        r = classify_pose(lm)
        assert r.source == "body_pose"


# ── Enabled allowlist filtering ───────────────────────────────────────────────


class TestEnabledFilter:
    def test_blocked_gesture_returns_none(self):
        lm = _make_hand_landmarks(fingers_up=[True, True, True, True])
        r = classify_hand(lm, enabled={"thumbs_up"})
        assert r is None

    def test_allowed_gesture_passes(self):
        lm = _make_hand_landmarks(fingers_up=[True, True, True, True])
        r = classify_hand(lm, enabled={"stop_palm"})
        assert r is not None
        assert r.gesture_type == "stop_palm"

    def test_none_enabled_means_all(self):
        lm = _make_hand_landmarks(fingers_up=[True, True, True, True])
        r = classify_hand(lm, enabled=None)
        assert r is not None

    def test_pose_filter_arms_up_blocked(self):
        lm = _make_pose_landmarks(arms_up=True)
        r = classify_pose(lm, enabled={"crouch"})
        assert r is None

    def test_pose_filter_arms_up_allowed(self):
        lm = _make_pose_landmarks(arms_up=True)
        r = classify_pose(lm, enabled={"arms_up"})
        assert r is not None
        assert r.gesture_type == "arms_up"


# ── Edge cases ────────────────────────────────────────────────────────────────


class TestEdgeCases:
    def test_empty_landmarks_returns_none(self):
        assert classify_hand([]) is None

    def test_too_few_landmarks_returns_none(self):
        lm = [_lm(0.5, 0.5)] * 10  # only 10, need 21
        assert classify_hand(lm) is None

    def test_too_few_pose_landmarks_returns_none(self):
        lm = [_lm(0.5, 0.5)] * 10
        assert classify_pose(lm) is None

    def test_no_wave_detector_no_wave(self):
        """Without a WaveDetector, wave is never returned."""
        lm = _make_hand_landmarks()
        r = classify_hand(lm, wave_det=None)
        assert r is None or r.gesture_type != "wave"

    def test_all_fingers_down_thumb_neutral_returns_none(self):
        lm = _make_hand_landmarks(
            fingers_up=[False, False, False, False],
            thumb_up=False, thumb_down=False,
        )
        r = classify_hand(lm)
        assert r is None

    def test_hand_x_y_populated(self):
        lm = _make_hand_landmarks(fingers_up=[True, True, True, True])
        r = classify_hand(lm)
        assert r is not None
        assert 0.0 <= r.hand_x <= 1.0
        assert 0.0 <= r.hand_y <= 1.0

    def test_is_right_hand_default_true(self):
        lm = _make_hand_landmarks(
            fingers_up=[False, False, False, False], thumb_up=True
        )
        r = classify_hand(lm)
        assert r.is_right_hand is True

    def test_wave_confidence_zero_for_no_wave(self):
        det = WaveDetector()
        # Stationary wrist — no wave
        for _ in range(24):
            waving, conf = det.push(0.5)
        assert waving is False
        assert conf == 0.0


# ── Point direction edge cases ────────────────────────────────────────────────


class TestPointDirection:
    def _hand_pointing(self, dx: float, dy: float) -> list:
        lm = _make_hand_landmarks(fingers_up=[True, False, False, False])
        # Set index MCP and TIP to produce the desired vector
        lm[5] = _lm(0.5, 0.5)
        lm[8] = _lm(0.5 + dx, 0.5 + dy)
        return lm

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

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

    def test_pointing_up(self):
        lm = self._hand_pointing(dx=0.0, dy=-0.2)
        assert _point_direction(lm) == "up"

    def test_pointing_down(self):
        lm = self._hand_pointing(dx=0.0, dy=0.2)
        assert _point_direction(lm) == "down"

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