"""
Server-driven invalidation + merge resolution — the adapter-agnostic core.

This is the canonical implementation (formerly housed in the Django executor).
Every adapter calls `resolve_invalidation` / `resolve_merges` / `format_invalidate_header`
so the wire shape is identical across backends.

Invalidation entries take one of two shapes:
  - a bare context/function name string         → broad invalidation
  - {"context": name, "params": {...}}          → scoped invalidation
Function-level `affects=` resolves to the function NAME as the key (v1 refetches
the whole context anyway).
"""

from __future__ import annotations

from typing import Any
from urllib.parse import quote

from pydantic import BaseModel

from mizan_core.registry import get_context_groups, get_function
from mizan_core.type_utils import types_match_for_merge


__all__ = ["resolve_invalidation", "resolve_merges", "format_invalidate_header"]


def _resolve_affects_target(target_name: str) -> tuple[str, str, str | None]:
    """Classify an affects target → ("context", name, None) | ("function", name, ctx)."""
    groups = get_context_groups()
    if target_name in groups:
        return ("context", target_name, None)
    for ctx_name, fn_names in groups.items():
        if target_name in fn_names:
            return ("function", target_name, ctx_name)
    # Unknown — treat as a context name (non-context fn, or not-yet-registered).
    return ("context", target_name, None)


def _context_param_names(context_name: str) -> set[str]:
    """Union of Input field names across the functions in a context."""
    param_names: set[str] = set()
    for fn_name in get_context_groups().get(context_name, []):
        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:
    """Three-tier auto-scoping over `@client(affects=...)`. None if nothing to invalidate.

    Tier 1: arg-name matching against the context's params → scoped entry.
    Tier 2: auth inference — Edge-side, not handled here.
    Tier 3: broad fallback — bare name.
    """
    if view_class is None:
        return None
    affects = getattr(view_class, "_meta", {}).get("affects")
    if not affects:
        return None

    result: list[str | dict[str, Any]] = []
    seen: set[str] = set()
    for target in affects:
        if target["type"] == "context":
            target_name = target["name"]
        elif target["type"] == "function" and target.get("context"):
            target_name = target["name"]
        else:
            continue

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

        resolved = _resolve_affects_target(target_name)
        ctx_for_params = resolved[2] if resolved[0] == "function" else resolved[1]

        if input_data and ctx_for_params:
            context_params = _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

        result.append(target_name)

    return result or None


def _resolve_merge_slot(context_name: str, mutation_output: Any) -> str | None:
    """The unique function-name slot in a context whose return type matches the mutation output."""
    if mutation_output is None:
        return None
    matches: list[str] = []
    for fn_name in get_context_groups().get(context_name, []):
        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 types_match_for_merge(fn_output, mutation_output):
            matches.append(fn_name)
    return matches[0] if len(matches) == 1 else None


def resolve_merges(
    view_class: type | None,
    input_data: dict[str, Any] | None,
    result_data: Any,
) -> list[dict[str, Any]] | None:
    """Build the `merge` list from `@client(merge=...)`. None when no targets resolve.

    Each entry is `{context, slot, value, params?}`; `slot` is the context-function
    whose return type matches the mutation output (server-side type-checked routing,
    no client shape inference). Ambiguous/unmatched targets are dropped.
    """
    if view_class is None:
        return None
    targets = getattr(view_class, "_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)
        if slot is None:
            continue
        entry: dict[str, Any] = {"context": ctx_name, "slot": slot, "value": result_data}
        if input_data:
            matched = {k: v for k, v in input_data.items() if k in _context_param_names(ctx_name)}
            if matched:
                entry["params"] = matched
        out.append(entry)
    return out or None


def format_invalidate_header(invalidate: list[str | dict[str, Any]]) -> str:
    """Serialize invalidation targets to the `X-Mizan-Invalidate` header value.

    Comma-separated contexts; semicolon-separated URL-encoded params per context.
        ["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"
    """
    parts: list[str] = []
    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)
