"""Cache abstraction — Protocol + LRU/Null defaults (C-5, v0.37.0).

Stability tier: **experimental** (AP-11) — added in 0.37.0.
Wave 3 federation primitive: every data-heavy consumer caches
SOMETHING (Redis, Django's local-memory, hand-rolled). This
module standardises the **shape** — the Protocol — so consumers
can:

1. Start on the stdlib-only :class:`LRUCache` default.
2. Drop in a Redis-backed implementation later without
   touching call sites.
3. Disable caching entirely (e.g., in tests) via
   :class:`NullCache`.

What ships here
---------------

- :class:`Cache` — Protocol. Five-method surface:
  ``get`` / ``set`` / ``delete`` / ``clear`` / ``get_or_set``.
- :class:`LRUCache` — stdlib-only TTL+capacity LRU. Thread-safe.
  Suitable for in-process / single-replica deployments.
- :class:`NullCache` — no-op. Useful in tests + as the "caching
  disabled" toggle.
- :class:`CacheMiss` — sentinel for ``get(...)`` when the key
  is absent. Lets consumers distinguish "key not present" from
  "key present, value is None".

Why a Protocol
--------------

A Redis-backed impl with the same surface is the canonical
production wiring. Same call sites, different bytes-flying:

.. code-block:: python

    # tests / dev:
    cache: Cache = LRUCache(maxsize=100, ttl_seconds=60)

    # prod:
    from my_org.redis_cache import RedisCache
    cache: Cache = RedisCache(client=redis_client, ttl_seconds=300)

Cross-references
----------------

- **AP-11** — stability tiers.
- **C-5** federation row.
- :class:`OrderedDict` — what :class:`LRUCache` uses
  internally for O(1) move-to-end on access.
"""

from __future__ import annotations

import threading
import time
from collections import OrderedDict
from typing import (
    Any,
    Final,
    Protocol,
    runtime_checkable,
)

__all__ = [
    "Cache",
    "CacheMiss",
    "LRUCache",
    "NullCache",
]


class _CacheMissSentinel:
    """Singleton sentinel for cache-miss returns."""

    __slots__ = ()

    def __repr__(self) -> str:
        return "<CACHE_MISS>"

    def __bool__(self) -> bool:
        return False


CacheMiss: Final = _CacheMissSentinel()
"""Sentinel returned by :meth:`Cache.get` when the key is absent.

Distinguishable from ``None`` (which is a valid cached value).
Falsy under ``bool()`` for the common ``if cached := cache.get(k):``
shape — though that idiom does NOT distinguish "miss" from
"cached None / False / 0 / ''"; use ``is CacheMiss`` for the
clean check.
"""


@runtime_checkable
class Cache(Protocol):
    """Cache Protocol — the contract every backend satisfies.

    Implementations decide their storage (in-process dict, Redis,
    Memcached, ...). The contract:

    - :meth:`get` returns the cached value OR :data:`CacheMiss`.
    - :meth:`set` stores ``value`` under ``key`` with optional
      TTL. Returns ``None``.
    - :meth:`delete` removes the key. No-op if absent.
    - :meth:`clear` removes everything.
    - :meth:`get_or_set` is the atomic-ish convenience: get,
      compute-if-miss, set, return. Backends that can do this
      atomically (Redis ``SETNX``) should; the default Protocol
      implementation racing two callers is acceptable.
    """

    def get(self, key: str) -> Any:
        """Return cached value OR :data:`CacheMiss`."""
        ...

    def set(
        self,
        key: str,
        value: Any,
        *,
        ttl_seconds: float | None = None,
    ) -> None:
        """Store ``value`` under ``key``. ``ttl_seconds=None``
        means "use the backend default" (no expiry for some;
        configured-default for others)."""
        ...

    def delete(self, key: str) -> None:
        """Remove ``key``. No-op if absent."""
        ...

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


class NullCache:
    """No-op cache. Every ``get`` is a miss; every ``set`` is dropped.

    Useful as the test-default ("disable caching for this
    test") and as a startup-bootstrap impl before the real
    backend is wired.
    """

    def get(self, key: str) -> Any:
        return CacheMiss

    def set(self, key: str, value: Any, *, ttl_seconds: float | None = None) -> None:
        pass

    def delete(self, key: str) -> None:
        pass

    def clear(self) -> None:
        pass


class _LRUEntry:
    """Internal entry — value + expiry timestamp."""

    __slots__ = ("expires_at", "value")

    def __init__(self, value: Any, expires_at: float | None) -> None:
        self.value = value
        self.expires_at = expires_at  # None = no TTL


class LRUCache:
    """Stdlib-only TTL+capacity LRU cache. Thread-safe.

    Eviction order: least-recently-USED. ``get`` AND ``set``
    both count as "used" — recently-touched keys move to the
    end. When ``len > maxsize``, the head (oldest) gets evicted.

    TTL: per-entry, or fall back to ``default_ttl_seconds``.
    Expired entries are evicted lazily on access (no background
    thread). A consumer wanting eager eviction wraps the cache
    in a Scheduler tick.

    Parameters
    ----------
    maxsize
        Max number of entries before LRU eviction kicks in.
    default_ttl_seconds
        TTL applied when ``set(...)`` is called without an
        explicit ``ttl_seconds``. ``None`` = no expiry (entry
        only evicted on LRU pressure). Default: ``None``.
    """

    def __init__(
        self,
        *,
        maxsize: int = 1024,
        default_ttl_seconds: float | None = None,
    ) -> None:
        if maxsize <= 0:
            raise ValueError(
                f"LRUCache maxsize must be > 0; got {maxsize}",
            )
        if default_ttl_seconds is not None and default_ttl_seconds <= 0:
            raise ValueError(
                f"default_ttl_seconds must be > 0 or None; got {default_ttl_seconds}",
            )
        self.maxsize = maxsize
        self.default_ttl_seconds = default_ttl_seconds
        self._entries: OrderedDict[str, _LRUEntry] = OrderedDict()
        self._lock = threading.Lock()

    def get(self, key: str) -> Any:
        now = time.monotonic()
        with self._lock:
            entry = self._entries.get(key)
            if entry is None:
                return CacheMiss
            if entry.expires_at is not None and now >= entry.expires_at:
                # Expired — evict + miss.
                del self._entries[key]
                return CacheMiss
            # Bump to most-recently-used.
            self._entries.move_to_end(key)
            return entry.value

    def set(
        self,
        key: str,
        value: Any,
        *,
        ttl_seconds: float | None = None,
    ) -> None:
        ttl = ttl_seconds if ttl_seconds is not None else self.default_ttl_seconds
        expires_at = (time.monotonic() + ttl) if ttl is not None else None
        with self._lock:
            self._entries[key] = _LRUEntry(value=value, expires_at=expires_at)
            self._entries.move_to_end(key)
            # Evict LRU entries until at or below capacity.
            while len(self._entries) > self.maxsize:
                self._entries.popitem(last=False)

    def delete(self, key: str) -> None:
        with self._lock:
            self._entries.pop(key, None)

    def clear(self) -> None:
        with self._lock:
            self._entries.clear()

    def __len__(self) -> int:
        """Number of entries currently stored (including
        not-yet-evicted-but-expired ones)."""
        with self._lock:
            return len(self._entries)
