#!/usr/bin/env node
/**
 * Check the gzip-transfer size of an Angular production build against
 * the ADR-0017 perf budgets. Complements Angular CLI's native
 * `budgets` (which only compare RAW sizes — no built-in gzip mode).
 *
 * Strategy
 * --------
 * 1. Parse the Angular `index.html` of the build output. Every
 *    `<script src="…">` and `<link href="…">` referencing a JS/CSS
 *    asset is part of the **initial** payload. Everything else in
 *    the output dir is a **lazy** chunk loaded on demand.
 * 2. Gzip every JS/CSS file with level 9 (matching what HTTP
 *    servers typically emit for static assets) and report the
 *    resulting transfer size.
 * 3. Compare:
 *    - sum of initial JS  ≤ BUDGET_INITIAL_JS_TOTAL
 *    - any single lazy JS ≤ BUDGET_LAZY_JS_EACH
 *    - sum of all CSS     ≤ BUDGET_CSS_TOTAL
 *    Exit non-zero on any violation.
 *
 * Locale-split builds (ADR-0019)
 * ------------------------------
 * `@angular/localize` emits one self-contained bundle per locale under
 * `dist/apps/portal-shell/browser/<locale>/`. The script auto-detects
 * the layout: if the root has no `index.html` but immediate
 * subdirectories do, each subdirectory is checked against the budgets
 * separately. Budget thresholds apply *per locale* — each bundle is
 * what the user's browser actually downloads. Violations across
 * locales are aggregated and reported together so a single CI run
 * surfaces every breach.
 *
 * Invocation: `node scripts/check-gzip-budgets.mjs [dist-dir]`.
 * The default `dist-dir` is `dist/apps/portal-shell/browser`.
 */

import { readdir, readFile, stat } from 'node:fs/promises';
import { gzipSync } from 'node:zlib';
import { basename, join } from 'node:path';

const DEFAULT_DIST = 'dist/apps/portal-shell/browser';
const dist = process.argv[2] ?? DEFAULT_DIST;

// ADR-0017 budgets (gzip transfer, in bytes).
const BUDGET_INITIAL_JS_TOTAL = 300 * 1024;
const BUDGET_LAZY_JS_EACH = 100 * 1024;
const BUDGET_CSS_TOTAL = 150 * 1024;

const fmtKb = (n) => `${(n / 1024).toFixed(2)} KB`;
const padFile = (s) => s.padEnd(36);

async function gzipSize(path) {
  return gzipSync(await readFile(path), { level: 9 }).byteLength;
}

async function fileExists(path) {
  try {
    await stat(path);
    return true;
  } catch {
    return false;
  }
}

/**
 * Check one Angular build directory (a single `index.html` and its
 * sibling JS/CSS assets) against the ADR-0017 budgets. Returns the
 * violations found — empty array means the bundle passed.
 *
 * The header passed in is rendered above the per-file table so the
 * multi-locale invocation can prefix each section with the locale
 * tag.
 */
async function checkBundle(bundleDir, header) {
  let indexHtml;
  try {
    indexHtml = await readFile(join(bundleDir, 'index.html'), 'utf8');
  } catch (err) {
    throw new Error(`could not read ${join(bundleDir, 'index.html')}: ${err.message}`);
  }

  // Match every src=/href= that points at a .js or .css file in the
  // output dir. Robust enough for Angular's emitted index.html (which
  // is well-formed and predictable).
  const referenced = new Set(
    [...indexHtml.matchAll(/(?:src|href)\s*=\s*["']([^"']+\.(?:js|css))["']/g)].map((m) =>
      basename(m[1]),
    ),
  );

  const all = await readdir(bundleDir);
  const jsFiles = all.filter((f) => f.endsWith('.js'));
  const cssFiles = all.filter((f) => f.endsWith('.css'));

  const initialJs = jsFiles.filter((f) => referenced.has(f));
  const lazyJs = jsFiles.filter((f) => !referenced.has(f));

  const sized = async (files) =>
    Promise.all(files.map(async (f) => ({ f, size: await gzipSize(join(bundleDir, f)) })));

  const initialJsSizes = await sized(initialJs);
  const lazyJsSizes = await sized(lazyJs);
  const cssSizes = await sized(cssFiles);

  const sum = (arr) => arr.reduce((s, x) => s + x.size, 0);
  const totalInitialJs = sum(initialJsSizes);
  const totalCss = sum(cssSizes);

  const violations = [];

  console.log(header);

  console.log('  Initial JS (loaded on first paint):');
  for (const { f, size } of initialJsSizes.sort((a, b) => b.size - a.size)) {
    console.log(`    ${padFile(f)} ${fmtKb(size)}`);
  }
  const initialFlag = totalInitialJs > BUDGET_INITIAL_JS_TOTAL ? ' ✗ EXCEEDS BUDGET' : ' ✓';
  console.log(
    `    ${padFile('TOTAL')} ${fmtKb(totalInitialJs)} / ${fmtKb(BUDGET_INITIAL_JS_TOTAL)}${initialFlag}`,
  );
  if (totalInitialJs > BUDGET_INITIAL_JS_TOTAL) {
    violations.push(
      `${header.trim()}: initial JS total ${fmtKb(totalInitialJs)} > budget ${fmtKb(BUDGET_INITIAL_JS_TOTAL)}`,
    );
  }

  console.log('\n  Lazy JS chunks (loaded on demand):');
  if (lazyJsSizes.length === 0) {
    console.log('    (none)');
  }
  for (const { f, size } of lazyJsSizes.sort((a, b) => b.size - a.size)) {
    const flag = size > BUDGET_LAZY_JS_EACH ? ' ✗ EXCEEDS BUDGET' : '';
    console.log(`    ${padFile(f)} ${fmtKb(size)}${flag}`);
    if (size > BUDGET_LAZY_JS_EACH) {
      violations.push(
        `${header.trim()}: lazy chunk ${f} ${fmtKb(size)} > per-chunk budget ${fmtKb(BUDGET_LAZY_JS_EACH)}`,
      );
    }
  }
  console.log(`    (per-chunk budget: ${fmtKb(BUDGET_LAZY_JS_EACH)})`);

  console.log('\n  CSS files:');
  for (const { f, size } of cssSizes.sort((a, b) => b.size - a.size)) {
    console.log(`    ${padFile(f)} ${fmtKb(size)}`);
  }
  const cssFlag = totalCss > BUDGET_CSS_TOTAL ? ' ✗ EXCEEDS BUDGET' : ' ✓';
  console.log(
    `    ${padFile('TOTAL')} ${fmtKb(totalCss)} / ${fmtKb(BUDGET_CSS_TOTAL)}${cssFlag}\n`,
  );
  if (totalCss > BUDGET_CSS_TOTAL) {
    violations.push(
      `${header.trim()}: CSS total ${fmtKb(totalCss)} > budget ${fmtKb(BUDGET_CSS_TOTAL)}`,
    );
  }

  return violations;
}

/**
 * Resolve which directories to check. A locale-split build has no
 * `index.html` at the root but does have one in each immediate
 * subdirectory (ADR-0019 §"`@angular/localize` build-time mode").
 * Returns either `[dist]` (flat layout) or `[dist/fr, dist/en, …]`
 * (locale split, sorted alphabetically for deterministic output).
 */
async function resolveBundles(root) {
  if (await fileExists(join(root, 'index.html'))) {
    return [{ dir: root, label: root }];
  }
  let entries;
  try {
    entries = await readdir(root, { withFileTypes: true });
  } catch (err) {
    throw new Error(`could not read ${root}: ${err.message}`);
  }
  const subdirsWithIndex = [];
  for (const entry of entries) {
    if (!entry.isDirectory()) continue;
    const candidate = join(root, entry.name);
    if (await fileExists(join(candidate, 'index.html'))) {
      subdirsWithIndex.push({ dir: candidate, label: entry.name });
    }
  }
  if (subdirsWithIndex.length === 0) {
    throw new Error(
      `no index.html in ${root} and no locale subdirectory contains one — ` +
        `did you run "nx build portal-shell --configuration=production" first?`,
    );
  }
  subdirsWithIndex.sort((a, b) => a.label.localeCompare(b.label));
  return subdirsWithIndex;
}

async function main() {
  let bundles;
  try {
    bundles = await resolveBundles(dist);
  } catch (err) {
    console.error(`✗ ${err.message}`);
    process.exit(2);
  }

  const multi = bundles.length > 1;
  console.log(
    `Gzip-transfer budget check (ADR-0017) — output: ${dist}` +
      (multi ? ` (${bundles.length} locale bundles)` : '') +
      '\n',
  );

  const violations = [];
  for (const { dir, label } of bundles) {
    const header = multi ? `[locale: ${label}]\n` : '';
    const found = await checkBundle(dir, header);
    violations.push(...found);
  }

  if (violations.length > 0) {
    console.error('✗ ADR-0017 budget violations:');
    for (const v of violations) console.error(`  - ${v}`);
    process.exit(1);
  }

  console.log('✓ All ADR-0017 gzip-transfer budgets respected.');
}

main().catch((err) => {
  console.error('Budget check failed:', err);
  process.exit(2);
});
