"""Generic cursor-based paginator for any API that uses cursor pagination."""

import time
from typing import Callable, TypeVar

T = TypeVar("T")


def cursor_paginate(
    fetch_page: Callable[..., list[T]],
    get_cursor: Callable[[T], str | None],
    page_size: int = 100,
    max_items: int = 2000,
    max_retries: int = 3,
    retry_delay: float = 2.0,
    retryable_exceptions: tuple[type[Exception], ...] = (
        ConnectionError,
        TimeoutError,
        OSError,
    ),
) -> list[T]:
    """Paginate through a cursor-based API, collecting all items.

    Fetches pages one at a time by calling fetch_page with limit and cursor
    kwargs. Retries each page on transient errors using exponential backoff.
    Stops when a page is empty, a partial page is returned, max_items is
    reached, or the cursor from the last item is None.

    Args:
        fetch_page: Callable that accepts ``limit`` and ``cursor`` as keyword
            arguments and returns a list of items for that page.
        get_cursor: Callable that receives the last item of a page and returns
            the cursor string to use for the next page, or None if there are
            no more pages.
        page_size: Number of items to request per page.
        max_items: Hard cap on total items collected. Collection stops and the
            list is truncated once this limit is reached.
        max_retries: Maximum number of retry attempts per page after the first
            failure.
        retry_delay: Base delay in seconds between retries (doubled each
            attempt — exponential backoff without jitter).
        retryable_exceptions: Tuple of exception types that trigger a retry.
            Any other exception propagates immediately.

    Returns:
        List of all collected items, in the order they were returned by the
        API, truncated to max_items.

    Raises:
        Exception: Re-raises the last exception if all retries for a page are
            exhausted.
    """
    all_items: list[T] = []
    cursor: str | None = None

    while True:
        batch = _fetch_with_retry(
            fetch_page=fetch_page,
            page_size=page_size,
            cursor=cursor,
            max_retries=max_retries,
            retry_delay=retry_delay,
            retryable_exceptions=retryable_exceptions,
        )

        if not batch:
            break

        all_items.extend(batch)

        if len(all_items) >= max_items:
            del all_items[max_items:]
            break

        if len(batch) < page_size:
            break

        cursor = get_cursor(batch[-1])
        if cursor is None:
            break

    return all_items


def _fetch_with_retry(
    fetch_page: Callable[..., list[T]],
    page_size: int,
    cursor: str | None,
    max_retries: int,
    retry_delay: float,
    retryable_exceptions: tuple[type[Exception], ...],
) -> list[T]:
    """Call fetch_page once, retrying on retryable_exceptions with exponential backoff."""
    last_exc: Exception | None = None
    for attempt in range(max_retries + 1):
        try:
            return fetch_page(limit=page_size, cursor=cursor)
        except retryable_exceptions as exc:
            last_exc = exc
            if attempt >= max_retries:
                raise
            delay = retry_delay * (2 ** attempt)
            time.sleep(delay)

    raise last_exc  # unreachable; satisfies type checkers
