"""Tests for evalkit.contrib — BaseTracker, ToolCallTracker, TrackerRun."""

from __future__ import annotations

import asyncio
from unittest.mock import MagicMock, patch

import pytest

from evalkit.contrib.tracker import BaseTracker, ToolCallTracker, TrackerRun
from evalkit.models import EvaluationReport, ToolCall


class TestBaseTrackerToTestCase:
    def test_builds_test_case_with_all_fields(self) -> None:
        tracker = _make_tracker(
            question="What is Python?",
            tool_calls=[ToolCall(name="search", parameters={"q": "python"})],
            context=["Python is a language."],
            answer="Python is a programming language.",
        )
        tc = tracker.to_test_case(
            expected_tool_calls=[ToolCall(name="search", parameters={"q": "python"})],
            expected_answer="A programming language",
        )
        assert tc.question == "What is Python?"
        assert tc.context == ["Python is a language."]
        assert tc.answer == "Python is a programming language."
        assert tc.expected_answer == "A programming language"
        assert tc.expected_tool_calls == [ToolCall(name="search", parameters={"q": "python"})]
        assert tc.actual_tool_calls == [ToolCall(name="search", parameters={"q": "python"})]

    def test_builds_test_case_without_expectations(self) -> None:
        tracker = _make_tracker(
            question="Hello",
            tool_calls=[],
            context=[],
            answer="Hi there",
        )
        tc = tracker.to_test_case()
        assert tc.question == "Hello"
        assert tc.expected_tool_calls is None
        assert tc.expected_answer is None
        assert tc.actual_tool_calls is None

    def test_actual_tool_calls_none_when_empty(self) -> None:
        tracker = _make_tracker(question="Q", tool_calls=[], context=[], answer="A")
        tc = tracker.to_test_case()
        assert tc.actual_tool_calls is None


class TestBaseTrackerEvaluate:
    def test_evaluate_returns_report(self, mock_judge: MagicMock) -> None:
        tracker = _make_tracker(
            question="What is Python?",
            tool_calls=[ToolCall(name="search", parameters={"q": "python"})],
            context=["Python is a language."],
            answer="Python is a programming language.",
        )
        report = tracker.evaluate(
            judge=mock_judge,
            expected_tool_calls=[ToolCall(name="search", parameters={"q": "python"})],
        )
        assert isinstance(report, EvaluationReport)
        assert report.summary.total_cases == 1

    def test_evaluate_auto_selects_agent_suite(self, mock_judge: MagicMock) -> None:
        tracker = _make_tracker(
            question="Q",
            tool_calls=[ToolCall(name="t", parameters={})],
            context=[],
            answer="A",
        )
        report = tracker.evaluate(
            judge=mock_judge,
            expected_tool_calls=[ToolCall(name="t", parameters={})],
        )
        assert report.suite_name == "agent_tool_call"

    def test_evaluate_auto_selects_rag_suite(self, mock_judge: MagicMock) -> None:
        tracker = _make_tracker(
            question="Q",
            tool_calls=[],
            context=["ctx"],
            answer="A",
        )
        report = tracker.evaluate(
            judge=mock_judge,
            expected_answer="expected A",
        )
        assert report.suite_name == "rag_qa"

    def test_evaluate_auto_selects_live_qa_suite(self, mock_judge: MagicMock) -> None:
        tracker = _make_tracker(
            question="Q",
            tool_calls=[],
            context=["ctx"],
            answer="A",
        )
        report = tracker.evaluate(judge=mock_judge)
        assert report.suite_name == "live_qa"


class TestBaseTrackerUpload:
    def test_upload_creates_client_and_evaluates(self) -> None:
        tracker = _make_tracker(
            question="Q",
            tool_calls=[],
            context=[],
            answer="A",
        )
        mock_client = MagicMock()
        mock_client.create_project.return_value = MagicMock(id=1)
        mock_client.evaluate.return_value = MagicMock(id=42)

        result = tracker.upload(
            project_name="Test Project",
            suite="live_qa",
            client=mock_client,
        )
        mock_client.create_project.assert_called_once_with("Test Project")
        mock_client.evaluate.assert_called_once()
        assert result.id == 42

    def test_upload_creates_new_client_when_none_provided(self) -> None:
        tracker = _make_tracker(question="Q", tool_calls=[], context=[], answer="A")
        with patch("evalkit.contrib.tracker.EvalKitClient") as mock_cls:
            mock_instance = MagicMock()
            mock_instance.create_project.return_value = MagicMock(id=1)
            mock_instance.evaluate.return_value = MagicMock(id=99)
            mock_cls.return_value = mock_instance

            result = tracker.upload("Proj", server_url="http://test:8000")
            mock_cls.assert_called_once_with("http://test:8000")
            assert result.id == 99


class TestTrackerRun:
    def test_properties(self) -> None:
        calls = [ToolCall(name="t", parameters={"x": 1})]
        run = TrackerRun(question="Q", calls=calls)
        assert run.question == "Q"
        assert run.tool_calls == calls
        assert run.context == []
        assert run.answer == ""

    def test_answer_setter(self) -> None:
        run = TrackerRun(question="Q", calls=[])
        run.answer = "A"
        assert run.answer == "A"

    def test_context_setter(self) -> None:
        run = TrackerRun(question="Q", calls=[])
        run.context = ["chunk1", "chunk2"]
        assert run.context == ["chunk1", "chunk2"]

    def test_tool_calls_returns_copy(self) -> None:
        calls = [ToolCall(name="t", parameters={})]
        run = TrackerRun(question="Q", calls=calls)
        result = run.tool_calls
        result.clear()
        assert len(run.tool_calls) == 1

    def test_is_base_tracker(self) -> None:
        run = TrackerRun(question="Q", calls=[])
        assert isinstance(run, BaseTracker)

    def test_to_test_case(self) -> None:
        run = TrackerRun(
            question="Q",
            calls=[ToolCall(name="t", parameters={"a": 1})],
        )
        run.answer = "A"
        run.context = ["ctx"]
        tc = run.to_test_case(
            expected_tool_calls=[ToolCall(name="t", parameters={"a": 1})],
        )
        assert tc.question == "Q"
        assert tc.answer == "A"
        assert tc.context == ["ctx"]
        assert tc.actual_tool_calls == [ToolCall(name="t", parameters={"a": 1})]


class TestToolCallTrackerWrapSync:
    def test_captures_single_call(self) -> None:
        tracker = ToolCallTracker()

        @tracker.wrap
        def get_weather(city: str) -> str:
            return f"Sunny in {city}"

        get_weather(city="Paris")
        assert tracker.tool_calls == [
            ToolCall(name="get_weather", parameters={"city": "Paris"}),
        ]

    def test_captures_multiple_calls(self) -> None:
        tracker = ToolCallTracker()

        @tracker.wrap
        def search(query: str) -> str:
            return query

        search(query="python")
        search(query="rust")
        assert len(tracker.tool_calls) == 2
        assert tracker.tool_calls[0].parameters == {"query": "python"}
        assert tracker.tool_calls[1].parameters == {"query": "rust"}

    def test_captures_positional_args(self) -> None:
        tracker = ToolCallTracker()

        @tracker.wrap
        def add(a: int, b: int) -> int:
            return a + b

        add(1, 2)
        assert tracker.tool_calls == [
            ToolCall(name="add", parameters={"a": 1, "b": 2}),
        ]

    def test_does_not_capture_defaults(self) -> None:
        tracker = ToolCallTracker()

        @tracker.wrap
        def get_weather(city: str, unit: str = "celsius") -> str:
            return f"{city} {unit}"

        get_weather(city="Tokyo")
        assert tracker.tool_calls[0].parameters == {"city": "Tokyo"}

    def test_captures_explicit_default_override(self) -> None:
        tracker = ToolCallTracker()

        @tracker.wrap
        def get_weather(city: str, unit: str = "celsius") -> str:
            return f"{city} {unit}"

        get_weather(city="Tokyo", unit="fahrenheit")
        assert tracker.tool_calls[0].parameters == {"city": "Tokyo", "unit": "fahrenheit"}

    def test_preserves_function_return_value(self) -> None:
        tracker = ToolCallTracker()

        @tracker.wrap
        def add(a: int, b: int) -> int:
            return a + b

        assert add(3, 4) == 7

    def test_preserves_function_name(self) -> None:
        tracker = ToolCallTracker()

        @tracker.wrap
        def my_tool() -> str:
            return "ok"

        assert my_tool.__name__ == "my_tool"

    def test_preserves_docstring(self) -> None:
        tracker = ToolCallTracker()

        @tracker.wrap
        def my_tool() -> str:
            """My docstring."""
            return "ok"

        assert my_tool.__doc__ == "My docstring."


class TestToolCallTrackerWrapAsync:
    def test_captures_async_call(self) -> None:
        tracker = ToolCallTracker()

        @tracker.wrap
        async def get_weather(city: str) -> str:
            return f"Sunny in {city}"

        asyncio.run(get_weather(city="London"))
        assert tracker.tool_calls == [
            ToolCall(name="get_weather", parameters={"city": "London"}),
        ]

    def test_preserves_async_return_value(self) -> None:
        tracker = ToolCallTracker()

        @tracker.wrap
        async def add(a: int, b: int) -> int:
            return a + b

        assert asyncio.run(add(1, 2)) == 3


class TestToolCallTrackerNameOverride:
    def test_custom_name(self) -> None:
        tracker = ToolCallTracker()

        @tracker.wrap(name="custom_weather")
        def get_weather(city: str) -> str:
            return city

        get_weather(city="Paris")
        assert tracker.tool_calls[0].name == "custom_weather"

    def test_default_uses_function_name(self) -> None:
        tracker = ToolCallTracker()

        @tracker.wrap
        def my_tool_name(x: int) -> int:
            return x

        my_tool_name(x=1)
        assert tracker.tool_calls[0].name == "my_tool_name"


class TestToolCallTrackerReset:
    def test_clears_all_calls(self) -> None:
        tracker = ToolCallTracker()

        @tracker.wrap
        def noop() -> None:
            pass

        noop()
        noop()
        assert len(tracker.tool_calls) == 2
        tracker.reset()
        assert tracker.tool_calls == []

    def test_continues_capturing_after_reset(self) -> None:
        tracker = ToolCallTracker()

        @tracker.wrap
        def noop() -> None:
            pass

        noop()
        tracker.reset()
        noop()
        assert len(tracker.tool_calls) == 1


class TestToolCallTrackerToolCallsProperty:
    def test_returns_copy(self) -> None:
        tracker = ToolCallTracker()

        @tracker.wrap
        def noop() -> None:
            pass

        noop()
        calls = tracker.tool_calls
        calls.clear()
        assert len(tracker.tool_calls) == 1

    def test_empty_initially(self) -> None:
        tracker = ToolCallTracker()
        assert tracker.tool_calls == []


class TestToolCallTrackerMultipleTools:
    def test_captures_across_tools(self) -> None:
        tracker = ToolCallTracker()

        @tracker.wrap
        def tool_a(x: int) -> int:
            return x

        @tracker.wrap
        def tool_b(y: str) -> str:
            return y

        tool_a(x=1)
        tool_b(y="hello")
        tool_a(x=2)

        assert len(tracker.tool_calls) == 3
        assert tracker.tool_calls[0] == ToolCall(name="tool_a", parameters={"x": 1})
        assert tracker.tool_calls[1] == ToolCall(name="tool_b", parameters={"y": "hello"})
        assert tracker.tool_calls[2] == ToolCall(name="tool_a", parameters={"x": 2})


class TestToolCallTrackerDecoratorStacking:
    def test_works_under_another_decorator(self) -> None:
        """Simulates @function_tool / @tool stacking."""
        tracker = ToolCallTracker()

        def framework_decorator(fn):
            """Fake framework decorator that wraps the function."""
            fn._is_tool = True
            return fn

        @framework_decorator
        @tracker.wrap
        def get_weather(city: str) -> str:
            return city

        assert get_weather._is_tool is True
        result = get_weather(city="Berlin")
        assert result == "Berlin"
        assert tracker.tool_calls == [
            ToolCall(name="get_weather", parameters={"city": "Berlin"}),
        ]


class TestToolCallTrackerCapture:
    def test_capture_scopes_calls(self) -> None:
        tracker = ToolCallTracker()

        @tracker.wrap
        def tool_a(x: int) -> int:
            return x

        # Call outside capture — goes to main list
        tool_a(x=0)

        with tracker.capture("What is 1?") as run:
            tool_a(x=1)

        # Main list should still have only the pre-capture call
        assert len(tracker.tool_calls) == 1
        assert tracker.tool_calls[0].parameters == {"x": 0}

        # Run should have only the scoped call
        assert len(run.tool_calls) == 1
        assert run.tool_calls[0].parameters == {"x": 1}
        assert run.question == "What is 1?"

    def test_capture_restores_after_exit(self) -> None:
        tracker = ToolCallTracker()

        @tracker.wrap
        def tool_a(x: int) -> int:
            return x

        with tracker.capture("Q") as _run:
            tool_a(x=1)

        # After capture, new calls go to main list
        tool_a(x=2)
        assert len(tracker.tool_calls) == 1
        assert tracker.tool_calls[0].parameters == {"x": 2}

    def test_capture_run_answer_and_context(self) -> None:
        tracker = ToolCallTracker()

        with tracker.capture("Q") as run:
            run.answer = "The answer"
            run.context = ["chunk1", "chunk2"]

        assert run.answer == "The answer"
        assert run.context == ["chunk1", "chunk2"]

    def test_capture_nesting_raises(self) -> None:
        tracker = ToolCallTracker()

        with tracker.capture("Q1"):
            with pytest.raises(RuntimeError, match="does not support nesting"):
                tracker.capture("Q2").__enter__()

    def test_capture_run_is_base_tracker(self) -> None:
        tracker = ToolCallTracker()

        with tracker.capture("Q") as run:
            pass

        assert isinstance(run, BaseTracker)

    def test_capture_run_evaluate(self, mock_judge: MagicMock) -> None:
        tracker = ToolCallTracker()

        @tracker.wrap
        def search(q: str) -> str:
            return f"Result for {q}"

        with tracker.capture("What is Python?") as run:
            search(q="python")
            run.answer = "Python is a language."
            run.context = ["Python is a programming language."]

        report = run.evaluate(judge=mock_judge)
        assert isinstance(report, EvaluationReport)
        assert report.summary.total_cases == 1


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


class _ConcreteTracker(BaseTracker):
    """Minimal concrete implementation for testing BaseTracker."""

    def __init__(
        self,
        question: str,
        tool_calls: list[ToolCall],
        context: list[str],
        answer: str,
    ) -> None:
        self._question = question
        self._tool_calls = tool_calls
        self._context = context
        self._answer = answer

    @property
    def question(self) -> str:
        return self._question

    @property
    def tool_calls(self) -> list[ToolCall]:
        return list(self._tool_calls)

    @property
    def context(self) -> list[str]:
        return list(self._context)

    @property
    def answer(self) -> str:
        return self._answer


def _make_tracker(
    question: str = "Q",
    tool_calls: list[ToolCall] | None = None,
    context: list[str] | None = None,
    answer: str = "A",
) -> _ConcreteTracker:
    return _ConcreteTracker(
        question=question,
        tool_calls=tool_calls or [],
        context=context or [],
        answer=answer,
    )
