'use strict';

const { spawn } = require('child_process');
const EventEmitter = require('events');
const { h264LiveArgs } = require('./hwencode');
const { Fmp4Parser } = require('./fmp4Parser');

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// Video-Ausgabe-Args, umschaltbar via ENCODE_MODE (server.js):
//   'copybsf' (Default) → Bitstream-Passthrough. KEIN De-/Encode → niedrigste CPU.
//      `mjpeg2jpeg` ergänzt die JPEG-Tables, die das Kamera-MJPEG weglässt → ohne den
//      Filter verschluckt sich der mpjpeg-Muxer (107% + Hang, siehe 09). MIT Filter:
//      sauberer Copy, browser-valide JPEGs. Auf der Hardware getestet (2026-06-05).
//   'mjpeg'            → Re-Encode mjpeg→mjpeg (~50%, wie go2rtc). Sicherer Fallback.
function videoOutArgs(mode) {
  if (mode === 'mjpeg') return ['-c:v', 'mjpeg', '-q:v', '5'];
  return ['-c:v', 'copy', '-bsf:v', 'mjpeg2jpeg'];
}

// Liest Bildbreite aus JPEG-Header (SOF0/SOF2-Marker) ohne externe Bibliothek.
// Gibt null zurück wenn der Marker nicht gefunden wird.
function readJpegWidth(buf) {
  let i = 2; // SOI (FF D8) überspringen
  while (i < buf.length - 8) {
    if (buf[i] !== 0xFF) break;
    const marker = buf[i + 1];
    const segLen = buf.readUInt16BE(i + 2);
    if (marker === 0xC0 || marker === 0xC2) {
      return buf.readUInt16BE(i + 7); // Breite bei Offset +7 im SOF
    }
    i += 2 + segLen;
  }
  return null;
}

// ── Parser für FFmpeg `-f mpjpeg` ────────────────────────────────────────────
// FFmpeg schreibt pro Frame:  --<boundary>\r\nContent-Type: image/jpeg\r\n
// Content-Length: <n>\r\n\r\n<n bytes JPEG>\r\n
// Wir keyen auf Content-Length → deterministisch, unabhängig vom Boundary-String.
class MpjpegParser {
  constructor(onFrame) {
    this.onFrame = onFrame;
    this.buf = Buffer.alloc(0);
    this.need = -1; // -1 = Header-Modus, sonst erwartete Body-Bytes
  }

  push(chunk) {
    this.buf = this.buf.length ? Buffer.concat([this.buf, chunk]) : chunk;
    for (;;) {
      if (this.need < 0) {
        const headEnd = this.buf.indexOf('\r\n\r\n');
        if (headEnd < 0) {
          // Schutz gegen unbegrenztes Puffern bei unerwartetem Müll
          if (this.buf.length > (1 << 20)) this.buf = this.buf.subarray(this.buf.length - 4096);
          return;
        }
        const header = this.buf.toString('latin1', 0, headEnd);
        const m = /content-length:\s*(\d+)/i.exec(header);
        if (!m) { this.buf = this.buf.subarray(headEnd + 4); continue; }
        this.need = parseInt(m[1], 10);
        this.buf = this.buf.subarray(headEnd + 4);
      }
      if (this.buf.length < this.need) return;
      const frame = this.buf.subarray(0, this.need);
      this.buf = this.buf.subarray(this.need);
      this.need = -1;
      try { this.onFrame(frame); } catch (_e) { /* Consumer-Fehler ignorieren */ }
    }
  }
}

// ── CameraSwitch ─────────────────────────────────────────────────────────────
// Eine Instanz pro physischem Gerät. Der EINZIGE Öffner von /dev/videoN.
// Hält IMMER nur EINEN FFmpeg-Prozess: entweder Live (640) oder HD-Grab (1280) —
// NIE beide. Der Übergang wird über das `close`-Event des FFmpeg-Kindprozesses
// synchronisiert: Prozess weg ⇒ Kernel hat den Device-FD geschlossen ⇒ Gerät frei.
// Genau dieses Signal verweigert go2rtcs API — deshalb der Eigenbau.
//
// Events:  'frame' (Buffer)  – je ein Live-JPEG
class CameraSwitch extends EventEmitter {
  constructor({ id, device, liveSize = '640x480', liveFps = 30, hiresSize = '1280x960', hiresFps = 15, encode = 'copybsf', hiresEncode, onDemand = true, idleGraceMs = 15000, stream = true, hwenc = null, h264 = {}, mseCodec = null }) {
    super();
    this.setMaxListeners(0); // beliebig viele Stream-Clients
    this.id = id;
    this.device = device;
    this.liveSize = liveSize;
    this.liveFps = liveFps;
    this.streamEnabled = stream; // UI "Aus": Kamera darf NICHT live gehen (gated _spawnLive)
    this.hiresSize = hiresSize;
    this.hiresFps = hiresFps;
    this.encode = encode;           // Live: 'copybsf' (Default) | 'mjpeg' (Re-Encode) | 'h264' (GPU)
    this.hiresEncode = hiresEncode ?? (encode === 'h264' ? 'copybsf' : encode); // Grab bleibt JPEG (h264 → copybsf)
    this.hwenc = hwenc;             // { encoder, device } – GPU-Backend (global, von server.js); nur für encode='h264'
    this.h264 = h264;               // Tuning { bitrate, gop, profile, fragMs, jpegFps }
    this.mseCodec = mseCodec;       // MSE-Codec-String für den Browser (z. B. 'avc1.4D401F')
    this.onDemand = onDemand;       // Live nur laufen lassen, solange Verbraucher da sind
    this.idleGraceMs = idleGraceMs; // Karenz nach letztem Verbraucher vor dem Stop

    this.proc = null;          // aktueller FFmpeg-Prozess (Live ODER Grab)
    this.latest = null;        // letztes Live-JPEG (für /api/snapshot); null wenn Live aus
    this.initSegment = null;   // fMP4 Init (ftyp+moov), nur im h264-Modus gesetzt
    this.state = 'stopped';    // stopped | live | grabbing
    this.lock = false;         // Mutex: nur ein Grab gleichzeitig
    this.stopping = false;     // unterscheidet absichtliches Kill von Crash
    this.restartTimer = null;
    this.subscribers = 0;      // aktive Verbraucher (Stream-Clients + laufende Snapshots)
    this.idleTimer = null;
  }

  start() {
    // On-Demand: lazy – Live startet erst beim ersten Verbraucher (acquire()).
    if (this.onDemand) return;
    if (this.streamEnabled && this.state === 'stopped' && !this.proc) this._spawnLive();
  }

  // ── Verbraucher-Zählung / On-Demand ────────────────────────────────────────
  acquire() {
    this.subscribers++;
    if (this.idleTimer) { clearTimeout(this.idleTimer); this.idleTimer = null; }
    if (this.onDemand && this.streamEnabled && this.state === 'stopped' && !this.lock && !this.proc) this._spawnLive();
  }

  release() {
    this.subscribers = Math.max(0, this.subscribers - 1);
    if (this.subscribers === 0 && this.onDemand) this._scheduleIdleStop();
  }

  _scheduleIdleStop() {
    if (this.idleTimer) return;
    this.idleTimer = setTimeout(() => {
      this.idleTimer = null;
      if (this.subscribers === 0 && this.state === 'live' && !this.lock && this.proc) {
        console.log(`[cam ${this.id}] idle (keine Clients) → Live gestoppt`);
        this.stopping = true; // → 'close'-Handler startet NICHT neu
        try { this.proc.kill('SIGTERM'); } catch (_e) {}
      }
    }, this.idleGraceMs);
  }

  // Aktuelles Frame für /api/snapshot. Startet bei Bedarf on-demand und wartet
  // auf das erste frische Frame (latest ist null, solange Live aus ist).
  async getFrame(timeoutMs = 4000) {
    this.acquire();
    try {
      if (this.latest) return this.latest; // frisch (nur gesetzt solange Live läuft)
      return await new Promise((resolve, reject) => {
        const t = setTimeout(() => { this.removeListener('frame', onF); reject(new Error('Frame-Timeout')); }, timeoutMs);
        const onF = (f) => { clearTimeout(t); this.removeListener('frame', onF); resolve(f); };
        this.on('frame', onF);
      });
    } finally {
      this.release();
    }
  }

  // ── Live-Producer (Dauerbetrieb, Auto-Restart bei Crash) ───────────────────
  _spawnLive() {
    if (!this.streamEnabled) return; // UI "Aus" → Kamera bleibt dunkel
    this.stopping = false;
    const isH264 = this.encode === 'h264';

    // Args + stdio je nach Modus. h264 nutzt fd 3 für den MJPEG-Nebenausgang
    // (hält `latest`/Snapshots am Leben), der MJPEG-Pfad bleibt unverändert.
    let args;
    let useFd3 = false;
    if (isH264) {
      if (!this.hwenc) { console.error(`[cam ${this.id}] encode=h264 ohne hwenc-Konfig → kein Start`); return; }
      ({ args, useFd3 } = h264LiveArgs({
        device: this.device, size: this.liveSize, fps: this.liveFps, hwenc: this.hwenc,
        bitrate: this.h264.bitrate, gop: this.h264.gop, profile: this.h264.profile,
        fragMs: this.h264.fragMs, jpegFps: this.h264.jpegFps, jpegSnapshots: true,
      }));
    } else {
      args = [
        '-hide_banner', '-loglevel', 'warning',
        '-fflags', 'nobuffer',                       // Input nicht puffern → niedrige Latenz
        '-f', 'v4l2', '-input_format', 'mjpeg',
        '-video_size', this.liveSize, '-framerate', String(this.liveFps),
        '-i', this.device,
        ...videoOutArgs(this.encode),
        '-f', 'mpjpeg', '-flush_packets', '1', 'pipe:1', // jedes Frame sofort rausschreiben
      ];
    }

    let p;
    try {
      p = spawn('ffmpeg', args, { stdio: useFd3 ? ['ignore', 'pipe', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe'] });
    } catch (e) {
      console.error(`[cam ${this.id}] spawn fehlgeschlagen: ${e.message} → Retry in 1.5s`);
      this._scheduleRestart();
      return;
    }
    this.proc = p;
    this.state = 'live';

    if (isH264) {
      // pipe:1 = fMP4 → Init-Segment cachen + Fragmente an die MSE-Clients fan-outen.
      const fmp4 = new Fmp4Parser({
        onInit: (init) => { this.initSegment = init; this.emit('init', init); },
        onSegment: (seg) => this.emit('segment', seg),
      });
      p.stdout.on('data', (c) => fmp4.push(c));
      // pipe:3 = gedrosseltes MJPEG → `latest` für /api/snapshot + <img>-Fallback.
      if (useFd3 && p.stdio[3]) {
        const jpg = new MpjpegParser((frame) => { this.latest = frame; this.emit('frame', frame); });
        p.stdio[3].on('data', (c) => jpg.push(c));
      }
    } else {
      const parser = new MpjpegParser((frame) => {
        this.latest = frame;
        this.emit('frame', frame);
      });
      p.stdout.on('data', (c) => parser.push(c));
    }

    p.stderr.on('data', (c) => {
      const s = c.toString();
      if (/error|busy|invalid|no such|cannot|denied/i.test(s)) console.error(`[cam ${this.id}] ffmpeg: ${s.trim()}`);
    });
    p.on('error', (e) => console.error(`[cam ${this.id}] live ffmpeg error: ${e.message}`));
    p.on('close', (code, sig) => {
      this.proc = null;
      this.latest = null; // Producer weg → gepuffertes Frame ist stale
      this.initSegment = null; // neuer Producer ⇒ neues Init-Segment
      const wasStopping = this.stopping;
      if (this.state === 'live') this.state = 'stopped';
      if (wasStopping) return; // beabsichtigt (HD-Grab/idle) → kein Auto-Restart hier
      console.warn(`[cam ${this.id}] live ffmpeg unerwartet beendet (code=${code} sig=${sig}) → Restart`);
      this._scheduleRestart();
    });
    console.log(`[cam ${this.id}] live gestartet (${this.liveSize}@${this.liveFps}, ${this.device}, encode=${this.encode}${isH264 ? `/${this.hwenc.encoder}` : ''})`);
  }

  _scheduleRestart() {
    if (!this.streamEnabled) return; // UI "Aus" → kein Auto-Restart
    if (this.onDemand && this.subscribers === 0) return; // niemand schaut → nicht neu starten
    if (this.restartTimer) return;
    this.restartTimer = setTimeout(() => {
      this.restartTimer = null;
      if (this.streamEnabled && this.state === 'stopped' && !this.lock && (!this.onDemand || this.subscribers > 0)) this._spawnLive();
    }, 1500);
  }

  // ── Hot-Reload (config.html / POST /api/config) ────────────────────────────
  // Wendet eine neue Live-Auflösung bzw. Stream-An/Aus zur Laufzeit an, OHNE
  // Container-Restart. Nutzt die vorhandenen Bausteine (_killCurrentAndWait /
  // _spawnLive) und respektiert Lock (HD-Grab) sowie On-Demand.
  async reconfigure({ liveSize, stream, encode } = {}) {
    if (typeof stream === 'boolean') this.streamEnabled = stream;
    const encodeChanged = !!encode && encode !== this.encode;
    if (encode) this.encode = encode;

    // Während eines HD-Grabs nicht eingreifen – die neue liveSize/encode gilt nach
    // dem Grab (grabHires startet Live über _spawnLive neu, das beide liest).
    if (this.lock) { if (liveSize) this.liveSize = liveSize; return; }

    const sizeChanged = !!liveSize && liveSize !== this.liveSize;
    if (liveSize) this.liveSize = liveSize;

    // Stream deaktiviert → laufenden Live-Prozess stoppen, NICHT neu starten.
    if (!this.streamEnabled) {
      if (this.proc && this.state === 'live') await this._killCurrentAndWait();
      this.state = 'stopped';
      return;
    }

    // Auflösung ODER Encoder geändert → laufenden Prozess beenden (FD frei via close).
    if ((sizeChanged || encodeChanged) && this.proc && this.state === 'live') {
      await this._killCurrentAndWait();
      this.state = 'stopped';
    }

    // (Neu) starten, wenn nichts läuft und Verbraucher da sind (On-Demand) bzw.
    // Dauerbetrieb. _spawnLive ist zusätzlich durch streamEnabled gegated.
    if (this.state === 'stopped' && !this.proc && (!this.onDemand || this.subscribers > 0)) {
      this._spawnLive();
    }
  }

  // ── HD-Grab ────────────────────────────────────────────────────────────────
  // Wenn liveSize == hiresSize: kein Format-Wechsel nötig. Live-Frame direkt
  // zurückgeben (on-demand startet den Stream bei Bedarf). Schnell, kein Gerät-
  // Neustart, kein Format-Übergangs-Problem.
  //
  // Wenn liveSize ≠ hiresSize: Live stoppen → hires-FFmpeg → zurück.
  // minWidth (90 % der Soll-Breite) verhindert dass Übergangs-Frames (falsche
  // Auflösung aus dem v4l2-Buffer des vorigen Formats) akzeptiert werden.
  // Beispiel: hiresSize="1280x960" → minWidth=1152 → lehnt 640er-Frames ab.
  async grabHires(opts = {}) {
    // Shortcut: keine Format-Umschaltung wenn Live- und Hires-Auflösung identisch
    if (this.liveSize === this.hiresSize) {
      return this.grabSnapshot();
    }

    const hiresW = parseInt(this.hiresSize.split('x')[0], 10);
    const {
      minSize = 15000,
      minWidth = Math.floor(hiresW * 0.9),  // nur Frames nahe der Soll-Breite akzeptieren
      settleFrames = 6,
      maxWaitMs = 10000,
    } = opts;
    if (this.lock) throw new Error('HD-Grab läuft bereits');
    this.lock = true;
    const t0 = Date.now();
    if (this.restartTimer) { clearTimeout(this.restartTimer); this.restartTimer = null; }

    try {
      // 1. Live-FFmpeg beenden, auf Prozess-Ende warten (= Device-FD frei).
      //    Das close-Event ist der harte Beweis, dass der FD zu ist → kein zweiter
      //    Öffner. KEIN warmup/sleep: auf dem Host bestätigt (A/B-Test 2026-06-06),
      //    dass ein direkter Open auf die Zielauflösung sofort korrekte Frames liefert
      //    (1920×1080 bzw. 1280×960, jedes Frame). Ein zweites ffmpeg dazwischen
      //    erzeugt nur „Device or resource busy".
      await this._killCurrentAndWait();
      this.state = 'grabbing';
      console.log(`[cam ${this.id}] HD: Live gestoppt nach ${Date.now() - t0}ms, Gerät frei → ${this.hiresSize}-Grab (minWidth=${minWidth}, encode=${this.hiresEncode})`);

      // 2. hires-FFmpeg starten, warmlaufen lassen (settleFrames), besten Frame greifen.
      //    minWidth lehnt etwaige Übergangs-Frames in falscher Auflösung ab.
      const jpeg = await this._captureAt(this.hiresSize, this.hiresFps, this.hiresEncode, { minSize, minWidth, settleFrames, maxWaitMs });
      const gotW = readJpegWidth(jpeg) ?? '?';
      console.log(`[cam ${this.id}] HD OK – ${jpeg.length} bytes, Breite=${gotW}px (Soll: ${hiresW}px, ${Date.now() - t0}ms)`);
      return jpeg;
    } finally {
      // 3. Zurück auf Live, sofern noch Verbraucher da sind (On-Demand). Live hat Priorität.
      this.state = 'stopped';
      if (!this.onDemand || this.subscribers > 0) this._spawnLive();
      this.lock = false;
      console.log(`[cam ${this.id}] HD beendet${(!this.onDemand || this.subscribers > 0) ? ', Live zurück' : ' (idle, kein Live-Restart)'} (gesamt ${Date.now() - t0}ms)`);
    }
  }

  // ── Einzelbild-Snapshot (für /api/snapshot, inkl. Snapshot-Modus) ──────────
  // Läuft Live → aktuelles Frame (schnell, kein Geräte-Neustart). Sonst:
  //   • streamEnabled (Live an, aber Frame noch nicht da) → getFrame() (on-demand).
  //   • stream:false (Snapshot-Modus) → one-shot open/grab/close an liveSize. Das
  //     Gerät bleibt dazwischen geschlossen → spart CPU/USB; der Viewer pollt das
  //     alle paar Sekunden. streamEnabled bleibt aus (kein Dauer-Stream).
  async grabSnapshot() {
    if (this.latest) return this.latest;            // Live läuft → sofort
    if (this.streamEnabled) return this.getFrame(); // Live an, Frame kommt gleich

    if (this.lock) throw new Error('Gerät belegt (Grab läuft)');
    this.lock = true;
    if (this.restartTimer) { clearTimeout(this.restartTimer); this.restartTimer = null; }
    this.state = 'grabbing';
    try {
      const w = parseInt(this.liveSize.split('x')[0], 10) || 0;
      return await this._captureAt(this.liveSize, this.liveFps, this.encode, {
        minSize: 1000,
        minWidth: w ? Math.floor(w * 0.8) : 0,
        settleFrames: 3,
        maxWaitMs: 6000,
      });
    } finally {
      this.state = 'stopped';
      this.lock = false;
      // Falls inzwischen wieder Live gewünscht ist (stream:true + Verbraucher):
      if (this.streamEnabled && (!this.onDemand || this.subscribers > 0)) this._spawnLive();
    }
  }

  // Beendet den aktuellen Prozess und resolved erst nach dessen 'close' (FD frei).
  _killCurrentAndWait(timeoutMs = 4000) {
    return new Promise((resolve) => {
      const p = this.proc;
      if (!p) return resolve();
      this.stopping = true;
      let done = false;
      const fin = () => { if (!done) { done = true; resolve(); } };
      p.once('close', fin);
      try { p.kill('SIGTERM'); } catch (_e) { /* schon weg */ }
      setTimeout(() => { if (!done) { try { p.kill('SIGKILL'); } catch (_e) {} } }, Math.max(500, timeoutMs - 1000));
      setTimeout(fin, timeoutMs); // Sicherheitsnetz
    });
  }

  // Generischer one-shot Capture an beliebiger Auflösung (open → settle → grab → close).
  // Genutzt von grabHires (hiresSize) und grabSnapshot (liveSize, Snapshot-Modus).
  _captureAt(size, fps, encode, { minSize, minWidth, settleFrames, maxWaitMs }) {
    return new Promise((resolve, reject) => {
      const args = [
        '-hide_banner', '-loglevel', 'warning',
        '-fflags', 'nobuffer',
        '-probesize', '5000000',
        '-analyzeduration', '1000000',
        '-f', 'v4l2', '-input_format', 'mjpeg',
        '-video_size', size, '-framerate', String(fps),
        '-i', this.device,
        ...videoOutArgs(encode), '-f', 'mpjpeg', 'pipe:1',
      ];
      let p;
      try {
        p = spawn('ffmpeg', args, { stdio: ['ignore', 'pipe', 'pipe'] });
      } catch (e) { return reject(e); }
      this.proc = p;
      this.stopping = false;

      let count = 0;
      let best = null;
      let decided = false;
      let closed = false;
      let finalized = false;
      let storedResult = null;
      let storedErr = null;
      let timer = setTimeout(() => decide(best, best ? null : new Error('HD-Timeout')), maxWaitMs);
      let hardFin = null;

      function finalize(self) {
        if (finalized) return;
        finalized = true;
        if (hardFin) clearTimeout(hardFin);
        self.proc = null;
        if (storedResult) resolve(storedResult);
        else reject(storedErr || new Error('kein HD-Frame'));
      }

      const self = this;
      function decide(result, err) {
        if (decided) return;
        decided = true;
        storedResult = result;
        storedErr = err;
        if (timer) { clearTimeout(timer); timer = null; }
        if (closed) { finalize(self); return; }
        // Prozess beenden; finalize() erst nach 'close' (= FD frei)
        self.stopping = true;
        try { p.kill('SIGTERM'); } catch (_e) {}
        setTimeout(() => { if (!closed) { try { p.kill('SIGKILL'); } catch (_e) {} } }, 800);
        hardFin = setTimeout(() => finalize(self), 2500); // falls 'close' ausbleibt
      }

      const parser = new MpjpegParser((frame) => {
        count++;
        if (!best || frame.length > best.length) best = frame;
        const w = readJpegWidth(frame);
        if (count >= settleFrames) {
          if (frame.length >= minSize && (w === null || w >= minWidth)) {
            decide(Buffer.from(frame), null); // kopieren: subarray teilt den Parser-Puffer
          } else {
            console.warn(`[cam ${this.id}] hires frame ${count} verworfen: ${frame.length} bytes width=${w ?? '?'} (minSize=${minSize}, minWidth=${minWidth})`);
          }
        }
      });

      p.stdout.on('data', (c) => parser.push(c));
      p.stderr.on('data', (c) => {
        const s = c.toString().trim();
        if (!s) return;
        if (/error|busy|invalid|no such|cannot|denied|input is truncated|Could not find codec parameters|Immediate exit requested|Output file is empty/i.test(s)) {
          console.warn(`[cam ${this.id}] hires ffmpeg: ${s}`);
        } else {
          console.log(`[cam ${this.id}] hires ffmpeg: ${s}`);
        }
      });
      p.on('error', (e) => { if (!decided) decide(null, e); });
      p.on('close', () => {
        closed = true;
        if (decided) finalize(self);
        else decide(best ? Buffer.from(best) : null, best ? null : new Error('HD-FFmpeg vorzeitig beendet'));
      });
    });
  }
}


module.exports = { CameraSwitch, MpjpegParser, readJpegWidth };
