// 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 } 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';

// 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) {
    const kindEnergy = KIND_ENERGY[section.kind] ?? 0.5;
    const measured = Math.min(1, section.energy / Math.max(1e-6, summary.meanLoudness * 1.6));
    const energy = kindEnergy * 0.6 + measured * 0.4;

    // 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: Math.min(1, energy * 0.7 + section.flux * 1.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));

/**
 * 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((m) => m.role !== 'accent');
    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) => m.role !== 'accent' && (!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((m) => m.role !== 'accent');
        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 non-accent 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;
}

/**
 * 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, sometimes a second scene composited over
 * it, sometimes an accent on top of that.
 *
 * Three deliberately different jobs:
 *
 *   background — the shot. Always present, always opaque.
 *   overlay    — a SECOND full 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.
 *   accent     — the depth pass. Mostly-empty by design (role: 'accent'),
 *                additive, low opacity.
 *
 * Quiet material mostly goes without either — an intro is supposed to be sparse.
 */
function buildStack(module, accentRoster, overlayRoster, bias, rng, temperament) {
    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.
    const overlayChance = module.family === 'glitch'
        ? 0.05
        : 0.12 + bias.energy * 0.35 + (temperament ? Math.max(0, temperament.detail) * 0.2 : 0);

    const overlays = overlayRoster.filter((m) => m.family !== module.family && m.name !== module.name);
    if (overlays.length && rng.bool(Math.min(0.6, overlayChance))) {
        const overlay = rng.pick(overlays);
        // 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]);
        layers.push({
            module: overlay,
            params: sampleValues(overlay, rng.fork(`overlay:${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),
        });
    }

    // --- accent ---------------------------------------------------------
    if (accentRoster.length && rng.bool(bias.energy * 0.8)) {
        const eligible = accentRoster.filter((m) => m.family !== module.family);
        const accent = rng.pick(eligible.length ? eligible : accentRoster);
        layers.push({
            module: accent,
            params: sampleValues(accent, rng.fork('accent'), bias, temperament),
            seed: rng.int(0, 0x7fffffff),
            blend: rng.pickWeighted(['add', 'screen'], [2, 1]),
            opacity: rng.range(0.18, 0.5),
        });
    }
    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) => m.role !== 'accent' && 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);
    const rosterByKind = assignRostersByKind(
        track.sections, rng.fork('scenes'), personality.signature, director, pool);
    // 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'));
    const { post, feedback } = derivePost(summary, rng.fork('post'), grain);

    // Scenes that declare role 'accent' composite over a background rather than
    // being one — most of their frame is empty by design. They are never chosen
    // as a section's primary scene.
    // Accents honour the signature too where they can. If none can, the track
    // goes without depth layers rather than putting an off-design element into
    // every stack.
    const accentRoster = scenes.filter((m) => m.role === 'accent');

    // 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.
    const overlayRoster = pool.length >= 4 ? pool : scenes.filter((m) => m.role !== 'accent');

    const sections = track.sections.map((section) => {
        const roster = rosterByKind.get(section.kind) || [scenes[0]];
        const sectionRng = rng.fork(`section:${section.index}:${roster[0].name}`);
        const bias = biasFor(section, summary, personality.motion);

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

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

        return {
            index: section.index,
            kind: section.kind,
            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,
        paletteArc,
        framing,
        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((m) => m.role !== 'accent');
    if (!candidates.length) candidates = scenes.filter((m) => m.role !== 'accent');

    // 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 accentRoster = scenes.filter((m) => m.role === 'accent');
    const overlayRoster = castingPool(rng.fork('pool'), signature);
    section.variants = roster.map((module, v) => buildStack(
        module, accentRoster, overlayRoster, section.bias, rng.fork(`variant:${v}`),
        look.personality && look.personality.temperament,
    ));
    section.shots = planShots(
        section, track, section.bias, section.variants.length, rng.fork('shots'),
    );
    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)} · ` +
           `${[...new Set(kinds)].join(', ')}`;
}

export { defaultValues };
