"""Tests for ``mgf.http.AsyncHttpClient``.

Uses respx to mock httpx — no real network calls.
"""

from __future__ import annotations

import warnings

import httpx
import pytest
import respx

from mgf.http import AsyncHttpClient, HttpTransportError, RetryPolicy

# ---------------------------------------------------------------------------
# Construction.
# ---------------------------------------------------------------------------


class TestConstruction:
    def test_defaults(self) -> None:
        client = AsyncHttpClient()
        assert client._timeout == 30.0
        assert client._retry.max_attempts == 0
        # User-Agent set on the underlying httpx client.
        assert "User-Agent" in client.client.headers

    def test_user_agent_includes_mgf_http(self) -> None:
        client = AsyncHttpClient()
        ua = client.client.headers["User-Agent"]
        assert "mgf-http/" in ua

    def test_explicit_user_agent_overrides_default(self) -> None:
        client = AsyncHttpClient(user_agent="my-app/1.0")
        assert client.client.headers["User-Agent"] == "my-app/1.0"

    def test_default_headers_merged(self) -> None:
        client = AsyncHttpClient(default_headers={"X-Tenant": "acme"})
        assert client.client.headers["x-tenant"] == "acme"
        # Default User-Agent still present.
        assert "User-Agent" in client.client.headers

    def test_zero_timeout_rejected(self) -> None:
        with pytest.raises(ValueError, match="> 0"):
            AsyncHttpClient(timeout=0)

    def test_negative_timeout_rejected(self) -> None:
        with pytest.raises(ValueError, match="> 0"):
            AsyncHttpClient(timeout=-5.0)

    def test_verify_tls_disabled_emits_warning(self, monkeypatch: pytest.MonkeyPatch) -> None:
        # Reset the one-shot guard so the warning fires.
        from mgf.http import _client as _client_mod

        monkeypatch.setattr(_client_mod, "_TLS_DISABLE_WARNED", False)

        with warnings.catch_warnings(record=True) as caught:
            warnings.simplefilter("always")
            AsyncHttpClient(verify_tls=False)
        msgs = [str(w.message) for w in caught if issubclass(w.category, UserWarning)]
        assert any("verify_tls=False" in m for m in msgs)

    def test_verify_tls_disabled_warning_one_shot(self, monkeypatch: pytest.MonkeyPatch) -> None:
        from mgf.http import _client as _client_mod

        monkeypatch.setattr(_client_mod, "_TLS_DISABLE_WARNED", False)

        with warnings.catch_warnings(record=True) as caught:
            warnings.simplefilter("always")
            AsyncHttpClient(verify_tls=False)
            AsyncHttpClient(verify_tls=False)
            AsyncHttpClient(verify_tls=False)
        tls_warnings = [w for w in caught if "verify_tls=False" in str(w.message)]
        assert len(tls_warnings) == 1


# ---------------------------------------------------------------------------
# Basic requests via respx.
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
class TestBasicRequests:
    @respx.mock
    async def test_get(self) -> None:
        respx.get("https://api.example.com/users").mock(
            return_value=httpx.Response(200, json=[{"id": 1}])
        )
        async with AsyncHttpClient() as client:
            response = await client.get("https://api.example.com/users")
        assert response.status_code == 200
        assert response.json() == [{"id": 1}]

    @respx.mock
    async def test_post_with_json(self) -> None:
        import json as _json

        route = respx.post("https://api.example.com/users").mock(
            return_value=httpx.Response(201, json={"id": "new"})
        )
        async with AsyncHttpClient() as client:
            response = await client.post(
                "https://api.example.com/users",
                json={"name": "alice"},
            )
        assert response.status_code == 201
        # JSON-decode-compare to dodge spaces vs. no-spaces in encoding.
        sent = _json.loads(route.calls.last.request.read())
        assert sent == {"name": "alice"}

    @respx.mock
    async def test_put_patch_delete_head(self) -> None:
        respx.put("https://x.test/y").mock(return_value=httpx.Response(200))
        respx.patch("https://x.test/y").mock(return_value=httpx.Response(200))
        respx.delete("https://x.test/y").mock(return_value=httpx.Response(204))
        respx.head("https://x.test/y").mock(return_value=httpx.Response(200))

        async with AsyncHttpClient() as client:
            assert (await client.put("https://x.test/y")).status_code == 200
            assert (await client.patch("https://x.test/y")).status_code == 200
            assert (await client.delete("https://x.test/y")).status_code == 204
            assert (await client.head("https://x.test/y")).status_code == 200

    @respx.mock
    async def test_4xx_returns_response_not_raises(self) -> None:
        respx.get("https://x.test/y").mock(return_value=httpx.Response(404))
        async with AsyncHttpClient() as client:
            response = await client.get("https://x.test/y")
        # 4xx is the consumer's decision; we don't raise here.
        assert response.status_code == 404

    @respx.mock
    async def test_500_returns_response_not_raises(self) -> None:
        respx.get("https://x.test/y").mock(return_value=httpx.Response(500))
        async with AsyncHttpClient() as client:
            response = await client.get("https://x.test/y")
        assert response.status_code == 500


# ---------------------------------------------------------------------------
# Headers + base_url.
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
class TestHeadersAndBaseUrl:
    @respx.mock
    async def test_request_headers_merged_with_default(self) -> None:
        route = respx.get("https://x.test/y").mock(return_value=httpx.Response(200))
        async with AsyncHttpClient(default_headers={"X-Tenant": "acme"}) as client:
            await client.get(
                "https://x.test/y",
                headers={"X-Request-Id": "req-001"},
            )
        sent = route.calls.last.request
        assert sent.headers["x-tenant"] == "acme"
        assert sent.headers["x-request-id"] == "req-001"

    @respx.mock
    async def test_base_url_prefix(self) -> None:
        respx.get("https://api.example.com/v1/users").mock(return_value=httpx.Response(200))
        async with AsyncHttpClient(base_url="https://api.example.com/v1") as client:
            response = await client.get("/users")
        assert response.status_code == 200

    @respx.mock
    async def test_cookies_attached(self) -> None:
        route = respx.get("https://x.test/y").mock(return_value=httpx.Response(200))
        async with AsyncHttpClient() as client:
            await client.get(
                "https://x.test/y",
                cookies={"session": "session-id-xyz"},
            )
        sent = route.calls.last.request
        cookie_header = sent.headers.get("cookie", "")
        assert "session=session-id-xyz" in cookie_header


# ---------------------------------------------------------------------------
# Error translation.
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
class TestErrorTranslation:
    @respx.mock
    async def test_timeout_raises_httpclienterror(self) -> None:
        respx.get("https://x.test/y").mock(side_effect=httpx.ReadTimeout("read timed out"))
        async with AsyncHttpClient() as client:
            with pytest.raises(HttpTransportError) as exc_info:
                await client.get("https://x.test/y")
        assert "timeout" in exc_info.value.cause_summary.lower()
        assert exc_info.value.url == "https://x.test/y"
        assert exc_info.value.attempts == 1
        assert isinstance(exc_info.value.__cause__, httpx.ReadTimeout)

    @respx.mock
    async def test_connect_error_raises_httpclienterror(self) -> None:
        respx.get("https://x.test/y").mock(side_effect=httpx.ConnectError("dns down"))
        async with AsyncHttpClient() as client:
            with pytest.raises(HttpTransportError) as exc_info:
                await client.get("https://x.test/y")
        assert "connect-error" in exc_info.value.cause_summary.lower()

    @respx.mock
    async def test_network_error_raises_httpclienterror(self) -> None:
        respx.get("https://x.test/y").mock(side_effect=httpx.ReadError("read failed"))
        async with AsyncHttpClient() as client:
            with pytest.raises(HttpTransportError) as exc_info:
                await client.get("https://x.test/y")
        assert "network-error" in exc_info.value.cause_summary.lower()

    @respx.mock
    async def test_unsupported_protocol_not_retried(self) -> None:
        # UnsupportedProtocol is an httpx.HTTPError that's NOT a
        # network/timeout — covers the catch-all "other HTTPError"
        # path. Non-retryable; single attempt; wrapped in
        # HttpTransportError.
        respx.get("https://x.test/y").mock(
            side_effect=httpx.UnsupportedProtocol("scheme not supported")
        )
        async with AsyncHttpClient(retry=RetryPolicy(max_attempts=3)) as client:
            with pytest.raises(HttpTransportError) as exc_info:
                await client.get("https://x.test/y")
        assert exc_info.value.attempts == 1


# ---------------------------------------------------------------------------
# Retry behavior.
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
class TestRetry:
    @respx.mock
    async def test_no_retry_by_default(self) -> None:
        route = respx.get("https://x.test/y").mock(side_effect=httpx.ReadTimeout("timeout"))
        async with AsyncHttpClient() as client:
            with pytest.raises(HttpTransportError) as exc_info:
                await client.get("https://x.test/y")
        # Single attempt because max_attempts=0.
        assert route.call_count == 1
        assert exc_info.value.attempts == 1

    @respx.mock
    async def test_retry_on_transport_failure(self, monkeypatch: pytest.MonkeyPatch) -> None:
        # Make backoff instant for fast tests.
        from mgf.http import _retry

        monkeypatch.setattr(_retry.RetryPolicy, "backoff_for_attempt", lambda self, attempt: 0.0)

        # First two calls fail, third succeeds.
        responses = [
            httpx.ReadTimeout("timeout 1"),
            httpx.ReadTimeout("timeout 2"),
            httpx.Response(200, json={"ok": True}),
        ]
        route = respx.get("https://x.test/y").mock(side_effect=responses)

        async with AsyncHttpClient(retry=RetryPolicy(max_attempts=3)) as client:
            response = await client.get("https://x.test/y")

        assert response.status_code == 200
        assert route.call_count == 3

    @respx.mock
    async def test_retry_exhaustion_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
        from mgf.http import _retry as _r

        monkeypatch.setattr(_r.RetryPolicy, "backoff_for_attempt", lambda self, attempt: 0.0)

        respx.get("https://x.test/y").mock(side_effect=httpx.ReadTimeout("perma-timeout"))
        async with AsyncHttpClient(retry=RetryPolicy(max_attempts=2)) as client:
            with pytest.raises(HttpTransportError) as exc_info:
                await client.get("https://x.test/y")
        # 1 initial + 2 retries = 3 attempts.
        assert exc_info.value.attempts == 3

    @respx.mock
    async def test_post_not_retried_by_default(self, monkeypatch: pytest.MonkeyPatch) -> None:
        from mgf.http import _retry as _r

        monkeypatch.setattr(_r.RetryPolicy, "backoff_for_attempt", lambda self, attempt: 0.0)

        # POST is not retryable by default.
        route = respx.post("https://x.test/y").mock(side_effect=httpx.ReadTimeout("timeout"))
        async with AsyncHttpClient(retry=RetryPolicy(max_attempts=5)) as client:
            with pytest.raises(HttpTransportError) as exc_info:
                await client.post("https://x.test/y")
        # Single attempt — POST not in retryable set.
        assert route.call_count == 1
        assert exc_info.value.attempts == 1

    @respx.mock
    async def test_post_retried_when_explicitly_allowed(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        from mgf.http import _retry as _r

        monkeypatch.setattr(_r.RetryPolicy, "backoff_for_attempt", lambda self, attempt: 0.0)

        responses = [
            httpx.ReadTimeout("timeout"),
            httpx.Response(201, json={"id": "new"}),
        ]
        route = respx.post("https://x.test/y").mock(side_effect=responses)
        policy = RetryPolicy(
            max_attempts=2,
            retryable_methods=frozenset({"POST"}),
        )
        async with AsyncHttpClient(retry=policy) as client:
            response = await client.post("https://x.test/y")
        assert response.status_code == 201
        assert route.call_count == 2

    @respx.mock
    async def test_retry_on_503(self, monkeypatch: pytest.MonkeyPatch) -> None:
        from mgf.http import _retry as _r

        monkeypatch.setattr(_r.RetryPolicy, "backoff_for_attempt", lambda self, attempt: 0.0)

        responses = [
            httpx.Response(503),
            httpx.Response(200, json={"ok": True}),
        ]
        route = respx.get("https://x.test/y").mock(side_effect=responses)
        async with AsyncHttpClient(retry=RetryPolicy(max_attempts=2)) as client:
            response = await client.get("https://x.test/y")
        assert response.status_code == 200
        assert route.call_count == 2

    @respx.mock
    async def test_no_retry_on_404(self) -> None:
        # 404 is not in the retryable_statuses set.
        respx.get("https://x.test/y").mock(return_value=httpx.Response(404))
        async with AsyncHttpClient(retry=RetryPolicy(max_attempts=3)) as client:
            response = await client.get("https://x.test/y")
        assert response.status_code == 404


# ---------------------------------------------------------------------------
# Per-call overrides.
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
class TestPerCallOverrides:
    @respx.mock
    async def test_per_call_retry_overrides_constructor(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        from mgf.http import _retry as _r

        monkeypatch.setattr(_r.RetryPolicy, "backoff_for_attempt", lambda self, attempt: 0.0)

        responses = [httpx.ReadTimeout("timeout"), httpx.Response(200)]
        route = respx.get("https://x.test/y").mock(side_effect=responses)

        # Constructor: max_attempts=0. Per-call: max_attempts=2.
        async with AsyncHttpClient() as client:
            response = await client.request(
                "GET",
                "https://x.test/y",
                retry=RetryPolicy(max_attempts=2),
            )
        assert response.status_code == 200
        assert route.call_count == 2


# ---------------------------------------------------------------------------
# Lifecycle.
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
class TestLifecycle:
    async def test_aclose_idempotent(self) -> None:
        client = AsyncHttpClient()
        await client.aclose()
        await client.aclose()  # second call must not raise

    @respx.mock
    async def test_async_context_manager(self) -> None:
        respx.get("https://x.test/y").mock(return_value=httpx.Response(200))
        async with AsyncHttpClient() as client:
            response = await client.get("https://x.test/y")
        assert response.status_code == 200


# ---------------------------------------------------------------------------
# Redaction in logs.
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
class TestRedaction:
    @respx.mock
    async def test_authorization_redacted_in_log(self, caplog: pytest.LogCaptureFixture) -> None:
        import logging

        respx.get("https://x.test/y").mock(return_value=httpx.Response(200))
        with caplog.at_level(logging.INFO, logger="mgf.http"):
            async with AsyncHttpClient() as client:
                await client.get(
                    "https://x.test/y",
                    headers={"Authorization": "Bearer secret-xyz"},
                )

        log_text = " ".join(r.getMessage() for r in caplog.records)
        # The header NAME is in the log; the VALUE is not.
        assert "Authorization" in log_text
        assert "Bearer secret-xyz" not in log_text
        assert "<redacted>" in log_text

    @respx.mock
    async def test_cookie_redacted_in_log(self, caplog: pytest.LogCaptureFixture) -> None:
        import logging

        respx.get("https://x.test/y").mock(return_value=httpx.Response(200))
        with caplog.at_level(logging.INFO, logger="mgf.http"):
            async with AsyncHttpClient() as client:
                await client.get(
                    "https://x.test/y",
                    headers={"Cookie": "session=secret-session-id"},
                )
        log_text = " ".join(r.getMessage() for r in caplog.records)
        assert "secret-session-id" not in log_text

    @respx.mock
    async def test_non_secret_headers_not_redacted(self, caplog: pytest.LogCaptureFixture) -> None:
        import logging

        respx.get("https://x.test/y").mock(return_value=httpx.Response(200))
        with caplog.at_level(logging.INFO, logger="mgf.http"):
            async with AsyncHttpClient() as client:
                await client.get(
                    "https://x.test/y",
                    headers={"X-Tenant": "acme"},
                )
        log_text = " ".join(r.getMessage() for r in caplog.records)
        assert "acme" in log_text


class TestCookieInjectionRejection:
    """SH-01 / RA-03: cookie values containing CR/LF/NUL are rejected at
    the boundary. Defence-in-depth against header injection — httpx
    ≥ 0.27 catches some of this at the transport layer, but failing
    here keeps the error attributable to OUR code path and lets us
    raise `HttpTransportError` (the same shape every other transport
    failure uses)."""

    @pytest.mark.asyncio
    async def test_crlf_in_cookie_raises(self) -> None:
        client = AsyncHttpClient()
        try:
            with pytest.raises(HttpTransportError) as exc_info:
                await client.get(
                    "https://api.example.com/items",
                    cookies={"sess": "abc\r\nX-Inject: yes"},
                )
            assert "sess" in exc_info.value.cause_summary
            assert "CR/LF/NUL" in exc_info.value.cause_summary
        finally:
            await client.aclose()

    @pytest.mark.asyncio
    async def test_lf_only_in_cookie_raises(self) -> None:
        client = AsyncHttpClient()
        try:
            with pytest.raises(HttpTransportError):
                await client.get(
                    "https://api.example.com/items",
                    cookies={"sess": "abc\nbar"},
                )
        finally:
            await client.aclose()

    @pytest.mark.asyncio
    async def test_nul_in_cookie_raises(self) -> None:
        client = AsyncHttpClient()
        try:
            with pytest.raises(HttpTransportError):
                await client.get(
                    "https://api.example.com/items",
                    cookies={"sess": "abc\x00bar"},
                )
        finally:
            await client.aclose()

    @pytest.mark.asyncio
    @respx.mock
    async def test_clean_cookie_value_passes(self) -> None:
        respx.get("https://api.example.com/items").mock(return_value=httpx.Response(200))
        client = AsyncHttpClient()
        try:
            response = await client.get(
                "https://api.example.com/items",
                cookies={"sess": "perfectly-normal-token"},
            )
            assert response.status_code == 200
        finally:
            await client.aclose()


class TestTlsDisabledStructuredLog:
    """SH-03: verify_tls=False emits a structured WARNING log line
    in addition to the (one-shot) UserWarning. A pytest config that
    suppresses UserWarning still sees the structured log — durable
    audit trail."""

    @pytest.mark.asyncio
    async def test_structured_log_fires_on_construction(self, caplog) -> None:
        import logging

        # Suppress the UserWarning here — it's the OTHER surface
        # we're not testing in this case; SH-03's signal is the log.
        with (
            caplog.at_level(logging.WARNING, logger="mgf.http"),
            warnings.catch_warnings(),
        ):
            warnings.simplefilter("ignore", UserWarning)
            client = AsyncHttpClient(verify_tls=False)
        try:
            relevant = [r for r in caplog.records if "verify_tls" in r.getMessage()]
            assert relevant, (
                "SH-03: AsyncHttpClient(verify_tls=False) did not emit "
                "the expected structured WARNING log line."
            )
            # The audit-true extra is what SIEMs filter on.
            assert relevant[0].audit is True
            assert relevant[0].event == "mgf_http.tls_disabled"
        finally:
            await client.aclose()

    @pytest.mark.asyncio
    async def test_default_does_not_emit_warning_log(self, caplog) -> None:
        """Sanity: TLS-on default (verify_tls=True) is silent."""
        import logging

        with caplog.at_level(logging.WARNING, logger="mgf.http"):
            client = AsyncHttpClient()
        try:
            for rec in caplog.records:
                assert "verify_tls" not in rec.getMessage(), (
                    "SH-03: warning fired on the safe default — operators would learn to ignore it."
                )
        finally:
            await client.aclose()


class TestTlsWarnThreadSafety:
    """RA-01: the verify_tls=False UserWarning fires exactly once
    even under concurrent construction. Lock-guarded test-and-set
    on `_TLS_DISABLE_WARNED`."""

    def test_concurrent_construction_fires_warning_once(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        import threading

        from mgf.http import _client as _client_mod

        # Reset the module-level flag so this test starts clean.
        monkeypatch.setattr(_client_mod, "_TLS_DISABLE_WARNED", False)

        n_threads = 20
        barrier = threading.Barrier(n_threads)

        with warnings.catch_warnings(record=True) as caught:
            warnings.simplefilter("always")

            def _target() -> None:
                barrier.wait(timeout=5.0)
                AsyncHttpClient(verify_tls=False)

            threads = [threading.Thread(target=_target) for _ in range(n_threads)]
            for t in threads:
                t.start()
            for t in threads:
                t.join(timeout=10.0)

        tls_warnings = [w for w in caught if "verify_tls=False" in str(w.message)]
        # Exactly one warning fired across N concurrent constructors.
        assert len(tls_warnings) == 1, (
            f"RA-01: expected exactly 1 verify_tls=False warning "
            f"across {n_threads} threads, got {len(tls_warnings)}. "
            f"The `_TLS_DISABLE_WARN_LOCK`-guarded test-and-set is "
            f"the single-shot contract; a regression here re-spams "
            f"the operator's stderr."
        )


class TestRetryAfterParser:
    """RA-02: _parse_retry_after handles the three RFC 7231 §7.1.3
    shapes (numeric delta-seconds, HTTP-date, parse-failure)."""

    def test_numeric_seconds(self) -> None:
        from mgf.http._client import _parse_retry_after

        assert _parse_retry_after("30") == 30.0
        assert _parse_retry_after("0") == 0.0
        assert _parse_retry_after("  60  ") == 60.0  # whitespace tolerated

    def test_negative_numeric_returns_none(self) -> None:
        from mgf.http._client import _parse_retry_after

        assert _parse_retry_after("-5") is None

    def test_http_date_future(self) -> None:
        from datetime import UTC, datetime, timedelta
        from email.utils import format_datetime

        from mgf.http._client import _parse_retry_after

        future = datetime.now(UTC) + timedelta(seconds=120)
        formatted = format_datetime(future, usegmt=True)
        parsed = _parse_retry_after(formatted)
        assert parsed is not None
        # ~120s minus a few seconds of test latency.
        assert 100 < parsed < 130

    def test_http_date_past_returns_zero(self) -> None:
        from datetime import UTC, datetime, timedelta
        from email.utils import format_datetime

        from mgf.http._client import _parse_retry_after

        past = datetime.now(UTC) - timedelta(seconds=60)
        formatted = format_datetime(past, usegmt=True)
        parsed = _parse_retry_after(formatted)
        # Date already past → retry immediately (0.0).
        assert parsed == 0.0

    def test_malformed_returns_none(self) -> None:
        from mgf.http._client import _parse_retry_after

        assert _parse_retry_after("not a number or date") is None
        assert _parse_retry_after("") is None
        assert _parse_retry_after(None) is None


class TestRetryAfterHonoured:
    """RA-02: when the retry loop receives a retryable status WITH a
    Retry-After header, the next iteration sleeps for the header's
    value instead of the policy's jittered backoff."""

    @pytest.mark.asyncio
    @respx.mock
    async def test_retry_after_value_overrides_backoff(self) -> None:
        """End-to-end: 429 with Retry-After: 1 → wait ~1 sec → retry → 200.
        The policy's base_backoff is set to 100 sec so we'd never
        finish in normal CI time if Retry-After weren't honoured."""
        import time

        route = respx.get("https://api.example.com/x").mock(
            side_effect=[
                httpx.Response(429, headers={"Retry-After": "1"}),
                httpx.Response(200),
            ]
        )
        async with AsyncHttpClient(
            retry=RetryPolicy(
                max_attempts=2,
                base_backoff_seconds=100.0,  # would never finish if used
                max_backoff_seconds=120.0,
            ),
        ) as client:
            t0 = time.perf_counter()
            response = await client.get("https://api.example.com/x")
            elapsed = time.perf_counter() - t0
        assert response.status_code == 200
        assert route.call_count == 2
        # Should have waited ~1s (Retry-After value), NOT
        # ~100s (the base_backoff). 5s ceiling is generous for CI.
        assert elapsed < 5.0, (
            f"RA-02: retry-after value ignored — elapsed {elapsed:.1f}s "
            f"suggests the policy's base_backoff_seconds=100 was used."
        )

    @pytest.mark.asyncio
    @respx.mock
    async def test_honour_retry_after_false_falls_back_to_policy(self) -> None:
        """When honour_retry_after=False, the policy's backoff is
        used regardless of the Retry-After header."""
        import time

        respx.get("https://api.example.com/x").mock(
            side_effect=[
                httpx.Response(429, headers={"Retry-After": "100"}),
                httpx.Response(200),
            ]
        )
        async with AsyncHttpClient(
            retry=RetryPolicy(
                max_attempts=2,
                base_backoff_seconds=0.01,  # 10ms-ish ceiling
                max_backoff_seconds=1.0,
                honour_retry_after=False,
            ),
        ) as client:
            t0 = time.perf_counter()
            response = await client.get("https://api.example.com/x")
            elapsed = time.perf_counter() - t0
        assert response.status_code == 200
        # Should NOT have waited the Retry-After's 100s.
        assert elapsed < 5.0


# ---------------------------------------------------------------------------
# RA-04 / SH-04: cause_summary is secret-safe (no str(exc) interpolation).
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
class TestCauseSummaryIsSecretSafe:
    """RA-04 / SH-04: the ``cause_summary`` on a wrapping
    ``HttpTransportError`` is a category name only — it does NOT
    include ``str(exc)`` from the underlying httpx exception. The
    original exception is still preserved as ``__cause__`` so a
    debugger / crash-reporter sees the full chain (which then passes
    through the mgf-common Redactor on its own surface).

    The risk being closed: httpx's exception ``__str__`` may surface
    the request URL, which can carry credentials as query-string
    parameters (``?api_key=secret``). Those would otherwise land in
    ``HttpTransportError.message`` → consumer logs / CLI output /
    error-response bodies.
    """

    @respx.mock
    async def test_timeout_summary_has_no_exception_text(self) -> None:
        # httpx's TimeoutException str is "the leaky message body"
        # — assert that string is NOT in the wrapper's surface.
        leaky = "secret-token-abc-987 leaked-via-str"
        respx.get("https://x.test/y").mock(side_effect=httpx.ReadTimeout(leaky))
        async with AsyncHttpClient() as client:
            with pytest.raises(HttpTransportError) as exc_info:
                await client.get("https://x.test/y")
        # cause_summary is category-only.
        assert exc_info.value.cause_summary == "timeout"
        # The httpx-exception text MUST NOT appear in cause_summary
        # OR in the user-facing message.
        assert leaky not in exc_info.value.cause_summary
        assert leaky not in str(exc_info.value)
        # __cause__ chain is preserved for the debugger / redactor.
        assert isinstance(exc_info.value.__cause__, httpx.ReadTimeout)

    @respx.mock
    async def test_connect_error_summary_has_no_exception_text(self) -> None:
        leaky = "credential-in-url=https://api/?k=secret"
        respx.get("https://x.test/y").mock(side_effect=httpx.ConnectError(leaky))
        async with AsyncHttpClient() as client:
            with pytest.raises(HttpTransportError) as exc_info:
                await client.get("https://x.test/y")
        assert exc_info.value.cause_summary == "connect-error"
        assert leaky not in str(exc_info.value)
        assert isinstance(exc_info.value.__cause__, httpx.ConnectError)

    @respx.mock
    async def test_network_error_summary_has_no_exception_text(self) -> None:
        leaky = "x-api-key:topsecret-do-not-log"
        respx.get("https://x.test/y").mock(side_effect=httpx.ReadError(leaky))
        async with AsyncHttpClient() as client:
            with pytest.raises(HttpTransportError) as exc_info:
                await client.get("https://x.test/y")
        assert exc_info.value.cause_summary == "network-error"
        assert leaky not in str(exc_info.value)

    @respx.mock
    async def test_other_httperror_summary_has_no_exception_text(self) -> None:
        # The catch-all httpx.HTTPError branch (non-retryable).
        leaky = "url=https://api.example.com/v1?secret=abc"
        respx.get("https://x.test/y").mock(side_effect=httpx.UnsupportedProtocol(leaky))
        async with AsyncHttpClient() as client:
            with pytest.raises(HttpTransportError) as exc_info:
                await client.get("https://x.test/y")
        # Category is the exception type name only.
        assert exc_info.value.cause_summary == "UnsupportedProtocol"
        assert leaky not in str(exc_info.value)
        assert isinstance(exc_info.value.__cause__, httpx.UnsupportedProtocol)


# ---------------------------------------------------------------------------
# RA-05: follow_redirects kwarg.
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
class TestFollowRedirectsKwarg:
    """RA-05: consumers can opt out of redirect-following at
    construction. Default remains ``True`` (the API-to-provider use
    case where redirects are normal); ``False`` gives user-supplied-
    URL consumers a clean carve-out against the open-redirect class
    AND against custom-auth-header leakage on cross-origin redirects
    (SH-02)."""

    async def test_default_follow_redirects_true(self) -> None:
        client = AsyncHttpClient()
        assert client.client.follow_redirects is True
        await client.aclose()

    async def test_follow_redirects_false_disables(self) -> None:
        client = AsyncHttpClient(follow_redirects=False)
        assert client.client.follow_redirects is False
        await client.aclose()

    @respx.mock
    async def test_follow_redirects_false_returns_3xx(self) -> None:
        # When the kwarg is False, a 302 response surfaces to the
        # caller instead of being auto-followed.
        respx.get("https://x.test/y").mock(
            return_value=httpx.Response(302, headers={"Location": "https://x.test/elsewhere"})
        )
        async with AsyncHttpClient(follow_redirects=False) as client:
            response = await client.get("https://x.test/y")
        assert response.status_code == 302
        assert response.headers["Location"] == "https://x.test/elsewhere"


# ---------------------------------------------------------------------------
# RA-06: aclose() idempotency contract.
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
class TestAcloseIdempotency:
    """RA-06: pin the documented "Safe to call multiple times"
    contract on :meth:`AsyncHttpClient.aclose`. The wrapper delegates
    to httpx.AsyncClient.aclose (idempotent per httpx's contract);
    these tests catch a regression in EITHER an httpx version bump
    that breaks idempotency OR a wrapper refactor that adds
    wrapper-side state and forgets to keep close idempotent."""

    async def test_explicit_double_close_no_error(self) -> None:
        client = AsyncHttpClient()
        await client.aclose()
        await client.aclose()  # must not raise

    async def test_explicit_triple_close_no_error(self) -> None:
        client = AsyncHttpClient()
        await client.aclose()
        await client.aclose()
        await client.aclose()  # must not raise

    async def test_context_manager_then_explicit_close(self) -> None:
        # Implicit close on context-exit, then explicit close.
        # Common in tests that build a client in a fixture and
        # also call .aclose() defensively.
        async with AsyncHttpClient() as client:
            pass
        await client.aclose()  # must not raise after __aexit__

    @respx.mock
    async def test_close_after_request_then_reclose(self) -> None:
        # Realistic shape: a request happens, the client is closed,
        # the close is called again (e.g., teardown in a finally).
        respx.get("https://x.test/y").mock(return_value=httpx.Response(200))
        client = AsyncHttpClient()
        await client.get("https://x.test/y")
        await client.aclose()
        await client.aclose()  # must not raise


# ---------------------------------------------------------------------------
# PAPER-36: max_response_size ctor kwarg.
# ---------------------------------------------------------------------------


class TestMaxResponseSizeConstruction:
    def test_default_is_none(self) -> None:
        client = AsyncHttpClient()
        assert client._max_response_size is None

    def test_positive_int_accepted(self) -> None:
        client = AsyncHttpClient(max_response_size=1_000_000)
        assert client._max_response_size == 1_000_000

    def test_zero_rejected(self) -> None:
        with pytest.raises(ValueError, match="max_response_size must be > 0"):
            AsyncHttpClient(max_response_size=0)

    def test_negative_rejected(self) -> None:
        with pytest.raises(ValueError, match="max_response_size must be > 0"):
            AsyncHttpClient(max_response_size=-1)


@pytest.mark.asyncio
class TestMaxResponseSize:
    @respx.mock
    async def test_under_cap_returns_full_body(self) -> None:
        body = b"x" * 500
        respx.get("https://x.test/y").mock(
            return_value=httpx.Response(200, content=body),
        )
        async with AsyncHttpClient(max_response_size=1000) as client:
            response = await client.get("https://x.test/y")
        assert response.status_code == 200
        assert response.content == body

    @respx.mock
    async def test_at_cap_returns_full_body(self) -> None:
        body = b"x" * 1000
        respx.get("https://x.test/y").mock(
            return_value=httpx.Response(200, content=body),
        )
        async with AsyncHttpClient(max_response_size=1000) as client:
            response = await client.get("https://x.test/y")
        assert response.status_code == 200
        assert response.content == body

    @respx.mock
    async def test_over_cap_raises_response_too_large(self) -> None:
        body = b"x" * 1001
        respx.get("https://x.test/y").mock(
            return_value=httpx.Response(200, content=body),
        )
        async with AsyncHttpClient(max_response_size=1000) as client:
            with pytest.raises(HttpTransportError) as exc_info:
                await client.get("https://x.test/y")
        assert exc_info.value.cause_summary == "response-too-large"
        assert exc_info.value.url == "https://x.test/y"

    @respx.mock
    async def test_default_none_buffers_fully(self) -> None:
        # Without a cap, a 10 MB body comes back intact — the cap path
        # is opt-in only and doesn't change the default behaviour.
        body = b"x" * 10_000_000
        respx.get("https://x.test/y").mock(
            return_value=httpx.Response(200, content=body),
        )
        async with AsyncHttpClient() as client:
            response = await client.get("https://x.test/y")
        assert response.status_code == 200
        assert len(response.content) == 10_000_000


# ---------------------------------------------------------------------------
# PAPER-36: public .stream() method.
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
class TestStream:
    @respx.mock
    async def test_yields_response_with_status_and_headers(self) -> None:
        respx.get("https://x.test/y").mock(
            return_value=httpx.Response(
                200,
                headers={"X-Custom": "v"},
                content=b"hello",
            ),
        )
        async with (
            AsyncHttpClient() as client,
            client.stream("GET", "https://x.test/y") as response,
        ):
            assert response.status_code == 200
            assert response.headers["X-Custom"] == "v"
            body = b""
            async for chunk in response.aiter_bytes():
                body += chunk
        assert body == b"hello"

    @respx.mock
    async def test_caller_can_stop_reading_early(self) -> None:
        # The whole point of stream() — caller controls how much they
        # consume. Breaking out of aiter_bytes is fine.
        body = b"x" * 10_000
        respx.get("https://x.test/y").mock(
            return_value=httpx.Response(200, content=body),
        )
        async with (
            AsyncHttpClient() as client,
            client.stream("GET", "https://x.test/y") as response,
        ):
            buf = bytearray()
            async for chunk in response.aiter_bytes():
                buf.extend(chunk)
                if len(buf) >= 100:
                    break
        assert len(buf) >= 100  # we read at least the cap before stopping

    @respx.mock
    async def test_user_agent_present_on_streamed_request(self) -> None:
        route = respx.get("https://x.test/y").mock(
            return_value=httpx.Response(200, content=b"ok"),
        )
        async with (
            AsyncHttpClient() as client,
            client.stream("GET", "https://x.test/y") as response,
        ):
            async for _ in response.aiter_bytes():
                pass
        ua = route.calls.last.request.headers.get("user-agent", "")
        assert "mgf-http/" in ua

    async def test_retry_on_transport_failure(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        # First two attempts time out; third succeeds. The retry
        # policy on the stream() path must mirror request()'s.
        calls = {"n": 0}

        async def _stream_side_effect(
            *args: object,
            **kwargs: object,
        ) -> object:
            calls["n"] += 1
            if calls["n"] < 3:
                raise httpx.TimeoutException("simulated")
            # Use a real httpx stream-shaped response via respx is
            # awkward to monkeypatch; the simpler shape: replace
            # AsyncHttpClient._client.stream with a side-effect that
            # returns a working stream on the 3rd call.
            return httpx.AsyncClient().stream("GET", "https://x.test/y")

        # Easier path: use respx with sequential side effects.
        with respx.mock:
            respx.get("https://x.test/y").mock(
                side_effect=[
                    httpx.TimeoutException("simulated 1"),
                    httpx.TimeoutException("simulated 2"),
                    httpx.Response(200, content=b"ok"),
                ],
            )
            monkeypatch.setattr("asyncio.sleep", _noop_sleep)
            async with (
                AsyncHttpClient(
                    retry=RetryPolicy(max_attempts=2),
                ) as client,
                client.stream(
                    "GET",
                    "https://x.test/y",
                ) as response,
            ):
                body = b""
                async for chunk in response.aiter_bytes():
                    body += chunk
            assert response.status_code == 200
            assert body == b"ok"

    async def test_retry_on_retryable_status(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        # Two 503s then a 200 — same policy as request() should apply
        # on the streaming path.
        monkeypatch.setattr("asyncio.sleep", _noop_sleep)
        with respx.mock:
            respx.get("https://x.test/y").mock(
                side_effect=[
                    httpx.Response(503),
                    httpx.Response(503),
                    httpx.Response(200, content=b"ok"),
                ],
            )
            async with (
                AsyncHttpClient(
                    retry=RetryPolicy(max_attempts=2),
                ) as client,
                client.stream(
                    "GET",
                    "https://x.test/y",
                ) as response,
            ):
                body = b""
                async for chunk in response.aiter_bytes():
                    body += chunk
            assert response.status_code == 200
            assert body == b"ok"

    async def test_transport_failure_after_retries_raises_typed(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        monkeypatch.setattr("asyncio.sleep", _noop_sleep)
        with respx.mock:
            respx.get("https://x.test/y").mock(
                side_effect=httpx.TimeoutException("simulated"),
            )
            async with AsyncHttpClient(
                retry=RetryPolicy(max_attempts=2),
            ) as client:
                with pytest.raises(HttpTransportError) as exc_info:
                    async with client.stream(
                        "GET",
                        "https://x.test/y",
                    ):
                        pass
            assert exc_info.value.attempts == 3
            assert "timeout" in exc_info.value.cause_summary

    @respx.mock
    async def test_no_retry_on_404(self) -> None:
        # 404 is not in retryable_statuses; should yield directly.
        route = respx.get("https://x.test/y").mock(
            return_value=httpx.Response(404, content=b""),
        )
        async with (
            AsyncHttpClient(
                retry=RetryPolicy(max_attempts=3),
            ) as client,
            client.stream(
                "GET",
                "https://x.test/y",
            ) as response,
        ):
            assert response.status_code == 404
        assert route.call_count == 1

    @respx.mock
    async def test_cookie_crlf_injection_rejected_on_stream(self) -> None:
        async with AsyncHttpClient() as client:
            with pytest.raises(HttpTransportError) as exc_info:
                async with client.stream(
                    "GET",
                    "https://x.test/y",
                    cookies={"bad": "value\r\nX-Injected: yes"},
                ):
                    pass
        assert "CR/LF/NUL" in exc_info.value.cause_summary


# Module-level helper for the retry tests above. Tests that drive
# retries replace asyncio.sleep with this to avoid the real backoff
# delays — same trick the existing TestRetry class uses.
async def _noop_sleep(seconds: float) -> None:
    return None
