"""In-process LRU cache for ``SessionService.is_session_valid`` (U3).

The middleware calls :func:`is_session_valid` on every request carrying
a V2 JWT (sid claim).  Without a cache, that adds one SQLite round-trip
per request.  The cache keeps the result for ``ttl_seconds`` (default
30s) — long enough to absorb a burst, short enough that a server-side
revocation propagates quickly enough for the user to notice.

This is a *negative-caching-friendly* cache: a "valid" answer is
cached for 30s, a "revoked/not found" answer is also cached for 30s.
A revocation therefore takes up to 30s to be observed by the
middleware — acceptable trade-off, since the user is the one who
revoked it (not an attacker).

For a tighter revocation window, the route layer can be configured to
bypass the cache on the ``/auth/logout`` path; see the admin "force
logout" flow.
"""

from __future__ import annotations

import time
from collections import OrderedDict

from .session_service import SessionService

DEFAULT_TTL_SECONDS = 30
DEFAULT_MAX_ENTRIES = 1_000


class SessionValidationCache:
    """Tiny LRU+TTL wrapper around :class:`SessionService`.

    The cache is keyed by session id.  The value is a tuple of
    ``(valid, expires_at_monotonic)``.

    The cache is intentionally process-local; multi-process deployments
    get per-worker caching (still beneficial) and rely on the eventual
    expiry for cross-worker consistency.
    """

    def __init__(
        self,
        service: SessionService,
        *,
        ttl_seconds: int = DEFAULT_TTL_SECONDS,
        max_entries: int = DEFAULT_MAX_ENTRIES,
    ) -> None:
        self._service = service
        self._ttl = ttl_seconds
        self._max = max_entries
        self._entries: "OrderedDict[str, tuple[bool, float]]" = OrderedDict()

    async def is_valid(self, session_id: str) -> bool:
        """Return whether the session is currently valid, using the cache."""
        cached = self._entries.get(session_id)
        if cached is not None:
            valid, expires_at = cached
            if expires_at > time.monotonic():
                # LRU touch
                self._entries.move_to_end(session_id)
                return valid
            # Expired — drop and re-check.
            self._entries.pop(session_id, None)

        valid = await self._service.is_session_valid(session_id)
        self._entries[session_id] = (valid, time.monotonic() + self._ttl)
        self._entries.move_to_end(session_id)
        while len(self._entries) > self._max:
            self._entries.popitem(last=False)
        return valid

    def invalidate(self, session_id: str) -> None:
        """Drop a single entry (used by /auth/logout)."""
        self._entries.pop(session_id, None)

    def clear(self) -> None:
        """Drop all entries (used in tests)."""
        self._entries.clear()

    def __len__(self) -> int:  # pragma: no cover — debug helper
        return len(self._entries)


_cache: SessionValidationCache | None = None


def get_validation_cache() -> SessionValidationCache | None:
    """Return the process-wide cache, or ``None`` if not initialised."""
    return _cache


def init_validation_cache(
    service: SessionService,
    *,
    ttl_seconds: int = DEFAULT_TTL_SECONDS,
    max_entries: int = DEFAULT_MAX_ENTRIES,
) -> SessionValidationCache:
    """Initialise (or replace) the process-wide cache and return it.

    Call this from the app factory once the :class:`SessionService`
    singleton is ready.  Returns the new cache so callers can hold
    a reference for tests.
    """
    global _cache
    _cache = SessionValidationCache(service, ttl_seconds=ttl_seconds, max_entries=max_entries)
    return _cache


def set_validation_cache(cache: SessionValidationCache | None) -> None:
    """Inject a custom cache (used by tests)."""
    global _cache
    _cache = cache
