// Phase 11 gate — Epic 2. See EPIC-2.md.
//
// Phase 10 asked whether two tracks look different from each other. This phase
// asks the question that only shows up when you actually sit and watch one:
// does a single track hold five minutes?
//
// The failures it exists to catch were all measured on a working build, not
// imagined — a cut metronome, a palette that never moves, scenes that are
// violently animated and read as static, and a kind-to-family table that made
// every video make the same genre decisions.

import { check, expect } from './framework.js';
import { generateLook } from '../look/LookGenerator.js';
import { FeatureTrack } from '../audio/FeatureTrack.js';
import { synthesizeSectioned } from '../audio/synth.js';
import { MIN_SHOT_SECONDS, MAX_SHOT_SECONDS } from '../look/shots.js';
import { DIRECTORS, RESTFUL_FAMILIES, pickDirector } from '../look/directors.js';
import { scenes, scenesInFamily } from '../scenes/registry.js';
import { paletteShiftAt, MAX_HUE_ROTATION } from '../look/paletteArc.js';
import { shiftPalette, paletteContrast } from '../look/palette.js';
import { ArcDriver } from '../look/ArcDriver.js';

/** Tracks at several tempos: shot length is measured in bars, so tempo is the axis. */
let cached = null;
function tempoBattery() {
    if (!cached) {
        cached = [
            { name: '90bpm', track: track(90) },
            { name: '124bpm', track: track(124) },
            { name: '150bpm', track: track(150) },
        ];
    }
    return cached;
}
function track(bpm) {
    return FeatureTrack.fromAudioBuffer(
        synthesizeSectioned({ bpm, duration: 300, changeAt: 150 }), { fps: 60 });
}

/** Every shot in a look, with its length in seconds, grouped by section. */
function shotLengths(look) {
    return look.sections.map((s) => (s.shots || []).map((sh) => (sh.endFrame - sh.startFrame) / 60));
}

check(11, 'a section does not cut on a metronome', () => {
    // The complaint, as a number. Before this, a five-minute track at 90 BPM
    // was sixteen shots of 18.7 seconds — coefficient of variation about 0.01.
    // A section has to show real spread between its shot lengths, or the eye
    // starts predicting the cuts and the video reads as a slideshow.
    const problems = [];
    let measured = 0;

    for (const { name, track: t } of tempoBattery()) {
        for (let s = 0; s < 6; s++) {
            const look = generateLook(t, { seed: 2100 + s * 7919 });
            shotLengths(look).forEach((lengths, si) => {
                if (lengths.length < 3) return;      // too few shots to have a rhythm
                measured++;
                const mean = lengths.reduce((a, b) => a + b, 0) / lengths.length;
                const sd = Math.sqrt(
                    lengths.reduce((a, b) => a + (b - mean) ** 2, 0) / lengths.length);
                const cv = sd / Math.max(1e-6, mean);
                if (cv < 0.12) {
                    problems.push(`${name} s${s}/§${si}: cv ${cv.toFixed(3)} over ` +
                        `${lengths.length} shots (${lengths.map((l) => l.toFixed(1)).join(', ')})`);
                }
            });
        }
    }

    return expect(measured > 0 && problems.length === 0,
        problems.length ? problems.slice(0, 3).join(' · ')
            : `${measured} sections all cut with varying shot lengths`);
});

check(11, 'the cutting rhythm repeats rather than wandering', () => {
    // The counter-check, and the reason the gate above is not sufficient on its
    // own: RANDOM shot lengths would pass it and would look worse than a
    // metronome. The ear is following an eight-bar structure; the eye has to be
    // following one too. So a section's shot lengths must come from a small set
    // of values that recur, not from a continuum.
    const problems = [];
    let measured = 0;

    for (const { name, track: t } of tempoBattery()) {
        for (let s = 0; s < 6; s++) {
            const look = generateLook(t, { seed: 3300 + s * 6841 });
            shotLengths(look).forEach((lengths, si) => {
                if (lengths.length < 5) return;
                measured++;
                // Quantise to a quarter second: two shots from the same pattern
                // entry differ only by downbeat snapping.
                const buckets = new Set(lengths.map((l) => Math.round(l * 4)));
                if (buckets.size > Math.ceil(lengths.length * 0.7)) {
                    problems.push(`${name} s${s}/§${si}: ${buckets.size} distinct lengths ` +
                        `over ${lengths.length} shots — wandering, not phrased`);
                }
            });
        }
    }

    return expect(measured > 0 && problems.length === 0,
        problems.length ? problems.slice(0, 3).join(' · ')
            : `${measured} sections reuse a small set of shot lengths`);
});

check(11, 'shot length floor and ceiling still hold', () => {
    // The rhythm work moves cuts around; these two limits are what keep it from
    // producing a subliminal flash or a two-minute hold.
    const problems = [];
    let count = 0;

    for (const { name, track: t } of tempoBattery()) {
        for (let s = 0; s < 8; s++) {
            const look = generateLook(t, { seed: 4400 + s * 15485863 });
            shotLengths(look).forEach((lengths, si) => {
                lengths.forEach((l, i) => {
                    count++;
                    // Half a frame of slack: shot bounds are rounded to frames.
                    if (l < MIN_SHOT_SECONDS - 0.02) problems.push(`${name} s${s}/§${si}#${i}: ${l.toFixed(2)}s under floor`);
                    if (l > MAX_SHOT_SECONDS + 0.02) problems.push(`${name} s${s}/§${si}#${i}: ${l.toFixed(2)}s over ceiling`);
                });
            });
        }
    }

    return expect(problems.length === 0,
        problems.length ? problems.slice(0, 4).join(' · ')
            : `${count} shots all within ${MIN_SHOT_SECONDS}–${MAX_SHOT_SECONDS}s`);
});

check(11, 'cuts land on the beat grid', () => {
    // A cut that lands across a phrase instead of on it reads as a mistake, and
    // it is the thing most easily lost when shot timing stops being uniform.
    const problems = [];
    let total = 0;
    let onGrid = 0;

    for (const { name, track: t } of tempoBattery()) {
        const beatSeconds = t.tempo.period / 60;
        for (let s = 0; s < 6; s++) {
            const look = generateLook(t, { seed: 5500 + s * 2654435761 });
            look.sections.forEach((section) => {
                (section.shots || []).forEach((shot, i) => {
                    if (i === 0) return;             // section starts are not cuts
                    total++;
                    const at = shot.startFrame / 60;
                    // Distance to the nearest downbeat, in beats.
                    let best = Infinity;
                    for (const d of (t.tempo.downbeats || [])) {
                        best = Math.min(best, Math.abs(d - at));
                    }
                    if (best <= beatSeconds * 0.75) onGrid++;
                });
            });
        }
        if (!t.tempo.downbeats || !t.tempo.downbeats.length) problems.push(`${name}: no downbeat grid`);
    }

    const ratio = total ? onGrid / total : 0;
    return expect(problems.length === 0 && total > 0 && ratio > 0.8,
        `${onGrid}/${total} cuts within three quarters of a beat of a downbeat ` +
        `(${(ratio * 100).toFixed(0)}%, floor 80%)`);
});

// --- the director ----------------------------------------------------------
// EPIC-2.md §3.2. The kind-to-family mapping used to be a module constant, so
// every video made the same genre decisions before a single seeded draw
// happened. It is now a per-track choice — see look/directors.js.

check(11, 'every scene in the library is reachable', () => {
    // The number that made this worth doing: across twelve tracks, twenty-nine
    // of forty-two scenes were cast in none of them. A scene no director can
    // reach is dead weight, and the fault is in the mapping rather than in the
    // scene, so this is asked of the mapping directly.
    const reachable = new Set();
    for (const d of DIRECTORS) {
        for (const families of Object.values(d.families)) {
            for (const f of families) {
                for (const m of scenesInFamily(f)) reachable.add(m.name);
            }
        }
    }
    // Accents are cast by role rather than by family and are always eligible.
    const missing = scenes.filter((m) => m.role !== 'accent' && !reachable.has(m.name));

    return expect(missing.length === 0,
        missing.length ? `unreachable: ${missing.map((m) => m.name).join(', ')}`
            : `all ${reachable.size} non-accent scenes reachable across ${DIRECTORS.length} directors`);
});

check(11, 'no director starves a section kind', () => {
    // A director is a point of view, not a corner. Every kind it defines has to
    // leave enough scenes to build a roster from and still have room for the
    // signature filter to remove some.
    const problems = [];
    for (const d of DIRECTORS) {
        for (const [kind, families] of Object.entries(d.families)) {
            const pool = new Set();
            for (const f of families) {
                for (const m of scenesInFamily(f)) if (m.role !== 'accent') pool.add(m.name);
            }
            if (pool.size < 12) problems.push(`${d.name}/${kind}: only ${pool.size}`);
        }
        const kinds = Object.keys(d.families);
        for (const k of ['intro', 'build', 'drop', 'sustain', 'breakdown', 'outro']) {
            if (!kinds.includes(k)) problems.push(`${d.name}: no mapping for '${k}'`);
        }
    }
    return expect(problems.length === 0,
        problems.length ? problems.join(' · ')
            : `${DIRECTORS.length} directors, every kind backed by 12+ scenes`);
});

check(11, 'no director puts a loud family in a quiet section', () => {
    // Phase 7 has enforced this since the minimal family existed, and the first
    // draft of directors.js broke it — `corrupt` opened on glitch, `geometer`
    // on geometric. An intro that opens strobing and a breakdown that answers a
    // lull with a dense pattern are the two specific mistakes the family
    // coupling exists to prevent, and a viewer meets them in the first fifteen
    // seconds. Asserted against the mappings directly so a new director cannot
    // reintroduce it without tripping this rather than a downstream render gate.
    const problems = [];
    for (const d of DIRECTORS) {
        for (const kind of ['intro', 'breakdown', 'outro']) {
            for (const f of d.families[kind] || []) {
                if (!RESTFUL_FAMILIES.includes(f)) problems.push(`${d.name}/${kind}: ${f}`);
            }
        }
    }
    return expect(problems.length === 0,
        problems.length ? problems.join(' · ')
            : `${DIRECTORS.length} directors keep intro/breakdown/outro on ${RESTFUL_FAMILIES.join('/')}`);
});

check(11, 'every director is castable', () => {
    // Weighting tilts the odds by timbre; none of it may drive a weight to zero,
    // or a director exists in the file and never in a video.
    const probes = [
        { meanFlatness: 0.02, meanCentroid: 0.15 },   // tonal and dark
        { meanFlatness: 0.5,  meanCentroid: 0.9 },    // noisy and bright
        { meanFlatness: 0.2,  meanCentroid: 0.5 },
    ];
    const rng = { pickWeighted: (arr, w) => w };      // capture the weights
    const problems = [];
    for (const p of probes) {
        const weights = pickDirector(p, rng);
        weights.forEach((w, i) => {
            if (!(w > 0)) problems.push(`${DIRECTORS[i].name} unreachable at flatness ${p.meanFlatness}`);
        });
    }
    return expect(problems.length === 0,
        problems.length ? problems.join(' · ')
            : `all ${DIRECTORS.length} directors carry weight on every timbre probed`);
});

check(11, 'two tracks do not agree on what a section kind looks like', () => {
    // The population question. Across a battery, the same section kind must be
    // answered by more than one family, or the director layer is decorative.
    const seen = new Map();       // kind -> Set of family names actually cast
    const directors = new Set();

    for (const { track: t } of tempoBattery()) {
        for (let s = 0; s < 20; s++) {
            const look = generateLook(t, { seed: 6600 + s * 7919 });
            directors.add(look.director);
            look.sections.forEach((section) => {
                const set = seen.get(section.kind) || new Set();
                (section.variants || [section.layers]).forEach((v) => set.add(v[0].module.family));
                seen.set(section.kind, set);
            });
        }
    }

    const thin = [...seen.entries()].filter(([, set]) => set.size < 3)
        .map(([kind, set]) => `${kind}: ${[...set].join('/')}`);

    // Three of five in a population this size is a real spread; demanding all
    // five would be demanding a particular draw rather than a working mechanism,
    // and reachability is asserted directly by the check above.
    return expect(directors.size >= 3 && thin.length === 0,
        `${directors.size}/${DIRECTORS.length} directors cast · ` +
        (thin.length ? `thin kinds — ${thin.join(' · ')}`
            : [...seen.entries()].map(([k, v]) => `${k}:${v.size}fam`).join(' ')));
});

check(11, 'a track shows more of the library than it used to', () => {
    // Twelve tracks used to reach thirteen distinct scenes between them. This
    // asks the same question of the same size population.
    const cast = new Set();
    tempoBattery().forEach(({ track: t }, i) => {
        for (let s = 0; s < 4; s++) {
            const look = generateLook(t, { seed: (i * 2654435761 + s * 40503) >>> 0 });
            look.sections.forEach((section) =>
                (section.variants || [section.layers]).forEach((v) => cast.add(v[0].module.name)));
        }
    });
    const pool = scenes.filter((m) => m.role !== 'accent').length;
    return expect(cast.size >= pool * 0.4,
        `${cast.size}/${pool} scenes cast across 12 tracks (floor ${Math.ceil(pool * 0.4)})`);
});

// --- colour movement -------------------------------------------------------
// EPIC-2.md §3.3. The palette was generated once and pushed to every layer of
// every section for the whole runtime — five minutes, one scheme, no movement,
// with colour being the strongest perceptual variable available.

check(11, 'the palette moves across a track', () => {
    // The complaint as a number. Sample the arc from first frame to last and
    // require the colours to actually end up somewhere else.
    const moved = [];
    const held = [];

    for (const { track: t } of tempoBattery()) {
        for (let s = 0; s < 8; s++) {
            const look = generateLook(t, { seed: 7700 + s * 6841 });
            const arc = look.paletteArc;
            let worst = 0;
            for (const kind of ['intro', 'drop', 'breakdown', 'outro']) {
                for (const p of [0, 0.5, 1]) {
                    const shift = paletteShiftAt(arc, {
                        progress: p, sectionKind: kind,
                        features: { sectionEnergy: p, buildSlope: 0 },
                    });
                    const shifted = shiftPalette(look.palette, shift);
                    for (let i = 0; i < look.palette.length; i++) {
                        const a = look.palette[i], b = shifted[i];
                        worst = Math.max(worst, Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]));
                    }
                }
            }
            (arc.mode === 'static' ? held : moved).push(worst);
        }
    }

    const weak = moved.filter((d) => d < 0.03).length;
    const ratio = moved.length / (moved.length + held.length);
    return expect(weak === 0 && ratio > 0.6,
        `${moved.length} moving / ${held.length} held · weakest move ` +
        `${(moved.length ? Math.min(...moved) : 0).toFixed(3)} channel distance (floor 0.03)`);
});

check(11, 'colour movement never breaks the contrast floor', () => {
    // The Phase 3 palette gate proves the palette is usable when it is
    // generated. That says nothing about where the arc takes it — a saturation
    // lift or a lightness shift can flatten a perfectly good set. So the floor
    // is asserted at every sampled point of the movement rather than only at
    // the two ends, which is where this would otherwise be silently violated.
    const problems = [];
    let sampled = 0;

    for (const { track: t } of tempoBattery()) {
        for (let s = 0; s < 6; s++) {
            const look = generateLook(t, { seed: 8800 + s * 15485863 });
            const base = paletteContrast(look.palette).luminanceSpread;
            for (const kind of ['intro', 'build', 'drop', 'sustain', 'breakdown', 'outro']) {
                for (let p = 0; p <= 1.001; p += 0.25) {
                    for (const energy of [0, 0.5, 1]) {
                        sampled++;
                        const shift = paletteShiftAt(look.paletteArc, {
                            progress: p, sectionKind: kind,
                            features: { sectionEnergy: energy, buildSlope: energy },
                        });
                        const spread = paletteContrast(
                            shiftPalette(look.palette, shift)).luminanceSpread;
                        // Held against the palette's OWN spread, not an absolute:
                        // the arc must not degrade what generation achieved.
                        if (spread < Math.min(0.18, base * 0.75)) {
                            problems.push(`seed ${s} ${kind}@${p.toFixed(2)}/e${energy}: ` +
                                `spread ${spread.toFixed(3)} from base ${base.toFixed(3)}`);
                        }
                    }
                }
            }
        }
    }

    return expect(problems.length === 0,
        problems.length ? `${problems.length} of ${sampled} points muddy — ${problems[0]}`
            : `${sampled} points across the movement all clear the contrast floor`);
});

check(11, 'colour movement stays inside its identity', () => {
    // The counter-check. A palette that moves far enough is a different
    // palette, and then the track has no colour identity at all — which would
    // pass the movement gate above with room to spare.
    const problems = [];
    for (const { track: t } of tempoBattery()) {
        for (let s = 0; s < 8; s++) {
            const look = generateLook(t, { seed: 9900 + s * 7919 });
            for (const kind of ['intro', 'drop', 'breakdown']) {
                for (const p of [0, 0.5, 1]) {
                    const shift = paletteShiftAt(look.paletteArc, {
                        progress: p, sectionKind: kind,
                        features: { sectionEnergy: p, buildSlope: p },
                    });
                    if (Math.abs(shift.hue) > MAX_HUE_ROTATION + 1e-6) {
                        problems.push(`seed ${s}: hue ${shift.hue.toFixed(2)} past the ceiling`);
                    }
                }
            }
        }
    }
    return expect(problems.length === 0,
        problems.length ? problems[0]
            : `hue travel stays within ±${(MAX_HUE_ROTATION * 57.3).toFixed(0)}° of the track's palette`);
});

check(11, 'a moved palette is the same on a seek as on playback', () => {
    // The movement is memoised on a rounded shift. Rounding is what keeps a
    // seeked frame bit-identical to a played one rather than merely close, and
    // getting that wrong would be a determinism fault that only shows up in an
    // export.
    const t = tempoBattery()[1].track;
    const look = generateLook(t, { seed: 4321 });
    const a = new ArcDriver(look, t);
    const b = new ArcDriver(look, t);

    const probes = [0, 900, 4500, 9000, 12000];
    let worst = 0;
    try {
        // One driver plays through; the other jumps straight to each probe.
        for (let f = 0; f <= 12000; f += 30) a.update(f, t.at(f));
        for (const f of probes) {
            const seq = a._paletteAt(f, t.at(f));
            const jump = b._paletteAt(f, t.at(f));
            for (let i = 0; i < seq.length; i++) {
                for (let c = 0; c < 3; c++) worst = Math.max(worst, Math.abs(seq[i][c] - jump[i][c]));
            }
        }
    } finally {
        a.dispose(); b.dispose();
    }

    return expect(worst === 0,
        `worst channel difference between seeked and played colours: ${worst}`);
});
