"""``HttpxTransport`` — the v0.1 network-backed :class:`Transport`.

See: ``DESIGN.md`` §14 (``transport/httpx_transport.py``).

Wraps :class:`httpx.AsyncClient`. One transport instance holds one
client (with its connection pool); reusing the transport across
suite runs reuses the pool. The transport is the v0.1 default for
:meth:`Suite.run` when consumers don't pass one explicitly.

Auth flows are resolved synchronously per probe (the v0.1 AuthFlow
Protocol is sync — see ``core/auth.py`` rationale). Each probe's
``probe.auth.prepare()`` call yields :class:`AuthCredentials`
fragments that get merged into the httpx request.

Errors at the transport layer (DNS, TLS handshake, connection
refused, timeouts) are caught + re-raised as
:class:`mgf.apiprobe.exceptions.TransportError`. The :class:`Suite`
catches these and converts them to synthetic Findings — provider-
side issues never reach this layer.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

import httpx

from mgf.apiprobe.core.response import Response
from mgf.apiprobe.core.transport import Transport
from mgf.apiprobe.exceptions import TransportError

if TYPE_CHECKING:
    from mgf.apiprobe.config import ApiprobeSettings
    from mgf.apiprobe.core.context import Context
    from mgf.apiprobe.core.probe import Probe


class HttpxTransport(Transport):
    """httpx-backed Transport.

    Construct with an :class:`ApiprobeSettings` instance to inherit
    timeout, user-agent, TLS-verification, and retry policy. The
    transport defers per-probe overrides (``probe.timeout_seconds``,
    ``probe.headers``, etc.) at send time.

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

        async with HttpxTransport(settings) as transport:
            report = await suite.run(transport=transport)

        # equivalent:
        transport = HttpxTransport(settings)
        try:
            report = await suite.run(transport=transport)
        finally:
            await transport.aclose()
    """

    def __init__(self, settings: ApiprobeSettings | None = None) -> None:
        from mgf.apiprobe.config import ApiprobeSettings

        self.settings = settings or ApiprobeSettings()
        self._client = httpx.AsyncClient(
            timeout=self.settings.timeout_seconds,
            verify=self.settings.verify_tls,
            headers={"User-Agent": self.settings.default_user_agent},
            follow_redirects=True,
        )

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

    async def __aexit__(self, *exc_info: object) -> None:
        await self.aclose()

    async def aclose(self) -> None:
        await self._client.aclose()

    async def send(self, probe: Probe, ctx: Context) -> Response:
        """Send ``probe``; return :class:`Response`.

        Raises :class:`TransportError` on any I/O-level failure.
        Provider-side 4xx/5xx responses are NOT raised here — they
        return as a normal :class:`Response` and surface as Findings
        via the suite's check chain.
        """
        url = probe.absolute_url()

        # Assemble headers + cookies + query from probe + auth fragments.
        # request_params uses the broader value type httpx expects so
        # mypy doesn't reject the str-only list against httpx's union.
        request_headers: list[tuple[str, str]] = list(probe.headers)
        request_cookies: dict[str, str] = {}
        request_params: list[tuple[str, str | int | float | bool | None]] = [
            (k, v) for k, v in probe.query_params
        ]

        if probe.auth is not None:
            # AuthError raised by prepare() propagates — the suite
            # treats it as fatal for that probe (caller's responsibility).
            creds = probe.auth.prepare()
            request_headers.extend(creds.headers)
            request_cookies.update(dict(creds.cookies))
            request_params.extend(creds.query_params)

        # SH-04 / RA-04: validate cookie + header values for CR/LF
        # before handing off to httpx. An auth flow that interpolates
        # consumer-supplied data into a cookie or header value without
        # validation could otherwise inject a second header (request
        # smuggling). We fail loud — raise TransportError — rather
        # than silently strip; a CRLF in a credential is a real bug
        # operators need to see.
        _assert_no_crlf_in_headers(request_headers, url=url)
        _assert_no_crlf_in_cookies(request_cookies, url=url)

        timeout = (
            probe.timeout_seconds
            if probe.timeout_seconds is not None
            else self.settings.timeout_seconds
        )

        try:
            httpx_response = await self._client.request(
                method=probe.method,
                url=url,
                headers=request_headers,
                params=request_params or None,
                cookies=request_cookies or None,
                json=probe.json_body,
                timeout=timeout,
            )
        except httpx.TimeoutException as exc:
            raise TransportError(url=url, cause=f"timeout after {timeout}s") from exc
        except httpx.ConnectError as exc:
            raise TransportError(url=url, cause=f"connect error: {exc}") from exc
        except httpx.NetworkError as exc:
            # Catch-all for DNS / read / write / network-misc errors.
            raise TransportError(url=url, cause=f"network error: {exc}") from exc
        except httpx.HTTPError as exc:
            raise TransportError(url=url, cause=f"httpx error: {exc}") from exc

        return _to_apiprobe_response(httpx_response)


def _to_apiprobe_response(r: httpx.Response) -> Response:
    """Convert :class:`httpx.Response` to :class:`mgf.apiprobe.Response`."""
    return Response(
        url=str(r.url),
        status=r.status_code,
        headers=dict(r.headers),
        body=r.content,
        elapsed_ms=r.elapsed.total_seconds() * 1000.0,
    )


def _assert_no_crlf_in_headers(
    headers: list[tuple[str, str]],
    *,
    url: str,
) -> None:
    """SH-04 / RA-04: reject CR/LF in any outbound header value.

    A header value carrying `\\r\\n` could split the request and let
    an attacker inject a second header (request-smuggling class). This
    can happen via:

      - An `AuthFlow` interpolating consumer-controlled data into a
        header (custom flow, BasicAuth username with newline, etc.).
      - A probe builder receiving externally-sourced header values
        (a CI step pulling from a vault that returns junk).

    Raises :class:`TransportError` (not a plain ``ValueError``) so the
    suite's transport-error path catches it cleanly and yields a
    synthetic Finding with ``RootCause.NETWORK`` — operators see the
    failure in the report.
    """
    for name, value in headers:
        if "\r" in value or "\n" in value:
            raise TransportError(
                url=url,
                cause=(
                    f"header {name!r} contains CR/LF; refusing to send "
                    f"(would risk header injection)"
                ),
            )


def _assert_no_crlf_in_cookies(
    cookies: dict[str, str],
    *,
    url: str,
) -> None:
    """SH-04 / RA-04: reject CR/LF in any outbound cookie value.

    Same threat shape as the header check — cookies are serialised
    into a ``Cookie:`` header and CR/LF in a cookie value escapes
    the header boundary.
    """
    for name, value in cookies.items():
        if "\r" in value or "\n" in value:
            raise TransportError(
                url=url,
                cause=(
                    f"cookie {name!r} contains CR/LF; refusing to send "
                    f"(would risk header injection)"
                ),
            )


__all__ = ["HttpxTransport"]
