// Phase 5 gate — compositing depth. "C" complete.
//
// Multi-layer stacks, blend modes, feedback, the post chain and 3D layers. The
// feedback stability run and the flash-rate check matter most: both fail
// silently, slowly, and only in the finished file.

import { check, expect, expectBelow } from './framework.js';
import { Engine } from '../engine/Engine.js';
import { Show } from '../Show.js';
import { generateLook } from '../look/LookGenerator.js';
import { FeatureTrack, featureProviderFor } from '../audio/FeatureTrack.js';
import { synthesizeSectioned } from '../audio/synth.js';
import { defaultValues, sampleValues } from '../params/schema.js';
import { scenes } from '../scenes/registry.js';
import { Rng } from '../engine/rng.js';
import { frameLuminance, frameVariance, frameMaxDelta } from '../engine/hash.js';
import { peakFlashRate } from '../engine/flash.js';
import { particleField } from '../scenes/layers3d/particles.js';
import { nebula } from '../scenes/shader/nebula.js';
import { BLEND_MODES } from '../engine/Layer.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 track5() {
    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 = track5();
    engine.timeline.setDuration(track.duration);
    engine.setFeatureProvider(featureProviderFor(track));
    return engine;
}

check(5, 'a 3D layer renders and composites', () => {
    const engine = makeEngine();
    try {
        engine.setLayerSpecs([{
            module: particleField, params: defaultValues(particleField),
            seed: 31337, opacity: 1, blend: 'normal', palette: PALETTE,
        }]);
        const pixels = engine.readPixels(engine.renderFrame(1200));
        const lum = frameLuminance(pixels);
        const variance = frameVariance(pixels);
        // Judged on variance, not mean luminance: this is an additive accent over
        // a dark field, so most of the frame is legitimately black and a mean of
        // ~0.001 is the correct result rather than a dead render.
        return expect(variance > 0.005 && lum > 0.0002,
            `particle field: variance ${variance.toFixed(4)}, mean luminance ${lum.toFixed(4)}`);
    } finally {
        engine.dispose();
    }
});

check(5, '3D layer motion is analytic, so a seek matches sequential playback', () => {
    // The rule 3D layers must obey: no integrated state. An integrated particle
    // system drifts apart between a seek and playback and silently breaks export
    // parity.
    const engine = makeEngine();
    try {
        engine.setLayerSpecs([{
            module: particleField, params: defaultValues(particleField),
            seed: 31337, opacity: 1, blend: 'normal', palette: PALETTE,
        }]);
        const sequential = engine.hashRun(0, 200);
        engine.compositor.reset();
        const direct = engine.hashCurrent(engine.renderFrame(199));
        return expect(direct === sequential[199],
            `seek→199 ${direct} vs sequential ${sequential[199]}`);
    } finally {
        engine.dispose();
    }
});

check(5, 'every blend mode composites two layers distinctly', () => {
    const engine = makeEngine();
    try {
        const seen = new Map();
        for (const blend of BLEND_MODES) {
            engine.setLayerSpecs([
                { module: nebula, params: defaultValues(nebula), seed: 11, opacity: 1, blend: 'normal', palette: PALETTE },
                { module: particleField, params: defaultValues(particleField), seed: 22, opacity: 0.6, blend, palette: PALETTE },
            ]);
            engine.compositor.reset();
            seen.set(blend, engine.hashCurrent(engine.renderFrame(1200)));
        }
        const unique = new Set(seen.values()).size;
        return expect(unique === BLEND_MODES.length,
            `${unique}/${BLEND_MODES.length} blend modes produced distinct output`);
    } finally {
        engine.dispose();
    }
});

check(5, 'layer solo isolates each layer', () => {
    const engine = makeEngine();
    try {
        engine.setLayerSpecs([
            { module: nebula, params: defaultValues(nebula), seed: 11, opacity: 1, blend: 'normal', palette: PALETTE },
            { module: particleField, params: defaultValues(particleField), seed: 22, opacity: 0.8, blend: 'add', palette: PALETTE },
        ]);
        engine.compositor.reset();
        const both = engine.hashCurrent(engine.renderFrame(1200));

        engine.compositor.soloIndex = 0;
        engine.compositor.reset();
        const first = engine.hashCurrent(engine.renderFrame(1200));

        engine.compositor.soloIndex = 1;
        engine.compositor.reset();
        const second = engine.hashCurrent(engine.renderFrame(1200));
        engine.compositor.soloIndex = -1;

        return expect(new Set([both, first, second]).size === 3,
            `combined ${both}, solo-0 ${first}, solo-1 ${second}`);
    } finally {
        engine.dispose();
    }
});

check(5, 'feedback stays stable over a long run', () => {
    // A feedback loop with gain at or above 1 saturates to white; too much decay
    // and it dies to black. Either takes thousands of frames to show, so it never
    // appears in a short check — and always appears in a six-minute export.
    const engine = makeEngine(128, 72);
    try {
        engine.setLayerSpecs([{
            module: nebula, params: defaultValues(nebula),
            seed: 4242, opacity: 1, blend: 'normal', palette: PALETTE,
        }]);
        engine.compositor.setFeedback({ amount: 0.75, decay: 0.94, zoom: 1.006, rotate: 0.003 });
        engine.compositor.reset();

        const track = track5();
        const samples = [];
        for (let f = 0; f < 10000; f++) {
            engine.timeline.seek(f % track.frameCount);
            const target = engine.compositor.render({
                timeline: engine.timeline, features: track.at(f % track.frameCount),
            });
            if (f % 500 === 0 || f === 9999) samples.push(frameLuminance(engine.readPixels(target)));
        }

        const min = Math.min(...samples);
        const max = Math.max(...samples);
        const tail = samples.slice(-4);
        return expect(max < 0.97 && min > 0.003,
            `10000 frames · luminance ${min.toFixed(4)}..${max.toFixed(4)} · ` +
            `tail ${tail.map((v) => v.toFixed(3)).join(', ')}`);
    } finally {
        engine.dispose();
    }
}, { slow: true });

check(5, 'generated looks stay within the flash-rate ceiling', () => {
    // WCAG 2.3.1 / Harding: at most three light-dark cycles per second. An
    // unsupervised generator finds unsafe states on its own, and this is the only
    // thing between one of them and a published video.
    const track = track5();
    const problems = [];
    let worst = 0;
    let worstLabel = '';

    for (let s = 0; s < 5; s++) {
        const show = new Show({ width: 96, height: 54 });
        try {
            show.useTrack(track, generateLook(track, { seed: 5000 + s * 7919 }));
            // Per SHOT, not per section: a section rotates between two or three
            // stage visuals and only the first of them sits at the section start,
            // so sweeping sections would leave most of what ships unmeasured. The
            // window opens before the cut so the cut itself is inside it.
            for (const cue of show.arc.cues.slice(0, 8)) {
                const start = Math.max(0, cue.startFrame - 20);
                const end = Math.min(cue.endFrame, start + 300);   // 5 seconds
                if (end - start < 120) continue;

                show.engine.compositor.reset();
                for (let f = Math.max(0, start - 40); f < start; f++) show.renderFrame(f);

                const luminance = [];
                for (let f = start; f < end; f++) {
                    luminance.push(frameLuminance(show.readPixels(show.renderFrame(f))));
                }
                const rate = peakFlashRate(luminance, 60);
                const section = show.look.sections[cue.sectionIndex];
                const scene = (section.variants[cue.variant] || section.layers)[0].module.name;
                const label = `seed ${s} ${section.kind}/${scene}`;
                if (rate > worst) { worst = rate; worstLabel = label; }
                if (rate > 3) problems.push(`${label}: ${rate}/s`);
            }
        } finally {
            show.dispose();
        }
    }
    return expect(problems.length === 0,
        problems.length
            ? problems.slice(0, 4).join(' · ')
            : `peak ${worst} flashes/s (ceiling 3) at ${worstLabel}`);
}, { slow: true });

check(5, 'no scene in the library strobes at aggressive settings', () => {
    // Per-scene rather than per-look: the look generator only samples a slice of
    // the parameter space, so a scene can hide an unsafe region for a long time.
    // This drives every scene to high energy/density/motion across several seeds,
    // which is where strobing lives. Phase 7 runs the same check for each new
    // scene before it joins the library.
    const track = track5();
    const problems = [];
    let worst = 0;
    let worstScene = '';

    for (const module of scenes) {
        for (let s = 0; s < 4; s++) {
            const engine = makeEngine(240, 135);
            try {
                const rng = new Rng(1000 + s * 7919);
                engine.setLayerSpecs([{
                    module,
                    params: sampleValues(module, rng, { energy: 0.9, density: 0.9, motion: 0.9 }),
                    seed: s * 31 + 7, opacity: 1, blend: 'normal', palette: PALETTE,
                }]);
                engine.compositor.reset();
                const luminance = [];
                for (let f = 4000; f < 4240; f++) {
                    luminance.push(frameLuminance(engine.readPixels(engine.renderFrame(f))));
                }
                const rate = peakFlashRate(luminance, 60);
                if (rate > worst) { worst = rate; worstScene = `${module.name} seed ${s}`; }
                if (rate > 3) problems.push(`${module.name} seed ${s}: ${rate}/s`);
            } finally {
                engine.dispose();
            }
        }
    }
    return expect(problems.length === 0,
        problems.length ? problems.slice(0, 4).join(' · ')
                        : `${scenes.length} scenes, worst ${worst} flashes/s (ceiling 3) at ${worstScene}`);
}, { slow: true });

check(5, 'the full stack holds interactive frame rates at preview resolution', () => {
    const track = track5();
    const show = new Show({ width: 1280, height: 720 });
    try {
        show.useTrack(track, generateLook(track, { seed: 2468 }));
        show.look.feedback.amount = Math.max(0.4, show.look.feedback.amount);

        for (let f = 1200; f < 1230; f++) show.renderFrame(f);   // warm caches

        const started = performance.now();
        const frames = 60;
        for (let f = 1300; f < 1300 + frames; f++) show.renderFrame(f);
        const perFrame = (performance.now() - started) / frames;

        const layers = show.arc.activeLayers.length;
        return expectBelow(perFrame, 16.7,
            `${perFrame.toFixed(2)}ms/frame at 1280x720 with ${layers} layer(s) + feedback + post`);
    } finally {
        show.dispose();
    }
}, { slow: true });

check(5, 'multi-layer looks render live frames across the library', () => {
    const track = track5();
    const problems = [];
    let stacks = 0;

    for (let s = 0; s < 8; s++) {
        const show = new Show({ width: 160, height: 90 });
        try {
            show.useTrack(track, generateLook(track, { seed: 9000 + s * 104729 }));
            for (const section of show.look.sections) {
                if (section.layers.length > 1) stacks++;
                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(' + ')}]: ` +
                        `lum ${lum.toFixed(4)} var ${variance.toFixed(4)}`);
                }
            }
        } finally {
            show.dispose();
        }
    }
    return expect(problems.length === 0,
        problems.length ? problems.slice(0, 4).join(' · ')
                        : `${stacks} multi-layer section(s) across 8 seeds, all live`);
}, { slow: true });

check(5, 'determinism survives feedback, post and 3D layers together', () => {
    // Two INDEPENDENTLY GENERATED looks, rendered through ONE engine.
    //
    // This used to build two Shows and compare them, which also compared two
    // WebGL contexts — and once the library grew heavier scenes that started
    // failing at 1-2/255 with nothing wrong: measured, the same context renders
    // the same frames bit-exactly (delta 0 over 40 frames, feedback at 0.6),
    // while two contexts on the same GPU disagree by up to 2/255 whether
    // feedback is on or off. That is driver-level variance between contexts, and
    // it is not what this check is for.
    //
    // Sharing the engine isolates the question that matters — does generating
    // the look twice, and driving layers, feedback, post and a 3D layer twice,
    // produce the same images — and lets it stay bit-exact rather than
    // acquiring a tolerance that would hide a real fault.
    // The two halves are asked separately, because only one of them can be
    // answered bit-exactly. Generation is pure JS and must match EXACTLY —
    // anything else is a real fault. Rendering the same look twice comes back
    // within 1/255 but not always at 0: measured, rebuilding a look recompiles
    // its programs, and a freshly linked program can differ from the previous
    // one by a single level on the heavier scenes. That is the same GPU variance
    // Phase 7 and PLAN.md §1 already account for, and hashing cannot express it.
    const show = new Show({ width: 128, height: 72 });
    try {
        const shape = (look) => JSON.stringify(look.sections.map((s) =>
            (s.variants || [s.layers]).map((v) => v.map((l) =>
                [l.module.name, l.blend, l.opacity, l.seed, l.params]))));

        const lookA = generateLook(track5(), { seed: 1357 });
        const lookB = generateLook(track5(), { seed: 1357 });
        const generationMatches = shape(lookA) === shape(lookB)
            && JSON.stringify(lookA.personality) === JSON.stringify(lookB.personality);

        const run = (look) => {
            show.setLook(look);
            show.look.feedback.amount = 0.6;
            show.engine.compositor.reset();
            const out = [];
            for (let f = 3000; f < 3060; f++) {
                out.push(Uint8Array.from(show.readPixels(show.renderFrame(f))));
            }
            return out;
        };

        show.useTrack(track5(), lookA);
        const fa = run(lookA);
        const fb = run(lookB);
        const worst = Math.max(...fa.map((frame, i) => frameMaxDelta(frame, fb[i])));

        // Two levels rather than one, and only because feedback is on: the loop
        // re-reads its own output at 0.6 gain every frame, so a single-level
        // difference on frame n is still a fraction of a level on frame n+5.
        // Measured at 2/255 over 60 frames; a real fault scores in the tens.
        return expect(generationMatches && worst <= 2,
            `generation identical: ${generationMatches} · worst render delta ${worst}/255 over 60 frames`);
    } finally {
        show.dispose();
    }
});
