"""
FastAPI router exposing Mizan's HTTP endpoints:

    POST /call/                — RPC dispatch
    GET  /ctx/{context_name}/  — bundled context fetch

    from fastapi import FastAPI
    from mizan_fastapi import router, mizan_exception_handler, MizanError

    app = FastAPI()
    app.include_router(router, prefix="/api/mizan")
    app.add_exception_handler(MizanError, mizan_exception_handler)
"""

from __future__ import annotations

import json
from typing import Any

from fastapi import APIRouter, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, Response
from pydantic import BaseModel, Field, ValidationError
from starlette.datastructures import UploadFile

from mizan_core.auth import INVALID, authenticate
from mizan_core.dispatch import DispatchRequest, dispatch_call, dispatch_context
from mizan_core.errors import BadRequest, ErrorCode, MizanError, Unauthorized
from mizan_core.registry import get_function
from mizan_core.upload import UploadedFile, bind_uploads

from .config import MizanConfig, get_config


router = APIRouter()


def _no_store(payload: Any, status_code: int = 200) -> JSONResponse:
    return JSONResponse(payload, status_code=status_code, headers={"Cache-Control": "no-store"})


# ─── Endpoints ──────────────────────────────────────────────────────────────


@router.get("/session/")
async def session_init() -> JSONResponse:
    """Session-init probe. Parity with mizan-django's session endpoint.

    CSRF is a Django-only concern at the protocol level; FastAPI surfaces a
    null token so the response shape stays uniform across backends. The
    wire-parity harness uses this endpoint as its readiness probe.
    """
    return _no_store({"csrfToken": None})


class CallBody(BaseModel):
    fn: str = Field(..., min_length=1)
    args: dict[str, Any] = Field(default_factory=dict)


async def _parse_call(request: Request) -> tuple[str, dict[str, Any]]:
    """Read a call request, JSON or multipart. Returns `(fn, args)`.

    Multipart carries the non-file fields in a JSON `args` part and each file as
    its own part; the file parts bind into the Input's Upload fields with the
    declarative `File(...)` constraints enforced.
    """
    content_type = request.headers.get("content-type", "")
    if content_type.startswith("multipart/form-data"):
        form = await request.form()
        fn = form.get("fn")
        if not isinstance(fn, str) or not fn:
            raise BadRequest("Missing 'fn' field")
        raw_args = form.get("args")
        try:
            args: dict[str, Any] = json.loads(raw_args) if raw_args else {}
        except (TypeError, ValueError):
            raise BadRequest("Invalid JSON in 'args' field")

        fn_class = get_function(fn)
        input_cls = getattr(fn_class, "Input", None) if fn_class else None
        if input_cls is not None and hasattr(input_cls, "model_fields"):
            files: dict[str, list[UploadedFile]] = {}
            for key in set(form.keys()):
                wrapped = [
                    UploadedFile(p.filename, p.content_type, await p.read())
                    for p in form.getlist(key)
                    if isinstance(p, UploadFile)
                ]
                if wrapped:
                    files[key] = wrapped
            err = bind_uploads(input_cls, args, files)
            if err is not None:
                raise BadRequest(err)
        return fn, args

    try:
        body = CallBody(**(await request.json()))
    except (ValueError, ValidationError):
        raise BadRequest("Invalid request body")
    return body.fn, body.args


def _identity(request: Request, cfg: MizanConfig):
    """Identity for dispatch: a host-set `request.state.user`, else a token decode.

    A present-but-invalid token rejects (401); no token → None (anonymous).
    """
    existing = getattr(getattr(request, "state", None), "user", None)
    if existing is not None:
        return existing
    ident = authenticate(request.headers, cfg.auth)
    if ident is INVALID:
        raise Unauthorized("Invalid or expired token")
    return ident


@router.post("/call/")
async def function_call(request: Request) -> JSONResponse:
    """RPC dispatch — JSON or multipart → `{"result", "invalidate", "merge"?}` with
    the `X-Mizan-Invalidate` header alongside the body."""
    cfg = get_config(request)
    fn, args = await _parse_call(request)
    res = await dispatch_call(
        DispatchRequest(identity=_identity(request, cfg), args=args, native_request=request),
        fn, cfg.cache,
    )
    payload: dict[str, Any] = {"result": res.data, "invalidate": res.invalidate or []}
    if res.merge:
        payload["merge"] = res.merge
    headers = {"Cache-Control": "no-store"}
    if res.invalidate_header:
        headers["X-Mizan-Invalidate"] = res.invalidate_header
    return JSONResponse(payload, headers=headers)


@router.get("/ctx/{context_name}/")
async def context_fetch(context_name: str, request: Request) -> Response:
    """Bundled context fetch — origin-cached. `{function_name: result, ...}`."""
    cfg = get_config(request)
    res = await dispatch_context(
        DispatchRequest(identity=_identity(request, cfg), args=dict(request.query_params),
                        native_request=request),
        context_name, cfg.cache,
    )
    headers = {"Cache-Control": "no-store"}
    if res.cache_status:
        headers["X-Mizan-Cache"] = res.cache_status
    return Response(content=res.body_bytes, media_type="application/json", headers=headers)


# ─── Exception handler ──────────────────────────────────────────────────────


async def mizan_exception_handler(_request: Request, exc: MizanError) -> JSONResponse:
    """FastAPI exception handler — renders MizanError to the protocol's error envelope."""
    body: dict[str, Any] = {"error": {"code": exc.code.value, "message": exc.message}}
    if exc.details:
        body["error"]["details"] = exc.details
    return _no_store(body, status_code=exc.status_code)


async def mizan_validation_handler(_request: Request, exc: RequestValidationError) -> JSONResponse:
    """Maps malformed request bodies (invalid JSON, missing top-level fields) to BAD_REQUEST."""
    return _no_store(
        {
            "error": {
                "code": ErrorCode.BAD_REQUEST.value,
                "message": "Invalid request body",
                "details": {"errors": exc.errors()},
            }
        },
        status_code=400,
    )
