const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
const { spawn, spawnSync } = require("child_process");
const { publishWebEvent } = require("./web-events");

const DATA_ROOT = path.join(
  process.env.LUMI_DATA_DIR ? path.resolve(process.env.LUMI_DATA_DIR) : path.join(__dirname, "..", "..", "data"),
  "stream-testing"
);
const MAX_SESSION_MS = Math.min(60 * 60 * 1000, Math.max(5 * 60 * 1000, Number(process.env.LUMI_STREAM_TEST_MAX_MS) || 30 * 60 * 1000));
const INACTIVITY_MS = Math.min(5 * 60 * 1000, Math.max(20 * 1000, Number(process.env.LUMI_STREAM_TEST_INACTIVITY_MS) || 45 * 1000));
const MAX_OUTPUT_BYTES = Math.min(4 * 1024 ** 3, Math.max(128 * 1024 ** 2, Number(process.env.LUMI_STREAM_TEST_MAX_BYTES) || 1024 ** 3));
const INGEST_PORT = Math.min(65535, Math.max(1024, Number(process.env.LUMI_STREAM_TEST_INGEST_PORT) || 19350));
const PUBLIC_INGEST_PORT = Math.min(65535, Math.max(1, Number(process.env.LUMI_STREAM_TEST_PUBLIC_PORT) || INGEST_PORT));
const FFMPEG = String(process.env.LUMI_FFMPEG_PATH || "ffmpeg");

function finite(value, fallback, min, max) {
  const parsed = Number(value);
  return Number.isFinite(parsed) ? Math.min(max, Math.max(min, parsed)) : fallback;
}

function redactUrl(value) {
  try {
    const parsed = new URL(value);
    parsed.pathname = "/lumi-test/[session]";
    parsed.search = "";
    return parsed.toString();
  } catch {
    return "configured ingest endpoint";
  }
}

function redactDiagnostic(value, session) {
  let result = String(value || "");
  if (session?.listenerUrl) result = result.split(session.listenerUrl).join("rtmp://[private-stream-test]");
  return result.replace(/rtmps?:\/\/[^\s'"]+\/lumi-test\/[^\s'"]+/gi, "rtmp://[private-stream-test]");
}

function directoryBytes(root) {
  try {
    return fs.readdirSync(root, { withFileTypes: true }).reduce((total, entry) => {
      if (!entry.isFile()) return total;
      try { return total + fs.statSync(path.join(root, entry.name)).size; } catch { return total; }
    }, 0);
  } catch {
    return 0;
  }
}

function refreshWarnings(session) {
  session.warnings = [
    ...session.baseWarnings,
    ...(session.obsWarnings || []),
    ...(session.receiverWarnings || []),
    ...(session.captionWarnings || [])
  ];
}

let receiverStatusCache = null;
function executableStatus({ refresh = false } = {}) {
  if (!refresh && receiverStatusCache && Date.now() - receiverStatusCache.checkedAt < 60_000) return receiverStatusCache.value;
  const result = spawnSync(FFMPEG, ["-hide_banner", "-version"], { encoding: "utf8", timeout: 5000, windowsHide: true });
  if (result.error || result.status !== 0) {
    const value = { available: false, detail: "FFmpeg is not available. Install a current FFmpeg build or set LUMI_FFMPEG_PATH." };
    receiverStatusCache = { checkedAt: Date.now(), value };
    return value;
  }
  const encoders = spawnSync(FFMPEG, ["-hide_banner", "-encoders"], { encoding: "utf8", timeout: 8000, windowsHide: true });
  const hardwareAdvertised = /\bh264_nvenc\b/.test(encoders.stdout || "");
  const hardwareProbe = hardwareAdvertised
    ? spawnSync(FFMPEG, ["-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "color=size=64x64:rate=1", "-frames:v", "1", "-c:v", "h264_nvenc", "-f", "null", "-"], { encoding: "utf8", timeout: 8000, windowsHide: true })
    : null;
  const hardware = hardwareProbe?.status === 0;
  const value = {
    available: true,
    encoder: process.env.LUMI_STREAM_TEST_ENCODER || (hardware ? "h264_nvenc" : "libx264"),
    hardware,
    detail: hardware
      ? "FFmpeg with NVIDIA NVENC is ready."
      : hardwareAdvertised
        ? "FFmpeg is ready. NVENC was advertised but failed its encode probe, so Lumi will use CPU H.264."
        : "FFmpeg is ready with CPU H.264 encoding."
  };
  receiverStatusCache = { checkedAt: Date.now(), value };
  return value;
}

function ladderFor(source = {}) {
  const width = Math.round(finite(source.width, 1920, 160, 7680));
  const height = Math.round(finite(source.height, 1080, 90, 4320));
  const fps = finite(source.fps, 30, 1, 120);
  const ladder = [{ name: "source", label: `Source (${width}×${height})`, width, height, bitrate: Math.min(12000, Math.max(2500, Math.round(width * height * fps / 9000))) }];
  if (height > 720) ladder.push({ name: "720p", label: "720p", height: 720, width: Math.max(2, Math.round((width * 720 / height) / 2) * 2), bitrate: 4500 });
  if (height > 480) ladder.push({ name: "480p", label: "480p", height: 480, width: Math.max(2, Math.round((width * 480 / height) / 2) * 2), bitrate: 2200 });
  return { width, height, fps, variants: ladder };
}

function ffmpegArgs(session) {
  const variants = session.source.variants;
  const splitNames = variants.map((_, index) => `[split${index}]`).join("");
  const filters = variants.length > 1 ? [`[0:v:0]split=${variants.length}${splitNames}`] : [];
  const maps = [];
  const streamMap = [];
  variants.forEach((variant, index) => {
    if (variant.name === "source") {
      if (variants.length > 1) {
        filters.push(`[split${index}]scale=w='min(iw,${variant.width})':h='min(ih,${variant.height})':force_original_aspect_ratio=decrease:force_divisible_by=2[v${index}]`);
      }
      maps.push("-map", variants.length > 1 ? `[v${index}]` : "0:v:0");
    } else {
      filters.push(`[split${index}]scale=w=${variant.width}:h=${variant.height}:flags=lanczos[v${index}]`);
      maps.push("-map", `[v${index}]`);
    }
    maps.push("-map", "0:a:0");
    streamMap.push(`v:${index},a:${index},name:${variant.name}`);
  });
  const encoderArgs = session.receiver.encoder === "h264_nvenc"
    ? ["-c:v", "h264_nvenc", "-preset", "p4", "-tune", "ll", "-rc", "vbr"]
    : ["-c:v", "libx264", "-preset", "veryfast", "-tune", "zerolatency"];
  const bitrateArgs = variants.flatMap((variant, index) => [
    `-b:v:${index}`, `${variant.bitrate}k`,
    `-maxrate:v:${index}`, `${Math.round(variant.bitrate * 1.15)}k`,
    `-bufsize:v:${index}`, `${Math.round(variant.bitrate * 2)}k`
  ]);
  return [
    "-hide_banner", "-nostdin", "-loglevel", "warning",
    "-listen", "1", "-rw_timeout", `${INACTIVITY_MS * 1000}`, "-thread_queue_size", "512",
    "-i", session.listenerUrl,
    ...(filters.length ? ["-filter_complex", filters.join(";")] : []),
    ...maps,
    ...encoderArgs,
    ...bitrateArgs,
    "-c:a", "aac", "-b:a", "128k", "-ar", "48000",
    "-max_muxing_queue_size", "1024",
    "-g", `${Math.max(30, Math.round(session.source.fps * 2))}`, "-keyint_min", `${Math.max(30, Math.round(session.source.fps * 2))}`, "-sc_threshold", "0",
    "-f", "hls", "-hls_time", "2", "-hls_list_size", "8",
    "-hls_flags", "delete_segments+independent_segments+program_date_time+temp_file",
    "-master_pl_name", "master.m3u8",
    "-var_stream_map", streamMap.join(" "),
    "-hls_segment_filename", path.join(session.directory, "stream_%v_%06d.ts"),
    path.join(session.directory, "stream_%v.m3u8"),
    "-progress", "pipe:1"
  ];
}

class StreamTestingService {
  constructor() {
    this.active = null;
    this.timeline = [];
    this.lastCreateAt = new Map();
    this.timer = setInterval(() => this.sweep(), 2000);
    this.timer.unref?.();
    this.exitHandler = () => {
      for (const child of [this.active?.patternProcess, this.active?.process]) {
        try {
          if (child && child.exitCode === null) child.kill("SIGKILL");
        } catch {}
      }
      try {
        if (this.active?.directory) fs.rmSync(this.active.directory, { recursive: true, force: true });
      } catch {}
    };
    process.once("exit", this.exitHandler);
    fs.mkdirSync(DATA_ROOT, { recursive: true });
    this.cleanupStale();
  }

  receiverStatus() {
    return executableStatus();
  }

  publicStatus() {
    const session = this.active;
    const receiver = session?.receiver || executableStatus();
    return {
      available: receiver.available,
      receiver: { available: receiver.available, encoder: receiver.encoder || null, hardware: Boolean(receiver.hardware), detail: receiver.detail },
      active: Boolean(session),
      session: session ? {
        id: session.id,
        state: session.state,
        started_at: session.startedAt,
        expires_at: session.expiresAt,
        last_activity_at: session.lastActivityAt,
        device: session.deviceName,
        source: session.source,
        playback_url: `/admin/stream-testing/media/${session.id}/master.m3u8`,
        captions_url: `/admin/stream-testing/media/${session.id}/captions.vtt`,
        metrics: session.metrics,
        warnings: session.warnings,
        ingest: redactUrl(session.listenerUrl)
      } : null,
      timeline: this.timeline.slice(-100)
    };
  }

  create(device, input = {}, send = null) {
    const now = Date.now();
    if (this.active) {
      const error = new Error("A stream test is already active. End it before starting another.");
      error.code = "STREAM_TEST_ACTIVE";
      throw error;
    }
    if (now - (this.lastCreateAt.get(device.id) || 0) < 5000) {
      const error = new Error("Wait a few seconds before creating another stream test.");
      error.code = "STREAM_TEST_RATE_LIMIT";
      throw error;
    }
    const receiver = executableStatus({ refresh: true });
    if (!receiver.available) {
      const error = new Error(receiver.detail);
      error.code = "STREAM_TEST_RECEIVER_UNAVAILABLE";
      throw error;
    }
    const source = ladderFor(input.source || {});
    if (source.width > 3840 || source.height > 2160 || source.fps > 60) {
      const error = new Error("Stream testing is bounded to 3840×2160 at 60 fps.");
      error.code = "STREAM_TEST_SOURCE_LIMIT";
      throw error;
    }
    const id = crypto.randomUUID();
    const secret = crypto.randomBytes(32).toString("base64url");
    const directory = path.join(DATA_ROOT, id);
    fs.mkdirSync(directory, { recursive: true });
    const configuredHost = String(process.env.LUMI_STREAM_TEST_INGEST_HOST || (input.pattern === true ? "127.0.0.1" : "")).trim();
    if (!configuredHost) {
      fs.rmSync(directory, { recursive: true, force: true });
      const error = new Error("Set LUMI_STREAM_TEST_INGEST_HOST to the hostname reachable from the streaming computer.");
      error.code = "STREAM_TEST_INGEST_UNCONFIGURED";
      throw error;
    }
    const protocol = process.env.LUMI_STREAM_TEST_RTMPS === "true" ? "rtmps" : "rtmp";
    const key = `${id}/${secret}`;
    const listenerUrl = `rtmp://0.0.0.0:${INGEST_PORT}/lumi-test/${key}`;
    const session = {
      id,
      deviceId: device.id,
      deviceName: String(device.name || device.metadata?.name || "Paired Companion").slice(0, 120),
      state: "starting",
      source,
      receiver,
      directory,
      listenerUrl,
      createdAt: now,
      startedAt: null,
      expiresAt: now + MAX_SESSION_MS,
      lastActivityAt: now,
      metrics: { obs: {}, receiver: {}, bytes: 0 },
      baseWarnings: protocol === "rtmp" ? [{ severity: "warning", code: "unencrypted_ingest", message: "The ingest hop uses an ephemeral credential but is not transport-encrypted. Keep the ingest port private or enable RTMPS at the reverse-proxy boundary." }] : [],
      obsWarnings: [],
      receiverWarnings: [],
      captionWarnings: [],
      warnings: protocol === "rtmp" ? [{ severity: "warning", code: "unencrypted_ingest", message: "The ingest hop uses an ephemeral credential but is not transport-encrypted. Keep the ingest port private or enable RTMPS at the reverse-proxy boundary." }] : [],
      captions: [],
      process: null,
      patternProcess: null,
      pattern: input.pattern === true,
      send: typeof send === "function" ? send : null,
      stopping: false
    };
    this.active = session;
    this.lastCreateAt.set(device.id, now);
    this.event("created", "Test session created.", session);
    this.startReceiver(session);
    return {
      id,
      state: session.state,
      expires_at: session.expiresAt,
      ingest: {
        server: `${protocol}://${configuredHost}:${PUBLIC_INGEST_PORT}/lumi-test`,
        key,
        encrypted: protocol === "rtmps"
      },
      source,
      warning: session.warnings[0]?.message || null
    };
  }

  createPattern() {
    const created = this.create(
      { id: "admin-test-pattern", name: "Deterministic test pattern" },
      { pattern: true, source: { width: 1280, height: 720, fps: 30 } }
    );
    const session = this.active;
    if (!session) throw new Error("The deterministic receiver session was not created.");
    const publishUrl = session.listenerUrl.replace("0.0.0.0", "127.0.0.1");
    setTimeout(() => {
      if (this.active?.id !== session.id || session.stopping) return;
      const args = [
        "-hide_banner", "-nostdin", "-loglevel", "warning", "-re",
        "-f", "lavfi", "-i", "testsrc2=size=1280x720:rate=30",
        "-f", "lavfi", "-i", "sine=frequency=880:sample_rate=48000",
        "-c:v", "libx264", "-preset", "veryfast", "-tune", "zerolatency",
        "-b:v", "3500k", "-g", "60", "-c:a", "aac", "-b:a", "128k",
        "-f", "flv", publishUrl
      ];
      const feeder = spawn(FFMPEG, args, { windowsHide: true, stdio: ["ignore", "ignore", "pipe"] });
      session.patternProcess = feeder;
      feeder.stderr.resume();
      feeder.on("error", (error) => this.fail(session, `Test pattern could not start: ${error.message}`));
      feeder.on("exit", (code, signal) => {
        if (!session.stopping && this.active?.id === session.id)
          this.fail(session, `Test pattern stopped unexpectedly (${signal || `exit ${code}`}).`);
      });
      this.event("pattern_started", "Deterministic color, motion, timing, and audio pattern started.", session);
    }, 750).unref?.();
    return created;
  }

  startReceiver(session) {
    const args = ffmpegArgs(session);
    const child = spawn(FFMPEG, args, { cwd: session.directory, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
    session.process = child;
    let progress = "";
    let diagnostic = "";
    child.stdout.setEncoding("utf8");
    child.stdout.on("data", (chunk) => {
      progress += chunk;
      const records = progress.split(/\r?\n/);
      progress = records.pop() || "";
      for (const line of records) {
        const separator = line.indexOf("=");
        if (separator < 1) continue;
        const key = line.slice(0, separator);
        const value = line.slice(separator + 1);
        if (["fps", "bitrate", "speed", "drop_frames", "dup_frames", "out_time_ms"].includes(key)) session.metrics.receiver[key] = value;
        if (key === "progress") {
          session.lastActivityAt = Date.now();
          if (session.state === "starting") {
            session.state = "receiving";
            session.startedAt = Date.now();
            this.event("receiving", "OBS stream reached Lumi.", session);
          }
        }
      }
    });
    child.stderr.setEncoding("utf8");
    child.stderr.on("data", (chunk) => { diagnostic = `${diagnostic}${chunk}`.slice(-4000); });
    child.on("error", (error) => this.fail(session, `Receiver could not start: ${error.message}`));
    child.on("exit", (code, signal) => {
      if (session.stopping || this.active?.id !== session.id) return;
      this.fail(session, `Receiver stopped unexpectedly (${signal || `exit ${code}`}). ${redactDiagnostic(diagnostic.trim().slice(-600), session)}`);
    });
  }

  updateObs(deviceId, input = {}) {
    const session = this.requireDeviceSession(deviceId, input.session_id);
    session.lastActivityAt = Date.now();
    session.metrics.obs = {
      bitrate_kbps: finite(input.bitrate_kbps, 0, 0, 100000),
      dropped_frames: Math.round(finite(input.dropped_frames, 0, 0, Number.MAX_SAFE_INTEGER)),
      total_frames: Math.round(finite(input.total_frames, 0, 0, Number.MAX_SAFE_INTEGER)),
      congestion: finite(input.congestion, 0, 0, 1),
      active: input.active === true,
      width: Math.round(finite(input.width, session.source.width, 0, 7680)),
      height: Math.round(finite(input.height, session.source.height, 0, 4320)),
      fps: finite(input.fps, session.source.fps, 0, 120),
      encoder: String(input.encoder || "").slice(0, 160) || null,
      service: String(input.service || "").slice(0, 160) || null,
      at: Date.now()
    };
    const dynamicWarnings = [];
    const droppedRatio = session.metrics.obs.total_frames > 0 ? session.metrics.obs.dropped_frames / session.metrics.obs.total_frames : 0;
    if (droppedRatio >= 0.03) dynamicWarnings.push({ severity: "critical", code: "obs_frame_loss", message: `${(droppedRatio * 100).toFixed(1)}% of OBS output frames were dropped.` });
    else if (droppedRatio >= 0.005) dynamicWarnings.push({ severity: "warning", code: "obs_frame_loss", message: `${(droppedRatio * 100).toFixed(1)}% of OBS output frames were dropped.` });
    if (session.metrics.obs.bitrate_kbps > 0 && session.metrics.obs.bitrate_kbps < 500) dynamicWarnings.push({ severity: "critical", code: "obs_bitrate_collapse", message: `OBS output bitrate fell to ${Math.round(session.metrics.obs.bitrate_kbps)} kbps.` });
    if (session.metrics.obs.congestion >= 0.25) dynamicWarnings.push({ severity: "critical", code: "obs_congestion", message: `OBS reports ${Math.round(session.metrics.obs.congestion * 100)}% network congestion.` });
    else if (session.metrics.obs.congestion >= 0.08) dynamicWarnings.push({ severity: "warning", code: "obs_congestion", message: `OBS reports ${Math.round(session.metrics.obs.congestion * 100)}% network congestion.` });
    session.obsWarnings = dynamicWarnings;
    refreshWarnings(session);
    this.notify();
    return this.publicStatus().session;
  }

  addCaption(deviceId, input = {}) {
    const session = this.requireDeviceSession(deviceId, input.session_id);
    const text = String(input.text || "").replace(/[\r\n]+/g, " ").trim().slice(0, 500);
    if (!text) return;
    const start = finite(input.start_seconds, Math.max(0, (Date.now() - (session.startedAt || Date.now())) / 1000), 0, MAX_SESSION_MS / 1000);
    const end = Math.max(start + 0.3, finite(input.end_seconds, start + 3, start, MAX_SESSION_MS / 1000));
    session.captions.push({ start, end, text });
    if (session.captions.length > 1000) session.captions.shift();
    const delayMs = finite(input.delay_ms, 0, 0, MAX_SESSION_MS);
    session.metrics.captions = { delivered: session.captions.length, last_delay_ms: delayMs, last_received_at: Date.now() };
    session.captionWarnings = delayMs >= 5000
      ? [{ severity: "critical", code: "caption_delay", message: `The latest caption took ${Math.round(delayMs)} ms to become available.` }]
      : delayMs >= 2500
        ? [{ severity: "warning", code: "caption_delay", message: `The latest caption took ${Math.round(delayMs)} ms to become available.` }]
        : [];
    refreshWarnings(session);
    session.lastActivityAt = Date.now();
  }

  captionFile(id) {
    const session = this.active?.id === id ? this.active : null;
    if (!session) return null;
    const timestamp = (seconds) => {
      const milliseconds = Math.round(seconds * 1000);
      const date = new Date(milliseconds);
      return `${String(Math.floor(milliseconds / 3600000)).padStart(2, "0")}:${String(date.getUTCMinutes()).padStart(2, "0")}:${String(date.getUTCSeconds()).padStart(2, "0")}.${String(date.getUTCMilliseconds()).padStart(3, "0")}`;
    };
    return `WEBVTT\n\n${session.captions.map((cue, index) => `${index + 1}\n${timestamp(cue.start)} --> ${timestamp(cue.end)}\n${cue.text}\n`).join("\n")}`;
  }

  mediaFile(id, name) {
    const session = this.active?.id === id ? this.active : null;
    if (!session || !/^(?:master|stream_(?:source|720p|480p))(?:\.m3u8|_\d{6}\.ts)$/.test(name)) return null;
    const resolved = path.join(session.directory, name);
    return fs.existsSync(resolved) && fs.statSync(resolved).isFile() ? resolved : null;
  }

  requireDeviceSession(deviceId, id) {
    const session = this.active;
    if (!session || session.id !== id || session.deviceId !== deviceId) {
      const error = new Error("The stream test session is not active for this Companion.");
      error.code = "STREAM_TEST_NOT_ACTIVE";
      throw error;
    }
    return session;
  }

  async stop(input = {}) {
    const session = this.active;
    if (!session || (input.session_id && session.id !== input.session_id)) return { stopped: false };
    if (input.device_id && session.deviceId !== input.device_id) throw new Error("This Companion does not own the active stream test.");
    session.stopping = true;
    session.state = input.failed ? "failed" : "stopping";
    this.event(input.failed ? "failed" : "stopping", input.reason || (input.failed ? "The test failed." : "The test is ending."), session);
    const child = session.process;
    if (session.patternProcess && session.patternProcess.exitCode === null) session.patternProcess.kill("SIGTERM");
    if (child && child.exitCode === null) {
      child.kill("SIGTERM");
      await Promise.race([
        new Promise((resolve) => child.once("exit", resolve)),
        new Promise((resolve) => setTimeout(resolve, 3000))
      ]);
      if (child.exitCode === null) child.kill("SIGKILL");
    }
    if (this.active?.id === session.id) this.active = null;
    try {
      session.send?.("stream_test_ended", {
        session_id: session.id,
        reason: input.reason || "The private stream test ended.",
        failed: Boolean(input.failed)
      });
    } catch {}
    this.event(input.failed ? "failed" : "ended", input.reason || "Test session ended and temporary media was removed.", session);
    try { await fs.promises.rm(session.directory, { recursive: true, force: true }); } catch {}
    this.notify();
    return { stopped: true, id: session.id };
  }

  fail(session, detail) {
    if (this.active?.id !== session.id || session.stopping) return;
    session.warnings.push({ severity: "critical", code: "receiver_stopped", message: String(detail).slice(0, 1000) });
    this.event("receiver_error", String(detail).slice(0, 1000), session);
    void this.stop({ session_id: session.id, failed: true, reason: "The Lumi receiver stopped. OBS recovery was requested." });
  }

  sweep() {
    const session = this.active;
    if (!session) return;
    session.metrics.bytes = directoryBytes(session.directory);
    const receiverSpeed = Number.parseFloat(String(session.metrics.receiver.speed || "").replace(/x$/, ""));
    session.receiverWarnings = Number.isFinite(receiverSpeed) && receiverSpeed > 0 && receiverSpeed < 0.75
      ? [{ severity: "critical", code: "receiver_slow", message: `Receiver transcoding is only ${receiverSpeed.toFixed(2)}× real time.` }]
      : Number.isFinite(receiverSpeed) && receiverSpeed > 0 && receiverSpeed < 0.95
        ? [{ severity: "warning", code: "receiver_slow", message: `Receiver transcoding is ${receiverSpeed.toFixed(2)}× real time.` }]
        : [];
    refreshWarnings(session);
    const now = Date.now();
    if (session.metrics.bytes > MAX_OUTPUT_BYTES) return void this.stop({ session_id: session.id, failed: true, reason: "The test reached its temporary media safety limit." });
    if (now >= session.expiresAt) return void this.stop({ session_id: session.id, reason: "The maximum test duration expired." });
    if (now - session.lastActivityAt >= INACTIVITY_MS) return void this.stop({ session_id: session.id, failed: true, reason: "No stream activity reached Lumi before the inactivity limit." });
    this.notify();
  }

  event(kind, message, session = this.active) {
    this.timeline.push({ at: Date.now(), kind, message, session_id: session?.id || null });
    if (this.timeline.length > 200) this.timeline.shift();
    this.notify();
  }

  notify() {
    publishWebEvent("stream-test:changed", { active: Boolean(this.active), state: this.active?.state || "idle" }, { role: "admin" });
  }

  cleanupStale() {
    for (const entry of fs.readdirSync(DATA_ROOT, { withFileTypes: true })) {
      if (!entry.isDirectory()) continue;
      fs.rmSync(path.join(DATA_ROOT, entry.name), { recursive: true, force: true });
    }
  }

  async close() {
    clearInterval(this.timer);
    await this.stop({ reason: "Lumi is shutting down; OBS recovery was requested." });
    process.removeListener("exit", this.exitHandler);
  }
}

const streamTestingService = new StreamTestingService();

module.exports = { StreamTestingService, streamTestingService, ladderFor, executableStatus, ffmpegArgs, DATA_ROOT };
