// Phase 6 gate — export.
//
// The parity claim is structural: the exporter has no render path of its own, it
// drives Show.renderFrame exactly as the preview does. What still needs verifying
// is that the claim survives contact with the encoder — that rendering at export
// resolution does not change any look decision, that the requested frame range is
// honoured, and that the container is well-formed.

import { check, expect } from './framework.js';
import { Show } from '../Show.js';
import { generateLook } from '../look/LookGenerator.js';
import { FeatureTrack } from '../audio/FeatureTrack.js';
import { synthesizeSectioned } from '../audio/synth.js';
import { Exporter, isSupported, PRESETS } from '../export/Exporter.js';
import { frameDistance, frameMaxDelta } from '../engine/hash.js';

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

function makeShow(width = 320, height = 180) {
    const show = new Show({ width, height });
    const track = track6();
    show.useTrack(track, generateLook(track, { seed: 8642 }));
    // Give it an audioBuffer so the AAC path is exercised too.
    show.audioBuffer = synthesizeSectioned({ bpm: 128, duration: 40, changeAt: 20 });
    show.fileName = 'check';
    return show;
}

check(6, 'WebCodecs VideoEncoder is available', () =>
    expect(isSupported(), isSupported()
        ? 'VideoEncoder and VideoFrame present'
        : 'unavailable — export cannot run in this browser'));

check(6, 'render resolution does not change any look decision', () => {
    // Resolution must change only the sampling, never which scene is on screen or
    // what its parameters are. If it did, a preview would be lying about the export.
    const show = makeShow(320, 180);
    try {
        const probe = (frames) => frames.map((f) => {
            show.renderFrame(f);
            return `${show.arc.state.sceneName}|${show.arc.state.kind}|` +
                   JSON.stringify(show.arc.activeLayers.map((l) => l.baseParams));
        });
        const frames = [300, 900, 1500];
        const small = probe(frames);
        show.setSize(1920, 1080);
        const large = probe(frames);

        const same = small.every((v, i) => v === large[i]);
        return expect(same, same
            ? '320x180 and 1920x1080 agree on scene and params at 3 probe frames'
            : 'look decisions changed with resolution');
    } finally {
        show.dispose();
    }
});

check(6, 'audio-clock preview and frame-counted export agree', () => {
    // Preview derives the frame index from a playback position; export counts.
    // Same Show, same size — the images must be identical.
    const show = makeShow(320, 180);
    try {
        const start = 600;
        const count = 40;

        // Prime first, as BOTH real paths do — the exporter calls prime() and the
        // preview prewarms on load. Without it the first pass here renders through
        // programs that are still linking and the first few frames come back
        // different, which is the hazard Compositor.prime() documents rather than
        // anything about preview versus export. Measured: unprimed, frames 0/2/3
        // of the first pass differed and every later pass was identical.
        show.prime(start);

        // Then render the range once and throw it away.
        //
        // Priming compiles the programs and discards one frame, which is not
        // quite enough on the heavier scenes: measured on this look (Tide Rings,
        // a seven-source interference field), the FIRST forty-frame pass in a
        // fresh context differs from the second by up to 2/255 and every pass
        // after that is bit-exact — 2, 0, 0 over three consecutive attempts.
        //
        // Warming the range costs one pass and lets this check keep asking its
        // real question — does an audio clock produce the same images as a frame
        // counter — at a 1/255 tolerance, instead of loosening the tolerance to
        // absorb something that is not about preview versus export at all.
        // First-render stability has its own check, in Phase 4.
        for (let i = 0; i < count; i++) show.renderFrame(start + i);

        show.engine.compositor.reset();
        const preview = [];
        for (let i = 0; i < count; i++) {
            show.timeline.syncToAudio((start + i) / show.fps);
            preview.push(Uint8Array.from(show.readPixels(show.renderFrame(show.timeline.frame))));
        }

        show.engine.compositor.reset();
        const exported = [];
        for (let i = 0; i < count; i++) {
            exported.push(Uint8Array.from(show.readPixels(show.renderFrame(start + i))));
        }

        // Judged on a two-LSB tolerance rather than bit-exact hashes, for the
        // reason PLAN.md §1 records: the heavier shaders vary by a level or two
        // under differing GPU load, and this look renders a seven-source
        // interference field at 320x180 twice in a row.
        //
        // Measured, in this order. Unprimed: frames 0/2/3 of the first pass
        // differed, every later pass identical — fixed by priming. Primed and
        // run in isolation: 0/40, delta 0, three times over. Primed, warmed AND
        // run at the end of the full suite: 3-4 frames at delta 2. The warm-up
        // stays because it is correct and removes one source of noise, but the
        // residue is load, not logic, and it is the same 1-2/255 Phase 5 and
        // Phase 7 already account for.
        //
        // The check keeps its teeth: a real preview/export divergence — a
        // different scene, a different param, a frame off by one — scores in the
        // tens or hundreds here, not 2.
        const deltas = preview.map((f, i) => frameMaxDelta(f, exported[i]));
        const worst = Math.max(...deltas);
        return expect(worst <= 2,
            `${deltas.filter((d) => d > 1).length}/${count} frames differed visibly ` +
            `· worst channel delta ${worst}`);
    } finally {
        show.dispose();
    }
});

check(6, 'a warmed export range matches sequential playback into it', () => {
    // The exporter warms up before a mid-track range so the first exported frame
    // carries the feedback state continuous playback would have given it.
    const show = makeShow(256, 144);
    try {
        show.look.feedback.amount = 0.6;
        show.look.feedback.decay = 0.92;
        const start = 1200;

        show.engine.compositor.reset();
        for (let f = 400; f < start; f++) show.renderFrame(f);
        const sequential = Uint8Array.from(show.readPixels(show.renderFrame(start)));

        show.warmUp(start, show.warmupFrames());
        const warmed = Uint8Array.from(show.readPixels(show.renderFrame(start)));

        const distance = frameDistance(sequential, warmed);
        return expect(distance < 0.01,
            `mean distance ${distance.toFixed(5)} after ${show.warmupFrames()} warm-up frames`);
    } finally {
        show.dispose();
    }
});

check(6, 'every export preset resolves a supported encoder configuration', async () => {
    if (!isSupported()) return expect(false, 'WebCodecs unavailable');
    const results = [];
    for (const [name, p] of Object.entries(PRESETS)) {
        let ok = false;
        for (const codec of ['avc1.640034', 'avc1.640033', 'avc1.4d0034', 'avc1.42003e']) {
            try {
                const support = await VideoEncoder.isConfigSupported({
                    codec, width: p.width, height: p.height, bitrate: p.bitrate, framerate: 60,
                });
                if (support.supported) { ok = true; break; }
            } catch { /* try the next candidate */ }
        }
        results.push(`${name}:${ok ? 'ok' : 'NO'}`);
    }
    return expect(!results.some((r) => r.endsWith('NO')), results.join(' '));
});

check(6, 'a short export produces a well-formed mp4', async () => {
    if (!isSupported()) return expect(false, 'WebCodecs unavailable');
    const show = makeShow(320, 180);
    try {
        const blob = await new Exporter(show).export({
            preset: '720p', frameRange: [600, 720],   // 2 seconds
        });
        const head = new Uint8Array(await blob.slice(0, 12).arrayBuffer());
        const brand = String.fromCharCode(...head.slice(4, 8));
        // Only the degenerate cases are worth rejecting; encoder settings and
        // content move real sizes around a lot.
        const plausible = blob.size > 20_000 && blob.size < 40_000_000;
        return expect(brand === 'ftyp' && plausible,
            `${(blob.size / 1024).toFixed(0)} KB · box '${brand}' · ${blob.type}`);
    } finally {
        show.dispose();
    }
}, { slow: true });

check(6, 'export honours the requested frame range', async () => {
    if (!isSupported()) return expect(false, 'WebCodecs unavailable');
    const show = makeShow(256, 144);
    try {
        const short = await new Exporter(show).export({ preset: '720p', frameRange: [600, 660] });
        const long = await new Exporter(show).export({ preset: '720p', frameRange: [600, 780] });
        return expect(long.size > short.size * 1.5,
            `1s ${(short.size / 1024).toFixed(0)} KB vs 3s ${(long.size / 1024).toFixed(0)} KB`);
    } finally {
        show.dispose();
    }
}, { slow: true });

check(6, 'export restores the preview resolution afterwards', async () => {
    if (!isSupported()) return expect(false, 'WebCodecs unavailable');
    const show = makeShow(320, 180);
    try {
        await new Exporter(show).export({ preset: '720p', frameRange: [600, 630] });
        return expect(show.engine.width === 320 && show.engine.height === 180,
            `back to ${show.engine.width}x${show.engine.height} after a 1280x720 export`);
    } finally {
        show.dispose();
    }
}, { slow: true });

check(6, 'a cancelled export stops and restores state', async () => {
    if (!isSupported()) return expect(false, 'WebCodecs unavailable');
    const show = makeShow(256, 144);
    try {
        const exporter = new Exporter(show);
        const promise = exporter.export({ preset: '720p', frameRange: [600, 2000] });
        setTimeout(() => exporter.cancel(), 60);
        let message = '';
        try { await promise; } catch (err) { message = err.message; }
        return expect(message.includes('cancel') && show.engine.width === 256,
            `threw "${message}", size restored to ${show.engine.width}x${show.engine.height}`);
    } finally {
        show.dispose();
    }
}, { slow: true });

check(6, 'upload to the real video platform', () =>
    // Deliberately manual. Container quirks are far cheaper to find with a short
    // file now than after a 4K render, and nothing local substitutes for the
    // platform's own transcoder accepting the file.
    ({ pass: true, detail: 'MANUAL: export a 20s test render and upload it once before trusting a full export' }),
    { manual: true });
