"""Shared HTTP utilities — retry with exponential backoff.

Provides ``request_with_retry`` for resilient HTTP requests across
all axm-bib API clients. Handles 429 rate limits, transient 5xx
errors, and ``Retry-After`` headers.
"""

from __future__ import annotations

import logging
import random
import time

import httpx

from axm_bib.exceptions import APIUnavailable, RateLimitError

__all__ = ["request_with_retry"]

logger = logging.getLogger(__name__)

_MAX_RETRY_AFTER = 30.0
"""Maximum seconds to honour from a Retry-After header."""

_JITTER_MAX = 0.5
"""Upper bound for random jitter added to backoff."""

_UNAVAILABLE_CODES = frozenset({502, 503, 504})


def _raise_structured(resp: httpx.Response) -> None:
    """Convert an HTTP error response to a structured exception.

    Raises:
        RateLimitError: On HTTP 429.
        APIUnavailable: On HTTP 502/503/504.
        httpx.HTTPStatusError: On all other error status codes.
    """
    try:
        resp.raise_for_status()
    except httpx.HTTPStatusError as exc:
        if resp.status_code == 429:  # noqa: PLR2004
            retry_after = _parse_retry_after(resp)
            msg = f"Rate limited: {resp.status_code} for {resp.request.url}"
            raise RateLimitError(msg, retry_after=retry_after) from exc
        if resp.status_code in _UNAVAILABLE_CODES:
            msg = f"API unavailable: {resp.status_code} for {resp.request.url}"
            raise APIUnavailable(msg) from exc
        raise


def request_with_retry(
    method: str,
    url: str,
    *,
    max_retries: int = 3,
    backoff_base: float = 1.0,
    retry_on: tuple[int, ...] = (429, 500, 502, 503),
    **httpx_kwargs: object,
) -> httpx.Response:
    """Execute an HTTP request with exponential backoff on retryable errors.

    Retries on status codes listed in *retry_on* and on transient
    network errors (``httpx.TransportError``).  Respects the
    ``Retry-After`` header (numeric only, capped at 30 s).

    Args:
        method: HTTP method (``"GET"``, ``"POST"``, etc.).
        url: Request URL.
        max_retries: Maximum retry attempts before re-raising.
        backoff_base: Base delay in seconds (doubles each attempt).
        retry_on: HTTP status codes that trigger a retry.
        **httpx_kwargs: Forwarded to ``httpx.request()``.

    Returns:
        Successful ``httpx.Response``.

    Raises:
        RateLimitError: After *max_retries* exhausted on HTTP 429.
        APIUnavailable: After *max_retries* exhausted on HTTP
            502/503/504.
        httpx.HTTPStatusError: After *max_retries* exhausted on other
            retryable statuses, or immediately on a non-retryable
            error status.
        httpx.TransportError: After *max_retries* exhausted on
            network-level failures.
    """
    last_exc: Exception | None = None

    for attempt in range(max_retries + 1):
        try:
            resp = httpx.request(method, url, **httpx_kwargs)  # type: ignore[arg-type]
        except httpx.TransportError as exc:
            last_exc = exc
            if attempt >= max_retries:
                raise
            delay = _backoff_delay(attempt, backoff_base)
            logger.warning(
                "Retry %d/%d for %s %s (network error: %s), sleeping %.1fs",
                attempt + 1,
                max_retries,
                method,
                url,
                exc,
                delay,
            )
            time.sleep(delay)
            continue

        # Non-error status — return immediately
        if resp.status_code < 400:  # noqa: PLR2004
            return resp

        # Non-retryable error — raise immediately
        if resp.status_code not in retry_on:
            resp.raise_for_status()
            return resp  # unreachable; raise_for_status always raises ≥ 400

        # Retryable status — retry or raise on last attempt
        if attempt >= max_retries:
            _raise_structured(resp)

        delay = _parse_retry_after(resp) or _backoff_delay(
            attempt,
            backoff_base,
        )
        logger.warning(
            "Retry %d/%d for %s %s (HTTP %d), sleeping %.1fs",
            attempt + 1,
            max_retries,
            method,
            url,
            resp.status_code,
            delay,
        )
        last_exc = None  # clear — we have a response
        time.sleep(delay)

    # Unreachable in practice, but satisfies type checker
    if last_exc is not None:  # pragma: no cover
        raise last_exc
    msg = "request_with_retry: unexpected state"  # pragma: no cover
    raise RuntimeError(msg)  # pragma: no cover


def _backoff_delay(attempt: int, base: float) -> float:
    """Compute exponential backoff delay with jitter.

    Formula: ``base * 2^attempt + random(0, JITTER_MAX)``.

    Args:
        attempt: Zero-based attempt number.
        base: Base delay in seconds.

    Returns:
        Delay in seconds.
    """
    return float(base * (2**attempt) + random.uniform(0, _JITTER_MAX))  # noqa: S311


def _parse_retry_after(resp: httpx.Response) -> float | None:
    """Extract ``Retry-After`` delay from response headers.

    Only handles numeric values (seconds).  Non-numeric values
    (HTTP-date format) return ``None`` so the caller falls back
    to exponential backoff.

    Args:
        resp: HTTP response to inspect.

    Returns:
        Delay in seconds (capped at 30 s), or ``None``.
    """
    header = resp.headers.get("retry-after")
    if header is None:
        return None
    try:
        value = float(header)
    except ValueError:
        return None
    return min(value, _MAX_RETRY_AFTER)
