#!/usr/bin/env node
// ============================================================
// STATIC REFERENCE LINTER
// ============================================================
// Scans public/js/ for DOM id references and verifies each target
// exists in some public/*.html or public/components/*.html. Catches
// the class of bug where a component is moved/renamed/deleted and a
// JS file keeps calling getElementById('orphan') — the handler then
// silently no-ops at runtime. The lightbox that broke for Bedside
// pathway images was exactly this: the markup lived in
// calculators.html, but after the reorg the bedside tab never loaded
// it, so getElementById('img-lightbox') returned null.
//
// Also checks:
//  - <button data-img-src="/path"> resolves to a file in public/
//  - <audio|img src="/path"> resolves to a file in public/
//
// Prints a list of unresolved references and exits non-zero. An
// optional allowlist below handles IDs that are legitimately created
// at runtime (e.g. dynamically injected components).
// ============================================================

'use strict';

const fs = require('fs');
const path = require('path');

const ROOT = path.resolve(__dirname, '..');
const PUB = path.join(ROOT, 'public');

// ── Files to scan for id references ──────────────────────────
function walk(dir, ext, acc = []) {
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
    const p = path.join(dir, entry.name);
    if (entry.isDirectory()) walk(p, ext, acc);
    else if (ext.test(entry.name)) acc.push(p);
  }
  return acc;
}

const jsFiles = walk(path.join(PUB, 'js'), /\.(js|mjs)$/);
const htmlFiles = walk(PUB, /\.html$/);

// ── Collect every id defined anywhere ────────────────────────
// Both static HTML attributes and dynamic JS (innerHTML strings,
// createElement().id = 'X'). If an id is "defined" by any file in
// the repo, a reference to it is considered resolved — whether it
// appears in the DOM at any given moment depends on control flow,
// which this linter doesn't model. We only catch the dead-reference
// class of bug: a JS module reaches for an id that literally no
// source in the tree ever produces.
const definedIds = new Set();
function collect(src) {
  for (const m of src.matchAll(/\bid\s*=\s*["']([a-zA-Z_][\w-]*)["']/g)) definedIds.add(m[1]);
  for (const m of src.matchAll(/\.id\s*=\s*["']([a-zA-Z_][\w-]*)["']/g)) definedIds.add(m[1]);
}
for (const f of htmlFiles) collect(fs.readFileSync(f, 'utf8'));
for (const f of jsFiles) collect(fs.readFileSync(f, 'utf8'));

// Dynamic-id allowlist: created by JS via innerHTML or createElement.
// Keep this list short and specific; expand only when sure the id is
// genuinely runtime-constructed. Use prefixes ending in '-' to match
// an entire family.
const DYNAMIC_ID_PREFIXES = [
  'em-',            // bedside section containers built by sub-nav.js
  'shadess-',       // wellvisit SSHADESS domain injection
  'wv-panel-',      // wellvisit subpanel ids (these exist in HTML but also built in JS)
  'milestone-',     // milestones checklist
  'quiz-',          // learning hub quiz questions
  'lh-quiz-',       // learning hub quiz answers
  'pe-',            // PE guide steps are rendered dynamically
  'ros-',           // ROS dynamic rows
  'nrp-',           // NRP pathway dynamic widgets
  'seizure-',       // seizure pathway dynamic widgets
  'sepsis-',        // sepsis pathway dynamic widgets
  'airway-',        // airway pathway dynamic widgets
  'cardiac-',       // cardiac-arrest pathway dynamic widgets
  'anaph-',         // anaphylaxis
  'burn-',          // burns
  'vent-',          // ventilation
  'tox-',           // toxicology
  'trauma-',        // trauma
  'neo-',           // neonatal
  'resp-',          // respiratory
  'sed-',           // sedation
  'agit-',          // agitation
  'antiemetic-',    // antiemetics
  'antibio-',       // antimicrobials
  'bili-',          // bilirubin dynamic columns
  'bmi-',           // BMI chart elements
  'bp-',            // BP percentile chart elements
  'growth-',        // growth chart elements
  'dose-',          // dosing widgets
  'bsa-',           // BSA calc
  'equip-',         // equipment sizing
  'resus-',         // resus meds
  'gcs-',           // GCS components
  'vitals-',        // vitals reference
  'catchup-',       // catch-up schedule
  'totp-',          // 2FA elements
  'sessions-',      // active sessions list
  'nc-',            // nextcloud settings
  'mem-',           // memories
  'doc-',           // documents
  'audio-',         // audio backups
  'saved-',         // saved encounters
  'ext-',           // extensions CRUD
  'cms-',           // content manager
  'fpa-',           // forgot-password accept screen
  'rsp-',           // reset password
  'admin-',         // admin panel
  'adminms-',       // admin milestones
  '2fa-',           // 2FA UI
  'calc-',          // calculator panels (some may be missing from HTML)
];

function isDynamicId(id) {
  return DYNAMIC_ID_PREFIXES.some(p => id.startsWith(p));
}

// ── Scan JS for getElementById / querySelector('#id') / '#id' ─
const unresolved = [];
const idRef = /getElementById\s*\(\s*['"]([^'"]+)['"]\s*\)|querySelector(?:All)?\s*\(\s*['"]#([a-zA-Z_][\w-]*)\s*['"]|querySelector(?:All)?\s*\(\s*['"][^'"]*?#([a-zA-Z_][\w-]+)/g;
for (const f of jsFiles) {
  const src = fs.readFileSync(f, 'utf8');
  const seen = new Set();
  for (const m of src.matchAll(idRef)) {
    const id = m[1] || m[2] || m[3];
    if (!id || seen.has(id)) continue;
    seen.add(id);
    if (definedIds.has(id) || isDynamicId(id)) continue;
    unresolved.push({ file: path.relative(ROOT, f), id });
  }
}

// ── Scan HTML for asset references that must resolve on disk ──
const brokenAssets = [];
function checkAsset(file, p) {
  if (!p || !p.startsWith('/')) return;      // only root-absolute
  if (p.startsWith('//') || p.startsWith('/api/') || p.startsWith('/components/')) return;
  if (p.startsWith('/#')) return;            // hash-only
  const onDisk = path.join(PUB, p.split('?')[0].split('#')[0]);
  if (!fs.existsSync(onDisk)) brokenAssets.push({ file: path.relative(ROOT, file), path: p });
}

const ASSET_PATTERNS = [
  /data-img-src\s*=\s*["']([^"']+)["']/g,
  /<(?:img|audio|video|source|link)[^>]*\bsrc\s*=\s*["']([^"']+)["']/g,
  /<(?:link)[^>]*\bhref\s*=\s*["']([^"']+)["']/g,
];
for (const f of htmlFiles) {
  const html = fs.readFileSync(f, 'utf8');
  for (const pat of ASSET_PATTERNS) {
    for (const m of html.matchAll(pat)) checkAsset(f, m[1]);
  }
}

// ── Report ────────────────────────────────────────────────────
let failed = false;

if (unresolved.length) {
  console.error('\n❌ Unresolved DOM id references:');
  for (const { file, id } of unresolved) console.error(`  ${file}  ->  #${id}`);
  failed = true;
} else {
  console.log('✓ All JS id references resolve to an HTML id.');
}

if (brokenAssets.length) {
  console.error('\n❌ Broken asset paths:');
  for (const { file, path: p } of brokenAssets) console.error(`  ${file}  ->  ${p}`);
  failed = true;
} else {
  console.log('✓ All asset paths resolve to a file on disk.');
}

process.exit(failed ? 1 : 0);
