import { spawn, execFileSync } from 'child_process';
import { mkdirSync, writeFileSync, createReadStream, statSync, unlinkSync } 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.
//
// This is the FIFTH format iteration. The four prior attempts and WHY they
// failed (root-caused with authoritative sources + live structural analysis on
// the zampp2 capture image, see docs/design/2026-05-31-growing-mxf-xdcam.md):
//
//   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: the prior team concluded "MXF-while-growing is
//      fundamentally unreliable." That conclusion was WRONG — the real culprit
//      was the CODEC, not MXF. Verified live: a DNxHR MXF SIGKILLed mid-write
//      has ZERO body partitions, 1 (header) index segment, and probes
//      duration=N/A — unreadable. DNxHR's large VBR frames don't trigger
//      ffmpeg's per-partition flush, so nothing but the header is on disk.
//
//   3) MPEG-TS H.264 High 4:2:2: ffprobe/VLC OK, Premiere rejects — its 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 H.264 stream as a
//      clean importable clip (TS is a transport/playback container to Premiere,
//      not an acquisition format); there is no officially-supported H.264-in-TS
//      *growing* ingest path. This is a dead end regardless of chroma.
//
// FIX — MXF OP1a carrying XDCAM HD422 (MPEG-2 422 @ 50 Mbps-class) + PCM.
//
//   WHY THIS, authoritatively:
//   * Adobe OFFICIALLY recommends MXF for growing-file workflows; the natively-
//     supported growing codecs are XDCAM HD422, AVC-Intra 50/100, IMX, DV, and
//     DNxHD — read by Premiere's built-in MXF reader with no plugin. XDCAM HD422
//     (MPEG-2 422 in MXF OP1a) is the most reliable of these for edit-while-
//     ingest (Softron/Drastic broadcast vendors ship exactly this).
//   * Premiere reads a growing MXF only if index table segments are written
//     INCREMENTALLY into body partitions during the record (it does NOT wait for
//     a footer) — the bmx/Adobe requirement: "write the index inside the new
//     essence partition," initial duration 0, must be readable with NO footer.
//
//   WHY ffmpeg CAN do it here (the key discovery):
//   * ffmpeg's MXF muxer (this 2026 build) flushes a NEW body partition + a NEW
//     incremental index table segment for CBR essence like mpeg2video. Verified
//     live by SIGKILLing the writer mid-record (no clean finalize, no footer):
//       partitions: [Hdr, Body, Body, Body, Body, Body, Body]
//       index table segments: 5   (one per body partition, NOT footer-deferred)
//       ffprobe duration: 59.2s   (real, growing — estimated from header
//                                  edit-rate + body offset, no footer needed)
//       decode of the unfinalized file: exit 0
//     This is exactly the incremental-index structure Premiere needs. The
//     contrast with DNxHR (attempt #2: 0 body partitions, duration=N/A) is the
//     whole story — same muxer, same SIGKILL, opposite result, purely codec.
//
//   Audio: PCM (pcm_s16le) — the native, broadcast-standard MXF audio mapping
//   that Premiere's MXF reader expects (NOT AAC; AAC-in-MXF is not a standard
//   XDCAM mapping and is not reliably read).
//
// HONEST CAVEAT (cannot be verified without real Premiere on the workstation):
//   ffprobe/decode mid-write is PROVEN above, and the incremental index/body-
//   partition structure 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, and
//   "Write XMP ID to Files on Import" / "Write clip markers to XMP" DISABLED)
//   can confirm the end-to-end edit-while-record. Those Premiere prefs are a
//   hard requirement for growing MXF and are outside this file's control.
//
// Video args: MPEG-2 422, 8-bit 4:2:2 (Premiere-native for XDCAM HD422). `-dc 10`
// + intra-VLC-friendly settings match XDCAM HD422 essence. The operator's
// target bitrate is applied as CBR (-b:v/-minrate/-maxrate) in buildEncodeArgs;
// when unset we default to 50 Mbps (canonical XDCAM HD422). `-g 15` keeps a
// short GOP so partition flushes (and thus the editable head) stay close to the
// record head.
const GROWING_VIDEO_ARGS = [
  '-c:v', 'mpeg2video', '-pix_fmt', 'yuv422p',
  '-dc', '10', '-g', '15', '-bf', '2',
];
const GROWING_DEFAULT_BITRATE = '50M';
const GROWING_EXT = 'mxf';

// ── 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,
      };
    },
  },

  // Stubs — hardware/SDK plumbing not yet implemented. These throw clearly so a
  // misconfigured recorder fails fast instead of silently falling back to the
  // wrong card.
  deltacast: {
    buildInput() {
      throw new Error('deltacast backend not yet implemented — requires hardware');
    },
  },
  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,
}) {
  // ── Growing master: MXF OP1a + XDCAM HD422 (MPEG-2 422) + PCM. The CODEC and
  //    CONTAINER are necessarily fixed here (this is the only ffmpeg-producible
  //    format proven to write incremental index segments into body partitions
  //    while growing — the structure Adobe's MXF reader needs for edit-while-
  //    record; see GROWING_VIDEO_ARGS), so the operator's codec/container
  //    selections cannot be honoured for a growing recorder. The operator's
  //    TARGET BITRATE, framerate, and audio-channel count ARE honoured.
  //    Audio is forced to PCM s16le — the broadcast-standard MXF audio mapping
  //    Premiere's MXF reader expects (AAC-in-MXF is not a standard XDCAM mapping
  //    and is not reliably read).
  if (growing) {
    const args = [];
    if (isNetwork) args.push('-map', '0:v:0?', '-map', '0:a:0?');
    args.push(...GROWING_VIDEO_ARGS);
    // CBR is required so the MXF muxer flushes a body partition + incremental
    // index segment at a steady cadence (VBR/DNxHR defers everything to the
    // footer → unreadable while growing). Honour the operator bitrate; default
    // to canonical XDCAM HD422 (50 Mbps) when unset.
    const vb = videoBitrate || GROWING_DEFAULT_BITRATE;
    args.push('-b:v', vb, '-minrate', vb, '-maxrate', vb, '-bufsize', vb);
    if (framerate && framerate !== 'native') args.push('-r', framerate);
    args.push('-c:a', 'pcm_s16le', '-ar', '48000');
    if (audioChannels) args.push('-ac', String(audioChannels));
    args.push('-f', 'mxf');
    return args;
  }

  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, 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 VideoMaster SDK FFmpeg plugin.
    // FFmpeg input format is 'deltacast', device address is 'deltacast://<index>'.
    // When the physical device is absent (/dev/deltacast<N> missing), fall back
    // to a lavfi test card so development and integration testing work without hardware.
    if (sourceType === 'deltacast') {
      const idx = (typeof device === 'number' || /^\d+$/.test(String(device)))
        ? parseInt(device, 10)
        : 0;
      const { existsSync } = await import('node:fs');
      const deviceNode = `/dev/deltacast${idx}`;
      if (existsSync(deviceNode)) {
        console.log(`[capture] Deltacast index ${idx} → ${deviceNode} (hardware)`);
        return {
          inputArgs: ['-f', 'deltacast', '-i', `deltacast://${idx}`],
          isNetwork: false,
        };
      } else {
        // No hardware — lavfi test card with port label + timecode burn-in.
        // Matches the deltacast-sdi-recorder standalone app fallback exactly so
        // recorded files look right in the MAM library during dev.
        console.warn(`[capture] Deltacast device ${deviceNode} not found — using lavfi test card for port ${idx}`);
        const testSrc = [
          `testsrc2=size=1920x1080:rate=30`,
          `drawtext=text='DELTACAST PORT ${idx} — TEST MODE':fontsize=48:fontcolor=white:x=(w-text_w)/2:y=(h-text_h)/2`,
          `drawtext=text='%{localtime\\:%H\\:%M\\:%S}':fontsize=32:fontcolor=yellow:x=10:y=10`,
        ].join(',');
        return {
          inputArgs: [
            '-f', 'lavfi', '-i', testSrc,
            '-f', 'lavfi', '-i', 'sine=frequency=1000:sample_rate=48000',
            '-map', '0:v:0', '-map', '1:a:0',
          ],
          isNetwork: false,
        };
      }
    }

    // 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 });
  }

  /**
   * 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,
    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 (the format Premiere reads
    // while growing — see GROWING_VIDEO_ARGS), 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 promotion worker uploads the on-share .ts to this
    //    key, so it has the .ts 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();

    const { inputArgs, isNetwork } = await this._buildInputArgs({
      sourceType, sourceBackend, device, sourceUrl, listen, listenPort, streamKey,
    });

    const hiresCodecArgs = buildEncodeArgs({
      codec: videoCodec, videoBitrate, framerate,
      audioCodec, audioBitrate, audioChannels,
      container,
      isNetwork,
      isProxy: false,
      // Only the growing-file master (written to the SMB share for
      // edit-while-record) uses the MXF OP1a / XDCAM HD422 growing format. The
      // finalized, S3-piped master is a clean non-fragmented MOV.
      growing: !!growingPath,
    });

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

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

    // Master output destination.
    //
    //  - Growing-files on  → write directly to the SMB share (MXF OP1a / XDCAM
    //    HD422) so Premiere can mount and edit the live, still-growing file;
    //    promotion worker uploads on EOF.
    //
    //  - Growing-files off → write to a LOCAL SEEKABLE temp file, then 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 master Premiere opens natively.
    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 = growingPath ? growingPath : localMasterPath;
    // ffmpeg now writes a file (not stdout) in both modes → stdout is unused.
    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;
    let hiresArgs;
    if (sourceType === 'sdi' && this._assetIdForHls) {
      const fsMod = await import('node:fs');
      sdiHlsDir = '/live/' + this._assetIdForHls;
      try { fsMod.mkdirSync(sdiHlsDir, { recursive: true }); } catch (_) {}
      hiresArgs = [
        ...inputArgs,
        '-filter_complex', '[0:v]yadif=mode=1:deint=1,split=2[vhi][vlo]',
        // Output 0 — ProRes master (S3 pipe or growing file)
        '-map', '[vhi]', '-map', '0:a:0?',
        ...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', '0:a:0?',
        ...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 ];
    }

    const 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.
    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,
      startedAt,
      duration: 0,
      uploads,
      codecs: {
        videoCodec, videoBitrate, framerate,
        audioCodec, audioBitrate, audioChannels, container,
        proxyEnabled, proxyVideoCodec, proxyVideoBitrate,
        proxyAudioCodec, proxyAudioBitrate, proxyAudioChannels, proxyContainer,
      },
    };

    return this._formatSessionResponse();
  }

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

    const { processes, currentSession } = this.state;

    // Send SIGINT and WAIT for ffmpeg to exit. This is what flushes the MOV
    // trailer (writes the moov atom with the full sample tables). If we uploaded
    // before ffmpeg finalized, the object would have no moov → "moov atom not
    // found" / "file cannot be opened" in Premiere.
    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 ffmpeg refuses to exit.
      setTimeout(() => { try { proc.kill('SIGKILL'); } catch (_) {} finish(); }, 15000);
    });

    if (processes.hires) processes.hires.kill('SIGINT');
    if (processes.proxy) processes.proxy.kill('SIGINT');
    if (processes.hls)   { try { processes.hls.kill('SIGINT'); } catch (_) {} }

    // 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);
    }

    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 };
