from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Any

import yaml

DEFAULT_EXTERNAL_TOOLS_PATH = "config/external_tools.yaml"


class CapabilityDeniedError(Exception):
    """Capability 门禁拒绝 — HTTP/MCP 映射为 403。"""

    def __init__(self, reason: str) -> None:
        self.reason = reason
        super().__init__(reason)


@dataclass(frozen=True)
class CapabilityMeta:
    capability_id: str
    label: str
    memory_space: str
    client_allowlist: frozenset[str]
    functions: frozenset[str]
    scopes: frozenset[str]
    auto_learn_default: str
    chat_enabled: bool = False

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> CapabilityMeta:
        return cls(
            capability_id=str(data.get("capability_id") or "").strip(),
            label=str(data.get("label") or "").strip(),
            memory_space=str(data.get("memory_space") or "").strip(),
            client_allowlist=frozenset(str(c) for c in (data.get("client_allowlist") or [])),
            functions=frozenset(str(f) for f in (data.get("functions") or [])),
            scopes=frozenset(str(s) for s in (data.get("scopes") or [])),
            auto_learn_default=str(data.get("auto_learn_default") or "off").strip(),
            chat_enabled=bool(data.get("chat_enabled", False)),
        )


def _registry_path(config: dict[str, Any] | None) -> Path:
    cfg = config or {}
    ext = cfg.get("external_tools")
    if isinstance(ext, dict):
        raw = ext.get("registry") or ext.get("path") or DEFAULT_EXTERNAL_TOOLS_PATH
    else:
        raw = DEFAULT_EXTERNAL_TOOLS_PATH
    return Path(str(raw))


def load_capabilities(config: dict[str, Any] | None = None) -> list[dict]:
    """从 config.external_tools.capabilities 或 config/external_tools.yaml 加载注册表。"""
    cfg = config or {}
    ext = cfg.get("external_tools")
    if isinstance(ext, dict) and isinstance(ext.get("capabilities"), list):
        return [c for c in ext["capabilities"] if isinstance(c, dict)]

    path = _registry_path(config)
    if not path.is_file():
        return []
    with path.open(encoding="utf-8") as f:
        data = yaml.safe_load(f) or {}
    caps = data.get("capabilities") if isinstance(data, dict) else []
    if not isinstance(caps, list):
        return []
    return [c for c in caps if isinstance(c, dict)]


def save_capabilities(
    capabilities: list[dict[str, Any]],
    *,
    config: dict[str, Any] | None = None,
) -> list[dict]:
    caps = [c for c in capabilities if isinstance(c, dict)]
    cfg = config if config is not None else {}
    ext = cfg.setdefault("external_tools", {})
    if not isinstance(ext, dict):
        raise ValueError("external_tools config must be a mapping")
    ext["capabilities"] = caps
    path = _registry_path(cfg)
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("w", encoding="utf-8") as f:
        yaml.safe_dump({"capabilities": caps}, f, allow_unicode=True, sort_keys=False)
    return caps


def _index_capabilities(capabilities: list[dict]) -> dict[str, CapabilityMeta]:
    out: dict[str, CapabilityMeta] = {}
    for raw in capabilities:
        meta = CapabilityMeta.from_dict(raw)
        if meta.capability_id:
            out[meta.capability_id] = meta
    return out


def assert_capability_allowed(
    capability_id: str,
    client: str,
    operation: str,
    *,
    function: str = "",
    capabilities: list[dict] | None = None,
    config: dict[str, Any] | None = None,
) -> CapabilityMeta:
    cap_id = str(capability_id or "").strip()
    client_name = str(client or "").strip()
    op = str(operation or "").strip().lower()
    fn = str(function or "").strip()

    if not cap_id:
        raise CapabilityDeniedError("capability_id required")
    if not client_name:
        raise CapabilityDeniedError("caller.client required")

    caps_raw = capabilities if capabilities is not None else load_capabilities(config)
    meta = _index_capabilities(caps_raw).get(cap_id)
    if meta is None:
        raise CapabilityDeniedError(f"unregistered capability_id: {cap_id}")

    if client_name not in meta.client_allowlist:
        raise CapabilityDeniedError(f"client not allowed: {client_name}")

    if fn and meta.functions and fn not in meta.functions:
        raise CapabilityDeniedError(f"function not allowed: {fn}")

    _ = op  # operation gate reserved for later tasks
    return meta
