#!/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',
    'u_sigSides', 'u_sigRound', 'u_sigElong', 'u_sigTilt',
    'u_sigDrift', 'u_sigSway', 'u_sigSwayRate', 'u_sigSpin', 'u_sigBreathe',
    'u_sigHorizon', 'u_sigDepth', 'u_sigWash',
    'u_sigLine', 'u_sigSoft', 'u_sigTexture', 'u_sigFold',
    'u_sigFrameScale', 'u_sigFrameShift',
    // The identity artifacts. See look/Identity.js. A scene reads these through
    // castMain/inkMask/stageNode rather than directly, but a migrated scene may
    // legitimately branch on one — Prism Bloom takes its facet count from the
    // cast's side count — so they belong in the contract set.
    'u_castSides', 'u_castRound', 'u_castElong', 'u_castTilt',
    'u_castNotchN', 'u_castNotchD', 'u_castHollow',
    'u_chorusSides', 'u_chorusRound', 'u_chorusElong', 'u_chorusTilt',
    'u_chorusNotchN', 'u_chorusNotchD', 'u_chorusHollow',
    'u_inkWeight', 'u_inkEdge', 'u_inkFill', 'u_inkHatchAngle',
    'u_inkHatchScale', 'u_inkOutline', 'u_inkPosterize',
    'u_latKind', 'u_latJitter', 'u_latSpread',
    'u_latScaleSpread', 'u_latScaleBias', 'u_latScale',
    'u_focusN', 'u_focusR', 'u_focusPull',
]);

/**
 * What counts as honouring a personality trait, in shader source.
 *
 * The disqualification rule in look/Personality.js is only as good as these
 * declarations: a scene that claims `shape` and draws circles anyway will be
 * cast in the hexagon video and be the one shot that looks filmed elsewhere.
 * So the claim is machine-checked against the source rather than trusted.
 */
// A scene that draws the song's CAST is expressing `shape` more completely than
// sigShape ever did — the form is the subject rather than a hint applied to one
// — so castMain/castChorus count as evidence.
//
// The INK counts under `style` too, but only since surface treatment moved out
// of the scenes. It did not when the two were independent — an earlier version
// listed it here, Eclipse Field claimed a trait it had stopped honouring, and
// the runtime gate caught it. What changed is that sigGrain went to the post
// chain and sigEdge became inkStroke, leaving the trait with nothing of its own
// to express, so inkStroke and inkMask now read u_sigLine and u_sigSoft
// directly. A scene drawing in the song's hand honours the track's line weight
// by construction, and the runtime probe confirms it rather than taking it on
// trust.
//
// The same idea as TRAIT_EVIDENCE, for the identity artifacts: a scene that
// declares it consumes the cast has to actually draw it. Without this,
// `consumes` is a comment, and the migration becomes unverifiable the moment it
// is more than a handful of files.
const ARTIFACT_EVIDENCE = {
    cast:    /\bcast(Main|Chorus|SDF|Form)\s*\(/,
    ink:     /\bink(Mask|Value|Pattern|Stroke)\s*\(/,
    staging: /\b(stageNode|stageScale)\s*\(/,
};

const TRAIT_EVIDENCE = {
    shape:  /\b(sig(Shape|Form)|cast(Main|Chorus|SDF|Form))\s*\(/,
    camera: /\bsigCamera\s*\(/,
    space:  /\b(sigHorizonY|sigAir)\s*\(|\bu_sig(Horizon|Depth|Wash)\b/,
    style:  /\b(sigEdge|sigGrain|sigFolded|ink(Mask|Stroke|Value|Pattern))\s*\(|\bu_sig(Line|Soft|Texture|Fold)\b/,
};

// Every shader in this project lives inside a JS template literal, so a
// backtick anywhere in one silently closes it. Three times now that has cost a
// debugging round: twice in the preamble, where it produced a check page that
// hung on "starting…" with an empty console, and once in a scene, where at
// least the module failed to parse loudly. A GLSL comment is the natural place
// to reach for backticks when quoting a param name, which is exactly why this
// keeps happening.
//
// Checked across the contract AND every scene, since the scene case is the one
// a mechanical pass over sixty files will keep reintroducing.
console.log('\nshader literals');
{
    const targets = [join(SRC, 'engine/shader-contract.js'), ...walk(join(SRC, 'scenes'))];
    let clean = 0;
    for (const file of targets) {
        const src = readFileSync(file, 'utf8');
        const rel = relative(SRC, file).replace(/\\/g, '/');
        // Every template literal in the file, then the lines inside them that
        // carry a backtick without being an interpolation.
        const stray = [];
        const rx = /`([\s\S]*?)`/g;
        let m;
        while ((m = rx.exec(src)) !== null) {
            if (!/\bvec4 scene|precision highp|void main/.test(m[1])) continue;
            for (const line of m[1].split('\n')) {
                if (line.includes('`') && !line.includes('${')) stray.push(line);
            }
        }
        // A shader body that swallowed a closing backtick shows up as an
        // unbalanced count across the file.
        const ticks = (src.match(/`/g) || []).length;
        if (stray.length || ticks % 2 !== 0) {
            fail(`${rel}: backtick inside a shader literal — it closes the string` +
                 (stray.length ? `:\n${stray.map((l) => `      ${l.trim()}`).join('\n')}` : ''));
        } else clean++;
    }
    if (clean === targets.length) ok(`${clean} shader literals balanced, no stray backticks`);
}

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') {
            // 3D modules honour traits in JS, against the `personality` handed
            // to update(); there is no shader source to grep, so the evidence
            // check is just that they read it at all.
            if ((module.traits || []).length && !/personality/.test(String(module.update))) {
                fail(`${id}: declares traits but update() never reads \`personality\``);
            }
            continue;
        }

        const src = module.shader || '';
        // Comment-free copy for rules that ask "does the code do X". A scene
        // that mentions prev() in a comment explaining why it does NOT rely on
        // prev() should not be failed for relying on prev().
        const code = src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
        const declared = new Map();
        for (const [name, def] of Object.entries(module.params || {})) {
            if (def.uniform) declared.set(def.uniform, name);
        }

        // Collision with the shader contract. A param that reuses a contract
        // uniform name (u_width, u_time, u_seed...) is a GLSL redefinition error,
        // and the whole scene renders as a black frame with no other symptom.
        for (const [uniform, param] of declared) {
            if (CONTRACT_UNIFORMS.has(uniform)) {
                fail(`${id}: param '${param}' uses '${uniform}', which the shader ` +
                     `contract already declares — pick another 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)`);
        }

        // Personality traits: declaring one is a promise to express it.
        for (const trait of module.traits || []) {
            const evidence = TRAIT_EVIDENCE[trait];
            if (evidence && !evidence.test(src)) {
                fail(`${id}: declares trait '${trait}' but the shader never uses it — ` +
                     `either express it or drop the claim, or the scene will be cast ` +
                     `in tracks built on something it ignores`);
            }
        }

        for (const artifact of module.consumes || []) {
            const evidence = ARTIFACT_EVIDENCE[artifact];
            if (evidence && !evidence.test(src)) {
                fail(`${id}: declares it consumes '${artifact}' but the shader never calls it — ` +
                     `an artifact that is declared and ignored is worse than one that is ` +
                     `not declared, because the casting code will believe it`);
            }
        }
        // The reverse: using an artifact without declaring it hides the scene
        // from the migration status report and from anything that selects on
        // capability later.
        for (const [artifact, evidence] of Object.entries(ARTIFACT_EVIDENCE)) {
            if (evidence.test(src) && !(module.consumes || []).includes(artifact)) {
                fail(`${id}: uses the '${artifact}' artifact but does not declare it in \`consumes\``);
            }
        }

        // A dead camera: `p = sigCamera(p)` and then nothing reads p again.
        // This passed the evidence grep above, passed review, and shipped — the
        // Phase 9 render gate later measured the scene's response to the camera
        // at exactly zero. Cheaper to catch here than on a GPU.
        const cameraAssign = code.match(/(\w+)\s*=\s*sig(?:Camera|Folded)\s*\([^;]*\);/);
        if (cameraAssign) {
            const target = cameraAssign[1];
            const after = code.slice(code.indexOf(cameraAssign[0]) + cameraAssign[0].length);
            const reads = new RegExp(`\\b${target}\\b`).test(after);
            if (!reads) {
                fail(`${id}: assigns sigCamera to '${target}' and never reads it again — ` +
                     `the trait is declared but the image cannot change`);
            }
        }

        // A scene whose only content is the previous frame is black on its first
        // frames and different after a seek than after playback.
        if (/\bprev\s*\(/.test(code)) {
            const bodyBeforePrev = code.slice(0, code.search(/\bprev\s*\(/));
            if (!/\b(pal|palRamp|fbm|vnoise|hash1[12])\s*\(/.test(bodyBeforePrev)) {
                fail(`${id}: reads prev() without generating a base image first — ` +
                     `it will be black until feedback converges and will not survive a seek`);
            }
        }

        // Loop cost. GLSL needs a constant bound, so the pattern here is a
        // generous fixed bound plus an early break on the param that actually
        // decides the count — that break is what keeps the cost proportional to
        // what the look asked for. A big bound WITHOUT one runs every iteration
        // on every pixel at 4K, which the budget check will catch on a GPU and
        // this catches in a second.
        for (const loop of code.matchAll(/for\s*\(\s*int\s+\w+\s*=\s*0\s*;\s*\w+\s*<\s*(\d+)[^)]*\)/g)) {
            const bound = Number(loop[1]);
            const body = code.slice(code.indexOf(loop[0]) + loop[0].length, code.indexOf(loop[0]) + loop[0].length + 600);
            const breaksEarly = /\bbreak\s*;/.test(body);
            // Opt-out for a genuinely fixed-cost loop — sampling a curve at a
            // fixed resolution has nothing to break on. The author states it,
            // and the measured 4K budget check still governs.
            const at = src.indexOf(loop[0].replace(/\s+/g, ' ')) >= 0
                ? src.indexOf(loop[0].replace(/\s+/g, ' '))
                : src.search(new RegExp(`for\\s*\\(\\s*int\\s+\\w+\\s*=\\s*0\\s*;\\s*\\w+\\s*<\\s*${bound}\\b`));
            const optOut = at >= 0 && /\/\/\s*lint:\s*fixed-cost/.test(
                src.slice(Math.max(0, at - 220), at));
            if (optOut) continue;

            if (bound > 64) {
                fail(`${id}: fixed loop bound ${bound} is too large whatever it breaks on`);
            } else if (bound > 16 && !breaksEarly) {
                fail(`${id}: loop of ${bound} with no early break — bound it on the param ` +
                     `(\`if (i >= u_count) break;\`) so the cost follows what the look asked for`);
            }
        }

        // 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');
