"""Unit tests for axm_bib.http — request_with_retry."""

from __future__ import annotations

from unittest.mock import MagicMock, patch

import httpx
import pytest

from axm_bib.exceptions import APIUnavailable, RateLimitError
from axm_bib.http import request_with_retry


def _mock_response(status: int, *, headers: dict[str, str] | None = None) -> MagicMock:
    """Build a mock httpx.Response with the given status code."""
    resp = MagicMock(spec=httpx.Response)
    resp.status_code = status
    resp.headers = headers or {}

    # raise_for_status should raise on error codes
    if status >= 400:
        error = httpx.HTTPStatusError(
            f"HTTP {status}",
            request=MagicMock(),
            response=resp,
        )
        resp.raise_for_status.side_effect = error

    return resp


class TestRequestWithRetry:
    """Core retry logic tests."""

    @patch("axm_bib.http.time.sleep")
    @patch("axm_bib.http.httpx.request")
    def test_success_no_retry(
        self,
        mock_request: MagicMock,
        mock_sleep: MagicMock,
    ) -> None:
        """200 response returns immediately, no sleep called."""
        mock_request.return_value = _mock_response(200)

        resp = request_with_retry("GET", "https://example.com")

        assert resp.status_code == 200
        mock_sleep.assert_not_called()
        assert mock_request.call_count == 1

    @patch("axm_bib.http.time.sleep")
    @patch("axm_bib.http.httpx.request")
    def test_retry_on_429(
        self,
        mock_request: MagicMock,
        mock_sleep: MagicMock,
    ) -> None:
        """429 triggers retry, returns 200 on second attempt."""
        mock_request.side_effect = [
            _mock_response(429),
            _mock_response(200),
        ]

        resp = request_with_retry("GET", "https://example.com")

        assert resp.status_code == 200
        assert mock_request.call_count == 2
        assert mock_sleep.call_count == 1

    @patch("axm_bib.http.time.sleep")
    @patch("axm_bib.http.httpx.request")
    def test_retry_on_503(
        self,
        mock_request: MagicMock,
        mock_sleep: MagicMock,
    ) -> None:
        """503 triggers retry, returns 200 on second attempt."""
        mock_request.side_effect = [
            _mock_response(503),
            _mock_response(200),
        ]

        resp = request_with_retry("GET", "https://example.com")

        assert resp.status_code == 200
        assert mock_request.call_count == 2
        assert mock_sleep.call_count == 1


class TestRetryAfterHeader:
    """Retry-After header parsing tests."""

    @patch("axm_bib.http.time.sleep")
    @patch("axm_bib.http.httpx.request")
    def test_respects_retry_after_header(
        self,
        mock_request: MagicMock,
        mock_sleep: MagicMock,
    ) -> None:
        """Retry-After: 5 causes sleep(5.0)."""
        mock_request.side_effect = [
            _mock_response(429, headers={"retry-after": "5"}),
            _mock_response(200),
        ]

        request_with_retry("GET", "https://example.com")

        mock_sleep.assert_called_once_with(5.0)

    @patch("axm_bib.http.time.sleep")
    @patch("axm_bib.http.httpx.request")
    def test_retry_after_capped_at_max(
        self,
        mock_request: MagicMock,
        mock_sleep: MagicMock,
    ) -> None:
        """Retry-After: 300 is capped at 30s."""
        mock_request.side_effect = [
            _mock_response(429, headers={"retry-after": "300"}),
            _mock_response(200),
        ]

        request_with_retry("GET", "https://example.com")

        mock_sleep.assert_called_once_with(30.0)

    @patch("axm_bib.http.time.sleep")
    @patch("axm_bib.http.httpx.request")
    def test_non_numeric_retry_after(
        self,
        mock_request: MagicMock,
        mock_sleep: MagicMock,
    ) -> None:
        """Non-numeric Retry-After falls back to exponential backoff."""
        mock_request.side_effect = [
            _mock_response(
                429,
                headers={"retry-after": "Thu, 01 Dec 2025 16:00:00 GMT"},
            ),
            _mock_response(200),
        ]

        request_with_retry("GET", "https://example.com")

        # Should have slept with backoff, not Retry-After value
        assert mock_sleep.call_count == 1
        delay = mock_sleep.call_args[0][0]
        # backoff_base=1.0, attempt=0 → 1.0 * 2^0 + jitter (0..0.5)
        assert 1.0 <= delay <= 1.5


class TestBackoffProgression:
    """Exponential backoff timing tests."""

    @patch("axm_bib.http.time.sleep")
    @patch("axm_bib.http.httpx.request")
    def test_exponential_backoff(
        self,
        mock_request: MagicMock,
        mock_sleep: MagicMock,
    ) -> None:
        """Sleep durations increase: ~1s → ~2s."""
        mock_request.side_effect = [
            _mock_response(429),
            _mock_response(429),
            _mock_response(200),
        ]

        request_with_retry("GET", "https://example.com")

        assert mock_sleep.call_count == 2
        delays = [call[0][0] for call in mock_sleep.call_args_list]
        # attempt 0: 1.0 * 2^0 + jitter = ~1.0-1.5
        # attempt 1: 1.0 * 2^1 + jitter = ~2.0-2.5
        assert 1.0 <= delays[0] <= 1.5
        assert 2.0 <= delays[1] <= 2.5


class TestMaxRetriesExhausted:
    """Behaviour when all retries are exhausted."""

    @patch("axm_bib.http.time.sleep")
    @patch("axm_bib.http.httpx.request")
    def test_max_retries_exceeded(
        self,
        mock_request: MagicMock,
        mock_sleep: MagicMock,
    ) -> None:
        """Raises RateLimitError after max_retries exhausted on 429."""
        mock_request.return_value = _mock_response(429)

        with pytest.raises(RateLimitError):
            request_with_retry("GET", "https://example.com", max_retries=3)

        # 1 initial + 3 retries = 4 calls
        assert mock_request.call_count == 4
        assert mock_sleep.call_count == 3

    @patch("axm_bib.http.time.sleep")
    @patch("axm_bib.http.httpx.request")
    def test_max_retries_503_raises_api_unavailable(
        self,
        mock_request: MagicMock,
        mock_sleep: MagicMock,
    ) -> None:
        """Raises APIUnavailable after max_retries exhausted on 503."""
        mock_request.return_value = _mock_response(503)

        with pytest.raises(APIUnavailable):
            request_with_retry("GET", "https://example.com", max_retries=2)

        assert mock_request.call_count == 3
        assert mock_sleep.call_count == 2


class TestNonRetryableErrors:
    """Non-retryable HTTP errors."""

    @patch("axm_bib.http.time.sleep")
    @patch("axm_bib.http.httpx.request")
    def test_no_retry_on_4xx(
        self,
        mock_request: MagicMock,
        mock_sleep: MagicMock,
    ) -> None:
        """400 raises immediately, no retry."""
        mock_request.return_value = _mock_response(400)

        with pytest.raises(httpx.HTTPStatusError):
            request_with_retry("GET", "https://example.com")

        assert mock_request.call_count == 1
        mock_sleep.assert_not_called()


class TestNetworkErrors:
    """Transport-level error retry tests."""

    @patch("axm_bib.http.time.sleep")
    @patch("axm_bib.http.httpx.request")
    def test_network_timeout_during_retry(
        self,
        mock_request: MagicMock,
        mock_sleep: MagicMock,
    ) -> None:
        """ConnectError counts as retry attempt."""
        mock_request.side_effect = [
            httpx.ConnectError("Connection refused"),
            _mock_response(200),
        ]

        resp = request_with_retry("GET", "https://example.com")

        assert resp.status_code == 200
        assert mock_request.call_count == 2
        assert mock_sleep.call_count == 1

    @patch("axm_bib.http.time.sleep")
    @patch("axm_bib.http.httpx.request")
    def test_network_error_exhausts_retries(
        self,
        mock_request: MagicMock,
        mock_sleep: MagicMock,
    ) -> None:
        """Transport errors raise after max retries."""
        mock_request.side_effect = httpx.ConnectError("Connection refused")

        with pytest.raises(httpx.ConnectError):
            request_with_retry("GET", "https://example.com", max_retries=2)

        assert mock_request.call_count == 3
        assert mock_sleep.call_count == 2
