// Phase 13 gate — the story.
//
// Phase 8 gave a track cuts, Phase 9 an identity, Phase 10 a hand on the dials.
// All three are properties of a video at a MOMENT. This phase is about the one
// property that only exists over its whole length: that it is going somewhere,
// and that where it is going follows the song rather than the seed.
//
// Everything here runs on the analysis and the look, without a GPU. The
// rendered half of the question — does the arc reach the IMAGE — is the
// `direction` statistic in the variety report (checks/variety/signature.js),
// because answering it requires probing real frames.
//
// The three things this gate is actually protecting, in order of how bad the
// failure would be:
//
//   1. the story never overrides the song. A plot that declares a climax where
//      the track is quiet is worse than no plot, and a tension that lifts a
//      breakdown into a drop breaks the quiet-kind coupling every earlier phase
//      depends on.
//   2. the same kind twice is not the same twice. This is the whole feature.
//   3. it stays pure and continuous in the frame, or the export stops matching
//      the preview and the boundary pops.

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 { song } from '../audio/songbank.js';
import { storyStateAt, STORY_VARS, PLOT_NAMES } from '../look/Story.js';

/**
 * Bank songs rather than the two-section synthetics the other phases use.
 *
 * A story is a property of a SEQUENCE of sections, so a track that segments into
 * two has nothing for this gate to look at — measured, the synthetic battery
 * states no kind twice in any of its four tracks, which is precisely the case
 * the central check here exists to measure. Four minutes each, across the bank's
 * range, so every track has a real arrangement under it.
 */
const NAMES = ['ember', 'centre', 'elegy', 'lattice'];
let cachedBattery = null;
function battery() {
    if (!cachedBattery) {
        cachedBattery = NAMES.map((name) => ({ name, track: song(name, { duration: 220 }).track }));
    }
    return cachedBattery;
}

const looks = (seed0) => battery().map(({ track, name }, i) => ({
    name, track, look: generateLook(track, { seed: (seed0 + i * 7919) >>> 0 }),
}));

check(13, 'the climax is where the song is loudest', () => {
    // The one non-negotiable. Every moment in a story is READ off the section
    // energies rather than placed by the seed, so this is a check that the
    // derivation still says what it claims and has not drifted into being
    // decorative — a story that puts its peak somewhere the audio does not is
    // the failure mode that would make the whole layer worth deleting.
    const bad = [];
    for (const { name, track, look } of looks(0x5709)) {
        const energies = track.sections.map((s) => s.energy || 0);
        const peak = Math.max(...energies);
        const at = look.story.moments.climax;
        if (energies[at] < peak - 1e-9) bad.push(`${name}: climax §${at} at ${energies[at].toFixed(3)} vs peak ${peak.toFixed(3)}`);
    }
    return expect(bad.length === 0,
        bad.length ? bad.join(' · ') : `${battery().length} tracks · climax on the loudest section in each`);
});

check(13, 'the story does not lift a quiet section into a loud one', () => {
    // Tension moves the parameter bias, and it is bounded so that it can only
    // decide WHICH drop this is, never whether a breakdown is one. If this ever
    // fails, the family coupling in directors.js is next: an intro at drop
    // energy is exactly the strobing opening that coupling exists to prevent.
    const QUIET = new Set(['intro', 'breakdown', 'outro']);
    let worstQuiet = 0;
    let loudest = 0;
    let checked = 0;
    for (const { look } of looks(0x1a71)) {
        for (const section of look.sections) {
            checked++;
            if (QUIET.has(section.kind)) worstQuiet = Math.max(worstQuiet, section.bias.energy);
            if (section.kind === 'drop') loudest = Math.max(loudest, section.bias.energy);
        }
    }
    return expect(worstQuiet < 0.62,
        `${checked} sections · loudest quiet-kind bias ${worstQuiet.toFixed(3)} ` +
        `(limit 0.62) · loudest drop bias ${loudest.toFixed(3)}`);
});

check(13, 'the second time a kind happens is not the first time again', () => {
    // The feature, as a number. Take every track that states a kind twice and
    // require the two occurrences to differ in something a viewer could name:
    // which scene opens them, or how hard their parameters are pushed.
    //
    // Both halves count, because either alone is achievable and neither alone
    // is the point — a different scene at the same intensity is a shuffle, and
    // the same scene at a different intensity is a fade.
    const seen = [];
    for (const { name, look } of looks(0x2b0c)) {
        const byKind = new Map();
        for (const section of look.sections) {
            if (!byKind.has(section.kind)) byKind.set(section.kind, []);
            byKind.get(section.kind).push(section);
        }
        for (const [kind, list] of byKind) {
            if (list.length < 2) continue;
            const first = list[0];
            const last = list[list.length - 1];
            const sceneChanged = first.layers[0].module.name !== last.layers[0].module.name;
            const pushed = Math.abs(last.story.tension - first.story.tension);
            seen.push({ name, kind, sceneChanged, pushed });
        }
    }
    if (!seen.length) {
        return expect(false, 'no track in the battery states a kind twice — the check measured nothing');
    }
    const moved = seen.filter((s) => s.sceneChanged || s.pushed > 0.08);
    return expect(moved.length === seen.length,
        `${seen.length} repeated kinds · ${moved.length} differ · ` +
        seen.map((s) => `${s.name}/${s.kind}: ${s.sceneChanged ? 'recast' : 'same scene'} ` +
            `Δtension ${s.pushed.toFixed(2)}`).join(' · '));
});

check(13, 'the journey travels, and travels in stages', () => {
    // Two failures at once. A journey that does not move is the old blind slow
    // axis with extra machinery; a journey that slides continuously is a slow
    // zoom, which is an effect rather than a narrative. So it has to cover most
    // of its range AND spend most of the video not moving at all.
    const rows = [];
    for (const { name, track, look } of looks(0x3f11)) {
        let lo = 1, hi = 0, moving = 0, frames = 0, maxStep = 0;
        let previous = null;
        for (let f = 0; f < track.frameCount; f += 5) {
            const j = storyStateAt(look.story, f).journey;
            lo = Math.min(lo, j); hi = Math.max(hi, j);
            if (previous !== null) {
                const step = Math.abs(j - previous);
                maxStep = Math.max(maxStep, step);
                if (step > 1e-4) moving++;
            }
            previous = j;
            frames++;
        }
        rows.push({ name, travel: hi - lo, held: 1 - moving / Math.max(1, frames - 1), maxStep });
    }
    const worstTravel = Math.min(...rows.map((r) => r.travel));
    const worstHeld = Math.min(...rows.map((r) => r.held));
    const worstStep = Math.max(...rows.map((r) => r.maxStep));
    return expect(worstTravel > 0.5 && worstHeld > 0.5 && worstStep < 0.1,
        rows.map((r) => `${r.name}: travel ${r.travel.toFixed(2)} held ${(r.held * 100).toFixed(0)}% ` +
            `worst step ${r.maxStep.toFixed(3)}`).join(' · '));
});

check(13, 'the story is a pure function of the frame', () => {
    // Same rule as everything else on the render path: a seek must land on the
    // story position playback would have reached, or the export stops matching
    // the preview. Sampled forwards, then backwards, then at random.
    const { track, look } = looks(0x4c33)[1];
    const forward = [];
    for (let f = 0; f < track.frameCount; f += 37) forward.push(storyStateAt(look.story, f));

    let mismatch = null;
    for (let i = forward.length - 1; i >= 0 && !mismatch; i--) {
        const again = storyStateAt(look.story, i * 37);
        for (const key of [...STORY_VARS, 'journey']) {
            if (again[key] !== forward[i][key]) {
                mismatch = `frame ${i * 37} ${key}: ${again[key]} vs ${forward[i][key]}`;
                break;
            }
        }
    }
    return expect(!mismatch, mismatch || `${forward.length} frames sampled in both directions, identical`);
});

check(13, 'two tracks do not tell the same story', () => {
    // A story layer that gave every video the same arc would be the exact
    // failure directors.js was written to fix, one level up. Population check,
    // like every variety measurement here: what matters is the spread, not any
    // single track's plot.
    const plots = new Map();
    let total = 0;
    for (let s = 0; s < 6; s++) {
        for (const { look } of looks(0x7000 + s * 104729)) {
            plots.set(look.story.plot, (plots.get(look.story.plot) || 0) + 1);
            total++;
        }
    }
    const reached = plots.size;
    const commonest = Math.max(...plots.values()) / total;
    return expect(reached >= 3 && commonest < 0.6,
        `${total} looks · ${reached}/${PLOT_NAMES.length} plots reached · ` +
        `commonest ${(commonest * 100).toFixed(0)}% · ` +
        [...plots].map(([k, v]) => `${k} ${v}`).join(', '));
});

check(13, 'a recapitulation actually recapitulates', () => {
    // When the story says the outro answers the intro, it has to be the same
    // scene — and the sections still have to be told apart by everything else,
    // or a "return" is just the video repeating itself.
    const rows = [];
    for (let s = 0; s < 8; s++) {
        for (const { look } of looks(0x9100 + s * 15485863)) {
            const intro = look.sections.find((x) => x.kind === 'intro');
            const outro = look.sections.find((x) => x.kind === 'outro');
            if (!look.story.recap || !intro || !outro) continue;
            // Not journey alone. The plot most likely to ask for a recap is
            // 'return', whose whole shape is an arch that comes BACK — so its
            // outro sits near the intro on the journey by design, and it is the
            // residue on everything else that makes the ending a return rather
            // than a rewind.
            rows.push({
                same: intro.layers[0].module.name === outro.layers[0].module.name,
                moved: Math.max(...['journey', 'reveal', 'tension'].map((k) =>
                    Math.abs(outro.story[k] - intro.story[k]))),
            });
        }
    }
    if (!rows.length) return expect(true, 'no recap track in this sample — nothing to check');
    const kept = rows.filter((r) => r.same).length;
    // 0.15 rather than something ambitious, and measured rather than guessed:
    // across the sample the median recap arrives a full 1.0 from where it
    // started and the closest — a 'return' on a four-section track, where the
    // arch has the least room — lands at 0.18. What this rules out is an outro
    // that is bit-identical to the intro, which is the failure worth gating.
    const travelled = rows.filter((r) => r.moved > 0.15).length;
    return expect(kept === rows.length && travelled === rows.length,
        `${rows.length} recap tracks · ${kept} re-cast the opening scene · ` +
        `${travelled} arrive at it changed · ` +
        `closest ${Math.min(...rows.map((r) => r.moved)).toFixed(2)}`);
});

check(13, 'a track with no structure gets no story', () => {
    // The degradation path. Two sections is not enough to carry an arc, and
    // forcing one produces a video that lurches rather than one that
    // progresses — so the whole layer fades toward neutral instead.
    const track = FeatureTrack.fromAudioBuffer(
        synthesizeSectioned({ bpm: 120, duration: 40, changeAt: 20 }), { fps: 60 });
    const look = generateLook(track, { seed: 0xd15a });
    const n = look.sections.length;
    const worst = Math.max(...look.sections.map((s) =>
        Math.max(...STORY_VARS.map((k) => Math.abs(s.story[k] - 0.5)))));
    return expect(n > 3 || worst < 0.35,
        `${n} sections · strength ${look.story.strength.toFixed(2)} · ` +
        `furthest any variable travels from neutral ${worst.toFixed(2)}`);
});
