#!/usr/bin/env node
// Static gates that don't need a GPU:
//
//   1. Determinism grep — no wall-clock or unseeded randomness in engine/scene code.
//   2. Scene schema lint — the params block and the shader source must agree,
//      in BOTH directions. A declared param that no shader reads is dead weight;
//      a uniform the shader reads that nothing declares is a silent zero, which
//      is the single most annoying way for a scene to look subtly wrong.
//
// Run: npm run lint:scenes

import { readdirSync, readFileSync, statSync } from 'fs';
import { join, relative, dirname } from 'path';
import { fileURLToPath, pathToFileURL } from 'url';

const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const SRC = join(root, 'src');

let failures = 0;
const fail = (msg) => { console.error(`  ✗ ${msg}`); failures++; };
const ok = (msg) => console.log(`  ✓ ${msg}`);

function walk(dir, out = []) {
    for (const entry of readdirSync(dir)) {
        const full = join(dir, entry);
        if (statSync(full).isDirectory()) walk(full, out);
        else if (entry.endsWith('.js')) out.push(full);
    }
    return out;
}

// ---------------------------------------------------------------- grep gate

const FORBIDDEN = [
    { pattern: /\bMath\.random\s*\(/, name: 'Math.random()', why: 'use Rng — unseeded randomness breaks reproducibility' },
    { pattern: /\bperformance\.now\s*\(/, name: 'performance.now()', why: 'use Timeline — wall clock breaks preview/export parity' },
    { pattern: /\bDate\.now\s*\(/, name: 'Date.now()', why: 'use Timeline' },
    { pattern: /\bnew Date\s*\(/, name: 'new Date()', why: 'use Timeline' },
];

// Directories whose output must be a pure function of (seed, params, frame).
const DETERMINISTIC_DIRS = ['engine', 'scenes', 'look', 'audio', 'params'];
// Files legitimately allowed a wall clock: perf measurement, not image content.
const ALLOWED = new Set(['engine/perf.js']);

console.log('\ndeterminism grep');
{
    let checked = 0;
    for (const dir of DETERMINISTIC_DIRS) {
        const full = join(SRC, dir);
        let files;
        try { files = walk(full); } catch { continue; }
        for (const file of files) {
            const rel = relative(SRC, file).replace(/\\/g, '/');
            if (ALLOWED.has(rel)) continue;
            checked++;
            const source = readFileSync(file, 'utf8');
            const lines = source.split('\n');
            lines.forEach((line, i) => {
                if (/^\s*(\/\/|\*)/.test(line)) return;  // comments may name them
                for (const f of FORBIDDEN) {
                    if (f.pattern.test(line)) fail(`${rel}:${i + 1} uses ${f.name} — ${f.why}`);
                }
            });
        }
    }
    if (!failures) ok(`${checked} files clean of wall-clock and unseeded randomness`);
}

// ------------------------------------------------------------- scene lint

const CONTRACT_UNIFORMS = new Set([
    'u_resolution', 'u_aspect', 'u_pixelScale', 'u_time', 'u_frame', 'u_progress',
    'u_seed', 'u_opacity', 'u_colors', 'u_colorCount', 'u_prev', 'u_hasPrev',
    'u_loudness', 'u_rms', 'u_bandSub', 'u_bandLow', 'u_bandMid', 'u_bandHigh',
    'u_bandAir', 'u_flux', 'u_centroid', 'u_flatness', 'u_width', 'u_beat',
    'u_beatPhase', 'u_barPhase', 'u_phrasePhase', 'u_sectionProgress',
    'u_sectionEnergy', 'u_buildSlope',
]);

console.log('\nscene schema lint');
{
    const { scenes, FAMILIES } = await import(pathToFileURL(join(SRC, 'scenes/registry.js')).href);
    const { validateModule } = await import(pathToFileURL(join(SRC, 'params/schema.js')).href);

    if (!scenes.length) fail('no scenes registered');

    const seenNames = new Set();

    for (const module of scenes) {
        const id = module.name || '<unnamed>';

        for (const err of validateModule(module)) fail(err);

        if (seenNames.has(id)) fail(`duplicate scene name '${id}'`);
        seenNames.add(id);

        if (module.family && !FAMILIES[module.family]) {
            fail(`${id}: unknown family '${module.family}'`);
        }
        if (module.kind !== 'fragment') continue;

        const src = module.shader || '';
        const declared = new Map();
        for (const [name, def] of Object.entries(module.params || {})) {
            if (def.uniform) declared.set(def.uniform, name);
        }

        // Direction 1: every declared uniform is actually read by the shader.
        for (const [uniform, param] of declared) {
            const used = new RegExp(`\\b${uniform}\\b`).test(src);
            if (!used) fail(`${id}: param '${param}' declares ${uniform}, but the shader never reads it`);
        }

        // Direction 2: every u_* the shader reads is declared somewhere.
        const referenced = new Set(src.match(/\bu_[A-Za-z0-9_]+\b/g) || []);
        for (const uniform of referenced) {
            if (CONTRACT_UNIFORMS.has(uniform)) continue;
            if (declared.has(uniform)) continue;
            fail(`${id}: shader reads ${uniform}, which no param declares (it will silently be 0)`);
        }

        // Rate params: anything the shader multiplies absolute time by must be
        // flagged `rate: true`, which stops reactivity and drift from touching it.
        // Modulating such a param jumps the phase by elapsed*delta — sixty seconds
        // in, a wobble of 0.05 throws the phase by three units between frames.
        // That measured as 6 flashes/second on Classic Wave, twice the WCAG 2.3.1
        // ceiling, and it gets worse the longer the track runs.
        const timeProducts = [
            ...src.matchAll(/u_time\s*\*\s*(u_[A-Za-z0-9_]+)/g),
            ...src.matchAll(/(u_[A-Za-z0-9_]+)\s*\*\s*u_time/g),
        ];
        for (const match of timeProducts) {
            const uniform = match[1];
            const paramName = declared.get(uniform);
            if (!paramName) continue;
            if (!module.params[paramName].rate) {
                fail(`${id}: '${paramName}' (${uniform}) multiplies u_time but is not marked ` +
                     `\`rate: true\` — reactivity or drift on it will cause phase jumps`);
            }
        }

        // A scene with a palette param should actually use the palette helpers,
        // otherwise the look generator cannot recolour it.
        const hasPalette = Object.values(module.params || {}).some((d) => d.type === 'palette');
        if (hasPalette && !/\b(pal|palRamp)\s*\(/.test(src)) {
            fail(`${id}: declares a palette but never calls pal()/palRamp()`);
        }
        // Hardcoded saturated colours are the thing that made the party-stage
        // shaders resist recolouring; flag the obvious cases.
        const literalColors = src.match(/vec3\s*\(\s*[01]\.\d+\s*,\s*[01]\.\d+\s*,\s*[01]\.\d+\s*\)/g) || [];
        const suspicious = literalColors.filter((c) => !/vec3\s*\(\s*0\.0*\s*,\s*0\.0*\s*,\s*0\.0*\s*\)/.test(c));
        if (hasPalette && suspicious.length > 2) {
            fail(`${id}: ${suspicious.length} hardcoded vec3 colour literals — use pal()`);
        }
    }

    if (!failures) ok(`${scenes.length} scenes: schemas and shaders agree both ways`);
    else console.log(`  (${scenes.length} scenes checked)`);
}

console.log('');
if (failures) {
    console.error(`FAILED — ${failures} problem${failures === 1 ? '' : 's'}\n`);
    process.exit(1);
}
console.log('all static gates passed\n');
