"""Tests for _function_ranking (profiling-based function ranking)."""

from __future__ import annotations

import sqlite3
from pathlib import Path

from codeflash_python._model import FunctionToOptimize
from codeflash_python.analysis._function_ranking import (
    DEFAULT_IMPORTANCE_THRESHOLD,
    PYTEST_FILE_PATTERNS,
    PYTEST_FUNC_PATTERNS,
    FunctionRanker,
    is_pytest_infrastructure,
)


def create_trace_db(
    path: Path,
    rows: list[tuple[str, int, str, str | None, int, int, int, int]],
) -> Path:
    """Create a SQLite trace database with pstats data."""
    conn = sqlite3.connect(str(path))
    conn.execute(
        """
        CREATE TABLE pstats (
            filename TEXT,
            line_number INTEGER,
            function TEXT,
            class_name TEXT,
            call_count_nonrecursive INTEGER,
            num_callers INTEGER,
            total_time_ns INTEGER,
            cumulative_time_ns INTEGER,
            callers BLOB
        )
        """
    )
    for row in rows:
        conn.execute(
            "INSERT INTO pstats VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
            (*row, b"[]"),
        )
    conn.commit()
    conn.close()
    return path


def fto(
    name: str,
    file: str = "mod.py",
) -> FunctionToOptimize:
    """Build a FunctionToOptimize with minimal fields."""
    return FunctionToOptimize(function_name=name, file_path=Path(file))


class TestConstants:
    """Module-level constants."""

    def test_default_importance_threshold(self) -> None:
        """DEFAULT_IMPORTANCE_THRESHOLD is 0.001."""
        assert 0.001 == DEFAULT_IMPORTANCE_THRESHOLD

    def test_pytest_file_patterns_contents(self) -> None:
        """PYTEST_FILE_PATTERNS contains all expected patterns."""
        expected = {
            "<frozen",
            "<string>",
            "_pytest/",
            "pytest",
            "pluggy/",
            "_pydev",
            "runpy.py",
        }
        assert expected == PYTEST_FILE_PATTERNS

    def test_pytest_func_patterns_contents(self) -> None:
        """PYTEST_FUNC_PATTERNS contains all expected patterns."""
        expected = {"pytest_", "_pytest", "runtest"}
        assert expected == PYTEST_FUNC_PATTERNS

    def test_pytest_file_patterns_is_frozenset(self) -> None:
        """PYTEST_FILE_PATTERNS is a frozenset."""
        assert isinstance(PYTEST_FILE_PATTERNS, frozenset)

    def test_pytest_func_patterns_is_frozenset(self) -> None:
        """PYTEST_FUNC_PATTERNS is a frozenset."""
        assert isinstance(PYTEST_FUNC_PATTERNS, frozenset)


class TestIsPytestInfrastructure:
    """is_pytest_infrastructure detects test framework internals."""

    def test_pytest_internal_filename(self) -> None:
        """Filenames containing _pytest/ are infrastructure."""
        assert (
            is_pytest_infrastructure("_pytest/config.py", "some_func") is True
        )

    def test_pluggy_filename(self) -> None:
        """Filenames containing pluggy/ are infrastructure."""
        assert is_pytest_infrastructure("pluggy/hooks.py", "some_func") is True

    def test_frozen_module_filename(self) -> None:
        """Filenames containing <frozen are infrastructure."""
        assert (
            is_pytest_infrastructure(
                "<frozen importlib._bootstrap>", "exec_module"
            )
            is True
        )

    def test_string_eval_filename(self) -> None:
        """Filenames containing <string> are infrastructure."""
        assert is_pytest_infrastructure("<string>", "some_func") is True

    def test_pydev_debugger_filename(self) -> None:
        """Filenames containing _pydev are infrastructure."""
        assert (
            is_pytest_infrastructure("_pydev_bundle/pydev_log.py", "log")
            is True
        )

    def test_runpy_filename(self) -> None:
        """Filenames matching runpy.py are infrastructure."""
        assert is_pytest_infrastructure("runpy.py", "run_module") is True

    def test_pytest_filename_pattern(self) -> None:
        """Filenames containing pytest are infrastructure."""
        assert is_pytest_infrastructure("pytest_main.py", "user_func") is True

    def test_pytest_function_name_prefix(self) -> None:
        """Function names starting with pytest_ are infrastructure."""
        assert (
            is_pytest_infrastructure("user_code.py", "pytest_configure")
            is True
        )

    def test_internal_pytest_function_name(self) -> None:
        """Function names containing _pytest are infrastructure."""
        assert (
            is_pytest_infrastructure("user_code.py", "do_pytest_stuff") is True
        )

    def test_runtest_function_name(self) -> None:
        """Function names containing runtest are infrastructure."""
        assert (
            is_pytest_infrastructure("user_code.py", "runtest_protocol")
            is True
        )

    def test_normal_user_code(self) -> None:
        """Normal user code is not infrastructure."""
        assert (
            is_pytest_infrastructure("src/mymodule.py", "compute_result")
            is False
        )

    def test_normal_filename_and_function(self) -> None:
        """A plain filename with a plain function is not infrastructure."""
        assert (
            is_pytest_infrastructure("/home/user/project/utils.py", "helper")
            is False
        )

    def test_function_name_matching_is_case_insensitive(self) -> None:
        """Function name matching uses .lower() for case insensitivity."""
        assert (
            is_pytest_infrastructure("user_code.py", "PYTEST_configure")
            is True
        )

    def test_function_name_uppercase_runtest(self) -> None:
        """Uppercase RunTest is detected via case-insensitive matching."""
        assert (
            is_pytest_infrastructure("user_code.py", "RunTest_protocol")
            is True
        )


class TestFunctionRankerConstruction:
    """FunctionRanker initialization from trace databases."""

    def test_loads_valid_trace_database(self, tmp_path: Path) -> None:
        """Successfully loads stats from a valid trace database."""
        db_path = create_trace_db(
            tmp_path / "trace.db",
            [("src/mod.py", 10, "compute", None, 5, 1, 1000, 2000)],
        )

        ranker = FunctionRanker(db_path)

        assert 1 == len(ranker._function_stats)

    def test_handles_missing_pstats_table(self, tmp_path: Path) -> None:
        """A database without a pstats table results in empty stats."""
        db_path = tmp_path / "trace.db"
        conn = sqlite3.connect(str(db_path))
        conn.close()

        ranker = FunctionRanker(db_path)

        assert {} == ranker._function_stats

    def test_handles_empty_pstats_table(self, tmp_path: Path) -> None:
        """An empty pstats table results in empty stats."""
        db_path = create_trace_db(tmp_path / "trace.db", [])

        ranker = FunctionRanker(db_path)

        assert {} == ranker._function_stats

    def test_filters_out_pytest_infrastructure(self, tmp_path: Path) -> None:
        """Rows matching pytest infrastructure patterns are excluded."""
        db_path = create_trace_db(
            tmp_path / "trace.db",
            [
                ("src/mod.py", 10, "compute", None, 5, 1, 1000, 2000),
                ("_pytest/config.py", 1, "init", None, 3, 1, 500, 800),
            ],
        )

        ranker = FunctionRanker(db_path)

        assert 1 == len(ranker._function_stats)
        stats = next(iter(ranker._function_stats.values()))
        assert "compute" == stats["function_name"]

    def test_skips_rows_with_zero_call_count(self, tmp_path: Path) -> None:
        """Rows with call_count <= 0 are excluded."""
        db_path = create_trace_db(
            tmp_path / "trace.db",
            [
                ("src/mod.py", 10, "compute", None, 5, 1, 1000, 2000),
                ("src/mod.py", 20, "dead_code", None, 0, 0, 0, 0),
            ],
        )

        ranker = FunctionRanker(db_path)

        assert 1 == len(ranker._function_stats)

    def test_skips_rows_with_negative_call_count(self, tmp_path: Path) -> None:
        """Rows with negative call_count are excluded."""
        db_path = create_trace_db(
            tmp_path / "trace.db",
            [
                ("src/mod.py", 10, "compute", None, 5, 1, 1000, 2000),
                ("src/mod.py", 20, "broken", None, -1, 0, 0, 0),
            ],
        )

        ranker = FunctionRanker(db_path)

        assert 1 == len(ranker._function_stats)

    def test_parses_class_methods(self, tmp_path: Path) -> None:
        """Class methods produce qualified names like ClassName.method."""
        db_path = create_trace_db(
            tmp_path / "trace.db",
            [("src/mod.py", 10, "process", "MyClass", 3, 1, 900, 1800)],
        )

        ranker = FunctionRanker(db_path)

        stats = next(iter(ranker._function_stats.values()))
        assert "process" == stats["function_name"]
        assert "MyClass" == stats["class_name"]
        assert "MyClass.process" == stats["qualified_name"]

    def test_plain_function_has_no_class_name(self, tmp_path: Path) -> None:
        """A plain function (no class) has class_name=None."""
        db_path = create_trace_db(
            tmp_path / "trace.db",
            [("src/mod.py", 10, "compute", None, 5, 1, 1000, 2000)],
        )

        ranker = FunctionRanker(db_path)

        stats = next(iter(ranker._function_stats.values()))
        assert stats["class_name"] is None

    def test_computes_own_time_and_callee_time(self, tmp_path: Path) -> None:
        """own_time is total_time; callee_time is cumulative minus total."""
        db_path = create_trace_db(
            tmp_path / "trace.db",
            [("src/mod.py", 10, "compute", None, 2, 1, 1000, 3000)],
        )

        ranker = FunctionRanker(db_path)

        stats = next(iter(ranker._function_stats.values()))
        assert 1000 == stats["own_time_ns"]
        assert 2000 == stats["time_in_callees_ns"]

    def test_computes_addressable_time(self, tmp_path: Path) -> None:
        """addressable_time = own_time + (time_in_callees / call_count)."""
        db_path = create_trace_db(
            tmp_path / "trace.db",
            [("src/mod.py", 10, "compute", None, 4, 1, 1000, 5000)],
        )

        ranker = FunctionRanker(db_path)

        stats = next(iter(ranker._function_stats.values()))
        # addressable = 1000 + (4000 / 4) = 2000
        assert 2000.0 == stats["addressable_time_ns"]


class TestGetFunctionStatsSummary:
    """get_function_stats_summary looks up stats by function name and file."""

    def test_returns_stats_for_known_function(self, tmp_path: Path) -> None:
        """Returns a stats dict when the function is found."""
        db_path = create_trace_db(
            tmp_path / "trace.db",
            [("src/mod.py", 10, "compute", None, 5, 1, 1000, 2000)],
        )
        ranker = FunctionRanker(db_path)
        func = fto("compute", "src/mod.py")

        result = ranker.get_function_stats_summary(func)

        assert result is not None
        assert "compute" == result["function_name"]

    def test_returns_none_for_unknown_function(self, tmp_path: Path) -> None:
        """Returns None when the function name is not in stats."""
        db_path = create_trace_db(
            tmp_path / "trace.db",
            [("src/mod.py", 10, "compute", None, 5, 1, 1000, 2000)],
        )
        ranker = FunctionRanker(db_path)
        func = fto("nonexistent", "src/mod.py")

        result = ranker.get_function_stats_summary(func)

        assert result is None

    def test_matches_by_filename(self, tmp_path: Path) -> None:
        """Matching uses file_path.name (basename) in the key."""
        db_path = create_trace_db(
            tmp_path / "trace.db",
            [
                ("src/mod.py", 10, "compute", None, 5, 1, 1000, 2000),
                ("lib/other.py", 20, "compute", None, 3, 1, 500, 800),
            ],
        )
        ranker = FunctionRanker(db_path)
        func = fto("compute", "lib/other.py")

        result = ranker.get_function_stats_summary(func)

        assert result is not None
        assert "other.py" in result["filename"]

    def test_returns_none_when_filename_mismatch(self, tmp_path: Path) -> None:
        """Returns None when function exists but in a different file."""
        db_path = create_trace_db(
            tmp_path / "trace.db",
            [("src/mod.py", 10, "compute", None, 5, 1, 1000, 2000)],
        )
        ranker = FunctionRanker(db_path)
        func = fto("compute", "src/different.py")

        result = ranker.get_function_stats_summary(func)

        assert result is None

    def test_handles_class_methods(self, tmp_path: Path) -> None:
        """Class methods are matched by their base function name."""
        db_path = create_trace_db(
            tmp_path / "trace.db",
            [("src/mod.py", 10, "process", "MyClass", 3, 1, 900, 1800)],
        )
        ranker = FunctionRanker(db_path)
        func = fto("process", "src/mod.py")

        result = ranker.get_function_stats_summary(func)

        assert result is not None
        assert "process" == result["function_name"]
        assert "MyClass" == result["class_name"]


class TestGetFunctionAddressableTime:
    """get_function_addressable_time returns addressable time (ns)."""

    def test_returns_addressable_time_for_known_function(
        self, tmp_path: Path
    ) -> None:
        """Returns the correct addressable_time_ns for a found function."""
        db_path = create_trace_db(
            tmp_path / "trace.db",
            [("src/mod.py", 10, "compute", None, 2, 1, 1000, 3000)],
        )
        ranker = FunctionRanker(db_path)
        func = fto("compute", "src/mod.py")

        result = ranker.get_function_addressable_time(func)

        # addressable = 1000 + (2000 / 2) = 2000
        assert 2000.0 == result

    def test_returns_zero_for_unknown_function(self, tmp_path: Path) -> None:
        """Returns 0.0 when the function is not in stats."""
        db_path = create_trace_db(
            tmp_path / "trace.db",
            [("src/mod.py", 10, "compute", None, 5, 1, 1000, 2000)],
        )
        ranker = FunctionRanker(db_path)
        func = fto("nonexistent", "src/mod.py")

        result = ranker.get_function_addressable_time(func)

        assert 0.0 == result

    def test_own_time_only_function(self, tmp_path: Path) -> None:
        """A function with total == cumulative has addressable = own_time."""
        db_path = create_trace_db(
            tmp_path / "trace.db",
            [("src/mod.py", 10, "leaf", None, 10, 1, 5000, 5000)],
        )
        ranker = FunctionRanker(db_path)
        func = fto("leaf", "src/mod.py")

        result = ranker.get_function_addressable_time(func)

        assert 5000.0 == result


class TestRankFunctions:
    """rank_functions ranks and filters by importance threshold."""

    def test_returns_empty_when_no_stats(self, tmp_path: Path) -> None:
        """Returns an empty list when no stats are available."""
        db_path = create_trace_db(tmp_path / "trace.db", [])
        ranker = FunctionRanker(db_path)
        funcs = [fto("compute", "src/mod.py")]

        result = ranker.rank_functions(funcs)

        assert [] == result

    def test_returns_empty_for_empty_input(self, tmp_path: Path) -> None:
        """Returns an empty list when given no functions to rank."""
        db_path = create_trace_db(
            tmp_path / "trace.db",
            [("src/mod.py", 10, "compute", None, 5, 1, 1000, 2000)],
        )
        ranker = FunctionRanker(db_path)

        result = ranker.rank_functions([])

        assert [] == result

    def test_ranks_by_addressable_time_descending(
        self, tmp_path: Path
    ) -> None:
        """Functions are sorted by addressable_time in descending order."""
        db_path = create_trace_db(
            tmp_path / "trace.db",
            [
                ("src/mod.py", 10, "slow", None, 1, 1, 5000, 10000),
                ("src/mod.py", 20, "fast", None, 1, 1, 1000, 2000),
                ("src/mod.py", 30, "medium", None, 1, 1, 3000, 6000),
            ],
        )
        ranker = FunctionRanker(db_path)
        funcs = [
            fto("fast", "src/mod.py"),
            fto("slow", "src/mod.py"),
            fto("medium", "src/mod.py"),
        ]

        result = ranker.rank_functions(funcs)

        names = [f.function_name for f in result]
        assert "slow" == names[0]
        assert "medium" == names[1]
        assert "fast" == names[2]

    def test_filters_below_importance_threshold(self, tmp_path: Path) -> None:
        """Functions below the importance threshold are excluded."""
        # total own_time for the file = 1_000_000
        # tiny function has addressable = 0.5 (importance ~0.0000005)
        db_path = create_trace_db(
            tmp_path / "trace.db",
            [
                (
                    "src/mod.py",
                    10,
                    "big",
                    None,
                    1,
                    1,
                    1_000_000,
                    2_000_000,
                ),
                ("src/mod.py", 20, "tiny", None, 2, 1, 1, 1),
            ],
        )
        ranker = FunctionRanker(db_path)
        funcs = [
            fto("big", "src/mod.py"),
            fto("tiny", "src/mod.py"),
        ]

        result = ranker.rank_functions(funcs)

        names = [f.function_name for f in result]
        assert "big" in names
        assert "tiny" not in names

    def test_passes_through_when_total_time_is_zero(
        self, tmp_path: Path
    ) -> None:
        """When total_program_time is 0, all functions pass through."""
        # All functions have own_time_ns = 0 but cumulative > 0
        # so they exist in stats but total file time is 0
        db_path = create_trace_db(
            tmp_path / "trace.db",
            [
                ("src/mod.py", 10, "alpha", None, 1, 1, 0, 1000),
                ("src/mod.py", 20, "beta", None, 1, 1, 0, 500),
            ],
        )
        ranker = FunctionRanker(db_path)
        funcs = [
            fto("alpha", "src/mod.py"),
            fto("beta", "src/mod.py"),
        ]

        result = ranker.rank_functions(funcs)

        assert 2 == len(result)

    def test_uses_file_relative_importance(self, tmp_path: Path) -> None:
        """Importance is relative to functions in the same file only."""
        # other_file has huge own_time, but importance is computed
        # relative to target file only
        db_path = create_trace_db(
            tmp_path / "trace.db",
            [
                (
                    "src/mod.py",
                    10,
                    "target_func",
                    None,
                    1,
                    1,
                    10_000,
                    20_000,
                ),
                (
                    "src/other.py",
                    10,
                    "huge_func",
                    None,
                    1,
                    1,
                    10_000_000,
                    20_000_000,
                ),
            ],
        )
        ranker = FunctionRanker(db_path)
        funcs = [fto("target_func", "src/mod.py")]

        result = ranker.rank_functions(funcs)

        # target_func importance = 20000/10000 = 2.0, well above 0.001
        assert 1 == len(result)
        assert "target_func" == result[0].function_name

    def test_handles_function_not_in_stats(self, tmp_path: Path) -> None:
        """Functions without stats are silently excluded from ranking."""
        db_path = create_trace_db(
            tmp_path / "trace.db",
            [("src/mod.py", 10, "known", None, 1, 1, 5000, 10000)],
        )
        ranker = FunctionRanker(db_path)
        funcs = [
            fto("known", "src/mod.py"),
            fto("unknown", "src/mod.py"),
        ]

        result = ranker.rank_functions(funcs)

        assert 1 == len(result)
        assert "known" == result[0].function_name

    def test_multiple_functions_same_file(self, tmp_path: Path) -> None:
        """All significant functions from the same file appear in results."""
        db_path = create_trace_db(
            tmp_path / "trace.db",
            [
                ("src/mod.py", 10, "alpha", None, 1, 1, 5000, 10000),
                ("src/mod.py", 20, "beta", None, 2, 1, 3000, 7000),
                ("src/mod.py", 30, "gamma", None, 3, 1, 2000, 5000),
            ],
        )
        ranker = FunctionRanker(db_path)
        funcs = [
            fto("alpha", "src/mod.py"),
            fto("beta", "src/mod.py"),
            fto("gamma", "src/mod.py"),
        ]

        result = ranker.rank_functions(funcs)

        assert 3 == len(result)
        # Verify descending addressable time order
        times = [ranker.get_function_addressable_time(f) for f in result]
        assert times == sorted(times, reverse=True)
