// render_slop_clip — turn 30 seconds of a ParVagues recording into a vertical
// reel, driven by Slopmotion (hydra-live-hexa) reacting to the actual audio.
//
// WHY THIS LIVES IN THE TIDAL REPO
// It drives hydra-live-hexa but does not belong to it: hexa is Kevin's project and
// its `main` is clean. So the script is ours, and it borrows hexa's node_modules
// via createRequire rather than being committed into someone else's tree.
//
// USAGE
//   # 1. start the dev server (node 22):
//   cd ~/Work/Sound/hydra-live-hexa && . ~/.nvm/nvm.sh && nvm use 22 && npm run dev
//   # 2. render:
//   node visuals/slop/render_slop_clip.mjs --idea wap
//   node visuals/slop/render_slop_clip.mjs --idea desire --cut 90
//   node visuals/slop/render_slop_clip.mjs --audio mix/take94/take94_trimmed_v2.wav \
//        --start 300 --dur 30 --playset liquid-metal --name take94_probe
//
// THE ONE NON-OBVIOUS DECISION: capture is REALTIME, via CDP screencast, not
// per-frame screenshots. Hydra's animation clock is wall time, so frame-stepping
// plays motion back roughly 3x fast and drifts away from the audio it is supposed
// to be reacting to. Screencast frames arrive timestamped; ffmpeg turns that
// variable-rate stream into constant-rate video. A dropped frame becomes a slightly
// longer one, which is invisible; a wrong clock is not.

import { createRequire } from "node:module";
import { execFileSync, spawnSync } from "node:child_process";
import {
  mkdirSync, writeFileSync, rmSync, readFileSync, existsSync, readdirSync,
} from "node:fs";
import { resolve, dirname, basename } from "node:path";
import { fileURLToPath } from "node:url";

const __dirname = dirname(fileURLToPath(import.meta.url));
const TIDAL = resolve(__dirname, "../..");
const HEXA = process.env.HEXA_DIR ?? "/home/pln/Work/Sound/hydra-live-hexa";
const BASE = process.env.SLOP_BASE_URL ?? "http://localhost:5173";
const OUT_DIR = resolve(__dirname, "out");
// Served by the dev server, so the page can fetch the slice over HTTP. Removed in
// a finally block: leaving a multi-MB wav inside someone else's public/ is rude.
const PUB = resolve(HEXA, "public/__slop");

const require = createRequire(resolve(HEXA, "package.json"));
const { chromium } = require("playwright");

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// Newest full-chromium build in the playwright cache, or undefined to let
// playwright pick (which is right on a machine whose cache matches its version).
function chromeBinary() {
  if (process.env.SLOP_CHROME) return process.env.SLOP_CHROME;
  const root = resolve(process.env.HOME ?? "/root", ".cache/ms-playwright");
  if (!existsSync(root)) return undefined;
  const builds = readdirSync(root)
    .filter((d) => /^chromium-\d+$/.test(d))
    .sort((a, b) => Number(b.split("-")[1]) - Number(a.split("-")[1]));
  for (const b of builds) {
    for (const sub of ["chrome-linux64/chrome", "chrome-linux/chrome"]) {
      const p = resolve(root, b, sub);
      if (existsSync(p)) return p;
    }
  }
  return undefined;
}

// ------------------------------------------------------------------ arguments

function args() {
  const a = { cut: "30", fps: "30" };
  const v = process.argv.slice(2);
  for (let i = 0; i < v.length; i++) {
    if (!v[i].startsWith("--")) continue;
    const k = v[i].slice(2);
    a[k] = v[i + 1]?.startsWith("--") || v[i + 1] === undefined ? "true" : v[++i];
  }
  return a;
}

const A = args();

function loadIdea(stem) {
  const p = resolve(__dirname, "clip_ideas.json");
  const doc = JSON.parse(readFileSync(p, "utf8"));
  const hit = doc.ideas.find(
    (i) => i.tidal_stem === stem || String(i.setlist_pos) === stem,
  );
  if (!hit) {
    const have = doc.ideas.map((i) => i.tidal_stem).join(", ");
    throw new Error(`no clip idea for "${stem}". Have: ${have}`);
  }
  return hit;
}

// Resolve what to render into one flat job, so the two entry paths (a planned
// idea vs an ad-hoc window of any file) cannot diverge downstream.
function plan() {
  if (A.audio) {
    const audio = resolve(TIDAL, A.audio);
    if (!existsSync(audio)) throw new Error(`no such audio: ${audio}`);
    return {
      name: A.name ?? basename(audio).replace(/\.\w+$/, ""),
      audio,
      start: Number(A.start ?? 0),
      dur: Number(A.dur ?? 30),
      playsets: [A.playset ?? "liquid-metal"],
      fx: A.fx ? JSON.parse(A.fx) : DEFAULT_FX,
      triggerHeavy: A.triggerHeavy === "true",
      source: "ad-hoc window",
    };
  }
  if (!A.idea) {
    throw new Error("need --idea <stem|pos> or --audio <file> --start --dur");
  }
  const i = loadIdea(A.idea);
  const cut = i.promo_cuts[`${A.cut}s`] ?? i.promo_cuts["30s"];
  if (!cut) throw new Error(`idea ${A.idea} has no ${A.cut}s cut`);
  return {
    name: `${i.tidal_stem}_${A.cut}s`,
    audio: A.master ? resolve(TIDAL, A.master) : i.master_audio,
    start: cut.master_start,
    dur: cut.duration,
    playsets: A.playset ? [A.playset] : i.visual.playsets,
    fx: i.visual.fx,
    triggerHeavy: i.visual.triggerHeavy,
    source: `${i.opal_title} — ${cut.anchor} anchor, ${i.measured_bpm} BPM`,
  };
}

// A neutral, motion-forward set for ad-hoc probes where no plan exists.
const DEFAULT_FX = {
  glitch: { base: 0.3 }, rgbDelay: { base: 0.25 },
  liquix: { base: 0.5 }, slowmo: { base: 0.4 },
};

// Pick a backdrop clip from the playset, deterministically. Math.random would make
// two renders of the same cut incomparable, which is the opposite of what a
// review pipeline needs.
function pickVideo(playset, seed) {
  const lib = JSON.parse(
    readFileSync(resolve(HEXA, "src/data/videoLibrary.json"), "utf8"),
  );
  const row = lib.find((e) => e.folder === playset);
  if (!row?.videos?.length) {
    throw new Error(`playset "${playset}" not in videoLibrary.json`);
  }
  let h = 0;
  for (const c of seed) h = (h * 31 + c.charCodeAt(0)) >>> 0;
  return row.videos[h % row.videos.length].path;
}

// ---------------------------------------------------------------------- main

const job = plan();
mkdirSync(OUT_DIR, { recursive: true });
mkdirSync(PUB, { recursive: true });

const slice = resolve(PUB, "clip.wav");
const framesDir = resolve(PUB, "frames");
const outMp4 = resolve(OUT_DIR, `${job.name}.mp4`);

console.log(`slop: ${job.name}`);
console.log(`  source   ${job.source}`);
console.log(`  audio    ${basename(job.audio)} @ ${job.start.toFixed(1)}s +${job.dur.toFixed(1)}s`);

let browser;
try {
  // --- 1. slice the audio -------------------------------------------------
  execFileSync("ffmpeg", [
    "-hide_banner", "-loglevel", "error", "-y",
    "-ss", String(job.start), "-t", String(job.dur), "-i", job.audio,
    "-ac", "2", "-ar", "48000", slice,
  ]);

  const video = pickVideo(job.playsets[0], job.name);
  console.log(`  playset  ${job.playsets[0]} → ${basename(video)}`);
  console.log(`  fx       ${Object.keys(job.fx).join(", ")}`);

  // --- 2. drive the page --------------------------------------------------
  browser = await chromium.launch({
    headless: true,
    // Playwright 1.60 wants chromium build 1223; the cache here has 1217, which
    // runs these pages fine. Point at what exists rather than pulling ~150 MB
    // into PLN's shared browser cache behind his back — and prefer the FULL
    // chromium over chrome-headless-shell, because the shell build ships without
    // the GPU/ANGLE stack this needs for WebGL.
    executablePath: chromeBinary(),
    args: [
      // GL backend is measurable, not a guess: swiftshader renders these scenes at
      // ~7 fps at 1080x1920, which is not a clip. `--gl hw` asks for the real
      // integrated GPU via EGL. Falls back by simply measuring worse.
      ...(A.gl === "hw"
        ? ["--use-gl=egl", "--enable-gpu-rasterization"]
        : ["--use-gl=angle", "--use-angle=swiftshader"]),
      "--ignore-gpu-blocklist",
      "--autoplay-policy=no-user-gesture-required",
      "--disable-features=IsolateOrigins,site-per-process",
    ],
  });
  // 540x960 at dpr 2 = 1080x1920. Set on the VIEWPORT rather than upscaling
  // later, so Hydra composes for the vertical frame instead of being cropped
  // into it — a 16:9 scene centre-cropped to 9:16 loses most of the motion.
  const ctx = await browser.newContext({
    viewport: { width: 540, height: 960 },
    deviceScaleFactor: Number(A.dpr ?? 2),
  });
  const page = await ctx.newPage();
  page.on("console", (m) => {
    if (/error/i.test(m.type())) console.log("  [page]", m.text().slice(0, 160));
  });

  await page.goto(`${BASE}/`, { waitUntil: "domcontentloaded", timeout: 60000 });
  await page.waitForSelector("#hydra-canvas", { timeout: 30000 });
  await page.waitForFunction(
    () => typeof window.__hydraApplyScene === "function", { timeout: 30000 });
  await page.waitForFunction(
    () => typeof window.__hydraSetPreviewVideo === "function", { timeout: 30000 });
  await sleep(1500);

  await page.evaluate((p) => window.__hydraSetPreviewVideo?.(p), video);
  await page.evaluate(async (p) => {
    const tail = p.split("/").pop() ?? p;
    for (let i = 0; i < 100; i++) {
      const el = window.s0?.src;
      if (el instanceof HTMLVideoElement && el.readyState >= 2 &&
          (el.currentSrc.includes(tail) || el.src.includes(tail))) return;
      await new Promise((r) => setTimeout(r, 100));
    }
  }, video);
  await sleep(1200);

  // FX rows, from the plan's measured tilt/density.
  await page.evaluate(({ fx, heavy }) => {
    const s = window.hydraSettings;
    if (!s?.fx) return;
    const CORE = ["videoSpeed", "autoMix", "transition", "colorAdjust"];
    for (const k of Object.keys(s.fx)) {
      if (CORE.includes(k)) continue;
      if (s.fx[k]) { s.fx[k].enabled = false; s.fx[k].isTrigger = false; }
    }
    for (const [k, cfg] of Object.entries(fx)) {
      const row = s.fx[k];
      if (!row) continue;
      row.enabled = true;
      row.base = cfg.base;
      if (cfg.params) row.params = { ...(row.params || {}), ...cfg.params };
      if (heavy) { row.isTrigger = true; row.syncBand = "kick"; }
    }
  }, { fx: job.fx, heavy: job.triggerHeavy });
  await page.evaluate(() => window.__hydraApplyScene?.());
  await page.evaluate((on) => window.__hydraSetTriggerGate?.(on), true);

  // Clean full-bleed canvas.
  const live = page.locator('button[title^="Minimal performance shell"]').first();
  if (await live.count()) { await live.click().catch(() => {}); await sleep(1500); }
  await page.locator("body").click({ position: { x: 5, y: 5 } }).catch(() => {});
  await page.keyboard.press("h");
  await sleep(500);
  await page.addStyleTag({
    content: `button[aria-label*="interface" i], button[title*="interface" i],
              button[title*="Press H" i] { display: none !important; }`,
  }).catch(() => {});

  // --- 3. real audio -> real bands ---------------------------------------
  // The whole point: the visuals react to what PLN actually played, not to the
  // synthetic pump the FX-preview script uses. An <audio> element through an
  // AnalyserNode, with the band split done here rather than reusing the app's
  // audio-source UI — that UI is built for a mic/tab and fights automation.
  await page.evaluate(async () => {
    const a = new Audio("/__slop/clip.wav");
    a.crossOrigin = "anonymous";
    a.preload = "auto";
    await new Promise((r) => {
      a.addEventListener("canplaythrough", r, { once: true });
      a.load();
    });
    const ac = new AudioContext();
    const src = ac.createMediaElementSource(a);
    const an = ac.createAnalyser();
    an.fftSize = 2048;
    an.smoothingTimeConstant = 0.6;
    src.connect(an);
    an.connect(ac.destination);
    const bins = new Uint8Array(an.frequencyBinCount);
    const hz = ac.sampleRate / an.fftSize;
    const band = (lo, hi) => {
      const a0 = Math.max(0, Math.floor(lo / hz));
      const b0 = Math.min(bins.length - 1, Math.ceil(hi / hz));
      let s = 0;
      for (let i = a0; i <= b0; i++) s += bins[i];
      return s / ((b0 - a0 + 1) * 255);
    };
    // Kick = a fast rise in the sub band above its own slow average. An absolute
    // threshold would have to be retuned per track; a relative one rides the mix.
    let floor = 0, prevLow = 0;
    const loop = () => {
      an.getByteFrequencyData(bins);
      const low = band(20, 150), mid = band(150, 2000), high = band(2000, 12000);
      floor = floor * 0.98 + low * 0.02;
      const kick = Math.max(0, Math.min(1, (low - floor) * 4 + (low - prevLow) * 6));
      prevLow = low;
      const master = (low + mid + high) / 3;
      window.customBands = {
        kick, beat: kick, snare: high, bass: low, vocals: mid,
        low, mid, high, rhythm: kick, specFast: high, specSlow: low, master,
      };
      window.__slopFrames = (window.__slopFrames || 0) + 1;
      requestAnimationFrame(loop);
    };
    requestAnimationFrame(loop);
    window.__slopAudio = a;
    window.__slopCtx = ac;
  });

  // --- 4. realtime screencast --------------------------------------------
  mkdirSync(framesDir, { recursive: true });
  const cdp = await ctx.newCDPSession(page);
  const frames = [];
  cdp.on("Page.screencastFrame", async (f) => {
    frames.push({ t: f.metadata.timestamp, data: f.data });
    try { await cdp.send("Page.screencastFrameAck", { sessionId: f.sessionId }); }
    catch { /* the session can close mid-flight; a lost ack is not fatal */ }
  });

  await page.evaluate(() => { window.__slopCtx?.resume(); window.__slopAudio?.play(); });
  await cdp.send("Page.startScreencast", {
    format: "jpeg", quality: 90, everyNthFrame: 1,
    maxWidth: 1080, maxHeight: 1920,
  });
  const wall = Date.now();
  await sleep(job.dur * 1000 + 400);
  await cdp.send("Page.stopScreencast").catch(() => {});
  const elapsed = (Date.now() - wall) / 1000;

  const rendered = await page.evaluate(() => window.__slopFrames ?? 0);
  console.log(`  captured ${frames.length} frames over ${elapsed.toFixed(1)}s `
    + `(${(frames.length / elapsed).toFixed(1)} fps; page rendered ${rendered})`);
  if (frames.length < job.dur * 5) {
    throw new Error(
      `only ${frames.length} frames for ${job.dur.toFixed(0)}s — under 5 fps is not a `
      + `clip. Is the dev server serving WebGL (swiftshader) correctly?`);
  }

  // --- 5. vfr -> cfr -> mux ----------------------------------------------
  // Durations come from the frames' own timestamps, so the video clock matches
  // the audio clock even though the capture rate wobbles.
  const t0 = frames[0].t;
  let concat = "ffconcat version 1.0\n";
  frames.forEach((f, i) => {
    const name = `f${String(i).padStart(5, "0")}.jpg`;
    writeFileSync(resolve(framesDir, name), Buffer.from(f.data, "base64"));
    const next = i + 1 < frames.length ? frames[i + 1].t : f.t + 1 / Number(A.fps);
    concat += `file '${name}'\nduration ${Math.max(0.001, next - f.t).toFixed(6)}\n`;
  });
  concat += `file 'f${String(frames.length - 1).padStart(5, "0")}.jpg'\n`;
  writeFileSync(resolve(framesDir, "list.ffconcat"), concat);
  void t0;

  const r = spawnSync("ffmpeg", [
    "-hide_banner", "-loglevel", "error", "-y",
    "-f", "concat", "-safe", "0", "-i", resolve(framesDir, "list.ffconcat"),
    "-i", slice,
    "-vf", `fps=${A.fps},scale=1080:1920:force_original_aspect_ratio=increase,`
         + `crop=1080:1920,format=yuv420p`,
    "-c:v", "libx264", "-preset", "medium", "-crf", "20",
    "-c:a", "aac", "-b:a", "192k",
    "-movflags", "+faststart", "-shortest", outMp4,
  ], { encoding: "utf8" });
  if (r.status !== 0) throw new Error(`ffmpeg mux failed: ${r.stderr?.slice(0, 400)}`);

  const size = execFileSync("stat", ["-c", "%s", outMp4], { encoding: "utf8" }).trim();
  console.log(`\n  -> ${outMp4}  (${(Number(size) / 1e6).toFixed(1)} MB)`);
  console.log(`  judge it, then log the reaction in visuals/slop/FEEDBACK.md (#29)`);
} finally {
  await browser?.close().catch(() => {});
  // Never leave build droppings inside hexa's public/.
  rmSync(PUB, { recursive: true, force: true });
}
