// Phase 1 gate — the offline audio pipeline feeding the renderer.
//
// The numeric correctness of tempo and segmentation is covered by node tests
// against synthetic ground truth (test/audio.test.js) and, for real material, by
// the click track. What can only be checked here is the join: that the frame-indexed
// table reaches the shader, and that driving the clock from audio produces exactly
// the same frames as counting them.

import { check, expect, expectBelow } from './framework.js';
import { Engine } from '../engine/Engine.js';
import { FeatureTrack, featureProviderFor } from '../audio/FeatureTrack.js';
import { synthesizeSectioned } from '../audio/synth.js';
import { nebula } from '../scenes/shader/nebula.js';
import { defaultValues } from '../params/schema.js';

const PALETTE = [[0.05, 0.02, 0.15], [0.85, 0.15, 0.55], [0.15, 0.75, 0.95], [0.98, 0.85, 0.35]];

let cachedTrack = null;
export function testTrack() {
    if (!cachedTrack) {
        const buffer = synthesizeSectioned({ bpm: 128, duration: 90, changeAt: 45 });
        cachedTrack = FeatureTrack.fromAudioBuffer(buffer, { fps: 60 });
    }
    return cachedTrack;
}

function makeEngine(track, width = 256, height = 144) {
    const engine = new Engine({ width, height });
    engine.timeline.setDuration(track.duration);
    engine.setFeatureProvider(featureProviderFor(track));
    engine.setLayerSpecs([{
        module: nebula, params: defaultValues(nebula), seed: 4242,
        opacity: 1, blend: 'normal', palette: PALETTE,
    }]);
    return engine;
}

check(1, 'feature track builds and every row is finite and in range', () => {
    const track = testTrack();
    let bad = 0;
    for (let f = 0; f < track.frameCount; f += 7) {
        const row = track.at(f);
        for (const v of Object.values(row)) {
            if (!Number.isFinite(v) || v < 0 || v > 1) { bad++; break; }
        }
    }
    return expect(bad === 0,
        `${bad} bad rows of ${Math.ceil(track.frameCount / 7)} sampled · ` +
        `${track.frameCount} frames · ${track.sections.length} sections · ` +
        `${track.summary.bpm.toFixed(1)} BPM · confidence ${track.tempo.confidence.toFixed(2)}`);
});

check(1, 'audio-driven clock and fixed-step counting agree', () => {
    // The core preview/export parity claim. Realtime maps currentTime to a frame
    // index; export counts frames. Both must land on identical images.
    const track = testTrack();
    const engine = makeEngine(track);
    try {
        const fixed = engine.hashRun(1000, 100);

        engine.compositor.reset();
        const driven = [];
        for (let i = 0; i < 100; i++) {
            const frame = 1000 + i;
            // Jittered playback position within the frame's window, as a real
            // audio element would report it.
            const jitter = (((i * 7919) % 1000) / 1000) * 0.9;
            engine.timeline.syncToAudio((frame + jitter) / 60);
            const target = engine.renderCurrent();
            driven.push(engine.hashCurrent(target));
        }
        const mismatches = fixed.filter((h, i) => h !== driven[i]).length;
        return expect(mismatches === 0, `${mismatches}/100 frames differed`);
    } finally {
        engine.dispose();
    }
});

check(1, 'features actually reach the shader', () => {
    // A scene wired to a table it never reads would pass every other check here.
    const track = testTrack();
    const engine = makeEngine(track);
    try {
        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));

        engine.compositor.reset();
        const quietHash = engine.hashCurrent(engine.renderFrame(quiet.startFrame + 60));
        engine.compositor.reset();
        const loudHash = engine.hashCurrent(engine.renderFrame(loud.startFrame + 60));

        return expect(quietHash !== loudHash,
            `quiet ${quiet.kind} ${quietHash} vs loud ${loud.kind} ${loudHash}`);
    } finally {
        engine.dispose();
    }
});

check(1, 'section-boundary seek is exact without warm-up', () => {
    const track = testTrack();
    const engine = makeEngine(track);
    try {
        const boundary = track.sections[1] ? track.sections[1].startFrame : 600;
        const sequential = engine.hashRun(boundary, 3);
        engine.compositor.reset();
        const direct = engine.hashCurrent(engine.renderFrame(boundary));
        return expect(direct === sequential[0], `direct ${direct} vs sequential ${sequential[0]}`);
    } finally {
        engine.dispose();
    }
});

check(1, 'mid-section seek converges under warm-up with feedback active', () => {
    // With feedback on, an arbitrary seek can only converge, not match exactly.
    // The gate is that warm-up gets it visually indistinguishable — the documented
    // behaviour in PLAN.md §6.
    const track = testTrack();
    const engine = makeEngine(track);
    try {
        engine.compositor.setFeedback({ amount: 0.7, decay: 0.92, zoom: 0.99 });

        const targetFrame = 1500;
        engine.compositor.reset();
        for (let f = 1200; f < targetFrame; f++) engine.renderFrame(f);
        const sequentialPixels = Uint8Array.from(
            engine.readPixels(engine.renderFrame(targetFrame)),
        );

        engine.warmUp(targetFrame, 120);
        const warmedPixels = Uint8Array.from(engine.readPixels(engine.renderCurrent()));

        let sum = 0;
        for (let i = 0; i < sequentialPixels.length; i += 4) {
            sum += Math.abs(sequentialPixels[i] - warmedPixels[i]);
        }
        const distance = sum / (sequentialPixels.length / 4) / 255;
        return expectBelow(distance, 0.02, 'mean red-channel distance after warm-up');
    } finally {
        engine.dispose();
    }
});

check(1, 'analysis of a five-minute track stays within budget', () => {
    const buffer = synthesizeSectioned({ bpm: 128, duration: 300, changeAt: 150 });
    const started = Date.now();
    const track = FeatureTrack.fromAudioBuffer(buffer, { fps: 60 });
    const elapsed = (Date.now() - started) / 1000;
    return expect(elapsed < 6,
        `${elapsed.toFixed(2)}s for a 5-minute track (${track.frameCount} frames)`);
}, { slow: true });
