"""
test_face_emotion.py — Unit tests for the geometric face emotion classifier.

All tests use synthetic FaceLandmarks constructed with known geometry —
no camera, no MediaPipe, no ROS2 runtime needed.

Coordinate convention: x ∈ [0,1] (left→right), y ∈ [0,1] (top→bottom).

Test face dimensions
--------------------
  forehead : (0.50, 0.20)   ─┐
  chin     : (0.50, 0.70)   ─┘ face_height = 0.50

  Neutral eye/brow/mouth positions chosen so all emotion scores are below
  their trigger thresholds (verified analytically below each fixture).
"""

import pytest

from saltybot_bringup._face_emotion import (
    EMOTION_LABELS,
    FaceLandmarks,
    EmotionFeatures,
    EmotionResult,
    compute_features,
    classify_emotion,
    detect_emotion,
    MOUTH_UPPER, MOUTH_LOWER, MOUTH_LEFT, MOUTH_RIGHT,
    L_EYE_TOP, L_EYE_BOT, R_EYE_TOP, R_EYE_BOT,
    L_BROW_INNER, L_BROW_OUTER, R_BROW_INNER, R_BROW_OUTER,
    CHIN, FOREHEAD,
    _T_SURPRISED_BROW, _T_SURPRISED_EYE, _T_SURPRISED_MOUTH,
    _T_HAPPY_SMILE, _T_ANGRY_FURL, _T_SAD_FROWN,
)


# ─────────────────────────────────────────────────────────────────────────────
# Fixture helpers
# ─────────────────────────────────────────────────────────────────────────────

# face_height = 0.70 - 0.20 = 0.50
_FOREHEAD = (0.50, 0.20)
_CHIN     = (0.50, 0.70)
_FH       = 0.50   # face_height


def _neutral_face() -> FaceLandmarks:
    """
    Neutral expression:
      mouth_open  = (0.57-0.55)/0.50 = 0.04   < 0.07 (not open)
      smile       = (0.56-0.56)/0.50 = 0.00   in [-0.025, 0.025]
      brow_raise  = (0.38-0.33)/0.50 = 0.10   < 0.12 (not raised enough)
      eye_open    = (0.42-0.38)/0.50 = 0.08   > 0.07 — but surprised needs ALL 3
      brow_furl   = (0.33-0.34)/0.50 = -0.02  < 0.02 (not furrowed)
    → neutral
    """
    return FaceLandmarks(
        mouth_upper  = (0.50, 0.55),
        mouth_lower  = (0.50, 0.57),
        mouth_left   = (0.46, 0.56),
        mouth_right  = (0.54, 0.56),
        l_eye_top    = (0.44, 0.38),
        l_eye_bot    = (0.44, 0.42),
        r_eye_top    = (0.56, 0.38),
        r_eye_bot    = (0.56, 0.42),
        l_eye_left   = (0.41, 0.40),
        l_eye_right  = (0.47, 0.40),
        r_eye_left   = (0.53, 0.40),
        r_eye_right  = (0.59, 0.40),
        l_brow_inner = (0.46, 0.33),
        l_brow_outer = (0.41, 0.34),
        r_brow_inner = (0.54, 0.33),
        r_brow_outer = (0.59, 0.34),
        chin         = _CHIN,
        forehead     = _FOREHEAD,
    )


def _happy_face() -> FaceLandmarks:
    """
    Happy: corners raised to y=0.54, mid_y=0.56 → smile=(0.56-0.54)/0.50=0.04 > 0.025
    All other signals stay below their surprised/angry/sad thresholds.
    """
    fl = _neutral_face()
    fl.mouth_left  = (0.46, 0.54)   # corners above midpoint
    fl.mouth_right = (0.54, 0.54)
    return fl


def _surprised_face() -> FaceLandmarks:
    """
    Surprised:
      brow_raise  = (0.38-0.26)/0.50 = 0.24 > 0.12
      eye_open    = (0.45-0.38)/0.50 = 0.14 > 0.07
      mouth_open  = (0.65-0.55)/0.50 = 0.20 > 0.07
    """
    fl = _neutral_face()
    fl.l_brow_inner = (0.46, 0.26)   # brows raised high
    fl.r_brow_inner = (0.54, 0.26)
    fl.l_brow_outer = (0.41, 0.27)
    fl.r_brow_outer = (0.59, 0.27)
    fl.l_eye_bot    = (0.44, 0.45)   # eyes wide open
    fl.r_eye_bot    = (0.56, 0.45)
    fl.mouth_lower  = (0.50, 0.65)   # mouth open
    return fl


def _angry_face() -> FaceLandmarks:
    """
    Angry:
      brow_furl  = (0.37-0.30)/0.50 = 0.14 > 0.02
      smile      = (0.56-0.56)/0.50 = 0.00 < 0.01  (neutral mouth)
      brow_raise = (0.38-0.37)/0.50 = 0.02 < 0.12  (not surprised)
    """
    fl = _neutral_face()
    fl.l_brow_inner = (0.46, 0.37)   # inner brow lowered (toward eye)
    fl.r_brow_inner = (0.54, 0.37)
    fl.l_brow_outer = (0.41, 0.30)   # outer brow raised
    fl.r_brow_outer = (0.59, 0.30)
    return fl


def _sad_face() -> FaceLandmarks:
    """
    Sad:
      smile     = (0.56-0.59)/0.50 = -0.06 < -0.025
      brow_furl = (0.33-0.34)/0.50 = -0.02 < 0.015  (not furrowed → not angry)
    """
    fl = _neutral_face()
    fl.mouth_left  = (0.46, 0.59)   # corners below midpoint (frown)
    fl.mouth_right = (0.54, 0.59)
    return fl


# ─────────────────────────────────────────────────────────────────────────────
# EMOTION_LABELS
# ─────────────────────────────────────────────────────────────────────────────

def test_emotion_labels_tuple():
    assert isinstance(EMOTION_LABELS, tuple)


def test_emotion_labels_count():
    assert len(EMOTION_LABELS) == 5


def test_emotion_labels_values():
    assert set(EMOTION_LABELS) == {'neutral', 'happy', 'surprised', 'angry', 'sad'}


def test_emotion_labels_neutral_first():
    assert EMOTION_LABELS[0] == 'neutral'


# ─────────────────────────────────────────────────────────────────────────────
# Landmark index constants
# ─────────────────────────────────────────────────────────────────────────────

def test_landmark_indices_in_range():
    indices = [
        MOUTH_UPPER, MOUTH_LOWER, MOUTH_LEFT, MOUTH_RIGHT,
        L_EYE_TOP, L_EYE_BOT, R_EYE_TOP, R_EYE_BOT,
        L_BROW_INNER, L_BROW_OUTER, R_BROW_INNER, R_BROW_OUTER,
        CHIN, FOREHEAD,
    ]
    for idx in indices:
        assert 0 <= idx < 468, f'Landmark index {idx} out of range'


def test_landmark_indices_unique():
    indices = [
        MOUTH_UPPER, MOUTH_LOWER, MOUTH_LEFT, MOUTH_RIGHT,
        L_EYE_TOP, L_EYE_BOT, R_EYE_TOP, R_EYE_BOT,
        L_BROW_INNER, L_BROW_OUTER, R_BROW_INNER, R_BROW_OUTER,
        CHIN, FOREHEAD,
    ]
    assert len(set(indices)) == len(indices)


# ─────────────────────────────────────────────────────────────────────────────
# compute_features — neutral face
# ─────────────────────────────────────────────────────────────────────────────

def test_features_face_height():
    f = compute_features(_neutral_face())
    assert abs(f.face_height - 0.50) < 1e-6


def test_features_mouth_open_neutral():
    f = compute_features(_neutral_face())
    # (0.57 - 0.55) / 0.50 = 0.04
    assert abs(f.mouth_open - 0.04) < 1e-6


def test_features_smile_neutral():
    f = compute_features(_neutral_face())
    # mid_y=0.56, corner_y=0.56 → smile=0.0
    assert abs(f.smile) < 1e-6


def test_features_brow_raise_neutral():
    f = compute_features(_neutral_face())
    # (0.38 - 0.33) / 0.50 = 0.10
    assert abs(f.brow_raise - 0.10) < 1e-6


def test_features_brow_furl_neutral():
    f = compute_features(_neutral_face())
    # l_furl = (0.33 - 0.34) / 0.50 = -0.02 per side → mean = -0.02
    assert abs(f.brow_furl - (-0.02)) < 1e-6


def test_features_eye_open_neutral():
    f = compute_features(_neutral_face())
    # (0.42 - 0.38) / 0.50 = 0.08
    assert abs(f.eye_open - 0.08) < 1e-6


def test_features_mouth_open_non_negative():
    """Mouth open must always be ≥ 0."""
    # Invert lips (impossible geometry)
    fl = _neutral_face()
    fl.mouth_upper = (0.50, 0.60)
    fl.mouth_lower = (0.50, 0.55)
    f = compute_features(fl)
    assert f.mouth_open == 0.0


def test_features_eye_open_non_negative():
    fl = _neutral_face()
    fl.l_eye_top = (0.44, 0.42)
    fl.l_eye_bot = (0.44, 0.38)
    f = compute_features(fl)
    assert f.eye_open >= 0.0


# ─────────────────────────────────────────────────────────────────────────────
# compute_features — happy face
# ─────────────────────────────────────────────────────────────────────────────

def test_features_smile_happy():
    f = compute_features(_happy_face())
    # mid_y=0.56, corner_y=0.54 → smile=(0.56-0.54)/0.50=0.04
    assert abs(f.smile - 0.04) < 1e-6


def test_features_smile_positive_happy():
    f = compute_features(_happy_face())
    assert f.smile > _T_HAPPY_SMILE


# ─────────────────────────────────────────────────────────────────────────────
# compute_features — surprised face
# ─────────────────────────────────────────────────────────────────────────────

def test_features_brow_raise_surprised():
    f = compute_features(_surprised_face())
    # (0.38 - 0.26) / 0.50 = 0.24
    assert abs(f.brow_raise - 0.24) < 1e-6


def test_features_brow_raise_above_threshold():
    f = compute_features(_surprised_face())
    assert f.brow_raise > _T_SURPRISED_BROW


def test_features_eye_open_surprised():
    f = compute_features(_surprised_face())
    # (0.45 - 0.38) / 0.50 = 0.14
    assert abs(f.eye_open - 0.14) < 1e-6


def test_features_mouth_open_surprised():
    f = compute_features(_surprised_face())
    # (0.65 - 0.55) / 0.50 = 0.20
    assert abs(f.mouth_open - 0.20) < 1e-6


# ─────────────────────────────────────────────────────────────────────────────
# compute_features — angry face
# ─────────────────────────────────────────────────────────────────────────────

def test_features_brow_furl_angry():
    f = compute_features(_angry_face())
    # l_furl = (0.37 - 0.30) / 0.50 = 0.14 per side → mean = 0.14
    assert abs(f.brow_furl - 0.14) < 1e-6


def test_features_brow_furl_above_threshold():
    f = compute_features(_angry_face())
    assert f.brow_furl > _T_ANGRY_FURL


def test_features_smile_near_zero_angry():
    f = compute_features(_angry_face())
    assert abs(f.smile) < 0.005


# ─────────────────────────────────────────────────────────────────────────────
# compute_features — sad face
# ─────────────────────────────────────────────────────────────────────────────

def test_features_smile_sad():
    f = compute_features(_sad_face())
    # mid_y=0.56, corner_y=0.59 → smile=(0.56-0.59)/0.50=-0.06
    assert abs(f.smile - (-0.06)) < 1e-6


def test_features_smile_negative_sad():
    f = compute_features(_sad_face())
    assert f.smile < -_T_SAD_FROWN


# ─────────────────────────────────────────────────────────────────────────────
# classify_emotion
# ─────────────────────────────────────────────────────────────────────────────

def test_classify_returns_known_label():
    feat = compute_features(_neutral_face())
    label, _ = classify_emotion(feat)
    assert label in EMOTION_LABELS


def test_classify_confidence_range():
    for make in [_neutral_face, _happy_face, _surprised_face,
                 _angry_face, _sad_face]:
        feat = compute_features(make())
        _, conf = classify_emotion(feat)
        assert 0.0 < conf <= 1.0, f'{make.__name__}: confidence={conf}'


def test_classify_neutral():
    feat = compute_features(_neutral_face())
    label, conf = classify_emotion(feat)
    assert label == 'neutral', f'Expected neutral, got {label!r}'
    assert conf > 0.0


def test_classify_happy():
    feat = compute_features(_happy_face())
    label, conf = classify_emotion(feat)
    assert label == 'happy', f'Expected happy, got {label!r}'


def test_classify_surprised():
    feat = compute_features(_surprised_face())
    label, conf = classify_emotion(feat)
    assert label == 'surprised', f'Expected surprised, got {label!r}'


def test_classify_angry():
    feat = compute_features(_angry_face())
    label, conf = classify_emotion(feat)
    assert label == 'angry', f'Expected angry, got {label!r}'


def test_classify_sad():
    feat = compute_features(_sad_face())
    label, conf = classify_emotion(feat)
    assert label == 'sad', f'Expected sad, got {label!r}'


def test_classify_surprised_priority_over_happy():
    """Surprised should win even if smile is also positive."""
    fl = _surprised_face()
    fl.mouth_left  = (0.46, 0.54)   # add smile too
    fl.mouth_right = (0.54, 0.54)
    feat = compute_features(fl)
    label, _ = classify_emotion(feat)
    assert label == 'surprised'


# ─────────────────────────────────────────────────────────────────────────────
# detect_emotion — end-to-end
# ─────────────────────────────────────────────────────────────────────────────

def test_detect_returns_result():
    res = detect_emotion(_neutral_face())
    assert isinstance(res, EmotionResult)


def test_detect_neutral():
    res = detect_emotion(_neutral_face())
    assert res.emotion == 'neutral'


def test_detect_happy():
    res = detect_emotion(_happy_face())
    assert res.emotion == 'happy'


def test_detect_surprised():
    res = detect_emotion(_surprised_face())
    assert res.emotion == 'surprised'


def test_detect_angry():
    res = detect_emotion(_angry_face())
    assert res.emotion == 'angry'


def test_detect_sad():
    res = detect_emotion(_sad_face())
    assert res.emotion == 'sad'


def test_detect_confidence_range():
    for make in [_neutral_face, _happy_face, _surprised_face,
                 _angry_face, _sad_face]:
        res = detect_emotion(make())
        assert 0.0 < res.confidence <= 1.0, (
            f'{make.__name__}: confidence={res.confidence}'
        )


def test_detect_features_attached():
    res = detect_emotion(_neutral_face())
    assert isinstance(res.features, EmotionFeatures)


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

def test_zero_face_height_no_crash():
    """Degenerate face (chin == forehead) must not crash."""
    fl = _neutral_face()
    fl.forehead = fl.chin  # face_height → ~1e-4 clamp
    res = detect_emotion(fl)
    assert res.emotion in EMOTION_LABELS


def test_extreme_smile_caps_confidence():
    """Wildly exaggerated smile should not push confidence above 1.0."""
    fl = _neutral_face()
    fl.mouth_left  = (0.46, 0.20)   # corners pulled very high
    fl.mouth_right = (0.54, 0.20)
    res = detect_emotion(fl)
    assert res.confidence <= 1.0


def test_extreme_brow_raise_caps_confidence():
    fl = _surprised_face()
    fl.l_brow_inner = (0.46, 0.01)   # brows at top of image
    fl.r_brow_inner = (0.54, 0.01)
    res = detect_emotion(fl)
    assert res.confidence <= 1.0


def test_gradual_smile_crosses_threshold():
    """Smile just above threshold → happy; just below → neutral."""
    fl = _neutral_face()
    # Just below threshold: smile < _T_HAPPY_SMILE
    offset = _T_HAPPY_SMILE * _FH * 0.5   # half the threshold distance
    mid_y = 0.56
    fl.mouth_left  = (0.46, mid_y - offset)
    fl.mouth_right = (0.54, mid_y - offset)
    res_below = detect_emotion(fl)

    # Just above threshold: smile > _T_HAPPY_SMILE
    offset_above = _T_HAPPY_SMILE * _FH * 1.5
    fl.mouth_left  = (0.46, mid_y - offset_above)
    fl.mouth_right = (0.54, mid_y - offset_above)
    res_above = detect_emotion(fl)

    assert res_below.emotion == 'neutral'
    assert res_above.emotion == 'happy'


def test_gradual_frown_crosses_threshold():
    """Frown just above threshold → sad; just below → neutral."""
    fl = _neutral_face()
    mid_y = 0.56
    offset_below = _T_SAD_FROWN * _FH * 0.5
    fl.mouth_left  = (0.46, mid_y + offset_below)
    fl.mouth_right = (0.54, mid_y + offset_below)
    res_below = detect_emotion(fl)

    offset_above = _T_SAD_FROWN * _FH * 1.5
    fl.mouth_left  = (0.46, mid_y + offset_above)
    fl.mouth_right = (0.54, mid_y + offset_above)
    res_above = detect_emotion(fl)

    assert res_below.emotion == 'neutral'
    assert res_above.emotion == 'sad'


def test_angry_requires_no_smile():
    """If brows are furrowed but face is smiling, should not be angry."""
    fl = _angry_face()
    fl.mouth_left  = (0.46, 0.54)   # add a clear smile
    fl.mouth_right = (0.54, 0.54)
    res = detect_emotion(fl)
    assert res.emotion != 'angry'


def test_symmetry_independent():
    """One-sided brow raise still contributes to brow_raise metric."""
    fl = _neutral_face()
    fl.l_brow_inner = (0.46, 0.26)   # left brow raised
    # right brow unchanged at 0.33
    f = compute_features(fl)
    # brow_raise = avg of left (0.24) and right (0.10) = 0.17 > 0.12
    assert f.brow_raise > _T_SURPRISED_BROW
