// 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, surfaceOf, canBackground } from '../params/schema.js';
import { planShots } from './shots.js';
import {
    generatePersonality, sceneHonours, signatureWeight, describePersonality,
} from './Personality.js';
import { deriveGrain, describeGrain, applyGrainToPost } from './grain.js';
import { pickDirector, directorByName } 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;
}

/**
 * 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),
        },
    };
}

/**
 * One layer stack: a background scene, and sometimes one or two composable
 * scenes composited over it.
 *
 *   background — the shot. Always present, always opaque.
 *   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) {
    const layers = [{
        module,
        params: sampleValues(module, rng, bias, temperament),
        seed: rng.int(0, 0x7fffffff),
        blend: 'normal',
        opacity: 1,
    }];

    // --- 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()) {
        if (!available.length || !rng.bool(chance)) break;
        // Prefer a different family so the images argue instead of blurring.
        const offFamily = available.filter((m) => m.family !== module.family);
        const overlay = rng.pick(offFamily.length ? offFamily : available);
        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);
    // 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 variants = roster.map((module, v) => buildStack(
            module, overlayRoster, bias,
            sectionRng.fork(`variant:${section.index}:${v}`), temperament, state,
        ));

        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 families = directorByName(look.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;
    section.variants = roster.map((module, v) => buildStack(
        module, overlayRoster, section.bias, rng.fork(`variant:${v}`),
        temperamentFor(look.personality && look.personality.temperament, state), state,
    ));
    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(look.sections[i].layers[0].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}:${s.layers[0].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 };
