"""
Caching functionality for OpenZIM MCP server.

Provides an in-memory LRU cache with TTL support, optional background
cleanup to proactively remove expired entries, and optional persistence
for cache warmup between restarts.
"""

import atexit
import functools
import heapq
import json
import logging
import threading
import time
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple

from .config import CacheConfig
from .exceptions import OpenZimMcpValidationError

logger = logging.getLogger(__name__)

# Default interval for background cleanup (in seconds)
DEFAULT_CLEANUP_INTERVAL = 60

# File extension for persistence files
CACHE_FILE_EXTENSION = ".json"


def _silence_logging_errors(func: Callable[..., Any]) -> Callable[..., Any]:
    """Wrap an atexit-registered callable to suppress logging-handler errors.

    During interpreter shutdown — or under pytest stream capture — stdout and
    stderr may be closed before atexit handlers run. Logging calls inside
    those handlers then raise ``ValueError: I/O operation on closed file``
    inside ``StreamHandler.emit``, which ``handleError`` reports by writing
    "--- Logging error ---" plus a traceback to stderr (spamming test output
    and confusing users). Toggling ``logging.raiseExceptions`` disables that
    fallback path so failed log writes are silently dropped instead.
    """

    @functools.wraps(func)
    def wrapper(*args: Any, **kwargs: Any) -> Any:
        prev = logging.raiseExceptions
        logging.raiseExceptions = False
        try:
            return func(*args, **kwargs)
        finally:
            logging.raiseExceptions = prev

    return wrapper


def _approximate_size_bytes(value: Any) -> int:
    """Approximate the cached value's memory cost via JSON encoding length.

    JSON length is not the actual Python in-memory footprint, but it is
    a stable, monotonic proxy that callers can budget against, and it's
    cheap (no full pympler-style traversal). Non-serializable values
    fall back to a conservative ``2 KB`` so they still count toward the
    cap.
    """
    try:
        return len(json.dumps(value, ensure_ascii=False, default=str))
    except (TypeError, ValueError):
        return 2048


class CacheEntry:
    """Represents a single cache entry with TTL."""

    def __init__(self, value: Any, ttl_seconds: int):
        """
        Initialize cache entry.

        Args:
            value: Value to cache
            ttl_seconds: Time to live in seconds

        Example:
            >>> entry = CacheEntry("cached_value", ttl_seconds=3600)
            >>> entry.is_expired()
            False
        """
        self.value = value
        self.size_bytes = _approximate_size_bytes(value)
        # Use monotonic clock for in-memory expiry/LRU so wall-clock
        # adjustments (NTP, DST, manual changes) can't break expiry.
        self.created_at = time.monotonic()
        self.ttl_seconds = ttl_seconds

    def is_expired(self) -> bool:
        """Check if cache entry has expired."""
        return time.monotonic() - self.created_at > self.ttl_seconds


class OpenZimMcpCache:
    """Simple in-memory cache with TTL support and hit/miss statistics.

    This cache implements LRU (Least Recently Used) eviction with TTL-based
    expiration. It tracks cache hits and misses for performance monitoring.

    Features:
    - Thread-safe operations with locking
    - Background cleanup thread for proactive expiration
    - LRU eviction using heap for O(log n) performance
    - Hit/miss statistics for monitoring
    - Optional persistence for cache warmup between restarts

    Example:
        >>> from openzim_mcp.config import CacheConfig
        >>> config = CacheConfig(enabled=True, max_size=100, ttl_seconds=3600)
        >>> cache = OpenZimMcpCache(config)
        >>> cache.set("key1", "value1")
        >>> cache.get("key1")
        'value1'
        >>> stats = cache.stats()
        >>> stats["hits"]
        1
    """

    def __init__(
        self,
        config: CacheConfig,
        enable_background_cleanup: bool = True,
        cleanup_interval: int = DEFAULT_CLEANUP_INTERVAL,
    ):
        """
        Initialize cache.

        Args:
            config: Cache configuration
            enable_background_cleanup: If True, start a background thread to
                proactively clean up expired entries
            cleanup_interval: Interval in seconds between cleanup runs
        """
        self.config = config
        self._cache: Dict[str, CacheEntry] = {}
        # Running total of ``CacheEntry.size_bytes`` across all live
        # entries. Updated incrementally on every set/eviction so the
        # byte-bound eviction check is O(1).
        self._total_bytes: int = 0
        # Monotonic counter for LRU access ordering. A counter is used in
        # preference to a wall-clock or monotonic timestamp because clock
        # resolution varies by platform (Windows ticks at ~15.6ms by default),
        # which would let multiple operations land on the same timestamp and
        # break LRU ordering when (timestamp, key) tuples fall back to
        # lexicographic key comparison.
        self._access_counter: int = 0
        self._access_order: Dict[str, int] = {}
        # Heap for O(log n) LRU eviction: (access_counter, key)
        # Uses lazy deletion - entries may be stale if key was updated or removed
        self._lru_heap: List[Tuple[int, str]] = []
        self._hits: int = 0
        self._misses: int = 0
        self._lock = threading.RLock()  # Reentrant lock for thread safety

        # Background cleanup thread
        self._cleanup_thread: Optional[threading.Thread] = None
        self._cleanup_stop_event = threading.Event()
        self._cleanup_interval = cleanup_interval

        # Persistence settings
        self._persistence_enabled = getattr(config, "persistence_enabled", False)
        configured_path = getattr(config, "persistence_path", None)
        if configured_path:
            self._persistence_path = Path(configured_path).expanduser()
        else:
            # M27: default to an XDG-style cache directory rather than CWD.
            # The previous default (``.openzim_mcp_cache`` relative to CWD)
            # silently failed inside read-only Docker images that ran
            # from ``/app`` with ``--chmod=555``; the save error was
            # caught and logged but the user's cache warmup was lost
            # without obvious feedback. ``XDG_CACHE_HOME`` is the
            # well-known location and is writable in every standard
            # Linux/macOS deployment.
            import os as _os

            xdg = _os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
            self._persistence_path = Path(xdg) / "openzim-mcp" / "cache.json"
            # Ensure parent dir exists; the actual file is written
            # later by ``_save_to_disk``.
            try:
                self._persistence_path.parent.mkdir(parents=True, exist_ok=True)
            except OSError as e:
                logger.warning(
                    "Cache persistence default path %s parent could not "
                    "be created (%s); persistence will be disabled.",
                    self._persistence_path,
                    e,
                )
                self._persistence_enabled = False

        # Load persisted cache if enabled
        if config.enabled and self._persistence_enabled:
            self._load_from_disk()

        # atexit handlers fire in LIFO order, so register persistence FIRST
        # and the cleanup-thread stop SECOND. That way at shutdown the cleanup
        # thread is signalled to stop *before* _save_to_disk runs, so the save
        # snapshot doesn't race with concurrent expirations from the cleanup
        # loop.
        # Wrap both with _silence_logging_errors so that interpreter shutdown
        # — when stdout/stderr may already be closed by pytest's capture or
        # the runtime itself — does not spam "--- Logging error ---" tracebacks.
        if config.enabled and self._persistence_enabled:
            atexit.register(_silence_logging_errors(self._save_to_disk))

        if config.enabled and enable_background_cleanup:
            self._start_cleanup_thread()
            atexit.register(_silence_logging_errors(self._stop_cleanup_thread))

        logger.info(
            f"Cache initialized: enabled={config.enabled}, "
            f"max_size={config.max_size}, ttl={config.ttl_seconds}s, "
            f"background_cleanup={enable_background_cleanup}, "
            f"persistence={self._persistence_enabled}"
        )

    def _start_cleanup_thread(self) -> None:
        """Start the background cleanup thread."""
        if self._cleanup_thread is not None and self._cleanup_thread.is_alive():
            return  # Already running

        self._cleanup_stop_event.clear()
        self._cleanup_thread = threading.Thread(
            target=self._background_cleanup_loop,
            name="CacheCleanupThread",
            daemon=True,  # Daemon thread will exit when main program exits
        )
        self._cleanup_thread.start()
        logger.debug("Cache background cleanup thread started")

    def _stop_cleanup_thread(self) -> None:
        """Stop the background cleanup thread gracefully."""
        if self._cleanup_thread is None:
            return

        self._cleanup_stop_event.set()
        # Give the thread a chance to exit cleanly
        self._cleanup_thread.join(timeout=2.0)
        if self._cleanup_thread.is_alive():
            logger.debug("Cache cleanup thread did not stop in time")
        else:
            logger.debug("Cache background cleanup thread stopped")
        self._cleanup_thread = None

    def _background_cleanup_loop(self) -> None:
        """Background loop that periodically cleans up expired entries."""
        while not self._cleanup_stop_event.is_set():
            try:
                # Wait for the cleanup interval or until stop is signaled
                if self._cleanup_stop_event.wait(timeout=self._cleanup_interval):
                    break  # Stop event was set

                # Perform cleanup
                self._cleanup_expired()
            except Exception as e:
                # Log but don't crash the cleanup thread
                logger.debug(f"Error in cache cleanup thread: {e}")

    def get(self, key: str) -> Optional[Any]:
        """
        Get value from cache (thread-safe).

        Args:
            key: Cache key

        Returns:
            Cached value or None if not found/expired
        """
        if not self.config.enabled:
            return None

        with self._lock:
            if key not in self._cache:
                self._misses += 1
                return None

            entry = self._cache[key]

            # Check if expired
            if entry.is_expired():
                self._remove(key)
                self._misses += 1
                logger.debug(f"Cache entry expired: {key}")
                return None

            # Update access counter for LRU
            self._access_counter += 1
            access_counter = self._access_counter
            self._access_order[key] = access_counter
            # Push to heap - old entries skipped during eviction (lazy cleanup)
            heapq.heappush(self._lru_heap, (access_counter, key))
            self._hits += 1
            logger.debug(f"Cache hit: {key}")
            return entry.value

    def set(self, key: str, value: Any) -> None:
        """
        Set value in cache (thread-safe).

        Args:
            key: Cache key
            value: Value to cache

        Raises:
            OpenZimMcpValidationError: When persistence is enabled and the
                value is not JSON-serializable. Surfacing the error at write
                time prevents silent ``str()`` coercion on save (M21) — a
                ``Path`` or ``datetime`` round-tripped through persistence
                would otherwise come back as a plain string and lose its
                type.
        """
        if not self.config.enabled:
            return

        # Validate JSON-serializability at write time when persistence is
        # enabled. Pure in-memory caches still accept arbitrary Python
        # objects — only the persisted path needs JSON guarantees.
        if self._persistence_enabled:
            try:
                json.dumps(value)
            except (TypeError, ValueError) as exc:
                raise OpenZimMcpValidationError(
                    f"Cache value for key {key!r} is not JSON-serializable; "
                    f"persistence is enabled so the value must be JSON-safe.",
                    details=str(exc),
                ) from exc

        with self._lock:
            # Check if we need to evict by entry count
            if len(self._cache) >= self.config.max_size and key not in self._cache:
                self._evict_lru()

            # Replace path: drop the old entry's byte count first so
            # ``_total_bytes`` stays accurate when the same key is reset
            # with a value of different size.
            prior = self._cache.get(key)
            if prior is not None:
                self._total_bytes -= prior.size_bytes
                if self._total_bytes < 0:
                    self._total_bytes = 0

            # Add/update entry
            entry = CacheEntry(value, self.config.ttl_seconds)
            self._cache[key] = entry
            self._total_bytes += entry.size_bytes
            self._access_counter += 1
            access_counter = self._access_counter
            self._access_order[key] = access_counter
            # Push to heap for O(log n) LRU eviction
            heapq.heappush(self._lru_heap, (access_counter, key))
            logger.debug(
                f"Cache set: {key} ({entry.size_bytes} bytes, total "
                f"{self._total_bytes} bytes)"
            )

            # Byte-budget eviction: if the cache exceeds ``max_bytes``,
            # evict LRU entries until we're back under. Skip when the
            # cap is 0 (disabled) or when the cache only holds this one
            # entry (a single oversized value can legitimately exceed
            # the cap; the count cap still bounds entry count).
            max_bytes = getattr(self.config, "max_bytes", 0)
            if max_bytes > 0:
                while self._total_bytes > max_bytes and len(self._cache) > 1:
                    self._evict_lru()

    def delete(self, key: str) -> None:
        """
        Delete a specific key from cache (thread-safe).

        Args:
            key: Cache key to delete
        """
        if not self.config.enabled:
            return

        with self._lock:
            if key in self._cache:
                self._remove(key)
                logger.debug(f"Cache entry deleted: {key}")

    def _remove(self, key: str) -> None:
        """Remove entry from cache (must be called with lock held)."""
        entry = self._cache.pop(key, None)
        if entry is not None:
            self._total_bytes -= entry.size_bytes
            if self._total_bytes < 0:
                self._total_bytes = 0
        self._access_order.pop(key, None)

    def _cleanup_expired(self) -> None:
        """Remove all expired entries (thread-safe).

        Also compacts the LRU heap when stale entries dominate. get()/set()
        push a new (access_counter, key) on every access for lazy LRU;
        without compaction the heap grows unboundedly with total operations
        rather than with cache size.
        """
        with self._lock:
            expired_keys = [
                key for key, entry in self._cache.items() if entry.is_expired()
            ]

            for key in expired_keys:
                self._remove(key)

            if expired_keys:
                logger.debug(f"Cleaned up {len(expired_keys)} expired cache entries")

            # Compact the heap when it grew well past the live cache size.
            # 4x is a cheap heuristic — small enough to keep memory bounded,
            # large enough that we don't rebuild the heap on every cleanup.
            if len(self._lru_heap) > 4 * max(len(self._access_order), 1):
                self._lru_heap = [(t, k) for k, t in self._access_order.items()]
                heapq.heapify(self._lru_heap)
                logger.debug(f"Compacted LRU heap to {len(self._lru_heap)} entries")

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

        Uses lazy deletion: entries in heap may be stale if key was accessed again
        or removed. We skip invalid entries until we find a valid one to evict.
        """
        while self._lru_heap:
            # Pop the oldest entry from heap
            access_counter, key = heapq.heappop(self._lru_heap)

            # Check if this entry is still valid (not stale)
            # An entry is valid if:
            # 1. The key still exists in cache
            # 2. The access counter matches (not updated since)
            if key in self._access_order and self._access_order[key] == access_counter:
                self._remove(key)
                logger.debug(f"Evicted LRU cache entry: {key}")
                return

            # Entry is stale (key was removed or accessed more recently), skip it

        # Fallback: if heap is empty but cache has entries, use linear scan
        # This shouldn't happen in normal operation but provides safety
        if self._access_order:
            lru_key = min(
                self._access_order.keys(), key=lambda k: self._access_order[k]
            )
            self._remove(lru_key)
            logger.debug(f"Evicted LRU cache entry (fallback): {lru_key}")

    def clear(self) -> None:
        """Clear all cache entries and reset statistics (thread-safe)."""
        with self._lock:
            self._cache.clear()
            self._access_order.clear()
            self._lru_heap.clear()
            self._total_bytes = 0
            self._hits = 0
            self._misses = 0
        logger.info("Cache cleared")

    def stats(self) -> Dict[str, Any]:
        """Get cache statistics including hit/miss rates (thread-safe)."""
        with self._lock:
            total_requests = self._hits + self._misses
            hit_rate = self._hits / total_requests if total_requests > 0 else 0.0

            stats = {
                "enabled": self.config.enabled,
                "size": len(self._cache),
                "max_size": self.config.max_size,
                "size_bytes": self._total_bytes,
                "max_bytes": getattr(self.config, "max_bytes", 0),
                "ttl_seconds": self.config.ttl_seconds,
                "hits": self._hits,
                "misses": self._misses,
                "hit_rate": round(hit_rate, 4),
                "background_cleanup": (
                    self._cleanup_thread is not None and self._cleanup_thread.is_alive()
                ),
                "persistence_enabled": self._persistence_enabled,
            }

            # Add persistence file info if enabled
            if self._persistence_enabled:
                persistence_file = self._get_persistence_file()
                stats["persistence_path"] = str(persistence_file)
                stats["persistence_file_exists"] = persistence_file.exists()

            return stats

    def shutdown(self) -> None:
        """Shutdown the cache and stop background threads."""
        self._stop_cleanup_thread()
        if self._persistence_enabled:
            self._save_to_disk()
        logger.debug("Cache shutdown complete")

    def _get_persistence_file(self) -> Path:
        """Get the path to the persistence file."""
        if self._persistence_path.suffix == CACHE_FILE_EXTENSION:
            return self._persistence_path
        return self._persistence_path.with_suffix(CACHE_FILE_EXTENSION)

    def _save_to_disk(self) -> None:
        """Save cache contents to disk for persistence.

        Only saves non-expired entries. Thread-safe.
        """
        if not self._persistence_enabled:
            return

        try:
            # Hold the lock for the whole snapshot+write so concurrent set/
            # delete calls can't be silently dropped between the snapshot and
            # the rename, and so the empty-cache file deletion doesn't race
            # with a concurrent set().
            with self._lock:
                # Collect non-expired entries. created_at is monotonic
                # in memory; convert to wall-clock for persistence so the
                # data is meaningful across process restarts.
                entries_to_save: Dict[str, Dict[str, Any]] = {}
                now_monotonic = time.monotonic()
                now_wall = time.time()
                for key, entry in self._cache.items():
                    age = now_monotonic - entry.created_at
                    if age <= entry.ttl_seconds:
                        entries_to_save[key] = {
                            "value": entry.value,
                            "created_at": now_wall - age,
                            "ttl_seconds": entry.ttl_seconds,
                        }

                persistence_file = self._get_persistence_file()

                if not entries_to_save:
                    if persistence_file.exists():
                        persistence_file.unlink()
                        logger.debug("Removed empty cache persistence file")
                    return

                persistence_file.parent.mkdir(parents=True, exist_ok=True)

                # Write to temp file first, then rename for atomicity.
                # We deliberately omit ``default=str``: ``cache.set`` already
                # validates JSON-serializability when persistence is enabled
                # (see :meth:`set`), so any non-JSON value reaching this
                # point is a bug we want to surface rather than silently
                # coerce to a ``str()`` repr that loses type information on
                # reload.
                temp_file = persistence_file.with_suffix(".tmp")
                with open(temp_file, "w", encoding="utf-8") as f:
                    json.dump(
                        {
                            "version": 1,
                            "saved_at": now_wall,
                            "entries": entries_to_save,
                        },
                        f,
                        indent=2,
                    )

                # Atomic rename
                temp_file.replace(persistence_file)
                logger.debug(f"Saved {len(entries_to_save)} cache entries to disk")

        except Exception as e:
            logger.warning(f"Failed to save cache to disk: {e}")

    def _restore_entry(
        self, key: str, entry_data: Any, now_wall: float, now_monotonic: float
    ) -> bool:
        """Restore a single cache entry from its serialized form.

        Returns True if the entry was loaded, False if it was skipped because
        it had already expired. Raises on malformed input so the caller can
        log and continue.
        """
        if not isinstance(entry_data, dict):
            raise ValueError("entry payload is not a dict")
        if "value" not in entry_data:
            raise ValueError("entry missing 'value' field")

        created_at = entry_data.get("created_at", 0)
        ttl_seconds = entry_data.get("ttl_seconds", self.config.ttl_seconds)

        # Skip expired entries (compare wall-clock-to-wall-clock)
        age = now_wall - created_at
        if age > ttl_seconds:
            return False

        # Restore entry. CacheEntry.__init__ stamps a monotonic created_at;
        # rewind it by the entry's age so remaining TTL is preserved across
        # the restart.
        entry = CacheEntry(entry_data["value"], ttl_seconds)
        entry.created_at = now_monotonic - age
        self._cache[key] = entry
        # ``_total_bytes`` must reflect every cached entry's size — without
        # this, ``max_bytes`` enforcement in ``set()`` reads zero across a
        # warm-start (the eviction loop's ``while self._total_bytes > max_bytes``
        # never fires on already-loaded entries) and the byte budget is
        # silently inoperative until enough new sets accumulate to cross
        # the threshold from zero. ``set()`` and ``_remove()`` maintain
        # this invariant; ``_restore_entry`` was the lone gap.
        self._total_bytes += entry.size_bytes

        # Assign a fresh access_counter value to preserve LRU ordering across
        # restart. Loaded entries get successively higher counters in
        # iteration order; live operations after restart continue from a
        # higher counter.
        self._access_counter += 1
        self._access_order[key] = self._access_counter
        heapq.heappush(self._lru_heap, (self._access_counter, key))
        return True

    def _load_from_disk(self) -> None:
        """Load cache contents from disk.

        Skips expired entries and entries with non-deserializable values.
        """
        if not self._persistence_enabled:
            return

        persistence_file = self._get_persistence_file()
        if not persistence_file.exists():
            logger.debug("No cache persistence file found")
            return

        try:
            # Hold ``_lock`` across the file read AND the entry restore.
            # The window between ``json.load()`` and lock acquisition was
            # narrow (this is normally called from ``__init__`` before
            # other threads hold a reference to the cache) but a test
            # fixture sharing a server instance across threads could race
            # a concurrent ``clear()`` or ``set()`` against the loaded
            # snapshot. JSON parse inside the critical section adds a
            # brief one-time blocking window at startup with no
            # contention in production.
            with self._lock:
                with open(persistence_file, "r", encoding="utf-8") as f:
                    data = json.load(f)

                if data.get("version") != 1:
                    logger.warning(
                        f"Unsupported cache persistence version: "
                        f"{data.get('version')}"
                    )
                    return

                entries = data.get("entries", {})
                now_wall = time.time()
                now_monotonic = time.monotonic()
                loaded_count = 0
                skipped_count = 0

                for key, entry_data in entries.items():
                    # Per-entry try/except so a single malformed entry
                    # doesn't abort the load of every other valid entry.
                    try:
                        if self._restore_entry(
                            key, entry_data, now_wall, now_monotonic
                        ):
                            loaded_count += 1
                    except Exception as entry_err:
                        skipped_count += 1
                        logger.debug(
                            f"Skipped malformed cache entry {key!r}: {entry_err}"
                        )

                # Enforce the configured caps against the loaded snapshot.
                # A persistence file written under one config can be loaded
                # under a smaller ``max_size`` / ``max_bytes`` (operator
                # tightened limits between restarts). Without these passes,
                # the loaded cache violates the configured cap until enough
                # new sets force eviction. Eviction here uses the same LRU
                # heap that ``set()`` maintains.
                while len(self._cache) > self.config.max_size:
                    self._evict_lru()
                max_bytes = getattr(self.config, "max_bytes", 0)
                if max_bytes > 0:
                    while self._total_bytes > max_bytes and len(self._cache) > 1:
                        self._evict_lru()

            if skipped_count:
                logger.warning(
                    f"Loaded {loaded_count} cache entries from disk; "
                    f"skipped {skipped_count} malformed entries"
                )
            else:
                logger.info(f"Loaded {loaded_count} cache entries from disk")

        except json.JSONDecodeError as e:
            logger.warning(f"Failed to parse cache persistence file: {e}")
        except Exception as e:
            logger.warning(f"Failed to load cache from disk: {e}")
