"""扫描插件目录、校验并产出 OpenAI 工具片段与 HANDLERS。"""

from __future__ import annotations

import importlib.util
import json
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable

from aicd.agent.handlers.registry import tool_handlers
from aicd.config import AppConfig
from aicd.plugins.manifest import PluginManifest, load_manifest_file
from aicd.version import VERSION

Handler = Callable[[Any, dict[str, Any]], Any]


def _parse_version_tuple(s: str) -> tuple[int, ...]:
    parts: list[int] = []
    for seg in re.split(r"[^\d]+", s.strip()):
        if seg.isdigit():
            parts.append(int(seg))
        elif parts:
            break
    return tuple(parts) if parts else (0,)


def version_at_least(current: str, minimum: str | None) -> bool:
    if not minimum:
        return True
    return _parse_version_tuple(current) >= _parse_version_tuple(minimum)


def prefixed_tool_name(plugin_id: str, raw_name: str) -> str:
    sid = re.sub(r"[^a-z0-9_-]", "_", plugin_id.lower())
    sname = re.sub(r"[^a-zA-Z0-9_-]", "_", raw_name.strip())
    return f"plugin_{sid}__{sname}"


def builtin_tool_names() -> frozenset[str]:
    return frozenset(tool_handlers().keys())


def _read_optional_text(root: Path, rel: str | None) -> str:
    if not rel:
        return ""
    p = (root / rel).resolve()
    root_r = root.resolve()
    try:
        p.relative_to(root_r)
    except ValueError:
        return ""
    if not p.is_file():
        return ""
    return p.read_text(encoding="utf-8", errors="replace").strip()


def _normalize_tools_json_items(raw: Any) -> tuple[list[dict[str, Any]], list[str]]:
    errors: list[str] = []
    if raw is None:
        return [], errors
    if isinstance(raw, dict) and "tools" in raw:
        raw = raw["tools"]
    if not isinstance(raw, list):
        errors.append("tools.json must be a JSON array")
        return [], errors
    out: list[dict[str, Any]] = []
    for i, item in enumerate(raw):
        if not isinstance(item, dict):
            errors.append(f"tools[{i}] must be object")
            continue
        fn = item.get("function") if item.get("type") == "function" else item
        if not isinstance(fn, dict):
            errors.append(f"tools[{i}] missing function object")
            continue
        name = fn.get("name")
        desc = fn.get("description") or ""
        params = fn.get("parameters")
        if not isinstance(name, str) or not name.strip():
            errors.append(f"tools[{i}] missing name")
            continue
        if params is None:
            params = {"type": "object", "properties": {}}
        if not isinstance(params, dict):
            errors.append(f"tools[{i}] parameters must be object")
            continue
        out.append(
            {
                "type": "function",
                "function": {
                    "name": name.strip(),
                    "description": str(desc).strip(),
                    "parameters": params,
                },
            }
        )
    return out, errors


def _openai_fragment_from_normalized(
    prefixed_name: str, fn: dict[str, Any]
) -> dict[str, Any]:
    return {
        "type": "function",
        "function": {
            "name": prefixed_name,
            "description": str(fn.get("description") or ""),
            "parameters": fn.get("parameters") or {"type": "object", "properties": {}},
        },
    }


def _load_dynamic_handlers(
    path: Path,
    module_key: str,
) -> tuple[dict[str, Handler], list[str]]:
    errors: list[str] = []
    try:
        src = path.read_text(encoding="utf-8")
        compile(src, str(path), "exec")
    except (OSError, SyntaxError) as e:
        errors.append(f"handlers.py compile error: {e}")
        return {}, errors
    key = f"aicd_plugin_dyn_{module_key}"
    sys.modules.pop(key, None)
    spec = importlib.util.spec_from_file_location(key, path)
    if spec is None or spec.loader is None:
        errors.append("handlers.py importlib spec failed")
        return {}, errors
    mod = importlib.util.module_from_spec(spec)
    try:
        spec.loader.exec_module(mod)
    except Exception as e:  # noqa: BLE001
        errors.append(f"handlers.py import error: {e!r}")
        return {}, errors
    raw_handlers = getattr(mod, "HANDLERS", None)
    if not isinstance(raw_handlers, dict):
        errors.append("handlers.py must define HANDLERS dict")
        return {}, {}
    out: dict[str, Handler] = {}
    for k, v in raw_handlers.items():
        kn = str(k)
        if not callable(v):
            errors.append(f"HANDLERS[{kn!r}] is not callable")
            continue
        out[kn] = v
    return out, errors


def _stub_handler(plugin_id: str, tool: str) -> Handler:
    def _h(router: Any, args: dict[str, Any]) -> dict[str, Any]:
        _ = router, args
        return {
            "ok": False,
            "error": (
                f"Plugin {plugin_id!r} tool {tool!r} has no handler. "
                "Enable plugins.allow_python_handlers and implement HANDLERS in handlers.py, "
                "or remove this tool from tools.json."
            ),
        }

    return _h


@dataclass
class PluginLoadOutcome:
    plugin_id: str
    ok: bool
    errors: list[str] = field(default_factory=list)
    warnings: list[str] = field(default_factory=list)
    prompt_parts: list[str] = field(default_factory=list)
    openai_fragments: list[dict[str, Any]] = field(default_factory=list)
    handlers: dict[str, Handler] = field(default_factory=dict)
    manifest: PluginManifest | None = None


def load_one_plugin(
    plugin_root: Path,
    *,
    builtins: frozenset[str],
    app: AppConfig,
) -> PluginLoadOutcome:
    pid = plugin_root.name
    out = PluginLoadOutcome(plugin_id=pid, ok=False)
    mf_path = plugin_root / "plugin.yaml"
    if not mf_path.is_file():
        out.errors.append("missing plugin.yaml")
        return out
    try:
        man = load_manifest_file(mf_path)
    except Exception as e:  # noqa: BLE001
        out.errors.append(f"plugin.yaml: {e!r}")
        return out
    if man.id != pid:
        out.errors.append(f"manifest id {man.id!r} != directory name {pid!r}")
        return out
    out.manifest = man
    if not man.enabled:
        out.ok = True
        out.warnings.append("disabled in manifest")
        return out
    if not version_at_least(VERSION, man.min_aicd_version):
        out.errors.append(
            f"min_aicd_version {man.min_aicd_version} not satisfied (running {VERSION})"
        )
        return out

    ents = man.entries
    prompt_path = ents.get("prompt_file") or ents.get("promptFile")
    skill_path = ents.get("skill_file") or ents.get("skillFile")
    tools_path = ents.get("tools_file") or ents.get("toolsFile") or "tools.json"
    handlers_name = ents.get("handlers_py") or ents.get("handlersPy") or "handlers.py"

    ptext = _read_optional_text(plugin_root, prompt_path)
    stext = _read_optional_text(plugin_root, skill_path)
    parts: list[str] = []
    if ptext:
        parts.append(f"### Plugin `{pid}` (prompt)\n\n{ptext}")
    if stext:
        parts.append(f"### Plugin `{pid}` (skill)\n\n{stext}")
    out.prompt_parts = parts

    tools_file = plugin_root / tools_path
    raw_tools: list[dict[str, Any]] = []
    tool_errors: list[str] = []
    if tools_file.is_file():
        try:
            traw = json.loads(tools_file.read_text(encoding="utf-8"))
            raw_tools, tool_errors = _normalize_tools_json_items(traw)
        except (OSError, json.JSONDecodeError) as e:
            tool_errors.append(f"tools.json: {e!r}")
    out.errors.extend(tool_errors)

    handlers_py = plugin_root / handlers_name
    dyn: dict[str, Handler] = {}
    if handlers_py.is_file():
        if app.plugins.allow_python_handlers:
            dyn, derr = _load_dynamic_handlers(
                handlers_py,
                re.sub(r"[^a-zA-Z0-9_]", "_", pid),
            )
            out.errors.extend(derr)
        else:
            out.warnings.append(
                "handlers.py present but plugins.allow_python_handlers is false; using stub handlers"
            )
    elif raw_tools:
        out.warnings.append("tools.json present but handlers.py missing; using stubs")

    fragments: list[dict[str, Any]] = []
    handler_map: dict[str, Handler] = {}
    used_names: set[str] = set(builtins)

    for item in raw_tools:
        fn = item["function"]
        raw_name = str(fn["name"])
        pfx = prefixed_tool_name(pid, raw_name)
        if pfx in used_names:
            out.errors.append(f"tool name collision after prefix: {pfx}")
            continue
        used_names.add(pfx)
        inner = {
            "description": fn.get("description") or "",
            "parameters": fn.get("parameters")
            or {"type": "object", "properties": {}},
        }
        fragments.append(_openai_fragment_from_normalized(pfx, inner))
        impl = dyn.get(raw_name)
        if impl is not None:
            handler_map[pfx] = impl
        else:
            handler_map[pfx] = _stub_handler(pid, raw_name)

    if out.errors:
        out.openai_fragments = []
        out.handlers = {}
        out.ok = False
        return out

    out.openai_fragments = fragments
    out.handlers = handler_map
    out.ok = True
    return out


# 不参与加载的目录名（大小写不敏感）：备份副本、临时目录等，避免误与正式插件一同加载。
_RESERVED_PLUGIN_DIR_NAMES = frozenset(
    {
        "_template",
        "backup",
        "backups",
    }
)
_BACKUP_LIKE_SUFFIXES = (
    "_backup",
    "-backup",
    ".backup",
    "_bak",
    ".bak",
    "_old",
    "-old",
    "_copy",
    "-copy",
    "_orig",
    "-orig",
    "_prev",
    "-prev",
    "_save",
    "-save",
)


def plugin_dir_name_is_loadable(name: str) -> bool:
    """若为 ``plugins/<name>/`` 且应作为插件候选目录扫描，返回 True。"""
    if not name or not name.strip():
        return False
    if name.startswith("."):
        return False
    low = name.lower().strip()
    if low in _RESERVED_PLUGIN_DIR_NAMES:
        return False
    for suf in _BACKUP_LIKE_SUFFIXES:
        if low.endswith(suf):
            return False
    if low.endswith("~"):
        return False
    if low.startswith("copy of "):
        return False
    if low.startswith("copy_") or low.startswith("copy-"):
        return False
    return True


def iter_plugin_dirs(plugins_root: Path) -> list[Path]:
    if not plugins_root.is_dir():
        return []
    out: list[Path] = []
    for p in sorted(plugins_root.iterdir()):
        if not p.is_dir():
            continue
        if not plugin_dir_name_is_loadable(p.name):
            continue
        out.append(p)
    return out
