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

/**
 * Which families suit which section kind, in preference order.
 *
 * This is the coupling that stops a breakdown landing on a strobing glitch scene
 * and an intro opening at full density. It is also why families exist at all.
 */
const FAMILY_BY_KIND = {
    intro:     ['minimal', 'flow', 'organic'],
    build:     ['structural', 'geometric', 'flow'],
    drop:      ['geometric', 'glitch', 'structural'],
    sustain:   ['organic', 'flow', 'geometric'],
    breakdown: ['minimal', 'organic', 'flow'],
    outro:     ['minimal', 'flow', 'organic'],
};

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) {
    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;

    return {
        energy,
        density: Math.min(1, energy * 0.7 + section.flux * 1.2),
        motion: Math.min(1, 0.25 + energy * 0.5 + (summary.bpm - 90) / 180),
    };
}

/**
 * Scenes are chosen per section KIND, not per section.
 *
 * All of a track's drops therefore share a scene, all its breakdowns share
 * another, and the video acquires an identity instead of reading as a shuffle.
 * Variation between two sections of the same kind comes from their parameter
 * sets and from the arc driver's drift, which is enough to keep them distinct
 * without losing the through-line.
 */
function assignScenesByKind(sections, rng) {
    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 families = FAMILY_BY_KIND[kind] || Object.keys(FAMILIES);
        let candidates = [];
        for (const family of families) {
            const inFamily = scenesInFamily(family);
            // 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, weight: weight * (used.has(scene.name) ? 0.15 : 1) });
            }
        }
        if (!candidates.length) candidates = scenes.map((scene) => ({ scene, weight: 1 }));

        const chosen = rng.pickWeighted(
            candidates.map((c) => c.scene),
            candidates.map((c) => c.weight),
        );
        byKind.set(kind, chosen);
        used.add(chosen.name);
    }
    return byKind;
}

/**
 * Post-processing and feedback derived from track character.
 * Ambient material gets more feedback and bloom and less grain; dense club
 * material gets tighter, punchier settings.
 */
function derivePost(summary, rng) {
    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),
            grain: 0.02 + noisy * 0.05,
            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),
        },
    };
}

/**
 * @param {FeatureTrack} track
 * @param {object} options
 * @returns {object} LookSpec
 */
export function generateLook(track, { seed = null, samples = null, overrides = null } = {}) {
    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);

    const sceneByKind = assignScenesByKind(track.sections, rng.fork('scenes'));
    const { post, feedback } = derivePost(summary, rng.fork('post'));

    // Scenes eligible as accents: 3D layers composite over a shader background
    // without fighting it, so they are preferred where available.
    const accentRoster = scenes.filter((m) => m.kind === 'layer3d');

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

        const layers = [{
            module,
            params: sampleValues(module, sectionRng, bias),
            seed: sectionRng.int(0, 0x7fffffff),
            blend: 'normal',
            opacity: 1,
        }];

        // Accent layer. Composited additively over the background at low opacity,
        // and drawn from a DIFFERENT family so it reads as depth rather than as a
        // second competing scene. Quiet sections mostly go without — an intro is
        // supposed to be sparse.
        const accentChance = bias.energy * 0.8;
        if (accentRoster.length && sectionRng.bool(accentChance)) {
            const accent = sectionRng.pick(accentRoster.filter((m) => m.family !== module.family) || accentRoster)
                        || accentRoster[0];
            layers.push({
                module: accent,
                params: sampleValues(accent, sectionRng.fork(`accent:${section.index}`), bias),
                seed: sectionRng.int(0, 0x7fffffff),
                blend: sectionRng.pickWeighted(['add', 'screen'], [2, 1]),
                opacity: sectionRng.range(0.18, 0.5),
            });
        }

        return {
            index: section.index,
            kind: section.kind,
            startFrame: section.startFrame,
            endFrame: section.endFrame,
            start: section.start,
            end: section.end,
            locked: false,
            bias,
            layers,
        };
    });

    const look = {
        seed: resolvedSeed,
        palette,
        paletteScheme: paletteSource.lastScheme,
        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 families = FAMILY_BY_KIND[section.kind] || Object.keys(FAMILIES);
    const candidates = families.flatMap((f) => scenesInFamily(f));
    const module = candidates.length ? rng.pick(candidates) : scenes[0];

    section.layers = [{
        module,
        params: sampleValues(module, rng, section.bias),
        seed: rng.int(0, 0x7fffffff),
        blend: 'normal',
        opacity: 1,
    }];
    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.paletteScheme} · ${[...new Set(kinds)].join(', ')}`;
}

export { defaultValues };
