"""
Plugin security: manifest parsing, directory hashing, and approval state persistence.

Approval state is stored in ~/.mugi/app_prefs.json under the "plugins" key.
Each plugin entry tracks: enabled (bool), approved_hash (str), approved_at (ISO timestamp).
"""
from __future__ import annotations

import hashlib
import json
import os
from datetime import datetime, timezone
from typing import Any

try:
    import tomllib
except ImportError:
    import tomli as tomllib  # type: ignore[no-redef]


_VALID_FILESYSTEM_VALUES = ("none", "read", "read_write")
_SECRET_SENTINEL = "__MUGI_KEYCHAIN_SECRET__"
_KEYCHAIN_SERVICE = "MugiPluginSecret"

# plugin.toml [plugin].kind — general agents vs dashboard-only plugins (separate install dir).
PLUGIN_KIND_GENERAL = "general"
PLUGIN_KIND_DASHBOARD = "dashboard"


class PluginManifest:
    """Parsed plugin.toml manifest."""

    __slots__ = (
        "name",
        "version",
        "author",
        "description",
        "homepage",
        "permissions",
        "tool_permissions",
        "path",
        "realtime_tools",
        "user_decision_allowed_kinds",
        "visible_in_tier0",
        "kind",
        "agent_hooks",
    )

    def __init__(
        self,
        name: str,
        version: str = "0.0.0",
        author: str = "",
        description: str = "",
        homepage: str = "",
        permissions: dict | None = None,
        tool_permissions: dict | None = None,
        path: str = "",
        realtime_tools: set | None = None,
        user_decision_allowed_kinds: set | None = None,
        visible_in_tier0: bool = False,
        kind: str = PLUGIN_KIND_GENERAL,
        agent_hooks: bool = False,
    ):
        self.name = name
        self.version = version
        self.author = author
        self.description = description
        self.homepage = homepage
        self.permissions = permissions or {"network": False, "shell": False, "filesystem": "none"}
        self.tool_permissions: dict[str, dict] = tool_permissions or {}
        self.path = path
        self.realtime_tools: set[str] = realtime_tools or set()
        self.user_decision_allowed_kinds: set[str] = user_decision_allowed_kinds or set()
        self.visible_in_tier0 = bool(visible_in_tier0)
        self.kind = kind if kind in (PLUGIN_KIND_GENERAL, PLUGIN_KIND_DASHBOARD) else PLUGIN_KIND_GENERAL
        self.agent_hooks = bool(agent_hooks)

    @classmethod
    def from_toml(cls, toml_path: str) -> "PluginManifest":
        """Parse a plugin.toml file. Raises ValueError if [plugin].name is missing."""
        with open(toml_path, "rb") as f:
            data = tomllib.load(f)

        plugin_section = data.get("plugin")
        if not isinstance(plugin_section, dict) or not plugin_section.get("name"):
            raise ValueError(f"plugin.toml missing required [plugin].name: {toml_path}")

        perms_raw = data.get("permissions", {})
        if not isinstance(perms_raw, dict):
            perms_raw = {}
        fs_val = str(perms_raw.get("filesystem", "none")).strip()
        if fs_val not in _VALID_FILESYSTEM_VALUES:
            fs_val = "none"
        permissions = {
            "network": bool(perms_raw.get("network", False)),
            "shell": bool(perms_raw.get("shell", False)),
            "filesystem": fs_val,
        }
        # Optional subprocess basename allowlist for sandboxed plugins (see plugin_runtime_host handshake).
        # Only stored when non-empty; empty list is ignored so the runtime keeps its safe defaults.
        raw_allow = perms_raw.get("shell_allowlist")
        if isinstance(raw_allow, list):
            allow_items = [str(x).strip() for x in raw_allow if str(x).strip()]
            if allow_items:
                permissions["shell_allowlist"] = allow_items

        # Parse [tools.*] sections for per-tool metadata (e.g. realtime, permissions).
        tools_section = data.get("tools", {})
        realtime_tools: set[str] = set()
        tool_permissions: dict[str, dict] = {}
        if isinstance(tools_section, dict):
            for tool_name, tool_meta in tools_section.items():
                if not isinstance(tool_meta, dict):
                    continue
                if tool_meta.get("realtime"):
                    realtime_tools.add(str(tool_name))
                # Per-tool permission overrides (same keys as [permissions]).
                tp_raw = tool_meta.get("permissions")
                if isinstance(tp_raw, dict):
                    tp: dict[str, bool | str] = {}
                    if "network" in tp_raw:
                        tp["network"] = bool(tp_raw["network"])
                    if "shell" in tp_raw:
                        tp["shell"] = bool(tp_raw["shell"])
                    if "filesystem" in tp_raw:
                        fs = str(tp_raw["filesystem"]).strip()
                        tp["filesystem"] = fs if fs in _VALID_FILESYSTEM_VALUES else "none"
                    if "browser_ui_automation" in tp_raw:
                        tp["browser_ui_automation"] = bool(tp_raw["browser_ui_automation"])
                    if tp:
                        tool_permissions[str(tool_name)] = tp

        user_decision_section = data.get("user_decision", {})
        allowed_kinds: set[str] = set()
        if isinstance(user_decision_section, dict):
            raw_kinds = user_decision_section.get("allowed_kinds", [])
            if isinstance(raw_kinds, list):
                for item in raw_kinds:
                    kind = str(item or "").strip()
                    if kind:
                        allowed_kinds.add(kind)

        raw_plugin_kind = str(plugin_section.get("kind", PLUGIN_KIND_GENERAL)).strip().lower()
        if raw_plugin_kind not in (PLUGIN_KIND_GENERAL, PLUGIN_KIND_DASHBOARD):
            raw_plugin_kind = PLUGIN_KIND_GENERAL

        return cls(
            name=str(plugin_section["name"]).strip(),
            version=str(plugin_section.get("version", "0.0.0")).strip(),
            author=str(plugin_section.get("author", "")).strip(),
            description=str(plugin_section.get("description", "")).strip(),
            homepage=str(plugin_section.get("homepage", "")).strip(),
            permissions=permissions,
            tool_permissions=tool_permissions,
            path=os.path.dirname(os.path.abspath(toml_path)),
            realtime_tools=realtime_tools,
            user_decision_allowed_kinds=allowed_kinds,
            visible_in_tier0=bool(plugin_section.get("visible_in_tier0", False)),
            kind=raw_plugin_kind,
            agent_hooks=bool(plugin_section.get("agent_hooks", False)),
        )

    def permissions_summary(self) -> str:
        """Human-readable one-line summary of requested permissions."""
        parts: list[str] = []
        if self.permissions.get("network"):
            parts.append("network access")
        if self.permissions.get("shell"):
            parts.append("shell execution")
        fs = self.permissions.get("filesystem", "none")
        if fs == "read":
            parts.append("file read")
        elif fs == "read_write":
            parts.append("file read/write")
        return ", ".join(parts) if parts else "no special permissions"


# -- Directory hashing --------------------------------------------------------

def hash_plugin_directory(plugin_dir: str) -> str:
    """SHA-256 over all .py files and plugin.toml, sorted by relative path for determinism."""
    h = hashlib.sha256()
    entries: list[str] = []
    for root, _dirs, files in os.walk(plugin_dir):
        _dirs.sort()
        for fname in sorted(files):
            if fname.endswith(".py") or fname == "plugin.toml":
                entries.append(os.path.join(root, fname))
    entries.sort(key=lambda p: os.path.relpath(p, plugin_dir))
    for fpath in entries:
        rel = os.path.relpath(fpath, plugin_dir)
        h.update(rel.encode("utf-8"))
        with open(fpath, "rb") as f:
            h.update(f.read())
    return h.hexdigest()


# -- Approval state persistence (app_prefs.json) -----------------------------

def _app_prefs_path() -> str:
    """Same location as the main app uses for app_prefs.json."""
    prefs_dir = os.path.expanduser("~/.mugi")
    os.makedirs(prefs_dir, exist_ok=True)
    return os.path.join(prefs_dir, "app_prefs.json")


def _load_app_prefs() -> dict:
    try:
        from utils.app_prefs_store import load_app_prefs_cached
        return load_app_prefs_cached()
    except Exception:
        return {}


def _save_app_prefs(prefs: dict) -> None:
    if not isinstance(prefs, dict):
        return
    try:
        from utils.app_prefs_store import save_app_prefs
        save_app_prefs(prefs)
    except Exception:
        pass


def get_plugin_approval_state(plugin_name: str) -> dict:
    """Return approval dict for a plugin, or empty dict if not yet seen."""
    prefs = _load_app_prefs()
    plugins = prefs.get("plugins", {})
    if not isinstance(plugins, dict):
        return {}
    return plugins.get(plugin_name, {})


def set_plugin_approval(plugin_name: str, *, enabled: bool, approved_hash: str) -> None:
    """Store approval state for a plugin."""
    prefs = _load_app_prefs()
    if not isinstance(prefs.get("plugins"), dict):
        prefs["plugins"] = {}
    prior = prefs["plugins"].get(plugin_name)
    prior_dict = prior if isinstance(prior, dict) else {}
    preserved_settings = prior_dict.get("settings")
    entry = {
        "enabled": enabled,
        "approved_hash": approved_hash,
        "approved_at": datetime.now(timezone.utc).isoformat(),
    }
    if isinstance(preserved_settings, dict) and preserved_settings:
        entry["settings"] = dict(preserved_settings)
    prefs["plugins"][plugin_name] = entry
    _save_app_prefs(prefs)


def disable_plugin_approval(plugin_name: str) -> None:
    """Mark a plugin as disabled (keeps hash so re-enable doesn't re-prompt)."""
    prefs = _load_app_prefs()
    if not isinstance(prefs.get("plugins"), dict):
        prefs["plugins"] = {}
    existing = prefs["plugins"].get(plugin_name, {})
    existing["enabled"] = False
    prefs["plugins"][plugin_name] = existing
    _save_app_prefs(prefs)


def remove_plugin_approval(plugin_name: str) -> None:
    """Remove all approval state for a plugin."""
    prefs = _load_app_prefs()
    plugins = prefs.get("plugins")
    if isinstance(plugins, dict) and plugin_name in plugins:
        del plugins[plugin_name]
        _save_app_prefs(prefs)


def get_all_plugin_states() -> dict[str, dict]:
    """Return the full plugins approval dict."""
    prefs = _load_app_prefs()
    plugins = prefs.get("plugins", {})
    return plugins if isinstance(plugins, dict) else {}


def get_plugin_settings_values(plugin_name: str, schema: dict | None = None) -> dict[str, Any]:
    """Return settings dict for a plugin slug, resolving secret fields from Keychain."""
    state = get_plugin_approval_state(plugin_name)
    s = state.get("settings")
    out = dict(s) if isinstance(s, dict) else {}
    secret_keys = _secret_keys_from_schema(schema)
    if not secret_keys:
        return out
    mutated = False
    for k in secret_keys:
        raw = out.get(k)
        if raw == _SECRET_SENTINEL:
            out[k] = _keyring_get_secret(plugin_name, k) or ""
            continue
        # Backward compatibility + migration: plaintext previously stored in prefs.
        if isinstance(raw, str) and raw:
            if _keyring_set_secret(plugin_name, k, raw):
                out[k] = raw
                state_copy = state if isinstance(state, dict) else {}
                settings_copy = dict(state_copy.get("settings") or {})
                settings_copy[k] = _SECRET_SENTINEL
                state_copy["settings"] = settings_copy
                prefs = _load_app_prefs()
                if not isinstance(prefs.get("plugins"), dict):
                    prefs["plugins"] = {}
                prefs["plugins"][plugin_name] = state_copy
                _save_app_prefs(prefs)
                mutated = True
    if mutated:
        # Re-read once so caller sees canonical resolved values after migration.
        return get_plugin_settings_values(plugin_name, schema=schema)
    return out


def _secret_keys_from_schema(schema: dict | None) -> set[str]:
    keys: set[str] = set()
    if not schema or not isinstance(schema.get("fields"), list):
        return keys
    for f in schema["fields"]:
        if not isinstance(f, dict):
            continue
        k = str(f.get("key") or "").strip()
        typ = str(f.get("type") or "string").strip().lower()
        if k and typ == "secret":
            keys.add(k)
    return keys


def _set_keychain_item_accessible_when_unlocked(service: str, account: str) -> None:
    """Best-effort hardening for Keychain item accessibility on macOS."""
    try:
        from Security import (  # type: ignore[import-not-found]
            SecItemUpdate,
            kSecClass, kSecClassGenericPassword,
            kSecAttrService, kSecAttrAccount,
            kSecAttrAccessible, kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
        )
        query = {kSecClass: kSecClassGenericPassword, kSecAttrService: service, kSecAttrAccount: account}
        update = {kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly}
        SecItemUpdate(query, update)
    except Exception:
        pass


def _secret_account(plugin_name: str, key: str) -> str:
    return f"{plugin_name}:{key}"


def _keyring_get_secret(plugin_name: str, key: str) -> str | None:
    try:
        import importlib
        keyring = importlib.import_module("keyring")
        return keyring.get_password(_KEYCHAIN_SERVICE, _secret_account(plugin_name, key))
    except Exception:
        return None


def _keyring_set_secret(plugin_name: str, key: str, value: str) -> bool:
    try:
        import importlib
        keyring = importlib.import_module("keyring")
        keyring.set_password(_KEYCHAIN_SERVICE, _secret_account(plugin_name, key), value)
        _set_keychain_item_accessible_when_unlocked(_KEYCHAIN_SERVICE, _secret_account(plugin_name, key))
        return True
    except Exception:
        return False


def merge_plugin_settings_payload(
    schema: dict | None,
    updates: dict[str, Any],
) -> tuple[dict[str, Any] | None, str | None]:
    """Validate *updates* against ``register_settings`` *schema* keys and types.

    Returns ``(coerced_dict, None)`` or ``(None, error_message)``.
    Only keys present in *updates* are validated and coerced (partial updates).
    """
    if not schema or not isinstance(schema.get("fields"), list):
        return None, "Plugin has no settings schema"
    allowed: dict[str, dict] = {}
    for f in schema["fields"]:
        if not isinstance(f, dict):
            continue
        k = str(f.get("key") or "").strip()
        if k:
            allowed[k] = f
    if not isinstance(updates, dict):
        return None, "settings must be a JSON object"
    coerced: dict[str, Any] = {}
    for key, raw in updates.items():
        if key not in allowed:
            return None, f"Unknown setting {key!r}"
        f = allowed[key]
        typ = str(f.get("type") or "string").strip().lower()
        if typ in ("string", "secret", ""):
            coerced[key] = "" if raw is None else str(raw)
        elif typ in ("bool", "boolean"):
            if isinstance(raw, bool):
                coerced[key] = raw
            elif isinstance(raw, (int, float)):
                coerced[key] = bool(raw)
            elif isinstance(raw, str):
                v = raw.strip().lower()
                coerced[key] = v in ("1", "true", "yes", "on")
            else:
                return None, f"Invalid boolean for {key!r}"
        elif typ == "number":
            if isinstance(raw, bool):
                return None, f"Invalid number for {key!r}"
            if isinstance(raw, int) and not isinstance(raw, bool):
                coerced[key] = raw
            elif isinstance(raw, float):
                coerced[key] = int(raw) if raw.is_integer() else raw
            else:
                try:
                    x = float(str(raw).strip())
                    coerced[key] = int(x) if x.is_integer() else x
                except (TypeError, ValueError):
                    return None, f"Invalid number for {key!r}"
        else:
            return None, f"Unsupported field type {typ!r} for {key!r}"
    return coerced, None


def effective_plugin_settings(schema: dict | None, stored: dict[str, Any]) -> dict[str, Any]:
    """Merge schema field defaults with *stored* values (for API responses and forms)."""
    out: dict[str, Any] = {}
    if isinstance(stored, dict):
        out.update(stored)
    if not schema or not isinstance(schema.get("fields"), list):
        return out
    for f in schema["fields"]:
        if not isinstance(f, dict):
            continue
        k = str(f.get("key") or "").strip()
        if not k or k in out:
            continue
        if "default" in f:
            out[k] = f["default"]
    return out


def save_plugin_settings_values(
    plugin_name: str,
    coerced: dict[str, Any],
    schema: dict | None = None,
) -> dict[str, Any]:
    """Merge settings and persist.

    Secret fields are stored in Keychain and persisted as sentinels in app_prefs.
    Returns effective values (with secret plaintext resolved from Keychain).
    """
    prefs = _load_app_prefs()
    if not isinstance(prefs.get("plugins"), dict):
        prefs["plugins"] = {}
    raw_entry = prefs["plugins"].get(plugin_name)
    entry: dict[str, Any] = dict(raw_entry) if isinstance(raw_entry, dict) else {}
    prev = entry.get("settings")
    settings: dict[str, Any] = dict(prev) if isinstance(prev, dict) else {}
    secret_keys = _secret_keys_from_schema(schema)
    for key, value in (coerced or {}).items():
        if key in secret_keys:
            s = "" if value is None else str(value)
            if s:
                # Prefer Keychain at rest; sentinel marks externalized value.
                if _keyring_set_secret(plugin_name, key, s):
                    settings[key] = _SECRET_SENTINEL
                else:
                    # Fallback if keyring unavailable: keep plaintext behavior.
                    settings[key] = s
            else:
                settings[key] = ""
        else:
            settings[key] = value
    # Opportunistic migration for existing plaintext secret values.
    for key in secret_keys:
        raw = settings.get(key)
        if isinstance(raw, str) and raw and raw != _SECRET_SENTINEL:
            if _keyring_set_secret(plugin_name, key, raw):
                settings[key] = _SECRET_SENTINEL
    entry["settings"] = settings
    prefs["plugins"][plugin_name] = entry
    _save_app_prefs(prefs)
    return get_plugin_settings_values(plugin_name, schema=schema)
