"""Tests for HTTP client retry logic (http_client module).

Tests cover:
- RetryConfig defaults and customisation
- _calculate_delay: exponential backoff, jitter, Retry-After cap
- _parse_retry_after: header extraction
- _is_retryable_status: status code classification
- RetryClient (sync): retries on 5xx/429, connection errors, no retry on 4xx
- AsyncRetryClient: same retry semantics in async context
- Retry-After header is respected
- Max retries exhausted returns last response / raises last exception
"""
from __future__ import annotations

import asyncio
from unittest.mock import MagicMock, AsyncMock, patch

import httpx
import pytest

from greymatter_core.http_client import (
    AsyncRetryClient,
    RetryClient,
    RetryConfig,
    _calculate_delay,
    _is_retryable_status,
    _parse_retry_after,
    _DEFAULT_RETRYABLE_CODES,
)


# ---------------------------------------------------------------------------
# RetryConfig
# ---------------------------------------------------------------------------

class TestRetryConfig:
    def test_defaults(self):
        cfg = RetryConfig()
        assert cfg.max_retries == 3
        assert cfg.base_delay == 1.0
        assert cfg.max_delay == 30.0
        assert cfg.jitter is True
        assert cfg.retryable_status_codes == _DEFAULT_RETRYABLE_CODES

    def test_custom_values(self):
        cfg = RetryConfig(max_retries=5, base_delay=0.5, max_delay=10.0, jitter=False)
        assert cfg.max_retries == 5
        assert cfg.base_delay == 0.5
        assert cfg.max_delay == 10.0
        assert cfg.jitter is False

    def test_immutable(self):
        cfg = RetryConfig()
        with pytest.raises(AttributeError):
            setattr(cfg, "max_retries", 10)


# ---------------------------------------------------------------------------
# _calculate_delay
# ---------------------------------------------------------------------------

class TestCalculateDelay:
    def test_exponential_without_jitter(self):
        cfg = RetryConfig(base_delay=1.0, jitter=False)
        assert _calculate_delay(0, cfg) == 1.0   # 1 * 2^0
        assert _calculate_delay(1, cfg) == 2.0   # 1 * 2^1
        assert _calculate_delay(2, cfg) == 4.0   # 1 * 2^2
        assert _calculate_delay(3, cfg) == 8.0   # 1 * 2^3

    def test_capped_at_max_delay(self):
        cfg = RetryConfig(base_delay=1.0, max_delay=5.0, jitter=False)
        assert _calculate_delay(10, cfg) == 5.0

    def test_jitter_within_range(self):
        cfg = RetryConfig(base_delay=2.0, jitter=True)
        for _ in range(50):
            delay = _calculate_delay(0, cfg)
            # base=2.0, attempt=0 -> raw delay = 2.0
            # jitter: [2.0 * 0.5, 2.0 * 1.0] = [1.0, 2.0]
            assert 1.0 <= delay <= 2.0

    def test_retry_after_honoured(self):
        cfg = RetryConfig(base_delay=1.0, jitter=False)
        assert _calculate_delay(0, cfg, retry_after=5.0) == 5.0

    def test_retry_after_capped_at_max(self):
        cfg = RetryConfig(max_delay=10.0)
        assert _calculate_delay(0, cfg, retry_after=60.0) == 10.0


# ---------------------------------------------------------------------------
# _parse_retry_after
# ---------------------------------------------------------------------------

class TestParseRetryAfter:
    def test_numeric_value(self):
        resp = MagicMock(spec=httpx.Response)
        resp.headers = {"retry-after": "3"}
        assert _parse_retry_after(resp) == 3.0

    def test_float_value(self):
        resp = MagicMock(spec=httpx.Response)
        resp.headers = {"retry-after": "1.5"}
        assert _parse_retry_after(resp) == 1.5

    def test_missing_header(self):
        resp = MagicMock(spec=httpx.Response)
        resp.headers = {}
        assert _parse_retry_after(resp) is None

    def test_non_numeric_value(self):
        resp = MagicMock(spec=httpx.Response)
        resp.headers = {"retry-after": "Wed, 21 Oct 2026 07:28:00 GMT"}
        assert _parse_retry_after(resp) is None


# ---------------------------------------------------------------------------
# _is_retryable_status
# ---------------------------------------------------------------------------

class TestIsRetryableStatus:
    @pytest.mark.parametrize("code", [429, 500, 502, 503, 504])
    def test_retryable_codes(self, code):
        resp = MagicMock(spec=httpx.Response)
        resp.status_code = code
        assert _is_retryable_status(resp, RetryConfig()) is True

    @pytest.mark.parametrize("code", [200, 201, 301, 400, 401, 403, 404, 409])
    def test_non_retryable_codes(self, code):
        resp = MagicMock(spec=httpx.Response)
        resp.status_code = code
        assert _is_retryable_status(resp, RetryConfig()) is False


# ---------------------------------------------------------------------------
# RetryClient (sync)
# ---------------------------------------------------------------------------

class TestRetryClient:
    def _mock_response(self, status_code: int = 200, headers: dict | None = None):
        resp = MagicMock(spec=httpx.Response)
        resp.status_code = status_code
        resp.headers = headers or {}
        resp.json.return_value = {"ok": True}
        return resp

    @patch("greymatter_core.http_client.time.sleep")
    def test_successful_request_no_retry(self, mock_sleep):
        """200 response on first attempt — no retries."""
        cfg = RetryConfig(max_retries=3)
        with RetryClient(retry_config=cfg) as client:
            with patch.object(client._client, "request", return_value=self._mock_response(200)):
                resp = client.get("http://example.com/api")
        assert resp.status_code == 200
        mock_sleep.assert_not_called()

    @patch("greymatter_core.http_client.time.sleep")
    def test_retries_on_503_then_succeeds(self, mock_sleep):
        """503 on first two attempts, 200 on third."""
        cfg = RetryConfig(max_retries=3, base_delay=0.1, jitter=False)
        with RetryClient(retry_config=cfg) as client:
            with patch.object(
                client._client, "request",
                side_effect=[
                    self._mock_response(503),
                    self._mock_response(503),
                    self._mock_response(200),
                ],
            ):
                resp = client.post("http://example.com/api", json={"x": 1})
        assert resp.status_code == 200
        assert mock_sleep.call_count == 2

    @patch("greymatter_core.http_client.time.sleep")
    def test_retries_on_429_respects_retry_after(self, mock_sleep):
        """429 with Retry-After header — delay should match header."""
        cfg = RetryConfig(max_retries=2, base_delay=1.0, jitter=False)
        with RetryClient(retry_config=cfg) as client:
            with patch.object(
                client._client, "request",
                side_effect=[
                    self._mock_response(429, headers={"retry-after": "2"}),
                    self._mock_response(200),
                ],
            ):
                resp = client.get("http://example.com/api")
        assert resp.status_code == 200
        mock_sleep.assert_called_once_with(2.0)

    @patch("greymatter_core.http_client.time.sleep")
    def test_returns_last_response_when_retries_exhausted(self, mock_sleep):
        """All retries exhausted on 500 — returns the final 500 response."""
        cfg = RetryConfig(max_retries=2, base_delay=0.1, jitter=False)
        with RetryClient(retry_config=cfg) as client:
            with patch.object(
                client._client, "request",
                return_value=self._mock_response(500),
            ):
                resp = client.get("http://example.com/api")
        # After max_retries attempts, the last 500 should be returned
        assert resp.status_code == 500
        assert mock_sleep.call_count == 2

    @patch("greymatter_core.http_client.time.sleep")
    def test_no_retry_on_client_error(self, mock_sleep):
        """4xx (non-429) should not be retried."""
        cfg = RetryConfig(max_retries=3)
        with RetryClient(retry_config=cfg) as client:
            with patch.object(client._client, "request", return_value=self._mock_response(403)):
                resp = client.get("http://example.com/api")
        assert resp.status_code == 403
        mock_sleep.assert_not_called()

    @patch("greymatter_core.http_client.time.sleep")
    def test_retries_on_connection_error(self, mock_sleep):
        """Connection error is retried, then succeeds."""
        cfg = RetryConfig(max_retries=3, base_delay=0.1, jitter=False)
        with RetryClient(retry_config=cfg) as client:
            with patch.object(
                client._client, "request",
                side_effect=[
                    httpx.ConnectError("refused"),
                    self._mock_response(200),
                ],
            ):
                resp = client.get("http://example.com/api")
        assert resp.status_code == 200
        assert mock_sleep.call_count == 1

    @patch("greymatter_core.http_client.time.sleep")
    def test_raises_after_all_connection_retries_exhausted(self, mock_sleep):
        """Connection error on every attempt — raises on the last one."""
        cfg = RetryConfig(max_retries=2, base_delay=0.1, jitter=False)
        with RetryClient(retry_config=cfg) as client:
            with patch.object(
                client._client, "request",
                side_effect=httpx.ConnectError("refused"),
            ):
                with pytest.raises(httpx.ConnectError):
                    client.get("http://example.com/api")
        assert mock_sleep.call_count == 2

    @patch("greymatter_core.http_client.time.sleep")
    def test_retries_on_read_timeout(self, mock_sleep):
        """ReadTimeout is retried."""
        cfg = RetryConfig(max_retries=2, base_delay=0.1, jitter=False)
        with RetryClient(retry_config=cfg) as client:
            with patch.object(
                client._client, "request",
                side_effect=[
                    httpx.ReadTimeout("timed out"),
                    self._mock_response(200),
                ],
            ):
                resp = client.get("http://example.com/api")
        assert resp.status_code == 200

    def test_zero_retries_no_retry(self):
        """max_retries=0 means no retries — fail immediately."""
        cfg = RetryConfig(max_retries=0)
        with RetryClient(retry_config=cfg) as client:
            with patch.object(client._client, "request", return_value=self._mock_response(503)):
                resp = client.get("http://example.com/api")
        assert resp.status_code == 503

    def test_context_manager_required(self):
        """Using request without context manager raises RuntimeError."""
        client = RetryClient()
        with pytest.raises(RuntimeError, match="context manager"):
            client.get("http://example.com/api")

    @patch("greymatter_core.http_client.time.sleep")
    def test_exponential_backoff_delays(self, mock_sleep):
        """Verify delays grow exponentially."""
        cfg = RetryConfig(max_retries=3, base_delay=1.0, jitter=False)
        with RetryClient(retry_config=cfg) as client:
            with patch.object(
                client._client, "request",
                return_value=self._mock_response(500),
            ):
                resp = client.get("http://example.com/api")
        assert resp.status_code == 500
        delays = [call.args[0] for call in mock_sleep.call_args_list]
        assert delays == [1.0, 2.0, 4.0]

    @patch("greymatter_core.http_client.time.sleep")
    def test_put_and_delete_verbs(self, mock_sleep):
        """PUT and DELETE use the same retry logic."""
        cfg = RetryConfig(max_retries=1, base_delay=0.1, jitter=False)
        with RetryClient(retry_config=cfg) as client:
            with patch.object(client._client, "request", return_value=self._mock_response(200)):
                resp_put = client.put("http://example.com/api")
                resp_del = client.delete("http://example.com/api")
        assert resp_put.status_code == 200
        assert resp_del.status_code == 200


# ---------------------------------------------------------------------------
# AsyncRetryClient
# ---------------------------------------------------------------------------

class TestAsyncRetryClient:
    def _mock_response(self, status_code: int = 200, headers: dict | None = None):
        resp = MagicMock(spec=httpx.Response)
        resp.status_code = status_code
        resp.headers = headers or {}
        resp.json.return_value = {"ok": True}
        return resp

    @pytest.mark.asyncio
    @patch("greymatter_core.http_client.asyncio.sleep", new_callable=AsyncMock)
    async def test_successful_request_no_retry(self, mock_sleep):
        cfg = RetryConfig(max_retries=3)
        async with AsyncRetryClient(retry_config=cfg) as client:
            with patch.object(client._client, "request", new_callable=AsyncMock, return_value=self._mock_response(200)):
                resp = await client.get("http://example.com/api")
        assert resp.status_code == 200
        mock_sleep.assert_not_called()

    @pytest.mark.asyncio
    @patch("greymatter_core.http_client.asyncio.sleep", new_callable=AsyncMock)
    async def test_retries_on_502_then_succeeds(self, mock_sleep):
        cfg = RetryConfig(max_retries=3, base_delay=0.1, jitter=False)
        async with AsyncRetryClient(retry_config=cfg) as client:
            with patch.object(
                client._client, "request",
                new_callable=AsyncMock,
                side_effect=[
                    self._mock_response(502),
                    self._mock_response(200),
                ],
            ):
                resp = await client.post("http://example.com/api", json={"x": 1})
        assert resp.status_code == 200
        assert mock_sleep.call_count == 1

    @pytest.mark.asyncio
    @patch("greymatter_core.http_client.asyncio.sleep", new_callable=AsyncMock)
    async def test_retries_on_connection_error(self, mock_sleep):
        cfg = RetryConfig(max_retries=2, base_delay=0.1, jitter=False)
        async with AsyncRetryClient(retry_config=cfg) as client:
            with patch.object(
                client._client, "request",
                new_callable=AsyncMock,
                side_effect=[
                    httpx.ConnectError("refused"),
                    self._mock_response(200),
                ],
            ):
                resp = await client.get("http://example.com/api")
        assert resp.status_code == 200

    @pytest.mark.asyncio
    @patch("greymatter_core.http_client.asyncio.sleep", new_callable=AsyncMock)
    async def test_raises_after_all_retries_exhausted(self, mock_sleep):
        cfg = RetryConfig(max_retries=1, base_delay=0.1, jitter=False)
        async with AsyncRetryClient(retry_config=cfg) as client:
            with patch.object(
                client._client, "request",
                new_callable=AsyncMock,
                side_effect=httpx.ConnectTimeout("timed out"),
            ):
                with pytest.raises(httpx.ConnectTimeout):
                    await client.get("http://example.com/api")

    @pytest.mark.asyncio
    @patch("greymatter_core.http_client.asyncio.sleep", new_callable=AsyncMock)
    async def test_returns_last_response_when_retries_exhausted(self, mock_sleep):
        cfg = RetryConfig(max_retries=2, base_delay=0.1, jitter=False)
        async with AsyncRetryClient(retry_config=cfg) as client:
            with patch.object(
                client._client, "request",
                new_callable=AsyncMock,
                return_value=self._mock_response(504),
            ):
                resp = await client.get("http://example.com/api")
        assert resp.status_code == 504
        assert mock_sleep.call_count == 2

    @pytest.mark.asyncio
    @patch("greymatter_core.http_client.asyncio.sleep", new_callable=AsyncMock)
    async def test_429_respects_retry_after(self, mock_sleep):
        cfg = RetryConfig(max_retries=2, base_delay=1.0, jitter=False)
        async with AsyncRetryClient(retry_config=cfg) as client:
            with patch.object(
                client._client, "request",
                new_callable=AsyncMock,
                side_effect=[
                    self._mock_response(429, headers={"retry-after": "3"}),
                    self._mock_response(200),
                ],
            ):
                resp = await client.get("http://example.com/api")
        assert resp.status_code == 200
        mock_sleep.assert_called_once_with(3.0)

    @pytest.mark.asyncio
    @patch("greymatter_core.http_client.asyncio.sleep", new_callable=AsyncMock)
    async def test_no_retry_on_404(self, mock_sleep):
        cfg = RetryConfig(max_retries=3)
        async with AsyncRetryClient(retry_config=cfg) as client:
            with patch.object(
                client._client, "request",
                new_callable=AsyncMock,
                return_value=self._mock_response(404),
            ):
                resp = await client.get("http://example.com/api")
        assert resp.status_code == 404
        mock_sleep.assert_not_called()

    @pytest.mark.asyncio
    async def test_context_manager_required(self):
        client = AsyncRetryClient()
        with pytest.raises(RuntimeError, match="context manager"):
            await client.get("http://example.com/api")

    @pytest.mark.asyncio
    @patch("greymatter_core.http_client.asyncio.sleep", new_callable=AsyncMock)
    async def test_exponential_backoff_delays(self, mock_sleep):
        cfg = RetryConfig(max_retries=3, base_delay=1.0, jitter=False)
        async with AsyncRetryClient(retry_config=cfg) as client:
            with patch.object(
                client._client, "request",
                new_callable=AsyncMock,
                return_value=self._mock_response(500),
            ):
                resp = await client.get("http://example.com/api")
        assert resp.status_code == 500
        delays = [call.args[0] for call in mock_sleep.call_args_list]
        assert delays == [1.0, 2.0, 4.0]
