"""ACMS Strategy Coordinator — facade over Phase 1 pipeline components.

Provides ``StrategyCoordinator``, a high-level facade that wraps the
three Phase 1 pipeline components (strategy selection, budget allocation,
and parallel execution) behind a single ``coordinate`` call.

Key responsibilities:

- **Strategy selection**: Delegates to ``ConfidenceWeightedSelector`` to
  rank strategies by confidence with optional preference boosting.
- **Budget allocation**: Delegates to ``ProportionalBudgetAllocator``
  with per-strategy max cap enforcement.
- **Parallel execution**: Delegates to ``ParallelStrategyExecutor`` with
  circuit-breaker fault tolerance and configurable timeouts.
- **Error handling**: Graceful degradation when strategies fail —
  circuit-broken or timed-out strategies are excluded from results.

Based on ``docs/specification.md`` ~line 42615 and issue #192.

ISSUES CLOSED: #192
"""

from __future__ import annotations

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

from cleveragents.application.services.acms_pipeline import (
    CircuitBreaker,
    ConfidenceWeightedSelector,
    ParallelStrategyExecutor,
    ProportionalBudgetAllocator,
)
from cleveragents.application.services.acms_service import ContextStrategy
from cleveragents.domain.models.core.context_fragment import (
    ContextBudget,
    ContextFragment,
)

logger = logging.getLogger(__name__)


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


@dataclass(frozen=True)
class CoordinatorConfig:
    """Configuration for :class:`StrategyCoordinator`.

    Attributes:
        min_useful_budget: Minimum token allocation below which a
            strategy is excluded from execution.
        executor_timeout: Per-strategy timeout in seconds.
        executor_max_workers: Maximum concurrent strategy threads.
        circuit_breaker_threshold: Consecutive failures before a
            strategy's circuit is opened.
        preference_boost: Confidence multiplier for preferred strategies.
        per_strategy_max_cap: Optional per-strategy token cap.  When set,
            no strategy receives more than this many tokens regardless
            of its proportional share.
    """

    min_useful_budget: int = 64
    executor_timeout: float = 30.0
    executor_max_workers: int = 4
    circuit_breaker_threshold: int = 3
    preference_boost: float = 1.5
    per_strategy_max_cap: int | None = None


@dataclass(frozen=True)
class CoordinationResult:
    """Result of a :meth:`StrategyCoordinator.coordinate` call.

    Attributes:
        fragments: Collected fragments from all executed strategies.
        strategies_used: Names of strategies that contributed fragments.
        allocations: Budget allocations as (name, confidence, tokens)
            triples.
        circuit_broken: Strategy names that were circuit-broken and
            therefore skipped.
    """

    fragments: list[ContextFragment] = field(default_factory=list)
    strategies_used: list[str] = field(default_factory=list)
    allocations: list[tuple[str, float, int]] = field(default_factory=list)
    circuit_broken: list[str] = field(default_factory=list)


# ---------------------------------------------------------------------------
# StrategyCoordinator
# ---------------------------------------------------------------------------


class StrategyCoordinator:
    """High-level facade over ACMS Phase 1 pipeline components.

    Wraps ``ConfidenceWeightedSelector``, ``ProportionalBudgetAllocator``,
    and ``ParallelStrategyExecutor`` behind a single ``coordinate`` method
    that handles the full strategy orchestration lifecycle.

    Example::

        coordinator = StrategyCoordinator()
        result = coordinator.coordinate(
            request={"preferred_strategies": ["relevance"]},
            strategies=[RelevanceStrategy(), RecencyStrategy()],
            budget=ContextBudget(max_tokens=4096),
        )
        for frag in result.fragments:
            print(frag.content)

    Based on ``docs/specification.md`` ~line 42615 and issue #192.
    """

    def __init__(
        self,
        config: CoordinatorConfig | None = None,
        *,
        selector: ConfidenceWeightedSelector | None = None,
        allocator: ProportionalBudgetAllocator | None = None,
        executor: ParallelStrategyExecutor | None = None,
    ) -> None:
        self._config = config or CoordinatorConfig()
        self._selector = selector or ConfidenceWeightedSelector(
            preference_boost=self._config.preference_boost,
        )
        self._allocator = allocator or ProportionalBudgetAllocator(
            min_useful_budget=self._config.min_useful_budget,
        )
        cb = CircuitBreaker(
            failure_threshold=self._config.circuit_breaker_threshold,
        )
        self._executor = executor or ParallelStrategyExecutor(
            timeout_seconds=self._config.executor_timeout,
            max_workers=self._config.executor_max_workers,
            circuit_breaker=cb,
        )

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

    @property
    def selector(self) -> ConfidenceWeightedSelector:
        """Return the underlying strategy selector."""
        return self._selector

    @property
    def allocator(self) -> ProportionalBudgetAllocator:
        """Return the underlying budget allocator."""
        return self._allocator

    @property
    def executor(self) -> ParallelStrategyExecutor:
        """Return the underlying strategy executor."""
        return self._executor

    def coordinate(
        self,
        request: dict[str, Any],
        strategies: Sequence[ContextStrategy],
        budget: ContextBudget,
        fragments: Sequence[ContextFragment] | None = None,
        backends: dict[str, Any] | None = None,
        plan_context: dict[str, Any] | None = None,
    ) -> CoordinationResult:
        """Orchestrate strategy selection, budget allocation, and execution.

        Args:
            request: Context request dict.  May include
                ``preferred_strategies`` to boost certain strategies.
            strategies: Available context strategies to select from.
            budget: Token budget for the coordination round.
            fragments: Input fragments for strategies to process.
                Defaults to an empty sequence.
            backends: Optional backend configuration (reserved for future
                use by backend-aware strategies).
            plan_context: Optional plan context (reserved for future use
                by plan-aware strategies).

        Returns:
            A :class:`CoordinationResult` with collected fragments and
            metadata about the coordination round.
        """
        if fragments is None:
            fragments = []

        # Merge backend/plan context hints into the request dict for
        # strategies that inspect these fields.
        enriched_request = dict(request)
        if backends is not None:
            enriched_request["backends"] = backends
        if plan_context is not None:
            enriched_request["plan_context"] = plan_context

        # Phase 1a: Strategy selection
        candidates = self._selector.select(strategies, enriched_request)

        if not candidates:
            logger.warning(
                "No strategies selected for request",
                extra={"request_keys": list(enriched_request.keys())},
            )
            return CoordinationResult()

        # Phase 1b: Budget allocation with per-strategy max cap
        allocations = self._allocator.allocate(
            candidates, budget.available_tokens, None
        )
        capped_allocations = self._apply_max_caps(allocations)

        # Track circuit-broken strategies
        cb = self._executor.circuit_breaker
        circuit_broken = [
            s.name for s, _c, _t in capped_allocations if cb.is_open(s.name)
        ]

        # Phase 1c: Parallel execution
        result_fragments = list(
            self._executor.execute(capped_allocations, fragments, budget)
        )

        # Derive which strategies actually contributed
        strategies_used = _extract_strategies_used(
            capped_allocations, result_fragments, cb
        )

        allocation_summary = [(s.name, c, t) for s, c, t in capped_allocations]

        logger.info(
            "Coordination complete",
            extra={
                "candidates": len(candidates),
                "allocations": len(capped_allocations),
                "fragments_returned": len(result_fragments),
                "strategies_used": strategies_used,
                "circuit_broken": circuit_broken,
            },
        )

        return CoordinationResult(
            fragments=result_fragments,
            strategies_used=strategies_used,
            allocations=allocation_summary,
            circuit_broken=circuit_broken,
        )

    def _apply_max_caps(
        self,
        allocations: list[tuple[ContextStrategy, float, int]],
    ) -> list[tuple[ContextStrategy, float, int]]:
        """Enforce per-strategy max cap on token allocations.

        When ``per_strategy_max_cap`` is configured, any allocation
        exceeding the cap is clamped.  Excess tokens are redistributed
        proportionally among uncapped strategies using the
        largest-remainder method.
        """
        cap = self._config.per_strategy_max_cap
        if cap is None or not allocations:
            return allocations

        capped: list[tuple[ContextStrategy, float, int]] = []
        excess = 0

        # First pass: clamp and collect excess
        uncapped_indices: list[int] = []
        for i, (strategy, confidence, tokens) in enumerate(allocations):
            if tokens > cap:
                excess += tokens - cap
                capped.append((strategy, confidence, cap))
            else:
                capped.append((strategy, confidence, tokens))
                uncapped_indices.append(i)

        # Second pass: redistribute excess among uncapped strategies
        if excess > 0 and uncapped_indices:
            total_conf = sum(capped[i][1] for i in uncapped_indices)
            if total_conf > 0:
                for idx in uncapped_indices:
                    s, c, t = capped[idx]
                    share = int(excess * c / total_conf)
                    new_tokens = min(t + share, cap)
                    capped[idx] = (s, c, new_tokens)
            else:
                # Equal split among uncapped
                share = excess // len(uncapped_indices)
                for idx in uncapped_indices:
                    s, c, t = capped[idx]
                    capped[idx] = (s, c, min(t + share, cap))

            if excess > 0:
                logger.debug(
                    "Per-strategy max cap applied",
                    extra={
                        "cap": cap,
                        "excess_redistributed": excess,
                    },
                )

        return capped


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


def _extract_strategies_used(
    allocations: list[tuple[ContextStrategy, float, int]],
    fragments: list[ContextFragment],
    circuit_breaker: CircuitBreaker,
) -> list[str]:
    """Determine which strategies contributed fragments.

    A strategy is considered "used" if it was allocated tokens, was not
    circuit-broken, and returned at least one fragment (inferred from
    the allocation being non-empty and the strategy not being disabled).
    """
    # If fragments have strategy_source metadata, use that
    source_names: set[str] = set()
    for frag in fragments:
        if frag.strategy_source:
            source_names.add(frag.strategy_source)

    if source_names:
        return sorted(source_names)

    # Fallback: consider all non-circuit-broken, non-zero-budget strategies
    return sorted(
        s.name
        for s, _c, t in allocations
        if t > 0 and not circuit_breaker.is_open(s.name)
    )
