from __future__ import annotations

import os
import sqlite3
from pathlib import Path

from codeflash_python._model import VerificationType
from codeflash_python.test_discovery.linking import (
    module_name_from_file_path,
)
from codeflash_python.test_discovery.models import TestType
from codeflash_python.testing._data_parsers import parse_sqlite_test_results
from codeflash_python.testing._path_resolution import (
    file_name_from_test_module_name,
    file_path_from_module_name,
)
from codeflash_python.testing._result_merger import merge_test_results
from codeflash_python.testing._stdout_parsers import (
    parse_test_failures_from_stdout,
)
from codeflash_python.testing._xml_parser import parse_test_xml
from codeflash_python.testing.models import (
    FunctionTestInvocation,
    InvocationId,
    TestConfig,
    TestFile,
    TestFiles,
    TestResults,
)


def make_invocation_id(
    *,
    module: str = "tests.test_foo",
    cls: str | None = None,
    func: str = "test_bar",
    target: str = "bar",
    iteration: str = "0",
) -> InvocationId:
    """Create an InvocationId with sensible defaults."""
    return InvocationId(
        test_module_path=module,
        test_class_name=cls,
        test_function_name=func,
        function_getting_tested=target,
        iteration_id=iteration,
    )


def make_invocation(
    *,
    loop_index: int = 1,
    inv_id: InvocationId | None = None,
    runtime: int | None = 100,
    did_pass: bool = True,
) -> FunctionTestInvocation:
    """Create a FunctionTestInvocation with sensible defaults."""
    return FunctionTestInvocation(
        loop_index=loop_index,
        id=inv_id or make_invocation_id(),
        file_name=Path("tests/test_foo.py"),
        did_pass=did_pass,
        runtime=runtime,
        test_framework="pytest",
        test_type=TestType.EXISTING_UNIT_TEST,
        return_value=None,
        timed_out=False,
    )


class TestModuleNameFromFilePath:
    """module_name_from_file_path path-to-module conversion."""

    def test_basic_conversion(self, tmp_path: Path) -> None:
        """Converts a simple path to dotted module name."""
        test_file = tmp_path / "test_foo.py"
        test_file.touch()
        result = module_name_from_file_path(test_file, tmp_path)
        assert "test_foo" == result

    def test_nested_path(self, tmp_path: Path) -> None:
        """Handles nested directories correctly."""
        nested = tmp_path / "tests" / "unit" / "test_bar.py"
        nested.parent.mkdir(parents=True)
        nested.touch()
        result = module_name_from_file_path(nested, tmp_path)
        assert "tests.unit.test_bar" == result


class TestFilePathFromModuleName:
    """file_path_from_module_name module-to-path conversion."""

    def test_basic_conversion(self, tmp_path: Path) -> None:
        """Converts dotted name to path with .py extension."""
        result = file_path_from_module_name("test_foo", tmp_path)
        expected = tmp_path / "test_foo.py"
        assert expected == result

    def test_nested_path(self, tmp_path: Path) -> None:
        """Handles nested dotted module names."""
        result = file_path_from_module_name("tests.unit.test_bar", tmp_path)
        expected = tmp_path / "tests" / "unit" / "test_bar.py"
        assert expected == result


class TestFileNameFromTestModuleName:
    """file_name_from_test_module_name progressive resolution."""

    def test_resolves_existing_file(self, tmp_path: Path) -> None:
        """Resolves when the full module matches a file."""
        test_file = tmp_path / "tests" / "test_foo.py"
        test_file.parent.mkdir(parents=True)
        test_file.touch()
        result = file_name_from_test_module_name("tests.test_foo", tmp_path)
        assert test_file == result

    def test_strips_trailing_components(self, tmp_path: Path) -> None:
        """Strips trailing components until a file matches."""
        test_file = tmp_path / "tests" / "test_foo.py"
        test_file.parent.mkdir(parents=True)
        test_file.touch()
        result = file_name_from_test_module_name(
            "tests.test_foo.TestClass", tmp_path
        )
        assert test_file == result

    def test_returns_none_when_not_found(self, tmp_path: Path) -> None:
        """Returns None when no matching file exists."""
        result = file_name_from_test_module_name(
            "nonexistent.module", tmp_path
        )
        assert result is None


class TestParseTestFailuresFromStdout:
    """parse_test_failures_from_stdout failure extraction."""

    def test_extracts_failures(self) -> None:
        """Parses pytest failure output into name->text dict."""
        stdout = (
            "collected 2 items\n"
            "= FAILURES =\n"
            "___ test_alpha ___\n"
            "assert 1 == 2\n"
            "___ test_beta ___\n"
            "assert False\n"
            "= short test summary info =\n"
            "FAILED test_alpha\n"
        )
        result = parse_test_failures_from_stdout(stdout)
        assert "test_alpha" in result
        assert "test_beta" in result
        assert "1 == 2" in result["test_alpha"]

    def test_no_failures(self) -> None:
        """Returns empty dict when no failures section."""
        stdout = "collected 2 items\n2 passed\n"
        result = parse_test_failures_from_stdout(stdout)
        assert {} == result

    def test_failures_to_end_of_output(self) -> None:
        """Handles failures section at end without summary."""
        stdout = "= FAILURES =\n___ test_only ___\nsomething failed\n"
        result = parse_test_failures_from_stdout(stdout)
        assert "test_only" in result


class TestMergeTestResults:
    """merge_test_results XML + data merging."""

    def test_merge_xml_only(self) -> None:
        """XML results with no matching data pass through."""
        xml = TestResults()
        inv_id = make_invocation_id(func="test_a")
        xml.add(make_invocation(inv_id=inv_id))
        data = TestResults()

        merged = merge_test_results(xml, data)
        assert 1 == len(merged)

    def test_merge_single_xml_with_data(self) -> None:
        """Single XML result gets data merged in."""
        xml = TestResults()
        xml_id = make_invocation_id(func="test_a")
        xml.add(make_invocation(inv_id=xml_id, runtime=None))
        data = TestResults()
        data_id = make_invocation_id(
            func="test_a",
            target="foo",
        )
        data.add(make_invocation(inv_id=data_id, runtime=500))

        merged = merge_test_results(xml, data)
        assert 1 == len(merged)
        assert 500 == merged[0].runtime


class TestParseTestXml:
    """parse_test_xml JUnit XML parsing."""

    def test_missing_xml_file(self, tmp_path: Path) -> None:
        """Returns empty TestResults for nonexistent file."""
        config = TestConfig(tests_project_rootdir=tmp_path)
        files = TestFiles()
        result = parse_test_xml(
            tmp_path / "missing.xml",
            files,
            config,
        )
        assert 0 == len(result)

    def test_parses_basic_xml(self, tmp_path: Path) -> None:
        """Creates a minimal JUnit XML and parses it."""
        test_file = tmp_path / "test_example.py"
        test_file.touch()

        test_module = "test_example"

        xml_content = (
            '<?xml version="1.0" encoding="utf-8"?>'
            "<testsuites>"
            f'<testsuite name="pytest" file="test_example.py">'
            f'<testcase classname="{test_module}"'
            f' name="test_hello" />'
            "</testsuite>"
            "</testsuites>"
        )
        xml_file = tmp_path / "results.xml"
        xml_file.write_text(xml_content)

        config = TestConfig(tests_project_rootdir=tmp_path)
        tf = TestFile(
            original_file_path=test_file,
            instrumented_behavior_file_path=test_file,
            test_type=TestType.EXISTING_UNIT_TEST,
        )
        files = TestFiles(test_files=[tf])

        result = parse_test_xml(xml_file, files, config)
        assert len(result) >= 1
        assert result[0].did_pass is True


class TestParseSqliteTestResults:
    """parse_sqlite_test_results SQLite parsing."""

    def test_missing_file(self, tmp_path: Path) -> None:
        """Returns empty TestResults for nonexistent file."""
        config = TestConfig(tests_project_rootdir=tmp_path)
        files = TestFiles()
        result = parse_sqlite_test_results(
            tmp_path / "missing.sqlite",
            files,
            config,
        )
        assert 0 == len(result)

    def test_parses_basic_sqlite(self, tmp_path: Path) -> None:
        """Create sqlite with test_results table, parse it."""
        test_file = tmp_path / "tests" / "test_foo.py"
        test_file.parent.mkdir(parents=True)
        test_file.touch()

        db_path = tmp_path / "results.sqlite"
        conn = sqlite3.connect(db_path)
        conn.execute(
            "CREATE TABLE test_results ("
            "  test_module_path TEXT,"
            "  test_class_name TEXT,"
            "  test_function_name TEXT,"
            "  function_getting_tested TEXT,"
            "  loop_index INTEGER,"
            "  iteration_id TEXT,"
            "  runtime INTEGER,"
            "  return_value BLOB,"
            "  verification_type TEXT"
            ")"
        )
        module_name = f"tests{os.sep}test_foo".replace(os.sep, ".")
        conn.execute(
            "INSERT INTO test_results VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
            (
                module_name,
                None,
                "test_bar",
                "bar",
                1,
                "0",
                100,
                None,
                VerificationType.FUNCTION_CALL.value,
            ),
        )
        conn.commit()
        conn.close()

        config = TestConfig(tests_project_rootdir=tmp_path)
        tf = TestFile(
            original_file_path=test_file,
            test_type=TestType.EXISTING_UNIT_TEST,
        )
        files = TestFiles(test_files=[tf])

        result = parse_sqlite_test_results(db_path, files, config)
        assert 1 == len(result)
        assert result[0].did_pass is True
        assert 100 == result[0].runtime
