"""Tests for EvalKitClient SDK."""

from __future__ import annotations

import json
from unittest.mock import MagicMock, patch

import pytest

from evalkit.client import EvalKitAPIError, EvalKitClient, Evaluation, Project, ServerConfig
from evalkit.models import (
    EvaluationReport,
    EvaluationResult,
    MetricScore,
    ReportSummary,
)
from evalkit.models import (
    TestCase as EvalTestCase,
)


@pytest.fixture
def client():
    return EvalKitClient("http://testserver:8000")


def _mock_response(data: dict | list, status: int = 200):
    body = json.dumps(data).encode()
    resp = MagicMock()
    resp.read.return_value = body
    resp.__enter__ = MagicMock(return_value=resp)
    resp.__exit__ = MagicMock(return_value=False)
    return resp


class TestGetConfig:
    def test_returns_server_config(self, client):
        with patch("urllib.request.urlopen") as mock_urlopen:
            mock_urlopen.return_value = _mock_response(
                {
                    "judge_provider": "openai",
                    "judge_model": "gpt-4o-mini",
                    "suite": "rag_qa",
                    "available_suites": ["rag_qa", "document_search"],
                    "available_providers": ["openai", "anthropic"],
                }
            )
            config = client.get_config()

        assert isinstance(config, ServerConfig)
        assert config.judge_provider == "openai"
        assert config.suite == "rag_qa"
        assert "rag_qa" in config.available_suites


class TestListProjects:
    def test_returns_project_list(self, client):
        with patch("urllib.request.urlopen") as mock_urlopen:
            mock_urlopen.return_value = _mock_response(
                [
                    {"id": 1, "name": "proj-a", "description": "A"},
                    {"id": 2, "name": "proj-b", "description": "B"},
                ]
            )
            projects = client.list_projects()

        assert len(projects) == 2
        assert all(isinstance(p, Project) for p in projects)
        assert projects[0].name == "proj-a"

    def test_returns_empty_list(self, client):
        with patch("urllib.request.urlopen") as mock_urlopen:
            mock_urlopen.return_value = _mock_response([])
            projects = client.list_projects()

        assert projects == []


class TestCreateProject:
    def test_creates_and_returns_project(self, client):
        with patch("urllib.request.urlopen") as mock_urlopen:
            mock_urlopen.return_value = _mock_response({"id": 1, "name": "my-app", "description": "test"})
            project = client.create_project("my-app", "test")

        assert isinstance(project, Project)
        assert project.id == 1
        assert project.name == "my-app"

    def test_sends_correct_body(self, client):
        with patch("urllib.request.urlopen") as mock_urlopen:
            mock_urlopen.return_value = _mock_response({"id": 1, "name": "my-app", "description": "desc"})
            client.create_project("my-app", "desc")

        req = mock_urlopen.call_args[0][0]
        body = json.loads(req.data.decode())
        assert body == {"name": "my-app", "description": "desc"}

    def test_default_description_is_empty(self, client):
        with patch("urllib.request.urlopen") as mock_urlopen:
            mock_urlopen.return_value = _mock_response({"id": 1, "name": "x", "description": ""})
            client.create_project("x")

        req = mock_urlopen.call_args[0][0]
        body = json.loads(req.data.decode())
        assert body["description"] == ""

    def test_returns_existing_project_on_409(self, client):
        import urllib.error

        error_body = json.dumps({"detail": "Project 'my-app' already exists"}).encode()
        http_error = urllib.error.HTTPError(
            url="http://test",
            code=409,
            msg="Conflict",
            hdrs=MagicMock(),
            fp=MagicMock(read=MagicMock(return_value=error_body)),
        )

        with patch("urllib.request.urlopen") as mock_urlopen:
            mock_urlopen.side_effect = [
                http_error,
                _mock_response(
                    [
                        {"id": 5, "name": "my-app", "description": "existing"},
                    ]
                ),
            ]
            project = client.create_project("my-app")

        assert isinstance(project, Project)
        assert project.id == 5
        assert project.name == "my-app"


class TestEvaluate:
    @pytest.fixture(autouse=True)
    def _no_git(self, monkeypatch):
        """Prevent _collect_auto_tags from running real git commands."""
        monkeypatch.setattr(
            "evalkit.client.subprocess.run", lambda *a, **kw: type("R", (), {"returncode": 1, "stdout": ""})()
        )

    def test_returns_evaluation(self, client):
        with patch("urllib.request.urlopen") as mock_urlopen:
            mock_urlopen.return_value = _mock_response({"evaluation_id": 42, "status": "pending"})
            evaluation = client.evaluate(
                project_id=1,
                test_cases=[{"question": "Q", "context": ["C"], "answer": "A"}],
                suite="rag_qa",
            )

        assert isinstance(evaluation, Evaluation)
        assert evaluation.id == 42
        assert evaluation.status == "pending"

    def test_accepts_test_case_objects(self, client):
        tc = EvalTestCase(question="Q", context=["C"], answer="A")
        with patch("urllib.request.urlopen") as mock_urlopen:
            mock_urlopen.return_value = _mock_response({"evaluation_id": 1, "status": "pending"})
            client.evaluate(project_id=1, test_cases=[tc])

        req = mock_urlopen.call_args[0][0]
        body = json.loads(req.data.decode())
        assert body["test_cases"][0]["question"] == "Q"

    def test_omits_none_fields(self, client):
        with patch("urllib.request.urlopen") as mock_urlopen:
            mock_urlopen.return_value = _mock_response({"evaluation_id": 1, "status": "pending"})
            client.evaluate(
                project_id=1,
                test_cases=[{"question": "Q", "context": ["C"], "answer": "A"}],
            )

        req = mock_urlopen.call_args[0][0]
        body = json.loads(req.data.decode())
        assert "suite" not in body
        assert "judge_provider" not in body
        assert "judge_model" not in body
        assert "api_key" not in body

    def test_includes_optional_fields_when_set(self, client):
        with patch("urllib.request.urlopen") as mock_urlopen:
            mock_urlopen.return_value = _mock_response({"evaluation_id": 1, "status": "pending"})
            client.evaluate(
                project_id=1,
                test_cases=[{"question": "Q", "context": ["C"], "answer": "A"}],
                suite="document_search",
                judge_provider="anthropic",
                judge_model="claude-haiku-4-5-20251001",
                api_key="sk-test",
            )

        req = mock_urlopen.call_args[0][0]
        body = json.loads(req.data.decode())
        assert body["suite"] == "document_search"
        assert body["judge_provider"] == "anthropic"
        assert body["judge_model"] == "claude-haiku-4-5-20251001"
        assert body["api_key"] == "sk-test"


class TestGetEvaluation:
    def test_returns_evaluation(self, client):
        with patch("urllib.request.urlopen") as mock_urlopen:
            mock_urlopen.return_value = _mock_response(
                {
                    "id": 5,
                    "status": "complete",
                    "suite_name": "rag_qa",
                    "average_score": 0.85,
                    "created_at": "2026-01-01T00:00:00",
                    "completed_at": "2026-01-01T00:01:00",
                }
            )
            evaluation = client.get_evaluation(5)

        assert isinstance(evaluation, Evaluation)
        assert evaluation.id == 5
        assert evaluation.status == "complete"
        assert evaluation.average_score == 0.85


class TestWaitForEvaluation:
    def test_returns_when_complete(self, client):
        with patch.object(client, "get_evaluation") as mock_get:
            mock_get.return_value = Evaluation(id=1, status="complete", suite_name="rag_qa", average_score=0.9)
            result = client.wait_for_evaluation(1)

        assert result.status == "complete"
        mock_get.assert_called_once_with(1)

    def test_returns_when_failed(self, client):
        with patch.object(client, "get_evaluation") as mock_get:
            mock_get.return_value = Evaluation(id=1, status="failed", suite_name="rag_qa")
            result = client.wait_for_evaluation(1)

        assert result.status == "failed"

    def test_polls_until_complete(self, client):
        pending = Evaluation(id=1, status="pending", suite_name="rag_qa")
        running = Evaluation(id=1, status="running", suite_name="rag_qa")
        complete = Evaluation(id=1, status="complete", suite_name="rag_qa", average_score=0.8)

        with (
            patch.object(client, "get_evaluation", side_effect=[pending, running, complete]) as mock_get,
            patch("evalkit.client.time.sleep") as mock_sleep,
        ):
            result = client.wait_for_evaluation(1, poll_interval=0.1)

        assert result.status == "complete"
        assert mock_get.call_count == 3
        assert mock_sleep.call_count == 2

    def test_raises_timeout(self, client):
        pending = Evaluation(id=1, status="pending", suite_name="rag_qa")

        with (
            patch.object(client, "get_evaluation", return_value=pending),
            patch("evalkit.client.time.sleep"),
            patch("evalkit.client.time.monotonic", side_effect=[0.0, 0.0, 999.0]),
        ):
            with pytest.raises(TimeoutError, match="did not complete"):
                client.wait_for_evaluation(1, timeout=10.0)


class TestUploadResults:
    @pytest.fixture(autouse=True)
    def _no_git(self, monkeypatch):
        """Prevent _collect_auto_tags from running real git commands."""
        monkeypatch.setattr(
            "evalkit.client.subprocess.run", lambda *a, **kw: type("R", (), {"returncode": 1, "stdout": ""})()
        )

    @pytest.fixture
    def sample_report(self):
        return EvaluationReport(
            suite_name="rag_qa",
            results=[
                EvaluationResult(
                    test_case=EvalTestCase(question="Q1", context=["C1"], answer="A1"),
                    scores=[
                        MetricScore(metric_name="faithfulness", score=0.9, reason="Good", passed=True),
                        MetricScore(metric_name="relevancy", score=0.7, reason="OK", passed=True),
                    ],
                ),
            ],
            summary=ReportSummary(
                total_cases=1,
                passed=1,
                failed=0,
                average_score=0.8,
                score_by_metric={"faithfulness": 0.9, "relevancy": 0.7},
            ),
            failures=[],
        )

    def test_returns_evaluation(self, client, sample_report):
        with patch("urllib.request.urlopen") as mock_urlopen:
            mock_urlopen.return_value = _mock_response({"evaluation_id": 10, "status": "complete"})
            evaluation = client.upload_results(project_id=1, report=sample_report)

        assert isinstance(evaluation, Evaluation)
        assert evaluation.id == 10
        assert evaluation.status == "complete"
        assert evaluation.suite_name == "rag_qa"

    def test_sends_correct_body(self, client, sample_report):
        with patch("urllib.request.urlopen") as mock_urlopen:
            mock_urlopen.return_value = _mock_response({"evaluation_id": 10, "status": "complete"})
            client.upload_results(project_id=3, report=sample_report)

        req = mock_urlopen.call_args[0][0]
        body = json.loads(req.data.decode())
        assert body["project_id"] == 3
        assert body["suite_name"] == "rag_qa"
        assert body["average_score"] == 0.8
        assert len(body["results"]) == 1
        result = body["results"][0]
        assert result["test_case"]["question"] == "Q1"
        assert len(result["scores"]) == 2
        assert result["scores"][0]["metric_name"] == "faithfulness"
        assert result["overall_score"] == pytest.approx(0.8)


class TestErrorHandling:
    def test_connection_error(self, client):
        import urllib.error

        with patch("urllib.request.urlopen", side_effect=urllib.error.URLError("refused")):
            with pytest.raises(ConnectionError, match="Cannot connect"):
                client.get_config()

    def test_api_error_with_json_detail(self, client):
        import urllib.error

        error_body = json.dumps({"detail": "Project not found"}).encode()
        http_error = urllib.error.HTTPError(
            url="http://test",
            code=404,
            msg="Not Found",
            hdrs=MagicMock(),
            fp=MagicMock(read=MagicMock(return_value=error_body)),
        )

        with patch("urllib.request.urlopen", side_effect=http_error):
            with pytest.raises(EvalKitAPIError) as exc_info:
                client.get_evaluation(999)

        assert exc_info.value.status_code == 404
        assert "Project not found" in exc_info.value.detail

    def test_api_error_with_plain_text(self, client):
        import urllib.error

        http_error = urllib.error.HTTPError(
            url="http://test",
            code=500,
            msg="Server Error",
            hdrs=MagicMock(),
            fp=MagicMock(read=MagicMock(return_value=b"Internal error")),
        )

        with patch("urllib.request.urlopen", side_effect=http_error):
            with pytest.raises(EvalKitAPIError) as exc_info:
                client.get_config()

        assert exc_info.value.status_code == 500


class TestRequestConstruction:
    def test_base_url_trailing_slash_stripped(self):
        c = EvalKitClient("http://localhost:8000/")
        assert c.base_url == "http://localhost:8000"

    def test_get_request_has_no_body(self, client):
        with patch("urllib.request.urlopen") as mock_urlopen:
            mock_urlopen.return_value = _mock_response([])
            client.list_projects()

        req = mock_urlopen.call_args[0][0]
        assert req.data is None
        assert req.get_method() == "GET"

    def test_post_request_has_json_content_type(self, client):
        with patch("urllib.request.urlopen") as mock_urlopen:
            mock_urlopen.return_value = _mock_response({"id": 1, "name": "x", "description": ""})
            client.create_project("x")

        req = mock_urlopen.call_args[0][0]
        assert req.get_header("Content-type") == "application/json"


class TestEvaluateTags:
    @pytest.fixture(autouse=True)
    def _no_git(self, monkeypatch):
        monkeypatch.setattr(
            "evalkit.client.subprocess.run", lambda *a, **kw: type("R", (), {"returncode": 1, "stdout": ""})()
        )

    def test_evaluate_sends_tags(self, client):
        with patch("urllib.request.urlopen") as mock_urlopen:
            mock_urlopen.return_value = _mock_response({"evaluation_id": 1, "status": "pending"})
            client.evaluate(1, [{"question": "Q", "context": ["C"], "answer": "A"}], tags={"branch": "main"})

            call_data = json.loads(mock_urlopen.call_args[0][0].data.decode())
            assert call_data["tags"]["branch"] == "main"

    def test_evaluate_auto_tags_git(self, client, monkeypatch):

        def mock_run(cmd, **kwargs):
            result = type("Result", (), {"returncode": 0, "stdout": ""})()
            if "rev-parse" in cmd and "--short" in cmd:
                result.stdout = "abc1234\n"
            elif "rev-parse" in cmd and "--abbrev-ref" in cmd:
                result.stdout = "feature-x\n"
            return result

        monkeypatch.setattr("evalkit.client.subprocess.run", mock_run)
        with patch("urllib.request.urlopen") as mock_urlopen:
            mock_urlopen.return_value = _mock_response({"evaluation_id": 1, "status": "pending"})
            client.evaluate(1, [{"question": "Q", "context": ["C"], "answer": "A"}])

            call_data = json.loads(mock_urlopen.call_args[0][0].data.decode())
            assert call_data["tags"]["git_commit"] == "abc1234"
            assert call_data["tags"]["git_branch"] == "feature-x"

    def test_evaluate_user_tags_override_auto(self, client, monkeypatch):
        def mock_run(cmd, **kwargs):
            return type("Result", (), {"returncode": 0, "stdout": "auto-val\n"})()

        monkeypatch.setattr("evalkit.client.subprocess.run", mock_run)
        with patch("urllib.request.urlopen") as mock_urlopen:
            mock_urlopen.return_value = _mock_response({"evaluation_id": 1, "status": "pending"})
            client.evaluate(1, [{"question": "Q", "context": ["C"], "answer": "A"}], tags={"git_commit": "manual"})

            call_data = json.loads(mock_urlopen.call_args[0][0].data.decode())
            assert call_data["tags"]["git_commit"] == "manual"

    def test_auto_tags_fallback_no_git(self, client, monkeypatch):
        def mock_run(cmd, **kwargs):
            raise FileNotFoundError("git not found")

        monkeypatch.setattr("evalkit.client.subprocess.run", mock_run)
        with patch("urllib.request.urlopen") as mock_urlopen:
            mock_urlopen.return_value = _mock_response({"evaluation_id": 1, "status": "pending"})
            client.evaluate(1, [{"question": "Q", "context": ["C"], "answer": "A"}])

            call_data = json.loads(mock_urlopen.call_args[0][0].data.decode())
            assert "git_commit" not in call_data.get("tags", {})


class TestUploadResultsTags:
    @pytest.fixture(autouse=True)
    def _no_git(self, monkeypatch):
        monkeypatch.setattr(
            "evalkit.client.subprocess.run", lambda *a, **kw: type("R", (), {"returncode": 1, "stdout": ""})()
        )

    def test_upload_results_sends_tags(self, client):
        with patch("urllib.request.urlopen") as mock_urlopen:
            mock_urlopen.return_value = _mock_response({"evaluation_id": 1, "status": "complete"})
            report = EvaluationReport(
                suite_name="rag_qa",
                results=[
                    EvaluationResult(
                        test_case=EvalTestCase(question="Q", context=["C"], answer="A"),
                        scores=[MetricScore(metric_name="f", score=0.9, passed=True, reason="ok")],
                    )
                ],
                summary=ReportSummary(total_cases=1, passed=1, failed=0, average_score=0.9, score_by_metric={"f": 0.9}),
                failures=[],
            )
            client.upload_results(1, report, tags={"env": "staging"})

            call_data = json.loads(mock_urlopen.call_args[0][0].data.decode())
            assert call_data["tags"]["env"] == "staging"


class TestUpdateTags:
    def test_update_tags(self, client):
        with patch("urllib.request.urlopen") as mock_urlopen:
            mock_urlopen.return_value = _mock_response({"tags": {"model": "gpt-4o-mini", "branch": "main"}})
            result = client.update_tags(1, {"model": "gpt-4o-mini"})
            assert result == {"model": "gpt-4o-mini", "branch": "main"}

            req = mock_urlopen.call_args[0][0]
            assert req.method == "PUT"
            assert "/evaluations/1/tags" in req.full_url


class TestEvaluationModelTags:
    def test_evaluation_model_has_tags(self, client):
        with patch("urllib.request.urlopen") as mock_urlopen:
            mock_urlopen.return_value = _mock_response(
                {
                    "id": 1,
                    "status": "complete",
                    "suite_name": "rag_qa",
                    "average_score": 0.9,
                    "created_at": "2026-03-23",
                    "completed_at": None,
                    "tags": {"model": "gpt-4o"},
                }
            )
            ev = client.get_evaluation(1)
            assert ev.tags == {"model": "gpt-4o"}


class TestCompare:
    def test_compare_sends_correct_request(self, client):
        with patch("urllib.request.urlopen") as mock_urlopen:
            mock_urlopen.return_value = _mock_response(
                {
                    "baseline": {"id": 1},
                    "comparisons": [{"id": 2}],
                    "questions": [],
                    "new_questions": [],
                    "summary": {"improved": 0, "regressed": 0, "unchanged": 0},
                }
            )
            result = client.compare(1, [1, 2], baseline=1)

            req = mock_urlopen.call_args[0][0]
            assert req.method == "GET"
            assert "/projects/1/compare?ids=1,2&baseline=1" in req.full_url
            assert result["baseline"]["id"] == 1

    def test_compare_without_baseline(self, client):
        with patch("urllib.request.urlopen") as mock_urlopen:
            mock_urlopen.return_value = _mock_response(
                {
                    "baseline": {"id": 1},
                    "comparisons": [{"id": 2}],
                    "questions": [],
                    "new_questions": [],
                    "summary": {"improved": 0, "regressed": 0, "unchanged": 0},
                }
            )
            client.compare(1, [1, 2])

            req = mock_urlopen.call_args[0][0]
            assert "baseline" not in req.full_url
