# -*- coding: utf-8 -*-
"""Location: ./mcpgateway/cache/global_config_cache.py
Copyright 2026
SPDX-License-Identifier: Apache-2.0
Authors: Mihai Criveti

GlobalConfig In-Memory Cache.

This module implements a thread-safe in-memory cache for GlobalConfig with TTL expiration.
GlobalConfig is a singleton configuration table that stores passthrough headers settings.
Since this data rarely changes (admin configuration), caching it in memory eliminates
thousands of redundant database queries under load.

Performance Impact:
    - Before: 42,000+ DB queries per load test for 0-1 row table
    - After: 1 DB query per TTL period (default 60 seconds)

Security Considerations:
    - Stale config window: Changes take up to TTL to propagate
    - Mitigation: Admin UI should call invalidate() after config changes
    - Cache poisoning: Not applicable (populated from DB only)
    - Information leakage: Not applicable (stores header names only, not values)

Examples:
    >>> from unittest.mock import Mock, patch
    >>> from mcpgateway.cache.global_config_cache import GlobalConfigCache

    >>> # Test cache miss and hit
    >>> cache = GlobalConfigCache(ttl_seconds=60)
    >>> mock_db = Mock()
    >>> mock_config = Mock()
    >>> mock_config.passthrough_headers = ["Authorization", "X-Request-ID"]
    >>> mock_db.query.return_value.first.return_value = mock_config

    >>> # First call - cache miss, queries DB
    >>> result = cache.get(mock_db)
    >>> result.passthrough_headers
    ['Authorization', 'X-Request-ID']
    >>> mock_db.query.return_value.first.call_count
    1

    >>> # Second call - cache hit, no DB query
    >>> result = cache.get(mock_db)
    >>> mock_db.query.return_value.first.call_count
    1

    >>> # After invalidation - queries DB again
    >>> cache.invalidate()
    >>> result = cache.get(mock_db)
    >>> mock_db.query.return_value.first.call_count
    2
"""

# Standard
import logging
import threading
import time

# Use standard logging to avoid circular imports with services
logger = logging.getLogger(__name__)


class GlobalConfigCache:
    """
    Thread-safe in-memory cache for GlobalConfig with TTL.

    This cache stores the GlobalConfig singleton to avoid repeated database queries.
    GlobalConfig contains passthrough headers configuration that rarely changes.

    Attributes:
        ttl_seconds: Time-to-live in seconds before cache refresh
        _cache: Cached GlobalConfig object (or None)
        _expiry: Timestamp when cache expires
        _lock: Threading lock for thread-safe operations

    Examples:
        >>> from unittest.mock import Mock
        >>> cache = GlobalConfigCache(ttl_seconds=60)
        >>> mock_db = Mock()
        >>> mock_db.query.return_value.first.return_value = None

        >>> # Returns None when no GlobalConfig exists
        >>> cache.get(mock_db) is None
        True

        >>> # get_passthrough_headers returns default when no config
        >>> cache.get_passthrough_headers(mock_db, ["Default-Header"])
        ['Default-Header']
    """

    # Sentinel value to distinguish "not cached" from "cached None"
    _NOT_CACHED = object()

    def __init__(self, ttl_seconds: int = 60):
        """
        Initialize the GlobalConfig cache.

        Args:
            ttl_seconds: Time-to-live in seconds (default: 60).
                        After this duration, the cache will refresh from DB.

        Examples:
            >>> cache = GlobalConfigCache(ttl_seconds=30)
            >>> cache.ttl_seconds
            30
        """
        self.ttl_seconds = ttl_seconds
        self._cache = self._NOT_CACHED  # Use sentinel to distinguish from cached None
        self._expiry: float = 0
        self._lock = threading.Lock()
        self._hit_count = 0
        self._miss_count = 0

    def get(self, db):
        """
        Get GlobalConfig from cache or database.

        Uses a double-checked locking pattern for thread safety with minimal
        lock contention on the hot path (cache hit).

        Args:
            db: SQLAlchemy database session

        Returns:
            GlobalConfig object or None if not configured

        Examples:
            >>> from unittest.mock import Mock
            >>> cache = GlobalConfigCache(ttl_seconds=60)
            >>> mock_db = Mock()
            >>> mock_config = Mock()
            >>> mock_db.query.return_value.first.return_value = mock_config
            >>> cache.get(mock_db) is mock_config
            True
        """
        now = time.time()

        # Fast path: cache hit (no lock needed for read)
        # Use sentinel check to properly cache None (empty GlobalConfig table)
        if now < self._expiry and self._cache is not self._NOT_CACHED:
            self._hit_count += 1
            return self._cache

        # Slow path: cache miss or expired - acquire lock
        with self._lock:
            # Double-check after acquiring lock (another thread may have refreshed)
            if now < self._expiry and self._cache is not self._NOT_CACHED:
                self._hit_count += 1
                return self._cache

            # Import here to avoid circular imports
            # First-Party
            from mcpgateway.db import GlobalConfig  # pylint: disable=import-outside-toplevel

            # Refresh from database
            self._cache = db.query(GlobalConfig).first()
            self._expiry = now + self.ttl_seconds
            self._miss_count += 1

            if self._cache:
                logger.debug(f"GlobalConfig cache refreshed (TTL: {self.ttl_seconds}s)")
            else:
                logger.debug("GlobalConfig not found in database, using defaults")

            return self._cache

    def get_passthrough_headers(self, db, default: list[str]) -> list[str]:
        """
        Get passthrough headers based on PASSTHROUGH_HEADERS_SOURCE setting.

        Supports three modes:
        - "env": Environment variable always wins (ignore database)
        - "db": Database wins if configured, fallback to env (default, backward compatible)
        - "merge": Union of env and database headers (DB overrides for duplicates)

        Args:
            db: SQLAlchemy database session
            default: Default headers from environment variable (settings.default_passthrough_headers)

        Returns:
            List of allowed passthrough header names

        Examples:
            >>> from unittest.mock import Mock, patch
            >>> cache = GlobalConfigCache(ttl_seconds=60)
            >>> mock_db = Mock()

            >>> # "db" mode (default): When no config exists, returns default
            >>> mock_db.query.return_value.first.return_value = None
            >>> cache.invalidate()  # Clear any cached value
            >>> with patch("mcpgateway.config.settings") as mock_settings:
            ...     mock_settings.passthrough_headers_source = "db"
            ...     cache.get_passthrough_headers(mock_db, ["X-Default"])
            ['X-Default']

            >>> # "env" mode: Always returns default, ignores database
            >>> mock_config = Mock()
            >>> mock_config.passthrough_headers = ["Authorization"]
            >>> mock_db.query.return_value.first.return_value = mock_config
            >>> cache.invalidate()
            >>> with patch("mcpgateway.config.settings") as mock_settings:
            ...     mock_settings.passthrough_headers_source = "env"
            ...     cache.get_passthrough_headers(mock_db, ["X-Default"])
            ['X-Default']

            >>> # "merge" mode: Combines both sources
            >>> cache.invalidate()
            >>> with patch("mcpgateway.config.settings") as mock_settings:
            ...     mock_settings.passthrough_headers_source = "merge"
            ...     result = cache.get_passthrough_headers(mock_db, ["X-Default"])
            ...     "X-Default" in result and "Authorization" in result
            True
        """
        # Import here to avoid circular imports
        # First-Party
        from mcpgateway.config import settings  # pylint: disable=import-outside-toplevel

        source = settings.passthrough_headers_source

        if source == "env":
            # Environment always wins - don't query database at all
            logger.debug("Passthrough headers source=env: using environment variable only")
            return default if default else []

        config = self.get(db)

        if source == "merge":
            # Union of both sources, preserving original casing
            # Use lowercase keys for deduplication, original casing for values
            env_headers = {h.lower(): h for h in (default or [])}
            db_headers = {h.lower(): h for h in (config.passthrough_headers or [])} if config else {}
            # DB values override env for same header (handles case differences)
            merged = {**env_headers, **db_headers}
            result = list(merged.values())
            logger.debug(f"Passthrough headers source=merge: combined {len(result)} headers from env and db")
            return result

        # Default "db" mode - current behavior for backward compatibility
        if config and config.passthrough_headers:
            logger.debug("Passthrough headers source=db: using database configuration")
            return config.passthrough_headers
        logger.debug("Passthrough headers source=db: no database config, using environment default")
        return default

    def invalidate(self) -> None:
        """
        Invalidate the cache, forcing a refresh on next access.

        Call this method after updating GlobalConfig in the database
        to ensure changes propagate immediately.

        Examples:
            >>> cache = GlobalConfigCache(ttl_seconds=60)
            >>> cache._expiry = time.time() + 1000  # Set future expiry
            >>> cache.invalidate()
            >>> cache._expiry
            0
        """
        with self._lock:
            self._cache = self._NOT_CACHED
            self._expiry = 0
            logger.info("GlobalConfig cache invalidated")

    def stats(self) -> dict:
        """
        Get cache statistics.

        Returns:
            Dictionary with hit_count, miss_count, and hit_rate

        Examples:
            >>> cache = GlobalConfigCache(ttl_seconds=60)
            >>> cache._hit_count = 90
            >>> cache._miss_count = 10
            >>> stats = cache.stats()
            >>> stats["hit_rate"]
            0.9
        """
        total = self._hit_count + self._miss_count
        return {
            "hit_count": self._hit_count,
            "miss_count": self._miss_count,
            "hit_rate": self._hit_count / total if total > 0 else 0.0,
            "ttl_seconds": self.ttl_seconds,
            "is_cached": self._cache is not self._NOT_CACHED and time.time() < self._expiry,
        }


# Global singleton instance
# This is the primary interface for accessing cached GlobalConfig
global_config_cache = GlobalConfigCache()
