"""Retry policy for :class:`mgf.http.AsyncHttpClient`.

A ``RetryPolicy`` describes when + how to retry transient failures.
The default policy is conservative: 0 retries (deterministic single-
attempt) — explicit consumer choice is required to opt into retries
since they change failure-mode visibility.

Design notes:

  - Exponential backoff with full jitter (the AWS-recommended shape:
    each backoff is a uniform random in ``[0, base * 2^attempt]``).
    Avoids the thundering-herd shape pure-exponential produces.
  - Retryable conditions are split: TRANSPORT-level failures
    (timeouts, connect errors) are always retried up to
    ``max_attempts``. Provider-level failures (5xx) are retried
    only if the response status is in ``retryable_statuses``.
  - Idempotent methods (GET, HEAD, OPTIONS, PUT, DELETE) are
    retryable by default. POST is NOT retryable by default —
    repeating a POST may double-submit. Consumers can override
    via ``retryable_methods``.
"""

from __future__ import annotations

import random
from dataclasses import dataclass, field

# RFC 7231 idempotent methods + DELETE (which is idempotent in
# practice for typed REST APIs). POST is intentionally excluded.
_DEFAULT_RETRYABLE_METHODS: frozenset[str] = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"})

# Status codes typically safe to retry. 503 + 504 are the canonical
# "service unavailable, try again" pair; 429 is rate-limit (the
# Retry-After header should ideally be honoured but the simple
# policy here just backs off + retries).
_DEFAULT_RETRYABLE_STATUSES: frozenset[int] = frozenset({429, 503, 504})


@dataclass(frozen=True, slots=True)
class RetryPolicy:
    """Configurable retry policy.

    Default: zero retries (no retry). Enable with
    ``RetryPolicy(max_attempts=3)``.
    """

    max_attempts: int = 0
    """Number of retries AFTER the first attempt. Total attempts =
    ``max_attempts + 1``. Must be ≥ 0.

    EH-13 (retry-amplification bound): because this is a FINITE budget, a
    failing endpoint sees at most ``(max_attempts + 1)`` requests per logical
    call — never an unbounded storm. That documented ceiling is why mgf-http
    forgoes a circuit breaker: the amplification is bounded by construction."""

    base_backoff_seconds: float = 0.5
    """Base for the exponential backoff. Backoff for attempt N is a
    uniform random in ``[0, base_backoff_seconds * 2^N]``."""

    max_backoff_seconds: float = 30.0
    """Cap on the backoff between attempts. Prevents runaway delays."""

    retryable_methods: frozenset[str] = field(default_factory=lambda: _DEFAULT_RETRYABLE_METHODS)
    """HTTP methods eligible for retry. Defaults to idempotent set."""

    retryable_statuses: frozenset[int] = field(default_factory=lambda: _DEFAULT_RETRYABLE_STATUSES)
    """Response status codes to retry. Default: 429, 503, 504."""

    honour_retry_after: bool = True
    """When ``True`` (default), the retry loop reads the ``Retry-After``
    response header on retryable 4xx/5xx and uses its value (capped at
    :attr:`max_backoff_seconds`) instead of the exponential-backoff
    calculation. Per RFC 7231 §7.1.3, providers SHOULD send
    ``Retry-After`` with 429 / 503 / 504; honouring it prevents
    burning the rate-limit window faster than the provider's
    intended schedule. Set to ``False`` only when testing against a
    misbehaving provider where the header values are wrong. (RA-02.)"""

    def __post_init__(self) -> None:
        if self.max_attempts < 0:
            raise ValueError(f"max_attempts must be >= 0 (got {self.max_attempts})")
        if self.base_backoff_seconds <= 0:
            raise ValueError(f"base_backoff_seconds must be > 0 (got {self.base_backoff_seconds})")
        if self.max_backoff_seconds < self.base_backoff_seconds:
            raise ValueError(
                f"max_backoff_seconds ({self.max_backoff_seconds}) "
                f"must be >= base_backoff_seconds "
                f"({self.base_backoff_seconds})"
            )

    def is_method_retryable(self, method: str) -> bool:
        """Return True iff ``method`` is in :attr:`retryable_methods`."""
        return method.upper() in self.retryable_methods

    def is_status_retryable(self, status: int) -> bool:
        """Return True iff ``status`` is in :attr:`retryable_statuses`."""
        return status in self.retryable_statuses

    def backoff_for_attempt(self, attempt: int) -> float:
        """Compute the sleep before the (attempt+1)-th try.

        ``attempt`` is 0-indexed: 0 = before the first retry (i.e.,
        between the initial attempt and the 2nd attempt). The result
        is a uniform random in ``[0, base * 2^attempt]``, capped at
        :attr:`max_backoff_seconds`.

        Uses ``random.random()`` — fine for retry jitter (S311 in ruff
        is suppressed at the call site since this isn't security-sensitive
        randomness).
        """
        if attempt < 0:
            raise ValueError(f"attempt must be >= 0 (got {attempt})")
        ceiling = min(
            self.base_backoff_seconds * (2**attempt),
            self.max_backoff_seconds,
        )
        return float(random.random() * ceiling)  # noqa: S311 — retry jitter; not cryptographic


__all__ = ["RetryPolicy"]
