"""Context Tier Service for hot/warm/cold fragment management.

Core tier lifecycle: store, get, promote, demote, evict, metrics.
Runtime logic (auto-promotion, staleness enforcement, budget-based
eviction, event emission) is provided by ``TierRuntimeMixin`` in
``tier_runtime.py``.

Scope enforcement methods live in ``scoped_tiers.py`` and are mixed
in via ``ScopedTierMixin``.

Based on ``docs/specification.md`` ACMS tier sections, issue #208,
issue #193 (scoped backend view filtering), and issue #821
(runtime promotion/demotion/eviction).
"""

from __future__ import annotations

from datetime import UTC, datetime, timedelta

import structlog

from cleveragents.application.services.scoped_tiers import ScopedTierMixin
from cleveragents.application.services.tier_runtime import TierRuntimeMixin
from cleveragents.config.settings import Settings
from cleveragents.domain.models.acms.scoped_view import (
    ScopedBackendView,
)
from cleveragents.domain.models.acms.tiers import (
    ActorContextView,
    ActorRole,
    ContextTier,
    TierBudget,
    TieredFragment,
    TierMetrics,
)
from cleveragents.infrastructure.events.protocol import EventBus
from cleveragents.infrastructure.events.types import EventType

logger = structlog.get_logger(__name__)

# ---------------------------------------------------------------------------
# Default budget when settings are not provided
# ---------------------------------------------------------------------------

_DEFAULT_MAX_TOKENS_HOT = 8000
_DEFAULT_MAX_DECISIONS_WARM = 500
_DEFAULT_MAX_DECISIONS_COLD = 5000

# ---------------------------------------------------------------------------
# Default runtime policy values
# ---------------------------------------------------------------------------

_DEFAULT_PROMOTION_THRESHOLD = 5
_DEFAULT_HOT_TTL_HOURS = 24
_DEFAULT_WARM_TTL_HOURS = 24

# Maximum content length kept after cold-tier summarisation
_COLD_SUMMARY_MAX_CHARS = 200


class ContextTierService(TierRuntimeMixin, ScopedTierMixin):
    """Manage context fragments across hot/warm/cold tiers.

    Runtime behaviour (issue #821):

    * **Auto-promotion on access**: When ``get()`` is called and the
      fragment's ``access_count`` reaches the promotion threshold, the
      fragment is automatically promoted one tier up.
    * **Staleness enforcement**: ``enforce_staleness()`` inspects every
      fragment's ``last_accessed`` timestamp and demotes stale fragments
      (hot → warm after ``hot_ttl``, warm → cold after ``warm_ttl``).
    * **Budget enforcement on store**: ``store()`` enforces
      ``TierBudget.max_tokens_hot`` — when storing a fragment in the hot
      tier would exceed the budget, the least-recently-used fragment(s) are
      automatically evicted until the budget is met.  If a single fragment
      exceeds the entire hot-tier budget it is redirected to the warm tier.
    * **Event emission**: Tier transitions (promote, demote, evict) emit
      ``DomainEvent`` instances through the optional ``EventBus``.
      Event emission is best-effort; a failing bus does not break tier
      operations.

    Scope enforcement (``get_scoped``, ``get_scoped_by_resource``,
    ``validate_fragment_scope``, ``store_with_scope_check``,
    ``get_scoped_metrics``) is provided by ``ScopedTierMixin``.

    Runtime policy (``enforce_staleness``, ``_maybe_auto_promote``,
    ``_re_fetch_after_promotion``, ``_enforce_hot_budget``,
    ``_emit_tier_event``) is provided by ``TierRuntimeMixin``.

    .. note::

       This service is designed for **single-threaded** use.  The
       in-memory tier stores are plain ``dict`` instances without
       synchronisation.  Concurrent callers must coordinate externally.
    """

    def __init__(
        self,
        settings: Settings | None = None,
        event_bus: EventBus | None = None,
    ) -> None:
        budget = _budget_from_settings(settings)
        self._budget = budget
        self._event_bus = event_bus

        # --- runtime policy from settings ----------------------------------
        self._promotion_threshold: int = _promotion_threshold_from_settings(
            settings,
        )
        self._hot_ttl: timedelta = _hot_ttl_from_settings(settings)
        self._warm_ttl: timedelta = _warm_ttl_from_settings(settings)

        # --- tier stores ---------------------------------------------------
        self._hot: dict[str, TieredFragment] = {}
        self._warm: dict[str, TieredFragment] = {}
        self._cold: dict[str, TieredFragment] = {}

        # --- metrics -------------------------------------------------------
        self._hot_hit = 0
        self._hot_miss = 0
        self._warm_hit = 0
        self._warm_miss = 0

    # ------------------------------------------------------------------
    # Store
    # ------------------------------------------------------------------

    def store(self, fragment: TieredFragment) -> None:
        """Store a fragment in the tier indicated by ``fragment.tier``.

        If the fragment already exists in any tier it is moved to the
        requested tier.  When storing in the hot tier, budget enforcement
        is applied: if total hot-tier tokens would exceed
        ``TierBudget.max_tokens_hot``, the least-recently-used fragments
        are evicted until the budget is satisfied.

        If a single fragment's ``token_count`` exceeds the entire
        hot-tier budget, the fragment is redirected to the **warm** tier
        instead of being silently evicted after insertion.

        Args:
            fragment: The fragment to store.

        Raises:
            ValueError: If ``fragment.fragment_id`` is empty.
        """
        if not fragment.fragment_id:
            raise ValueError("fragment_id must be non-empty")

        # Remove from other tiers first
        self._remove_from_all(fragment.fragment_id)

        if fragment.tier == ContextTier.HOT:
            if fragment.token_count > self._budget.max_tokens_hot:
                logger.warning(
                    "tier.oversized_fragment_redirected",
                    fragment_id=fragment.fragment_id,
                    token_count=fragment.token_count,
                    budget=self._budget.max_tokens_hot,
                    target_tier=ContextTier.WARM.value,
                )
                redirected = fragment.model_copy(
                    update={"tier": ContextTier.WARM},
                )
                self._warm[fragment.fragment_id] = redirected
                self._emit_tier_event(
                    EventType.TIER_DEMOTED,
                    fragment.fragment_id,
                    from_tier=ContextTier.HOT,
                    to_tier=ContextTier.WARM,
                )
            else:
                self._hot[fragment.fragment_id] = fragment
                self._enforce_hot_budget()
        elif fragment.tier == ContextTier.WARM:
            self._warm[fragment.fragment_id] = fragment
        else:
            self._cold[fragment.fragment_id] = fragment

    # ------------------------------------------------------------------
    # Retrieve
    # ------------------------------------------------------------------

    def get(self, fragment_id: str) -> TieredFragment | None:
        """Retrieve a fragment from any tier, updating access metadata.

        When a fragment's ``access_count`` reaches the promotion
        threshold, it is automatically promoted one tier up
        (cold → warm, warm → hot).

        Returns ``None`` when the fragment is not found.
        """
        if fragment_id in self._hot:
            self._hot_hit += 1
            frag = self._hot[fragment_id]
            return self._touch(frag)

        self._hot_miss += 1

        if fragment_id in self._warm:
            self._warm_hit += 1
            frag = self._warm[fragment_id]
            touched = self._touch(frag)
            self._maybe_auto_promote(fragment_id, touched)
            return self._re_fetch_after_promotion(fragment_id, touched)

        self._warm_miss += 1

        if fragment_id in self._cold:
            touched = self._touch(self._cold[fragment_id])
            self._maybe_auto_promote(fragment_id, touched)
            return self._re_fetch_after_promotion(fragment_id, touched)

        return None

    def get_all_fragments(self) -> list[TieredFragment]:
        """Return all fragments across hot/warm/cold tiers.

        This exposes a stable public read API for callers that need a
        snapshot across all tiers without reaching into private stores.
        """
        fragments: list[TieredFragment] = []
        for store in (self._hot, self._warm, self._cold):
            fragments.extend(store.values())
        return fragments

    def get_hot_fragments(self) -> list[TieredFragment]:
        """Return all fragments currently in the hot tier."""
        return list(self._hot.values())

    # ------------------------------------------------------------------
    # Actor views
    # ------------------------------------------------------------------

    def get_for_actor(
        self,
        actor_role: ActorRole,
        project_names: list[str] | None = None,
    ) -> list[TieredFragment]:
        """Return fragments visible to *actor_role*, scoped to projects.

        Uses ``ActorContextView`` default tiers for the role and
        ``ScopedBackendView`` for project isolation (when project_names
        is provided).
        """
        view = ActorContextView(actor_role=actor_role)
        effective_tiers = view.get_effective_tiers()

        candidates: list[TieredFragment] = []
        for tier in effective_tiers:
            store = self._store_for_tier(tier)
            candidates.extend(store.values())

        # Apply project scoping
        if project_names:
            scoped = ScopedBackendView(
                allowed_projects=frozenset(project_names),
            )
            candidates = [f for f in candidates if scoped.is_visible(f)]
            logger.debug(
                "tier.actor_view_filtered",
                actor_role=actor_role.value,
                after=len(candidates),
                project_names=project_names,
            )

        # Sort by last_accessed descending, cap at max_fragments
        candidates.sort(key=lambda f: f.last_accessed, reverse=True)
        return candidates[: view.max_fragments]

    # ------------------------------------------------------------------
    # Scoped view
    # ------------------------------------------------------------------

    def get_scoped_view(
        self,
        project_names: list[str],
    ) -> list[TieredFragment]:
        """Return all fragments scoped to the given project names.

        Enforces project isolation via ``ScopedBackendView`` so actors
        never see resources outside the plan's project set.
        """
        if not project_names:
            return []
        scoped = ScopedBackendView(
            allowed_projects=frozenset(project_names),
        )
        all_frags: list[TieredFragment] = []
        for store in (self._hot, self._warm, self._cold):
            all_frags.extend(f for f in store.values() if scoped.is_visible(f))
        all_frags.sort(key=lambda f: f.last_accessed, reverse=True)
        logger.debug(
            "tier.scoped_view_filtered",
            project_names=project_names,
            result_count=len(all_frags),
        )
        return all_frags

    # ------------------------------------------------------------------
    # Promote / Demote
    # ------------------------------------------------------------------

    def promote(self, fragment_id: str) -> TieredFragment | None:
        """Promote a fragment one tier up (cold→warm or warm→hot).

        Returns the promoted fragment or ``None`` if not found or
        already in the hot tier.  Emits a ``TIER_PROMOTED`` event.

        Raises:
            ValueError: If *fragment_id* is empty.
        """
        if not fragment_id:
            raise ValueError("fragment_id must be non-empty")

        if fragment_id in self._cold:
            frag = self._cold.pop(fragment_id)
            promoted = frag.model_copy(update={"tier": ContextTier.WARM})
            self._warm[fragment_id] = promoted
            self._emit_tier_event(
                EventType.TIER_PROMOTED,
                fragment_id,
                from_tier=ContextTier.COLD,
                to_tier=ContextTier.WARM,
            )
            return promoted

        if fragment_id in self._warm:
            frag = self._warm.pop(fragment_id)
            promoted = frag.model_copy(update={"tier": ContextTier.HOT})
            self._hot[fragment_id] = promoted
            # Emit PROMOTED before budget enforcement so event ordering
            # reflects the causal chain (promotion caused eviction).
            self._emit_tier_event(
                EventType.TIER_PROMOTED,
                fragment_id,
                from_tier=ContextTier.WARM,
                to_tier=ContextTier.HOT,
            )
            self._enforce_hot_budget()
            # If budget enforcement evicted the just-promoted fragment,
            # restore it to the warm tier to prevent silent data loss.
            if fragment_id not in self._hot:
                restored = promoted.model_copy(
                    update={"tier": ContextTier.WARM},
                )
                self._warm[fragment_id] = restored
                logger.warning(
                    "tier.promotion_budget_fallback",
                    fragment_id=fragment_id,
                    reason="hot budget exceeded after promotion",
                )
                return restored
            return promoted

        return None

    def demote(self, fragment_id: str) -> TieredFragment | None:
        """Demote a fragment one tier down (hot→warm or warm→cold).

        When demoting to cold tier, the summarisation hook is applied.
        Returns the demoted fragment or ``None`` if not found or
        already in the cold tier.  Emits a ``TIER_DEMOTED`` event.

        The ``access_count`` is reset to ``0`` on demotion so that
        the fragment must accumulate fresh accesses before it can be
        auto-promoted again.  Without this reset, a previously popular
        fragment whose ``access_count`` already exceeds the promotion
        threshold would be re-promoted on the very next ``get()``
        call, making staleness enforcement ineffective.

        Raises:
            ValueError: If *fragment_id* is empty.
        """
        if not fragment_id:
            raise ValueError("fragment_id must be non-empty")

        if fragment_id in self._hot:
            frag = self._hot.pop(fragment_id)
            demoted = frag.model_copy(
                update={"tier": ContextTier.WARM, "access_count": 0},
            )
            self._warm[fragment_id] = demoted
            self._emit_tier_event(
                EventType.TIER_DEMOTED,
                fragment_id,
                from_tier=ContextTier.HOT,
                to_tier=ContextTier.WARM,
            )
            return demoted

        if fragment_id in self._warm:
            frag = self._warm.pop(fragment_id)
            summarised = self._summarize_for_cold(frag)
            demoted = summarised.model_copy(
                update={"tier": ContextTier.COLD, "access_count": 0},
            )
            self._cold[fragment_id] = demoted
            self._emit_tier_event(
                EventType.TIER_DEMOTED,
                fragment_id,
                from_tier=ContextTier.WARM,
                to_tier=ContextTier.COLD,
            )
            return demoted

        return None

    # ------------------------------------------------------------------
    # LRU eviction
    # ------------------------------------------------------------------

    def evict_lru(self, tier: ContextTier, count: int) -> list[str]:
        """Evict the *count* least-recently-used fragments from *tier*.

        Returns the list of evicted fragment IDs.

        Raises:
            ValueError: If *count* is not positive.
        """
        if count <= 0:
            raise ValueError(f"count must be positive, got {count}")

        store = self._store_for_tier(tier)
        if not store:
            return []

        # Sort by last_accessed ascending (oldest first)
        sorted_ids = sorted(
            store.keys(),
            key=lambda fid: store[fid].last_accessed,
        )
        to_evict = sorted_ids[:count]
        for fid in to_evict:
            del store[fid]
            self._emit_tier_event(
                EventType.TIER_EVICTED,
                fid,
                from_tier=tier,
                to_tier=None,
            )
        return to_evict

    # ------------------------------------------------------------------
    # Metrics
    # ------------------------------------------------------------------

    def get_metrics(self) -> TierMetrics:
        """Return current hit/miss and population metrics."""
        return TierMetrics(
            hot_count=len(self._hot),
            warm_count=len(self._warm),
            cold_count=len(self._cold),
            hot_hit_count=self._hot_hit,
            hot_miss_count=self._hot_miss,
            warm_hit_count=self._warm_hit,
            warm_miss_count=self._warm_miss,
        )

    # Scope enforcement methods (get_scoped, get_scoped_by_resource,
    # validate_fragment_scope, store_with_scope_check, get_scoped_metrics)
    # are provided by ScopedTierMixin — see scoped_tiers.py.

    # ------------------------------------------------------------------
    # Budget
    # ------------------------------------------------------------------

    @property
    def budget(self) -> TierBudget:
        """Return the active tier budget."""
        return self._budget

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

    def _find_fragment(self, fragment_id: str) -> TieredFragment | None:
        """Look up a fragment across all tiers **without** side effects.

        Unlike :meth:`get`, this does not call :meth:`_touch` and
        therefore leaves ``last_accessed``, ``access_count``, and
        hit/miss counters unchanged.  Used by validation methods that
        must be read-only.
        """
        for store in (self._hot, self._warm, self._cold):
            if fragment_id in store:
                return store[fragment_id]
        return None

    def _store_for_tier(
        self,
        tier: ContextTier,
    ) -> dict[str, TieredFragment]:
        """Return the backing store dict for *tier*."""
        if tier == ContextTier.HOT:
            return self._hot
        if tier == ContextTier.WARM:
            return self._warm
        return self._cold

    def _remove_from_all(self, fragment_id: str) -> None:
        """Remove *fragment_id* from all tiers."""
        self._hot.pop(fragment_id, None)
        self._warm.pop(fragment_id, None)
        self._cold.pop(fragment_id, None)

    @staticmethod
    def _touch(fragment: TieredFragment) -> TieredFragment:
        """Update access metadata on *fragment* and return it."""
        fragment.last_accessed = datetime.now(tz=UTC)
        fragment.access_count += 1
        return fragment

    @staticmethod
    def _summarize_for_cold(fragment: TieredFragment) -> TieredFragment:
        """Summarisation hook: truncate content for cold storage.

        This is a stub implementation that truncates the content.
        A production implementation would call an LLM summariser.
        """
        content = fragment.content
        if len(content) > _COLD_SUMMARY_MAX_CHARS:
            content = content[:_COLD_SUMMARY_MAX_CHARS] + "..."
        return fragment.model_copy(update={"content": content})


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


def _budget_from_settings(settings: Settings | None) -> TierBudget:
    """Build a ``TierBudget`` from application settings (or defaults)."""
    if settings is None:
        return TierBudget()
    return TierBudget(
        max_tokens_hot=getattr(
            settings, "context_max_tokens_hot", _DEFAULT_MAX_TOKENS_HOT
        ),
        max_decisions_warm=getattr(
            settings, "context_max_decisions_warm", _DEFAULT_MAX_DECISIONS_WARM
        ),
        max_decisions_cold=getattr(
            settings, "context_max_decisions_cold", _DEFAULT_MAX_DECISIONS_COLD
        ),
    )


# ---------------------------------------------------------------------------
# Runtime policy helpers
# ---------------------------------------------------------------------------


def _promotion_threshold_from_settings(settings: Settings | None) -> int:
    """Read promotion threshold from settings (or use default)."""
    if settings is None:
        return _DEFAULT_PROMOTION_THRESHOLD
    return int(
        getattr(
            settings,
            "context_tier_promotion_threshold",
            _DEFAULT_PROMOTION_THRESHOLD,
        ),
    )


def _hot_ttl_from_settings(settings: Settings | None) -> timedelta:
    """Read hot-tier TTL from settings (or use default)."""
    if settings is None:
        return timedelta(hours=_DEFAULT_HOT_TTL_HOURS)
    hours = int(
        getattr(
            settings,
            "context_tier_hot_ttl_hours",
            _DEFAULT_HOT_TTL_HOURS,
        ),
    )
    return timedelta(hours=hours)


def _warm_ttl_from_settings(settings: Settings | None) -> timedelta:
    """Read warm-tier TTL from settings (or use default)."""
    if settings is None:
        return timedelta(hours=_DEFAULT_WARM_TTL_HOURS)
    hours = int(
        getattr(
            settings,
            "context_tier_warm_ttl_hours",
            _DEFAULT_WARM_TTL_HOURS,
        ),
    )
    return timedelta(hours=hours)


__all__ = ["ContextTierService"]
