/*
 * Build a vertical /learn video from a script file.
 *
 *   node scripts/learn-film.mjs docs/learn-films/<slug>.json
 *
 * The five existing platform videos were cut by an ad-hoc pipeline that was
 * never committed, so the first time the UI changed nobody could re-shoot them.
 * This is that pipeline, written down. `scripts/screenshots.mjs` exists for the
 * same reason and this file deliberately reuses its two hard-won lessons: 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 — which is how
 * the previous set was found to be wrong.
 *
 * Typography is composed in the browser, not in ffmpeg: the product's typeface
 * is Manrope Variable and ffmpeg's drawtext would have to fall back to DejaVu,
 * which is visibly not the same product. So Playwright renders a transparent
 * chrome layer per shot and ffmpeg only moves pixels.
 *
 * Requires: a running API + web (see 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 the /learn scripts are written to; see docs/learn-scripts.md. */
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 FRAME_W = 1080;
const FRAME_H = 1920;
/* The app is captured at 1120 wide and lands at 1080, so it is very nearly 1:1
 * and the product's own type sizes stay legible on a phone. Any narrower and the
 * sidebar drops to the mobile tab bar (NAV_BREAKPOINT is 1024). */
const SHOT_W = 1120;
const SHOT_H = 1560;
const APP_W = FRAME_W;
const APP_H = Math.round((SHOT_H * FRAME_W) / SHOT_W);
const APP_Y = 250;
const FPS = 30;

/** Every anchor a script may name, and how to frame it. */
/*
 * `focus` is `full` (the whole viewport) or `column` (the content column only).
 *
 * A vertical video is watched on a phone, and the whole 1120px viewport scaled
 * into 1080 leaves the product's own 12px labels at about 11px on the finished
 * frame — technically visible, actually unreadable. So a detail shot drops the
 * sidebar and keeps the content column, which is about 1.4x closer.
 *
 * The crop is driven by the column's WIDTH rather than by the height of the
 * thing being talked about, which was the first attempt: a crop sized to the
 * target element is narrower than the column, so it sliced the cards either
 * side of centre and the frame read as broken rather than as close. Width
 * first, height derived from the aspect, vertical position centred on the
 * target — that way nothing is ever cut off horizontally.
 */
const ANCHORS = {
  hero: { route: '/motion', scroll: 'top', focus: 'full' },
  stats: { route: '/motion', scroll: 'top', focus: 'column' },
  'stage-rail': { route: '/motion', text: 'The motion, stage by stage', focus: 'column' },
  kinds: { route: '/motion', text: 'Library cover, by kind', focus: 'column' },
  promotions: { route: '/motion', text: 'What got easier', focus: 'column' },
  'open-engagements': { route: '/motion', text: 'Open engagements', focus: 'column' },
  'library-list': { route: '/motion/library', scroll: 'top', focus: 'full' },
  'template-head': { route: '/motion/library/PLAYBOOK', scroll: 'top', focus: 'column' },
  'template-use': { route: '/motion/library/PLAYBOOK', text: 'Use this template', focus: 'column' },
  'template-body': { route: '/motion/library/PLAYBOOK', text: 'The document', focus: 'column' },
  'eng-list': { route: '/motion/engagements', scroll: 'top', focus: 'full' },
  'eng-head': { route: '/motion/engagements/POC', scroll: 'top', focus: 'column' },
  'eng-stages': { route: '/motion/engagements/POC', text: 'Where the work sits', focus: 'column' },
  'eng-qualification': { route: '/motion/engagements/POC', text: 'Qualification', focus: 'column' },
  'eng-artefacts': { route: '/motion/engagements/POC', text: 'Artefacts', focus: 'column' },
};

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());
  /* The service answers with raw WAV bytes, not JSON. A JSON.parse here is the
   * kind of mistake that 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 shotDurations(shots, total) {
  const words = shots.map((shot) => shot.line.trim().split(/\s+/).length);
  const sum = words.reduce((a, b) => a + b, 0);
  const raw = words.map((n) => (n / sum) * total);
  /* Rounded to whole frames, with the remainder pushed onto the last shot so
   * the video and the narration end on the same frame rather than drifting. */
  const frames = raw.map((seconds) => Math.max(FPS, Math.round(seconds * FPS)));
  const target = Math.round(total * FPS);
  frames[frames.length - 1] += target - frames.reduce((a, b) => a + b, 0);
  return frames;
}

const CHROME_HTML = (opts) => `<!doctype html><html><head><meta charset="utf-8"><style>
@font-face{font-family:'Manrope';src:url('file://${opts.font}') format('woff2');font-weight:200 800;font-display:block}
*{margin:0;padding:0;box-sizing:border-box}
html,body{width:${FRAME_W}px;height:${FRAME_H}px;background:transparent}
body{font-family:'Manrope',system-ui,sans-serif;-webkit-font-smoothing:antialiased}
.band{position:absolute;left:0;right:0;background:${opts.bg}}
.top{top:0;height:${APP_Y}px;display:flex;flex-direction:column;justify-content:center;padding:0 64px;gap:14px}
.bottom{top:${APP_Y + APP_H}px;height:${FRAME_H - APP_Y - APP_H}px;display:flex;align-items:center;justify-content:space-between;padding:0 64px}
.eyebrow{font-size:26px;font-weight:700;letter-spacing:.22em;text-transform:uppercase;color:${opts.accent}}
.caption{font-size:58px;font-weight:700;letter-spacing:-.02em;line-height:1.08;color:${opts.fg}}
.mark{display:flex;align-items:center;gap:14px;font-size:28px;font-weight:700;color:${opts.fg};opacity:.85}
.dot{width:12px;height:12px;border-radius:50%;background:${opts.accent}}
.track{position:absolute;left:64px;right:64px;top:${FRAME_H - 26}px;height:6px;border-radius:3px;background:${opts.rule}}
.fill{height:6px;border-radius:3px;background:${opts.accent};width:${Math.round(opts.progress * 100)}%}
.hair{position:absolute;left:0;right:0;height:1px;background:${opts.rule}}
</style></head><body>
<div class="band top"><div class="eyebrow">${opts.eyebrow}</div><div class="caption">${opts.caption}</div></div>
<div class="hair" style="top:${APP_Y - 1}px"></div>
<div class="hair" style="top:${APP_Y + APP_H}px"></div>
<div class="band bottom"><div class="mark"><span class="dot"></span>PIG · Motion</div><div class="mark" style="opacity:.5">primeintellectgrowth.com</div></div>
<div class="track"><div class="fill"></div></div>
</body></html>`;

const THEME = {
  dark: { bg: '#0a0a0b', fg: '#fafafa', rule: '#26262b', accent: '#22c55e' },
  light: { bg: '#ffffff', fg: '#0a0a0b', rule: '#e6e6ea', accent: '#16a34a' },
};

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((shot) => shot.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 = shotDurations(film.shots, duration);

  // 2 ---------------------------------------------------------- the capture
  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 poc = engagements.engagements.find((e) => e.stage === 'poc') ?? engagements.engagements[0];
  const playbook = templates.templates.find((t) => t.kind === 'playbook') ?? templates.templates[0];

  const browser = await chromium.launch({ channel: 'chrome' });
  const contexts = {};
  for (const theme of ['dark', 'light']) {
    const context = await browser.newContext({
      viewport: { width: SHOT_W, height: SHOT_H },
      /* 3x, because a detail shot crops to about half the viewport and is then
       * scaled back up to 1080 wide; at 2x that is an upscale and it shows. */
      deviceScaleFactor: 3,
      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 chromePage = await browser.newPage({ viewport: { width: FRAME_W, height: FRAME_H } });

  const segments = [];
  for (const [index, shot] of film.shots.entries()) {
    const anchor = ANCHORS[shot.anchor];
    if (!anchor) throw new Error(`unknown anchor "${shot.anchor}" in ${film.slug}`);
    const route = anchor.route.replace('PLAYBOOK', playbook.id).replace('POC', poc.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 rendered = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
    if (rendered !== shot.theme) throw new Error(`shot ${index} rendered ${rendered}, wanted ${shot.theme}`);
    if (anchor.text) {
      await page.evaluate((text) => {
        const node = [...document.querySelectorAll('h1,h2,h3')].find(
          (el) => el.textContent?.trim() === text,
        );
        if (!node) throw new Error(`no heading "${text}"`);
        const card = node.closest('section,[class*="card"],div');
        (card ?? node).scrollIntoView({ block: 'center' });
        window.scrollBy(0, -60);
      }, anchor.text);
    } else {
      await page.evaluate(() => window.scrollTo(0, 0));
    }
    await page.waitForTimeout(500);

    const appPng = join(work, `app-${index}.png`);
    const clip = anchor.focus === 'full' ? null : await page.evaluate(
      ({ text, shotW, shotH }) => {
        const column = document.querySelector('main');
        if (!column) return null;
        const rect = column.getBoundingClientRect();
        const width = Math.min(shotW, Math.max(560, rect.width));
        const height = Math.min(shotH, width * (shotH / shotW));
        const heading = text
          ? [...document.querySelectorAll('h1,h2,h3')].find((el) => el.textContent?.trim() === text)
          : null;
        const box = (heading?.closest('section,[class*="card"]') ?? heading)?.getBoundingClientRect();
        const centreY = box ? box.top + box.height / 2 : height / 2;
        return {
          x: Math.round(Math.max(0, Math.min(shotW - width, rect.left))),
          y: Math.round(Math.max(0, Math.min(shotH - height, centreY - height / 2))),
          width: Math.round(width),
          height: Math.round(height),
        };
      },
      { text: anchor.text ?? null, shotW: SHOT_W, shotH: SHOT_H },
    );
    await page.screenshot(clip ? { path: appPng, clip } : { path: appPng });
    await page.close();

    const palette = THEME[shot.theme];
    await chromePage.setContent(
      CHROME_HTML({
        ...palette,
        font,
        eyebrow: index === 0 ? 'Prime Intellect Growth' : 'Motion',
        caption: shot.caption,
        progress: (index + 1) / film.shots.length,
      }),
      { waitUntil: 'load' },
    );
    await chromePage.waitForTimeout(250);
    const chromePng = join(work, `chrome-${index}.png`);
    await chromePage.screenshot({ path: chromePng, omitBackground: true });

    // 3 ------------------------------------------------ one shot -> one clip
    const n = frames[index];
    const zoom =
      shot.zoom === 'out'
        ? `'if(eq(on,0),1.14,max(1.0,zoom-${(0.14 / n).toFixed(6)}))'`
        : shot.zoom === 'hold'
          ? `'1.03'`
          : `'if(eq(on,0),1.0,min(1.12,zoom+${(0.12 / n).toFixed(6)}))'`;
    const segment = join(work, `seg-${index}.mp4`);
    await run('ffmpeg', [
      '-v', 'error', '-y',
      '-loop', '1', '-framerate', String(FPS), '-t', String(n / FPS), '-i', appPng,
      '-i', chromePng,
      '-filter_complex',
      `[0:v]zoompan=z=${zoom}:x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':d=${n}:s=${APP_W}x${APP_H}:fps=${FPS},` +
        `pad=${FRAME_W}:${FRAME_H}:0:${APP_Y}:color=${palette.bg}[base];` +
        `[base][1:v]overlay=0:0:format=auto,format=yuv420p[v]`,
      '-map', '[v]', '-frames:v', String(n),
      /* CRF 22, not 18. The frame is a mostly-static UI with a slow zoom, so 18
       * spent 3.7 Mbps on 32 seconds — a 15 MB download for a video whose whole
       * point is that somebody opens it on a phone between meetings. 22 holds
       * the product's 12px labels and lands under 6 MB. */
      '-c:v', 'libx264', '-preset', 'slow', '-crf', '22', segment,
    ]);
    segments.push(segment);
    console.log(`  shot ${index + 1}/${film.shots.length} ${shot.anchor} ${shot.theme} ${(n / FPS).toFixed(2)}s`);
  }
  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',
    /* The bed sits far under the voice and fades at both ends. Loudness is
     * normalised so clips do not jump against each other on the Learn page. */
    `[2:a]volume=0.085,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', '192k',
    '-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`);
  console.log(`  ${named}`);
  console.log(`  ${poster}`);
  await writeFile(
    join(work, 'result.json'),
    JSON.stringify(
      { slug: film.slug, title: film.title, summary: film.summary, hash, durationSeconds: Math.round(finalDuration), words, file: named, poster },
      null,
      2,
    ),
  );
}

await main();
