// Turns a FeatureTrack into a complete LookSpec: palette, per-section scene
// assignments, parameter sets, and the post/feedback settings.
//
// Runs once per track. Deterministic in the seed, and the seed is derived from
// the decoded audio, so a given file always renders the same video.

import { Rng, hashSamples } from '../engine/rng.js';
import { AudioPalette, generateUsablePalette } from './palette.js';
import { scenes, scenesInFamily, FAMILIES } from '../scenes/registry.js';
import { sampleValues, defaultValues, canBackground } from '../params/schema.js';
import {
    canGround, surfaceOf, structuralDistance, groundBiasFrom, groundTemperamentFrom,
    coverageOf as sceneCoverage, GROUND_MIN,
} from '../scenes/surface.js';
import { GROUND, subjectOf } from './stack.js';
import { planShots } from './shots.js';
import {
    generatePersonality, sceneHonours, signatureWeight, describePersonality,
} from './Personality.js';
import { deriveGrain, describeGrain, applyGrainToPost } from './grain.js';
import {
    pickDirector, directorByName, crowdOf, blazeOf, RESTFUL_FAMILIES, QUIET_KINDS,
} from './directors.js';
import { derivePaletteArc, describePaletteArc } from './paletteArc.js';
import { deriveFramingStyle, describeFraming } from './framing.js';
import { deriveCamera, describeCamera } from './Camera.js';
import { deriveStory, storyForSection, NEUTRAL_STATE } from './Story.js';

// Which families suit which section kind now comes from the track's DIRECTOR
// (look/directors.js) rather than from a constant here. The coupling it
// provides is the same — it is what stops a breakdown landing on a strobing
// glitch scene and an intro opening at full density — but which coupling a
// given track gets is a decision, not a fact about the program.

const KIND_ENERGY = {
    intro: 0.25, build: 0.55, drop: 0.95, sustain: 0.6, breakdown: 0.25, outro: 0.2,
};

/**
 * Parameter bias per section: the values scenes declare a `bias` key against.
 *
 * This is how a track's measured character reaches a scene's parameters without
 * the scene knowing anything about audio. A dense, loud drop pushes `density`
 * and `energy` up; a breakdown pulls them down. Seed variation still dominates,
 * so two tracks with the same structure do not converge on the same look.
 */
function biasFor(section, summary, motion = null, story = null) {
    const kindEnergy = KIND_ENERGY[section.kind] ?? 0.5;
    const measured = Math.min(1, section.energy / Math.max(1e-6, summary.meanLoudness * 1.6));
    // Where this section sits in the story moves the bias, and is deliberately
    // the smallest term in it. The kind decides what a section IS — the spread
    // between an intro and a drop is 0.7 of the range — and the story decides
    // which drop this is, worth a tenth of that. Bounded rather than trusted:
    // a breakdown at maximum tension is still, unambiguously, a breakdown, and
    // the quiet-kind coupling in directors.js depends on it staying that way.
    const tension = story ? story.tension : 0.5;
    const population = story ? story.population : 0.5;
    const energy = clamp01(kindEnergy * 0.6 + measured * 0.4 + (tension - 0.5) * 0.16);

    // 60bpm → 0, 180bpm → 1. Tempo, not energy, is what a viewer reads as
    // "this is moving too fast for the song": a slow track can have a huge drop
    // and still want scenes that drift. Motion used to be mostly energy with
    // tempo as a small correction, which is why a 70bpm ballad got a drop
    // biased to 0.9 motion and scenes that skittered over it.
    const tempo = clamp01((summary.bpm - 60) / 120);

    // The track's motion CHARACTER, on top of its tempo. Tempo alone compresses
    // — 124 and 138bpm are the same number to a viewer — and motion was the
    // weakest axis in every measurement because it was the only lever. Stillness
    // is allowed to halve the animation rate or half again raise it, which is a
    // difference anyone can see, and it is a property of the track rather than
    // of the section. See look/Personality.js.
    const still = motion ? motion.stillness : 0.5;
    const churn = motion ? motion.churn : 0.25;

    return {
        energy,
        density: clamp01(energy * 0.7 + section.flux * 1.2 + (population - 0.5) * 0.2),
        motion: clamp01(0.12 + tempo * 0.4 + energy * 0.2 + (1 - still) * 0.35),
        // Applied on top of every `rate: true` param, so absolute animation
        // speed scales with the song rather than only its sampled position in
        // a range. Bounded well short of a stop or a blur. See params/schema.js.
        rateScale: (0.45 + tempo * 0.95) * (1.35 - still * 0.75) * (1 + churn * 0.25),
    };
}

const clamp01 = (x) => Math.max(0, Math.min(1, x));

/**
 * The track's temperament, moved to where this section sits in the story.
 *
 * Temperament is the hand on every parameter dial and it was constant for the
 * whole video, which is why two occurrences of a kind sampled around the same
 * point however far apart they were. Scaling `extremity` by tension is the
 * ratchet: the same scene, sampled nearer the ends of its own ranges the later
 * it appears. Bounded by the range extremity is drawn from — this moves where a
 * track sits inside its own character, it does not give it a different one.
 */
function temperamentFor(temperament, state) {
    if (!temperament || !state) return temperament;
    const tension = state.tension;
    return {
        ...temperament,
        intensity: Math.max(-1, Math.min(1, temperament.intensity + (tension - 0.5) * 0.5)),
        extremity: clamp01(temperament.extremity * (0.82 + tension * 0.4)),
    };
}

/**
 * Scenes eligible for a section kind, weighted by how well the family fits.
 *
 * `signature` is the track's personality signature, and it is a hard filter
 * rather than a weight: a scene with no way to express what the track is built
 * on is not a worse choice, it is the shot that was clearly filmed somewhere
 * else. See look/Personality.js.
 */
/**
 * The scenes THIS TRACK is allowed to cast from — a seeded subset of the
 * library, not the whole thing.
 *
 * There is a real tension here, and the first attempt at fixing the signature
 * gate walked straight into it. The hard trait filter was doing two jobs at
 * once: it was collapsing the library onto eleven over-declared scenes, which
 * was the bug, and it was also giving each track a DIFFERENT pool to cast from,
 * which was load-bearing. Replacing it with a soft weight fixed the collapse and
 * removed the differentiation — every track then drew from the same weighted
 * library, and measured song separation went from 0.03 to -0.15. Two songs came
 * out more alike than before.
 *
 * So the differentiation is kept and its bias removed. Every track gets its own
 * pool of about a third of the library, sampled without replacement, weighted by
 * the signature so the track still has a point of view. What changes is that a
 * scene declaring two traits is now merely less likely to be drawn than one
 * declaring four, instead of being ineligible for six tracks in seven.
 */
/**
 * How many of the library's scenes one track is allowed to draw on.
 *
 * Swept directly — checks.html?sweep=1 — across 4, 8, 16 and 32, over twelve
 * songs with three pool draws each. The answer is that it does not matter:
 *
 *     pool 4    spread +0.0061 ±0.0035
 *     pool 8    spread +0.0090 ±0.0040
 *     pool 16   spread +0.0102 ±0.0046
 *     pool 32   spread +0.0035 ±0.0032
 *
 * The differences are the same size as the run-to-run noise. This corrects a
 * claim made when the Epic 3 arms first came in: those arms appeared to show
 * that a small roster was the largest available win, but they varied two things
 * at once — the pool was smaller AND it was the same pool for every song — and
 * the sweep isolating size finds nothing.
 *
 * So 8 is chosen on grounds the metric cannot see. It puts about nine distinct
 * scenes in a video rather than seventeen, and a video a viewer can hold in
 * their head is worth having even when the instrument is indifferent.
 */
export const POOL_SIZE = 8;

function castingPool(rng, signature, size = POOL_SIZE) {
    const pool = scenes.filter(canBackground);
    const remaining = pool.slice();
    const weights = remaining.map((m) => signatureWeight(m, signature));
    const picked = [];
    const target = Math.min(size, remaining.length);
    while (picked.length < target && remaining.length) {
        const chosen = rng.pickWeighted(remaining, weights);
        const at = remaining.indexOf(chosen);
        remaining.splice(at, 1);
        weights.splice(at, 1);
        picked.push(chosen);
    }
    return picked;
}

function candidatesForKind(kind, used, signature = [], director, pool = null) {
    const families = director.families[kind] || Object.keys(FAMILIES);
    const allowed = pool ? new Set(pool.map((m) => m.name)) : null;
    const candidates = [];
    for (const family of families) {
        const inFamily = scenesInFamily(family)
            .filter((m) => canBackground(m) && (!allowed || allowed.has(m.name)));
        // Weight by family preference order, and push down anything already
        // used so a five-section track doesn't show one scene five times.
        const weight = families.length - families.indexOf(family);
        for (const scene of inFamily) {
            candidates.push({
                scene,
                // The signature is a lean now, not a wall. See
                // Personality.signatureWeight for why it had to stop being one.
                weight: weight * signatureWeight(scene, signature)
                    * (used.has(scene.name) ? 0.15 : 1),
            });
        }
    }
    if (!candidates.length) {
        // This track's pool holds nothing in the families the director wants for
        // this kind. Widen to the pool, then to the library — the track keeps
        // its scenes either way.
        const fallback = (pool && pool.length) ? pool : scenes.filter(canBackground);
        return fallback.map((scene) => ({ scene, weight: signatureWeight(scene, signature) }));
    }
    return candidates;
}

/**
 * How many stage visuals a kind rotates between. Busy material takes more.
 *
 * Sized against the library rather than picked out of the air: a kind draws
 * from three families, which is seven to nine castable scenes, so a roster of
 * four still leaves the weighting room to avoid what other kinds already took.
 * Variants a section never reaches cost nothing — layers are built per cue, so
 * only the ones its shots actually show are ever compiled.
 */
function rosterSizeFor(kind) {
    return (KIND_ENERGY[kind] ?? 0.5) > 0.5 ? 4 : 3;
}

/**
 * The most painted frame a stack is allowed to add up to.
 *
 * 1.0 is one filled picture. 2.0 is two of them stacked, which is where the
 * compositor's blends stop producing depth and start producing mud — past it
 * the layers are no longer readable as separate things, so nothing is gained
 * by the third pass except cost.
 */
const MAX_COVERAGE = 2.0;


/**
 * How full THIS section's frame is allowed to get, in painted coverage.
 *
 * Three inputs, in the order they matter:
 *
 *   the director  — how much this point of view lets happen at once. A
 *                   brutalist video is one large thing everywhere in it; a
 *                   corrupt one is everything over everything. See crowdOf.
 *   the song       — a loud, dense section carries more than a quiet one.
 *   the stage      — where the story is. `population` is literally how crowded
 *                    this point in the video wants to be, and layering is the
 *                    one lever on it that needs no cooperation from the scenes.
 *
 * The floor is GROUND_MIN because the ground is not optional: a section always
 * pays for its bed first, and the budget governs what may be stacked on it.
 */
function coverageBudgetFor(bias, story, director) {
    const stage = story ? story.population * 0.6 + story.tension * 0.4 : 0.5;
    const want = 0.6 + bias.energy * 0.5 + bias.density * 0.2 + (stage - 0.5) * 0.5;
    return Math.max(GROUND_MIN, Math.min(MAX_COVERAGE, want * crowdOf(director)));
}

/** The budget a KIND is planned against, before a section's own bias exists. */
function kindBudget(kind, director) {
    return coverageBudgetFor(
        { energy: KIND_ENERGY[kind] ?? 0.5, density: 0.5 }, null, director);
}

/**
 * The GROUND a kind's sections stand on: a canvas that paints at least half the
 * frame, cast once per kind so a section's cuts change the shot without moving
 * the video to another world.
 *
 * Most of the library cannot do this job and is not supposed to — two thirds of
 * it is composable, which means it reads as elements ON something and has
 * nothing of its own behind them. Those scenes were being cast as backgrounds
 * anyway, which is why a section could be a few bright things on black for
 * ninety seconds. The ground is what they are on.
 *
 * Chosen against the kind's budget rather than at random: a scene that paints
 * 98% of the frame is a legitimate ground for a drop and the wrong bed for an
 * intro, because everything the intro puts on it has to remain visible.
 */
function castGround(kind, roster, rng, signature, director, used) {
    const pool = scenes.filter(canGround);
    if (!pool.length) return null;

    const families = director.families[kind] || Object.keys(FAMILIES);
    const quiet = QUIET_KINDS.includes(kind);
    // What the shots standing on it will paint, so the ground leaves room for
    // the section it is under.
    const reserve = roster.length
        ? roster.reduce((sum, m) => sum + sceneCoverage(m), 0) / roster.length
        : 0.2;
    const headroom = kindBudget(kind, director) - reserve;

    const weights = pool.map((m) => {
        const at = families.indexOf(m.family);
        // Off-family grounds stay reachable — the ground is a bed, not the
        // director's statement — but the director still leads.
        let w = at >= 0 ? families.length - at : 0.35;
        // The quiet-kind rule applies to the floor as well. An intro standing on
        // a strobing glitch canvas is the mistake that rule exists to prevent,
        // and it is worse underneath than on top because nothing hides it.
        if (quiet && !RESTFUL_FAMILIES.includes(m.family)) w *= 0.15;
        w *= signatureWeight(m, signature);
        // Overshooting the budget is allowed and discouraged: the ground is
        // mandatory, so an oversized one is spent frame the shot cannot use.
        w /= 1 + 4 * Math.max(0, sceneCoverage(m) - headroom);
        // The bed has to be unlike the things standing on it, or the section is
        // one texture at double density. Measured against the whole roster,
        // because every member of it will be shot against this ground.
        w *= contrastWeight(m, roster.map((r) => ({ module: r })));
        // A video returns to its world rather than visiting six of them.
        if (used.has(m.name)) w *= 3;
        // Nothing stands on itself. If the kind's own anchor is groundable it
        // will be its own ground in buildStack, and this pick is for the rest.
        if (roster.some((r) => r.name === m.name)) w *= 0.1;
        return Math.max(1e-4, w);
    });

    const ground = rng.pickWeighted(pool, weights);
    used.add(ground.name);
    return ground;
}

/** One ground per section kind. See castGround. */
function assignGroundsByKind(rosterByKind, rng, signature, director) {
    const grounds = new Map();
    const used = new Set();
    // Loud kinds first, for the same reason rosters are assigned that way: they
    // are what the video is remembered for, so they choose their world first.
    const priority = ['drop', 'sustain', 'build', 'breakdown', 'intro', 'outro'];
    const kinds = [...rosterByKind.keys()]
        .sort((a, b) => priority.indexOf(a) - priority.indexOf(b));
    for (const kind of kinds) {
        grounds.set(kind, castGround(
            kind, rosterByKind.get(kind) || [], rng.fork(`ground:${kind}`),
            signature, director, used));
    }
    return grounds;
}

/**
 * Scenes are chosen per section KIND, not per section — and a kind gets a
 * ROSTER of two or three, not one.
 *
 * All of a track's drops therefore cut between the same small set of visuals,
 * all its breakdowns between another, and the video acquires an identity
 * instead of reading as a shuffle. The first entry is the anchor: it opens
 * every section of that kind and comes back most often, so the rotation reads
 * as one idea with variations rather than as three unrelated scenes.
 *
 * Variation between two sections of the same kind comes from their parameter
 * sets, from where their shots fall, and from the arc driver's drift.
 */
function assignRostersByKind(sections, rng, signature = [], director, pool = null) {
    const byKind = new Map();
    const used = new Set();

    const kinds = [...new Set(sections.map((s) => s.kind))];
    // Order matters for variety: assign the high-impact kinds first so they get
    // first pick of the library rather than whatever is left.
    const priority = ['drop', 'sustain', 'build', 'breakdown', 'intro', 'outro'];
    kinds.sort((a, b) => priority.indexOf(a) - priority.indexOf(b));

    for (const kind of kinds) {
        const roster = [];
        const size = rosterSizeFor(kind);

        for (let slot = 0; slot < size; slot++) {
            const options = candidatesForKind(kind, used, signature, director, pool)
                .filter((c) => !roster.includes(c.scene))
                .map((c) => ({
                    scene: c.scene,
                    // Companions stay in the anchor's family where possible: a
                    // cut inside a section should change the image, not the
                    // whole visual language.
                    weight: c.weight * (roster.length && c.scene.family === roster[0].family ? 3 : 1),
                }));
            if (!options.length) break;

            const chosen = rng.pickWeighted(
                options.map((c) => c.scene), options.map((c) => c.weight));
            roster.push(chosen);
            used.add(chosen.name);
        }

        byKind.set(kind, roster.length ? roster : [scenes[0]]);
    }
    return byKind;
}

/**
 * The RECAPITULATION: the outro re-casts what the intro opened on.
 *
 * The oldest device in the form and the cheapest one available here — the scene
 * is already in the roster and already compiled, and all that changes is which
 * member of it anchors. What makes it read as a return rather than as a repeat
 * is that the outro plays it with the parameters the story has arrived at:
 * the same scene, four minutes further along its slow axis, at the story's
 * closing tension. See look/Story.js.
 */
function applyRecap(rosterByKind) {
    const intro = rosterByKind.get('intro');
    const outro = rosterByKind.get('outro');
    if (!intro || !outro || !intro.length || !outro.length) return;
    const opener = intro[0];
    // Keep the outro's own roster behind the recapped anchor, minus a duplicate:
    // the section still cuts away from it, it just opens and closes there.
    const rest = outro.filter((m) => m.name !== opener.name);
    rosterByKind.set('outro', [opener, ...rest]);
}

/**
 * Which member of the kind's roster anchors THIS section.
 *
 * `roster[0]` opened every section of its kind, so a track's biggest visual was
 * spent in the first fifteen seconds of the first drop and then spent again,
 * identically, at every drop after it. Reserving it makes the anchor something
 * the video arrives at: earlier occurrences open on a companion, and the
 * anchor's own section is the one the story calls the climax.
 *
 * The roster itself does not change — the section still cuts between all of it,
 * which is what keeps the kind's identity — only which member it opens on.
 */
function anchorOrder(roster, state) {
    if (roster.length < 2 || !state) return roster;
    // The climax, the resolution and any single occurrence get the real anchor.
    const earned = state.act === 'climax' || state.act === 'resolution'
        || state.ordinalOf < 2 || state.reveal > 0.66;
    if (earned) return roster;

    const companion = 1 + (state.ordinal % (roster.length - 1));
    return [roster[companion], ...roster.filter((_, i) => i !== companion)];
}

/**
 * Post-processing and feedback derived from track character.
 * Ambient material gets more feedback and bloom; dense club material gets
 * tighter, punchier settings.
 *
 * Grain is NOT decided here — see look/grain.js. `post.grain` carries only the
 * amount for the current frame, which the Show multiplies by the grain
 * envelope, so a track can be clean, permanently dirty, or anything between.
 */
function derivePost(summary, rng, grain) {
    const bright = summary.meanCentroid;
    const noisy = Math.min(1, summary.meanFlatness * 3);
    const dynamic = Math.min(1, summary.dynamicRange);

    return {
        post: {
            bloom: 0.25 + (1 - noisy) * 0.35 + rng.range(-0.05, 0.05),
            bloomThreshold: 0.45 + bright * 0.25,
            bloomKnee: 0.25,
            chroma: 0.05 + noisy * 0.25 + rng.range(0, 0.08),
            ...applyGrainToPost(grain, {}),
            vignette: 0.25 + (1 - bright) * 0.25,
            contrast: 1.0 + dynamic * 0.15,
            saturation: 1.0 + (1 - noisy) * 0.25,
            lift: 0.0,
            exposure: 1.0,
        },
        feedback: {
            // Dynamic, spacious material tolerates long trails; dense material
            // turns to smear, so it gets much less.
            amount: Math.min(0.75, 0.15 + dynamic * 0.5),
            decay: 0.86 + dynamic * 0.08,
            zoom: 1.0 + rng.range(-0.006, 0.006),
            rotate: rng.range(-0.004, 0.004),
        },
    };
}

/**
 * How much a candidate would add to a stack, structurally.
 *
 * The question a stack has to answer is not "are these two scenes different
 * things" but "will a viewer see two things". Those come apart: the measured
 * distance between two scenes' structural profiles is what a viewer reads, and
 * it does not follow the family labels. So a candidate is weighted by how far
 * it sits from everything already in the stack, taking the CLOSEST such
 * distance — one near-twin in the stack is enough to make the addition read as
 * more of the same, however unlike the other layers it is.
 *
 * A lean, not a filter, and for the same reason the signature weighting is:
 * measured distances are a description of the library as it is today, and a
 * generator that obeyed them exactly would cast the same handful of contrasts
 * in every video. Unmeasured scenes score neutral rather than zero — never
 * having been rendered is not evidence of sameness.
 */
const CONTRAST_NEUTRAL = 0.12;

function contrastWeight(candidate, stack) {
    let closest = Infinity;
    for (const layer of stack) {
        const d = structuralDistance(candidate, layer.module);
        if (d !== null) closest = Math.min(closest, d);
    }
    if (closest === Infinity) closest = CONTRAST_NEUTRAL;
    // 0.02 apart (twins) → 0.25; 0.12 (typical) → 1.0; 0.30 (unalike) → 2.1.
    return Math.max(0.15, Math.min(2.5, 0.15 + (closest / CONTRAST_NEUTRAL) * 0.85));
}

/**
 * One layer stack: the ground, the shot standing on it, and sometimes a pass
 * or two composited over both.
 *
 *   ground     — a canvas painting at least half the frame. Always present,
 *                always opaque, and usually NOT the scene the section is about:
 *                two thirds of the library is composable, and a composable
 *                scene on its own is a few bright things on black. It is the
 *                one layer the section does not choose freely — see castGround.
 *                When the shot is itself a full canvas it IS the ground, because
 *                two canvases stacked is two pictures fighting.
 *   shot       — what the section is about. Screened over the ground rather
 *                than replacing it, so what it does not paint is the ground
 *                rather than black. `subjectOf` finds it; see look/stack.js.
 *   overlay    — a composable scene at partial opacity. Not always: this is the
 *                variation valve, and a stack that always doubled up would read
 *                as permanently cluttered rather than as occasionally layered.
 *                Drawn from a different family so the two images argue instead
 *                of blurring, and kept off scenes that are already busy.
 *
 * There used to be a third slot, `accent`, reserved for scenes declaring
 * `role: 'accent'`. Exactly one scene ever declared it, and the overlay path
 * above required a `composable` label no scene carried — so the reserved slot
 * was the only layering that ever happened, and every layered stack in every
 * song was the same particle field. One path, one roster: what goes on top is
 * whatever is composable, which is now a third of the library.
 *
 * Quiet material mostly goes without any — an intro is supposed to be sparse.
 */
function buildStack(module, overlayRoster, bias, rng, temperament, story = null,
                    { ground = null, director = null, kind = null } = {}) {
    const sectionKind = kind;
    // A shot that fills the frame by itself is its own ground; anything else
    // gets one under it.
    const standsAlone = canGround(module) || !ground;

    // --- the blaze ------------------------------------------------------
    // Whether THIS section is one the director lets bloom out: the shot added
    // to its ground rather than keyed onto it, so the two brightnesses sum and
    // the highlights go to paper.
    //
    // A decision, and a rationed one. Screening every shot over its ground is
    // how a median quarter of every frame in every video ended up clipped —
    // the effect was not wrong, being the default was. It has to be earned:
    // the director's appetite, times a loud section, times a late point in the
    // story. Quiet kinds never blaze; a breakdown that goes white is not a
    // decision, it is a bug with a rationale.
    const blaze = !standsAlone
        && !QUIET_KINDS.includes(sectionKind)
        && rng.bool(blazeOf(director) * clamp01(bias.energy * 1.2)
            * (story ? 0.4 + story.tension * 0.9 : 0.7));

    // The shot is sampled first and from the caller's rng, so a stack draws the
    // same shot it always did and the ground arrives underneath it rather than
    // in front of it in the seed stream.
    const shot = {
        module,
        params: sampleValues(module, rng, bias, temperament),
        seed: rng.int(0, 0x7fffffff),
        // Keyed over the ground on its own brightness by default: what the shot
        // leaves unpainted is then the ground rather than black, which is the
        // whole point of standing it on one, and what it DOES paint stays its
        // own colour. 'screen' is the blaze — see above, and passes.js.
        blend: standsAlone ? 'normal' : (blaze ? 'screen' : 'lumakey'),
        // Carried so the HUD, the checks and a later pass over the look can all
        // tell a deliberate bloom-out from a broken one.
        blaze,
        opacity: 1,
    };

    // --- ground ---------------------------------------------------------
    // Sampled calmer and sparser than it would be as a shot, because a bed the
    // shot cannot be read against is not a bed.
    const layers = [];
    if (!standsAlone) {
        const groundRng = rng.fork(`ground:${ground.name}`);
        layers.push({
            module: ground,
            role: GROUND,
            // Calmed, but NOT thinned — see GROUND_BIAS in scenes/surface.js,
            // which is also the bias the ground was MEASURED at. The first
            // version subtracted 0.3 from density here, which is exactly
            // backwards for a bed: an intro is already biased sparse, so the
            // ground came out at density zero and the section was thin again
            // for a new reason. Three of forty rendered sections fell under 30%
            // painted with a ground under every one of them.
            params: sampleValues(ground, groundRng, groundBiasFrom(bias),
                groundTemperamentFrom(temperament)),
            seed: groundRng.int(0, 0x7fffffff),
            blend: 'normal',
            opacity: 1,
        });
    }
    layers.push(shot);

    // --- the budget -----------------------------------------------------
    // What is on the frame so far, and how much more this section is allowed
    // to put on it. See coverageBudgetFor: the ground and the shot are not
    // negotiable, so the budget governs the passes over them — a quiet intro
    // spends everything on its bed and stacks nothing, a crowded drop under a
    // director with an appetite for it gets two passes.
    const budget = coverageBudgetFor(bias, story, director);
    let spent = layers.reduce((sum, l) => sum + sceneCoverage(l.module), 0);

    // --- overlay --------------------------------------------------------
    // Roughly a third of stacks on busy material, rarely on quiet material, and
    // never on a background that is itself a full-frame glitch — two competing
    // corruption passes is noise, not depth.
    // Layering is much more likely now that what goes on top is guaranteed to
    // leave the shot underneath visible.
    // `population` is how crowded the story wants this point in the video to
    // be, and layering is the only lever on that which does not need the scene's
    // cooperation: a lone form in an empty frame and the same form under two
    // more passes are the sparse and crowded ends of one video.
    const crowd = story ? (story.population - 0.5) * 0.5 : 0;
    // The base rate was tuned when this branch was dead and layering only ever
    // came from the reserved accent slot. With a third of the library eligible
    // it lands on half of all stacks, which is the "permanently cluttered" the
    // comment above warns about — and it costs seed separation, because a video
    // where everything is doubled up looks like every other video where
    // everything is doubled up.
    const overlayChance = module.family === 'glitch'
        ? 0.1
        : 0.2 + bias.energy * 0.35 + crowd
            + (temperament ? Math.max(0, temperament.detail) * 0.2 : 0);

    // Only COMPOSABLE scenes go on top. A second canvas over the first is two
    // pictures fighting rather than one picture with depth, and it is what the
    // library did for as long as every scene was treated as interchangeable.
    //
    // The other half of the trade: a composable scene alone is a few bright
    // things on black, which scores well for variety and is thin to watch.
    // Layering is what turns both halves into one image.
    let available = overlayRoster.filter((m) => m.name !== module.name
        && surfaceOf(m) === 'composable');

    // Two passes at the same slot rather than two differently-named slots. The
    // second is rarer and only on loud, crowded material — that is where the
    // old accent pass used to land, and it is the difference between a shot with
    // something over it and a shot with a texture and a shimmer over it.
    const chances = [
        Math.min(0.65, overlayChance),
        Math.min(0.25, overlayChance * bias.energy * 0.5),
    ];
    for (const [pass, chance] of chances.entries()) {
        // The budget is a wall, and it is also a lean: as the frame fills up
        // the odds of adding to it fall away before the wall is reached, so a
        // stack that is already nearly full rarely gets a token last pass.
        const headroom = budget - spent;
        const fits = available.filter((m) => sceneCoverage(m) <= headroom);
        if (!fits.length || !rng.bool(chance * clamp01(headroom / 0.35))) break;
        available = fits;
        // Prefer a different family so the images argue instead of blurring —
        // and then, within that, prefer the ones that MEASURE different.
        //
        // Family is a label somebody typed; structural distance is what the
        // gallery saw when it rendered the two scenes under the same six songs.
        // They disagree often enough to matter: two 'geometric' scenes can be
        // 0.31 apart and a 'flow' and an 'organic' scene 0.04, and stacking the
        // second pair is one picture at double density rather than a picture
        // with something happening in it. See scenes/surface.js.
        const offFamily = available.filter((m) => m.family !== module.family);
        const pool = offFamily.length ? offFamily : available;
        const overlay = rng.pickWeighted(pool, pool.map((m) => contrastWeight(m, layers)));
        spent += sceneCoverage(overlay);
        available = available.filter((m) => m.name !== overlay.name
            && m.family !== overlay.family);
        // Screen and add keep the background readable underneath; softlight and
        // overlay tint it instead. All four preserve the shot; 'normal' would
        // simply replace it, which is what the shot cut is for.
        const blend = rng.pickWeighted(['screen', 'add', 'softlight', 'overlay'], [3, 2, 2, 1]);
        // A second pass sits lighter than the first, so what accumulates is
        // depth rather than a third opaque picture.
        const fade = pass === 0 ? 1 : 0.6;
        layers.push({
            module: overlay,
            params: sampleValues(overlay, rng.fork(`overlay:${pass}:${overlay.name}`), {
                // An overlay reads as texture over the shot, so it is sampled
                // sparser and calmer than it would be as a background.
                ...bias,
                density: Math.max(0, bias.density - 0.25),
                energy: Math.max(0, bias.energy - 0.2),
            }, temperament),
            seed: rng.int(0, 0x7fffffff),
            blend,
            opacity: (blend === 'add' ? rng.range(0.18, 0.42) : rng.range(0.25, 0.55)) * fade,
        });
    }
    return layers;
}

/**
 * @param {FeatureTrack} track
 * @param {object} options
 * @returns {object} LookSpec
 */
export function generateLook(track, {
    seed = null, samples = null, overrides = null,
    pool: poolOverride = null, poolSize = POOL_SIZE,
} = {}) {
    const resolvedSeed = seed !== null
        ? seed >>> 0
        : samples ? hashSamples(samples) : 0x9e3779b9;

    const rng = new Rng(resolvedSeed);
    const summary = track.summary;

    const paletteSource = new AudioPalette(summary, rng.fork('palette'));
    const palette = generateUsablePalette(paletteSource, 6);

    // The production design, decided before a single scene is cast — casting
    // depends on it. See look/Personality.js.
    const personality = generatePersonality(summary, rng.fork('personality'), (signature) =>
        scenes.filter((m) => canBackground(m) && sceneHonours(m, signature)).length,
        track.sections.length);

    // The track's point of view about what a song looks like. Cast before any
    // scene is, because it decides which scenes are even candidates.
    const director = pickDirector(summary, rng.fork('director'));

    // This track's cast, drawn before any section is assigned. See castingPool.
    //
    // `poolOverride` exists for the variety harness, which needs reference
    // videos that share no scenes with each other but are otherwise built by
    // exactly this code — a reference assembled by any other path stops being
    // comparable to the thing it is bounding.
    const pool = poolOverride && poolOverride.length
        ? poolOverride
        : castingPool(rng.fork('pool'), personality.signature, poolSize);
    // What HAPPENS over the track, as opposed to what it is made of. Derived
    // before casting because it decides which member of a roster anchors which
    // section, and whether the outro answers the intro. See look/Story.js.
    const story = deriveStory(track, summary, rng.fork('story'));

    const rosterByKind = assignRostersByKind(
        track.sections, rng.fork('scenes'), personality.signature, director, pool);
    if (story.recap) applyRecap(rosterByKind);
    // What each kind's sections stand on. After the recap, so the outro is
    // grounded against the roster it actually ends up with.
    const groundByKind = assignGroundsByKind(
        rosterByKind, rng.fork('grounds'), personality.signature, director);
    // The grain treatment: usually none, and when present described rather than
    // dialled. See look/grain.js.
    const grain = deriveGrain(summary, rng.fork('grain'));
    // How the palette MOVES over the track. See look/paletteArc.js.
    const paletteArc = derivePaletteArc(summary, rng.fork('paletteArc'));
    // Whether shots change SIZE at the cut, and how boldly. See look/framing.js.
    const framing = deriveFramingStyle(summary, rng.fork('framing'));
    // WHERE the camera looks, and how it travels there. The director leans
    // toward one camera the way it leans toward one family per kind, and the
    // seed decides — see look/Camera.js.
    const camera = deriveCamera(director, summary, rng.fork('camera'));
    const { post, feedback } = derivePost(summary, rng.fork('post'), grain);

    // Scenes eligible to be composited OVER a background. Same casting rule as
    // everything else — an overlay is on screen as much as the shot under it,
    // so an off-design one would be just as visible.
    //
    // Widened past the casting pool with the scenes that exist only to sit on
    // top: those are never drawn as a section's primary scene, so the pool —
    // which is built out of background candidates — would never contain them.
    const overlayOnly = scenes.filter((m) => !canBackground(m));
    const overlayRoster = (pool.length >= 4 ? pool : scenes.filter(canBackground))
        .concat(overlayOnly);

    const sections = track.sections.map((section) => {
        const state = storyForSection(story, section.index);
        const kindRoster = rosterByKind.get(section.kind) || [scenes[0]];
        // The kind's roster, opened on the member this point in the story has
        // earned. Same set, different anchor. See anchorOrder.
        const roster = anchorOrder(kindRoster, state);
        const sectionRng = rng.fork(`section:${section.index}:${roster[0].name}`);
        const bias = biasFor(section, summary, personality.motion, state);
        // The RATCHET: how hard the track pushes its dials is a property of the
        // track (Personality.temperament) scaled by where in the story it is,
        // so the last occurrence of a kind samples further out than the first.
        const temperament = temperamentFor(personality.temperament, state);

        const ground = groundByKind.get(section.kind) || null;

        const variants = roster.map((module, v) => buildStack(
            module, overlayRoster, bias,
            sectionRng.fork(`variant:${section.index}:${v}`), temperament, state,
            { ground, director, kind: section.kind },
        ));

        const shots = planShots(
            section, track, bias, variants.length,
            sectionRng.fork(`shots:${section.index}`), state,
        );

        return {
            index: section.index,
            kind: section.kind,
            story: state,
            startFrame: section.startFrame,
            endFrame: section.endFrame,
            start: section.start,
            end: section.end,
            locked: false,
            bias,
            variants,
            shots,
            // The anchor stack, aliased. Everything that predates shots — the
            // param panel, presets, the checks — edits a section through this,
            // and it is the same object the first variant holds.
            layers: variants[0],
        };
    });

    const look = {
        seed: resolvedSeed,
        palette,
        personality,
        paletteScheme: paletteSource.lastScheme,
        director: director.name,
        story,
        paletteArc,
        framing,
        camera,
        grain,
        post,
        feedback,
        sections,
        summary,
    };

    return overrides ? applyOverrides(look, overrides) : look;
}

/** Re-roll one section, leaving everything else — and locked sections — alone. */
export function rerollSection(look, track, sectionIndex, salt = 0) {
    const section = look.sections[sectionIndex];
    if (!section || section.locked) return look;

    const rng = new Rng((look.seed ^ (sectionIndex * 0x9e3779b9) ^ (salt * 0x85ebca6b)) >>> 0);
    const signature = (look.personality && look.personality.signature) || [];
    const director = directorByName(look.director);
    const families = director.families[section.kind] || Object.keys(FAMILIES);
    // A reroll re-draws this section's cast from the same kind of pool the track
    // was built with, weighted by the signature rather than filtered by it.
    let candidates = families.flatMap((f) => scenesInFamily(f))
        .filter(canBackground);
    if (!candidates.length) candidates = scenes.filter(canBackground);

    // Re-roll the whole roster, not just the anchor: the section's shots cut
    // between all of them, so replacing one would leave the section half old.
    const size = Math.min(rosterSizeFor(section.kind), Math.max(1, candidates.length));
    const roster = [];
    while (roster.length < size) {
        const options = candidates.filter((m) => !roster.includes(m));
        if (!options.length) break;
        roster.push(rng.pickWeighted(options, options.map((m) => signatureWeight(m, signature))));
    }
    if (!roster.length) roster.push(scenes[0]);

    const overlayRoster = castingPool(rng.fork('pool'), signature)
        .concat(scenes.filter((m) => !canBackground(m)));
    // A reroll changes what this section is made of. Where it sits in the story
    // is a property of the song, so it survives untouched.
    const state = section.story || NEUTRAL_STATE;
    // The section is re-cast, so its ground is re-cast with it — a reroll that
    // kept the old bed under new shots would be answering half the question.
    const ground = castGround(
        section.kind, roster, rng.fork('ground'), signature, director, new Set());
    section.variants = roster.map((module, v) => buildStack(
        module, overlayRoster, section.bias, rng.fork(`variant:${v}`),
        temperamentFor(look.personality && look.personality.temperament, state), state,
        { ground, director, kind: section.kind },
    ));
    section.shots = planShots(
        section, track, section.bias, section.variants.length, rng.fork('shots'), state,
    );
    section.layers = section.variants[0];
    return look;
}

/** Reroll the whole track with a new seed, preserving locked sections. */
export function rerollLook(look, track, newSeed) {
    const locked = new Map();
    look.sections.forEach((s) => { if (s.locked) locked.set(s.index, s); });

    const next = generateLook(track, { seed: newSeed >>> 0 });
    next.sections.forEach((s, i) => {
        if (locked.has(i)) next.sections[i] = locked.get(i);
    });
    return next;
}

function applyOverrides(look, overrides) {
    if (overrides.palette) look.palette = overrides.palette;
    if (overrides.post) look.post = { ...look.post, ...overrides.post };
    if (overrides.feedback) look.feedback = { ...look.feedback, ...overrides.feedback };
    if (overrides.sections) {
        overrides.sections.forEach((o, i) => {
            if (!look.sections[i]) return;
            if (o.locked !== undefined) look.sections[i].locked = o.locked;
            if (o.params) Object.assign(subjectOf(look.sections[i].layers).params, o.params);
        });
    }
    return look;
}

/** Compact description, used by the HUD and by check output. */
export function describeLook(look) {
    const kinds = look.sections.map((s) => `${s.kind}:${subjectOf(s.layers).module.name}`);
    return `seed ${look.seed.toString(16)} · ${look.director} · ${look.paletteScheme} · ` +
           `${describePersonality(look.personality)} · ${describeGrain(look.grain)} · ` +
           `${describePaletteArc(look.paletteArc)} · ${describeFraming(look.framing)} · ` +
           `${describeCamera(look.camera)} · ` +
           `${[...new Set(kinds)].join(', ')}`;
}

export { defaultValues };
