"""Redis cache backend for Swifty.

Provides a Redis-backed caching implementation with automatic reconnection,
environment-aware key prefixing, and pickle-based value serialization.
"""

import base64
import os
import pickle
from io import BytesIO
from functools import wraps
from dataclasses import dataclass
from typing import Any, Callable, Dict, Optional, Tuple, Union

from django.conf import settings

from swifty.cache.exceptions import NotInCache
from swifty.redis.constants import RedisCacheEvents
from swifty.logging.logger import SwiftyLoggerMixin
from swifty.utils.mapper import getpath

try:
    import redis
except ImportError:
    redis = None  # type: ignore


@dataclass(frozen=True)
class ClearCacheScope:
    """Defines cache clearing scopes."""

    METHOD: str = "METHOD"
    PATTERN: str = "PATTERN"
    ALL: str = "ALL"


def _require_redis():
    """Raise ImportError if redis is not installed."""
    if redis is None:
        raise ImportError(
            "Redis is required for RedisCache. "
            "Install it with: pip install django-swifty[redis]"
        )


def _redis_reconnect(func: Callable) -> Callable:
    """Decorator to automatically reconnect Redis on connection errors."""

    @wraps(func)
    def wrapper(self, *args, **kwargs):
        try:
            return func(self, *args, **kwargs)
        except (redis.ConnectionError, redis.TimeoutError):
            self.logger.error(RedisCacheEvents.TIMEOUT_CONNECTION, exc_info=True)
            self.reconnect()
            return func(self, *args, **kwargs)

    return wrapper


class RedisCache(SwiftyLoggerMixin):
    """Redis cache backend with logging and automatic reconnection.

    Features:
        - Automatic reconnection on connection errors
        - Environment-aware key prefixing
        - Configurable TTL and vary-by parameters
        - Pickle-based serialization for arbitrary Python objects
        - Pipeline-based batch deletions

    Configuration:
        Set ``REDIS_CONNECTION_POOL`` in Django settings::

            REDIS_CONNECTION_POOL = {
                "pool_1": redis.ConnectionPool(host="localhost", port=6379, db=1),
            }
    """

    DEFAULT_REDIS_CACHE_DB: int = int(os.environ.get("DEFAULT_REDIS_CACHE_DB", 1))
    DEFAULT_REDIS_CACHE_PATTERN: Dict[str, Union[str, int]] = {
        "prefix": "DEFAULT",
        "ttl": 3600,
        "vary_by": [""],
        "db": DEFAULT_REDIS_CACHE_DB,
    }
    REDIS_SESSION_CACHE: str = getattr(
        settings, "REDIS_SESSION_CACHE", "django.contrib.sessions.cache"
    )

    _redis_connections: Dict[int, Any] = {}

    @classmethod
    def redis_connection(cls, pool) -> "redis.StrictRedis":
        """Establish a Redis connection using the provided connection pool."""
        _require_redis()
        return redis.StrictRedis(connection_pool=pool)

    @classmethod
    def connect(cls, **kwargs) -> "redis.StrictRedis":
        """Get or create a Redis connection for the specified cache DB.

        Caches connections per db index to avoid creating duplicate connections.
        """
        cache_db = getpath(kwargs, "cache_db", cls.DEFAULT_REDIS_CACHE_DB)

        if cache_db not in cls._redis_connections:
            _require_redis()
            pool = settings.REDIS_CONNECTION_POOL.get(f"pool_{cache_db}")
            cls._redis_connections[cache_db] = cls.redis_connection(pool=pool)

        return cls._redis_connections[cache_db]

    @classmethod
    def close(cls) -> None:
        """Close all Redis connections."""
        for conn in cls._redis_connections.values():
            conn.close()
        cls._redis_connections.clear()

    @classmethod
    def reconnect(cls, cache_db: Optional[int] = None) -> "redis.StrictRedis":
        """Reconnect to Redis, disconnecting the old connection.

        Args:
            cache_db: Specific db to reconnect. If None, reconnects all.
        """
        if cache_db is not None and cache_db in cls._redis_connections:
            cls._redis_connections[cache_db].connection_pool.disconnect()
            del cls._redis_connections[cache_db]
        else:
            for conn in cls._redis_connections.values():
                conn.connection_pool.disconnect()
            cls._redis_connections.clear()
        return cls.connect(cache_db=cache_db or cls.DEFAULT_REDIS_CACHE_DB)

    def __init__(
        self,
        parent: Any,
        attr: str,
        ttl: Optional[int] = None,
        static_key: Optional[str] = None,
        vary_by: Tuple[str, ...] = (),
        context_getter: Optional[Callable] = None,
    ) -> None:
        self.parent = parent
        self.attr = attr
        self.ttl = ttl
        self.static_key = static_key
        self._vary_by = vary_by
        self.context_getter = context_getter

    def __contains__(self, item: str) -> bool:
        return getpath(self, item, False)

    def add(self, *args: Any, **kwargs: Any) -> Any:
        """Alias for :meth:`set`."""
        return self.set(*args, **kwargs)

    def _get_key(self, *args: Any, **kwargs: Any) -> str:
        """Generate the cache key."""
        return self.static_key or self._generate_key(*args, **kwargs)

    def _generate_key_prefix(self) -> str:
        """Generate the key prefix based on environment and pattern."""
        pattern = self._get_parent_pattern()
        env_prefix = os.environ.get("ENVIRONMENT_NAME")
        return "_".join(filter(None, [env_prefix, pattern["prefix"]]))

    def _get_parent_pattern(self) -> Dict[str, Union[str, int]]:
        """Get the cache pattern from the parent object."""
        return getpath(
            self.parent, "redis_cache_pattern", self.DEFAULT_REDIS_CACHE_PATTERN
        )

    def _generate_key(self, *args: Any, **kwargs: Any) -> str:
        """Generate a unique cache key based on class, method, vary-by, and args."""
        parent_name = self.parent.__class__.__name__
        vary_by = self._get_vary_by()
        parameters = (
            base64.b64encode(
                bytes(str(args + (sorted(kwargs.items()),)), encoding="utf-8")
            ).decode("utf-8")
            if args or kwargs
            else None
        )
        return "_".join(
            filter(
                None,
                [
                    self._generate_key_prefix(),
                    parent_name,
                    self.attr,
                    vary_by,
                    parameters,
                ],
            )
        )

    def _get_vary_by(self) -> str:
        """Construct the vary-by string from context."""
        context = self.context
        if isinstance(context, str):
            return context
        if context and self.vary_by:
            return "_".join(
                str(context.get(key)) for key in self.vary_by if context.get(key)
            )
        self.logger.warning(
            f"{RedisCacheEvents.NO_CONTEXT_FOUND} {self.parent.__class__.__name__}"
        )
        return ""

    @property
    def vary_by(self) -> Tuple[str, ...]:
        """Get the vary-by keys."""
        return self._vary_by or self._get_parent_pattern().get("vary_by")

    @property
    def context(self) -> Any:
        """Get context from the parent object."""
        if callable(self.context_getter):
            return self.context_getter(self.parent)
        return (
            getattr(self.parent, self.context_getter, {})
            if isinstance(self.context_getter, str)
            else getattr(self.parent, "context", {})
        )

    def get(self, *args: Any, **kwargs: Any) -> Any:
        """Retrieve a value from the cache.

        Raises:
            NotInCache: If the key is not found or deserialization fails.
        """
        key = self._get_key(*args, **kwargs)
        try:
            value = self._get(key)
            return pickle.load(BytesIO(value))  # type: ignore
        except NotInCache:
            raise
        except Exception as error:
            self.logger.error(
                event_name=RedisCacheEvents.GET_CACHE_ERROR,
                key=key or "<could not compute cache key>",
                method=self.attr,
                parent=self.parent.__class__.__name__,
                error=error,
                exc_info=True,
            )
            raise NotInCache from error

    def set(self, value: Any, *args: Any, **kwargs: Any) -> Any:
        """Store a value in the cache.

        Args:
            value: The value to cache (must be picklable).

        Returns:
            The original value (pass-through for chaining).
        """
        key = self._get_key(*args, **kwargs)
        encoded = pickle.dumps(value)
        self._set(key, encoded)
        return value

    def delete(self, *args: Any, **kwargs: Any) -> Optional[Any]:
        """Delete cached value(s).

        Supports special scopes via ``ClearCacheScope``:
            - ``ClearCacheScope.METHOD``: deletes all entries for this method.
        """
        if (args, kwargs) == ((ClearCacheScope.METHOD,), {}):
            return self._delete_method()
        if (args, kwargs) == ((ClearCacheScope.PATTERN,), {}):
            raise ValueError("Cannot pop cache pattern from instance methods")
        if (args, kwargs) == ((ClearCacheScope.ALL,), {}):
            raise ValueError("Cannot clear cache from instance methods")
        key = self._get_key(*args, **kwargs)
        return self._delete_value(key)

    def _get_cache_db(self) -> int:
        """Get the cache DB index from parent pattern."""
        if hasattr(self.parent, "redis_cache_pattern"):
            return self.parent.redis_cache_pattern.get(
                "db", self.DEFAULT_REDIS_CACHE_DB
            )
        return self.DEFAULT_REDIS_CACHE_DB

    @_redis_reconnect
    def _get(self, key: str) -> bytes:
        """Retrieve a raw value from Redis."""
        connection = self.connect(cache_db=self._get_cache_db())
        value = connection.get(key)
        if value is None:
            raise NotInCache
        return value

    @_redis_reconnect
    def _set(self, key: str, value: bytes) -> None:
        """Store a raw value in Redis with TTL using atomic SETEX."""
        connection = self.connect(
            cache_db=getpath(
                self.parent,
                "redis_cache_pattern.db",
                self.DEFAULT_REDIS_CACHE_PATTERN["db"],
            )
        )
        ttl = self.ttl or self.DEFAULT_REDIS_CACHE_PATTERN["ttl"]
        if ttl:
            connection.setex(key, ttl, value)
        else:
            connection.set(key, value)

    @_redis_reconnect
    def _delete_value(self, key: str) -> None:
        """Delete matching key(s) from Redis using pipeline for batch deletes."""
        connection = self.connect(cache_db=self._get_cache_db())
        keys = list(connection.scan_iter(key))
        if keys:
            pipe = connection.pipeline()
            for full_key in keys:
                pipe.delete(full_key)
            pipe.execute()

    @_redis_reconnect
    def _delete_method(self) -> None:
        """Delete all cache entries for a specific method using pipeline."""
        connection = self.connect(cache_db=self._get_cache_db())
        full_pattern = (
            "_".join(
                filter(
                    None,
                    [
                        os.environ.get("ENVIRONMENT_NAME", ""),
                        self._get_parent_pattern()["prefix"],
                        self.parent.__class__.__name__,
                        self.attr,
                    ],
                )
            )
            + "*"
        )

        keys = list(connection.scan_iter(full_pattern))
        if keys:
            pipe = connection.pipeline()
            for key in keys:
                pipe.delete(key)
            pipe.execute()
