"""Tests for deterministic text metrics: exact_match, string_containment, string_similarity, rouge, bleu."""

from __future__ import annotations

import pytest

from evalkit.metrics.exact_match import ExactMatchMetric
from evalkit.metrics.string_containment import StringContainmentMetric
from evalkit.metrics.string_similarity import StringSimilarityMetric
from evalkit.models import TestCase


def _tc(answer: str, expected_answer: str | None = None) -> TestCase:
    return TestCase(
        question="test question",
        answer=answer,
        expected_answer=expected_answer,
    )


class TestExactMatchMetric:
    def test_exact_match(self, mock_judge):
        metric = ExactMatchMetric()
        result = metric.score(_tc("Paris", "Paris"), mock_judge)
        assert result.score == 1.0
        assert result.passed is True
        assert mock_judge.call_count == 0

    def test_no_match(self, mock_judge):
        result = ExactMatchMetric().score(_tc("London", "Paris"), mock_judge)
        assert result.score == 0.0
        assert result.passed is False

    def test_case_insensitive_normalized(self, mock_judge):
        result = ExactMatchMetric().score(_tc("  PARIS  ", "paris"), mock_judge)
        assert result.score == 1.0

    def test_punctuation_normalized(self, mock_judge):
        result = ExactMatchMetric().score(_tc("Paris.", "Paris"), mock_judge)
        assert result.score == 1.0

    def test_normalize_disabled(self, mock_judge):
        result = ExactMatchMetric(normalize=False).score(_tc("PARIS", "Paris"), mock_judge)
        assert result.score == 0.0

    def test_missing_expected_answer(self, mock_judge):
        result = ExactMatchMetric().score(_tc("Paris", None), mock_judge)
        assert result.score == 0.0
        assert result.passed is False

    def test_both_empty(self, mock_judge):
        result = ExactMatchMetric().score(_tc("", ""), mock_judge)
        assert result.score == 1.0

    @pytest.mark.asyncio
    async def test_async(self, mock_judge):
        result = await ExactMatchMetric().score_async(_tc("Paris", "Paris"), mock_judge)
        assert result.score == 1.0
        assert mock_judge.call_count == 0


class TestStringContainmentMetric:
    def test_contained(self, mock_judge):
        result = StringContainmentMetric().score(_tc("The capital of France is Paris.", "Paris"), mock_judge)
        assert result.score == 1.0
        assert result.passed is True
        assert mock_judge.call_count == 0

    def test_not_contained(self, mock_judge):
        result = StringContainmentMetric().score(_tc("The capital of France is Paris.", "Berlin"), mock_judge)
        assert result.score == 0.0

    def test_case_insensitive(self, mock_judge):
        result = StringContainmentMetric().score(_tc("The answer is PARIS", "paris"), mock_judge)
        assert result.score == 1.0

    def test_missing_expected_answer(self, mock_judge):
        result = StringContainmentMetric().score(_tc("Paris", None), mock_judge)
        assert result.score == 0.0

    @pytest.mark.asyncio
    async def test_async(self, mock_judge):
        result = await StringContainmentMetric().score_async(_tc("Paris is the capital", "Paris"), mock_judge)
        assert result.score == 1.0


class TestStringSimilarityMetric:
    def test_identical(self, mock_judge):
        result = StringSimilarityMetric().score(_tc("Paris", "Paris"), mock_judge)
        assert result.score == 1.0
        assert mock_judge.call_count == 0

    def test_similar(self, mock_judge):
        result = StringSimilarityMetric().score(_tc("Pars", "Paris"), mock_judge)
        assert 0.5 < result.score < 1.0

    def test_completely_different(self, mock_judge):
        result = StringSimilarityMetric().score(_tc("abc", "xyz"), mock_judge)
        assert result.score == 0.0

    def test_both_empty(self, mock_judge):
        result = StringSimilarityMetric().score(_tc("", ""), mock_judge)
        assert result.score == 1.0

    def test_missing_expected_answer(self, mock_judge):
        result = StringSimilarityMetric().score(_tc("Paris", None), mock_judge)
        assert result.score == 0.0

    def test_threshold(self, mock_judge):
        metric = StringSimilarityMetric(threshold=0.99)
        result = metric.score(_tc("Pars", "Paris"), mock_judge)
        assert result.passed is False

    @pytest.mark.asyncio
    async def test_async(self, mock_judge):
        result = await StringSimilarityMetric().score_async(_tc("Paris", "Paris"), mock_judge)
        assert result.score == 1.0


class TestRougeMetric:
    @pytest.fixture(autouse=True)
    def _skip_if_no_rouge(self):
        pytest.importorskip("rouge_score")

    def test_high_overlap(self, mock_judge):
        from evalkit.metrics.rouge import RougeMetric

        result = RougeMetric().score(
            _tc("Python is a programming language", "Python is a programming language"),
            mock_judge,
        )
        assert result.score == pytest.approx(1.0)
        assert mock_judge.call_count == 0

    def test_partial_overlap(self, mock_judge):
        from evalkit.metrics.rouge import RougeMetric

        result = RougeMetric().score(
            _tc("Python is great for scripting", "Python is a programming language"),
            mock_judge,
        )
        assert 0.0 < result.score < 1.0

    def test_no_overlap(self, mock_judge):
        from evalkit.metrics.rouge import RougeMetric

        result = RougeMetric().score(
            _tc("The sky is blue", "Python is a programming language"),
            mock_judge,
        )
        assert result.score < 0.5

    def test_missing_expected_answer(self, mock_judge):
        from evalkit.metrics.rouge import RougeMetric

        result = RougeMetric().score(_tc("some answer", None), mock_judge)
        assert result.score == 0.0

    def test_rouge1(self, mock_judge):
        from evalkit.metrics.rouge import RougeMetric

        metric = RougeMetric(rouge_type="rouge1")
        result = metric.score(_tc("Python is a language", "Python is a language"), mock_judge)
        assert result.score == pytest.approx(1.0)

    def test_valid_measures(self, mock_judge):
        from evalkit.metrics.rouge import RougeMetric

        for measure in ("fmeasure", "precision", "recall"):
            metric = RougeMetric(measure=measure)
            result = metric.score(
                _tc("Python is a language", "Python is a language"),
                mock_judge,
            )
            assert result.score > 0.0


def test_rouge_invalid_measure_raises():
    from evalkit.metrics.rouge import RougeMetric

    with pytest.raises(ValueError, match="measure must be one of"):
        RougeMetric(measure="invalid")


class TestBleuMetric:
    @pytest.fixture(autouse=True)
    def _skip_if_no_nltk(self):
        pytest.importorskip("nltk")

    def test_identical(self, mock_judge):
        from evalkit.metrics.bleu import BleuMetric

        result = BleuMetric().score(
            _tc("Python is a programming language", "Python is a programming language"),
            mock_judge,
        )
        assert result.score == pytest.approx(1.0)
        assert mock_judge.call_count == 0

    def test_partial_overlap(self, mock_judge):
        from evalkit.metrics.bleu import BleuMetric

        result = BleuMetric().score(
            _tc("Python is great for data science", "Python is a programming language"),
            mock_judge,
        )
        assert 0.0 < result.score < 1.0

    def test_no_overlap(self, mock_judge):
        from evalkit.metrics.bleu import BleuMetric

        result = BleuMetric().score(_tc("xyz abc", "Python is a programming language"), mock_judge)
        assert result.score < 0.3

    def test_missing_expected_answer(self, mock_judge):
        from evalkit.metrics.bleu import BleuMetric

        result = BleuMetric().score(_tc("some answer", None), mock_judge)
        assert result.score == 0.0

    def test_empty_answer(self, mock_judge):
        from evalkit.metrics.bleu import BleuMetric

        result = BleuMetric().score(_tc("", "Python"), mock_judge)
        assert result.score == 0.0
