"""Tests for _call_graph (graph data types and operations)."""

from __future__ import annotations

import sqlite3
from pathlib import Path

import attrs
import pytest

from codeflash_python.analysis._call_graph import (
    CallEdge,
    CalleeMetadata,
    CallGraph,
    FunctionNode,
    IndexResult,
    augment_with_trace,
    callees_from_graph,
)


def node(
    name: str,
    file: str = "mod.py",
) -> FunctionNode:
    """Build a FunctionNode with a short name."""
    return FunctionNode(file_path=Path(file), qualified_name=name)


def edge(
    caller: str,
    callee: str,
    *,
    cross: bool = False,
    file: str = "mod.py",
) -> CallEdge:
    """Build a CallEdge between two named functions."""
    return CallEdge(
        caller=node(caller, file),
        callee=node(callee, file),
        is_cross_file=cross,
    )


def make_graph(edges: list[CallEdge]) -> CallGraph:
    """Build a CallGraph from a list of edges."""
    return CallGraph(edges=edges)


class TestFunctionNode:
    """FunctionNode construction, equality, and hashing."""

    def test_construction_with_path_and_string(self) -> None:
        """A FunctionNode stores file_path as Path and qualified_name."""
        fn = FunctionNode(
            file_path=Path("src/mod.py"),
            qualified_name="foo",
        )

        assert Path("src/mod.py") == fn.file_path
        assert "foo" == fn.qualified_name

    def test_string_file_path_converted_to_path(self) -> None:
        """Passing a string for file_path converts it to Path."""
        fn = FunctionNode(
            file_path="src/mod.py",  # type: ignore[arg-type]
            qualified_name="bar",
        )

        assert isinstance(fn.file_path, Path)
        assert Path("src/mod.py") == fn.file_path

    def test_equality_same_values(self) -> None:
        """Two FunctionNodes with the same fields are equal."""
        a = FunctionNode(file_path=Path("m.py"), qualified_name="f")
        b = FunctionNode(file_path=Path("m.py"), qualified_name="f")

        assert a == b

    def test_inequality_different_name(self) -> None:
        """Nodes with different qualified_name are not equal."""
        a = FunctionNode(file_path=Path("m.py"), qualified_name="f")
        b = FunctionNode(file_path=Path("m.py"), qualified_name="g")

        assert a != b

    def test_inequality_different_path(self) -> None:
        """Nodes with different file_path are not equal."""
        a = FunctionNode(file_path=Path("a.py"), qualified_name="f")
        b = FunctionNode(file_path=Path("b.py"), qualified_name="f")

        assert a != b

    def test_hashable_as_dict_key(self) -> None:
        """FunctionNode can be used as a dictionary key."""
        fn = FunctionNode(file_path=Path("m.py"), qualified_name="f")
        d = {fn: 42}

        assert 42 == d[fn]

    def test_equal_nodes_have_same_hash(self) -> None:
        """Equal FunctionNodes produce the same hash."""
        a = FunctionNode(file_path=Path("m.py"), qualified_name="f")
        b = FunctionNode(file_path=Path("m.py"), qualified_name="f")

        assert hash(a) == hash(b)

    def test_usable_in_set(self) -> None:
        """Duplicate FunctionNodes are deduplicated in a set."""
        a = FunctionNode(file_path=Path("m.py"), qualified_name="f")
        b = FunctionNode(file_path=Path("m.py"), qualified_name="f")

        assert 1 == len({a, b})

    def test_frozen(self) -> None:
        """FunctionNode is immutable."""
        fn = FunctionNode(file_path=Path("m.py"), qualified_name="f")

        with pytest.raises(attrs.exceptions.FrozenInstanceError):
            fn.qualified_name = "changed"  # type: ignore[misc]


class TestCalleeMetadata:
    """CalleeMetadata construction and field access."""

    def test_construction_and_fields(self) -> None:
        """All fields are accessible after construction."""
        meta = CalleeMetadata(
            fully_qualified_name="pkg.mod.helper",
            only_function_name="helper",
            definition_type="function",
            source_line="def helper(): ...",
        )

        assert "pkg.mod.helper" == meta.fully_qualified_name
        assert "helper" == meta.only_function_name
        assert "function" == meta.definition_type
        assert "def helper(): ..." == meta.source_line

    def test_frozen(self) -> None:
        """CalleeMetadata is immutable."""
        meta = CalleeMetadata(
            fully_qualified_name="x",
            only_function_name="y",
            definition_type="function",
            source_line="z",
        )

        with pytest.raises(attrs.exceptions.FrozenInstanceError):
            meta.only_function_name = "changed"  # type: ignore[misc]


class TestCallEdge:
    """CallEdge construction with and without optional fields."""

    def test_required_fields_only(self) -> None:
        """CallEdge with only required fields has None for optionals."""
        e = CallEdge(
            caller=node("a"),
            callee=node("b"),
            is_cross_file=False,
        )

        assert "a" == e.caller.qualified_name
        assert "b" == e.callee.qualified_name
        assert e.is_cross_file is False
        assert e.call_count is None
        assert e.total_time_ns is None
        assert e.callee_metadata is None

    def test_with_all_fields(self) -> None:
        """CallEdge accepts all optional fields."""
        meta = CalleeMetadata(
            fully_qualified_name="mod.b",
            only_function_name="b",
            definition_type="function",
            source_line="def b(): ...",
        )
        e = CallEdge(
            caller=node("a"),
            callee=node("b"),
            is_cross_file=True,
            call_count=5,
            total_time_ns=9000,
            callee_metadata=meta,
        )

        assert 5 == e.call_count
        assert 9000 == e.total_time_ns
        assert meta is e.callee_metadata

    def test_frozen(self) -> None:
        """CallEdge is immutable."""
        e = edge("a", "b")

        with pytest.raises(attrs.exceptions.FrozenInstanceError):
            e.is_cross_file = True  # type: ignore[misc]


class TestCallGraphEmpty:
    """CallGraph with no edges."""

    def test_empty_graph_has_no_nodes(self) -> None:
        """An empty graph contains zero nodes."""
        g = make_graph([])

        assert set() == g.nodes

    def test_empty_graph_has_empty_forward(self) -> None:
        """Forward index is empty for an empty graph."""
        g = make_graph([])

        assert {} == g.forward

    def test_empty_graph_has_empty_reverse(self) -> None:
        """Reverse index is empty for an empty graph."""
        g = make_graph([])

        assert {} == g.reverse

    def test_topological_order_empty(self) -> None:
        """Topological order of an empty graph is empty."""
        g = make_graph([])

        assert [] == g.topological_order()


class TestCallGraphIndexes:
    """CallGraph forward and reverse index construction."""

    def test_forward_index_maps_caller_to_edges(self) -> None:
        """Forward index maps a caller to its outgoing edges."""
        e1 = edge("a", "b")
        e2 = edge("a", "c")
        g = make_graph([e1, e2])

        assert 2 == len(g.forward[node("a")])

    def test_reverse_index_maps_callee_to_edges(self) -> None:
        """Reverse index maps a callee to its incoming edges."""
        e1 = edge("a", "c")
        e2 = edge("b", "c")
        g = make_graph([e1, e2])

        assert 2 == len(g.reverse[node("c")])

    def test_nodes_collects_all(self) -> None:
        """All callers and callees appear in the nodes property."""
        g = make_graph([edge("a", "b"), edge("c", "d")])

        assert {"a", "b", "c", "d"} == {n.qualified_name for n in g.nodes}


class TestCalleesOf:
    """CallGraph.callees_of returns direct outgoing edges."""

    def test_returns_direct_callees(self) -> None:
        """A node's callees are the targets of its outgoing edges."""
        g = make_graph([edge("a", "b"), edge("a", "c"), edge("b", "c")])
        callees = g.callees_of(node("a"))

        assert {"b", "c"} == {e.callee.qualified_name for e in callees}

    def test_returns_empty_for_leaf(self) -> None:
        """A leaf node has no outgoing edges."""
        g = make_graph([edge("a", "b")])

        assert [] == g.callees_of(node("b"))

    def test_returns_empty_for_unknown_node(self) -> None:
        """A node not in the graph returns an empty list."""
        g = make_graph([edge("a", "b")])

        assert [] == g.callees_of(node("z"))


class TestCallersOf:
    """CallGraph.callers_of returns direct incoming edges."""

    def test_returns_direct_callers(self) -> None:
        """A node's callers are the sources of its incoming edges."""
        g = make_graph([edge("a", "c"), edge("b", "c")])
        callers = g.callers_of(node("c"))

        assert {"a", "b"} == {e.caller.qualified_name for e in callers}

    def test_returns_empty_for_root(self) -> None:
        """A root node has no incoming edges."""
        g = make_graph([edge("a", "b")])

        assert [] == g.callers_of(node("a"))


class TestDescendants:
    """CallGraph.descendants transitive callee traversal."""

    def test_transitive_descendants(self) -> None:
        """Finds all nodes reachable from the start node."""
        g = make_graph([edge("a", "b"), edge("b", "c"), edge("c", "d")])

        assert {"b", "c", "d"} == {
            n.qualified_name for n in g.descendants(node("a"))
        }

    def test_max_depth_one(self) -> None:
        """max_depth=1 returns only direct callees."""
        g = make_graph([edge("a", "b"), edge("b", "c"), edge("c", "d")])

        assert {"b"} == {
            n.qualified_name for n in g.descendants(node("a"), max_depth=1)
        }

    def test_max_depth_two(self) -> None:
        """max_depth=2 reaches two levels deep."""
        g = make_graph([edge("a", "b"), edge("b", "c"), edge("c", "d")])

        assert {"b", "c"} == {
            n.qualified_name for n in g.descendants(node("a"), max_depth=2)
        }

    def test_handles_cycle(self) -> None:
        """Cycles do not cause infinite traversal."""
        g = make_graph([edge("a", "b"), edge("b", "a")])

        assert {"b", "a"} == {
            n.qualified_name for n in g.descendants(node("a"))
        }

    def test_empty_for_leaf(self) -> None:
        """A leaf node has no descendants."""
        g = make_graph([edge("a", "b")])

        assert set() == g.descendants(node("b"))


class TestAncestors:
    """CallGraph.ancestors transitive caller traversal."""

    def test_transitive_ancestors(self) -> None:
        """Finds all nodes that transitively call the target."""
        g = make_graph([edge("a", "b"), edge("b", "c"), edge("c", "d")])

        assert {"a", "b", "c"} == {
            n.qualified_name for n in g.ancestors(node("d"))
        }

    def test_max_depth_one(self) -> None:
        """max_depth=1 returns only direct callers."""
        g = make_graph([edge("a", "b"), edge("b", "c"), edge("c", "d")])

        assert {"c"} == {
            n.qualified_name for n in g.ancestors(node("d"), max_depth=1)
        }

    def test_empty_for_root(self) -> None:
        """A root node has no ancestors."""
        g = make_graph([edge("a", "b")])

        assert set() == g.ancestors(node("a"))


class TestSubgraph:
    """CallGraph.subgraph filtered subgraph."""

    def test_filters_to_selected_nodes(self) -> None:
        """Edges survive only when both endpoints are selected."""
        g = make_graph([edge("a", "b"), edge("b", "c"), edge("c", "d")])
        sub = g.subgraph({node("a"), node("b"), node("c")})

        assert 2 == len(sub.edges)
        callee_names = {e.callee.qualified_name for e in sub.edges}
        assert "d" not in callee_names

    def test_empty_subgraph(self) -> None:
        """Passing an empty node set produces an empty subgraph."""
        g = make_graph([edge("a", "b")])
        sub = g.subgraph(set())

        assert [] == sub.edges

    def test_subgraph_preserves_edge_data(self) -> None:
        """Edges in the subgraph retain their original attributes."""
        meta = CalleeMetadata(
            fully_qualified_name="mod.b",
            only_function_name="b",
            definition_type="function",
            source_line="def b(): ...",
        )
        e = CallEdge(
            caller=node("a"),
            callee=node("b"),
            is_cross_file=True,
            call_count=7,
            total_time_ns=3000,
            callee_metadata=meta,
        )
        g = make_graph([e])
        sub = g.subgraph({node("a"), node("b")})

        assert 1 == len(sub.edges)
        assert 7 == sub.edges[0].call_count
        assert meta is sub.edges[0].callee_metadata


class TestLeafFunctions:
    """CallGraph.leaf_functions nodes with no outgoing edges."""

    def test_identifies_leaves(self) -> None:
        """Nodes with no outgoing edges are leaves."""
        g = make_graph([edge("a", "b"), edge("a", "c"), edge("b", "d")])

        assert {"c", "d"} == {n.qualified_name for n in g.leaf_functions()}

    def test_single_edge(self) -> None:
        """In a single-edge graph the callee is the leaf."""
        g = make_graph([edge("a", "b")])

        assert {"b"} == {n.qualified_name for n in g.leaf_functions()}


class TestRootFunctions:
    """CallGraph.root_functions nodes with no incoming edges."""

    def test_identifies_roots(self) -> None:
        """Nodes with no incoming edges are roots."""
        g = make_graph([edge("a", "b"), edge("a", "c"), edge("b", "d")])

        assert {"a"} == {n.qualified_name for n in g.root_functions()}

    def test_single_edge(self) -> None:
        """In a single-edge graph the caller is the root."""
        g = make_graph([edge("a", "b")])

        assert {"a"} == {n.qualified_name for n in g.root_functions()}


class TestTopologicalOrder:
    """CallGraph.topological_order leaves-first ordering."""

    def test_linear_chain(self) -> None:
        """Leaves appear before their callers in the order."""
        g = make_graph([edge("a", "b"), edge("b", "c"), edge("c", "d")])
        order = g.topological_order()
        names = [n.qualified_name for n in order]

        assert names.index("d") < names.index("c")
        assert names.index("c") < names.index("b")
        assert names.index("b") < names.index("a")

    def test_diamond(self) -> None:
        """Diamond DAG: shared leaf appears before both parents."""
        g = make_graph(
            [
                edge("a", "b"),
                edge("a", "c"),
                edge("b", "d"),
                edge("c", "d"),
            ]
        )
        order = g.topological_order()
        names = [n.qualified_name for n in order]

        assert names.index("d") < names.index("b")
        assert names.index("d") < names.index("c")
        assert names.index("b") < names.index("a")
        assert names.index("c") < names.index("a")

    def test_handles_cycle_gracefully(self) -> None:
        """Cycles cause some nodes to be excluded, but no crash."""
        g = make_graph([edge("a", "b"), edge("b", "a")])
        order = g.topological_order()

        # With a pure cycle, the topological order may be shorter
        # than the total node count, but should not raise.
        assert len(order) <= len(g.nodes)


class TestIndexResult:
    """IndexResult construction and field access."""

    def test_construction_and_fields(self) -> None:
        """All fields are accessible after construction."""
        result = IndexResult(
            file_path=Path("src/mod.py"),
            cached=True,
            num_edges=3,
            edges=(("caller", "helper", False),),
            cross_file_edges=0,
            error=False,
        )

        assert Path("src/mod.py") == result.file_path
        assert result.cached is True
        assert 3 == result.num_edges
        assert 1 == len(result.edges)
        assert 0 == result.cross_file_edges
        assert result.error is False

    def test_string_file_path_converted(self) -> None:
        """Passing a string for file_path converts it to Path."""
        result = IndexResult(
            file_path="mod.py",  # type: ignore[arg-type]
            cached=False,
            num_edges=0,
            edges=(),
            cross_file_edges=0,
            error=False,
        )

        assert isinstance(result.file_path, Path)

    def test_frozen(self) -> None:
        """IndexResult is immutable."""
        result = IndexResult(
            file_path=Path("m.py"),
            cached=False,
            num_edges=0,
            edges=(),
            cross_file_edges=0,
            error=False,
        )

        with pytest.raises(attrs.exceptions.FrozenInstanceError):
            result.cached = True  # type: ignore[misc]


class TestAugmentWithTrace:
    """augment_with_trace enriches edges with profiling data."""

    def test_overlays_runtime_data(
        self,
        tmp_path: Path,
    ) -> None:
        """Matching edges get call_count and total_time_ns from the DB."""
        db_path = tmp_path / "trace.db"
        conn = sqlite3.connect(str(db_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
            )
            """
        )
        conn.execute(
            "INSERT INTO pstats VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
            ("mod.py", 1, "helper", None, 10, 1, 5000, 5000, b"[]"),
        )
        conn.commit()
        conn.close()

        g = make_graph([edge("caller", "helper")])
        augmented = augment_with_trace(g, db_path)

        assert 1 == len(augmented.edges)
        assert 10 == augmented.edges[0].call_count
        assert 5000 == augmented.edges[0].total_time_ns

    def test_returns_original_when_pstats_missing(
        self,
        tmp_path: Path,
    ) -> None:
        """Without a pstats table the original graph is returned."""
        db_path = tmp_path / "trace.db"
        conn = sqlite3.connect(str(db_path))
        conn.close()

        g = make_graph([edge("caller", "helper")])
        result = augment_with_trace(g, db_path)

        assert result.edges == g.edges

    def test_unmatched_edges_preserved(
        self,
        tmp_path: Path,
    ) -> None:
        """Edges without matching trace data keep None timing fields."""
        db_path = tmp_path / "trace.db"
        conn = sqlite3.connect(str(db_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
            )
            """
        )
        conn.commit()
        conn.close()

        g = make_graph([edge("caller", "helper")])
        augmented = augment_with_trace(g, db_path)

        assert 1 == len(augmented.edges)
        assert augmented.edges[0].call_count is None
        assert augmented.edges[0].total_time_ns is None

    def test_class_method_matching(
        self,
        tmp_path: Path,
    ) -> None:
        """Class methods are matched via 'ClassName.method' qualified name."""
        db_path = tmp_path / "trace.db"
        conn = sqlite3.connect(str(db_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
            )
            """
        )
        conn.execute(
            "INSERT INTO pstats VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
            ("mod.py", 5, "process", "MyClass", 3, 2, 9000, 12000, b"[]"),
        )
        conn.commit()
        conn.close()

        callee = FunctionNode(
            file_path=Path("mod.py"),
            qualified_name="MyClass.process",
        )
        caller = FunctionNode(
            file_path=Path("mod.py"),
            qualified_name="main",
        )
        g = CallGraph(
            edges=[
                CallEdge(
                    caller=caller,
                    callee=callee,
                    is_cross_file=False,
                )
            ]
        )

        augmented = augment_with_trace(g, db_path)

        assert 3 == augmented.edges[0].call_count
        assert 9000 == augmented.edges[0].total_time_ns


class TestCalleesFromGraph:
    """callees_from_graph extracts FunctionSource objects from edges."""

    def test_extracts_function_sources(self) -> None:
        """Edges with metadata produce FunctionSource objects."""
        meta = CalleeMetadata(
            fully_qualified_name="mod.helper",
            only_function_name="helper",
            definition_type="function",
            source_line="def helper(): ...",
        )
        e = CallEdge(
            caller=node("caller"),
            callee=node("helper"),
            is_cross_file=False,
            callee_metadata=meta,
        )
        g = CallGraph(edges=[e])

        file_map, source_list = callees_from_graph(g)

        assert 1 == len(source_list)
        fs = source_list[0]
        assert "helper" == fs.qualified_name
        assert "mod.helper" == fs.fully_qualified_name
        assert Path("mod.py") in file_map
        assert fs in file_map[Path("mod.py")]

    def test_skips_edges_without_metadata(self) -> None:
        """Edges with callee_metadata=None are skipped."""
        e1 = CallEdge(
            caller=node("a"),
            callee=node("b"),
            is_cross_file=False,
        )
        meta = CalleeMetadata(
            fully_qualified_name="mod.c",
            only_function_name="c",
            definition_type="function",
            source_line="def c(): ...",
        )
        e2 = CallEdge(
            caller=node("a"),
            callee=node("c"),
            is_cross_file=False,
            callee_metadata=meta,
        )
        g = CallGraph(edges=[e1, e2])

        _, source_list = callees_from_graph(g)

        assert 1 == len(source_list)
        assert "c" == source_list[0].qualified_name

    def test_empty_graph_returns_empty(self) -> None:
        """A graph with no edges produces empty results."""
        g = CallGraph(edges=[])

        file_map, source_list = callees_from_graph(g)

        assert {} == dict(file_map)
        assert [] == source_list
