"""
Mizan schema export for FastAPI backends.

Builds an OpenAPI 3.0 document from the registered Mizan functions, mirroring
the shape mizan-django emits via Django Ninja so the codegen consumes either
backend identically.

Usage:
    from mizan_fastapi.schema import build_schema
    schema = build_schema()                # uses globally registered functions
"""

from __future__ import annotations

import re
from typing import Any

from fastapi import FastAPI
from fastapi.openapi.utils import get_openapi
from pydantic import BaseModel, RootModel, create_model

from mizan_core.registry import get_all_functions, get_context_groups, get_function
from mizan_core.type_utils import extract_list_element, extract_optional


__all__ = ["build_schema", "snake_to_camel"]


# Common user identity param names — mirrors mizan-django's _USER_SCOPED_PARAMS
_USER_SCOPED_PARAMS = {"user_id", "user", "owner_id", "account_id"}


def snake_to_camel(name: str) -> str:
    """Convert snake_case or dotted.name to camelCase. Mirrors mizan-django."""
    components = re.split(r"[._]", name)
    return components[0] + "".join(c.title() for c in components[1:])


def _has_input(input_cls: Any) -> bool:
    return (
        input_cls is not None
        and input_cls is not BaseModel
        and hasattr(input_cls, "model_fields")
        and bool(input_cls.model_fields)
    )


def _annotation_to_jsonschema_type(annotation: Any) -> str:
    if annotation is int:
        return "integer"
    if annotation is float:
        return "number"
    if annotation is bool:
        return "boolean"
    return "string"


def _function_metadata(name: str, fn_class: Any) -> dict[str, Any]:
    """Build one entry of x-mizan-functions. Mirrors Django's shape exactly."""
    camel = snake_to_camel(name)
    meta = getattr(fn_class, "_meta", {})

    input_cls = getattr(fn_class, "Input", None)
    has_input = _has_input(input_cls)

    output_cls = getattr(fn_class, "Output", None)
    _, output_nullable = extract_optional(output_cls) if output_cls is not None else (None, False)

    entry: dict[str, Any] = {
        "name": name,
        "camelName": camel,
        "hasInput": has_input,
        "inputType": f"{camel}Input" if has_input else None,
        "outputType": f"{camel}Output",
        # Nullability of the response model — Pydantic `T | None` returns. Carried
        # on the function entry rather than the schema class because OpenAPI emits
        # `anyOf: [{$ref}, {type:null}]` at the response level, which strict
        # deserializers (Rust serde) won't decode as Option<T> without this hint.
        "outputNullable": output_nullable,
        "transport": "websocket" if meta.get("websocket") else "http",
        "isContext": meta.get("context", False),
        # Form metadata — always emitted so the schema shape matches Django's,
        # even for FastAPI projects that don't use forms (these stay False/None).
        "isForm": meta.get("form", False),
        "formName": meta.get("form_name"),
        "formRole": meta.get("form_role"),
    }

    if meta.get("affects"):
        entry["affects"] = meta["affects"]
    if meta.get("merge"):
        entry["merge"] = meta["merge"]

    return entry


def _context_metadata(context_groups: dict[str, list[str]]) -> dict[str, Any]:
    """Build x-mizan-contexts. Mirrors Django's param-elevation logic."""
    out: dict[str, Any] = {}

    for ctx_name, fn_names in context_groups.items():
        param_info: dict[str, dict[str, Any]] = {}

        for fn_name in fn_names:
            fn_cls = get_function(fn_name)
            if fn_cls is None:
                continue

            input_cls = getattr(fn_cls, "Input", None)
            if not _has_input(input_cls):
                continue

            for field_name, field_info in input_cls.model_fields.items():
                if field_name not in param_info:
                    param_info[field_name] = {
                        "type": _annotation_to_jsonschema_type(field_info.annotation),
                        "sharedBy": [],
                    }
                param_info[field_name]["sharedBy"].append(fn_name)

        # A param is required iff every function in the context declares it.
        for p_meta in param_info.values():
            p_meta["required"] = len(p_meta["sharedBy"]) == len(fn_names)

        out[ctx_name] = {
            "functions": list(fn_names),
            "params": param_info,
        }

    return out


def build_schema() -> dict[str, Any]:
    """
    Build an OpenAPI 3.0 schema for all registered Mizan functions.

    Drives FastAPI's native OpenAPI generation by registering a stub endpoint
    per function with the function's Input/Output Pydantic models, then
    appends the protocol's `x-mizan-functions` and `x-mizan-contexts`
    extensions.

    Returns a dict in the same shape mizan-django's schema export emits, so
    the same codegen pipeline consumes either.
    """
    functions = get_all_functions()
    context_groups = get_context_groups()

    schema_app = FastAPI(
        title="mizan Server Functions",
        version="1.0.0",
        description="Auto-generated schema for mizan server functions",
    )

    # Per-function endpoints + renamed Pydantic models so component names are
    # camelCase + "Input"/"Output" rather than the user's original class names.
    schema_classes: dict[str, type[BaseModel]] = {}
    function_metadata: list[dict[str, Any]] = []

    for name, fn_class in functions.items():
        camel = snake_to_camel(name)
        input_cls = getattr(fn_class, "Input", None)
        output_cls = getattr(fn_class, "Output", None) or BaseModel
        has_input = _has_input(input_cls)

        input_type_name = f"{camel}Input" if has_input else None
        output_type_name = f"{camel}Output"

        # Strip Optional so the rename gets a concrete base — nullability is
        # carried on the response declaration, not the schema class itself.
        output_inner, output_nullable = extract_optional(output_cls)

        if has_input:
            schema_classes[input_type_name] = create_model(
                input_type_name, __base__=input_cls,
            )
        if extract_list_element(output_inner) is not None:
            # list[T] — RootModel makes the rename emit `type: array` rather
            # than wrapping the list in a property.
            schema_classes[output_type_name] = type(
                output_type_name, (RootModel[output_inner],), {},
            )
        else:
            schema_classes[output_type_name] = create_model(
                output_type_name, __base__=output_inner,
            )

        response_model = schema_classes[output_type_name]
        if output_nullable:
            response_model = response_model | None

        # Stub endpoint — only exists so FastAPI walks Pydantic types into
        # components.schemas. Never invoked. Annotations are set explicitly
        # rather than via closures so forward-ref resolution doesn't trip on
        # locally-bound type names.
        if has_input:
            async def stub(payload):
                return None

            stub.__annotations__ = {"payload": schema_classes[input_type_name]}
        else:
            async def stub():
                return None

        schema_app.post(
            f"/mizan/{name}",
            response_model=response_model,
            operation_id=camel,
            summary=fn_class.__doc__ or f"Call {name}",
        )(stub)

        function_metadata.append(_function_metadata(name, fn_class))

    schema = get_openapi(
        title=schema_app.title,
        version=schema_app.version,
        description=schema_app.description,
        routes=schema_app.routes,
    )

    schema["x-mizan-functions"] = function_metadata

    if context_groups:
        schema["x-mizan-contexts"] = _context_metadata(context_groups)

    # Attach x-mizan operation metadata, mirroring Django.
    paths = schema.get("paths", {})
    for fn_meta in function_metadata:
        op = paths.get(f"/mizan/{fn_meta['name']}", {}).get("post")
        if op is not None:
            op["x-mizan"] = {
                "transport": fn_meta["transport"],
                "isContext": fn_meta["isContext"],
            }

    return schema
