"""Tests for testgen review and repair endpoints."""

from __future__ import annotations

import json
from unittest.mock import AsyncMock, MagicMock

import pytest

from codeflash_api.testgen._review_router import (
    _build_coverage_context,
    _get_syntax_error,
    _parse_review_verdicts,
)
from codeflash_api.testgen.schemas import (
    CoverageDetails,
    CoverageFunctionDetail,
    TestRepairRequest,
)


class TestBuildCoverageContext:
    """Tests for _build_coverage_context."""

    def test_none_returns_empty(self) -> None:
        """
        None coverage details yields empty string.
        """
        assert "" == _build_coverage_context(None)

    def test_formats_main_function(self) -> None:
        """
        Main function coverage is included with unexecuted lines.
        """
        details = CoverageDetails(
            coverage_percentage=80.0,
            threshold_percentage=90.0,
            main_function=CoverageFunctionDetail(
                name="compute",
                coverage=80.0,
                unexecuted_lines=[10, 15, 20],
            ),
        )

        result = _build_coverage_context(details)

        assert "compute" in result
        assert "80%" in result
        assert "[10, 15, 20]" in result

    def test_includes_dependent_function(self) -> None:
        """
        Dependent function coverage is appended when present.
        """
        details = CoverageDetails(
            coverage_percentage=75.0,
            threshold_percentage=90.0,
            main_function=CoverageFunctionDetail(
                name="main_fn",
                coverage=90.0,
            ),
            dependent_function=CoverageFunctionDetail(
                name="helper_fn",
                coverage=60.0,
                unexecuted_lines=[5, 6],
            ),
        )

        result = _build_coverage_context(details)

        assert "helper_fn" in result
        assert "60%" in result

    def test_includes_unexecuted_branches(self) -> None:
        """
        Unexecuted branches are included when present.
        """
        details = CoverageDetails(
            coverage_percentage=70.0,
            threshold_percentage=90.0,
            main_function=CoverageFunctionDetail(
                name="compute",
                coverage=70.0,
                unexecuted_branches=[[10, 12], [15, 18]],
            ),
        )

        result = _build_coverage_context(details)

        assert "Unexecuted branches" in result


class TestParseReviewVerdicts:
    """Tests for _parse_review_verdicts."""

    def test_parses_json_object(self) -> None:
        """
        Standard JSON object with functions array is parsed.
        """
        content = json.dumps(
            {
                "functions": [
                    {
                        "function_name": "test_foo",
                        "verdict": "repair",
                        "reason": "bad test",
                    }
                ]
            }
        )

        verdicts = _parse_review_verdicts(content)

        assert 1 == len(verdicts)
        assert "test_foo" == verdicts[0].function_name
        assert "repair" == verdicts[0].verdict
        assert "bad test" == verdicts[0].reason

    def test_parses_json_in_code_block(self) -> None:
        """
        JSON inside a markdown code block is extracted.
        """
        content = (
            'Some text\n```json\n{"functions": ['
            '{"function_name": "test_bar",'
            ' "verdict": "repair", "reason": "x"}'
            "]}\n```\nMore text"
        )

        verdicts = _parse_review_verdicts(content)

        assert 1 == len(verdicts)
        assert "test_bar" == verdicts[0].function_name

    def test_filters_non_repair_verdicts(self) -> None:
        """
        Only repair verdicts are returned.
        """
        content = json.dumps(
            {
                "functions": [
                    {
                        "function_name": "test_ok",
                        "verdict": "pass",
                        "reason": "",
                    },
                    {
                        "function_name": "test_bad",
                        "verdict": "repair",
                        "reason": "broken",
                    },
                ]
            }
        )

        verdicts = _parse_review_verdicts(content)

        assert 1 == len(verdicts)
        assert "test_bad" == verdicts[0].function_name

    def test_skips_non_dict_entries(self) -> None:
        """
        Non-dict entries in functions list are silently skipped.
        """
        content = json.dumps(
            {
                "functions": [
                    "not a dict",
                    {
                        "function_name": "test_a",
                        "verdict": "repair",
                        "reason": "bad",
                    },
                ]
            }
        )

        verdicts = _parse_review_verdicts(content)

        assert 1 == len(verdicts)
        assert "test_a" == verdicts[0].function_name

    def test_empty_functions_returns_empty(self) -> None:
        """
        Empty functions list yields no verdicts.
        """
        content = '{"functions": []}'

        assert [] == _parse_review_verdicts(content)

    def test_invalid_json_returns_empty(self) -> None:
        """
        Unparseable JSON returns empty list.
        """
        assert [] == _parse_review_verdicts("not json at all")

    def test_parses_array_directly(self) -> None:
        """
        A raw JSON array is accepted as the functions list.
        """
        content = json.dumps(
            [
                {
                    "function_name": "test_x",
                    "verdict": "repair",
                    "reason": "r",
                }
            ]
        )

        verdicts = _parse_review_verdicts(content)

        assert 1 == len(verdicts)
        assert "test_x" == verdicts[0].function_name
        assert "repair" == verdicts[0].verdict

    def test_non_dict_non_list_json_returns_empty(self) -> None:
        """
        Parsed JSON that is neither dict nor list returns empty list.
        """
        assert [] == _parse_review_verdicts('"just a string"')

    def test_integer_json_returns_empty(self) -> None:
        """
        Parsed JSON that is an integer returns empty list.
        """
        assert [] == _parse_review_verdicts("42")


class TestGetSyntaxError:
    """Tests for _get_syntax_error."""

    def test_valid_code(self) -> None:
        """
        Valid Python returns empty string.
        """
        assert "" == _get_syntax_error("x = 1\n")

    def test_syntax_error(self) -> None:
        """
        Invalid Python returns error with line number.
        """
        result = _get_syntax_error("def f(\n")

        assert result.startswith("Line ")
        assert "1" in result


class TestTestgenReviewEndpoint:
    """Integration tests for POST /ai/testgen_review."""

    async def test_success(self, client, mock_llm_client) -> None:  # type: ignore[no-untyped-def]
        """
        Successful review returns verdicts.
        """
        mock_llm_client.call = AsyncMock(
            return_value=MagicMock(
                content='{"functions": []}',
                cost=0.01,
            ),
        )

        resp = await client.post(
            "/ai/testgen_review",
            json={
                "tests": [
                    {
                        "test_source": "def test_a(): pass",
                        "test_index": 0,
                    }
                ],
                "function_source_code": "def f(): return 1",
                "function_name": "f",
                "trace_id": "12345678-1234-4000-8000-000000000000",
            },
        )

        assert 200 == resp.status_code
        data = resp.json()
        assert 1 == len(data["reviews"])
        assert 0 == data["reviews"][0]["test_index"]
        assert [] == data["reviews"][0]["functions"]

    async def test_invalid_trace_id(self, client) -> None:  # type: ignore[no-untyped-def]
        """
        Invalid trace_id returns 400.
        """
        resp = await client.post(
            "/ai/testgen_review",
            json={
                "tests": [],
                "function_source_code": "def f(): pass",
                "function_name": "f",
                "trace_id": "bad-id",
            },
        )

        assert 400 == resp.status_code

    async def test_non_python_returns_empty(
        self, client, mock_llm_client
    ) -> None:  # type: ignore[no-untyped-def]
        """
        Non-Python language returns empty reviews without LLM call.
        """
        resp = await client.post(
            "/ai/testgen_review",
            json={
                "tests": [
                    {
                        "test_source": "test code",
                        "test_index": 0,
                    }
                ],
                "function_source_code": "fn f() {}",
                "function_name": "f",
                "trace_id": "12345678-1234-4000-8000-000000000000",
                "language": "rust",
            },
        )

        assert 200 == resp.status_code
        assert [] == resp.json()["reviews"]
        mock_llm_client.call.assert_not_called()

    async def test_failed_tests_marked_for_repair(
        self, client, mock_llm_client
    ) -> None:  # type: ignore[no-untyped-def]
        """
        Tests with behavioral failures are auto-flagged for repair.
        """
        mock_llm_client.call = AsyncMock(
            return_value=MagicMock(
                content='{"functions": []}',
                cost=0.01,
            ),
        )

        resp = await client.post(
            "/ai/testgen_review",
            json={
                "tests": [
                    {
                        "test_source": "def test_a(): assert False",
                        "test_index": 0,
                        "failed_test_functions": ["test_a"],
                        "failure_messages": {"test_a": "AssertionError"},
                    }
                ],
                "function_source_code": "def f(): return 1",
                "function_name": "f",
                "trace_id": "12345678-1234-4000-8000-000000000000",
            },
        )

        assert 200 == resp.status_code
        reviews = resp.json()["reviews"]
        assert 1 == len(reviews)
        fns = reviews[0]["functions"]
        assert 1 == len(fns)
        assert "test_a" == fns[0]["function_name"]
        assert "repair" == fns[0]["verdict"]
        assert "AssertionError" == fns[0]["reason"]

    async def test_llm_failure_returns_empty_verdicts(
        self, client, mock_llm_client
    ) -> None:  # type: ignore[no-untyped-def]
        """
        LLM failure during review falls back to empty AI verdicts.
        """
        mock_llm_client.call = AsyncMock(
            side_effect=RuntimeError("LLM down"),
        )

        resp = await client.post(
            "/ai/testgen_review",
            json={
                "tests": [
                    {
                        "test_source": "def test_a(): pass",
                        "test_index": 0,
                    }
                ],
                "function_source_code": "def f(): return 1",
                "function_name": "f",
                "trace_id": "12345678-1234-4000-8000-000000000000",
            },
        )

        assert 200 == resp.status_code
        assert 1 == len(resp.json()["reviews"])
        assert [] == resp.json()["reviews"][0]["functions"]


class TestTestgenRepairEndpoint:
    """Integration tests for POST /ai/testgen_repair."""

    async def test_success(self, client, mock_llm_client) -> None:  # type: ignore[no-untyped-def]
        """
        Successful repair returns repaired code.
        """
        repaired = "def test_a(): assert True"
        mock_llm_client.call = AsyncMock(
            return_value=MagicMock(
                content=f"```python\n{repaired}\n```",
                cost=0.01,
            ),
        )

        resp = await client.post(
            "/ai/testgen_repair",
            json={
                "test_source": "def test_a(): assert False",
                "functions_to_repair": [
                    {
                        "function_name": "test_a",
                        "reason": "Always fails",
                    }
                ],
                "function_source_code": "def f(): return 1",
                "function_name": "f",
                "module_path": "src/mod.py",
                "test_module_path": "tests/test_mod.py",
                "test_framework": "pytest",
                "test_timeout": 60,
                "trace_id": "12345678-1234-4000-8000-000000000000",
            },
        )

        assert 200 == resp.status_code
        data = resp.json()
        assert repaired == data["generated_tests"].strip()
        mock_llm_client.call.assert_called_once()

    async def test_invalid_trace_id(self, client) -> None:  # type: ignore[no-untyped-def]
        """
        Invalid trace_id returns 400.
        """
        resp = await client.post(
            "/ai/testgen_repair",
            json={
                "test_source": "code",
                "functions_to_repair": [],
                "function_source_code": "def f(): pass",
                "function_name": "f",
                "module_path": "m.py",
                "test_module_path": "t.py",
                "test_framework": "pytest",
                "test_timeout": 60,
                "trace_id": "bad",
            },
        )

        assert 400 == resp.status_code

    async def test_non_python_returns_400(self, client) -> None:  # type: ignore[no-untyped-def]
        """
        Non-Python language returns 400.
        """
        resp = await client.post(
            "/ai/testgen_repair",
            json={
                "test_source": "code",
                "functions_to_repair": [],
                "function_source_code": "fn f() {}",
                "function_name": "f",
                "module_path": "m.rs",
                "test_module_path": "t.rs",
                "test_framework": "cargo",
                "test_timeout": 60,
                "trace_id": "12345678-1234-4000-8000-000000000000",
                "language": "rust",
            },
        )

        assert 400 == resp.status_code

    async def test_syntax_error_triggers_retry(
        self, client, mock_llm_client
    ) -> None:  # type: ignore[no-untyped-def]
        """
        Syntax error in first response triggers a retry.
        """
        bad_code = "def test_a(:\n  pass"
        good_code = "def test_a():\n    pass"
        mock_llm_client.call = AsyncMock(
            side_effect=[
                MagicMock(
                    content=f"```python\n{bad_code}\n```",
                    cost=0.01,
                ),
                MagicMock(
                    content=f"```python\n{good_code}\n```",
                    cost=0.01,
                ),
            ],
        )

        resp = await client.post(
            "/ai/testgen_repair",
            json={
                "test_source": "def test_a(): assert False",
                "functions_to_repair": [
                    {
                        "function_name": "test_a",
                        "reason": "broken",
                    }
                ],
                "function_source_code": "def f(): return 1",
                "function_name": "f",
                "module_path": "m.py",
                "test_module_path": "t.py",
                "test_framework": "pytest",
                "test_timeout": 60,
                "trace_id": "12345678-1234-4000-8000-000000000000",
            },
        )

        assert 200 == resp.status_code
        assert good_code == resp.json()["generated_tests"].strip()
        assert 2 == mock_llm_client.call.await_count

    async def test_all_retries_fail_returns_422(
        self, client, mock_llm_client
    ) -> None:  # type: ignore[no-untyped-def]
        """
        If all retries produce syntax errors, return 422.
        """
        bad_code = "def test_a(:\n  pass"
        mock_llm_client.call = AsyncMock(
            return_value=MagicMock(
                content=f"```python\n{bad_code}\n```",
                cost=0.01,
            ),
        )

        resp = await client.post(
            "/ai/testgen_repair",
            json={
                "test_source": "def test_a(): pass",
                "functions_to_repair": [
                    {
                        "function_name": "test_a",
                        "reason": "x",
                    }
                ],
                "function_source_code": "def f(): return 1",
                "function_name": "f",
                "module_path": "m.py",
                "test_module_path": "t.py",
                "test_framework": "pytest",
                "test_timeout": 60,
                "trace_id": "12345678-1234-4000-8000-000000000000",
            },
        )

        assert 422 == resp.status_code
        assert 2 == mock_llm_client.call.await_count

    async def test_llm_failure_returns_422(
        self, client, mock_llm_client
    ) -> None:  # type: ignore[no-untyped-def]
        """
        LLM exception on first attempt returns 422 without retry.
        """
        mock_llm_client.call = AsyncMock(
            side_effect=RuntimeError("LLM down"),
        )

        resp = await client.post(
            "/ai/testgen_repair",
            json={
                "test_source": "def test_a(): pass",
                "functions_to_repair": [
                    {
                        "function_name": "test_a",
                        "reason": "x",
                    }
                ],
                "function_source_code": "def f(): return 1",
                "function_name": "f",
                "module_path": "m.py",
                "test_module_path": "t.py",
                "test_framework": "pytest",
                "test_timeout": 60,
                "trace_id": "12345678-1234-4000-8000-000000000000",
            },
        )

        assert 422 == resp.status_code
        assert 1 == mock_llm_client.call.await_count

    async def test_no_code_block_retries_then_fails(
        self, client, mock_llm_client
    ) -> None:  # type: ignore[no-untyped-def]
        """
        LLM responses without code blocks exhaust retries and return 422.
        """
        mock_llm_client.call = AsyncMock(
            return_value=MagicMock(
                content="I cannot fix this test.",
                cost=0.01,
            ),
        )

        resp = await client.post(
            "/ai/testgen_repair",
            json={
                "test_source": "def test_a(): pass",
                "functions_to_repair": [
                    {
                        "function_name": "test_a",
                        "reason": "broken",
                    }
                ],
                "function_source_code": "def f(): return 1",
                "function_name": "f",
                "module_path": "m.py",
                "test_module_path": "t.py",
                "test_framework": "pytest",
                "test_timeout": 60,
                "trace_id": "12345678-1234-4000-8000-000000000000",
            },
        )

        assert 422 == resp.status_code
        assert 2 == mock_llm_client.call.await_count


class TestTestRepairRequestValidator:
    """Tests for TestRepairRequest model validator."""

    def test_resolves_python_version_from_language_version(
        self,
    ) -> None:
        """
        python_version is set from language_version when not provided.
        """
        req = TestRepairRequest(
            test_source="code",
            functions_to_repair=[],
            function_source_code="def f(): pass",
            function_name="f",
            module_path="m.py",
            test_module_path="t.py",
            test_framework="pytest",
            test_timeout=60,
            trace_id="abc",
            language_version="3.12.1",
        )

        assert "3.12.1" == req.python_version

    def test_does_not_override_explicit_python_version(
        self,
    ) -> None:
        """
        Explicit python_version is not overridden by language_version.
        """
        req = TestRepairRequest(
            test_source="code",
            functions_to_repair=[],
            function_source_code="def f(): pass",
            function_name="f",
            module_path="m.py",
            test_module_path="t.py",
            test_framework="pytest",
            test_timeout=60,
            trace_id="abc",
            python_version="3.11.0",
            language_version="3.12.1",
        )

        assert "3.11.0" == req.python_version
