import random
import time
from urllib.parse import urlparse

import redis
from redis.backoff import ExponentialBackoff
from redis.exceptions import (
    BusyLoadingError,
    ConnectionError,
    TimeoutError,
    TryAgainError,
)
from redis.retry import Retry
from redis.sentinel import MasterNotFoundError, Sentinel

from pypeline.constants import (
    REDIS_SENTINEL_MASTER_NAME,
    DEFAULT_REDIS_SOCKET_CONNECT_TIMEOUT,
    DEFAULT_REDIS_SOCKET_TIMEOUT,
    DEFAULT_REDIS_RETRY_ON_TIMEOUT,
    DEFAULT_REDIS_SOCKET_KEEPALIVE,
    DEFAULT_REDIS_HEALTH_CHECK_INTERVAL,
)


TRANSIENT_REDIS_ERRORS = (
    ConnectionError,
    TimeoutError,
    MasterNotFoundError,
    BusyLoadingError,
    TryAgainError,
)

DEFAULT_TIMEOUT = 180.0
DEFAULT_RETRY_INTERVAL = 0.25
MAX_BACKOFF = 5.0


class ResilientRedisClient:
    def __init__(self, redis_url, decode_responses=False):
        self.redis_url = redis_url
        self.redis = self._get_redis_connection(decode_responses=decode_responses)

    def _get_redis_connection(self, decode_responses=False):
        retry = Retry(ExponentialBackoff(), 5)

        common_kwargs = {
            "socket_connect_timeout": DEFAULT_REDIS_SOCKET_CONNECT_TIMEOUT,
            "socket_timeout": DEFAULT_REDIS_SOCKET_TIMEOUT,
            "retry_on_timeout": DEFAULT_REDIS_RETRY_ON_TIMEOUT,
            "socket_keepalive": DEFAULT_REDIS_SOCKET_KEEPALIVE,
            "health_check_interval": DEFAULT_REDIS_HEALTH_CHECK_INTERVAL,
            "retry": retry,
            "retry_on_error": [
                ConnectionError,
                TimeoutError,
                MasterNotFoundError,
                BusyLoadingError,
                TryAgainError,
            ],
        }

        if REDIS_SENTINEL_MASTER_NAME is not None:
            parsed_redis_url = urlparse(self.redis_url)
            redis_sentinel = Sentinel(
                sentinels=[(parsed_redis_url.hostname, parsed_redis_url.port)],
                sentinel_kwargs=common_kwargs,
            )
            return redis_sentinel.master_for(
                REDIS_SENTINEL_MASTER_NAME,
                db=int(parsed_redis_url.path[1:]) if parsed_redis_url.path else 0,
                password=parsed_redis_url.password,
                decode_responses=decode_responses,
                **common_kwargs,
            )

        return redis.StrictRedis.from_url(
            self.redis_url,
            decode_responses=decode_responses,
            **common_kwargs,
        )

    def _sleep_with_exponential_backoff(
        self, attempt: int, retry_interval: float
    ) -> None:
        delay = retry_interval * (2**attempt)
        delay = min(delay, 5.0)
        jitter = min(delay * 0.2, 1.0)
        delay += random.uniform(0, jitter)
        time.sleep(delay)

    def call(
        self,
        fn,
        *,
        timeout: float = DEFAULT_TIMEOUT,
        retry_interval: float = DEFAULT_RETRY_INTERVAL,
        op_name: str = "redis operation",
        retry_on_result=None,
    ):
        deadline = time.monotonic() + timeout
        attempt = 0
        last_error = None

        while True:
            try:
                result = fn()

                if retry_on_result is not None and retry_on_result(result):
                    if time.monotonic() >= deadline:
                        raise TimeoutError(
                            f"{op_name} timed out after {timeout}s without success"
                        )
                    self._sleep_with_exponential_backoff(attempt, retry_interval)
                    attempt += 1
                    continue

                return result

            except TRANSIENT_REDIS_ERRORS as exc:
                last_error = exc
                if time.monotonic() >= deadline:
                    raise TimeoutError(
                        f"{op_name} failed after {timeout}s; last error: {exc}"
                    ) from exc

                self._sleep_with_exponential_backoff(attempt, retry_interval)
                attempt += 1

    def safe_set(
        self,
        *args,
        timeout=DEFAULT_TIMEOUT,
        retry_interval=DEFAULT_RETRY_INTERVAL,
        retry_on_result=None,
        **kwargs,
    ):
        return self.call(
            lambda: self.redis.set(*args, **kwargs),
            timeout=timeout,
            retry_interval=retry_interval,
            op_name="redis.set",
            retry_on_result=retry_on_result,
        )

    def safe_exists(
        self,
        *args,
        timeout=DEFAULT_TIMEOUT,
        retry_interval=DEFAULT_RETRY_INTERVAL,
        **kwargs,
    ):
        return self.call(
            lambda: self.redis.exists(*args, **kwargs),
            timeout=timeout,
            retry_interval=retry_interval,
            op_name="redis.exists",
        )

    def safe_delete(
        self,
        *args,
        timeout=DEFAULT_TIMEOUT,
        retry_interval=DEFAULT_RETRY_INTERVAL,
        **kwargs,
    ):
        return self.call(
            lambda: self.redis.delete(*args, **kwargs),
            timeout=timeout,
            retry_interval=retry_interval,
            op_name="redis.delete",
        )

    def safe_get(
        self,
        *args,
        timeout=DEFAULT_TIMEOUT,
        retry_interval=DEFAULT_RETRY_INTERVAL,
        **kwargs,
    ):
        return self.call(
            lambda: self.redis.get(*args, **kwargs),
            timeout=timeout,
            retry_interval=retry_interval,
            op_name="redis.get",
        )

    def safe_setex(
        self,
        *args,
        timeout=DEFAULT_TIMEOUT,
        retry_interval=DEFAULT_RETRY_INTERVAL,
        **kwargs,
    ):
        return self.call(
            lambda: self.redis.setex(*args, **kwargs),
            timeout=timeout,
            retry_interval=retry_interval,
            op_name="redis.setex",
        )
