"""
Enterprise Redis Service for empowernow_common.

Production-ready Redis service with connection pooling, circuit breakers,
health monitoring, and comprehensive error handling. Based on IdP redis_service.py
pattern but generalized for use across all empowernow services.
"""

import asyncio
import json
import logging
import os
import random
import time
import weakref
from dataclasses import dataclass, field
from datetime import datetime, UTC
from enum import Enum
from functools import wraps
from typing import Any, Dict, Optional

try:
    import redis.asyncio as redis
    REDIS_AVAILABLE = True
except ImportError:
    REDIS_AVAILABLE = False

logger = logging.getLogger(__name__)


class CircuitState(Enum):
    """Circuit breaker states."""
    CLOSED = "closed"
    OPEN = "open"
    HALF_OPEN = "half_open"


@dataclass
class CircuitBreaker:
    """Production-grade circuit breaker for Redis operations."""
    failure_threshold: int = 5
    recovery_timeout: float = 60.0
    expected_exception: type = Exception

    failure_count: int = field(default=0, init=False)
    last_failure_time: float = field(default=0.0, init=False)
    state: CircuitState = field(default=CircuitState.CLOSED, init=False)
    _lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False)

    async def call(self, func, *args, **kwargs):
        """Execute function with circuit breaker protection."""
        async with self._lock:
            if self.state == CircuitState.OPEN:
                if time.time() - self.last_failure_time > self.recovery_timeout:
                    self.state = CircuitState.HALF_OPEN
                    self.failure_count = 0
                else:
                    raise Exception("Circuit breaker is OPEN – short-circuiting call")

        try:
            result = await func(*args, **kwargs)
            async with self._lock:
                if self.state == CircuitState.HALF_OPEN:
                    self.state = CircuitState.CLOSED
                    self.failure_count = 0
            return result
        except Exception as exc:
            is_expected = isinstance(exc, self.expected_exception)
            if is_expected:
                async with self._lock:
                    self.failure_count += 1
                    self.last_failure_time = time.time()
                    if self.failure_count >= self.failure_threshold:
                        self.state = CircuitState.OPEN
                        logger.error(
                            "Circuit breaker opened after %d failures (func=%s)",
                            self.failure_count,
                            func.__name__,
                            extra={"component": "redis", "circuit_state": "open"},
                        )
            raise


def retry_with_circuit_breaker(
    max_retries: int = 3,
    initial_backoff: float = 0.25,
    max_backoff: float = 2.0,
    backoff_factor: float = 2.0,
    circuit_breaker: Optional[CircuitBreaker] = None,
):
    """Decorator combining circuit-breaker and exponential backoff."""
    if circuit_breaker is None:
        circuit_breaker = CircuitBreaker()

    def decorator(func):
        @wraps(func)
        async def wrapper(*args, **kwargs):
            backoff = initial_backoff
            for attempt in range(max_retries):
                try:
                    return await circuit_breaker.call(func, *args, **kwargs)
                except Exception as exc:
                    if (
                        attempt == max_retries - 1
                        or circuit_breaker.state == CircuitState.OPEN
                    ):
                        raise
                    jitter = backoff * random.uniform(0, 0.1)
                    await asyncio.sleep(backoff + jitter)
                    backoff = min(backoff * backoff_factor, max_backoff)
        return wrapper
    return decorator


def handle_redis_errors(default_value):
    """Decorator that catches Redis errors, logs with structured extras, and returns a default."""
    def decorator(func):
        @wraps(func)
        async def wrapper(*args, **kwargs):
            try:
                return await func(*args, **kwargs)
            except Exception as e:
                service = args[0]
                key = args[1] if len(args) > 1 else kwargs.get("key", "unknown")
                error_str = str(e).lower()
                extra = {
                    "component": "redis",
                    "operation": func.__name__,
                    "key": key,
                    "error": str(e),
                    "error_type": type(e).__name__,
                }
                if "too many" in error_str or "connection" in error_str:
                    extra["max_connections"] = service.max_connections
                    extra["pool_stats"] = service.get_pool_stats()
                logger.error("Redis %s error: %s", func.__name__, str(e), extra=extra)
                return default_value
        return wrapper
    return decorator


@dataclass(frozen=True)
class _PoolKey:
    """Immutable key for unique Redis pool configuration."""
    max_connections: int
    redis_url: str


class EnterpriseRedisService:
    """
    Enterprise-grade Redis service with connection pooling and circuit breakers.
    
    Features:
    - Per-event-loop connection pooling
    - Circuit breaker protection
    - Exponential backoff retry logic  
    - Health monitoring and metrics
    - Graceful error handling
    - FIPS-compliant operation
    """

    # Per-event-loop, per-config connection management
    # Outer dict keyed by event loop for cleanup, inner dict keyed by pool config
    _pools: "weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, Dict[_PoolKey, redis.ConnectionPool]]" = (
        weakref.WeakKeyDictionary()
    )
    _clients: "weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, Dict[_PoolKey, redis.Redis]]" = (
        weakref.WeakKeyDictionary()
    )
    _locks: "weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Lock]" = (
        weakref.WeakKeyDictionary()
    )
    _init_lock: Optional[asyncio.Lock] = None

    # Global circuit breaker
    _circuit_breaker = CircuitBreaker()

    def __init__(self, redis_url: str, max_connections: int = 20, key_prefix: str = ""):
        """
        Initialize enterprise Redis service.
        
        Args:
            redis_url: Redis connection URL
            max_connections: Maximum connections in pool
            key_prefix: Optional key prefix for namespacing
        """
        if not REDIS_AVAILABLE:
            raise ImportError("redis package not available - install with: pip install redis")
        
        self.redis_url = redis_url
        self.max_connections = max_connections
        self.key_prefix = key_prefix

    def _get_pool_key(self) -> _PoolKey:
        """Create a pool key for this instance's configuration."""
        return _PoolKey(max_connections=self.max_connections, redis_url=self.redis_url)

    async def get_redis(self, force_reconnect: bool = False) -> "redis.Redis":
        """Get or create Redis client with per-loop, per-config pooling."""
        loop = asyncio.get_running_loop()
        pool_key = self._get_pool_key()

        # Initialize per-loop lock
        init_lock = self._init_lock
        if init_lock is None:
            init_lock = asyncio.Lock()
            self._init_lock = init_lock

        async with init_lock:
            lock = self._locks.get(loop)
            if lock is None:
                lock = asyncio.Lock()
                self._locks[loop] = lock

        async with lock:
            # Initialize per-loop dicts if needed
            if loop not in self._pools:
                self._pools[loop] = {}
            if loop not in self._clients:
                self._clients[loop] = {}

            loop_pools = self._pools[loop]
            loop_clients = self._clients[loop]

            if pool_key not in loop_pools or pool_key not in loop_clients or force_reconnect:
                if force_reconnect and (pool_key in loop_pools or pool_key in loop_clients):
                    logger.info(
                        "Forcing Redis reconnection for current event loop and config",
                        extra={"max_connections": self.max_connections, "redis_url": self.redis_url},
                    )
                    try:
                        if pool_key in loop_clients:
                            await loop_clients[pool_key].close()
                        if pool_key in loop_pools:
                            await loop_pools[pool_key].disconnect()
                    except Exception as e:
                        logger.warning("Error closing existing Redis connection: %s", str(e))

                    loop_pools.pop(pool_key, None)
                    loop_clients.pop(pool_key, None)

                logger.info(
                    "Connecting to Redis at %s with max_connections=%d",
                    self.redis_url,
                    self.max_connections,
                )

                try:
                    loop_pools[pool_key] = redis.ConnectionPool.from_url(
                        self.redis_url,
                        max_connections=self.max_connections,
                        decode_responses=True,
                        health_check_interval=5,
                        socket_connect_timeout=float(os.environ.get("REDIS_SOCKET_CONNECT_TIMEOUT", "5")),
                        socket_keepalive=True,
                        retry_on_timeout=True,
                    )

                    loop_clients[pool_key] = redis.Redis(connection_pool=loop_pools[pool_key])

                    # Test connection
                    ping_timeout = float(os.environ.get("REDIS_PING_TIMEOUT", "6"))
                    ping_task = loop_clients[pool_key].ping()
                    ping_success = await asyncio.wait_for(ping_task, timeout=ping_timeout)

                    if ping_success:
                        logger.info(
                            "Connected to Redis at %s with max_connections=%d",
                            self.redis_url,
                            self.max_connections,
                        )
                    else:
                        raise Exception("Redis connection check failed")

                except Exception as e:
                    logger.error(
                        "Failed to connect to Redis: %s (max_connections=%d)",
                        str(e),
                        self.max_connections,
                    )
                    if pool_key in loop_clients:
                        try:
                            await loop_clients[pool_key].close()
                        except Exception:
                            pass
                        loop_clients.pop(pool_key, None)

                    if pool_key in loop_pools:
                        try:
                            await loop_pools[pool_key].disconnect()
                        except Exception:
                            pass
                        loop_pools.pop(pool_key, None)
                    raise

        return loop_clients[pool_key]

    def _make_key(self, key: str) -> str:
        """Create prefixed Redis key."""
        return f"{self.key_prefix}{key}" if self.key_prefix else key

    @retry_with_circuit_breaker()
    @handle_redis_errors(default_value=None)
    async def get(self, key: str) -> Optional[Any]:
        """Get value from Redis with JSON parsing."""
        redis_client = await self.get_redis()
        value = await redis_client.get(self._make_key(key))

        if value is None:
            return None

        try:
            return json.loads(value)
        except json.JSONDecodeError:
            return value

    @retry_with_circuit_breaker()
    @handle_redis_errors(default_value=False)
    async def set(self, key: str, value: Any, ttl: Optional[int] = None) -> bool:
        """Set value in Redis with optional TTL."""
        redis_client = await self.get_redis()

        if isinstance(value, (dict, list)):
            serialized_value = json.dumps(value, default=str)
        else:
            serialized_value = str(value)

        if ttl:
            result = await redis_client.setex(self._make_key(key), ttl, serialized_value)
        else:
            result = await redis_client.set(self._make_key(key), serialized_value)

        return bool(result)

    @retry_with_circuit_breaker()
    @handle_redis_errors(default_value=False)
    async def delete(self, key: str) -> bool:
        """Delete key from Redis."""
        redis_client = await self.get_redis()
        result = await redis_client.delete(self._make_key(key))
        return result > 0

    @retry_with_circuit_breaker()
    @handle_redis_errors(default_value=False)
    async def exists(self, key: str) -> bool:
        """Check if key exists in Redis."""
        redis_client = await self.get_redis()
        result = await redis_client.exists(self._make_key(key))
        return result > 0

    @retry_with_circuit_breaker()
    @handle_redis_errors(default_value=False)
    async def setex(self, key: str, ttl: int, value: Any) -> bool:
        """Set key with expiration time."""
        redis_client = await self.get_redis()

        if isinstance(value, (dict, list)):
            serialized_value = json.dumps(value, default=str)
        else:
            serialized_value = str(value)

        result = await redis_client.setex(self._make_key(key), ttl, serialized_value)
        return bool(result)

    @retry_with_circuit_breaker()
    @handle_redis_errors(default_value=False)
    async def expire(self, key: str, ttl: int) -> bool:
        """Set expiration time for existing key."""
        redis_client = await self.get_redis()
        result = await redis_client.expire(self._make_key(key), ttl)
        return bool(result)

    async def ping(self) -> bool:
        """Ping Redis server."""
        try:
            redis_client = await self.get_redis()
            result = await redis_client.ping()
            return bool(result)
        except Exception as e:
            logger.error("Redis ping error: %s", str(e))
            return False

    async def health_check(self, timeout: float = 1.0) -> Dict[str, Any]:
        """Comprehensive health check."""
        result = {
            "status": "unhealthy",
            "latency_ms": 0,
            "last_checked": datetime.now(UTC).isoformat(),
            "circuit_state": self._circuit_breaker.state.value,
            "error": None,
        }

        try:
            redis_client = await asyncio.wait_for(self.get_redis(), timeout=timeout)
            
            start_time = time.perf_counter()
            await redis_client.ping()
            end_time = time.perf_counter()
            
            latency_ms = (end_time - start_time) * 1000
            result.update({
                "status": "healthy",
                "latency_ms": round(latency_ms, 2),
                "error": None
            })

        except asyncio.TimeoutError:
            result.update({
                "error": "Connection timed out",
                "error_type": "TimeoutError"
            })
        except Exception as e:
            result.update({
                "error": str(e),
                "error_type": type(e).__name__
            })

        return result

    @classmethod
    async def close_all(cls) -> None:
        """Close all Redis connections across all configurations."""
        for loop, clients_dict in list(cls._clients.items()):
            for pool_key, client in list(clients_dict.items()):
                try:
                    await client.close()
                except Exception as e:
                    logger.warning("Error closing Redis client: %s", str(e))
                finally:
                    clients_dict.pop(pool_key, None)
            cls._clients.pop(loop, None)

        for loop, pools_dict in list(cls._pools.items()):
            for pool_key, pool in list(pools_dict.items()):
                try:
                    await pool.disconnect()
                except Exception as e:
                    logger.warning("Error disconnecting Redis pool: %s", str(e))
                finally:
                    pools_dict.pop(pool_key, None)
            cls._pools.pop(loop, None)

        logger.info("Closed all Redis clients & pools")

    def get_pool_stats(self) -> Dict[str, Any]:
        """Get connection pool statistics for this instance's configuration."""
        try:
            loop = asyncio.get_running_loop()
        except RuntimeError:
            return {"status": "no_event_loop", "requested_max_connections": self.max_connections}

        pools_dict = self._pools.get(loop)
        if pools_dict is None:
            return {"status": "no_pool", "requested_max_connections": self.max_connections}

        pool_key = self._get_pool_key()
        pool = pools_dict.get(pool_key)
        if pool is None:
            return {"status": "no_pool", "requested_max_connections": self.max_connections}

        try:
            return {
                "max_connections": pool.max_connections,
                "created_connections": len(pool._created_connections),
                "available_connections": len(pool._available_connections),
                "in_use_connections": len(pool._in_use_connections),
                "status": "healthy" if pool._available_connections else "exhausted",
                "requested_max_connections": self.max_connections,
            }
        except Exception as e:
            return {"status": "error", "error": str(e), "requested_max_connections": self.max_connections}


def create_redis_service(
    redis_url: Optional[str] = None, 
    max_connections: int = 20,
    key_prefix: str = ""
) -> EnterpriseRedisService:
    """
    Factory function to create Redis service with environment configuration.
    
    Args:
        redis_url: Redis URL (defaults to REDIS_URL env var)
        max_connections: Max connections in pool
        key_prefix: Key prefix for namespacing
        
    Returns:
        Configured EnterpriseRedisService
    """
    if redis_url is None:
        redis_url = (
            os.environ.get("REDIS__URL") or
            os.environ.get("REDIS_URL") or
            os.environ.get("REDIS_CONNECTION_URL")
        )
    
    if not redis_url:
        raise ValueError(
            "Redis URL not configured. Set REDIS_URL environment variable or pass redis_url parameter."
        )
    
    return EnterpriseRedisService(
        redis_url=redis_url,
        max_connections=max_connections, 
        key_prefix=key_prefix
    )