"""Tests for retry_with_backoff and retry_with_backoff_async."""

import asyncio

import pytest

from retry_with_backoff import retry_with_backoff, retry_with_backoff_async


# ---------------------------------------------------------------------------
# retry_with_backoff (sync)
# ---------------------------------------------------------------------------


def test_funcion_que_falla_dos_veces_y_luego_exito():
    """Debe tener exito en el tercer intento tras 2 fallos."""
    calls = []

    @retry_with_backoff(max_retries=3, initial_delay=0.0, jitter=False)
    def flaky():
        calls.append(1)
        if len(calls) < 3:
            raise ConnectionError("transient")
        return "ok"

    result = flaky()
    assert result == "ok"
    assert len(calls) == 3


def test_funcion_que_siempre_falla_agota_retries_y_lanza():
    """Debe lanzar la excepcion tras agotar todos los reintentos."""
    @retry_with_backoff(max_retries=2, initial_delay=0.0, jitter=False)
    def always_fails():
        raise ValueError("permanent")

    with pytest.raises(ValueError, match="permanent"):
        always_fails()


def test_on_retry_callback_se_llama():
    """El callback on_retry debe invocarse una vez por reintento."""
    retries_seen: list[tuple[Exception, int]] = []

    def on_retry(exc: Exception, attempt: int) -> None:
        retries_seen.append((exc, attempt))

    @retry_with_backoff(max_retries=2, initial_delay=0.0, jitter=False, on_retry=on_retry)
    def flaky():
        raise RuntimeError("boom")

    with pytest.raises(RuntimeError):
        flaky()

    assert len(retries_seen) == 2
    assert all(isinstance(e, RuntimeError) for e, _ in retries_seen)
    assert [a for _, a in retries_seen] == [0, 1]


def test_jitter_produce_delays_variables():
    """Con jitter=True los delays deben variar entre llamadas."""
    delays: list[float] = []
    original_sleep = __import__("time").sleep

    import time

    def capture_sleep(d: float) -> None:
        delays.append(d)

    import unittest.mock as mock

    @retry_with_backoff(max_retries=3, initial_delay=1.0, max_delay=30.0, jitter=True)
    def always_fails():
        raise RuntimeError("x")

    with mock.patch("time.sleep", side_effect=capture_sleep):
        with pytest.raises(RuntimeError):
            always_fails()

    # All delays should be > 0
    assert all(d > 0 for d in delays)
    # With jitter they are unlikely to be all identical (probabilistic; seed not fixed)
    # At minimum we verify the count matches max_retries
    assert len(delays) == 3


def test_solo_reintenta_excepciones_especificadas():
    """Solo debe reintentar las excepciones incluidas en el tuple."""
    calls = []

    @retry_with_backoff(
        max_retries=3,
        initial_delay=0.0,
        jitter=False,
        exceptions=(ConnectionError,),
    )
    def raises_value_error():
        calls.append(1)
        raise ValueError("not retryable")

    with pytest.raises(ValueError):
        raises_value_error()

    # Should have been called exactly once — no retries for ValueError
    assert len(calls) == 1


# ---------------------------------------------------------------------------
# retry_with_backoff_async
# ---------------------------------------------------------------------------


def test_async_funcion_que_falla_dos_veces_y_luego_exito():
    """Async: debe tener exito en el tercer intento tras 2 fallos."""
    calls = []

    @retry_with_backoff_async(max_retries=3, initial_delay=0.0, jitter=False)
    async def flaky():
        calls.append(1)
        if len(calls) < 3:
            raise ConnectionError("transient")
        return "ok"

    result = asyncio.run(flaky())
    assert result == "ok"
    assert len(calls) == 3


def test_async_funcion_que_siempre_falla_agota_retries_y_lanza():
    """Async: debe lanzar la excepcion tras agotar todos los reintentos."""
    @retry_with_backoff_async(max_retries=2, initial_delay=0.0, jitter=False)
    async def always_fails():
        raise ValueError("permanent")

    with pytest.raises(ValueError, match="permanent"):
        asyncio.run(always_fails())


def test_async_on_retry_callback_se_llama():
    """Async: el callback on_retry debe invocarse una vez por reintento."""
    retries_seen: list[tuple[Exception, int]] = []

    def on_retry(exc: Exception, attempt: int) -> None:
        retries_seen.append((exc, attempt))

    @retry_with_backoff_async(max_retries=2, initial_delay=0.0, jitter=False, on_retry=on_retry)
    async def flaky():
        raise RuntimeError("boom")

    with pytest.raises(RuntimeError):
        asyncio.run(flaky())

    assert len(retries_seen) == 2
    assert [a for _, a in retries_seen] == [0, 1]


def test_async_jitter_produce_delays_variables():
    """Async: con jitter=True los delays registrados deben ser mayores que cero."""
    import unittest.mock as mock

    delays: list[float] = []

    async def capture_sleep(d: float) -> None:
        delays.append(d)

    @retry_with_backoff_async(max_retries=3, initial_delay=1.0, max_delay=30.0, jitter=True)
    async def always_fails():
        raise RuntimeError("x")

    with mock.patch("asyncio.sleep", side_effect=capture_sleep):
        with pytest.raises(RuntimeError):
            asyncio.run(always_fails())

    assert all(d > 0 for d in delays)
    assert len(delays) == 3


def test_async_solo_reintenta_excepciones_especificadas():
    """Async: solo debe reintentar las excepciones del tuple especificado."""
    calls = []

    @retry_with_backoff_async(
        max_retries=3,
        initial_delay=0.0,
        jitter=False,
        exceptions=(ConnectionError,),
    )
    async def raises_value_error():
        calls.append(1)
        raise ValueError("not retryable")

    with pytest.raises(ValueError):
        asyncio.run(raises_value_error())

    assert len(calls) == 1
