"""Tests for import graph construction and module clustering.

Tests build_import_graph, get_hub_files, get_connected_components,
and detect_modules from analysis/graph/.
Pure functions with zero IO. Exact count assertions.
"""

from __future__ import annotations

import random

from tests.conftest import make_file_info
from unfamiliar.analysis.adapters.protocol import AdapterRegistry
from unfamiliar.analysis.adapters.python_adapter import PythonAdapter
from unfamiliar.analysis.graph.clustering import (
    _compute_confidence,
    _compute_edge_weight,
    detect_modules,
)
from unfamiliar.analysis.graph.import_graph import (
    ImportGraph,
    build_import_graph,
    get_connected_components,
    get_hub_files,
)
from unfamiliar.analysis.models import ParsedFile, ParsedImport

# --- Test Helpers ---


def make_import(source: str, target: str) -> ParsedImport:
    """Create a ParsedImport for testing."""
    return ParsedImport(
        source_path=source,
        target=target,
        is_relative=target.startswith("."),
        names=["foo"],
    )


def make_parsed_with_imports(path: str, imports: list[ParsedImport]) -> ParsedFile:
    """Create a ParsedFile with specified imports."""
    return ParsedFile(
        path=path,
        language="python",
        imports=imports,
        definitions=[],
        entry_points=[],
        complexity=0,
        line_count=10,
    )


class TestBuildImportGraph:
    """Tests for build_import_graph function."""

    def test_empty_parsed_files_returns_empty_graph(self):
        """Empty parsed_files dict returns empty ImportGraph."""
        registry = AdapterRegistry()
        registry.register(PythonAdapter())

        result = build_import_graph({}, {}, registry, set())

        assert result.edges == {}
        assert result.in_degree == {}
        assert result.files == {}
        assert result.files == {}

    def test_file_with_no_imports_has_zero_in_degree(self):
        """File with no imports has in_degree=0 and no edges."""
        registry = AdapterRegistry()
        registry.register(PythonAdapter())

        parsed = {"a.py": make_parsed_with_imports("a.py", [])}
        files = {"a.py": make_file_info("a.py")}

        result = build_import_graph(parsed, files, registry, {"a.py"})

        assert result.edges == {}
        assert result.in_degree == {"a.py": 0}
        assert result.files == files

    def test_a_imports_b_creates_edge_and_updates_in_degree(self):
        """A imports B creates edge A->{B} and B.in_degree=1."""
        registry = AdapterRegistry()
        registry.register(PythonAdapter())

        parsed = {
            "a.py": make_parsed_with_imports("a.py", [make_import("a.py", "b")]),
            "b.py": make_parsed_with_imports("b.py", []),
        }
        files = {
            "a.py": make_file_info("a.py"),
            "b.py": make_file_info("b.py"),
        }
        repo_files = {"a.py", "b.py"}

        # Need to set up the adapter to resolve 'b' -> 'b.py'
        result = build_import_graph(parsed, files, registry, repo_files)

        # Edge should be a.py -> b.py
        assert "a.py" in result.edges
        assert "b.py" in result.edges["a.py"]
        assert result.in_degree.get("b.py", 0) == 1
        assert result.files == files

    def test_self_import_not_added_as_edge(self):
        """Self-import (A imports A) is not added to edges."""
        registry = AdapterRegistry()
        registry.register(PythonAdapter())

        parsed = {
            "a.py": make_parsed_with_imports("a.py", [make_import("a.py", "a")]),
        }
        files = {"a.py": make_file_info("a.py")}
        repo_files = {"a.py"}

        result = build_import_graph(parsed, files, registry, repo_files)

        # Self-import should not create edge
        assert "a.py" not in result.edges.get("a.py", set())

    def test_external_import_not_added_to_edges(self):
        """External import (not in repo_files) is not added to edges."""
        registry = AdapterRegistry()
        registry.register(PythonAdapter())

        parsed = {
            "a.py": make_parsed_with_imports(
                "a.py", [make_import("a.py", "external_lib")]
            ),
        }
        files = {"a.py": make_file_info("a.py")}
        repo_files = {"a.py"}

        result = build_import_graph(parsed, files, registry, repo_files)

        # External lib should not appear in edges
        assert result.edges == {}

    def test_two_files_importing_same_target_accumulate_in_degree(self):
        """When A and B both import C, C.in_degree should be 2, not 1."""
        registry = AdapterRegistry()
        registry.register(PythonAdapter())

        parsed = {
            "a.py": make_parsed_with_imports("a.py", [make_import("a.py", "c")]),
            "b.py": make_parsed_with_imports("b.py", [make_import("b.py", "c")]),
            "c.py": make_parsed_with_imports("c.py", []),
        }

        files = {
            "a.py": make_file_info("a.py"),
            "b.py": make_file_info("b.py"),
            "c.py": make_file_info("c.py"),
        }

        repo_files = {"a.py", "b.py", "c.py"}

        result = build_import_graph(parsed, files, registry, repo_files)

        assert result.in_degree["c.py"] == 2
        assert result.files == files


class TestGetHubFiles:
    """Tests for get_hub_files function."""

    def test_empty_graph_returns_empty_list(self):
        """Empty graph returns empty list."""
        graph = ImportGraph()
        result = get_hub_files(graph, threshold=3)
        assert result == []

    def test_no_files_meet_threshold_returns_empty(self):
        """All files below threshold returns empty list."""
        graph = ImportGraph()
        graph.in_degree = {"a.py": 1, "b.py": 2}
        graph.files = {"a.py": make_file_info("a.py"), "b.py": make_file_info("b.py")}

        result = get_hub_files(graph, threshold=3)
        assert result == []

    def test_files_above_threshold_returned_sorted(self):
        """Files with in_degree >= threshold returned sorted descending."""
        graph = ImportGraph()
        graph.in_degree = {"a.py": 5, "b.py": 3, "c.py": 10}
        graph.files = {
            "a.py": make_file_info("a.py"),
            "b.py": make_file_info("b.py"),
            "c.py": make_file_info("c.py"),
        }

        result = get_hub_files(graph, threshold=3)

        assert len(result) == 3
        # Sorted by in_degree descending
        assert result[0] == "c.py"
        assert result[1] == "a.py"
        assert result[2] == "b.py"

    def test_exact_threshold_included(self):
        """File with in_degree exactly equal to threshold is included."""
        graph = ImportGraph()
        graph.in_degree = {"a.py": 3}
        graph.files = {"a.py": make_file_info("a.py")}

        result = get_hub_files(graph, threshold=3)

        assert len(result) == 1
        assert result[0] == "a.py"

    def test_default_threshold_is_3(self):
        """get_hub_files uses threshold 3 by default.
        A file with in_degree=3 is a hub if no threshold is passed."""
        graph = ImportGraph()
        graph.in_degree = {"hub.py": 3, "not_hub.py": 2}
        graph.files = {
            "hub.py": make_file_info("hub.py"),
            "not_hub.py": make_file_info("not_hub.py"),
        }

        result = get_hub_files(graph)

        assert result == ["hub.py"]


class TestGetConnectedComponents:
    """Tests for get_connected_components function."""

    def test_single_isolated_file_one_component(self):
        """Single isolated file returns one component of size 1."""
        graph = ImportGraph()
        graph.files = {"a.py": make_file_info("a.py")}
        graph.edges = {}

        result = get_connected_components(graph)

        assert len(result) == 1
        assert result[0] == {"a.py"}

    def test_three_files_no_edges_three_components(self):
        """Three disconnected files return three components."""
        graph = ImportGraph()
        graph.files = {
            "a.py": make_file_info("a.py"),
            "b.py": make_file_info("b.py"),
            "c.py": make_file_info("c.py"),
        }
        graph.edges = {}

        result = get_connected_components(graph)

        assert len(result) == 3
        # Each component has exactly 1 file
        for comp in result:
            assert len(comp) == 1

    def test_chain_a_to_b_to_c_one_component(self):
        """Chain A->B->C results in one component with all 3 files."""
        graph = ImportGraph()
        graph.files = {
            "a.py": make_file_info("a.py"),
            "b.py": make_file_info("b.py"),
            "c.py": make_file_info("c.py"),
        }
        graph.edges = {
            "a.py": {"b.py"},
            "b.py": {"c.py"},
        }

        result = get_connected_components(graph)

        assert len(result) == 1
        assert result[0] == {"a.py", "b.py", "c.py"}

    def test_two_disconnected_groups_two_components(self):
        """Two disconnected groups result in two components."""
        graph = ImportGraph()
        graph.files = {
            "a1.py": make_file_info("a1.py"),
            "a2.py": make_file_info("a2.py"),
            "b1.py": make_file_info("b1.py"),
            "b2.py": make_file_info("b2.py"),
        }
        graph.edges = {
            "a1.py": {"a2.py"},
            "b1.py": {"b2.py"},
        }

        result = get_connected_components(graph)

        assert len(result) == 2
        paths = {frozenset(comp) for comp in result}
        assert frozenset({"a1.py", "a2.py"}) in paths
        assert frozenset({"b1.py", "b2.py"}) in paths


class TestDetectModules:
    """Tests for detect_modules function."""

    def test_empty_graph_returns_empty_list(self):
        """Empty graph returns empty list."""
        graph = ImportGraph()
        # Seed for determinism
        random.seed(42)
        result = detect_modules(graph, min_module_size=3)
        assert result == []

    def test_primary_path_non_empty(self):
        """All modules have non-empty primary_path."""
        graph = ImportGraph()
        graph.files = {
            f"src/file{i}.py": make_file_info(f"src/file{i}.py") for i in range(5)
        }
        graph.edges = {
            f"src/file{i}.py": {f"src/file{(i + 1) % 5}.py"} for i in range(5)
        }

        random.seed(42)
        result = detect_modules(graph, min_module_size=3)

        for module in result:
            assert module.primary_path != ""

    def test_confidence_between_0_and_1(self):
        """All modules have confidence between 0.0 and 1.0."""
        graph = ImportGraph()
        graph.files = {f"file{i}.py": make_file_info(f"file{i}.py") for i in range(5)}
        graph.edges = {f"file{i}.py": {f"file{(i + 1) % 5}.py"} for i in range(5)}

        random.seed(42)
        result = detect_modules(graph, min_module_size=3)

        for module in result:
            assert 0.0 <= module.confidence <= 1.0


class TestComputeEdgeWeight:
    """Tests for _compute_edge_weight private helper."""

    def test_same_directory_weight_higher(self):
        """Files in same directory get weight > 1.0."""
        weight = _compute_edge_weight("src/a.py", "src/b.py")
        assert weight == 1.3

    def test_different_directory_weight_base(self):
        """Files in different directories get weight 1.0."""
        weight = _compute_edge_weight("src/a.py", "lib/b.py")
        assert weight == 1.0


class TestComputeConfidence:
    """Tests for _compute_confidence private helper."""

    def test_fully_internal_cluster_confidence_1(self):
        """Cluster with all internal edges has confidence 1.0."""
        graph = ImportGraph()
        graph.edges = {
            "a.py": {"b.py", "c.py"},
            "b.py": {"a.py"},
            "c.py": {"a.py"},
        }
        graph.files = {
            "a.py": make_file_info("a.py"),
            "b.py": make_file_info("b.py"),
            "c.py": make_file_info("c.py"),
        }

        cluster = {"a.py", "b.py", "c.py"}
        confidence = _compute_confidence(cluster, graph)

        assert confidence == 1.0

    def test_no_edges_confidence_0(self):
        """Cluster with no edges has confidence 0.0."""
        graph = ImportGraph()
        graph.edges = {}
        graph.files = {
            "a.py": make_file_info("a.py"),
            "b.py": make_file_info("b.py"),
        }

        cluster = {"a.py", "b.py"}
        confidence = _compute_confidence(cluster, graph)

        assert confidence == 0.0
