// The song's STORY: where a section sits in the video, as opposed to what kind
// of section it is.
//
// Everything that decides what this generator puts on screen is keyed on
// section KIND — the roster (LookGenerator.assignRostersByKind), the parameter
// bias (KIND_ENERGY), the cutting rhythm (shots.rhythmFor), the colour offset
// (paletteArc.kindHue). Kinds recur. So the fourth drop is cast from the same
// roster, biased to the same energy, cut at the same rate and tinted the same
// hue as the first one, and nothing in the video can tell you which of them you
// are watching.
//
// The one exception was ArcDriver's slow axis, which travels one way across the
// whole track — and it is blind: it does not know where the drop is, and its
// direction is a coin flip per scene, so two scenes in one video routinely
// travel against each other.
//
// That is a song structure without a story: recurrence without consequence.
// This module adds the missing coordinate.
//
//   POSITION  which occurrence of its kind a section is, and which act it is in.
//             `ordinal` alone — "the 3rd of 4 drops" — unlocks most of what
//             follows, and nothing downstream had it.
//   MOMENTS   the four frames the video turns on, FOUND in the audio rather
//             than placed by the seed. A plot that declares a climax where the
//             track is quiet is worse than no plot at all.
//   PLOT      what the track does with them, as one of a few coherent narrative
//             shapes, chosen the way look/directors.js chooses a director.
//
// The plot is expressed as five variables that everything downstream reads.
// They are STAGED, not ramped: they hold flat inside a section and move at its
// boundary. A story advances in scenes; a smooth ramp over five minutes is a
// slow zoom, which is an effect rather than a narrative.
//
// Everything here is a pure function of (story, frame). No state, no random
// source past derivation — the same rule the rest of the render path follows,
// and what keeps a seek frame-identical to playback.

const clamp01 = (x) => Math.max(0, Math.min(1, x));
const smooth = (x) => { const t = clamp01(x); return t * t * (3 - 2 * t); };
const lerp = (a, b, t) => a + (b - a) * t;

/** The variables a plot moves. Neutral is 0.5 for all of them. */
export const STORY_VARS = ['tension', 'reveal', 'closeness', 'population', 'order'];

/**
 * What the story reads as when there is none — a look built by hand, or a check
 * that constructs sections directly. Everything downstream must render exactly
 * what it rendered before this module existed when it sees these.
 */
export const NEUTRAL_STATE = {
    tension: 0.5, reveal: 0.5, closeness: 0.5, population: 0.5, order: 0.5,
    journey: 0.5, act: 'development',
};

/**
 * The narrative shapes.
 *
 * One coherent point of view about what happens over a song, the same way a
 * director is one point of view about what a song looks like. Weighted, with
 * the audio tilting the odds and never deciding — a fixed mapping from measured
 * features to narrative is how a library ends up with one story per genre.
 *
 * `curve` receives a section's narrative position and the track's seeded curve
 * shape, and returns the five variables plus the journey the slow axis follows.
 *
 * ctx:
 *   p        0..1 through the section list
 *   rise     0..1 approach to the climax (1 from the climax onward)
 *   after    0..1 through the aftermath (0 up to the climax)
 *   ord      0..1 which occurrence of this kind it is
 *   energy   0..1 the section's measured energy, against the track's loudest
 */
export const PLOTS = [
    {
        name: 'emergence',
        weight: 3,
        // Almost nothing, then something. The reveal rises and stays up: what
        // was uncovered does not go back in the box.
        curve: (ctx, sh) => {
            const r = smooth(ctx.rise * 0.85 + ctx.p * 0.15);
            return {
                tension: 0.2 + 0.7 * r,
                reveal: lerp(0.12, 1, r),
                population: 0.25 + 0.6 * r,
                closeness: 0.35 + 0.4 * r * sh.closeDir,
                order: 0.6 - 0.1 * r,
                journey: r,
            };
        },
    },
    {
        name: 'escalation',
        weight: 3,
        // Each recurrence goes further than the last. A ratchet rather than a
        // curve — this is the plot that needs `ordinal`, and the one a viewer
        // names as "it kept going somewhere".
        curve: (ctx, sh) => {
            const r = clamp01(ctx.rise * 0.5 + ctx.ord * 0.5);
            return {
                tension: 0.25 + 0.75 * r,
                reveal: 0.3 + 0.6 * r,
                population: 0.3 + 0.6 * r,
                closeness: 0.35 + 0.45 * r * sh.closeDir,
                order: 0.7 - 0.35 * r,
                journey: clamp01(r * 0.8 + ctx.p * 0.2),
            };
        },
    },
    {
        name: 'collapse',
        weight: 2,
        // Order into entropy. The climax is the thing coming apart rather than
        // the thing at its peak, so `order` is monotone down and everything else
        // follows the energy.
        curve: (ctx, sh) => {
            const r = smooth(ctx.rise);
            return {
                tension: 0.3 + 0.6 * r,
                reveal: 0.35 + 0.5 * ctx.p,
                population: clamp01(0.35 + 0.5 * r - 0.35 * ctx.after),
                closeness: 0.3 + 0.45 * ctx.p * sh.closeDir,
                order: 0.9 - 0.8 * smooth(ctx.p * 0.7 + ctx.rise * 0.3),
                journey: ctx.p,
            };
        },
    },
    {
        name: 'return',
        weight: 2,
        // ABA. Everything arches up to the climax and comes back down — but not
        // all the way, and not to the same place: the small residue on `after`
        // is what makes it a return rather than a loop. The outro re-casts the
        // intro's scene; see LookGenerator and `recap`.
        curve: (ctx, sh) => {
            const arch = clamp01(smooth(ctx.rise) * (1 - smooth(ctx.after)));
            return {
                tension: 0.2 + 0.7 * arch,
                reveal: clamp01(0.25 + 0.6 * arch + 0.18 * ctx.after),
                population: 0.3 + 0.55 * arch,
                closeness: 0.35 + 0.45 * arch * sh.closeDir,
                order: 0.65 - 0.2 * arch,
                journey: clamp01(arch * 0.85 + ctx.p * 0.15),
            };
        },
    },
    {
        name: 'unveiling',
        weight: 2,
        // The song's protagonist is withheld and then it is all there is.
        // Population falls as reveal rises for that reason: a frame full of
        // chorus is what was hiding it.
        curve: (ctx, sh) => {
            const shown = smooth((ctx.rise - sh.holdOut) / Math.max(0.15, 1 - sh.holdOut));
            return {
                tension: 0.25 + 0.6 * smooth(ctx.rise),
                reveal: 0.1 + 0.9 * shown,
                population: 0.6 - 0.35 * shown,
                closeness: 0.3 + 0.6 * shown * sh.closeDir,
                order: 0.55 + 0.15 * shown,
                journey: clamp01(shown * 0.75 + ctx.p * 0.25),
            };
        },
    },
];

export const PLOT_NAMES = PLOTS.map((p) => p.name);

export function plotByName(name) {
    return PLOTS.find((p) => p.name === name) || PLOTS[0];
}

/**
 * Cast the plot.
 *
 * The tilts are deliberately loose: a track that keeps changing what it is
 * doing has recurrences to ratchet, a dynamic one has somewhere to come back
 * from, a noisy one comes apart more readily than it builds. Every plot stays
 * reachable for every track.
 */
function pickPlot(summary, sectionCount, rng) {
    const noisy = Math.min(1, (summary.meanFlatness ?? 0.2) * 3);
    const dynamic = clamp01(summary.dynamicRange ?? 0.5);
    const busy = clamp01((sectionCount - 2) / 5);

    const weights = PLOTS.map((plot) => {
        let w = plot.weight;
        if (plot.name === 'escalation') w *= 0.5 + busy * 1.8;
        if (plot.name === 'collapse')   w *= 0.5 + noisy * 1.8;
        if (plot.name === 'return')     w *= 0.5 + dynamic * 1.4;
        if (plot.name === 'emergence')  w *= 0.6 + (1 - busy) * 1.2;
        if (plot.name === 'unveiling')  w *= 0.6 + (1 - noisy) * 1.0;
        return w;
    });
    return rng.pickWeighted(PLOTS, weights);
}

/**
 * The frames the story turns on.
 *
 * Every one of these is read off the section energies `segment.js` already
 * produced. Nothing here may invent a moment the audio does not have: the story
 * follows the song, and the one failure mode worse than no story is a story
 * that declares its climax where the track is quiet.
 *
 * A short or flat track collapses several of these onto the same section, which
 * is correct rather than degenerate — a song with no structure gets no story.
 */
function findMoments(sections) {
    const n = sections.length;
    const energies = sections.map((s) => s.energy || 0);
    const peak = Math.max(...energies, 1e-6);

    // The climax is the loudest section, ties broken toward the later one: when
    // a track states the same peak twice, the second one is the one that means
    // something, because the first has already happened.
    let climax = 0;
    for (let i = 0; i < n; i++) if (energies[i] >= energies[climax]) climax = i;

    // The arrival is the video's first "here it is" — the first section that
    // clears most of the way to the peak.
    let arrival = null;
    for (let i = 0; i < n; i++) {
        if (energies[i] >= peak * 0.6) { arrival = i; break; }
    }
    if (arrival === null) arrival = Math.min(climax, n - 1);

    // The turn is the largest fall, and it has to happen after something has
    // arrived — a quiet opening followed by a quieter one is not a reversal.
    let turn = null;
    let worst = 0;
    for (let i = arrival + 1; i < n; i++) {
        const fall = energies[i - 1] - energies[i];
        if (fall > worst) { worst = fall; turn = i; }
    }

    // The resolution is where the track stops trying to top itself.
    let resolution = null;
    for (let i = climax + 1; i < n; i++) {
        if (energies[i] < energies[climax] * 0.85) { resolution = i; break; }
    }
    if (resolution === null && climax < n - 1) resolution = n - 1;

    return { arrival, turn, climax, resolution };
}

function actFor(index, moments) {
    if (index === moments.climax) return 'climax';
    if (moments.resolution !== null && index >= moments.resolution) return 'resolution';
    if (moments.turn !== null && index === moments.turn) return 'turn';
    if (moments.arrival !== null && index < moments.arrival) return 'setup';
    return 'development';
}

/**
 * Derive the story for a track.
 *
 * @param {FeatureTrack} track
 * @param {object} summary  track.summary
 * @param {Rng} rng
 * @returns {object} plain data — no functions, so a look stays serialisable
 */
export function deriveStory(track, summary, rng) {
    const sections = track.sections || [];
    const n = sections.length;
    const plot = pickPlot(summary, n, rng);

    // The track's own version of its plot. Two tracks telling the same story
    // still have to differ, for the reason directors.js gives about house
    // styles — so the shape of the curves is seeded even when the plot is not.
    const shape = {
        // Whether this video moves toward its subject or pulls away from it.
        // Toward, usually: a video that ends further away than it started is a
        // real choice and a rarer one.
        closeDir: rng.bool(0.75) ? 1 : -0.6,
        // How long `unveiling` keeps its protagonist back.
        holdOut: rng.range(0.45, 0.8),
        // How hard this track commits to its plot at all.
        bite: rng.range(0.7, 1.0),
    };

    const moments = findMoments(sections);

    // How much story a track has room for. Under about four sections the curves
    // have nowhere to travel, and forcing them produces a video that lurches
    // rather than one that progresses, so the whole layer fades toward neutral.
    const strength = clamp01((n - 1) / 3) * shape.bite;

    const peak = Math.max(...sections.map((s) => s.energy || 0), 1e-6);
    const climax = moments.climax;
    const seenKind = new Map();
    const countKind = new Map();
    for (const s of sections) countKind.set(s.kind, (countKind.get(s.kind) || 0) + 1);

    const states = sections.map((section, i) => {
        const ordinal = seenKind.get(section.kind) || 0;
        seenKind.set(section.kind, ordinal + 1);
        const ordinalOf = countKind.get(section.kind) || 1;

        const ctx = {
            p: n > 1 ? i / (n - 1) : 0.5,
            rise: climax > 0 ? clamp01(i / climax) : 1,
            after: i > climax && n - 1 > climax ? clamp01((i - climax) / (n - 1 - climax)) : 0,
            ord: ordinalOf > 1 ? ordinal / (ordinalOf - 1) : (n > 1 ? i / (n - 1) : 0.5),
            energy: clamp01((section.energy || 0) / peak),
        };

        const raw = plot.curve(ctx, shape);

        // The plot proposes and the song disposes. Blending the section's
        // measured energy back in is what stops a story-driven tension from
        // overriding a quiet section — the narrative may say "further than
        // before", it may not say "loud" where the track is not.
        raw.tension = raw.tension * 0.75 + ctx.energy * 0.25;

        const state = { index: i, kind: section.kind, ordinal, ordinalOf, act: actFor(i, moments) };
        for (const key of STORY_VARS) {
            state[key] = clamp01(lerp(0.5, clamp01(raw[key]), strength));
        }
        // The journey keeps its full travel — it is the axis the whole video
        // moves along, and halving it on a four-section track is the same as
        // not having it. Its NEUTRAL is the plain progress ramp the slow axis
        // used before this module existed, so a thin track degrades to exactly
        // the old behaviour rather than to a flat line.
        state.journey = clamp01(lerp(ctx.p, clamp01(raw.journey), strength));
        return state;
    });

    // How long a story variable takes to arrive at its new value. Roughly a
    // bar: long enough that nothing pops mid-crossfade (the failure buildSlope
    // already taught us about at a boundary), short enough that against a
    // five-minute video it still reads as a step rather than a ramp.
    const barSeconds = track.tempo
        ? (track.tempo.period * track.tempo.beatsPerBar) / track.fps : 2;
    const blendFrames = Math.max(6, Math.round(barSeconds * track.fps));

    return {
        plot: plot.name,
        shape,
        moments,
        strength,
        blendFrames,
        // All of a video's scenes should travel the same WAY. Magnitude stays
        // per scene, but the sign is the track's, because two scenes drifting
        // against each other is what made the slow axis read as wobble rather
        // than as a direction.
        axisSign: rng.bool() ? 1 : -1,
        // The oldest device available and the cheapest one here: the outro
        // re-casts the intro's scene, played with the parameters the story has
        // arrived at rather than the ones it opened with.
        recap: plot.name === 'return' || rng.bool(0.15),
        sections: states,
        frames: sections.map((s) => ({ startFrame: s.startFrame, endFrame: s.endFrame })),
    };
}

/** The story state of one section, or the neutral read. */
export function storyForSection(story, index) {
    if (!story || !story.sections || !story.sections[index]) return { ...NEUTRAL_STATE };
    return story.sections[index];
}

/**
 * The story state at a frame.
 *
 * Held flat inside a section and blended over `blendFrames` after each boundary.
 * Continuous in frame on purpose: the outgoing layer of a crossfade is still on
 * screen when the boundary passes, and stepping its inputs there is a visible
 * pop at exactly the moment the edit is trying to hide.
 */
export function storyStateAt(story, frame) {
    if (!story || !story.sections || !story.sections.length) return { ...NEUTRAL_STATE };

    const frames = story.frames;
    let lo = 0;
    let hi = frames.length - 1;
    while (lo < hi) {
        const mid = (lo + hi + 1) >> 1;
        if (frames[mid].startFrame <= frame) lo = mid; else hi = mid - 1;
    }

    const current = story.sections[lo];
    if (lo === 0) return current;

    const into = frame - frames[lo].startFrame;
    if (into >= story.blendFrames) return current;

    const previous = story.sections[lo - 1];
    const t = smooth(into / story.blendFrames);
    const out = { index: current.index, kind: current.kind, act: current.act,
                  ordinal: current.ordinal, ordinalOf: current.ordinalOf };
    for (const key of STORY_VARS) out[key] = lerp(previous[key], current[key], t);
    out.journey = lerp(previous.journey, current.journey, t);
    return out;
}

/** One line for the HUD, the look panel and check output. */
export function describeStory(story) {
    if (!story) return 'story: none';
    const m = story.moments;
    const at = (i) => (i === null || i === undefined ? '–' : `§${i}`);
    return `story: ${story.plot}${story.recap ? ' + recap' : ''} · `
        + `arrival ${at(m.arrival)} turn ${at(m.turn)} climax ${at(m.climax)} `
        + `resolution ${at(m.resolution)}`;
}
