#!/usr/bin/env node
/**
 * Renders the social card for every route.
 *
 * One 1200x630 PNG per route, screenshotted from the real built page, written
 * to `public/og/`. Not generated from a template: a card drawn by a separate
 * renderer drifts from the page it advertises, and this way a change to the
 * board or the palette shows up in the card by construction.
 *
 * ── ORDERING ───────────────────────────────────────────────────────────────
 *
 * This reads `dist/` and writes `public/`, which means the cards are always one
 * build behind until you build again:
 *
 *     pnpm build && node scripts/og.mjs && pnpm build
 *
 * That is deliberate. The alternative — screenshotting the dev server — renders
 * unminified CSS and a different font-loading path, and the cards came out
 * subtly wrong in a way nobody noticed until they were on X.
 *
 * ── WHERE THIS RUNS ────────────────────────────────────────────────────────
 *
 * amd-server (x86), where Playwright's chromium is installed. It is NOT part of
 * the deploy: the deploy host has no browser, and a deploy that silently
 * skipped card generation would ship a site whose every link previews as a
 * broken image. Run it here, commit the PNGs, deploy the PNGs.
 *
 * Env:
 *   PIG_OG_SCALE  device pixel ratio, default 2 (so the file is 2400x1260 for
 *                 a 1200x630 CSS-pixel card; every unfurler downsamples, and
 *                 1x text on a retina timeline looks like a fax).
 */

import fs from 'node:fs';
import path from 'node:path';

import { abs, die, dim, exists, expandRoutes, green, loadAllMetas, loadVerticals, rel, serveStatic, table } from './_lib.mjs';

const WIDTH = 1200;
const HEIGHT = 630;
const SCALE = Number(process.env.PIG_OG_SCALE ?? 2);

const DIST = abs('dist');
const OUT_DIR = abs('public', 'og');

if (!exists(path.join(DIST, 'index.html'))) {
  die(`${rel(DIST)}/index.html does not exist. Run \`pnpm build\` first — the cards are shot from the built site.`);
}

const { chromium } = await import('playwright').catch(() => {
  die('playwright is not installed. `pnpm install` first.');
});

const executable = chromium.executablePath();
if (!exists(executable)) {
  die(
    `Playwright's chromium is not installed at ${executable}.\n` +
      '      Run this on amd-server (x86), where it is installed, and commit the PNGs. ' +
      'The deploy host has no browser, which is exactly why card generation is not part of the deploy.',
  );
}

/* ------------------------------------------------------- routes -> filenames */

const { metas } = loadAllMetas();
const verticals = loadVerticals();
if (verticals.error) die(`could not read the verticals: ${verticals.error}`);

const { routes, errors } = expandRoutes({ metas, verticals: verticals.list });
if (errors.length) die(`route enumeration failed:\n  - ${errors.join('\n  - ')}`);

/**
 * A demo's filename comes from its own `meta.ogImage`, not from its slug.
 *
 * `check-demos` rule 4 asserts that file exists; if this script invented a
 * different name, the two would disagree and the check would fail on a card
 * that had just been generated.
 */
function outputName(route) {
  if (route.kind === 'home') return 'home.png';
  if (route.kind === 'demo') {
    const declared = metas.get(route.slug)?.ogImage;
    return typeof declared === 'string' && declared.trim() !== '' ? path.basename(declared) : `${route.slug}.png`;
  }
  // Prefixed, because a vertical slug and a demo slug live in the same
  // directory and nothing stops them colliding.
  if (route.kind === 'vertical') return `vertical-${route.id}.png`;
  return `${route.path.replace(/^\/+/, '').replace(/\//g, '-') || 'home'}.png`;
}

const targets = routes.map((route) => ({ route, name: outputName(route) }));

const byName = new Map();
for (const target of targets) {
  const clash = byName.get(target.name);
  if (clash) {
    die(
      `${target.route.path} and ${clash.path} would both write public/og/${target.name}. ` +
        'Change one of their `meta.ogImage` values; a shared card means one of the two pages advertises the other.',
    );
  }
  byName.set(target.name, target.route);
}

/* --------------------------------------------------------------- screenshot */

fs.mkdirSync(OUT_DIR, { recursive: true });

const server = await serveStatic(DIST);
const browser = await chromium.launch();
const context = await browser.newContext({
  viewport: { width: WIDTH, height: HEIGHT },
  deviceScaleFactor: SCALE,
  colorScheme: 'light',
  // Every animation on the site is an entrance. Shooting mid-flight catches
  // elements at 40% opacity, which reads as a rendering bug in the card.
  reducedMotion: 'reduce',
});

const rows = [];
try {
  for (const { route, name } of targets) {
    const page = await context.newPage();
    await page.goto(`${server.origin}${route.path}`, { waitUntil: 'load', timeout: 30_000 });
    await page.waitForSelector('#root > *', { timeout: 30_000 }).catch(() => {});
    await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {});
    await page.evaluate(() => document.fonts.ready.then(() => true)).catch(() => {});

    const file = path.join(OUT_DIR, name);
    // `clip` rather than fullPage: the card is a 1200x630 window onto the top
    // of the page, and fullPage would hand the unfurler a 1200x9000 strip that
    // every platform crops to something arbitrary.
    await page.screenshot({ path: file, clip: { x: 0, y: 0, width: WIDTH, height: HEIGHT } });
    await page.close();

    rows.push([route.path, `public/og/${name}`, `${(fs.statSync(file).size / 1024).toFixed(0)} kB`]);
  }
} finally {
  await context.close();
  await browser.close();
  await server.close();
}

console.log(table(['route', 'card', 'size'], rows));
console.log('');
console.log(green(`og: wrote ${rows.length} cards at ${WIDTH}x${HEIGHT} CSS px (x${SCALE}).`));
console.log(dim('Rebuild before deploying, or dist/ still holds the previous cards.'));
