"""
cortex/core/token_budget.py — Token Budget Manager (Module 9 / M9).

Manages token allocation for memory injection into the agent's context window.
This is the "resource scheduler" for memory — it ensures that the most important
memories (cornerstones) are always included, while lower-priority content
competes for the remaining budget.

Priority order (highest → lowest):
    CORNERSTONE (reserved, guaranteed) → SESSION → SEMANTIC → PROCEDURAL

Key design decisions:
    - Cornerstones get a RESERVED allocation that's held regardless of actual
      content size. This ensures cornerstone space is always available.
    - Non-cornerstone layers get soft caps as fractions of the remaining budget:
      SESSION (40%) → SEMANTIC (35%) → PROCEDURAL (25%).
    - Token counting uses tiktoken's cl100k_base encoding (Claude/GPT-4 family).
    - Items within each layer are included greedily from the front (assumed to be
      pre-sorted by relevance from RetrievalPipeline).

Dependencies:
    - tiktoken (cl100k_base encoding for token counting)
    - cortex.types.BudgetAllocation, cortex.types.MemoryLayer

Used by:
    - RetrievalPipeline (M8) — calls count_tokens() and create_budget()
    - CortexPlugin (M13) — assemble_context() uses allocate_layers()

Usage::

    manager = TokenBudgetManager(config)
    budget = manager.create_budget()

    result = manager.allocate_layers(
        layers={
            MemoryLayer.CORNERSTONE: ["cornerstone memory text..."],
            MemoryLayer.SESSION: ["recent conversation..."],
            MemoryLayer.SEMANTIC: ["long-term fact..."],
            MemoryLayer.PROCEDURAL: ["how to do X..."],
        }
    )
"""

from __future__ import annotations

import logging
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple

import tiktoken

from cortex.types import (
    BudgetAllocation,
    CompressedItem,
    CompressionLevel,
    InjectionSlot,
    MemoryLayer,
)

logger = logging.getLogger(__name__)

# Tiktoken encoding used for token counting.
# cl100k_base is used by Claude/GPT-4 family.
_ENCODING_NAME = "cl100k_base"


@dataclass
class TokenBudget:
    """Live budget state tracking allocation across memory layers.

    Created via TokenBudgetManager.create_budget().
    """

    total: int
    """Total token budget (default 4000)."""

    reserved_cornerstones: int
    """Tokens unconditionally reserved for cornerstones."""

    allocated: Dict[str, int] = field(default_factory=dict)
    """Tokens allocated per category key so far."""

    def remaining(self) -> int:
        """Tokens still available for non-cornerstone allocation."""
        used = self.reserved_cornerstones + sum(self.allocated.values())
        return max(0, self.total - used)

    def allocate(self, category: str, tokens: int) -> bool:
        """Attempt to allocate *tokens* to *category*.

        Returns True if the allocation fits within the remaining budget,
        False if the budget would be exceeded (allocation is NOT recorded).
        """
        if tokens <= self.remaining():
            self.allocated[category] = self.allocated.get(category, 0) + tokens
            return True
        return False

    def used(self) -> int:
        """Total tokens consumed including cornerstone reservation."""
        return self.reserved_cornerstones + sum(self.allocated.values())

    def utilization(self) -> float:
        """Budget utilization as a fraction [0.0, 1.0]."""
        if self.total == 0:
            return 0.0
        return self.used() / self.total


# ---------------------------------------------------------------------------
# Minimal config duck-type (avoids circular import from cortex.config)
# ---------------------------------------------------------------------------

class _ConfigProtocol:
    """Structural type expected from CortexConfig."""
    total_memory_token_budget: int
    cornerstone_token_budget: int


class TokenBudgetManager:
    """Count tokens and manage budget allocation across memory types.

    Responsibilities:
    - Accurate token counting via tiktoken (cl100k_base)
    - Per-layer budget allocation with cornerstone reservation guarantee
    - Truncation of lowest-priority content when budget is exceeded
    - Configurable total budget and per-layer soft limits
    """

    # Default budgets (tokens). Override via CortexConfig or constructor args.
    DEFAULT_TOTAL_BUDGET: int = 4000
    DEFAULT_CORNERSTONE_BUDGET: int = 800

    # Default fractional soft caps for non-cornerstone layers.
    # Applied as a fraction of the non-cornerstone budget (total - cornerstones).
    DEFAULT_LAYER_FRACTIONS: Dict[str, float] = {
        MemoryLayer.SESSION.value: 0.40,      # 40% of non-cornerstone budget
        MemoryLayer.SEMANTIC.value: 0.35,     # 35%
        MemoryLayer.PROCEDURAL.value: 0.25,   # 25%
    }

    # --- Issue #374: Hard context-window budget fractions ---
    # Within the total LLM context window the memory block occupies the
    # memory_fraction slice; user/tool/reasoning gets user_fraction; the rest
    # is held as a reserve for dynamic updates / long answers.
    DEFAULT_MEMORY_FRACTION: float = 0.25   # 20-30% per spec
    DEFAULT_USER_FRACTION: float = 0.55     # 50-60% per spec
    DEFAULT_RESERVE_FRACTION: float = 0.20  # 10-20% per spec

    # Default slot priority order (within the memory budget).
    DEFAULT_SLOT_PRIORITIES: List[str] = InjectionSlot.default_priority_names()

    # --- Issue #373: Compression level token caps (approximate, without LLM) ---
    # Used by compress_to_fit() to estimate how many tokens each level uses.
    # These are soft guidance values; actual counts come from tiktoken.
    # Audit fix: adjusted caps to match spec ratios:
    #   EVIDENCE→COMPACT: 80/200 = 40%  (spec: ~40%)
    #   COMPACT→POINTER:  12/80  = 15%  (spec: ~15%)
    _COMPRESSION_MAX_TOKENS: Dict[CompressionLevel, int] = {
        CompressionLevel.POINTER: 12,    # Very short label (~15% of COMPACT)
        CompressionLevel.COMPACT: 80,    # One bullet summary (~40% of EVIDENCE)
        CompressionLevel.EVIDENCE: 200,  # 2-4 bullets with support
    }

    def __init__(
        self,
        config: Optional[_ConfigProtocol] = None,
        *,
        total_budget: Optional[int] = None,
        cornerstone_budget: Optional[int] = None,
        layer_fractions: Optional[Dict[str, float]] = None,
        memory_fraction: Optional[float] = None,
        user_fraction: Optional[float] = None,
        reserve_fraction: Optional[float] = None,
        slot_priorities: Optional[List[str]] = None,
    ) -> None:
        """Initialise the manager.

        Args:
            config: CortexConfig instance (reads total_memory_token_budget and
                    cornerstone_token_budget from it).  If None, defaults are used.
            total_budget: Override total token budget (takes precedence over config).
            cornerstone_budget: Override cornerstone reservation.
            layer_fractions: Dict mapping MemoryLayer.value → fraction of
                             non-cornerstone budget.  Must sum to ≤ 1.0.
            memory_fraction: Fraction of total context for memory/compiler blocks
                             (Issue #374).  Default 0.25.
            user_fraction: Fraction for user input, tool results, reasoning room.
                           Default 0.55.
            reserve_fraction: Reserve fraction for dynamic updates / long answers.
                              Default 0.20.
            slot_priorities: Ordered list of InjectionSlot value strings defining
                             slot allocation priority (Issue #374).
        """
        if total_budget is not None:
            self._total_budget = total_budget
        elif config is not None:
            self._total_budget = config.total_memory_token_budget
        else:
            self._total_budget = self.DEFAULT_TOTAL_BUDGET

        if cornerstone_budget is not None:
            self._cornerstone_budget = cornerstone_budget
        elif config is not None:
            self._cornerstone_budget = config.cornerstone_token_budget
        else:
            self._cornerstone_budget = self.DEFAULT_CORNERSTONE_BUDGET

        if self._cornerstone_budget > self._total_budget:
            raise ValueError(
                f"cornerstone_budget ({self._cornerstone_budget}) exceeds "
                f"total_budget ({self._total_budget})"
            )

        self._layer_fractions: Dict[str, float] = (
            layer_fractions if layer_fractions is not None
            else dict(self.DEFAULT_LAYER_FRACTIONS)
        )

        # --- Issue #374: context-window hard budget fractions ---
        if memory_fraction is not None:
            self._memory_fraction = memory_fraction
        elif config is not None and hasattr(config, "token_budget_memory_fraction"):
            self._memory_fraction = config.token_budget_memory_fraction
        else:
            self._memory_fraction = self.DEFAULT_MEMORY_FRACTION

        if user_fraction is not None:
            self._user_fraction = user_fraction
        elif config is not None and hasattr(config, "token_budget_user_fraction"):
            self._user_fraction = config.token_budget_user_fraction
        else:
            self._user_fraction = self.DEFAULT_USER_FRACTION

        if reserve_fraction is not None:
            self._reserve_fraction = reserve_fraction
        elif config is not None and hasattr(config, "token_budget_reserve_fraction"):
            self._reserve_fraction = config.token_budget_reserve_fraction
        else:
            self._reserve_fraction = self.DEFAULT_RESERVE_FRACTION

        if slot_priorities is not None:
            self._slot_priorities = slot_priorities
        elif config is not None and hasattr(config, "token_budget_slot_priorities"):
            self._slot_priorities = config.token_budget_slot_priorities
        else:
            self._slot_priorities = list(self.DEFAULT_SLOT_PRIORITIES)

        # Audit fix: validate fractions are in [0.0, 1.0] — negative fractions
        # would produce negative token counts.
        for name, value in [
            ("memory_fraction", self._memory_fraction),
            ("user_fraction", self._user_fraction),
            ("reserve_fraction", self._reserve_fraction),
        ]:
            if not (0.0 <= value <= 1.0):
                raise ValueError(
                    f"{name} must be in [0.0, 1.0], got {value}"
                )

        # Warn if fractions don't sum to approximately 1.0
        frac_sum = self._memory_fraction + self._user_fraction + self._reserve_fraction
        if abs(frac_sum - 1.0) > 0.05:
            logger.warning(
                "Context window fractions sum to %.3f (expected ~1.0): "
                "memory=%.3f, user=%.3f, reserve=%.3f",
                frac_sum, self._memory_fraction, self._user_fraction, self._reserve_fraction,
            )

        # Initialise tiktoken encoder (cl100k_base — Claude/GPT-4 family).
        try:
            self.encoder = tiktoken.get_encoding(_ENCODING_NAME)
        except Exception as exc:  # pragma: no cover
            logger.warning("tiktoken encoding %s unavailable: %s", _ENCODING_NAME, exc)
            raise

    # ------------------------------------------------------------------
    # Token counting
    # ------------------------------------------------------------------

    def count_tokens(self, text: str) -> int:
        """Return the exact token count for *text* using tiktoken."""
        if not text:
            return 0
        return len(self.encoder.encode(text))

    def count_tokens_batch(self, texts: List[str]) -> List[int]:
        """Return token counts for a list of texts."""
        return [self.count_tokens(t) for t in texts]

    # ------------------------------------------------------------------
    # Budget creation
    # ------------------------------------------------------------------

    def create_budget(self, total: Optional[int] = None) -> TokenBudget:
        """Create a fresh TokenBudget with the configured allocations.

        Args:
            total: Override total token budget for this specific budget instance.
                   Defaults to the value set at construction time.
        """
        total_tokens = total if total is not None else self._total_budget
        cornerstone_reserved = min(self._cornerstone_budget, total_tokens)
        return TokenBudget(
            total=total_tokens,
            reserved_cornerstones=cornerstone_reserved,
            allocated={},
        )

    # ------------------------------------------------------------------
    # Soft cap helpers
    # ------------------------------------------------------------------

    def layer_soft_cap(self, layer: MemoryLayer, total: Optional[int] = None) -> int:
        """Return the soft token cap for a non-cornerstone *layer*.

        The cap is a fraction of the non-cornerstone budget.
        """
        total_tokens = total if total is not None else self._total_budget
        non_cornerstone = max(0, total_tokens - self._cornerstone_budget)
        fraction = self._layer_fractions.get(layer.value, 0.0)
        return int(non_cornerstone * fraction)

    # ------------------------------------------------------------------
    # Allocation
    # ------------------------------------------------------------------

    def allocate_layers(
        self,
        layers: Dict[MemoryLayer, List[str]],
        total: Optional[int] = None,
    ) -> BudgetAllocation:
        """Allocate budget across memory layers respecting priority order.

        Args:
            layers: Mapping of MemoryLayer → list of content strings to inject.
            total: Override total budget for this allocation pass.

        Returns:
            BudgetAllocation with per-layer token counts and any truncated content.

        Behaviour:
        - Cornerstones always get their full reserved allocation (guaranteed).
        - Remaining layers are filled in priority order: SESSION → SEMANTIC → PROCEDURAL.
        - If a layer's content exceeds its remaining budget, items are dropped from
          the tail (lowest-relevance items last, consistent with ranked retrieval).
        - Truncated token counts are recorded in BudgetAllocation.truncated.
        """
        total_tokens = total if total is not None else self._total_budget
        budget = self.create_budget(total_tokens)

        allocations: Dict[str, int] = {}
        truncated: Dict[str, int] = {}

        # --- Pass 1: Cornerstones (guaranteed, up to reserved amount) ---
        cornerstone_texts = layers.get(MemoryLayer.CORNERSTONE, [])
        cornerstone_tokens = 0
        cornerstone_truncated = 0

        for text in cornerstone_texts:
            t = self.count_tokens(text)
            if cornerstone_tokens + t <= self._cornerstone_budget:
                cornerstone_tokens += t
            else:
                cornerstone_truncated += t
                logger.debug(
                    "Cornerstone item truncated (exceeded reserved %d tokens)",
                    self._cornerstone_budget,
                )

        if cornerstone_tokens > 0:
            allocations[MemoryLayer.CORNERSTONE.value] = cornerstone_tokens
        if cornerstone_truncated > 0:
            truncated[MemoryLayer.CORNERSTONE.value] = cornerstone_truncated

        # Update budget's cornerstone reservation to actual usage
        # (we still hold reserved_cornerstones as the ceiling)

        # --- Pass 2: Remaining layers in priority order ---
        non_cornerstone_layers = [
            l for l in MemoryLayer.by_priority()
            if l != MemoryLayer.CORNERSTONE
        ]

        # Available tokens = total - cornerstone_reserved (not actual usage —
        # the reservation is held regardless of actual cornerstone content size).
        available = max(0, total_tokens - self._cornerstone_budget)

        for layer in non_cornerstone_layers:
            texts = layers.get(layer, [])
            if not texts:
                continue

            soft_cap = self.layer_soft_cap(layer, total_tokens)
            layer_tokens = 0
            layer_truncated = 0

            for text in texts:
                t = self.count_tokens(text)
                # soft_cap == 0 means the layer has no budget allocation
                fits_in_layer = soft_cap > 0 and (layer_tokens + t) <= soft_cap
                fits_in_budget = (layer_tokens + t) <= available

                if fits_in_layer and fits_in_budget:
                    layer_tokens += t
                else:
                    layer_truncated += t

            if layer_tokens > 0:
                allocations[layer.value] = layer_tokens
                available -= layer_tokens

            if layer_truncated > 0:
                truncated[layer.value] = layer_truncated
                logger.debug(
                    "Layer %s truncated %d tokens (budget exhausted or soft cap hit)",
                    layer.value, layer_truncated,
                )

        return BudgetAllocation(
            total_budget=total_tokens,
            cornerstone_reserved=self._cornerstone_budget,
            allocations=allocations,
            truncated=truncated,
        )

    # ------------------------------------------------------------------
    # Truncation helpers
    # ------------------------------------------------------------------

    def truncate_to_budget(
        self,
        text: str,
        max_tokens: int,
        *,
        truncation_suffix: str = "…",
    ) -> str:
        """Truncate *text* to fit within *max_tokens* tokens.

        Appends *truncation_suffix* when truncation occurs.
        Uses tiktoken for accurate token-level truncation.
        """
        if max_tokens <= 0:
            return ""

        tokens = self.encoder.encode(text)
        if len(tokens) <= max_tokens:
            return text

        suffix_tokens = self.encoder.encode(truncation_suffix)
        keep = max_tokens - len(suffix_tokens)
        if keep <= 0:
            return truncation_suffix

        truncated = self.encoder.decode(tokens[:keep])
        return truncated + truncation_suffix

    def fit_items_to_budget(
        self,
        items: List[str],
        max_tokens: int,
    ) -> tuple[List[str], int]:
        """Select as many items as possible from *items* within *max_tokens*.

        Items are taken in order (highest relevance first); excess items are
        dropped entirely (no partial truncation of individual items).

        Returns:
            (selected_items, total_tokens_used)
        """
        selected: List[str] = []
        used = 0

        for item in items:
            t = self.count_tokens(item)
            if used + t <= max_tokens:
                selected.append(item)
                used += t
            else:
                break  # Remaining items won't fit either (greedy from front)

        return selected, used

    # ------------------------------------------------------------------
    # Issue #374 — Slot-based allocation
    # ------------------------------------------------------------------

    def context_window_budgets(self, context_window: int) -> Dict[str, int]:
        """Split a total LLM context window into memory / user / reserve buckets.

        Args:
            context_window: Total tokens available in the LLM context window.

        Returns:
            Dict with keys ``memory``, ``user``, and ``reserve`` mapping to
            token counts computed from the configured fractions.  The sum
            equals *context_window* (any rounding remainder goes to ``user``).

        Note:
            Audit fix: ``reserve_fraction`` is now used explicitly in the
            calculation.  The ``user`` bucket absorbs any rounding remainder
            so the three buckets always sum to exactly *context_window*.
        """
        memory_tokens = int(context_window * self._memory_fraction)
        # Audit fix: actually USE self._reserve_fraction instead of deriving it
        reserve_tokens = int(context_window * self._reserve_fraction)
        # User absorbs rounding remainder so total == context_window
        user_tokens = context_window - memory_tokens - reserve_tokens
        return {
            "memory": memory_tokens,
            "user": max(0, user_tokens),
            "reserve": max(0, reserve_tokens),
        }

    def allocate_slots(
        self,
        total_budget: int,
        slots_with_items: Dict[InjectionSlot, List[str]],
    ) -> Dict[str, int]:
        """Allocate *total_budget* tokens across injection slots by priority.

        Slots are allocated in priority order (TASK_CONTEXT first, EPISODIC
        last). Each slot greedily consumes as many of its items as will fit
        in the remaining budget.  There are no per-slot soft caps — the
        highest-priority slot wins the full budget if it needs it.

        Args:
            total_budget: Total tokens available for all slots combined.
            slots_with_items: Mapping of InjectionSlot → list of text items.
                              Items within each slot are assumed to be sorted
                              from most to least relevant.

        Returns:
            Dict mapping InjectionSlot.value → tokens allocated to that slot.
            Slots with no items are omitted.

        Example::

            manager = TokenBudgetManager(total_budget=1000)
            result = manager.allocate_slots(
                total_budget=500,
                slots_with_items={
                    InjectionSlot.TASK_CONTEXT: ["active task text..."],
                    InjectionSlot.EPISODIC: ["old memory 1", "old memory 2"],
                },
            )
            # result["task_context"] is ≤ 500; result["episodic"] gets leftover
        """
        if total_budget <= 0:
            return {}

        # Sort slots by priority using configured order, falling back to enum order.
        slot_order: List[InjectionSlot] = []
        seen: set[InjectionSlot] = set()

        for slot_name in self._slot_priorities:
            try:
                slot = InjectionSlot(slot_name)
            except ValueError:
                logger.warning("Unknown slot_priority value %r — skipping", slot_name)
                continue
            if slot in slots_with_items and slot not in seen:
                slot_order.append(slot)
                seen.add(slot)

        # Append any slots present in the input but not in slot_priorities
        for slot in InjectionSlot.by_priority():
            if slot in slots_with_items and slot not in seen:
                slot_order.append(slot)
                seen.add(slot)

        remaining = total_budget
        result: Dict[str, int] = {}

        for slot in slot_order:
            items = slots_with_items.get(slot, [])
            if not items or remaining <= 0:
                continue

            slot_tokens = 0
            for item in items:
                t = self.count_tokens(item)
                if slot_tokens + t <= remaining:
                    slot_tokens += t
                else:
                    break  # Greedy — skip remaining items in this slot too

            if slot_tokens > 0:
                result[slot.value] = slot_tokens
                remaining -= slot_tokens
                logger.debug(
                    "Slot %s allocated %d tokens (%d remaining)",
                    slot.value, slot_tokens, remaining,
                )

        return result

    # ------------------------------------------------------------------
    # Issue #373 — Bundle compression
    # ------------------------------------------------------------------

    def compress_to_fit(
        self,
        items: List[str],
        target_tokens: int,
        min_compression: CompressionLevel = CompressionLevel.COMPACT,
    ) -> List[CompressedItem]:
        """Fit *items* into *target_tokens* by selecting appropriate compression levels.

        Compression auto-selection strategy:
        - If the item fits at EVIDENCE level → use EVIDENCE.
        - If budget is tight → downgrade to COMPACT (truncate to compact cap).
        - If budget is very tight → downgrade to POINTER (truncate to pointer cap).
        - Items are processed in order (highest priority first).
        - Items whose POINTER representation still won't fit are omitted.

        The ``min_compression`` argument sets a floor — the function will
        never use a compression level *less* compressed than specified.
        For example, ``min_compression=COMPACT`` prevents EVIDENCE even
        when budget allows it.

        Args:
            items: List of text strings to compress, ordered by priority
                   (most important first).
            target_tokens: Maximum total tokens for all returned items.
            min_compression: Minimum compression level to apply.  Items will
                             be compressed to at least this level even if the
                             budget would permit a lower level.

        Returns:
            List of :class:`CompressedItem` objects, each with the selected
            compression level, token count, and (possibly truncated) content.
            Items that cannot fit even at POINTER level are omitted.
        """
        if target_tokens <= 0:
            return []

        result: List[CompressedItem] = []
        remaining = target_tokens

        # Audit fix: removed three dead-code variables (candidate_levels,
        # allowed_levels, allowed_levels_asc) — compression logic uses the
        # cascade steps below, not these lists.

        pointer_max = self._COMPRESSION_MAX_TOKENS[CompressionLevel.POINTER]
        compact_max = self._COMPRESSION_MAX_TOKENS[CompressionLevel.COMPACT]
        evidence_max = self._COMPRESSION_MAX_TOKENS[CompressionLevel.EVIDENCE]

        for item in items:
            if remaining <= 0:
                break

            original_tokens = self.count_tokens(item)

            # --- min_compression=POINTER: force maximum compression always ---
            # When the caller requires pointer-level compression (e.g. for very
            # low-priority items or batch budget trimming), skip all other levels.
            if min_compression == CompressionLevel.POINTER:
                cap = min(pointer_max, remaining)
                if cap <= 0:
                    continue
                compressed_text = self.truncate_to_budget(item, cap)
                compressed_tokens = self.count_tokens(compressed_text)
                if compressed_tokens > remaining:
                    continue  # Cannot fit at all — skip.
                result.append(CompressedItem(
                    content=compressed_text,
                    compression=CompressionLevel.POINTER,
                    token_count=compressed_tokens,
                    original_tokens=original_tokens,
                ))
                remaining -= compressed_tokens
                continue

            # --- Natural compression cascade (min_compression != POINTER) ---
            #
            # Step 1: Try EVIDENCE — use the full item if it fits without
            #         truncation (both within the budget and within evidence_max).
            #         Level = EVIDENCE.
            #         Audit fix: skip EVIDENCE when min_compression == COMPACT.
            #
            # Step 2: If EVIDENCE doesn't fit (or is blocked), truncate to
            #         min(compact_max, remaining).
            #         Assign level by output size:
            #           output ≤ pointer_max → POINTER
            #           output ≤ compact_max → COMPACT
            #
            # Step 3: If compact truncation still doesn't fit, try pointer cap.

            chosen_level: Optional[CompressionLevel] = None
            chosen_content: Optional[str] = None
            chosen_tokens: int = 0

            # Step 1: Full item at EVIDENCE level (only if min_compression allows it)
            evidence_allowed = min_compression.value >= CompressionLevel.EVIDENCE.value
            if evidence_allowed and original_tokens <= remaining and original_tokens <= evidence_max:
                chosen_level = CompressionLevel.EVIDENCE
                chosen_content = item
                chosen_tokens = original_tokens

            # Step 2: COMPACT truncation
            if chosen_level is None:
                cap = min(compact_max, remaining)
                if cap > 0:
                    compressed_text = self.truncate_to_budget(item, cap)
                    compressed_tokens = self.count_tokens(compressed_text)
                    if compressed_tokens <= remaining:
                        if compressed_tokens <= pointer_max:
                            chosen_level = CompressionLevel.POINTER
                        else:
                            chosen_level = CompressionLevel.COMPACT
                        chosen_content = compressed_text
                        chosen_tokens = compressed_tokens

            # Step 3: POINTER truncation (last resort)
            if chosen_level is None:
                cap = min(pointer_max, remaining)
                if cap > 0:
                    compressed_text = self.truncate_to_budget(item, cap)
                    compressed_tokens = self.count_tokens(compressed_text)
                    if compressed_tokens <= remaining:
                        chosen_level = CompressionLevel.POINTER
                        chosen_content = compressed_text
                        chosen_tokens = compressed_tokens

            if chosen_level is None or chosen_content is None:
                # Cannot fit even at POINTER — skip this item.
                logger.debug(
                    "compress_to_fit: item cannot fit at any compression level "
                    "(remaining=%d, original_tokens=%d) — omitting",
                    remaining, original_tokens,
                )
                continue

            result.append(CompressedItem(
                content=chosen_content,
                compression=chosen_level,
                token_count=chosen_tokens,
                original_tokens=original_tokens,
            ))
            remaining -= chosen_tokens

        return result

    # ------------------------------------------------------------------
    # Introspection
    # ------------------------------------------------------------------

    @property
    def total_budget(self) -> int:
        """Hard ceiling for ALL memory content tokens."""
        return self._total_budget

    @property
    def cornerstone_budget(self) -> int:
        """Tokens reserved exclusively for cornerstone injection."""
        return self._cornerstone_budget

    @property
    def memory_fraction(self) -> float:
        """Fraction of the context window allocated to memory blocks."""
        return self._memory_fraction

    @property
    def user_fraction(self) -> float:
        """Fraction of the context window allocated to user input / tools."""
        return self._user_fraction

    @property
    def reserve_fraction(self) -> float:
        """Fraction of the context window held as reserve for dynamic updates."""
        return self._reserve_fraction

    @property
    def slot_priorities(self) -> List[str]:
        """Ordered list of injection slot names, highest priority first."""
        return list(self._slot_priorities)

    def __repr__(self) -> str:
        return (
            f"TokenBudgetManager("
            f"total={self._total_budget}, "
            f"cornerstone_reserved={self._cornerstone_budget})"
        )
