#!/usr/bin/env node
// Compare the *computed* styles of a legacy page against its Astro
// replacement, at several viewport widths.
//
//   node .agents/scripts/computed-style-diff.mjs full-guide
//   node .agents/scripts/computed-style-diff.mjs full-guide --widths 560,880,1050
//
// Why this exists: a ported media query can sit in the built stylesheet,
// match the viewport, and still do nothing. Astro scopes a component's rules
// as `.tree-node[data-astro-cid-lsutp3lb]` (specificity 0,2,0); a rule ported
// verbatim as `.tree-node` (0,1,0) loses to it and never applies. Task 15e
// attempt 4 shipped exactly that: `@media (max-width: 1050px) .tree-node
// { width: 145px }` was present in dist and the node stayed 180px wide.
//
// Checking that the breakpoint *appears* in the built CSS cannot catch this.
// Only asking the browser what it actually computed can.
import { spawn } from 'node:child_process';
import { cpSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { createServer } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { chromium } from 'playwright';

const route = process.argv[2];
if (!route) {
  console.error('usage: computed-style-diff.mjs <route> [--widths a,b,c]');
  process.exit(2);
}

const widthsArg = process.argv.indexOf('--widths');
const widths =
  widthsArg === -1
    ? [520, 560, 600, 620, 720, 800, 880, 1050, 1100, 1600]
    : process.argv[widthsArg + 1].split(',').map(Number);

// The selectors worth checking are the ones the responsive layer moves at a
// breakpoint, so read them out of the legacy stylesheet's @media blocks only.
// Taking every class in the file buries the signal under generic ones like
// `.active`, whose state the islands own anyway.
const responsive = readFileSync(new URL('../../responsive.css', import.meta.url), 'utf8');
const mediaBlocks = [];
for (const match of responsive.matchAll(/@media[^{]*\{/g)) {
  let depth = 0;
  for (let i = match.index; i < responsive.length; i += 1) {
    if (responsive[i] === '{') depth += 1;
    else if (responsive[i] === '}') {
      depth -= 1;
      if (depth === 0) {
        mediaBlocks.push(responsive.slice(match.index + match[0].length, i));
        break;
      }
    }
  }
}
const selectors = [...new Set(mediaBlocks.join('\n').match(/\.[a-z][a-z0-9-]*/g) || [])].sort();

// Properties a responsive rule actually moves. Comparing every property would
// drown the signal in font stacks and inherited colour.
const PROPERTIES = [
  'display',
  'grid-template-columns',
  'grid-template-rows',
  'flex-direction',
  'width',
  'height',
  'max-width',
  'padding',
  'margin',
  'gap',
  'font-size',
  'position',
  'inset',
  'overflow',
];

// The legacy pages were deleted at cutover; run this from a pre-cutover
// worktree, or the legacy side will 404.
const legacyPath = route === 'index' ? 'index.html' : `${route}/index.html`;
const astroPath = route === 'index' ? '' : `${route}/`;

const staging = mkdtempSync(join(tmpdir(), 'af-csd-'));
cpSync('dist', join(staging, 'ai-for-dummies'), { recursive: true });

const freePort = () =>
  new Promise((resolve, reject) => {
    const probe = createServer();
    probe.on('error', reject);
    probe.listen(0, '127.0.0.1', () => {
      const { port } = probe.address();
      probe.close(() => resolve(port));
    });
  });

const legacyPort = await freePort();
const astroPort = await freePort();
const serve = (dir, port) =>
  spawn('python3', ['-m', 'http.server', String(port), '-d', dir], { stdio: 'ignore' });
const servers = [serve('.', legacyPort), serve(staging, astroPort)];
const stop = () => {
  servers.forEach((s) => s.kill());
  rmSync(staging, { recursive: true, force: true });
};

// Every element matching each selector, so a rule that applies to the first
// node and not the rest cannot pass.
const collect = ([selectors, properties]) => {
  const out = {};
  for (const selector of selectors) {
    const nodes = [...document.querySelectorAll(selector)];
    out[selector] = nodes.map((node) => {
      const style = getComputedStyle(node);
      return properties
        .map((property) => `${property}:${style.getPropertyValue(property)}`)
        .join(';');
    });
  }
  return out;
};

let failures = 0;
try {
  const browser = await chromium.launch();
  const read = async (url, width) => {
    const page = await browser.newPage({ viewport: { width, height: 900 } });
    const response = await page.goto(url, { waitUntil: 'load' });
    if (!response || !response.ok()) {
      throw new Error(`${url} returned ${response ? response.status() : 'no response'}`);
    }
    await page.waitForTimeout(1500);
    const styles = await page.evaluate(collect, [selectors, PROPERTIES]);
    await page.close();
    return styles;
  };

  for (const width of widths) {
    const legacy = await read(`http://localhost:${legacyPort}/${legacyPath}`, width);
    const astro = await read(`http://localhost:${astroPort}/ai-for-dummies/${astroPath}`, width);

    for (const selector of selectors) {
      const before = legacy[selector];
      const after = astro[selector];
      if (before.length === 0 && after.length === 0) continue;
      if (before.length !== after.length) {
        console.log(
          `${width}px  ${selector}  legacy ${before.length} nodes, astro ${after.length}`,
        );
        failures += 1;
        continue;
      }
      let reported = 0;
      before.forEach((expected, index) => {
        if (expected === after[index]) return;
        failures += 1;
        // Three examples is enough to identify a rule that did not apply.
        reported += 1;
        if (reported > 3) return;
        const differing = expected
          .split(';')
          .filter((pair, i) => pair !== after[index].split(';')[i]);
        const got = after[index].split(';').filter((pair, i) => pair !== expected.split(';')[i]);
        console.log(`${width}px  ${selector}[${index}]`);
        console.log(`   legacy ${differing.join(' ')}`);
        console.log(`   astro  ${got.join(' ')}`);
      });
    }
  }

  await browser.close();
  console.log(failures === 0 ? 'computed styles match' : `${failures} computed-style differences`);
  process.exitCode = failures === 0 ? 0 : 1;
} finally {
  stop();
}
