"""Decision caching — configurable TTL cache with policy-change invalidation.

Caches authorization decisions to avoid re-evaluating the same request.
The cache is automatically invalidated when policies or roles change.

Usage:
    from pyauthz.core.cache import DecisionCache

    cache = DecisionCache(ttl_seconds=60, max_size=10000)
    rp = PyAuthz()
    rp.set_cache(cache)

    # First call evaluates, subsequent calls hit cache
    rp.is_allowed(user, "read", doc)  # Evaluates
    rp.is_allowed(user, "read", doc)  # Cache hit
"""

from __future__ import annotations

import threading
import time
from typing import Any

from pyauthz.core.decision import Decision


class DecisionCache:
    """LRU-style decision cache with TTL and manual invalidation.

    Thread-safe through a lock. Entries expire after ttl_seconds.
    The cache is invalidated (cleared) when policies or roles change.

    Attributes:
        ttl_seconds: Time-to-live for cache entries in seconds.
        max_size: Maximum number of entries. Oldest entries are evicted when full.
    """

    def __init__(self, ttl_seconds: float = 60.0, max_size: int = 10000) -> None:
        self.ttl_seconds = ttl_seconds
        self.max_size = max_size
        self._cache: dict[str, tuple[Decision, float]] = {}
        self._lock = threading.Lock()
        self._hits = 0
        self._misses = 0

    def get(self, key: str) -> Decision | None:
        """Look up a cached decision.

        Returns None on cache miss or expired entry.
        """
        with self._lock:
            entry = self._cache.get(key)
            if entry is None:
                self._misses += 1
                return None

            decision, timestamp = entry
            if time.monotonic() - timestamp > self.ttl_seconds:
                del self._cache[key]
                self._misses += 1
                return None

            self._hits += 1
            return decision

    def put(self, key: str, decision: Decision) -> None:
        """Store a decision in the cache."""
        with self._lock:
            # Evict oldest entries if at capacity
            if len(self._cache) >= self.max_size and key not in self._cache:
                # Remove the oldest entry
                oldest_key = next(iter(self._cache))
                del self._cache[oldest_key]

            self._cache[key] = (decision, time.monotonic())

    def invalidate(self) -> None:
        """Clear the entire cache. Called when policies or roles change."""
        with self._lock:
            self._cache.clear()

    def stats(self) -> dict[str, Any]:
        """Return cache statistics."""
        with self._lock:
            return {
                "size": len(self._cache),
                "max_size": self.max_size,
                "ttl_seconds": self.ttl_seconds,
                "hits": self._hits,
                "misses": self._misses,
                "hit_rate": (
                    self._hits / (self._hits + self._misses)
                    if (self._hits + self._misses) > 0
                    else 0.0
                ),
            }

    @staticmethod
    def make_key(
        subject_id: str,
        subject_roles: list[str],
        action: str,
        resource_id: str,
        resource_type: str,
    ) -> str:
        """Build a cache key from request components.

        The key includes all components that affect the authorization decision.
        """
        roles_part = ",".join(sorted(subject_roles))
        return f"{subject_id}|{roles_part}|{action}|{resource_type}|{resource_id}"
