"""Module boundary detection via weighted label propagation clustering.

Groups files into logical modules based on import graph structure.
Uses a simple label propagation algorithm with directory-based weighting.
"""

from __future__ import annotations

import logging
from typing import TYPE_CHECKING

from unfamiliar.analysis.models import DetectedModule
from unfamiliar.constants import InvestigationCategory

if TYPE_CHECKING:
    from unfamiliar.analysis.models import ImportGraph

logger = logging.getLogger(__name__)

# Maximum iterations for label propagation convergence
_MAX_ITERATIONS = 50

# Category hints based on path/keyword patterns
_CATEGORY_PATTERNS: dict[InvestigationCategory, list[str]] = {
    InvestigationCategory.TESTING: [
        "test_",
        "_test.py",
        "tests/",
        "__tests__/",
        "spec/",
    ],
    InvestigationCategory.ARCHITECTURE: [
        "main.py",
        "__init__.py",
        "app.py",
        "server.py",
    ],
    InvestigationCategory.DEPENDENCIES: [
        "requirements",
        "setup.py",
        "pyproject.toml",
    ],
    InvestigationCategory.TECHNICAL_DEBT: [
        "todo",
        "fixme",
        "hack",
        "deprecated",
    ],
    InvestigationCategory.DOMAIN_BUSINESS_LOGIC: [
        "service",
        "handler",
        "processor",
        "manager",
    ],
    InvestigationCategory.DATA_FLOW: ["pipeline", "stream", "flow", "etl"],
    InvestigationCategory.CONVENTIONS: ["config", "settings", "constants"],
}


def detect_modules(
    graph: ImportGraph,
    min_module_size: int = 3,
) -> list[DetectedModule]:
    """Detect module boundaries using weighted label propagation.

    Args:
        graph: The import graph to cluster.
        min_module_size: Minimum files per module. Smaller clusters are merged.

    Returns:
        List of DetectedModule, one per cluster.
    """
    if not graph.files:
        return []

    # Initialize: each file gets its own label
    labels: dict[str, str] = {path: path for path in graph.files}

    # Build undirected adjacency for label propagation
    neighbors: dict[str, set[str]] = {path: set() for path in graph.files}
    for source, targets in graph.edges.items():
        for target in targets:
            if source in neighbors and target in neighbors:
                neighbors[source].add(target)
                neighbors[target].add(source)

    # Label propagation loop
    for iteration in range(_MAX_ITERATIONS):
        changed = False
        paths = sorted(graph.files.keys())

        for path in paths:
            path_neighbors = neighbors[path]
            if not path_neighbors:
                continue

            # Count labels among neighbors with weights
            label_weights: dict[str, float] = {}
            for neighbor in path_neighbors:
                neighbor_label = labels[neighbor]
                weight = _compute_edge_weight(path, neighbor)
                label_weights[neighbor_label] = (
                    label_weights.get(neighbor_label, 0.0) + weight
                )

            # Find most frequent weighted label
            if label_weights:
                best_label = max(label_weights, key=lambda k: label_weights[k])
                if labels[path] != best_label:
                    labels[path] = best_label
                    changed = True

        if not changed:
            logger.debug(
                "Label propagation converged after %d iterations",
                iteration + 1,
            )
            break

    # Group files by final label
    clusters: dict[str, set[str]] = {}
    for path, label in labels.items():
        if label not in clusters:
            clusters[label] = set()
        clusters[label].add(path)

    # Merge small clusters into their most-connected neighbor
    merged = _merge_small_clusters(clusters, neighbors, min_module_size)

    # Build DetectedModule for each cluster
    modules: list[DetectedModule] = []
    for cluster_files in merged.values():
        if not cluster_files:
            continue

        cluster_list = sorted(cluster_files)
        primary_path = _compute_primary_path(cluster_list)
        confidence = _compute_confidence(cluster_files, graph)
        category_hints = _infer_category_hints(cluster_list)

        modules.append(
            DetectedModule(
                files=cluster_list,
                primary_path=primary_path,
                file_count=len(cluster_list),
                confidence=confidence,
                category_hints=category_hints,
            )
        )

    # Sort by file count descending for consistent ordering
    modules.sort(key=lambda m: m.file_count, reverse=True)
    return modules


def _compute_edge_weight(path1: str, path2: str) -> float:
    """Compute edge weight between two files.

    Import edges get base weight 1.0. Files in the same directory
    get a proximity bonus of 0.3.
    """
    weight = 1.0

    dir1 = path1.rsplit("/", 1)[0] if "/" in path1 else ""
    dir2 = path2.rsplit("/", 1)[0] if "/" in path2 else ""

    if dir1 == dir2:
        weight += 0.3

    return weight


def _merge_small_clusters(
    clusters: dict[str, set[str]],
    neighbors: dict[str, set[str]],
    min_size: int,
) -> dict[str, set[str]]:
    """Merge clusters smaller than min_size into their most-connected neighbor."""
    result: dict[str, set[str]] = {}
    small_clusters: list[tuple[str, set[str]]] = []

    for label, files in clusters.items():
        if len(files) < min_size:
            small_clusters.append((label, files))
        else:
            result[label] = set(files)

    # Build reverse index: file_path -> cluster_label
    file_to_cluster: dict[str, str] = {}
    for label, files in result.items():
        for f in files:
            file_to_cluster[f] = label

    for _label, files in small_clusters:
        # Count connections to each candidate cluster
        cluster_connections: dict[str, int] = {}

        for file_path in files:
            for neighbor in neighbors.get(file_path, set()):
                target_label = file_to_cluster.get(neighbor)
                if target_label is not None:
                    cluster_connections[target_label] = (
                        cluster_connections.get(target_label, 0) + 1
                    )

        # Find the cluster with the most connections (that's large enough)
        best_target: str | None = None
        best_connections = 0
        for target_label, conn_count in cluster_connections.items():
            if len(result[target_label]) >= min_size and conn_count > best_connections:
                best_connections = conn_count
                best_target = target_label

        if best_target:
            # Update reverse index for merged files
            for f in files:
                file_to_cluster[f] = best_target
            result[best_target].update(files)
        else:
            # No suitable merge target - keep as small cluster
            new_label = min(files)
            result[new_label] = set(files)
            for f in files:
                file_to_cluster[f] = new_label

    return result


def _compute_primary_path(files: list[str]) -> str:
    """Compute the primary path for a module cluster.

    Uses the most common directory prefix, or the first file if
    files are in different top-level directories.
    """
    if not files:
        return ""

    if len(files) == 1:
        return files[0]

    # Find common directory prefix
    dirs = [f.rsplit("/", 1)[0] if "/" in f else "" for f in files]

    # Find the longest common prefix
    common = dirs[0]
    for d in dirs[1:]:
        while common and not d.startswith(common + "/") and d != common:
            common = common.rsplit("/", 1)[0] if "/" in common else ""

    return common if common else files[0]


def _compute_confidence(cluster_files: set[str], graph: ImportGraph) -> float:
    """Compute cluster confidence based on internal vs external edges.

    Returns ratio of internal edges to total edges.
    1.0 = fully self-contained cluster.
    0.0 = no internal connections.
    """
    if len(cluster_files) <= 1:
        return 1.0

    internal = 0
    external = 0

    for source in cluster_files:
        for target in graph.edges.get(source, set()):
            if target in cluster_files:
                internal += 1
            else:
                external += 1

    total = internal + external
    if total == 0:
        return 0.0

    return internal / total


def _infer_category_hints(files: list[str]) -> list[InvestigationCategory]:
    """Infer relevant investigation categories from file paths."""
    hints: set[InvestigationCategory] = set()

    for file_path in files:
        file_lower = file_path.lower()
        for category, patterns in _CATEGORY_PATTERNS.items():
            if any(pattern in file_lower for pattern in patterns):
                hints.add(category)

    # Security relevance check
    security_keywords = [
        "auth",
        "security",
        "password",
        "token",
        "secret",
        "credential",
    ]
    for file_path in files:
        file_lower = file_path.lower()
        if any(kw in file_lower for kw in security_keywords):
            hints.add(InvestigationCategory.DOMAIN_BUSINESS_LOGIC)
            break

    return sorted(hints, key=lambda c: c.value)
