"""``AsyncHttpClient`` — wraps :class:`httpx.AsyncClient` with mgf defaults.

The class is intentionally small. The value-add isn't in the request
shape (``httpx`` already nailed that); it's in the four cross-cutting
concerns every consumer reinvents:

  - **Identity** — User-Agent auto-derived from
    :func:`mgf.common.app_name` + :func:`mgf.common.app_version` +
    the library's own version, so traffic can be attributed.
  - **Retry** — :class:`RetryPolicy`-driven exponential-with-jitter
    on transient failures. Defaults to zero retries (deterministic).
  - **Redaction** — secret-shaped request headers (``Authorization``,
    ``Cookie``, ``X-Api-Key`` family, etc.) are redacted in the
    structured log line we emit per request. The response body
    isn't logged.
  - **Errors** — ``httpx`` exceptions are wrapped in
    :class:`mgf.http.HttpTransportError` so the standard CLI
    exit-code translator + ``except AppError:`` patterns work.

OTel span emission is **best-effort**: when the ``[observability]``
extra is installed (and a TracerProvider is wired), each
:meth:`request` call opens a span named ``http.client.<METHOD>``.
Without OTel, the span block no-ops.

TLS verification is **default-on**. Constructing with
``verify_tls=False`` emits :class:`UserWarning` once per process —
disabling TLS in production is almost always a bug.
"""

from __future__ import annotations

import asyncio
import contextlib
import functools
import logging
import threading
import warnings
from typing import TYPE_CHECKING, Any
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit

import httpx

from mgf.http._errors import HttpTransportError
from mgf.http._retry import RetryPolicy

if TYPE_CHECKING:
    from collections.abc import AsyncIterator, Callable, Mapping
    from pathlib import Path
    from types import TracebackType

    from mgf.http._download import DownloadResult

_LOG = logging.getLogger("mgf.http")

# Header names treated as secret-shaped for redaction in logs/spans.
# Case-insensitive comparison via .lower() at lookup time.
_SECRET_HEADER_KEYS: frozenset[str] = frozenset(
    {
        "authorization",
        "cookie",
        "set-cookie",
        "x-api-key",
        "x-auth-token",
        "x-secret",
        "proxy-authorization",
    }
)

# Tracks whether the verify_tls=False warning has already fired
# this process. One-shot per process to avoid spam in test runs
# that exercise the disabled-tls path repeatedly.
#
# RA-01: gate the test-and-set behind a lock. Two threads
# concurrently constructing `AsyncHttpClient(verify_tls=False)`
# could otherwise both observe `_TLS_DISABLE_WARNED is False`
# before either set it, producing duplicate warnings (mild
# nuisance today, but the wrong shape; future "warn-once"
# diagnostics added in this file inherit the same pattern, so
# fix the precedent here).
_TLS_DISABLE_WARNED = False
_TLS_DISABLE_WARN_LOCK = threading.Lock()


class AsyncHttpClient:
    """Thin wrapper over :class:`httpx.AsyncClient` with mgf defaults.

    Use as an async context manager OR call :meth:`aclose` explicitly::

        async with AsyncHttpClient(timeout=10.0) as http:
            response = await http.get("https://api.example.com/users")

        # equivalent:
        http = AsyncHttpClient(timeout=10.0)
        try:
            response = await http.get("https://api.example.com/users")
        finally:
            await http.aclose()

    Reuse one instance across many calls to share the connection pool.

    For tests, mock the underlying httpx layer with respx — the
    wrapper is transparent to it.

    Security — cross-origin redirects (SH-02)
    -----------------------------------------

    The client follows redirects by default. httpx (≥ 0.20) **strips
    the ``Authorization`` and ``Proxy-Authorization`` headers** on
    redirects to a different origin, preventing those credentials
    from leaking to an attacker-controlled host via a 302. This
    sibling inherits that behaviour; no extra wrapping needed for
    the standard auth headers.

    **What httpx does NOT strip:** custom auth headers
    (``X-Api-Key``, ``X-Auth-Token``, vendor-specific names) — httpx
    doesn't know they're secret. For endpoints behind custom
    headers, treat any redirect to an off-origin as a potential
    leak. Options:

      - **Don't follow redirects on high-stakes calls.** Pass the
        request through httpx's lower-level API and surface 3xx as
        a Finding rather than chasing.
      - **Restrict the redirect chain to known origins.** Wrap the
        client with a transport that rejects redirects whose
        ``Location`` resolves outside an allowlist.
      - **Use the Bearer / Authorization header shape** wherever
        feasible — httpx's strip rule covers it.

    PAPER references: the RA-05 follow-up tracks shipping a
    ``follow_redirects=`` kwarg so consumers can opt out at
    construction.
    """

    def __init__(
        self,
        *,
        base_url: str = "",
        timeout: float = 30.0,
        verify_tls: bool = True,
        user_agent: str | None = None,
        default_headers: Mapping[str, str] | None = None,
        retry: RetryPolicy | None = None,
        follow_redirects: bool = True,
        max_response_size: int | None = None,
    ) -> None:
        """Construct an ``AsyncHttpClient``.

        Args:
            base_url: prefix for relative request URLs.
            timeout: per-request timeout in seconds (must be > 0).
            verify_tls: enable TLS certificate verification. Default
                ``True``; set ``False`` only for test fixtures and
                expect a ``UserWarning`` + structured WARNING log.
            user_agent: override the auto-derived User-Agent.
            default_headers: merged into every request after User-Agent.
            retry: per-client default :class:`RetryPolicy`.
            follow_redirects: follow 3xx redirects automatically.
                Defaults to ``True`` because mgf-http's intended use
                case is outbound API-to-provider calls where redirects
                are normal (provider → CDN). For URLs that include
                user-supplied components (open-redirect class) or
                high-stakes endpoints behind custom auth headers
                (httpx strips ``Authorization`` on cross-origin
                redirects but NOT custom ``X-Api-Key``-style headers,
                per SH-02), set ``follow_redirects=False`` and handle
                3xx in the caller. (RA-05.)
            max_response_size: cap on the response body size in bytes
                that :meth:`request` will accept. When set to a
                positive int, :meth:`request` streams the response
                under the hood and raises
                :class:`HttpTransportError` with
                ``cause_summary="response-too-large"`` as soon as the
                accumulated body exceeds the cap (preventing
                unbounded memory growth from a malicious or
                misconfigured upstream). Default ``None`` preserves
                the historical buffer-fully behaviour — useful for
                trusted-provider calls where the response shape is
                known. SSRF-conscious consumers (link previews, OG
                metadata scrapers, citation enrichers fetching
                user-submitted URLs) should set this explicitly.
                PAPER-36.
        """
        if timeout <= 0:
            raise ValueError(f"timeout must be > 0 (got {timeout})")
        if max_response_size is not None and max_response_size <= 0:
            raise ValueError(f"max_response_size must be > 0 or None (got {max_response_size})")

        if not verify_tls:
            global _TLS_DISABLE_WARNED
            # RA-01: lock-guarded test-and-set. Two threads racing
            # on AsyncHttpClient(verify_tls=False) construction can't
            # both observe the flag as False; exactly one fires the
            # warning regardless of timing.
            should_warn = False
            with _TLS_DISABLE_WARN_LOCK:
                if not _TLS_DISABLE_WARNED:
                    _TLS_DISABLE_WARNED = True
                    should_warn = True
            if should_warn:
                warnings.warn(
                    "AsyncHttpClient: verify_tls=False — TLS certificate "
                    "verification is DISABLED. This is almost never the "
                    "right thing in production. If this is a test fixture, "
                    "narrow the scope (per-test client, not global). If a "
                    "provider's TLS chain is broken, file with them.",
                    UserWarning,
                    stacklevel=2,
                )
            # SH-03: also surface the disabled-TLS state via a
            # structured WARNING log line. The UserWarning above is
            # fragile — pytest configs that suppress UserWarning lose
            # the signal. The log line fires on EVERY construction
            # (not once-per-process) so the signal is durable;
            # `audit=True` extra lets SIEMs filter the audit trail.
            _LOG.warning(
                "AsyncHttpClient: verify_tls=False",
                extra={
                    "audit": True,
                    "event": "mgf_http.tls_disabled",
                },
            )

        self._timeout = timeout
        self._retry = retry or RetryPolicy()
        self._user_agent = user_agent or _default_user_agent()
        self._max_response_size = max_response_size

        # Assemble default headers: User-Agent first (consumer
        # overrides win), then user-supplied defaults.
        headers = {"User-Agent": self._user_agent}
        if default_headers:
            headers.update(dict(default_headers))

        self._client = httpx.AsyncClient(
            base_url=base_url,
            timeout=timeout,
            verify=verify_tls,
            headers=headers,
            follow_redirects=follow_redirects,
        )

    # ── Lifecycle ────────────────────────────────────────────────────────

    async def aclose(self) -> None:
        """Close the underlying connection pool. Safe to call multiple times."""
        await self._client.aclose()

    async def __aenter__(self) -> AsyncHttpClient:
        return self

    async def __aexit__(
        self,
        exc_type: type[BaseException] | None,
        exc_value: BaseException | None,
        traceback: TracebackType | None,
    ) -> None:
        await self.aclose()

    # ── Underlying client (escape hatch) ──────────────────────────────────

    @property
    def client(self) -> httpx.AsyncClient:
        """The wrapped :class:`httpx.AsyncClient`.

        Exposed so consumers needing httpx-specific features (streaming
        downloads, multipart uploads, custom transports) can reach
        through. The wrapper's defaults still apply at construction
        time; per-call kwargs fall back to httpx semantics.

        Avoid using this for the request shapes :meth:`request` already
        covers — you'll lose retry, redaction, and error translation.
        """
        return self._client

    # ── Convenience method shortcuts ──────────────────────────────────────

    async def get(self, url: str, **kwargs: Any) -> httpx.Response:
        """Send a GET request; see :meth:`request` for retry/timeout/redaction semantics.

        Example::

            resp = await client.get("/items")
        """
        return await self.request("GET", url, **kwargs)

    async def post(self, url: str, **kwargs: Any) -> httpx.Response:
        """Send a POST request; see :meth:`request` for retry/timeout/redaction semantics.

        Example::

            resp = await client.post("/items")
        """
        return await self.request("POST", url, **kwargs)

    async def put(self, url: str, **kwargs: Any) -> httpx.Response:
        """Send a PUT request; see :meth:`request` for retry/timeout/redaction semantics.

        Example::

            resp = await client.put("/items")
        """
        return await self.request("PUT", url, **kwargs)

    async def patch(self, url: str, **kwargs: Any) -> httpx.Response:
        """Send a PATCH request; see :meth:`request` for retry/timeout/redaction semantics.

        Example::

            resp = await client.patch("/items")
        """
        return await self.request("PATCH", url, **kwargs)

    async def delete(self, url: str, **kwargs: Any) -> httpx.Response:
        """Send a DELETE request; see :meth:`request` for retry/timeout/redaction semantics.

        Example::

            resp = await client.delete("/items")
        """
        return await self.request("DELETE", url, **kwargs)

    async def head(self, url: str, **kwargs: Any) -> httpx.Response:
        """Send a HEAD request; see :meth:`request` for retry/timeout/redaction semantics.

        Example::

            resp = await client.head("/items")
        """
        return await self.request("HEAD", url, **kwargs)

    # ── The load-bearing call ─────────────────────────────────────────────

    async def request(
        self,
        method: str,
        url: str,
        *,
        headers: Mapping[str, str] | None = None,
        params: Mapping[str, Any] | None = None,
        cookies: Mapping[str, str] | None = None,
        json: Any = None,
        content: bytes | str | None = None,
        timeout: float | None = None,  # noqa: ASYNC109 — passthrough to httpx
        retry: RetryPolicy | None = None,
    ) -> httpx.Response:
        """Send a request; return the response.

        ``method`` is uppercase per HTTP convention. ``url`` is
        absolute or relative to the client's ``base_url``.

        Per-call overrides:

          - ``timeout`` overrides the constructor's default for this
            request only (None = use constructor default).
          - ``retry`` overrides the policy for this request only.

        Raises:

          - :class:`HttpTransportError` for transport-level failures
            (timeout, connect error, network error) AFTER retries
            exhaust. Original ``httpx`` exception preserved via
            ``__cause__``.
          - :class:`HttpTransportError` with
            ``cause_summary="response-too-large"`` when the
            client was constructed with ``max_response_size`` and
            the accumulated body exceeds the cap. PAPER-36.

        4xx and 5xx responses are NOT raised — they return as
        :class:`httpx.Response`. Use ``response.raise_for_status()``
        if you want a typed exception, or check ``response.status_code``.

        When the client's ``max_response_size`` is set, this method
        routes through :meth:`stream` internally to accumulate the
        response body with a hard cap — preventing unbounded memory
        growth from a malicious or misconfigured upstream. The
        returned :class:`httpx.Response` is buffered exactly as it
        would be without a cap; the only observable difference is
        that responses exceeding the cap raise
        :class:`HttpTransportError` instead of completing.
        """
        method_upper = method.upper()
        retry_policy = retry or self._retry
        per_call_timeout = timeout if timeout is not None else self._timeout

        if self._max_response_size is None:
            with _client_span(method_upper, url):
                return await self._send_with_retries(
                    method=method_upper,
                    url=url,
                    headers=headers,
                    params=params,
                    cookies=cookies,
                    json=json,
                    content=content,
                    timeout=per_call_timeout,
                    retry_policy=retry_policy,
                )

        # PAPER-36: cap path. Stream the response under the hood and
        # populate response.content (via the documented httpx
        # _content attribute) up to the cap; raise if exceeded.
        cap = self._max_response_size
        async with self.stream(
            method_upper,
            url,
            headers=headers,
            params=params,
            cookies=cookies,
            json=json,
            content=content,
            timeout=per_call_timeout,
            retry=retry_policy,
        ) as response:
            body = bytearray()
            async for chunk in response.aiter_bytes():
                body.extend(chunk)
                if len(body) > cap:
                    raise HttpTransportError(
                        url=url,
                        cause_summary="response-too-large",
                        attempts=1,
                    )
            # Attach the buffered content to the response so the
            # caller can use .content / .text / .json() after the
            # stream context closes. httpx's own Response.aread()
            # sets _content the same way; the attribute is stable
            # across httpx 0.27+ (used by respx for mocking too).
            response._content = bytes(body)
        return response

    @contextlib.asynccontextmanager
    async def stream(
        self,
        method: str,
        url: str,
        *,
        headers: Mapping[str, str] | None = None,
        params: Mapping[str, Any] | None = None,
        cookies: Mapping[str, str] | None = None,
        json: Any = None,
        content: bytes | str | None = None,
        timeout: float | None = None,  # noqa: ASYNC109 — passthrough to httpx
        retry: RetryPolicy | None = None,
    ) -> AsyncIterator[httpx.Response]:
        """Open a streaming request; yield the response while the body is unread.

        The wrapper's retry + identity + redaction + typed-error
        translation all apply, same as :meth:`request`. The
        difference: the response is yielded to the caller with the
        body NOT YET buffered. The caller drives the read via
        ``response.aiter_bytes()`` / ``aiter_text()`` / ``aiter_raw()``
        and decides how much to consume.

        Use this for:

          - **Large downloads** where buffering the full body into
            memory is wasteful or dangerous (caller streams to disk).
          - **Bounded reads** where the caller wants to stop at N
            bytes regardless of what the upstream offers (see
            ``max_response_size=`` on the constructor for the simpler
            "I just want a hard cap on .request()" shape).
          - **Server-sent events / NDJSON** where the caller parses
            chunks as they arrive.

        Yields:
            :class:`httpx.Response` with the body unread. The caller
            iterates the body via the response's async-iter methods.

        Raises:
            :class:`HttpTransportError` for transport-level failures
            (timeout, connect error, network error) AFTER retries
            exhaust. Original ``httpx`` exception preserved via
            ``__cause__``.

        Retry behaviour: identical to :meth:`request` for transport
        failures and retryable statuses. Once the response is yielded
        to the caller, retries are off the table — if the body stream
        fails mid-read, the caller catches ``httpx.HTTPError`` and
        decides how to handle it.

        PAPER-36.
        """
        method_upper = method.upper()
        retry_policy = retry or self._retry
        per_call_timeout = timeout if timeout is not None else self._timeout

        with _client_span(method_upper, url):
            method_retryable = retry_policy.is_method_retryable(method_upper)
            max_attempts = retry_policy.max_attempts if method_retryable else 0

            last_exc: BaseException | None = None
            next_retry_after: float | None = None

            for attempt in range(max_attempts + 1):
                if attempt > 0:
                    if next_retry_after is not None:
                        backoff = next_retry_after
                        next_retry_after = None
                        _LOG.debug(
                            "http stream retry-after: method=%s url=%s "
                            "attempt=%s backoff=%.3fs (from Retry-After header)",
                            method_upper,
                            url,
                            attempt,
                            backoff,
                        )
                    else:
                        backoff = retry_policy.backoff_for_attempt(attempt - 1)
                        _LOG.debug(
                            "http stream retry: method=%s url=%s attempt=%s backoff=%.3fs",
                            method_upper,
                            url,
                            attempt,
                            backoff,
                        )
                    await asyncio.sleep(backoff)

                request_headers = self._assemble_request_headers(
                    headers=headers,
                    cookies=cookies,
                    url=url,
                    attempt=attempt,
                )

                try:
                    stream_cm = self._client.stream(
                        method=method_upper,
                        url=url,
                        headers=request_headers or None,
                        params=params,
                        json=json,
                        content=content,
                        timeout=per_call_timeout,
                    )
                    yielded = False
                    async with stream_cm as response:
                        # Retry on retryable status — close this
                        # stream and re-open in the next iteration.
                        if attempt < max_attempts and retry_policy.is_status_retryable(
                            response.status_code
                        ):
                            _LOG.warning(
                                "http stream retryable status: method=%s "
                                "url=%s status=%s attempt=%s",
                                method_upper,
                                url,
                                response.status_code,
                                attempt + 1,
                            )
                            if retry_policy.honour_retry_after:
                                parsed = _parse_retry_after(response.headers.get("Retry-After"))
                                if parsed is not None:
                                    next_retry_after = min(
                                        parsed,
                                        retry_policy.max_backoff_seconds,
                                    )
                            # Exit the async-with cleanly (closes the
                            # underlying stream) and re-enter the
                            # outer for-loop for the next attempt.
                            continue

                        # Yield the still-open response to the
                        # caller. Once the caller's `async with` block
                        # exits, control returns here and we fall out
                        # of the inner `async with` (closing the
                        # stream).
                        yielded = True
                        yield response
                    # The inner `async with` has closed; httpx has
                    # set `response._elapsed`. Log now — `_log_response`
                    # reads `elapsed` and would raise RuntimeError if
                    # called while the stream was still open.
                    if yielded:
                        _log_response(method_upper, url, response, headers)
                        return
                except httpx.TimeoutException as exc:
                    last_exc = exc
                    _LOG.warning(
                        "http stream timeout: method=%s url=%s attempt=%s",
                        method_upper,
                        url,
                        attempt + 1,
                    )
                    continue
                except httpx.ConnectError as exc:
                    last_exc = exc
                    _LOG.warning(
                        "http stream connect error: method=%s url=%s attempt=%s err=%s",
                        method_upper,
                        url,
                        attempt + 1,
                        exc,
                    )
                    continue
                except httpx.NetworkError as exc:
                    last_exc = exc
                    _LOG.warning(
                        "http stream network error: method=%s url=%s attempt=%s err=%s",
                        method_upper,
                        url,
                        attempt + 1,
                        exc,
                    )
                    continue
                except httpx.HTTPError as exc:
                    _LOG.warning(
                        "http stream error (non-retryable): method=%s url=%s err=%s",
                        method_upper,
                        url,
                        exc,
                    )
                    raise HttpTransportError(
                        url=url,
                        cause_summary=type(exc).__name__,
                        attempts=attempt + 1,
                    ) from exc

            # All attempts exhausted on transport-level failures.
            cause = _categorise_exc(last_exc) if last_exc else "unknown"
            raise HttpTransportError(
                url=url,
                cause_summary=cause,
                attempts=max_attempts + 1,
            ) from last_exc

    def _assemble_request_headers(
        self,
        *,
        headers: Mapping[str, str] | None,
        cookies: Mapping[str, str] | None,
        url: str,
        attempt: int,
    ) -> dict[str, str]:
        """Build the per-request headers dict + cookie validation.

        Shared between :meth:`request`'s buffered path
        (:meth:`_send_with_retries`) and :meth:`stream`. Validates each
        cookie value for CR/LF/NUL header-injection (SH-01 / RA-03)
        and repacks ``cookies`` into a single ``Cookie`` header to
        sidestep httpx 0.28+'s deprecation of the per-request
        ``cookies=`` kwarg.

        Raises :class:`HttpTransportError` if any cookie value
        contains CR/LF/NUL.
        """
        request_headers = dict(headers) if headers else {}
        if cookies:
            for k, v in cookies.items():
                if any(ch in v for ch in ("\r", "\n", "\0")):
                    raise HttpTransportError(
                        url=url,
                        cause_summary=(
                            f"cookie {k!r} contains CR/LF/NUL; "
                            f"refusing to send (would risk header injection)"
                        ),
                        attempts=attempt,
                    )
            cookie_header = "; ".join(f"{k}={v}" for k, v in cookies.items())
            existing = request_headers.get("Cookie") or request_headers.get("cookie")
            if existing:
                cookie_header = f"{existing}; {cookie_header}"
                request_headers.pop("Cookie", None)
                request_headers.pop("cookie", None)
            request_headers["Cookie"] = cookie_header
        return request_headers

    async def _send_with_retries(
        self,
        *,
        method: str,
        url: str,
        headers: Mapping[str, str] | None,
        params: Mapping[str, Any] | None,
        cookies: Mapping[str, str] | None,
        json: Any,
        content: bytes | str | None,
        timeout: float,  # noqa: ASYNC109 — passthrough to httpx
        retry_policy: RetryPolicy,
    ) -> httpx.Response:
        """The inner retry + send loop.

        Returns the final response if successful (or a non-retryable
        4xx/5xx). Raises :class:`HttpTransportError` on persistent
        transport-level failure.
        """
        method_retryable = retry_policy.is_method_retryable(method)
        max_attempts = retry_policy.max_attempts if method_retryable else 0

        last_exc: BaseException | None = None
        # RA-02: a Retry-After value captured from the previous
        # response's headers, to be used INSTEAD of the policy
        # backoff at the top of the next iteration. Cleared after
        # use so subsequent retries fall back to the policy's
        # jittered backoff.
        next_retry_after: float | None = None
        for attempt in range(max_attempts + 1):
            if attempt > 0:
                if next_retry_after is not None:
                    backoff = next_retry_after
                    next_retry_after = None
                    _LOG.debug(
                        "http retry-after: method=%s url=%s attempt=%s "
                        "backoff=%.3fs (from Retry-After header)",
                        method,
                        url,
                        attempt,
                        backoff,
                    )
                else:
                    backoff = retry_policy.backoff_for_attempt(attempt - 1)
                    _LOG.debug(
                        "http retry: method=%s url=%s attempt=%s backoff=%.3fs",
                        method,
                        url,
                        attempt,
                        backoff,
                    )
                await asyncio.sleep(backoff)

            # httpx 0.28+ deprecated per-request cookies= in favour
            # of client-level cookies. We need per-request cookies for
            # our per-probe / per-call shape (CookieAuth in apiprobe,
            # etc.), so we pack the dict into a single Cookie header
            # instead. That sidesteps the deprecation entirely and is
            # exactly what httpx would have done internally.
            # SH-01 / RA-03: cookie values are validated for
            # CR/LF/NUL header-injection inside the helper.
            request_headers = self._assemble_request_headers(
                headers=headers,
                cookies=cookies,
                url=url,
                attempt=attempt,
            )

            try:
                response = await self._client.request(
                    method=method,
                    url=url,
                    headers=request_headers or None,
                    params=params,
                    json=json,
                    content=content,
                    timeout=timeout,
                )
            except httpx.TimeoutException as exc:
                last_exc = exc
                _LOG.warning(
                    "http timeout: method=%s url=%s attempt=%s",
                    method,
                    url,
                    attempt + 1,
                )
                continue
            except httpx.ConnectError as exc:
                last_exc = exc
                _LOG.warning(
                    "http connect error: method=%s url=%s attempt=%s err=%s",
                    method,
                    url,
                    attempt + 1,
                    exc,
                )
                continue
            except httpx.NetworkError as exc:
                last_exc = exc
                _LOG.warning(
                    "http network error: method=%s url=%s attempt=%s err=%s",
                    method,
                    url,
                    attempt + 1,
                    exc,
                )
                continue
            except httpx.HTTPError as exc:
                # Catch-all for any other httpx error; do NOT retry.
                # RA-04 / SH-04: category-only summary; do not
                # interpolate str(exc) into the user-facing message
                # (it may carry the request URL with credentials).
                # The full exception is preserved via __cause__.
                _LOG.warning(
                    "http error (non-retryable): method=%s url=%s err=%s",
                    method,
                    url,
                    exc,
                )
                raise HttpTransportError(
                    url=url,
                    cause_summary=type(exc).__name__,
                    attempts=attempt + 1,
                ) from exc

            # Response received. Decide whether to retry on status.
            if attempt < max_attempts and retry_policy.is_status_retryable(response.status_code):
                _LOG.warning(
                    "http retryable status: method=%s url=%s status=%s attempt=%s",
                    method,
                    url,
                    response.status_code,
                    attempt + 1,
                )
                # RA-02: honour Retry-After per RFC 7231 §7.1.3 when
                # the policy allows it. Providers (Stripe, GitHub,
                # Cloudflare, Clerk) send this on 429 / 503 / 504;
                # ignoring it burns the rate-limit window faster than
                # the provider's intended schedule.
                if retry_policy.honour_retry_after:
                    parsed = _parse_retry_after(response.headers.get("Retry-After"))
                    if parsed is not None:
                        next_retry_after = min(parsed, retry_policy.max_backoff_seconds)
                await response.aclose()
                continue

            # Successful or non-retryable response. Log + return.
            _log_response(method, url, response, headers)
            return response

        # All attempts exhausted on transport-level failures.
        cause = _categorise_exc(last_exc) if last_exc else "unknown"
        raise HttpTransportError(
            url=url,
            cause_summary=cause,
            attempts=max_attempts + 1,
        ) from last_exc

    # ── download — resumable + integrity-verified (H-NEW-1) ──────────────

    async def download(
        self,
        url: str,
        dest: Path,
        *,
        expected_sha256: str | None = None,
        expected_sha1: str | None = None,
        expected_size: int | None = None,
        resume: bool = True,
        chunk_size: int = 1024 * 1024,
        on_progress: Callable[[int, int | None], None] | None = None,
        headers: Mapping[str, str] | None = None,
    ) -> DownloadResult:
        """Stream a URL to ``dest`` with optional resume + integrity verification.

        H-NEW-1 — two-consumer signal (iosRecovery IPSW + VManager
        VM-image downloads). See :mod:`mgf.http._download` for the
        full design rationale.

        Parameters
        ----------
        url
            The download URL.
        dest
            Final destination path. The PARENT directory MUST exist
            (we never create arbitrary directory trees per SC-13).
            Writes go to ``<dest>.partial`` and rename atomically
            on verified completion.
        expected_sha256, expected_sha1
            Optional hex digests for integrity verification. SHA-256
            preferred; SHA-1 supported because that's what Apple
            ships for IPSW firmware (iosRecovery's use case).
            Mismatched values raise :class:`IntegrityError`.
        expected_size
            Optional byte count. Mismatched size raises
            :class:`IntegrityError` with ``algorithm="size"``.
        resume
            When ``True`` (default), an existing ``<dest>.partial``
            is honoured — its bytes are hashed, then a ``Range``
            request fetches the rest. When ``False``, any existing
            partial is deleted and the download starts fresh.
        chunk_size
            Bytes per read iteration. Default 1 MiB. Affects only
            granularity of ``on_progress``; throughput is httpx-
            limited.
        on_progress
            Optional callback ``(bytes_so_far, expected_size)``
            called after each chunk write. ``expected_size`` is
            ``None`` if the consumer didn't supply ``expected_size``.
        headers
            Extra headers to attach (in addition to the
            User-Agent + Authorization scrubbing the wrapper
            already does). The ``Range`` header is library-managed
            — supplying one explicitly is rejected.

        Returns
        -------
        :class:`DownloadResult`
            Always populated on success (the file is at ``dest``,
            verified, partial-rename complete).

        Raises
        ------
        :class:`IntegrityError`
            On size mismatch or hash mismatch. Subclass of
            :class:`HttpTransportError`, so ``except HttpTransportError``
            catches it.
        :class:`HttpTransportError`
            On transport-level failures (timeout, connect-error,
            network-error) AFTER retries. The partial file is
            preserved for the next resume attempt.
        :class:`FileNotFoundError`
            If ``dest.parent`` doesn't exist (SC-13 — no
            auto-created directory trees).
        """
        if headers is not None and "Range" in headers:
            raise ValueError(
                "download(): `Range` header is library-managed; the "
                "resume=True path sets it automatically. Use resume="
                "False if you want to disable, or pass any other "
                "headers but not Range.",
            )
        from mgf.http._download import download as _impl

        return await _impl(
            self,
            url,
            dest,
            expected_sha256=expected_sha256,
            expected_sha1=expected_sha1,
            expected_size=expected_size,
            resume=resume,
            chunk_size=chunk_size,
            on_progress=on_progress,
            headers=headers,
        )


# ---------------------------------------------------------------------------
# Helpers — User-Agent, span, redaction, error categorisation.
# ---------------------------------------------------------------------------


@functools.lru_cache(maxsize=1)
def _lib_version() -> str:
    """Return the installed ``mgf-http`` package version, memoised.

    The metadata read goes through `importlib.metadata` which walks
    the entry-points database — measurable cost (~tens of µs per
    call). The version is process-stable, so cache the answer:
    subsequent `_default_user_agent` calls return in constant time.
    (PC-03.)
    """
    import importlib.metadata

    try:
        return importlib.metadata.version("mgf-http")
    except importlib.metadata.PackageNotFoundError:
        return "0.0.0"


def _default_user_agent() -> str:
    """Return ``<app_name>/<app_version> mgf-http/<libver>``.

    Uses :func:`mgf.common.app_name` + :func:`mgf.common.app_version`
    when bootstrap has been called (the public accessors fall back
    to identity defaults — ``"app/unknown"`` — when bootstrap is
    not yet wired). The pre-bootstrap ``UnconfiguredWarning`` from
    those accessors is intentionally suppressed here: User-Agent
    derivation is best-effort and runs at every ``AsyncHttpClient``
    construction; the consumer's own bootstrap warning at startup
    is the right channel for that diagnostic, not one warning per
    HTTP client built.
    """
    import warnings as _warnings

    import mgf.common
    from mgf.common import UnconfiguredWarning

    lib_version = _lib_version()

    with _warnings.catch_warnings():
        _warnings.simplefilter("ignore", UnconfiguredWarning)
        app_name = mgf.common.app_name()
        app_version = mgf.common.app_version()

    return f"{app_name}/{app_version} mgf-http/{lib_version}"


def _redact_headers(headers: Mapping[str, str] | None) -> dict[str, str]:
    """Return a copy of ``headers`` with secret-shaped values redacted."""
    if not headers:
        return {}
    out: dict[str, str] = {}
    for key, value in headers.items():
        if key.lower() in _SECRET_HEADER_KEYS:
            out[key] = "<redacted>"
        else:
            out[key] = value
    return out


def _safe_url(url: str) -> str:
    """Return ``url`` safe to emit to logs / OTel spans.

    SH-04 / RA-04: a URL can carry credentials in two places that do
    NOT pass through the header-redaction path — the query string
    (``?api_key=secret``) and the userinfo component
    (``https://user:pass@host``). The library already refuses to
    interpolate ``str(exc)`` for exactly this reason (see
    :func:`_categorise_exc`); this helper closes the matching gap on
    the log line + the ``url.full`` span attribute, which previously
    emitted the raw URL.

    Query-parameter VALUES are masked (keys kept, so the shape stays
    debuggable) and any userinfo is dropped. Scheme/host/path are
    preserved. An unparseable URL degrades to a safe sentinel rather
    than risking a partial leak.
    """
    try:
        parts = urlsplit(url)
    except ValueError:
        return "<unparseable-url>"
    netloc = parts.netloc
    if "@" in netloc:
        # Drop userinfo (user[:password]@) — keep host[:port].
        netloc = netloc.rsplit("@", 1)[1]
    query = parts.query
    if query:
        query = urlencode([(k, "<redacted>") for k, _ in parse_qsl(query, keep_blank_values=True)])
    return urlunsplit((parts.scheme, netloc, parts.path, query, parts.fragment))


def _log_response(
    method: str,
    url: str,
    response: httpx.Response,
    request_headers: Mapping[str, str] | None,
) -> None:
    """Emit one structured log line per completed request."""
    _LOG.info(
        "http request: method=%s url=%s status=%s elapsed_ms=%.1f request_headers=%s",
        method,
        _safe_url(url),
        response.status_code,
        response.elapsed.total_seconds() * 1000.0,
        _redact_headers(request_headers),
    )


def _categorise_exc(exc: BaseException) -> str:
    """Short, secret-safe category string for log + error messages.

    RA-04 / SH-04: deliberately does NOT interpolate ``str(exc)`` from
    the underlying httpx exception. httpx's exception ``__str__`` MAY
    surface the request URL (which can carry a query-string-embedded
    credential like ``?api_key=secret``) or other transport-layer
    detail that doesn't belong in an error message destined for
    consumer logs / CLI output / response bodies.

    The original exception remains available as ``__cause__`` on the
    wrapping :class:`HttpTransportError`, so a debugger or crash
    reporter sees the full chain — those surfaces pass through the
    mgf-common Redactor and can scrub URLs / headers separately.
    The user-facing message stays category-only.
    """
    if isinstance(exc, httpx.TimeoutException):
        return "timeout"
    if isinstance(exc, httpx.ConnectError):
        return "connect-error"
    if isinstance(exc, httpx.NetworkError):
        return "network-error"
    return type(exc).__name__


def _parse_retry_after(raw: str | None) -> float | None:
    """Parse the ``Retry-After`` header per RFC 7231 §7.1.3.

    The header takes two shapes:

      - **delta-seconds** — a non-negative integer like ``"30"``.
      - **HTTP-date** — `"Wed, 21 Oct 2026 07:28:00 GMT"`.

    Returns the wait in seconds (float) on success, or ``None`` on
    parse failure / negative delta / unrepresentable values. Callers
    are responsible for capping against their own max-backoff
    (typically `retry_policy.max_backoff_seconds`). RA-02.
    """
    if raw is None:
        return None
    value = raw.strip()
    if not value:
        return None
    # Numeric (delta-seconds) shape first — the common case from
    # Stripe / GitHub / Clerk.
    try:
        seconds = float(value)
    except ValueError:
        pass
    else:
        if seconds < 0:
            return None
        return seconds
    # HTTP-date shape — convert to delta from now.
    try:
        from datetime import UTC, datetime
        from email.utils import parsedate_to_datetime

        target = parsedate_to_datetime(value)
        if target.tzinfo is None:
            # Treat naive datetimes as UTC per RFC 7231 §7.1.1.1.
            target = target.replace(tzinfo=UTC)
        delta = (target - datetime.now(UTC)).total_seconds()
    except (TypeError, ValueError):
        return None
    if delta < 0:
        return 0.0  # date already past — retry immediately
    return delta


# ---------------------------------------------------------------------------
# OTel span — best-effort; no-ops when the SDK isn't installed.
# ---------------------------------------------------------------------------


def _client_span(method: str, url: str) -> Any:
    """Return a context manager that opens an OTel client span.

    No-op when ``[observability]`` extra is not installed.
    """
    try:
        from opentelemetry import trace
    except ImportError:
        return _NullSpan()

    tracer = trace.get_tracer("mgf.http")
    return tracer.start_as_current_span(
        f"http.client.{method}",
        attributes={
            "http.request.method": method,
            # url.full is masked (query values + userinfo) — span
            # exporters persist attributes, so a raw URL would leak
            # credentials into the trace pipeline (SH-04).
            "url.full": _safe_url(url),
        },
    )


class _NullSpan:
    """No-op span context manager. Used when OTel SDK is absent."""

    def __enter__(self) -> _NullSpan:
        return self

    def __exit__(
        self,
        exc_type: type[BaseException] | None,
        exc_value: BaseException | None,
        traceback: TracebackType | None,
    ) -> None:
        return


__all__ = ["AsyncHttpClient"]
