# Copyright (c) 2026 MergeGuide, Inc. All rights reserved.
# SPDX-License-Identifier: Proprietary
"""TTL-based in-memory cache for Lambda environments.

This module provides a generic, thread-safe TTL cache optimized for AWS Lambda.
The cache persists across warm invocations within the same Lambda container,
reducing DynamoDB load and improving response times.

Key features:
- Configurable TTL per entry or default TTL
- LRU eviction when max_size is reached
- Prefix-based invalidation for bulk cache clearing
- Hit/miss statistics for monitoring
- Thread-safe operations using RLock

Example usage:
    >>> from mergeguide.cache.ttl_cache import TTLCache
    >>> cache = TTLCache[dict](default_ttl_seconds=300, max_size=1000)
    >>> cache.set("policy:org123", {"rules": [...]})
    >>> result = cache.get("policy:org123")  # Returns dict or None
    >>> cache.invalidate_prefix("policy:")  # Clear all policy entries
"""

from __future__ import annotations

import logging
import threading
import time
from dataclasses import dataclass, field
from typing import Generic, TypeVar

logger = logging.getLogger(__name__)

T = TypeVar("T")


@dataclass
class CacheEntry(Generic[T]):
    """A single cache entry with value and expiration timestamp.

    Attributes:
        value: The cached value of type T.
        expires_at: Unix timestamp when this entry expires.
        created_at: Unix timestamp when this entry was created.
    """

    value: T
    expires_at: float
    created_at: float = field(default_factory=time.time)

    def is_expired(self) -> bool:
        """Check if this entry has expired."""
        return time.time() > self.expires_at


class TTLCache(Generic[T]):
    """Thread-safe TTL cache with LRU eviction.

    This cache is designed for Lambda environments where:
    - Memory is limited (configurable max_size)
    - Entries should expire after a TTL to ensure freshness
    - Cache persists across warm invocations

    Attributes:
        default_ttl: Default time-to-live in seconds for entries.
        max_size: Maximum number of entries before LRU eviction.

    Example:
        >>> cache = TTLCache[list[Rule]](default_ttl_seconds=300, max_size=1000)
        >>> cache.set("policy:org123", rules)
        >>> rules = cache.get("policy:org123")  # Returns None if expired
    """

    def __init__(self, default_ttl_seconds: int = 300, max_size: int = 1000) -> None:
        """Initialize the TTL cache.

        Args:
            default_ttl_seconds: Default TTL in seconds for cache entries.
                Use 300 (5 min) for policies, 600 (10 min) for org configs.
            max_size: Maximum number of entries before LRU eviction kicks in.
                Set based on expected entry count and memory constraints.
        """
        self._cache: dict[str, CacheEntry[T]] = {}
        self._default_ttl = default_ttl_seconds
        self._max_size = max_size
        self._hits = 0
        self._misses = 0
        self._lock = threading.RLock()
        # Track access order for LRU eviction
        self._access_order: list[str] = []

    def get(self, key: str) -> T | None:
        """Get value if exists and not expired.

        Thread-safe retrieval that:
        - Returns None for missing or expired entries
        - Updates access order for LRU tracking
        - Increments hit/miss counters

        Args:
            key: The cache key to look up.

        Returns:
            The cached value of type T, or None if not found/expired.
        """
        with self._lock:
            entry = self._cache.get(key)

            if entry is None:
                self._misses += 1
                return None

            if entry.is_expired():
                # Clean up expired entry
                del self._cache[key]
                self._remove_from_access_order(key)
                self._misses += 1
                return None

            # Update access order for LRU
            self._update_access_order(key)
            self._hits += 1
            return entry.value

    def set(self, key: str, value: T, ttl: int | None = None) -> None:
        """Set value with optional custom TTL.

        Thread-safe storage that:
        - Uses custom TTL if provided, otherwise default_ttl
        - Evicts LRU entries if max_size is exceeded
        - Updates access order for existing keys

        Args:
            key: The cache key to store under.
            value: The value to cache.
            ttl: Optional TTL in seconds (overrides default_ttl).
        """
        ttl_seconds = ttl if ttl is not None else self._default_ttl
        expires_at = time.time() + ttl_seconds

        with self._lock:
            # Check if we need to evict before adding
            if key not in self._cache and len(self._cache) >= self._max_size:
                self._evict_lru()

            self._cache[key] = CacheEntry(value=value, expires_at=expires_at)
            self._update_access_order(key)

    def invalidate(self, key: str) -> bool:
        """Remove specific key from cache.

        Thread-safe removal of a single cache entry.

        Args:
            key: The cache key to remove.

        Returns:
            True if the key was found and removed, False otherwise.
        """
        with self._lock:
            if key in self._cache:
                del self._cache[key]
                self._remove_from_access_order(key)
                return True
            return False

    def invalidate_prefix(self, prefix: str) -> int:
        """Remove all keys matching prefix.

        Useful for bulk invalidation, e.g., invalidating all
        policies for an org with prefix "policy:org123".

        Args:
            prefix: The key prefix to match.

        Returns:
            Number of entries removed.
        """
        with self._lock:
            keys_to_remove = [k for k in self._cache if k.startswith(prefix)]
            for key in keys_to_remove:
                del self._cache[key]
                self._remove_from_access_order(key)
            return len(keys_to_remove)

    def clear(self) -> None:
        """Clear entire cache."""
        with self._lock:
            self._cache.clear()
            self._access_order.clear()

    def stats(self) -> dict:
        """Return hit/miss statistics.

        Returns:
            Dict with hits, misses, hit_ratio, size, and max_size.
        """
        with self._lock:
            total = self._hits + self._misses
            hit_ratio = self._hits / total if total > 0 else 0.0
            return {
                "hits": self._hits,
                "misses": self._misses,
                "hit_ratio": round(hit_ratio, 4),
                "size": len(self._cache),
                "max_size": self._max_size,
            }

    def reset_stats(self) -> None:
        """Reset hit/miss counters to zero."""
        with self._lock:
            self._hits = 0
            self._misses = 0

    def _update_access_order(self, key: str) -> None:
        """Update LRU access order (must be called with lock held)."""
        self._remove_from_access_order(key)
        self._access_order.append(key)

    def _remove_from_access_order(self, key: str) -> None:
        """Remove key from access order list (must be called with lock held)."""
        try:
            self._access_order.remove(key)
        except ValueError:
            pass  # Key not in access order

    def _evict_lru(self) -> None:
        """Evict least recently used entry (must be called with lock held).

        Also cleans up any expired entries encountered during eviction.
        """
        # First, try to remove expired entries
        now = time.time()
        expired_keys = [k for k, v in self._cache.items() if v.expires_at < now]
        for key in expired_keys:
            del self._cache[key]
            self._remove_from_access_order(key)

        # If still at capacity, evict LRU entry
        if len(self._cache) >= self._max_size and self._access_order:
            lru_key = self._access_order[0]
            if lru_key in self._cache:
                del self._cache[lru_key]
                self._access_order.pop(0)
                logger.debug(f"Evicted LRU cache entry: {lru_key}")

    def __contains__(self, key: str) -> bool:
        """Check if key exists and is not expired (without updating stats)."""
        with self._lock:
            entry = self._cache.get(key)
            if entry is None:
                return False
            if entry.is_expired():
                del self._cache[key]
                self._remove_from_access_order(key)
                return False
            return True

    def __len__(self) -> int:
        """Return number of entries (including potentially expired ones)."""
        with self._lock:
            return len(self._cache)
