"""
mizan OpenAPI Schema Generator

Generates OpenAPI 3.0 compatible schema from registered server functions.
Uses Django Ninja's battle-tested schema generation for robust Pydantic→OpenAPI conversion.

This schema is consumed by the frontend generator which uses openapi-typescript
for robust type generation.

NOTE: Schema export is only available via management command for security.
      HTTP endpoint has been removed to prevent function enumeration.

Usage:
    python manage.py export_mizan_schema
"""

from __future__ import annotations

import json
import re
from typing import TYPE_CHECKING, Any

# Lazy imports to avoid Django settings access at module load time
# (asgi.py imports mizan before Django is fully configured)
if TYPE_CHECKING:
    from django import forms
    from ninja import NinjaAPI

from mizan_core.registry import get_registry, get_schema, get_context_groups, get_function
from mizan_core.type_utils import extract_list_element, extract_optional


__all__ = [
    "get_schema",
    "generate_openapi_schema",
    "generate_openapi_json",
    "generate_edge_manifest",
    "generate_edge_manifest_json",
]


def _extract_form_fields(form_class: type) -> list[dict[str, Any]]:
    """
    Extract field definitions with constraints from a Django Form class.

    Returns a list of field metadata suitable for Zod schema generation:
    - name: field name
    - zodType: base Zod type ("string", "number", "boolean", "array")
    - required: whether field is required
    - constraints: dict of Zod-compatible constraints

    Constraints include:
    - min/max: for string length or number range
    - email/url: for format validation
    - regex: for pattern validation
    - choices: for enum validation
    """
    try:
        # Try to instantiate form to get bound fields
        form = form_class()
        fields_dict = form.fields
    except TypeError:
        # Form requires extra args - use base_fields
        fields_dict = getattr(form_class, "base_fields", {})

    result = []

    for name, field in fields_dict.items():
        field_meta = _extract_field_constraints(name, field)
        result.append(field_meta)

    return result


def _extract_field_constraints(name: str, field: "forms.Field") -> dict[str, Any]:
    """
    Extract Zod-compatible constraints from a single Django form field.
    """
    from django import forms  # Lazy import

    meta: dict[str, Any] = {
        "name": name,
        "required": field.required,
        "constraints": {},
    }

    # Determine base Zod type
    if isinstance(field, forms.BooleanField):
        meta["zodType"] = "boolean"
    elif isinstance(field, (forms.IntegerField, forms.FloatField, forms.DecimalField)):
        meta["zodType"] = "number"
        if isinstance(field, forms.IntegerField):
            meta["constraints"]["int"] = True
    elif isinstance(field, forms.MultipleChoiceField):
        meta["zodType"] = "array"
        meta["constraints"]["items"] = "string"
    elif isinstance(field, forms.FileField):
        meta["zodType"] = "file"
    else:
        # Default to string (CharField, EmailField, URLField, etc.)
        meta["zodType"] = "string"

    # Extract string constraints
    if hasattr(field, "max_length") and field.max_length is not None:
        meta["constraints"]["max"] = field.max_length
    if hasattr(field, "min_length") and field.min_length is not None:
        meta["constraints"]["min"] = field.min_length

    # Extract number constraints
    if hasattr(field, "max_value") and field.max_value is not None:
        meta["constraints"]["max"] = field.max_value
    if hasattr(field, "min_value") and field.min_value is not None:
        meta["constraints"]["min"] = field.min_value

    # Email/URL format
    if isinstance(field, forms.EmailField):
        meta["constraints"]["email"] = True
    elif isinstance(field, forms.URLField):
        meta["constraints"]["url"] = True

    # Choices (for enum validation)
    if hasattr(field, "choices") and field.choices:
        # Extract choice values (not labels)
        choices = []
        for choice in field.choices:
            if isinstance(choice, (list, tuple)) and len(choice) >= 1:
                # Skip empty/blank choices
                if choice[0] != "":
                    choices.append(str(choice[0]))
            else:
                choices.append(str(choice))
        if choices:
            meta["constraints"]["choices"] = choices

    # Regex validators
    for validator in field.validators:
        if hasattr(validator, "regex"):
            # RegexValidator - extract pattern
            pattern = validator.regex.pattern
            meta["constraints"]["regex"] = pattern
            if hasattr(validator, "message"):
                meta["constraints"]["regexMessage"] = validator.message
            break  # Only use first regex validator

    return meta


def snake_to_camel(name: str) -> str:
    """Convert snake_case or dotted.name to camelCase.

    Examples:
        - login -> login
        - login.schema -> loginSchema
        - activate_totp -> activateTotp
        - activate_totp.schema -> activateTotpSchema
    """
    # Split on both underscores and dots
    components = re.split(r"[._]", name)
    return components[0] + "".join(x.title() for x in components[1:])


def _register_schema_endpoint(
    api: "NinjaAPI",
    path: str,
    operation_id: str,
    summary: str,
    input_cls: type | None,
    output_cls: type,
) -> None:
    """
    Register a dummy endpoint on the API for schema generation.

    Sets __annotations__ directly to avoid closure capture issues
    and exec() security concerns.
    """
    if input_cls is not None:

        def endpoint(request, data):
            pass

        # Set annotations directly to the actual type objects (not strings)
        endpoint.__annotations__ = {"data": input_cls}
    else:

        def endpoint(request):
            pass

    # Register with Ninja
    api.post(path, response=output_cls, operation_id=operation_id, summary=summary)(
        endpoint
    )


def generate_openapi_schema() -> dict[str, Any]:
    """
    Generate OpenAPI 3.0 schema for all registered mizan functions.

    Uses Django Ninja's schema generation internally to ensure proper
    Pydantic→OpenAPI conversion (handling $refs, nested types, etc.).

    Returns a complete OpenAPI document that can be processed by openapi-typescript.
    """
    from ninja import NinjaAPI  # Lazy import
    from pydantic import BaseModel, RootModel, create_model  # Lazy import

    registry = get_registry()
    functions = registry.get("functions", {})

    # Create a temporary Ninja API for schema generation only
    # This is NOT exposed as an HTTP endpoint - purely for leveraging Ninja's
    # battle-tested Pydantic→OpenAPI conversion
    schema_api = NinjaAPI(
        title="mizan Server Functions",
        version="1.0.0",
        description="Auto-generated schema for mizan server functions",
        docs_url=None,  # No docs endpoint
        openapi_url=None,  # No openapi endpoint
    )

    function_metadata: list[dict[str, Any]] = []

    # Store dynamically created classes so they persist for schema generation
    schema_classes: dict[str, type] = {}

    for name, fn_class in functions.items():
        camel_name = snake_to_camel(name)
        meta = getattr(fn_class, "_meta", {})

        # Get Input/Output classes
        input_cls = getattr(fn_class, "Input", None)
        output_cls = getattr(fn_class, "Output", None) or BaseModel

        # Check if input_cls is a valid Pydantic model with fields
        has_input = (
            input_cls is not None
            and input_cls is not BaseModel
            and hasattr(input_cls, "model_fields")
            and bool(input_cls.model_fields)
        )

        # Determine type names for metadata
        input_type_name = f"{camel_name}Input" if has_input else None
        output_type_name = f"{camel_name}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)

        # Create renamed Pydantic classes for cleaner schema names
        # Store them in schema_classes so they persist beyond loop scope
        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_cls = schema_classes[output_type_name]
        if output_nullable:
            response_cls = response_cls | None

        # Register endpoint using helper to avoid closure capture issues
        _register_schema_endpoint(
            api=schema_api,
            path=f"/mizan/{name}",
            operation_id=camel_name,
            summary=fn_class.__doc__ or f"Call {name}",
            input_cls=schema_classes.get(input_type_name),
            output_cls=response_cls,
        )

        # Collect function metadata for provider generation
        fn_meta_entry: dict[str, Any] = {
            "name": name,
            "camelName": camel_name,
            "hasInput": has_input,
            "inputType": input_type_name,
            "outputType": output_type_name,
            "transport": "websocket" if meta.get("websocket") else "http",
            "isContext": meta.get("context", False),
            # Form metadata
            "isForm": meta.get("form", False),
            "formName": meta.get("form_name"),
            "formRole": meta.get("form_role"),  # "schema", "validate", "submit"
        }

        # Affects metadata (mutation invalidation)
        if meta.get("affects"):
            fn_meta_entry["affects"] = meta["affects"]
        if meta.get("merge"):
            fn_meta_entry["merge"] = meta["merge"]

        # For form schema functions, extract field definitions for Zod generation
        if meta.get("form") and meta.get("form_role") == "schema":
            form_class = meta.get("form_class")
            if form_class is not None:
                try:
                    fn_meta_entry["formFields"] = _extract_form_fields(form_class)
                except Exception as e:
                    # Don't fail schema generation if field extraction fails
                    fn_meta_entry["formFields"] = []
                    fn_meta_entry["formFieldsError"] = str(e)

        function_metadata.append(fn_meta_entry)

    # Get the OpenAPI schema from Ninja (handles all Pydantic conversion properly)
    schema = schema_api.get_openapi_schema(path_prefix="")

    # Add custom extension with function metadata for provider generation
    schema["x-mizan-functions"] = function_metadata

    # Add x-mizan-contexts: grouped context metadata with param elevation
    context_groups = get_context_groups()
    if context_groups:
        contexts_meta: dict[str, Any] = {}
        for ctx_name, fn_names in context_groups.items():
            # Analyze params across all functions in the context
            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 input_cls and input_cls is not BaseModel and hasattr(input_cls, "model_fields"):
                    for field_name, field_info in input_cls.model_fields.items():
                        if field_name not in param_info:
                            annotation = field_info.annotation
                            # Map Python types to JSON schema types
                            type_name = "string"
                            if annotation in (int,):
                                type_name = "integer"
                            elif annotation in (float,):
                                type_name = "number"
                            elif annotation in (bool,):
                                type_name = "boolean"
                            param_info[field_name] = {
                                "type": type_name,
                                "sharedBy": [],
                            }
                        param_info[field_name]["sharedBy"].append(fn_name)

            # A param is required if ALL functions in the context declare it
            for p_name, p_meta in param_info.items():
                p_meta["required"] = len(p_meta["sharedBy"]) == len(fn_names)

            contexts_meta[ctx_name] = {
                "functions": fn_names,
                "params": param_info,
            }
        schema["x-mizan-contexts"] = contexts_meta

    # Add x-mizan metadata to each operation
    for fn_meta in function_metadata:
        path = f"/mizan/{fn_meta['name']}"
        if path in schema.get("paths", {}):
            schema["paths"][path]["post"]["x-mizan"] = {
                "transport": fn_meta["transport"],
                "isContext": fn_meta["isContext"],
            }

    return schema


def generate_openapi_json(indent: int = 2) -> str:
    """Generate OpenAPI schema as formatted JSON string."""
    schema = generate_openapi_schema()
    return json.dumps(schema, indent=indent)


def generate_edge_manifest(
    base_url: str = "/api/mizan",
    view_urls: dict[str, list[str]] | None = None,
) -> dict[str, Any]:
    """
    Generate the Edge manifest — a static JSON mapping contexts to URL
    patterns and params for CDN cache purging.

    The manifest is consumed by Mizan Edge at deploy time. When Edge
    receives X-Mizan-Invalidate: user;user_id=5, it:
    1. Looks up 'user' in the manifest
    2. Resolves URL patterns with params: /profile/:user_id/ → /profile/5/
    3. Purges the resolved URLs + the context API endpoint

    Args:
        base_url: The Mizan API mount point (default: /api/mizan)
        view_urls: Optional mapping of context names to URL patterns for
                   view-path functions. These are URLs that Edge should
                   also purge when a context is invalidated.
                   Example: {"user": ["/profile/:user_id/"]}

    Returns:
        Manifest dict suitable for JSON serialization.
    """
    from pydantic import BaseModel as PydanticBaseModel

    # Common user identity param names for user_scoped detection
    _USER_SCOPED_PARAMS = {"user_id", "user", "owner_id", "account_id"}

    groups = get_context_groups()
    registry = get_registry()
    all_functions = registry.get("functions", {})

    manifest: dict[str, Any] = {"version": 1, "contexts": {}, "mutations": {}}

    for ctx_name, fn_names in groups.items():
        # Collect params and routes from all functions in this context
        param_names: set[str] = set()
        functions_meta: list[dict[str, Any]] = []
        page_routes: list[str] = []

        for fn_name in fn_names:
            fn_cls = all_functions.get(fn_name)
            if fn_cls is None:
                continue

            meta = getattr(fn_cls, "_meta", {})
            is_view = meta.get("view_path", False)

            # Collect param names from Input schema
            input_cls = getattr(fn_cls, "Input", None)
            if (
                input_cls
                and input_cls is not PydanticBaseModel
                and hasattr(input_cls, "model_fields")
            ):
                param_names.update(input_cls.model_fields.keys())

            fn_entry: dict[str, Any] = {
                "name": fn_name,
                "path": "view" if is_view else "rpc",
            }

            # Collect routes from view-path functions
            fn_route = meta.get("route")
            if fn_route:
                fn_entry["route"] = fn_route
                fn_entry["methods"] = meta.get("methods", ["GET"])
                page_routes.append(fn_route)

            # Cache protocol metadata
            if "rev" in meta:
                fn_entry["rev"] = meta["rev"]
            if "cache" in meta:
                fn_entry["cache"] = meta["cache"]

            functions_meta.append(fn_entry)

        sorted_params = sorted(param_names)
        user_scoped = bool(param_names & _USER_SCOPED_PARAMS)

        ctx_entry: dict[str, Any] = {
            "functions": functions_meta,
            "endpoints": [f"{base_url}/ctx/{ctx_name}/"],
            "params": sorted_params,
            "user_scoped": user_scoped,
            "render_strategy": "dynamic_cached" if user_scoped else "psr",
        }

        # Add page routes from view-path functions with route=
        if page_routes:
            ctx_entry["page_routes"] = page_routes

        # Add externally-declared view URLs
        if view_urls and ctx_name in view_urls:
            ctx_entry.setdefault("page_routes", []).extend(view_urls[ctx_name])

        manifest["contexts"][ctx_name] = ctx_entry

    # Mutations section — all functions with affects=
    for fn_name, fn_cls in all_functions.items():
        meta = getattr(fn_cls, "_meta", {})
        affects = meta.get("affects")
        if not affects:
            continue

        # Resolve context names from affects targets
        affected_contexts = []
        for target in affects:
            if target["type"] == "context":
                affected_contexts.append(target["name"])
            elif target["type"] == "function" and target.get("context"):
                affected_contexts.append(target["context"])
        affected_contexts = list(dict.fromkeys(affected_contexts))

        # Determine which params auto-scope
        auto_scoped = []
        input_cls = getattr(fn_cls, "Input", None)
        if input_cls and input_cls is not PydanticBaseModel and hasattr(input_cls, "model_fields"):
            fn_params = set(input_cls.model_fields.keys())
            for ctx_name in affected_contexts:
                ctx_params = set()
                for ctx_fn_name in groups.get(ctx_name, []):
                    ctx_fn_cls = all_functions.get(ctx_fn_name)
                    if ctx_fn_cls:
                        ctx_input = getattr(ctx_fn_cls, "Input", None)
                        if ctx_input and ctx_input is not PydanticBaseModel and hasattr(ctx_input, "model_fields"):
                            ctx_params.update(ctx_input.model_fields.keys())
                auto_scoped.extend(sorted(fn_params & ctx_params))
        auto_scoped = list(dict.fromkeys(auto_scoped))

        mutation_entry: dict[str, Any] = {
            "affects": affected_contexts,
        }
        if auto_scoped:
            mutation_entry["auto_scoped_params"] = auto_scoped
        if meta.get("private"):
            mutation_entry["private"] = True
        if meta.get("route"):
            mutation_entry["route"] = meta["route"]
            mutation_entry["methods"] = meta.get("methods", ["POST"])

        manifest["mutations"][fn_name] = mutation_entry

    return manifest


def generate_edge_manifest_json(
    indent: int = 2,
    base_url: str = "/api/mizan",
    view_urls: dict[str, list[str]] | None = None,
) -> str:
    """Generate Edge manifest as formatted JSON string."""
    manifest = generate_edge_manifest(base_url=base_url, view_urls=view_urls)
    return json.dumps(manifest, indent=indent, sort_keys=True)
