#!/usr/bin/env python3
"""server — The Bridge: always-on ParVagues web dashboard.

A proper webserver (stdlib http.server, same lineage as the Foundry's) serving a
Ship's Bridge dashboard: a live perf/thermal toolbar (folded from perf-tray) plus
an extensible hub of the fleet's tools. Designed to run as a systemd --user
service (parvagues-bridge.service).

    python3 bridge.py serve                 # http://127.0.0.1:8773/
    python3 bridge.py serve --host 0.0.0.0  # opt into LAN (phone)

Binds 127.0.0.1 by DEFAULT: POST /api/perf flips the CPU governor via sudo, so
mode-switching must not be LAN-triggerable unless you say so.

API:
    GET  /api/perf          → perf.snapshot()  (mode, temps, freq, fans, throttle)
    POST /api/perf {mode}   → switch perf mode  → {ok, msg, snapshot}
    GET  /api/launchers     → {web:[…], apps:[…]}  (live running/available state)
    POST /api/launch {key}  → start-or-open a tool → {ok, status, msg, url?}
    GET  /api/health        → {ok:true}
"""
from __future__ import annotations

import json
import os
import queue
import socket
import sys
import time
from functools import partial
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse, parse_qs

HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
import perf as P  # noqa: E402
import launchers as L  # noqa: E402
import midistream as MS  # noqa: E402

UI = HERE / "ui"

# One Thermals instance per process → throttle_delta is relative to Bridge start.
_THERM = P.Thermals()
# One MIDI fan-out for the whole Bridge (lazy: opens aseqdump only while watched).
_MIDI = MS.MidiStream()


class Handler(BaseHTTPRequestHandler):
    host_is_lan = False

    def do_GET(self):
        p = urlparse(self.path).path
        if p in ("/", ""):
            return self._file(UI / "index.html", "text/html; charset=utf-8")
        if p == "/api/health":
            return self._json({"ok": True})
        if p == "/api/perf":
            return self._json(P.snapshot(_THERM))
        if p == "/api/launchers":
            return self._json(L.snapshot())
        if p == "/api/midi/ports":
            return self._json(_MIDI.list_ports())
        if p == "/api/midi/stream":
            return self._sse_midi(parse_qs(urlparse(self.path).query))
        # static assets under ui/
        asset = (UI / p.lstrip("/")).resolve()
        if UI in asset.parents and asset.is_file():
            return self._file(asset, self._ctype(asset))
        return self.send_error(404)

    def do_POST(self):
        p = urlparse(self.path).path
        if p == "/api/perf":
            body = self._body()
            mode = (body.get("mode") or "").strip()
            ok, msg = P.set_mode(mode)
            return self._json({"ok": ok, "msg": msg, "snapshot": P.snapshot(_THERM)},
                              200 if ok else 400)
        if p == "/api/launch":
            key = (self._body().get("key") or "").strip()
            r = L.launch(key)
            return self._json(r, 200 if r["ok"] else 400)
        return self.send_error(404)

    # ── live MIDI (SSE) ──────────────────────────────────────────────────
    def _sse_midi(self, q):
        """Stream parsed MIDI events as Server-Sent Events until the client leaves.

        ThreadingHTTPServer gives each connection its own thread, so blocking on
        the subscriber queue here is fine. A ': ping' every 15 s keeps the
        connection (and any proxy) from idling out.

        Batched, for the same reason the LCXL painter is (#71/#72): one write+flush per
        event meant a fader sweep cost ~400 HTTP flushes a second per open tab. Here we
        block for the FIRST event, then sweep up everything already waiting, coalesce
        away superseded controller values, and emit the lot in one write. The wire
        format is unchanged -- still one `data:` line per event -- so the UI's
        `onmessage`/`JSON.parse` is untouched.

        The rate limit is a floor on the gap BETWEEN frames, not a fixed tick, so an
        idle stream still delivers a single note the instant it arrives.
        """
        port = (q.get("port") or [None])[0] or None
        sub = _MIDI.subscribe(port)
        frame = 1.0 / 30.0
        next_ok = 0.0
        try:
            self.send_response(200)
            self.send_header("Content-Type", "text/event-stream")
            self.send_header("Cache-Control", "no-cache")
            self.send_header("Connection", "keep-alive")
            self.end_headers()
            self.wfile.write(b": connected\n\n")
            self.wfile.flush()
            while True:
                try:
                    batch = [sub.get(timeout=15)]
                except queue.Empty:
                    self.wfile.write(b": ping\n\n")
                    self.wfile.flush()
                    continue
                gap = next_ok - time.monotonic()
                if gap > 0:
                    time.sleep(gap)          # the coalescing window
                while len(batch) < 1024:
                    try:
                        batch.append(sub.get_nowait())
                    except queue.Empty:
                        break
                payload = "".join("data: " + json.dumps(e) + "\n\n"
                                  for e in MS.coalesce(batch))
                self.wfile.write(payload.encode())
                self.wfile.flush()
                next_ok = time.monotonic() + frame
        except (BrokenPipeError, ConnectionResetError, OSError):
            pass  # client navigated away / closed the tab — normal
        finally:
            _MIDI.unsubscribe(sub)

    # ── helpers ─────────────────────────────────────────────────────────
    def _body(self):
        n = int(self.headers.get("Content-Length", 0))
        return json.loads(self.rfile.read(n) or b"{}") if n else {}

    def _json(self, obj, code=200):
        b = json.dumps(obj).encode()
        self.send_response(code)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(b)))
        self.end_headers()
        self.wfile.write(b)

    def _file(self, path: Path, ctype: str):
        if not path.is_file():
            return self.send_error(404, f"missing {path.name}")
        b = path.read_bytes()
        self.send_response(200)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(b)))
        self.end_headers()
        self.wfile.write(b)

    @staticmethod
    def _ctype(path: Path):
        return {".css": "text/css", ".js": "text/javascript",
                ".svg": "image/svg+xml", ".png": "image/png"}.get(
            path.suffix, "application/octet-stream")

    def log_message(self, *a):
        pass


def _lan_ip():
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    try:
        s.connect(("192.168.1.1", 1))
        return s.getsockname()[0]
    finally:
        s.close()


def run(host: str = "127.0.0.1", port: int = 8773):
    httpd = ThreadingHTTPServer((host, port), partial(Handler))
    print(f"⚓ The Bridge — perf mode: {P.detect_mode()}, "
          f"pkg {P.snapshot(_THERM)['package_c']}°C")
    print(f"  http://{'127.0.0.1' if host in ('127.0.0.1','localhost') else host}:{port}/")
    if host == "0.0.0.0":
        try:
            print(f"  LAN: http://{_lan_ip()}:{port}/  ⚠ mode-switch exposed to LAN")
        except OSError:
            pass
    if not P.script_available():
        print(f"  ⓘ mode-switching disabled ({P.SCRIPT} not deployed — see README)")
    try:
        httpd.serve_forever()
    except KeyboardInterrupt:
        print("\n⚓ Bridge docked.")


if __name__ == "__main__":
    import argparse
    ap = argparse.ArgumentParser()
    ap.add_argument("--host", default="127.0.0.1")
    ap.add_argument("--port", type=int, default=8773)
    a = ap.parse_args()
    run(a.host, a.port)
