"""Drop-in plugin loader.

A plugin is a folder under DATA_DIR/plugins/ with a plugin.json manifest:

    {
      "id": "cs2-pack",                  // slug; should match the folder name
      "name": "Counter-Strike 2 Pack",
      "version": "1.0.0",
      "api_version": 1,                  // plugin API generation it targets
      "min_app_version": "0.1.0",
      "capabilities": ["game_adapter"],  // game_adapter|media_provider|publisher|theme
      "entry": "plugin.py",              // omitted for data-only (theme) plugins
      "settings_schema": { "demo_dir": {"type": "path", "default": ""} },
      "author": "...", "homepage": "..."
    }

Install = unzip into the plugins folder; nothing executes before the next
boot. The entry module is imported in isolation (no sys.path pollution) and
wired by declared capability:

    game_adapter    module itself is an adapter (GAME + parse), or ADAPTERS = [..]
    media_provider  PROVIDERS = [..]
    publisher       PUBLISHERS = [..]
    theme           themes/<id>.css (+ optional themes/<id>.json {label, dark})

TRUST: plugins are arbitrary local code running at the app's own privilege.
There is no sandbox — install plugins you trust, the same rule as any
program you download. Curation happens at the distribution layer.

Error isolation: a plugin that fails to import or validate is reported in
/api/plugins with status "error" (or "missing-dep" for ImportError) and the
app boots without it. Runtime callbacks are guarded at the call sites
(e.g. adapter.parse errors are logged and dropped by the GSI ingest).
"""
import importlib.util
import json
import re
import traceback

from clipshelf import API_VERSION, __version__
from clipshelf.plugin_host import registry

PLUGINS: dict = {}   # id -> info dict (id, name, version, status, error, ...)

_ID = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")


def _ver(s) -> tuple:
    try:
        return tuple(int(x) for x in str(s).split("."))
    except ValueError:
        return (0,)


def _import_entry(pid: str, path):
    spec = importlib.util.spec_from_file_location(f"product_plugin_{pid.replace('-', '_')}", path)
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod


def _wire(info: dict, mod, pdir):
    caps = info["capabilities"]
    wired = []
    if "game_adapter" in caps:
        adapters = getattr(mod, "ADAPTERS", None)
        if adapters is None and mod is not None and hasattr(mod, "GAME"):
            adapters = [mod]
        for a in adapters or []:
            slug = registry.register_game_adapter(a, source=info["id"])
            assets = getattr(a, "ASSETS_DIR", None)
            if assets:
                info.setdefault("asset_dirs", []).append(str((pdir / assets).resolve()))
            wired.append(f"game:{slug}")
    if "media_provider" in caps:
        for p in getattr(mod, "PROVIDERS", []) or []:
            wired.append(f"provider:{registry.register_media_provider(p, source=info['id'])}")
    if "publisher" in caps:
        for p in getattr(mod, "PUBLISHERS", []) or []:
            wired.append(f"publisher:{registry.register_publisher(p, source=info['id'])}")
    if "theme" in caps:
        tdir = pdir / "themes"
        for css in sorted(tdir.glob("*.css")) if tdir.is_dir() else []:
            meta = {}
            mp = css.with_suffix(".json")
            if mp.exists():
                try:
                    meta = json.loads(mp.read_text(encoding="utf-8"))
                except Exception:
                    meta = {}
            wired.append(f"theme:{registry.register_theme(css.stem, meta, css, source=info['id'])}")
    info["provides"] = wired


def _settings_domain(info: dict):
    """Manifest settings_schema (JSON) → a registered PresetDomain
    'plugin-<id>' so plugin settings ride the shared preset store/UI."""
    schema_json = info.get("settings_schema") or {}
    if not schema_json:
        return
    from clipshelf import presets
    defaults, clamp = {}, {}
    for key, spec in schema_json.items():
        if not isinstance(spec, dict):
            continue
        t = spec.get("type", "str")
        d = spec.get("default")
        if t in ("str", "path"):
            clamp[key] = ("str", int(spec.get("maxlen", 260)))
            defaults[key] = str(d or "")
        elif t == "num":
            clamp[key] = ("num", float(spec.get("min", -1e12)), float(spec.get("max", 1e12)))
            defaults[key] = float(d or 0)
        elif t == "int":
            clamp[key] = ("int", int(spec.get("min", -10**12)), int(spec.get("max", 10**12)))
            defaults[key] = int(d or 0)
        elif t == "bool":
            clamp[key] = ("bool",)
            defaults[key] = bool(d)
        elif t == "enum":
            choices = [str(c) for c in (spec.get("choices") or [])] or [""]
            clamp[key] = ("enum", choices)
            defaults[key] = d if d in choices else choices[0]
    if defaults:
        presets.register(presets.PresetDomain(f"plugin-{info['id']}", defaults, clamp))


def load_all(plugins_dir=None, disabled=None) -> dict:
    """Scan, validate, import and wire every plugin. Returns PLUGINS."""
    from clipshelf.config import CFG, PLUGINS_DIR
    plugins_dir = plugins_dir or PLUGINS_DIR
    disabled = set(disabled if disabled is not None
                   else CFG.get("plugins_disabled") or [])

    PLUGINS.clear()
    registry.reset()
    registry.init_core()

    if not plugins_dir.is_dir():
        return PLUGINS
    for pdir in sorted(p for p in plugins_dir.iterdir() if p.is_dir()):
        mf = pdir / "plugin.json"
        if not mf.exists():
            continue
        info = {"id": pdir.name, "dir": str(pdir), "status": "ok", "error": None,
                "capabilities": [], "provides": []}
        try:
            manifest = json.loads(mf.read_text(encoding="utf-8"))
            pid = str(manifest.get("id") or pdir.name)
            if not _ID.match(pid):
                raise ValueError(f"bad plugin id '{pid}'")
            info.update(
                id=pid,
                name=str(manifest.get("name") or pid)[:80],
                version=str(manifest.get("version") or "0")[:20],
                api_version=int(manifest.get("api_version") or 1),
                capabilities=[c for c in (manifest.get("capabilities") or [])
                              if c in ("game_adapter", "media_provider", "publisher", "theme")],
                author=str(manifest.get("author") or "")[:80],
                homepage=str(manifest.get("homepage") or "")[:200],
                settings_schema=manifest.get("settings_schema") or {},
            )
            if info["api_version"] > API_VERSION:
                raise ValueError(f"needs plugin API v{info['api_version']}, app has v{API_VERSION}")
            if _ver(manifest.get("min_app_version") or "0") > _ver(__version__):
                raise ValueError(f"needs app >= {manifest['min_app_version']}")
            if info["id"] in disabled:
                info["status"] = "disabled"
                PLUGINS[info["id"]] = info
                continue
            mod = None
            entry = manifest.get("entry")
            if entry:
                epath = (pdir / entry).resolve()
                if not str(epath).startswith(str(pdir.resolve())) or not epath.exists():
                    raise ValueError(f"entry '{entry}' missing or escapes the plugin dir")
                mod = _import_entry(info["id"], epath)
            _wire(info, mod, pdir)
            _settings_domain(info)
        except ImportError as e:
            info.update(status="missing-dep", error=str(e))
        except Exception as e:
            info.update(status="error",
                        error="".join(traceback.format_exception_only(type(e), e)).strip(),
                        trace=traceback.format_exc()[-2000:])
        PLUGINS[info["id"]] = info
        tag = info["status"] if info["status"] != "ok" else ", ".join(info["provides"]) or "ok"
        print(f"[plugins] {info['id']} {info.get('version', '')}: {tag}", flush=True)
    return PLUGINS
