"""
Redis-based caching service for the Pixel API.

Provides:
- Key-value caching with multiple strategies (TTL, LRU, LFU)
- Pattern-based cache invalidation
- Decorator for function result caching
- Cache hit/miss metrics logging
- Cache penetration protection (Bloom Filter)
- Cache stampede protection (Distributed Lock)
- Cache warming
"""

import json
import hashlib
import logging
import asyncio
from typing import Optional, Any, Callable, Dict, List
from functools import wraps
from enum import Enum
from dataclasses import dataclass
from redis import asyncio as aioredis
from redis.exceptions import RedisError

from src.utils.errors import CacheException

logger = logging.getLogger(__name__)


class CacheStrategy(str, Enum):
    """Cache eviction strategies"""
    TTL = "ttl"  # Time To Live
    LRU = "lru"  # Least Recently Used
    LFU = "lfu"  # Least Frequently Used


@dataclass
class CacheStats:
    """Cache statistics"""
    hits: int = 0
    misses: int = 0
    sets: int = 0
    deletes: int = 0
    evictions: int = 0
    
    @property
    def hit_rate(self) -> float:
        """Calculate cache hit rate"""
        total = self.hits + self.misses
        return self.hits / total if total > 0 else 0.0


class BloomFilter:
    """
    Simple Bloom Filter implementation for cache penetration protection.
    
    Uses Redis SETBIT operations to implement a space-efficient probabilistic
    data structure for testing set membership.
    """
    
    def __init__(self, redis: aioredis.Redis, key: str = "bloom_filter", size: int = 10000):
        """
        Initialize Bloom Filter.
        
        Args:
            redis: Redis connection
            key: Redis key for the bloom filter
            size: Size of the bit array
        """
        self.redis = redis
        self.key = key
        self.size = size
        self.hash_count = 3  # Number of hash functions
    
    def _hash(self, item: str, seed: int) -> int:
        """Generate hash for an item with a seed"""
        h = hashlib.md5(f"{item}:{seed}".encode()).hexdigest()
        return int(h, 16) % self.size
    
    async def add(self, item: str):
        """Add an item to the bloom filter"""
        try:
            for i in range(self.hash_count):
                pos = self._hash(item, i)
                await self.redis.setbit(self.key, pos, 1)
        except RedisError as e:
            logger.error(f"Failed to add to bloom filter: {e}")
    
    async def contains(self, item: str) -> bool:
        """
        Check if an item might be in the set.
        
        Returns:
            True if item might be in set (or false positive)
            False if item is definitely not in set
        """
        try:
            for i in range(self.hash_count):
                pos = self._hash(item, i)
                bit = await self.redis.getbit(self.key, pos)
                if bit == 0:
                    return False
            return True
        except RedisError as e:
            logger.error(f"Failed to check bloom filter: {e}")
            return True  # Fail open - assume it might exist
    
    async def clear(self):
        """Clear the bloom filter"""
        try:
            await self.redis.delete(self.key)
        except RedisError as e:
            logger.error(f"Failed to clear bloom filter: {e}")


class CacheService:
    """
    Redis-based caching service with async support and multiple strategies.
    
    Supports:
    - TTL (Time To Live) strategy
    - LRU (Least Recently Used) strategy
    - LFU (Least Frequently Used) strategy
    - Cache penetration protection
    - Cache stampede protection
    - Pattern-based invalidation
    """
    
    def __init__(
        self, 
        redis_url: str = "redis://localhost:6379",
        default_strategy: CacheStrategy = CacheStrategy.TTL,
        max_size: int = 10000
    ):
        """
        Initialize the cache service.
        
        Args:
            redis_url: Redis connection URL
            default_strategy: Default cache eviction strategy
            max_size: Maximum number of keys for LRU/LFU strategies
        """
        self.redis_url = redis_url
        self.default_strategy = default_strategy
        self.max_size = max_size
        self._redis: Optional[aioredis.Redis] = None
        self._connected = False
        self._stats = CacheStats()
        self._bloom_filter: Optional['BloomFilter'] = None
        self._locks: Dict[str, asyncio.Lock] = {}
    
    async def connect(self):
        """Establish connection to Redis."""
        if not self._connected:
            try:
                self._redis = await aioredis.from_url(
                    self.redis_url,
                    decode_responses=True,
                    encoding="utf-8"
                )
                # Test connection
                await self._redis.ping()
                self._connected = True
                
                # Initialize bloom filter
                self._bloom_filter = BloomFilter(self._redis)
                
                logger.info(f"Connected to Redis at {self.redis_url}")
            except RedisError as e:
                logger.error(f"Failed to connect to Redis: {e}")
                self._connected = False
                # Don't raise exception - allow app to run without cache
    
    async def disconnect(self):
        """Close connection to Redis."""
        if self._redis:
            await self._redis.close()
            self._connected = False
            logger.info("Disconnected from Redis")
    
    async def get(self, key: str, strategy: Optional[CacheStrategy] = None) -> Optional[Any]:
        """
        Get value from cache.
        
        Args:
            key: Cache key
            strategy: Cache strategy to use (defaults to service default)
            
        Returns:
            Cached value if found, None otherwise
        """
        if not self._connected:
            logger.warning("Redis not connected, cache miss")
            self._stats.misses += 1
            return None
        
        strategy = strategy or self.default_strategy
        
        try:
            value = await self._redis.get(key)
            if value:
                logger.debug(f"Cache hit: {key}")
                self._stats.hits += 1
                
                # Update access metadata based on strategy
                if strategy == CacheStrategy.LRU:
                    await self._update_lru_access(key)
                elif strategy == CacheStrategy.LFU:
                    await self._update_lfu_access(key)
                
                return json.loads(value)
            else:
                logger.debug(f"Cache miss: {key}")
                self._stats.misses += 1
                return None
        except RedisError as e:
            logger.error(f"Redis get error for key {key}: {e}")
            self._stats.misses += 1
            return None
        except json.JSONDecodeError as e:
            logger.error(f"Failed to decode cached value for key {key}: {e}")
            self._stats.misses += 1
            return None
    
    async def set(
        self, 
        key: str, 
        value: Any, 
        ttl: int = 3600,
        strategy: Optional[CacheStrategy] = None
    ):
        """
        Set value in cache with specified strategy.
        
        Args:
            key: Cache key
            value: Value to cache (must be JSON serializable)
            ttl: Time to live in seconds (default: 1 hour)
            strategy: Cache strategy to use (defaults to service default)
        """
        if not self._connected:
            logger.warning("Redis not connected, skipping cache set")
            return
        
        strategy = strategy or self.default_strategy
        
        try:
            # Check if we need to evict based on strategy
            if strategy in (CacheStrategy.LRU, CacheStrategy.LFU):
                await self._enforce_max_size(strategy)
            
            serialized_value = json.dumps(value, ensure_ascii=False)
            
            if strategy == CacheStrategy.TTL:
                await self._redis.setex(key, ttl, serialized_value)
            elif strategy == CacheStrategy.LRU:
                await self._redis.set(key, serialized_value)
                await self._update_lru_access(key)
            elif strategy == CacheStrategy.LFU:
                await self._redis.set(key, serialized_value)
                await self._update_lfu_access(key)
            
            self._stats.sets += 1
            logger.debug(f"Cache set: {key} (strategy: {strategy}, TTL: {ttl}s)")
        except RedisError as e:
            logger.error(f"Redis set error for key {key}: {e}")
        except (TypeError, ValueError) as e:
            logger.error(f"Failed to serialize value for key {key}: {e}")
    
    async def delete(self, key: str):
        """
        Delete key from cache.
        
        Args:
            key: Cache key to delete
        """
        if not self._connected:
            logger.warning("Redis not connected, skipping cache delete")
            return
        
        try:
            await self._redis.delete(key)
            # Also delete metadata keys
            await self._redis.delete(f"{key}:lru")
            await self._redis.delete(f"{key}:lfu")
            self._stats.deletes += 1
            logger.debug(f"Cache delete: {key}")
        except RedisError as e:
            logger.error(f"Redis delete error for key {key}: {e}")
    
    async def _update_lru_access(self, key: str):
        """Update LRU access timestamp for a key"""
        try:
            import time
            await self._redis.set(f"{key}:lru", time.time())
        except RedisError as e:
            logger.error(f"Failed to update LRU access for {key}: {e}")
    
    async def _update_lfu_access(self, key: str):
        """Increment LFU access count for a key"""
        try:
            await self._redis.incr(f"{key}:lfu")
        except RedisError as e:
            logger.error(f"Failed to update LFU access for {key}: {e}")
    
    async def _enforce_max_size(self, strategy: CacheStrategy):
        """Enforce maximum cache size by evicting based on strategy"""
        try:
            # Count current keys (excluding metadata keys)
            count = 0
            keys = []
            async for key in self._redis.scan_iter():
                if not key.endswith((':lru', ':lfu')):
                    keys.append(key)
                    count += 1
            
            if count >= self.max_size:
                # Need to evict
                if strategy == CacheStrategy.LRU:
                    await self._evict_lru(keys)
                elif strategy == CacheStrategy.LFU:
                    await self._evict_lfu(keys)
        except RedisError as e:
            logger.error(f"Failed to enforce max size: {e}")
    
    async def _evict_lru(self, keys: List[str]):
        """Evict least recently used key"""
        try:
            oldest_key = None
            oldest_time = float('inf')
            
            for key in keys:
                lru_time = await self._redis.get(f"{key}:lru")
                if lru_time:
                    lru_time = float(lru_time)
                    if lru_time < oldest_time:
                        oldest_time = lru_time
                        oldest_key = key
            
            if oldest_key:
                await self.delete(oldest_key)
                self._stats.evictions += 1
                logger.debug(f"Evicted LRU key: {oldest_key}")
        except RedisError as e:
            logger.error(f"Failed to evict LRU: {e}")
    
    async def _evict_lfu(self, keys: List[str]):
        """Evict least frequently used key"""
        try:
            least_used_key = None
            least_count = float('inf')
            
            for key in keys:
                lfu_count = await self._redis.get(f"{key}:lfu")
                if lfu_count:
                    lfu_count = int(lfu_count)
                else:
                    lfu_count = 0
                
                if lfu_count < least_count:
                    least_count = lfu_count
                    least_used_key = key
            
            if least_used_key:
                await self.delete(least_used_key)
                self._stats.evictions += 1
                logger.debug(f"Evicted LFU key: {least_used_key}")
        except RedisError as e:
            logger.error(f"Failed to evict LFU: {e}")
    
    async def invalidate_pattern(self, pattern: str):
        """
        Invalidate all keys matching a pattern.
        
        Args:
            pattern: Redis key pattern (e.g., "user:*", "project:123:*")
        """
        if not self._connected:
            logger.warning("Redis not connected, skipping pattern invalidation")
            return
        
        try:
            keys = []
            async for key in self._redis.scan_iter(match=pattern):
                keys.append(key)
            
            if keys:
                await self._redis.delete(*keys)
                logger.info(f"Invalidated {len(keys)} keys matching pattern: {pattern}")
            else:
                logger.debug(f"No keys found matching pattern: {pattern}")
        except RedisError as e:
            logger.error(f"Redis pattern invalidation error for pattern {pattern}: {e}")
    
    async def invalidate_prefix(self, prefix: str):
        """
        Invalidate all keys with a specific prefix.
        
        Args:
            prefix: Key prefix (e.g., "project", "task")
        """
        await self.invalidate_pattern(f"{prefix}:*")
    
    async def invalidate_multiple(self, keys: List[str]):
        """
        Invalidate multiple specific keys.
        
        Args:
            keys: List of cache keys to invalidate
        """
        if not self._connected:
            logger.warning("Redis not connected, skipping multiple invalidation")
            return
        
        if not keys:
            return
        
        try:
            await self._redis.delete(*keys)
            logger.info(f"Invalidated {len(keys)} keys")
        except RedisError as e:
            logger.error(f"Redis multiple invalidation error: {e}")
    
    async def clear_all(self):
        """
        Clear all cache entries.
        
        WARNING: This will delete ALL keys in the Redis database.
        Use with caution!
        """
        if not self._connected:
            logger.warning("Redis not connected, skipping clear all")
            return
        
        try:
            await self._redis.flushdb()
            logger.warning("Cleared all cache entries")
        except RedisError as e:
            logger.error(f"Failed to clear all cache: {e}")
    
    def cache_key(self, prefix: str, **kwargs) -> str:
        """
        Generate cache key from parameters.
        
        Args:
            prefix: Key prefix (e.g., "script_analysis", "image_gen")
            **kwargs: Parameters to include in the key
            
        Returns:
            Generated cache key
        """
        # Sort kwargs for consistent key generation
        params_str = json.dumps(kwargs, sort_keys=True, ensure_ascii=False)
        hash_str = hashlib.md5(params_str.encode()).hexdigest()
        return f"{prefix}:{hash_str}"
    
    async def exists(self, key: str) -> bool:
        """
        Check if key exists in cache.
        
        Args:
            key: Cache key
            
        Returns:
            True if key exists, False otherwise
        """
        if not self._connected:
            return False
        
        try:
            return await self._redis.exists(key) > 0
        except RedisError as e:
            logger.error(f"Redis exists error for key {key}: {e}")
            return False
    
    async def get_ttl(self, key: str) -> Optional[int]:
        """
        Get remaining TTL for a key.
        
        Args:
            key: Cache key
            
        Returns:
            Remaining TTL in seconds, or None if key doesn't exist
        """
        if not self._connected:
            return None
        
        try:
            ttl = await self._redis.ttl(key)
            return ttl if ttl > 0 else None
        except RedisError as e:
            logger.error(f"Redis TTL error for key {key}: {e}")
            return None
    
    def get_stats(self) -> CacheStats:
        """
        Get cache statistics.
        
        Returns:
            CacheStats object with hit/miss/eviction counts
        """
        return self._stats
    
    async def get_with_protection(
        self, 
        key: str, 
        loader: Optional[Callable] = None,
        strategy: Optional[CacheStrategy] = None,
        ttl: int = 3600,
        null_ttl: int = 60
    ) -> Optional[Any]:
        """
        Get value from cache with penetration protection.
        
        Uses Bloom Filter to prevent queries for non-existent keys.
        Caches null values to prevent repeated database queries.
        
        Args:
            key: Cache key
            loader: Optional function to load data if not in cache
            strategy: Cache strategy to use
            ttl: TTL for cached values
            null_ttl: TTL for null values (shorter to allow recovery)
            
        Returns:
            Cached or loaded value, or None
        """
        # Check bloom filter first
        if self._bloom_filter and not await self._bloom_filter.contains(key):
            logger.debug(f"Bloom filter miss: {key} definitely not in cache")
            self._stats.misses += 1
            
            if loader:
                # Load data and cache it
                value = await loader()
                if value is not None:
                    await self.set(key, value, ttl, strategy)
                    await self._bloom_filter.add(key)
                else:
                    # Cache null value with shorter TTL
                    await self.set(key, {"__null__": True}, null_ttl, strategy)
                return value
            return None
        
        # Try to get from cache
        value = await self.get(key, strategy)
        
        # Check if it's a cached null value
        if value and isinstance(value, dict) and value.get("__null__") is True:
            logger.debug(f"Cached null value: {key}")
            return None
        
        if value is not None:
            return value
        
        # Cache miss - load if loader provided
        if loader:
            value = await loader()
            if value is not None:
                await self.set(key, value, ttl, strategy)
                if self._bloom_filter:
                    await self._bloom_filter.add(key)
            else:
                # Cache null value
                await self.set(key, {"__null__": True}, null_ttl, strategy)
            return value
        
        return None
    
    async def get_with_lock(
        self,
        key: str,
        loader: Callable,
        ttl: int = 3600,
        lock_timeout: int = 10,
        strategy: Optional[CacheStrategy] = None
    ) -> Optional[Any]:
        """
        Get value from cache with stampede protection using distributed lock.
        
        When cache expires, only one request loads the data while others wait.
        Implements double-check locking pattern.
        
        Args:
            key: Cache key
            loader: Function to load data if not in cache
            ttl: TTL for cached value
            lock_timeout: Maximum time to hold the lock (seconds)
            strategy: Cache strategy to use
            
        Returns:
            Cached or loaded value
        """
        # First check - try to get from cache
        value = await self.get(key, strategy)
        if value is not None:
            return value
        
        # Cache miss - acquire lock
        lock_key = f"lock:{key}"
        lock_acquired = False
        
        try:
            # Try to acquire lock with timeout
            lock_acquired = await self._acquire_lock(lock_key, lock_timeout)
            
            if lock_acquired:
                # Double check - another process might have loaded it
                value = await self.get(key, strategy)
                if value is not None:
                    return value
                
                # Load data
                logger.debug(f"Loading data for key: {key}")
                value = await loader()
                
                # Cache the result
                if value is not None:
                    await self.set(key, value, ttl, strategy)
                
                return value
            else:
                # Failed to acquire lock - wait and retry
                logger.debug(f"Waiting for lock on key: {key}")
                await asyncio.sleep(0.1)  # Brief wait
                
                # Try to get from cache again (should be loaded by lock holder)
                value = await self.get(key, strategy)
                if value is not None:
                    return value
                
                # Still not available - load it ourselves
                value = await loader()
                if value is not None:
                    await self.set(key, value, ttl, strategy)
                
                return value
                
        finally:
            if lock_acquired:
                await self._release_lock(lock_key)
    
    async def _acquire_lock(self, lock_key: str, timeout: int) -> bool:
        """
        Acquire a distributed lock.
        
        Args:
            lock_key: Lock key
            timeout: Lock timeout in seconds
            
        Returns:
            True if lock acquired, False otherwise
        """
        if not self._connected:
            return False
        
        try:
            # Use SET NX EX to atomically set lock with expiration
            result = await self._redis.set(
                lock_key,
                "1",
                nx=True,  # Only set if not exists
                ex=timeout  # Expiration time
            )
            return result is not None
        except RedisError as e:
            logger.error(f"Failed to acquire lock {lock_key}: {e}")
            return False
    
    async def _release_lock(self, lock_key: str):
        """
        Release a distributed lock.
        
        Args:
            lock_key: Lock key
        """
        if not self._connected:
            return
        
        try:
            await self._redis.delete(lock_key)
        except RedisError as e:
            logger.error(f"Failed to release lock {lock_key}: {e}")
    
    async def clear_stats(self):
        """Reset cache statistics"""
        self._stats = CacheStats()


class CacheWarmer:
    """
    Cache warming service to preload frequently accessed data.
    
    Warms up cache on system startup or after cache clear.
    """
    
    def __init__(self, cache_service: CacheService):
        """
        Initialize cache warmer.
        
        Args:
            cache_service: CacheService instance to warm
        """
        self.cache = cache_service
    
    async def warm_up(self):
        """
        Warm up cache with frequently accessed data.
        
        This method should be called on application startup.
        """
        logger.info("Starting cache warm-up...")
        
        try:
            # Warm up model configurations
            await self._warm_model_configs()
            
            # Warm up system configurations
            await self._warm_system_configs()
            
            logger.info("Cache warm-up completed successfully")
        except Exception as e:
            logger.error(f"Cache warm-up failed: {e}")
    
    async def _warm_model_configs(self):
        """Preload model configurations"""
        try:
            from src.services.provider.registry import ModelRegistry
            
            # Load all registered service configurations
            configs = ModelRegistry.get_all_configs()
            
            # Group by provider for caching
            provider_configs = {}
            for service_id, config in configs.items():
                provider = config.get("provider")
                if provider:
                    if provider not in provider_configs:
                        provider_configs[provider] = []
                    provider_configs[provider].append(config)
            
            # Cache each provider's config
            for provider, services in provider_configs.items():
                try:
                    # Cache key format matching the original implementation expectation
                    # (though original code seemed to expect the raw config dict structure, 
                    #  here we might be changing what's stored slightly, but it's more comprehensive)
                    # If the frontend expects a specific structure, we might need to adjust.
                    # Assuming consumers just need the list of services for the provider.
                    cache_key = f"model_config:{provider}"
                    await self.cache.set(cache_key, services, ttl=86400)  # 24 hours
                    logger.debug(f"Warmed up model config for provider: {provider}")
                except Exception as e:
                    logger.warning(f"Failed to warm up {provider} config: {e}")
                    
        except Exception as e:
            logger.error(f"Failed to warm model configs: {e}")
    
    async def _warm_system_configs(self):
        """Preload system configurations"""
        try:
            from src.config import settings
            
            # Cache common settings
            system_config = {
                'storage_type': settings.STORAGE_TYPE,
                'redis_enabled': settings.REDIS_ENABLED,
                'data_dir': settings.DATA_DIR
            }
            
            cache_key = "system_config"
            await self.cache.set(cache_key, system_config, ttl=86400)  # 24 hours
            logger.debug("Warmed up system config")
        except Exception as e:
            logger.error(f"Failed to warm system configs: {e}")


# Global cache service instance
_cache_service: Optional[CacheService] = None
_cache_warmer: Optional[CacheWarmer] = None


def get_cache_service() -> CacheService:
    """
    Get the global cache service instance.
    
    Returns:
        CacheService instance
    """
    global _cache_service
    if _cache_service is None:
        from src.config.settings import REDIS_URL
        _cache_service = CacheService(redis_url=REDIS_URL)
    return _cache_service


def get_cache_warmer() -> CacheWarmer:
    """
    Get the global cache warmer instance.
    
    Returns:
        CacheWarmer instance
    """
    global _cache_warmer
    if _cache_warmer is None:
        _cache_warmer = CacheWarmer(get_cache_service())
    return _cache_warmer


def cached(prefix: str, ttl: int = 3600, key_builder: Optional[Callable] = None):
    """
    Decorator for caching function results.
    
    Args:
        prefix: Cache key prefix
        ttl: Time to live in seconds
        key_builder: Optional custom function to build cache key from args/kwargs
        
    Example:
        @cached(prefix="script_analysis", ttl=7200)
        async def analyze_script(novel_text: str, model: str) -> dict:
            # ... expensive operation ...
            return result
    """
    def decorator(func: Callable):
        @wraps(func)
        async def wrapper(*args, **kwargs):
            cache = get_cache_service()
            
            # Build cache key
            if key_builder:
                cache_key = key_builder(*args, **kwargs)
            else:
                # Default: use function name and kwargs
                cache_key = cache.cache_key(
                    prefix=f"{prefix}:{func.__name__}",
                    **kwargs
                )
            
            # Try to get from cache
            cached_result = await cache.get(cache_key)
            if cached_result is not None:
                logger.info(f"Cache hit for {func.__name__}: {cache_key}")
                return cached_result
            
            # Execute function
            logger.info(f"Cache miss for {func.__name__}: {cache_key}")
            result = await func(*args, **kwargs)
            
            # Store in cache
            await cache.set(cache_key, result, ttl)
            
            return result
        
        return wrapper
    return decorator
