"""Run a plugin in an isolated subprocess under capability enforcement.

This is the first increment of true process isolation. It launches the plugin
in a **separate Python process** with a global capability enforcer
(:mod:`mixlar.enforce`) installed *before* the plugin is imported, so anything
it does beyond its declared ``permissions`` is blocked — and, being a child
process, a crash or hang can't take the app down (it's killed on timeout).

What this increment proves and covers:

  * Plugins whose runtime is self-contained (the ``CAP_ONLY`` set — network /
    native / subprocess / input device logic) load and run confined to exactly
    the capabilities they declared.
  * Undeclared capability use is caught as a :class:`enforce.CapabilityViolation`.

What is intentionally **not** here yet (documented in ``docs/sandbox.md``):
plugins that build their settings/editor UI in-process with the app's Qt objects
and injected style globals, or that reach into the running app object (e.g.
Spotify's ``_get_spotify_client``), need a UI/app-API **bridge** over IPC before
they can run fully isolated. Until that lands, the shipping app keeps running
those in-process, guarded by the same enforcer applied per-plugin (least
privilege) — the security win without the compatibility break.
"""
from __future__ import annotations

import json
import os
import subprocess
import sys
from dataclasses import dataclass
from typing import List, Optional

from . import permissions as _perms
from . import manifest as _manifest

_MARKER = "__MIXLAR_SANDBOX__"


@dataclass
class SandboxResult:
    ok: bool
    loaded: bool = False
    instantiated: bool = False
    note: str = ""
    error: str = ""
    violations: Optional[List[dict]] = None
    raw: str = ""

    def __bool__(self) -> bool:
        return self.ok


def run_isolated(pkg_dir: str, permissions: Optional[List[str]] = None,
                 timeout: float = 20.0) -> SandboxResult:
    """Load a plugin in an isolated child process confined to ``permissions``.

    If ``permissions`` is None, uses the manifest's declared permissions.
    Returns a :class:`SandboxResult`.
    """
    man, err = _manifest.read_manifest(pkg_dir)
    if err:
        return SandboxResult(ok=False, error=f"invalid manifest: {err}")
    caps = permissions if permissions is not None else sorted(_perms.declared_caps(man))

    # Child inherits our sys.path so it can import the same `mixlar`.
    env = dict(os.environ)
    env["PYTHONPATH"] = os.pathsep.join(
        [p for p in sys.path if p] + [env.get("PYTHONPATH", "")]).strip(os.pathsep)

    cmd = [sys.executable, "-I", "-m", "mixlar._sandbox_child", pkg_dir, ",".join(caps)]
    # -I is isolated mode; drop it if it strips our PYTHONPATH need. Keep env.
    cmd = [sys.executable, "-m", "mixlar._sandbox_child", pkg_dir, ",".join(caps)]
    try:
        proc = subprocess.run(cmd, capture_output=True, text=True,
                              timeout=timeout, env=env)
    except subprocess.TimeoutExpired:
        return SandboxResult(ok=False, error=f"plugin hung (killed after {timeout}s)")

    payload = _extract(proc.stdout)
    if payload is None:
        return SandboxResult(ok=False,
                             error=f"no sandbox result (exit {proc.returncode}): "
                                   f"{(proc.stderr or proc.stdout)[-400:]}",
                             raw=proc.stdout)
    return SandboxResult(
        ok=bool(payload.get("ok")),
        loaded=bool(payload.get("loaded")),
        instantiated=bool(payload.get("instantiated")),
        note=payload.get("note", ""),
        error=payload.get("error", ""),
        violations=payload.get("violations"),
        raw=proc.stdout,
    )


def _extract(stdout: str) -> Optional[dict]:
    for line in reversed(stdout.splitlines()):
        if line.startswith(_MARKER):
            try:
                return json.loads(line[len(_MARKER):])
            except ValueError:
                return None
    return None


__all__ = ["run_isolated", "SandboxResult"]
