# tools/plugin_creation.py - create_plugin tool for evolution-mode self-extension.
from __future__ import annotations

import ast
import os
import re

from langchain_core.tools import tool
from tools._logging import _debug_log, _emit_input_status
from tools.live_view_visuals import emit_live_view_preview
from utils.path_safety import DASHBOARD_PLUGINS_DIR, PLUGINS_DIR

_SLUG_RE = re.compile(r"^[a-z0-9_]+$")
_DASH_SLUG_RE = re.compile(r"^dash_[a-z0-9_]+$")
# Same rule as plugin_manager._plugin_slug — discovery keys prefs/API by this, not by folder name.
_MANIFEST_SLUG_CHARS = re.compile(r"[^a-z0-9_]")


def _slug_from_manifest_name(name: str) -> str:
    return _MANIFEST_SLUG_CHARS.sub("_", name.lower().strip()).strip("_") or "unnamed"


_MAX_FILE_SIZE = 5 * 1024 * 1024  # 5 MB per file
_MAX_FILES_PER_PLUGIN = 100

# Loader blocks these top-level imports when network=false (plugin_manager); keep in sync.
_NETWORK_TOPLEVEL = (
    "requests",
    "httpx",
    "urllib",
    "urllib3",
    "socket",
    "aiohttp",
    "http",
    "huggingface_hub",
    "transformers",
    "openai",
    "websockets",
    "grpc",
    "tls_client",
    "ssl",
)
_NET_IMPORT_RE = re.compile(
    r"^\s*(?:import|from)\s+(" + "|".join(re.escape(m) for m in _NETWORK_TOPLEVEL) + r")\b",
    re.MULTILINE,
)

_MANIFEST_TEMPLATE = """\
[plugin]
name = "{name}"
version = "{version}"
author = "{author}"
description = "{description}"

[permissions]
network = false
shell = false
filesystem = "none"
"""


_DASHBOARD_MANIFEST_TEMPLATE = """\
[plugin]
name = "{name}"
version = "{version}"
author = "{author}"
description = "{description}"
kind = "dashboard"

[permissions]
network = false
shell = false
filesystem = "none"
"""


def _normalize_dashboard_slug(raw: str) -> tuple[str, str | None]:
    """Return (slug, original_if_changed) or ("", None) if invalid."""
    s = (raw or "").strip().lower().replace("-", "_")
    if not s:
        return "", None
    normalized_from: str | None = None
    if raw.strip() != s:
        normalized_from = raw.strip()
    if not s.startswith("dash_"):
        s = "dash_" + s.lstrip("_")
        if normalized_from is None:
            normalized_from = (raw or "").strip()
    if not _DASH_SLUG_RE.match(s):
        return "", None
    if s == "dash_":
        return "", None
    return s, normalized_from


def _default_dashboard_plugin_py(name: str) -> str:
    safe = name.replace("\\", "\\\\").replace('"', '\\"')
    return (
        f'"""Auto-generated dashboard plugin: {safe}"""\n\n\n'
        "def register(ctx):\n"
        f'    ctx.log("{safe} dashboard plugin loaded")\n'
        '    ctx.register_dashboard_tile({"title": "Status", "path": "/status"})\n'
        "\n"
        "    def route_handler(req):\n"
        '        path = str(req.get("path") or "/").rstrip("/") or "/"\n'
        '        if path == "/status":\n'
        "            import json\n"
        "\n"
        '            body = json.dumps({"ok": True, "message": "dashboard plugin"})\n'
        "            return {\n"
        '                "status": 200,\n'
        '                "headers": {"Content-Type": "application/json"},\n'
        '                "body": body,\n'
        "            }\n"
        '        return {"status": 404, "headers": {"Content-Type": "text/plain"}, "body": "not found"}\n'
        "\n"
        "    ctx.register_route_handler(route_handler)\n"
    )



@tool
def create_plugin(
    slug: str,
    name: str,
    description: str = "",
    author: str = "Mugi (auto-generated)",
    version: str = "0.1.0",
    plugin_py_content: str = "",
    extra_files: str = "",
    overwrite: bool = False,
) -> str:
    """Create a new plugin under ~/.mugi/plugins/<slug>/.

    Generates plugin.toml (manifest) and plugin.py (entry point).
    Only available in evolution mode.

    Args:
        slug: Plugin directory name. Must be lowercase alphanumeric + underscore (e.g. 'my_tool').
        name: Human-readable plugin name (e.g. 'My Tool').
        description: Short description of what the plugin does.
        author: Author string.
        version: Semantic version (default '0.1.0').
        plugin_py_content: Python code for plugin.py. If empty, a minimal template is generated.
        extra_files: Optional JSON string mapping relative filename to content (e.g. '{"utils.py": "..."}').
                     Filenames must be relative (no / prefix, no ..), max 100 files total.
        overwrite: If True, overwrite an existing plugin directory. Default False.
    """
    _emit_input_status("Creating plugin…")
    try:
        slug = (slug or "").strip()
        if not slug:
            return "Error: 'slug' is required."
        normalized_from: str | None = None
        if not _SLUG_RE.match(slug):
            candidate = slug.lower().replace("-", "_")
            if _SLUG_RE.match(candidate):
                normalized_from = slug
                slug = candidate
            else:
                return f"Error: slug must be lowercase alphanumeric + underscore only. Got: {slug!r}"
        if ".." in slug or "/" in slug or os.sep in slug:
            return f"Error: slug contains invalid characters: {slug!r}"

        plugin_dir = os.path.join(PLUGINS_DIR, slug)
        resolved_dir = os.path.normpath(os.path.abspath(os.path.expanduser(plugin_dir)))
        resolved_plugins = os.path.normpath(os.path.abspath(os.path.expanduser(PLUGINS_DIR)))
        if not (resolved_dir == resolved_plugins or resolved_dir.startswith(resolved_plugins + os.sep)):
            return f"Error: resolved plugin path escapes plugins directory: {resolved_dir}"

        if os.path.exists(resolved_dir):
            if not overwrite:
                return (
                    f"Plugin directory already exists: {resolved_dir}\n"
                    "Pass overwrite=True to replace it, or choose a different slug."
                )
            _debug_log(f"[create_plugin] Overwriting existing plugin at {resolved_dir}", "WARN")

        name = (name or slug).strip()
        if not name:
            return "Error: 'name' is required."

        extra = {}
        if extra_files and extra_files.strip():
            import json
            try:
                extra = json.loads(extra_files)
            except (json.JSONDecodeError, TypeError) as e:
                return f"Error: extra_files must be valid JSON: {e}"
            if not isinstance(extra, dict):
                return "Error: extra_files must be a JSON object mapping filename to content."

        total_files = 2 + len(extra)  # plugin.toml + plugin.py + extras
        if total_files > _MAX_FILES_PER_PLUGIN:
            return f"Error: too many files ({total_files}). Maximum is {_MAX_FILES_PER_PLUGIN}."

        for fname, content in extra.items():
            fname_str = str(fname).strip()
            if not fname_str or fname_str.startswith("/") or ".." in fname_str:
                return f"Error: invalid extra filename: {fname_str!r}"
            if os.sep != "/" and os.sep in fname_str:
                return f"Error: invalid extra filename: {fname_str!r}"
            if not isinstance(content, str):
                return f"Error: content for {fname_str!r} must be a string."
            if len(content.encode("utf-8")) > _MAX_FILE_SIZE:
                return f"Error: {fname_str!r} exceeds max file size ({_MAX_FILE_SIZE // (1024 * 1024)} MB)."

        manifest_content = _MANIFEST_TEMPLATE.format(
            name=name.replace('"', '\\"'),
            version=(version or "0.1.0").strip(),
            author=(author or "").strip().replace('"', '\\"'),
            description=(description or "").strip().replace('"', '\\"'),
        )

        if not plugin_py_content or not plugin_py_content.strip():
            plugin_py_content = (
                '"""Auto-generated plugin: ' + name + '"""\n'
                "\n"
                "\n"
                "def register(ctx):\n"
                '    ctx.log("' + name.replace('"', '\\"') + ' plugin loaded")\n'
            )

        if len(plugin_py_content.encode("utf-8")) > _MAX_FILE_SIZE:
            return f"Error: plugin.py exceeds max file size ({_MAX_FILE_SIZE // (1024 * 1024)} MB)."

        os.makedirs(resolved_dir, exist_ok=True)

        toml_path = os.path.join(resolved_dir, "plugin.toml")
        with open(toml_path, "w", encoding="utf-8") as f:
            f.write(manifest_content)

        py_path = os.path.join(resolved_dir, "plugin.py")
        with open(py_path, "w", encoding="utf-8") as f:
            f.write(plugin_py_content)

        for fname, content in extra.items():
            fname_str = str(fname).strip()
            fpath = os.path.normpath(os.path.join(resolved_dir, fname_str))
            if not fpath.startswith(resolved_dir + os.sep) and fpath != resolved_dir:
                return f"Error: extra file path escapes plugin directory: {fname_str!r}"
            fdir = os.path.dirname(fpath)
            if fdir and not os.path.exists(fdir):
                os.makedirs(fdir, exist_ok=True)
            with open(fpath, "w", encoding="utf-8") as f:
                f.write(content)

        emit_live_view_preview(
            f"Plugin created: {name} ({slug})",
            tool_name="create_plugin",
        )

        files_written = ["plugin.toml", "plugin.py"] + list(extra.keys())
        normalized_suffix = ""
        if normalized_from is not None and normalized_from != slug:
            normalized_suffix = f"Slug normalized: {normalized_from!r} -> {slug!r}\n\n"
        return (
            f"Plugin created at {resolved_dir}\n"
            f"Files: {', '.join(files_written)}\n\n"
            f"{normalized_suffix}"
            "The plugin must be approved by the user in Settings > Plugins before it runs. "
            "Use Settings > Plugins > Reload to discover the new plugin."
        )
    except Exception as e:
        _debug_log(f"[ERROR create_plugin] {e}", "ERROR")
        return f"create_plugin failed: {str(e)}"


@tool
def create_dashboard_plugin(
    slug: str,
    name: str,
    description: str = "",
    author: str = "Mugi (auto-generated)",
    version: str = "0.1.0",
    plugin_py_content: str = "",
    extra_files: str = "",
    overwrite: bool = False,
) -> str:
    """Create a dashboard plugin under ~/.mugi/dashboard_plugins/<slug>/.

    Slug must match ``dash_[a-z0-9_]+``. The manifest sets ``kind = "dashboard"``.
    Only available in evolution mode.

    Args:
        slug: Directory name, e.g. ``dash_my_widget`` (``dash_`` prefix required).
        name: Human-readable name.
        description: Short description.
        author: Author string.
        version: Semantic version (default ``0.1.0``).
        plugin_py_content: Python for plugin.py; if empty, a minimal tile + route template is used.
        extra_files: Optional JSON object mapping relative filename to string content.
        overwrite: If True, replace an existing directory.
    """
    _emit_input_status("Creating dashboard plugin…")
    try:
        slug, normalized_from = _normalize_dashboard_slug(slug)
        if not slug:
            return (
                "Error: slug must match dash_[a-z0-9_]+ after normalization "
                "(e.g. dash_weather or weather → dash_weather)."
            )

        plugin_dir = os.path.join(DASHBOARD_PLUGINS_DIR, slug)
        resolved_dir = os.path.normpath(os.path.abspath(os.path.expanduser(plugin_dir)))
        resolved_root = os.path.normpath(os.path.abspath(os.path.expanduser(DASHBOARD_PLUGINS_DIR)))
        if not (resolved_dir == resolved_root or resolved_dir.startswith(resolved_root + os.sep)):
            return f"Error: resolved path escapes dashboard plugins directory: {resolved_dir}"

        if os.path.exists(resolved_dir):
            if not overwrite:
                return (
                    f"Dashboard plugin directory already exists: {resolved_dir}\n"
                    "Pass overwrite=True to replace it, or choose a different slug."
                )
            _debug_log(f"[create_dashboard_plugin] Overwriting existing plugin at {resolved_dir}", "WARN")

        name = (name or slug).strip()
        if not name:
            return "Error: 'name' is required."

        extra = {}
        if extra_files and extra_files.strip():
            import json

            try:
                extra = json.loads(extra_files)
            except (json.JSONDecodeError, TypeError) as e:
                return f"Error: extra_files must be valid JSON: {e}"
            if not isinstance(extra, dict):
                return "Error: extra_files must be a JSON object mapping filename to content."

        total_files = 2 + len(extra)
        if total_files > _MAX_FILES_PER_PLUGIN:
            return f"Error: too many files ({total_files}). Maximum is {_MAX_FILES_PER_PLUGIN}."

        for fname, content in extra.items():
            fname_str = str(fname).strip()
            if not fname_str or fname_str.startswith("/") or ".." in fname_str:
                return f"Error: invalid extra filename: {fname_str!r}"
            if os.sep != "/" and os.sep in fname_str:
                return f"Error: invalid extra filename: {fname_str!r}"
            if not isinstance(content, str):
                return f"Error: content for {fname_str!r} must be a string."
            if len(content.encode("utf-8")) > _MAX_FILE_SIZE:
                return f"Error: {fname_str!r} exceeds max file size ({_MAX_FILE_SIZE // (1024 * 1024)} MB)."

        manifest_content = _DASHBOARD_MANIFEST_TEMPLATE.format(
            name=name.replace('"', '\\"'),
            version=(version or "0.1.0").strip(),
            author=(author or "").strip().replace('"', '\\"'),
            description=(description or "").strip().replace('"', '\\"'),
        )

        if not plugin_py_content or not plugin_py_content.strip():
            plugin_py_content = _default_dashboard_plugin_py(name)

        if len(plugin_py_content.encode("utf-8")) > _MAX_FILE_SIZE:
            return f"Error: plugin.py exceeds max file size ({_MAX_FILE_SIZE // (1024 * 1024)} MB)."

        os.makedirs(resolved_dir, exist_ok=True)

        toml_path = os.path.join(resolved_dir, "plugin.toml")
        with open(toml_path, "w", encoding="utf-8") as f:
            f.write(manifest_content)

        py_path = os.path.join(resolved_dir, "plugin.py")
        with open(py_path, "w", encoding="utf-8") as f:
            f.write(plugin_py_content)

        for fname, content in extra.items():
            fname_str = str(fname).strip()
            fpath = os.path.normpath(os.path.join(resolved_dir, fname_str))
            if not fpath.startswith(resolved_dir + os.sep) and fpath != resolved_dir:
                return f"Error: extra file path escapes plugin directory: {fname_str!r}"
            fdir = os.path.dirname(fpath)
            if fdir and not os.path.exists(fdir):
                os.makedirs(fdir, exist_ok=True)
            with open(fpath, "w", encoding="utf-8") as f:
                f.write(content)

        emit_live_view_preview(
            f"Dashboard plugin created: {name} ({slug})",
            tool_name="create_dashboard_plugin",
        )

        files_written = ["plugin.toml", "plugin.py"] + list(extra.keys())
        normalized_suffix = ""
        if normalized_from is not None and normalized_from != slug:
            normalized_suffix = f"Slug normalized: {normalized_from!r} -> {slug!r}\n\n"
        return (
            f"Dashboard plugin created at {resolved_dir}\n"
            f"Files: {', '.join(files_written)}\n\n"
            f"{normalized_suffix}"
            "Approve and enable it under Settings → Dashboard. "
            "Use Settings → Dashboard → Reload to discover new plugins."
        )
    except Exception as e:
        _debug_log(f"[ERROR create_dashboard_plugin] {e}", "ERROR")
        return f"create_dashboard_plugin failed: {str(e)}"


def _normalize_plugin_slug(raw: str) -> tuple[str, str | None]:
    """Return (normalized_slug, original_if_changed) or ("", None) if invalid."""
    slug = (raw or "").strip()
    if not slug:
        return "", None
    normalized_from: str | None = None
    if not _SLUG_RE.match(slug):
        candidate = slug.lower().replace("-", "_")
        if _SLUG_RE.match(candidate):
            normalized_from = slug
            slug = candidate
        else:
            return "", None
    if ".." in slug or "/" in slug or os.sep in slug:
        return "", None
    return slug, normalized_from


def _ast_collect_tool_names(tree: ast.AST) -> list[str]:
    """LangChain-style @tool names: @tool(\"x\") def f(): or @tool def f(): (name = f)."""
    names: list[str] = []
    for node in tree.body:
        if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            continue
        for dec in node.decorator_list:
            tool_name: str | None = None
            if isinstance(dec, ast.Call):
                fn = dec.func
                is_tool = (isinstance(fn, ast.Name) and fn.id == "tool") or (
                    isinstance(fn, ast.Attribute) and fn.attr == "tool"
                )
                if is_tool and dec.args:
                    arg0 = dec.args[0]
                    if isinstance(arg0, ast.Constant) and isinstance(arg0.value, str):
                        tool_name = arg0.value
            elif isinstance(dec, ast.Name) and dec.id == "tool":
                tool_name = node.name
            if tool_name:
                names.append(tool_name)
                break
    return names


def _ast_register_tools_issues(tree: ast.AST) -> list[str]:
    issues: list[str] = []
    for node in ast.walk(tree):
        if not isinstance(node, ast.Call):
            continue
        fn = node.func
        if not isinstance(fn, ast.Attribute) or fn.attr != "register_tools":
            continue
        if not node.args:
            issues.append(
                "register_tools() is called without a tools list — use ctx.register_tools([tool1, ...])."
            )
            continue
        arg0 = node.args[0]
        if isinstance(arg0, ast.List) and len(arg0.elts) == 0:
            issues.append(
                "register_tools([]) registers zero tools; the agent will not see any tools from this plugin."
            )
    return issues


def _tool_names_align_manifest_slug(tool_names: list[str], manifest_slug: str) -> bool:
    """True if at least one tool looks like slug__fn or slug_fn (LLM collapse)."""
    if not tool_names:
        return True
    sp = manifest_slug.replace("__", "_").strip("_")
    if not sp:
        return True
    for n in tool_names:
        np = n.replace("__", "_")
        if np.startswith(sp + "_"):
            return True
    return False


def validate_plugin_py_source(
    source: str,
    *,
    manifest_slug: str | None = None,
) -> tuple[list[str], list[str]]:
    """Static checks on plugin.py source (no execution). Returns (errors, warnings)."""
    errors: list[str] = []
    warnings: list[str] = []
    text = source or ""
    if not text.strip():
        errors.append("plugin.py content is empty.")
        return errors, warnings
    try:
        tree = ast.parse(text)
    except SyntaxError as e:
        errors.append(f"SyntaxError (line {e.lineno or '?'}): {e.msg}")
        if e.text:
            errors.append(f"  Source line: {e.text.strip()}")
        return errors, warnings

    has_register = any(
        isinstance(node, ast.FunctionDef) and node.name == "register"
        for node in tree.body
    )
    if not has_register:
        errors.append(
            "Missing top-level `def register(ctx):` — required by Mugi (see ~/.mugi/PLUGINS.md)."
        )

    if "register_tools" not in text:
        warnings.append(
            "No `register_tools` call found; the agent may not see any tools from this plugin."
        )
    if "register_routes(" in text:
        warnings.append(
            "`register_routes()` is deprecated in sandbox mode. Use `ctx.register_route_handler(handler)` "
            "and return a dict like {status, headers, body}."
        )
    if "register_route_handler(" not in text and "register_routes(" in text:
        warnings.append(
            "Route migration needed: plugin uses route setup callbacks but has no route handler for IPC proxy mode."
        )

    warnings.extend(_ast_register_tools_issues(tree))

    tool_names = _ast_collect_tool_names(tree)
    for m in re.finditer(r"@tool\s*\(\s*[\"']([^\"']+)[\"']\s*\)", text):
        tool_names.append(m.group(1))
    tool_names_uniq = list(dict.fromkeys(tool_names))
    if (
        manifest_slug
        and tool_names_uniq
        and not _tool_names_align_manifest_slug(tool_names_uniq, manifest_slug)
    ):
        warnings.append(
            f"Tool name(s) {tool_names_uniq!r} do not use manifest slug prefix "
            f"{manifest_slug!r} + '__' (e.g. `{manifest_slug}__my_action`). "
            "Wrong names are treated as unknown tools and can be policy-blocked."
        )

    if "get_config(" in text or ".get_config(" in text:
        warnings.append(
            "PluginContext has no `get_config()`. Use `ctx.get_setting(key)` (after "
            "`register_settings`) or environment variables."
        )

    if re.search(r"^\s*def\s+register\s*\(\s*\)\s*:", text, re.MULTILINE):
        warnings.append("`register()` should accept `ctx` (e.g. `def register(ctx):`).")

    if re.search(r"\bimport\s+subprocess\b|\bfrom\s+subprocess\b", text):
        warnings.append(
            "Code imports `subprocess`; set `shell = true` under [permissions] in plugin.toml, "
            "or avoid subprocess."
        )

    if _NET_IMPORT_RE.search(text):
        warnings.append(
            "Code appears to import network-related modules; set `network = true` under [permissions] "
            "in plugin.toml or the loader may block imports at load time."
        )

    return errors, warnings


@tool
def validate_plugin(slug: str, plugin_py_content: str = "") -> str:
    """Validate a plugin before or after writing: syntax, register(ctx), manifest, common mistakes.

    Use after drafting `plugin_py_content` with create_plugin or when debugging load failures.
    If `plugin_py_content` is empty, reads `~/.mugi/plugins/<slug>/plugin.py` from disk.
    When non-empty, validates that string as plugin.py (still reads plugin.toml from disk if present).

    Args:
        slug: Plugin directory name (lowercase a-z, digits, underscore), e.g. `image_generator`.
        plugin_py_content: Optional full plugin.py source to validate instead of the file on disk.
    """
    _emit_input_status("Validating plugin…")
    try:
        slug_norm, normalized_from = _normalize_plugin_slug(slug)
        if not slug_norm and not (plugin_py_content or "").strip():
            return (
                "Error: provide `slug` (plugin directory name). "
                "To validate a draft only, pass `slug` plus `plugin_py_content`."
            )
        if not slug_norm and (plugin_py_content or "").strip():
            py_errors, py_warnings = validate_plugin_py_source(
                plugin_py_content, manifest_slug=None
            )
            lines = ["Plugin validation (draft `plugin_py` only, no slug path checks):\n"]
            if py_errors:
                lines.append("ERRORS:")
                lines.extend(f"  - {e}" for e in py_errors)
            else:
                lines.append("Python: no blocking errors.")
            if py_warnings:
                lines.append("WARNINGS:")
                lines.extend(f"  - {w}" for w in py_warnings)
            return "\n".join(lines)

        plugin_dir = os.path.join(PLUGINS_DIR, slug_norm)
        resolved_dir = os.path.normpath(os.path.abspath(os.path.expanduser(plugin_dir)))
        resolved_plugins = os.path.normpath(os.path.abspath(os.path.expanduser(PLUGINS_DIR)))
        if not (
            resolved_dir == resolved_plugins
            or resolved_dir.startswith(resolved_plugins + os.sep)
        ):
            return f"Error: resolved path escapes plugins directory: {resolved_dir}"

        toml_path = os.path.join(resolved_dir, "plugin.toml")
        py_path = os.path.join(resolved_dir, "plugin.py")

        if (plugin_py_content or "").strip():
            source = plugin_py_content
            src_label = "provided `plugin_py_content`"
        else:
            if not os.path.isfile(py_path):
                return (
                    f"Error: no plugin.py at {py_path}. "
                    "Pass `plugin_py_content` to validate a draft, or create files first."
                )
            with open(py_path, encoding="utf-8") as f:
                source = f.read()
            src_label = f"file {py_path}"

        manifest_errors: list[str] = []
        manifest_warnings: list[str] = []
        manifest_slug: str | None = None
        if os.path.isfile(toml_path):
            try:
                from plugin_security import PluginManifest

                man = PluginManifest.from_toml(toml_path)
                manifest_slug = _slug_from_manifest_name(man.name)
                if manifest_slug != slug_norm:
                    manifest_errors.append(
                        f"Folder name is {slug_norm!r} but [plugin].name {man.name!r} yields discovery slug "
                        f"{manifest_slug!r}. Mugi keys Settings, approval, and prefs by manifest slug, not "
                        f"folder name. Rename directory to {manifest_slug!r} or change [plugin].name so "
                        f"its slug matches this folder."
                    )
                if man.permissions.get("shell") is False and re.search(
                    r"\bimport\s+subprocess\b|\bfrom\s+subprocess\b", source
                ):
                    manifest_warnings.append(
                        "plugin.toml has shell=false but plugin.py imports subprocess; "
                        "set shell=true or remove subprocess."
                    )
            except Exception as e:
                manifest_errors.append(f"plugin.toml: {e}")
        else:
            manifest_errors.append(f"Missing plugin.toml next to plugin.py (expected {toml_path}).")

        py_errors, py_warnings = validate_plugin_py_source(
            source, manifest_slug=manifest_slug
        )

        lines: list[str] = []
        if normalized_from is not None:
            lines.append(f"Slug normalized: {normalized_from!r} -> {slug_norm!r}\n")
        lines.append(f"Validated {src_label} under `{slug_norm}`.\n")
        if manifest_errors:
            lines.append("MANIFEST ERRORS:")
            lines.extend(f"  - {e}" for e in manifest_errors)
        if py_errors:
            lines.append("PYTHON ERRORS:")
            lines.extend(f"  - {e}" for e in py_errors)
        if not manifest_errors and not py_errors:
            lines.append("OK: manifest and Python structure look valid for loading.")
        combined_w = py_warnings + manifest_warnings
        if combined_w:
            lines.append("WARNINGS:")
            lines.extend(f"  - {w}" for w in combined_w)
        if py_errors or manifest_errors:
            lines.append(
                "\nFix the issues, then use create_plugin(..., overwrite=True) or edit files "
                "and validate again."
            )
        else:
            lines.append(
                "\nNote: After changing plugin.py or plugin.toml, the directory hash changes — "
                "use Settings → Plugins → Reload, then re-approve if prompted, so the new code loads."
            )
        return "\n".join(lines)
    except Exception as e:
        _debug_log(f"[ERROR validate_plugin] {e}", "ERROR")
        return f"validate_plugin failed: {str(e)}"


@tool
def diagnose_plugin_sandbox_migration(slug: str, plugin_py_content: str = "") -> str:
    """Diagnose migration readiness for sandbox runtime (IPC route + hook/tool safety)."""
    try:
        slug_norm, _ = _normalize_plugin_slug(slug)
        source = plugin_py_content or ""
        if not source.strip():
            plugin_dir = os.path.join(PLUGINS_DIR, slug_norm)
            py_path = os.path.join(plugin_dir, "plugin.py")
            if not os.path.isfile(py_path):
                return f"Error: plugin.py not found for slug '{slug_norm}'."
            with open(py_path, "r", encoding="utf-8") as f:
                source = f.read()

        errors, warnings = validate_plugin_py_source(source, manifest_slug=slug_norm or None)
        findings: list[str] = []
        if "register_routes(" in source:
            findings.append("Uses deprecated register_routes(); migrate to register_route_handler(req)->{status,headers,body}.")
        if "register_route_handler(" not in source:
            findings.append("No route handler found; plugin HTTP endpoints will not work under runtime proxy.")
        if "ctx.get_setting(" in source and "register_settings(" not in source:
            findings.append("Uses get_setting without register_settings schema; define settings schema for deterministic defaults.")
        if "yield " in source:
            findings.append("Generator/yield detected; ensure streamed chunks are bounded and cancellation-aware.")

        if not findings and not warnings and not errors:
            return "Sandbox migration diagnostics: no blocking issues detected."

        lines = ["Sandbox migration diagnostics:"]
        if errors:
            lines.append("ERRORS:")
            lines.extend(f"  - {e}" for e in errors)
        if findings:
            lines.append("MIGRATION FINDINGS:")
            lines.extend(f"  - {f}" for f in findings)
        if warnings:
            lines.append("WARNINGS:")
            lines.extend(f"  - {w}" for w in warnings)
        lines.append("Recommended next step: update plugin.py, run validate_plugin, then reload/re-approve plugin.")
        return "\n".join(lines)
    except Exception as e:
        return f"Sandbox migration diagnostics failed: {e}"


__all__ = [
    "create_plugin",
    "create_dashboard_plugin",
    "validate_plugin",
    "validate_plugin_py_source",
    "diagnose_plugin_sandbox_migration",
]
