"""Centralized HTTP client with exponential backoff retry.

Provides RetryClient (sync) and AsyncRetryClient (async) wrappers
around httpx that automatically retry on transient failures:

- 429 Too Many Requests (respects Retry-After header)
- 500, 502, 503, 504 server errors
- Connection errors, timeouts

Exponential backoff: delay = min(base_delay * 2^attempt, max_delay),
with optional jitter to avoid thundering herd.
"""
from __future__ import annotations

import asyncio
import logging
import time
from dataclasses import dataclass, field

# OS-backed RNG for jitter — avoids Bandit S311 and provides
# better distribution across concurrent retry storms.
_rng = __import__("random").SystemRandom()

import httpx

logger = logging.getLogger(__name__)

# Default set of HTTP status codes worth retrying
_DEFAULT_RETRYABLE_CODES: frozenset[int] = frozenset({429, 500, 502, 503, 504})


@dataclass(frozen=True)
class RetryConfig:
    """Configuration for retry behaviour.

    Attributes:
        max_retries: Maximum number of retry attempts (0 = no retries).
        base_delay: Initial delay in seconds before first retry.
        max_delay: Upper bound on delay between retries.
        jitter: When True, randomise delay between [delay*0.5, delay].
        retryable_status_codes: HTTP status codes that trigger a retry.
    """
    max_retries: int = 3
    base_delay: float = 1.0
    max_delay: float = 30.0
    jitter: bool = True
    retryable_status_codes: frozenset[int] = _DEFAULT_RETRYABLE_CODES


# Exceptions that represent transient network problems worth retrying.
_RETRYABLE_EXCEPTIONS = (
    httpx.ConnectError,
    httpx.ConnectTimeout,
    httpx.ReadTimeout,
    httpx.WriteTimeout,
    httpx.PoolTimeout,
)


def _calculate_delay(
    attempt: int,
    config: RetryConfig,
    retry_after: float | None = None,
) -> float:
    """Compute the delay before the next retry attempt.

    If the server sent a ``Retry-After`` header we honour it (capped to
    ``max_delay``).  Otherwise we use exponential backoff with optional
    jitter.
    """
    if retry_after is not None:
        return min(retry_after, config.max_delay)
    delay = min(config.base_delay * (2 ** attempt), config.max_delay)
    if config.jitter:
        delay *= 0.5 + _rng.random() * 0.5
    return delay


def _parse_retry_after(response: httpx.Response) -> float | None:
    """Extract ``Retry-After`` header value as seconds, or None."""
    value = response.headers.get("retry-after")
    if value is None:
        return None
    try:
        return float(value)
    except (ValueError, TypeError):
        return None


def _is_retryable_status(
    response: httpx.Response,
    config: RetryConfig,
) -> bool:
    """Return True if the response status code warrants a retry."""
    return response.status_code in config.retryable_status_codes


# ---------------------------------------------------------------------------
# Synchronous client
# ---------------------------------------------------------------------------

class RetryClient:
    """Synchronous HTTP client with exponential-backoff retry.

    Drop-in replacement for ``httpx.Client`` as a context manager::

        with RetryClient(timeout=30.0) as client:
            resp = client.post(url, json=payload)

    Retries transient HTTP errors and connection failures up to
    ``RetryConfig.max_retries`` times with exponential backoff.
    """

    def __init__(
        self,
        timeout: float = 30.0,
        verify: bool = False,
        retry_config: RetryConfig | None = None,
    ) -> None:
        self.timeout = timeout
        self.verify = verify
        self.retry_config = retry_config or RetryConfig()
        self._client: httpx.Client | None = None

    # -- context manager -----------------------------------------------------

    def __enter__(self) -> RetryClient:
        self._client = httpx.Client(verify=self.verify, timeout=self.timeout)
        return self

    def __exit__(self, *args: object) -> None:
        if self._client is not None:
            self._client.close()
            self._client = None

    # -- HTTP verbs ----------------------------------------------------------

    def request(self, method: str, url: str, **kwargs: object) -> httpx.Response:
        """Execute an HTTP request with retry logic."""
        if self._client is None:
            raise RuntimeError("RetryClient must be used as a context manager")

        config = self.retry_config
        last_exc: Exception | None = None

        for attempt in range(config.max_retries + 1):
            try:
                response = self._client.request(method, url, **kwargs)

                if attempt < config.max_retries and _is_retryable_status(response, config):
                    retry_after = _parse_retry_after(response)
                    delay = _calculate_delay(attempt, config, retry_after)
                    logger.warning(
                        "Retryable %d from %s %s (attempt %d/%d, sleeping %.1fs)",
                        response.status_code, method.upper(), url,
                        attempt + 1, config.max_retries, delay,
                    )
                    time.sleep(delay)
                    continue

                return response

            except _RETRYABLE_EXCEPTIONS as exc:
                last_exc = exc
                if attempt < config.max_retries:
                    delay = _calculate_delay(attempt, config)
                    logger.warning(
                        "Connection error on %s %s: %s (attempt %d/%d, sleeping %.1fs)",
                        method.upper(), url, exc,
                        attempt + 1, config.max_retries, delay,
                    )
                    time.sleep(delay)
                    continue
                raise

        # Unreachable in normal flow, but satisfies type checker.
        if last_exc is not None:
            raise last_exc
        raise RuntimeError("Retry loop exited without result")  # pragma: no cover

    def get(self, url: str, **kwargs: object) -> httpx.Response:
        return self.request("GET", url, **kwargs)

    def post(self, url: str, **kwargs: object) -> httpx.Response:
        return self.request("POST", url, **kwargs)

    def put(self, url: str, **kwargs: object) -> httpx.Response:
        return self.request("PUT", url, **kwargs)

    def delete(self, url: str, **kwargs: object) -> httpx.Response:
        return self.request("DELETE", url, **kwargs)


# ---------------------------------------------------------------------------
# Asynchronous client
# ---------------------------------------------------------------------------

class AsyncRetryClient:
    """Asynchronous HTTP client with exponential-backoff retry.

    Drop-in replacement for ``httpx.AsyncClient`` as an async context
    manager::

        async with AsyncRetryClient(timeout=30.0) as client:
            resp = await client.post(url, json=payload)
    """

    def __init__(
        self,
        timeout: float = 30.0,
        verify: bool = False,
        retry_config: RetryConfig | None = None,
    ) -> None:
        self.timeout = timeout
        self.verify = verify
        self.retry_config = retry_config or RetryConfig()
        self._client: httpx.AsyncClient | None = None

    # -- context manager -----------------------------------------------------

    async def __aenter__(self) -> AsyncRetryClient:
        self._client = httpx.AsyncClient(verify=self.verify, timeout=self.timeout)
        return self

    async def __aexit__(self, *args: object) -> None:
        if self._client is not None:
            await self._client.aclose()
            self._client = None

    # -- HTTP verbs ----------------------------------------------------------

    async def request(self, method: str, url: str, **kwargs: object) -> httpx.Response:
        """Execute an async HTTP request with retry logic."""
        if self._client is None:
            raise RuntimeError("AsyncRetryClient must be used as an async context manager")

        config = self.retry_config
        last_exc: Exception | None = None

        for attempt in range(config.max_retries + 1):
            try:
                response = await self._client.request(method, url, **kwargs)

                if attempt < config.max_retries and _is_retryable_status(response, config):
                    retry_after = _parse_retry_after(response)
                    delay = _calculate_delay(attempt, config, retry_after)
                    logger.warning(
                        "Retryable %d from %s %s (attempt %d/%d, sleeping %.1fs)",
                        response.status_code, method.upper(), url,
                        attempt + 1, config.max_retries, delay,
                    )
                    await asyncio.sleep(delay)
                    continue

                return response

            except _RETRYABLE_EXCEPTIONS as exc:
                last_exc = exc
                if attempt < config.max_retries:
                    delay = _calculate_delay(attempt, config)
                    logger.warning(
                        "Connection error on %s %s: %s (attempt %d/%d, sleeping %.1fs)",
                        method.upper(), url, exc,
                        attempt + 1, config.max_retries, delay,
                    )
                    await asyncio.sleep(delay)
                    continue
                raise

        if last_exc is not None:
            raise last_exc
        raise RuntimeError("Retry loop exited without result")  # pragma: no cover

    async def get(self, url: str, **kwargs: object) -> httpx.Response:
        return await self.request("GET", url, **kwargs)

    async def post(self, url: str, **kwargs: object) -> httpx.Response:
        return await self.request("POST", url, **kwargs)

    async def put(self, url: str, **kwargs: object) -> httpx.Response:
        return await self.request("PUT", url, **kwargs)

    async def delete(self, url: str, **kwargs: object) -> httpx.Response:
        return await self.request("DELETE", url, **kwargs)
