"""Django middleware classes — request-id + AppContext propagation.

Both middlewares follow Django's standard middleware shape: a
class with ``__init__(self, get_response)`` and ``__call__(self,
request) -> response``. They are sync-mode-only (the most common
Django shape); async-mode support can be added in v0.2+ if needed.
"""

from __future__ import annotations

import logging
from typing import TYPE_CHECKING

from mgf.common import (
    REQUEST_ID_HEADER,
    generate_request_id,
    reset_request_id,
    set_request_id,
)

_LOG = logging.getLogger("mgf.django.middleware")

if TYPE_CHECKING:
    from collections.abc import Callable

    from django.http import HttpRequest, HttpResponse


# SH-01: cap inbound `X-Request-Id` length so a maliciously-long value
# can't bloat logs or response headers. 128 chars accommodates any
# real correlation-id format (UUIDs are 36; ULIDs 26; vendor formats
# rarely exceed 64).
_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). Django's `BadHeaderError`
    catches this at response-build time, but only AFTER the view has
    executed — DB writes, emails, etc. are already committed by then.

    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 can fall through to UUID generation
    without branching on the validation outcome.

    (SH-01.)
    """
    if raw is None:
        return None
    if len(raw) > _REQUEST_ID_MAX_LEN:
        return None
    # Reject any control character (CR, LF, NUL, tab, etc.) — the
    # header MUST be a single line of printable ASCII for safe
    # reflection. The narrow RFC 6265 cookie-value alphabet is
    # stricter; we use the broader "no control chars" rule so a
    # legitimate vendor request-id format isn't rejected for using
    # `=` or `:`.
    for ch in raw:
        if ord(ch) < 0x20 or ord(ch) == 0x7F:
            return None
    return raw


class MgfRequestIdMiddleware:
    """Django middleware: inject/forward ``X-Request-Id`` per request.

    Same contract as :class:`mgf.fastapi.RequestIdMiddleware`
    — same contextvar, same header name, same generation rule. A
    Django app calling a FastAPI service via an internal RPC will
    see the same request id propagate end-to-end (assuming the
    upstream call sets the header).

    Install in ``MIDDLEWARE`` *early* so downstream middleware +
    views see ``request.mgf_request_id`` populated::

        MIDDLEWARE = [
            "mgf.django.MgfRequestIdMiddleware",  # earliest
            "mgf.django.MgfContextMiddleware",
            "django.middleware.security.SecurityMiddleware",
            # ... your other middleware ...
        ]

    Composability (SH-02)
    ---------------------

    This middleware MUST come **before** any middleware that emits
    audit log lines — otherwise those log records won't carry the
    request id, breaking operator correlation. Concrete cases:

      - ``CsrfViewMiddleware`` — logs token mismatches / missing
        tokens at WARNING level. Without ``MgfRequestIdMiddleware``
        first, the audit line has no correlation id.
      - ``LocaleMiddleware`` — 404 fallbacks log at INFO.
      - ``SessionMiddleware`` — session-create / invalid-session
        log records.
      - Any consumer middleware that emits structured logs (auth
        decisions, rate-limit hits, feature-flag flips).

    The recipe above orders ``MgfRequestIdMiddleware`` first —
    follow that rule. If you must move it later for a project-
    specific reason (a custom middleware that runs even earlier
    must NOT contribute correlation), document the trade-off and
    accept the partial audit-log coverage.

    Security (SH-01)
    ----------------

    The inbound ``X-Request-Id`` value is sanitised before
    reflection — values containing CR / LF / control characters, or
    longer than 128 chars, are rejected and a fresh UUID is
    generated. This prevents response-splitting via the reflected
    header (an attacker setting ``X-Request-Id: foo\\r\\nSet-Cookie:
    m=1`` doesn't get a ``Set-Cookie`` line in the response).

    Async support (RA-05)
    ---------------------

    This middleware is **sync-mode only**. ``async_capable = False``
    is set explicitly. In a Django app that runs ``async def`` views,
    Django auto-wraps the sync middleware via ``sync_to_async`` —
    every request pays the thread-pool round-trip cost on the
    middleware chain. For high-throughput async-Django apps this is
    measurable; an async-capable variant is on the post-v1.0
    roadmap. See ``docs/inprogress/RELIABILITY_AND_ACCURACY.md`` RA-05.
    """

    # RA-05: explicit. Django auto-adapts via sync_to_async when an
    # async-context middleware chain sees this — documented above.
    async_capable = False

    def __init__(
        self,
        get_response: Callable[[HttpRequest], HttpResponse],
    ) -> None:
        self.get_response = get_response

    def __call__(self, request: HttpRequest) -> HttpResponse:
        # Django stores headers under META as ``HTTP_<UPPER_SNAKE>``.
        # Header lookup is case-insensitive at the WSGI layer.
        # SH-01: sanitise before reflecting — a CR/LF/control char in
        # the inbound value could split the response header.
        existing = _sanitise_request_id(request.META.get("HTTP_X_REQUEST_ID"))
        request_id = existing or generate_request_id()

        # Set on request for view access (matches FastAPI's
        # request.state.request_id pattern).
        request.mgf_request_id = request_id
        token = set_request_id(request_id)
        try:
            response = self.get_response(request)
        finally:
            reset_request_id(token)

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


class MgfContextMiddleware:
    """Django middleware: expose the active :class:`AppContext` on requests.

    Reads the active context from :func:`mgf.common.current_context`
    (set by :class:`MgfCommonConfig.ready`) and stashes it on
    ``request.mgf_context`` so views can ask:

    .. code-block:: python

        def my_view(request):
            ctx = request.mgf_context
            log.info("handling", extra={"app": ctx.app_name})

    Install AFTER :class:`MgfRequestIdMiddleware` so that the request
    id is already bound to the contextvar when the view runs::

        MIDDLEWARE = [
            "mgf.django.MgfRequestIdMiddleware",
            "mgf.django.MgfContextMiddleware",
            # ... your other middleware ...
        ]

    If :class:`MgfCommonConfig` hasn't run yet (e.g., in tests that
    don't load the app), ``request.mgf_context`` is set to ``None``
    rather than raising.

    Async support (RA-05)
    ---------------------

    Sync-mode only. ``async_capable = False`` set explicitly; see
    :class:`MgfRequestIdMiddleware` for the same discussion.
    """

    # RA-05: explicit. Async-capable variant is on the post-v1.0 roadmap.
    async_capable = False

    def __init__(
        self,
        get_response: Callable[[HttpRequest], HttpResponse],
    ) -> None:
        self.get_response = get_response

    def __call__(self, request: HttpRequest) -> HttpResponse:
        from mgf.common import current_context

        # RA-02: tighten the previous `except Exception: ctx = None`.
        # `current_context()` raises `LookupError` when bootstrap
        # hasn't run yet — that's a documented, expected case and
        # the right behaviour is to set `ctx = None` and continue.
        # Any OTHER exception is a real failure (a refactor of
        # mgf.common._lifecycle introducing a new exception type,
        # an internal state-corruption case) and silently swallowing
        # it would hide a real bug from operators. Per EH-04 +
        # EH-10: best-effort observers MUST audit-log; bare swallow
        # is forbidden.
        try:
            ctx = current_context()
        except LookupError:
            ctx = None
        except Exception:
            # Audit-log + re-raise. Django's exception middleware
            # surfaces a 500; operators see the new exception class
            # name in the log + a real traceback in their error
            # tracker. Without the re-raise, a future bug here would
            # silently leave `request.mgf_context = None` on every
            # request — debug-hostile.
            _LOG.warning(
                "MgfContextMiddleware: current_context() raised "
                "an unexpected exception type; re-raising. "
                "(RA-02.)",
                extra={"audit": True, "event": "mgf_django.context_middleware_unexpected_error"},
                exc_info=True,
            )
            raise
        request.mgf_context = ctx
        return self.get_response(request)
