// Phase 3 gate — look generation. "A" complete: a track in, a coherent video out.
//
// The load-bearing check here is look-space spread. It is entirely possible to
// build a generator that is deterministic, valid and well-typed and that produces
// visually identical output for every seed — failing the whole premise of the
// project (PLAN.md, "sameness across tracks") while passing every other test.
// The contact sheet measures it directly.

import { check, expect } from './framework.js';
import { Engine } from '../engine/Engine.js';
import { FeatureTrack, featureProviderFor } from '../audio/FeatureTrack.js';
import { synthesizeBeat, synthesizeSectioned } from '../audio/synth.js';
import { generateLook, rerollSection, rerollLook, describeLook } from '../look/LookGenerator.js';
import { paletteContrast, relativeLuminance } from '../look/palette.js';
import { frameDistance, frameLuminance, frameVariance } from '../engine/hash.js';
import { testTrack } from './phase1.js';

/** Four deliberately different tracks: the differentiation gate needs real spread. */
let cachedBattery = null;
export function battery() {
    if (cachedBattery) return cachedBattery;
    const specs = [
        { name: 'dark ambient',  buffer: synthesizeBeat({ bpm: 92, duration: 60, hats: false, kickGain: 0.4, padRoot: 55, padGain: 0.22 }) },
        { name: 'mid house',     buffer: synthesizeBeat({ bpm: 124, duration: 60, hatGain: 0.25, padRoot: 165 }) },
        { name: 'bright techno', buffer: synthesizeBeat({ bpm: 140, duration: 60, hatGain: 0.5, padRoot: 440, padGain: 0.2 }) },
        { name: 'structured',    buffer: synthesizeSectioned({ bpm: 128, duration: 120, changeAt: 60 }) },
    ];
    cachedBattery = specs.map((s) => ({
        name: s.name,
        track: FeatureTrack.fromAudioBuffer(s.buffer, { fps: 60 }),
    }));
    return cachedBattery;
}

export function renderLookFrame(engine, track, look, frame) {
    const section = look.sections[track.sectionIndexAt(frame)] || look.sections[0];
    const layer = section.layers[0];
    engine.setLayerSpecs([{
        module: layer.module,
        params: layer.params,
        seed: layer.seed,
        opacity: layer.opacity,
        blend: layer.blend,
        palette: look.palette,
    }]);
    engine.compositor.setPost(look.post).setFeedback(look.feedback);
    engine.compositor.reset();
    return Uint8Array.from(engine.readPixels(engine.renderFrame(frame)));
}

check(3, 'the same audio always produces the same look', () => {
    const track = testTrack();
    const samples = new Float32Array(2048).map((_, i) => Math.sin(i * 0.01));
    const a = generateLook(track, { samples });
    const b = generateLook(track, { samples });

    if (a.seed !== b.seed) return expect(false, `seeds differ: ${a.seed} vs ${b.seed}`);
    const sameScenes = a.sections.every((s, i) => s.layers[0].module.name === b.sections[i].layers[0].module.name);
    const sameParams = JSON.stringify(a.sections.map((s) => s.layers[0].params))
                     === JSON.stringify(b.sections.map((s) => s.layers[0].params));
    const samePalette = JSON.stringify(a.palette) === JSON.stringify(b.palette);

    return expect(sameScenes && sameParams && samePalette,
        `seed ${a.seed.toString(16)} · scenes ${sameScenes} · params ${sameParams} · palette ${samePalette}`);
});

check(3, 'different audio content produces a different seed', () => {
    const track = testTrack();
    const a = generateLook(track, { samples: new Float32Array(4096).map((_, i) => Math.sin(i * 0.01)) });
    const b = generateLook(track, { samples: new Float32Array(4096).map((_, i) => Math.sin(i * 0.013)) });
    return expect(a.seed !== b.seed, `${a.seed.toString(16)} vs ${b.seed.toString(16)}`);
});

check(3, 'palettes clear the contrast floor', () => {
    const problems = [];
    for (const { name, track } of battery()) {
        for (let s = 0; s < 12; s++) {
            const look = generateLook(track, { seed: 1000 + s * 7919 });
            const { luminanceSpread, chromaSpread } = paletteContrast(look.palette);
            if (luminanceSpread < 0.18) {
                problems.push(`${name} seed ${s}: luminance spread ${luminanceSpread.toFixed(3)}`);
            }
            if (chromaSpread < 0.20) {
                problems.push(`${name} seed ${s}: chroma spread ${chromaSpread.toFixed(3)}`);
            }
            if (look.palette.some((c) => c.some((v) => !Number.isFinite(v) || v < 0 || v > 1))) {
                problems.push(`${name} seed ${s}: colour out of gamut`);
            }
        }
    }
    return expect(problems.length === 0,
        problems.length ? problems.slice(0, 4).join(' · ') : '48 palettes all usable');
});

check(3, 'look space is genuinely wide across seeds', () => {
    // The seed contact sheet, measured. One frame per seed, mean pairwise
    // distance. A collapsed generator fails here and nowhere else.
    const track = testTrack();
    const engine = new Engine({ width: 160, height: 90 });
    try {
        engine.timeline.setDuration(track.duration);
        engine.setFeatureProvider(featureProviderFor(track));

        const frames = [];
        for (let s = 0; s < 16; s++) {
            const look = generateLook(track, { seed: 7000 + s * 104729 });
            frames.push(renderLookFrame(engine, track, look, 1500));
        }

        let sum = 0;
        let pairs = 0;
        let minDistance = 1;
        for (let i = 0; i < frames.length; i++) {
            for (let j = i + 1; j < frames.length; j++) {
                const d = frameDistance(frames[i], frames[j]);
                sum += d; pairs++;
                minDistance = Math.min(minDistance, d);
            }
        }
        const mean = sum / pairs;
        return expect(mean > 0.08 && minDistance > 0.01,
            `mean pairwise distance ${mean.toFixed(4)} (floor 0.08), closest pair ${minDistance.toFixed(4)} (floor 0.01)`);
    } finally {
        engine.dispose();
    }
}, { slow: true });

check(3, 'different tracks get different looks at the same seed', () => {
    const looks = battery().map(({ name, track }) => ({
        name,
        look: generateLook(track, { seed: 42 }),   // fixed seed: the difference must come from the audio
    }));

    const palettes = looks.map(({ look }) => look.palette.map(relativeLuminance).join(','));
    const uniquePalettes = new Set(palettes).size;
    const sceneSets = looks.map(({ look }) =>
        [...new Set(look.sections.map((s) => s.layers[0].module.name))].sort().join('+'));

    return expect(uniquePalettes === looks.length,
        `${uniquePalettes}/${looks.length} distinct palettes at a fixed seed · ` +
        looks.map((l, i) => `${l.name}→${sceneSets[i]}`).join(' · '));
});

check(3, 'every generated look renders a live frame on every track', () => {
    // The generator wanders into corners of the parameter space that the Phase 2
    // sweep only tests one axis at a time; this tests them in combination.
    const problems = [];
    let rendered = 0;

    for (const { name, track } of battery()) {
        const engine = new Engine({ width: 160, height: 90 });
        try {
            engine.timeline.setDuration(track.duration);
            engine.setFeatureProvider(featureProviderFor(track));

            for (let s = 0; s < 6; s++) {
                const look = generateLook(track, { seed: 300 + s * 15485863 });
                for (const section of look.sections) {
                    const frame = Math.min(track.frameCount - 1,
                        section.startFrame + Math.floor((section.endFrame - section.startFrame) / 2));
                    const pixels = renderLookFrame(engine, track, look, frame);
                    rendered++;
                    const lum = frameLuminance(pixels);
                    const variance = frameVariance(pixels);
                    if (lum < 0.0008 || lum > 0.99 || variance < 0.0015) {
                        problems.push(`${name} s${s} ${section.kind}/${section.layers[0].module.name}: ` +
                            `lum ${lum.toFixed(4)} var ${variance.toFixed(4)}`);
                    }
                }
            }
        } finally {
            engine.dispose();
        }
    }
    return expect(problems.length === 0,
        problems.length ? problems.slice(0, 5).join(' · ') : `${rendered} generated frames, all live`);
}, { slow: true });

check(3, 'sections of the same kind share a scene', () => {
    // Coherence: a track's drops should look like each other, or the video reads
    // as a shuffle rather than as one piece.
    const { track } = battery()[3];
    const look = generateLook(track, { seed: 99 });
    const byKind = new Map();
    let violations = 0;
    for (const s of look.sections) {
        const name = s.layers[0].module.name;
        if (byKind.has(s.kind) && byKind.get(s.kind) !== name) violations++;
        byKind.set(s.kind, name);
    }
    return expect(violations === 0,
        violations ? `${violations} kind(s) using multiple scenes` : describeLook(look));
});

check(3, 'reroll changes a section and respects locks', () => {
    const track = testTrack();
    const look = generateLook(track, { seed: 555 });
    const before = JSON.stringify(look.sections[0].layers[0].params);

    rerollSection(look, track, 0, 1);
    const afterUnlocked = JSON.stringify(look.sections[0].layers[0].params);

    look.sections[0].locked = true;
    rerollSection(look, track, 0, 2);
    const afterLocked = JSON.stringify(look.sections[0].layers[0].params);

    return expect(before !== afterUnlocked && afterUnlocked === afterLocked,
        `changed when unlocked: ${before !== afterUnlocked}, held when locked: ${afterUnlocked === afterLocked}`);
});

check(3, 'a whole-track reroll preserves locked sections', () => {
    const track = testTrack();
    const look = generateLook(track, { seed: 777 });
    look.sections[0].locked = true;
    const lockedScene = look.sections[0].layers[0].module.name;
    const lockedParams = JSON.stringify(look.sections[0].layers[0].params);

    const next = rerollLook(look, track, 888);
    return expect(
        next.sections[0].layers[0].module.name === lockedScene &&
        JSON.stringify(next.sections[0].layers[0].params) === lockedParams,
        `locked section survived a full reroll (${lockedScene})`);
});
