"""Hot storage tier: in-memory LRU cache with configurable capacity.

The hot storage tier is the primary access layer for the ACMS, holding
recently and frequently accessed context entries in memory for fast
retrieval.  It is backed by an ``OrderedDict`` to maintain LRU ordering
with O(1) get/put operations.

Capacity is controlled by two independent limits:

* ``max_entries`` — maximum number of entries in the cache.
* ``max_bytes``   — maximum total byte size of all cached content.

When either limit is exceeded, the least-recently-used entry is evicted
first.  An optional ``on_evict`` callback is invoked for each evicted
entry, enabling warm-tier demotion.

Thread safety is provided by a ``threading.RLock`` so that concurrent
reads and writes from parallel plan execution do not corrupt the cache.

Based on ``docs/specification.md`` ACMS tier sections and issue #9972.
"""

from __future__ import annotations

import threading
from collections import OrderedDict
from collections.abc import Callable

import structlog

logger = structlog.get_logger(__name__)


class HotStorageTier:
    """In-memory LRU cache for ACMS hot-tier context entries.

    Entries are keyed by a string ``entry_id`` and store arbitrary
    ``str`` content.  The cache tracks byte sizes using UTF-8 encoding
    of the content string.

    Args:
        max_entries: Maximum number of entries.  ``None`` means no limit.
        max_bytes:   Maximum total byte size of all content.  ``None``
                     means no limit.
        on_evict:    Optional callback invoked with ``(entry_id, content)``
                     when an entry is evicted due to capacity overflow.
                     Intended for warm-tier demotion.  Errors raised by
                     the callback are logged and suppressed so that cache
                     operations are never interrupted by callback failures.

    Raises:
        ValueError: If ``max_entries`` is not ``None`` and is less than 1.
        ValueError: If ``max_bytes`` is not ``None`` and is less than 1.
    """

    def __init__(
        self,
        max_entries: int | None = None,
        max_bytes: int | None = None,
        on_evict: Callable[[str, str], None] | None = None,
    ) -> None:
        if max_entries is not None and max_entries < 1:
            raise ValueError(f"max_entries must be at least 1, got {max_entries}")
        if max_bytes is not None and max_bytes < 1:
            raise ValueError(f"max_bytes must be at least 1, got {max_bytes}")

        self._max_entries = max_entries
        self._max_bytes = max_bytes
        self._on_evict = on_evict

        # OrderedDict preserves insertion order; we move accessed keys to
        # the end (most-recently-used) so the front is always the LRU entry.
        self._cache: OrderedDict[str, str] = OrderedDict()
        self._size_bytes: int = 0

        self._hit_count: int = 0
        self._miss_count: int = 0

        self._lock = threading.RLock()

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def put(self, entry_id: str, content: str) -> None:
        """Insert or update an entry in the cache.

        If the entry already exists it is updated in-place and moved to
        the most-recently-used position.  After insertion, capacity
        limits are enforced by evicting LRU entries until both limits
        are satisfied.

        Args:
            entry_id: Unique identifier for the entry.
            content:  String content to cache.

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

        content_bytes = len(content.encode("utf-8"))

        with self._lock:
            # If the entry already exists, remove it first so we can
            # re-insert at the MRU end and update the byte accounting.
            if entry_id in self._cache:
                old_content = self._cache[entry_id]
                self._size_bytes -= len(old_content.encode("utf-8"))
                del self._cache[entry_id]

            self._cache[entry_id] = content
            self._size_bytes += content_bytes

            # Move to MRU end (already there since we just inserted, but
            # explicit for clarity).
            self._cache.move_to_end(entry_id)

            self._enforce_capacity()

    def get(self, entry_id: str) -> str | None:
        """Retrieve an entry, updating its LRU position.

        Returns the cached content string, or ``None`` if the entry is
        not present.  Updates hit/miss counters.

        Args:
            entry_id: Unique identifier for the entry.
        """
        with self._lock:
            if entry_id in self._cache:
                self._hit_count += 1
                # Move to MRU end
                self._cache.move_to_end(entry_id)
                return self._cache[entry_id]

            self._miss_count += 1
            return None

    def remove(self, entry_id: str) -> str | None:
        """Remove an entry from the cache without invoking the evict callback.

        Returns the removed content, or ``None`` if the entry was not
        present.

        Args:
            entry_id: Unique identifier for the entry.
        """
        with self._lock:
            if entry_id not in self._cache:
                return None
            content = self._cache.pop(entry_id)
            self._size_bytes -= len(content.encode("utf-8"))
            return content

    def clear(self) -> None:
        """Remove all entries from the cache.

        Does **not** invoke the ``on_evict`` callback for cleared entries.
        Resets ``size_bytes`` to zero but preserves hit/miss counters.
        """
        with self._lock:
            self._cache.clear()
            self._size_bytes = 0

    # ------------------------------------------------------------------
    # Metrics (read-only properties)
    # ------------------------------------------------------------------

    @property
    def hit_count(self) -> int:
        """Total number of cache hits since construction."""
        with self._lock:
            return self._hit_count

    @property
    def miss_count(self) -> int:
        """Total number of cache misses since construction."""
        with self._lock:
            return self._miss_count

    @property
    def entry_count(self) -> int:
        """Current number of entries in the cache."""
        with self._lock:
            return len(self._cache)

    @property
    def size_bytes(self) -> int:
        """Current total byte size of all cached content (UTF-8 encoded)."""
        with self._lock:
            return self._size_bytes

    # ------------------------------------------------------------------
    # Configuration accessors
    # ------------------------------------------------------------------

    @property
    def max_entries(self) -> int | None:
        """Configured maximum entry count limit (``None`` = unlimited)."""
        return self._max_entries

    @property
    def max_bytes(self) -> int | None:
        """Configured maximum byte size limit (``None`` = unlimited)."""
        return self._max_bytes

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

    def _enforce_capacity(self) -> None:
        """Evict LRU entries until both capacity limits are satisfied.

        Called after every ``put`` operation.  Evicts from the front of
        the ``OrderedDict`` (least-recently-used end).
        """
        while self._cache and self._is_over_capacity():
            self._evict_lru()

    def _is_over_capacity(self) -> bool:
        """Return ``True`` if either capacity limit is currently exceeded."""
        if self._max_entries is not None and len(self._cache) > self._max_entries:
            return True
        return self._max_bytes is not None and self._size_bytes > self._max_bytes

    def _evict_lru(self) -> None:
        """Evict the single least-recently-used entry.

        Invokes ``on_evict`` callback if configured.  Callback errors
        are caught, logged, and suppressed.
        """
        if not self._cache:
            return

        # The LRU entry is at the front of the OrderedDict.
        lru_id, lru_content = next(iter(self._cache.items()))
        del self._cache[lru_id]
        self._size_bytes -= len(lru_content.encode("utf-8"))

        logger.debug(
            "hot_storage.evicted",
            entry_id=lru_id,
            remaining_entries=len(self._cache),
            remaining_bytes=self._size_bytes,
        )

        if self._on_evict is not None:
            try:
                self._on_evict(lru_id, lru_content)
            except Exception:
                logger.warning(
                    "hot_storage.evict_callback_failed",
                    entry_id=lru_id,
                    exc_info=True,
                )


__all__: list[str] = ["HotStorageTier"]
