"""VDO.Ninja integration — stream DJ HUD output via WebRTC through the browser.

Architecture:
    DJ HUD  →  WebSocket (JPEG frames)  →  Local HTML page  →  WHIP → VDO.Ninja
                                            ↑ served by aiohttp server

The HTML page:
 1. Receives JPEG frames over a local WebSocket from DJ HUD
 2. Draws them onto a <canvas>
 3. Captures the canvas as a MediaStream via captureStream()
 4. Pushes the stream directly to VDO.Ninja via the WHIP protocol
    (standard WebRTC HTTP Ingest Protocol — RFC 9725)
 5. Viewer opens: https://vdo.ninja/?whip=STREAM_ID

Uses aiohttp for combined HTTP + WebSocket on a single port.
Works reliably on Windows, macOS, and Linux.
"""

import asyncio
import io
import threading
import time
import webbrowser
from typing import Optional

try:
    from aiohttp import web
except ImportError:
    web = None

try:
    from PIL import Image
except ImportError:
    Image = None

# ─── Constants ────────────────────────────────────────────────────────────────

DEFAULT_PORT = 7088
JPEG_QUALITY = 75
MAX_FPS = 30


# ─── HTML page — WHIP direct push ────────────────────────────────────────────

def _build_html(ws_port: int, stream_id: str, fps: int) -> str:
    return f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>DJ HUD — Meshcast Stream</title>
<style>
  * {{ margin: 0; padding: 0; box-sizing: border-box; }}
  body {{ background: #0f0f1a; color: #e0e0e0; font-family: system-ui, -apple-system, sans-serif;
         display: flex; flex-direction: column; min-height: 100vh; }}

  .header {{ padding: 12px 20px; background: #16213e; border-bottom: 1px solid #2a3a5e;
             display: flex; align-items: center; gap: 16px; flex-shrink: 0; }}
  .header h1 {{ font-size: 15px; font-weight: 600; color: #7ec8ff; white-space: nowrap; }}
  .badge {{ font-size: 12px; padding: 3px 10px; border-radius: 10px; font-weight: 500; }}
  .badge-off {{ background: #3d1414; color: #f87171; }}
  .badge-ready {{ background: #143d1f; color: #4ade80; }}
  .badge-live {{ background: #14293d; color: #60a5fa; animation: pulse 2s infinite; }}
  @keyframes pulse {{ 0%,100% {{ opacity:1; }} 50% {{ opacity:0.7; }} }}

  .toolbar {{ padding: 10px 20px; background: #121225; border-bottom: 1px solid #222;
              display: flex; gap: 12px; align-items: center; flex-wrap: wrap; flex-shrink: 0; }}
  .toolbar label {{ font-size: 13px; color: #999; }}
  .toolbar input {{ background: #1a1a2e; color: #eee; border: 1px solid #333;
                    padding: 5px 10px; border-radius: 4px; font-size: 13px; width: 170px; }}
  .toolbar input:focus {{ border-color: #4a7fff; outline: none; }}

  .btn {{ border: none; padding: 7px 18px; border-radius: 5px; cursor: pointer;
          font-size: 13px; font-weight: 500; transition: all 0.15s; white-space: nowrap; }}
  .btn-go {{ background: #16a34a; color: white; }}
  .btn-go:hover {{ background: #15803d; }}
  .btn-go:disabled {{ background: #333; color: #666; cursor: default; }}
  .btn-stop {{ background: #dc2626; color: white; }}
  .btn-stop:hover {{ background: #b91c1c; }}
  .btn-sm {{ padding: 4px 12px; font-size: 12px; background: #333; color: #ccc; }}
  .btn-sm:hover {{ background: #444; }}

  .viewer-bar {{ padding: 8px 20px; background: #0d1b2a; border-bottom: 1px solid #1a2a3e;
                 display: none; align-items: center; gap: 10px; font-size: 13px; flex-shrink: 0; }}
  .viewer-bar a {{ color: #60a5fa; text-decoration: none; word-break: break-all; }}
  .viewer-bar a:hover {{ text-decoration: underline; }}

  .canvas-wrap {{ flex: 1; display: flex; align-items: center; justify-content: center;
                  padding: 16px; min-height: 0; }}
  canvas {{ border: 1px solid #2a2a3e; border-radius: 4px; max-width: 100%;
            max-height: 100%; background: #000; object-fit: contain; }}

  .footer {{ padding: 8px 20px; font-size: 12px; color: #555; border-top: 1px solid #1a1a2e;
             display: flex; gap: 16px; flex-shrink: 0; }}

  .log {{ padding: 6px 20px; font-size: 11px; color: #666; max-height: 60px;
          overflow-y: auto; border-top: 1px solid #1a1a2e; flex-shrink: 0; }}
  .log .err {{ color: #f87171; }}
  .log .ok {{ color: #4ade80; }}

  .viewer-bar {{ padding: 8px 20px; background: #0d1b2a; border-bottom: 1px solid #1a2a3e;
                 display: none; align-items: center; gap: 10px; font-size: 13px; flex-shrink: 0; }}
  .viewer-bar a {{ color: #60a5fa; text-decoration: none; word-break: break-all; }}
  .viewer-bar a:hover {{ text-decoration: underline; }}
</style>
</head>
<body>

<div class="header">
  <h1>DJ HUD &rarr; Meshcast</h1>
  <span class="badge badge-off" id="wsBadge">DJ HUD: connecting...</span>
  <span class="badge badge-off" id="whipBadge">Meshcast: idle</span>
</div>

<div class="toolbar">
  <label>Stream ID:
    <input type="text" id="streamId" value="{stream_id}" placeholder="pick a unique name"
           spellcheck="false" autocomplete="off" oninput="updateViewer()">
  </label>
  <button class="btn btn-go" id="startBtn" onclick="toggleWhip()" disabled>Start streaming</button>
</div>

<div class="viewer-bar" id="viewerBar" style="display:none;">
  <span>Viewer link (share this!):</span>
  <a id="viewerLink" href="#" target="_blank"></a>
  <button class="btn btn-sm" onclick="copyLink()">Copy</button>
  <button class="btn btn-sm" onclick="openViewer()">Open</button>
</div>

<div class="canvas-wrap">
  <canvas id="c" width="1080" height="80"></canvas>
</div>

<div class="footer">
  <span id="fpsInfo">&mdash; fps</span>
  <span id="sizeInfo">&mdash;</span>
  <span id="bitrateInfo"></span>
</div>
<div class="log" id="log"></div>

<script>
// ═══════════════════════════════════════════════════════════════════
// CONFIG
// ═══════════════════════════════════════════════════════════════════
const WS_URL  = "ws://" + window.location.host + "/ws";
const LOCAL_BASE = "http://" + window.location.host;
const VIEW_BASE = "https://meshcast.io/view.html?id=";
const CANVAS_FPS = {fps};
const STUN = {{ urls: "stun:stun4.l.google.com:19302" }};
let meshcastUrl = "https://cae1.meshcast.io"; // default fallback
let meshcastLabel = "Canada-East-1";

// ═══════════════════════════════════════════════════════════════════
// STATE
// ═══════════════════════════════════════════════════════════════════
const canvas = document.getElementById("c");
const ctx    = canvas.getContext("2d");
let ws = null, wsOk = false;
let pc = null, whipSession = null, streaming = false;
let frameCount = 0, fpsTimer = Date.now();

// ═══════════════════════════════════════════════════════════════════
// LOGGING
// ═══════════════════════════════════════════════════════════════════
function log(msg, cls) {{
  const el = document.getElementById("log");
  const d = document.createElement("div");
  if (cls) d.className = cls;
  d.textContent = new Date().toLocaleTimeString() + "  " + msg;
  el.appendChild(d);
  el.scrollTop = el.scrollHeight;
  while (el.children.length > 50) el.removeChild(el.firstChild);
}}

// ═══════════════════════════════════════════════════════════════════
// WEBSOCKET — receive frames from DJ HUD
// ═══════════════════════════════════════════════════════════════════
function connectWs() {{
  ws = new WebSocket(WS_URL);
  ws.binaryType = "arraybuffer";

  ws.onopen = () => {{
    wsOk = true;
    document.getElementById("wsBadge").textContent = "DJ HUD: connected";
    document.getElementById("wsBadge").className = "badge badge-ready";
    document.getElementById("startBtn").disabled = false;
    log("Connected to DJ HUD", "ok");
  }};

  ws.onmessage = (evt) => {{
    const blob = new Blob([evt.data], {{ type: "image/jpeg" }});
    const url  = URL.createObjectURL(blob);
    const img  = new Image();
    img.onload = () => {{
      if (canvas.width !== img.width || canvas.height !== img.height) {{
        canvas.width  = img.width;
        canvas.height = img.height;
        document.getElementById("sizeInfo").textContent = img.width + " x " + img.height;
      }}
      ctx.drawImage(img, 0, 0);
      URL.revokeObjectURL(url);
      frameCount++;
      const now = Date.now();
      if (now - fpsTimer >= 1000) {{
        document.getElementById("fpsInfo").textContent = frameCount + " fps";
        frameCount = 0;
        fpsTimer = now;
      }}
    }};
    img.src = url;
  }};

  ws.onclose = () => {{
    wsOk = false;
    document.getElementById("wsBadge").textContent = "DJ HUD: disconnected";
    document.getElementById("wsBadge").className = "badge badge-off";
    if (!streaming) document.getElementById("startBtn").disabled = true;
    log("Disconnected from DJ HUD — retrying in 2s");
    setTimeout(connectWs, 2000);
  }};

  ws.onerror = () => {{ try {{ ws.close(); }} catch(_) {{}} }};
}}

// ═══════════════════════════════════════════════════════════════════
// WHIP — push canvas stream to VDO.Ninja
// ═══════════════════════════════════════════════════════════════════

async function startWhip() {{
  const sid = document.getElementById("streamId").value.trim();
  if (!sid) {{
    log("Enter a Stream ID first", "err");
    return;
  }}

  const whipUrl = meshcastUrl + "/whip/" + encodeURIComponent(sid);
  const proxyUrl = LOCAL_BASE + "/whip/" + encodeURIComponent(sid) + "?server=" + encodeURIComponent(meshcastUrl);
  log("Publishing to Meshcast: " + whipUrl + " ...");

  // Capture canvas as MediaStream
  const stream = canvas.captureStream(CANVAS_FPS);

  // Create peer connection
  pc = new RTCPeerConnection({{ iceServers: [STUN] }});

  // Add video track (send-only)
  for (const track of stream.getVideoTracks()) {{
    pc.addTrack(track, stream);
  }}

  // Prefer H264 for best compatibility
  try {{
    const transceivers = pc.getTransceivers();
    for (const t of transceivers) {{
      if (t.sender && t.sender.track && t.sender.track.kind === "video") {{
        const codecs = RTCRtpSender.getCapabilities("video").codecs;
        const h264 = codecs.filter(c => c.mimeType === "video/H264");
        if (h264.length > 0) {{
          t.setCodecPreferences([...h264, ...codecs.filter(c => c.mimeType !== "video/H264")]);
        }}
      }}
    }}
  }} catch(e) {{ /* setCodecPreferences not in all browsers */ }}

  // Monitor connection state
  pc.oniceconnectionstatechange = () => {{
    log("ICE: " + pc.iceConnectionState);
    if (pc.iceConnectionState === "connected" || pc.iceConnectionState === "completed") {{
      document.getElementById("whipBadge").textContent = "Meshcast: live";
      document.getElementById("whipBadge").className = "badge badge-live";
    }} else if (pc.iceConnectionState === "failed" || pc.iceConnectionState === "disconnected") {{
      document.getElementById("whipBadge").textContent = "Meshcast: " + pc.iceConnectionState;
      document.getElementById("whipBadge").className = "badge badge-off";
      if (pc.iceConnectionState === "failed") {{
        log("ICE connection failed", "err");
      }}
    }}
  }};

  try {{
    // Create and set local offer
    const offer = await pc.createOffer();
    await pc.setLocalDescription(offer);

    // Wait for ICE gathering
    await waitForIce(pc, 5000);
    log("ICE gathering complete — sending offer to Meshcast...");

    // WHIP: POST SDP offer to Meshcast (via local proxy to avoid CORS)
    const resp = await fetch(proxyUrl, {{
      method: "POST",
      headers: {{ "Content-Type": "application/sdp" }},
      body: pc.localDescription.sdp,
    }});

    if (!resp.ok) {{
      const errBody = await resp.text();
      throw new Error("Meshcast " + resp.status + ": " + errBody.slice(0, 200));
    }}

    // Set remote answer
    const answerSdp = await resp.text();
    whipSession = resp.headers.get("Location");
    await pc.setRemoteDescription({{ type: "answer", sdp: answerSdp }});
    log("Meshcast connected — stream is live!", "ok");

    // Update UI
    streaming = true;
    document.getElementById("startBtn").textContent = "Stop streaming";
    document.getElementById("startBtn").className = "btn btn-stop";
    document.getElementById("whipBadge").textContent = "Meshcast: connecting...";
    document.getElementById("whipBadge").className = "badge badge-ready";

    const serverCode = meshcastUrl.replace("https://","").replace(".meshcast.io","");
    const viewUrl = LOCAL_BASE + "/viewer?id=" + encodeURIComponent(sid) + "&server=" + encodeURIComponent(meshcastUrl);
    const vpnNote = (window.location.hostname !== "127.0.0.1" && window.location.hostname !== "localhost")
      ? "" : " (replace 127.0.0.1 with your VPN/LAN IP for remote viewing)";
    document.getElementById("viewerLink").href = viewUrl;
    document.getElementById("viewerLink").textContent = viewUrl;
    document.getElementById("viewerBar").style.display = "flex";
    log("Viewer link: " + viewUrl + " (share this — works from any device!)" + vpnNote, "ok");

    startBitrateMonitor();

  }} catch(e) {{
    log("Meshcast error: " + e.message, "err");
    if (e.message && e.message.includes("Failed to fetch")) {{
      log("Cannot reach Meshcast server — check your internet connection", "err");
    }}
    cleanupPc();
  }}
}}

function waitForIce(pc, ms) {{
  return new Promise(resolve => {{
    if (pc.iceGatheringState === "complete") {{ resolve(); return; }}
    const t = setTimeout(resolve, ms);
    pc.onicegatheringstatechange = () => {{
      if (pc.iceGatheringState === "complete") {{ clearTimeout(t); resolve(); }}
    }};
  }});
}}

async function stopWhip() {{
  log("Stopping stream...");
  if (whipSession) {{
    try {{
      // whipSession may be absolute or relative; proxy if needed
      let delUrl = whipSession;
      if (delUrl && !delUrl.startsWith(window.location.origin)) {{
        // Proxy through our local server
        delUrl = LOCAL_BASE + "/whip/" + document.getElementById("streamId").value.trim()
          + "?server=" + encodeURIComponent(meshcastUrl);
      }}
      await fetch(delUrl, {{ method: "DELETE" }});
    }} catch(_) {{}}
    whipSession = null;
  }}
  cleanupPc();
  streaming = false;
  document.getElementById("startBtn").textContent = "Start streaming";
  document.getElementById("startBtn").className = "btn btn-go";
  document.getElementById("whipBadge").textContent = "Meshcast: idle";
  document.getElementById("whipBadge").className = "badge badge-off";
  document.getElementById("viewerBar").style.display = "none";
  document.getElementById("bitrateInfo").textContent = "";
  log("Stopped");
}}

function cleanupPc() {{
  if (pc) {{ try {{ pc.close(); }} catch(_) {{}} pc = null; }}
}}

function toggleWhip() {{
  if (streaming) stopWhip(); else startWhip();
}}

// ═══════════════════════════════════════════════════════════════════
// BITRATE MONITOR
// ═══════════════════════════════════════════════════════════════════
let brInterval = null, prevBytes = 0, prevTs = 0;

function startBitrateMonitor() {{
  if (brInterval) clearInterval(brInterval);
  prevBytes = 0; prevTs = 0;
  brInterval = setInterval(async () => {{
    if (!pc || !streaming) {{ clearInterval(brInterval); return; }}
    try {{
      const stats = await pc.getStats();
      stats.forEach(r => {{
        if (r.type === "outbound-rtp" && r.kind === "video") {{
          if (prevTs > 0) {{
            const kbps = Math.round(((r.bytesSent - prevBytes) * 8) / ((r.timestamp - prevTs) / 1000) / 1000);
            document.getElementById("bitrateInfo").textContent = kbps + " kbps";
          }}
          prevBytes = r.bytesSent;
          prevTs = r.timestamp;
        }}
      }});
    }} catch(_) {{}}
  }}, 2000);
}}

// ═══════════════════════════════════════════════════════════════════
// COPY / OPEN VIEWER LINK
// ═══════════════════════════════════════════════════════════════════
function copyLink() {{
  const url = document.getElementById("viewerLink").textContent;
  navigator.clipboard.writeText(url).then(() => {{
    const b = event.target;
    b.textContent = "Copied!";
    setTimeout(() => b.textContent = "Copy", 1500);
  }});
}}

function openViewer() {{
  const url = document.getElementById("viewerLink").href;
  if (url && url !== "#") window.open(url, "_blank");
}}

function updateViewer() {{
  const sid = document.getElementById("streamId").value.trim();
  if (sid && streaming) {{
    const viewUrl = VIEW_BASE + encodeURIComponent(sid);
    document.getElementById("viewerLink").href = viewUrl;
    document.getElementById("viewerLink").textContent = viewUrl;
  }}
}}

// ═══════════════════════════════════════════════════════════════════
// MESHCAST SERVER SELECTION — pick closest server automatically
// ═══════════════════════════════════════════════════════════════════
async function selectMeshcastServer() {{
  try {{
    const resp = await fetch(LOCAL_BASE + "/servers.json");
    if (!resp.ok) throw new Error("HTTP " + resp.status);
    const servers = await resp.json();

    // Sort by timezone proximity
    const myTz = new Date().getTimezoneOffset();
    const alive = servers
      .filter(s => s.alive && s.penalty < 1000)
      .sort((a, b) => Math.abs(a.tz - myTz) - Math.abs(b.tz - myTz));

    if (alive.length > 0) {{
      meshcastUrl = alive[0].url;
      meshcastLabel = alive[0].label + " (" + alive[0].id + ")";
      log("Meshcast server: " + meshcastLabel, "ok");
      if (alive.length < servers.filter(s => s.penalty < 1000).length) {{
        const dead = servers.filter(s => !s.alive && s.penalty < 1000).map(s => s.id);
        log("Offline servers skipped: " + dead.join(", "));
      }}
    }} else {{
      log("No Meshcast servers responded — using default (" + meshcastLabel + ")", "err");
    }}
  }} catch(e) {{
    log("Could not fetch Meshcast servers — using default (" + meshcastLabel + ")");
  }}
}}

// ═══════════════════════════════════════════════════════════════════
// INIT
// ═══════════════════════════════════════════════════════════════════
connectWs();
selectMeshcastServer();
log("Ready — enter a Stream ID and click Start streaming");
</script>
</body>
</html>"""


# ═══════════════════════════════════════════════════════════════════════════════
# aiohttp-based HTTP + WebSocket Server
# ═══════════════════════════════════════════════════════════════════════════════

class VDONinjaServer:
    """Local HTTP + WebSocket server that bridges DJ HUD frames to the browser.

    Uses aiohttp for reliable cross-platform WebSocket support.
    The server runs in its own thread with a dedicated asyncio event loop,
    so it doesn't interfere with tkinter's main loop.

    Public API (unchanged from v0.9.3):
        start()          — start the server thread
        stop()           — shut down gracefully
        push_frame(img)  — send a PIL Image to connected browsers
        is_running       — bool property
        client_count     — int property
        url              — str property
        open_browser()   — open the page in default browser
    """

    def __init__(self, port: int = DEFAULT_PORT, stream_id: str = "",
                 target_fps: int = 15):
        if web is None:
            raise ImportError(
                "aiohttp is required for VDO.Ninja streaming. "
                "Install it with: pip install aiohttp"
            )
        self._port = port
        self.stream_id = stream_id
        self.target_fps = target_fps
        self._running = False
        self._thread: Optional[threading.Thread] = None
        self._loop: Optional[asyncio.AbstractEventLoop] = None
        self._ws_clients: set = set()          # set of aiohttp WebSocketResponse
        self._ws_lock = threading.Lock()
        self._last_frame_time = 0.0

    # ── HTTP routes ──────────────────────────────────────────────────

    async def _handle_index(self, request):
        html = _build_html(
            ws_port=self._port,
            stream_id=self.stream_id,
            fps=min(MAX_FPS, self.target_fps),
        )
        return web.Response(
            text=html,
            content_type="text/html",
            headers={"Cache-Control": "no-cache"},
        )

    async def _handle_health(self, request):
        return web.Response(text="ok")

    # ── WHIP proxy (avoids CORS issues) ──────────────────────────────

    async def _handle_whip_proxy(self, request):
        """Proxy WHIP POST/DELETE to the Meshcast server.

        The browser page sends requests to /whip/STREAM_ID on our local
        server, and we forward them to https://SERVER.meshcast.io/whip/STREAM_ID.
        This avoids all CORS issues since the browser only talks to 127.0.0.1.
        """
        import aiohttp as aio

        stream_id = request.match_info.get("stream_id", "")
        body = await request.read()
        content_type = request.headers.get("Content-Type", "")

        # Get the target Meshcast server URL from query param or default
        target_base = request.query.get("server", "https://de1.meshcast.io")
        target_url = f"{target_base}/whip/{stream_id}"

        try:
            async with aio.ClientSession() as session:
                if request.method == "POST":
                    async with session.post(
                        target_url,
                        data=body,
                        headers={"Content-Type": content_type},
                    ) as resp:
                        resp_body = await resp.read()
                        headers = {}
                        # Forward important response headers
                        for h in ("Location", "Content-Type", "WHEP",
                                  "Access-Control-Expose-Headers"):
                            if h in resp.headers:
                                headers[h] = resp.headers[h]
                        return web.Response(
                            status=resp.status,
                            body=resp_body,
                            headers=headers,
                        )
                elif request.method == "DELETE":
                    async with session.delete(target_url) as resp:
                        resp_body = await resp.read()
                        return web.Response(status=resp.status, body=resp_body)
                else:
                    return web.Response(status=405, text="Method not allowed")
        except Exception as e:
            print(f"[VDO.Ninja] WHIP proxy error: {e}")
            return web.Response(status=502, text=f"Proxy error: {e}")

    async def _handle_servers_proxy(self, request):
        """Fetch Meshcast servers and health-check them server-side.

        Returns the servers.json list with an added 'alive' field,
        sorted by timezone proximity to the requesting client.
        This avoids CORS issues with browser-side health checks.
        """
        import aiohttp as aio
        try:
            async with aio.ClientSession(
                timeout=aio.ClientTimeout(total=5)
            ) as session:
                # Fetch server list
                async with session.get("https://meshcast.io/servers.json") as resp:
                    servers = await resp.json()

                # Health-check each server (parallel, 3s timeout)
                async def check(s):
                    try:
                        async with session.get(
                            s["url"] + "/healthcheck",
                            timeout=aio.ClientTimeout(total=3),
                        ) as r:
                            s["alive"] = r.status == 200
                    except Exception:
                        s["alive"] = False
                    return s

                import asyncio as _aio
                await _aio.gather(*(check(s) for s in servers))

                body = __import__("json").dumps(servers)
                return web.Response(
                    status=200,
                    body=body,
                    content_type="application/json",
                )
        except Exception as e:
            print(f"[VDO.Ninja] Servers proxy error: {e}")
            return web.Response(status=502, text=f"Proxy error: {e}")

    # ── WHEP proxy (for the viewer page) ─────────────────────────────

    async def _handle_whep_proxy(self, request):
        """Proxy WHEP POST to the Meshcast server for the viewer."""
        import aiohttp as aio

        if request.method == "OPTIONS":
            return web.Response(
                status=204,
                headers={
                    "Access-Control-Allow-Origin": "*",
                    "Access-Control-Allow-Methods": "POST, OPTIONS",
                    "Access-Control-Allow-Headers": "Content-Type",
                    "Access-Control-Expose-Headers": "Location",
                },
            )

        stream_id = request.match_info.get("stream_id", "")
        body = await request.read()
        content_type = request.headers.get("Content-Type", "")
        target_base = request.query.get("server", "https://cae1.meshcast.io")
        target_url = f"{target_base}/whep/{stream_id}"

        try:
            async with aio.ClientSession() as session:
                async with session.post(
                    target_url,
                    data=body,
                    headers={"Content-Type": content_type},
                ) as resp:
                    resp_body = await resp.read()
                    headers = {
                        "Access-Control-Allow-Origin": "*",
                        "Access-Control-Expose-Headers": "Location",
                    }
                    for h in ("Location", "Content-Type"):
                        if h in resp.headers:
                            headers[h] = resp.headers[h]
                    return web.Response(
                        status=resp.status,
                        body=resp_body,
                        headers=headers,
                    )
        except Exception as e:
            print(f"[VDO.Ninja] WHEP proxy error: {e}")
            return web.Response(status=502, text=f"Proxy error: {e}")

    # ── Self-hosted WHEP viewer page ─────────────────────────────────

    async def _handle_viewer(self, request):
        """Serve a self-contained WHEP viewer page."""
        stream_id = request.query.get("id", "")
        server = request.query.get("server", "https://cae1.meshcast.io")
        # The viewer page fetches WHEP through our local proxy
        # so it works from localhost without CORS issues
        local_port = self._port
        html = f"""<!DOCTYPE html>
<html><head>
<meta charset="utf-8">
<title>DJ HUD Stream — {{stream_id}}</title>
<style>
  * {{ margin:0; padding:0; box-sizing:border-box; }}
  body {{ background:#000; display:flex; align-items:center; justify-content:center;
         min-height:100vh; font-family:system-ui,sans-serif; color:#ccc; }}
  video {{ max-width:100%; max-height:100vh; }}
  #status {{ position:fixed; top:10px; left:10px; font-size:13px; background:rgba(0,0,0,0.7);
             padding:6px 12px; border-radius:6px; }}
  .ok {{ color:#4ade80; }}
  .err {{ color:#f87171; }}
</style>
</head><body>
<video id="v" autoplay playsinline muted></video>
<div id="status">Connecting...</div>
<script>
const WHEP_PROXY = window.location.origin + "/whep/{stream_id}?server={server}";
const STUN = {{ urls: "stun:stun4.l.google.com:19302" }};
const statusEl = document.getElementById("status");

async function startViewer() {{
  const pc = new RTCPeerConnection({{ iceServers: [STUN] }});

  pc.ontrack = (e) => {{
    document.getElementById("v").srcObject = e.streams[0];
    statusEl.innerHTML = '<span class="ok">Connected — streaming</span>';
  }};

  // We need to add a transceiver to receive video
  pc.addTransceiver("video", {{ direction: "recvonly" }});

  const offer = await pc.createOffer();
  await pc.setLocalDescription(offer);

  // Wait for ICE
  await new Promise(resolve => {{
    if (pc.iceGatheringState === "complete") {{ resolve(); return; }}
    const t = setTimeout(resolve, 3000);
    pc.onicegatheringstatechange = () => {{
      if (pc.iceGatheringState === "complete") {{ clearTimeout(t); resolve(); }}
    }};
  }});

  statusEl.textContent = "Sending WHEP request...";

  try {{
    const resp = await fetch(WHEP_PROXY, {{
      method: "POST",
      headers: {{ "Content-Type": "application/sdp" }},
      body: pc.localDescription.sdp,
    }});

    if (!resp.ok) {{
      const errText = await resp.text();
      throw new Error("WHEP " + resp.status + ": " + errText.slice(0, 200));
    }}

    const answerSdp = await resp.text();
    await pc.setRemoteDescription({{ type: "answer", sdp: answerSdp }});
    statusEl.textContent = "WHEP connected, waiting for video...";
  }} catch(e) {{
    statusEl.innerHTML = '<span class="err">Error: ' + e.message + '</span>';
    // Retry after 3 seconds
    setTimeout(startViewer, 3000);
  }}

  pc.oniceconnectionstatechange = () => {{
    if (pc.iceConnectionState === "failed" || pc.iceConnectionState === "disconnected") {{
      statusEl.innerHTML = '<span class="err">Disconnected — retrying...</span>';
      pc.close();
      setTimeout(startViewer, 2000);
    }}
  }};
}}

startViewer();
</script>
</body></html>"""
        return web.Response(
            text=html,
            content_type="text/html",
            headers={"Cache-Control": "no-cache"},
        )

    # ── WebSocket handler ────────────────────────────────────────────

    async def _handle_ws(self, request):
        """Handle a WebSocket connection from the browser page.

        aiohttp manages the full upgrade handshake and frame protocol,
        so there's no manual 101/RFC-6455 work needed — this is what
        fixes the Windows disconnect bug.
        """
        ws_response = web.WebSocketResponse()
        await ws_response.prepare(request)

        print(f"[VDO.Ninja] WebSocket client connected from {request.remote}")
        with self._ws_lock:
            self._ws_clients.add(ws_response)

        try:
            # Read loop — keeps the connection alive and handles
            # ping/pong, close frames, and any messages from the browser
            async for msg in ws_response:
                if msg.type == web.WSMsgType.TEXT:
                    pass  # browser doesn't send text; ignore
                elif msg.type == web.WSMsgType.BINARY:
                    pass  # browser doesn't send binary; ignore
                elif msg.type in (web.WSMsgType.ERROR, web.WSMsgType.CLOSE,
                                  web.WSMsgType.CLOSING, web.WSMsgType.CLOSED):
                    break
        except Exception as e:
            print(f"[VDO.Ninja] WebSocket error: {e}")
        finally:
            with self._ws_lock:
                self._ws_clients.discard(ws_response)
            print("[VDO.Ninja] WebSocket client disconnected")

        return ws_response

    # ── Server lifecycle ─────────────────────────────────────────────

    def start(self):
        if self._running:
            return
        self._running = True
        self._thread = threading.Thread(target=self._run_server, daemon=True)
        self._thread.start()

    def _run_server(self):
        """Run the aiohttp server in a dedicated asyncio event loop."""
        self._loop = asyncio.new_event_loop()
        asyncio.set_event_loop(self._loop)

        app = web.Application()
        app.router.add_get("/", self._handle_index)
        app.router.add_get("/index.html", self._handle_index)
        app.router.add_get("/health", self._handle_health)
        app.router.add_get("/ws", self._handle_ws)
        app.router.add_get("/servers.json", self._handle_servers_proxy)
        app.router.add_get("/viewer", self._handle_viewer)
        app.router.add_route("POST", "/whip/{stream_id}", self._handle_whip_proxy)
        app.router.add_route("DELETE", "/whip/{stream_id}", self._handle_whip_proxy)
        app.router.add_route("POST", "/whep/{stream_id}", self._handle_whep_proxy)
        app.router.add_route("OPTIONS", "/whep/{stream_id}", self._handle_whep_proxy)

        runner = web.AppRunner(app)
        self._loop.run_until_complete(runner.setup())

        site = web.TCPSite(runner, "0.0.0.0", self._port)
        self._loop.run_until_complete(site.start())
        print(f"[VDO.Ninja] Server running on port {self._port} (all interfaces)")

        try:
            self._loop.run_forever()
        except Exception:
            pass
        finally:
            self._loop.run_until_complete(runner.cleanup())
            self._loop.close()
            self._loop = None

    def stop(self):
        self._running = False

        # Close all WebSocket connections
        with self._ws_lock:
            clients = list(self._ws_clients)
            self._ws_clients.clear()

        if self._loop and not self._loop.is_closed():
            # Schedule graceful WS close from the event loop thread
            for ws_client in clients:
                try:
                    asyncio.run_coroutine_threadsafe(
                        ws_client.close(), self._loop
                    )
                except Exception:
                    pass

            # Stop the event loop
            self._loop.call_soon_threadsafe(self._loop.stop)

        if self._thread:
            self._thread.join(timeout=3.0)
            self._thread = None

        print("[VDO.Ninja] Server stopped")

    # ── Frame push ───────────────────────────────────────────────────

    def push_frame(self, pil_image: "Image.Image", quality: int = JPEG_QUALITY):
        """Encode a PIL image as JPEG and push to all connected browsers."""
        with self._ws_lock:
            if not self._ws_clients or Image is None:
                return
        # FPS throttle
        now = time.monotonic()
        if now - self._last_frame_time < 1.0 / max(1, self.target_fps):
            return
        self._last_frame_time = now

        buf = io.BytesIO()
        pil_image.save(buf, format="JPEG", quality=quality, optimize=False)
        jpeg_bytes = buf.getvalue()

        with self._ws_lock:
            clients = list(self._ws_clients)

        loop = self._loop
        if loop is None or loop.is_closed():
            return

        # Schedule the async sends from the tkinter thread
        for ws_client in clients:
            try:
                asyncio.run_coroutine_threadsafe(
                    self._send_frame(ws_client, jpeg_bytes), loop
                )
            except Exception:
                pass

    async def _send_frame(self, ws_client, data: bytes):
        """Send binary frame data to one WebSocket client."""
        try:
            await ws_client.send_bytes(data)
        except Exception:
            # Client gone — will be cleaned up in the read loop
            with self._ws_lock:
                self._ws_clients.discard(ws_client)

    # ── Properties ───────────────────────────────────────────────────

    @property
    def is_running(self):
        return self._running

    @property
    def client_count(self):
        with self._ws_lock:
            return len(self._ws_clients)

    @property
    def url(self):
        return f"http://127.0.0.1:{self._port}"

    def open_browser(self):
        webbrowser.open(self.url)
