"""Redis connection utilities.

Provides connection factory functions for standalone, Sentinel, and Cluster
Redis deployments, with optional async support and automatic connection caching.
"""

from __future__ import annotations

import asyncio
import inspect
import logging
import time
from typing import Any
from urllib.parse import ParseResult, urlparse

import redis as _redis_sync

from open_webui.env import (
    REDIS_CLUSTER,
    REDIS_HEALTH_CHECK_INTERVAL,
    REDIS_RECONNECT_DELAY,
    REDIS_SENTINEL_HOSTS,
    REDIS_SENTINEL_MAX_RETRY_COUNT,
    REDIS_SENTINEL_PORT,
    REDIS_SOCKET_CONNECT_TIMEOUT,
    REDIS_SOCKET_KEEPALIVE,
    REDIS_URL,
)

log = logging.getLogger(__name__)

_ACCEPTED_SCHEMES = frozenset({'redis', 'rediss'})
_SENTINEL_RETRYABLE = (
    _redis_sync.exceptions.ConnectionError,
    _redis_sync.exceptions.ReadOnlyError,
)
_FACTORY_METHODS = frozenset({'pipeline', 'pubsub', 'monitor', 'client', 'transaction'})
_CONNECTION_POOL: dict[tuple, Any] = {}


def parse_redis_url(url: str) -> dict[str, Any]:
    """Break a ``redis://`` URL into its parts: service, port, db, username, password."""
    parts: ParseResult = urlparse(url)
    if parts.scheme not in _ACCEPTED_SCHEMES:
        raise ValueError(f"Invalid Redis URL scheme '{parts.scheme}'; expected 'redis' or 'rediss'.")
    return {
        'service': parts.hostname or 'mymaster',
        'port': parts.port or 6379,
        'db': int(parts.path.lstrip('/') or '0'),
        'username': parts.username or None,
        'password': parts.password or None,
    }


parse_redis_service_url = parse_redis_url


def get_sentinels_from_env(
    hosts_csv: str | None,
    port: str | int | None,
) -> list[tuple[str, int]]:
    """Turn a comma-separated host string into ``[(host, port), …]``."""
    if not hosts_csv:
        return []
    resolved_port = int(port) if port else 26379
    return [(host.strip(), resolved_port) for host in hosts_csv.split(',') if host.strip()]


def build_sentinel_url(
    base_url: str,
    hosts_csv: str,
    port: str | int,
) -> str:
    """Construct a ``redis+sentinel://`` connection string.

    ``base_url`` supplies credentials, db index, and master service name.
    ``hosts_csv`` is a comma-separated list of sentinel hostnames.
    """
    cfg = parse_redis_url(base_url)
    auth = ''
    if cfg['username'] or cfg['password']:
        auth = f'{cfg["username"] or ""}:{cfg["password"] or ""}@'
    nodes = ','.join(f'{host.strip()}:{port}' for host in hosts_csv.split(',') if host.strip())
    return f'redis+sentinel://{auth}{nodes}/{cfg["db"]}/{cfg["service"]}'


def get_redis_client(async_mode: bool = False) -> Any | None:
    """Create a Redis connection using settings from environment variables.

    Returns ``None`` when Redis is not configured or the connection fails.
    """
    sentinel_list = get_sentinels_from_env(REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT)
    if not REDIS_URL and not sentinel_list:
        return None
    try:
        return get_redis_connection(
            REDIS_URL,
            redis_sentinels=sentinel_list,
            redis_cluster=REDIS_CLUSTER,
            async_mode=async_mode,
        )
    except Exception:
        log.debug('Could not establish Redis connection', exc_info=True)
        return None


# ---------------------------------------------------------------------------
# Sentinel proxy with automatic failover retry
# ---------------------------------------------------------------------------


class SentinelRedisProxy:
    """Transparent proxy that re-resolves the Sentinel master on connection errors.

    Every call (sync or async) is wrapped with retry logic so that transient
    Sentinel failovers are handled without caller intervention.
    """

    def __init__(
        self,
        sentinel: Any,
        service_name: str,
        *,
        async_mode: bool = True,
    ) -> None:
        self._sentinel = sentinel
        self._service_name = service_name
        self._async_mode = async_mode

    def __getattr__(self, name: str) -> Any:
        """Proxy attribute access with automatic Sentinel failover retry."""
        current_master = self._sentinel.master_for(self._service_name)
        original = getattr(current_master, name)

        # Non-callable or factory attributes pass through without wrapping.
        if not callable(original) or name in _FACTORY_METHODS:
            return original

        # Select the retry wrapper matching the execution mode.
        if not self._async_mode:
            return self._wrap_sync(name)
        return self._wrap_async(name, original)

    def _resolve_master(self) -> Any:
        """Ask Sentinel for the current master connection."""
        return self._sentinel.master_for(self._service_name)

    def _should_retry(self, attempt: int) -> bool:
        return attempt < REDIS_SENTINEL_MAX_RETRY_COUNT - 1

    def _log_retry(self, exc: Exception, attempt: int) -> None:
        log.debug(
            'Sentinel failover (%s) — retry %d/%d',
            type(exc).__name__,
            attempt + 1,
            REDIS_SENTINEL_MAX_RETRY_COUNT,
        )

    def _log_exhausted(self, exc: Exception) -> None:
        log.error(
            'Redis operation failed after %d retries: %s',
            REDIS_SENTINEL_MAX_RETRY_COUNT,
            exc,
        )

    # -- async wrappers -----------------------------------------------------

    def _wrap_async(self, name: str, attr: Any) -> Any:
        if inspect.isasyncgenfunction(attr):
            return self._wrap_async_gen(name)
        return self._wrap_async_call(name)

    def _wrap_async_gen(self, name: str) -> Any:
        proxy = self

        def wrapper(*args: Any, **kwargs: Any) -> Any:
            async def _inner():
                for attempt in range(REDIS_SENTINEL_MAX_RETRY_COUNT):
                    try:
                        method = getattr(proxy._resolve_master(), name)
                        async for value in method(*args, **kwargs):
                            yield value
                        return
                    except _SENTINEL_RETRYABLE as exc:
                        if proxy._should_retry(attempt):
                            proxy._log_retry(exc, attempt)
                            if REDIS_RECONNECT_DELAY:
                                time.sleep(REDIS_RECONNECT_DELAY / 1000)
                            continue
                        proxy._log_exhausted(exc)
                        raise

            return _inner()

        return wrapper

    def _wrap_async_call(self, name: str) -> Any:
        proxy = self

        async def wrapper(*args: Any, **kwargs: Any) -> Any:
            for attempt in range(REDIS_SENTINEL_MAX_RETRY_COUNT):
                try:
                    method = getattr(proxy._resolve_master(), name)
                    result = method(*args, **kwargs)
                    if inspect.iscoroutine(result):
                        return await result
                    return result
                except _SENTINEL_RETRYABLE as exc:
                    if proxy._should_retry(attempt):
                        proxy._log_retry(exc, attempt)
                        if REDIS_RECONNECT_DELAY:
                            await asyncio.sleep(REDIS_RECONNECT_DELAY / 1000)
                        continue
                    proxy._log_exhausted(exc)
                    raise

        return wrapper

    # -- sync wrapper -------------------------------------------------------

    def _wrap_sync(self, name: str) -> Any:
        proxy = self

        def wrapper(*args: Any, **kwargs: Any) -> Any:
            for attempt in range(REDIS_SENTINEL_MAX_RETRY_COUNT):
                try:
                    method = getattr(proxy._resolve_master(), name)
                    return method(*args, **kwargs)
                except _SENTINEL_RETRYABLE as exc:
                    if proxy._should_retry(attempt):
                        proxy._log_retry(exc, attempt)
                        if REDIS_RECONNECT_DELAY:
                            time.sleep(REDIS_RECONNECT_DELAY / 1000)
                        continue
                    proxy._log_exhausted(exc)
                    raise

        return wrapper


# ---------------------------------------------------------------------------
# Connection factory
# ---------------------------------------------------------------------------


def _socket_options() -> dict[str, Any]:
    """Collect optional socket-level kwargs once instead of repeating them."""
    opts: dict[str, Any] = {}
    if REDIS_SOCKET_CONNECT_TIMEOUT is not None:
        opts['socket_connect_timeout'] = REDIS_SOCKET_CONNECT_TIMEOUT
    if REDIS_SOCKET_KEEPALIVE:
        opts['socket_keepalive'] = True
    if REDIS_HEALTH_CHECK_INTERVAL:
        opts['health_check_interval'] = REDIS_HEALTH_CHECK_INTERVAL
    return opts


def _build_sentinel(
    redis_module: Any,
    url: str,
    sentinels: list[tuple[str, int]],
    decode_responses: bool,
    async_mode: bool,
) -> SentinelRedisProxy:
    """Create a SentinelRedisProxy from a redis URL and sentinel list."""
    cfg = parse_redis_url(url)
    sentinel = redis_module.sentinel.Sentinel(
        sentinels,
        port=cfg['port'],
        db=cfg['db'],
        username=cfg['username'],
        password=cfg['password'],
        decode_responses=decode_responses,
        socket_connect_timeout=REDIS_SOCKET_CONNECT_TIMEOUT,
        **{k: v for k, v in _socket_options().items() if k != 'socket_connect_timeout'},
    )
    return SentinelRedisProxy(sentinel, cfg['service'], async_mode=async_mode)


def get_redis_connection(
    redis_url: str | None,
    redis_sentinels: list[tuple[str, int]] | None = None,
    redis_cluster: bool = False,
    async_mode: bool = False,
    decode_responses: bool = True,
) -> Any | None:
    """Return a cached Redis connection (or create one).

    Supports three topologies in order of precedence:
    1. **Sentinel** — when ``redis_sentinels`` is non-empty.
    2. **Cluster** — when ``redis_cluster`` is True.
    3. **Standalone** — plain ``redis://`` connection.
    """
    cache_key = (
        redis_url,
        tuple(redis_sentinels) if redis_sentinels else (),
        async_mode,
        decode_responses,
    )
    if cache_key in _CONNECTION_POOL:
        return _CONNECTION_POOL[cache_key]

    extra = _socket_options()
    connection: Any = None

    # Pick the right redis module for sync vs async.
    if async_mode:
        import redis.asyncio as redis_mod
    else:
        import redis as redis_mod  # type: ignore[no-redef]

    if redis_sentinels:
        connection = _build_sentinel(redis_mod, redis_url, redis_sentinels, decode_responses, async_mode)
    elif redis_cluster:
        if not redis_url:
            raise ValueError('Redis URL is required for cluster mode.')
        connection = redis_mod.cluster.RedisCluster.from_url(
            redis_url,
            decode_responses=decode_responses,
            **extra,
        )
    elif redis_url:
        factory = getattr(redis_mod, 'from_url', None) or redis_mod.Redis.from_url
        connection = factory(redis_url, decode_responses=decode_responses, **extra)

    _CONNECTION_POOL[cache_key] = connection
    return connection
