"""Retry primitive — exponential backoff + full jitter (EH-11, v0.48.0).

Stability tier: **experimental** (AP-11) — added in 0.48.0.

The canonical resilience primitive mandated by ERROR_HANDLING **EH-11**:
*"Retries MUST use exponential backoff with full jitter and a finite cap.
Linear backoff and immediate retry are forbidden. The retry budget MUST be
configurable and observable (`audit=true` log per attempt)."* (§13.1)

Reference: AWS Architecture Blog, *"Exponential Backoff And Jitter"* (2015) —
full jitter empirically minimises retry-storm contention.

    from mgf.common.retry import retry, aretry
    from mgf.common.exceptions import HttpServiceUnavailableError

    result = retry(
        lambda: call_flaky_api(),
        retryable=(HttpServiceUnavailableError,),
        max_attempts=5,
        operation="fetch-quote",
    )

Use ONLY for typed **transient** failures (timeouts, 503s, lock contention).
NEVER for validation errors — the input is wrong; retrying won't fix it. The
default ``retryable=(AppError,)`` is deliberately broad; **narrow it** to the
transient types your call actually raises.
"""

from __future__ import annotations

import asyncio
import logging
import random
import time
from typing import TYPE_CHECKING, TypeVar

from mgf.common.exceptions import AppError

if TYPE_CHECKING:
    from collections.abc import Awaitable, Callable

__all__ = ["aretry", "retry"]

_LOG = logging.getLogger(__name__)
T = TypeVar("T")


def _full_jitter_delay(attempt: int, base_delay_s: float, max_delay_s: float) -> float:
    """Full jitter: ``uniform[0, min(cap, base * 2**(attempt-1))]`` (EH-11)."""
    ceiling = min(max_delay_s, base_delay_s * (2 ** (attempt - 1)))
    return random.uniform(0, ceiling)  # noqa: S311 — jitter, not crypto


def _log_attempt(operation: str, attempt: int, delay_s: float, exc: BaseException) -> None:
    _LOG.warning(
        "retrying after failure",
        extra={
            "audit": True,
            "event": "retry.attempt",
            "operation": operation,
            "attempt": attempt,
            "delay_s": delay_s,
            "exception_type": type(exc).__name__,
        },
    )


def _log_exhausted(operation: str, attempts: int) -> None:
    _LOG.error(
        "retry budget exhausted",
        extra={
            "audit": True,
            "event": "retry.exhausted",
            "operation": operation,
            "attempts": attempts,
        },
    )


def _validate(max_attempts: int, base_delay_s: float, max_delay_s: float) -> None:
    if max_attempts < 1:
        raise ValueError("max_attempts must be >= 1")
    if base_delay_s < 0 or max_delay_s < 0:
        raise ValueError("delays must be non-negative")


def retry(
    fn: Callable[[], T],
    *,
    retryable: tuple[type[BaseException], ...] = (AppError,),
    max_attempts: int = 5,
    base_delay_s: float = 0.5,
    max_delay_s: float = 30.0,
    operation: str = "<unnamed>",
) -> T:
    """Call ``fn`` with exponential backoff + full jitter on failure (EH-11).

    Re-raises the last exception when the budget is exhausted. Emits an audit
    log per attempt (``event=retry.attempt``) and on exhaustion
    (``event=retry.exhausted``) so retry behaviour is observable in production.

    Only exceptions in ``retryable`` are retried; everything else propagates
    immediately (a non-transient failure must not burn the budget).
    """
    _validate(max_attempts, base_delay_s, max_delay_s)
    for attempt in range(1, max_attempts + 1):
        try:
            return fn()
        except retryable as exc:
            if attempt >= max_attempts:
                _log_exhausted(operation, attempt)
                raise
            delay = _full_jitter_delay(attempt, base_delay_s, max_delay_s)
            _log_attempt(operation, attempt, delay, exc)
            time.sleep(delay)
    raise AssertionError("unreachable: loop returns or re-raises")  # pragma: no cover


async def aretry(
    fn: Callable[[], Awaitable[T]],
    *,
    retryable: tuple[type[BaseException], ...] = (AppError,),
    max_attempts: int = 5,
    base_delay_s: float = 0.5,
    max_delay_s: float = 30.0,
    operation: str = "<unnamed>",
) -> T:
    """Async analogue of :func:`retry` — same MUSTs, ``asyncio.sleep`` backoff."""
    _validate(max_attempts, base_delay_s, max_delay_s)
    for attempt in range(1, max_attempts + 1):
        try:
            return await fn()
        except retryable as exc:
            if attempt >= max_attempts:
                _log_exhausted(operation, attempt)
                raise
            delay = _full_jitter_delay(attempt, base_delay_s, max_delay_s)
            _log_attempt(operation, attempt, delay, exc)
            await asyncio.sleep(delay)
    raise AssertionError("unreachable: loop returns or re-raises")  # pragma: no cover
