"""Skeleton compressor service for ACMS context inheritance.

The ``SkeletonCompressorService`` takes a collection of context
fragments from a parent plan and produces a compressed representation
suitable for propagation to child plans.  The compression is governed
by ``skeleton_budget`` — an absolute token count:

- **0** — no fragments are kept (empty result).
- **large value** — all fragments pass through if they fit.

Fragments are sorted by ``relevance_score`` in **descending** order
(highest first).  A stable secondary sort on ``fragment_id`` ensures
deterministic output for equal-relevance fragments.

The caller is responsible for converting a ``skeleton_ratio`` (fraction
of the total context budget) to an absolute ``skeleton_budget`` before
invoking ``compress()``.  For example::

    skeleton_budget = int(total_tokens * skeleton_ratio)
    compressed = service.compress(fragments, skeleton_budget)

Based on ``docs/specification.md`` ACMS Skeleton section.
"""

from __future__ import annotations

from cleveragents.application.services.acms_service import SkeletonCompressor
from cleveragents.domain.models.core.context_fragment import ContextFragment

# ---------------------------------------------------------------------------
# Service
# ---------------------------------------------------------------------------


class SkeletonCompressorService:
    """Compress context fragments for subplan context inheritance.

    Implements the ``SkeletonCompressor`` protocol defined in
    :mod:`cleveragents.application.services.acms_service`.  The service
    is stateless; all state lives in the arguments and the returned tuple.

    The caller is responsible for computing ``skeleton_budget`` from a
    ratio and the total available token count before invoking
    :meth:`compress`.
    """

    def compress(
        self,
        fragments: tuple[ContextFragment, ...],
        skeleton_budget: int,
    ) -> tuple[ContextFragment, ...]:
        """Compress *fragments* to fit within *skeleton_budget* tokens.

        Args:
            fragments: Context fragments to compress.  Each must
                have ``token_count >= 0`` and ``relevance_score`` in
                ``[0.0, 1.0]``.
            skeleton_budget: Maximum total token count for the returned
                fragments.  Must be a non-negative integer.  When zero
                an empty tuple is returned.

        Returns:
            A tuple of ``ContextFragment`` objects whose combined
            ``token_count`` does not exceed *skeleton_budget*, ordered
            by relevance descending then ``fragment_id`` ascending.

        Raises:
            TypeError: If *fragments* is not a tuple or *skeleton_budget*
                is not an integer.
            ValueError: If *skeleton_budget* is negative or any fragment
                has invalid fields.
        """
        # -- argument validation ------------------------------------------
        if not isinstance(fragments, tuple):
            raise TypeError(
                f"fragments must be a tuple, got {type(fragments).__name__}"
            )

        if not isinstance(skeleton_budget, int):
            raise TypeError(
                f"skeleton_budget must be an int, got {type(skeleton_budget).__name__}"
            )

        if skeleton_budget < 0:
            raise ValueError(f"skeleton_budget must be >= 0, got {skeleton_budget}")

        self._validate_fragments(fragments)

        if skeleton_budget == 0 or not fragments:
            return ()

        # -- stable sort: relevance desc, fragment_id asc -----------------
        sorted_fragments = sorted(
            fragments,
            key=lambda f: (-f.relevance_score, f.fragment_id),
        )

        # -- select fragments within budget -------------------------------
        kept = self._select_fragments(sorted_fragments, skeleton_budget)

        return tuple(kept)

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

    @staticmethod
    def _validate_fragments(fragments: tuple[ContextFragment, ...]) -> None:
        """Validate every fragment in the tuple.

        Raises:
            TypeError: If an element is not a ``ContextFragment``.
            ValueError: If a fragment has invalid field values.
        """
        for idx, frag in enumerate(fragments):
            if not isinstance(frag, ContextFragment):
                raise TypeError(
                    f"fragments[{idx}] must be a ContextFragment, "
                    f"got {type(frag).__name__}"
                )
            if frag.token_count < 0:
                raise ValueError(
                    f"fragments[{idx}].token_count must be >= 0, got {frag.token_count}"
                )
            if frag.relevance_score < 0.0 or frag.relevance_score > 1.0:
                raise ValueError(
                    f"fragments[{idx}].relevance_score must be in [0.0, 1.0], "
                    f"got {frag.relevance_score}"
                )
            if not frag.fragment_id:
                raise ValueError(f"fragments[{idx}].fragment_id must be non-empty")

    @staticmethod
    def _select_fragments(
        sorted_fragments: list[ContextFragment],
        budget: int,
    ) -> list[ContextFragment]:
        """Select which fragments to keep given the token *budget*.

        Fragments are added in relevance order (highest first) until the
        budget is exhausted.  At least one fragment is always included
        when the input is non-empty, even if it exceeds the budget, to
        ensure callers always receive some context.
        """
        kept: list[ContextFragment] = []
        used = 0
        for frag in sorted_fragments:
            if used + frag.token_count > budget and kept:
                # Already have at least one fragment and budget exceeded
                break
            kept.append(frag)
            used += frag.token_count
            if used >= budget:
                break

        return kept


# ---------------------------------------------------------------------------
# Structural subtype assertion — prevents future protocol drift
# ---------------------------------------------------------------------------
# This assertion verifies at import time that SkeletonCompressorService
# satisfies the SkeletonCompressor Protocol.  If the protocol signature
# changes and the service is not updated, this will raise AssertionError
# immediately rather than failing silently at runtime.

assert isinstance(SkeletonCompressorService(), SkeletonCompressor), (
    "SkeletonCompressorService does not satisfy the SkeletonCompressor protocol. "
    "Ensure compress(fragments: tuple[ContextFragment, ...], skeleton_budget: int) "
    "-> tuple[ContextFragment, ...] matches the protocol definition."
)
