"""Module coupling detector via import analysis.

Analyzes Python import statements to build a dependency graph and detect
high coupling between modules. High coupling = high technical debt risk.
"""

from __future__ import annotations

import ast
from collections import defaultdict
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any


@dataclass
class ImportEdge:
    """A single import relationship."""

    source_file: str
    target_module: str
    names: list[str] = field(default_factory=list)
    line_number: int = 0


@dataclass
class ModuleCoupling:
    """Coupling metrics for a single module."""

    module: str
    afferent_coupling: int = 0   # Number of modules that depend on this one (Ca)
    efferent_coupling: int = 0   # Number of modules this one depends on (Ce)

    @property
    def instability(self) -> float:
        """Instability metric: Ce / (Ca + Ce).

        0.0 = maximally stable (many dependents, few dependencies)
        1.0 = maximally unstable (no dependents, many dependencies)
        """
        total = self.afferent_coupling + self.efferent_coupling
        if total == 0:
            return 0.0
        return self.efferent_coupling / total

    @property
    def total_coupling(self) -> int:
        return self.afferent_coupling + self.efferent_coupling

    def to_dict(self) -> dict[str, Any]:
        return {
            "module": self.module,
            "afferent_coupling": self.afferent_coupling,
            "efferent_coupling": self.efferent_coupling,
            "instability": round(self.instability, 3),
            "total_coupling": self.total_coupling,
        }


class _ImportVisitor(ast.NodeVisitor):
    """Extract all imports from a Python file."""

    def __init__(self) -> None:
        self.imports: list[tuple[str, list[str], int]] = []

    def visit_Import(self, node: ast.Import) -> None:
        for alias in node.names:
            self.imports.append((alias.name, [alias.name], node.lineno))

    def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
        module = node.module or ""
        names = [alias.name for alias in node.names]
        self.imports.append((module, names, node.lineno))


class CouplingAnalyzer:
    """Analyze module coupling through import graph analysis."""

    SUPPORTED_EXTENSIONS = {".py"}

    def __init__(self) -> None:
        self._edges: list[ImportEdge] = []
        self._file_to_module: dict[str, str] = {}

    def analyze_directory(
        self,
        directory: str | Path,
        exclude_patterns: list[str] | None = None,
    ) -> dict[str, ModuleCoupling]:
        """Analyze all Python files and compute coupling metrics.

        Returns:
            Dict mapping module name to its coupling metrics.
        """
        directory = Path(directory).resolve()
        exclude_patterns = exclude_patterns or [
            "venv", "node_modules", ".git", "__pycache__", ".tox",
            "build", "dist",
        ]

        self._edges = []
        self._file_to_module = {}

        # Collect all Python files and map to module names
        py_files: list[Path] = []
        for py_file in directory.rglob("*.py"):
            parts = py_file.relative_to(directory).parts
            if any(excl in parts for excl in exclude_patterns):
                continue
            py_files.append(py_file)
            module_name = self._path_to_module(py_file, directory)
            self._file_to_module[str(py_file)] = module_name

        # Known internal modules (top-level packages)
        internal_packages: set[str] = set()
        for mod in self._file_to_module.values():
            top = mod.split(".")[0]
            if top:
                internal_packages.add(top)

        # Parse imports from each file
        for py_file in py_files:
            source_module = self._file_to_module[str(py_file)]
            try:
                source = py_file.read_text(encoding="utf-8", errors="replace")
                tree = ast.parse(source, filename=str(py_file))
            except (OSError, SyntaxError):
                continue

            visitor = _ImportVisitor()
            visitor.visit(tree)

            for target_module, names, lineno in visitor.imports:
                # Only track internal imports (within the project)
                top_level = target_module.split(".")[0] if target_module else ""
                if top_level in internal_packages:
                    self._edges.append(
                        ImportEdge(
                            source_file=str(py_file),
                            target_module=target_module,
                            names=names,
                            line_number=lineno,
                        )
                    )

        # Compute coupling metrics
        return self._compute_coupling(internal_packages)

    def _path_to_module(self, file_path: Path, root: Path) -> str:
        """Convert a file path to a Python module name."""
        relative = file_path.relative_to(root)
        parts = list(relative.parts)
        if parts[-1] == "__init__.py":
            parts = parts[:-1]
        elif parts[-1].endswith(".py"):
            parts[-1] = parts[-1][:-3]
        return ".".join(parts)

    def _compute_coupling(
        self, internal_packages: set[str]
    ) -> dict[str, ModuleCoupling]:
        """Compute afferent and efferent coupling for each module."""
        # Track dependencies at the top-level module (package) level
        # source_module -> set of target top-level modules
        efferent_deps: dict[str, set[str]] = defaultdict(set)
        afferent_deps: dict[str, set[str]] = defaultdict(set)

        all_modules: set[str] = set(self._file_to_module.values())

        for edge in self._edges:
            source_mod = self._file_to_module.get(edge.source_file, "")
            target_mod = edge.target_module

            # Normalize: use the closest matching internal module
            source_top = source_mod.split(".")[0] if source_mod else ""
            target_top = target_mod.split(".")[0] if target_mod else ""

            # Skip self-coupling (same package)
            if source_top == target_top:
                continue

            if source_mod:
                efferent_deps[source_mod].add(target_top)
            if target_mod:
                afferent_deps[target_top].add(source_mod)

        results: dict[str, ModuleCoupling] = {}
        for mod in all_modules:
            top = mod.split(".")[0]
            ce = len(efferent_deps.get(mod, set()))
            ca = len(afferent_deps.get(top, set())) if mod == top else 0
            results[mod] = ModuleCoupling(
                module=mod,
                efferent_coupling=ce,
                afferent_coupling=ca,
            )

        return results

    def get_dependency_graph(self) -> dict[str, list[str]]:
        """Return the dependency graph as adjacency list."""
        graph: dict[str, list[str]] = defaultdict(list)
        for edge in self._edges:
            source = self._file_to_module.get(edge.source_file, edge.source_file)
            if edge.target_module not in graph[source]:
                graph[source].append(edge.target_module)
        return dict(graph)

    def get_most_coupled(
        self, coupling: dict[str, ModuleCoupling], limit: int = 20
    ) -> list[ModuleCoupling]:
        """Get modules with the highest total coupling."""
        modules = sorted(
            coupling.values(),
            key=lambda m: m.total_coupling,
            reverse=True,
        )
        return modules[:limit]

    def find_circular_dependencies(self) -> list[list[str]]:
        """Detect circular dependency chains.

        Uses DFS to find cycles in the import graph.
        """
        graph = self.get_dependency_graph()
        visited: set[str] = set()
        rec_stack: set[str] = set()
        cycles: list[list[str]] = []

        def _dfs(node: str, path: list[str]) -> None:
            visited.add(node)
            rec_stack.add(node)
            path.append(node)

            for neighbor in graph.get(node, []):
                # Normalize neighbor to match graph keys
                if neighbor in rec_stack:
                    # Found cycle — extract it
                    cycle_start = path.index(neighbor) if neighbor in path else -1
                    if cycle_start >= 0:
                        cycle = path[cycle_start:] + [neighbor]
                        cycles.append(cycle)
                elif neighbor not in visited and neighbor in graph:
                    _dfs(neighbor, path)

            path.pop()
            rec_stack.discard(node)

        for node in graph:
            if node not in visited:
                _dfs(node, [])

        return cycles

    def summary(self, coupling: dict[str, ModuleCoupling]) -> dict[str, Any]:
        """Generate summary of coupling analysis."""
        if not coupling:
            return {
                "total_modules": 0,
                "avg_coupling": 0,
                "max_coupling": 0,
                "highly_coupled_count": 0,
            }

        values = list(coupling.values())
        total_couplings = [m.total_coupling for m in values]
        highly_coupled = sum(1 for m in values if m.total_coupling > 10)

        return {
            "total_modules": len(values),
            "avg_coupling": round(sum(total_couplings) / len(total_couplings), 2),
            "max_coupling": max(total_couplings),
            "highly_coupled_count": highly_coupled,
            "circular_dependencies": len(self.find_circular_dependencies()),
        }
