"""``ExceptionTranslationMiddleware`` — typed AppError → typed HTTP response.

A consumer that already has a typed ``AppError`` hierarchy (per
``mgf.common.exceptions``) shouldn't have to repeat itself in every
handler with ``try / except / HTTPException(...)``. This middleware
catches :class:`AppError` subclasses bubbling out of handlers + maps
them to clean JSON responses with the right status code.

Default mapping (override via the ``status_map=`` constructor kwarg):

  - :class:`SchemaValidationError`         → 400 Bad Request
  - :class:`AppConfigError` / :class:`ConfigError`  → 500 Internal Server Error
    (config errors are server-side; the consumer fix the config, not the client)
  - :class:`ResourceNotFoundError`          → 404 Not Found
  - :class:`ResourceAlreadyExistsError`     → 409 Conflict
  - :class:`HostEnvironmentError`           → 503 Service Unavailable
  - :class:`VaultError`                     → 500 Internal Server Error
  - :class:`OperationError`                 → 500 Internal Server Error
  - :class:`AppError` (catch-all)           → 500 Internal Server Error

The response shape is:

    {
      "error": {
        "type": "ResourceNotFoundError",
        "message": "user not found: alice",
        "request_id": "<...>"   # if RequestIdMiddleware is installed
      }
    }

Consumers wanting different shapes (RFC 7807 problem+json, JSON:API,
etc.) provide a custom ``response_factory`` to the constructor.
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Any

from mgf.common.exceptions import (
    AppError,
    ConfigError,
    HostEnvironmentError,
    OperationError,
    ResourceAlreadyExistsError,
    ResourceNotFoundError,
    SchemaValidationError,
    VaultError,
)
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse

if TYPE_CHECKING:
    from collections.abc import Awaitable, Callable

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


# Default exception → status code mapping. Order matters: more
# specific subclasses BEFORE their parent classes, so the dispatch
# loop picks the most specific match.
DEFAULT_STATUS_MAP: dict[type[AppError], int] = {
    SchemaValidationError: 400,
    ResourceNotFoundError: 404,
    ResourceAlreadyExistsError: 409,
    HostEnvironmentError: 503,
    ConfigError: 500,
    VaultError: 500,
    OperationError: 500,
    AppError: 500,
}


class ExceptionTranslationMiddleware(BaseHTTPMiddleware):
    """Catch :class:`AppError` from handlers; translate to JSON response.

    Install at app construction time::

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

        app = FastAPI()
        app.add_middleware(ExceptionTranslationMiddleware)
        app.add_middleware(RequestIdMiddleware)

    Order: install RequestIdMiddleware AFTER (so it runs FIRST),
    so the request id is set before the translation middleware needs
    to read it for the response payload.

    Custom mapping::

        from mgf.fastapi import ExceptionTranslationMiddleware
        from mgf.common.exceptions import AppError

        class MyDomainError(AppError): ...

        app.add_middleware(
            ExceptionTranslationMiddleware,
            status_map={MyDomainError: 422, **DEFAULT_STATUS_MAP},
        )

    Custom response shape::

        def my_response(exc, status, request_id):
            return JSONResponse(
                {"detail": str(exc), "trace": request_id},
                status_code=status,
            )

        app.add_middleware(
            ExceptionTranslationMiddleware,
            response_factory=my_response,
        )
    """

    def __init__(
        self,
        app: Any,
        *,
        status_map: dict[type[AppError], int] | None = None,
        response_factory: (Callable[[AppError, int, str], Response] | None) = None,
        redact_response_body: bool = True,
    ) -> None:
        super().__init__(app)
        self._status_map = status_map or DEFAULT_STATUS_MAP
        self._response_factory = response_factory or _default_response_factory
        # SH-04: redact the exception message via mgf-common's
        # Redactor before placing it in the response body. Defence-
        # in-depth against accidentally shipping a secret in an
        # exception's args — `raise OperationError(f"... token={t}")`
        # would otherwise put the token in the JSON `message` field.
        # Set `redact_response_body=False` if your exception args are
        # known safe AND the per-request redactor cost matters.
        self._redact_response_body = redact_response_body
        if redact_response_body:
            from mgf.common.observability.redaction import Redactor

            self._redactor: Redactor | None = Redactor.default()
        else:
            self._redactor = None

    async def dispatch(
        self,
        request: Request,
        call_next: Callable[[Request], Awaitable[Response]],
    ) -> Response:
        try:
            return await call_next(request)
        except AppError as exc:
            status = self._lookup_status(type(exc))
            request_id = getattr(request.state, "request_id", "")
            # SH-04: scrub exc.args via the Redactor before the
            # response factory reads `str(exc)`. The exception is
            # about to be discarded (we return a response, not
            # re-raise), so mutating .args in place is safe.
            if self._redactor is not None and exc.args:
                scrubbed_args = tuple(
                    self._redactor.redact_string(arg) if isinstance(arg, str) else arg
                    for arg in exc.args
                )
                if scrubbed_args != exc.args:
                    exc.args = scrubbed_args
            return self._response_factory(exc, status, request_id)

    def _lookup_status(self, exc_type: type[AppError]) -> int:
        """Return the status for ``exc_type`` walking MRO until a hit.

        Resolution order (closes PAPER-24 — HTTP-01 ↔ middleware
        composition):

        1. ``status_map`` entry for any non-``AppError`` ancestor —
           this is the "explicit override" seam. ``AppError`` itself
           is deferred because it acts as a catch-all and would mask
           the class-declared status below.
        2. Class-declared :attr:`HttpError.http_status` — HTTP-01 leaves
           (``HttpNotFoundError``, ``HttpConflictError``, …) carry their status as
           a class attribute. A consumer who roots their domain
           exceptions in ``HttpError`` subclasses gets the right status
           without having to register every leaf in ``status_map``.
        3. ``status_map[AppError]`` catch-all (default 500).
        """
        appbase_status: int | None = None
        for ancestor in exc_type.__mro__:
            if not isinstance(ancestor, type):
                continue
            if not issubclass(ancestor, AppError):
                continue
            if ancestor not in self._status_map:
                continue
            if ancestor is AppError:
                appbase_status = self._status_map[ancestor]
                continue
            return self._status_map[ancestor]
        declared = getattr(exc_type, "http_status", None)
        if isinstance(declared, int):
            return declared
        return appbase_status if appbase_status is not None else 500


def _default_response_factory(
    exc: AppError,
    status: int,
    request_id: str,
) -> Response:
    """Default JSON response shape for translated AppErrors."""
    body: dict[str, Any] = {
        "error": {
            "type": type(exc).__name__,
            "message": str(exc),
        },
    }
    if request_id:
        body["error"]["request_id"] = request_id
    return JSONResponse(body, status_code=status)


__all__ = [
    "DEFAULT_STATUS_MAP",
    "ExceptionTranslationMiddleware",
]
