/*
 * Build a /learn video from a script file.
 *
 *   node scripts/learn-film.mjs docs/learn-films/<slug>.json
 *
 * The five original platform videos were cut by an ad-hoc process that was
 * never committed, so the first time the UI moved nobody could re-shoot them.
 * This is that pipeline, written down. It reuses the two hard-won lessons from
 * `scripts/screenshots.mjs`: the appearance preference is stored SERVER-side
 * and adopted after hydration, so seeding localStorage is not enough and the
 * profile response has to be rewritten; and per-device layout state
 * (`pig.sidebarOpen`, `pig.piggyDockOpen`) has to be forced or the framing is
 * whatever a human last left behind.
 *
 * ORDER IS LOAD-BEARING. The narration is rendered FIRST and its measured
 * duration drives every shot length. A shot list timed by guess leaves the
 * narrator talking over a frozen frame, or gets cut mid-sentence.
 *
 * THE CAMERA. Landscape, and still by default. An earlier version drifted a
 * slow zoom across every shot, which reads as restless rather than as emphasis
 * — the whole frame moving all the time is the thing people mean when they call
 * a screen recording "annoying". This one holds still, and where a line points
 * at one thing it eases IN on that element, holds, and eases back OUT, the way
 * Cap zooms toward a click. The easing is a smoothstep either side of a hold,
 * so there is no visible start or stop.
 *
 * Typography is composed in the browser, not in ffmpeg: the product's face is
 * Manrope Variable and drawtext would fall back to DejaVu, which reads as a
 * different company. Playwright renders a transparent caption layer per shot
 * and ffmpeg only moves pixels.
 *
 * Requires: a running API + web (PIG_WEB_URL), Chatterbox reachable, ffmpeg.
 */
import { chromium } from 'playwright';
import { execFile } from 'node:child_process';
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { promisify } from 'node:util';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const run = promisify(execFile);
const HERE = dirname(fileURLToPath(import.meta.url));
const REPO = resolve(HERE, '..');

const WEB = process.env.PIG_WEB_URL ?? 'http://localhost:5179';
const TTS = process.env.PIG_TTS_URL ?? 'http://100.127.247.67:8095';
const VOICE = process.env.PIG_TTS_VOICE ?? 'karti';
/* 1.20x is the rate docs/learn-scripts.md is written to. */
const TEMPO = Number(process.env.PIG_TTS_TEMPO ?? 1.2);
const MUSIC = process.env.PIG_FILM_MUSIC ?? join(REPO, 'apps/web/public/audio/pig-ambient.mp3');

const W = 1920;
const H = 1080;
const SCALE = 2;
const FPS = 30;

/* The zoom envelope, as fractions of the shot: hold, ease in, hold, ease out. */
const ZOOM_IN_START = 0.16;
const ZOOM_IN_END = 0.42;
const ZOOM_OUT_START = 0.76;
const ZOOM_OUT_END = 0.98;

const THEME = {
  dark: { bg: '#0a0a0b', fg: '#fafafa', pill: 'rgba(18,18,20,.92)', rule: 'rgba(255,255,255,.14)', accent: '#22c55e' },
  light: { bg: '#ffffff', fg: '#0a0a0b', pill: 'rgba(255,255,255,.94)', rule: 'rgba(0,0,0,.12)', accent: '#16a34a' },
};

async function ffprobeDuration(path) {
  const { stdout } = await run('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', path]);
  return Number(stdout.trim());
}

async function narrate(text, out) {
  const response = await fetch(`${TTS}/generate`, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ text, voice: VOICE }),
  });
  if (!response.ok) throw new Error(`Chatterbox ${response.status}: ${await response.text()}`);
  const wav = Buffer.from(await response.arrayBuffer());
  /* Raw WAV bytes, not JSON. A JSON.parse here reads as a TTS outage. */
  if (wav.subarray(0, 4).toString() !== 'RIFF') {
    throw new Error(`Chatterbox did not return WAV: ${wav.subarray(0, 120).toString()}`);
  }
  await writeFile(out, wav);
  return ffprobeDuration(out);
}

/** Shot lengths are proportional to the words spoken over them. */
function shotFrames(shots, total) {
  const words = shots.map((shot) => shot.line.trim().split(/\s+/).length);
  const sum = words.reduce((a, b) => a + b, 0);
  const frames = words.map((n) => Math.max(FPS, Math.round(((n / sum) * total) * FPS)));
  const target = Math.round(total * FPS);
  frames[frames.length - 1] += target - frames.reduce((a, b) => a + b, 0);
  return frames;
}

/**
 * A smoothstep ramp between two fractions of the shot, as an ffmpeg expression.
 *
 * Written out rather than accumulated with zoompan's own `zoom` variable, which
 * refers to the PREVIOUS frame and drifts: expressing the curve purely as a
 * function of `on` means the zoom is the same on every re-render and lands
 * exactly on 1.0 at both ends.
 */
function ramp(from, to, n) {
  const u = `clip((on/${n - 1}-${from})/${(to - from).toFixed(4)},0,1)`;
  return `(pow(${u},2)*(3-2*${u}))`;
}

function zoomExpression(scale, n) {
  const rise = ramp(ZOOM_IN_START, ZOOM_IN_END, n);
  const fall = ramp(ZOOM_OUT_START, ZOOM_OUT_END, n);
  return `1+${(scale - 1).toFixed(4)}*(${rise}-${fall})`;
}

const CAPTION_HTML = (o) => `<!doctype html><html><head><meta charset="utf-8"><style>
@font-face{font-family:'Manrope';src:url('file://${o.font}') format('woff2');font-weight:200 800;font-display:block}
*{margin:0;padding:0;box-sizing:border-box}
html,body{width:${W}px;height:${H}px;background:transparent}
body{font-family:'Manrope',system-ui,sans-serif;-webkit-font-smoothing:antialiased}
.pill{position:absolute;left:56px;bottom:66px;display:flex;align-items:center;gap:16px;
  padding:16px 26px;border-radius:999px;background:${o.pill};border:1px solid ${o.rule};
  backdrop-filter:blur(12px);box-shadow:0 10px 40px rgba(0,0,0,.28)}
.dot{width:11px;height:11px;border-radius:50%;background:${o.accent};flex:none}
.text{font-size:29px;font-weight:600;letter-spacing:-.01em;color:${o.fg};white-space:nowrap}
.track{position:absolute;left:0;right:0;bottom:0;height:5px;background:${o.rule}}
.fill{height:5px;background:${o.accent};width:${(o.progress * 100).toFixed(2)}%}
</style></head><body>
<div class="pill"><span class="dot"></span><span class="text">${o.caption}</span></div>
<div class="track"><div class="fill"></div></div>
</body></html>`;

async function main() {
  const scriptPath = process.argv[2];
  if (!scriptPath) throw new Error('usage: learn-film.mjs <script.json>');
  const film = JSON.parse(await readFile(scriptPath, 'utf8'));
  const work = join(REPO, '.film', film.slug);
  await rm(work, { recursive: true, force: true });
  await mkdir(work, { recursive: true });

  const font = join(
    REPO,
    'node_modules/.pnpm/@fontsource-variable+manrope@5.3.0/node_modules/@fontsource-variable/manrope/files/manrope-latin-wght-normal.woff2',
  );

  // 1 -------------------------------------------------------- the narration
  const narration = film.shots.map((s) => s.line.trim()).join(' ');
  const words = narration.split(/\s+/).length;
  const rawVoice = join(work, 'voice-raw.wav');
  const spoken = await narrate(narration, rawVoice);
  const voice = join(work, 'voice.wav');
  await run('ffmpeg', ['-v', 'error', '-y', '-i', rawVoice, '-filter:a', `atempo=${TEMPO}`, voice]);
  const duration = await ffprobeDuration(voice);
  console.log(`  narration ${words} words -> ${spoken.toFixed(2)}s at 1.0x -> ${duration.toFixed(2)}s at ${TEMPO}x`);
  const frames = shotFrames(film.shots, duration);

  // 2 ----------------------------------------------------------- the routes
  const engagements = await fetch(`${WEB}/api/motion/engagements`).then((r) => r.json());
  const templates = await fetch(`${WEB}/api/motion/templates`).then((r) => r.json());
  const byStage = (stage) => engagements.engagements.find((e) => e.stage === stage);
  const alias = {
    /* By slug, not by kind: two shipped templates are `playbook` kind, and
     * which one `find` returned depended on list order. */
    PLAYBOOK: templates.templates.find((t) => t.slug === 'strategic-deployment-playbook')?.id,
    SCORECARD: templates.templates.find((t) => t.slug === 'trainability-qualification')?.id,
    HALCYON: byStage('poc')?.id,
    NORTHWIND: byStage('expansion')?.id,
  };
  for (const [name, id] of Object.entries(alias)) {
    if (!id) throw new Error(`route alias ${name} resolved to nothing — is the demo book seeded?`);
  }

  const browser = await chromium.launch({ channel: 'chrome' });
  const contexts = {};
  for (const theme of ['dark', 'light']) {
    const context = await browser.newContext({
      /*
       * A real 1920 desktop viewport, captured at 2x and delivered at 1920.
       * Setting the viewport to W/SCALE was wrong in a way that looked right:
       * it produced a 1920px image, but the PAGE believed it was 960 wide, so
       * the sidebar collapsed and the app filmed its compact layout. The
       * viewport is what the product responds to; the scale factor is only how
       * much detail there is to zoom into.
       */
      viewport: { width: W, height: H },
      deviceScaleFactor: SCALE,
      colorScheme: theme,
    });
    await context.addInitScript(`
      localStorage.setItem('pig.themeMode', '${theme}');
      localStorage.setItem('pig.sidebarOpen', 'true');
      localStorage.setItem('pig.piggyDockOpen', 'false');
      localStorage.setItem('pig.audio.enabled', 'false');
    `);
    await context.route('**/api/me/profile', async (route) => {
      const response = await route.fetch();
      const body = await response.json().catch(() => ({}));
      await route.fulfill({ json: { ...body, themeMode: theme } });
    });
    contexts[theme] = context;
  }
  const captionPage = await browser.newPage({ viewport: { width: W, height: H } });

  const segments = [];
  for (const [index, shot] of film.shots.entries()) {
    let route = shot.route;
    for (const [name, id] of Object.entries(alias)) route = route.replace(name, id);

    const page = await contexts[shot.theme].newPage();
    await page.goto(`${WEB}${route}`, { waitUntil: 'networkidle' });
    /* Lazy route chunks sit behind a Suspense boundary; a screenshot taken
     * before the fallback resolves photographs the spinner. */
    await page.waitForTimeout(1400);
    const painted = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
    if (painted !== shot.theme) throw new Error(`shot ${index} painted ${painted}, wanted ${shot.theme}`);

    if (shot.scrollTo) {
      const ok = await page.evaluate((text) => {
        const node = [...document.querySelectorAll('h1,h2,h3')].find((el) => el.textContent?.trim() === text);
        if (!node) return false;
        (node.closest('section,[class*="card"]') ?? node).scrollIntoView({ block: 'center' });
        return true;
      }, shot.scrollTo);
      if (!ok) throw new Error(`shot ${index}: no heading "${shot.scrollTo}" on ${route}`);
      await page.waitForTimeout(600);
    }

    /* The zoom target, in captured pixels. Smallest element containing the
     * text, so naming "82.8%" lands on the figure rather than on the card. */
    let focus = null;
    if (shot.zoom) {
      /*
       * Target selection is "smallest element already in frame", not "last
       * match in document order", which is what this did first and got wrong in
       * a way that only shows up on a long page. The Halcyon engagement prints
       * the same phrases twice — once in the score note and again inside the
       * artefact bodies further down — so "kappa 0.54" resolved to a paragraph
       * two and a half thousand pixels below the fold, and no amount of
       * scrolling brought it into view because an ancestor clips it.
       *
       * Preferring what is already on screen keeps the zoom in the frame the
       * `scrollTo` chose; preferring the smallest match makes naming a figure
       * land on the figure rather than on the card around it. Only if nothing
       * matches in frame does it scroll, and then it re-measures.
       */
      /* Inlined in both evaluates rather than injected as a string: the page
       * is the only place this can run, and `new Function` is one CSP away from
       * failing in an environment that is not the dev server. */
      const locate = ({ text, scale, move }) => {
        const matches = [...document.querySelectorAll('body *')].filter(
          (el) => el.textContent?.includes(text) && el.getBoundingClientRect().width > 0,
        );
        if (!matches.length) return { found: false };
        const area = (el) => {
          const r = el.getBoundingClientRect();
          return r.width * r.height;
        };
        const inFrame = matches.filter((el) => {
          const r = el.getBoundingClientRect();
          return r.bottom > 0 && r.top < window.innerHeight;
        });
        const el = (inFrame.length ? inFrame : matches).sort((a, b) => area(a) - area(b))[0];
        const r = el.getBoundingClientRect();
        if (r.bottom < 0 || r.top > window.innerHeight) {
          if (move) {
            el.scrollIntoView({ block: 'center' });
            return { found: true, offScreen: true };
          }
          return { found: true, offScreen: true };
        }
        return {
          found: true,
          offScreen: false,
          cx: (r.left + r.width / 2) * scale,
          cy: (r.top + r.height / 2) * scale,
        };
      };

      const first = await page.evaluate(locate, { text: shot.zoom.on, scale: SCALE, move: true });
      if (!first.found) throw new Error(`shot ${index}: zoom target "${shot.zoom.on}" is not on ${route}`);
      if (first.offScreen) await page.waitForTimeout(500);
      focus = first.offScreen
        ? await page.evaluate(locate, { text: shot.zoom.on, scale: SCALE, move: false })
        : first;
      if (focus.offScreen) focus = null;
      if (!focus) {
        throw new Error(`shot ${index}: zoom target "${shot.zoom.on}" would not come into view on ${route}`);
      }
    }

    const framePng = join(work, `frame-${index}.png`);
    await page.screenshot({ path: framePng });
    await page.close();

    const palette = THEME[shot.theme];
    await captionPage.setContent(
      CAPTION_HTML({ ...palette, font, caption: shot.caption, progress: (index + 1) / film.shots.length }),
      { waitUntil: 'load' },
    );
    await captionPage.waitForTimeout(250);
    const captionPng = join(work, `caption-${index}.png`);
    await captionPage.screenshot({ path: captionPng, omitBackground: true });

    // 3 ---------------------------------------------- one shot -> one clip
    const n = frames[index];
    const camera = focus
      ? `zoompan=z='${zoomExpression(shot.zoom.scale ?? 1.6, n)}':` +
        `x='max(0,min(iw-iw/zoom,${focus.cx.toFixed(1)}-(iw/zoom)/2))':` +
        `y='max(0,min(ih-ih/zoom,${focus.cy.toFixed(1)}-(ih/zoom)/2))':` +
        `d=${n}:s=${W}x${H}:fps=${FPS}`
      : `scale=${W}:${H}`;

    const segment = join(work, `seg-${index}.mp4`);
    await run('ffmpeg', [
      '-v', 'error', '-y',
      '-loop', '1', '-framerate', String(FPS), '-t', String(n / FPS), '-i', framePng,
      '-i', captionPng,
      '-filter_complex', `[0:v]${camera},format=yuv420p[base];[base][1:v]overlay=0:0:format=auto,format=yuv420p[v]`,
      '-map', '[v]', '-frames:v', String(n),
      /* CRF 23 on a still frame with an occasional slow zoom. The originals
       * ship at about 420 kbps for 1440x810; this lands in the same class. */
      '-c:v', 'libx264', '-preset', 'slow', '-crf', '23', segment,
    ]);
    segments.push(segment);
    console.log(
      `  ${String(index + 1).padStart(2)}/${film.shots.length} ${shot.theme.padEnd(5)} ` +
        `${(n / FPS).toFixed(2)}s ${focus ? `zoom x${shot.zoom.scale ?? 1.6} on "${shot.zoom.on}"` : 'still'}`,
    );
  }
  await browser.close();

  // 4 ------------------------------------------------ cut, score, normalise
  const list = join(work, 'segments.txt');
  await writeFile(list, segments.map((s) => `file '${s}'`).join('\n'));
  const silent = join(work, 'silent.mp4');
  await run('ffmpeg', ['-v', 'error', '-y', '-f', 'concat', '-safe', '0', '-i', list, '-c', 'copy', silent]);

  const out = join(work, 'film.mp4');
  await run('ffmpeg', [
    '-v', 'error', '-y',
    '-i', silent, '-i', voice, '-stream_loop', '-1', '-i', MUSIC,
    '-filter_complex',
    `[2:a]volume=0.075,afade=t=in:st=0:d=1.2,afade=t=out:st=${(duration - 1.6).toFixed(2)}:d=1.6[bed];` +
      `[1:a]adelay=250|250,apad[vox];` +
      `[vox][bed]amix=inputs=2:duration=first:dropout_transition=0,` +
      `atrim=0:${duration.toFixed(3)},loudnorm=I=-16:TP=-1.5:LRA=11[a]`,
    '-map', '0:v', '-map', '[a]',
    '-c:v', 'copy', '-c:a', 'aac', '-b:a', '160k', '-movflags', '+faststart', '-shortest', out,
  ]);

  const finalDuration = await ffprobeDuration(out);
  const hash = createHash('sha256').update(await readFile(out)).digest('hex').slice(0, 8);
  const named = join(work, `${film.slug}.${hash}.mp4`);
  await run('cp', [out, named]);
  const poster = join(work, `${film.slug}.${hash}.jpg`);
  await run('ffmpeg', ['-v', 'error', '-y', '-ss', '1.2', '-i', named, '-frames:v', '1', '-q:v', '3', poster]);

  console.log(`\n  ${film.slug}: ${finalDuration.toFixed(2)}s, ${words} words, ${W}x${H}`);
  console.log(`  ${named}\n  ${poster}`);
}

await main();
