"""test_emotion_classifier.py — Unit tests for emotion_classifier (Issue #161).

Tests cover:
  - Emotion constant definitions
  - Softmax normalisation
  - PersonEmotionTracker: EMA smoothing, age expiry, opt-out, reset
  - Landmark heuristic classifier: geometric edge cases
  - EmotionClassifier: classify_crop fallback, classify_from_landmarks
  - Score renormalisation, argmax, utility helpers
  - Integration: full classify pipeline with mock engine

No ROS2, TensorRT, or OpenCV runtime required.
"""

import math
import pytest

from saltybot_social.emotion_classifier import (
    EMOTIONS,
    N_CLASSES,
    ClassifiedEmotion,
    PersonEmotionTracker,
    EmotionClassifier,
    classify_from_landmarks,
    _softmax,
    _argmax,
    _uniform_scores,
    _renorm,
)


# ── Helpers ───────────────────────────────────────────────────────────────────


def _lm_neutral() -> list:
    """5-point SCRFD landmarks for a frontal neutral face (normalised)."""
    return [
        0.35, 0.38,   # left_eye
        0.65, 0.38,   # right_eye
        0.50, 0.52,   # nose
        0.38, 0.72,   # left_mouth
        0.62, 0.72,   # right_mouth
    ]


def _lm_happy() -> list:
    """Wide mouth, symmetric corners, mouth well below nose."""
    return [
        0.35, 0.38,
        0.65, 0.38,
        0.50, 0.52,
        0.30, 0.74,   # wide mouth
        0.70, 0.74,
    ]


def _lm_sad() -> list:
    """Compressed mouth, tight width."""
    return [
        0.35, 0.38,
        0.65, 0.38,
        0.50, 0.52,
        0.44, 0.62,   # tight, close to nose
        0.56, 0.62,
    ]


def _lm_asymmetric() -> list:
    """Asymmetric mouth corners → disgust/sad signal."""
    return [
        0.35, 0.38,
        0.65, 0.38,
        0.50, 0.52,
        0.38, 0.65,
        0.62, 0.74,   # right corner lower → asymmetric
    ]


# ── TestEmotionConstants ──────────────────────────────────────────────────────


class TestEmotionConstants:
    def test_emotions_length(self):
        assert len(EMOTIONS) == 7

    def test_n_classes(self):
        assert N_CLASSES == 7

    def test_emotions_labels(self):
        expected = ["happy", "sad", "angry", "surprised", "fearful", "disgusted", "neutral"]
        assert EMOTIONS == expected

    def test_happy_index(self):
        assert EMOTIONS[0] == "happy"

    def test_neutral_index(self):
        assert EMOTIONS[6] == "neutral"

    def test_all_lowercase(self):
        for e in EMOTIONS:
            assert e == e.lower()


# ── TestSoftmax ───────────────────────────────────────────────────────────────


class TestSoftmax:
    def test_sums_to_one(self):
        logits = [1.0, 2.0, 0.5, -1.0, 3.0, 0.0, 1.5]
        result = _softmax(logits)
        assert abs(sum(result) - 1.0) < 1e-6

    def test_all_positive(self):
        logits = [1.0, 2.0, 3.0, 0.0, -1.0, -2.0, 0.5]
        result = _softmax(logits)
        assert all(s > 0 for s in result)

    def test_max_logit_gives_max_prob(self):
        logits = [0.0, 0.0, 10.0, 0.0, 0.0, 0.0, 0.0]
        result = _softmax(logits)
        assert _argmax(result) == 2

    def test_uniform_logits_uniform_probs(self):
        logits = [1.0] * 7
        result = _softmax(logits)
        for p in result:
            assert abs(p - 1.0 / 7.0) < 1e-6

    def test_length_preserved(self):
        logits = [0.0] * 7
        assert len(_softmax(logits)) == 7

    def test_numerically_stable_large_values(self):
        logits = [1000.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
        result = _softmax(logits)
        assert abs(result[0] - 1.0) < 1e-6


# ── TestArgmax ────────────────────────────────────────────────────────────────


class TestArgmax:
    def test_finds_max(self):
        assert _argmax([0.1, 0.2, 0.9, 0.3, 0.1, 0.1, 0.1]) == 2

    def test_single_element(self):
        assert _argmax([0.5]) == 0

    def test_first_when_all_equal(self):
        result = _argmax([0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5])
        assert result == 0


# ── TestUniformScores ─────────────────────────────────────────────────────────


class TestUniformScores:
    def test_length(self):
        assert len(_uniform_scores()) == N_CLASSES

    def test_sums_to_one(self):
        assert abs(sum(_uniform_scores()) - 1.0) < 1e-9

    def test_all_equal(self):
        s = _uniform_scores()
        assert all(abs(x - s[0]) < 1e-9 for x in s)


# ── TestRenorm ────────────────────────────────────────────────────────────────


class TestRenorm:
    def test_sums_to_one(self):
        s = [0.1, 0.2, 0.3, 0.1, 0.1, 0.1, 0.1]
        r = _renorm(s)
        assert abs(sum(r) - 1.0) < 1e-9

    def test_all_zero_returns_uniform(self):
        r = _renorm([0.0] * 7)
        assert len(r) == 7
        assert abs(sum(r) - 1.0) < 1e-9

    def test_preserves_order(self):
        s = [0.5, 0.3, 0.0, 0.0, 0.0, 0.0, 0.2]
        r = _renorm(s)
        assert r[0] > r[1] > r[6]


# ── TestPersonEmotionTracker ──────────────────────────────────────────────────


class TestPersonEmotionTracker:
    def _scores(self, idx: int, val: float = 0.8) -> list:
        s = [0.0] * N_CLASSES
        s[idx] = val
        total = val + (N_CLASSES - 1) * 0.02
        s = [v if i == idx else 0.02 for i, v in enumerate(s)]
        s[idx] = val
        t = sum(s)
        return [x / t for x in s]

    def test_first_call_returns_input(self):
        tracker = PersonEmotionTracker(alpha=0.5)
        scores = self._scores(0)
        result = tracker.smooth(1, scores)
        assert abs(result[0] - scores[0]) < 1e-9

    def test_ema_converges_toward_new_dominant(self):
        tracker = PersonEmotionTracker(alpha=0.5)
        happy = self._scores(0)
        sad   = self._scores(1)
        tracker.smooth(1, happy)
        # Push sad repeatedly
        prev_sad = tracker.smooth(1, sad)[1]
        for _ in range(20):
            result = tracker.smooth(1, sad)
        assert result[1] > prev_sad   # sad score increased

    def test_alpha_1_no_smoothing(self):
        tracker = PersonEmotionTracker(alpha=1.0)
        s1 = self._scores(0)
        s2 = self._scores(1)
        tracker.smooth(1, s1)
        result = tracker.smooth(1, s2)
        for a, b in zip(result, s2):
            assert abs(a - b) < 1e-9

    def test_alpha_0_frozen(self):
        tracker = PersonEmotionTracker(alpha=0.0)
        s1 = self._scores(0)
        s2 = self._scores(1)
        tracker.smooth(1, s1)
        result = tracker.smooth(1, s2)
        for a, b in zip(result, s1):
            assert abs(a - b) < 1e-9

    def test_different_persons_independent(self):
        tracker = PersonEmotionTracker(alpha=0.5)
        happy = self._scores(0)
        sad   = self._scores(1)
        tracker.smooth(1, happy)
        tracker.smooth(2, sad)
        r1 = tracker.smooth(1, happy)
        r2 = tracker.smooth(2, sad)
        assert r1[0] > r2[0]   # person 1 more happy
        assert r2[1] > r1[1]   # person 2 more sad

    def test_negative_person_id_no_tracking(self):
        tracker = PersonEmotionTracker()
        scores = self._scores(2)
        result = tracker.smooth(-1, scores)
        assert result == scores   # unchanged, not stored

    def test_reset_clears_ema(self):
        tracker = PersonEmotionTracker(alpha=0.5)
        s1 = self._scores(0)
        tracker.smooth(1, s1)
        tracker.reset(1)
        assert 1 not in tracker.tracked_ids

    def test_reset_all_clears_all(self):
        tracker = PersonEmotionTracker(alpha=0.5)
        for pid in range(5):
            tracker.smooth(pid, self._scores(pid % N_CLASSES))
        tracker.reset_all()
        assert tracker.tracked_ids == []

    def test_tracked_ids_populated(self):
        tracker = PersonEmotionTracker()
        tracker.smooth(10, self._scores(0))
        tracker.smooth(20, self._scores(1))
        assert set(tracker.tracked_ids) == {10, 20}

    def test_age_expiry_resets_ema(self):
        tracker = PersonEmotionTracker(alpha=0.5, max_age=3)
        tracker.smooth(1, self._scores(0))
        # Advance age beyond max_age
        for _ in range(4):
            tracker.tick()
        # Next smooth after expiry should reset (first call returns input unchanged)
        fresh_scores = self._scores(1)
        result = tracker.smooth(1, fresh_scores)
        # After reset, result should equal fresh_scores exactly
        for a, b in zip(result, fresh_scores):
            assert abs(a - b) < 1e-9


# ── TestOptOut ────────────────────────────────────────────────────────────────


class TestOptOut:
    def test_set_opt_out_true(self):
        tracker = PersonEmotionTracker()
        tracker.set_opt_out(42, True)
        assert tracker.is_opt_out(42)

    def test_set_opt_out_false(self):
        tracker = PersonEmotionTracker()
        tracker.set_opt_out(42, True)
        tracker.set_opt_out(42, False)
        assert not tracker.is_opt_out(42)

    def test_opt_out_clears_ema(self):
        tracker = PersonEmotionTracker()
        tracker.smooth(42, [0.5, 0.1, 0.1, 0.1, 0.1, 0.0, 0.1])
        assert 42 in tracker.tracked_ids
        tracker.set_opt_out(42, True)
        assert 42 not in tracker.tracked_ids

    def test_unknown_person_not_opted_out(self):
        tracker = PersonEmotionTracker()
        assert not tracker.is_opt_out(99)

    def test_multiple_opt_outs(self):
        tracker = PersonEmotionTracker()
        for pid in [1, 2, 3]:
            tracker.set_opt_out(pid, True)
        for pid in [1, 2, 3]:
            assert tracker.is_opt_out(pid)


# ── TestClassifyFromLandmarks ─────────────────────────────────────────────────


class TestClassifyFromLandmarks:
    def test_returns_classified_emotion(self):
        result = classify_from_landmarks(_lm_neutral())
        assert isinstance(result, ClassifiedEmotion)

    def test_emotion_is_valid_label(self):
        result = classify_from_landmarks(_lm_neutral())
        assert result.emotion in EMOTIONS

    def test_scores_length(self):
        result = classify_from_landmarks(_lm_neutral())
        assert len(result.scores) == N_CLASSES

    def test_scores_sum_to_one(self):
        result = classify_from_landmarks(_lm_neutral())
        # Scores are rounded to 4 dp; allow 1e-3 accumulation across 7 terms
        assert abs(sum(result.scores) - 1.0) < 1e-3

    def test_confidence_matches_top_score(self):
        result = classify_from_landmarks(_lm_neutral())
        # confidence is round(score, 3) and max score is round(s, 4) → ≤0.5e-3 diff
        assert abs(result.confidence - max(result.scores)) < 5e-3

    def test_source_is_landmark_heuristic(self):
        result = classify_from_landmarks(_lm_neutral())
        assert result.source == "landmark_heuristic"

    def test_happy_landmarks_boost_happy(self):
        happy = classify_from_landmarks(_lm_happy())
        neutral = classify_from_landmarks(_lm_neutral())
        # Happy landmarks should give relatively higher happy score
        assert happy.scores[0] >= neutral.scores[0]

    def test_sad_landmarks_suppress_happy(self):
        sad_result = classify_from_landmarks(_lm_sad())
        # Happy score should be relatively low for sad landmarks
        assert sad_result.scores[0] < 0.5

    def test_asymmetric_mouth_non_neutral(self):
        asym = classify_from_landmarks(_lm_asymmetric())
        # Asymmetric → disgust or sad should be elevated
        assert asym.scores[5] > 0.10 or asym.scores[1] > 0.10

    def test_empty_landmarks_returns_neutral(self):
        result = classify_from_landmarks([])
        assert result.emotion == "neutral"

    def test_short_landmarks_returns_neutral(self):
        result = classify_from_landmarks([0.5, 0.5])
        assert result.emotion == "neutral"

    def test_all_positive_scores(self):
        result = classify_from_landmarks(_lm_happy())
        assert all(s >= 0.0 for s in result.scores)

    def test_confidence_in_range(self):
        result = classify_from_landmarks(_lm_neutral())
        assert 0.0 <= result.confidence <= 1.0


# ── TestEmotionClassifier ─────────────────────────────────────────────────────


class TestEmotionClassifier:
    def test_init_no_engine_not_ready(self):
        clf = EmotionClassifier(engine_path="")
        assert not clf.ready

    def test_load_empty_path_returns_false(self):
        clf = EmotionClassifier(engine_path="")
        assert clf.load() is False

    def test_load_nonexistent_path_returns_false(self):
        clf = EmotionClassifier(engine_path="/nonexistent/engine.trt")
        result = clf.load()
        assert result is False

    def test_classify_crop_fallback_without_engine(self):
        """Without TRT engine, classify_crop should return a valid result
        with landmark heuristic source."""
        clf = EmotionClassifier(engine_path="")
        # Build a minimal synthetic 48x48 BGR image
        try:
            import numpy as np
            fake_crop = np.zeros((48, 48, 3), dtype="uint8")
            result = clf.classify_crop(fake_crop, person_id=-1, tracker=None)
            assert isinstance(result, ClassifiedEmotion)
            assert result.emotion in EMOTIONS
            assert 0.0 <= result.confidence <= 1.0
            assert len(result.scores) == N_CLASSES
        except ImportError:
            pytest.skip("numpy not available")

    def test_classify_from_landmarks_delegates(self):
        clf = EmotionClassifier(engine_path="")
        result = clf.classify_from_landmarks(_lm_happy())
        assert isinstance(result, ClassifiedEmotion)
        assert result.source == "landmark_heuristic"

    def test_classify_from_landmarks_with_tracker_smooths(self):
        clf = EmotionClassifier(engine_path="", alpha=0.5)
        tracker = PersonEmotionTracker(alpha=0.5)
        r1 = clf.classify_from_landmarks(_lm_happy(), person_id=1, tracker=tracker)
        r2 = clf.classify_from_landmarks(_lm_sad(), person_id=1, tracker=tracker)
        # After smoothing, r2's top score should not be identical to raw sad scores
        # (EMA blends r1 history into r2)
        raw_sad = classify_from_landmarks(_lm_sad())
        assert r2.scores != raw_sad.scores

    def test_classify_from_landmarks_no_tracker_no_smooth(self):
        clf = EmotionClassifier(engine_path="")
        r1 = clf.classify_from_landmarks(_lm_happy(), person_id=1, tracker=None)
        r2 = clf.classify_from_landmarks(_lm_happy(), person_id=1, tracker=None)
        # Without tracker, same input → same output
        assert r1.scores == r2.scores

    def test_source_fallback_when_no_engine(self):
        try:
            import numpy as np
        except ImportError:
            pytest.skip("numpy not available")
        clf = EmotionClassifier(engine_path="")
        crop = __import__("numpy").zeros((48, 48, 3), dtype="uint8")
        result = clf.classify_crop(crop)
        assert result.source == "landmark_heuristic"

    def test_classifier_alpha_propagated_to_tracker(self):
        clf = EmotionClassifier(engine_path="", alpha=0.1)
        assert clf._alpha == 0.1


# ── TestClassifiedEmotionDataclass ───────────────────────────────────────────


class TestClassifiedEmotionDataclass:
    def test_fields(self):
        ce = ClassifiedEmotion(
            emotion="happy",
            confidence=0.85,
            scores=[0.85, 0.02, 0.02, 0.03, 0.02, 0.02, 0.04],
            source="cnn_trt",
        )
        assert ce.emotion == "happy"
        assert ce.confidence == 0.85
        assert len(ce.scores) == 7
        assert ce.source == "cnn_trt"

    def test_default_source(self):
        ce = ClassifiedEmotion("neutral", 0.6, [0.0] * 7)
        assert ce.source == "cnn_trt"

    def test_emotion_is_mutable(self):
        ce = ClassifiedEmotion("neutral", 0.6, [0.0] * 7)
        ce.emotion = "happy"
        assert ce.emotion == "happy"


# ── TestEdgeCases ─────────────────────────────────────────────────────────────


class TestEdgeCases:
    def test_softmax_single_element(self):
        result = _softmax([5.0])
        assert abs(result[0] - 1.0) < 1e-9

    def test_tracker_negative_id_no_stored_state(self):
        tracker = PersonEmotionTracker()
        scores = [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.4]
        tracker.smooth(-1, scores)
        assert tracker.tracked_ids == []

    def test_tick_increments_age(self):
        tracker = PersonEmotionTracker(max_age=2)
        tracker.smooth(1, [0.1] * 7)
        tracker.tick()
        tracker.tick()
        tracker.tick()
        # Age should exceed max_age → next smooth resets
        fresh = [0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0]
        result = tracker.smooth(1, fresh)
        assert abs(result[2] - 1.0) < 1e-9

    def test_renorm_negative_scores_safe(self):
        # Negative scores shouldn't crash (though unusual in practice)
        scores = [0.1, 0.2, 0.0, 0.0, 0.0, 0.0, 0.3]
        r = _renorm(scores)
        assert abs(sum(r) - 1.0) < 1e-9

    def test_landmark_heuristic_very_close_eye_mouth(self):
        # Degenerate face where everything is at same y → should not crash
        lm = [0.3, 0.5, 0.7, 0.5, 0.5, 0.5, 0.4, 0.5, 0.6, 0.5]
        result = classify_from_landmarks(lm)
        assert result.emotion in EMOTIONS

    def test_opt_out_then_re_enable(self):
        tracker = PersonEmotionTracker()
        tracker.smooth(5, [0.1] * 7)
        tracker.set_opt_out(5, True)
        assert tracker.is_opt_out(5)
        tracker.set_opt_out(5, False)
        assert not tracker.is_opt_out(5)
        # Should be able to smooth again after re-enable
        result = tracker.smooth(5, [0.2] * 7)
        assert len(result) == N_CLASSES
