"""Reliability + contract tests for ``ExceptionTranslationMiddleware``.

Two concerns, kept separate from ``test_exceptions.py`` so the wire
contract (`@pytest.mark.contract`) and the cancellation-propagation
reliability pin are individually greppable:

1. **CancelledError must propagate.** ``asyncio.CancelledError`` is
   *control flow*, not an application error. Since Python 3.8 it
   inherits from :class:`BaseException` (NOT :class:`Exception`), and
   the middleware's ``dispatch`` only catches
   :class:`mgf.common.exceptions.AppError` (an :class:`Exception`
   subclass). So a cancellation bubbling out of a handler MUST pass
   straight through the translation middleware untouched — never
   become a 500. These tests PIN that behaviour: if a future refactor
   widened the ``except`` clause to ``Exception`` / ``BaseException``,
   the cancellation would be swallowed and they would fail loudly.

2. **Wire contract.** The translated-``AppError`` response SHAPE
   (status code + JSON body keys) and the ``X-Request-Id`` header
   presence/propagation are the public wire format consumers depend
   on. Marked ``contract`` so the format is pinned + greppable.
"""

from __future__ import annotations

import asyncio

import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from mgf.common.exceptions import ConfigError, ResourceNotFoundError
from starlette.requests import Request

from mgf.fastapi import (
    REQUEST_ID_HEADER,
    ExceptionTranslationMiddleware,
    RequestIdMiddleware,
)


class TestCancelledErrorPropagates:
    """``asyncio.CancelledError`` is control-flow, not an app error.

    The middleware catches :class:`AppError` only. ``CancelledError``
    is a :class:`BaseException` (not :class:`Exception`) subclass since
    Python 3.8, so it is OUTSIDE the ``except AppError`` net and must
    propagate. If it were wrongly translated to a 500, async task
    cancellation (client disconnect, timeout, shutdown) would be
    masked as a server error — corrupting cancellation semantics and
    potentially leaving work half-done while reporting "handled".
    """

    def test_cancellederror_is_baseexception_not_exception(self) -> None:
        # The structural fact the propagation guarantee rests on.
        assert issubclass(asyncio.CancelledError, BaseException)
        assert not issubclass(asyncio.CancelledError, Exception)

    @pytest.mark.asyncio
    async def test_dispatch_lets_cancellederror_propagate(self) -> None:
        """Call ``dispatch`` directly with a ``call_next`` that raises
        ``CancelledError``. The middleware must NOT swallow/translate
        it — the same ``CancelledError`` must escape ``dispatch``."""
        mw = ExceptionTranslationMiddleware(app=lambda *a, **k: None)

        sentinel = asyncio.CancelledError("client gone")

        async def _cancelling_call_next(_request: Request) -> object:
            raise sentinel

        scope = {
            "type": "http",
            "method": "GET",
            "path": "/",
            "headers": [],
        }
        request = Request(scope)

        with pytest.raises(asyncio.CancelledError) as exc_info:
            await mw.dispatch(request, _cancelling_call_next)

        # The very same exception object — proving it propagated
        # untouched rather than being re-raised as something new.
        assert exc_info.value is sentinel

    @pytest.mark.asyncio
    async def test_dispatch_still_translates_apperror(self) -> None:
        """Counter-test: an ``AppError`` (an ``Exception`` subclass)
        IS caught + translated. This pins that the narrow ``except
        AppError`` clause still does its job — so the propagation test
        above isn't passing merely because translation is broken."""
        mw = ExceptionTranslationMiddleware(app=lambda *a, **k: None)

        async def _raising_call_next(_request: Request) -> object:
            raise ResourceNotFoundError(name="alice")

        request = Request({"type": "http", "method": "GET", "path": "/", "headers": []})

        response = await mw.dispatch(request, _raising_call_next)
        # Translated to a real JSON response, not re-raised.
        assert response.status_code == 404


class TestTranslatedErrorWireContract:
    """``contract``: the public wire shape of a translated ``AppError``.

    Pins the JSON body keys + status code + ``X-Request-Id`` header so
    a refactor that changes the wire format trips a contract test.
    """

    @staticmethod
    def _build_app() -> FastAPI:
        app = FastAPI()
        app.add_middleware(ExceptionTranslationMiddleware)
        app.add_middleware(RequestIdMiddleware)

        @app.get("/boom")
        def _boom() -> dict[str, str]:
            raise ConfigError("bad config")

        return app

    @pytest.mark.contract
    def test_translated_apperror_body_shape(self) -> None:
        with TestClient(self._build_app()) as client:
            response = client.get("/boom")

        assert response.status_code == 500
        body = response.json()
        # Top-level envelope key.
        assert set(body.keys()) == {"error"}
        # Inner shape: type + message always present; request_id present
        # because RequestIdMiddleware is installed.
        assert set(body["error"].keys()) == {"type", "message", "request_id"}
        assert body["error"]["type"] == "ConfigError"
        assert body["error"]["message"] == "bad config"
        assert body["error"]["request_id"]  # non-empty

    @pytest.mark.contract
    def test_request_id_header_present_and_matches_body(self) -> None:
        """The ``X-Request-Id`` response header is present and equals
        the ``request_id`` echoed into the error body — so a client
        correlating by header sees the same id the body reports."""
        with TestClient(self._build_app()) as client:
            response = client.get("/boom")

        header_id = response.headers[REQUEST_ID_HEADER]
        assert header_id
        assert response.json()["error"]["request_id"] == header_id

    @pytest.mark.contract
    def test_inbound_request_id_propagates_to_header_and_body(self) -> None:
        """An inbound ``X-Request-Id`` is reflected in both the
        response header AND the error body — the propagation contract
        even on the error path."""
        with TestClient(self._build_app()) as client:
            response = client.get(
                "/boom",
                headers={REQUEST_ID_HEADER: "trace-contract-42"},
            )

        assert response.headers[REQUEST_ID_HEADER] == "trace-contract-42"
        assert response.json()["error"]["request_id"] == "trace-contract-42"
