"""Load and activate locally installed Agent Plugin packages."""

from __future__ import annotations

import base64
import json
import re
from dataclasses import dataclass, replace
from hashlib import sha256
from pathlib import Path
from typing import cast

from loguru import logger
from pydantic import ValidationError

from nanobot.agent.skills import parse_skill_metadata, valid_skill_metadata
from nanobot.config.loader import get_config_path
from nanobot.config.schema import MCPServerConfig

AGENT_PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"
AGENT_PLUGIN_MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json"

_PLUGIN_NAME = re.compile(r"^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$")
_MCP_SERVER_FIELDS = {"type", "command", "args", "env", "cwd"}
_MAX_LOGO_BYTES = 256 * 1024


@dataclass(frozen=True, slots=True)
class _PackageSnapshot:
    root: Path
    fingerprint: str
    skill_dirs: tuple[Path, ...]


@dataclass(frozen=True, slots=True)
class _SkillCacheEntry:
    skills: tuple[tuple[str, Path], ...]
    packages: tuple[_PackageSnapshot, ...]


_SKILL_CACHE: dict[tuple[Path, Path], _SkillCacheEntry] = {}


@dataclass(frozen=True)
class AgentPlugin:
    """A validated, locally installed Agent Plugins v1 package."""

    name: str
    root: Path
    description: str
    repository: str
    display_name: str
    category: str
    accent_color: str | None
    logo: str | None
    permissions: tuple[str, ...]
    mcp_servers: tuple[str, ...] = ()
    enabled: bool = False


def _installed_plugins(workspace: Path) -> list[AgentPlugin]:
    """Return installed packages found under ``<workspace>/plugins/*``."""
    workspace = workspace.expanduser().resolve()
    root = _contained(workspace / "plugins", workspace, directory=True)
    if root is None:
        return []
    plugins: dict[str, AgentPlugin | None] = {}
    for candidate in _children(root, "Agent Plugins directory"):
        plugin_root = _contained(candidate, root, directory=True)
        if plugin_root is None:
            continue
        plugin = _load_manifest(plugin_root)
        if plugin is not None:
            if plugin.name in plugins:
                logger.warning("Ignoring duplicate Agent Plugin identity '{}'", plugin.name)
                plugins[plugin.name] = None
            else:
                plugins[plugin.name] = plugin
    return [plugin for plugin in plugins.values() if plugin is not None]


def enabled_agent_plugin_skills(workspace: Path) -> list[tuple[str, Path]]:
    """Verify and return skills from plugins the user has explicitly enabled."""
    skills: list[tuple[str, Path]] = []
    packages: list[_PackageSnapshot] = []
    for plugin in _installed_plugins(workspace):
        plugin_skills = _discover_plugin_skills(plugin.name, plugin.root)
        fingerprint = _enabled_package_fingerprint(workspace, plugin)
        if fingerprint is None:
            continue
        skills.extend(plugin_skills)
        if plugin_skills:
            packages.append(
                _PackageSnapshot(
                    root=plugin.root,
                    fingerprint=fingerprint,
                    skill_dirs=tuple(path.parent for _name, path in plugin_skills),
                )
            )

    key = _skill_cache_key(workspace)
    _SKILL_CACHE[key] = _SkillCacheEntry(tuple(skills), tuple(packages))
    return skills


def enabled_agent_plugin_skill_dirs(
    workspace: Path,
    *,
    requested_path: str | Path | None = None,
) -> tuple[Path, ...]:
    """Return skill roots authorized for one read, revalidating their package."""
    key = _skill_cache_key(workspace)
    cached = _SKILL_CACHE.get(key)
    if cached is None:
        enabled_agent_plugin_skills(workspace)
        cached = _SKILL_CACHE.get(key)
    if cached is None:
        return ()

    target = (
        Path(requested_path).expanduser().resolve(strict=False)
        if requested_path is not None
        else None
    )
    packages = tuple(
        package
        for package in cached.packages
        if target is None
        or any(target == root or target.is_relative_to(root) for root in package.skill_dirs)
    )
    if any(_package_fingerprint(package.root) != package.fingerprint for package in packages):
        # Re-run the full activation check so a changed package loses its
        # marker and cannot become readable again through this cache.
        _invalidate_skill_cache(workspace)
        enabled_agent_plugin_skills(workspace)
        return ()

    if target is None:
        return tuple(root for package in packages for root in package.skill_dirs)
    return tuple(
        root
        for package in packages
        for root in package.skill_dirs
        if target == root or target.is_relative_to(root)
    )


def _skill_cache_key(workspace: Path) -> tuple[Path, Path]:
    return (
        workspace.expanduser().resolve(),
        get_config_path().expanduser().resolve(),
    )


def _invalidate_skill_cache(workspace: Path) -> None:
    _SKILL_CACHE.pop(_skill_cache_key(workspace), None)


def _package_fingerprint(root: Path) -> str | None:
    """Hash package paths, link targets, and file contents."""
    digest = sha256()
    try:
        for candidate in sorted(root.rglob("*")):
            relative = candidate.relative_to(root).as_posix()
            digest.update(relative.encode())
            if candidate.is_symlink():
                digest.update(b"\0link\0")
                digest.update(candidate.readlink().as_posix().encode())
            elif candidate.is_file():
                digest.update(b"\0file\0")
                digest.update(candidate.read_bytes())
            elif candidate.is_dir():
                digest.update(b"\0dir\0")
            else:
                return None
            digest.update(b"\0")
    except OSError:
        return None
    return digest.hexdigest()


def _load_manifest(plugin_root: Path) -> AgentPlugin | None:
    payload = _read_object(plugin_root / "plugin.json", plugin_root)
    if payload is None:
        return None
    if payload.get("$schema") != AGENT_PLUGIN_SCHEMA:
        return None
    name = payload.get("name")
    if (
        not isinstance(name, str)
        or len(name) > 64
        or _PLUGIN_NAME.fullmatch(name) is None
    ):
        logger.warning("Ignoring Agent Plugin manifest in '{}': invalid name", plugin_root)
        return None
    extension = payload.get("extensions")
    extension_payload = cast(dict[str, object], extension) if isinstance(extension, dict) else {}
    nanobot_value = extension_payload.get("dev.nanobot")
    nanobot = cast(dict[str, object], nanobot_value) if isinstance(nanobot_value, dict) else {}
    return AgentPlugin(
        name=name,
        root=plugin_root,
        description=_string(payload.get("description")),
        repository=_string(payload.get("repository")),
        display_name=_string(nanobot.get("displayName")) or name,
        category=_string(nanobot.get("category")) or "Plugin",
        accent_color=_accent_color(nanobot.get("accentColor")),
        logo=_plugin_logo(nanobot.get("logo"), plugin_root),
        permissions=_string_tuple(nanobot.get("permissions")),
    )


def agent_plugin_mcp_servers(
    workspace: Path,
    configured: dict[str, MCPServerConfig] | None = None,
) -> dict[str, MCPServerConfig]:
    """Merge explicitly enabled plugin MCP servers with user configuration.

    User configuration wins on the unlikely event of a namespaced collision.
    """
    servers: dict[str, MCPServerConfig] = {}
    for plugin in _installed_plugins(workspace):
        if not _enabled(workspace, plugin):
            continue
        plugin_servers = _plugin_mcp_servers(workspace, plugin)
        for name, server in plugin_servers.items():
            # ``--`` cannot occur in a valid plugin identity, so multi-server
            # namespaces cannot collide with a single-server plugin name.
            host_name = plugin.name if len(plugin_servers) == 1 else f"{plugin.name}--{name}"
            servers[host_name] = server
    configured = configured or {}
    if collisions := servers.keys() & configured.keys():
        logger.warning("Configured MCP servers override Agent Plugins: {}", ", ".join(sorted(collisions)))
    return servers | configured


def discover_agent_plugins(workspace: Path) -> list[AgentPlugin]:
    """Return component and lifecycle state for discovered plugins."""
    return [
        replace(
            plugin,
            mcp_servers=tuple(sorted(_plugin_mcp_servers(workspace, plugin))),
            enabled=_enabled(workspace, plugin),
        )
        for plugin in _installed_plugins(workspace)
    ]


def set_agent_plugin_enabled(workspace: Path, name: str, enabled: bool) -> None:
    """Enable or disable one installed plugin."""
    plugin = next((item for item in _installed_plugins(workspace) if item.name == name), None)
    if plugin is None:
        raise ValueError(f"unknown Agent Plugin '{name}'")
    data = _plugin_data_dir(workspace, plugin.name, create=True)
    marker = data / "enabled"
    if enabled:
        activation = _activation_marker(plugin)
        if activation is None:
            raise RuntimeError(f"Agent Plugin '{name}' changed while it was being enabled")
        marker.write_text(activation, encoding="utf-8")
        marker.chmod(0o600)
    else:
        marker.unlink(missing_ok=True)
    _invalidate_skill_cache(workspace)


def _string(value: object) -> str:
    return value.strip() if isinstance(value, str) else ""


def _string_tuple(value: object) -> tuple[str, ...]:
    items = cast(list[object], value) if isinstance(value, list) else []
    return tuple(item.strip() for item in items if isinstance(item, str) and item.strip())


def _accent_color(value: object) -> str | None:
    return value if isinstance(value, str) and re.fullmatch(r"#[0-9a-fA-F]{6}", value) else None


def _plugin_logo(value: object, plugin_root: Path) -> str | None:
    """Resolve nanobot's optional packaged logo extension."""
    if value is None:
        return None
    if not isinstance(value, str) or not value.startswith("./"):
        logger.warning("Ignoring invalid Agent Plugin logo in '{}'", plugin_root)
        return None
    logo = _contained(plugin_root / value[2:], plugin_root)
    try:
        data = logo.read_bytes() if logo is not None else b""
        suffix = logo.suffix.lower() if logo is not None else ""
        if len(data) <= _MAX_LOGO_BYTES and (
            suffix == ".png" and data.startswith(b"\x89PNG\r\n\x1a\n")
            or suffix in {".jpg", ".jpeg"} and data.startswith(b"\xff\xd8\xff")
            or suffix == ".webp" and data.startswith(b"RIFF") and data[8:12] == b"WEBP"
        ):
            mime = "jpeg" if suffix in {".jpg", ".jpeg"} else suffix[1:]
            return f"data:image/{mime};base64,{base64.b64encode(data).decode('ascii')}"
    except OSError:
        pass
    logger.warning("Ignoring invalid Agent Plugin logo in '{}'", plugin_root)
    return None


def _plugin_mcp_servers(workspace: Path, plugin: AgentPlugin) -> dict[str, MCPServerConfig]:
    payload = _read_object(plugin.root / "mcp.json", plugin.root)
    if payload is None:
        return {}
    raw_servers = payload.get("mcpServers")
    if (
        payload.keys() != {"$schema", "mcpServers"}
        or payload.get("$schema") != AGENT_PLUGIN_MCP_SCHEMA
        or not isinstance(raw_servers, dict)
    ):
        logger.warning("Ignoring invalid MCP component for Agent Plugin '{}'", plugin.name)
        return {}

    data = _plugin_data_dir(workspace, plugin.name, create=True)
    servers: dict[str, MCPServerConfig] = {}
    for name, raw in cast(dict[str, object], raw_servers).items():
        if not name or len(name) > 128 or any(ord(char) < 32 for char in name):
            logger.warning("Ignoring invalid MCP server name in Agent Plugin '{}'", plugin.name)
            continue
        server = _plugin_mcp_server(raw, plugin.root, data)
        if server is None:
            logger.warning("Ignoring invalid MCP server '{}' in Agent Plugin '{}'", name, plugin.name)
            continue
        servers[name] = server
    return servers


def _plugin_mcp_server(raw: object, root: Path, data: Path) -> MCPServerConfig | None:
    if not isinstance(raw, dict):
        return None
    payload = cast(dict[str, object], raw)
    if payload.keys() - _MCP_SERVER_FIELDS:
        return None
    try:
        server = MCPServerConfig.model_validate(payload)
    except ValidationError:
        return None
    command = _stdio_command(server.command, root)
    cwd = _stdio_cwd(payload.get("cwd"), root, data)
    if server.type != "stdio" or command is None or cwd is None:
        return None
    if {"PLUGIN_ROOT", "PLUGIN_DATA"} & server.env.keys():
        return None
    return server.model_copy(
        update={
            "command": command,
            "args": [_expand(item, root, data) for item in server.args],
            "env": {
                **{key: _expand(value, root, data) for key, value in server.env.items()},
                "PYTHONDONTWRITEBYTECODE": "1",
                "PLUGIN_ROOT": str(root),
                "PLUGIN_DATA": str(data),
            },
            "cwd": str(cwd),
        }
    )


def _stdio_command(value: object, root: Path) -> str | None:
    if not isinstance(value, str) or not value:
        return None
    if value.startswith("./"):
        executable = _contained(root / value[2:], root)
        return str(executable) if executable is not None else None
    if any(char.isspace() for char in value) or "/" in value or "\\" in value:
        return None
    return value


def _stdio_cwd(value: object, root: Path, data: Path) -> Path | None:
    if value is None:
        return root
    if not isinstance(value, str):
        return None
    if value.startswith("./"):
        return _contained(root / value[2:], root, directory=True)
    for placeholder, base in (("${PLUGIN_ROOT}", root), ("${PLUGIN_DATA}", data)):
        if value == placeholder or value.startswith(f"{placeholder}/"):
            relative = value[len(placeholder):].lstrip("/")
            candidate = (base / relative).resolve()
            if not candidate.is_relative_to(base):
                return None
            if base == data:
                candidate.mkdir(parents=True, exist_ok=True)
                candidate.chmod(0o700)
            return candidate if candidate.is_dir() else None
    return None


def _expand(value: str, root: Path, data: Path) -> str:
    return value.replace("${PLUGIN_ROOT}", str(root)).replace("${PLUGIN_DATA}", str(data))


def _plugin_data_dir(workspace: Path, name: str, *, create: bool) -> Path:
    workspace_id = sha256(str(workspace.expanduser().resolve()).encode()).hexdigest()[:12]
    current = get_config_path().expanduser().resolve().parent
    for segment in ("plugin-data", workspace_id, name):
        path = current / segment
        if create:
            path.mkdir(parents=True, exist_ok=True)
        try:
            resolved = path.resolve(strict=create)
        except OSError as exc:
            raise RuntimeError("Agent Plugin data directory is unavailable") from exc
        if not resolved.is_relative_to(current):
            raise RuntimeError("Agent Plugin data directory escapes its parent")
        if create:
            resolved.chmod(0o700)
        current = resolved
    return current


def _enabled_package_fingerprint(workspace: Path, plugin: AgentPlugin) -> str | None:
    """Return the content fingerprint when this exact package is enabled."""
    marker = _plugin_data_dir(workspace, plugin.name, create=False) / "enabled"
    try:
        if not marker.is_file():
            return None
        current = marker.read_text(encoding="utf-8")
        activation = _activation_marker(plugin)
        if activation is None:
            marker.unlink(missing_ok=True)
            _invalidate_skill_cache(workspace)
            return None
        payload = cast(dict[str, object], json.loads(activation))
        fingerprint = payload.get("fingerprint")
        if not isinstance(fingerprint, str):
            return None
        if current == activation:
            return fingerprint
        if current == str(plugin.root):
            marker.write_text(activation, encoding="utf-8")
            marker.chmod(0o600)
            return fingerprint
        marker.unlink(missing_ok=True)
        _invalidate_skill_cache(workspace)
        return None
    except (OSError, json.JSONDecodeError):
        _invalidate_skill_cache(workspace)
        return None


def _enabled(workspace: Path, plugin: AgentPlugin) -> bool:
    return _enabled_package_fingerprint(workspace, plugin) is not None


def _activation_marker(plugin: AgentPlugin) -> str | None:
    """Bind activation to one immutable package snapshot."""
    fingerprint = _package_fingerprint(plugin.root)
    if fingerprint is None:
        return None
    return json.dumps(
        {"fingerprint": fingerprint, "root": str(plugin.root)},
        separators=(",", ":"),
        sort_keys=True,
    )


def _discover_plugin_skills(plugin_name: str, plugin_root: Path) -> list[tuple[str, Path]]:
    skills_root = _contained(plugin_root / "skills", plugin_root, directory=True)
    if skills_root is None:
        return []

    skills: list[tuple[str, Path]] = []
    for candidate in _children(skills_root, f"Agent Plugin '{plugin_name}' skills"):
        skill_root = _contained(candidate, skills_root, directory=True)
        if skill_root is None:
            continue
        skill_file = _contained(skill_root / "SKILL.md", plugin_root)
        if skill_file is None:
            continue
        try:
            metadata = parse_skill_metadata(skill_file.read_text(encoding="utf-8"))
        except (OSError, UnicodeError):
            metadata = None
        if metadata is None or not valid_skill_metadata(metadata, candidate.name):
            logger.warning("Ignoring Agent Plugin '{}' skill '{}': invalid metadata", plugin_name, candidate.name)
            continue
        skills.append((candidate.name, skill_file))
    return skills


def _children(root: Path, label: str) -> list[Path]:
    try:
        return sorted(root.iterdir(), key=lambda path: path.name)
    except OSError as exc:
        logger.warning("Could not inspect {}: {}", label, exc)
        return []


def _contained(path: Path, root: Path, *, directory: bool = False) -> Path | None:
    try:
        resolved = path.resolve(strict=True)
    except OSError:
        return None
    expected_kind = resolved.is_dir() if directory else resolved.is_file()
    return resolved if expected_kind and resolved.is_relative_to(root) else None


def _read_object(path: Path, root: Path) -> dict[str, object] | None:
    contained = _contained(path, root)
    if contained is None:
        return None
    try:
        value = cast(object, json.loads(contained.read_text(encoding="utf-8")))
    except (OSError, UnicodeError, json.JSONDecodeError) as exc:
        logger.warning("Ignoring invalid Agent Plugin component '{}': {}", contained, exc)
        return None
    return cast(dict[str, object], value) if isinstance(value, dict) else None
