"""
Plugin discovery, loading and dispatch.

Scans ``backend/plugins/*.py``, imports each one, and collects any module-level
``Plugin`` instance. Reloading is supported at runtime so you can edit a plugin
and press "Reload" in the Extensions tab without restarting the server.

A plugin that fails to import does not take the dashboard down - the error is
recorded and shown in the UI next to the plugin that caused it.
"""

from __future__ import annotations

import asyncio
import importlib
import importlib.util
import sys
import traceback
from pathlib import Path

from .plugin_api import Context, Plugin, invoke

PLUGIN_DIR = Path(__file__).resolve().parent / "plugins"


class Registry:
    def __init__(self, hub, config: dict):
        self.hub = hub
        self.config = config
        self.bridge = None
        self.plugins: dict[str, Plugin] = {}
        self.errors: list[dict] = []
        self._tasks: dict[str, asyncio.Task] = {}
        self._storage: dict[str, dict] = {}

    def attach(self, bridge) -> None:
        self.bridge = bridge

    # -- loading ------------------------------------------------------------

    async def load(self) -> dict:
        """Import every plugin file. Safe to call repeatedly."""
        await self._stop_tasks()
        self.plugins.clear()
        self.errors.clear()

        PLUGIN_DIR.mkdir(parents=True, exist_ok=True)
        init = PLUGIN_DIR / "__init__.py"
        if not init.exists():
            init.write_text("", encoding="utf-8")

        # A leading underscore marks a file as private: __init__.py and the
        # _template.py starting point are both skipped.
        for path in sorted(PLUGIN_DIR.glob("*.py")):
            if path.name.startswith("_"):
                continue
            self._load_file(path)

        for plugin in self.plugins.values():
            plugin.storage = self._storage.setdefault(plugin.id, {})
            await self._start_plugin(plugin)

        summary = {
            "loaded": sorted(self.plugins),
            "errors": list(self.errors),
            "count": len(self.plugins),
        }
        if self.errors:
            await self.hub.emit("warn", "plugins",
                                f"{len(self.plugins)} plugin(s) loaded, {len(self.errors)} failed")
        else:
            await self.hub.emit("info", "plugins", f"{len(self.plugins)} plugin(s) loaded")
        return summary

    def _load_file(self, path: Path) -> None:
        module_name = f"backend.plugins.{path.stem}"
        try:
            # Always build a fresh module from the file rather than calling
            # importlib.reload. reload() requires the parent package to be in
            # sys.modules and re-runs stale bytecode paths; executing the file
            # afresh is both simpler and a true reload of what is on disk.
            spec = importlib.util.spec_from_file_location(module_name, path)
            if spec is None or spec.loader is None:
                raise ImportError(f"Cannot build import spec for {path.name}")
            module = importlib.util.module_from_spec(spec)
            sys.modules[module_name] = module
            spec.loader.exec_module(module)

            found = [v for v in vars(module).values() if isinstance(v, Plugin)]
            if not found:
                self.errors.append({
                    "file": path.name,
                    "error": "No Plugin instance found at module level",
                    "trace": "",
                })
                return

            for plugin in found:
                if plugin.id in self.plugins:
                    self.errors.append({
                        "file": path.name,
                        "error": f"Duplicate plugin id '{plugin.id}' - ignoring this one",
                        "trace": "",
                    })
                    continue
                self.plugins[plugin.id] = plugin

        except Exception as exc:
            self.errors.append({
                "file": path.name,
                "error": f"{type(exc).__name__}: {exc}",
                "trace": traceback.format_exc(limit=6),
            })
            sys.modules.pop(module_name, None)

    async def _start_plugin(self, plugin: Plugin) -> None:
        ctx = self.context(plugin)
        if plugin._on_start:
            try:
                await invoke(plugin._on_start, ctx)
            except Exception as exc:
                self.errors.append({
                    "file": plugin.id,
                    "error": f"on_start failed: {exc}",
                    "trace": traceback.format_exc(limit=6),
                })
        if plugin._on_tick:
            self._tasks[plugin.id] = asyncio.create_task(
                self._tick_loop(plugin), name=f"plugin-{plugin.id}"
            )

    async def _tick_loop(self, plugin: Plugin) -> None:
        ctx = self.context(plugin)
        failures = 0
        while True:
            try:
                await asyncio.sleep(plugin._tick_interval)
                await invoke(plugin._on_tick, ctx)
                failures = 0
            except asyncio.CancelledError:
                raise
            except Exception as exc:
                failures += 1
                await self.hub.emit("error", f"plugin:{plugin.id}", f"Tick failed: {exc}")
                if failures >= 5:
                    await self.hub.emit("warn", f"plugin:{plugin.id}",
                                        "Tick disabled after 5 consecutive failures")
                    return
                await asyncio.sleep(2.0)

    async def _stop_tasks(self) -> None:
        for task in self._tasks.values():
            task.cancel()
        for task in list(self._tasks.values()):
            try:
                await task
            except (asyncio.CancelledError, Exception):
                pass
        self._tasks.clear()

    async def shutdown(self) -> None:
        await self._stop_tasks()

    # -- dispatch -----------------------------------------------------------

    def context(self, plugin: Plugin) -> Context:
        return Context(
            bridge=self.bridge,
            hub=self.hub,
            config=self.config,
            storage=self._storage.setdefault(plugin.id, {}),
            plugin_id=plugin.id,
        )

    async def dispatch(self, plugin_id: str, control_key: str, value=None) -> dict:
        plugin = self.plugins.get(plugin_id)
        if plugin is None:
            return {"ok": False, "message": f"No plugin '{plugin_id}'"}

        control = plugin.find(control_key)
        if control is None or control.handler is None:
            return {"ok": False, "message": f"No control '{control_key}' in '{plugin_id}'"}

        try:
            result = await invoke(control.handler, self.context(plugin), value)
        except Exception as exc:
            await self.hub.emit("error", f"plugin:{plugin_id}", f"{control_key}: {exc}")
            return {"ok": False, "message": f"{type(exc).__name__}: {exc}",
                    "detail": traceback.format_exc(limit=4)}

        if isinstance(result, dict) and "ok" in result:
            return result
        return {"ok": True, "message": str(result) if result is not None else "Done"}

    # -- introspection ------------------------------------------------------

    def manifest(self) -> dict:
        return {
            "plugins": sorted((p.manifest() for p in self.plugins.values()),
                              key=lambda m: (m["order"], m["name"])),
            "errors": list(self.errors),
            "directory": str(PLUGIN_DIR),
        }
