"""Runtime policy mixin for the Context Tier Service.

Extracted from ``context_tiers.py`` to keep modules under the 500-line
guideline.  Provides auto-promotion, staleness enforcement, budget
enforcement, and event emission methods that are mixed into
``ContextTierService``.

All methods assume access to ``_hot``, ``_warm``, ``_cold`` tier stores,
``_budget``, ``_promotion_threshold``, ``_hot_ttl``, ``_warm_ttl``,
``_event_bus``, and the ``promote``, ``demote``, ``_find_fragment``
helpers defined on the host class.

Based on ``docs/specification.md`` ACMS tier sections and issue #821
(runtime promotion/demotion/eviction).
"""

from __future__ import annotations

from datetime import UTC, datetime, timedelta

import structlog

from cleveragents.domain.models.acms.tiers import (
    ContextTier,
    TierBudget,
    TieredFragment,
)
from cleveragents.infrastructure.events.models import DomainEvent
from cleveragents.infrastructure.events.protocol import EventBus
from cleveragents.infrastructure.events.types import EventType

logger = structlog.get_logger(__name__)


class TierRuntimeMixin:
    """Mixin providing runtime tier management for ``ContextTierService``.

    Requires the host class to provide:

    - ``_hot``, ``_warm``, ``_cold``: ``dict[str, TieredFragment]``
    - ``_budget``: ``TierBudget``
    - ``_promotion_threshold``: ``int``
    - ``_hot_ttl``: ``timedelta``
    - ``_warm_ttl``: ``timedelta``
    - ``_event_bus``: ``EventBus | None``
    - ``promote(fragment_id: str) -> TieredFragment | None``
    - ``demote(fragment_id: str) -> TieredFragment | None``
    - ``_find_fragment(fragment_id: str) -> TieredFragment | None``
    """

    # Declared for type checkers; actual attrs live on the host class.
    _hot: dict[str, TieredFragment]
    _warm: dict[str, TieredFragment]
    _cold: dict[str, TieredFragment]
    _budget: TierBudget
    _promotion_threshold: int
    _hot_ttl: timedelta
    _warm_ttl: timedelta
    _event_bus: EventBus | None

    def promote(self, fragment_id: str) -> TieredFragment | None: ...
    def demote(self, fragment_id: str) -> TieredFragment | None: ...
    def _find_fragment(self, fragment_id: str) -> TieredFragment | None: ...

    # ------------------------------------------------------------------
    # Staleness enforcement (runtime — issue #821)
    # ------------------------------------------------------------------

    def enforce_staleness(self) -> list[str]:
        """Enforce time-based demotion across all tiers.

        Inspects every fragment's ``last_accessed`` timestamp:

        * Hot fragments older than ``hot_ttl`` → demoted to warm.
        * Warm fragments older than ``warm_ttl`` → demoted to cold.

        Each pass only considers fragments that were already present
        in that tier before this method was called — fragments freshly
        demoted from a higher tier are not immediately re-demoted.

        .. note::

           The specification's tier table describes hot-tier retention
           as *"Until resource removed"*.  The ``hot_ttl`` controls
           tier **placement** (demotion to warm), not data retention
           (deletion).  Demoted fragments continue to exist in lower
           tiers, consistent with the spec's retention semantics.

        Returns a list of fragment IDs that were demoted.
        """
        now = datetime.now(tz=UTC)
        demoted_ids: list[str] = []

        # Snapshot warm tier IDs before hot demotion so we don't
        # double-demote fragments in the same pass.
        warm_before: set[str] = set(self._warm.keys())

        # Hot → warm demotion
        hot_cutoff = now - self._hot_ttl
        stale_hot = [
            fid for fid, frag in self._hot.items() if frag.last_accessed < hot_cutoff
        ]
        for fid in stale_hot:
            self.demote(fid)
            demoted_ids.append(fid)
            logger.info(
                "tier.staleness_demoted",
                fragment_id=fid,
                from_tier=ContextTier.HOT.value,
                to_tier=ContextTier.WARM.value,
            )

        # Warm → cold demotion (only fragments that were warm BEFORE this pass)
        warm_cutoff = now - self._warm_ttl
        stale_warm = [
            fid
            for fid in warm_before
            if fid in self._warm and self._warm[fid].last_accessed < warm_cutoff
        ]
        for fid in stale_warm:
            self.demote(fid)
            demoted_ids.append(fid)
            logger.info(
                "tier.staleness_demoted",
                fragment_id=fid,
                from_tier=ContextTier.WARM.value,
                to_tier=ContextTier.COLD.value,
            )

        return demoted_ids

    # ------------------------------------------------------------------
    # Auto-promotion (runtime — issue #821)
    # ------------------------------------------------------------------

    def _maybe_auto_promote(
        self,
        fragment_id: str,
        fragment: TieredFragment,
    ) -> None:
        """Promote *fragment* if its access count meets the threshold.

        Called by :meth:`get` after touching the fragment.  Only
        promotes one tier at a time (cold → warm, warm → hot).
        The access counter is reset after each successful promotion
        so the fragment must accumulate fresh accesses before it can
        be promoted again.
        """
        if fragment.access_count >= self._promotion_threshold:
            promoted = self.promote(fragment_id)
            if promoted is not None:
                # Reset counter so the fragment must accumulate
                # fresh accesses before the next promotion.
                promoted.access_count = 0
                logger.info(
                    "tier.auto_promoted",
                    fragment_id=fragment_id,
                    access_count=fragment.access_count,
                    new_tier=promoted.tier.value,
                )

    def _re_fetch_after_promotion(
        self,
        fragment_id: str,
        fallback: TieredFragment,
    ) -> TieredFragment:
        """Return the current state of *fragment_id* after possible promotion.

        After auto-promotion the fragment may have moved to a different
        store, so we re-look it up.  Falls back to *fallback* if the
        fragment is not found (should not happen).
        """
        found = self._find_fragment(fragment_id)
        return found if found is not None else fallback

    # ------------------------------------------------------------------
    # Budget enforcement (runtime — issue #821)
    # ------------------------------------------------------------------

    def _enforce_hot_budget(self) -> None:
        """Evict LRU hot-tier fragments when the token budget is exceeded.

        Called automatically by :meth:`store` and :meth:`promote` after
        inserting a fragment into the hot tier.

        Complexity: **O(n + n*k)** where *n* is the number of hot-tier
        fragments and *k* is the number of evictions.  Token tracking
        uses incremental subtraction (O(1) per eviction), but each
        eviction selects the LRU fragment via ``min()`` over the
        remaining entries (O(n) per eviction).
        """
        budget_tokens = self._budget.max_tokens_hot
        total_tokens = sum(f.token_count for f in self._hot.values())
        while total_tokens > budget_tokens and self._hot:
            # Evict the single oldest fragment
            oldest_id = min(
                self._hot.keys(),
                key=lambda fid: self._hot[fid].last_accessed,
            )
            evicted_tokens = self._hot[oldest_id].token_count
            del self._hot[oldest_id]
            total_tokens -= evicted_tokens
            self._emit_tier_event(
                EventType.TIER_EVICTED,
                oldest_id,
                from_tier=ContextTier.HOT,
                to_tier=None,
            )
            logger.info(
                "tier.budget_evicted",
                fragment_id=oldest_id,
                hot_tokens=total_tokens + evicted_tokens,
                budget=budget_tokens,
            )

    # ------------------------------------------------------------------
    # Event emission (runtime — issue #821)
    # ------------------------------------------------------------------

    def _emit_tier_event(
        self,
        event_type: EventType,
        fragment_id: str,
        from_tier: ContextTier,
        to_tier: ContextTier | None,
    ) -> None:
        """Emit a tier transition event via the event bus (if available).

        Emission is best-effort: a failing event bus does not break
        the tier operation in progress.
        """
        if self._event_bus is None:
            return
        try:
            details: dict[str, str | None] = {
                "fragment_id": fragment_id,
                "from_tier": from_tier.value,
                "to_tier": to_tier.value if to_tier is not None else None,
            }
            event = DomainEvent(
                event_type=event_type,
                details=details,
            )
            self._event_bus.emit(event)
        except Exception:
            logger.warning(
                "tier.event_emission_failed",
                event_type=event_type.value,
                fragment_id=fragment_id,
                exc_info=True,
            )
