"""ACMS pipeline Phase 2 — Fragment Fusion component implementations.

Provides the four production-grade Phase 2 components for the ACMS context
assembly pipeline, replacing the no-op defaults from ``acms_service.py``:

4. **ContentHashDeduplicator** — Groups fragments by UKO node URI, hashes
   content within groups to detect duplicates, retains the fragment with
   the highest ``relevance_score``.
5. **MaxDepthResolver** — When the same UKO node appears at multiple detail
   depths, retains the highest-depth rendering per node.
6. **WeightedCompositeScorer** — Computes a composite relevance score from
   configurable weighted factors (relevance, hierarchy, quality, recency).
7. **GreedyKnapsackPacker** — Selects fragments that fit within the token
   budget using a greedy knapsack algorithm with depth fallback.

All components satisfy the v1 Protocol signatures defined in
``acms_service.py`` and can be injected into ``ACMSPipeline`` via
constructor dependency injection.

Based on ``docs/specification.md`` §42930-42937.
"""

from __future__ import annotations

import hashlib
import logging
from collections import defaultdict
from collections.abc import Sequence
from typing import Final

from cleveragents.domain.models.core.context_fragment import (
    ContextBudget,
    ContextFragment,
    ScoredFragment,
)

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Default configuration constants (from spec §43004-43012)
# ---------------------------------------------------------------------------

DEFAULT_RELEVANCE_WEIGHT: Final[float] = 0.4
DEFAULT_HIERARCHY_WEIGHT: Final[float] = 0.3
DEFAULT_QUALITY_WEIGHT: Final[float] = 0.2
DEFAULT_RECENCY_WEIGHT: Final[float] = 0.1

DEFAULT_DEPTH_FALLBACK_STEPS: Final[tuple[int, ...]] = (9, 4, 2, 0)
DEFAULT_MIN_FRAGMENT_TOKENS: Final[int] = 10


# ---------------------------------------------------------------------------
# 4. ContentHashDeduplicator  (FragmentDeduplicator protocol)
# ---------------------------------------------------------------------------


class ContentHashDeduplicator:
    """Remove duplicate fragments using content hashing.

    Groups fragments by ``uko_node`` URI; within each group, computes a
    SHA-256 hash of the content to detect duplicates.  When duplicates are
    found, the fragment with the highest ``relevance_score`` is retained.

    Implements ``FragmentDeduplicator`` protocol from ``acms_service.py``.

    Based on ``docs/specification.md`` §42934 — ``ContentHashDeduplicator``.
    """

    def deduplicate(
        self,
        fragments: Sequence[ContextFragment],
    ) -> Sequence[ContextFragment]:
        """Return deduplicated fragments, retaining highest-scored duplicates."""
        if not fragments:
            return list(fragments)

        # Group by (uko_node, content_hash)
        groups: dict[tuple[str, str], ContextFragment] = {}
        dedup_count = 0

        for frag in fragments:
            content_hash = _content_hash(frag.content)
            key = (frag.uko_node, content_hash)

            if key in groups:
                dedup_count += 1
                existing = groups[key]
                if frag.relevance_score > existing.relevance_score:
                    groups[key] = frag
            else:
                groups[key] = frag

        result = list(groups.values())
        if dedup_count > 0:
            logger.info(
                "Deduplicated fragments",
                extra={
                    "dedup_count": dedup_count,
                    "input_count": len(fragments),
                    "output_count": len(result),
                },
            )
        return result


# ---------------------------------------------------------------------------
# 5. MaxDepthResolver  (DetailDepthResolver protocol)
# ---------------------------------------------------------------------------


class MaxDepthResolver:
    """Resolve detail depth conflicts by retaining the highest depth per node.

    When the same UKO node appears at multiple detail depths across strategy
    outputs, retains the highest-depth rendering.  Fragments for distinct
    UKO nodes pass through unchanged.

    Implements ``DetailDepthResolver`` protocol from ``acms_service.py``.

    Based on ``docs/specification.md`` §42935 — ``MaxDepthResolver``.
    """

    def resolve(
        self,
        fragments: Sequence[ContextFragment],
        budget: int = 0,
    ) -> Sequence[ContextFragment]:
        """Return fragments with depth conflicts resolved to maximum depth."""
        if not fragments:
            return list(fragments)

        # Group by uko_node, keep max depth per node
        best_by_node: dict[str, ContextFragment] = {}

        for frag in fragments:
            node = frag.uko_node
            if node in best_by_node:
                existing = best_by_node[node]
                if frag.detail_depth > existing.detail_depth:
                    best_by_node[node] = frag
                elif (
                    frag.detail_depth == existing.detail_depth
                    and frag.relevance_score > existing.relevance_score
                ):
                    # Same depth: prefer higher relevance
                    best_by_node[node] = frag
            else:
                best_by_node[node] = frag

        # Preserve original order for fragments that survived
        survived_ids = {f.fragment_id for f in best_by_node.values()}
        result = [f for f in fragments if f.fragment_id in survived_ids]

        resolved_count = len(fragments) - len(result)
        if resolved_count > 0:
            logger.info(
                "Resolved depth conflicts",
                extra={
                    "resolved_count": resolved_count,
                    "input_count": len(fragments),
                    "output_count": len(result),
                },
            )
        return result


# ---------------------------------------------------------------------------
# 6. WeightedCompositeScorer  (FragmentScorer protocol)
# ---------------------------------------------------------------------------


class ScorerWeights:
    """Configurable scoring weight parameters.

    Based on ``docs/specification.md`` §43004-43008.
    """

    __slots__ = ("hierarchy", "quality", "recency", "relevance")

    def __init__(
        self,
        *,
        relevance: float = DEFAULT_RELEVANCE_WEIGHT,
        hierarchy: float = DEFAULT_HIERARCHY_WEIGHT,
        quality: float = DEFAULT_QUALITY_WEIGHT,
        recency: float = DEFAULT_RECENCY_WEIGHT,
    ) -> None:
        self.relevance = relevance
        self.hierarchy = hierarchy
        self.quality = quality
        self.recency = recency


class WeightedCompositeScorer:
    """Score fragments with a composite of weighted factors.

    Computes::

        composite = (relevance_score * relevance_weight
                     + hierarchy_weight_factor * hierarchy_weight
                     + strategy_quality * quality_weight
                     + recency_bonus * recency_weight)

    Factor values are sourced from fragment metadata when available, with
    sensible defaults (0.5 for hierarchy, 0.5 for quality, 1.0 for recency).

    The composite is clamped to [0.0, 1.0] and stored in the fragment's
    ``relevance_score`` field (since the v1 pipeline uses ``ContextFragment``
    throughout, not ``ScoredFragment``).  The original relevance score and
    component breakdown are preserved in ``metadata``.

    Implements ``FragmentScorer`` protocol from ``acms_service.py``.

    Based on ``docs/specification.md`` §42936 — ``WeightedCompositeScorer``.
    """

    def __init__(self, weights: ScorerWeights | None = None) -> None:
        self._weights = weights or ScorerWeights()

    @property
    def weights(self) -> ScorerWeights:
        """Return the configured scoring weights."""
        return self._weights

    def score(
        self,
        fragments: Sequence[ContextFragment],
    ) -> Sequence[ContextFragment]:
        """Return fragments re-scored with composite relevance scores."""
        if not fragments:
            return list(fragments)

        scored: list[ContextFragment] = []
        for frag in fragments:
            scored_frag = self._score_fragment(frag)
            scored.append(scored_frag)

        logger.info(
            "Scored fragments",
            extra={
                "count": len(scored),
                "min_score": min(f.relevance_score for f in scored),
                "max_score": max(f.relevance_score for f in scored),
            },
        )
        return scored

    def score_detailed(
        self,
        fragments: Sequence[ContextFragment],
    ) -> list[ScoredFragment]:
        """Score fragments and return ``ScoredFragment`` wrappers.

        This is the spec-aligned interface that produces ``ScoredFragment``
        objects with full component breakdowns.  Not used by the v1 pipeline
        but available for callers that need detailed scoring.
        """
        result: list[ScoredFragment] = []
        for frag in fragments:
            components = self._compute_components(frag)
            composite = self._composite_from_components(components)
            result.append(
                ScoredFragment(
                    fragment=frag,
                    composite_score=composite,
                    score_components=components,
                )
            )
        return result

    def _score_fragment(self, frag: ContextFragment) -> ContextFragment:
        """Compute composite score and return a new fragment with it."""
        components = self._compute_components(frag)
        composite = self._composite_from_components(components)

        # Preserve component breakdown in metadata
        new_meta = dict(frag.metadata)
        new_meta["_original_relevance"] = str(frag.relevance_score)
        for key, val in components.items():
            new_meta[f"_score_{key}"] = str(round(val, 6))

        return ContextFragment(
            fragment_id=frag.fragment_id,
            uko_node=frag.uko_node,
            content=frag.content,
            detail_depth=frag.detail_depth,
            token_count=frag.token_count,
            relevance_score=composite,
            provenance=frag.provenance,
            tier=frag.tier,
            metadata=new_meta,
            created_at=frag.created_at,
        )

    def _compute_components(self, frag: ContextFragment) -> dict[str, float]:
        """Extract individual scoring factor values from a fragment."""
        meta = frag.metadata

        relevance = frag.relevance_score
        hierarchy = float(meta.get("hierarchy_weight", "0.5"))
        quality = float(meta.get("strategy_quality", "0.5"))
        recency = float(meta.get("recency_bonus", "1.0"))

        return {
            "relevance": relevance,
            "hierarchy": hierarchy,
            "quality": quality,
            "recency": recency,
        }

    def _composite_from_components(self, components: dict[str, float]) -> float:
        """Compute the weighted composite from component values."""
        w = self._weights
        raw = (
            components["relevance"] * w.relevance
            + components["hierarchy"] * w.hierarchy
            + components["quality"] * w.quality
            + components["recency"] * w.recency
        )
        return max(0.0, min(1.0, round(raw, 6)))


# ---------------------------------------------------------------------------
# 7. GreedyKnapsackPacker  (BudgetPacker protocol)
# ---------------------------------------------------------------------------


class GreedyKnapsackPacker:
    """Pack fragments into the token budget using a greedy knapsack algorithm.

    Sorts fragments by ``relevance_score`` descending, then greedily adds
    fragments until the budget is exhausted.  Fragments below
    ``min_fragment_tokens`` are excluded.

    When a high-value fragment does not fit at its current depth, the packer
    attempts **depth fallback**: it looks for alternative renderings of the
    same UKO node at progressively lower depths from the input set.  This
    allows the packer to include a lower-depth version of important content
    when the full version is too large.

    Implements ``BudgetPacker`` protocol from ``acms_service.py``.

    Based on ``docs/specification.md`` §42937 — ``GreedyKnapsackPacker``.
    """

    def __init__(
        self,
        *,
        depth_fallback_steps: tuple[int, ...] = DEFAULT_DEPTH_FALLBACK_STEPS,
        min_fragment_tokens: int = DEFAULT_MIN_FRAGMENT_TOKENS,
    ) -> None:
        self._depth_fallback_steps = depth_fallback_steps
        self._min_fragment_tokens = min_fragment_tokens

    @property
    def depth_fallback_steps(self) -> tuple[int, ...]:
        """Return the configured depth fallback sequence."""
        return self._depth_fallback_steps

    @property
    def min_fragment_tokens(self) -> int:
        """Return the minimum fragment token threshold."""
        return self._min_fragment_tokens

    def pack(
        self,
        fragments: Sequence[ContextFragment],
        budget: ContextBudget,
    ) -> Sequence[ContextFragment]:
        """Pack fragments into budget using greedy knapsack with depth fallback."""
        if not fragments:
            return list(fragments)

        available = budget.available_tokens

        # Pre-filter fragments below minimum token threshold
        eligible = [f for f in fragments if f.token_count >= self._min_fragment_tokens]

        # Build a lookup for depth fallback: uko_node → {depth → fragment}
        node_depth_map: dict[str, dict[int, ContextFragment]] = defaultdict(dict)
        for frag in eligible:
            node = frag.uko_node
            depth = frag.detail_depth
            # Keep highest-scored fragment per (node, depth) pair
            if depth not in node_depth_map[node] or (
                frag.relevance_score > node_depth_map[node][depth].relevance_score
            ):
                node_depth_map[node][depth] = frag

        # Sort by relevance_score descending for greedy selection
        sorted_frags = sorted(eligible, key=lambda f: f.relevance_score, reverse=True)

        packed: list[ContextFragment] = []
        used_tokens = 0
        packed_nodes: set[str] = set()  # Track which nodes are already packed
        fallback_count = 0

        for frag in sorted_frags:
            if frag.uko_node in packed_nodes:
                # Node already packed (possibly via fallback)
                continue

            if used_tokens + frag.token_count <= available:
                # Fragment fits directly
                packed.append(frag)
                used_tokens += frag.token_count
                packed_nodes.add(frag.uko_node)
            else:
                # Try depth fallback — look for lower-depth versions
                fallback = self._try_depth_fallback(
                    frag.uko_node,
                    available - used_tokens,
                    node_depth_map,
                )
                if fallback is not None:
                    packed.append(fallback)
                    used_tokens += fallback.token_count
                    packed_nodes.add(frag.uko_node)
                    fallback_count += 1

        logger.info(
            "Packed fragments into budget",
            extra={
                "input_count": len(fragments),
                "eligible_count": len(eligible),
                "packed_count": len(packed),
                "used_tokens": used_tokens,
                "available_tokens": available,
                "budget_utilization": round(
                    used_tokens / available if available > 0 else 0.0, 4
                ),
                "fallback_count": fallback_count,
            },
        )
        return packed

    def _try_depth_fallback(
        self,
        uko_node: str,
        remaining_budget: int,
        node_depth_map: dict[str, dict[int, ContextFragment]],
    ) -> ContextFragment | None:
        """Try progressively lower depths for a fragment that doesn't fit."""
        depths = node_depth_map.get(uko_node, {})
        if not depths:
            return None

        for target_depth in self._depth_fallback_steps:
            if target_depth in depths:
                candidate = depths[target_depth]
                if (
                    candidate.token_count <= remaining_budget
                    and candidate.token_count >= self._min_fragment_tokens
                ):
                    return candidate

        return None


# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------


def _content_hash(content: str) -> str:
    """Compute SHA-256 hash of fragment content for deduplication."""
    return hashlib.sha256(content.encode("utf-8")).hexdigest()
