"""Unit tests for evalkit.models."""

from __future__ import annotations

import pytest
from pydantic import ValidationError

from evalkit.models import (
    EvaluationReport,
    EvaluationResult,
    FailureGroup,
    JudgeResponse,
    MetricScore,
    MetricSummary,
    ReportSummary,
    ToolCall,
)
from evalkit.models import (
    TestCase as EvalTestCase,
)

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


def _make_test_case(**kwargs) -> EvalTestCase:
    defaults = dict(
        question="What is the capital of France?",
        context=["France is a country in Western Europe.", "Paris is its capital."],
        answer="The capital of France is Paris.",
    )
    defaults.update(kwargs)
    return EvalTestCase(**defaults)


def _make_metric_score(name: str = "relevancy", score: float = 0.9, passed: bool = True) -> MetricScore:
    return MetricScore(
        metric_name=name,
        score=score,
        reason="The answer is highly relevant.",
        passed=passed,
    )


def _make_eval_result(scores: list[MetricScore] | None = None) -> EvaluationResult:
    if scores is None:
        scores = [_make_metric_score()]
    return EvaluationResult(test_case=_make_test_case(), scores=scores)


# ---------------------------------------------------------------------------
# TestCase
# ---------------------------------------------------------------------------


class TestTestCase:
    def test_basic_instantiation(self):
        tc = _make_test_case()
        assert tc.question == "What is the capital of France?"
        assert tc.context == [
            "France is a country in Western Europe.",
            "Paris is its capital.",
        ]
        assert tc.answer == "The capital of France is Paris."

    def test_without_expected_answer(self):
        tc = _make_test_case()
        assert tc.expected_answer is None

    def test_with_expected_answer(self):
        tc = _make_test_case(expected_answer="Paris")
        assert tc.expected_answer == "Paris"

    def test_default_metadata_is_empty_dict(self):
        tc = _make_test_case()
        assert tc.metadata == {}

    def test_custom_metadata(self):
        tc = _make_test_case(metadata={"source": "wiki", "difficulty": 1})
        assert tc.metadata["source"] == "wiki"

    def test_metadata_default_is_not_shared(self):
        """Each instance should have its own metadata dict."""
        tc1 = _make_test_case()
        tc2 = _make_test_case()
        tc1.metadata["key"] = "value"
        assert "key" not in tc2.metadata

    def test_model_dump(self):
        tc = _make_test_case(expected_answer="Paris")
        data = tc.model_dump()
        assert data["question"] == "What is the capital of France?"
        assert data["expected_answer"] == "Paris"


# ---------------------------------------------------------------------------
# JudgeResponse
# ---------------------------------------------------------------------------


class TestJudgeResponse:
    def test_basic_instantiation(self):
        jr = JudgeResponse(score=0.8, reason="Good")
        assert jr.score == 0.8
        assert jr.reason == "Good"

    def test_score_clamped_by_field(self):
        """Pydantic Field(ge=0.0, le=1.0) enforces valid range."""
        import pydantic

        with pytest.raises(pydantic.ValidationError):
            JudgeResponse(score=1.5, reason="too high")
        with pytest.raises(pydantic.ValidationError):
            JudgeResponse(score=-0.1, reason="too low")

    def test_model_dump(self):
        jr = JudgeResponse(score=0.9, reason="Great")
        data = jr.model_dump()
        assert data["score"] == 0.9
        assert data["reason"] == "Great"

    def test_json_schema_has_descriptions(self):
        schema = JudgeResponse.model_json_schema()
        assert "description" in schema["properties"]["score"]
        assert "description" in schema["properties"]["reason"]


# ---------------------------------------------------------------------------
# MetricScore
# ---------------------------------------------------------------------------


class TestMetricScore:
    def test_basic_instantiation(self):
        ms = _make_metric_score()
        assert ms.metric_name == "relevancy"
        assert ms.score == 0.9
        assert ms.passed is True

    def test_passed_false(self):
        ms = _make_metric_score(score=0.3, passed=False)
        assert ms.passed is False

    def test_score_boundary_zero(self):
        ms = MetricScore(metric_name="m", score=0.0, reason="low", passed=False)
        assert ms.score == 0.0

    def test_score_boundary_one(self):
        ms = MetricScore(metric_name="m", score=1.0, reason="perfect", passed=True)
        assert ms.score == 1.0

    def test_score_below_zero_rejected(self):
        with pytest.raises(ValidationError):
            MetricScore(metric_name="m", score=-0.1, reason="bad", passed=False)

    def test_score_above_one_rejected(self):
        with pytest.raises(ValidationError):
            MetricScore(metric_name="m", score=1.1, reason="bad", passed=True)

    def test_model_dump(self):
        ms = _make_metric_score()
        data = ms.model_dump()
        assert data["metric_name"] == "relevancy"
        assert data["score"] == 0.9


# ---------------------------------------------------------------------------
# EvaluationResult
# ---------------------------------------------------------------------------


class TestEvaluationResult:
    def test_overall_score_computed_from_single_score(self):
        ms = _make_metric_score(score=0.8)
        er = EvaluationResult(test_case=_make_test_case(), scores=[ms])
        assert er.overall_score == pytest.approx(0.8)

    def test_overall_score_computed_as_mean_of_multiple(self):
        scores = [
            _make_metric_score("faithfulness", 0.6),
            _make_metric_score("relevancy", 0.8),
            _make_metric_score("completeness", 1.0),
        ]
        er = EvaluationResult(test_case=_make_test_case(), scores=scores)
        assert er.overall_score == pytest.approx((0.6 + 0.8 + 1.0) / 3)

    def test_overall_score_default_when_no_scores(self):
        er = EvaluationResult(test_case=_make_test_case(), scores=[])
        assert er.overall_score == 0.0

    def test_overall_score_explicit_value_kept_when_no_scores(self):
        er = EvaluationResult(test_case=_make_test_case(), scores=[], overall_score=0.5)
        assert er.overall_score == pytest.approx(0.5)

    def test_overall_score_overrides_explicit_when_scores_present(self):
        """Explicit value should be ignored when scores is non-empty."""
        ms = _make_metric_score(score=0.7)
        er = EvaluationResult(test_case=_make_test_case(), scores=[ms], overall_score=0.0)
        assert er.overall_score == pytest.approx(0.7)

    def test_overall_score_invalid_rejected(self):
        with pytest.raises(ValidationError):
            EvaluationResult(test_case=_make_test_case(), scores=[], overall_score=1.5)

    def test_model_dump(self):
        er = _make_eval_result()
        data = er.model_dump()
        assert "test_case" in data
        assert "scores" in data
        assert "overall_score" in data


# ---------------------------------------------------------------------------
# FailureGroup
# ---------------------------------------------------------------------------


class TestFailureGroup:
    def test_basic_instantiation(self):
        fg = FailureGroup(
            failure_type="hallucination",
            count=3,
            examples=[_make_eval_result()],
            suggestion="Improve retrieval quality.",
        )
        assert fg.failure_type == "hallucination"
        assert fg.count == 3
        assert len(fg.examples) == 1
        assert "retrieval" in fg.suggestion

    def test_model_dump(self):
        fg = FailureGroup(
            failure_type="low_relevancy",
            count=1,
            examples=[],
            suggestion="Check chunking strategy.",
        )
        data = fg.model_dump()
        assert data["failure_type"] == "low_relevancy"


# ---------------------------------------------------------------------------
# ReportSummary
# ---------------------------------------------------------------------------


class TestReportSummary:
    def test_basic_instantiation(self):
        rs = ReportSummary(
            total_cases=10,
            passed=8,
            failed=2,
            average_score=0.85,
            score_by_metric={"relevancy": 0.9, "faithfulness": 0.8},
        )
        assert rs.total_cases == 10
        assert rs.passed == 8
        assert rs.average_score == pytest.approx(0.85)

    def test_average_score_below_zero_rejected(self):
        with pytest.raises(ValidationError):
            ReportSummary(
                total_cases=1,
                passed=0,
                failed=1,
                average_score=-0.1,
                score_by_metric={},
            )

    def test_average_score_above_one_rejected(self):
        with pytest.raises(ValidationError):
            ReportSummary(
                total_cases=1,
                passed=1,
                failed=0,
                average_score=1.1,
                score_by_metric={},
            )

    def test_model_dump(self):
        rs = ReportSummary(
            total_cases=5,
            passed=5,
            failed=0,
            average_score=1.0,
            score_by_metric={"relevancy": 1.0},
        )
        data = rs.model_dump()
        assert data["score_by_metric"] == {"relevancy": 1.0}


# ---------------------------------------------------------------------------
# EvaluationReport
# ---------------------------------------------------------------------------


class TestEvaluationReport:
    def _make_report(self) -> EvaluationReport:
        er = _make_eval_result()
        summary = ReportSummary(
            total_cases=1,
            passed=1,
            failed=0,
            average_score=0.9,
            score_by_metric={"relevancy": 0.9},
        )
        return EvaluationReport(
            suite_name="smoke-test",
            results=[er],
            summary=summary,
            failures=[],
        )

    def test_basic_instantiation(self):
        report = self._make_report()
        assert report.suite_name == "smoke-test"
        assert len(report.results) == 1
        assert report.summary.total_cases == 1

    def test_model_dump(self):
        report = self._make_report()
        data = report.model_dump()
        assert isinstance(data, dict)
        assert data["suite_name"] == "smoke-test"
        assert "results" in data
        assert "summary" in data
        assert "failures" in data

    def test_model_dump_json(self):
        report = self._make_report()
        json_str = report.model_dump_json()
        assert "smoke-test" in json_str

    def test_nested_serialization(self):
        report = self._make_report()
        data = report.model_dump()
        # results -> EvaluationResult -> scores -> MetricScore
        first_result = data["results"][0]
        assert "overall_score" in first_result
        assert len(first_result["scores"]) == 1
        assert first_result["scores"][0]["metric_name"] == "relevancy"

    def test_print_report(self, capsys):
        report = self._make_report()
        report.print_report()
        captured = capsys.readouterr().out
        assert "smoke-test" in captured
        assert "90.00%" in captured
        assert "relevancy" in captured
        assert "Total cases:   1" in captured

    def test_print_report_with_failures(self, capsys):
        er = _make_eval_result()
        fg = FailureGroup(
            failure_type="low_score",
            count=1,
            examples=[er],
            suggestion="Improve quality.",
        )
        summary = ReportSummary(
            total_cases=1,
            passed=0,
            failed=1,
            average_score=0.4,
            score_by_metric={"relevancy": 0.4},
        )
        report = EvaluationReport(
            suite_name="fail-test",
            results=[er],
            summary=summary,
            failures=[fg],
        )
        report.print_report()
        captured = capsys.readouterr().out
        assert "Suggestions (1 groups):" in captured
        assert "[low_score]" in captured
        assert "Improve quality." in captured


# ---------------------------------------------------------------------------
# ToolCall
# ---------------------------------------------------------------------------


class TestToolCall:
    def test_basic_instantiation(self):
        tc = ToolCall(name="get_weather")
        assert tc.name == "get_weather"
        assert tc.parameters == {}

    def test_default_parameters_is_empty_dict(self):
        tc = ToolCall(name="search")
        assert tc.parameters == {}

    def test_parameters_default_not_shared(self):
        """Each instance should have its own parameters dict."""
        tc1 = ToolCall(name="tool_a")
        tc2 = ToolCall(name="tool_b")
        tc1.parameters["key"] = "value"
        assert "key" not in tc2.parameters

    def test_model_dump(self):
        tc = ToolCall(name="get_weather", parameters={"city": "Paris"})
        data = tc.model_dump()
        assert data["name"] == "get_weather"
        assert data["parameters"] == {"city": "Paris"}

    def test_nested_parameters(self):
        tc = ToolCall(
            name="complex_tool",
            parameters={"location": {"city": "Paris", "country": "FR"}, "units": "metric"},
        )
        assert tc.parameters["location"]["city"] == "Paris"
        assert tc.parameters["units"] == "metric"


# ---------------------------------------------------------------------------
# TestCase — tool_calls fields
# ---------------------------------------------------------------------------


class TestTestCaseToolCalls:
    def test_default_tool_calls_are_none(self):
        tc = _make_test_case()
        assert tc.expected_tool_calls is None
        assert tc.actual_tool_calls is None

    def test_with_tool_calls(self):
        call = ToolCall(name="get_weather", parameters={"city": "Paris"})
        tc = _make_test_case(
            expected_tool_calls=[call],
            actual_tool_calls=[call],
        )
        assert len(tc.expected_tool_calls) == 1
        assert tc.expected_tool_calls[0].name == "get_weather"
        assert len(tc.actual_tool_calls) == 1

    def test_tool_calls_serialization(self):
        call = ToolCall(name="search", parameters={"query": "weather"})
        tc = _make_test_case(expected_tool_calls=[call])
        data = tc.model_dump()
        assert data["expected_tool_calls"] is not None
        assert len(data["expected_tool_calls"]) == 1
        assert data["expected_tool_calls"][0]["name"] == "search"
        assert data["expected_tool_calls"][0]["parameters"] == {"query": "weather"}

    def test_agent_only_test_case(self):
        """TestCase should work with no answer or context — agent eval use case."""
        tc = EvalTestCase(
            question="What is the weather in Paris?",
            expected_tool_calls=[
                ToolCall(name="get_weather", parameters={"city": "Paris"}),
            ],
        )
        assert tc.question == "What is the weather in Paris?"
        assert tc.context == []
        assert tc.answer == ""
        assert len(tc.expected_tool_calls) == 1
        assert tc.actual_tool_calls is None


class TestMetricSummary:
    def test_basic_creation(self):
        ms = MetricSummary(
            average_score=0.75,
            pass_rate=0.8,
            min_score=0.2,
            max_score=1.0,
            total_count=10,
            failed_count=2,
        )
        assert ms.average_score == 0.75
        assert ms.total_count == 10


class TestFailureGroupEnhancements:
    def test_new_fields_have_defaults(self):
        fg = FailureGroup(
            failure_type="faithfulness",
            count=3,
            examples=[],
            suggestion="Fix it.",
        )
        assert fg.severity == "warning"
        assert fg.average_score == 0.0
        assert fg.patterns == []
        assert fg.llm_analysis is None

    def test_new_fields_can_be_set(self):
        fg = FailureGroup(
            failure_type="faithfulness",
            count=3,
            examples=[],
            suggestion="Fix it.",
            severity="critical",
            average_score=0.15,
            patterns=["long context"],
            llm_analysis="The model hallucinates numbers.",
        )
        assert fg.severity == "critical"
        assert fg.patterns == ["long context"]


class TestReportSummaryEnhancements:
    def test_metric_summaries_defaults_to_empty(self):
        s = ReportSummary(
            total_cases=5,
            passed=3,
            failed=2,
            average_score=0.6,
            score_by_metric={"f": 0.7},
        )
        assert s.metric_summaries == {}

    def test_metric_summaries_can_be_set(self):
        ms = MetricSummary(
            average_score=0.7,
            pass_rate=0.8,
            min_score=0.1,
            max_score=1.0,
            total_count=10,
            failed_count=2,
        )
        s = ReportSummary(
            total_cases=10,
            passed=8,
            failed=2,
            average_score=0.7,
            score_by_metric={"f": 0.7},
            metric_summaries={"f": ms},
        )
        assert s.metric_summaries["f"].pass_rate == 0.8
