// Phase 8 gate — shots.
//
// The problem this phase exists to fix is measurable, so the gate measures it:
// how long does the same image stay on screen? Everything else here guards the
// ways more cuts could go wrong — cuts off the bar grid, cuts so fast they
// become a strobe, or a rotation so busy the section loses its identity.

import { check, expect, expectBelow } from './framework.js';
import { Show } from '../Show.js';
import { FeatureTrack } from '../audio/FeatureTrack.js';
import { synthesizeSectioned } from '../audio/synth.js';
import { generateLook } from '../look/LookGenerator.js';
import { MIN_SHOT_SECONDS, MAX_SHOT_SECONDS, HARD_CUT_ENERGY } from '../look/shots.js';
import { frameDistance } from '../engine/hash.js';

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

function looks(count = 8) {
    const track = track8();
    return Array.from({ length: count }, (_, i) => generateLook(track, { seed: 1000 + i * 7919 }));
}

/**
 * A rendered frame as an independent buffer. Renderer.readPixels hands back a
 * reused array, so comparing two of its results compares one frame with itself.
 */
function copyPixels(show, frame) {
    return Uint8Array.from(show.readPixels(show.renderFrame(frame)));
}

/** Every shot in a look, flattened, in playback order. */
function allShots(look) {
    return look.sections.flatMap((s) => (s.shots || []).map((shot) => ({ ...shot, section: s })));
}

check(8, 'no image is held past the ceiling', () => {
    // The complaint that started the phase. A shot is the longest a single
    // image can stay up, so this is the whole fix expressed as a number.
    const fps = track8().fps;
    let worst = 0;
    let worstWhere = '';

    for (const look of looks()) {
        for (const shot of allShots(look)) {
            const seconds = (shot.endFrame - shot.startFrame) / fps;
            if (seconds > worst) {
                worst = seconds;
                worstWhere = `${shot.section.kind} shot ${shot.index}`;
            }
        }
    }
    return expectBelow(worst, MAX_SHOT_SECONDS + 0.05, `longest held image ${worstWhere}`);
});

check(8, 'shots are not shorter than the floor', () => {
    // The other end of the same axis: a cut every two seconds is not editing,
    // it is a strobe, and the flash meter would be the next thing to complain.
    const fps = track8().fps;
    let shortest = Infinity;
    for (const look of looks()) {
        for (const shot of allShots(look)) {
            shortest = Math.min(shortest, (shot.endFrame - shot.startFrame) / fps);
        }
    }
    return expect(shortest >= MIN_SHOT_SECONDS - 0.05,
        `shortest shot ${shortest.toFixed(2)}s (floor ${MIN_SHOT_SECONDS}s)`);
});

check(8, 'cuts land on the bar grid', () => {
    // A cut that lands between phrases reads as a mistake even when the image
    // is good, so this is a quality gate rather than a correctness one.
    const track = track8();
    const barSeconds = (track.tempo.period * track.tempo.beatsPerBar) / track.fps;
    let total = 0;
    let onGrid = 0;

    for (const look of looks()) {
        for (const shot of allShots(look)) {
            if (shot.index === 0) continue;                 // section boundary, snapped upstream
            total++;
            const time = shot.startFrame / track.fps;
            const nearest = track.tempo.downbeats.reduce(
                (best, d) => Math.min(best, Math.abs(d - time)), Infinity);
            if (nearest <= barSeconds * 0.26) onGrid++;
        }
    }
    const ratio = total ? onGrid / total : 1;
    return expect(ratio >= 0.9, `${onGrid}/${total} intra-section cuts within a quarter-bar`);
});

check(8, 'a section keeps its identity across its shots', () => {
    // Shots must not become a shuffle. A section rotates between three or four
    // visuals, the anchor opens it, and the anchor keeps coming back.
    //
    // "Keeps coming back" is deliberately not "is strictly the most shown". The
    // rotation is random with a bias, and on a nine-shot section a companion can
    // legitimately edge the anchor by one without the section losing its centre.
    // Demanding a strict maximum would either fail on ordinary seeds or force a
    // rigid A-B-A-C pattern, which is audible as a pattern within three cycles.
    const problems = [];
    for (const look of looks()) {
        for (const section of look.sections) {
            const shots = section.shots || [];
            if (!shots.length) continue;
            const counts = section.variants.map((_, v) => shots.filter((s) => s.variant === v).length);
            if (section.variants.length > 4) problems.push(`${section.kind}: ${section.variants.length} variants`);
            if (shots[0].variant !== 0) problems.push(`${section.kind}: opens on variant ${shots[0].variant}`);
            if (counts[0] < Math.max(...counts) - 1) {
                problems.push(`${section.kind}: anchor shown ${counts[0]}x against ${Math.max(...counts)}x`);
            }
            for (let i = 1; i < shots.length; i++) {
                if (shots[i].variant === shots[i - 1].variant) {
                    problems.push(`${section.kind}: repeats variant ${shots[i].variant} back to back`);
                }
            }
        }
    }
    return expect(problems.length === 0, problems.slice(0, 4).join(' · ') || 'rosters well formed');
});

check(8, 'the same section kind reuses the same roster', () => {
    // The identity rule one level up: two drops cut between the same visuals.
    const problems = [];
    for (const look of looks()) {
        const byKind = new Map();
        for (const section of look.sections) {
            const roster = (section.variants || [section.layers]).map((v) => v[0].module.name).join('+');
            const seen = byKind.get(section.kind);
            if (seen && seen !== roster) problems.push(`${section.kind}: ${seen} vs ${roster}`);
            byKind.set(section.kind, roster);
        }
    }
    return expect(problems.length === 0, problems.slice(0, 3).join(' · ') || 'rosters stable per kind');
});

check(8, 'dissolves are the default and straight cuts are reserved for energy', () => {
    // A cut on calm material reads as a glitch rather than as an edit, so the
    // policy is: nothing below the threshold ever cuts. This also guards the
    // pacing from the other side — if every transition became a hard cut the
    // video would feel like a slideshow, and no other check would notice.
    const problems = [];
    let cuts = 0;
    let dissolves = 0;

    for (const look of looks()) {
        for (const section of look.sections) {
            for (const shot of section.shots || []) {
                if (shot.index === 0) continue;
                if (shot.hardCut) {
                    cuts++;
                    if (section.bias.energy <= HARD_CUT_ENERGY) {
                        problems.push(`${section.kind} cuts at energy ${section.bias.energy.toFixed(2)}`);
                    }
                } else {
                    dissolves++;
                }
            }
        }
    }
    return expect(problems.length === 0 && dissolves > cuts,
        problems.slice(0, 3).join(' · ') || `${dissolves} dissolves, ${cuts} cuts`);
});

check(8, 'a section rotates through more than two visuals when it is long enough', () => {
    // The roster is only worth having if the shots reach it. Sections with four
    // or more shots must show at least three distinct visuals — otherwise the
    // rotation has collapsed back to A/B, which is the complaint this sizing
    // was meant to answer.
    const problems = [];
    let checked = 0;

    for (const look of looks()) {
        for (const section of look.sections) {
            const shots = section.shots || [];
            if (shots.length < 4 || section.variants.length < 3) continue;
            checked++;
            const distinct = new Set(shots.map((s) => s.variant)).size;
            if (distinct < 3) {
                problems.push(`${section.kind}: ${shots.length} shots, only ${distinct} visuals`);
            }
        }
    }
    if (!checked) return expect(true, 'no section long enough to rotate');
    return expect(problems.length === 0,
        problems.slice(0, 3).join(' · ') || `${checked} long section(s) all reached 3+ visuals`);
});

check(8, 'shot cuts do not produce a black frame or a jump cut to nothing', () => {
    // A cut is a short crossfade, not a swap. Rendered either side of every
    // intra-section cut, consecutive frames must still be continuous enough that
    // nothing goes black — the failure mode of getting the layer bookkeeping
    // wrong is one empty frame, which is invisible in review and obvious in an
    // export.
    const show = new Show({ width: 160, height: 90 });
    try {
        const track = track8();
        show.useTrack(track, generateLook(track, { seed: 4242 }));

        const cuts = show.arc.cues.filter((c) => !c.atSectionStart).slice(0, 6);
        if (!cuts.length) return expect(false, 'no intra-section cuts were planned');

        let worst = 0;
        let worstAt = 0;
        for (const cue of cuts) {
            show.seek(Math.max(0, cue.startFrame - 4));
            let previous = copyPixels(show, cue.startFrame - 3);
            for (let f = cue.startFrame - 2; f <= cue.startFrame + cue.fadeFrames + 2; f++) {
                const pixels = copyPixels(show, f);
                const d = frameDistance(previous, pixels);
                if (d > worst) { worst = d; worstAt = f; }
                previous = pixels;
            }
        }
        return expectBelow(worst, 0.35, `largest frame-to-frame delta across ${cuts.length} cuts @${worstAt}`);
    } finally {
        show.dispose();
    }
}, { slow: true });
