"""
Exponential backoff retry utility for the Morphe Adapter SDK.

Design mirrors the TypeScript SDK's ``withRetry`` function:
- Configurable ``max_retries`` and ``base_delay``
- Jitter in [0.5, 1.5] range to spread retries across clients
- Injectable ``_sleep`` for test isolation (avoids monkeypatching ``time.sleep``)
"""
from __future__ import annotations

import random
import time
from typing import Callable, Optional, TypeVar

T = TypeVar("T")


def with_retry(
    fn: Callable[[], T],
    max_retries: int,
    base_delay: float,
    should_retry: Callable[[Exception], bool],
    _sleep: Optional[Callable[[float], None]] = None,
) -> T:
    """
    Execute ``fn``, retrying on transient errors with exponential backoff.

    - Does NOT retry when ``should_retry(err)`` returns False (e.g. 4xx errors).
    - Raises the last error after ``max_retries`` exhausted.
    - Backoff formula: ``base_delay * 2^attempt * jitter`` where ``jitter ∈ [0.5, 1.5]``.

    Args:
        fn:           Zero-argument callable to execute and retry.
        max_retries:  Maximum number of retry attempts (0 = no retries).
        base_delay:   Base delay in seconds before first retry.
        should_retry: Predicate: returns True if the error is transient.
        _sleep:       Injectable sleep function for testing. Defaults to ``time.sleep``.

    Returns:
        The return value of ``fn`` on success.

    Raises:
        The last exception raised by ``fn`` after all retries are exhausted.
    """
    sleep_fn = _sleep if _sleep is not None else time.sleep
    attempt = 0

    while True:
        try:
            return fn()
        except Exception as err:
            if attempt >= max_retries or not should_retry(err):
                raise
            jitter = random.uniform(0.5, 1.5)
            delay = base_delay * (2 ** attempt) * jitter
            sleep_fn(delay)
            attempt += 1
