'use strict';

// ── Architektur ───────────────────────────────────────────────────────────────
// Der Server (Node) besitzt die Kameras und liefert den Live-Stream als
// MJPEG multipart/x-mixed-replace unter /api/stream/<id>. Der Browser rendert
// das nativ in einem <img>. KEIN WebRTC, KEIN go2rtc, kein Transcode.
//
// HD-Snapshot: GET /api/snapshot/<id>/hires. Der Server-Schalter pausiert dafür
// den Live-FFmpeg kurz (~1–2 s), greift 1280×960, schaltet zurück. Der <img>-
// Stream friert in dieser Zeit ein und läuft danach weiter – kein Client-Handling
// nötig (das war früher die Fehlerquelle).

const P = '[WebcamViewer]';
const log = (c, m) => console.log(`${P}[${c}] ${m}`);
const warn = (c, m) => console.warn(`${P}[${c}] ⚠ ${m}`);
const logErr = (c, m, e) => console.error(`${P}[${c}] ✗ ${m}`, e ?? '');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

const cameras = []; // { id, box, img, infoEl, toggleBtn, hdBtn, active, busy }

// ── Live-Stream an/aus ────────────────────────────────────────────────────────
function startStream(cam) {
  cam.active = true;
  // Cache-Buster erzwingt eine frische Verbindung (sonst hängt Reconnect manchmal)
  cam.img.src = `/api/stream/${encodeURIComponent(cam.id)}?t=${Date.now()}`;
  cam.toggleBtn.textContent = '⏸';
  cam.toggleBtn.title = 'Stream ausschalten';
  setInfo(cam, 'verbindet…', '');
  log(cam.id, 'Live an');
}

function stopStream(cam) {
  cam.active = false;
  cam.img.removeAttribute('src'); // schließt die multipart-Verbindung
  cam.toggleBtn.textContent = '▶';
  cam.toggleBtn.title = 'Stream einschalten';
  setInfo(cam, 'aus', '');
  log(cam.id, 'Live aus');
}

// ── H.264-Live über MSE ─────────────────────────────────────────────────────
// Der Server liefert unter /api/stream/<id> ein fortlaufendes fragmentiertes MP4
// (Init-Segment zuerst, dann Fragmente). Wir lesen den Body als ReadableStream
// und speisen die Bytes der Reihe nach in einen SourceBuffer → <video>.
// Kein MSE / Codec nicht unterstützt → automatischer Snapshot-Fallback (kein
// schwarzes Bild). Latenz wird durch „an die Live-Kante springen" klein gehalten.
const H264_KEEP_S = 6;       // so viel Puffer hinter currentTime behalten
const H264_MAX_LAG_S = 2.0;  // mehr Rückstand → an die Kante springen

function startH264Stream(cam) {
  cam.active = true;
  cam.toggleBtn.textContent = '⏸';
  cam.toggleBtn.title = 'Stream ausschalten';

  const mime = `video/mp4; codecs="${cam.mseCodec || 'avc1.4D401F'}"`;
  if (!('MediaSource' in window) || !window.MediaSource.isTypeSupported(mime)) {
    warn(cam.id, `MSE/${mime} nicht unterstützt → Snapshot-Fallback`);
    startH264Fallback(cam);
    return;
  }

  teardownH264(cam); // evtl. alte Session abräumen
  setInfo(cam, 'verbindet… (H.264)', '');

  const ms = new MediaSource();
  cam.mediaSource = ms;
  cam.h264Queue = [];
  const objUrl = URL.createObjectURL(ms);
  cam.video.src = objUrl;

  ms.addEventListener('sourceopen', () => {
    URL.revokeObjectURL(objUrl);
    let sb;
    try { sb = ms.addSourceBuffer(mime); }
    catch (e) { logErr(cam.id, 'addSourceBuffer', e); startH264Fallback(cam); return; }
    sb.mode = 'segments';
    cam.sourceBuffer = sb;
    sb.addEventListener('updateend', () => { keepLiveEdge(cam); appendNext(cam); });
    pumpH264(cam, mime);
  });
}

async function pumpH264(cam, mime) {
  const ctrl = new AbortController();
  cam.h264Abort = ctrl;
  try {
    const resp = await fetch(`/api/stream/${encodeURIComponent(cam.id)}?t=${Date.now()}`, { signal: ctrl.signal });
    if (!resp.ok || !resp.body) throw new Error(`HTTP ${resp.status}`);
    const reader = resp.body.getReader();
    cam.video.play().catch(() => {}); // muted → Autoplay erlaubt
    log(cam.id, 'H.264 verbunden');
    for (;;) {
      const { done, value } = await reader.read();
      if (done || !cam.active) break;
      if (value && value.length) { cam.h264Queue.push(value); appendNext(cam); }
    }
  } catch (e) {
    if (!cam.active) return;
    if (e && e.name === 'AbortError') return;
    setInfo(cam, 'Verbindungsfehler – neu…', 'crit');
    logErr(cam.id, 'H.264-Stream abgebrochen', e);
    setTimeout(() => { if (cam.active && !cam.busy) startH264Stream(cam); }, 2000);
  }
}

function appendNext(cam) {
  const sb = cam.sourceBuffer, q = cam.h264Queue;
  if (!sb || sb.updating || !q) return;
  if (q.length) {
    const chunk = q.shift();
    try {
      sb.appendBuffer(chunk);
      if (cam.active && !cam.busy) setInfo(cam, 'H.264 · live', 'ok');
    } catch (e) {
      if (e && e.name === 'QuotaExceededError') { q.unshift(chunk); trimBuffer(cam, true); }
      else logErr(cam.id, 'appendBuffer', e);
    }
  } else {
    trimBuffer(cam, false); // Leerlauf nutzen, um alten Puffer zu kappen
  }
}

function trimBuffer(cam, force) {
  const sb = cam.sourceBuffer, v = cam.video;
  if (!sb || sb.updating || !sb.buffered.length) return;
  const start = sb.buffered.start(0);
  const cutoff = (v.currentTime || 0) - (force ? 2 : H264_KEEP_S);
  if (cutoff > start + 0.5) { try { sb.remove(start, cutoff); } catch (_e) {} }
}

function keepLiveEdge(cam) {
  const sb = cam.sourceBuffer, v = cam.video;
  if (!sb || !sb.buffered.length) return;
  const end = sb.buffered.end(sb.buffered.length - 1);
  if (end - (v.currentTime || 0) > H264_MAX_LAG_S) v.currentTime = end - 0.3; // aufholen
}

function teardownH264(cam) {
  if (cam.h264Abort) { try { cam.h264Abort.abort(); } catch (_e) {} cam.h264Abort = null; }
  if (cam.mediaSource && cam.mediaSource.readyState === 'open') { try { cam.mediaSource.endOfStream(); } catch (_e) {} }
  cam.mediaSource = null; cam.sourceBuffer = null; cam.h264Queue = null;
  if (cam.video) { try { cam.video.pause(); cam.video.removeAttribute('src'); cam.video.load(); } catch (_e) {} }
}

function stopH264Stream(cam) {
  cam.active = false;
  teardownH264(cam);
  if (cam.snapTimer) { clearInterval(cam.snapTimer); cam.snapTimer = null; cam.snapshotActive = false; }
  cam.toggleBtn.textContent = '▶';
  cam.toggleBtn.title = 'Stream einschalten';
  setInfo(cam, 'aus', '');
  log(cam.id, 'Live aus (H.264)');
}

// MSE nicht verfügbar: <video> gegen <img> tauschen und das Live-JPEG pollen
// (der Server hält dafür einen gedrosselten MJPEG-Nebenausgang vor).
function startH264Fallback(cam) {
  if (cam.video && !cam.fallbackImg) {
    const img = document.createElement('img');
    img.className = 'cam-img';
    img.alt = cam.video.alt || cam.id;
    cam.video.replaceWith(img);
    cam.fallbackImg = img;
    cam.img = img;
  }
  cam.snapshotActive = true;
  fetchLiveSnapshot(cam);
  cam.snapTimer = setInterval(() => fetchLiveSnapshot(cam), 1000);
  setInfo(cam, 'H.264 ohne MSE – Snapshot-Fallback', 'warn');
}

async function fetchLiveSnapshot(cam) {
  if (!cam.snapshotActive) return;
  try {
    const r = await fetch(`/api/snapshot/${encodeURIComponent(cam.id)}?t=${Date.now()}`, { signal: AbortSignal.timeout(8000) });
    if (!r.ok) return;
    const blob = await r.blob();
    const url = URL.createObjectURL(blob);
    cam.img.src = url;
    if (cam.lastBlobUrl) URL.revokeObjectURL(cam.lastBlobUrl);
    cam.lastBlobUrl = url;
  } catch (_e) { /* nächstes Intervall versucht es erneut */ }
}

// ── Snapshot-Modus (stream:false): alle 15 s ein HD-Einzelbild ──────────────
// Kein Video – pro Snapshot holt der Viewer ein Bild in HD-Auflösung (/hires,
// pro Kamera in cameras.json konfiguriert). Da nur 1 Bild / 15 s übertragen wird,
// ist HD hier bandbreiten-unkritisch und liefert das beste Standbild. Der Server
// öffnet das Gerät pro Grab kurz und schliesst es wieder. Fehler (z. B. Gerät
// gerade belegt) werden still übersprungen, das letzte gute Bild bleibt stehen.
const SNAPSHOT_INTERVAL_MS = 15000;

async function fetchSnapshot(cam) {
  if (!cam.snapshotActive) return;
  try {
    const r = await fetch(`/api/snapshot/${encodeURIComponent(cam.id)}/hires?t=${Date.now()}`,
      { signal: AbortSignal.timeout(20000) });
    if (!r.ok) { setInfo(cam, `Snapshot-Fehler (HTTP ${r.status})`, 'warn'); return; }
    const blob = await r.blob();
    const w = r.headers.get('X-Frame-Width') || '?';
    const url = URL.createObjectURL(blob);
    cam.img.src = url;
    if (cam.lastBlobUrl) URL.revokeObjectURL(cam.lastBlobUrl);
    cam.lastBlobUrl = url;
    setInfo(cam, `Einzelbild ${w}px · ${new Date().toLocaleTimeString()}`, 'ok');
  } catch (_e) {
    setInfo(cam, 'Snapshot-Timeout', 'warn');
  }
}

function startSnapshotMode(cam) {
  cam.snapshotActive = true;
  setInfo(cam, 'Einzelbild lädt…', '');
  fetchSnapshot(cam); // sofort das erste Bild
  cam.snapTimer = setInterval(() => fetchSnapshot(cam), SNAPSHOT_INTERVAL_MS);
  log(cam.id, `Snapshot-Modus (alle ${SNAPSHOT_INTERVAL_MS / 1000} s)`);
}

// ── HD-Snapshot ───────────────────────────────────────────────────────────────
async function runHiresGrab(cam) {
  if (cam.busy) return;
  cam.busy = true;
  cam.hdBtn.disabled = true;
  setInfo(cam, cam.stream ? 'HD: erfasse… (Stream friert kurz)' : 'HD: erfasse…', 'warn');
  log(cam.id, '── HD-Grab gestartet ──');

  let blobUrl = null;
  try {
    const r = await fetch(`/api/snapshot/${encodeURIComponent(cam.id)}/hires`, { signal: AbortSignal.timeout(20000) });
    if (!r.ok) {
      const body = await r.json().catch(() => ({}));
      throw new Error(body.error ?? `HTTP ${r.status}`);
    }
    const blob = await r.blob();
    const width = r.headers.get('X-Frame-Width') || '?';
    blobUrl = URL.createObjectURL(blob);

    const a = document.createElement('a');
    a.href = blobUrl;
    a.download = `${cam.id}_hires_${Date.now()}.jpg`;
    document.body.appendChild(a);
    a.click();
    a.remove();

    setInfo(cam, `HD gespeichert (${width}px)`, 'ok');
    log(cam.id, `HD-Grab OK – ${blob.size} bytes, ${width}px`);
  } catch (e) {
    logErr(cam.id, 'HD-Grab fehlgeschlagen', e);
    setInfo(cam, `HD Fehler: ${e.message}`, 'crit');
  } finally {
    if (blobUrl) setTimeout(() => URL.revokeObjectURL(blobUrl), 10000);
    cam.busy = false;
    cam.hdBtn.disabled = false;
    log(cam.id, '── HD-Grab beendet ──');
  }
}

// ── HD-Snapshot aller Kameras (parallel) ──────────────────────────────────────
// cam0/cam1 liegen auf getrennten Geräten → der Schalter grabbt beide parallel
// gefahrlos (jeder Schalter steuert nur sein eigenes Gerät).
async function snapshotAllHires() {
  const snapBtn = document.getElementById('snapAllBtn');
  if (snapBtn) snapBtn.disabled = true;
  log('snap', `HD-Grab alle: ${cameras.map((c) => c.id).join(', ')}`);
  try {
    await Promise.allSettled(cameras.map((c) => runHiresGrab(c)));
  } finally {
    if (snapBtn) snapBtn.disabled = false;
    log('snap', '── HD-Grab alle beendet ──');
  }
}

// ── Status-Anzeige ────────────────────────────────────────────────────────────
function setInfo(cam, text, cls) {
  cam.infoEl.textContent = text;
  cam.infoEl.className = 'cam-info ' + (cls ?? '');
}

// ── Kamera-View aufbauen ──────────────────────────────────────────────────────
// camMeta = { id, name, position, stream, hires }
function buildCamera(camMeta, container) {
  const box = document.createElement('div');
  box.className = 'cam-box';

  const labelText = camMeta.name + (camMeta.position ? ` · ${camMeta.position}` : '');
  const label = document.createElement('div');
  label.className = 'cam-label';
  label.textContent = labelText;

  const info = document.createElement('div');
  info.className = 'cam-info';
  info.textContent = camMeta.stream ? '…' : 'Nur Snapshot';

  const hd = document.createElement('button');
  hd.className = 'cam-hdtest';
  hd.textContent = 'HD';

  const cam = { id: camMeta.id, stream: camMeta.stream, encode: camMeta.encode, mseCodec: camMeta.mseCodec, box, infoEl: info, hdBtn: hd, active: false, busy: false };

  hd.onclick = () => runHiresGrab(cam);

  if (camMeta.stream && camMeta.encode === 'h264') {
    // H.264-Kamera: <video> + MSE statt <img>.
    const video = document.createElement('video');
    video.className = 'cam-img';
    video.muted = true; video.autoplay = true; video.playsInline = true;
    video.setAttribute('playsinline', ''); video.setAttribute('muted', '');

    const toggle = document.createElement('button');
    toggle.className = 'cam-toggle';
    toggle.onclick = () => { if (!cam.busy) (cam.active ? stopH264Stream(cam) : startH264Stream(cam)); };

    cam.video = video;
    cam.toggleBtn = toggle;
    hd.title = 'Hi-Res-Snapshot – Live friert kurz ein, dann Download';

    box.appendChild(video);
    box.appendChild(toggle);
    startH264Stream(cam);
  } else if (camMeta.stream) {
    const img = document.createElement('img');
    img.className = 'cam-img';
    img.alt = labelText;
    img.addEventListener('load', () => { if (cam.active && !cam.busy) setInfo(cam, 'MJPEG · live', 'ok'); });
    img.addEventListener('error', () => {
      if (!cam.active) return;
      setInfo(cam, 'Verbindungsfehler – neu…', 'crit');
      setTimeout(() => { if (cam.active && !cam.busy) startStream(cam); }, 2000);
    });

    const toggle = document.createElement('button');
    toggle.className = 'cam-toggle';
    toggle.onclick = () => { if (!cam.busy) (cam.active ? stopStream(cam) : startStream(cam)); };

    cam.img = img;
    cam.toggleBtn = toggle;
    hd.title = 'Hi-Res-Snapshot (1280×960) – Live friert kurz ein, dann Download';

    box.appendChild(img);
    box.appendChild(toggle);
    startStream(cam);
  } else {
    // Snapshot-Modus: grosser Banner + Einzelbild, das alle 5 s aktualisiert wird.
    const banner = document.createElement('div');
    banner.className = 'single-pic-banner';
    banner.textContent = 'Single Picture no Video';

    const img = document.createElement('img');
    img.className = 'cam-img';
    img.alt = labelText;

    cam.img = img;
    hd.title = 'Hi-Res-Snapshot – Download';

    box.appendChild(banner);
    box.appendChild(img);
    startSnapshotMode(cam);
  }

  box.appendChild(label);
  box.appendChild(info);
  box.appendChild(hd);
  container.appendChild(box);

  cameras.push(cam);
}

// ── Init ──────────────────────────────────────────────────────────────────────
async function init() {
  log('init', 'Starte...');
  const container = document.getElementById('cameras');
  const statusText = document.getElementById('statusText');

  let camList = [];
  try {
    const r = await fetch('/api/snapshot');
    log('init', `/api/snapshot → HTTP ${r.status}`);
    if (r.ok) camList = (await r.json()).cameras ?? [];
    log('init', `Kameras: ${camList.map((c) => c.id).join(', ') || '(keine)'}`);
  } catch (e) {
    logErr('init', '/api/snapshot Fehler – Fallback', e);
  }
  if (camList.length === 0) {
    warn('init', 'Fallback cam0, cam1');
    camList = [
      { id: 'cam0', name: 'cam0', position: '', stream: true, hires: true },
      { id: 'cam1', name: 'cam1', position: '', stream: true, hires: true },
    ];
  }

  const snapBtn = document.getElementById('snapAllBtn');
  if (snapBtn) { snapBtn.onclick = snapshotAllHires; snapBtn.disabled = false; }

  camList.forEach((c) => buildCamera(c, container));
  const modes = new Set(camList.filter((c) => c.stream !== false).map((c) => (c.encode === 'h264' ? 'H.264' : 'MJPEG')));
  const modeLabel = modes.size ? [...modes].join('+') : '—';
  statusText.textContent = `${camList.length} Kamera${camList.length !== 1 ? 's' : ''} · ${modeLabel}`;
  log('init', 'Fertig');
}

init();
