import { AmcpClient } from './amcp.js';
import { spawn } from 'node:child_process';
import { mkdirSync, readdirSync, unlinkSync } from 'node:fs';

// Playout manager — owns one CasparCG channel's lifecycle inside this sidecar.
//
// One sidecar container == one CasparCG Server == one logical channel (channel
// index 1 in CasparCG terms). We add the output consumer (DeckLink / NDI / SRT
// / RTMP) at start, then walk a playlist by cueing the next clip on a background
// layer (LOADBG ... AUTO) so CasparCG performs a gapless transition at end of
// the current clip.
//
// Media is referenced by a path relative to CasparCG's configured media folder
// (/media inside the container). The mam-api stages assets from S3 to that
// shared volume and passes the resolved relative path on each item.

const CHANNEL = 1;       // single CasparCG channel per sidecar
const FG_LAYER = 10;     // foreground (on-air) layer
const MEDIA_ROOT = process.env.CASPAR_MEDIA_ROOT || '/media';

// Channel-id-derived HLS preview path. The mam-api proxies /live/<channel_id>/
// to this directory (shared media volume) so the UI's existing HLS player
// (capture's /live/<id> plumbing) works for playout monitors with zero new
// transport.
const CHANNEL_ID = process.env.CHANNEL_ID || '';
const HLS_DIR    = CHANNEL_ID ? `${MEDIA_ROOT}/live/${CHANNEL_ID}` : '';

// Loopback UDP port CasparCG's preview STREAM consumer publishes mpegts to, and
// the standalone ffmpeg re-muxer reads from. One CasparCG per sidecar, so a
// fixed port is fine; allow override for parallel local testing.
const PREVIEW_UDP_PORT = parseInt(process.env.PREVIEW_UDP_PORT || '9710', 10);
const PREVIEW_UDP_URL  = `udp://127.0.0.1:${PREVIEW_UDP_PORT}`;

// CasparCG SEEK / LENGTH are in frames, not seconds. Capture standard is 59.94;
// SD/film modes need their own values. Default 60000/1001 matches both
// '1080p5994' and '1080i5994'.
function fpsFor(videoFormat) {
  const f = String(videoFormat || '').toLowerCase();
  if (f.endsWith('5994')) return 60000 / 1001;
  if (f.endsWith('p60') || f.endsWith('i60'))  return 60;
  if (f.endsWith('p50') || f.endsWith('i50'))  return 50;
  if (f.endsWith('2997')) return 30000 / 1001;
  if (f.endsWith('p30'))  return 30;
  if (f.endsWith('p25'))  return 25;
  if (f.endsWith('p24') || f.endsWith('2398')) return 24000 / 1001;
  return 60000 / 1001;   // safe default for the house standard
}

// CasparCG transition syntax fragments keyed by our item.transition value.
function transitionArgs(transition, ms, fps) {
  if (!transition || transition === 'cut' || !ms) return '';
  const frames = Math.max(1, Math.round((ms / 1000) * fps));
  if (transition === 'mix') return ` MIX ${frames}`;
  if (transition === 'wipe') return ` WIPE ${frames}`;
  return '';
}

// Turn an absolute /media path (or a relative one) into the token CasparCG
// expects: a path relative to MEDIA_ROOT, without extension, forward-slashed.
// CasparCG resolves "subdir/clip" against its media folder + probes extensions.
function toCasparToken(mediaPath) {
  let p = String(mediaPath || '');
  if (p.startsWith(MEDIA_ROOT)) p = p.slice(MEDIA_ROOT.length);
  p = p.replace(/^\/+/, '');
  p = p.replace(/\.[^/.]+$/, ''); // strip extension
  return p;
}

export class PlayoutManager {
  constructor() {
    this.amcp = new AmcpClient({
      host: process.env.CASPAR_HOST || '127.0.0.1',
      port: parseInt(process.env.CASPAR_PORT || '5250', 10),
    });
    this.state = {
      running: false,
      outputType: null,
      outputConfig: null,
      videoFormat: null,
      playlist: [],        // resolved items in play order
      currentIndex: -1,
      loop: false,
      currentClip: null,
      startedAt: null,
      lastError: null,
      // SCTE-35: the currently-active ad break, if any. Set by triggerScte and
      // cleared by a timer when the break window elapses. Surfaced in getStatus
      // so the UI can render an "in break" state + countdown.
      scteActive: null,    // { eventId, type, durationS, firedAt(iso), endsAt(iso) }
    };
    this._advanceTimer = null;
    this._scteTimer = null;
    this._hlsProc = null;     // standalone ffmpeg re-mux child process
    this._hlsRestartTimer = null;
  }

  async _consumerCommand(outputType, cfg) {
    // Returns the AMCP ADD argument string for the requested output target.
    if (outputType === 'decklink') {
      const dev = cfg.device_index || 1;
      return `DECKLINK DEVICE ${dev} EMBEDDED_AUDIO`;
    }
    if (outputType === 'ndi') {
      const name = cfg.ndi_name || 'DRAGONFLIGHT';
      return `NDI NAME "${name}"`;
    }
    if (outputType === 'srt' || outputType === 'rtmp') {
      // CasparCG 2.3 streams via the FFMPEG consumer, invoked with the STREAM
      // keyword (FILE/STREAM are interchangeable aliases for it; the bare word
      // "FFMPEG" is the PRODUCER and is NOT a valid consumer keyword). Args must
      // use ffmpeg's -param:stream form (-codec:v, not -vcodec) or CasparCG
      // rejects them. The channel feeds the consumer as RGBA, so a
      // format=yuv420p filter is required before libx264.
      const url = cfg.url || '';
      if (outputType === 'srt') {
        const latency = cfg.latency || 200;
        const full = url.includes('latency=') ? url : `${url}${url.includes('?') ? '&' : '?'}latency=${latency}`;
        return `STREAM "${full}" -format mpegts -codec:v libx264 -preset:v veryfast -tune:v zerolatency -b:v 6M -codec:a aac -b:a 192k -filter:v format=yuv420p`;
      }
      const target = cfg.key ? `${url}/${cfg.key}` : url;
      return `STREAM "${target}" -format flv -codec:v libx264 -preset:v veryfast -tune:v zerolatency -b:v 6M -codec:a aac -b:a 192k -filter:v format=yuv420p`;
    }
    throw new Error(`Unknown output_type: ${outputType}`);
  }

  // Start the channel: bring up CasparCG's primary output consumer for the
  // target, plus a second FFMPEG consumer writing HLS for the UI preview
  // monitor (~4-6s lag, reuses capture's /live/<id> plumbing).
  //
  // The primary consumer failure is NON-FATAL. CasparCG can decode and route
  // media through its pipeline even without an output consumer. This means:
  //  - NDI channels work (load/play/transport) even if libndi.so is absent.
  //  - SRT/RTMP channels work even if the destination URL is unreachable.
  //  - The HLS preview consumer is always attempted independently.
  //
  // state.consumerError is set when the primary consumer fails so the mam-api
  // can surface a warning in the channel status without blocking operation.
  async startChannel({ outputType, outputConfig = {}, videoFormat = '1080p5994' }) {
    await this.amcp.waitReady(30000);

    // Set the channel video mode first.
    try { await this.amcp.send(`SET ${CHANNEL} MODE ${videoFormat}`); }
    catch (err) { console.warn(`[playout] SET MODE failed (continuing): ${err.message}`); }

    // Primary output consumer — non-fatal.
    let consumerError = null;
    try {
      const consumer = await this._consumerCommand(outputType, outputConfig);
      await this.amcp.send(`ADD ${CHANNEL} ${consumer}`);
    } catch (err) {
      consumerError = err.message;
      console.warn(`[playout] primary consumer ADD failed (continuing without output): ${err.message}`);
    }

    // HLS preview consumer — always attempt, independently non-fatal.
    if (HLS_DIR) {
      try {
        await this._addHlsConsumer();
        console.log(`[playout] HLS preview at ${HLS_DIR}/index.m3u8`);
      } catch (err) {
        console.warn(`[playout] HLS preview consumer failed: ${err.message}`);
      }
    }

    this.state.running = true;
    this.state.outputType = outputType;
    this.state.outputConfig = outputConfig;
    this.state.videoFormat = videoFormat;
    this.state.fps = fpsFor(videoFormat);
    this.state.startedAt = new Date().toISOString();
    this.state.lastError = consumerError;
    console.log(`[playout] channel started output=${outputType} mode=${videoFormat} fps=${this.state.fps.toFixed(3)}${consumerError ? ' ⚠ consumer: ' + consumerError : ''}`);
    return this.getStatus();
  }

  // HLS preview for the web UI confidence monitor.
  //
  // ── Why not CasparCG's own HLS (FILE/STREAM ".../index.m3u8") ──────────────
  // CasparCG's bundled FFMPEG consumer muxes a BROKEN audio track into the HLS:
  // ffprobe reports `aac, sample_rate=0` and ffmpeg decoding the playlist fails
  // with "Invalid data ... abuffer: Value inf for parameter 'time_base' ...
  // time_base 1/0". That corrupt audio prevents BOTH ffmpeg and hls.js from
  // decoding, so the browser <video> sits at readyState 0 and the preview stays
  // black. The video track itself is perfectly clean h264. Critically, the
  // consumer IGNORES every arg that would fix it — `-an`, `-codec:a`, `-g`,
  // `-r`, `-force_key_frames` are all silently dropped ("Unused option"), so we
  // CANNOT remove the audio from inside CasparCG.
  //
  // ── The fix: STREAM mpegts to UDP loopback, re-mux with a STANDALONE ffmpeg ─
  // CasparCG outputs a plain mpegts elementary stream to a local UDP port (its
  // STREAM/mpegts path is fine — the breakage is specific to its HLS muxer). A
  // Node-spawned standalone ffmpeg (where `-an` actually works) reads that UDP
  // stream, drops audio, copies the clean h264 video, and writes proper HLS.
  // `-c:v copy` avoids re-encoding. The program audio is untouched — it rides
  // the PRIMARY SRT/RTMP/SDI/NDI consumer, which we never modify.
  async _addHlsConsumer() {
    // 1) CasparCG → mpegts over UDP loopback. The channel feeds RGBA, so a
    //    format=yuv420p filter is required before libx264.
    const streamArgs = [
      `STREAM "${PREVIEW_UDP_URL}?pkt_size=1316"`,
      '-format mpegts',
      '-codec:v libx264 -preset:v veryfast -tune:v zerolatency',
      '-b:v 2M -maxrate 2M -bufsize 4M',
      '-codec:a aac -b:a 96k',
      '-filter:v format=yuv420p',
    ].join(' ');
    await this.amcp.send(`ADD ${CHANNEL} ${streamArgs}`);

    // 2) Standalone ffmpeg re-mux: UDP mpegts → clean video-only HLS.
    this._startHlsRemux();
  }

  // Spawn (or respawn) the standalone ffmpeg that re-muxes the loopback mpegts
  // into video-only HLS. Restarts automatically if it dies while the channel is
  // still running (e.g. brief UDP gap before CasparCG's consumer is up).
  _startHlsRemux() {
    if (!HLS_DIR) return;
    try { mkdirSync(HLS_DIR, { recursive: true }); } catch (_) {}
    // Purge stale HLS artifacts from any prior session before starting. The
    // /media volume is a shared host bind, so a previous (or duplicate/failover)
    // sidecar can leave orphaned index*.ts + an old index.m3u8 behind. ffmpeg's
    // index%d.ts counter restarts at 0, so those leftovers collide with the new
    // segment numbering and can briefly corrupt the live playlist hls.js reads
    // (it sees a frozen / non-monotonic edge → monitor goes black). A clean dir
    // per session guarantees a coherent live timeline.
    try {
      for (const f of readdirSync(HLS_DIR)) {
        if (/\.ts$/.test(f) || /\.m3u8$/.test(f)) {
          try { unlinkSync(`${HLS_DIR}/${f}`); } catch (_) {}
        }
      }
    } catch (_) {}
    this._stopHlsRemux();

    const out = `${HLS_DIR}/index.m3u8`;
    const args = [
      '-hide_banner', '-loglevel', 'warning',
      // Read the live mpegts loopback. genpts rebuilds timestamps; the analyze/
      // probe sizes are kept small so playback starts promptly.
      '-fflags', '+genpts',
      '-analyzeduration', '2000000', '-probesize', '2000000',
      '-i', `${PREVIEW_UDP_URL}?fifo_size=1000000&overrun_nonfatal=1`,
      // Drop the (broken) audio entirely.
      '-an',
      // Re-encode (NOT -c:v copy) to uniform, keyframe-aligned 2s segments with
      // regenerated CFR timestamps. -c:v copy passed CasparCG's erratic
      // real-time keyframes straight through, producing segments of 0.6–2.8s
      // and irregular PTS; hls.js can't build a live timeline from that — it
      // logs "sliding 0.00 / MISSED", never loads a fragment, and the monitor
      // stays black even though the stream decodes cleanly server-side. A
      // standalone ffmpeg honours -force_key_frames, so every GOP (and thus
      // every HLS segment) is exactly 2.0s.
      //
      // This is a CONFIDENCE MONITOR, kept deliberately tiny: 360p / 20fps /
      // ultrafast. The sidecar has no NVENC, so this is a CPU libx264 encode
      // running ALONGSIDE CasparCG's mixer + its own STREAM consumer. At 720p30
      // the re-encode couldn't sustain real time, the UDP input overran, and the
      // HLS output stalled (playlist froze → monitor black). 360p20 ultrafast is
      // a fraction of the cost and keeps up comfortably. fps=20 forces CFR;
      // -g 40 = 2.0s GOP at 20fps.
      '-vf', 'fps=20,scale=-2:360,format=yuv420p',
      '-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'zerolatency',
      '-b:v', '600k', '-maxrate', '800k', '-bufsize', '1200k',
      '-g', '40', '-keyint_min', '40', '-sc_threshold', '0',
      '-force_key_frames', 'expr:gte(t,n_forced*2)',
      '-f', 'hls',
      '-hls_time', '2',
      '-hls_list_size', '8',
      '-hls_flags', 'delete_segments+append_list+independent_segments',
      '-hls_segment_filename', `${HLS_DIR}/index%d.ts`,
      out,
    ];
    const proc = spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'] });
    this._hlsProc = proc;
    proc.stderr.on('data', (d) => {
      const line = d.toString().trim();
      if (line) console.warn(`[playout][hls-ffmpeg] ${line}`);
    });
    proc.on('exit', (code, signal) => {
      console.warn(`[playout] HLS re-mux ffmpeg exited code=${code} signal=${signal}`);
      if (this._hlsProc === proc) this._hlsProc = null;
      // Auto-respawn while the channel is running (and we didn't kill it).
      if (this.state.running && signal !== 'SIGTERM' && signal !== 'SIGKILL') {
        this._hlsRestartTimer = setTimeout(() => {
          this._hlsRestartTimer = null;
          if (this.state.running) {
            console.log('[playout] respawning HLS re-mux ffmpeg');
            this._startHlsRemux();
          }
        }, 1000);
      }
    });
    proc.on('error', (err) => {
      console.warn(`[playout] HLS re-mux ffmpeg spawn error: ${err.message}`);
    });
    console.log(`[playout] HLS re-mux ffmpeg started: ${PREVIEW_UDP_URL} -> ${out}`);
  }

  _stopHlsRemux() {
    if (this._hlsRestartTimer) {
      clearTimeout(this._hlsRestartTimer);
      this._hlsRestartTimer = null;
    }
    if (this._hlsProc) {
      const proc = this._hlsProc;
      this._hlsProc = null;
      try { proc.kill('SIGTERM'); } catch (_) {}
    }
  }

  async stopChannel() {
    this._clearAdvance();
    this._clearScte();
    this.state.running = false;   // set first so the ffmpeg exit handler won't respawn
    this._stopHlsRemux();
    try { await this.amcp.send(`STOP ${CHANNEL}-${FG_LAYER}`); } catch (_) {}
    try { await this.amcp.send(`CLEAR ${CHANNEL}`); } catch (_) {}
    this.state.running = false;
    this.state.playlist = [];
    this.state.currentIndex = -1;
    this.state.currentClip = null;
    console.log('[playout] channel stopped');
    return { stopped: true };
  }

  // Load a playlist (array of { id, asset_id, media_path, in_point, out_point,
  // transition, transition_ms, clip_name }) and start playing from index 0.
  async loadPlaylist({ items = [], loop = false }) {
    if (!this.state.running) {
      throw new Error('Channel not started — call /channel/start first');
    }
    this.state.playlist = items;
    this.state.loop = !!loop;
    this.state.currentIndex = -1;
    if (items.length === 0) return this.getStatus();
    await this._playIndex(0);
    return this.getStatus();
  }

  async _playIndex(index) {
    const item = this.state.playlist[index];
    if (!item) return;
    const fps = this.state.fps || fpsFor(this.state.videoFormat);
    const token = toCasparToken(item.media_path);
    const seek = item.in_point ? ` SEEK ${Math.round(item.in_point * fps)}` : '';
    const length = (item.out_point && item.out_point > (item.in_point || 0))
      ? ` LENGTH ${Math.round((item.out_point - (item.in_point || 0)) * fps)}`
      : '';
    const trans = transitionArgs(item.transition, item.transition_ms, fps);

    // PLAY puts the clip on the foreground layer immediately (first clip), with
    // the configured transition. Subsequent clips are cued via LOADBG ... AUTO
    // for a gapless hand-off; see _scheduleAdvance.
    await this.amcp.send(`PLAY ${CHANNEL}-${FG_LAYER} "${token}"${seek}${length}${trans}`);
    this.state.currentIndex = index;
    this.state.currentClip = item.clip_name || token;
    console.log(`[playout] PLAY [${index}] ${token}`);
    this._reportAsRunStart(item);
    this._scheduleAdvance(item);
  }

  // Effective on-air duration of an item in milliseconds. Prefers an explicit
  // in/out trim, else the asset's full duration. Returns null when unknown (no
  // duration metadata + no out_point) so the caller can skip the timer.
  _itemDurationMs(item) {
    const inS = item.in_point || 0;
    if (item.out_point && item.out_point > inS) return (item.out_point - inS) * 1000;
    if (item.asset_duration_ms != null) return Math.max(0, item.asset_duration_ms - inS * 1000);
    return null;
  }

  // CasparCG's LOADBG ... AUTO swaps the cued background clip to foreground when
  // the current clip ends, giving a gapless visual take. But CasparCG won't cue
  // clip N+2 on its own and won't move OUR pointer / as-run bookkeeping. So we
  // also arm a duration-based timer: when the current clip is due to end we
  // advance currentIndex and cue the following clip. This keeps an arbitrary-
  // length playlist walking, not just the first two items.
  _scheduleAdvance(item) {
    this._clearAdvance();
    const next = this._nextIndex();
    if (next === null) return;   // end of a non-looping playlist
    const nextItem = this.state.playlist[next];
    const nextToken = toCasparToken(nextItem.media_path);
    const fps = this.state.fps || fpsFor(this.state.videoFormat);
    const trans = transitionArgs(nextItem.transition, nextItem.transition_ms, fps);
    // Cue next on background with AUTO so CasparCG performs the gapless take.
    this.amcp.send(`LOADBG ${CHANNEL}-${FG_LAYER} "${nextToken}" AUTO${trans}`)
      .catch((err) => console.warn(`[playout] LOADBG failed: ${err.message}`));

    // Arm the pointer-advance timer. Without duration metadata we can't time the
    // hand-off; leave AUTO to take clip N+1 visually but log a warning since the
    // pointer (and thus clip N+2 cueing) will stall.
    const durMs = this._itemDurationMs(item);
    if (durMs == null) {
      console.warn(`[playout] no duration for clip [${this.state.currentIndex}] — pointer advance stalled after this clip`);
      return;
    }
    this._advanceTimer = setTimeout(() => {
      this._advanceTimer = null;
      // The AUTO take already happened in CasparCG; just move our pointer and
      // cue the clip after next. _playIndex would re-PLAY and double-take, so we
      // advance state directly and re-arm.
      this.state.currentIndex = next;
      this.state.currentClip = nextItem.clip_name || nextToken;
      console.log(`[playout] advance -> [${next}] ${nextToken}`);
      this._reportAsRunStart(nextItem);
      this._scheduleAdvance(nextItem);
    }, Math.max(250, durMs));
  }

  _nextIndex() {
    const n = this.state.currentIndex + 1;
    if (n < this.state.playlist.length) return n;
    if (this.state.loop && this.state.playlist.length > 0) return 0;
    return null;
  }

  _clearAdvance() {
    if (this._advanceTimer) { clearTimeout(this._advanceTimer); this._advanceTimer = null; }
  }

  async skip() {
    const next = this._nextIndex();
    if (next === null) { await this.stopChannel(); return this.getStatus(); }
    await this._playIndex(next);
    return this.getStatus();
  }

  async pause() {
    try { await this.amcp.send(`PAUSE ${CHANNEL}-${FG_LAYER}`); } catch (_) {}
    return this.getStatus();
  }

  async resume() {
    try { await this.amcp.send(`RESUME ${CHANNEL}-${FG_LAYER}`); } catch (_) {}
    return this.getStatus();
  }

  // ── SCTE-35 ad-break splice ──────────────────────────────────────────────
  // Act on an ad-break cue. The mam-api owns scheduling + persistence; the
  // sidecar performs the actual splice on the live output and tracks the active
  // break locally so /status can report a countdown.
  //
  // What this does today, end to end:
  //   1. Records the break as the active break (UI reads it from /status for the
  //      "SCTE BREAK" on-air state + countdown). A timer clears it after
  //      durationS so the UI returns to normal automatically.
  //   2. Emits an operator-visible log line at the splice point.
  //   3. Returns the cue descriptor so the mam-api can stamp the as-run log.
  //
  // ── Real in-stream SCTE-35 injection (the injection point) ─────────────────
  // True SCTE-35 requires inserting a splice_info_section into the OUTPUT
  // transport stream on a dedicated SCTE-35 PID, time-aligned to the splice
  // point (pts_time). CasparCG 2.3's FFMPEG consumer does NOT expose an SCTE-35
  // muxer option, so we cannot ask CasparCG to carry the cue. The two viable
  // production paths, neither of which the current single-process CasparCG
  // output supports out of the box, are:
  //
  //   (a) ffmpeg-based output: when the primary consumer is replaced by a
  //       Node-spawned ffmpeg (as the HLS preview re-mux already is), mux an
  //       SCTE-35 data stream. ffmpeg can pass through a -map'd scte35 PID, and
  //       for HLS can emit #EXT-X-CUE-OUT/#EXT-X-CUE-IN (or DATERANGE) tags. The
  //       hook would build the splice_insert binary section here and feed it to
  //       that ffmpeg via a data input / sidecar packetizer.
  //   (b) A downstream SCTE-35 inserter (e.g. an OTT packager / encoder that
  //       accepts cue triggers over its own API). The hook would POST the cue
  //       to that device's API at the splice instant.
  //
  // Until one of those output paths is wired, the splice is faithfully
  // scheduled, triggered, countdown-tracked, and as-run-logged — but the cue is
  // NOT yet embedded in the SRT/RTMP/SDI/NDI elementary stream. Replace the body
  // of _injectScteCue below to enable real injection.
  triggerScte({ eventId = 1, type = 'splice_insert', durationS = 30 } = {}) {
    const firedAt = new Date();
    const endsAt = new Date(firedAt.getTime() + (durationS > 0 ? durationS * 1000 : 0));

    // Build + emit the cue on the output (TODO injection point — see above).
    this._injectScteCue({ eventId, type, durationS });

    // A splice_in / return-to-network ends any active break immediately.
    if (type === 'splice_in') {
      this._clearScte();
      console.log(`[playout][scte] splice_in event=${eventId} — return to network`);
      return { eventId, type, durationS: 0, firedAt: firedAt.toISOString(), endsAt: firedAt.toISOString() };
    }

    this.state.scteActive = {
      eventId, type, durationS,
      firedAt: firedAt.toISOString(),
      endsAt: endsAt.toISOString(),
    };
    console.log(`[playout][scte] ${type} event=${eventId} duration=${durationS}s — splice OUT at ${firedAt.toISOString()}`);

    // Auto-clear the active break when its window elapses (splice_out is
    // open-ended, so it stays until an explicit splice_in).
    if (this._scteTimer) { clearTimeout(this._scteTimer); this._scteTimer = null; }
    if (durationS > 0 && type !== 'splice_out') {
      this._scteTimer = setTimeout(() => {
        this._scteTimer = null;
        console.log(`[playout][scte] break event=${eventId} ended — return to network`);
        this._clearScte();
      }, durationS * 1000);
    }
    return this.state.scteActive;
  }

  // The SCTE-35 cue packetizer / injection hook. See the long comment on
  // triggerScte for why this is a stub on the current CasparCG output path and
  // what to put here to enable real in-stream injection.
  _injectScteCue({ eventId, type, durationS }) {
    // TODO(scte-injection): build the splice_info_section (splice_insert with
    // splice_event_id=eventId, out_of_network_indicator per type,
    // break_duration=durationS*90000 ticks) and emit it on the output's SCTE-35
    // PID via an ffmpeg-based output, or POST it to a downstream inserter's API.
    // No-op until the output path supports it; the scheduling/trigger/as-run
    // path above is fully functional regardless.
    return null;
  }

  _clearScte() {
    if (this._scteTimer) { clearTimeout(this._scteTimer); this._scteTimer = null; }
    this.state.scteActive = null;
  }

  _reportAsRunStart(item) {
    // The mam-api owns the as-run table; the sidecar just logs locally. The API
    // polls /status and writes as-run rows on clip change. Keeping the DB write
    // in the API avoids giving the sidecar a DB connection.
    this.state.currentItemId = item.id || null;
    this.state.currentItemStartedAt = new Date().toISOString();
  }

  getStatus() {
    return {
      running: this.state.running,
      outputType: this.state.outputType,
      videoFormat: this.state.videoFormat,
      currentIndex: this.state.currentIndex,
      currentClip: this.state.currentClip,
      currentItemId: this.state.currentItemId || null,
      currentItemStartedAt: this.state.currentItemStartedAt || null,
      playlistLength: this.state.playlist.length,
      loop: this.state.loop,
      startedAt: this.state.startedAt,
      lastError: this.state.lastError,
      scteActive: this.state.scteActive || null,
    };
  }
}

export default new PlayoutManager();
