"""Tests for exponential backoff retry utility."""
from __future__ import annotations

from typing import List
from unittest.mock import MagicMock

import pytest

from morphe_adapter_sdk.retry import with_retry


class TransientError(Exception):
    pass


class FatalError(Exception):
    pass


def make_should_retry():
    return lambda err: isinstance(err, TransientError)


def test_returns_immediately_on_success():
    calls = 0

    def fn():
        nonlocal calls
        calls += 1
        return "ok"

    result = with_retry(fn, max_retries=3, base_delay=0.1, should_retry=make_should_retry())
    assert result == "ok"
    assert calls == 1


def test_retries_on_transient_then_succeeds():
    calls = 0

    def fn():
        nonlocal calls
        calls += 1
        if calls < 3:
            raise TransientError("transient")
        return "recovered"

    noop = MagicMock()
    result = with_retry(fn, max_retries=3, base_delay=0.1, should_retry=make_should_retry(), _sleep=noop)
    assert result == "recovered"
    assert calls == 3


def test_raises_immediately_on_fatal_error():
    calls = 0

    def fn():
        nonlocal calls
        calls += 1
        raise FatalError("fatal")

    with pytest.raises(FatalError):
        with_retry(fn, max_retries=3, base_delay=0.1, should_retry=make_should_retry())
    assert calls == 1


def test_raises_after_max_retries_exhausted():
    calls = 0

    def fn():
        nonlocal calls
        calls += 1
        raise TransientError("always fails")

    noop = MagicMock()
    with pytest.raises(TransientError):
        with_retry(fn, max_retries=3, base_delay=0.1, should_retry=make_should_retry(), _sleep=noop)
    assert calls == 4  # 1 initial + 3 retries


def test_does_not_retry_when_max_retries_is_zero():
    calls = 0

    def fn():
        nonlocal calls
        calls += 1
        raise TransientError("always fails")

    with pytest.raises(TransientError):
        with_retry(fn, max_retries=0, base_delay=0.1, should_retry=make_should_retry())
    assert calls == 1


def test_sleeps_between_retries_with_exponential_backoff():
    calls = 0
    sleep_calls: List[float] = []

    def fn():
        nonlocal calls
        calls += 1
        if calls < 3:
            raise TransientError("transient")
        return "done"

    def record_sleep(delay: float) -> None:
        sleep_calls.append(delay)

    with_retry(fn, max_retries=3, base_delay=0.1, should_retry=make_should_retry(), _sleep=record_sleep)

    assert len(sleep_calls) == 2
    # First delay: base_delay * 2^0 * jitter → 0.05..0.15
    assert 0.05 <= sleep_calls[0] <= 0.15
    # Second delay: base_delay * 2^1 * jitter → 0.10..0.30
    assert 0.10 <= sleep_calls[1] <= 0.30
    # Second delay should be >= first delay (on average; within range)
    assert sleep_calls[1] >= sleep_calls[0] * 0.5  # loose check given jitter
