// Phase 7 gate — the library.
//
// Everything here is per-scene rather than per-phase, and it is the gate every
// future scene has to clear too. The static half (schema/shader agreement, rate
// params) runs in tools/lint-scenes.js; the range sweep is Phase 2's and the
// flash sweep is Phase 5's — both automatically cover new scenes because they
// iterate the registry.

import { check, expect, expectBelow } from './framework.js';
import { Engine } from '../engine/Engine.js';
import { Show } from '../Show.js';
import { scenes, FAMILIES, scenesInFamily } from '../scenes/registry.js';
import { defaultValues, sampleValues } from '../params/schema.js';
import { Rng } from '../engine/rng.js';
import { FeatureTrack, featureProviderFor } from '../audio/FeatureTrack.js';
import { synthesizeSectioned } from '../audio/synth.js';
import { generateLook } from '../look/LookGenerator.js';
import { frameDistance, frameMaxDelta, frameLuminance, frameVariance } from '../engine/hash.js';

const PALETTE = [
    [0.06, 0.03, 0.16], [0.85, 0.15, 0.55], [0.15, 0.75, 0.95],
    [0.98, 0.85, 0.35], [0.55, 0.25, 0.85], [0.2, 0.95, 0.6],
];

let cached = null;
function track7() {
    if (!cached) {
        cached = FeatureTrack.fromAudioBuffer(
            synthesizeSectioned({ bpm: 128, duration: 120, changeAt: 60 }), { fps: 60 });
    }
    return cached;
}

function makeEngine(width = 192, height = 108) {
    const engine = new Engine({ width, height });
    const track = track7();
    engine.timeline.setDuration(track.duration);
    engine.setFeatureProvider(featureProviderFor(track));
    return engine;
}

check(7, 'every family has enough scenes to choose between', () => {
    const counts = Object.keys(FAMILIES).map((f) => [f, scenesInFamily(f).length]);
    const thin = counts.filter(([, n]) => n < 2);
    return expect(thin.length === 0,
        counts.map(([f, n]) => `${f}:${n}`).join(' ') +
        (thin.length ? ` — too thin: ${thin.map(([f]) => f).join(', ')}` : ` · ${scenes.length} total`));
});

check(7, 'no two scenes render the same image', () => {
    // Catches a copy-paste scene whose shader was never actually changed, and
    // accidental near-duplicates that would waste a library slot.
    const engine = makeEngine();
    try {
        const frames = scenes.map((module) => {
            engine.setLayerSpecs([{
                module, params: defaultValues(module), seed: 99,
                opacity: 1, blend: 'normal', palette: PALETTE,
            }]);
            engine.compositor.reset();
            return { name: module.name, pixels: Uint8Array.from(engine.readPixels(engine.renderFrame(1200))) };
        });

        // Largest single-channel difference, not the mean: two sparse scenes are
        // both mostly black, so their MEAN distance is tiny even when they look
        // nothing alike. Identical scenes score 0 here; different ones score high.
        let closest = 255;
        let pair = '';
        for (let i = 0; i < frames.length; i++) {
            for (let j = i + 1; j < frames.length; j++) {
                const d = frameMaxDelta(frames[i].pixels, frames[j].pixels);
                if (d < closest) { closest = d; pair = `${frames[i].name} / ${frames[j].name}`; }
            }
        }
        return expect(closest > 24, `closest pair ${pair} at max delta ${closest} (floor 24)`);
    } finally {
        engine.dispose();
    }
}, { slow: true });

check(7, 'every scene stays live across seeds and section energies', () => {
    // The per-scene acceptance run: several seeds, both a quiet and a loud
    // context, checking nothing goes black, blows out or freezes flat.
    const track = track7();
    const quiet = track.sections.reduce((a, b) => (a.energy < b.energy ? a : b));
    const loud = track.sections.reduce((a, b) => (a.energy > b.energy ? a : b));
    const problems = [];
    let rendered = 0;

    for (const module of scenes) {
        const engine = makeEngine();
        try {
            for (let s = 0; s < 3; s++) {
                const rng = new Rng(4200 + s * 7919);
                const bias = s === 0 ? { energy: 0.15, density: 0.2, motion: 0.2 }
                          : s === 1 ? { energy: 0.5, density: 0.5, motion: 0.5 }
                                    : { energy: 0.95, density: 0.9, motion: 0.9 };
                engine.setLayerSpecs([{
                    module, params: sampleValues(module, rng, bias), seed: s * 31 + 5,
                    opacity: 1, blend: 'normal', palette: PALETTE,
                }]);

                for (const section of [quiet, loud]) {
                    const frame = section.startFrame + 120;
                    engine.compositor.reset();
                    const pixels = engine.readPixels(engine.renderFrame(frame));
                    rendered++;
                    const lum = frameLuminance(pixels);
                    const variance = frameVariance(pixels);
                    const accent = module.role === 'accent';
                    const dead = accent ? variance < 0.0008
                                        : (lum < 0.0008 || lum > 0.99 || variance < 0.0015);
                    if (dead) {
                        problems.push(`${module.name} s${s} ${section.kind}: ` +
                            `lum ${lum.toFixed(4)} var ${variance.toFixed(4)}`);
                    }
                }
            }
        } catch (err) {
            problems.push(`${module.name}: ${err.message}`);
        } finally {
            engine.dispose();
        }
    }
    return expect(problems.length === 0,
        problems.length ? problems.slice(0, 5).join(' · ')
                        : `${rendered} frames across ${scenes.length} scenes, all live`);
}, { slow: true });

check(7, 'every scene animates rather than sitting still', () => {
    // A scene that renders a beautiful static frame passes every other check and
    // is useless. Compare frames two seconds apart.
    const engine = makeEngine();
    const problems = [];
    try {
        for (const module of scenes) {
            engine.setLayerSpecs([{
                module, params: defaultValues(module), seed: 1234,
                opacity: 1, blend: 'normal', palette: PALETTE,
            }]);
            engine.compositor.reset();
            const a = Uint8Array.from(engine.readPixels(engine.renderFrame(1200)));
            engine.compositor.reset();
            const b = Uint8Array.from(engine.readPixels(engine.renderFrame(1320)));
            // Measured as the largest single-channel change, not the mean: a
            // sparse scene (thin bars on black) moves few pixels, so a mean-based
            // threshold fails it for being tasteful rather than for being static.
            const d = frameMaxDelta(a, b);
            if (d < 12) problems.push(`${module.name}: max channel delta only ${d} over 2s`);
        }
        return expect(problems.length === 0,
            problems.length ? problems.join(' · ') : `${scenes.length} scenes all move`);
    } finally {
        engine.dispose();
    }
}, { slow: true });

check(7, 'every scene is deterministic', () => {
    // Judged on a one-LSB tolerance rather than bit-exact hashes.
    //
    // With the engine primed, most scenes reproduce byte-for-byte. The heaviest
    // shaders do not quite: they come back with a handful of pixels differing by
    // 1/255, which is GPU floating-point variance under differing load, not a
    // logic fault. Demanding bit-exactness of them would be demanding something
    // the hardware does not offer, so the criterion is "no visible difference"
    // — and 1/255 is comfortably below that. Anything with a real bug scores in
    // the tens or hundreds here, not 1. See PLAN.md §1.
    const problems = [];
    let worst = 0;
    let worstScene = '';

    for (const module of scenes) {
        const engine = makeEngine(128, 72);
        try {
            engine.setLayerSpecs([{
                module, params: defaultValues(module), seed: 4242,
                opacity: 1, blend: 'normal', palette: PALETTE,
            }]);
            engine.prime(600);

            const capture = () => {
                engine.compositor.reset();
                const out = [];
                for (let f = 600; f < 620; f++) {
                    out.push(Uint8Array.from(engine.readPixels(engine.renderFrame(f))));
                }
                return out;
            };
            const a = capture();
            const b = capture();

            let sceneWorst = 0;
            for (let i = 0; i < a.length; i++) {
                sceneWorst = Math.max(sceneWorst, frameMaxDelta(a[i], b[i]));
            }
            if (sceneWorst > worst) { worst = sceneWorst; worstScene = module.name; }
            if (sceneWorst > 1) problems.push(`${module.name}: max delta ${sceneWorst}`);
        } finally {
            engine.dispose();
        }
    }
    return expect(problems.length === 0,
        problems.length ? problems.join(' · ')
                        : `${scenes.length} scenes reproducible · worst ${worst}/255 (${worstScene || 'none'})`);
}, { slow: true });

check(7, 'every scene stays within the 4K frame budget', () => {
    // 16.7ms is the realtime bar; at 4K a scene is allowed more, but a scene an
    // order of magnitude over would make a six-minute export unreasonable.
    const engine = makeEngine(3840, 2160);
    const timings = [];
    try {
        for (const module of scenes) {
            engine.setLayerSpecs([{
                module, params: defaultValues(module), seed: 7,
                opacity: 1, blend: 'normal', palette: PALETTE,
            }]);
            engine.renderFrame(1200);                 // compile and warm
            const started = performance.now();
            for (let f = 1200; f < 1210; f++) engine.renderFrame(f);
            engine.readPixels(engine.compositor.outputTarget);   // force the GPU to finish
            timings.push({ name: module.name, ms: (performance.now() - started) / 10 });
        }
        timings.sort((a, b) => b.ms - a.ms);
        const worst = timings[0];
        return expectBelow(worst.ms, 60,
            `worst ${worst.name} ${worst.ms.toFixed(1)}ms/frame at 3840x2160 · ` +
            timings.slice(0, 3).map((t) => `${t.name} ${t.ms.toFixed(1)}`).join(', '));
    } finally {
        engine.dispose();
    }
}, { slow: true });

check(7, 'quiet sections now get minimal scenes', () => {
    // The concrete payoff of filling the family. Before Phase 7 there were no
    // 'minimal' scenes, so intros and breakdowns fell through to flow/organic
    // and every track opened at full density.
    const track = track7();
    const kinds = { intro: 0, breakdown: 0, outro: 0 };
    const restful = new Set(['minimal', 'flow', 'organic']);
    let total = 0;
    let restfulCount = 0;
    let minimalCount = 0;

    for (let s = 0; s < 24; s++) {
        const look = generateLook(track, { seed: 11000 + s * 104729 });
        for (const section of look.sections) {
            if (!(section.kind in kinds)) continue;
            total++;
            const family = section.layers[0].module.family;
            if (restful.has(family)) restfulCount++;
            if (family === 'minimal') minimalCount++;
        }
    }
    return expect(total > 0 && restfulCount === total && minimalCount > 0,
        `${restfulCount}/${total} quiet sections got a restful family, ` +
        `${minimalCount} of them minimal, across 24 seeds`);
});

check(7, 'the library still renders whole looks end to end', () => {
    const track = track7();
    const problems = [];
    let sections = 0;

    for (let s = 0; s < 6; s++) {
        const show = new Show({ width: 160, height: 90 });
        try {
            show.useTrack(track, generateLook(track, { seed: 21000 + s * 15485863 }));
            for (const section of show.look.sections) {
                sections++;
                const frame = section.startFrame + Math.floor((section.endFrame - section.startFrame) / 2);
                show.engine.compositor.reset();
                const pixels = show.readPixels(show.renderFrame(frame));
                const lum = frameLuminance(pixels);
                const variance = frameVariance(pixels);
                if (lum < 0.0008 || lum > 0.99 || variance < 0.0015) {
                    problems.push(`seed ${s} ${section.kind} ` +
                        `[${section.layers.map((l) => l.module.name).join(' + ')}]`);
                }
            }
        } catch (err) {
            problems.push(`seed ${s}: ${err.message}`);
        } finally {
            show.dispose();
        }
    }
    return expect(problems.length === 0,
        problems.length ? problems.slice(0, 4).join(' · ')
                        : `${sections} sections across 6 seeds, all live`);
}, { slow: true });
