"""SlidingWindowStrategy — configurable sliding window context strategy.

Implements the ``ContextStrategy`` protocol for the ACMS pipeline.  A
sliding window strategy limits token usage by keeping only the most
recent N messages or tokens in context, which is critical for
long-running agent sessions that would otherwise exceed LLM context
limits.

The strategy supports two window modes:

- **messages** — keeps the most recent ``window_size`` non-system
  fragments.
- **tokens** — keeps the most recent fragments whose cumulative
  ``token_count`` does not exceed ``window_size``.

In both modes, fragments whose ``metadata["role"]`` is ``"system"``
are always preserved outside the sliding window and are never subject
to truncation.

The strategy is registered in the ``ACMSPipeline`` under the key
``"sliding_window"`` so it can be selected by name.

Based on ``docs/specification.md`` §25207 and issue #9995.
"""

from __future__ import annotations

import logging
from collections.abc import Sequence
from typing import Any, Literal

from cleveragents.application.services.acms_service import (
    StrategyCapabilities,
    _pack_budget,
)
from cleveragents.domain.models.core.context_fragment import (
    ContextBudget,
    ContextFragment,
)

logger = logging.getLogger(__name__)

# Valid window mode literals.
WindowMode = Literal["messages", "tokens"]
_VALID_MODES: frozenset[str] = frozenset({"messages", "tokens"})

# Metadata key used to identify system-prompt fragments.
_ROLE_KEY: str = "role"
_SYSTEM_ROLE: str = "system"


def _is_system_fragment(fragment: ContextFragment) -> bool:
    """Return True if *fragment* is a system-prompt fragment.

    A fragment is considered a system prompt when its ``metadata``
    contains ``{"role": "system"}``.
    """
    return fragment.metadata.get(_ROLE_KEY) == _SYSTEM_ROLE


class SlidingWindowStrategy:
    """Sliding window context strategy with configurable window size.

    Keeps only the most recent N messages or tokens in context,
    preserving system-prompt fragments unconditionally.

    Parameters
    ----------
    window_size:
        Number of messages (``window_mode="messages"``) or maximum
        cumulative token count (``window_mode="tokens"``) to retain.
        Must be a positive integer.
    window_mode:
        ``"messages"`` — keep the most recent ``window_size`` non-system
        fragments.
        ``"tokens"`` — keep the most recent non-system fragments whose
        cumulative ``token_count`` does not exceed ``window_size``.

    Raises
    ------
    ValueError
        If ``window_size`` is not a positive integer, or if
        ``window_mode`` is not one of ``"messages"`` or ``"tokens"``.

    Implements ``ContextStrategy`` protocol.
    """

    def __init__(
        self,
        *,
        window_size: int = 100,
        window_mode: WindowMode = "messages",
    ) -> None:
        if window_size <= 0:
            msg = f"window_size must be a positive integer, got {window_size!r}"
            raise ValueError(msg)
        if window_mode not in _VALID_MODES:
            msg = (
                f"window_mode must be one of {sorted(_VALID_MODES)!r}, "
                f"got {window_mode!r}"
            )
            raise ValueError(msg)
        self._window_size = window_size
        self._window_mode: WindowMode = window_mode

    @property
    def window_size(self) -> int:
        """Return the configured window size."""
        return self._window_size

    @property
    def window_mode(self) -> WindowMode:
        """Return the configured window mode (``"messages"`` or ``"tokens"``)."""
        return self._window_mode

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

    @property
    def capabilities(self) -> StrategyCapabilities:
        return StrategyCapabilities(
            supports_semantic_search=False,
            supports_graph_navigation=False,
            supports_temporal_archaeology=False,
            quality_score=0.5,
        )

    def can_handle(self, request: dict[str, Any]) -> float:
        """Return 0.5 confidence — general-purpose sliding window."""
        return 0.5

    def assemble(
        self,
        fragments: Sequence[ContextFragment],
        budget: ContextBudget,
    ) -> Sequence[ContextFragment]:
        """Apply the sliding window and return fragments within budget.

        Algorithm:

        1. Separate *fragments* into system-prompt fragments (always
           kept) and non-system fragments (subject to the window).
        2. Apply the sliding window to the non-system fragments,
           keeping only the most recent ones that fit within
           ``window_size``.
        3. Recombine system fragments with the windowed non-system
           fragments, preserving the original relative order.
        4. Apply the token budget via ``_pack_budget``.

        Parameters
        ----------
        fragments:
            All context fragments for the current session, in
            chronological order (oldest first).
        budget:
            Token budget for the assembled context.

        Returns
        -------
        Sequence[ContextFragment]
            The windowed and budget-constrained fragments.
        """
        if not fragments:
            return []

        # Partition into system and non-system fragments, preserving order.
        system_frags: list[ContextFragment] = []
        non_system_frags: list[ContextFragment] = []
        for frag in fragments:
            if _is_system_fragment(frag):
                system_frags.append(frag)
            else:
                non_system_frags.append(frag)

        # Apply the sliding window to non-system fragments.
        windowed_non_system = self._apply_window(non_system_frags)

        # Rebuild the combined list preserving original order.
        # We use the original fragment list as the ordering reference.
        windowed_ids: set[str] = {f.fragment_id for f in windowed_non_system}
        system_ids: set[str] = {f.fragment_id for f in system_frags}
        combined: list[ContextFragment] = [
            f
            for f in fragments
            if f.fragment_id in system_ids or f.fragment_id in windowed_ids
        ]

        logger.info(
            "SlidingWindow assembled context",
            extra={
                "window_size": self._window_size,
                "window_mode": self._window_mode,
                "total_input": len(fragments),
                "system_count": len(system_frags),
                "windowed_count": len(windowed_non_system),
                "combined_count": len(combined),
            },
        )

        return _pack_budget(combined, budget)

    def _apply_window(
        self,
        non_system_frags: list[ContextFragment],
    ) -> list[ContextFragment]:
        """Apply the sliding window to *non_system_frags*.

        Returns the most recent fragments that fit within the window,
        in their original (chronological) order.
        """
        if not non_system_frags:
            return []

        if self._window_mode == "messages":
            return self._apply_message_window(non_system_frags)
        # window_mode == "tokens"
        return self._apply_token_window(non_system_frags)

    def _apply_message_window(
        self,
        frags: list[ContextFragment],
    ) -> list[ContextFragment]:
        """Keep the most recent ``window_size`` fragments."""
        if len(frags) <= self._window_size:
            return frags
        # Slice from the end to keep the most recent N.
        return frags[-self._window_size :]

    def _apply_token_window(
        self,
        frags: list[ContextFragment],
    ) -> list[ContextFragment]:
        """Keep the most recent fragments within the token budget.

        Iterates from the most recent fragment backwards, accumulating
        token counts until the window is full.  Returns the selected
        fragments in their original chronological order.
        """
        selected: list[ContextFragment] = []
        accumulated = 0
        for frag in reversed(frags):
            if accumulated + frag.token_count <= self._window_size:
                selected.append(frag)
                accumulated += frag.token_count
            else:
                break
        # Reverse to restore chronological order.
        selected.reverse()
        return selected

    def explain(self) -> str:
        return (
            f"Sliding window strategy ({self._window_mode} mode, "
            f"window_size={self._window_size}). "
            "Keeps only the most recent messages or tokens in context, "
            "preserving system-prompt fragments unconditionally. "
            "Critical for long-running agent sessions that would otherwise "
            "exceed LLM context limits."
        )
