"""ACMS v1 context assembly pipeline service.

Implements the Adaptive Context Management System pipeline that integrates
UKO (Unified Knowledge Ontology) and CRP (Context Request Protocol)
components with pluggable context strategies.  The pipeline assembles
``ContextFragment`` objects into a budget-constrained ``ContextPayload``
for actor consumption.

The spec defines a 10-component pluggable pipeline across 3 phases:

**Strategy Orchestration**: StrategySelector, BudgetAllocator, StrategyExecutor
**Fragment Fusion**: FragmentDeduplicator, DetailDepthResolver, FragmentScorer,
    BudgetPacker, FragmentOrderer
**Context Finalization**: PreambleGenerator, SkeletonCompressor

This v1 implementation provides default (pass-through) implementations for
all 10 components.  Production replacements will be wired in via the plugin
registry as the pipeline matures.

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

from __future__ import annotations

import re
from collections.abc import Sequence
from dataclasses import dataclass
from threading import local
from typing import TYPE_CHECKING, Any, ClassVar, Protocol, runtime_checkable

import structlog

if TYPE_CHECKING:
    from cleveragents.config.settings import Settings
    from cleveragents.infrastructure.database.unit_of_work import UnitOfWork
    from cleveragents.infrastructure.plugins.manager import PluginManager

from cleveragents.domain.models.acms.crp import (
    ContextRequest,
)
from cleveragents.domain.models.core.context_fragment import (
    ULID_PATTERN,
    ContextBudget,
    ContextFragment,
    ContextPayload,
    build_provenance_map,
    compute_context_hash,
)
from cleveragents.domain.models.core.context_policy import (
    BudgetEnforcementResult,
    ContextView,
    enforce_size_budget,
)

# Lazy import helper — resolved at runtime to avoid circular imports.
_GreedyKnapsackPacker: type | None = None

# Lazy import helpers for spec-required built-in strategies.
# These are imported lazily to avoid circular imports and to keep the
# acms_service module lightweight.
_SPEC_BUILTIN_STRATEGIES: dict[str, Any] | None = None


def _get_spec_builtin_strategies() -> dict[str, Any]:
    """Return the spec-required built-in strategy instances, importing lazily.

    These are the 6 strategies defined in ``strategy_stubs.py`` that
    implement the domain-model ``ContextStrategy`` protocol.  They are
    wrapped in ``SpecStrategyAdapter`` instances so they can be used
    with the ``ACMSPipeline``'s fragment-ranking interface.
    """
    global _SPEC_BUILTIN_STRATEGIES
    if _SPEC_BUILTIN_STRATEGIES is None:
        from cleveragents.domain.models.acms.strategy_stubs import (
            BUILTIN_STRATEGY_CLASSES,
        )

        _SPEC_BUILTIN_STRATEGIES = {cls().name: cls for cls in BUILTIN_STRATEGY_CLASSES}
    return _SPEC_BUILTIN_STRATEGIES


def _get_greedy_knapsack_packer_class() -> type:
    """Return the :class:`GreedyKnapsackPacker` class, importing lazily."""
    global _GreedyKnapsackPacker
    if _GreedyKnapsackPacker is None:
        from cleveragents.application.services.acms_phase2 import (
            GreedyKnapsackPacker,
        )

        _GreedyKnapsackPacker = GreedyKnapsackPacker
    return _GreedyKnapsackPacker


logger = structlog.get_logger(__name__)


# ---------------------------------------------------------------------------
# Strategy capabilities (spec ~line 25167)
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class StrategyCapabilities:
    """Capabilities declared by a context strategy."""

    supports_semantic_search: bool = False
    supports_graph_navigation: bool = False
    supports_temporal_archaeology: bool = False
    max_fragments: int | None = None
    quality_score: float = 1.0


# ---------------------------------------------------------------------------
# Context strategy protocol (spec ~line 25167)
# ---------------------------------------------------------------------------


@runtime_checkable
class ContextStrategy(Protocol):
    """Protocol for context strategies.

    Based on ``docs/specification.md`` ~line 25167.  Each strategy declares
    a ``name`` and ``capabilities``, can report its confidence for a request
    via ``can_handle``, produce fragments via ``assemble``, and explain its
    approach via ``explain``.
    """

    @property
    def name(self) -> str: ...

    @property
    def capabilities(self) -> StrategyCapabilities: ...

    def can_handle(self, request: dict[str, Any]) -> float:
        """Return confidence (0.0-1.0) that this strategy can handle *request*."""
        ...

    def assemble(
        self,
        fragments: Sequence[ContextFragment],
        budget: ContextBudget,
    ) -> Sequence[ContextFragment]:
        """Rank/filter *fragments* to fit within *budget*."""
        ...

    def explain(self) -> str:
        """Return a human-readable explanation of this strategy."""
        ...


# ---------------------------------------------------------------------------
# Built-in strategy implementations
# ---------------------------------------------------------------------------


def _pack_budget(
    sorted_frags: Sequence[ContextFragment],
    budget: ContextBudget,
) -> list[ContextFragment]:
    """Pack fragments into budget with early termination."""
    result: list[ContextFragment] = []
    total = 0
    available = budget.available_tokens
    for frag in sorted_frags:
        if total >= available:
            break
        if total + frag.token_count <= available:
            result.append(frag)
            total += frag.token_count
    return result


class RelevanceStrategy:
    """Rank fragments by relevance score (highest first), fitting budget."""

    @property
    def name(self) -> str:
        return "relevance"

    @property
    def capabilities(self) -> StrategyCapabilities:
        return StrategyCapabilities(supports_semantic_search=True)

    def can_handle(self, request: dict[str, Any]) -> float:
        return 0.8  # general-purpose, high confidence

    def assemble(
        self,
        fragments: Sequence[ContextFragment],
        budget: ContextBudget,
    ) -> Sequence[ContextFragment]:
        sorted_frags = sorted(
            fragments,
            key=lambda f: f.relevance_score,
            reverse=True,
        )
        return _pack_budget(sorted_frags, budget)

    def explain(self) -> str:
        return (
            "Ranks fragments by relevance score (highest first), packs within budget."
        )


class RecencyStrategy:
    """Rank fragments by creation time (most recent first), fitting budget."""

    @property
    def name(self) -> str:
        return "recency"

    @property
    def capabilities(self) -> StrategyCapabilities:
        return StrategyCapabilities(supports_temporal_archaeology=True)

    def can_handle(self, request: dict[str, Any]) -> float:
        return 0.6

    def assemble(
        self,
        fragments: Sequence[ContextFragment],
        budget: ContextBudget,
    ) -> Sequence[ContextFragment]:
        # Sort by datetime object (not string comparison)
        sorted_frags = sorted(
            fragments,
            key=lambda f: f.created_at,
            reverse=True,
        )
        return _pack_budget(sorted_frags, budget)

    def explain(self) -> str:
        return (
            "Ranks fragments by creation time (most recent first), packs within budget."
        )


_TIER_PRIORITY: dict[str, int] = {"hot": 0, "warm": 1, "cold": 2}


class TieredStrategy:
    """Rank fragments by tier priority (hot > warm > cold), then relevance.

    .. note::

       In v1 tiers are sort-priority labels, not storage tiers with
       retention policies.  Full hot/warm/cold storage-tier semantics
       (promotion, demotion, eviction) are implemented separately in
       ``ContextTierService``.
    """

    @property
    def name(self) -> str:
        return "tiered"

    @property
    def capabilities(self) -> StrategyCapabilities:
        return StrategyCapabilities()

    def can_handle(self, request: dict[str, Any]) -> float:
        return 0.7

    def assemble(
        self,
        fragments: Sequence[ContextFragment],
        budget: ContextBudget,
    ) -> Sequence[ContextFragment]:
        sorted_frags = sorted(
            fragments,
            key=lambda f: (
                _TIER_PRIORITY.get(f.tier, 99),
                -f.relevance_score,
            ),
        )
        return _pack_budget(sorted_frags, budget)

    def explain(self) -> str:
        return "Ranks by tier priority (hot > warm > cold), then relevance within tier."


# ---------------------------------------------------------------------------
# SpecStrategyAdapter — bridges spec-required strategies to ACMSPipeline
# ---------------------------------------------------------------------------


class SpecStrategyAdapter:
    """Adapter that wraps a spec-required ``ContextStrategy`` for use in
    ``ACMSPipeline``.

    The spec-required strategies (``strategy_stubs.py``) implement the
    domain-model ``ContextStrategy`` protocol with signature::

        assemble(request, backends, budget, plan_context) -> list[ContextFragment]

    The ``ACMSPipeline`` uses a different protocol with signature::

        assemble(fragments, budget) -> Sequence[ContextFragment]

    This adapter bridges the two by:
    - Accepting the pipeline's ``(fragments, budget)`` call signature.
    - Ranking/filtering the pre-fetched fragments by relevance score
      (since the spec strategy's backends are not available in this context).
    - Exposing the spec strategy's ``name``, ``capabilities``, and
      ``explain()`` to the pipeline.

    When the pipeline is refactored to pass ``ContextRequest`` and
    ``BackendSet`` directly (issue #3491), this adapter can be removed
    and the spec strategies can be registered directly.
    """

    def __init__(self, spec_strategy: Any) -> None:
        self._spec_strategy = spec_strategy

    @property
    def name(self) -> str:
        return self._spec_strategy.name

    @property
    def capabilities(self) -> StrategyCapabilities:
        # Bridge: map spec StrategyCapabilities to pipeline StrategyCapabilities.
        spec_caps = self._spec_strategy.capabilities
        return StrategyCapabilities(
            supports_semantic_search=getattr(spec_caps, "uses_vector", False),
            supports_graph_navigation=getattr(spec_caps, "uses_graph", False),
            supports_temporal_archaeology=getattr(spec_caps, "uses_temporal", False),
            quality_score=getattr(spec_caps, "quality_score", 0.5),
        )

    def can_handle(self, request: dict[str, Any]) -> float:
        """Return the spec strategy's quality score as confidence."""
        return self._spec_strategy.capabilities.quality_score

    def assemble(
        self,
        fragments: Sequence[ContextFragment],
        budget: ContextBudget,
    ) -> Sequence[ContextFragment]:
        """Rank pre-fetched fragments by relevance score within budget.

        Since the spec strategy requires backends that are not available
        in the pipeline's fragment-ranking phase, this adapter falls back
        to relevance-based ranking of the pre-fetched fragments.
        """
        sorted_frags = sorted(
            fragments,
            key=lambda f: f.relevance_score,
            reverse=True,
        )
        return _pack_budget(sorted_frags, budget)

    def explain(self) -> str:
        return self._spec_strategy.explain()


# ---------------------------------------------------------------------------
# Phase 1 — Strategy Orchestration protocols (spec §42630-42636)
# ---------------------------------------------------------------------------


class StrategySelector(Protocol):
    """Decide which strategies to invoke for a given request.

    Spec: ``StrategySelectorProtocol`` — calls ``can_handle()`` on all
    registered strategies; returns ``(strategy, confidence)`` pairs.
    """

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


class BudgetAllocator(Protocol):
    """Distribute the token budget across selected strategies.

    Spec: ``BudgetAllocatorProtocol`` — returns
    ``(strategy, confidence, allocated_tokens)`` triples.
    """

    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."""
        ...


class StrategyExecutor(Protocol):
    """Control how strategies are invoked (parallelism, timeouts).

    Spec: ``StrategyExecutorProtocol`` — executes strategies and
    collects all resulting fragments.
    """

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


# ---------------------------------------------------------------------------
# Phase 2 — Fragment Fusion protocols (spec §42638-42646)
# ---------------------------------------------------------------------------


class FragmentDeduplicator(Protocol):
    """Remove duplicate fragments."""

    def deduplicate(
        self,
        fragments: Sequence[ContextFragment],
    ) -> Sequence[ContextFragment]: ...


class DetailDepthResolver(Protocol):
    """Resolve detail depth levels."""

    def resolve(
        self,
        fragments: Sequence[ContextFragment],
        budget: int = 0,
    ) -> Sequence[ContextFragment]: ...


class FragmentScorer(Protocol):
    """Score fragments for relevance."""

    def score(
        self,
        fragments: Sequence[ContextFragment],
    ) -> Sequence[ContextFragment]: ...


class BudgetPacker(Protocol):
    """Pack fragments within budget."""

    def pack(
        self,
        fragments: Sequence[ContextFragment],
        budget: ContextBudget,
    ) -> Sequence[ContextFragment]: ...


class FragmentOrderer(Protocol):
    """Order fragments for final output."""

    def order(
        self,
        fragments: Sequence[ContextFragment],
    ) -> Sequence[ContextFragment]: ...


# ---------------------------------------------------------------------------
# Spec-aligned Protocol aliases (spec §44794-44856)
# ---------------------------------------------------------------------------
# The specification uses "Protocol" suffix for all pipeline component
# interfaces (e.g. ``FragmentDeduplicatorProtocol``).  These aliases
# expose the canonical spec names alongside the shorter runtime names so
# that code referencing either form works correctly.

FragmentDeduplicatorProtocol = FragmentDeduplicator
DetailDepthResolverProtocol = DetailDepthResolver
FragmentScorerProtocol = FragmentScorer
BudgetPackerProtocol = BudgetPacker
FragmentOrdererProtocol = FragmentOrderer


# ---------------------------------------------------------------------------
# Phase 3 — Context Finalization protocols (spec §42648-42653)
# ---------------------------------------------------------------------------


class PreambleGenerator(Protocol):
    """Generate a preamble summary.

    Spec: ``PreambleGeneratorProtocol`` — generates a structured
    preamble listing included resources, strategies used, budget
    utilization.
    """

    def generate(
        self,
        fragments: Sequence[ContextFragment],
    ) -> str | None: ...


class SkeletonCompressor(Protocol):
    """Compress parent context into a skeleton for child plan inheritance.

    Spec: ``SkeletonCompressorProtocol`` — re-renders parent context
    fragments at reduced depth (0-1) to fit within ``skeleton_budget``.
    """

    def compress(
        self,
        fragments: tuple[ContextFragment, ...],
        skeleton_budget: int,
    ) -> tuple[ContextFragment, ...]:
        """Return compressed fragments fitting within *skeleton_budget*."""
        ...


# ---------------------------------------------------------------------------
# Default (pass-through) component implementations
# ---------------------------------------------------------------------------


class DefaultStrategySelector:
    """Select strategies based on confidence scores.

    Evaluates all strategies via ``can_handle`` and returns those with
    confidence > 0.0, sorted by confidence descending.  The pipeline
    passes all registered strategies so a custom ``StrategySelector``
    can implement multi-strategy fusion or confidence-weighted selection.
    """

    def select(
        self,
        strategies: Sequence[ContextStrategy],
        request: dict[str, Any],
    ) -> list[tuple[ContextStrategy, float]]:
        scored = [(s, s.can_handle(request)) for s in strategies]
        return [
            (s, c)
            for s, c in sorted(scored, key=lambda x: x[1], reverse=True)
            if c > 0.0
        ]


class DefaultBudgetAllocator:
    """Distribute the token budget proportionally across candidates.

    Each candidate receives a share of ``total_budget`` proportional to
    ``confidence * quality_score`` per spec §42689.  When only one
    candidate is present (v1 norm), it receives the full budget.  With
    multiple candidates the sum of allocated tokens never exceeds
    ``total_budget``.
    """

    @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]]:
        if not candidates:
            return []
        n = len(candidates)
        weights = [self._weighted_score(s, c) for s, c in candidates]
        total_weight = sum(weights)
        if total_weight <= 0:
            # Equal split when all weighted scores are zero.
            share = total_budget // n
            remainder = total_budget - share * n
            # Distribute leftover tokens one-per-candidate (largest-remainder).
            return [
                (s, c, share + (1 if i < remainder else 0))
                for i, (s, c) in enumerate(candidates)
            ]
        # Proportional allocation with largest-remainder distribution so
        # the full budget is used and no tokens are silently lost.
        raw = [(total_budget * w / total_weight) for w in weights]
        floors = [int(r) for r in raw]
        remainder = total_budget - sum(floors)
        # Award one extra token to candidates with the largest fractional parts.
        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
        return [(s, c, floors[i]) for i, (s, c) in enumerate(candidates)]


class DefaultStrategyExecutor:
    """Execute strategies synchronously using their allocated budgets.

    Each strategy receives a ``ContextBudget`` scoped to the tokens
    assigned by the ``BudgetAllocator``.  Results from all strategies
    are concatenated.  Strategies with zero-token allocations are
    skipped (per spec §42927 ``min_useful_budget`` exclusion rule).

    Aggregate budget enforcement is deferred to the downstream
    ``BudgetPacker`` component (spec §42937).  The ``budget`` parameter
    is accepted for interface compatibility and future use.

    Future versions will invoke strategies in parallel with timeouts
    and circuit breaking.
    """

    def execute(
        self,
        allocations: list[tuple[ContextStrategy, float, int]],
        fragments: Sequence[ContextFragment],
        budget: ContextBudget,
    ) -> Sequence[ContextFragment]:
        if not allocations:
            return []
        collected: list[ContextFragment] = []
        for strategy, _confidence, allocated_tokens in allocations:
            if allocated_tokens <= 0:
                continue
            scoped_budget = ContextBudget(
                max_tokens=allocated_tokens,
                reserved_tokens=0,
            )
            collected.extend(strategy.assemble(fragments, scoped_budget))
        return collected


class DefaultDeduplicator:
    """No-op deduplicator — returns fragments unchanged."""

    def deduplicate(
        self,
        fragments: Sequence[ContextFragment],
    ) -> Sequence[ContextFragment]:
        return fragments


class DefaultDepthResolver:
    """No-op depth resolver — returns fragments unchanged."""

    def resolve(
        self,
        fragments: Sequence[ContextFragment],
        budget: int = 0,
    ) -> Sequence[ContextFragment]:
        return fragments


class DefaultScorer:
    """No-op scorer — returns fragments unchanged."""

    def score(
        self,
        fragments: Sequence[ContextFragment],
    ) -> Sequence[ContextFragment]:
        return fragments


class DefaultOrderer:
    """No-op orderer — returns fragments unchanged."""

    def order(
        self,
        fragments: Sequence[ContextFragment],
    ) -> Sequence[ContextFragment]:
        return fragments


class DefaultPreambleGenerator:
    """No-op preamble generator — returns None."""

    def generate(
        self,
        fragments: Sequence[ContextFragment],
    ) -> str | None:
        return None


class DefaultBudgetPacker:
    """No-op budget packer — returns fragments unchanged.

    In v1 budget packing is handled within each strategy's ``assemble``
    method via ``_pack_budget``.  Production implementations will use
    greedy knapsack with depth fallback per spec §42937.
    """

    def pack(
        self,
        fragments: Sequence[ContextFragment],
        budget: ContextBudget,
    ) -> Sequence[ContextFragment]:
        return fragments


class DefaultSkeletonCompressor:
    """No-op skeleton compressor — returns fragments unchanged.

    Production implementations will re-render fragments at depth 0-1
    to fit within ``skeleton_budget`` for child plan inheritance.
    """

    def compress(
        self,
        fragments: tuple[ContextFragment, ...],
        skeleton_budget: int,
    ) -> tuple[ContextFragment, ...]:
        return fragments


# ---------------------------------------------------------------------------
# Pipeline
# ---------------------------------------------------------------------------


class ACMSPipeline:
    """ACMS v1 context assembly pipeline.

    Assembles ``ContextFragment`` objects into a budget-constrained
    ``ContextPayload`` using pluggable context strategies and the
    10-component pipeline architecture defined in the spec.

    Example::

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

    BUILTIN_STRATEGIES: ClassVar[dict[str, type[ContextStrategy]]] = {
        "relevance": RelevanceStrategy,  # type: ignore[dict-item]
        "recency": RecencyStrategy,  # type: ignore[dict-item]
        "tiered": TieredStrategy,  # type: ignore[dict-item]
    }

    def __init__(
        self,
        default_strategy: str = "relevance",
        *,
        settings: Settings | None = None,
        unit_of_work: UnitOfWork | None = None,
        plugin_manager: PluginManager | 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,
    ) -> None:
        # DI placeholders — not referenced in v1 methods.  Stored here to
        # match the ``DecisionService`` DI pattern so that future milestones
        # can wire configuration (e.g. default budget, strategy allow-lists)
        # and persistence without changing the constructor signature.
        self._settings = settings
        self._unit_of_work = unit_of_work

        self._strategies: dict[str, ContextStrategy] = {
            name: cls() for name, cls in self.BUILTIN_STRATEGIES.items()
        }
        # Register the 6 spec-required built-in strategies via adapters.
        # These strategies implement the domain-model ContextStrategy protocol
        # (strategy_stubs.py) and are wrapped in SpecStrategyAdapter instances
        # so they can be used with the ACMSPipeline's fragment-ranking interface.
        # When issue #3491 is resolved (protocol consolidation), these adapters
        # can be replaced with direct registrations.
        for spec_name, spec_cls in _get_spec_builtin_strategies().items():
            if spec_name not in self._strategies:
                self._strategies[spec_name] = SpecStrategyAdapter(spec_cls())  # type: ignore[assignment]

        if default_strategy not in self._strategies:
            msg = (
                f"Unknown strategy {default_strategy!r}. "
                f"Available: {', '.join(sorted(self._strategies))}"
            )
            raise ValueError(msg)
        self._default_strategy = default_strategy
        self._logger = logger.bind(service="acms_pipeline")
        self._enforcement_result_local = local()

        # All 10 pipeline components (default to pass-through stubs)
        self._strategy_selector = strategy_selector or DefaultStrategySelector()
        self._budget_allocator = budget_allocator or DefaultBudgetAllocator()
        self._strategy_executor = strategy_executor or DefaultStrategyExecutor()
        self._deduplicator = deduplicator or DefaultDeduplicator()
        self._depth_resolver = depth_resolver or DefaultDepthResolver()
        self._scorer = scorer or DefaultScorer()
        self._packer = packer or _get_greedy_knapsack_packer_class()()
        self._orderer = orderer or DefaultOrderer()
        self._preamble_generator = preamble_generator or DefaultPreambleGenerator()
        self._skeleton_compressor = skeleton_compressor or DefaultSkeletonCompressor()

        # Plugin extension point integration (#939).
        # Store the plugin manager reference so discovered context.*
        # extension points can be queried at runtime.
        self._plugin_manager = plugin_manager
        self._discover_context_extensions()

    # ------------------------------------------------------------------
    # Plugin extension point discovery
    # ------------------------------------------------------------------

    def _discover_context_extensions(self) -> None:
        """Query context.* extension points from the PluginManager.

        Logs discovered extension points for observability.  Plugin
        implementations are not auto-activated here — they are wired
        on demand when a matching strategy or pipeline component is
        requested.
        """
        if self._plugin_manager is None:
            return

        context_eps = [
            ep
            for ep in self._plugin_manager.list_extension_points()
            if ep.name.startswith("context.")
        ]
        if context_eps:
            self._logger.info(
                "context_extensions_discovered",
                count=len(context_eps),
                names=[ep.name for ep in context_eps],
            )

    @property
    def context_extension_points(self) -> list[str]:
        """Return names of registered context.* extension points.

        Returns an empty list if no plugin manager is wired.
        """
        if self._plugin_manager is None:
            return []
        return [
            ep.name
            for ep in self._plugin_manager.list_extension_points()
            if ep.name.startswith("context.")
        ]

    # ------------------------------------------------------------------
    # Budget enforcement helper
    # ------------------------------------------------------------------

    def _apply_budget_enforcement(
        self,
        fragments: Sequence[ContextFragment],
        context_view: ContextView | None,
    ) -> Sequence[ContextFragment]:
        """Pre-filter *fragments* against the byte-size limits in *context_view*.

        Stores the :class:`BudgetEnforcementResult` on the pipeline so
        callers can inspect it via :attr:`last_enforcement_result`.

        Returns the (potentially filtered) fragment sequence.
        """
        if context_view is not None:
            enforcement = enforce_size_budget(fragments, context_view)
            self._enforcement_result_local.result = enforcement
            if enforcement.violations:
                accepted_set = set(enforcement.accepted)
                fragments = [f for f in fragments if f.fragment_id in accepted_set]
                self._logger.info(
                    "Size budget enforcement applied",
                    accepted=len(enforcement.accepted),
                    violations=len(enforcement.violations),
                    total_bytes=enforcement.total_size,
                )
        else:
            self._enforcement_result_local.result = None
        return fragments

    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 fragments into a budget-constrained payload.

        When *context_view* is provided its ``max_file_size`` and
        ``max_total_size`` limits are enforced as a pre-filter before
        strategy orchestration.  Violations are logged and the
        ``BudgetEnforcementResult`` is stored on the pipeline instance
        as ``last_enforcement_result`` for caller inspection.

        When *parent_fragments* are provided, the ``SkeletonCompressor``
        is invoked to compress them into a skeleton for child plan
        inheritance.  The skeleton budget is computed as
        ``int(budget.available_tokens * skeleton_ratio)``.  The
        compressed fragments are included in the returned
        ``ContextPayload.skeleton_fragments``.
        """
        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)

        self._logger.info(
            "Assembling context",
            plan_id=plan_id,
            strategy=strategy_name,
            fragment_count=len(fragments),
            budget_tokens=budget.available_tokens,
        )

        # Phase 1: Strategy Orchestration
        # Pass the full registry so custom selectors can see all strategies.
        # The request dict includes the caller's chosen strategy name so the
        # selector (or pipeline) can narrow down when needed.
        all_strategies = list(self._strategies.values())
        resolved = self._strategies[strategy_name]
        candidates = self._strategy_selector.select(
            all_strategies,
            {"strategy": strategy_name},
        )
        # Ensure the explicitly-requested strategy is among candidates.
        # If the selector returned multiple, keep only the requested one
        # for v1 single-strategy execution; future multi-strategy support
        # will remove this filter.  Compare by name rather than identity
        # so custom selectors that wrap/copy strategy objects still match.
        candidates = [(s, c) for s, c in candidates if s.name == strategy_name] or [
            (resolved, 1.0)
        ]
        allocations = self._budget_allocator.allocate(
            candidates,
            budget.available_tokens,
            request,
        )
        ranked = self._strategy_executor.execute(allocations, fragments, budget)

        # Phase 2: Fragment Fusion pipeline
        fused = self._deduplicator.deduplicate(ranked)
        fused = self._depth_resolver.resolve(fused, budget.available_tokens)
        fused = self._scorer.score(fused)
        fused = self._packer.pack(fused, budget)
        fused = self._orderer.order(fused)

        # Phase 3: Context Finalization
        preamble = self._preamble_generator.generate(fused)

        # 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._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),
            )

        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._logger.info(
            "Context assembled",
            plan_id=plan_id,
            strategy=strategy_name,
            fragments_selected=len(final_fragments),
            total_tokens=total_tokens,
            budget_used=round(budget_used, 4),
        )

        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,
        )

    @property
    def last_enforcement_result(self) -> BudgetEnforcementResult | None:
        """Return the calling thread's most recent enforcement result.

        The result is stored in thread-local state so concurrent callers
        using the same ``ACMSPipeline`` instance do not overwrite each
        other's values.
        """
        result = getattr(self._enforcement_result_local, "result", None)
        return result if isinstance(result, BudgetEnforcementResult) else None

    def register_strategy(
        self,
        name: str,
        strategy: ContextStrategy,
    ) -> None:
        """Register a custom context strategy instance."""
        self._strategies[name] = strategy
        self._logger.info("Registered strategy", name=name)
