"""``RequestIdMiddleware`` — per-request X-Request-Id generation + propagation.

Every HTTP request gets a request id:

  - If the inbound request carries ``X-Request-Id`` (canonical name —
    case-insensitive lookup), reuse it.
  - Otherwise, generate a UUID4.

The request id is:

  - Set on ``request.state.request_id`` (FastAPI standard surface).
  - Set on a contextvar so :func:`current_request_id` works from any
    code path inside the request — including async task children
    via :pep:`567` propagation.
  - Echoed in the response's ``X-Request-Id`` header.

Logging integration: add a logging filter that injects the contextvar
into log records. The filter is shipped in v0.7 Phase D's async-logging
work; for v0.6, consumers can attach it manually via ``%(request_id)s``
format directive in their log config.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

# Shared request-id surface — same identity across framework
# adapters so request-id correlation works in mixed processes.
# Public since mgf-common 0.40.0 (PAPER-61).
from mgf.common import (
    REQUEST_ID_HEADER,
    current_request_id,
    generate_request_id,
    reset_request_id,
    set_request_id,
)
from starlette.middleware.base import BaseHTTPMiddleware

if TYPE_CHECKING:
    from collections.abc import Awaitable, Callable

    from starlette.requests import Request
    from starlette.responses import Response


# SH-01: cap inbound `X-Request-Id` length so a maliciously-long value
# can't bloat logs or response headers. Twin of mgf-django SH-01 —
# both siblings share the contextvar; both share the validation rule.
_REQUEST_ID_MAX_LEN = 128


def _sanitise_request_id(raw: str | None) -> str | None:
    """Return ``raw`` if it's safe to echo; else ``None``.

    Inbound ``X-Request-Id`` is reflected into the outbound response
    header. An attacker who can set the inbound header to a value
    containing CR/LF/NUL could split the response and inject a
    ``Set-Cookie:`` line (response-splitting). Starlette catches some
    of this at response-build time, but only AFTER the view has
    executed — DB writes, downstream side effects already committed.

    Defence-in-depth: validate at the inbound boundary. Reject any
    value with control characters, NUL, or length above the cap; the
    caller generates a fresh UUID4 instead. Returns ``None`` on
    rejection so the middleware falls through to UUID generation
    without branching on the validation outcome.

    Twin of mgf-django `_sanitise_request_id` — the two siblings
    share the `mgf.common._request_id` contextvar and MUST apply the
    same validation rule to keep the cross-framework contract uniform.
    (SH-01.)
    """
    if raw is None:
        return None
    if len(raw) > _REQUEST_ID_MAX_LEN:
        return None
    for ch in raw:
        if ord(ch) < 0x20 or ord(ch) == 0x7F:
            return None
    return raw


class RequestIdMiddleware(BaseHTTPMiddleware):
    """ASGI middleware that injects/forwards ``X-Request-Id``.

    Install at app construction time::

        from fastapi import FastAPI
        from mgf.fastapi import RequestIdMiddleware

        app = FastAPI()
        app.add_middleware(RequestIdMiddleware)

    Order matters when combined with other middlewares: install
    :class:`RequestIdMiddleware` **early** so downstream middlewares +
    handlers see ``request.state.request_id`` populated.
    """

    async def dispatch(
        self,
        request: Request,
        call_next: Callable[[Request], Awaitable[Response]],
    ) -> Response:
        # Case-insensitive header lookup. Starlette normalises header
        # keys to lowercase internally.
        # SH-01: sanitise before reflecting — a CR/LF/control char in
        # the inbound value could split the response header.
        existing = _sanitise_request_id(request.headers.get(REQUEST_ID_HEADER.lower()))
        request_id = existing or generate_request_id()

        request.state.request_id = request_id
        token = set_request_id(request_id)
        try:
            response = await call_next(request)
        finally:
            reset_request_id(token)

        # Echo in the response so clients can correlate too.
        response.headers[REQUEST_ID_HEADER] = request_id
        return response


__all__ = [
    "REQUEST_ID_HEADER",
    "RequestIdMiddleware",
    "current_request_id",
]
