#!/usr/bin/env node
/**
 * Bakes a real `<head>` and real markup into one HTML file per route.
 *
 * A client-only SPA ships ONE document. Every route gets the homepage's title,
 * the homepage's description and the homepage's og:image, because the tags the
 * app sets live in JavaScript that a crawler or a link unfurler never runs. So
 * every shared link previews as the homepage, and the per-route OG cards that
 * `scripts/og.mjs` spent two minutes rendering are dead weight on disk.
 *
 * This fixes that the only way that actually works without a server: run the
 * real app in a real browser against the built output, let it render and set
 * its own head, then write what the browser ended up with to
 * `dist/<route>/index.html`. Static hosting serves those directly; the SPA
 * boots on top and takes over as normal.
 *
 * Three things it is careful about, each of which has broken this before:
 *
 *   - The app is mounted with `createRoot`, not `hydrateRoot`, so React
 *     discards the baked markup and re-renders. That is FINE and deliberate:
 *     there is no hydration mismatch to worry about, and the markup exists for
 *     the crawler, not for the first paint.
 *   - next-themes stamps `data-theme` and `color-scheme` onto <html> from the
 *     viewer's OS preference. Capturing that bakes ONE viewer's theme into the
 *     file every other viewer downloads, so both are reset before writing.
 *   - A route that never calls `useSeo` silently keeps the homepage's head.
 *     That is the exact bug this script exists to fix, so it is a failure, not
 *     a warning.
 *
 * Routes come from the router source and the demo/vertical data, never from a
 * list in here — see `expandRoutes` in `_lib.mjs`.
 *
 * Usage: `node scripts/prerender.mjs` (runs as part of `pnpm build`).
 * Env: `PIG_DEMO_ORIGIN` overrides the public origin baked into canonical/og:url.
 */

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

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

const DIST = abs('dist');
const INDEX = path.join(DIST, 'index.html');

if (!exists(INDEX)) die(`${rel(INDEX)} does not exist. Run \`vite build\` before prerendering.`);

const { chromium } = await import('playwright').catch(() => {
  die('playwright is not installed. `pnpm install` first; prerender needs a real browser to render the app.');
});

/* ------------------------------------------------------------- the routes */

const { metas, errors: metaErrors } = loadAllMetas();
for (const e of metaErrors) console.warn(`warn  ${e.file}: ${e.error}`);

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  - ')}`);

const verticalById = new Map(verticals.list.map((v) => [v.id, v]));

/* --------------------------------------------- the homepage's own defaults */

const indexHtml = read(INDEX);
const attr = (re) => indexHtml.match(re)?.[1] ?? '';
const BASE = {
  title: attr(/<title>([^<]*)<\/title>/i),
  description: attr(/<meta[^>]+name=["']description["'][^>]*content=["']([^"']*)["']/i),
  siteName: attr(/<meta[^>]+property=["']og:site_name["'][^>]*content=["']([^"']*)["']/i) || 'PIG Demo',
  image: attr(/<meta[^>]+property=["']og:image["'][^>]*content=["']([^"']*)["']/i) || `${SITE_ORIGIN}/og/home.png`,
};
if (!BASE.title) die(`${rel(INDEX)} has no <title>. The baked head is built from it; there is nothing to start from.`);

const absoluteUrl = (value) => (/^https?:\/\//i.test(value) ? value : `${SITE_ORIGIN}${value.startsWith('/') ? '' : '/'}${value}`);

/** What this route's head SHOULD say, from the data, where we know. */
function expected(route) {
  if (route.kind === 'demo') {
    const meta = metas.get(route.slug);
    if (!meta) return null;
    return {
      title: `${meta.title} — ${BASE.siteName}`,
      description: meta.tagline,
      image: meta.ogImage ?? BASE.image,
    };
  }
  if (route.kind === 'vertical') {
    const vertical = verticalById.get(route.id);
    if (!vertical) return null;
    return {
      title: `${vertical.title} — ${BASE.siteName}`,
      description: vertical.description ?? BASE.description,
      image: BASE.image,
    };
  }
  if (route.kind === 'home') return { title: BASE.title, description: BASE.description, image: BASE.image };
  return null; // /gallery, /honesty — only the app knows what these say
}

/* ------------------------------------------------------------------ render */

const server = await serveStatic(DIST);
const browser = await chromium.launch();
const context = await browser.newContext({
  viewport: { width: 1280, height: 900 },
  // Bake the light theme. Capturing whatever the build machine's OS preferred
  // would ship one viewer's palette to everyone until React re-renders.
  colorScheme: 'light',
  reducedMotion: 'reduce',
});

/** @type {{file: string, html: string}[]} */
const pending = [];
/** @type {string[]} */
const failures = [];

try {
  for (const route of routes) {
    const page = await context.newPage();
    /** @type {string[]} */
    const crashes = [];
    page.on('pageerror', (error) => crashes.push(error.message));

    const url = `${server.origin}${route.path}`;
    await page.goto(url, { waitUntil: 'load', timeout: 30_000 });
    // The router's lazy routes resolve a chunk before they render anything, so
    // "load" is too early for every page but the eagerly-imported home.
    // Two things are easy to get wrong here and both fail silently.
    //
    // Wait for `main`, not `#root > *`: the app's first child is the skip link,
    // and `waitForSelector` defaults to waiting for VISIBILITY, so a
    // visually-hidden first child burns the full timeout on every route while
    // the page has in fact been rendered the whole time.
    //
    // Then settle. `useSeo` writes the head from an effect, so the title is
    // still the one baked into index.html for a tick after the markup appears.
    // Reading too early bakes the homepage's head into every route — the exact
    // bug this script exists to prevent, reintroduced by the fix for the first.
    await page.waitForSelector('#root main', { timeout: 30_000 }).catch(() => {});
    await page
      .waitForFunction(() => document.readyState === 'complete', null, { timeout: 10_000 })
      .catch(() => {});
    await page.waitForTimeout(250);
    await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {});
    // `document.fonts.ready` resolves to a FontFaceSet, which does not survive
    // serialisation back across the bridge; return a boolean instead.
    await page.evaluate(() => document.fonts.ready.then(() => true)).catch(() => {});

    const seen = await page.evaluate(() => ({
      title: document.title,
      description: document.querySelector('meta[name="description"]')?.getAttribute('content') ?? '',
      canonical: document.querySelector('link[rel="canonical"]')?.getAttribute('href') ?? '',
      image: document.querySelector('meta[property="og:image"]')?.getAttribute('content') ?? '',
      bodyText: (document.querySelector('#root')?.textContent ?? '').trim().length,
      lang: document.documentElement.lang,
    }));

    if (crashes.length) failures.push(`${route.path}: the page threw while rendering — ${crashes[0]}`);
    if (seen.bodyText < 200) {
      failures.push(
        `${route.path}: #root rendered ${seen.bodyText} characters of text. A prerendered page with no content ` +
          'is worse than none, because it caches an empty page at a real URL.',
      );
    }

    const want = expected(route);
    const appSetItsOwnHead = seen.title !== '' && (route.kind === 'home' || seen.title !== BASE.title);
    const title = appSetItsOwnHead ? seen.title : want?.title;
    const description = (appSetItsOwnHead && seen.description) || want?.description || BASE.description;
    const image = (appSetItsOwnHead && seen.image) || want?.image || BASE.image;

    if (!title) {
      failures.push(
        `${route.path}: still carries the homepage title "${BASE.title}" after rendering, and there is no ` +
          'demo or vertical record to build one from. Call `useSeo(...)` in the page component — a route ' +
          'that does not set its own head is the exact bug this script exists to fix.',
      );
      await page.close();
      continue;
    }
    if (!appSetItsOwnHead) {
      console.warn(
        dim(`warn  ${route.path}: the app left the homepage head in place; baking the value from the ${route.kind} record instead.`),
      );
    }

    await page.evaluate(bakeHead, {
      title,
      description,
      canonical: absoluteUrl(route.path),
      image: absoluteUrl(image),
      siteName: BASE.siteName,
      robots: null,
    });

    pending.push({ file: outputFile(route.path), html: await page.content() });
    await page.close();
  }

  /* ------------------------------------------------------------- 404.html */

  const page = await context.newPage();
  // A path no route can ever match, so the router's `*` branch renders.
  await page.goto(`${server.origin}/__prerender-not-found__`, { waitUntil: 'load', timeout: 30_000 });
  await page.waitForSelector('#root > *', { timeout: 30_000 }).catch(() => {});
  await page.evaluate(bakeHead, {
    title: `Page not found — ${BASE.siteName}`,
    description: 'That page does not exist. Every demo on this site is a directory in the repository, so a missing one is usually a renamed slug.',
    canonical: null,
    image: absoluteUrl(BASE.image),
    siteName: BASE.siteName,
    // The 404 body is served at every wrong URL there will ever be. Indexing
    // it means indexing an unbounded set of URLs that all say "not found".
    robots: 'noindex, follow',
  });
  pending.push({ file: path.join(DIST, '404.html'), html: await page.content() });
  await page.close();
} finally {
  await context.close();
  await browser.close();
  await server.close();
}

if (failures.length) die(`prerender refused to write:\n  - ${failures.join('\n  - ')}`);

/* ------------------------------------------------------------------- write */

for (const { file, html } of pending) {
  fs.mkdirSync(path.dirname(file), { recursive: true });
  fs.writeFileSync(file, `${html}\n`, 'utf8');
}

console.log(green(`prerender: wrote ${pending.length} files under ${rel(DIST)} (origin ${SITE_ORIGIN}).`));
for (const { file } of pending) console.log(dim(`  ${rel(file)}`));

/* ----------------------------------------------------------------- helpers */

function outputFile(route) {
  if (route === '/') return path.join(DIST, 'index.html');
  return path.join(DIST, route.replace(/^\/+/, ''), 'index.html');
}

/**
 * Runs INSIDE the page. Rewrites the head, then undoes the theme stamping.
 *
 * Written as a single self-contained function because it is serialised to the
 * browser — it cannot close over anything from this module.
 */
function bakeHead(values) {
  const head = document.head;

  const upsert = (selector, create, content) => {
    let tag = head.querySelector(selector);
    if (!tag) {
      tag = create();
      head.appendChild(tag);
    }
    tag.setAttribute(tag.tagName === 'LINK' ? 'href' : 'content', content);
    return tag;
  };
  const meta = (name, content) =>
    upsert(`meta[name="${name}"]`, () => {
      const el = document.createElement('meta');
      el.setAttribute('name', name);
      return el;
    }, content);
  const property = (prop, content) =>
    upsert(`meta[property="${prop}"]`, () => {
      const el = document.createElement('meta');
      el.setAttribute('property', prop);
      return el;
    }, content);

  document.title = values.title;
  meta('description', values.description);

  property('og:type', 'website');
  property('og:site_name', values.siteName);
  property('og:title', values.title);
  property('og:description', values.description);
  property('og:image', values.image);

  // summary_large_image is the only card type that shows a 1200x630 image.
  // Without it X renders a thumbnail and the card reads as a link, not a page.
  meta('twitter:card', 'summary_large_image');
  meta('twitter:title', values.title);
  meta('twitter:description', values.description);
  meta('twitter:image', values.image);

  if (values.canonical) {
    upsert('link[rel="canonical"]', () => {
      const el = document.createElement('link');
      el.setAttribute('rel', 'canonical');
      return el;
    }, values.canonical);
    property('og:url', values.canonical);
  } else {
    head.querySelector('link[rel="canonical"]')?.remove();
    head.querySelector('meta[property="og:url"]')?.remove();
  }

  if (values.robots) meta('robots', values.robots);
  else head.querySelector('meta[name="robots"]')?.remove();

  // next-themes wrote these from the rendering machine's OS preference. Left
  // in, every visitor downloads that machine's theme and sees it flash to
  // their own once React boots.
  document.documentElement.setAttribute('data-theme', 'light');
  document.documentElement.style.removeProperty('color-scheme');
  if (document.documentElement.getAttribute('style') === '') document.documentElement.removeAttribute('style');
}
