// Phase 4 gate — segmentation driving the arc.
//
// Segmentation accuracy itself is measured in node against synthetic ground
// truth. What is checked here is that the structure actually reaches the screen:
// scenes change where the music changes, transitions don't pop, and the lookahead
// ramp is genuinely wired rather than merely present in the table.
//
// The check this phase CANNOT automate is monotony. Watching full tracks is the
// only way to catch it, and PLAN.md §9 keeps that as an explicit manual gate.

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

let cached = null;
function arcTrack() {
    if (!cached) {
        const buffer = synthesizeSectioned({ bpm: 128, duration: 150, changeAt: 75 });
        cached = FeatureTrack.fromAudioBuffer(buffer, { fps: 60 });
    }
    return cached;
}

function makeShow(seed = 2024, width = 160, height = 90) {
    const show = new Show({ width, height });
    const track = arcTrack();
    show.useTrack(track, generateLook(track, { seed }));
    return show;
}

check(4, 'the scene changes only on a planned cut', () => {
    // Since Phase 8 a cut is a SHOT boundary, not only a section boundary — a
    // section rotates between its stage visuals. What must still hold is that no
    // change happens anywhere the look did not plan one.
    const show = makeShow();
    try {
        const track = show.track;
        const boundaries = show.arc.cues.map((c) => c.startFrame);
        const changes = [];
        let previous = null;

        for (let f = 0; f < track.frameCount; f += 5) {
            show.arc.update(f, track.at(f));
            const name = show.arc.state.sceneName;
            if (previous !== null && name !== previous) changes.push(f);
            previous = name;
        }

        const stray = changes.filter((f) => !boundaries.some((b) => Math.abs(f - b) <= 10));
        return expect(stray.length === 0,
            `${changes.length} scene change(s), ${stray.length} away from a cut · ` +
            `${track.sections.length} sections, ${boundaries.length} shots: ` +
            track.sections.map((s) => s.kind).join(', '));
    } finally {
        show.dispose();
    }
});

check(4, 'transitions produce no pops or black frames', () => {
    // Neither a raw delta threshold nor an outlier-vs-local-median test works
    // here: these scenes flash on the beat, so large isolated deltas are the
    // intended behaviour and both metrics flag them. The only meaningful question
    // is whether a boundary is worse than the same scene's ordinary behaviour, so
    // this A/Bs each boundary window against a control window with no boundary in
    // it. Beat flashes appear in both and cancel out.
    const show = makeShow();
    try {
        const track = show.track;

        const scan = (start, end) => {
            show.engine.compositor.reset();
            for (let f = Math.max(0, start - 30); f < start; f++) show.renderFrame(f);

            let previous = null;
            let peak = 0;
            let peakAt = start;
            let darkest = 1;
            for (let f = start; f < Math.min(end, track.frameCount); f++) {
                const pixels = Uint8Array.from(show.readPixels(show.renderFrame(f)));
                darkest = Math.min(darkest, frameLuminance(pixels));
                if (previous) {
                    const d = frameDistance(previous, pixels);
                    if (d > peak) { peak = d; peakAt = f; }
                }
                previous = pixels;
            }
            return { peak, peakAt, darkest };
        };

        // The control must sit in the SAME scenes the boundary window contains.
        // Scenes differ enormously in inherent frame-to-frame motion — one busy
        // scene next to a calm one reads as an 8x "spike" against a control taken
        // from the calm one, with no cut anywhere near it.
        //
        // Since Phase 8 that means the same SHOT, not merely the same section: a
        // section rotates between two or three visuals, and the middle of the
        // section is often not the visual that is on screen at the boundary.
        const interior = (cue) => {
            const mid = cue.startFrame + Math.floor((cue.endFrame - cue.startFrame) / 2);
            return scan(mid, Math.min(mid + 200, cue.endFrame));
        };

        const cueAt = (frame) => show.arc.cueAt(frame);

        let worstRatio = 0;
        let worstBoundary = -1;
        let darkest = 1;
        let controlUsed = 0;
        let worstPeakAt = -1;

        for (let i = 1; i < track.sections.length; i++) {
            const s = track.sections[i];
            const before = interior(cueAt(s.startFrame - 1));
            const after = interior(cueAt(s.startFrame));

            // BOTH shots' motion, not the busier one's. For most of the window
            // the two stacks are on screen simultaneously — four layers, two of
            // them accents in overlay or screen — and a nonlinear blend of two
            // moving stacks genuinely moves more than either does alone. Taking
            // the max says a dissolve may not exceed its busier half, which is
            // not a property a dissolve has.
            //
            // Measured when four calm minimal scenes were added to the library:
            // the boundary peak at frame 4528 was 0.124 with them and 0.130
            // without — unchanged — while the control fell from 0.119 to 0.072
            // because the mid-shot windows now landed on the calm new scenes.
            // The metric was reading the denominator, not a pop. A real pop is
            // still an order of magnitude clear of this: a cut to black and back
            // measures ~1.0 against a control of ~0.2.
            const control = before.peak + after.peak;

            // The window starts just before the boundary rather than a second
            // before it. The transition runs FORWARD from the boundary, so a
            // handful of pre-frames is all it takes to catch a pop on the
            // boundary frame itself — while a longer pre-roll would drag in the
            // lookahead build ramp, where a scene is deliberately driven to the
            // top of its range and flashes accordingly. That is the intended
            // climax of a build, not a transition fault, and the control window
            // (mid-shot, no ramp) has no equivalent to cancel it against.
            const w = scan(s.startFrame - 8, s.startFrame + cueAt(s.startFrame).fadeFrames + 60);
            darkest = Math.min(darkest, w.darkest, before.darkest, after.darkest);

            const ratio = w.peak / Math.max(control, 1e-6);
            if (ratio > worstRatio) {
                worstRatio = ratio; worstBoundary = s.startFrame; controlUsed = control;
                worstPeakAt = w.peakAt;
            }
        }

        if (worstBoundary < 0) return expect(true, 'single-section track');

        return expect(worstRatio < 1.6 && darkest > 0.002,
            `worst boundary peak ${worstRatio.toFixed(2)}x the adjacent scenes' own peak ` +
            `(control ${controlUsed.toFixed(4)}) at boundary ${worstBoundary}, peak frame ${worstPeakAt}, darkest ${darkest.toFixed(4)}`);
    } finally {
        show.dispose();
    }
}, { slow: true });

check(4, 'crossfade ramps rather than cuts', () => {
    const show = makeShow();
    try {
        const track = show.track;
        const boundary = track.sections[1] && track.sections[1].startFrame;
        if (!boundary) return expect(true, 'single-section track, nothing to cross-fade');

        // Read the length off the cue rather than assuming the nominal one: since
        // Phase 8 a dissolve is two bars on calm material and one on loud.
        const fadeFrames = show.arc.cueAt(boundary).fadeFrames;
        const samples = [];
        for (let f = boundary; f < boundary + fadeFrames; f += 2) {
            show.arc.update(f, track.at(f));
            samples.push(show.arc.state.crossfade);
        }
        const monotonic = samples.slice(1).every((v, i) => v >= samples[i] - 1e-6);
        const spans = samples[0] < 0.15 && samples[samples.length - 1] > 0.85;

        return expect(monotonic && spans,
            `${fadeFrames}-frame fade, monotonic ${monotonic}, ` +
            `${samples[0].toFixed(2)}→${samples[samples.length - 1].toFixed(2)}`);
    } finally {
        show.dispose();
    }
});

check(4, 'lookahead ramps params into a higher-energy section', () => {
    // The payoff of offline analysis. buildSlope must rise before the boundary
    // AND actually move a parameter, not merely exist in the table.
    const track = arcTrack();
    const rising = [];
    for (let i = 0; i < track.sections.length - 1; i++) {
        if (track.sections[i + 1].energy > track.sections[i].energy * 1.08) rising.push(i);
    }
    if (!rising.length) return expect(true, 'no rising transition in this track');

    const show = makeShow();
    try {
        const section = track.sections[rising[0]];
        const traces = [];
        for (let f = Math.max(section.startFrame, section.endFrame - 300); f < section.endFrame; f += 20) {
            const features = track.at(f);
            show.arc.update(f, features);
            const layer = show.arc.activeLayers[show.arc.activeLayers.length - 1];
            traces.push({ slope: features.buildSlope, params: { ...layer.baseParams } });
        }
        if (traces.length < 3) return expect(true, 'section too short to sample a ramp');

        const first = traces[0], last = traces[traces.length - 1];
        const slopeRises = last.slope > first.slope + 1e-6;
        const moved = Object.keys(first.params).filter((k) =>
            typeof first.params[k] === 'number' && Math.abs(last.params[k] - first.params[k]) > 1e-6);

        return expect(slopeRises && moved.length > 0,
            `buildSlope ${first.slope.toFixed(3)}→${last.slope.toFixed(3)}, ` +
            `${moved.length} param(s) ramped: ${moved.slice(0, 4).join(', ')}`);
    } finally {
        show.dispose();
    }
});

check(4, 'params drift within a held shot', () => {
    // Guards the failure mode automated checks are worst at: an image that is
    // technically correct and completely static. Measured over the longest SHOT
    // rather than the longest section — across a shot cut the scene itself
    // changes, which would pass this trivially and prove nothing about drift.
    const show = makeShow();
    try {
        const track = show.track;
        const longest = show.arc.cues.reduce((a, b) =>
            (b.endFrame - b.startFrame > a.endFrame - a.startFrame ? b : a));

        const sample = (frame) => {
            show.arc.update(frame, track.at(frame));
            const layer = show.arc.activeLayers[show.arc.activeLayers.length - 1];
            return { ...layer.baseParams };
        };

        const a = sample(longest.startFrame + 120);
        const b = sample(Math.max(longest.startFrame + 121, longest.endFrame - 120));

        const numeric = Object.keys(a).filter((k) => typeof a[k] === 'number');
        const moved = numeric.filter((k) => Math.abs(b[k] - a[k]) > 1e-4);

        return expect(moved.length >= Math.ceil(numeric.length * 0.5),
            `${moved.length}/${numeric.length} params moved across a ` +
            `${((longest.endFrame - longest.startFrame) / 60).toFixed(0)}s shot`);
    } finally {
        show.dispose();
    }
});

check(4, 'the arc-driven render is still deterministic', () => {
    const a = makeShow();
    const b = makeShow();
    try {
        const hashes = (show) => {
            show.engine.compositor.reset();
            const out = [];
            for (let f = 4400; f < 4460; f++) out.push(show.hashFrame(f));
            return out;
        };
        const ha = hashes(a);
        const hb = hashes(b);
        const mismatches = ha.filter((h, i) => h !== hb[i]).length;
        if (mismatches === 0) return expect(true, '60/60 frames bit-identical across two shows');

        // Two independently built shows are two WebGL contexts, and bit-exactness
        // is a same-context guarantee — engine/hash.js says so at the top. This
        // check got away with the stricter test for a long time because the
        // scenes it happened to cast were bit-stable; a casting change put
        // smooth-gradient scenes in the frame and the last bit started moving.
        //
        // So the cross-context comparison is a distance with a threshold, and it
        // is deliberately tight: three levels out of 255 is invisible, and
        // anything that is actually non-deterministic will blow past it rather
        // than creep up to it. Bit-exactness within ONE show is still demanded,
        // by the check immediately below this one.
        let worst = 0;
        for (let f = 4400; f < 4460; f++) {
            worst = Math.max(worst, frameMaxDelta(
                Uint8Array.from(a.readPixels(a.renderFrame(f))),
                Uint8Array.from(b.readPixels(b.renderFrame(f)))));
        }
        return expect(worst <= 4,
            `${mismatches}/60 frames differed between two WebGL contexts, ` +
            `worst channel delta ${worst}/255 (ceiling 4)`);
    } finally {
        a.dispose(); b.dispose();
    }
});

check(4, 'a frame renders the same the first time as every later time', () => {
    // Regression guard for an aliasing class no other check covered: FeatureTrack.at()
    // returns a reused row, so anything that calls it mid-render corrupts the row the
    // layer is about to read. The symptom is a frame that is correct on every repeat
    // and wrong on its first render — invisible to fresh-vs-fresh comparison, and
    // visible in an export, which renders every frame exactly once.
    const show = makeShow();
    try {
        const track = show.track;
        show.look.feedback.amount = 0;

        const frames = [
            ...track.sections.map((s) => s.startFrame),
            ...track.sections.map((s) => s.startFrame + 30),
            1000, 4000,
        ].filter((f) => f > 0 && f < track.frameCount);

        const problems = [];
        for (const f of frames) {
            const fresh = makeShow();
            fresh.look.feedback.amount = 0;
            fresh.engine.compositor.reset();
            const first = fresh.engine.hashCurrent(fresh.renderFrame(f));
            fresh.dispose();

            show.engine.compositor.reset();
            show.renderFrame(f);
            show.engine.compositor.reset();
            const repeat = show.engine.hashCurrent(show.renderFrame(f));

            if (first !== repeat) problems.push(`frame ${f}: first ${first} vs repeat ${repeat}`);
        }
        return expect(problems.length === 0,
            problems.length ? problems.join(' · ') : `${frames.length} frames stable on first render`);
    } finally {
        show.dispose();
    }
});

check(4, 'seek converges to sequential playback, and is exact without feedback', () => {
    // Corrected from the original plan: a boundary is not exact for free. Layer
    // state is re-seeded there, but the feedback buffer is global and carries
    // across, so any look with feedback needs warm-up wherever you land.
    const show = makeShow();
    try {
        const track = show.track;
        const boundary = track.sections[1] ? track.sections[1].startFrame : 600;

        // Force heavy feedback so the check is actually exercising convergence
        // rather than passing because this seed happened to generate very little.
        show.look.feedback.amount = 0.7;
        show.look.feedback.decay = 0.93;
        const warmup = show.warmupFrames();

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

        const warmed = Uint8Array.from(show.readPixels(show.seek(boundary)));
        const distance = frameDistance(sequential, warmed);

        // And with feedback off it must be bit-exact, proving nothing else is stateful.
        show.look.feedback.amount = 0;
        show.engine.compositor.reset();
        for (let f = boundary - 60; f < boundary; f++) show.renderFrame(f);
        const seqExact = show.engine.hashCurrent(show.renderFrame(boundary));
        const directExact = show.engine.hashCurrent(show.seek(boundary));

        return expect(distance < 0.01 && seqExact === directExact,
            `with feedback 0.7/0.93: converged to ${distance.toFixed(5)} after ${warmup} ` +
            `warm-up frames · without feedback: exact ${seqExact === directExact}`);
    } finally {
        show.dispose();
    }
});

check(4, 'head and tail fade rather than cut', () => {
    const show = makeShow();
    try {
        show.engine.compositor.reset();
        const first = frameLuminance(show.readPixels(show.renderFrame(0)));
        show.engine.compositor.reset();
        const early = frameLuminance(show.readPixels(show.renderFrame(400)));
        show.engine.compositor.reset();
        const last = frameLuminance(show.readPixels(show.renderFrame(show.frameCount - 1)));
        return expect(first < early * 0.4 && last < early * 0.4,
            `frame 0 ${first.toFixed(4)} · frame 400 ${early.toFixed(4)} · last ${last.toFixed(4)}`);
    } finally {
        show.dispose();
    }
});
