#!/usr/bin/env node
/**
 * The bundle budget.
 *
 * Two numbers, both gzipped, both enforced:
 *
 *   entry chunk   <= 160 kB    what every visitor downloads before they see
 *                              anything, on whatever connection the boardroom
 *                              wifi is having that morning
 *   any demo      <=  90 kB    a demo is lazy, so this is what clicking one
 *                              costs — and it is per demo, so the tenth demo
 *                              cannot be paid for by the first nine
 *
 * The vendor chunks (`react`, `charts`, named in vite.config.ts) are printed
 * but not capped. They are shared, cached across every route, and capping them
 * here would only produce pressure to inline them into the entry, which is the
 * opposite of what we want.
 *
 * Gzip, not brotli, and not raw: gzip is the floor every host actually serves,
 * so it is the honest number. Brotli would flatter us by about 15%.
 *
 * Run after a build: `pnpm build && node scripts/bundle-budget.mjs`.
 */

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

import { abs, die, dim, exists, green, gzipSize, kb, red, rel, table, yellow } from './_lib.mjs';

const ENTRY_BUDGET = 160 * 1024;
const DEMO_BUDGET = 90 * 1024;

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

if (!exists(INDEX)) die(`${rel(INDEX)} does not exist. Run \`pnpm build\` first.`);
if (!exists(ASSETS)) die(`${rel(ASSETS)} does not exist. The build produced no assets, which is itself the bug.`);

/**
 * The entry is whatever `index.html` loads directly.
 *
 * Derived rather than matched by filename: Vite's entry is `index-<hash>.js`
 * today, but the moment someone renames the entry in rollupOptions, a
 * name-matching version of this script starts measuring nothing and passing.
 */
const indexHtml = fs.readFileSync(INDEX, 'utf8');
const entryNames = new Set(
  [...indexHtml.matchAll(/<script[^>]+type=["']module["'][^>]+src=["']([^"']+)["']/g)].map((m) => path.basename(m[1])),
);
if (entryNames.size === 0) {
  die(`no module <script> found in ${rel(INDEX)}, so the entry chunk cannot be identified.`);
}

/** Named in vite.config.ts's manualChunks. Shared, cached, deliberately uncapped. */
const VENDOR = /^(react|charts)-/;

const files = fs
  .readdirSync(ASSETS)
  .filter((name) => name.endsWith('.js') || name.endsWith('.css'))
  .sort();

const rows = [];
const failures = [];
let totalGzip = 0;

for (const name of files) {
  const bytes = fs.readFileSync(path.join(ASSETS, name));
  const gz = gzipSize(bytes);
  totalGzip += gz;

  const kind = entryNames.has(name)
    ? 'entry'
    : name.endsWith('.css')
      ? 'css'
      : VENDOR.test(name)
        ? 'vendor'
        : 'demo/lazy';

  const budget = kind === 'entry' ? ENTRY_BUDGET : kind === 'demo/lazy' ? DEMO_BUDGET : null;
  const over = budget !== null && gz > budget;
  if (over) {
    failures.push(
      `assets/${name} is ${kb(gz)} gzipped, over the ${kb(budget)} ${kind === 'entry' ? 'entry' : 'per-demo'} budget ` +
        `by ${kb(gz - budget)}.`,
    );
  }

  rows.push([
    `assets/${name}`,
    kind,
    kb(bytes.length),
    kb(gz),
    budget === null ? '-' : `${((gz / budget) * 100).toFixed(0)}%`,
    over ? red('OVER') : budget === null ? dim('n/a') : green('ok'),
  ]);
}

console.log(table(['file', 'kind', 'raw', 'gzip', 'of budget', ''], rows));
console.log('');
console.log(dim(`total gzipped: ${kb(totalGzip)} across ${rows.length} assets`));

if (failures.length) {
  console.error('');
  for (const failure of failures) console.error(`${red('FAIL')}  ${failure}`);
  console.error('');
  console.error(
    red('bundle-budget: over budget.') +
      '\n      A demo that costs 90 kB is a demo somebody closes before it renders. The usual causes, in ' +
      '\n      order: a chart library pulled into a demo chunk instead of its own tab, an icon set imported ' +
      "\n      as a namespace rather than by name, and a `?raw` receipt that grew a file it didn't need.",
  );
  process.exit(1);
}

// A lazy chunk nobody can reach is not a saving, it is dead weight that still
// costs disk and cache. Worth a look, never worth failing a build over.
const unreferenced = rows.filter((row) => row[1] === 'demo/lazy' && !indexHtml.includes(path.basename(row[0])));
if (unreferenced.length > 8) {
  console.log(yellow(`warn  ${unreferenced.length} lazy chunks. Worth checking none of them are orphaned.`));
}

console.log('');
console.log(green('bundle-budget: within budget.'));
