"""Tests for ``mgf.fastapi.ExceptionTranslationMiddleware``."""

from __future__ import annotations

from fastapi import FastAPI
from fastapi.testclient import TestClient
from mgf.common.exceptions import (
    AppError,
    ConfigError,
    HostEnvironmentError,
    OperationError,
    ResourceAlreadyExistsError,
    ResourceNotFoundError,
    SchemaValidationError,
    VaultError,
)
from starlette.responses import JSONResponse

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


def _build_app(*, with_request_id: bool = False) -> FastAPI:
    app = FastAPI()
    app.add_middleware(ExceptionTranslationMiddleware)
    if with_request_id:
        app.add_middleware(RequestIdMiddleware)

    @app.get("/raise/{kind}")
    def _raise(kind: str) -> dict[str, str]:
        if kind == "config":
            raise ConfigError("bad config")
        if kind == "schema":
            raise SchemaValidationError(["timeout: must be > 0"])
        if kind == "not-found":
            raise ResourceNotFoundError(name="alice")
        if kind == "exists":
            raise ResourceAlreadyExistsError(name="alice")
        if kind == "host-env":
            raise HostEnvironmentError("libvirt missing")
        if kind == "vault":
            raise VaultError("vault unreachable")
        if kind == "operation":
            raise OperationError("operation failed")
        if kind == "appbase":
            raise AppError("generic app error")
        return {"ok": "true"}

    return app


class TestDefaultStatusMap:
    def test_config_error_returns_500(self) -> None:
        with TestClient(_build_app()) as client:
            response = client.get("/raise/config")
        assert response.status_code == 500
        assert response.json()["error"]["type"] == "ConfigError"

    def test_schema_validation_returns_400(self) -> None:
        with TestClient(_build_app()) as client:
            response = client.get("/raise/schema")
        assert response.status_code == 400
        assert response.json()["error"]["type"] == "SchemaValidationError"

    def test_not_found_returns_404(self) -> None:
        with TestClient(_build_app()) as client:
            response = client.get("/raise/not-found")
        assert response.status_code == 404

    def test_already_exists_returns_409(self) -> None:
        with TestClient(_build_app()) as client:
            response = client.get("/raise/exists")
        assert response.status_code == 409

    def test_host_env_returns_503(self) -> None:
        with TestClient(_build_app()) as client:
            response = client.get("/raise/host-env")
        assert response.status_code == 503

    def test_vault_returns_500(self) -> None:
        with TestClient(_build_app()) as client:
            response = client.get("/raise/vault")
        assert response.status_code == 500

    def test_operation_returns_500(self) -> None:
        with TestClient(_build_app()) as client:
            response = client.get("/raise/operation")
        assert response.status_code == 500

    def test_app_base_falls_through_to_500(self) -> None:
        with TestClient(_build_app()) as client:
            response = client.get("/raise/appbase")
        assert response.status_code == 500


class TestResponseShape:
    def test_default_shape_has_type_and_message(self) -> None:
        with TestClient(_build_app()) as client:
            response = client.get("/raise/config")
        body = response.json()
        assert "error" in body
        assert body["error"]["type"] == "ConfigError"
        assert body["error"]["message"] == "bad config"

    def test_request_id_included_when_middleware_present(self) -> None:
        with TestClient(_build_app(with_request_id=True)) as client:
            response = client.get(
                "/raise/config",
                headers={"X-Request-Id": "test-trace-001"},
            )
        body = response.json()
        assert body["error"]["request_id"] == "test-trace-001"

    def test_request_id_omitted_when_middleware_absent(self) -> None:
        with TestClient(_build_app(with_request_id=False)) as client:
            response = client.get("/raise/config")
        body = response.json()
        assert "request_id" not in body["error"]


class TestCustomMapping:
    def test_custom_status_map_overrides_default(self) -> None:
        class _CustomError(AppError):
            pass

        custom_map = {**DEFAULT_STATUS_MAP, _CustomError: 418}

        app = FastAPI()
        app.add_middleware(
            ExceptionTranslationMiddleware,
            status_map=custom_map,
        )

        @app.get("/teapot")
        def _teapot() -> dict[str, str]:
            raise _CustomError("I'm a teapot")

        with TestClient(app) as client:
            response = client.get("/teapot")
        assert response.status_code == 418
        assert response.json()["error"]["type"] == "_CustomError"


class TestCustomResponseFactory:
    def test_custom_factory_replaces_default_shape(self) -> None:
        def my_factory(exc, status, request_id):
            return JSONResponse(
                {"detail": str(exc), "trace": request_id},
                status_code=status,
            )

        app = FastAPI()
        app.add_middleware(
            ExceptionTranslationMiddleware,
            response_factory=my_factory,
        )

        @app.get("/x")
        def _x() -> dict[str, str]:
            raise ConfigError("wrong")

        with TestClient(app) as client:
            response = client.get("/x")
        body = response.json()
        assert body == {"detail": "wrong", "trace": ""}


class TestSubclassDispatch:
    def test_specific_subclass_picked_over_parent(self) -> None:
        # ConfigError is in DEFAULT_STATUS_MAP at 500;
        # AppConfigError (subclass) should also map to 500 via
        # parent walk.
        from mgf.common.exceptions import AppConfigError

        app = FastAPI()
        app.add_middleware(ExceptionTranslationMiddleware)

        @app.get("/x")
        def _x() -> dict[str, str]:
            raise AppConfigError("x")

        with TestClient(app) as client:
            response = client.get("/x")
        assert response.status_code == 500
        assert response.json()["error"]["type"] == "AppConfigError"


class TestHttpErrorAutoStatus:
    """HTTP-01 ↔ middleware composition (PAPER-24).

    ``HttpError`` leaves carry their HTTP status as a class attribute
    (``http_status: int``). The middleware reads it as a fallback when
    ``status_map`` has no entry — so a consumer rooting their domain
    exceptions in ``HttpNotFoundError`` / ``HttpConflictError`` / etc. doesn't
    need to register every leaf in ``status_map`` to get the right
    response code.
    """

    def test_httpnotfound_subclass_returns_404_without_status_map_entry(
        self,
    ) -> None:
        from mgf.common.exceptions import HttpNotFoundError

        class _TenantNotFoundError(HttpNotFoundError):
            pass

        app = FastAPI()
        app.add_middleware(ExceptionTranslationMiddleware)

        @app.get("/tenant")
        def _tenant() -> dict[str, str]:
            raise _TenantNotFoundError("tenant not found")

        with TestClient(app) as client:
            response = client.get("/tenant")
        assert response.status_code == 404
        assert response.json()["error"]["type"] == "_TenantNotFoundError"

    def test_httpconflict_direct_returns_409(self) -> None:
        from mgf.common.exceptions import HttpConflictError

        app = FastAPI()
        app.add_middleware(ExceptionTranslationMiddleware)

        @app.get("/dup")
        def _dup() -> dict[str, str]:
            raise HttpConflictError("duplicate key")

        with TestClient(app) as client:
            response = client.get("/dup")
        assert response.status_code == 409

    def test_httpclient_subclass_returns_400_via_classattr(self) -> None:
        # HttpClientError carries http_status=400 even though it's not
        # in DEFAULT_STATUS_MAP — verifies pure class-attribute path.
        from mgf.common.exceptions import HttpClientError

        app = FastAPI()
        app.add_middleware(ExceptionTranslationMiddleware)

        @app.get("/x")
        def _x() -> dict[str, str]:
            raise HttpClientError("bad input")

        with TestClient(app) as client:
            response = client.get("/x")
        assert response.status_code == 400

    def test_status_map_wins_over_http_status_classattr(self) -> None:
        # Override seam: a consumer who explicitly maps a type in
        # status_map should win over the class-declared http_status.
        from mgf.common.exceptions import HttpNotFoundError

        class _CustomNotFoundError(HttpNotFoundError):
            pass

        app = FastAPI()
        app.add_middleware(
            ExceptionTranslationMiddleware,
            status_map={**DEFAULT_STATUS_MAP, _CustomNotFoundError: 410},
        )

        @app.get("/gone")
        def _gone() -> dict[str, str]:
            raise _CustomNotFoundError("gone forever")

        with TestClient(app) as client:
            response = client.get("/gone")
        assert response.status_code == 410

    def test_non_httperror_appbase_still_500_when_no_map_entry(self) -> None:
        # Ensure the http_status fallback only kicks in for types that
        # actually declare it. AppError (no http_status attr beyond the
        # catch-all map entry) still resolves to 500.
        class _PlainCustom(AppError):  # noqa: N818 — intentional: pin behaviour for non-Error-suffix AppError subclasses
            pass

        app = FastAPI()
        app.add_middleware(ExceptionTranslationMiddleware)

        @app.get("/plain")
        def _plain() -> dict[str, str]:
            raise _PlainCustom("plain")

        with TestClient(app) as client:
            response = client.get("/plain")
        assert response.status_code == 500


class TestRedactResponseBody:
    """SH-04: ExceptionTranslationMiddleware redacts the exception
    message via mgf-common's Redactor before placing it in the
    response body. Defence-in-depth — a consumer-side
    `raise OperationError(f"... token={t}")` would otherwise ship
    the token to the client.
    """

    def test_secret_in_message_is_redacted_by_default(self) -> None:
        app = FastAPI()
        app.add_middleware(ExceptionTranslationMiddleware)

        @app.get("/leaky")
        def _leaky() -> None:
            raise OperationError(
                "auth failed: Authorization=Bearer eyJhbGciOiJIUzI1NiJ9.deadbeef.signature for user u@example.com"
            )

        with TestClient(app) as client:
            response = client.get("/leaky")

        assert response.status_code == 500
        body = response.json()
        msg = body["error"]["message"]
        # The bearer token is gone from the response.
        assert "deadbeef" not in msg
        assert "eyJhbGciOiJIUzI1NiJ9" not in msg

    def test_opt_out_preserves_message(self) -> None:
        """When the consumer sets `redact_response_body=False`, the
        original message reaches the client unchanged. Use this for
        APIs where exception args are known safe AND the per-request
        redactor cost matters."""
        app = FastAPI()
        app.add_middleware(
            ExceptionTranslationMiddleware,
            redact_response_body=False,
        )

        @app.get("/plain")
        def _plain() -> None:
            raise OperationError("Bearer eyJhbGciOiJIUzI1NiJ9.deadbeef.signature")

        with TestClient(app) as client:
            response = client.get("/plain")

        body = response.json()
        # Without redaction, the original is intact.
        assert "deadbeef" in body["error"]["message"]

    def test_non_secret_message_unaltered(self) -> None:
        """Sanity: a message with no secret pattern goes through
        unchanged; the redactor must not distort normal messages."""
        app = FastAPI()
        app.add_middleware(ExceptionTranslationMiddleware)

        @app.get("/normal")
        def _normal() -> None:
            raise OperationError("user not found: alice")

        with TestClient(app) as client:
            response = client.get("/normal")

        body = response.json()
        assert body["error"]["message"] == "user not found: alice"
