"""ACMS Context Assembly Pipeline orchestrator and Phase 1 components.

Implements the production-quality versions of the Phase 1 pipeline components
defined in ``docs/specification.md`` §42630-42636:

- **ConfidenceWeightedSelector** — Selects strategies by confidence score
  weighted by backend availability.  Respects ``preferred_strategies`` hints
  from the ``ContextRequest``.

- **ProportionalBudgetAllocator** — Distributes token budget proportionally
  across selected strategies with ``min_useful_budget`` enforcement.

- **ParallelStrategyExecutor** — Executes strategies concurrently via
  ``ThreadPoolExecutor`` with per-strategy timeouts and a circuit-breaker
  pattern (3 consecutive failures → temporarily disabled).

- **ContextAssemblyPipeline** — Full 10-stage pipeline mediator that wires
  all components and adds structured logging with per-stage timing.

All components conform to the Protocol interfaces defined in
``acms_service.py`` and can be used as drop-in replacements for the
``Default*`` pass-through stubs.

Based on ``docs/specification.md`` ~line 42615.

ISSUES CLOSED: #539
"""

from __future__ import annotations

import time
from collections.abc import Sequence
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import TYPE_CHECKING, Any

import structlog
from pydantic import BaseModel, Field

if TYPE_CHECKING:
    from cleveragents.config.settings import Settings
    from cleveragents.infrastructure.database.unit_of_work import UnitOfWork
    from cleveragents.infrastructure.events.protocol import EventBus

from cleveragents.application.services.acms_phase2 import (
    GreedyKnapsackPacker,
)
from cleveragents.application.services.acms_service import (
    ACMSPipeline,
    BudgetAllocator,
    BudgetPacker,
    ContextStrategy,
    DefaultDeduplicator,
    DefaultDepthResolver,
    DefaultOrderer,
    DefaultPreambleGenerator,
    DefaultScorer,
    DetailDepthResolver,
    FragmentDeduplicator,
    FragmentOrderer,
    FragmentScorer,
    PreambleGenerator,
    SkeletonCompressor,
    StrategyExecutor,
    StrategySelector,
)
from cleveragents.application.services.acms_skeleton_compressor import (
    resolve_configured_skeleton_compressor,
)
from cleveragents.domain.models.acms.crp import (
    ContextRequest,
)
from cleveragents.domain.models.core.context_fragment import (
    ContextBudget,
    ContextFragment,
    ContextPayload,
    build_provenance_map,
    compute_context_hash,
)
from cleveragents.domain.models.core.context_policy import ContextView
from cleveragents.infrastructure.events.models import DomainEvent
from cleveragents.infrastructure.events.types import EventType

logger = structlog.get_logger(__name__)


# ---------------------------------------------------------------------------
# Circuit Breaker (per-strategy failure tracking)
# ---------------------------------------------------------------------------


class CircuitBreaker:
    """Per-strategy circuit breaker for the ``ParallelStrategyExecutor``.

    Tracks consecutive failures per strategy name.  After
    ``failure_threshold`` consecutive failures the circuit *opens* and
    the strategy is temporarily disabled.

    Thread-safe: internal state is only mutated from the executor thread
    that owns the futures, never concurrently.

    Based on ``docs/specification.md`` ~line 42936 (ParallelStrategyExecutor
    uses Circuit Breaker pattern).
    """

    def __init__(self, failure_threshold: int = 3) -> None:
        self.failure_threshold = failure_threshold
        self._failures: dict[str, int] = {}
        self._disabled: set[str] = set()

    def record_success(self, name: str) -> None:
        """Record a successful execution, resetting the failure counter."""
        self._failures[name] = 0
        self._disabled.discard(name)

    def record_failure(self, name: str) -> None:
        """Record a failure; open the circuit after threshold breaches."""
        count = self._failures.get(name, 0) + 1
        self._failures[name] = count
        if count >= self.failure_threshold:
            self._disabled.add(name)

    def is_open(self, name: str) -> bool:
        """Return ``True`` if the strategy's circuit is open (disabled)."""
        return name in self._disabled

    def reset(self, name: str) -> None:
        """Reset a strategy's failure counter and re-enable it."""
        self._failures.pop(name, None)
        self._disabled.discard(name)

    def reset_all(self) -> None:
        """Reset all strategies."""
        self._failures.clear()
        self._disabled.clear()


# ---------------------------------------------------------------------------
# Phase 1 — ConfidenceWeightedSelector
# ---------------------------------------------------------------------------


class ConfidenceWeightedSelector:
    """Select strategies by confidence score weighted by backend availability.

    Evaluates all strategies via ``can_handle``, filters to confidence > 0,
    and sorts by effective confidence descending.  When the request dict
    contains a ``preferred_strategies`` key (list of names), those
    strategies receive a 1.5x confidence boost.

    Emits structured log events for strategy selection diagnostics.

    Based on ``docs/specification.md`` ~line 42918
    (``ConfidenceWeightedSelector``).
    """

    def __init__(self, *, preference_boost: float = 1.5) -> None:
        self._preference_boost = preference_boost
        self._logger = logger.bind(component="ConfidenceWeightedSelector")

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

        for strategy in strategies:
            raw_confidence = strategy.can_handle(request)
            if raw_confidence <= 0.0:
                continue
            effective = raw_confidence
            if preferred and strategy.name in preferred:
                effective = min(raw_confidence * self._preference_boost, 1.0)
            scored.append((strategy, effective))

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

        self._logger.debug(
            "Strategy selection complete",
            candidates=len(scored),
            preferred=preferred,
        )
        return scored


# ---------------------------------------------------------------------------
# Phase 1 — ProportionalBudgetAllocator
# ---------------------------------------------------------------------------


class ProportionalBudgetAllocator:
    """Distribute token budget proportionally with min-budget enforcement.

    Each candidate receives a share of ``total_budget`` proportional to
    ``confidence * quality_score`` per spec §42918.  Candidates whose
    proportional share falls below ``min_useful_budget`` are excluded and
    their tokens are redistributed among the remaining candidates.

    Uses the largest-remainder method to ensure the full budget is consumed
    with no token loss.

    Based on ``docs/specification.md`` ~line 42918
    (``ProportionalBudgetAllocator``).
    """

    def __init__(self, *, min_useful_budget: int = 64) -> None:
        self._min_useful_budget = min_useful_budget
        self._logger = logger.bind(component="ProportionalBudgetAllocator")

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

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

        # Filter candidates below min_useful_budget.
        # Single candidate always gets the full budget.
        working = list(candidates)
        if len(working) > 1:
            working = self._filter_by_min_budget(working, total_budget)

        if not working:
            # All candidates excluded — fall back to the highest-confidence one
            best = max(candidates, key=lambda x: x[1])
            return [(best[0], best[1], total_budget)]

        total_weight = sum(self._weighted_score(s, c) for s, c in working)
        if total_weight <= 0:
            # Equal split when all weighted scores are zero
            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."""
        total_weight = sum(self._weighted_score(s, c) for s, c in candidates)
        if total_weight <= 0:
            return candidates  # Can't compute proportions
        kept: list[tuple[ContextStrategy, float]] = []
        for strategy, confidence in candidates:
            weight = self._weighted_score(strategy, confidence)
            share = int(total_budget * weight / total_weight)
            if share >= self._min_useful_budget:
                kept.append((strategy, confidence))
        if not kept:
            return candidates  # Don't exclude everyone
        return kept

    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._weighted_score(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 = [
            (r - f, i) for i, (r, f) in enumerate(zip(raw, floors, strict=True))
        ]
        fractions.sort(reverse=True)
        for _, i in fractions[:remainder]:
            floors[i] += 1

        self._logger.debug(
            "Budget allocation complete",
            candidates=len(candidates),
            total_budget=total_budget,
            allocations=floors,
        )
        return [(s, c, floors[i]) for i, (s, c) in enumerate(candidates)]


# ---------------------------------------------------------------------------
# Phase 1 — ParallelStrategyExecutor
# ---------------------------------------------------------------------------


class ParallelStrategyExecutor:
    """Execute strategies concurrently with timeouts and circuit breaking.

    Uses ``ThreadPoolExecutor`` to run strategies in parallel.  Each
    strategy receives a per-strategy timeout (default 30s).  After
    ``circuit_breaker.failure_threshold`` consecutive failures, a strategy
    is temporarily disabled for the current pipeline instance.

    Failed strategies log warnings and are excluded from results — they do
    not cause the pipeline to fail.

    Based on ``docs/specification.md`` ~line 42918
    (``ParallelStrategyExecutor``).
    """

    def __init__(
        self,
        *,
        timeout_seconds: float = 30.0,
        max_workers: int = 4,
        circuit_breaker: CircuitBreaker | None = None,
    ) -> None:
        self._timeout_seconds = timeout_seconds
        self._max_workers = max_workers
        self._circuit_breaker = circuit_breaker or CircuitBreaker()
        self._logger = logger.bind(component="ParallelStrategyExecutor")

    @property
    def circuit_breaker(self) -> CircuitBreaker:
        """Access the circuit breaker for inspection/reset."""
        return self._circuit_breaker

    def execute(
        self,
        allocations: list[tuple[ContextStrategy, float, int]],
        fragments: Sequence[ContextFragment],
        budget: ContextBudget,
    ) -> Sequence[ContextFragment]:
        """Execute strategies and return collected fragments."""
        if not allocations:
            return []

        # Filter out circuit-broken and zero-budget strategies
        active: list[tuple[ContextStrategy, float, int]] = []
        for strategy, confidence, allocated_tokens in allocations:
            if self._circuit_breaker.is_open(strategy.name):
                self._logger.warning(
                    "Strategy circuit-broken, skipping",
                    strategy=strategy.name,
                )
                continue
            if allocated_tokens <= 0:
                continue
            active.append((strategy, confidence, allocated_tokens))

        if not active:
            return []

        # For single strategy, avoid threading overhead
        if len(active) == 1:
            return self._execute_single(active[0], fragments)

        return self._execute_parallel(active, fragments)

    def _execute_single(
        self,
        allocation: tuple[ContextStrategy, float, int],
        fragments: Sequence[ContextFragment],
    ) -> list[ContextFragment]:
        """Execute a single strategy synchronously."""
        strategy, _confidence, allocated_tokens = allocation
        scoped_budget = ContextBudget(
            max_tokens=allocated_tokens,
            reserved_tokens=0,
        )
        start = time.monotonic()
        try:
            result = list(strategy.assemble(fragments, scoped_budget))
            elapsed = time.monotonic() - start
            self._circuit_breaker.record_success(strategy.name)
            self._logger.info(
                "Strategy executed",
                strategy=strategy.name,
                fragments_returned=len(result),
                elapsed_ms=round(elapsed * 1000, 1),
            )
            return result
        except Exception:
            elapsed = time.monotonic() - start
            self._circuit_breaker.record_failure(strategy.name)
            self._logger.warning(
                "Strategy failed",
                strategy=strategy.name,
                elapsed_ms=round(elapsed * 1000, 1),
                exc_info=True,
            )
            return []

    def _execute_parallel(
        self,
        active: list[tuple[ContextStrategy, float, int]],
        fragments: Sequence[ContextFragment],
    ) -> list[ContextFragment]:
        """Execute multiple strategies in parallel via ThreadPoolExecutor."""
        collected: list[ContextFragment] = []

        with ThreadPoolExecutor(
            max_workers=min(self._max_workers, len(active))
        ) as executor:
            future_to_name: dict[Any, str] = {}
            for strategy, _confidence, allocated_tokens in active:
                scoped_budget = ContextBudget(
                    max_tokens=allocated_tokens,
                    reserved_tokens=0,
                )
                future = executor.submit(
                    self._run_strategy, strategy, fragments, scoped_budget
                )
                future_to_name[future] = strategy.name

            for future in as_completed(future_to_name, timeout=self._timeout_seconds):
                name = future_to_name[future]
                try:
                    result = future.result(timeout=0)
                    self._circuit_breaker.record_success(name)
                    collected.extend(result)
                    self._logger.info(
                        "Strategy executed (parallel)",
                        strategy=name,
                        fragments_returned=len(result),
                    )
                except Exception:
                    self._circuit_breaker.record_failure(name)
                    self._logger.warning(
                        "Strategy failed (parallel)",
                        strategy=name,
                        exc_info=True,
                    )

        return collected

    @staticmethod
    def _run_strategy(
        strategy: ContextStrategy,
        fragments: Sequence[ContextFragment],
        budget: ContextBudget,
    ) -> list[ContextFragment]:
        """Invoke a single strategy — called from worker thread."""
        return list(strategy.assemble(fragments, budget))


# ---------------------------------------------------------------------------
# Timing helper
# ---------------------------------------------------------------------------


class StageTimings(BaseModel, frozen=True):
    """Timing data for each pipeline stage, in milliseconds."""

    strategy_selection_ms: float = Field(default=0.0, ge=0.0)
    budget_allocation_ms: float = Field(default=0.0, ge=0.0)
    strategy_execution_ms: float = Field(default=0.0, ge=0.0)
    deduplication_ms: float = Field(default=0.0, ge=0.0)
    depth_resolution_ms: float = Field(default=0.0, ge=0.0)
    scoring_ms: float = Field(default=0.0, ge=0.0)
    packing_ms: float = Field(default=0.0, ge=0.0)
    ordering_ms: float = Field(default=0.0, ge=0.0)
    preamble_generation_ms: float = Field(default=0.0, ge=0.0)
    total_ms: float = Field(default=0.0, ge=0.0)


# ---------------------------------------------------------------------------
# ContextAssemblyPipeline — Full 10-stage mediator
# ---------------------------------------------------------------------------


class ContextAssemblyPipeline(ACMSPipeline):
    """Full 10-stage ACMS context assembly pipeline mediator.

    Extends ``ACMSPipeline`` with production Phase 1 components:

    - ``ConfidenceWeightedSelector`` (replaces ``DefaultStrategySelector``)
    - ``ProportionalBudgetAllocator`` (replaces ``DefaultBudgetAllocator``)
    - ``ParallelStrategyExecutor`` (replaces ``DefaultStrategyExecutor``)

    Adds structured logging with per-stage timing metrics and exposes
    ``StageTimings`` on the most recent assembly result.

    Example::

        pipeline = ContextAssemblyPipeline()
        payload = pipeline.assemble(
            plan_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
            fragments=[frag1, frag2],
            budget=ContextBudget(max_tokens=2048),
        )
        timings = pipeline.last_timings

    Based on ``docs/specification.md`` ~line 42615.
    """

    def __init__(
        self,
        default_strategy: str = "relevance",
        *,
        settings: Settings | None = None,
        unit_of_work: UnitOfWork | None = None,
        event_bus: EventBus | None = None,
        # Phase 1 — Strategy Orchestration
        strategy_selector: StrategySelector | None = None,
        budget_allocator: BudgetAllocator | None = None,
        strategy_executor: StrategyExecutor | None = None,
        # Phase 2 — Fragment Fusion
        deduplicator: FragmentDeduplicator | None = None,
        depth_resolver: DetailDepthResolver | None = None,
        scorer: FragmentScorer | None = None,
        packer: BudgetPacker | None = None,
        orderer: FragmentOrderer | None = None,
        # Phase 3 — Context Finalization
        preamble_generator: PreambleGenerator | None = None,
        skeleton_compressor: SkeletonCompressor | None = None,
        # Phase 1 configuration
        min_useful_budget: int = 64,
        executor_timeout: float = 30.0,
        executor_max_workers: int = 4,
    ) -> None:
        # Build Phase 1 defaults if not provided
        effective_selector = strategy_selector or ConfidenceWeightedSelector()
        effective_allocator = budget_allocator or ProportionalBudgetAllocator(
            min_useful_budget=min_useful_budget,
        )
        effective_executor = strategy_executor or ParallelStrategyExecutor(
            timeout_seconds=executor_timeout,
            max_workers=executor_max_workers,
        )

        effective_skeleton_compressor = (
            skeleton_compressor or resolve_configured_skeleton_compressor()
        )

        super().__init__(
            default_strategy,
            settings=settings,
            unit_of_work=unit_of_work,
            strategy_selector=effective_selector,
            budget_allocator=effective_allocator,
            strategy_executor=effective_executor,
            deduplicator=deduplicator or DefaultDeduplicator(),
            depth_resolver=depth_resolver or DefaultDepthResolver(),
            scorer=scorer or DefaultScorer(),
            packer=packer or GreedyKnapsackPacker(),
            orderer=orderer or DefaultOrderer(),
            preamble_generator=preamble_generator or DefaultPreambleGenerator(),
            skeleton_compressor=effective_skeleton_compressor,
        )
        self._last_timings: StageTimings | None = None
        self._pipeline_logger = logger.bind(service="context_assembly_pipeline")
        self._event_bus = event_bus

    @property
    def last_timings(self) -> StageTimings | None:
        """Return timing data from the most recent ``assemble`` call."""
        return self._last_timings

    def assemble(
        self,
        plan_id: str,
        fragments: Sequence[ContextFragment],
        budget: ContextBudget,
        strategy: str | None = None,
        request: ContextRequest | None = None,
        context_view: ContextView | None = None,
        skeleton_ratio: float = 0.15,
        parent_fragments: tuple[ContextFragment, ...] | None = None,
    ) -> ContextPayload:
        """Assemble context with per-stage timing instrumentation.

        Overrides :meth:`ACMSPipeline.assemble` to add structured logging
        with per-stage millisecond timings.  When *context_view* is
        provided, byte-size limits are enforced as a pre-filter before
        strategy orchestration.

        When *parent_fragments* are provided, the ``SkeletonCompressor``
        is invoked during Phase 3 to compress them into a skeleton for
        child plan context inheritance.  The skeleton budget is computed
        as ``int(budget.available_tokens * skeleton_ratio)``.  The
        compressed fragments are included in the returned
        ``ContextPayload.skeleton_fragments``.
        """
        import re

        from cleveragents.domain.models.core.context_fragment import ULID_PATTERN

        if not re.match(ULID_PATTERN, plan_id):
            msg = f"plan_id must be a valid ULID, got {plan_id!r}"
            raise ValueError(msg)

        strategy_name = strategy or self._default_strategy
        if strategy_name not in self._strategies:
            msg = (
                f"Unknown strategy {strategy_name!r}. "
                f"Available: {', '.join(sorted(self._strategies))}"
            )
            raise ValueError(msg)

        # --- Pre-filter: byte-size budget enforcement ---
        fragments = self._apply_budget_enforcement(fragments, context_view)

        pipeline_start = time.monotonic()
        self._pipeline_logger.info(
            "Pipeline started",
            plan_id=plan_id,
            strategy=strategy_name,
            fragment_count=len(fragments),
            budget_tokens=budget.available_tokens,
        )

        # --- Phase 1: Strategy Orchestration ---
        all_strategies = list(self._strategies.values())
        resolved = self._strategies[strategy_name]

        t0 = time.monotonic()
        candidates = self._strategy_selector.select(
            all_strategies,
            {"strategy": strategy_name},
        )
        candidates = [(s, c) for s, c in candidates if s.name == strategy_name] or [
            (resolved, 1.0)
        ]
        selection_ms = (time.monotonic() - t0) * 1000

        t0 = time.monotonic()
        allocations = self._budget_allocator.allocate(
            candidates,
            budget.available_tokens,
            request,
        )
        allocation_ms = (time.monotonic() - t0) * 1000

        t0 = time.monotonic()
        ranked = self._strategy_executor.execute(allocations, fragments, budget)
        execution_ms = (time.monotonic() - t0) * 1000

        # --- Phase 2: Fragment Fusion ---
        t0 = time.monotonic()
        fused: Sequence[ContextFragment] = self._deduplicator.deduplicate(ranked)
        dedup_ms = (time.monotonic() - t0) * 1000

        t0 = time.monotonic()
        fused = self._depth_resolver.resolve(fused, budget.available_tokens)
        depth_ms = (time.monotonic() - t0) * 1000

        t0 = time.monotonic()
        fused = self._scorer.score(fused)
        score_ms = (time.monotonic() - t0) * 1000

        t0 = time.monotonic()
        fused = self._packer.pack(fused, budget)
        pack_ms = (time.monotonic() - t0) * 1000

        t0 = time.monotonic()
        fused = self._orderer.order(fused)
        order_ms = (time.monotonic() - t0) * 1000

        # --- Phase 3: Context Finalization ---
        t0 = time.monotonic()
        preamble = self._preamble_generator.generate(fused)
        preamble_ms = (time.monotonic() - t0) * 1000

        # Phase 3: Skeleton compression for child plan context inheritance.
        # Compute skeleton_budget from skeleton_ratio and invoke the compressor
        # when parent_fragments are provided.
        skeleton_budget = int(budget.available_tokens * skeleton_ratio)
        skeleton_frags: tuple[ContextFragment, ...] = ()
        if parent_fragments:
            skeleton_frags = self._skeleton_compressor.compress(
                parent_fragments,
                skeleton_budget,
            )
            self._pipeline_logger.info(
                "Skeleton compressed",
                plan_id=plan_id,
                skeleton_budget=skeleton_budget,
                skeleton_ratio=skeleton_ratio,
                input_fragments=len(parent_fragments),
                output_fragments=len(skeleton_frags),
            )

        total_ms = (time.monotonic() - pipeline_start) * 1000

        self._last_timings = StageTimings(
            strategy_selection_ms=round(selection_ms, 3),
            budget_allocation_ms=round(allocation_ms, 3),
            strategy_execution_ms=round(execution_ms, 3),
            deduplication_ms=round(dedup_ms, 3),
            depth_resolution_ms=round(depth_ms, 3),
            scoring_ms=round(score_ms, 3),
            packing_ms=round(pack_ms, 3),
            ordering_ms=round(order_ms, 3),
            preamble_generation_ms=round(preamble_ms, 3),
            total_ms=round(total_ms, 3),
        )

        final_fragments = tuple(fused)
        total_tokens = sum(f.token_count for f in final_fragments)
        available = budget.available_tokens
        budget_used = total_tokens / available if available > 0 else 0.0
        context_hash = compute_context_hash(final_fragments)
        provenance_map = build_provenance_map(final_fragments)

        self._pipeline_logger.info(
            "Pipeline complete",
            plan_id=plan_id,
            strategy=strategy_name,
            fragments_selected=len(final_fragments),
            total_tokens=total_tokens,
            budget_used=round(budget_used, 4),
            total_ms=round(total_ms, 3),
        )

        # Emit CONTEXT_BUILT event
        if self._event_bus is not None:
            try:
                self._event_bus.emit(
                    DomainEvent(
                        event_type=EventType.CONTEXT_BUILT,
                        plan_id=plan_id,
                        details={
                            "strategy": strategy_name,
                            "fragments_selected": len(final_fragments),
                            "total_tokens": total_tokens,
                            "budget_used": round(budget_used, 4),
                            "total_ms": round(total_ms, 3),
                        },
                    )
                )
            except Exception:
                self._pipeline_logger.warning(
                    "event_bus_emit_failed",
                    event_type="CONTEXT_BUILT",
                    plan_id=plan_id,
                    exc_info=True,
                )

        return ContextPayload(
            plan_id=plan_id,
            fragments=final_fragments,
            total_tokens=total_tokens,
            budget=budget,
            budget_used=round(min(budget_used, 1.0), 4),
            strategies_used=(strategy_name,),
            context_hash=context_hash,
            preamble=preamble,
            provenance_map=provenance_map,
            skeleton_fragments=skeleton_frags,
        )
