"""Zero-dependency in-process TTL cache for hot endpoints.

Designed for read-heavy, mostly-idempotent endpoints like the admin report
pages (``/api/reports/stats-corporate``, ``/api/reports/student-performance``)
and the AI narrative generator. A single LRU-ish dict per worker with a hard
time bound is usually enough to absorb the dashboard refresh storm caused by
an admin tabbing between pages.

**Not** a substitute for Redis — cross-worker consistency and bounded memory
are out of scope. If ops ever needs those guarantees, swap the internal dict
for a ``redis.Redis.setex`` call; the public API (``memoize_ttl`` and
``invalidate``) stays identical.
"""

from __future__ import annotations

import hashlib
import json
import logging
import threading
import time
from functools import wraps
from typing import Any, Callable

_logger = logging.getLogger(__name__)

_cache: dict[str, tuple[float, Any]] = {}
_lock = threading.Lock()
_MAX_ENTRIES = 512


def _make_key(namespace: str, args: tuple, kwargs: dict) -> str:
    payload = json.dumps([args, kwargs], sort_keys=True, default=str)
    digest = hashlib.md5(payload.encode("utf-8")).hexdigest()
    return f"{namespace}:{digest}"


def get(key: str):
    entry = _cache.get(key)
    if not entry:
        return None
    expiry, value = entry
    if expiry < time.time():
        with _lock:
            _cache.pop(key, None)
        return None
    return value


def put(key: str, value, ttl_seconds: int) -> None:
    with _lock:
        if len(_cache) >= _MAX_ENTRIES:
            oldest = sorted(_cache.items(), key=lambda kv: kv[1][0])[: _MAX_ENTRIES // 4]
            for k, _v in oldest:
                _cache.pop(k, None)
        _cache[key] = (time.time() + max(1, ttl_seconds), value)


def invalidate(namespace: str | None = None) -> int:
    """Drop every cached entry, or just entries under ``namespace``.

    Returns the number of removed entries. Callers should invoke this from
    write endpoints that affect the cached read so subsequent reads observe
    the new state.
    """
    removed = 0
    with _lock:
        if namespace is None:
            removed = len(_cache)
            _cache.clear()
            return removed
        prefix = f"{namespace}:"
        keys = [k for k in _cache if k.startswith(prefix)]
        for k in keys:
            _cache.pop(k, None)
        removed = len(keys)
    return removed


def memoize_ttl(namespace: str, ttl_seconds: int = 30):
    """Decorator: cache ``func(*args, **kwargs)`` for ``ttl_seconds`` per key.

    The key is derived from the namespace + a stable JSON dump of the args,
    so callers don't need to worry about mutable keyword order or unhashable
    defaults. JWT decorator should run *before* this one so unauthenticated
    traffic never hits the cache.
    """
    def decorator(func: Callable):
        @wraps(func)
        def wrapper(*args, **kwargs):
            try:
                key = _make_key(namespace, args, kwargs)
            except Exception:
                return func(*args, **kwargs)
            cached = get(key)
            if cached is not None:
                return cached
            value = func(*args, **kwargs)
            try:
                put(key, value, ttl_seconds)
            except Exception:
                _logger.debug("cache put failed for %s", namespace, exc_info=True)
            return value
        wrapper._encoach_cache_namespace = namespace
        return wrapper
    return decorator
