"""Tests for the EH-11 retry primitive (mgf.common.retry)."""

from __future__ import annotations

import logging

import pytest

from mgf.common.exceptions import AppError
from mgf.common.retry import aretry, retry


class _TransientError(AppError):
    """A retryable transient failure."""


class _FatalError(Exception):
    """A non-retryable failure (not in the default AppError tree)."""


@pytest.fixture(autouse=True)
def _no_sleep(monkeypatch: pytest.MonkeyPatch) -> list[float]:
    """Capture (and skip) backoff sleeps so tests are instant."""
    slept: list[float] = []
    monkeypatch.setattr("mgf.common.retry.time.sleep", lambda d: slept.append(d))

    async def _fake_asleep(d: float) -> None:
        slept.append(d)

    monkeypatch.setattr("mgf.common.retry.asyncio.sleep", _fake_asleep)
    return slept


# --------------------------------------------------------------------------- sync
def test_returns_on_first_success(_no_sleep: list[float]) -> None:
    assert retry(lambda: 42) == 42
    assert _no_sleep == []  # no retry, no sleep


def test_retries_then_succeeds(_no_sleep: list[float]) -> None:
    calls = {"n": 0}

    def flaky() -> str:
        calls["n"] += 1
        if calls["n"] < 3:
            raise _TransientError("nope")
        return "ok"

    assert retry(flaky, retryable=(_TransientError,), operation="t") == "ok"
    assert calls["n"] == 3
    assert len(_no_sleep) == 2  # slept before attempts 2 and 3


def test_exhausts_and_reraises(_no_sleep: list[float]) -> None:
    def always_fail() -> None:
        raise _TransientError("still nope")

    with pytest.raises(_TransientError):
        retry(always_fail, retryable=(_TransientError,), max_attempts=4)
    assert len(_no_sleep) == 3  # 4 attempts → 3 sleeps


def test_non_retryable_propagates_immediately(_no_sleep: list[float]) -> None:
    calls = {"n": 0}

    def boom() -> None:
        calls["n"] += 1
        raise _FatalError("wrong input")

    with pytest.raises(_FatalError):
        retry(boom, retryable=(_TransientError,), max_attempts=5)
    assert calls["n"] == 1  # not retried
    assert _no_sleep == []


def test_full_jitter_is_bounded(monkeypatch: pytest.MonkeyPatch) -> None:
    """EH-11: delay ∈ [0, min(cap, base·2^(attempt-1))]; never linear/immediate."""
    captured: list[tuple[float, float]] = []
    monkeypatch.setattr("mgf.common.retry.time.sleep", lambda d: None)
    monkeypatch.setattr(
        "mgf.common.retry.random.uniform",
        lambda lo, hi: captured.append((lo, hi)) or hi,
    )

    def always_fail() -> None:
        raise _TransientError()

    with pytest.raises(_TransientError):
        retry(always_fail, retryable=(_TransientError,), max_attempts=5,
              base_delay_s=0.5, max_delay_s=4.0)
    # ceilings: 0.5, 1.0, 2.0, 4.0 (capped at max_delay_s=4.0 from attempt 4)
    assert [lo for lo, _ in captured] == [0, 0, 0, 0]          # full jitter floor
    assert [hi for _, hi in captured] == [0.5, 1.0, 2.0, 4.0]  # exponential, capped


def test_audit_logs_per_attempt_and_on_exhaustion(
    _no_sleep: list[float], caplog: pytest.LogCaptureFixture
) -> None:
    with caplog.at_level(logging.WARNING, logger="mgf.common.retry"), pytest.raises(_TransientError):
        retry(lambda: (_ for _ in ()).throw(_TransientError()),
              retryable=(_TransientError,), max_attempts=3, operation="op-x")
    events = [r.event for r in caplog.records if hasattr(r, "event")]
    assert events == ["retry.attempt", "retry.attempt", "retry.exhausted"]
    assert all(getattr(r, "audit", False) for r in caplog.records if hasattr(r, "event"))
    assert any(getattr(r, "operation", None) == "op-x" for r in caplog.records)


@pytest.mark.parametrize("bad", [{"max_attempts": 0}, {"base_delay_s": -1}, {"max_delay_s": -1}])
def test_validates_args(bad: dict[str, float]) -> None:
    with pytest.raises(ValueError):
        retry(lambda: 1, **bad)  # type: ignore[arg-type]


# -------------------------------------------------------------------------- async
@pytest.mark.asyncio
async def test_aretry_retries_then_succeeds(_no_sleep: list[float]) -> None:
    calls = {"n": 0}

    async def flaky() -> str:
        calls["n"] += 1
        if calls["n"] < 2:
            raise _TransientError()
        return "ok"

    assert await aretry(flaky, retryable=(_TransientError,)) == "ok"
    assert calls["n"] == 2
    assert len(_no_sleep) == 1


@pytest.mark.asyncio
async def test_aretry_exhausts_and_reraises(_no_sleep: list[float]) -> None:
    async def always_fail() -> None:
        raise _TransientError()

    with pytest.raises(_TransientError):
        await aretry(always_fail, retryable=(_TransientError,), max_attempts=2)
    assert len(_no_sleep) == 1
