"""ACMS Fusion Engine — facade over Phase 2 pipeline components.

Provides ``FusionEngine``, a high-level facade that wraps the four
Phase 2 pipeline components (deduplication, depth resolution, scoring,
and budget packing) behind a single ``fuse`` call.

Key responsibilities:

- **Fragment dedup**: Delegates to ``ContentHashDeduplicator`` to remove
  duplicate fragments by UKO URI + content hash.
- **Detail conflict resolution**: Delegates to ``MaxDepthResolver`` to
  retain the highest-depth rendering when the same UKO node appears at
  multiple depths.
- **Composite scoring**: Delegates to ``WeightedCompositeScorer`` to
  compute weighted relevance scores across multiple factors.
- **Greedy knapsack packing**: Delegates to ``GreedyKnapsackPacker``
  with deterministic tie-breakers (relevance, detail level, token count).
- **Budget overage guard**: Post-packing validation that drops
  lowest-relevance fragments when the total exceeds the budget,
  emitting structured warnings.

Based on ``docs/specification.md`` §42930-42937 and issue #192.

ISSUES CLOSED: #192
"""

from __future__ import annotations

import logging
from collections.abc import Sequence
from dataclasses import dataclass, field

from cleveragents.application.services.acms_phase2 import (
    ContentHashDeduplicator,
    GreedyKnapsackPacker,
    MaxDepthResolver,
    ScorerWeights,
    WeightedCompositeScorer,
)
from cleveragents.domain.models.core.context_fragment import (
    ContextBudget,
    ContextFragment,
)

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class FusionConfig:
    """Configuration for :class:`FusionEngine`.

    Attributes:
        scorer_weights: Weights for the composite scorer.  When
            ``None``, :class:`ScorerWeights` defaults are used.
        depth_fallback_steps: Depth fallback sequence for the
            greedy knapsack packer.
        min_fragment_tokens: Minimum token threshold for packing.
        overage_guard_enabled: Whether to run the budget overage
            guard after packing.
    """

    scorer_weights: ScorerWeights | None = None
    depth_fallback_steps: tuple[int, ...] = (9, 4, 2, 0)
    min_fragment_tokens: int = 10
    overage_guard_enabled: bool = True


@dataclass(frozen=True)
class FusionResult:
    """Result of a :meth:`FusionEngine.fuse` call.

    Attributes:
        fragments: Fused fragments fitting within the budget.
        input_count: Number of fragments received.
        dedup_count: Number of duplicates removed.
        depth_resolved_count: Number of depth conflicts resolved.
        packed_count: Number of fragments after packing.
        dropped_by_overage_guard: Number of fragments dropped by the
            budget overage guard.
        total_tokens: Total token count of the final fragments.
        budget_utilization: Fraction of budget used (0.0-1.0).
    """

    fragments: list[ContextFragment] = field(default_factory=list)
    input_count: int = 0
    dedup_count: int = 0
    depth_resolved_count: int = 0
    packed_count: int = 0
    dropped_by_overage_guard: int = 0
    total_tokens: int = 0
    budget_utilization: float = 0.0


# ---------------------------------------------------------------------------
# FusionEngine
# ---------------------------------------------------------------------------


class FusionEngine:
    """High-level facade over ACMS Phase 2 pipeline components.

    Wraps ``ContentHashDeduplicator``, ``MaxDepthResolver``,
    ``WeightedCompositeScorer``, and ``GreedyKnapsackPacker`` behind a
    single ``fuse`` method that handles the full fragment fusion pipeline.

    Example::

        engine = FusionEngine()
        result = engine.fuse(
            fragments=[frag1, frag2, frag3],
            budget=ContextBudget(max_tokens=2048),
        )
        for frag in result.fragments:
            print(frag.content)

    Based on ``docs/specification.md`` §42930-42937 and issue #192.
    """

    def __init__(
        self,
        config: FusionConfig | None = None,
        *,
        deduplicator: ContentHashDeduplicator | None = None,
        depth_resolver: MaxDepthResolver | None = None,
        scorer: WeightedCompositeScorer | None = None,
        packer: GreedyKnapsackPacker | None = None,
    ) -> None:
        self._config = config or FusionConfig()
        self._deduplicator = deduplicator or ContentHashDeduplicator()
        self._depth_resolver = depth_resolver or MaxDepthResolver()
        self._scorer = scorer or WeightedCompositeScorer(
            weights=self._config.scorer_weights,
        )
        self._packer = packer or GreedyKnapsackPacker(
            depth_fallback_steps=self._config.depth_fallback_steps,
            min_fragment_tokens=self._config.min_fragment_tokens,
        )

    @property
    def config(self) -> FusionConfig:
        """Return the engine configuration."""
        return self._config

    @property
    def deduplicator(self) -> ContentHashDeduplicator:
        """Return the underlying deduplicator."""
        return self._deduplicator

    @property
    def depth_resolver(self) -> MaxDepthResolver:
        """Return the underlying depth resolver."""
        return self._depth_resolver

    @property
    def scorer(self) -> WeightedCompositeScorer:
        """Return the underlying scorer."""
        return self._scorer

    @property
    def packer(self) -> GreedyKnapsackPacker:
        """Return the underlying packer."""
        return self._packer

    def fuse(
        self,
        fragments: Sequence[ContextFragment],
        budget: ContextBudget,
    ) -> FusionResult:
        """Fuse fragments through dedup, depth resolution, scoring, and packing.

        Args:
            fragments: Input fragments from strategy execution.
            budget: Token budget constraint.

        Returns:
            A :class:`FusionResult` with fused fragments and metrics.
        """
        input_count = len(fragments)

        if not fragments:
            return FusionResult(input_count=0)

        # Stage 1: Deduplicate by UKO URI + content hash
        deduped = list(self._deduplicator.deduplicate(fragments))
        dedup_count = input_count - len(deduped)

        # Stage 2: Resolve detail depth conflicts (max depth wins)
        resolved = list(self._depth_resolver.resolve(deduped, budget.available_tokens))
        depth_resolved_count = len(deduped) - len(resolved)

        # Stage 3: Score fragments with weighted composite
        scored = list(self._scorer.score(resolved))

        # Stage 4: Apply deterministic sort before packing
        # Tie-breakers: relevance desc, detail_depth desc, token_count asc
        sorted_scored = sorted(
            scored,
            key=lambda f: (-f.relevance_score, -f.detail_depth, f.token_count),
        )

        # Stage 5: Greedy knapsack packing
        packed = list(self._packer.pack(sorted_scored, budget))
        packed_count = len(packed)

        # Stage 6: Budget overage guard
        dropped_count = 0
        if self._config.overage_guard_enabled:
            packed, dropped_count = self._budget_overage_guard(packed, budget)

        total_tokens = sum(f.token_count for f in packed)
        available = budget.available_tokens
        utilization = total_tokens / available if available > 0 else 0.0

        logger.info(
            "Fusion complete",
            extra={
                "input_count": input_count,
                "dedup_count": dedup_count,
                "depth_resolved": depth_resolved_count,
                "packed_count": packed_count,
                "dropped_by_guard": dropped_count,
                "total_tokens": total_tokens,
                "budget_utilization": round(utilization, 4),
            },
        )

        return FusionResult(
            fragments=packed,
            input_count=input_count,
            dedup_count=dedup_count,
            depth_resolved_count=depth_resolved_count,
            packed_count=len(packed),
            dropped_by_overage_guard=dropped_count,
            total_tokens=total_tokens,
            budget_utilization=round(min(utilization, 1.0), 4),
        )

    @staticmethod
    def _budget_overage_guard(
        fragments: list[ContextFragment],
        budget: ContextBudget,
    ) -> tuple[list[ContextFragment], int]:
        """Drop lowest-relevance fragments until total fits within budget.

        Returns:
            Tuple of (surviving fragments, number dropped).
        """
        total = sum(f.token_count for f in fragments)
        available = budget.available_tokens

        if total <= available:
            return fragments, 0

        # Sort ascending by relevance so we drop least relevant first
        by_relevance = sorted(
            enumerate(fragments),
            key=lambda pair: (
                pair[1].relevance_score,
                pair[1].detail_depth,
                -pair[1].token_count,
            ),
        )

        dropped_indices: set[int] = set()

        for idx, frag in by_relevance:
            if total <= available:
                break
            total -= frag.token_count
            dropped_indices.add(idx)
            logger.warning(
                "Budget overage guard: dropping fragment",
                extra={
                    "fragment_id": frag.fragment_id,
                    "uko_node": frag.uko_node,
                    "relevance_score": frag.relevance_score,
                    "token_count": frag.token_count,
                },
            )

        surviving = [f for i, f in enumerate(fragments) if i not in dropped_indices]
        return surviving, len(dropped_indices)
