"""
mizan Function Executor

Handles execution of server functions.
This is the core of the "Server Functions" feature - callable from React
without REST boilerplate.

Security model:
- All input validated against Pydantic schema BEFORE execution
- Authentication: JWT (stateless) or Session (stateful) - auto-detected
  - JWT: Authorization header with Bearer token (no CSRF needed)
  - Session: Cookie-based with CSRF token (via X-CSRFToken header)
- WebSocket RPC uses Origin header checking instead
- No implicit function exposure - must be explicitly registered
"""

from __future__ import annotations

import json
import logging
from dataclasses import dataclass
from enum import Enum
from functools import wraps
from typing import TYPE_CHECKING, Any, Callable

from django.http import HttpRequest, HttpResponse, HttpResponseBase, JsonResponse
from django.views.decorators.csrf import csrf_protect
from pydantic import BaseModel, ValidationError

from mizan.cache import get_cache, cache_get, cache_put, cache_purge
from mizan_core.registry import get_function, get_context_groups
from mizan.setup.settings import get_settings

if TYPE_CHECKING:
    pass

logger = logging.getLogger(__name__)


class ErrorCode(str, Enum):
    """Standard error codes for function execution."""

    # Client errors (4xx)
    NOT_FOUND = "NOT_FOUND"  # Function not registered
    VALIDATION_ERROR = "VALIDATION_ERROR"  # Input failed Pydantic validation
    UNAUTHORIZED = "UNAUTHORIZED"  # User not authenticated (when required)
    FORBIDDEN = "FORBIDDEN"  # User lacks permission
    BAD_REQUEST = "BAD_REQUEST"  # Malformed request

    # Server errors (5xx)
    INTERNAL_ERROR = "INTERNAL_ERROR"  # Unhandled exception
    NOT_IMPLEMENTED = "NOT_IMPLEMENTED"  # Function exists but not implemented


@dataclass
class FunctionError:
    """Structured error response from function execution."""

    code: ErrorCode
    message: str
    details: dict[str, Any] | None = None

    def to_dict(self) -> dict[str, Any]:
        """Convert to JSON-serializable dict."""
        result = {
            "error": True,
            "code": self.code.value,
            "message": self.message,
        }
        if self.details:
            result["details"] = self.details
        return result

    def to_response(self, status: int = 400) -> JsonResponse:
        """Convert to Django JsonResponse."""
        return JsonResponse(self.to_dict(), status=status)


@dataclass
class FunctionResult:
    """Successful result from function execution."""

    data: dict[str, Any]

    def to_dict(self) -> dict[str, Any]:
        """Convert to JSON-serializable dict."""
        return {
            "error": False,
            "data": self.data,
        }

    def to_response(self) -> JsonResponse:
        """Convert to Django JsonResponse."""
        return JsonResponse(self.to_dict())


def _check_auth_requirement(
    request: HttpRequest,
    auth_requirement: str | Callable | None,
) -> FunctionError | None:
    """
    Check if the request meets the auth requirement.

    Args:
        request: The Django HttpRequest (with user set)
        auth_requirement: 'required', 'staff', 'superuser', callable, or None

    Returns:
        FunctionError if auth check fails, None if it passes.

    Note: This uses request.user which may be a JWTUser (stateless) or
    Django User (from session). Either way, no additional DB query is made
    for the built-in checks. Custom callables may query DB if they choose.
    """
    if auth_requirement is None:
        return None

    user = request.user

    # Handle callable auth
    if callable(auth_requirement):
        try:
            result = auth_requirement(request)
            if result:
                return None  # Authorized
            else:
                return FunctionError(
                    code=ErrorCode.FORBIDDEN,
                    message="Access denied",
                )
        except PermissionError as e:
            # Custom error message from the callable
            return FunctionError(
                code=ErrorCode.FORBIDDEN,
                message=str(e) or "Access denied",
            )

    # Check authentication (required for all string-based auth)
    if not getattr(user, "is_authenticated", False):
        return FunctionError(
            code=ErrorCode.UNAUTHORIZED,
            message="Authentication required",
        )

    # Check staff requirement
    if auth_requirement == "staff":
        if not getattr(user, "is_staff", False):
            return FunctionError(
                code=ErrorCode.FORBIDDEN,
                message="Staff access required",
            )

    # Check superuser requirement
    elif auth_requirement == "superuser":
        if not getattr(user, "is_superuser", False):
            return FunctionError(
                code=ErrorCode.FORBIDDEN,
                message="Superuser access required",
            )

    return None


_cache_log = logging.getLogger("mizan.cache")


def _purge_cache_for_invalidation(
    invalidate: list,
    request: HttpRequest | None = None,
) -> None:
    """Purge origin-side cache for invalidation targets. Includes user_id if available."""
    cache = get_cache()
    if cache is None:
        return

    settings = get_settings()
    if not settings.cache_secret:
        return

    user_id = None
    if request and hasattr(request, 'user') and hasattr(request.user, 'pk'):
        uid = getattr(request.user, 'pk', None)
        if uid is not None:
            user_id = str(uid)

    try:
        for entry in invalidate:
            if isinstance(entry, str):
                cache_purge(cache, entry)
            elif isinstance(entry, dict):
                cache_purge(
                    cache, entry["context"], entry.get("params"),
                    secret=settings.cache_secret,
                    user_id=user_id,
                )
    except Exception:
        _cache_log.warning("Cache purge failed", exc_info=True)


def _resolve_affects_target(target_name: str) -> tuple[str, str, str | None]:
    """
    Determine whether an affects target is a context name or function name.

    Returns:
        ("context", "user", None)                — full context invalidation
        ("function", "user_profile", "user")     — function within context
    """
    groups = get_context_groups()

    # Check if it's a context name directly
    if target_name in groups:
        return ("context", target_name, None)

    # Check if it's a function name within a context
    for ctx_name, fn_names in groups.items():
        if target_name in fn_names:
            return ("function", target_name, ctx_name)

    # Not a context or context function — treat as context name anyway
    # (it might be a non-context function or an as-yet-unregistered context)
    return ("context", target_name, None)


def _get_context_param_names(context_name: str) -> set[str]:
    """
    Get the set of parameter names used by functions in a context.

    Returns the union of all Input field names across context functions.
    """
    groups = get_context_groups()
    fn_names = groups.get(context_name, [])
    param_names: set[str] = set()

    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"):
            param_names.update(input_cls.model_fields.keys())

    return param_names


def _resolve_invalidation(
    view_class: type | None,
    input_data: dict[str, Any] | None = None,
) -> list[str | dict[str, Any]] | None:
    """
    Resolve invalidation targets with three-tier auto-scoping.

    Tier 1: Argument name matching — if the mutation's input args overlap
            with the context's params by name, auto-scope.
    Tier 2: Auth inference — Edge-side concern, not handled here.
    Tier 3: Broad fallback — invalidate all instances.

    Also handles function-level targeting: affects='user_profile' resolves
    to the function name (v1: runtime refetches the whole context anyway).

    Returns a list suitable for both JSON body and header serialization.
    Returns None if no invalidation needed.
    """
    if view_class is None:
        return None

    meta = getattr(view_class, "_meta", {})
    affects = meta.get("affects")
    if not affects:
        return None

    result = []
    seen = set()

    for target in affects:
        if target["type"] == "context":
            target_name = target["name"]
        elif target["type"] == "function" and target.get("context"):
            # Function-level: use the function name as the invalidation key
            target_name = target["name"]
        else:
            continue

        if target_name in seen:
            continue
        seen.add(target_name)

        # Resolve the context this target belongs to (for param lookup)
        resolved = _resolve_affects_target(target_name)
        ctx_for_params = resolved[2] if resolved[0] == "function" else resolved[1]

        # Tier 1: argument name matching
        if input_data and ctx_for_params:
            context_params = _get_context_param_names(ctx_for_params)
            matched = {
                k: v for k, v in input_data.items()
                if k in context_params
            }
            if matched:
                result.append({"context": target_name, "params": matched})
                continue

        # Tier 3: broad fallback
        result.append(target_name)

    return result if result else None


def _resolve_merges(
    view_class: type | None,
    input_data: dict[str, Any] | None,
    result_data: Any,
) -> list[dict[str, Any]] | None:
    """
    Resolve merge targets from @client(merge=...).

    Each entry is `{context, slot, value, params?}` — `slot` is the
    function-name inside the context bundle the value lands in, resolved
    server-side by matching the mutation's return type against each
    context-function's return type. Kernel does no shape inference.

    Mirrors _resolve_invalidation's tier-1 auto-scoping for params.
    Entries whose slot can't be uniquely resolved are dropped.
    """
    if view_class is None:
        return None

    from mizan_core.type_utils import types_match_for_merge

    meta = getattr(view_class, "_meta", {})
    targets = meta.get("merge") or []
    if not targets:
        return None

    mutation_output = getattr(view_class, "Output", None)

    out: list[dict[str, Any]] = []
    seen: set[str] = set()
    for ctx_name in targets:
        if ctx_name in seen:
            continue
        seen.add(ctx_name)

        slot = _resolve_merge_slot(ctx_name, mutation_output, types_match_for_merge)
        if slot is None:
            continue

        entry: dict[str, Any] = {"context": ctx_name, "slot": slot, "value": result_data}
        if input_data:
            context_params = _get_context_param_names(ctx_name)
            matched = {
                k: v for k, v in input_data.items()
                if k in context_params
            }
            if matched:
                entry["params"] = matched
        out.append(entry)
    return out


def _resolve_merge_slot(context_name: str, mutation_output: Any, type_matcher: Any) -> str | None:
    """Find the unique function-name slot in context whose return type matches mutation's output."""
    if mutation_output is None:
        return None
    groups = get_context_groups()
    fn_names = groups.get(context_name, [])
    matches: list[str] = []
    for fn_name in fn_names:
        fn_cls = get_function(fn_name)
        if fn_cls is None:
            continue
        fn_output = getattr(fn_cls, "Output", None)
        if fn_output is not None and type_matcher(fn_output, mutation_output):
            matches.append(fn_name)
    return matches[0] if len(matches) == 1 else None


def _format_invalidate_header(
    invalidate: list[str | dict[str, Any]],
) -> str:
    """
    Format invalidation targets as X-Mizan-Invalidate header value.

    Format: comma-separated contexts. Semicolon-separated params per context.
    Param values are URL-encoded to prevent delimiter collisions.

    Examples:
        ["user"]                     → "user"
        ["user", "notifications"]    → "user, notifications"
        [{"context": "user", "params": {"user_id": 5}}]
                                     → "user;user_id=5"
        [{"context": "search", "params": {"q": "hello world"}}]
                                     → "search;q=hello%20world"
    """
    from urllib.parse import quote

    parts = []
    for entry in invalidate:
        if isinstance(entry, str):
            parts.append(entry)
        elif isinstance(entry, dict):
            ctx = entry["context"]
            params = entry.get("params", {})
            if params:
                param_str = ";".join(
                    f"{quote(str(k), safe='')}={quote(str(v), safe='')}"
                    for k, v in sorted(params.items())
                )
                parts.append(f"{ctx};{param_str}")
            else:
                parts.append(ctx)
    return ", ".join(parts)


def execute_function(
    request: HttpRequest,
    fn_name: str,
    input_data: dict[str, Any] | None = None,
) -> "FunctionResult | FunctionError | HttpResponseBase":
    """
    Execute a registered server function.

    Args:
        request: The Django HttpRequest
        fn_name: Name of the registered function
        input_data: Input data to pass to the function

    Returns:
        FunctionResult on success, FunctionError on failure
    """
    from django.conf import settings

    # Look up the function by name
    view_class = get_function(fn_name)
    if view_class is None:
        # In DEBUG mode, include the name for easier debugging
        if settings.DEBUG:
            message = f"Function '{fn_name}' not found"
        else:
            message = "Function not found"
        return FunctionError(
            code=ErrorCode.NOT_FOUND,
            message=message,
        )

    # Reject private functions from RPC dispatch
    meta = getattr(view_class, "_meta", {})
    if meta.get("private"):
        return FunctionError(
            code=ErrorCode.FORBIDDEN,
            message="Function is not client-callable",
        )

    # Check auth requirement BEFORE executing
    auth_requirement = meta.get("auth")
    auth_error = _check_auth_requirement(request, auth_requirement)
    if auth_error is not None:
        return auth_error

    # Instantiate the view with the request
    view = view_class(request)

    # Check if this is a form function that handles input specially
    meta = getattr(view_class, "_meta", {})
    is_form_multipart = meta.get("multipart", False)

    # For form functions with Input=None, skip Pydantic validation
    # The form itself handles validation
    input_cls = view.Input
    if input_cls is None and is_form_multipart:
        # Form function - pass input_data directly (already parsed by view or will be)
        validated_input = input_data
    elif input_cls is BaseModel:
        has_input = False
        validated_input = None
    else:
        # Check if it has any fields defined
        has_input = bool(input_cls.model_fields) if input_cls else False

        # Validate input against Pydantic schema
        try:
            if input_data:
                # Ensure input_data is a dict (not array or other type)
                if not isinstance(input_data, dict):
                    return FunctionError(
                        code=ErrorCode.BAD_REQUEST,
                        message="Input must be an object, not "
                        + type(input_data).__name__,
                    )
                validated_input = input_cls(**input_data)
            elif has_input:
                # Check if function requires input fields
                input_schema = input_cls.model_json_schema()
                required_fields = input_schema.get("required", [])
                if required_fields:
                    # Format as field errors for consistency
                    errors = {field: ["Field required"] for field in required_fields}
                    return FunctionError(
                        code=ErrorCode.VALIDATION_ERROR,
                        message="Input validation failed",
                        details={"fields": errors},
                    )
                validated_input = input_cls()
            else:
                # No input expected, create empty model
                validated_input = None
        except ValidationError as e:
            # Convert Pydantic errors to our format
            errors = {}
            for error in e.errors():
                field = ".".join(str(loc) for loc in error["loc"])
                if field not in errors:
                    errors[field] = []
                errors[field].append(error["msg"])

            return FunctionError(
                code=ErrorCode.VALIDATION_ERROR,
                message="Input validation failed",
                details={"fields": errors},
            )

    # Execute the function
    try:
        output = view.call(validated_input)
    except NotImplementedError as e:
        logger.error(f"Function {fn_name} not implemented: {e}")
        return FunctionError(
            code=ErrorCode.NOT_IMPLEMENTED,
            message=str(e),
        )
    except PermissionError as e:
        # Functions can raise PermissionError for auth issues
        return FunctionError(
            code=ErrorCode.FORBIDDEN,
            message=str(e) or "Permission denied",
        )
    except Exception as e:
        # Log the full exception for debugging
        logger.exception(f"Error executing function {fn_name}")
        return FunctionError(
            code=ErrorCode.INTERNAL_ERROR,
            message="An internal error occurred",
            # Don't expose internal details in production
            details={"type": type(e).__name__}
            if logger.isEnabledFor(logging.DEBUG)
            else None,
        )

    # Return-type branching: HttpResponse (view path) vs data (RPC path)
    from django.http import HttpResponseBase

    if isinstance(output, HttpResponseBase):
        # View path — add invalidation header + purge origin cache
        invalidate = _resolve_invalidation(view_class, input_data)
        if invalidate:
            output["X-Mizan-Invalidate"] = _format_invalidate_header(invalidate)
            _purge_cache_for_invalidation(invalidate, request)
        output["Cache-Control"] = "no-store"
        return output

    # RPC path — serialize output. to_jsonable_python walks BaseModel /
    # list / dict recursively, so list[BaseModel] (and nested shapes) come
    # out wire-ready without a per-shape branch.
    from pydantic_core import to_jsonable_python

    return FunctionResult(data=to_jsonable_python(output))


def _try_mwt_auth(request: HttpRequest) -> bool:
    """
    Attempt to authenticate the request using MWT (Mizan Web Token).

    Checks the X-Mizan-Token header. If present and valid, sets request.user
    to an MWTUser. Returns True on success, False if no MWT header or invalid.
    """
    token = request.META.get("HTTP_X_MIZAN_TOKEN", "")
    if not token:
        return False

    try:
        settings = get_settings()
        if not settings.mwt_secret:
            logging.getLogger("mizan.mwt").warning(
                "X-Mizan-Token header present but MIZAN_MWT_SECRET is not configured"
            )
            return False

        from mizan_core.mwt import decode_mwt, MWTUser

        payload = decode_mwt(token, settings.mwt_secret)
        if payload is None:
            return False

        request.user = MWTUser(payload)
        request._mizan_mwt_authenticated = True
        return True
    except Exception:
        logging.getLogger("mizan.mwt").warning(
            "MWT authentication failed unexpectedly", exc_info=True
        )
        return False


def _has_mwt_header(request: HttpRequest) -> bool:
    """Check if request has an X-Mizan-Token header."""
    return bool(request.META.get("HTTP_X_MIZAN_TOKEN", ""))


def _try_jwt_auth(request: HttpRequest) -> bool:
    """
    Attempt to authenticate the request using JWT.

    If Authorization header contains a valid Bearer token, authenticates
    the request and sets request.user to a JWTUser. Returns True if JWT
    auth succeeded.

    IMPORTANT: This is stateless - no database query is made. The JWTUser
    object is created from the token claims. If you need the full User
    object, query it explicitly in your function.

    Security: If JWT is provided but invalid, we return False and do NOT
    fall back to session auth. The caller should reject the request.
    """
    auth_header = request.META.get("HTTP_AUTHORIZATION", "")
    if not auth_header.startswith("Bearer "):
        return False

    token = auth_header[7:]  # Strip "Bearer "
    if not token:
        return False

    try:
        from mizan.client.jwt import decode_token
        from mizan.jwt.tokens import JWTUser

        payload = decode_token(token, expected_type="access")
        if payload is None:
            return False

        # Create JWTUser from token claims - NO DATABASE QUERY
        request.user = JWTUser(payload)
        request._mizan_jwt_authenticated = True
        return True
    except Exception:
        return False


def _has_jwt_header(request: HttpRequest) -> bool:
    """Check if request has a JWT Authorization header."""
    auth_header = request.META.get("HTTP_AUTHORIZATION", "")
    return auth_header.startswith("Bearer ")


def _csrf_protect_unless_token(view_func):
    """
    Decorator that applies CSRF protection unless token auth is used.

    MWT (X-Mizan-Token) is checked first, then legacy JWT (Authorization: Bearer).
    Both are self-authenticating, so CSRF protection is not needed.

    Security: If a token is provided but invalid, reject the request - do NOT
    fall back to session auth.
    """
    csrf_protected_view = csrf_protect(view_func)

    @wraps(view_func)
    def wrapper(request: HttpRequest, *args, **kwargs):
        # MWT takes priority
        if _has_mwt_header(request):
            if _try_mwt_auth(request):
                return view_func(request, *args, **kwargs)
            return FunctionError(
                code=ErrorCode.UNAUTHORIZED,
                message="Invalid or expired MWT",
            ).to_response(status=401)

        # Legacy JWT fallback
        if _has_jwt_header(request):
            if _try_jwt_auth(request):
                return view_func(request, *args, **kwargs)
            return FunctionError(
                code=ErrorCode.UNAUTHORIZED,
                message="Invalid or expired JWT token",
            ).to_response(status=401)

        # No token — session auth with CSRF
        return csrf_protected_view(request, *args, **kwargs)

    return wrapper


@_csrf_protect_unless_token
def function_call_view(request: HttpRequest) -> JsonResponse:
    """
    Django view for handling function calls (HTTP fallback for WebSocket RPC).

    Authentication (auto-detected):
    - JWT: Authorization: Bearer <token> (stateless, no CSRF needed)
    - Session: Cookie-based with X-CSRFToken header (CSRF required)

    Endpoint: POST /api/mizan/call/

    Request body (JSON):
        {
            "fn": "function_name",  // Function name
            "args": { ... }         // Optional, depending on function
        }

    Request body (multipart/form-data for form submit functions):
        fn: function_name
        <field>: <value>
        ...

    Response on success:
        {
            "error": false,
            "data": { ... }  // Function output
        }

    Response on error:
        {
            "error": true,
            "code": "VALIDATION_ERROR",
            "message": "Input validation failed",
            "details": { ... }
        }
    """
    # Only allow POST
    if request.method != "POST":
        return FunctionError(
            code=ErrorCode.BAD_REQUEST,
            message="Only POST method allowed",
        ).to_response(status=405)

    # Check content type to determine parsing method
    content_type = request.content_type or ""
    is_multipart = content_type.startswith("multipart/form-data")

    if is_multipart:
        # Multipart form data - used by form submit functions
        fn_name = request.POST.get("fn")
        if not fn_name:
            return FunctionError(
                code=ErrorCode.BAD_REQUEST,
                message="Missing 'fn' field",
            ).to_response()

        # Get form data (excluding 'fn')
        input_data = {k: v for k, v in request.POST.dict().items() if k != "fn"}

        # Attach parsed form data and files to request for form functions
        request._mizan_form_data = input_data
        request._mizan_form_files = request.FILES

    else:
        # JSON body - standard RPC
        try:
            if request.body:
                body = json.loads(request.body)
            else:
                return FunctionError(
                    code=ErrorCode.BAD_REQUEST,
                    message="Request body required",
                ).to_response()
        except json.JSONDecodeError:
            return FunctionError(
                code=ErrorCode.BAD_REQUEST,
                message="Invalid JSON in request body",
            ).to_response()

        # Extract function name and args
        fn_name = body.get("fn")
        if not fn_name:
            return FunctionError(
                code=ErrorCode.BAD_REQUEST,
                message="Missing 'fn' field",
            ).to_response()

        input_data = body.get("args")

    # Execute the function
    result = execute_function(request, fn_name, input_data)

    # View path — function returned an HttpResponse directly
    from django.http import HttpResponseBase
    if isinstance(result, HttpResponseBase):
        return result

    # Return appropriate response
    if isinstance(result, FunctionError):
        status = {
            ErrorCode.NOT_FOUND: 404,
            ErrorCode.VALIDATION_ERROR: 422,
            ErrorCode.UNAUTHORIZED: 401,
            ErrorCode.FORBIDDEN: 403,
            ErrorCode.BAD_REQUEST: 400,
            ErrorCode.INTERNAL_ERROR: 500,
            ErrorCode.NOT_IMPLEMENTED: 501,
        }.get(result.code, 400)
        return result.to_response(status=status)

    # RPC path — build response with server-driven invalidation
    view_class = get_function(fn_name)
    response_data = {"result": result.data}
    invalidate_contexts = _resolve_invalidation(view_class, input_data)
    merges = _resolve_merges(view_class, input_data, result.data)

    if invalidate_contexts:
        response_data["invalidate"] = invalidate_contexts
    if merges:
        response_data["merge"] = merges

    response = JsonResponse(response_data)
    response["Cache-Control"] = "no-store"

    if invalidate_contexts:
        response["X-Mizan-Invalidate"] = _format_invalidate_header(invalidate_contexts)
        _purge_cache_for_invalidation(invalidate_contexts, request)

    return response


def execute_context(
    request: HttpRequest,
    context_name: str,
    params: dict[str, str],
) -> FunctionResult | FunctionError:
    """
    Execute all functions in a named context with merged params.

    Each function receives only the params it declares in its Input schema.
    If any function fails (auth, validation, execution), the entire request fails.

    Args:
        request: The Django HttpRequest
        context_name: Name of the context (e.g., 'user', 'global')
        params: Query parameters (strings — Pydantic coerces types)

    Returns:
        FunctionResult with bundled data, or FunctionError
    """
    groups = get_context_groups()
    fn_names = groups.get(context_name)
    if not fn_names:
        return FunctionError(
            code=ErrorCode.NOT_FOUND,
            message=f"Context '{context_name}' not found",
        )

    results = {}
    for fn_name in fn_names:
        view_class = get_function(fn_name)
        if view_class is None:
            continue

        # Filter params to only those in this function's Input schema
        input_cls = getattr(view_class, "Input", None)
        if input_cls and input_cls is not BaseModel and input_cls.model_fields:
            fn_params = {
                k: v for k, v in params.items()
                if k in input_cls.model_fields
            }
        else:
            fn_params = None

        result = execute_function(request, fn_name, fn_params)
        if isinstance(result, FunctionError):
            return result
        results[fn_name] = result.data

    return FunctionResult(data=results)


def _jwt_auth_only(view_func):
    """
    Decorator that handles token auth for GET endpoints (no CSRF needed for GET).
    Checks MWT first, then legacy JWT.
    """
    @wraps(view_func)
    def wrapper(request: HttpRequest, *args, **kwargs):
        # MWT takes priority
        if _has_mwt_header(request):
            if _try_mwt_auth(request):
                return view_func(request, *args, **kwargs)
            return FunctionError(
                code=ErrorCode.UNAUTHORIZED,
                message="Invalid or expired MWT",
            ).to_response(status=401)

        # Legacy JWT fallback
        if _has_jwt_header(request):
            if _try_jwt_auth(request):
                return view_func(request, *args, **kwargs)
            return FunctionError(
                code=ErrorCode.UNAUTHORIZED,
                message="Invalid or expired JWT token",
            ).to_response(status=401)

        # No token — session auth (no CSRF needed for GET)
        return view_func(request, *args, **kwargs)

    return wrapper


@_jwt_auth_only
def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
    """
    Fetch all functions in a named context in a single bundled GET request.

    Endpoint: GET /api/mizan/ctx/<context_name>/?param1=val1&param2=val2

    Response: raw bundled data, CDN-cacheable.
        {
            "user_profile": { ... },
            "user_orders": [ ... ]
        }

    Headers:
        Cache-Control: public, max-age=0, s-maxage=31536000
    """
    if request.method != "GET":
        return FunctionError(
            code=ErrorCode.BAD_REQUEST,
            message="Only GET method allowed",
        ).to_response(status=405)

    params = request.GET.dict()

    # Resolve effective rev and cache policy across all functions in this context
    _cache_log = logging.getLogger("mizan.cache")
    groups = get_context_groups()
    fn_names = groups.get(context_name, [])
    effective_rev = 0
    effective_cache: int | bool = True  # True=forever, False=no-store, int=TTL
    for fn_name in fn_names:
        fn_cls = get_function(fn_name)
        if fn_cls:
            meta = getattr(fn_cls, "_meta", {})
            fn_rev = meta.get("rev", 0)
            effective_rev = max(effective_rev, fn_rev)
            fn_cache = meta.get("cache", True)
            if fn_cache is False:
                effective_cache = False
                break
            elif isinstance(fn_cache, int):
                if effective_cache is True:
                    effective_cache = fn_cache
                else:
                    effective_cache = min(effective_cache, fn_cache)

    # Origin-side cache lookup (skip if cache=False)
    cache_backend = get_cache()
    cache_settings = get_settings()
    user_id = None
    if hasattr(request, "user") and hasattr(request.user, "pk") and request.user.pk:
        user_id = str(request.user.pk)
    use_cache = (
        cache_backend is not None
        and cache_settings.cache_secret
        and effective_cache is not False
    )
    if use_cache:
        try:
            cached = cache_get(
                cache_settings.cache_secret, cache_backend, context_name, params,
                user_id=user_id, rev=effective_rev,
            )
            if cached is not None:
                response = HttpResponse(cached, content_type="application/json")
                response["Cache-Control"] = "no-store"
                response["X-Mizan-Cache"] = "HIT"
                return response
        except Exception:
            _cache_log.warning("Cache lookup failed, falling through", exc_info=True)

    result = execute_context(request, context_name, params)

    if isinstance(result, FunctionError):
        status = {
            ErrorCode.NOT_FOUND: 404,
            ErrorCode.VALIDATION_ERROR: 422,
            ErrorCode.UNAUTHORIZED: 401,
            ErrorCode.FORBIDDEN: 403,
            ErrorCode.BAD_REQUEST: 400,
            ErrorCode.INTERNAL_ERROR: 500,
            ErrorCode.NOT_IMPLEMENTED: 501,
        }.get(result.code, 400)
        error_response = result.to_response(status=status)
        error_response["Cache-Control"] = "no-store"
        return error_response

    # Deterministic JSON (sorted keys) for consistent cache keys
    response = JsonResponse(result.data, json_dumps_params={"sort_keys": True})

    # Mizan's protocol layers handle caching (origin Redis, Edge Worker).
    # The browser and non-Mizan intermediaries must not cache.
    response["Cache-Control"] = "no-store"

    # Store in origin-side cache (skip if cache=False)
    if use_cache:
        try:
            cache_put(
                cache_settings.cache_secret, cache_backend, context_name, params,
                response.content, user_id=user_id, rev=effective_rev,
            )
            response["X-Mizan-Cache"] = "MISS"
        except Exception:
            _cache_log.warning("Cache store failed", exc_info=True)

    return response
