"""
The adapter-agnostic dispatch core.

Both `dispatch_call` (mutations/RPC) and `dispatch_context` (bundled reads) run
the full protocol: auth → input validation → execute (`await view.acall`, which
threadpools sync handlers) → serialize → resolve invalidation/merge → orchestrate
origin cache. They return a `DispatchResult` the adapter renders to its native
response. Errors raise `MizanError` (the adapter catches at its boundary).

The adapter owns native request parsing (multipart/JSON) and native response
construction; it hands the core a `DispatchRequest` carrying only what the core
reads, and renders what the core returns.
"""

from __future__ import annotations

import json
from dataclasses import dataclass, field
from typing import Any, Literal

from pydantic import BaseModel, ValidationError
from pydantic_core import to_jsonable_python

from mizan_core.authguard import enforce_auth
from mizan_core.cache.backend import CacheBackend
from mizan_core.cache.keys import CONTEXT_KEY_PREFIX, derive_cache_key
from mizan_core.errors import (
    BadRequest,
    InternalError,
    MizanError,
    NotFound,
    NotImplementedYet,
    ValidationFailed,
)
from mizan_core.identity import Identity, user_id_of
from mizan_core.invalidation import (
    format_invalidate_header,
    resolve_invalidation,
    resolve_merges,
)
from mizan_core.registry import get_context_groups, get_function


# ─── Request / result ───────────────────────────────────────────────────────


@dataclass
class DispatchRequest:
    """What the dispatch core reads. The adapter resolves `identity` (session OR
    token) and parses `args`/`files`; `native_request` is an opaque passthrough
    handed to `view_class(...)` and to `auth=callable`."""

    identity: Identity | None = None
    args: dict[str, Any] | None = None
    files: dict[str, list[Any]] | None = None
    native_request: Any = None


@dataclass
class DispatchResult:
    kind: Literal["rpc", "view", "context"] = "rpc"
    native_response: Any | None = None          # view-path: the handler's own response
    data: Any | None = None                     # rpc: serialized payload; context: bundle dict
    body_bytes: bytes | None = None             # context: canonical JSON to send/cache
    cache_status: str | None = None             # context: "HIT" | "MISS" | None
    invalidate: list[Any] | None = None
    merge: list[dict[str, Any]] | None = None
    invalidate_header: str | None = None


# ─── Cache orchestration ────────────────────────────────────────────────────


class CacheOrchestrator:
    """Origin-side cache, backend + secret injected by the adapter (config seam)."""

    def __init__(self, backend: CacheBackend | None, secret: str | None):
        self.backend = backend
        self.secret = secret

    @property
    def enabled(self) -> bool:
        return self.backend is not None and bool(self.secret)

    def get(self, context: str, params: dict[str, Any], user_id: str | None, rev: int) -> bytes | None:
        if not self.enabled:
            return None
        return self.backend.get(derive_cache_key(self.secret, context, params, user_id, rev))

    def put(self, context: str, params: dict[str, Any], value: bytes, user_id: str | None, rev: int) -> None:
        if not self.enabled:
            return
        self.backend.set(derive_cache_key(self.secret, context, params, user_id, rev), value)

    def purge(self, invalidate: list[Any], user_id: str | None) -> None:
        if not self.enabled:
            return
        for entry in invalidate:
            if isinstance(entry, str):
                self.backend.delete_by_prefix(f"{CONTEXT_KEY_PREFIX}{entry}:")
            elif isinstance(entry, dict):
                ctx = entry["context"]
                params = entry.get("params")
                if params:
                    self.backend.delete(derive_cache_key(self.secret, ctx, params, user_id, 0))
                else:
                    self.backend.delete_by_prefix(f"{CONTEXT_KEY_PREFIX}{ctx}:")


# ─── Shared dispatch helpers ────────────────────────────────────────────────


def _resolve_function(fn_name: str) -> Any:
    view_class = get_function(fn_name)
    if view_class is None:
        raise NotFound("Function not found")
    if getattr(view_class, "_meta", {}).get("private"):
        from mizan_core.errors import Forbidden
        raise Forbidden("Function is not client-callable")
    return view_class


def _validate_input(input_cls: Any, input_data: Any) -> BaseModel | None:
    """Validate `input_data` against the function's Input model."""
    if input_cls in (None, BaseModel) or not getattr(input_cls, "model_fields", None):
        return None
    required = [name for name, f in input_cls.model_fields.items() if f.is_required()]
    if not input_data:
        if required:
            raise ValidationFailed(
                "Input validation failed",
                details={"fields": {name: ["Field required"] for name in required}},
            )
        return input_cls()
    if not isinstance(input_data, dict):
        raise BadRequest(f"Input must be an object, got {type(input_data).__name__}")
    try:
        return input_cls(**input_data)
    except ValidationError as e:
        raise ValidationFailed("Input validation failed", details={"errors": e.errors()}) from e


def _serialize(result: Any) -> Any:
    return to_jsonable_python(result)


async def _run(view: Any, validated: Any) -> Any:
    try:
        return await view.acall(validated)
    except NotImplementedError as e:
        raise NotImplementedYet(str(e) or "Not implemented") from e
    except MizanError:
        raise
    except Exception as e:
        raise InternalError(str(e)) from e


def _canonical_bytes(data: Any) -> bytes:
    return json.dumps(data, sort_keys=True).encode("utf-8")


# ─── Entry points ───────────────────────────────────────────────────────────


async def dispatch_call(req: DispatchRequest, fn_name: str, cache: CacheOrchestrator) -> DispatchResult:
    """Mutation / RPC dispatch."""
    view_class = _resolve_function(fn_name)
    meta = getattr(view_class, "_meta", {})
    enforce_auth(req.identity, meta.get("auth"), req.native_request)

    view = view_class(req.native_request)
    validated = _validate_input(view.Input, req.args)
    result = await _run(view, validated)

    invalidate = resolve_invalidation(view_class, req.args)
    header = format_invalidate_header(invalidate) if invalidate else None
    if invalidate:
        cache.purge(invalidate, user_id_of(req.identity))

    if meta.get("view_path"):
        # Handler returned its own native response; carry it through + the header.
        return DispatchResult(kind="view", native_response=result,
                              invalidate=invalidate, invalidate_header=header)

    serialized = _serialize(result)
    return DispatchResult(
        kind="rpc",
        data=serialized,
        invalidate=invalidate,
        merge=resolve_merges(view_class, req.args, serialized),
        invalidate_header=header,
    )


def _effective_policy(fn_names: list[str]) -> tuple[int, int | bool]:
    """(effective_rev, effective_cache) across a context's functions."""
    rev = 0
    cache_policy: 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 is None:
            continue
        m = getattr(fn_cls, "_meta", {})
        rev = max(rev, m.get("rev", 0))
        fn_cache = m.get("cache", True)
        if fn_cache is False:
            return rev, False
        if isinstance(fn_cache, int):
            cache_policy = fn_cache if cache_policy is True else min(cache_policy, fn_cache)
    return rev, cache_policy


async def dispatch_context(req: DispatchRequest, context_name: str, cache: CacheOrchestrator) -> DispatchResult:
    """Bundled context read with origin-cache get/put."""
    groups = get_context_groups()
    fn_names = groups.get(context_name)
    if not fn_names:
        raise NotFound(f"Context '{context_name}' not found")

    params = req.args or {}
    rev, cache_policy = _effective_policy(fn_names)
    user_id = user_id_of(req.identity)
    use_cache = cache.enabled and cache_policy is not False

    if use_cache:
        cached = cache.get(context_name, params, user_id, rev)
        if cached is not None:
            return DispatchResult(kind="context", body_bytes=cached, cache_status="HIT")

    bundle: dict[str, Any] = {}
    for fn_name in fn_names:
        view_class = _resolve_function(fn_name)
        enforce_auth(req.identity, getattr(view_class, "_meta", {}).get("auth"), req.native_request)
        view = view_class(req.native_request)
        validated = _validate_input(view.Input, {k: v for k, v in params.items() if _declares(view.Input, k)})
        bundle[fn_name] = _serialize(await _run(view, validated))

    body = _canonical_bytes(bundle)
    if use_cache:
        cache.put(context_name, params, body, user_id, rev)
    return DispatchResult(kind="context", data=bundle, body_bytes=body,
                          cache_status="MISS" if use_cache else None)


def _declares(input_cls: Any, name: str) -> bool:
    return bool(
        input_cls and input_cls is not BaseModel
        and getattr(input_cls, "model_fields", None)
        and name in input_cls.model_fields
    )
