"""ACMS core pipeline component implementations.

Provides the five core ACMS pipeline components that form the central
assembly logic of the context assembly pipeline:

1. **ActorPhaseStrategySelector** -- Selects context strategies based on
   actor type and plan phase from the request context.  Applies actor-type
   and phase-specific confidence boosts to guide strategy selection.

2. **SpecBudgetAllocator** -- Computes per-strategy token budgets using the
   spec formula: ``budget_i = total_budget * (confidence_i * quality_i) /
   sum(confidence_j * quality_j)``.  Handles edge cases including empty
   candidates, zero total weight, and single-candidate scenarios.

3. **RelevanceRecencyPriorityScorer** -- Scores context fragments by a
   weighted composite of relevance, recency (normalised from ``created_at``),
   and priority (from fragment metadata).  Produces deterministic scores for
   identical inputs.

4. **ConstrainedKnapsackPacker** -- Greedy knapsack algorithm that packs
   fragments within the token budget while respecting ``max_file_size`` and
   ``max_total_size`` byte-size constraints from a ``ContextView``.

5. **PriorityCoherenceOrderer** -- Orders packed fragments for optimal LLM
   consumption by grouping related fragments (same UKO node prefix) and
   ordering groups by priority then relevance.

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

Based on ``docs/specification.md`` SS42630-42653.

ISSUES CLOSED: #10015
"""

from __future__ import annotations

import logging
from collections import defaultdict
from collections.abc import Sequence
from datetime import datetime
from typing import Any, Final

from cleveragents.application.services.acms_service import (
    ContextStrategy,
)
from cleveragents.domain.models.core.context_fragment import (
    ContextBudget,
    ContextFragment,
)
from cleveragents.domain.models.core.context_policy import ContextView

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Configuration constants
# ---------------------------------------------------------------------------

# Default scoring weights for RelevanceRecencyPriorityScorer
DEFAULT_RELEVANCE_WEIGHT: Final[float] = 0.5
DEFAULT_RECENCY_WEIGHT: Final[float] = 0.3
DEFAULT_PRIORITY_WEIGHT: Final[float] = 0.2

# Minimum token count for a fragment to be eligible for packing
DEFAULT_MIN_FRAGMENT_TOKENS: Final[int] = 1

# Actor-type strategy preference mappings
_ACTOR_STRATEGY_PREFERENCES: Final[dict[str, list[str]]] = {
    "planner": ["relevance", "arce", "tiered"],
    "executor": ["tiered", "relevance", "recency"],
    "reviewer": ["relevance", "recency", "arce"],
    "analyst": ["arce", "temporal-archaeology", "relevance"],
    "corrector": ["plan-decision-context", "relevance", "recency"],
}

# Plan-phase strategy preference mappings
_PHASE_STRATEGY_PREFERENCES: Final[dict[str, list[str]]] = {
    "strategize": ["relevance", "arce"],
    "execute": ["tiered", "relevance"],
    "apply": ["tiered", "recency"],
    "review": ["relevance", "recency"],
    "correct": ["plan-decision-context", "relevance"],
}

# Confidence boost applied to preferred strategies
_PREFERENCE_BOOST: Final[float] = 0.2


# ---------------------------------------------------------------------------
# 1. ActorPhaseStrategySelector  (StrategySelector protocol)
# ---------------------------------------------------------------------------


class ActorPhaseStrategySelector:
    """Select context strategies based on actor type and plan phase.

    Evaluates all strategies via ``can_handle`` and applies confidence
    boosts to strategies preferred by the current actor type and plan
    phase.  The request dict may contain:

    - ``actor_type``: The type of actor making the request.
    - ``plan_phase``: The current plan phase.

    Implements ``StrategySelector`` protocol from ``acms_service.py``.

    Based on ``docs/specification.md`` SS42630.
    """

    def __init__(
        self,
        *,
        preference_boost: float = _PREFERENCE_BOOST,
    ) -> None:
        self._preference_boost = preference_boost

    @property
    def preference_boost(self) -> float:
        """Return the configured preference boost value."""
        return self._preference_boost

    def select(
        self,
        strategies: Sequence[ContextStrategy],
        request: dict[str, Any],
    ) -> list[tuple[ContextStrategy, float]]:
        """Return (strategy, confidence) pairs sorted by priority."""
        actor_type: str = request.get("actor_type", "")
        plan_phase: str = request.get("plan_phase", "")

        actor_preferred: list[str] = _ACTOR_STRATEGY_PREFERENCES.get(actor_type, [])
        phase_preferred: list[str] = _PHASE_STRATEGY_PREFERENCES.get(plan_phase, [])

        scored: list[tuple[ContextStrategy, float]] = []

        for strategy in strategies:
            base_confidence = strategy.can_handle(request)
            if base_confidence <= 0.0:
                continue

            effective = base_confidence

            if strategy.name in actor_preferred:
                effective = min(effective + self._preference_boost, 1.0)

            if strategy.name in phase_preferred:
                effective = min(effective + self._preference_boost, 1.0)

            scored.append((strategy, effective))

        scored.sort(key=lambda x: x[1], reverse=True)

        logger.debug(
            "ActorPhaseStrategySelector: selected %d strategies",
            len(scored),
        )
        return scored


# ---------------------------------------------------------------------------
# 2. SpecBudgetAllocator  (BudgetAllocator protocol)
# ---------------------------------------------------------------------------


class SpecBudgetAllocator:
    """Compute per-strategy token budgets using the spec formula.

    Implements the spec budget allocation formula:

        budget_i = total_budget * (confidence_i * quality_i) /
                   sum(confidence_j * quality_j for all j)

    Implements ``BudgetAllocator`` protocol from ``acms_service.py``.

    Based on ``docs/specification.md`` SS42632.
    """

    def __init__(self, *, min_useful_budget: int = 0) -> None:
        self._min_useful_budget = min_useful_budget

    @property
    def min_useful_budget(self) -> int:
        """Return the minimum useful budget threshold."""
        return self._min_useful_budget

    @staticmethod
    def _weight(strategy: ContextStrategy, confidence: float) -> float:
        """Compute allocation weight: confidence * quality_score."""
        quality: float = getattr(strategy.capabilities, "quality_score", 1.0)
        return confidence * quality

    def allocate(
        self,
        candidates: list[tuple[ContextStrategy, float]],
        total_budget: int,
        request: Any = None,
    ) -> list[tuple[ContextStrategy, float, int]]:
        """Return (strategy, confidence, allocated_tokens) triples."""
        if not candidates:
            return []

        if len(candidates) == 1:
            strategy, confidence = candidates[0]
            return [(strategy, confidence, total_budget)]

        working = self._filter_by_min_budget(candidates, total_budget)
        if not working:
            best = max(candidates, key=lambda x: x[1])
            return [(best[0], best[1], total_budget)]

        total_weight = sum(self._weight(s, c) for s, c in working)
        if total_weight <= 0.0:
            return self._equal_split(working, total_budget)

        return self._proportional_split(working, total_budget, total_weight)

    def _filter_by_min_budget(
        self,
        candidates: list[tuple[ContextStrategy, float]],
        total_budget: int,
    ) -> list[tuple[ContextStrategy, float]]:
        """Remove candidates whose proportional share < min_useful_budget."""
        if self._min_useful_budget <= 0:
            return list(candidates)

        total_weight = sum(self._weight(s, c) for s, c in candidates)
        if total_weight <= 0.0:
            return list(candidates)

        kept: list[tuple[ContextStrategy, float]] = []
        for strategy, confidence in candidates:
            w = self._weight(strategy, confidence)
            share = int(total_budget * w / total_weight)
            if share >= self._min_useful_budget:
                kept.append((strategy, confidence))

        return kept if kept else list(candidates)

    def _equal_split(
        self,
        candidates: list[tuple[ContextStrategy, float]],
        total_budget: int,
    ) -> list[tuple[ContextStrategy, float, int]]:
        """Split budget equally with largest-remainder distribution."""
        n = len(candidates)
        share = total_budget // n
        remainder = total_budget - share * n
        return [
            (s, c, share + (1 if i < remainder else 0))
            for i, (s, c) in enumerate(candidates)
        ]

    def _proportional_split(
        self,
        candidates: list[tuple[ContextStrategy, float]],
        total_budget: int,
        total_weight: float,
    ) -> list[tuple[ContextStrategy, float, int]]:
        """Proportional allocation with largest-remainder rounding."""
        weights = [self._weight(s, c) for s, c in candidates]
        raw = [total_budget * w / total_weight for w in weights]
        floors = [int(r) for r in raw]
        remainder = total_budget - sum(floors)

        fractions = sorted(
            ((r - f, i) for i, (r, f) in enumerate(zip(raw, floors, strict=True))),
            reverse=True,
        )
        for _, i in fractions[:remainder]:
            floors[i] += 1

        return [(s, c, floors[i]) for i, (s, c) in enumerate(candidates)]


# ---------------------------------------------------------------------------
# 3. RelevanceRecencyPriorityScorer  (FragmentScorer protocol)
# ---------------------------------------------------------------------------


class RelevanceRecencyPriorityScorer:
    """Score context fragments by relevance, recency, and priority.

    Computes a weighted composite score:

        composite = (relevance_weight * relevance_score
                     + recency_weight * recency_norm
                     + priority_weight * priority)

    Produces deterministic scores for identical inputs.

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

    Based on ``docs/specification.md`` SS42636.
    """

    def __init__(
        self,
        *,
        relevance_weight: float = DEFAULT_RELEVANCE_WEIGHT,
        recency_weight: float = DEFAULT_RECENCY_WEIGHT,
        priority_weight: float = DEFAULT_PRIORITY_WEIGHT,
    ) -> None:
        self._relevance_weight = relevance_weight
        self._recency_weight = recency_weight
        self._priority_weight = priority_weight

    @property
    def relevance_weight(self) -> float:
        """Return the configured relevance weight."""
        return self._relevance_weight

    @property
    def recency_weight(self) -> float:
        """Return the configured recency weight."""
        return self._recency_weight

    @property
    def priority_weight(self) -> float:
        """Return the configured priority weight."""
        return self._priority_weight

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

        timestamps = [f.created_at for f in fragments]
        min_ts = min(timestamps)
        max_ts = max(timestamps)
        ts_range = (max_ts - min_ts).total_seconds()

        scored: list[ContextFragment] = []
        for frag in fragments:
            recency_norm = self._recency_norm(frag.created_at, min_ts, ts_range)
            priority = self._extract_priority(frag)
            composite = self._compute_composite(
                frag.relevance_score, recency_norm, priority
            )

            new_meta = dict(frag.metadata)
            new_meta["_original_relevance"] = str(frag.relevance_score)
            new_meta["_score_relevance"] = str(round(frag.relevance_score, 6))
            new_meta["_score_recency"] = str(round(recency_norm, 6))
            new_meta["_score_priority"] = str(round(priority, 6))

            scored.append(
                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,
                    strategy_source=frag.strategy_source,
                    tier=frag.tier,
                    metadata=new_meta,
                    created_at=frag.created_at,
                )
            )

        return scored

    def _recency_norm(
        self,
        created_at: datetime,
        min_ts: datetime,
        ts_range: float,
    ) -> float:
        """Compute normalised recency score in [0.0, 1.0]."""
        if ts_range <= 0.0:
            return 1.0
        elapsed = (created_at - min_ts).total_seconds()
        return elapsed / ts_range

    def _extract_priority(self, frag: ContextFragment) -> float:
        """Extract priority from fragment metadata, defaulting to 0.5."""
        raw = frag.metadata.get("priority", "0.5")
        try:
            value = float(raw)
            return max(0.0, min(1.0, value))
        except (ValueError, TypeError):
            return 0.5

    def _compute_composite(
        self,
        relevance: float,
        recency: float,
        priority: float,
    ) -> float:
        """Compute weighted composite score clamped to [0.0, 1.0]."""
        raw = (
            self._relevance_weight * relevance
            + self._recency_weight * recency
            + self._priority_weight * priority
        )
        return max(0.0, min(1.0, round(raw, 6)))


# ---------------------------------------------------------------------------
# 4. ConstrainedKnapsackPacker  (BudgetPacker protocol)
# ---------------------------------------------------------------------------


class ConstrainedKnapsackPacker:
    """Pack fragments within budget respecting max_file_size and max_total_size.

    Extends the greedy knapsack algorithm with byte-size constraints
    from a ``ContextView``.

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

    Based on ``docs/specification.md`` SS42637.
    """

    def __init__(
        self,
        *,
        max_file_size: int | None = None,
        max_total_size: int | None = None,
        min_fragment_tokens: int = DEFAULT_MIN_FRAGMENT_TOKENS,
        context_view: ContextView | None = None,
    ) -> None:
        self._max_file_size = max_file_size
        self._max_total_size = max_total_size
        self._min_fragment_tokens = min_fragment_tokens
        self._context_view = context_view

    @property
    def max_file_size(self) -> int | None:
        """Return the configured max_file_size constraint."""
        return self._max_file_size

    @property
    def max_total_size(self) -> int | None:
        """Return the configured max_total_size constraint."""
        return self._max_total_size

    @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 with byte-size constraints."""
        if not fragments:
            return list(fragments)

        max_file = self._max_file_size
        max_total = self._max_total_size

        if self._context_view is not None:
            if max_file is None:
                max_file = self._context_view.max_file_size
            if max_total is None:
                max_total = self._context_view.max_total_size

        available_tokens = budget.available_tokens

        eligible = [f for f in fragments if f.token_count >= self._min_fragment_tokens]

        if max_file is not None:
            eligible = [
                f
                for f in eligible
                if len(f.content.encode("utf-8", errors="replace")) <= max_file
            ]

        sorted_frags = sorted(eligible, key=lambda f: f.relevance_score, reverse=True)

        packed: list[ContextFragment] = []
        used_tokens = 0
        used_bytes = 0

        for frag in sorted_frags:
            if used_tokens + frag.token_count > available_tokens:
                continue

            content_bytes = len(frag.content.encode("utf-8", errors="replace"))
            if max_total is not None and used_bytes + content_bytes > max_total:
                continue

            packed.append(frag)
            used_tokens += frag.token_count
            used_bytes += content_bytes

        return packed


# ---------------------------------------------------------------------------
# 5. PriorityCoherenceOrderer  (FragmentOrderer protocol)
# ---------------------------------------------------------------------------


class PriorityCoherenceOrderer:
    """Order packed fragments for optimal LLM consumption.

    Groups related fragments (same UKO node prefix) and sorts groups by
    priority then relevance.

    Implements ``FragmentOrderer`` protocol from ``acms_service.py``.

    Based on ``docs/specification.md`` SS42648.
    """

    def __init__(
        self,
        *,
        min_shared_segments: int = 2,
    ) -> None:
        self._min_shared_segments = min_shared_segments

    @property
    def min_shared_segments(self) -> int:
        """Return the minimum shared URI segments for grouping."""
        return self._min_shared_segments

    def order(
        self,
        fragments: Sequence[ContextFragment],
    ) -> Sequence[ContextFragment]:
        """Order fragments for optimal LLM consumption."""
        if not fragments or len(fragments) <= 1:
            return list(fragments)

        groups: dict[str, list[ContextFragment]] = defaultdict(list)
        for frag in fragments:
            prefix = self._extract_prefix(frag.uko_node)
            groups[prefix].append(frag)

        def group_sort_key(group: list[ContextFragment]) -> tuple[float, float]:
            max_priority = max(self._extract_priority(f) for f in group)
            max_relevance = max(f.relevance_score for f in group)
            return (-max_priority, -max_relevance)

        sorted_groups = sorted(groups.values(), key=group_sort_key)

        ordered: list[ContextFragment] = []
        for group in sorted_groups:
            sorted_group = sorted(
                group,
                key=lambda f: (-self._extract_priority(f), -f.relevance_score),
            )
            ordered.extend(sorted_group)

        return ordered

    def _extract_prefix(self, uko_node: str) -> str:
        """Extract the grouping prefix from a UKO node URI."""
        segments = _uri_segments(uko_node)
        prefix_segments = segments[: self._min_shared_segments]
        return "/".join(prefix_segments) if prefix_segments else uko_node

    @staticmethod
    def _extract_priority(frag: ContextFragment) -> float:
        """Extract priority from fragment metadata, defaulting to 0.5."""
        raw = frag.metadata.get("priority", "0.5")
        try:
            value = float(raw)
            return max(0.0, min(1.0, value))
        except (ValueError, TypeError):
            return 0.5


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


def _uri_segments(uri: str) -> list[str]:
    """Split a UKO URI into path segments for hierarchy comparison."""
    if "://" in uri:
        uri = uri.split("://", 1)[1]
    return [seg for seg in uri.replace("\\", "/").split("/") if seg]
