"""
tests/test_eval_metrics.py — Unit tests for cortex/eval/metrics.py

Tests:
  - _normalise helper
  - exact_match
  - fuzzy_match (token_f1 method)
  - token_f1_score
  - compute_qa_metrics
  - precision_recall_f1
  - mean_reciprocal_rank
  - latency_stats
"""
from __future__ import annotations

import pytest
from cortex.eval.metrics import (
    _normalise,
    exact_match,
    fuzzy_match,
    token_f1_score,
    compute_qa_metrics,
    precision_recall_f1,
    mean_reciprocal_rank,
    latency_stats,
)


# ---------------------------------------------------------------------------
# _normalise
# ---------------------------------------------------------------------------

class TestNormalise:
    def test_lowercase(self):
        assert _normalise("Hello World") == "hello world"

    def test_strip_punctuation(self):
        assert _normalise("hello, world!") == "hello world"

    def test_collapse_whitespace(self):
        assert _normalise("hello   world") == "hello world"

    def test_strip_edges(self):
        assert _normalise("  hello  ") == "hello"

    def test_numbers_preserved(self):
        assert _normalise("40 miles") == "40 miles"


# ---------------------------------------------------------------------------
# exact_match
# ---------------------------------------------------------------------------

class TestExactMatch:
    def test_identical(self):
        assert exact_match("hiking", "hiking") is True

    def test_case_insensitive_normalise(self):
        assert exact_match("Hiking", "hiking") is True

    def test_punctuation_stripped(self):
        assert exact_match("hiking!", "hiking") is True

    def test_mismatch(self):
        assert exact_match("hiking", "running") is False

    def test_empty_both(self):
        assert exact_match("", "") is True

    def test_empty_vs_nonempty(self):
        assert exact_match("", "hiking") is False

    def test_no_normalise(self):
        assert exact_match("Hiking", "hiking", normalise=False) is False
        assert exact_match("hiking", "hiking", normalise=False) is True


# ---------------------------------------------------------------------------
# fuzzy_match
# ---------------------------------------------------------------------------

class TestFuzzyMatch:
    def test_exact_is_fuzzy(self):
        assert fuzzy_match("hiking", "hiking") is True

    def test_subset_match(self):
        # "40 miles" vs "I run 40 miles a week" — overlap on "40 miles"
        assert fuzzy_match("40 miles", "I run 40 miles a week", threshold=0.5) is True

    def test_no_overlap(self):
        assert fuzzy_match("hiking", "swimming") is False

    def test_threshold_boundary(self):
        # "hello world" vs "hello there" → overlap = {"hello"} → f1 = 2*(0.5*0.5)/(0.5+0.5) = 0.5
        assert fuzzy_match("hello world", "hello there", threshold=0.5) is True
        assert fuzzy_match("hello world", "hello there", threshold=0.6) is False

    def test_empty_prediction(self):
        assert fuzzy_match("", "hiking") is False

    def test_empty_reference(self):
        assert fuzzy_match("hiking", "") is False

    def test_rapidfuzz_fallback(self):
        # rapidfuzz method falls back gracefully if not installed
        # (this just shouldn't raise)
        try:
            result = fuzzy_match("hello", "hello world", threshold=0.5, method="rapidfuzz")
            assert isinstance(result, bool)
        except Exception as e:
            pytest.fail(f"rapidfuzz fallback raised: {e}")

    def test_invalid_method(self):
        with pytest.raises(ValueError, match="Unknown fuzzy method"):
            fuzzy_match("hello", "hello", method="invalid")


# ---------------------------------------------------------------------------
# token_f1_score
# ---------------------------------------------------------------------------

class TestTokenF1Score:
    def test_perfect(self):
        assert token_f1_score("hiking", "hiking") == pytest.approx(1.0)

    def test_zero(self):
        assert token_f1_score("hiking", "swimming") == pytest.approx(0.0)

    def test_partial(self):
        # "boston marathon" vs "boston"
        score = token_f1_score("boston marathon", "boston")
        assert 0.0 < score < 1.0

    def test_empty_prediction(self):
        assert token_f1_score("", "hiking") == pytest.approx(0.0)

    def test_empty_reference(self):
        assert token_f1_score("hiking", "") == pytest.approx(0.0)

    def test_both_empty(self):
        assert token_f1_score("", "") == pytest.approx(0.0)


# ---------------------------------------------------------------------------
# compute_qa_metrics
# ---------------------------------------------------------------------------

class TestComputeQAMetrics:
    def test_all_exact(self):
        preds = ["hiking", "40 miles", "boston"]
        refs = ["hiking", "40 miles", "boston"]
        m = compute_qa_metrics(preds, refs)
        assert m["exact_match_accuracy"] == pytest.approx(1.0)
        assert m["fuzzy_match_accuracy"] == pytest.approx(1.0)
        assert m["avg_token_f1"] == pytest.approx(1.0)
        assert m["n"] == 3

    def test_all_wrong(self):
        preds = ["swimming", "running"]
        refs = ["hiking", "cycling"]
        m = compute_qa_metrics(preds, refs)
        assert m["exact_match_accuracy"] == pytest.approx(0.0)
        assert m["n"] == 2

    def test_mixed(self):
        preds = ["hiking", "swimming"]
        refs = ["hiking", "hiking"]
        m = compute_qa_metrics(preds, refs)
        assert m["exact_match_accuracy"] == pytest.approx(0.5)
        assert m["n"] == 2

    def test_empty(self):
        m = compute_qa_metrics([], [])
        assert m["n"] == 0
        assert m["exact_match_accuracy"] == pytest.approx(0.0)

    def test_length_mismatch(self):
        with pytest.raises(ValueError):
            compute_qa_metrics(["a", "b"], ["a"])

    def test_llm_judge_called(self):
        calls = []

        def fake_judge(question, prediction, reference):
            calls.append((question, prediction, reference))
            return 1.0 if prediction == reference else 0.0

        preds = ["hiking", "running"]
        refs = ["hiking", "hiking"]
        questions = ["q1", "q2"]
        m = compute_qa_metrics(preds, refs, llm_judge=fake_judge, questions=questions)

        assert len(calls) == 2
        assert "avg_llm_judge_score" in m
        assert m["avg_llm_judge_score"] == pytest.approx(0.5)


# ---------------------------------------------------------------------------
# precision_recall_f1
# ---------------------------------------------------------------------------

class TestPrecisionRecallF1:
    def test_perfect(self):
        prf = precision_recall_f1(["a", "b", "c"], ["a", "b", "c"])
        assert prf["precision"] == pytest.approx(1.0)
        assert prf["recall"] == pytest.approx(1.0)
        assert prf["f1"] == pytest.approx(1.0)

    def test_no_overlap(self):
        prf = precision_recall_f1(["d", "e"], ["a", "b"])
        assert prf["precision"] == pytest.approx(0.0)
        assert prf["recall"] == pytest.approx(0.0)
        assert prf["f1"] == pytest.approx(0.0)

    def test_partial(self):
        prf = precision_recall_f1(["a", "b", "c"], ["a", "b"])
        assert prf["precision"] == pytest.approx(2 / 3)
        assert prf["recall"] == pytest.approx(1.0)

    def test_empty_retrieved(self):
        prf = precision_recall_f1([], ["a", "b"])
        assert prf["precision"] == pytest.approx(0.0)
        assert prf["recall"] == pytest.approx(0.0)

    def test_empty_relevant(self):
        prf = precision_recall_f1(["a"], [])
        assert prf["precision"] == pytest.approx(0.0)
        assert prf["recall"] == pytest.approx(0.0)


# ---------------------------------------------------------------------------
# mean_reciprocal_rank
# ---------------------------------------------------------------------------

class TestMRR:
    def test_first_hit(self):
        ranked = [["a", "b", "c"]]
        relevant = [{"a"}]
        assert mean_reciprocal_rank(ranked, relevant) == pytest.approx(1.0)

    def test_second_hit(self):
        ranked = [["x", "a", "b"]]
        relevant = [{"a"}]
        assert mean_reciprocal_rank(ranked, relevant) == pytest.approx(0.5)

    def test_no_hit(self):
        ranked = [["x", "y"]]
        relevant = [{"a"}]
        assert mean_reciprocal_rank(ranked, relevant) == pytest.approx(0.0)

    def test_multiple_queries(self):
        # Query 1: hit at rank 1 (RR=1.0), Query 2: hit at rank 2 (RR=0.5)
        ranked = [["a", "b"], ["x", "a"]]
        relevant = [{"a"}, {"a"}]
        mrr = mean_reciprocal_rank(ranked, relevant)
        assert mrr == pytest.approx(0.75)

    def test_length_mismatch(self):
        with pytest.raises(ValueError):
            mean_reciprocal_rank([["a"]], [{"a"}, {"b"}])

    def test_empty(self):
        assert mean_reciprocal_rank([], []) == pytest.approx(0.0)


# ---------------------------------------------------------------------------
# latency_stats
# ---------------------------------------------------------------------------

class TestLatencyStats:
    def test_single(self):
        stats = latency_stats([100.0])
        assert stats["mean"] == pytest.approx(100.0)
        assert stats["min"] == pytest.approx(100.0)
        assert stats["max"] == pytest.approx(100.0)

    def test_multiple(self):
        values = [10.0, 20.0, 30.0, 40.0, 50.0]
        stats = latency_stats(values)
        assert stats["mean"] == pytest.approx(30.0)
        assert stats["min"] == pytest.approx(10.0)
        assert stats["max"] == pytest.approx(50.0)
        assert stats["count"] == 5

    def test_empty(self):
        stats = latency_stats([])
        assert stats["mean"] == pytest.approx(0.0)
        assert stats["p95"] == pytest.approx(0.0)

    def test_percentiles(self):
        values = list(range(1, 101))  # 1..100
        stats = latency_stats(values)
        # p95 should be around 95
        assert stats["p95"] >= 90
        assert stats["p99"] >= 95
