"""Tests for hand_slots.route_hands and GestureSlotStabilizer."""
from __future__ import annotations

import pytest
from data_only_viz.hand_slots import route_hands, GestureSlotStabilizer


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _hand(cx: float = 0.5, size: float = 0.15) -> list:
    """21-landmark hand (list of [x, y, z]). Wrist at cx, middle-MCP 'size' above."""
    lm = [[cx, 0.5, 0.0] for _ in range(21)]
    lm[0] = [cx, 0.6, 0.0]           # wrist
    lm[9] = [cx, 0.6 - size, 0.0]    # middle-MCP -> hand_size == size
    return lm


def _small_hand(cx: float = 0.5) -> list:
    """Hand with size 0.02 (below default near_min 0.10)."""
    return _hand(cx=cx, size=0.02)


# ---------------------------------------------------------------------------
# Basic chirality routing
# ---------------------------------------------------------------------------

def test_chirality_routes_left_to_slot0():
    h_l = _hand(cx=0.8)   # camera-right = user's left (mirror)
    h_r = _hand(cx=0.2)
    result = route_hands([h_l, h_r], [0, 1])
    assert result[0] is h_l
    assert result[1] is h_r


def test_chirality_routes_right_to_slot1():
    h_r = _hand(cx=0.2)
    result = route_hands([h_r], [1])
    assert result[0] is None
    assert result[1] is h_r


def test_chirality_first_match_wins_per_slot():
    """Two hands with chirality==0: first wins, second dropped."""
    h1 = _hand(cx=0.3)
    h2 = _hand(cx=0.7)
    result = route_hands([h1, h2], [0, 0])
    assert result[0] is h1
    assert result[1] is None


# ---------------------------------------------------------------------------
# Chirality misaligned → cx fallback
# ---------------------------------------------------------------------------

def test_chirality_misaligned_uses_cx_fallback():
    h_l = _hand(cx=0.2)
    h_r = _hand(cx=0.8)
    # chirality length 1 != hands length 2 → misaligned
    result = route_hands([h_l, h_r], [0])
    # cx fallback: ascending cx order → cx=0.2 → slot 0, cx=0.8 → slot 1
    assert result[0] is h_l
    assert result[1] is h_r


def test_chirality_none_uses_cx_fallback():
    h_l = _hand(cx=0.2)
    h_r = _hand(cx=0.8)
    result = route_hands([h_l, h_r], None)
    assert result[0] is h_l
    assert result[1] is h_r


# ---------------------------------------------------------------------------
# mirror flips fallback order
# ---------------------------------------------------------------------------

def test_mirror_flips_cx_fallback_order():
    h_a = _hand(cx=0.2)   # low cx = screen-left normally
    h_b = _hand(cx=0.8)
    # Without mirror: slot0=cx0.2, slot1=cx0.8
    plain = route_hands([h_a, h_b])
    assert plain[0] is h_a
    # With mirror: flip cx → 1-0.2=0.8, 1-0.8=0.2 → sort ascending → cx0.8 goes slot0
    mirrored = route_hands([h_a, h_b], mirror=True)
    assert mirrored[0] is h_b
    assert mirrored[1] is h_a


# ---------------------------------------------------------------------------
# swap: inverts the CHIRALITY interpretation only (source chirality flipped);
# the cx fallback is screen-relative and must never be affected.
# ---------------------------------------------------------------------------

def test_swap_inverts_chirality_mapping():
    h_l = _hand(cx=0.8)  # Vision says left (chir=0) but source is inverted
    h_r = _hand(cx=0.2)  # Vision says right (chir=1)
    result = route_hands([h_l, h_r], [0, 1], swap=True)
    assert result[0] is h_r   # chir 1 -> left slot under swap
    assert result[1] is h_l   # chir 0 -> right slot under swap


def test_swap_does_not_alter_cx_fallback():
    """swap only concerns chirality; the cx fallback stays screen-relative."""
    h_a = _hand(cx=0.2)
    h_b = _hand(cx=0.8)
    result = route_hands([h_a, h_b], None, swap=True)
    assert result[0] is h_a   # unchanged: leftmost keeps slot 0
    assert result[1] is h_b


def test_swap_single_hand_moves_slot():
    """A lone chir==0 hand lands in slot 1 when swap is on."""
    h = _hand(cx=0.5)
    result = route_hands([h], [0], swap=True)
    assert result[0] is None
    assert result[1] is h


# ---------------------------------------------------------------------------
# near_min gate
# ---------------------------------------------------------------------------

def test_near_min_drops_small_hand():
    big = _hand(cx=0.3, size=0.15)
    small = _hand(cx=0.7, size=0.02)
    result = route_hands([big, small], near_min=0.10)
    assert result[0] is big
    assert result[1] is None   # too small


def test_near_min_preserves_big_slot():
    big = _hand(cx=0.5, size=0.20)
    result = route_hands([big], near_min=0.10)
    assert result[0] is big


def test_near_min_drops_both_when_all_small():
    s1 = _small_hand(cx=0.3)
    s2 = _small_hand(cx=0.7)
    result = route_hands([s1, s2], near_min=0.10)
    assert result == [None, None]


# ---------------------------------------------------------------------------
# Invalid hand filtering (alignment preserved)
# ---------------------------------------------------------------------------

def test_invalid_hand_skipped_chirality_alignment_preserved():
    """An invalid hand at index 0 must not shift chirality for index 1."""
    bad = None                        # invalid
    h_r = _hand(cx=0.5)
    # chirality: bad=0(ignored), h_r=1(right)
    result = route_hands([bad, h_r], [0, 1])
    assert result[0] is None
    assert result[1] is h_r


def test_short_hand_rejected():
    short = [[0.5, 0.5, 0.0]] * 10   # <21 landmarks
    h_ok = _hand(cx=0.5)
    result = route_hands([short, h_ok], [0, 1])
    assert result[0] is None          # short dropped
    assert result[1] is h_ok


# ---------------------------------------------------------------------------
# Edge cases
# ---------------------------------------------------------------------------

def test_empty_hands_returns_none_pair():
    assert route_hands([]) == [None, None]


def test_single_hand_no_chirality_slot0():
    h = _hand(cx=0.5)
    result = route_hands([h])
    assert result[0] is h
    assert result[1] is None


def test_two_hands_chirality_all_left():
    """Both hands claim left; first gets slot 0, second is dropped."""
    h1 = _hand(cx=0.2)
    h2 = _hand(cx=0.8)
    result = route_hands([h1, h2], [0, 0])
    assert result[0] is h1
    assert result[1] is None


def test_result_always_length_2():
    for hands, chir in [
        ([], None),
        ([_hand()], None),
        ([_hand(), _hand(cx=0.8)], [0, 1]),
        ([_hand(), _hand(cx=0.8), _hand(cx=0.3)], [0, 1, 0]),
    ]:
        r = route_hands(hands, chir)
        assert len(r) == 2, f"expected 2 slots, got {len(r)} for {chir}"


# ---------------------------------------------------------------------------
# GestureSlotStabilizer
# ---------------------------------------------------------------------------

def _near_hand(cx: float = 0.5, size: float = 0.15) -> list:
    """Near hand (size >= near_on=0.10) that also passes the face gate.

    kp[5]/kp[17] spread by 0.12 → facing = 0.12/0.15 = 0.8 >= face_min=0.5.
    All tests that verify near-hysteresis / hold / resumed behavior should use
    this helper so they continue to pass after the facing gate was added.
    """
    lm = _hand(cx=cx, size=size)
    lm[5] = [cx - 0.06, 0.5, 0.0]   # index-MCP
    lm[17] = [cx + 0.06, 0.5, 0.0]  # pinky-MCP → spread=0.12, facing≈0.8
    return lm


def _sideon_near_hand(cx: float = 0.5, size: float = 0.15) -> list:
    """Near hand (size >= near_on) whose kp[5]==kp[17] → facing=0.0 (fails face gate).

    Use this for tests that specifically verify the face gate rejects a side-on
    palm even when the hand is close enough to pass the near gate.
    """
    return _hand(cx=cx, size=size)


def _far_hand(cx: float = 0.5) -> list:
    """Hand with hand_size == 0.02 (below near_on=0.10)."""
    return _hand(cx=cx, size=0.02)


def test_stab_hold_last_one_frame_hole():
    """A 1-frame Vision hole keeps the last hand in the slot."""
    stab = GestureSlotStabilizer(hold_frames=2, near_on=0.10, near_off=0.08)
    h = _near_hand()
    stab.step([h, None])    # activate slot 0
    stab.step([h, None])    # slot 0 established
    out = stab.step([None, None])  # miss=1 <= hold -> slot 0 holds last hand
    assert out[0] is h, "slot 0 should hold last hand on 1-frame miss"
    assert out[1] is None


def test_stab_hold_last_two_frame_hole():
    """A 2-frame Vision hole keeps the last hand both frames."""
    stab = GestureSlotStabilizer(hold_frames=2, near_on=0.10, near_off=0.08)
    h = _near_hand()
    stab.step([h, None])
    out1 = stab.step([None, None])  # miss=1
    out2 = stab.step([None, None])  # miss=2 <= hold=2
    assert out1[0] is h
    assert out2[0] is h


def test_stab_slot_goes_none_after_hold_frames():
    """After hold_frames+1 consecutive misses the slot yields None."""
    stab = GestureSlotStabilizer(hold_frames=2, near_on=0.10, near_off=0.08)
    h = _near_hand()
    stab.step([h, None])
    stab.step([None, None])   # miss=1
    stab.step([None, None])   # miss=2 (still holding)
    out = stab.step([None, None])  # miss=3 > hold=2 -> None for real
    assert out[0] is None


def test_stab_hysteresis_stays_active_above_near_off():
    """Once active, hand stays active while size >= near_off."""
    stab = GestureSlotStabilizer(hold_frames=2, near_on=0.10, near_off=0.08)
    h_on = _near_hand(size=0.11)   # size >= near_on -> activates
    h_mid = _near_hand(size=0.09)  # near_off <= size < near_on -> stays active
    out1 = stab.step([h_on, None])
    assert out1[0] is h_on
    out2 = stab.step([h_mid, None])
    assert out2[0] is h_mid, "should stay active at size 0.09 (>= near_off 0.08)"


def test_stab_hysteresis_deactivates_below_near_off():
    """Hand deactivates when size drops below near_off."""
    stab = GestureSlotStabilizer(hold_frames=2, near_on=0.10, near_off=0.08)
    h_on = _near_hand(size=0.11)
    h_off = _near_hand(size=0.07)   # size < near_off -> deactivate
    stab.step([h_on, None])
    out = stab.step([h_off, None])
    assert out[0] is None, "slot should deactivate below near_off"


def test_stab_hysteresis_needs_near_on_to_reactivate():
    """After deactivation, size must reach near_on again to re-activate."""
    stab = GestureSlotStabilizer(hold_frames=2, near_on=0.10, near_off=0.08)
    h_on  = _near_hand(size=0.11)
    h_off = _near_hand(size=0.07)   # deactivates
    h_mid = _near_hand(size=0.09)   # between near_off and near_on -> stays inactive
    h_on2 = _near_hand(size=0.11)   # back to near_on -> reactivates
    stab.step([h_on, None])
    stab.step([h_off, None])         # deactivated
    out_mid = stab.step([h_mid, None])
    assert out_mid[0] is None, "should stay inactive at 0.09 after deactivation"
    out_on2 = stab.step([h_on2, None])
    assert out_on2[0] is h_on2, "should re-activate at near_on"


def test_stab_fresh_far_hand_returns_none():
    """A far hand on a fresh stabilizer yields None (not near)."""
    stab = GestureSlotStabilizer(near_on=0.10, near_off=0.08)
    h = _far_hand()
    out = stab.step([h, None])
    assert out[0] is None


def test_stab_active_flags_reflect_current_output():
    """active_flags() matches the last step() output per slot."""
    stab = GestureSlotStabilizer(near_on=0.10, near_off=0.08)
    h = _near_hand()
    stab.step([h, None])
    flags = stab.active_flags()
    assert flags == (True, False)
    stab.step([None, None])          # miss=1 -> hold
    flags2 = stab.active_flags()
    assert flags2 == (True, False)   # still holding


# ---------------------------------------------------------------------------
# GestureSlotStabilizer.resumed_flags
# ---------------------------------------------------------------------------

def test_resumed_flags_true_exactly_one_step_on_reappear():
    """resumed_flags True on the one step that transitions held -> real."""
    stab = GestureSlotStabilizer(hold_frames=2, near_on=0.10, near_off=0.08)
    h = _near_hand()
    h2 = _near_hand(cx=0.4)
    stab.step([h, None])              # activate slot 0
    stab.step([None, None])           # miss=1 -> held replay
    stab.step([h2, None])             # real hand returns -> resumed
    assert stab.resumed_flags()[0] is True, "should be True on first real frame after hold"
    # next step, still real -> False
    stab.step([h2, None])
    assert stab.resumed_flags()[0] is False, "should be False once hand stays real"


def test_resumed_flags_false_steady():
    """Continuously present hand -> resumed_flags never True."""
    stab = GestureSlotStabilizer(hold_frames=2, near_on=0.10, near_off=0.08)
    h = _near_hand()
    stab.step([h, None])
    stab.step([h, None])
    assert stab.resumed_flags()[0] is False


def test_resumed_flags_false_after_expired_hold():
    """Hand returning after hold window expired does not trigger resumed (slot went None)."""
    stab = GestureSlotStabilizer(hold_frames=2, near_on=0.10, near_off=0.08)
    h = _near_hand()
    h2 = _near_hand(cx=0.4)
    stab.step([h, None])              # activate
    stab.step([None, None])           # miss=1, held
    stab.step([None, None])           # miss=2, held
    stab.step([None, None])           # miss=3 > hold -> near_active cleared, out=None
    stab.step([h2, None])             # reappear after expired hold
    # _held_flag was False (hold expired), so resumed must be False
    assert stab.resumed_flags()[0] is False, "expired hold: held_flag reset, no resume"


def test_resumed_flags_independent_per_slot():
    """Each slot tracks held/resumed independently."""
    stab = GestureSlotStabilizer(hold_frames=2, near_on=0.10, near_off=0.08)
    h0 = _near_hand(cx=0.3)
    h1 = _near_hand(cx=0.7)
    h0b = _near_hand(cx=0.3)
    stab.step([h0, h1])               # activate both
    stab.step([None, h1])             # slot 0 held, slot 1 real
    stab.step([h0b, h1])              # slot 0 resumed, slot 1 unchanged
    r = stab.resumed_flags()
    assert r[0] is True,  "slot 0 just transitioned held -> real"
    assert r[1] is False, "slot 1 was never held"


# ---------------------------------------------------------------------------
# GestureSlotStabilizer — face gate
# ---------------------------------------------------------------------------

def test_stab_face_gate_sideon_never_activates():
    """A side-on hand (facing=0.0) never activates the slot even when near."""
    stab = GestureSlotStabilizer(hold_frames=2, near_on=0.10, near_off=0.08)
    h = _sideon_near_hand()   # size=0.15 >= near_on, but facing=0.0 < face_min=0.5
    out1 = stab.step([h, None])
    out2 = stab.step([h, None])
    assert out1[0] is None, "side-on: slot must be None (face gate rejects)"
    assert out2[0] is None, "side-on: slot still None after two frames"
    assert stab.active_flags() == (False, False)


def test_stab_face_gate_facing_activates():
    """A near, camera-facing hand activates the slot (passes both gates)."""
    stab = GestureSlotStabilizer(hold_frames=2, near_on=0.10, near_off=0.08)
    h = _near_hand()   # size=0.15 >= near_on, facing=0.8 >= face_min=0.5
    stab.step([h, None])
    out = stab.step([h, None])
    assert out[0] is h, "near+facing hand must be in slot 0"
    assert stab.active_flags() == (True, False)


def test_stab_face_gate_hysteresis_stays_active_between_thresholds():
    """Once active, slot stays active when facing drops between face_off and face_min.

    face_min=0.5, face_off=0.4.  A hand that was facing (0.8) then turns
    slightly side-on to facing=0.43 must NOT deactivate (hysteresis protects
    against flapping).  Only a drop below face_off (0.4) would deactivate.
    """
    stab = GestureSlotStabilizer(hold_frames=2, near_on=0.10, near_off=0.08)
    # Activate: near+facing hand
    h_facing = _near_hand()               # facing=0.8
    stab.step([h_facing, None])
    assert stab.step([h_facing, None])[0] is h_facing

    # Build a hand with facing=0.43 (between face_off=0.4 and face_min=0.5)
    # size=0.15, spread=0.43*0.15=0.0645
    h_mid = _hand()
    h_mid[5] = [0.5 - 0.032, 0.5, 0.0]
    h_mid[17] = [0.5 + 0.032, 0.5, 0.0]   # dist≈0.064/0.15≈0.43

    out = stab.step([h_mid, None])
    assert out[0] is h_mid, "hysteresis: slot stays active between face_off and face_min"
    assert stab.active_flags() == (True, False)
