import abc
import random
import time
from functools import partial
from typing import Callable, Generic, TypeVar

_T = TypeVar("_T")


class NoMoreAttempt(Exception):
    """Raised inside a payload to immediately abort retrying.

    Args:
        cause: Optional exception that prompted the abort decision.
    """

    def __init__(self, cause: Exception | None = None) -> None:
        super().__init__()
        self.cause = cause


class RetryConfig(abc.ABC):
    """Abstract base class for retry pause strategies."""

    @abc.abstractmethod
    def get_next_pause(self, attempt: int) -> float:
        """Returns the seconds to wait before the next attempt.

        Args:
            attempt: 0-indexed retry count (0 = before first retry).

        Returns:
            Non-negative pause duration in seconds.
        """


class ConstantPause(RetryConfig):
    """Waits a fixed interval between every retry.

    Args:
        interval: Pause duration in seconds.
    """

    def __init__(self, interval: float) -> None:
        assert interval >= 0, "interval must be non-negative"
        self._interval = interval

    def get_next_pause(self, attempt: int) -> float:
        return self._interval


class RandomPause(RetryConfig):
    """Waits a uniformly random duration in [low, high] between retries.

    Args:
        low: Minimum pause duration in seconds.
        high: Maximum pause duration in seconds.
    """

    def __init__(self, low: float, high: float) -> None:
        assert 0 <= low <= high, "requires 0 <= low <= high"
        self._low = low
        self._high = high

    def get_next_pause(self, attempt: int) -> float:
        return random.uniform(self._low, self._high)


class ExponentialBackoff(RetryConfig):
    """Waits base * (multiplier ** attempt) seconds, with optional jitter.

    Formula: interval = base * (multiplier ** attempt) + uniform(0, jitter).
    With attempt=0 the first retry waits `base` seconds; each subsequent
    retry multiplies by `multiplier`.

    Args:
        base: Initial pause duration in seconds.
        multiplier: Growth factor applied at each step (>= 1).
        jitter: Upper bound of a random additive term; None disables jitter.
    """

    def __init__(self, base: float, multiplier: float, jitter: float | None = None) -> None:
        assert base > 0, "base must be positive"
        assert multiplier >= 1, "multiplier must be >= 1"
        assert jitter is None or jitter >= 0, "jitter must be non-negative"
        self._base = base
        self._multiplier = multiplier
        self._jitter = jitter

    def get_next_pause(self, attempt: int) -> float:
        interval = self._base * (self._multiplier ** attempt)
        if self._jitter is not None:
            interval += random.uniform(0, self._jitter)
        return interval


def _callable_description(fn: Callable) -> str:
    """Returns a human-readable name for a callable."""
    if isinstance(fn, partial):
        return fn.func.__name__
    if name := getattr(fn, "__name__", None):
        return name
    return type(fn).__name__


class Attempter(Generic[_T]):
    """Wraps a callable and re-invokes it on failure according to a RetryConfig.

    Args:
        payload: The callable to execute. Must take no arguments.
        description: Human-readable label; auto-derived from the callable if None.
    """

    def __init__(self, payload: Callable[[], _T], description: str | None = None) -> None:
        self._payload = payload
        self._description = description or _callable_description(payload)

    @property
    def description(self) -> str:
        """Returns the human-readable label for this attempter."""
        return self._description

    def execute(self, config: RetryConfig, max_attempts: int) -> _T:
        """Executes the payload with retries.

        Calls the payload up to `max_attempts` times total. Between failures the
        caller sleeps for config.get_next_pause(attempt) seconds. Raises
        NoMoreAttempt immediately without retrying if the payload raises it.
        Re-raises the last exception after all attempts are exhausted.

        Args:
            config: Pause strategy between retries.
            max_attempts: Total number of times to call the payload (>= 1).

        Raises:
            NoMoreAttempt: If the payload raises it (propagated immediately).
            Exception: The last exception raised after all attempts are exhausted.
        """
        assert max_attempts >= 1, "max_attempts must be >= 1"
        for attempt in range(max_attempts):
            try:
                return self._payload()
            except NoMoreAttempt as e:
                if e.cause is not None:
                    raise e.cause
                raise
            except Exception:
                if attempt == max_attempts - 1:
                    raise
                time.sleep(config.get_next_pause(attempt))
        raise NotImplementedError  # unreachable
