#!/usr/bin/env node
// Phase 4c pre-assemble helper — wait for detached BGM, then write status.
//
// audio.mjs may launch Lyria / MusicGen in a detached process so voice work can
// keep moving. Before assemble-index.mjs decides whether to emit the BGM audio
// track, this script gives the background renderer a bounded chance to finish
// and converts log/process state into a small bgm_status.json file.
//
// Always exits 0 for normal pipeline use: missing/failed BGM should not block a
// voice/captions/SFX render. Structural invocation errors still exit 1.
//
// Usage:
//   node wait-bgm.mjs --audio-meta ./audio_meta.json --hyperframes . \
//     [--timeout-ms 120000] [--interval-ms 2000] [--out ./bgm_status.json]

import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";

const argv = process.argv.slice(2);
const flag = (name, def) => {
  const i = argv.indexOf(`--${name}`);
  return i >= 0 && i + 1 < argv.length ? argv[i + 1] : def;
};

function die(msg) {
  console.error(`✗ wait-bgm.mjs: ${msg}`);
  process.exit(1);
}

const audioMetaPath = resolve(flag("audio-meta", "./audio_meta.json"));
const hyperframesDir = resolve(flag("hyperframes", "."));
const outPath = resolve(flag("out", join(hyperframesDir, "bgm_status.json")));
const timeoutMs = Math.max(0, Number(flag("timeout-ms", "120000")) || 0);
const intervalMs = Math.max(250, Number(flag("interval-ms", "2000")) || 2000);

function sleep(ms) {
  return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
}

function isProcessAlive(pid) {
  if (!pid || !Number.isFinite(Number(pid))) return false;
  try {
    process.kill(Number(pid), 0);
    return true;
  } catch {
    return false;
  }
}

function readTail(path, maxChars = 6000) {
  if (!path || !existsSync(path)) return "";
  const s = statSync(path);
  const txt = readFileSync(path, "utf8");
  return txt.slice(Math.max(0, txt.length - Math.min(maxChars, s.size)));
}

function detectFailure(logTail) {
  if (!logTail) return "";
  const lines = logTail.split("\n");
  // Bare "out of range" over-matched benign BGM-renderer logs (e.g. a "sample rate
  // out of range, resampling" notice), mislabelling a healthy track as failed and
  // silently dropping the music. Anchor to the actual crash strings instead:
  // Python "(list) index out of range" and torch "index … out of bounds".
  const idx = lines.findIndex((line) =>
    /(Traceback|IndexError|RuntimeError|Exception|Killed|No space left|Cannot allocate|index out of range|out of bounds)/i.test(
      line,
    ),
  );
  if (idx < 0) return "";
  return lines.slice(idx).join("\n").trim();
}

function writeStatus(status) {
  const payload = {
    generated_at: new Date().toISOString(),
    ...status,
  };
  writeFileSync(outPath, JSON.stringify(payload, null, 2) + "\n");
  return payload;
}

if (!existsSync(audioMetaPath)) die(`audio_meta.json missing at ${audioMetaPath}`);

const audioMeta = JSON.parse(readFileSync(audioMetaPath, "utf8"));
const bgmPath = audioMeta.bgm?.path || "";
const bgmAbsPath = bgmPath ? join(hyperframesDir, bgmPath) : "";
const logPath = audioMeta.bgm_log || "";
const pid = audioMeta.bgm_pid || null;

const base = {
  enabled: Boolean(audioMeta.bgm_pending && bgmPath),
  provider: audioMeta.bgm_provider || null,
  mode: audioMeta.bgm_mode || null,
  path: bgmPath || null,
  log: logPath || null,
  pid,
  target_duration_s: audioMeta.bgm_target_duration_s || null,
  seed_duration_s: audioMeta.bgm_seed_duration_s || null,
  loop_count: audioMeta.bgm_loop_count || null,
  timeout_ms: timeoutMs,
};

if (!base.enabled) {
  const status = writeStatus({
    ...base,
    status: "disabled",
    ready: false,
    waited_ms: 0,
    message: "BGM not requested or disabled in audio_meta.json.",
  });
  console.log(`✓ bgm: ${status.status} (${status.message})`);
  process.exit(0);
}

const started = Date.now();
let lastFailure = "";
let lastTail = "";

while (Date.now() - started <= timeoutMs) {
  if (existsSync(bgmAbsPath)) {
    const size = statSync(bgmAbsPath).size;
    writeStatus({
      ...base,
      status: "ready",
      ready: true,
      waited_ms: Date.now() - started,
      size_bytes: size,
      message: `BGM ready at ${bgmPath}.`,
    });
    console.log(`✓ bgm: ready (${bgmPath}, ${size}B)`);
    process.exit(0);
  }

  lastTail = readTail(logPath);
  lastFailure = detectFailure(lastTail);
  const alive = isProcessAlive(pid);
  if (lastFailure || (!alive && logPath && existsSync(logPath))) {
    const message = lastFailure
      ? `BGM renderer failed; see ${logPath}.`
      : `BGM renderer exited without writing ${bgmPath}; see ${logPath}.`;
    const status = writeStatus({
      ...base,
      status: "failed",
      ready: false,
      waited_ms: Date.now() - started,
      process_alive: alive,
      message,
      error_tail: lastFailure || lastTail.slice(-2000),
    });
    console.log(`! bgm: failed (${status.message})`);
    process.exit(0);
  }

  if (timeoutMs === 0) break;
  await sleep(Math.min(intervalMs, Math.max(0, timeoutMs - (Date.now() - started))));
}

const status = writeStatus({
  ...base,
  status: "timeout",
  ready: false,
  waited_ms: Date.now() - started,
  process_alive: isProcessAlive(pid),
  message: `Timed out waiting for ${bgmPath}; assemble-index will skip BGM if still absent.`,
  log_tail: lastTail.slice(-2000),
});
console.log(`! bgm: timeout after ${status.waited_ms}ms (${bgmPath})`);
