#!/usr/bin/env node
/**
 * generate.mjs — turn the cast definitions in `casts.mjs` into asciinema
 * `.cast` recordings under `public/casts/`, using autocast (ADR-website-001
 * §2; driver chosen by the 2026-06-10 spike — autocast drives the full-screen
 * TUI correctly, asciinema-automation does not).
 *
 * Usage:
 *   pnpm casts            # regenerate every cast
 *   pnpm casts quickstart # regenerate just one
 *
 * Requires `autocast` on PATH (cargo install autocast) and a built
 * `rdbms-playground` binary at ../target/debug (run `cargo build` at the repo
 * root first). The binary's dir is added to PATH for the autocast child so the
 * recording shows a clean `$ rdbms-playground` launch line.
 */
import { spawnSync } from 'node:child_process';
import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import os from 'node:os';
import { casts } from './casts.mjs';

const here = dirname(fileURLToPath(import.meta.url));
const websiteRoot = resolve(here, '..');
const repoRoot = resolve(websiteRoot, '..');
const binDir = resolve(repoRoot, 'target', 'debug');
const outDir = resolve(websiteRoot, 'public', 'casts');
const cargoBin = resolve(os.homedir(), '.cargo', 'bin');

/** YAML-escape a single character for a double-quoted scalar. */
function charKey(ch) {
  if (ch === '\\') return '"\\\\"';
  if (ch === '"') return '"\\""';
  return `"${ch}"`;
}

/** Named single keys → autocast control codes. */
const NAMED_KEYS = {
  Enter: '^M',
  Tab: '^I',
  // Ctrl-C quits the app (Action::Quit). Used to end a cast *invisibly* — no
  // `quit` command typed into the input — so the recording ends on the last
  // content frame rather than a dangling, payoff-less `quit`.
  CtrlC: '^C',
  // Ctrl-O cycles the ADR-0046 schema-sidebar navigation focus
  // (Input → Tables → Relationships → Input); the only way to reveal the
  // sidebar in a 90-column cast (it auto-hides at that width).
  CtrlO: '^O',
  // Esc leaves navigation mode directly (back to the input field).
  Esc: '^[',
  // Ctrl-G: in demo mode the app aliases this to F1 (the contextual-hint key,
  // ADR-0053) and badges it AS [F1] (ADR-0047 Amendment 1). autocast can't emit
  // F1 (an escape sequence), but it can send this single control byte — so a
  // cast can show the live-input hint exactly as a real F1 press would.
  CtrlG: '^G',
};

/** Build the autocast `keys:` list (one entry per line) for a cast's steps. */
function keysFor(steps) {
  const keys = [];
  for (const step of steps) {
    if (step.wait != null) {
      keys.push(`${step.wait}ms`);
      continue;
    }
    if (step.key != null) {
      const code = NAMED_KEYS[step.key];
      if (!code) throw new Error(`unknown key: ${step.key}`);
      // Quote the control code so YAML-special bytes (`^[` ESC, `^]`) stay
      // valid scalars; `^M`/`^I`/`^C` are unaffected by the quoting.
      keys.push(JSON.stringify(code));
      if (step.after != null) keys.push(`${step.after}ms`);
      continue;
    }
    if (step.type != null) {
      for (const ch of step.type) keys.push(charKey(ch));
      if (step.enter !== false) keys.push('^M'); // Enter = CR (the TUI submits on \r)
      if (step.after != null) keys.push(`${step.after}ms`);
      continue;
    }
    if (step.caption != null) {
      // Demo step caption (ADR-0047 D3): `Ctrl+]` opens a stealth capture
      // buffer, the characters accumulate invisibly (they don't touch the
      // input line), and a second `Ctrl+]` commits them to the floating
      // caption box. The caption persists until the next keystroke, so the
      // trailing wait holds it on screen before the cast ends. Keep caption
      // text plain ASCII — it is typed character-by-character like any input.
      keys.push(JSON.stringify('^]')); // open capture
      for (const ch of step.caption) keys.push(charKey(ch));
      keys.push(JSON.stringify('^]')); // commit the caption
      if (step.after != null) keys.push(`${step.after}ms`);
      continue;
    }
    throw new Error(`unrecognised step: ${JSON.stringify(step)}`);
  }
  return keys;
}

/**
 * Trim a recorded cast to just the in-app portion and (optionally) hold the
 * final frame. The app enters the alternate screen (`?1049h`) right after
 * launch and leaves it (`?1049l`) on quit, so keeping the events between those
 * drops the shell prompt, the `$ rdbms-playground` launch line, and the
 * return-to-shell frame — the cast starts with the app already running and
 * ends on the last in-app frame (no stray cursor-under-prompt frame).
 * `holdEnd` re-emits the final frame after a pause so a looping cast lingers
 * before restarting.
 */
function trimCast(outPath, { holdEnd = 1.5 } = {}) {
  const lines = readFileSync(outPath, 'utf8').split('\n').filter(Boolean);
  const header = JSON.parse(lines[0]);
  const events = lines.slice(1).map((l) => JSON.parse(l));
  const isOut = (e) => Array.isArray(e) && e[1] === 'o' && typeof e[2] === 'string';

  const start = events.findIndex((e) => isOut(e) && e[2].includes('?1049h'));
  let end = -1;
  for (let i = events.length - 1; i >= 0; i--) {
    if (isOut(events[i]) && events[i][2].includes('?1049l')) {
      end = i;
      break;
    }
  }
  const kept = events.slice(start < 0 ? 0 : start, end < 0 ? events.length : end);
  if (kept.length === 0) return; // nothing matched — leave the cast untouched
  const t0 = kept[0][0];
  const rebased = kept.map((e) => [Number((e[0] - t0).toFixed(6)), e[1], e[2]]);
  if (holdEnd > 0) {
    const lastT = rebased[rebased.length - 1][0];
    rebased.push([Number((lastT + holdEnd).toFixed(6)), 'o', '']); // hold final frame
  }
  delete header.duration; // let the player recompute from events
  writeFileSync(
    outPath,
    [JSON.stringify(header), ...rebased.map((e) => JSON.stringify(e))].join('\n') + '\n'
  );
}

function yamlFor(cast, dataDir) {
  const keys = keysFor(cast.steps)
    .map((k) => `      - ${k}`)
    .join('\n');
  // Build the launch command. `--demo` turns on the demonstration overlay
  // (ADR-0047): automatic keystroke badges (so otherwise-invisible keys like
  // `j` / Tab / Enter are shown) plus `Ctrl+]`-delimited step captions.
  // `--data-dir` isolates the recording from the user's real projects (and
  // keeps the load picker listing only this cast's projects).
  const cmd = ['rdbms-playground'];
  if (cast.demo !== false) cmd.push('--demo'); // on for all casts (opt out with demo:false)
  if (dataDir) cmd.push('--data-dir', dataDir);
  return [
    'settings:',
    `  width: ${cast.width ?? 90}`,
    `  height: ${cast.height ?? 26}`,
    `  title: ${JSON.stringify(cast.title ?? cast.name)}`,
    `  type_speed: ${cast.typeSpeed ?? '45ms'}`,
    '  timeout: 90s',
    '  prompt: "$ "',
    'instructions:',
    '  - !Interactive',
    `    command: ${cmd.join(' ')}`,
    '    keys:',
    keys,
    '',
  ].join('\n');
}

const only = process.argv[2];
const selected = only ? casts.filter((c) => c.name === only) : casts;
if (only && selected.length === 0) {
  console.error(`no cast named "${only}". Known: ${casts.map((c) => c.name).join(', ')}`);
  process.exit(1);
}

mkdirSync(outDir, { recursive: true });
const env = { ...process.env, PATH: `${binDir}:${cargoBin}:${process.env.PATH}` };

let failures = 0;
for (const cast of selected) {
  const yamlPath = resolve(os.tmpdir(), `autocast-${cast.name}.yaml`);
  const outPath = resolve(outDir, `${cast.name}.cast`);
  // Per-cast isolated data root (so the load picker lists only this cast's
  // projects and nothing touches the user's real data dir). Wiped fresh each
  // run for a deterministic picker order.
  let dataDir = null;
  if (cast.dataDir) {
    dataDir = resolve(os.tmpdir(), `rdbms-cast-data-${cast.name}`);
    rmSync(dataDir, { recursive: true, force: true });
    mkdirSync(dataDir, { recursive: true });
  }
  writeFileSync(yamlPath, yamlFor(cast, dataDir));
  console.log(`▶ recording ${cast.name} → public/casts/${cast.name}.cast`);
  const res = spawnSync('autocast', ['--overwrite', yamlPath, outPath], {
    env,
    stdio: 'inherit',
  });
  rmSync(yamlPath, { force: true });
  if (res.status !== 0) {
    console.error(`✗ ${cast.name} failed (autocast exit ${res.status})`);
    failures += 1;
  } else {
    // Trim the shell launch/quit so the cast starts with the app already
    // running (the default; opt out with `keepShell: true` for casts that
    // deliberately document the CLI launch).
    if (!cast.keepShell) trimCast(outPath, { holdEnd: cast.holdEnd ?? 1.5 });
    console.log(`✓ ${cast.name}`);
  }
}

if (failures) process.exit(1);
console.log(`Done — ${selected.length} cast(s).`);
