"""Retry helpers with bounded attempts and cancellable backoff."""

from __future__ import annotations

import asyncio
import logging
import random
from collections.abc import Awaitable, Callable

from app.errors import RateLimitedError

logger = logging.getLogger(__name__)


async def cancellable_sleep(seconds: float, cancel_event: asyncio.Event | None = None) -> None:
    if seconds <= 0:
        return
    if cancel_event is None:
        await asyncio.sleep(seconds)
        return
    if cancel_event.is_set():
        raise asyncio.CancelledError
    try:
        await asyncio.wait_for(cancel_event.wait(), timeout=seconds)
    except TimeoutError:
        return
    raise asyncio.CancelledError


async def retry_async[T](
    operation: Callable[[], Awaitable[T]],
    *,
    attempts: int,
    base_delay: float = 1.0,
    max_delay: float = 30.0,
    retry_on: tuple[type[BaseException], ...] = (Exception,),
    retry_if: Callable[[BaseException], bool] | None = None,
    on_retry: Callable[[BaseException, int, float], None] | None = None,
    cancel_event: asyncio.Event | None = None,
) -> T:
    """Run an async operation with a bounded number of retries.

    ``attempts`` includes the first attempt. HTTP 429's Retry-After, when
    available, takes precedence over the exponential delay, capped at 120 s.
    """

    if attempts < 1:
        raise ValueError("attempts must be >= 1")
    last_error: BaseException | None = None
    for attempt in range(1, attempts + 1):
        if cancel_event is not None and cancel_event.is_set():
            raise asyncio.CancelledError
        try:
            return await operation()
        except asyncio.CancelledError:
            raise
        except BaseException as exc:  # noqa: BLE001 - decided by caller
            last_error = exc
            if not isinstance(exc, retry_on):
                raise
            if retry_if is not None and not retry_if(exc):
                raise
            if attempt >= attempts:
                raise
            delay = min(max_delay, base_delay * (2 ** (attempt - 1)))
            delay += random.uniform(0, max(0.05, delay * 0.2))
            if isinstance(exc, RateLimitedError) and exc.retry_after is not None:
                delay = min(120.0, max(0.0, exc.retry_after))
            if on_retry is not None:
                try:
                    on_retry(exc, attempt, delay)
                except Exception:
                    logger.exception("retry callback failed")
            logger.warning(
                "operation failed (attempt %s/%s), retrying in %.1fs: %s",
                attempt,
                attempts,
                delay,
                type(exc).__name__,
            )
            await cancellable_sleep(delay, cancel_event)
    assert last_error is not None
    raise last_error
