"""Plugin manager — discovery, loading, lifecycle."""

from __future__ import annotations

import importlib.util
import json
import logging
import os
import stat
import sys
from pathlib import Path
from typing import TYPE_CHECKING, Any

import yaml

from agntspace.plugins.base import PluginBase, PluginContext, PluginMeta

if TYPE_CHECKING:
    from agntspace.vault.paths import VaultPaths

log = logging.getLogger(__name__)


class PluginManager:
    """Discovers, loads, and manages plugins."""

    def __init__(
        self,
        app_dir: Path,
        vault_dir: Path,
        *,
        paths: VaultPaths | None = None,
        state_path: Path | None = None,
    ) -> None:
        self._app_dir = app_dir
        self._vault_dir = vault_dir
        self._paths = paths
        self._plugins: dict[str, PluginMeta] = {}
        self._instances: dict[str, PluginBase] = {}
        # State (enable/disable registry) is per-vault by default — see
        # config.plugin_state_path. Callers may override for tests.
        self._state_path = state_path if state_path is not None else vault_dir / "plugins.json"
        self._enabled_state = self._load_state()

    # ── state persistence ────────────────────────────────────────────

    def _load_state(self) -> dict[str, bool]:
        if self._state_path.exists():
            try:
                with open(self._state_path, encoding="utf-8") as f:
                    data = json.load(f)
                return data.get("enabled", {})
            except Exception:
                return {}
        return {}

    def _save_state(self) -> None:
        self._state_path.parent.mkdir(parents=True, exist_ok=True)
        with open(self._state_path, "w", encoding="utf-8") as f:
            json.dump({"enabled": self._enabled_state}, f, indent=2)

    # ── discovery security ──────────────────────────────────────────

    @staticmethod
    def _is_world_writable(path: Path) -> bool:
        """Reject directories that are writable by everyone (Unix only)."""
        if sys.platform == "win32":
            return False  # Windows ACLs don't have a world-writable bit
        try:
            return bool(path.stat().st_mode & stat.S_IWOTH)
        except OSError:
            return False

    @staticmethod
    def _escapes_root(plugin_dir: Path, root: Path) -> bool:
        """Reject symlinks that resolve outside the discovery root."""
        try:
            resolved = plugin_dir.resolve(strict=True)
            root_resolved = root.resolve(strict=True)
            # resolved must start with root_resolved
            return not str(resolved).startswith(str(root_resolved))
        except OSError:
            return True  # can't resolve → reject

    @staticmethod
    def _bad_ownership(path: Path) -> bool:
        """Reject directories not owned by the current user (Unix only)."""
        if sys.platform == "win32":
            return False
        try:
            return path.stat().st_uid != os.getuid()
        except (OSError, AttributeError):
            return False

    def _check_discovery_security(self, plugin_dir: Path, root: Path) -> str | None:
        """Return a reason string if the directory should be skipped, else None."""
        if self._escapes_root(plugin_dir, root):
            return f"symlink escapes discovery root {root}"
        if self._is_world_writable(plugin_dir):
            return "world-writable directory"
        if self._bad_ownership(plugin_dir):
            return "owned by different user"
        return None

    # ── discovery ────────────────────────────────────────────────────

    def discover(self) -> dict[str, PluginMeta]:
        """Scan all plugin directories and parse manifests."""
        self._plugins.clear()

        # Scan directories in priority order (later overrides earlier).
        # Bundled plugins are trusted (shipped with the package) — security
        # checks only apply to user-supplied plugin directories.
        bundled_dir = Path(__file__).parent / "bundled"
        user_dirs = [
            self._app_dir / "plugins",
            self._paths.resolve("plugins") if self._paths else self._vault_dir / "plugins",
        ]

        for base_dir, trusted in [(bundled_dir, True)] + [(d, False) for d in user_dirs]:
            if not base_dir.is_dir():
                continue
            for plugin_dir in sorted(base_dir.iterdir()):
                if not plugin_dir.is_dir():
                    continue

                # Security checks on non-bundled plugins
                if not trusted:
                    reason = self._check_discovery_security(plugin_dir, base_dir)
                    if reason:
                        log.warning(
                            "Plugin skipped (%s): %s",
                            reason,
                            plugin_dir,
                        )
                        continue

                manifest = plugin_dir / "plugin.yaml"
                if not manifest.exists():
                    manifest = plugin_dir / "plugin.yml"
                if not manifest.exists():
                    continue

                try:
                    meta = self._parse_manifest(manifest, plugin_dir)
                    # Apply saved enabled state (default: enabled)
                    if meta.name in self._enabled_state:
                        meta.enabled = self._enabled_state[meta.name]
                    self._plugins[meta.name] = meta
                except Exception as e:
                    log.warning("Failed to parse plugin manifest %s: %s", manifest, e)

        return self._plugins

    def _parse_manifest(self, path: Path, plugin_dir: Path) -> PluginMeta:
        with open(path, encoding="utf-8") as f:
            data = yaml.safe_load(f) or {}
        ui = data.get("ui") or {}
        if not isinstance(ui, dict):
            log.warning("Plugin %s: ui field must be a dict, got %s — ignored",
                        plugin_dir.name, type(ui).__name__)
            ui = {}
        return PluginMeta(
            name=data.get("name", plugin_dir.name),
            version=data.get("version", "0.0.0"),
            title=data.get("title", data.get("name", plugin_dir.name)),
            description=data.get("description", ""),
            author=data.get("author", ""),
            license=data.get("license", "MIT"),
            provides=data.get("provides", {"tools": [], "skills": [], "integrations": [], "routes": []}),
            contract_actions=data.get("contract_actions", []),
            ui=ui,
            path=plugin_dir,
        )

    # ── loading ──────────────────────────────────────────────────────

    async def load_all(self, ctx: PluginContext, app: Any = None) -> None:
        """Discover and load all enabled plugins."""
        self.discover()

        loaded = 0
        for name, meta in self._plugins.items():
            if not meta.enabled:
                continue
            try:
                await self._load_plugin(name, meta, ctx, app)
                loaded += 1
            except Exception as e:
                meta.error = str(e)
                log.error("Plugin %s failed to load: %s", name, e)

        log.debug("Loaded %d plugin(s) of %d discovered", loaded, len(self._plugins))

    async def _load_plugin(self, name: str, meta: PluginMeta, ctx: PluginContext, app: Any = None) -> None:
        plugin_py = meta.path / "plugin.py"
        if not plugin_py.exists():
            raise FileNotFoundError(f"plugin.py not found in {meta.path}")

        # Import the module. Pass ``submodule_search_locations`` so plugin.py
        # can do ``from . import sibling`` / ``from .helpers import X`` to
        # reach its own package directory — lets multi-file plugins exist
        # without each one having to inject its dir into sys.path manually.
        # The name uses underscores only (not hyphens) so that relative
        # imports resolve through the Python import machinery cleanly.
        module_name = f"agntspace_plugin_{name.replace('-', '_')}"
        spec = importlib.util.spec_from_file_location(
            module_name,
            plugin_py,
            submodule_search_locations=[str(meta.path)],
        )
        if not spec or not spec.loader:
            raise ImportError(f"Cannot load {plugin_py}")
        module = importlib.util.module_from_spec(spec)
        # Register before exec so relative imports inside the plugin can find
        # the parent package during initial module construction.
        sys.modules[module_name] = module
        try:
            spec.loader.exec_module(module)
        except Exception:
            sys.modules.pop(module_name, None)
            raise

        # Find plugin instance
        instance: PluginBase | None = None
        if hasattr(module, "plugin") and isinstance(module.plugin, PluginBase):
            instance = module.plugin
        elif hasattr(module, "create_plugin") and callable(module.create_plugin):
            instance = module.create_plugin()

        if not instance:
            raise TypeError(f"No 'plugin' instance or 'create_plugin()' factory in {plugin_py}")

        # Create per-plugin logger and data dir
        plugin_log = logging.getLogger(f"agntspace.plugins.{name}")
        data_dir = ctx.plugin_data_dir / name / "data"
        data_dir.mkdir(parents=True, exist_ok=True)

        plugin_ctx = PluginContext(
            registry=ctx.registry,
            vault=ctx.vault,
            vault_writer=ctx.vault_writer,
            llm=ctx.llm,
            settings=ctx.settings,
            db=ctx.db,
            skill_loader=ctx.skill_loader,
            plugin_data_dir=data_dir,
            log=plugin_log,
            hooks=ctx.hooks,
            activity_logger=ctx.activity_logger,
            decisions=ctx.decisions,
            conversation_memory=ctx.conversation_memory,
        )

        # Initialize — sync first (tool/hook registration), then optional async
        instance.init(plugin_ctx)

        # async_init() is optional — only call if the plugin overrides the
        # base no-op. This lets plugins open DB connections, call APIs, etc.
        if type(instance).async_init is not PluginBase.async_init:
            await instance.async_init(plugin_ctx)

        self._instances[name] = instance
        meta.error = ""

        # Mount custom routes if any
        if app is not None:
            router = instance.get_router()
            if router:
                app.include_router(router, prefix=f"/api/plugins/{name}")

        log.debug("Plugin loaded: %s v%s", meta.title, meta.version)

    # ── enable / disable ─────────────────────────────────────────────

    async def enable(self, name: str, ctx: PluginContext | None = None, app: Any = None) -> bool:
        meta = self._plugins.get(name)
        if not meta:
            return False
        meta.enabled = True
        self._enabled_state[name] = True
        self._save_state()

        if name not in self._instances and ctx:
            try:
                await self._load_plugin(name, meta, ctx, app)
            except Exception as e:
                meta.error = str(e)
                log.error("Failed to enable plugin %s: %s", name, e)
                return False
        return True

    def disable(self, name: str) -> bool:
        meta = self._plugins.get(name)
        if not meta:
            return False
        meta.enabled = False
        self._enabled_state[name] = False
        self._save_state()

        instance = self._instances.pop(name, None)
        if instance:
            try:
                instance.shutdown()
            except Exception as e:
                log.warning("Plugin %s shutdown error: %s", name, e)
        # Unregister any hook handlers this plugin had attached.
        hooks = getattr(self, "_hooks", None)
        if hooks is not None:
            try:
                hooks.unregister_plugin(name)
            except Exception as e:
                log.warning("Plugin %s hook unregister error: %s", name, e)
        return True

    # ── query ────────────────────────────────────────────────────────

    def list_plugins(self) -> list[dict]:
        return [meta.to_dict() for meta in self._plugins.values()]

    def get_plugin(self, name: str) -> dict | None:
        meta = self._plugins.get(name)
        return meta.to_dict() if meta else None

    # ── shutdown ─────────────────────────────────────────────────────

    def shutdown_all(self) -> None:
        # Reverse insertion order so plugins that depend on earlier ones tear
        # down first. Mirrors OpenClaw's reverse-order service teardown.
        hooks = getattr(self, "_hooks", None)
        for name, instance in reversed(list(self._instances.items())):
            try:
                instance.shutdown()
            except Exception as e:
                log.warning("Plugin %s shutdown error: %s", name, e)
            if hooks is not None:
                try:
                    hooks.unregister_plugin(name)
                except Exception as e:
                    log.warning("Plugin %s hook unregister error: %s", name, e)
        self._instances.clear()
