"""Tests for retry middleware."""

from unittest.mock import AsyncMock, MagicMock, patch

import httpx
import pytest

from oro_sdk.retry import (
    AsyncRetryTransport,
    RetryConfig,
    RetryContext,
    RetryTransport,
    compute_delay,
    parse_retry_after,
)

# ---------------------------------------------------------------------------
# parse_retry_after
# ---------------------------------------------------------------------------


class TestParseRetryAfter:
    def test_returns_none_when_header_missing(self):
        assert parse_retry_after(httpx.Response(429)) is None

    def test_parses_integer_seconds(self):
        assert parse_retry_after(httpx.Response(429, headers={"Retry-After": "120"})) == 120.0

    def test_parses_zero_seconds(self):
        assert parse_retry_after(httpx.Response(429, headers={"Retry-After": "0"})) == 0.0

    def test_parses_http_date(self):
        from datetime import datetime, timedelta, timezone
        from email.utils import format_datetime

        future = datetime.now(timezone.utc) + timedelta(seconds=60)
        resp = httpx.Response(429, headers={"Retry-After": format_datetime(future, usegmt=True)})
        result = parse_retry_after(resp)
        assert result is not None and 50.0 < result <= 65.0

    def test_returns_zero_for_past_date(self):
        from datetime import datetime, timedelta, timezone
        from email.utils import format_datetime

        past = datetime.now(timezone.utc) - timedelta(seconds=60)
        assert parse_retry_after(
            httpx.Response(429, headers={"Retry-After": format_datetime(past, usegmt=True)})
        ) == 0.0

    def test_returns_none_for_unparseable(self):
        assert parse_retry_after(httpx.Response(429, headers={"Retry-After": "not-valid"})) is None


# ---------------------------------------------------------------------------
# compute_delay
# ---------------------------------------------------------------------------


class TestComputeDelay:
    @pytest.mark.parametrize(
        ("attempt", "expected"),
        [(0, 1.0), (1, 2.0), (2, 4.0), (3, 8.0)],
    )
    def test_exponential_backoff(self, attempt: int, expected: float):
        assert compute_delay(attempt, 1.0, 30.0, False) == expected

    def test_caps_at_max_delay(self):
        assert compute_delay(10, 1.0, 30.0, False) == 30.0

    def test_jitter_produces_varied_delays(self):
        results = {compute_delay(2, 1.0, 30.0, True) for _ in range(100)}
        assert all(0 <= d < 4.0 for d in results)
        assert len(results) > 1

    def test_uses_retry_after_when_provided(self):
        assert compute_delay(0, 1.0, 30.0, False, retry_after_s=5.0) == 5.0

    def test_caps_retry_after_at_max(self):
        assert compute_delay(0, 1.0, 30.0, False, retry_after_s=60.0) == 30.0


# ---------------------------------------------------------------------------
# Helpers shared across sync/async transport tests
# ---------------------------------------------------------------------------

RETRYABLE_STATUSES = [429, 502, 503, 504]
NON_RETRYABLE_STATUSES = [400, 401, 404, 422]


def _make_config(**overrides) -> RetryConfig:
    defaults = dict(max_retries=3, jitter=False, base_delay_s=0.01)
    defaults.update(overrides)
    return RetryConfig(**defaults)


# ---------------------------------------------------------------------------
# RetryTransport (sync)
# ---------------------------------------------------------------------------


class TestRetryTransport:
    def test_returns_response_on_success(self):
        mock = MagicMock()
        mock.handle_request.return_value = httpx.Response(200)
        transport = RetryTransport(_make_config(), wrapped=mock)

        resp = transport.handle_request(httpx.Request("GET", "https://x.com/test"))

        assert resp.status_code == 200
        assert mock.handle_request.call_count == 1

    @pytest.mark.parametrize("status", RETRYABLE_STATUSES)
    @patch("oro_sdk.retry.time.sleep")
    def test_retries_on_retryable_status(self, mock_sleep: MagicMock, status: int):
        mock = MagicMock()
        mock.handle_request.side_effect = [httpx.Response(status), httpx.Response(200)]
        transport = RetryTransport(_make_config(), wrapped=mock)

        resp = transport.handle_request(httpx.Request("GET", "https://x.com/test"))

        assert resp.status_code == 200
        assert mock.handle_request.call_count == 2
        mock_sleep.assert_called_once()

    @pytest.mark.parametrize("status", NON_RETRYABLE_STATUSES)
    def test_does_not_retry_on_non_retryable_status(self, status: int):
        mock = MagicMock()
        mock.handle_request.return_value = httpx.Response(status)
        transport = RetryTransport(_make_config(), wrapped=mock)

        resp = transport.handle_request(httpx.Request("GET", "https://x.com/test"))

        assert resp.status_code == status
        assert mock.handle_request.call_count == 1

    @pytest.mark.parametrize("exc_cls", [httpx.ConnectError, httpx.ConnectTimeout])
    @patch("oro_sdk.retry.time.sleep")
    def test_retries_on_network_error(self, mock_sleep: MagicMock, exc_cls: type):
        mock = MagicMock()
        mock.handle_request.side_effect = [exc_cls("fail"), httpx.Response(200)]
        transport = RetryTransport(_make_config(), wrapped=mock)

        resp = transport.handle_request(httpx.Request("GET", "https://x.com/test"))

        assert resp.status_code == 200
        assert mock.handle_request.call_count == 2

    @patch("oro_sdk.retry.time.sleep")
    def test_exhausts_retries_returns_last_response(self, mock_sleep: MagicMock):
        mock = MagicMock()
        mock.handle_request.return_value = httpx.Response(503)
        transport = RetryTransport(_make_config(max_retries=2), wrapped=mock)

        resp = transport.handle_request(httpx.Request("GET", "https://x.com/test"))

        assert resp.status_code == 503
        assert mock.handle_request.call_count == 3  # 1 initial + 2 retries

    @patch("oro_sdk.retry.time.sleep")
    def test_exhausts_retries_raises_on_network_error(self, mock_sleep: MagicMock):
        mock = MagicMock()
        mock.handle_request.side_effect = httpx.ConnectError("fail")
        transport = RetryTransport(_make_config(max_retries=2), wrapped=mock)

        with pytest.raises(httpx.ConnectError):
            transport.handle_request(httpx.Request("GET", "https://x.com/test"))
        assert mock.handle_request.call_count == 3

    @patch("oro_sdk.retry.time.sleep")
    def test_calls_on_retry_callback(self, mock_sleep: MagicMock):
        mock = MagicMock()
        mock.handle_request.side_effect = [httpx.Response(503), httpx.Response(200)]
        on_retry = MagicMock()
        transport = RetryTransport(
            _make_config(base_delay_s=0.1, on_retry=on_retry), wrapped=mock
        )

        transport.handle_request(httpx.Request("GET", "https://x.com/test"))

        on_retry.assert_called_once()
        ctx: RetryContext = on_retry.call_args[0][0]
        assert ctx.attempt == 1
        assert ctx.status_code == 503
        assert ctx.delay_s == 0.1

    @patch("oro_sdk.retry.time.sleep")
    def test_respects_retry_after_header(self, mock_sleep: MagicMock):
        mock = MagicMock()
        mock.handle_request.side_effect = [
            httpx.Response(429, headers={"Retry-After": "2"}),
            httpx.Response(200),
        ]
        on_retry = MagicMock()
        transport = RetryTransport(
            _make_config(base_delay_s=0.1, on_retry=on_retry), wrapped=mock
        )

        transport.handle_request(httpx.Request("GET", "https://x.com/test"))

        ctx: RetryContext = on_retry.call_args[0][0]
        assert ctx.retry_after_s == 2.0
        assert ctx.delay_s == 2.0

    def test_no_retry_when_max_retries_zero(self):
        mock = MagicMock()
        mock.handle_request.return_value = httpx.Response(503)
        transport = RetryTransport(RetryConfig(max_retries=0), wrapped=mock)

        resp = transport.handle_request(httpx.Request("GET", "https://x.com/test"))

        assert resp.status_code == 503
        assert mock.handle_request.call_count == 1

    @patch("oro_sdk.retry.time.sleep")
    def test_on_retry_for_network_error_has_no_status(self, mock_sleep: MagicMock):
        mock = MagicMock()
        mock.handle_request.side_effect = [httpx.ConnectError("fail"), httpx.Response(200)]
        on_retry = MagicMock()
        transport = RetryTransport(
            _make_config(base_delay_s=0.1, on_retry=on_retry), wrapped=mock
        )

        transport.handle_request(httpx.Request("GET", "https://x.com/test"))

        ctx: RetryContext = on_retry.call_args[0][0]
        assert ctx.status_code is None


# ---------------------------------------------------------------------------
# AsyncRetryTransport — only tests that verify async plumbing;
# core retry logic is shared and already tested above.
# ---------------------------------------------------------------------------


class TestAsyncRetryTransport:
    @pytest.mark.asyncio
    async def test_returns_response_on_success(self):
        mock = MagicMock()
        mock.handle_async_request = AsyncMock(return_value=httpx.Response(200))
        transport = AsyncRetryTransport(RetryConfig(max_retries=3), wrapped=mock)

        resp = await transport.handle_async_request(httpx.Request("GET", "https://x.com/test"))

        assert resp.status_code == 200
        assert mock.handle_async_request.call_count == 1

    @pytest.mark.asyncio
    @patch("oro_sdk.retry.asyncio.sleep", new_callable=AsyncMock)
    async def test_retries_and_uses_asyncio_sleep(self, mock_sleep: AsyncMock):
        mock = MagicMock()
        mock.handle_async_request = AsyncMock(
            side_effect=[httpx.Response(429), httpx.Response(200)]
        )
        transport = AsyncRetryTransport(_make_config(), wrapped=mock)

        resp = await transport.handle_async_request(httpx.Request("GET", "https://x.com/test"))

        assert resp.status_code == 200
        assert mock.handle_async_request.call_count == 2
        mock_sleep.assert_awaited_once()

    @pytest.mark.asyncio
    @patch("oro_sdk.retry.asyncio.sleep", new_callable=AsyncMock)
    async def test_retries_on_connect_error(self, mock_sleep: AsyncMock):
        mock = MagicMock()
        mock.handle_async_request = AsyncMock(
            side_effect=[httpx.ConnectError("fail"), httpx.Response(200)]
        )
        transport = AsyncRetryTransport(_make_config(), wrapped=mock)

        resp = await transport.handle_async_request(httpx.Request("GET", "https://x.com/test"))

        assert resp.status_code == 200

    @pytest.mark.asyncio
    @patch("oro_sdk.retry.asyncio.sleep", new_callable=AsyncMock)
    async def test_exhausts_retries(self, mock_sleep: AsyncMock):
        mock = MagicMock()
        mock.handle_async_request = AsyncMock(return_value=httpx.Response(503))
        transport = AsyncRetryTransport(_make_config(max_retries=2), wrapped=mock)

        resp = await transport.handle_async_request(httpx.Request("GET", "https://x.com/test"))

        assert resp.status_code == 503
        assert mock.handle_async_request.call_count == 3

    @pytest.mark.asyncio
    async def test_no_retry_when_max_retries_zero(self):
        mock = MagicMock()
        mock.handle_async_request = AsyncMock(return_value=httpx.Response(503))
        transport = AsyncRetryTransport(RetryConfig(max_retries=0), wrapped=mock)

        resp = await transport.handle_async_request(httpx.Request("GET", "https://x.com/test"))

        assert resp.status_code == 503
        assert mock.handle_async_request.call_count == 1
