"""Tests for _reference_graph (ReferenceGraph and utility functions)."""

from __future__ import annotations

import sqlite3
import textwrap
from typing import TYPE_CHECKING

import pytest

from codeflash_python.analysis._reference_graph import (
    ReferenceGraph,
    belongs_to_function_qualified,
    get_qualified_name,
    path_belongs_to_site_packages,
)

if TYPE_CHECKING:
    from pathlib import Path

    from codeflash_python.analysis._call_graph import IndexResult


def write_file(
    project: Path,
    name: str,
    content: str,
) -> Path:
    """Write a file into the project directory and return its path."""
    fp = project / name
    fp.write_text(content, encoding="utf-8")
    return fp


@pytest.fixture
def project(tmp_path: Path) -> Path:
    """Create a project directory inside tmp_path."""
    project_root = tmp_path / "project"
    project_root.mkdir()
    return project_root


@pytest.fixture
def db_path(tmp_path: Path) -> Path:
    """Return a path for the SQLite cache database."""
    return tmp_path / "cache.db"


CALLER_HELPER_SRC = textwrap.dedent("""\
    def helper():
        return 1

    def caller():
        return helper()
""")


class TestGetQualifiedName:
    """get_qualified_name strips the module prefix from a full name."""

    def test_strips_module_prefix(self) -> None:
        """Normal case: returns the part after the module name."""
        assert "helper" == get_qualified_name("pkg.mod", "pkg.mod.helper")

    def test_nested_name(self) -> None:
        """Dotted suffix is preserved: Class.method."""
        assert "MyClass.method" == get_qualified_name(
            "pkg.mod", "pkg.mod.MyClass.method"
        )

    def test_raises_for_empty_full_name(self) -> None:
        """Empty full_qualified_name raises ValueError."""
        with pytest.raises(ValueError, match="empty"):
            get_qualified_name("mod", "")

    def test_raises_when_not_starting_with_module(self) -> None:
        """full_qualified_name not starting with module raises ValueError."""
        with pytest.raises(ValueError, match="does not start with"):
            get_qualified_name("pkg.mod", "other.mod.foo")

    def test_raises_when_equals_module(self) -> None:
        """full_qualified_name equal to module_name raises ValueError."""
        with pytest.raises(ValueError, match="same as"):
            get_qualified_name("pkg.mod", "pkg.mod")


class TestPathBelongsToSitePackages:
    """path_belongs_to_site_packages checks site-packages membership."""

    def test_returns_false_for_tmp_path(
        self,
        tmp_path: Path,
    ) -> None:
        """A file in tmp_path is not in site-packages."""
        fp = tmp_path / "mod.py"
        fp.write_text("x = 1", encoding="utf-8")

        assert path_belongs_to_site_packages(fp) is False

    def test_returns_false_for_project_file(
        self,
        project: Path,
    ) -> None:
        """A file inside the project root is not in site-packages."""
        fp = write_file(project, "util.py", "def f(): pass")

        assert path_belongs_to_site_packages(fp) is False


class TestBelongsToFunctionQualified:
    """belongs_to_function_qualified Jedi-specific ownership check."""

    def test_returns_false_for_non_jedi_name(self) -> None:
        """A plain object without Jedi Name interface returns False."""

        class FakeName:
            full_name = None
            module_name = None

            def parent(self) -> None:
                return None

        assert belongs_to_function_qualified(FakeName(), "foo") is False


class TestReferenceGraphConstruction:
    """ReferenceGraph initialisation creates the DB schema."""

    def test_creates_schema_tables(
        self,
        project: Path,
        db_path: Path,
    ) -> None:
        """After construction the required tables exist in the DB."""
        write_file(project, "placeholder.py", "x = 1")
        rg = ReferenceGraph(project, db_path=db_path)
        try:
            conn = sqlite3.connect(str(db_path))
            tables = {
                row[0]
                for row in conn.execute(
                    "SELECT name FROM sqlite_master WHERE type='table'"
                ).fetchall()
            }
            conn.close()

            assert "indexed_files" in tables
            assert "call_edges" in tables
            assert "cg_schema_version" in tables
        finally:
            rg.close()

    def test_schema_migration_on_version_mismatch(
        self,
        project: Path,
        db_path: Path,
    ) -> None:
        """Stale schema version triggers table recreation."""
        write_file(project, "placeholder.py", "x = 1")

        conn = sqlite3.connect(str(db_path))
        conn.execute(
            "CREATE TABLE IF NOT EXISTS cg_schema_version "
            "(version INTEGER PRIMARY KEY)"
        )
        conn.execute(
            "INSERT INTO cg_schema_version (version) VALUES (?)",
            (0,),
        )
        conn.commit()
        conn.close()

        rg = ReferenceGraph(project, db_path=db_path)
        try:
            conn = sqlite3.connect(str(db_path))
            row = conn.execute(
                "SELECT version FROM cg_schema_version LIMIT 1"
            ).fetchone()
            conn.close()

            assert row is not None
            assert row[0] == ReferenceGraph.SCHEMA_VERSION
        finally:
            rg.close()


class TestResolvePath:
    """ReferenceGraph.resolve_path caches path resolution."""

    def test_resolves_and_caches(
        self,
        project: Path,
        db_path: Path,
    ) -> None:
        """Repeated calls return the same resolved string."""
        write_file(project, "mod.py", "x = 1")
        rg = ReferenceGraph(project, db_path=db_path)
        try:
            fp = project / "mod.py"
            first = rg.resolve_path(fp)
            second = rg.resolve_path(fp)

            assert first == second
            assert isinstance(first, str)
        finally:
            rg.close()


class TestEnsureFileIndexed:
    """ReferenceGraph.ensure_file_indexed indexes a file."""

    def test_indexes_simple_file(
        self,
        project: Path,
        db_path: Path,
    ) -> None:
        """A simple Python file is indexed without errors."""
        write_file(project, "mod.py", CALLER_HELPER_SRC)
        rg = ReferenceGraph(project, db_path=db_path)
        try:
            result = rg.ensure_file_indexed(project / "mod.py")

            assert result.error is False
            assert result.cached is False
        finally:
            rg.close()

    def test_second_call_is_cached(
        self,
        project: Path,
        db_path: Path,
    ) -> None:
        """Indexing the same unmodified file again returns cached=True."""
        write_file(project, "mod.py", "x = 1\n")
        rg = ReferenceGraph(project, db_path=db_path)
        try:
            rg.ensure_file_indexed(project / "mod.py")
            result = rg.ensure_file_indexed(project / "mod.py")

            assert result.cached is True
            assert result.error is False
        finally:
            rg.close()


class TestBuildIndex:
    """ReferenceGraph.build_index processes multiple files."""

    def test_indexes_multiple_files(
        self,
        project: Path,
        db_path: Path,
    ) -> None:
        """build_index invokes the progress callback for each file."""
        write_file(
            project,
            "a.py",
            textwrap.dedent("""\
                def helper_a():
                    return 1

                def caller_a():
                    return helper_a()
            """),
        )
        write_file(
            project,
            "b.py",
            textwrap.dedent("""\
                from a import helper_a

                def caller_b():
                    return helper_a()
            """),
        )
        rg = ReferenceGraph(project, db_path=db_path)
        try:
            progress: list[object] = []
            rg.build_index(
                [project / "a.py", project / "b.py"],
                on_progress=progress.append,
            )

            assert 2 == len(progress)
        finally:
            rg.close()

    def test_cached_on_second_pass(
        self,
        project: Path,
        db_path: Path,
    ) -> None:
        """A second build_index call reports all files as cached."""
        write_file(project, "mod.py", "x = 1\n")
        rg = ReferenceGraph(project, db_path=db_path)
        try:
            rg.build_index([project / "mod.py"])

            cached_results: list[IndexResult] = []
            rg.build_index(
                [project / "mod.py"],
                on_progress=cached_results.append,
            )

            assert 1 == len(cached_results)
            assert cached_results[0].cached is True
        finally:
            rg.close()


class TestGetCallGraph:
    """ReferenceGraph.get_call_graph returns edges for indexed functions."""

    def test_simple_call_graph(
        self,
        project: Path,
        db_path: Path,
    ) -> None:
        """A function calling a helper produces one edge."""
        write_file(project, "mod.py", CALLER_HELPER_SRC)
        rg = ReferenceGraph(project, db_path=db_path)
        try:
            graph = rg.get_call_graph({project / "mod.py": {"caller"}})

            assert 1 == len(graph.edges)
            assert "caller" == graph.edges[0].caller.qualified_name
            assert "helper" == graph.edges[0].callee.qualified_name
            assert graph.edges[0].is_cross_file is False
        finally:
            rg.close()

    def test_cross_file_call(
        self,
        project: Path,
        db_path: Path,
    ) -> None:
        """A cross-file call is flagged as is_cross_file=True."""
        write_file(
            project,
            "utils.py",
            "def utility():\n    return 42\n",
        )
        write_file(
            project,
            "main.py",
            textwrap.dedent("""\
                from utils import utility

                def caller():
                    return utility()
            """),
        )
        rg = ReferenceGraph(project, db_path=db_path)
        try:
            graph = rg.get_call_graph({project / "main.py": {"caller"}})

            assert 1 == len(graph.edges)
            assert graph.edges[0].is_cross_file is True
            assert "utility" == graph.edges[0].callee.qualified_name
        finally:
            rg.close()

    def test_multiple_callees(
        self,
        project: Path,
        db_path: Path,
    ) -> None:
        """A function calling two helpers produces two edges."""
        write_file(
            project,
            "mod.py",
            textwrap.dedent("""\
                def a():
                    return 1

                def b():
                    return 2

                def caller():
                    return a() + b()
            """),
        )
        rg = ReferenceGraph(project, db_path=db_path)
        try:
            graph = rg.get_call_graph({project / "mod.py": {"caller"}})
            callee_names = {e.callee.qualified_name for e in graph.edges}

            assert {"a", "b"} == callee_names
        finally:
            rg.close()

    def test_empty_input(
        self,
        project: Path,
        db_path: Path,
    ) -> None:
        """Empty input produces an empty graph."""
        rg = ReferenceGraph(project, db_path=db_path)
        try:
            graph = rg.get_call_graph({})

            assert [] == graph.edges
        finally:
            rg.close()

    def test_leaf_has_no_callees(
        self,
        project: Path,
        db_path: Path,
    ) -> None:
        """A leaf function with no calls produces no edges."""
        write_file(
            project,
            "mod.py",
            "def leaf():\n    return 42\n",
        )
        rg = ReferenceGraph(project, db_path=db_path)
        try:
            graph = rg.get_call_graph({project / "mod.py": {"leaf"}})

            assert [] == graph.edges
        finally:
            rg.close()

    def test_include_metadata_populates_callee_metadata(
        self,
        project: Path,
        db_path: Path,
    ) -> None:
        """include_metadata=True populates CalleeMetadata on edges."""
        write_file(project, "mod.py", CALLER_HELPER_SRC)
        rg = ReferenceGraph(project, db_path=db_path)
        try:
            graph = rg.get_call_graph(
                {project / "mod.py": {"caller"}},
                include_metadata=True,
            )

            assert 1 == len(graph.edges)
            meta = graph.edges[0].callee_metadata
            assert meta is not None
            assert "helper" == meta.only_function_name
            assert "function" == meta.definition_type
        finally:
            rg.close()

    def test_no_metadata_by_default(
        self,
        project: Path,
        db_path: Path,
    ) -> None:
        """By default callee_metadata is None."""
        write_file(project, "mod.py", CALLER_HELPER_SRC)
        rg = ReferenceGraph(project, db_path=db_path)
        try:
            graph = rg.get_call_graph({project / "mod.py": {"caller"}})

            assert 1 == len(graph.edges)
            assert graph.edges[0].callee_metadata is None
        finally:
            rg.close()


class TestCacheInvalidation:
    """ReferenceGraph re-indexes when file content changes."""

    def test_modified_file_reindexed(
        self,
        project: Path,
        db_path: Path,
    ) -> None:
        """Changing file content causes re-indexing with new edges."""
        fp = write_file(project, "mod.py", CALLER_HELPER_SRC)
        rg = ReferenceGraph(project, db_path=db_path)
        try:
            _, result_list = rg.get_callees({project / "mod.py": {"caller"}})
            assert any(fs.qualified_name == "helper" for fs in result_list)

            fp.write_text(
                "def helper():\n    return 1\n\n"
                "def new_helper():\n    return 2\n\n"
                "def caller():\n    return new_helper()\n",
                encoding="utf-8",
            )
            _, result_list = rg.get_callees({project / "mod.py": {"caller"}})
            callee_qns = {fs.qualified_name for fs in result_list}

            assert "new_helper" in callee_qns
        finally:
            rg.close()


class TestPersistenceAcrossSessions:
    """ReferenceGraph reads cached data from a prior session."""

    def test_second_session_reads_from_db(
        self,
        project: Path,
        db_path: Path,
    ) -> None:
        """A fresh ReferenceGraph instance picks up prior indexing."""
        write_file(project, "mod.py", CALLER_HELPER_SRC)
        rg1 = ReferenceGraph(project, db_path=db_path)
        try:
            _, result_list = rg1.get_callees({project / "mod.py": {"caller"}})
            assert any(fs.qualified_name == "helper" for fs in result_list)
        finally:
            rg1.close()

        rg2 = ReferenceGraph(project, db_path=db_path)
        try:
            assert 0 == len(rg2.indexed_file_hashes)
            _, result_list = rg2.get_callees({project / "mod.py": {"caller"}})
            assert any(fs.qualified_name == "helper" for fs in result_list)
        finally:
            rg2.close()


class TestClose:
    """ReferenceGraph.close closes the DB connection."""

    def test_close_prevents_further_queries(
        self,
        project: Path,
        db_path: Path,
    ) -> None:
        """After close, the underlying connection is unusable."""
        write_file(project, "mod.py", "x = 1\n")
        rg = ReferenceGraph(project, db_path=db_path)
        rg.close()

        with pytest.raises(
            sqlite3.ProgrammingError,
            match="closed",
        ):
            rg.conn.execute("SELECT 1")


class TestCountCalleesPerFunction:
    """ReferenceGraph.count_callees_per_function counts."""

    def test_counts_callees(
        self,
        project: Path,
        db_path: Path,
    ) -> None:
        """Returns the number of callees per function."""
        write_file(
            project,
            "mod.py",
            textwrap.dedent("""\
                def helper_a():
                    return 1

                def helper_b():
                    return 2

                def caller_one():
                    return helper_a() + helper_b()

                def caller_two():
                    return helper_a()

                def leaf():
                    return 42
            """),
        )
        rg = ReferenceGraph(project, db_path=db_path)
        try:
            mod = project / "mod.py"
            rg.build_index([mod])
            counts = rg.count_callees_per_function(
                {mod: {"caller_one", "caller_two", "leaf"}}
            )

            assert 2 == counts[(mod, "caller_one")]
            assert 1 == counts[(mod, "caller_two")]
            assert 0 == counts[(mod, "leaf")]
        finally:
            rg.close()
