"""FastAPI ``Request``-adapter for :class:`HmacWebhookVerifier`.

The verifier itself is transport-agnostic. This module provides the
thin lift from a FastAPI ``Request`` into the verifier's
``Mapping[str, str]`` + ``bytes`` shape. Lives in its own file so the
core verifier stays importable without ``fastapi`` installed (matches
the closed-box invariant for ``mgf.fastapi``).
"""

from __future__ import annotations

import logging
from typing import TYPE_CHECKING

from mgf.fastapi.exceptions import HmacVerificationError

if TYPE_CHECKING:
    from fastapi import Request
    from mgf.fastapi.webhooks._verifier import HmacWebhookVerifier

__all__ = ["DEFAULT_MAX_BODY_BYTES", "verify_request"]

_LOGGER = logging.getLogger(__name__)

#: Default upper bound on a webhook body, in bytes (1 MiB). Webhook
#: payloads are small — Svix/Clerk events are a few KiB — so a 1 MiB
#: cap is generous while still closing the pre-auth DoS (SC-08). Tune
#: per route via ``verify_request(..., max_body_bytes=...)``.
DEFAULT_MAX_BODY_BYTES = 1024 * 1024


async def _read_body_capped(request: Request, max_body_bytes: int) -> bytes:
    """Stream the request body, rejecting once it crosses ``max_body_bytes``.

    Reads the ASGI body stream chunk-by-chunk and aborts the moment the
    accumulated size exceeds the cap — so an oversized (or chunked /
    Content-Length-less) payload is never fully buffered into memory.
    """
    chunks: list[bytes] = []
    total = 0
    async for chunk in request.stream():
        total += len(chunk)
        if total > max_body_bytes:
            raise HmacVerificationError(
                f"webhook body exceeds the {max_body_bytes}-byte cap "
                f"(max_body_bytes) — rejected mid-stream before the whole "
                f"payload was buffered or HMAC'd. (SC-08.)",
                reason="body_too_large",
            )
        chunks.append(chunk)
    return b"".join(chunks)


async def verify_request(
    request: Request,
    verifier: HmacWebhookVerifier,
    *,
    max_body_bytes: int = DEFAULT_MAX_BODY_BYTES,
) -> tuple[str, int]:
    """Read body + headers from a FastAPI ``Request`` and verify.

    :param request: A FastAPI ``Request``. Caller must NOT have
        consumed the body yet — this function reads it via the ASGI
        body stream.
    :param verifier: The configured :class:`HmacWebhookVerifier`.
    :param max_body_bytes: Hard upper bound on the webhook body, in
        bytes (default :data:`DEFAULT_MAX_BODY_BYTES`, 1 MiB). Enforced
        BEFORE the body is fully buffered or HMAC'd (SC-08): a declared
        ``Content-Length`` over the cap is rejected up front, and the
        body stream is aborted the moment the accumulated bytes cross
        the cap (so a missing or lying ``Content-Length`` cannot smuggle
        an oversized payload past the check). Must be positive.
    :returns: ``(event_id, timestamp)`` per :meth:`HmacWebhookVerifier.verify`.
    :raises HmacVerificationError: on any verification failure (including
        an over-cap body, ``reason="body_too_large"``). Every failure is
        logged once as an audit event (LG-15) before it propagates.
    :raises ValueError: if ``max_body_bytes`` is not positive.

    Typical usage in a FastAPI route::

        from mgf.fastapi.webhooks import (
            HmacWebhookVerifier,
            verify_request,
        )

        verifier = HmacWebhookVerifier(secret=settings.webhook_secret)

        @app.post("/webhooks/clerk")
        async def clerk_webhook(request: Request) -> dict:
            event_id, ts = await verify_request(request, verifier)
            payload = await request.json()
            ...
            return {"ok": True}

    Pair with :class:`mgf.fastapi.ExceptionTranslationMiddleware`
    to get the 401 response shape automatically — :class:`HmacVerificationError`
    inherits from :class:`HttpUnauthorizedError` so PAPER-24's ``http_status``
    resolution maps it without an explicit ``status_map`` entry.

    Body-size cap (SC-08)
    ---------------------

    The body is the only attacker-controlled, pre-authentication input on
    a webhook route: the HMAC is computed *over* it, so an unbounded body
    is a pre-auth memory + CPU DoS (buffer N bytes, then SHA-256 them,
    all before we know the request is forged). :func:`verify_request`
    caps it at ``max_body_bytes`` and rejects over-cap requests *before*
    buffering — first on a declared ``Content-Length``, then by aborting
    the body stream mid-read.

    Audit trail (LG-15)
    -------------------

    Every :class:`HmacVerificationError` — forged signature, replayed
    timestamp, missing header, over-cap body — is logged once at WARNING
    with ``event="mgf.fastapi.webhook.verification_failed"``,
    ``reason=<machine code>``, ``audit=True`` before it propagates, so
    forged/replayed webhooks leave an audit trail instead of a silent
    401. See :file:`docs/reference/EVENTS.md`.

    Body-already-consumed detection (RA-04)
    ---------------------------------------

    An upstream middleware that consumed the body stream
    (``await request.stream()``, ``await request.json()``) leaves the
    body empty here. The HMAC over an empty body then mismatches and the
    caller sees a generic ``signature mismatch`` — the real failure
    (upstream consumed the body) is invisible.

    Defence: when the body reads back empty AND ``Content-Length`` says
    the request had non-zero bytes, raise an actionable
    :class:`HmacVerificationError` whose message names the likely cause.
    Install :func:`verify_request` BEFORE any other middleware /
    dependency that consumes the body.
    """
    if max_body_bytes <= 0:
        raise ValueError(f"max_body_bytes must be positive (got {max_body_bytes!r})")
    try:
        # SC-08: reject on a declared Content-Length over the cap BEFORE
        # touching the body stream — the cheapest possible rejection.
        declared = request.headers.get("content-length")
        if declared is not None:
            try:
                declared_len = int(declared)
            except ValueError:
                declared_len = None
            if declared_len is not None and declared_len > max_body_bytes:
                raise HmacVerificationError(
                    f"webhook body Content-Length {declared_len} exceeds the "
                    f"{max_body_bytes}-byte cap (max_body_bytes) — rejected "
                    f"before buffering or HMAC. (SC-08.)",
                    reason="body_too_large",
                )

        # SC-08: stream + accumulate with the same cap so a missing or
        # lying Content-Length can't get an oversized body past us.
        body = await _read_body_capped(request, max_body_bytes)

        # RA-04: detect the body-already-consumed footgun. Content-Length
        # is the most reliable signal — Starlette preserves it from the
        # ASGI scope. If the client sent bytes and we got an empty body
        # back, an upstream middleware ate the stream.
        if len(body) == 0:
            content_length = request.headers.get("content-length")
            if content_length and content_length.strip() not in ("0", ""):
                raise HmacVerificationError(
                    f"request body was empty but Content-Length is "
                    f"{content_length!r} — an upstream middleware likely "
                    f"consumed the body before verify_request ran. Install "
                    f"verify_request EARLIER in the middleware chain, or "
                    f"avoid `await request.stream()` / `await request.json()` "
                    f"in upstream middleware. (RA-04.)",
                    reason="body_consumed",
                )
        return verifier.verify(request.headers, body)
    except HmacVerificationError as exc:
        # LG-15: leave an audit trail for every rejected webhook so
        # forged/replayed/oversized requests are queryable, not a silent
        # 401. No secret material — only the machine-readable reason.
        _LOGGER.warning(
            "webhook verification failed (%s)",
            exc.reason,
            extra={
                "event": "mgf.fastapi.webhook.verification_failed",
                "reason": exc.reason,
                "audit": True,
            },
        )
        raise
