#!/usr/bin/env node
// =============================================================
// verify.mjs — definition of done. Serves a game folder on a
// free port, loads it in a real browser, FAILS (exit 1) on ANY
// console/page error, confirms a <canvas> rendered (non-blank),
// and saves a screenshot.
//
// Usage:  node verify.mjs <gameDir>
// =============================================================
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { createRequire } from 'node:module';

const __dirname = path.dirname(fileURLToPath(import.meta.url));

// Resolve playwright/sharp PORTABLY: try the member's project (cwd), this
// tool's own folder, and the default resolution — in that order — so the kit
// works on any machine, not just where it was authored.
function portableRequire(pkg) {
  const bases = [process.cwd(), path.join(process.cwd(), 'node_modules'), __dirname, import.meta.url];
  for (const b of bases) {
    try {
      const req = createRequire(typeof b === 'string' ? pathToFileURL(path.join(b, 'noop.js')) : b);
      return req(pkg);
    } catch (e) { /* try next */ }
  }
  return null;
}
let chromium, sharp;
const pw = portableRequire('playwright') || portableRequire('playwright-core');
if (pw && pw.chromium) { chromium = pw.chromium; }
else {
  console.error('Playwright not found. Install it once in this project:\n  npm i -D playwright && npx playwright install chromium');
  process.exit(1);
}
sharp = portableRequire('sharp'); // optional (chroma-key/trim); null is fine

const MIME = {
  '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript',
  '.css': 'text/css', '.png': 'image/png', '.jpg': 'image/jpeg',
  '.jpeg': 'image/jpeg', '.json': 'application/json', '.svg': 'image/svg+xml',
  '.gif': 'image/gif', '.webp': 'image/webp',
};

function serve(root) {
  return new Promise((resolve) => {
    const server = http.createServer((req, res) => {
      let urlPath = decodeURIComponent(req.url.split('?')[0]);
      if (urlPath === '/') urlPath = '/index.html';
      const filePath = path.join(root, urlPath);
      if (!filePath.startsWith(path.resolve(root))) { res.writeHead(403); res.end(); return; }
      fs.readFile(filePath, (err, data) => {
        if (err) { res.writeHead(404); res.end('not found'); return; }
        res.writeHead(200, { 'Content-Type': MIME[path.extname(filePath)] || 'application/octet-stream' });
        res.end(data);
      });
    });
    server.listen(0, '127.0.0.1', () => resolve(server));
  });
}

async function main() {
  const gameDir = path.resolve(process.argv[2] || '.');
  if (!fs.existsSync(path.join(gameDir, 'index.html'))) {
    console.error('No index.html in', gameDir);
    process.exit(1);
  }

  const server = await serve(gameDir);
  const port = server.address().port;
  const url = `http://127.0.0.1:${port}/`;
  console.log('Serving', gameDir, 'at', url);

  const errors = [];
  const browser = await chromium.launch();
  const page = await browser.newPage({ viewport: { width: 480, height: 800 } });

  page.on('console', (msg) => {
    if (msg.type() === 'error') errors.push('[console.error] ' + msg.text());
  });
  page.on('pageerror', (err) => errors.push('[pageerror] ' + (err.stack || err.message)));
  page.on('requestfailed', (req) => {
    // ignore favicon noise
    if (!req.url().includes('favicon')) errors.push('[requestfailed] ' + req.url() + ' ' + (req.failure()?.errorText || ''));
  });

  let canvasOk = false;
  let nonBlank = false;
  try {
    await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
    // Wait for canvas to exist.
    await page.waitForSelector('canvas', { timeout: 15000 });
    canvasOk = true;

    // Let scenes boot / render a few frames.
    await page.waitForTimeout(1500);
    // Drive a gesture (so audio unlock path + menu->game work) and a few inputs.
    await page.mouse.click(240, 400);
    await page.waitForTimeout(400);
    await page.keyboard.press('Space');
    await page.waitForTimeout(1200);
    await page.keyboard.down('ArrowLeft');
    await page.waitForTimeout(300);
    await page.keyboard.up('ArrowLeft');
    await page.keyboard.down('ArrowRight');
    await page.waitForTimeout(300);
    await page.keyboard.up('ArrowRight');
    await page.waitForTimeout(800);

    // Screenshot the rendered canvas only (reliable for WebGL).
    const shotPath = path.join(gameDir, 'verify-screenshot.png');
    const canvasEl = await page.$('canvas');
    const shotBuf = await canvasEl.screenshot({ path: shotPath });
    console.log('Screenshot ->', shotPath);

    // Non-blank = the rendered pixels have real variance (not a flat fill).
    // Read it back from the screenshot, which captures the actual GPU output.
    if (sharp) {
      const stats = await sharp(shotBuf).stats();
      // stdev across channels > a small threshold means varied content.
      const maxStdev = Math.max(...stats.channels.map((c) => c.stdev));
      nonBlank = maxStdev > 4;
      console.log('pixel stdev (max channel):', maxStdev.toFixed(2));
    } else {
      // Fallback: byte-size heuristic (a flat PNG is tiny).
      nonBlank = shotBuf.length > 2000;
    }
  } catch (e) {
    errors.push('[navigation] ' + e.message);
  } finally {
    await browser.close();
    server.close();
  }

  console.log('\n=== VERIFY RESULT ===');
  console.log('canvas present:', canvasOk);
  console.log('canvas non-blank:', nonBlank);
  console.log('console/page errors:', errors.length);
  if (errors.length) {
    console.log('\n--- ERRORS ---');
    errors.forEach((e) => console.log(' - ' + e));
  }

  const pass = canvasOk && nonBlank === true && errors.length === 0;
  console.log('\n' + (pass ? 'PASS ✅  (0 errors, canvas rendered)' : 'FAIL ❌'));
  process.exit(pass ? 0 : 1);
}

main().catch((e) => { console.error(e); process.exit(1); });
