#!/usr/bin/env node
/**
 * Catch scope bugs in the inline <script> blocks of public/*.html.
 *
 * WHY THIS EXISTS
 * ---------------
 * 720ed35 declared `checkErrorsHtml` inside renderVitalsBar() but referenced it
 * inside renderReport(). That parses perfectly and throws ReferenceError at
 * runtime, so every DB report rendered "Report data could not be displayed" for
 * ~2 hours in production. A `new Function()` syntax check — which is what was
 * actually run before shipping it — CANNOT see this class of bug: the code is
 * syntactically valid, the identifier is simply not in scope where it is used.
 * ESLint's no-undef is precisely the rule that catches it.
 *
 * HOW
 * ---
 * Multiple <script> blocks in one HTML document SHARE ONE GLOBAL SCOPE, so all
 * inline blocks of a file are concatenated into one virtual module. Linting them
 * separately would report every cross-block call as no-undef and bury the signal.
 *
 * Two further patterns are valid at runtime but invisible to no-undef, and both
 * must be modelled or the gate is pure noise (125 false positives on the first
 * run, none of them real):
 *
 *   1. <script src="/connection-state.js"> — a local file under public/ whose
 *      top-level declarations (tvConn, tvNav, ...) are page globals. Those files
 *      are prepended so their declarations are genuinely in scope.
 *   2. `window.switchTab = function(){}` — assigns a global without declaring an
 *      identifier, so a later bare `switchTab(...)` looks undefined to ESLint
 *      while working fine in a browser. Every `window.NAME =` in the page and in
 *      its external scripts is registered as a global.
 *
 * Line numbers map back to the original .html, past any prepended code.
 *
 * Exits 2 (not 1) if the extractor finds nothing, so a regression in the
 * extractor cannot masquerade as a clean run.
 */
const fs = require('fs');
const path = require('path');
const { ESLint } = require('eslint');

const ROOT = path.resolve(__dirname, '..', '..');
const PUBLIC_DIR = path.join(ROOT, 'public');
const NL = String.fromCharCode(10);

// Third-party / server-injected identifiers. Keep tight — a lazy addition here
// re-opens the exact hole this script closes.
const EXTERNAL_GLOBALS = [
  'Chart', 'marked', 'DOMPurify',
  // CodeMirror 5 is a UMD bundle that publishes via `this.CodeMirror` inside a
  // closure, which the window.NAME= scan cannot see. It IS a real global.
  'CodeMirror',
  '__BLOG_POSTS__', '__REPORT__', '__USER__', '__CONN__',
];

function collectWindowAssignedGlobals(code) {
  const names = new Set();
  const re = /\bwindow\.([A-Za-z_$][\w$]*)\s*=(?!=)/g;
  let m;
  while ((m = re.exec(code)) !== null) names.add(m[1]);
  return names;
}

function localScriptSources(html) {
  const out = [];
  const re = /<script\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi;
  let m;
  while ((m = re.exec(html)) !== null) {
    const src = m[1];
    if (/^https?:|^\/\//i.test(src)) continue;
    const rel = src.replace(/^\//, '').split('?')[0];
    const abs = path.join(PUBLIC_DIR, rel);
    if (abs.startsWith(PUBLIC_DIR) && fs.existsSync(abs)) out.push(abs);
  }
  return out;
}

function extractInlineScripts(html) {
  const re = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
  const blocks = [];
  let m;
  while ((m = re.exec(html)) !== null) {
    const attrs = m[1] || '';
    if (/\bsrc\s*=/i.test(attrs)) continue;
    const typeMatch = attrs.match(/\btype\s*=\s*["']?([^"'\s>]+)/i);
    if (typeMatch) {
      const t = typeMatch[1].toLowerCase();
      if (t !== 'text/javascript' && t !== 'module' && t !== 'application/javascript') continue;
    }
    const body = m[2];
    const startLine = html.slice(0, m.index + m[0].indexOf(body)).split(NL).length;
    blocks.push({ body, startLine });
  }
  return blocks;
}

function buildVirtual(blocks) {
  const lines = [];
  const map = [];
  for (const b of blocks) {
    const bodyLines = b.body.split(NL);
    bodyLines.forEach((ln, i) => { lines.push(ln); map.push(b.startLine + i); });
    lines.push('');
    map.push(b.startLine + bodyLines.length);
  }
  return { code: lines.join(NL), map };
}

async function main() {
  const files = fs.readdirSync(PUBLIC_DIR).filter(f => f.endsWith('.html')).sort();
  if (!files.length) { console.error('FATAL: no HTML files found in public/'); process.exit(2); }

  const globalsObj = {};
  for (const g of EXTERNAL_GLOBALS) globalsObj[g] = 'readonly';
  const browserGlobals = require('globals').browser;

  let totalBlocks = 0;
  const problems = [];

  for (const f of files) {
    const html = fs.readFileSync(path.join(PUBLIC_DIR, f), 'utf8');
    const blocks = extractInlineScripts(html);
    if (!blocks.length) continue;
    totalBlocks += blocks.length;
    const { code, map } = buildVirtual(blocks);

    let extCode = '';
    for (const abs of localScriptSources(html)) {
      try { extCode += fs.readFileSync(abs, 'utf8') + NL; } catch (_) { /* unreadable: skip */ }
    }

    const perFileGlobals = {};
    for (const n of collectWindowAssignedGlobals(extCode + NL + code)) perFileGlobals[n] = 'readonly';

    const eslint = new ESLint({
      overrideConfigFile: true,
      overrideConfig: {
        languageOptions: {
          ecmaVersion: 2023,
          sourceType: 'script',
          globals: { ...browserGlobals, ...globalsObj, ...perFileGlobals },
        },
        linterOptions: { reportUnusedDisableDirectives: false },
        rules: { 'no-undef': 'error' },
      },
    });

    const extLines = extCode ? extCode.split(NL).length - 1 : 0;
    const lintCode = extCode + code;

    let results;
    try {
      results = await eslint.lintText(lintCode, {
        filePath: path.join(PUBLIC_DIR, f.replace(/\.html$/, '.inline.js')),
      });
    } catch (e) {
      console.error('FATAL: lint threw on ' + f + ': ' + e.message);
      process.exit(2);
    }

    for (const r of results) {
      for (const msg of r.messages) {
        const vLine = msg.line - extLines;
        if (vLine < 1) continue;                 // lives in an external .js, not this page
        const orig = map[vLine - 1] || msg.line;
        if (msg.fatal) problems.push({ file: f, line: orig, text: msg.message });
        else if (msg.ruleId === 'no-undef') problems.push({ file: f, line: orig, text: msg.message });
      }
    }
  }

  if (totalBlocks === 0) {
    console.error('FATAL: extractor found 0 inline script blocks — it is broken, not the code.');
    process.exit(2);
  }

  console.log('Linted ' + totalBlocks + ' inline <script> blocks across ' + files.length + ' HTML files.');

  if (problems.length) {
    console.error(NL + problems.length + ' undefined-reference problem(s):' + NL);
    const byFile = {};
    for (const p of problems) (byFile[p.file] = byFile[p.file] || []).push(p);
    for (const [f, ps] of Object.entries(byFile)) {
      console.error('  public/' + f);
      for (const p of ps) console.error('    ' + f + ':' + p.line + '  ' + p.text);
    }
    console.error(NL + 'A reference to an out-of-scope identifier parses fine and throws at runtime.');
    console.error('This is the checkErrorsHtml bug (720ed35) — fix the scope, do not add a global.' + NL);
    process.exit(1);
  }

  console.log('no-undef: clean.');
}

main().catch(e => { console.error('FATAL:', e); process.exit(2); });
