"""Orchestration service for large-project hierarchical decomposition.

:class:`DecompositionService` is the main entry point that combines
clustering heuristics, dependency closure, and DAG ordering into a
single cohesive API.  Decomposition decisions are optionally recorded
in :class:`DecisionService` for auditability.

Design decisions:

- **DI**: ``DecisionService`` and ``Settings`` are injected via the
  constructor (consistent with existing service patterns).
- **Stateless**: All decomposition state is derived from inputs — the
  service holds no mutable plan state.
- **Fail-safe**: When heuristics produce zero clusters a single
  fallback subplan is emitted with a warning.

Based on Forgejo issue #205.
"""

from __future__ import annotations

import logging
from collections import defaultdict
from typing import TYPE_CHECKING

from cleveragents.application.services.decomposition_clustering import (
    ClusteringStrategy,
    estimate_tokens_for_path,
)
from cleveragents.application.services.decomposition_graph import (
    DependencyClosureComputer,
)
from cleveragents.application.services.decomposition_models import (
    ClusterStrategy,
    DecompositionConfig,
    DecompositionNode,
    DecompositionResult,
    DependencyGraph,
)
from cleveragents.domain.models.core.decision import DecisionType

if TYPE_CHECKING:
    from cleveragents.application.services.decision_service import DecisionService
    from cleveragents.config.settings import Settings

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

_COUNTER: int = 0


def _next_node_id(prefix: str = "dn") -> str:
    """Generate a deterministic, monotonically increasing node ID."""
    global _COUNTER
    _COUNTER += 1
    return f"{prefix}-{_COUNTER:06d}"


def _reset_counter() -> None:
    """Reset the node ID counter (for testing)."""
    global _COUNTER
    _COUNTER = 0


def _dominant_extension(files: list[str]) -> str:
    """Return the most frequent file extension in *files*."""
    counts: dict[str, int] = defaultdict(int)
    for f in files:
        ext = ""
        dot_idx = f.rfind(".")
        if dot_idx != -1:
            ext = f[dot_idx:].lower()
        counts[ext] += 1
    if not counts:
        return ""
    return max(counts, key=counts.get)  # type: ignore[arg-type]


def _common_prefix(files: list[str]) -> str:
    """Return the longest common directory prefix of *files*."""
    if not files:
        return ""
    parts_list = [f.replace("\\", "/").split("/") for f in sorted(files)]
    prefix: list[str] = []
    for segments in zip(*parts_list, strict=False):
        if len(set(segments)) == 1:
            prefix.append(segments[0])
        else:
            break
    return "/".join(prefix)


# ---------------------------------------------------------------------------
# Service
# ---------------------------------------------------------------------------


class DecompositionService:
    """Orchestrates hierarchical decomposition of large file sets.

    Args:
        decision_service: Optional service for recording decomposition
            decisions.  When ``None`` decision recording is skipped.
        settings: Application settings providing default config values.
    """

    def __init__(
        self,
        decision_service: DecisionService | None = None,
        settings: Settings | None = None,
    ) -> None:
        self._decision_service = decision_service
        self._settings = settings
        self._closure_computer = DependencyClosureComputer()

    @property
    def decision_service(self) -> DecisionService | None:
        """The injected decision service (may be ``None``)."""
        return self._decision_service

    # ------------------------------------------------------------------
    # Main entry point
    # ------------------------------------------------------------------

    def decompose(
        self,
        files: list[str],
        config: DecompositionConfig | None = None,
    ) -> DecompositionResult:
        """Decompose *files* into a bounded hierarchy of subplans.

        When the number of files is at or below
        ``config.min_files_per_subplan`` the hierarchy is skipped and a
        single root node is returned.

        Args:
            files: Flat list of file paths to decompose.
            config: Decomposition parameters.  Uses defaults when
                ``None``.

        Returns:
            A :class:`DecompositionResult` with the full tree.
        """
        _reset_counter()
        cfg = config or DecompositionConfig()
        unique_files = sorted(set(files))

        # Guard: skip decomposition when below threshold
        if len(unique_files) <= cfg.min_files_per_subplan:
            root = DecompositionNode(
                node_id=_next_node_id(),
                parent_id=None,
                depth=0,
                file_paths=unique_files,
                language=_dominant_extension(unique_files),
                directory_prefix=_common_prefix(unique_files),
                estimated_tokens=sum(estimate_tokens_for_path(f) for f in unique_files),
                strategy=ClusterStrategy.FALLBACK,
            )
            return DecompositionResult(
                nodes=[root],
                max_depth_reached=0,
                total_files=len(unique_files),
                metrics={"skipped": 1},
            )

        nodes: list[DecompositionNode] = []
        max_depth = self._build_hierarchy(
            files=unique_files,
            config=cfg,
            depth=0,
            parent_id=None,
            nodes=nodes,
        )

        return DecompositionResult(
            nodes=nodes,
            max_depth_reached=max_depth,
            total_files=len(unique_files),
            metrics={
                "total_nodes": len(nodes),
                "leaf_nodes": sum(1 for n in nodes if n.is_leaf),
                "max_depth": max_depth,
            },
        )

    # ------------------------------------------------------------------
    # Recursive hierarchy builder
    # ------------------------------------------------------------------

    def _build_hierarchy(
        self,
        files: list[str],
        config: DecompositionConfig,
        depth: int,
        parent_id: str | None,
        nodes: list[DecompositionNode],
    ) -> int:
        """Recursively partition *files* into a tree.

        Returns the maximum depth reached during recursion.
        """
        estimated = sum(estimate_tokens_for_path(f) for f in files)

        # Leaf conditions
        # M6 requires deep hierarchical decomposition (4+ levels) for
        # large plans. To preserve this behavior, we only stop early when
        # we hit max_depth or the workset is trivially small; otherwise we
        # keep partitioning even when a cluster is already under the max
        # file/token bounds.
        is_leaf = (
            depth >= config.max_depth or len(files) <= config.min_files_per_subplan
        )

        node_id = _next_node_id()
        if is_leaf or len(files) <= config.min_files_per_subplan:
            node = DecompositionNode(
                node_id=node_id,
                parent_id=parent_id,
                depth=depth,
                file_paths=files,
                language=_dominant_extension(files),
                directory_prefix=_common_prefix(files),
                estimated_tokens=estimated,
                strategy=ClusterStrategy.DIRECTORY,
            )
            nodes.append(node)
            return depth

        # Cluster using directory strategy first, fall back to language
        clusters = ClusteringStrategy.cluster_by_directory(
            files, config.max_files_per_subplan
        )
        strategy = ClusterStrategy.DIRECTORY

        if len(clusters) <= 1:
            clusters = ClusteringStrategy.cluster_by_language(
                files, config.max_files_per_subplan
            )
            strategy = ClusterStrategy.LANGUAGE

        if len(clusters) <= 1:
            clusters = ClusteringStrategy.cluster_by_size(
                files, config.max_tokens_per_subplan
            )
            strategy = ClusterStrategy.SIZE

        # Fallback: no clusters or a single cluster that covers all files
        # (non-progress guard - recursing with the same set would loop).
        if not clusters or (len(clusters) == 1 and len(clusters[0]) == len(files)):
            logger.warning(
                "decomposition_fallback",
                extra={"file_count": len(files), "depth": depth},
            )
            node = DecompositionNode(
                node_id=node_id,
                parent_id=parent_id,
                depth=depth,
                file_paths=files,
                language=_dominant_extension(files),
                directory_prefix=_common_prefix(files),
                estimated_tokens=estimated,
                strategy=ClusterStrategy.FALLBACK,
            )
            nodes.append(node)
            return depth

        clusters = ClusteringStrategy.deterministic_sort(
            clusters, config.language_priority
        )

        children_ids: list[str] = []
        max_child_depth = depth

        for cluster in clusters:
            child_depth = self._build_hierarchy(
                files=cluster,
                config=config,
                depth=depth + 1,
                parent_id=node_id,
                nodes=nodes,
            )
            # The last added node is the child root
            children_ids.append(nodes[-1].node_id)
            max_child_depth = max(max_child_depth, child_depth)

        internal_node = DecompositionNode(
            node_id=node_id,
            parent_id=parent_id,
            depth=depth,
            file_paths=files,
            language=_dominant_extension(files),
            directory_prefix=_common_prefix(files),
            estimated_tokens=estimated,
            strategy=strategy,
            children_ids=children_ids,
        )
        nodes.append(internal_node)
        return max_child_depth

    # ------------------------------------------------------------------
    # Decision recording
    # ------------------------------------------------------------------

    def record_decisions(
        self,
        plan_id: str,
        result: DecompositionResult,
    ) -> None:
        """Persist decomposition decisions in :class:`DecisionService`.

        Records two types of decisions:

        1. ``strategy_choice`` — the overall decomposition strategy.
        2. ``subplan_spawn`` — one per leaf node.

        Args:
            plan_id: ULID of the parent plan.
            result: The decomposition result to record.
        """
        if self._decision_service is None:
            return

        # Strategy decision
        self._decision_service.record_decision(
            plan_id=plan_id,
            decision_type=DecisionType.STRATEGY_CHOICE,
            question="How should the project be decomposed?",
            chosen_option="hierarchical_decomposition",
            rationale=(
                f"Decomposed {result.total_files} files into "
                f"{len(result.nodes)} nodes across "
                f"{result.max_depth_reached} levels"
            ),
        )

        # Subplan spawn per leaf
        for node in result.nodes:
            if node.is_leaf:
                self._decision_service.record_decision(
                    plan_id=plan_id,
                    decision_type=DecisionType.SUBPLAN_SPAWN,
                    question=f"Spawn subplan for {node.directory_prefix}?",
                    chosen_option=f"spawn:{node.node_id}",
                    rationale=(
                        f"{len(node.file_paths)} files, "
                        f"~{node.estimated_tokens} tokens, "
                        f"lang={node.language}"
                    ),
                )

    # ------------------------------------------------------------------
    # Dependency ordering
    # ------------------------------------------------------------------

    def compute_dependency_order(
        self,
        graph: DependencyGraph,
    ) -> list[str]:
        """Return a DAG execution ordering for *graph*.

        Args:
            graph: The dependency graph.

        Returns:
            Topologically sorted list of file paths.
        """
        return self._closure_computer.topological_sort(graph)

    def compute_closure(
        self,
        graph: DependencyGraph,
        root_files: list[str],
        cutoff: int = 0,
    ) -> frozenset[str]:
        """Compute bounded transitive closure.

        Delegates to :class:`DependencyClosureComputer`.

        Args:
            graph: The dependency graph.
            root_files: Starting file paths.
            cutoff: Maximum closure size (``0`` = unlimited).

        Returns:
            Frozenset of reachable files.
        """
        return self._closure_computer.compute_closure(graph, root_files, cutoff)


__all__: list[str] = [
    "DecompositionService",
]
