import { spawn, execFileSync } from 'child_process';
import { mkdirSync, writeFileSync, createReadStream, statSync, unlinkSync } from 'node:fs';
import fs from 'node:fs';
import { dirname } from 'node:path';
import { v4 as uuidv4 } from 'uuid';
import { createUploadStream } from './s3/client.js';


const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon';

// Growing-files mode: writes the master to a local SMB-backed share that the
// editor can mount, instead of streaming to S3 in real time. The promotion
// worker uploads the finalized file to S3 after the recording stops.
// Toggled per-recorder via `GROWING_ENABLED=true` on the capture container
// (see routes/recorders.js where the env is composed).
const GROWING_ENABLED = process.env.GROWING_ENABLED === 'true';
const GROWING_PATH    = process.env.GROWING_PATH    || '/growing';

// Approach A: when a CIFS source is supplied, this (privileged) container mounts
// the SMB landing-zone share at GROWING_PATH itself, using credentials supplied
// by the API from global Settings. Empty GROWING_SMB_MOUNT → no system mount
// (the host-bound /growing volume is used instead, or S3 streaming if growing
// is off).
// mount.cifs needs a UNC source (//host/share). Operators (and Settings) often
// store the share as an `smb://host/share` URL or a Windows `\\host\share`
// path; the kernel rejects those outright ("Mounting cifs URL not implemented
// yet"), which silently drops us back to S3. Normalize any of these forms to
// the `//host/share` UNC the mount helper accepts.
function toUncShare(raw) {
  if (!raw) return '';
  let s = String(raw).trim().replace(/\\/g, '/');   // \\host\share -> //host/share
  s = s.replace(/^smb:\/\//i, '//');                // smb://host/share -> //host/share
  if (!s.startsWith('//')) s = '//' + s.replace(/^\/+/, '');  // host/share -> //host/share
  return s;
}
const GROWING_SMB_MOUNT    = toUncShare(process.env.GROWING_SMB_MOUNT || '');
const GROWING_SMB_USERNAME = process.env.GROWING_SMB_USERNAME || '';
const GROWING_SMB_PASSWORD = process.env.GROWING_SMB_PASSWORD || '';
const GROWING_SMB_VERS     = process.env.GROWING_SMB_VERS     || '3.0';
const SMB_CREDS_FILE       = '/run/smb-creds';

// True when GROWING_PATH is already a mountpoint (e.g. a prior session left it
// mounted, or a host bind-mount is present).
function isMounted(path) {
  try { execFileSync('mountpoint', ['-q', path]); return true; }
  catch { return false; }
}

// Mount the CIFS growing share at GROWING_PATH. Credentials go in a root-only
// file (NOT the command line) so they never appear in `ps`/process listings.
// Returns true on success (or if already mounted), false on failure — callers
// fall back to S3 streaming so a recording is never lost.
function mountGrowingShare() {
  if (!GROWING_SMB_MOUNT) return false;
  try {
    if (isMounted(GROWING_PATH)) {
      console.log('[capture] growing share already mounted at', GROWING_PATH);
      return true;
    }
    try { mkdirSync(GROWING_PATH, { recursive: true }); } catch (_) {}
    writeFileSync(
      SMB_CREDS_FILE,
      `username=${GROWING_SMB_USERNAME}\npassword=${GROWING_SMB_PASSWORD}\n`,
      { mode: 0o600 }
    );
    const opts = [
      `credentials=${SMB_CREDS_FILE}`,
      'uid=0', 'gid=0', 'file_mode=0664', 'dir_mode=0775',
      `vers=${GROWING_SMB_VERS}`,
    ].join(',');
    execFileSync('mount', ['-t', 'cifs', GROWING_SMB_MOUNT, GROWING_PATH, '-o', opts],
      { stdio: ['ignore', 'ignore', 'pipe'] });
    console.log('[capture] mounted CIFS growing share', GROWING_SMB_MOUNT, '->', GROWING_PATH);
    return true;
  } catch (err) {
    const stderr = err.stderr ? err.stderr.toString().trim() : err.message;
    console.error('[capture] CIFS mount failed (falling back to S3 streaming):', stderr);
    return false;
  }
}

// Best-effort unmount on session stop. Ignores "not mounted".
function unmountGrowingShare() {
  if (!GROWING_SMB_MOUNT) return;
  try {
    if (isMounted(GROWING_PATH)) {
      execFileSync('umount', [GROWING_PATH], { stdio: ['ignore', 'ignore', 'pipe'] });
      console.log('[capture] unmounted growing share at', GROWING_PATH);
    }
  } catch (err) {
    const stderr = err.stderr ? err.stderr.toString().trim() : err.message;
    console.warn('[capture] growing share unmount failed (ignored):', stderr);
  }
}

// ── Codec catalogue ──────────────────────────────────────────────────────
// Each entry maps the UI value to a base ffmpeg flag set. Bitrate / framerate
// / pix_fmt are layered on top from the per-recorder configuration.
const VIDEO_CODECS = {
  prores_hq:    { args: ['-c:v', 'prores_ks', '-profile:v', '3'], bitrateControl: false, pixFmt: 'yuv422p10le' },
  prores_422:   { args: ['-c:v', 'prores_ks', '-profile:v', '2'], bitrateControl: false, pixFmt: 'yuv422p10le' },
  prores_lt:    { args: ['-c:v', 'prores_ks', '-profile:v', '1'], bitrateControl: false, pixFmt: 'yuv422p10le' },
  prores_proxy: { args: ['-c:v', 'prores_ks', '-profile:v', '0'], bitrateControl: false, pixFmt: 'yuv422p10le' },
  dnxhd:        { args: ['-c:v', 'dnxhd'],                          bitrateControl: true,  pixFmt: 'yuv422p' },
  dnxhr_hq:     { args: ['-c:v', 'dnxhd', '-profile:v', 'dnxhr_hq'], bitrateControl: false, pixFmt: 'yuv422p' },
  dnxhr_sq:     { args: ['-c:v', 'dnxhd', '-profile:v', 'dnxhr_sq'], bitrateControl: false, pixFmt: 'yuv422p' },
  h264:         { args: ['-c:v', 'libx264', '-preset', 'medium'],   bitrateControl: true,  pixFmt: 'yuv420p' },
  h264_nvenc:   { args: ['-c:v', 'h264_nvenc', '-preset', 'p5'],    bitrateControl: true,  pixFmt: 'yuv420p' },
  h265:         { args: ['-c:v', 'libx265', '-preset', 'medium'],   bitrateControl: true,  pixFmt: 'yuv420p' },
  // All-Intra HEVC on NVENC — the growing-file master codec.
  // Goal: every frame an IDR (all-intra), so a still-growing file is decodable
  // to its last complete frame — the prerequisite for edit-while-record.
  //
  // NVENC will NOT accept `-g 1`: InitializeEncoder enforces
  // "GopLength > numBFrames + 1", so with -bf 0 the minimum GOP is 2 and -g 1
  // is rejected with EINVAL (validated on the L4, driver 595). The working
  // recipe for true all-intra is therefore:
  //   -bf 0              no B-frames
  //   -g 600             large GOP just to satisfy the init check
  //   -forced-idr 1      forced keyframes are emitted as IDR
  //   -force_key_frames expr:1   force a keyframe on EVERY frame
  // → ffprobe confirms pict_type = I for all frames.
  //
  // Container: fragmented MOV (+frag_keyframe+empty_moov+default_base_moof),
  // NOT MXF — this ffmpeg's MXF muxer rejects HEVC ("Operation not permitted").
  // The frag-MOV index is not deferred to EOF, so the file stays readable while
  // growing. (See docs/design/2026-05-29-all-intra-hevc-ingest.md §8.)
  //
  // -profile:v main10 / p010le: 10-bit 4:2:0 — the closest NVENC HEVC can get
  // to ProRes 4:2:2 10-bit. If strict 4:2:2 is required, use prores_hq (CPU).
  hevc_nvenc:   {
    args: ['-c:v', 'hevc_nvenc', '-preset', 'p4', '-rc', 'vbr', '-bf', '0', '-forced-idr', '1', '-g', '600', '-force_key_frames', 'expr:1', '-profile:v', 'main10'],
    bitrateControl: true,
    pixFmt: 'p010le',
  },
};

// nvenc codecs available in the capture image. Used both to validate the master
// codec and (issue #164) as the GPU-availability signal for the HLS preview.
const NVENC_CODECS = new Set(['h264_nvenc', 'hevc_nvenc']);

// ── GPU availability for this sidecar (issue #164) ───────────────────────
// The HLS monitor preview should be GPU-encoded (h264_nvenc) when — and only
// when — the GPU is actually attached to this capture container. A non-GPU
// recorder must keep using libx264, otherwise ffmpeg would fail to open the
// nvenc encoder and break the preview.
//
// Two signals, OR'd for robustness:
//   1) The master video codec is an nvenc codec. recorders.js derives `useGpu`
//      from exactly this (GPU_CODECS = [hevc_nvenc, h264_nvenc]) and node-agent
//      only attaches the NVIDIA runtime when useGpu is set — so an nvenc master
//      codec is a reliable proxy for "this sidecar has the GPU".
//   2) node-agent injects NVIDIA_VISIBLE_DEVICES into the sidecar env whenever
//      useGpu is set. This is the most direct in-process evidence the runtime
//      attached a GPU, and covers the (currently unused) case where the GPU is
//      present but the master codec is a CPU codec.
function gpuAvailableForPreview(masterCodec) {
  if (NVENC_CODECS.has(masterCodec)) return true;
  const vis = process.env.NVIDIA_VISIBLE_DEVICES;
  if (vis && vis !== 'void' && vis !== 'none') return true;
  return false;
}

// Build the HLS preview video-encode args. `segTime` is the HLS segment length
// (seconds); we pin the GOP/keyframe interval to one IDR per segment so every
// segment starts on a keyframe (misaligned keyframes were the root cause of the
// playout preview black/flashing bug — keep the preview robust).
function buildHlsVideoArgs(masterCodec, framerate) {
  // Frames-per-segment for keyframe alignment. The SDI preview runs at the
  // capture framerate; default to 30 (matches the test-card rate) when unknown.
  const fps = Number.parseFloat(framerate) || 30;
  const segTime = 2;                       // matches -hls_time below
  const gop = Math.max(1, Math.round(fps * segTime));
  if (gpuAvailableForPreview(masterCodec)) {
    // Low-latency NVENC preset (p1 + ll tune). forced-idr + a keyframe every GOP
    // frames keeps segment boundaries on IDR frames so hls.js can sync cleanly.
    return [
      '-c:v', 'h264_nvenc', '-preset', 'p1', '-tune', 'll',
      '-pix_fmt', 'yuv420p', '-b:v', '2M',
      '-g', String(gop), '-forced-idr', '1', '-sc_threshold', '0',
    ];
  }
  // No GPU → keep the original CPU encode (must not break a non-GPU recorder).
  return [
    '-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency',
    '-pix_fmt', 'yuv420p', '-b:v', '2M',
    '-g', String(gop), '-sc_threshold', '0',
  ];
}

const AUDIO_CODECS = {
  pcm_s16le:   { args: ['-c:a', 'pcm_s16le'],   bitrateControl: false },
  pcm_s24le:   { args: ['-c:a', 'pcm_s24le'],   bitrateControl: false },
  pcm_s32le:   { args: ['-c:a', 'pcm_s32le'],   bitrateControl: false },
  aac:         { args: ['-c:a', 'aac'],         bitrateControl: true  },
  ac3:         { args: ['-c:a', 'ac3'],         bitrateControl: true  },
  opus:        { args: ['-c:a', 'libopus'],     bitrateControl: true  },
  flac:        { args: ['-c:a', 'flac'],        bitrateControl: false },
};

const CONTAINER_FMT = {
  mov: 'mov',
  mp4: 'mp4',
  mkv: 'matroska',
  mxf: 'mxf',
  ts:  'mpegts',
};

const CONTAINER_EXT = {
  mov: 'mov', mp4: 'mp4', mkv: 'mkv', mxf: 'mxf', ts: 'ts',
};

// Growing-file (edit-while-record) master format — MXF OP1a / XDCAM HD422,
// written by bmx (raw2bmx), NOT by ffmpeg's MXF muxer.
//
// This is the SIXTH iteration. The five prior attempts and WHY they failed
// (root-caused with authoritative sources + live structural analysis on the
// zampp2 capture image):
//
//   1) Fragmented MP4/MOV (+frag_keyframe+empty_moov): Premiere's QuickTime
//      importer needs the classic stco/stsz/stts sample tables in one top-level
//      moov; a fragmented MOV never has them while growing → "unable to open".
//
//   2) MXF OP1a / DNxHR HQ via ffmpeg: a DNxHR MXF SIGKILLed mid-write has ZERO
//      body partitions and probes duration=N/A — DNxHR's large VBR frames don't
//      trigger ffmpeg's per-partition flush, so only the header is on disk.
//
//   3) MPEG-TS H.264 High 4:2:2: Premiere's H.264 importer only accepts 8-bit
//      4:2:0.
//
//   4) MPEG-TS H.264 High 4:2:0 all-intra + AAC: STILL "unsupported compression
//      type" — Premiere does not treat a raw .ts elementary stream as a clean
//      importable growing clip.
//
//   5) MXF OP1a / XDCAM HD422 (MPEG-2 422) via ffmpeg's `-f mxf` muxer: this was
//      believed to flush incremental body partitions, but PROVEN unable to
//      produce a TRUE growing file — ffmpeg's MXF muxer writes the real
//      duration/index only in the FOOTER at av_write_trailer (close). A
//      metadata-only probe of the mid-write file reports duration=N/A right up
//      until the writer exits, so Premiere's growing-file refresh never sees the
//      file extend. (Same muxer that defers the index to EOF.)
//
// FIX — MXF OP1a carrying XDCAM HD422 (MPEG-2 422 @ 50 Mbps-class) + PCM, muxed
// by bmx/raw2bmx (the reference growing-OP1a writer, used by BBC/broadcast):
//
//   WHY raw2bmx (the key discovery, PROVEN live on zampp2):
//   * raw2bmx with `-t op1a --part <interval>` writes a NEW body partition PLUS
//     a NEW IndexTableSegment (carrying an updated IndexDuration) at the
//     interval. The recorded duration is therefore readable — and INCREASES —
//     from the header+index ALONE while the file is still being written, no
//     footer needed. Verified by snapshotting the growing file mid-write and
//     parsing the IndexTableSegment IndexDuration (local tag 0x3F0C):
//       T= 3s: 7 partitions, max IndexDuration =  43 frames
//       T= 8s: 17 partitions, max IndexDuration = 193 frames
//       T=15s: 31 partitions, max IndexDuration = 403 frames
//     The recorded frame count grows monotonically, lagging the record head by
//     ~one partition interval — exactly the editable-head behaviour Premiere's
//     growing-MXF reader consumes. A mid-write snapshot also decodes cleanly
//     (mpeg2video 1920x1080 + 2×PCM, ffmpeg decode exit 0). Contrast with the
//     ffmpeg `-f mxf` path (attempt #5): duration=N/A until close.
//   * Adobe OFFICIALLY recommends MXF for growing-file workflows; XDCAM HD422
//     (MPEG-2 422 in MXF OP1a) + PCM is read by Premiere's built-in MXF reader
//     with no plugin and is the broadcast-standard growing acquisition format.
//
//   Pipeline (single SDI read — DeckLink cannot be opened twice):
//     ffmpeg  decklink → yadif → split →
//        (a) MPEG-2 422 elementary VIDEO  → named FIFO  ┐
//        (b) PCM s16le AUDIO              → named FIFO  ├→ raw2bmx -t op1a
//        (c) H.264 HLS preview (unchanged, keeps monitor live)
//     raw2bmx reads the two essence FIFOs and writes the growing OP1a MXF to the
//     CIFS share. On stop, ffmpeg is stopped cleanly so raw2bmx gets EOF and
//     finalizes the footer; we await raw2bmx exit before reporting complete.
//
//   Audio: PCM s16le — the native, broadcast-standard MXF audio mapping
//   Premiere's MXF reader expects (NOT AAC).
//
// HONEST CAVEAT (cannot be verified without real Premiere on the workstation):
//   the growing IndexDuration / body-partition structure is PROVEN above and
//   matches Adobe's documented growing-MXF requirement — but only the user
//   opening the growing .mxf in actual Premiere Pro (with "Automatically refresh
//   growing files" enabled in Preferences > Media) can confirm the end-to-end
//   edit-while-record.
//
// ── ffmpeg elementary-essence args (input to the FIFOs) ───────────────────
// (a) MPEG-2 422, 8-bit 4:2:2 (Premiere-native XDCAM HD422). `-dc 10` + the CBR
// bitrate (operator target, default 50 Mbps) match XDCAM HD422 essence. `-g 15`
// keeps a short GOP. Muxed to a raw `mpeg2video` elementary stream (no
// container) so raw2bmx ingests it via --mpeg2lg_*.
const GROWING_VIDEO_ELEMENTARY_ARGS = [
  '-c:v', 'mpeg2video', '-pix_fmt', 'yuv422p',
  '-dc', '10', '-g', '15', '-bf', '2',
];
const GROWING_DEFAULT_BITRATE = '25M';
const GROWING_EXT = 'mxf';
// Video essence partition interval (frames). raw2bmx starts a new body partition
// + IndexTableSegment every PART_INTERVAL frames; this is the granularity at
// which the growing file's recorded duration advances. ~1s at 25/29.97 fps.
const GROWING_PART_INTERVAL_FRAMES = 30;

// Map the recorder's resolution/fps to (1) the raw2bmx MPEG-2 Long GOP essence
// input flag and (2) the ffmpeg edit-rate (`-r`). raw2bmx needs the correct
// raster flag so the essence is wrapped as the right XDCAM HD422 variant; an
// 1080i59.94 default is used when the recorder fields are absent (the most
// common SDI broadcast raster). Returns:
//   { rawFlag, frameRate, ffRate }
// where rawFlag is e.g. '--mpeg2lg_422p_hl_1080i', frameRate is the raw2bmx
// `-f` value (e.g. '30000/1001'), and ffRate is the ffmpeg `-r` value.
//
// NOTE: the exact interlaced-vs-progressive raster and the fps for a real
// DeckLink SDI feed can only be confirmed against the live signal. This derives
// a sensible value from the recorder's configured resolution/framerate; if those
// are absent or ambiguous it defaults to 1080i59.94. A live DeckLink confirm of
// the actual SDI raster/fps is advised before production use (see report).
function deriveGrowingRaster(resolution, framerate) {
  // Normalise fps. Accept '59.94', '60000/1001', '25', '50', '30', '29.97'…
  let fpsNum = null;
  const fr = (framerate == null) ? '' : String(framerate).trim();
  if (/^\d+\/\d+$/.test(fr)) {
    const [n, d] = fr.split('/').map(Number);
    if (d) fpsNum = n / d;
  } else if (fr && fr !== 'native') {
    const f = Number.parseFloat(fr);
    if (Number.isFinite(f)) fpsNum = f;
  }

  // Resolution → height + scan. Accept '1920x1080', '1080i', '1080p', '720p',
  // '720', '576i', etc.
  const res = (resolution == null) ? '' : String(resolution).trim().toLowerCase();
  let height = null;
  let scan = null; // 'i' | 'p' | null
  const mDim = res.match(/(\d{3,4})x(\d{3,4})/);
  if (mDim) height = parseInt(mDim[2], 10);
  const mH = res.match(/(\d{3,4})\s*([ip])/);
  if (mH) { height = parseInt(mH[1], 10); scan = mH[2]; }
  if (height == null) {
    const only = res.match(/\b(2160|1080|720|576|480)\b/);
    if (only) height = parseInt(only[1], 10);
  }
  if (height == null) height = 1080; // default raster

  // ffmpeg rate + raw2bmx rate strings for the common broadcast rates.
  function rates(fps) {
    if (fps == null) return { ff: '30000/1001', raw: '30000/1001' }; // 1080i59.94 default
    if (Math.abs(fps - 59.94) < 0.2 || Math.abs(fps - 29.97) < 0.05)
      return { ff: '30000/1001', raw: '30000/1001' };
    if (Math.abs(fps - 60) < 0.05) return { ff: '60',    raw: '60' };
    if (Math.abs(fps - 50) < 0.05) return { ff: '25',    raw: '25' };   // 1080i50 → 25 fps frames
    if (Math.abs(fps - 25) < 0.05) return { ff: '25',    raw: '25' };
    if (Math.abs(fps - 24) < 0.2)  return { ff: '24000/1001', raw: '24000/1001' };
    if (Math.abs(fps - 30) < 0.05) return { ff: '30',    raw: '30' };
    return { ff: String(fps), raw: String(fps) };
  }

  // Default scan: 1080 → interlaced (broadcast SDI default), 720/below → p.
  if (scan == null) scan = (height >= 1080) ? 'i' : 'p';
  const r = rates(fpsNum);

  let rawFlag;
  if (height >= 1080) {
    rawFlag = (scan === 'p') ? '--mpeg2lg_422p_hl_1080p' : '--mpeg2lg_422p_hl_1080i';
  } else if (height >= 720) {
    rawFlag = '--mpeg2lg_422p_hl_720p';            // 720 is always progressive
    if (fpsNum == null) { r.ff = '60000/1001'; r.raw = '60000/1001'; }
  } else {
    rawFlag = '--mpeg2lg_422p_ml_576i';            // SD 576i (PAL); 25 fps
    r.ff = '25'; r.raw = '25';
  }

  return { rawFlag, frameRate: r.raw, ffRate: r.ff };
}

// ── Source-backend abstraction (issue #168) ──────────────────────────────
// The capture input was historically hard-wired to a single `-f decklink -i …`
// construction. To allow other SDI capture cards (Deltacast, AJA) to be added
// later without touching the encode/output/HLS pipeline, the per-backend FFmpeg
// INPUT-arg construction now lives behind this map. Each backend exposes:
//
//   buildInput(ctx) -> { inputArgs, isNetwork }   (may be async)
//
// where `ctx` carries the resolved recorder fields the backend needs (device).
// The rest of capture-manager consumes the returned `inputArgs` unchanged, so
// adding a backend is purely additive.
//
// IMPORTANT: `blackmagic` is a behaviour-preserving extraction of the previous
// default DeckLink path — for an existing DeckLink recorder the produced ffmpeg
// input args are byte-for-byte identical to the pre-refactor code. The
// `deltacast`/`aja` entries are stubs that throw until the hardware/SDK plumbing
// lands.
const sourceBackends = {
  // BlackMagic DeckLink over SDI (the only backend implemented today).
  // device may be an integer index (0-based) or a full device name string.
  // FFmpeg 7.x DeckLink requires the full name (e.g. 'DeckLink Duo 2 (2)').
  // Map integer index -> name using ffmpeg -sources decklink at runtime.
  //
  // ffmpeg -sources decklink output format:
  //   Auto-detected sources for decklink:
  //     DeckLink Duo 2
  //     DeckLink Duo 2 (2)
  // Lines containing device names start with whitespace; the header line
  // starts with a non-space character. Previous code used a v4l2-style
  // hex-address regex that never matched DeckLink output → index 1+ always
  // fell through to a wrong fallback, producing black output from port 2+.
  blackmagic: {
    async buildInput({ device }) {
      let deckLinkName = String(device);
      if (typeof device === 'number' || /^\d+$/.test(String(device))) {
        const idx = parseInt(device, 10);
        try {
          const { execSync } = await import('child_process');
          const out = execSync('ffmpeg -hide_banner -sources decklink 2>&1', { encoding: 'utf-8', timeout: 5000 });
          const names = [];
          for (const line of out.split('\n')) {
            // DeckLink source lines: "  81:76669a80:00000000 [DeckLink Duo (1)] (none)"
            const m = line.match(/^\s+[0-9a-f:]+\s+\[([^\]]+)\]/);
            if (m) names.push(m[1]);
          }
          if (names[idx]) {
            deckLinkName = names[idx];
            console.log(`[capture] DeckLink index ${idx} → "${deckLinkName}" (from ${names.length} detected: ${names.join(', ')})`);
          } else {
            // Fallback: cannot determine model name without enumeration.
            // Log a warning — operator should check the detected device list.
            console.warn(`[capture] DeckLink index ${idx} out of range (detected ${names.length} devices: ${names.join(', ')}). Falling back to index-only input — capture may fail.`);
            deckLinkName = `DeckLink (${idx})`;
          }
        } catch (err) {
          console.warn(`[capture] ffmpeg -sources decklink failed: ${err.message}. Using index ${device} directly.`);
          // Pass the numeric index directly; some ffmpeg builds accept it.
          deckLinkName = String(device);
        }
      }
      return {
        inputArgs: ['-f', 'decklink', '-i', deckLinkName],
        isNetwork: false,
      };
    },
  },

  deltacast: {
    // Unused stub — deltacast capture uses sourceType='deltacast' path in
    // _buildInputArgs, not the sourceBackends map.
    buildInput() {
      throw new Error('deltacast: use sourceType="deltacast" not sourceBackend');
    },
  },
  aja: {
    buildInput() {
      throw new Error('aja backend not yet implemented — requires hardware');
    },
  },
};

function buildEncodeArgs({
  codec, videoBitrate, framerate,
  audioCodec, audioBitrate, audioChannels,
  container, isNetwork, isProxy = false,
  growing = false,
}) {
  // NOTE: the growing master is NOT muxed by ffmpeg any more — raw2bmx writes
  // the growing OP1a MXF from elementary essence FIFOs (see start()). The
  // growing ffmpeg command (elementary MPEG-2 422 video + PCM audio to FIFOs,
  // plus the HLS preview) is constructed directly in start(), so buildEncodeArgs
  // is no longer called with growing=true. The `growing` param is retained for
  // call-site compatibility; if ever set, fall through to the finalized path so
  // we never silently produce a wrong file.

  const v = VIDEO_CODECS[codec] || (isProxy ? VIDEO_CODECS.h264 : VIDEO_CODECS.prores_hq);
  const a = AUDIO_CODECS[audioCodec] || (isProxy ? AUDIO_CODECS.aac : AUDIO_CODECS.pcm_s24le);
  const fmt = CONTAINER_FMT[container] || (isProxy ? 'mp4' : 'mov');

  const args = [];
  if (isNetwork) args.push('-map', '0:v:0?', '-map', '0:a:0?');

  args.push(...v.args);
  if (v.pixFmt) args.push('-pix_fmt', v.pixFmt);
  if (v.bitrateControl && videoBitrate) args.push('-b:v', videoBitrate);
  if (framerate && framerate !== 'native') args.push('-r', framerate);

  args.push(...a.args);
  if (a.bitrateControl && audioBitrate) args.push('-b:a', audioBitrate);
  if (audioChannels) args.push('-ac', String(audioChannels));

  // moov-atom placement is the difference between a Premiere-openable master and
  // a "file cannot be opened" error.
  //
  // Finalized masters (the S3-piped recording that stops cleanly) must NOT be
  // fragmented. Adobe Premiere's QuickTime/MOV importer reads the classic
  // stco/stsz/stts sample tables in a single top-level moov; a fragmented MOV
  // (moof/trun, empty sample tables) makes Premiere report "file cannot be
  // opened." We write a clean, non-fragmented MOV instead. `+faststart` puts the
  // moov before mdat on the second pass so the file is instantly
  // seekable/streamable too.
  if (fmt === 'mov' || fmt === 'mp4') {
    args.push('-movflags', '+faststart');
  }
  // ProRes-in-MOV must carry a QuickTime brand or some importers reject the tag.
  args.push('-f', fmt);

  return args;
}

class CaptureManager {
  constructor() {
    this.state = {
      recording: false,
      sessionId: null,
      processes: {},
      currentSession: {},
      framesReceived: 0,
      currentFps: 0,
      lastFrameAt: null,
      lastError: null,
    };
  }

  /**
   * Build FFmpeg input arguments based on source type.
   * Returns { inputArgs, isNetwork }
   * @private
   */
  async _buildInputArgs({ sourceType, sourceBackend = 'blackmagic', device, port, board, sourceUrl, listen, listenPort, streamKey }) {
    if (sourceType === 'srt') {
      let url;
      if (listen) {
        const port = listenPort || 9000;
        url = `srt://0.0.0.0:${port}?mode=listener`;
      } else {
        url = sourceUrl;
        if (!url.includes('mode=')) {
          url += (url.includes('?') ? '&' : '?') + 'mode=caller';
        }
      }
      return { inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-i', url], isNetwork: true };
    }

    if (sourceType === 'rtmp') {
      if (listen) {
        const port = listenPort || 1935;
        const key = streamKey || 'stream';
        return {
          inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-listen', '1', '-i', `rtmp://0.0.0.0:${port}/live/${key}`],
          isNetwork: true,
        };
      }
      return { inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-i', sourceUrl], isNetwork: true };
    }

    // Deltacast SDI via shared bridge daemon (deltacast-bridge).
    //
    // The bridge daemon is started by node-agent (host process, direct /dev access)
    // and writes each port's streams to named FIFOs in /dev/shm/deltacast/:
    //   /dev/shm/deltacast/video-<port>.fifo
    //   /dev/shm/deltacast/audio-<port>.fifo
    //
    // This sidecar just reads from those FIFOs. The bridge may still be starting
    // up or waiting for signal lock, so we wait up to 30s for the FIFOs to appear
    // before handing them to ffmpeg. The bridge process is managed by node-agent;
    // bridgeProcess is null here (no per-sidecar bridge spawn).
    if (sourceType === 'deltacast') {
      const idx      = (typeof device === 'number' || /^\d+$/.test(String(device)))
        ? parseInt(device, 10) : 0;
      const portIdx  = (typeof port === 'number' || /^\d+$/.test(String(port)))
        ? parseInt(port, 10) : idx;

      const DC_PIPE_DIR  = process.env.DELTACAST_PIPE_DIR || '/dev/shm/deltacast';
      const videoFifo    = `${DC_PIPE_DIR}/video-${portIdx}.fifo`;
      const audioFifo    = `${DC_PIPE_DIR}/audio-${portIdx}.fifo`;

      // Wait up to 30s for both FIFOs to exist (bridge starts asynchronously).
      const { existsSync: _exists } = await import('node:fs');
      const WAIT_MS    = 30_000;
      const POLL_MS    = 500;
      const deadline   = Date.now() + WAIT_MS;
      let videoReady   = false;
      let audioReady   = false;
      while (Date.now() < deadline) {
        videoReady = _exists(videoFifo);
        audioReady = _exists(audioFifo);
        if (videoReady && audioReady) break;
        await new Promise(r => setTimeout(r, POLL_MS));
      }
      if (!videoReady || !audioReady) {
        throw new Error(
          `deltacast bridge FIFOs not ready after ${WAIT_MS / 1000}s ` +
          `(video=${videoReady} audio=${audioReady}) — is deltacast-bridge running?`
        );
      }
      console.log(`[deltacast] port ${portIdx} FIFOs ready: ${videoFifo}, ${audioFifo}`);

      // Resolution/fps are not known until the FIFO reader connects and starts
      // receiving frames. We use sensible defaults here; ffmpeg's rawvideo demuxer
      // will accept whatever the bridge writes once the pipe opens.
      // The bridge daemon has already detected the signal and set up streams, so
      // the FIFO content is ready-to-read as soon as the reader connects.
      //
      // NOTE: The format JSON emitted by the bridge on signal lock goes to the
      // node-agent (which launched the bridge), not to this sidecar. The sidecar
      // therefore uses fixed rawvideo params here. If per-port format introspection
      // is needed in future, the node-agent should expose the fmt JSON via an API
      // and capture-manager can query it before building inputArgs.
      //
      // For now, both video dimensions and framerate come from the recorder's
      // configured values (passed to start() as `framerate` and implicit in the
      // codec args). The rawvideo input is -video_size / -framerate from env or
      // recorder config; ffmpeg tolerates a small mismatch in rawvideo (it just
      // reads N bytes per frame based on the declared size).
      //
      // DELTACAST_VIDEO_SIZE / DELTACAST_FRAMERATE: set by node-agent in the
      // sidecar env based on the bridge's per-port format JSON, if desired.
      const dcSize = process.env.DELTACAST_VIDEO_SIZE || '1920x1080';
      const dcFps  = process.env.DELTACAST_FRAMERATE  || '60000/1001';
      const dcInterlaced = process.env.DELTACAST_INTERLACED === '1';

      return {
        inputArgs: [
          '-f', 'rawvideo',
          '-pix_fmt',    'uyvy422',
          '-video_size', dcSize,
          '-framerate',  dcFps,
          '-i', videoFifo,
          '-f', 's16le',
          '-ar', '48000',
          '-ac', '2',
          '-i', audioFifo,
        ],
        isNetwork:     false,
        bridgeProcess: null,   /* bridge is managed by node-agent, not this sidecar */
        audioFifo:     null,   /* no per-session FIFO to clean up on stop */
        interlaced:    dcInterlaced,
      };
    }

    // Default: SDI via a pluggable source backend (issue #168). The backend
    // selection defaults to `blackmagic` (DeckLink) so existing SDI recorders
    // behave exactly as before. Deltacast/AJA backends throw until their
    // hardware/SDK plumbing lands.
    const backend = sourceBackends[sourceBackend];
    if (!backend) {
      throw new Error(`Unknown source backend "${sourceBackend}" — expected one of: ${Object.keys(sourceBackends).join(', ')}`);
    }
    return await backend.buildInput({ device });
  }

  /**
   * Build the bash orchestrator command for the GROWING master (raw2bmx).
   *
   * One ffmpeg reads the source once (DeckLink can't be opened twice) and writes
   * THREE outputs:
   *   (a) MPEG-2 422 elementary VIDEO  → video FIFO   ─┐ raw2bmx -t op1a reads
   *   (b) PCM s16le AUDIO              → audio FIFO   ─┘ these and writes the
   *                                                     growing OP1a MXF.
   *   (c) H.264 HLS preview (unchanged)              — keeps the UI monitor live.
   *
   * FIFO orchestration (the tricky part — proven on the live capture node):
   *   raw2bmx opens its inputs lazily (video first, reads the header, THEN opens
   *   audio), while ffmpeg opens ALL its outputs up-front and blocks on the
   *   audio FIFO until a reader appears → classic open-order deadlock. We break
   *   it by having the parent shell PRIME both FIFOs read-write (non-blocking
   *   open) so neither child blocks on open. CRUCIAL: the children must NOT
   *   inherit a priming *writer* (it would keep the FIFO open and starve raw2bmx
   *   of EOF forever), so each child closes the priming FDs before exec. The
   *   parent holds the priming FDs (as a reader/writer) only until raw2bmx has
   *   opened BOTH FIFOs, then drops them — leaving ffmpeg as the SOLE writer, so
   *   when ffmpeg exits raw2bmx gets a clean EOF and finalizes the MXF footer.
   *
   * Stop/finalize: the orchestrator traps SIGINT/SIGTERM and forwards SIGINT to
   * ffmpeg (clean stop → EOF to raw2bmx), then `wait`s for raw2bmx and exits
   * with raw2bmx's status. The Node side spawns this with detached:true and, on
   * stop(), signals it and AWAITS its exit — so the finalized, valid MXF is on
   * the share before the promotion worker uploads it.
   *
   * Returns the argv for spawn('bash', argv).
   */
  _buildGrowingOrchestrator({ inputArgs, videoBitrate, resolution, framerate, audioChannels, outPath, hlsDir, videoCodec, audioMap = '0:a:0?' }) {
    const { rawFlag, frameRate, ffRate } = deriveGrowingRaster(resolution, framerate);
    const vb = videoBitrate || GROWING_DEFAULT_BITRATE;
    const ach = audioChannels ? Number(audioChannels) : 2;

    // ffmpeg argv (shell-quoted). One decklink read → yadif → split → 3 outputs.
    const sh = (a) => "'" + String(a).replace(/'/g, `'\\''`) + "'";
    // `-y`: the FIFOs are pre-created by mkfifo, so ffmpeg must overwrite them
    // without the interactive "File already exists. Overwrite? [y/N]" prompt
    // (which would otherwise abort the video/audio outputs and produce nothing).
    const ff = ['ffmpeg', '-y', '-hide_banner', '-loglevel', 'warning', '-stats'];
    // SDI input is interlaced; yadif then split into the master + preview taps.
    // When there's an HLS dir we split the decode into the master ([vhi]) and
    // the H.264 preview ([vlo]); with no HLS dir, split=1 (master only) so no
    // split output is ever left unconnected (deltacast growing master had no
    // HLS dir, leaving [vlo] orphaned -> 'split output 1 (vlo) unconnected').
    const filterComplex = hlsDir
      ? '[0:v]yadif=mode=1:deint=1,split=2[vhi][vlo]'
      : '[0:v]yadif=mode=1:deint=1,split=1[vhi]';
    const ffArgs = [
      ...inputArgs,
      '-filter_complex', filterComplex,
      // (a) MPEG-2 422 elementary video → "$VF"
      '-map', '[vhi]',
      ...GROWING_VIDEO_ELEMENTARY_ARGS,
      '-b:v', vb, '-minrate', vb, '-maxrate', vb, '-bufsize', vb,
      '-r', ffRate,
      '-f', 'mpeg2video', '@VF@',
      // (b) PCM s16le audio → "$AF"
      '-map', audioMap,
      '-c:a', 'pcm_s16le', '-ar', '48000', '-ac', String(ach),
      '-f', 's16le', '@AF@',
    ];
    let ffHls = [];
    if (hlsDir) {
      ffHls = [
        // (c) H.264 HLS preview — GPU-gated, unchanged behaviour.
        '-map', '[vlo]', '-map', audioMap,
        ...buildHlsVideoArgs(videoCodec, framerate),
        '-c:a', 'aac', '-b:a', '128k', '-ar', '44100',
        '-f', 'hls', '-hls_time', '2', '-hls_list_size', '15',
        '-hls_flags', 'delete_segments+append_list+omit_endlist',
        '-hls_segment_filename', `${hlsDir}/seg-%05d.ts`,
        `${hlsDir}/index.m3u8`,
      ];
    }
    // @VF@/@AF@ are placeholders for the FIFO path shell variables; emit them as
    // unquoted "$VF"/"$AF" so the shell expands them, and shell-quote everything
    // else.
    const placeholder = (t) => (t === '@VF@' ? '"$VF"' : t === '@AF@' ? '"$AF"' : sh(t));
    const ffLine = [...ff, ...ffArgs, ...ffHls].map(placeholder).join(' ');

    // raw2bmx argv. Audio is de-interleaved by raw2bmx into mono PCM tracks
    // (the standard MXF mapping); --part starts a new body partition +
    // IndexTableSegment every GROWING_PART_INTERVAL_FRAMES frames.
    //
    // CLIP TYPE: rdd9 (SMPTE RDD-9 / "Sony MXF") — NOT plain op1a and NOT
    // --avid-gf. This is the make-or-break choice for Adobe Premiere:
    //   * --avid-gf produces an *Avid OP-Atom* growing file. That flavour needs a
    //     companion AAF to register the clip and is only read live by Avid Media
    //     Composer — Premiere cannot open it as a growing file. (Confirmed via the
    //     bmx mailing list + Softron/Drastic edit-while-ingest docs.) So it is
    //     removed.
    //   * Premiere's documented edit-while-ingest path expects XDCAM essence
    //     (MPEG-2 422 Long GOP, which we emit) wrapped as RDD-9. raw2bmx's `rdd9`
    //     clip type emits exactly that structure.
    // --index-follows: write the IndexTableSegment in the *same* partition as the
    //   essence it indexes (rather than a trailing index-only partition). This is
    //   what lets a reader that re-scans body partitions on refresh find an index
    //   covering the newly-written frames — required so Premiere can seek past its
    //   original frame map toward the record head.
    // The header Duration still starts at -1 and is only finalised in the footer
    // on stop, so the inline Python dur-patch below overwrites the header Duration
    // fields with the live frame count every 3s (Premiere reads the header
    // Duration on each refresh; without the patch it sees duration=N/A).
    const bmx = [
      'raw2bmx', '-t', 'rdd9', '-o', '"$OUT"', '-f', frameRate,
      '--part', String(GROWING_PART_INTERVAL_FRAMES),
      '--index-follows',
      rawFlag, '"$VF"',
      '-s', '48000', '-q', '16', '--audio-chan', String(ach), '--pcm', '"$AF"',
    ];
    const bmxLine = bmx
      .map((t) => (t.startsWith('"$') ? t : sh(t)))
      .join(' ');

    // The orchestration script. `set -m` is intentionally NOT used; we manage
    // children explicitly. Priming FDs 7/8; children close them before exec.
    // PATCHPID: inline Python duration-patcher that runs alongside raw2bmx and
    // patches the MXF header's Duration=-1 fields with the actual frame count
    // every 3 seconds. Without this Premiere sees Duration=N/A even as the file
    // grows, so the timeline never extends. The patcher reads the last body
    // partition's IndexTableSegment (IndexStartPosition+IndexDuration) to get
    // an exact frame count, then seeks back to the header Duration fields and
    // overwrites them in-place. It is killed by the cleanup trap on exit.
    const script = `
set -u
VF=$(mktemp -u /tmp/grow_v.XXXXXX); AF=$(mktemp -u /tmp/grow_a.XXXXXX)
OUT=${sh(outPath)}
mkfifo "$VF" "$AF"
PATCHPID=
cleanup() { rm -f "$VF" "$AF"; [ -n "$PATCHPID" ] && kill "$PATCHPID" 2>/dev/null; }
trap cleanup EXIT
# Prime both FIFOs read-write (non-blocking) to break the open-order deadlock.
exec 7<>"$VF" 8<>"$AF"
# raw2bmx: close priming FDs (no stray writer) before exec so it sees real EOF.
( exec 7>&- 8>&-; exec ${bmxLine} ) &
BMXPID=$!
# ffmpeg: also closes priming FDs; it opens its own write ends.
( exec 7>&- 8>&-; exec ${ffLine} ) &
FFPID=$!
# Forward a clean stop to ffmpeg; raw2bmx then gets EOF and finalizes the footer.
stop() { kill -INT "$FFPID" 2>/dev/null; }
trap stop INT TERM
# Drop the parent priming FDs once raw2bmx has opened BOTH FIFOs, so ffmpeg is
# the sole writer (its EOF reaches raw2bmx). If raw2bmx dies early, bail.
for i in $(seq 1 200); do
  kill -0 "$BMXPID" 2>/dev/null || break
  n=$(ls -l /proc/$BMXPID/fd 2>/dev/null | grep -c -- "$VF\\|$AF")
  [ "\${n:-0}" -ge 2 ] && break
  sleep 0.1
done
exec 7>&- 8>&-
# No header-duration patcher is needed. In this bmx v1.6 build, raw2bmx's rdd9
# writer with --part maintains a live, correct header Duration as the file grows
# (verified on-node: ffprobe reads a growing duration mid-write, e.g. 2.04s of a
# 10s clip while still recording). A patcher (the earlier dur-patch.py) was a
# no-op here — it searched for Duration=-1, which rdd9 never writes — and opening
# the file r+b while raw2bmx appends over CIFS only adds concurrency risk.
PATCHPID=
# Wait for ffmpeg (source end), then for raw2bmx to finalize the footer.
wait "$FFPID"; FFRC=$?
wait "$BMXPID"; BMXRC=$?
echo "[grow] ffmpeg rc=$FFRC raw2bmx rc=$BMXRC out=$OUT" >&2
exit "$BMXRC"
`;
    return ['-c', script];
  }

  /**
   * Start a new capture session.
   *
   * Codec parameters all have sensible defaults so legacy callers (no codec
   * args) still produce ProRes HQ master + H.264 proxy.
   */
  async start({
    assetId,
    projectId,
    binId,
    clipName,
    device,
    // Deltacast: one board (index 0) with 8 channels. `port` selects the
    // channel; `board` selects the physical board (default 0).
    port,
    board,
    sourceType = 'sdi',
    // Source-backend selection for SDI capture (issue #168). Defaults to
    // `blackmagic` (DeckLink) so existing recorders are unaffected.
    sourceBackend = 'blackmagic',
    sourceUrl,
    listen = false,
    listenPort,
    streamKey,
    // ── Recording codec ─────────────────────────────────────────────
    videoCodec     = 'prores_hq',
    videoBitrate   = null,
    framerate      = null,
    audioCodec     = 'pcm_s24le',
    audioBitrate   = null,
    audioChannels  = 2,
    container      = 'mov',
    // ── Proxy codec ─────────────────────────────────────────────────
    proxyEnabled       = true,
    proxyVideoCodec    = 'h264',
    proxyVideoBitrate  = '8M',
    proxyFramerate     = null,
    proxyAudioCodec    = 'aac',
    proxyAudioBitrate  = '192k',
    proxyAudioChannels = 2,
    proxyContainer     = 'mp4',
  }) {
    this._assetIdForHls = assetId || null;
    if (this.state.recording) {
      throw new Error('Capture already in progress');
    }

    const sessionId = uuidv4();
    const proxyExt  = CONTAINER_EXT[proxyContainer] || 'mp4';

    // Growing-files: write master to the SMB share instead of streaming to S3.
    // Path is relative to the container's GROWING_PATH mount.
    //
    // Approach A: if a CIFS source is configured, mount it now. A mount failure
    // is non-fatal — we fall back to S3 streaming so the recording is never
    // lost.
    let growingActive = GROWING_ENABLED;
    if (growingActive && GROWING_SMB_MOUNT) {
      if (!mountGrowingShare()) growingActive = false;  // fall back to S3
    }
    // Growing master is always MXF OP1a / XDCAM HD422 written by raw2bmx (the
    // format Premiere reads while growing — see GROWING_VIDEO_ELEMENTARY_ARGS /
    // _buildGrowingOrchestrator), regardless of the recorder's configured
    // container — so it gets a .mxf extension, not the container's.
    const growingPath = growingActive
      ? `${GROWING_PATH}/${projectId}/${clipName}.${GROWING_EXT}`
      : null;

    // hiresKey MUST match the actual master format/destination:
    //  - growing active → the master is a growing OP1a MXF on the share; the
    //    promotion worker uploads it to this key, so it has the .mxf extension.
    //    (A stale .mov key here would make the proxy job download a nonexistent
    //    object → "unable to open the file on disk".)
    //  - growing fell back to S3 → the normal container extension.
    const hiresExt  = growingPath ? GROWING_EXT : (CONTAINER_EXT[container] || 'mov');
    const hiresKey  = `projects/${projectId}/masters/${clipName}.${hiresExt}`;
    if (growingPath) {
      try { mkdirSync(dirname(growingPath), { recursive: true }); }
      catch (err) { console.error('[capture] could not create growing dir:', err.message); }
    }

    // DeckLink hardware does NOT support concurrent capture from the same port.
    // Opening a second ffmpeg process on the same DeckLink input while the first
    // is already capturing causes "Cannot Autodetect input stream or No signal"
    // on the second process — making the proxy empty and potentially crashing the
    // container before the hires upload completes.
    //
    // Treat SDI the same as SRT/RTMP: set proxyKey=null here and let the BullMQ
    // worker generate the proxy from the hires master after the recording stops.
    // The stop handler sets needsProxy=true so the worker picks it up.
    const proxyKey = null;

    const startedAt = new Date().toISOString();

    this._sessionIdForBridge = sessionId;
    const { inputArgs, isNetwork, bridgeProcess = null, audioFifo = null, interlaced = false } = await this._buildInputArgs({
      sourceType, sourceBackend, device, port, board, sourceUrl, listen, listenPort, streamKey,
    });

    // Audio input index: the deltacast shared bridge delivers video on input 0
    // (video FIFO) and audio on input 1 (audio FIFO), so audioMap is '1:a:0?'.
    // DeckLink SDI and network sources carry audio inside input 0.
    const audioMap = (sourceType === 'deltacast') ? '1:a:0?' : '0:a:0?';

    // Non-growing master: ffmpeg muxes the finalized MOV directly. Growing
    // master: raw2bmx muxes the OP1a from elementary FIFOs (handled below via
    // the orchestrator), so we don't build ffmpeg codec args here for it.
    const hiresCodecArgs = growingPath ? null : buildEncodeArgs({
      codec: videoCodec, videoBitrate, framerate,
      audioCodec, audioBitrate, audioChannels,
      container,
      isNetwork,
      isProxy: false,
    });

    if (hiresCodecArgs) console.log('[capture] hires ffmpeg args:', hiresCodecArgs.join(' '));

    const isInterlacedSource = sourceType === 'sdi' || (sourceType === 'deltacast' && interlaced);
    const sdiFilterArgs = isInterlacedSource ? ['-vf', 'yadif=mode=1:deint=1'] : [];

    // Master output destination (NON-growing path only).
    //
    //  - Growing-files on  → the growing OP1a MXF is written directly to the SMB
    //    share by raw2bmx (see the orchestrator below); ffmpeg only produces the
    //    elementary essence FIFOs + HLS preview. `localMasterPath`/`hiresOutput`
    //    are unused in this case (the master path is `growingPath`).
    //
    //  - Growing-files off → ffmpeg writes the MOV master to a LOCAL SEEKABLE
    //    temp file, then we upload to S3 on stop. We must NOT pipe the MOV muxer
    //    to S3 directly: the MOV/MP4 muxer cannot write to a non-seekable pipe
    //    without `empty_moov`, and an empty_moov/fragmented MOV is exactly what
    //    makes Adobe Premiere report "file cannot be opened" (no classic
    //    stco/stsz sample tables — samples live in moof/trun). A seekable file
    //    lets ffmpeg write a single contiguous moov with full sample tables and
    //    `+faststart` moves it to the front, producing a Premiere-native master.
    const localMasterPath = growingPath
      ? null
      : `/tmp/capture/${sessionId}.${hiresExt}`;
    if (localMasterPath) {
      try { mkdirSync(dirname(localMasterPath), { recursive: true }); }
      catch (err) { console.error('[capture] could not create temp master dir:', err.message); }
    }
    const hiresOutput = localMasterPath;
    // Deltacast reads from FIFOs (no stdin pipe needed). DeckLink pipes stdout.
    const hiresStdio  = ['ignore', 'ignore', 'pipe'];

    // For SDI we cannot open the DeckLink device a second time for a preview
    // tee, so the live HLS preview is produced as a SECOND OUTPUT of the hires
    // ffmpeg: one decklink read -> yadif -> split -> [ProRes/S3] + [H.264/HLS].
    let sdiHlsDir = null;
    if ((sourceType === 'sdi' || sourceType === 'deltacast') && this._assetIdForHls) {
      const fsMod = await import('node:fs');
      sdiHlsDir = '/live/' + this._assetIdForHls;
      try { fsMod.mkdirSync(sdiHlsDir, { recursive: true }); } catch (_) {}
    }

    let hiresProcess;
    if (growingPath) {
      // ── GROWING master: raw2bmx orchestrator ──────────────────────────
      // One ffmpeg (single SDI read) → MPEG-2 422 elementary + PCM to FIFOs +
      // the H.264 HLS preview; raw2bmx muxes the growing OP1a MXF from the FIFOs.
      // Spawned via bash so the FIFO priming / EOF / stop-forwarding logic (see
      // _buildGrowingOrchestrator) runs as one supervised unit. detached:true so
      // it leads its own process group and we can clean-stop the whole pipeline.
      const orchArgs = this._buildGrowingOrchestrator({
        inputArgs,
        videoBitrate,
        // Recorder raster for the raw2bmx essence flag. recorders.js sets
        // RECORDING_RESOLUTION (e.g. '1920x1080' / '1080i' / 'native'); when
        // 'native'/absent, deriveGrowingRaster defaults to 1080i59.94.
        resolution: process.env.RECORDING_RESOLUTION || null,
        framerate,
        audioChannels,
        outPath: growingPath,
        hlsDir: (sourceType === 'sdi' || sourceType === 'deltacast') ? sdiHlsDir : null,
        videoCodec,
        audioMap,
      });
      console.log('[capture] growing master via raw2bmx; orchestrator script length=' + orchArgs[1].length);
      hiresProcess = spawn('bash', orchArgs, { stdio: ['ignore', 'ignore', 'pipe'], detached: true });
    } else {
      // ── Finalized (non-growing) master: ffmpeg muxes the MOV directly ──
      let hiresArgs;
      if ((sourceType === 'sdi' || sourceType === 'deltacast') && this._assetIdForHls) {
        hiresArgs = [
          ...inputArgs,
          '-filter_complex', '[0:v]yadif=mode=1:deint=1,split=2[vhi][vlo]',
          // Output 0 — ProRes/MOV master (local temp, uploaded to S3 on stop)
          '-map', '[vhi]', '-map', audioMap,
          ...hiresCodecArgs,
          hiresOutput,
          // Output 1 — low-latency H.264 HLS preview for the UI monitor.
          // GPU-encoded (h264_nvenc) when the GPU is attached to this sidecar,
          // otherwise libx264 (issue #164). GOP is pinned to one IDR per HLS
          // segment so segments start on keyframes (avoids black/flashing).
          '-map', '[vlo]', '-map', audioMap,
          ...buildHlsVideoArgs(videoCodec, framerate),
          '-c:a', 'aac', '-b:a', '128k', '-ar', '44100',
          '-f', 'hls', '-hls_time', '2', '-hls_list_size', '15',
          '-hls_flags', 'delete_segments+append_list+omit_endlist',
          '-hls_segment_filename', sdiHlsDir + '/seg-%05d.ts',
          sdiHlsDir + '/index.m3u8',
        ];
        console.log('[HLS] SDI preview as 2nd output -> ' + sdiHlsDir);
      } else {
        hiresArgs = [ ...inputArgs, ...sdiFilterArgs, ...hiresCodecArgs, hiresOutput ];
      }
      hiresProcess = spawn('ffmpeg', hiresArgs, { stdio: hiresStdio });
    }

    // Growing-files: nothing to upload here (promotion worker handles S3).
    // Non-growing: the master is uploaded from the finalized local file in
    // stop(), once ffmpeg has written the moov and exited cleanly — we can't
    // upload while recording because the file isn't a valid MOV until finalize.
    // bridgeProcess is null for deltacast (bridge managed by node-agent on the host).
    const processes = { hires: hiresProcess };
    const uploads = { hires: growingPath ? Promise.resolve({ growingPath }) : null };

    // ── HLS tee for network sources (live preview in the UI) ──────────
    let hlsProcess = null;
    let hlsDir = null;
    if (isNetwork && this._assetIdForHls) {
      try {
        const fs = await import('node:fs');
        hlsDir = '/live/' + this._assetIdForHls;
        fs.mkdirSync(hlsDir, { recursive: true });
        const hlsArgs = [
          ...inputArgs,
          '-map', '0:v:0?', '-map', '0:a:0?',
          // GPU-gated preview encode, same as the SDI 2nd-output path (#164).
          ...buildHlsVideoArgs(videoCodec, framerate),
          '-c:a', 'aac', '-b:a', '128k', '-ar', '44100',
          '-f', 'hls', '-hls_time', '2', '-hls_list_size', '15',
          '-hls_flags', 'delete_segments+append_list+omit_endlist',
          '-hls_segment_filename', hlsDir + '/seg-%05d.ts',
          hlsDir + '/index.m3u8',
        ];
        hlsProcess = spawn('ffmpeg', hlsArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
        hlsProcess.stderr.on('data', (d) => { console.error('[HLS] ' + d); });
        hlsProcess.on('exit', (c) => console.log('[HLS] exited ' + c));
        processes.hls = hlsProcess;
        console.log('[HLS] tee started -> ' + hlsDir);
      } catch (err) {
        console.error('[HLS] tee failed:', err.message);
      }
    }

    hiresProcess.stderr.on('data', (data) => {
      const text = data.toString();
      console.error(`[HIRES] ${text}`);
      const m = text.match(/frame=\s*(\d+)\s+fps=\s*([\d.]+)/);
      if (m) {
        this.state.framesReceived = parseInt(m[1], 10);
        this.state.currentFps = parseFloat(m[2]);
        this.state.lastFrameAt = new Date().toISOString();
      }
      if (/Connection refused|No route to host|Connection failed|Input\/output error|Server returned|404 Not Found|Connection timed out/i.test(text)) {
        this.state.lastError = text.trim().slice(0, 240);
      }
    });

    // Proxy is generated after stop by the BullMQ worker (same as SRT/RTMP).
    // DeckLink hardware does not support two concurrent readers on the same port.

    this.state.recording = true;
    this.state.sessionId = sessionId;
    this.state.processes = processes;
    this.state.framesReceived = 0;
    this.state.currentFps = 0;
    this.state.lastFrameAt = null;
    this.state.lastError = null;
    this.state.currentSession = {
      sessionId,
      projectId,
      binId,
      clipName,
      device,
      sourceType,
      sourceUrl,
      hiresKey,
      proxyKey,
      growingPath,
      localMasterPath,
      audioFifo,
      startedAt,
      duration: 0,
      uploads,
      codecs: {
        videoCodec, videoBitrate, framerate,
        audioCodec, audioBitrate, audioChannels, container,
        proxyEnabled, proxyVideoCodec, proxyVideoBitrate,
        proxyAudioCodec, proxyAudioBitrate, proxyAudioChannels, proxyContainer,
      },
    };

    return this._formatSessionResponse();
  }

  async startIdlePreview() {
    if (this._previewProc) return; // already running
    const sourceType = process.env.SOURCE_TYPE;
    const recorderId = process.env.RECORDER_ID;
    if (!recorderId || !['deltacast', 'sdi'].includes(sourceType)) return;

    const previewDir = `/live/preview-${recorderId}`;
    try { await fs.promises.mkdir(previewDir, { recursive: true }); } catch (_) {}

    let inputArgs;
    if (sourceType === 'deltacast') {
      const size = process.env.DELTACAST_VIDEO_SIZE || '1920x1080';
      const fps  = process.env.DELTACAST_FRAMERATE  || '60000/1001';
      let cfg = {};
      try { cfg = JSON.parse(process.env.SOURCE_CONFIG || '{}'); } catch (_) {}
      const port = cfg.port ?? 0;
      const videoFifo = (process.env.DELTACAST_PIPE_DIR || '/dev/shm/deltacast') + `/video-${port}.fifo`;
      inputArgs = ['-f', 'rawvideo', '-pix_fmt', 'uyvy422', '-s', size, '-r', fps, '-i', videoFifo];
    } else {
      // SDI (blackmagic): not yet implemented — skip
      return;
    }

    const outputArgs = [
      '-an',
      '-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'zerolatency',
      '-b:v', '600k', '-maxrate', '800k', '-bufsize', '1200k',
      '-g', '30', '-sc_threshold', '0',
      '-hls_time', '1', '-hls_list_size', '4',
      '-hls_flags', 'delete_segments+omit_endlist+independent_segments',
      '-hls_segment_type', 'mpegts',
      '-hls_segment_filename', previewDir + '/seg-%05d.ts',
      '-f', 'hls', previewDir + '/index.m3u8',
    ];

    console.log('[preview] starting idle preview for', recorderId);
    this._previewProc = spawn('ffmpeg', [...inputArgs, ...outputArgs], {
      stdio: ['ignore', 'ignore', 'pipe'],
    });
    this._previewProc.stderr.on('data', () => { /* swallow */ });
    this._previewProc.on('exit', (code) => {
      console.log('[preview] idle preview exited', code);
      this._previewProc = null;
    });
    this._previewProc.on('error', (e) => {
      console.error('[preview] idle preview error:', e.message);
      this._previewProc = null;
    });
  }

  stopIdlePreview() {
    if (!this._previewProc) return;
    try { this._previewProc.kill('SIGTERM'); } catch (_) {}
    this._previewProc = null;
  }

  async stop(sessionId) {
    if (!this.state.recording || this.state.sessionId !== sessionId) {
      throw new Error('No active capture session or session ID mismatch');
    }

    this.stopIdlePreview();

    const { processes, currentSession } = this.state;

    const isGrowing = !!currentSession.growingPath;

    // Send SIGINT and WAIT for the master writer to exit cleanly.
    //  - Non-growing: SIGINT flushes ffmpeg's MOV trailer (the moov atom with
    //    full sample tables). Uploading before finalize → "moov atom not found".
    //  - Growing: `processes.hires` is the bash ORCHESTRATOR (detached group
    //    leader). SIGINT hits its trap, which forwards SIGINT to ffmpeg; ffmpeg
    //    stops → raw2bmx gets EOF → raw2bmx writes the OP1a FOOTER and exits;
    //    only then does the orchestrator exit. Awaiting it guarantees the
    //    finalized, valid MXF is on the share before the promotion worker
    //    uploads it. raw2bmx footer finalize of a long recording can take longer
    //    than a MOV trailer flush, so the growing safety-net is more generous.
    const finalizeTimeoutMs = isGrowing ? 60000 : 15000;
    const waitExit = (proc) => new Promise((resolve) => {
      if (!proc || proc.exitCode !== null || proc.signalCode !== null) return resolve();
      let done = false;
      const finish = () => { if (!done) { done = true; resolve(); } };
      proc.once('exit', finish);
      // Safety net: don't hang stop() forever if the writer refuses to exit.
      setTimeout(() => {
        try {
          // Detached orchestrator → kill the whole process group (ffmpeg +
          // raw2bmx + bash); otherwise just the process.
          if (isGrowing && proc.pid) { try { process.kill(-proc.pid, 'SIGKILL'); } catch (_) {} }
          proc.kill('SIGKILL');
        } catch (_) {}
        finish();
      }, finalizeTimeoutMs);
    });

    if (processes.hires) processes.hires.kill('SIGINT');
    if (processes.proxy) processes.proxy.kill('SIGINT');
    if (processes.hls)   { try { processes.hls.kill('SIGINT'); } catch (_) {} }
    /* processes.bridge: removed — bridge is managed by node-agent, not per-session */

    // Wait for the master writer to finalize before we read/upload the file.
    await waitExit(processes.hires);

    // Release the CIFS mount (best-effort) once the ffmpeg writers are done with
    // it. The promotion worker reads the staged file from the host/S3 side, not
    // through this container's mount, so unmounting here is safe.
    unmountGrowingShare();

    try {
      const uploadPromises = [];

      // Non-growing: upload the finalized local master file to S3 now that the
      // moov has been written. Growing: the promotion worker handles S3.
      if (currentSession.localMasterPath) {
        let size = 0;
        try { size = statSync(currentSession.localMasterPath).size; } catch (_) {}
        if (size > 0) {
          uploadPromises.push(
            createUploadStream(
              S3_BUCKET,
              currentSession.hiresKey,
              createReadStream(currentSession.localMasterPath),
            ).then(() => {
              try { unlinkSync(currentSession.localMasterPath); } catch (_) {}
            })
          );
        } else {
          console.warn('[capture] local master is 0 bytes — skipping upload:', currentSession.localMasterPath);
        }
      } else if (currentSession.uploads.hires) {
        uploadPromises.push(currentSession.uploads.hires);
      }

      if (currentSession.uploads.proxy) uploadPromises.push(currentSession.uploads.proxy);
      await Promise.all(uploadPromises);
    } catch (error) {
      console.error('Error during upload completion:', error);
    }

    if (currentSession.audioFifo) {
      try { unlinkSync(currentSession.audioFifo); } catch (_) {}
    }

    const stoppedAt = new Date().toISOString();
    const startTime = new Date(currentSession.startedAt);
    const stopTime = new Date(stoppedAt);
    const duration = Math.round((stopTime - startTime) / 1000);

    this.state.recording = false;
    this.state.sessionId = null;
    this.state.processes = {};

    // No frames received → the upload (if any) produced a 0-byte object.
    // Surface that so the shutdown handler can mark the asset as 'error'
    // instead of posting a broken hi-res key downstream.
    const framesReceived = this.state.framesReceived;

    return {
      sessionId,
      projectId: currentSession.projectId,
      binId: currentSession.binId,
      clipName: currentSession.clipName,
      sourceType: currentSession.sourceType,
      hiresKey: currentSession.hiresKey,
      proxyKey: currentSession.proxyKey,
      growingPath: currentSession.growingPath || null,
      startedAt: currentSession.startedAt,
      stoppedAt,
      duration,
      framesReceived,
      empty: framesReceived === 0,
    };
  }

  getStatus() {
    if (!this.state.recording) return { recording: false };

    const startTime = new Date(this.state.currentSession.startedAt);
    const now = new Date();
    const duration = Math.round((now - startTime) / 1000);

    const lastFrameAt = this.state.lastFrameAt;
    const msSinceFrame = lastFrameAt ? (Date.now() - new Date(lastFrameAt).getTime()) : null;
    let signal = 'connecting';
    if (this.state.framesReceived > 0) {
      signal = (msSinceFrame !== null && msSinceFrame < 5000) ? 'receiving' : 'lost';
    } else if (this.state.lastError) {
      signal = 'error';
    }
    return {
      recording: true,
      sessionId: this.state.sessionId,
      sourceType: this.state.currentSession.sourceType,
      device: this.state.currentSession.device,
      clipName: this.state.currentSession.clipName,
      projectId: this.state.currentSession.projectId,
      binId: this.state.currentSession.binId,
      duration,
      startedAt: this.state.currentSession.startedAt,
      signal,
      framesReceived: this.state.framesReceived,
      currentFps: this.state.currentFps,
      lastFrameAt,
      msSinceFrame,
      lastError: this.state.lastError,
      codecs: this.state.currentSession.codecs,
    };
  }

  _formatSessionResponse() {
    const { currentSession, sessionId } = this.state;
    return {
      sessionId,
      projectId: currentSession.projectId,
      binId: currentSession.binId,
      clipName: currentSession.clipName,
      device: currentSession.device,
      sourceType: currentSession.sourceType,
      hiresKey: currentSession.hiresKey,
      proxyKey: currentSession.proxyKey,
      startedAt: currentSession.startedAt,
      codecs: currentSession.codecs,
    };
  }
}

export default new CaptureManager();
export { VIDEO_CODECS, AUDIO_CODECS, CONTAINER_FMT, CONTAINER_EXT, sourceBackends };
