"""Tests for the testgen endpoint and generation pipeline."""

from __future__ import annotations

from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from codeflash_api.testgen._generate import (
    _did_generate_ellipsis,
    _generate_notes,
    _parse_and_validate_llm_output,
    _parse_python_version,
    build_prompt,
    generate_tests,
    select_model_for_test,
)
from codeflash_api.testgen._validate import (
    CodeValidationError,
    _find_decorator_start_line,
    _inherits_from_testcase,
    _repair_preamble,
    _split_code_into_parts,
    _split_code_with_ast,
    _split_code_with_regex,
    _validate_tests_individually,
    has_test_functions,
    validate_testgen_code,
)
from codeflash_api.testgen.schemas import TestGenRequest

from .conftest import _stub_llm_response


class TestSelectModelForTest:
    """Tests for select_model_for_test."""

    def test_even_index_returns_openai(self) -> None:
        """
        Even test_index selects the OpenAI model.
        """
        model, model_type = select_model_for_test(0)

        assert "openai" == model.model_type
        assert "openai" == model_type

    def test_odd_index_returns_anthropic(self) -> None:
        """
        Odd test_index selects the Anthropic (Haiku) model.
        """
        model, model_type = select_model_for_test(1)

        assert "anthropic" == model.model_type
        assert "anthropic" == model_type

    def test_even_large_index(self) -> None:
        """
        Large even index still returns OpenAI.
        """
        model, _ = select_model_for_test(100)

        assert "openai" == model.model_type


class TestGenerateNotes:
    """Tests for _generate_notes."""

    def test_torch_code_adds_note(self) -> None:
        """
        Source code containing 'torch' generates a tensor size note.
        """
        result = _generate_notes("import torch\nx = torch.zeros(10)")

        assert "torch" in result.lower()
        assert "100MB" in result

    def test_no_torch_returns_empty(self) -> None:
        """
        Source code without torch returns empty notes.
        """
        assert "" == _generate_notes("def f(): return 1")


class TestBuildPrompt:
    """Tests for build_prompt."""

    def test_sync_prompt(self) -> None:
        """
        Sync function produces system + user messages.
        """
        messages = build_prompt(
            function_name="compute",
            source_code="def compute(): return 1",
            unit_test_package="pytest",
            is_async=False,
            model_type="openai",
        )

        assert 2 == len(messages)
        assert "system" == messages[0]["role"]
        assert "user" == messages[1]["role"]
        assert "compute" in messages[1]["content"]

    def test_async_prompt(self) -> None:
        """
        Async function prompt includes async-specific rules.
        """
        messages = build_prompt(
            function_name="fetch",
            source_code="async def fetch(): pass",
            unit_test_package="pytest",
            is_async=True,
            model_type="anthropic",
        )

        system = messages[0]["content"]
        assert "async" in system.lower()

    def test_anthropic_model_uses_xml(self) -> None:
        """
        Anthropic model type produces XML-tagged system prompt.
        """
        messages = build_prompt(
            function_name="f",
            source_code="def f(): pass",
            unit_test_package="pytest",
            is_async=False,
            model_type="anthropic",
        )

        assert "<role>" in messages[0]["content"]

    def test_openai_model_uses_markdown(self) -> None:
        """
        OpenAI model type produces markdown-formatted system prompt.
        """
        messages = build_prompt(
            function_name="f",
            source_code="def f(): pass",
            unit_test_package="pytest",
            is_async=False,
            model_type="openai",
        )

        system = messages[0]["content"]
        assert "<role>" not in system
        assert "## Analysis Steps" in system


class TestParsePythonVersion:
    """Tests for _parse_python_version."""

    def test_three_part_version(self) -> None:
        """
        Standard three-part version is parsed correctly.
        """
        assert (3, 12, 1) == _parse_python_version("3.12.1")

    def test_two_part_version(self) -> None:
        """
        Two-part version defaults patch to 0.
        """
        assert (3, 11, 0) == _parse_python_version("3.11")

    def test_invalid_version_raises(self) -> None:
        """
        Single-part version raises ValueError.
        """
        with pytest.raises(ValueError, match="Invalid"):
            _parse_python_version("3")

    def test_unsupported_version_raises(self) -> None:
        """
        Python 2.x raises ValueError.
        """
        with pytest.raises(ValueError, match="Unsupported"):
            _parse_python_version("2.7.18")


class TestHasTestFunctions:
    """Tests for has_test_functions."""

    def test_detects_test_function(self) -> None:
        """
        A def test_* function is detected.
        """
        import ast

        tree = ast.parse("def test_foo(): pass")
        assert has_test_functions(tree)

    def test_detects_async_test(self) -> None:
        """
        An async def test_* function is detected.
        """
        import ast

        tree = ast.parse("async def test_bar(): pass")
        assert has_test_functions(tree)

    def test_no_tests_returns_false(self) -> None:
        """
        Code without test functions returns False.
        """
        import ast

        tree = ast.parse("def helper(): pass")
        assert not has_test_functions(tree)

    def test_test_in_class(self) -> None:
        """
        A test method inside a class is detected.
        """
        import ast

        code = "class TestFoo:\n    def test_bar(self): pass"
        tree = ast.parse(code)
        assert has_test_functions(tree)

    def test_module_with_async_helper_and_test(self) -> None:
        """
        Module with async non-test function and sync test is detected.
        """
        import ast

        code = (
            "async def helper():\n    pass\n\n"
            "def test_real():\n    assert True\n"
        )
        tree = ast.parse(code)
        assert has_test_functions(tree)

    def test_only_async_non_test_returns_false(self) -> None:
        """
        Module with only async non-test functions returns False.
        """
        import ast

        tree = ast.parse("async def fetch(): pass")
        assert not has_test_functions(tree)


class TestInheritsFromTestcase:
    """Tests for _inherits_from_testcase."""

    def test_direct_testcase_base(self) -> None:
        """
        Class inheriting from TestCase is detected.
        """
        import ast

        code = "class MyTest(TestCase): pass"
        tree = ast.parse(code)
        cls = tree.body[0]
        assert _inherits_from_testcase(cls)

    def test_attribute_testcase_base(self) -> None:
        """
        Class inheriting from unittest.TestCase is detected.
        """
        import ast

        code = "class MyTest(unittest.TestCase): pass"
        tree = ast.parse(code)
        cls = tree.body[0]
        assert _inherits_from_testcase(cls)

    def test_no_testcase_base(self) -> None:
        """
        Class not inheriting from TestCase returns False.
        """
        import ast

        code = "class MyTest(object): pass"
        tree = ast.parse(code)
        cls = tree.body[0]
        assert not _inherits_from_testcase(cls)


class TestCodeValidationErrorDebugDict:
    """Tests for CodeValidationError.to_debug_dict."""

    def test_returns_all_fields(self) -> None:
        """
        to_debug_dict returns all stored context.
        """
        err = CodeValidationError(
            "test error",
            initial_code="init",
            fixed_code="fixed",
            final_code="final",
            lines_removed=3,
            validation_error="some error",
        )

        d = err.to_debug_dict()

        assert "code_validation" == d["stage"]
        assert "init" == d["initial_code"]
        assert "fixed" == d["fixed_code"]
        assert "final" == d["final_code"]
        assert 3 == d["lines_removed"]
        assert "some error" == d["validation_error"]


class TestFindDecoratorStartLine:
    """Tests for _find_decorator_start_line."""

    def test_def_line_zero_returns_zero(self) -> None:
        """
        def_line at 0 returns 0 immediately.
        """
        assert 0 == _find_decorator_start_line(["def test(): pass"], 0)

    def test_finds_decorator(self) -> None:
        """
        Decorator above def line is found.
        """
        lines = ["@mark\n", "def test(): pass\n"]
        assert 0 == _find_decorator_start_line(lines, 1)

    def test_skips_blank_and_comment_lines(self) -> None:
        """
        Blank and comment lines between decorator and def are skipped.
        """
        lines = ["@mark\n", "\n", "# comment\n", "def test(): pass\n"]
        assert 0 == _find_decorator_start_line(lines, 3)


class TestValidateTestgenCode:
    """Tests for validate_testgen_code."""

    def test_valid_code_passes(self) -> None:
        """
        Valid test code is returned stripped.
        """
        code = "def test_foo():\n    assert True\n"

        result = validate_testgen_code(code, (3, 12))

        assert "def test_foo():" in result

    def test_empty_code_raises(self) -> None:
        """
        Empty code raises CodeValidationError.
        """
        with pytest.raises(CodeValidationError, match="No code"):
            validate_testgen_code("", (3, 12))

    def test_no_test_functions_raises(self) -> None:
        """
        Code without test functions raises CodeValidationError.
        """
        with pytest.raises(CodeValidationError, match="No test functions"):
            validate_testgen_code("def helper(): pass", (3, 12))

    def test_removes_invalid_test_keeps_valid(self) -> None:
        """
        Invalid tests are removed while valid ones are kept.
        """
        code = (
            "import os\n\n"
            "def test_good():\n    assert True\n\n"
            "def test_bad():\n    x = (\n"
        )

        result = validate_testgen_code(code, (3, 12))

        assert "test_good" in result
        assert "test_bad" not in result

    def test_syntax_error_in_all_tests_raises(self) -> None:
        """
        If all tests have syntax errors, raises CodeValidationError.
        """
        code = "def test_a(:\n    pass\ndef test_b(:\n    pass\n"

        with pytest.raises(CodeValidationError, match="syntax error"):
            validate_testgen_code(code, (3, 12))


class TestValidateTestsIndividually:
    """Tests for _validate_tests_individually."""

    def test_broken_preamble_is_repaired(self) -> None:
        """
        A broken preamble is repaired before validating tests.
        """
        code = (
            "import os\ndef bad(\nimport sys\n\n"
            "def test_a():\n    assert True\n"
        )

        result, removed = _validate_tests_individually(code, (3, 12))

        assert "test_a" in result
        assert "import os" in result

    def test_all_tests_invalid_returns_preamble_only(self) -> None:
        """
        When all tests fail validation, returns preamble with count.
        """
        code = (
            "import os\n\n"
            "def test_a():\n    x = (\n\n"
            "def test_b():\n    y = (\n"
        )

        result, removed = _validate_tests_individually(code, (3, 12))

        assert 2 == removed
        assert "import os" in result

    def test_no_preamble_validates_tests_alone(self) -> None:
        """
        Code with no preamble validates test functions directly.
        """
        code = "def test_a():\n    assert True\n"

        result, removed = _validate_tests_individually(code, (3, 12))

        assert "test_a" in result
        assert 0 == removed


class TestSplitCodeWithAst:
    """Tests for _split_code_with_ast."""

    def test_decorated_non_test_function_in_preamble(self) -> None:
        """
        Decorated non-test function is included in preamble with its decorator.
        """
        code = (
            "import functools\n\n"
            "@functools.lru_cache\n"
            "def helper():\n    return 1\n\n"
            "def test_a():\n    assert True\n"
        )

        result = _split_code_with_ast(code, (3, 12))

        assert result is not None
        preamble, tests = result
        assert "@functools.lru_cache" in preamble
        assert "helper" in preamble
        assert 1 == len(tests)

    def test_gap_between_non_test_nodes(self) -> None:
        """
        Blank lines between non-test preamble nodes are captured.
        """
        code = "import os\n\nCONSTANT = 42\n\ndef test_a():\n    assert True\n"

        result = _split_code_with_ast(code, (3, 12))

        assert result is not None
        preamble, tests = result
        assert "import os" in preamble
        assert "CONSTANT = 42" in preamble
        assert 1 == len(tests)

    def test_gap_with_code_before_test(self) -> None:
        """
        Non-comment content in gap between preamble and test is kept.
        """
        code = (
            "import os\n\n"
            "SETUP = True\n\n"
            "EXTRA = 'value'\n\n"
            "def test_a():\n    assert SETUP\n"
        )

        result = _split_code_with_ast(code, (3, 12))

        assert result is not None
        preamble, tests = result
        assert "SETUP" in preamble
        assert "EXTRA" in preamble
        assert 1 == len(tests)


class TestSplitCodeWithRegex:
    """Tests for _split_code_with_regex."""

    def test_extracts_test_class(self) -> None:
        """
        Test classes are identified via regex pattern.
        """
        code = (
            "import os\n\n"
            "class TestFoo:\n"
            "    def test_bar(self):\n"
            "        assert True\n"
        )

        preamble, tests = _split_code_with_regex(code)

        assert "import os" in preamble
        assert 1 == len(tests)
        assert "class TestFoo" in tests[0]

    def test_trailing_blank_lines_stripped(self) -> None:
        """
        Trailing blank lines after test functions are removed.
        """
        code = "import os\n\ndef test_a():\n    assert True\n\n\n"

        preamble, tests = _split_code_with_regex(code)

        assert 1 == len(tests)
        assert not tests[0].endswith("\n\n")


class TestSplitCodeIntoParts:
    """Tests for _split_code_into_parts."""

    def test_splits_preamble_and_tests(self) -> None:
        """
        Code is split into preamble (imports) and test functions.
        """
        code = (
            "import os\n\n"
            "def test_a():\n    assert True\n\n"
            "def test_b():\n    assert False\n"
        )

        preamble, tests = _split_code_into_parts(code, (3, 12))

        assert "import os" in preamble
        assert 2 == len(tests)

    def test_no_tests_returns_full_code(self) -> None:
        """
        Code with no tests returns the full code as preamble.
        """
        code = "import os\ndef helper(): pass\n"

        preamble, tests = _split_code_into_parts(code, (3, 12))

        assert 0 == len(tests)

    def test_decorated_test_function(self) -> None:
        """
        Decorated test functions include the decorator.
        """
        code = (
            "import pytest\n\n"
            "@pytest.mark.parametrize('x', [1, 2])\n"
            "def test_a(x):\n    assert x > 0\n"
        )

        preamble, tests = _split_code_into_parts(code, (3, 12))

        assert "import pytest" in preamble
        assert 1 == len(tests)
        assert "@pytest.mark.parametrize" in tests[0]

    def test_test_class_split(self) -> None:
        """
        Test classes are extracted as individual test items.
        """
        code = (
            "import unittest\n\n"
            "class TestFoo(unittest.TestCase):\n"
            "    def test_bar(self):\n"
            "        self.assertTrue(True)\n"
        )

        preamble, tests = _split_code_into_parts(code, (3, 12))

        assert "import unittest" in preamble
        assert 1 == len(tests)
        assert "class TestFoo" in tests[0]

    def test_regex_fallback_on_syntax_error(self) -> None:
        """
        Code with syntax errors falls back to regex splitter.
        """
        code = (
            "import os\n\n"
            "def test_a():\n    assert True\n\n"
            "def test_b():\n    x = (\n"
        )

        preamble, tests = _split_code_into_parts(code, (3, 12))

        assert "import os" in preamble
        assert 2 == len(tests)


class TestRepairPreamble:
    """Tests for _repair_preamble."""

    def test_valid_preamble_unchanged(self) -> None:
        """
        A valid preamble is returned unchanged.
        """
        code = "import os\nimport sys\n"

        result = _repair_preamble(code, (3, 12))

        assert "import os" in result
        assert "import sys" in result

    def test_removes_bad_line(self) -> None:
        """
        A line with a syntax error is removed.
        """
        code = "import os\ndef bad(\nimport sys\n"

        result = _repair_preamble(code, (3, 12))

        assert "import os" in result
        assert "import sys" in result

    def test_empty_input(self) -> None:
        """
        Empty input returns empty string.
        """
        assert "" == _repair_preamble("", (3, 12))

    def test_all_bad_lines_returns_empty(self) -> None:
        """
        If every line is invalid, returns empty string.
        """
        code = "def (\ndef (\ndef (\n"

        assert "" == _repair_preamble(code, (3, 12))

    def test_becomes_empty_during_iteration(self) -> None:
        """
        Lines that become empty after stripping return empty string.
        """
        code = "   \n  \n"

        assert "" == _repair_preamble(code, (3, 12))

    def test_syntax_error_with_no_lineno(self) -> None:
        """
        SyntaxError with lineno=None returns empty string.
        """
        with patch(
            "codeflash_api.testgen._validate.ast.parse",
            side_effect=SyntaxError("err", (None, None, None, None)),
        ):
            assert "" == _repair_preamble("import os\n", (3, 12))

    def test_error_idx_out_of_range(self) -> None:
        """
        Error on line beyond line count returns empty string.
        """
        err = SyntaxError("err")
        err.lineno = 999

        with patch(
            "codeflash_api.testgen._validate.ast.parse",
            side_effect=err,
        ):
            assert "" == _repair_preamble("import os\n", (3, 12))

    def test_exhausts_max_attempts(self) -> None:
        """
        After max_attempts still-invalid code returns empty string.
        """
        lines = [f"bad{i}(\n" for i in range(20)]
        code = "".join(lines)

        result = _repair_preamble(code, (3, 12))

        assert "" == result


class TestParseAndValidateLlmOutput:
    """Tests for _parse_and_validate_llm_output."""

    def test_extracts_valid_code(self) -> None:
        """
        Code in a markdown block is extracted and validated.
        """
        content = (
            "Here are tests:\n```python\ndef test_a():\n    assert True\n```\n"
        )

        result = _parse_and_validate_llm_output(
            content, "def f(): pass", (3, 12, 0)
        )

        assert "test_a" in result

    def test_no_code_block_raises(self) -> None:
        """
        Response without code block raises CodeValidationError.
        """
        with pytest.raises(CodeValidationError, match="Python code block"):
            _parse_and_validate_llm_output(
                "no code here", "def f(): pass", (3, 12, 0)
            )

    def test_code_block_without_python_content_raises(self) -> None:
        """
        Markdown block present but extract_code_block returns None.
        """
        content = "```python\n```\n"
        with pytest.raises(CodeValidationError, match="syntax error"):
            _parse_and_validate_llm_output(
                content, "def f(): pass", (3, 12, 0)
            )

    def test_extract_code_block_returns_none(self) -> None:
        """
        When extract_code_block returns None, raises CodeValidationError.
        """
        content = "has ```python marker but extract returns None"
        with (
            patch(
                "codeflash_api.testgen._generate.extract_code_block",
                return_value=None,
            ),
            pytest.raises(CodeValidationError, match="No Python code block"),
        ):
            _parse_and_validate_llm_output(
                content, "def f(): pass", (3, 12, 0)
            )

    def test_ellipsis_in_output_raises_syntax_error(self) -> None:
        """
        Generated code with assigned ellipsis not in source raises SyntaxError.
        """
        content = "```python\ndef test_a():\n    x = ...\n```\n"
        with pytest.raises(SyntaxError, match="Ellipsis"):
            _parse_and_validate_llm_output(
                content, "def f(): return 1", (3, 12, 0)
            )


class TestDidGenerateEllipsis:
    """Tests for _did_generate_ellipsis."""

    def test_no_ellipsis_returns_false(self) -> None:
        """
        Code without ellipsis returns False.
        """
        assert not _did_generate_ellipsis(
            "def f(): return 1",
            "def test_f():\n    assert True\n",
        )

    def test_assigned_ellipsis_in_generated_not_source(self) -> None:
        """
        Assigned ellipsis in generated code but not source returns True.
        """
        assert _did_generate_ellipsis(
            "def f(): return 1",
            "def test_f():\n    x = ...\n",
        )

    def test_ellipsis_in_both_returns_false(self) -> None:
        """
        Ellipsis present in both source and generated returns False.
        """
        assert not _did_generate_ellipsis(
            "x = ...",
            "def test_f():\n    y = ...\n",
        )

    def test_multi_context_source_without_ellipsis(self) -> None:
        """
        Multi-context source with no ellipsis while generated has it returns True.
        """
        source = "```python:src/mod.py\ndef f():\n    return 1\n```"
        generated = "def test_f():\n    x = ...\n"

        assert _did_generate_ellipsis(source, generated)

    def test_multi_context_source_with_ellipsis(self) -> None:
        """
        Multi-context source where all files have ellipsis returns False.
        """
        source = "```python:src/mod.py\nx = ...\n```"
        generated = "def test_f():\n    y = ...\n"

        assert not _did_generate_ellipsis(source, generated)


class TestTestGenRequestValidator:
    """Tests for TestGenRequest model validator."""

    def test_resolves_python_version_from_language_version(
        self,
    ) -> None:
        """
        python_version is set from language_version when not provided.
        """
        req = TestGenRequest(
            source_code_being_tested="def f(): pass",
            function_name="f",
            module_path="m.py",
            test_module_path="t.py",
            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.
        """
        req = TestGenRequest(
            source_code_being_tested="def f(): pass",
            function_name="f",
            module_path="m.py",
            test_module_path="t.py",
            trace_id="abc",
            python_version="3.11.0",
            language_version="3.12.1",
        )

        assert "3.11.0" == req.python_version


class TestTestgenEndpoint:
    """Integration tests for POST /ai/testgen."""

    async def test_success(self, client, mock_llm_client) -> None:
        """
        Successful test generation returns generated code.
        """
        generated = "def test_a():\n    assert True"
        mock_llm_client.call = AsyncMock(
            return_value=_stub_llm_response(
                f"```python\n{generated}\n```",
            ),
        )

        resp = await client.post(
            "/ai/testgen",
            json={
                "source_code_being_tested": "def f(): return 1",
                "function_name": "f",
                "module_path": "src/mod.py",
                "test_module_path": "tests/test_mod.py",
                "test_framework": "pytest",
                "trace_id": "12345678-1234-4000-8000-000000000000",
                "python_version": "3.12.1",
            },
        )

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

    async def test_invalid_trace_id(self, client) -> None:
        """
        Invalid trace_id returns 400 with UUID error detail.
        """
        resp = await client.post(
            "/ai/testgen",
            json={
                "source_code_being_tested": "def f(): pass",
                "function_name": "f",
                "module_path": "m.py",
                "test_module_path": "t.py",
                "trace_id": "bad-id",
                "python_version": "3.12.0",
            },
        )

        assert 400 == resp.status_code
        assert "trace" in resp.json()["detail"].lower()

    async def test_non_python_returns_400(self, client) -> None:
        """
        Non-Python language returns 400.
        """
        resp = await client.post(
            "/ai/testgen",
            json={
                "source_code_being_tested": "fn f() {}",
                "function_name": "f",
                "module_path": "m.rs",
                "test_module_path": "t.rs",
                "trace_id": "12345678-1234-4000-8000-000000000000",
                "language": "rust",
                "python_version": "3.12.0",
            },
        )

        assert 400 == resp.status_code
        assert "python" in resp.json()["detail"].lower()

    async def test_invalid_framework_returns_400(self, client) -> None:
        """
        Unsupported test framework returns 400.
        """
        resp = await client.post(
            "/ai/testgen",
            json={
                "source_code_being_tested": "def f(): pass",
                "function_name": "f",
                "module_path": "m.py",
                "test_module_path": "t.py",
                "trace_id": "12345678-1234-4000-8000-000000000000",
                "test_framework": "jest",
                "python_version": "3.12.0",
            },
        )

        assert 400 == resp.status_code
        assert "framework" in resp.json()["detail"].lower()

    async def test_missing_python_version_returns_400(self, client) -> None:
        """
        Missing python_version returns 400.
        """
        resp = await client.post(
            "/ai/testgen",
            json={
                "source_code_being_tested": "def f(): pass",
                "function_name": "f",
                "module_path": "m.py",
                "test_module_path": "t.py",
                "trace_id": "12345678-1234-4000-8000-000000000000",
            },
        )

        assert 400 == resp.status_code
        assert "version" in resp.json()["detail"].lower()

    async def test_llm_failure_returns_500(
        self, client, mock_llm_client
    ) -> None:
        """
        LLM exception returns 500.
        """
        mock_llm_client.call = AsyncMock(
            side_effect=RuntimeError("LLM down"),
        )

        resp = await client.post(
            "/ai/testgen",
            json={
                "source_code_being_tested": "def f(): return 1",
                "function_name": "f",
                "module_path": "m.py",
                "test_module_path": "t.py",
                "trace_id": "12345678-1234-4000-8000-000000000000",
                "python_version": "3.12.0",
            },
        )

        assert 500 == resp.status_code
        assert "detail" in resp.json()

    async def test_no_code_block_returns_422(
        self, client, mock_llm_client
    ) -> None:
        """
        LLM response without code block returns 422.
        """
        mock_llm_client.call = AsyncMock(
            return_value=_stub_llm_response(
                "Sorry, I cannot generate tests.",
            ),
        )

        resp = await client.post(
            "/ai/testgen",
            json={
                "source_code_being_tested": "def f(): return 1",
                "function_name": "f",
                "module_path": "m.py",
                "test_module_path": "t.py",
                "trace_id": "12345678-1234-4000-8000-000000000000",
                "python_version": "3.12.0",
            },
        )

        assert 422 == resp.status_code
        assert "detail" in resp.json()

    async def test_model_selection_even_index(
        self, client, mock_llm_client
    ) -> None:
        """
        Even test_index uses OpenAI model.
        """
        generated = "def test_a():\n    assert True"
        mock_llm_client.call = AsyncMock(
            return_value=_stub_llm_response(
                f"```python\n{generated}\n```",
            ),
        )

        resp = await client.post(
            "/ai/testgen",
            json={
                "source_code_being_tested": "def f(): return 1",
                "function_name": "f",
                "module_path": "m.py",
                "test_module_path": "t.py",
                "trace_id": "12345678-1234-4000-8000-000000000000",
                "python_version": "3.12.0",
                "test_index": 0,
            },
        )

        assert 200 == resp.status_code
        assert (
            "openai" == mock_llm_client.call.call_args.kwargs["llm"].model_type
        )

    async def test_model_selection_odd_index(
        self, client, mock_llm_client
    ) -> None:
        """
        Odd test_index uses Anthropic model.
        """
        generated = "def test_a():\n    assert True"
        mock_llm_client.call = AsyncMock(
            return_value=_stub_llm_response(
                f"```python\n{generated}\n```",
            ),
        )

        resp = await client.post(
            "/ai/testgen",
            json={
                "source_code_being_tested": "def f(): return 1",
                "function_name": "f",
                "module_path": "m.py",
                "test_module_path": "t.py",
                "trace_id": "12345678-1234-4000-8000-000000000000",
                "python_version": "3.12.0",
                "test_index": 1,
            },
        )

        assert 200 == resp.status_code
        assert (
            "anthropic"
            == mock_llm_client.call.call_args.kwargs["llm"].model_type
        )

    async def test_language_version_fallback(
        self, client, mock_llm_client
    ) -> None:
        """
        language_version is used when python_version is not set.
        """
        generated = "def test_a():\n    assert True"
        mock_llm_client.call = AsyncMock(
            return_value=_stub_llm_response(
                f"```python\n{generated}\n```",
            ),
        )

        resp = await client.post(
            "/ai/testgen",
            json={
                "source_code_being_tested": "def f(): return 1",
                "function_name": "f",
                "module_path": "m.py",
                "test_module_path": "t.py",
                "trace_id": "12345678-1234-4000-8000-000000000000",
                "language_version": "3.11.5",
            },
        )

        assert 200 == resp.status_code
        assert "test_a" in resp.json()["generated_tests"]


class TestGenerateTestsNoTestFunctions:
    """Tests for generate_tests when validated code has no test functions."""

    async def test_no_tests_after_validation_raises(self) -> None:
        """
        Validated code with no test functions raises CodeValidationError.
        """
        mock_client = MagicMock()
        mock_client.call = AsyncMock(
            return_value=MagicMock(
                content="```python\nimport os\n```",
                cost=0.01,
            ),
        )

        with (
            patch(
                "codeflash_api.testgen._generate.validate_testgen_code",
                return_value="import os",
            ),
            pytest.raises(
                CodeValidationError,
                match="No test functions",
            ),
        ):
            await generate_tests(
                mock_client,
                source_code="def f(): return 1",
                function_name="f",
                test_framework="pytest",
                python_version_str="3.12.0",
                is_async=False,
                trace_id="12345678-1234-4000-8000-000000000000",
                user_id="user1",
                test_index=0,
            )
