#!/usr/bin/env python3
"""
BGRTX plugin hotswap core · no server · SPV rides BGF
Sockets seat plugs · history undo/redo · validate · spit
"""
from __future__ import annotations

import json
import shutil
import time
from dataclasses import dataclass, asdict, field
from pathlib import Path
from typing import Any, Dict, List, Optional

ROOT = Path(__file__).resolve().parents[1]
SPVS = ROOT / "spvs"
CUR = ROOT / "current"
OUT = ROOT / "out"
HIST = ROOT / "current" / "history.json"
GPU_SPVS = ROOT.parent / "datacenter" / "spvs"
MAN = SPVS / "manifest.json"

# ESSIE = this hotswap system (not EZZIE atom)
ESSIE = {
    "name": "ESSIE",
    "role": "hotswap system",
    "not": "EZZIE",
    "cli": "wb",
}

SOCKETS = {
    "ui.stage": {
        "kind": "ui",
        # accepts extended by protocol family · any manifest plug id seats here
        "accepts": [
            "js",
            "dom",
            "grin",
            "html5_all",
            "css_all",
            "mp4",
            "stream",
            "nv_mp4",
            "nv",
            "nvenc",
            "kate",
            "term",
            "js_all",
            "jseng",
            "fox",
            "term",
            "essie",
            "article",
            "nav",
            "figure",
            "neon",
            "measure",
            "ezzie",
            "phi",
            "thermo",
            # protocol SPV wave
            "rtmp",
            "ssh",
            "telnet",
            "ftp",
            "sftp",
            "scp",
            "http",
            "https",
            "dns",
            "icmp",
            "ntp",
            "smtp",
            "imap",
            "ws",
            "mqtt",
            "tcp",
            "udp",
            "serial",
            "vnc",
            "rsync",
            "git",
            "socks",
            "proto_hub",
            "term",
        ],
        "desc": "ESSIE stage · HTML5/CSS · Stream · 20+ protocol SPVs · free lane",
    },
    "gpu.compute": {
        "kind": "compute",
        "accepts": ["comp", "spv", "raymarch", "shell"],
        "desc": "GPU .comp/.spv siblings · not a web server",
    },
}


def bgf(a: int, b: int) -> int:
    return (a - b) | 1


def spv3(a: int, b: int, c: int) -> int:
    return (a ^ b ^ c) | 1


def ensure_dirs() -> None:
    SPVS.mkdir(parents=True, exist_ok=True)
    CUR.mkdir(parents=True, exist_ok=True)
    OUT.mkdir(parents=True, exist_ok=True)
    GPU_SPVS.mkdir(parents=True, exist_ok=True)


def load_manifest() -> Dict[str, Any]:
    ensure_dirs()
    if not MAN.exists():
        return {"plugs": [], "ride": "BGF", "ground": "BGS"}
    return json.loads(MAN.read_text())


def save_manifest(man: Dict[str, Any]) -> None:
    ensure_dirs()
    MAN.write_text(json.dumps(man, indent=2) + "\n")


def plugs_by_id() -> Dict[str, Dict[str, Any]]:
    man = load_manifest()
    return {p["id"]: p for p in man.get("plugs", []) if "id" in p}


def load_seats() -> Dict[str, Any]:
    ensure_dirs()
    p = CUR / "manifest.json"
    if not p.exists():
        return {
            "hotswap": True,
            "ride": "BGF",
            "seats": {},
            "active": None,
            "history_i": -1,
        }
    data = json.loads(p.read_text())
    # migrate old single-active format
    if "seats" not in data:
        seats = {}
        if data.get("active"):
            seats["ui.stage"] = data["active"]
        data["seats"] = seats
        data.setdefault("history_i", -1)
    return data


def save_seats(data: Dict[str, Any]) -> None:
    ensure_dirs()
    data["hotswap"] = True
    data["ride"] = "BGF"
    data["updated"] = time.strftime("%Y-%m-%dT%H:%M:%S")
    # free fingerprint of seat ids
    ids = "".join(sorted((data.get("seats") or {}).keys()))
    data["free"] = spv3(len(ids), len(data.get("seats") or {}), 7)
    (CUR / "manifest.json").write_text(json.dumps(data, indent=2) + "\n")


def load_history() -> Dict[str, Any]:
    if not HIST.exists():
        return {"stack": [], "i": -1}
    return json.loads(HIST.read_text())


def save_history(h: Dict[str, Any]) -> None:
    ensure_dirs()
    # keep last 32
    h["stack"] = (h.get("stack") or [])[-32:]
    HIST.write_text(json.dumps(h, indent=2) + "\n")


def push_history(seats_snapshot: Dict[str, Any]) -> None:
    h = load_history()
    stack = h.get("stack") or []
    i = h.get("i", -1)
    # drop redo tail
    if i >= 0 and i < len(stack) - 1:
        stack = stack[: i + 1]
    stack.append({"ts": time.time(), "seats": seats_snapshot})
    h["stack"] = stack
    h["i"] = len(stack) - 1
    save_history(h)


def validate_ui_plug(p: Dict[str, Any]) -> List[str]:
    errs = []
    if not p.get("id"):
        errs.append("missing id")
    for k in ("js", "dom"):
        name = p.get(k)
        if not name:
            errs.append(f"missing {k}")
            continue
        path = SPVS / name
        if not path.exists():
            errs.append(f"missing file {path}")
    rides = p.get("rides", "BGF")
    if rides != "BGF":
        errs.append(f"rides={rides} expected BGF")
    return errs


def list_gpu_plugs() -> List[Dict[str, Any]]:
    out = []
    if not GPU_SPVS.exists():
        return out
    comps = {p.stem: p for p in GPU_SPVS.glob("*.comp")}
    spvs = {p.stem: p for p in GPU_SPVS.glob("*.spv")}
    for stem in sorted(set(comps) | set(spvs)):
        out.append(
            {
                "id": stem,
                "kind": "compute",
                "comp": comps[stem].name if stem in comps else None,
                "spv": spvs[stem].name if stem in spvs else None,
                "rides": "BGF",
                "socket": "gpu.compute",
            }
        )
    return out


def clear_ui_current_files() -> None:
    for f in CUR.iterdir():
        if f.name in ("history.json",):
            continue
        if f.is_file() and f.name != "README":
            # keep history
            if f.name == "manifest.json":
                continue
            f.unlink()


def seat_ui(plug_id: str, *, record_history: bool = True) -> Dict[str, Any]:
    plugs = plugs_by_id()
    # aliases
    if plug_id == "stream" and "mp4" in plugs:
        plug_id = "mp4"
    if plug_id in ("nv", "nvenc", "nvidia") and "nv_mp4" in plugs:
        plug_id = "nv_mp4"
    if plug_id in ("jseng", "js100", "js") and "js_all" in plugs:
        plug_id = "js_all"
    if plug_id not in plugs:
        raise SystemExit(f"unknown ui plug: {plug_id} · wb list")
    p = dict(plugs[plug_id])
    errs = validate_ui_plug(p)
    if errs:
        raise SystemExit("validate fail: " + "; ".join(errs))
    ensure_dirs()
    seats_data = load_seats()
    # clear previous ui files (not history)
    for f in list(CUR.iterdir()):
        if f.name in ("history.json", "manifest.json", "gpu.seat.json"):
            continue
        if f.suffix in (".js", ".html") or f.name.endswith(".dom.html"):
            f.unlink()
    for k in ("js", "dom"):
        name = p.get(k)
        if not name:
            continue
        src = SPVS / name
        shutil.copy2(src, CUR / name)
    seats = dict(seats_data.get("seats") or {})
    seats["ui.stage"] = p
    seats_data["seats"] = seats
    seats_data["active"] = p  # compat
    seats_data["library"] = "spvs"
    save_seats(seats_data)
    if record_history:
        push_history(dict(seats))
    return p


def seat_gpu(plug_id: str, *, record_history: bool = True) -> Dict[str, Any]:
    gpus = {g["id"]: g for g in list_gpu_plugs()}
    if plug_id not in gpus:
        raise SystemExit(f"unknown gpu plug: {plug_id} · wb list-gpu")
    g = gpus[plug_id]
    seats_data = load_seats()
    seats = dict(seats_data.get("seats") or {})
    seats["gpu.compute"] = g
    seats_data["seats"] = seats
    marker = CUR / "gpu.seat.json"
    marker.write_text(json.dumps(g, indent=2) + "\n")
    save_seats(seats_data)
    if record_history:
        push_history(dict(seats))
    return g


def unseat(socket_id: str) -> None:
    if socket_id not in SOCKETS:
        raise SystemExit(f"unknown socket: {socket_id}")
    seats_data = load_seats()
    push_history(dict(seats_data.get("seats") or {}))
    seats = dict(seats_data.get("seats") or {})
    seats.pop(socket_id, None)
    seats_data["seats"] = seats
    if socket_id == "ui.stage":
        seats_data["active"] = None
        for f in list(CUR.iterdir()):
            if f.suffix in (".js", ".html") or f.name.endswith(".dom.html"):
                f.unlink()
    if socket_id == "gpu.compute":
        g = CUR / "gpu.seat.json"
        if g.exists():
            g.unlink()
    save_seats(seats_data)


def history_undo() -> Optional[Dict[str, Any]]:
    h = load_history()
    stack = h.get("stack") or []
    i = h.get("i", -1)
    if i < 0 or not stack:
        return None
    # save present first if needed — already on stack
    i -= 1
    h["i"] = i
    save_history(h)
    if i < 0:
        seats_data = load_seats()
        seats_data["seats"] = {}
        seats_data["active"] = None
        save_seats(seats_data)
        return {}
    snap = stack[i]["seats"]
    _restore_seats(snap)
    return snap


def history_redo() -> Optional[Dict[str, Any]]:
    h = load_history()
    stack = h.get("stack") or []
    i = h.get("i", -1)
    if i >= len(stack) - 1:
        return None
    i += 1
    h["i"] = i
    save_history(h)
    snap = stack[i]["seats"]
    _restore_seats(snap)
    return snap


def _restore_seats(snap: Dict[str, Any]) -> None:
    seats_data = load_seats()
    seats_data["seats"] = snap
    # restore ui files
    for f in list(CUR.iterdir()):
        if f.suffix in (".js", ".html") or f.name.endswith(".dom.html"):
            f.unlink()
    ui = snap.get("ui.stage")
    if ui:
        seats_data["active"] = ui
        for k in ("js", "dom"):
            name = ui.get(k)
            if name and (SPVS / name).exists():
                shutil.copy2(SPVS / name, CUR / name)
    else:
        seats_data["active"] = None
    gpu = snap.get("gpu.compute")
    if gpu:
        (CUR / "gpu.seat.json").write_text(json.dumps(gpu, indent=2) + "\n")
    elif (CUR / "gpu.seat.json").exists():
        (CUR / "gpu.seat.json").unlink()
    save_seats(seats_data)


def status() -> Dict[str, Any]:
    man = load_manifest()
    seats = load_seats()
    h = load_history()
    return {
        "essie": ESSIE,
        "law": man.get("law"),
        "ride": "BGF",
        "ground": "BGS",
        "truth": man.get("truth", "GRIN"),
        "display": man.get("display"),
        "sockets": SOCKETS,
        "library_count": len(man.get("plugs", [])),
        "gpu_count": len(list_gpu_plugs()),
        "seats": seats.get("seats") or {},
        "active_ui": (seats.get("seats") or {}).get("ui.stage") or seats.get("active"),
        "history_i": h.get("i", -1),
        "history_len": len(h.get("stack") or []),
        "free": seats.get("free"),
        "no_server": True,
        "plugs": [p.get("id") for p in man.get("plugs", [])],
    }


def spit_bundle() -> Path:
    seats = load_seats()
    if not seats.get("seats") and not seats.get("active"):
        raise SystemExit("nothing seated · wb in <id> first")
    stamp = time.strftime("%Y%m%d-%H%M%S")
    dest = OUT / f"spit-{stamp}"
    dest.mkdir(parents=True, exist_ok=True)
    # copy current files
    for f in CUR.iterdir():
        if f.is_file() and f.name != "history.json":
            shutil.copy2(f, dest / f.name)
    bundle = {
        "spit_at": stamp,
        "seats": seats.get("seats"),
        "files": {},
        "ride": "BGF",
        "free": seats.get("free"),
    }
    for f in CUR.iterdir():
        if not f.is_file():
            continue
        if f.suffix in (".js", ".html") or f.name.endswith(".dom.html") or f.name.endswith(".json"):
            if f.name == "history.json":
                continue
            bundle["files"][f.name] = f.read_text(errors="replace")
    (dest / "spit.json").write_text(json.dumps(bundle, indent=2) + "\n")
    return dest


def write_back_library() -> List[str]:
    seats = load_seats()
    ui = (seats.get("seats") or {}).get("ui.stage") or seats.get("active")
    written = []
    if not ui:
        return written
    for k in ("js", "dom"):
        name = ui.get(k)
        if name and (CUR / name).exists():
            shutil.copy2(CUR / name, SPVS / name)
            written.append(name)
    return written


def next_ui(delta: int = 1) -> Dict[str, Any]:
    plugs = load_manifest().get("plugs", [])
    if not plugs:
        raise SystemExit("empty library")
    seats = load_seats()
    cur = (seats.get("seats") or {}).get("ui.stage") or seats.get("active") or {}
    ids = [p["id"] for p in plugs]
    try:
        i = ids.index(cur.get("id"))
    except ValueError:
        i = -1
    i = (i + delta) % len(ids)
    return seat_ui(ids[i])


def validate_all() -> Dict[str, Any]:
    man = load_manifest()
    report = {"ui": {}, "gpu": {}, "ok": True}
    for p in man.get("plugs", []):
        errs = validate_ui_plug(p)
        report["ui"][p.get("id", "?")] = errs or ["ok"]
        if errs:
            report["ok"] = False
    for g in list_gpu_plugs():
        errs = []
        if not g.get("comp") and not g.get("spv"):
            errs.append("no comp/spv")
        report["gpu"][g["id"]] = errs or ["ok"]
        if errs:
            report["ok"] = False
    return report
