"""Tests for Django middleware: request-id + context propagation."""

from __future__ import annotations

import re

import pytest
from django.test import Client

_UUID4_PATTERN = re.compile(
    r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
)


@pytest.fixture
def client() -> Client:
    return Client()


class TestRequestIdMiddleware:
    def test_generates_uuid4_when_no_header(self, client: Client) -> None:
        response = client.get("/ping/")

        assert response.status_code == 200
        rid = response["X-Request-Id"]
        assert _UUID4_PATTERN.match(rid), f"Expected UUID4, got: {rid!r}"

        # Echoed in the JSON body (the view reads request.mgf_request_id).
        assert response.json()["request_id"] == rid

    def test_forwards_inbound_header(self, client: Client) -> None:
        response = client.get(
            "/ping/",
            headers={"X-Request-Id": "test-correlation-abc"},
        )

        assert response.status_code == 200
        assert response["X-Request-Id"] == "test-correlation-abc"
        assert response.json()["request_id"] == "test-correlation-abc"

    def test_case_insensitive_inbound_header(self, client: Client) -> None:
        # Django normalises header case at the WSGI layer; but verify
        # via the canonical lookup we use.
        response = client.get(
            "/ping/",
            headers={"x-request-id": "lower-case-id"},
        )

        assert response["X-Request-Id"] == "lower-case-id"

    def test_contextvar_set_during_request(self, client: Client) -> None:
        # The contextvar is set during the view; assert via the JSON
        # response body that the view saw it.
        from mgf.common._request_id import current_request_id

        # OUTSIDE the request, the contextvar is empty.
        assert current_request_id() == ""

        response = client.get(
            "/ping/",
            headers={"X-Request-Id": "ctxvar-check"},
        )
        assert response.json()["request_id"] == "ctxvar-check"

        # AFTER the request, the contextvar resets.
        assert current_request_id() == ""


class TestContextMiddleware:
    def test_request_carries_app_context(self, client: Client) -> None:
        response = client.get("/ping/")

        # The conftest configures MGF_COMMON_APP_NAME = "test-app";
        # the AppContext should reflect that.
        assert response.json()["context_app"] == "test-app"

    def test_context_unavailable_pre_bootstrap(self) -> None:
        # If we somehow hit the middleware before bootstrap, the
        # response should still succeed with context=None. We can't
        # easily un-bootstrap mid-suite, so we test the middleware
        # logic directly with a fake current_context that raises.
        from unittest.mock import patch

        from mgf.django._middleware import MgfContextMiddleware

        called = {}

        def fake_get_response(request):
            called["mgf_context"] = request.mgf_context
            return

        middleware = MgfContextMiddleware(fake_get_response)

        class _FakeRequest:
            pass

        with patch(
            "mgf.common.current_context",
            side_effect=LookupError("not bootstrapped"),
        ):
            middleware(_FakeRequest())  # type: ignore[arg-type]

        # LookupError is the documented pre-bootstrap case; the
        # middleware sets ctx=None and continues.
        assert called["mgf_context"] is None


class TestContextMiddlewareUnexpectedErrorReraise:
    """RA-02: `MgfContextMiddleware` used to silently swallow EVERY
    exception from `current_context()`. The tightened contract:
    `LookupError` is the documented pre-bootstrap case (`ctx = None`,
    continue); any OTHER exception type is re-raised after an audit-
    log line — operators see the new exception in their error tracker
    instead of a silent `request.mgf_context = None` everywhere."""

    def test_lookup_error_falls_through_to_none(self) -> None:
        """LookupError — the documented pre-bootstrap case — sets
        ctx=None and continues without re-raising (sanity)."""
        from unittest.mock import patch

        from mgf.django._middleware import MgfContextMiddleware

        called = {}

        def fake_get_response(request):
            called["mgf_context"] = request.mgf_context
            return

        middleware = MgfContextMiddleware(fake_get_response)

        class _FakeRequest:
            pass

        with patch(
            "mgf.common.current_context",
            side_effect=LookupError("pre-bootstrap"),
        ):
            middleware(_FakeRequest())  # type: ignore[arg-type]

        assert called["mgf_context"] is None

    def test_unexpected_exception_reraised(self, caplog) -> None:
        """Any non-LookupError raised by current_context() is re-
        raised by the middleware AFTER an audit-log line — operators
        see the real failure in their error tracker."""
        import logging
        from unittest.mock import patch

        from mgf.django._middleware import MgfContextMiddleware

        class _CustomBootstrapError(Exception):
            """Hypothetical new exception type from a future
            mgf.common._lifecycle refactor."""

        def fake_get_response(request):
            return

        middleware = MgfContextMiddleware(fake_get_response)

        class _FakeRequest:
            pass

        with (
            caplog.at_level(logging.WARNING, logger="mgf.django.middleware"),
            patch(
                "mgf.common.current_context",
                side_effect=_CustomBootstrapError("partial init"),
            ),
            pytest.raises(_CustomBootstrapError, match="partial init"),
        ):
            middleware(_FakeRequest())  # type: ignore[arg-type]

        # Audit-log line fired before the re-raise.
        relevant = [r for r in caplog.records if "MgfContextMiddleware" in r.getMessage()]
        assert relevant, (
            "RA-02: audit-log line did not fire on the unexpected-"
            "exception path. A silent re-raise hides the new failure "
            "mode from operators."
        )
        rec = relevant[0]
        assert rec.audit is True
        assert rec.event == "mgf_django.context_middleware_unexpected_error"


class TestRequestIdInjectionGuard:
    """SH-01: malicious inbound X-Request-Id is rejected before
    reflection. Defence-in-depth against response-splitting — Django's
    own BadHeaderError catches at response-build time but only AFTER
    the view has executed (DB writes, emails).
    """

    def test_crlf_injection_rejected(self, client: Client) -> None:
        """CR/LF in the inbound header → fresh UUID generated;
        the response carries NO Set-Cookie from the injection."""
        response = client.get(
            "/ping/",
            headers={"X-Request-Id": "foo\r\nSet-Cookie: m=1"},
        )

        assert response.status_code == 200
        # A fresh UUID4 — NOT the attacker-supplied value.
        rid = response["X-Request-Id"]
        assert _UUID4_PATTERN.match(rid), (
            f"Expected UUID4 (rejection generated fresh id), got: {rid!r}"
        )
        # The injection did NOT produce a Set-Cookie header.
        assert "Set-Cookie" not in response or "m=1" not in response.get("Set-Cookie", "")

    def test_lf_only_rejected(self, client: Client) -> None:
        response = client.get(
            "/ping/",
            headers={"X-Request-Id": "foo\nbar"},
        )
        assert response.status_code == 200
        rid = response["X-Request-Id"]
        assert _UUID4_PATTERN.match(rid)

    def test_nul_rejected(self, client: Client) -> None:
        response = client.get(
            "/ping/",
            headers={"X-Request-Id": "foo\x00bar"},
        )
        assert response.status_code == 200
        rid = response["X-Request-Id"]
        assert _UUID4_PATTERN.match(rid)

    def test_overlong_value_rejected(self, client: Client) -> None:
        """Values longer than 128 chars get a fresh UUID."""
        response = client.get(
            "/ping/",
            headers={"X-Request-Id": "a" * 200},
        )
        assert response.status_code == 200
        rid = response["X-Request-Id"]
        assert _UUID4_PATTERN.match(rid)

    def test_legitimate_values_pass(self, client: Client) -> None:
        """Sanity: clean correlation-id values still pass through."""
        for value in (
            "test-correlation-abc",
            "01H8XGJ3M5B9YDQ7E2N4F6V8P0",  # ULID
            "uuid:550e8400-e29b-41d4-a716-446655440000",
        ):
            response = client.get(
                "/ping/",
                headers={"X-Request-Id": value},
            )
            assert response.status_code == 200
            assert response["X-Request-Id"] == value, (
                f"Clean value {value!r} got rewritten — guard too aggressive"
            )
