// The track's production design.
//
// A music video is not held together by its cuts. It is held together by the
// fact that every shot was filmed in the same location, with the same actors,
// the same camera operator and the same art direction. Cut between two shots of
// that and it reads as one piece even when the framing changes completely.
//
// Nothing in this project had an equivalent. Sections shared a palette and a
// post grade, and past that every scene was a separate short film. This module
// is the missing layer: one procedurally generated PERSONALITY per track, in
// four traits that map onto the four things a production shares.
//
//   shape   — the actors.  A signature form: how many sides, how round, how
//             elongated, at what tilt. Scenes that draw discrete elements stamp
//             this form instead of whatever primitive they would have used.
//   camera  — the operator. A drift direction, a sway, a slow spin, a breathing
//             zoom. Applied to the coordinate a scene works in, so every scene
//             is filmed by the same hand.
//   space   — the location. A horizon height, a depth falloff, a background
//             wash direction. Scenes that have a sense of place share one.
//   style   — the art direction. Line weight, edge softness, texture, and how
//             many times the frame is folded.
//
// Plus a fifth thing that is not a trait and is not declared by anyone: the
// TEMPERAMENT. Traits decide what a track looks like; temperament decides how
// hard it commits. It is the track's hand on every scene's parameter dials, and
// it exists because section bias alone is nearly identical between two tracks'
// drops — so one scene cast in two videos sampled around the same centre both
// times and the videos looked like each other. Temperament is per track and
// pushes those samples apart. See params/schema.js sampleValues.
//
// A scene declares which traits it can honour. Each track picks a SIGNATURE of
// one or two traits, and a scene that does not honour all of them is
// disqualified from that track — the library shrinks per track, on purpose. A
// scene with no way to express a hexagon should not appear in the hexagon
// video; it would be the shot that was clearly filmed somewhere else.
//
// Everything here is seeded off the look seed, so a track's personality is as
// reproducible as everything else.

import {
    generateIdentity, identityUniforms, describeIdentity, NEUTRAL_IDENTITY_UNIFORMS,
} from './Identity.js';

export const TRAITS = ['shape', 'camera', 'space', 'style'];

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

/**
 * Traits eligible to be a track's signature, and how often.
 *
 * `shape` and `space` carry the most identity — they are the ones a viewer can
 * actually name on a second watch — so they are the ones a signature is built
 * around. `camera` and `style` are near-universally supported and read as
 * treatment rather than as subject, so they join a signature but rarely define
 * one alone.
 */
const SIGNATURE_WEIGHTS = { shape: 4, space: 3, camera: 2, style: 2 };

/**
 * How the audio tilts the choice of signature.
 *
 * The signature decides which scenes a track can even cast, so if it is picked
 * from the seed alone then the single most consequential decision in the whole
 * generator has no relationship to the music. Measured, that is exactly what
 * happened: across seven songs the correlation between how different two tracks
 * SOUND and how different their videos LOOK was -0.03. The videos differed; the
 * differences just had nothing to do with the songs.
 *
 * A tilt, not a rule. Every trait stays reachable for every track — a mapping
 * rigid enough to predict is the failure this layer exists to avoid — but a
 * track built on transients leans toward shape, a spacious one toward space, a
 * fast one toward camera and a noisy one toward style.
 */
function signatureTilt(summary, sections) {
    const bright = summary.meanCentroid ?? 0.5;
    const noisy = Math.min(1, (summary.meanFlatness ?? 0.2) * 3);
    const fast = clamp01(((summary.bpm ?? 120) - 80) / 80);
    const dynamic = clamp01(summary.dynamicRange ?? 0.5);
    // A track that keeps changing what it is doing has structure to draw on;
    // one that states an idea and holds it has a place instead.
    const busy = clamp01((sections - 2) / 5);

    return {
        shape:  0.5 + busy * 1.2 + (1 - noisy) * 0.5,
        space:  0.5 + dynamic * 1.1 + (1 - busy) * 0.6,
        camera: 0.5 + fast * 1.2,
        style:  0.5 + noisy * 1.3 + bright * 0.4,
    };
}

/** Minimum scenes that must survive the signature filter for it to be usable. */
export const MIN_ELIGIBLE_SCENES = 6;

/**
 * Generate the personality.
 *
 * Trait VALUES lean on what was measured in the audio — a bright, noisy track
 * gets sharper lines and more texture; a slow one gets a lazier camera — but
 * the seed dominates, so two tracks with similar statistics still look like
 * different productions.
 *
 * @param {object} summary  FeatureTrack summary
 * @param {Rng} rng
 * @param {(traits: string[]) => number} countEligible
 *        How many scenes would survive a given signature. Injected rather than
 *        imported so this module never has to know the registry exists.
 */
export function generatePersonality(summary, rng, countEligible = null, sections = 4) {
    const bright = summary.meanCentroid;
    const noisy = Math.min(1, summary.meanFlatness * 3);
    const fast = Math.min(1, Math.max(0, (summary.bpm - 80) / 80));
    const loud = Math.min(1, (summary.dynamicRange ?? 0.5) + (summary.meanLoudness ?? 0.3));
    const dynamic = clamp01(summary.dynamicRange ?? 0.5);

    // Audio sets the CENTRE of each distribution and the seed picks within it.
    //
    // The alternative — deriving values from the audio outright — buys coupling
    // by destroying seed variety, since two seeds on one song would then agree
    // about everything. Centring keeps both: two songs land in different regions
    // of the space, two seeds land in different places inside one region. The
    // window is deliberately wide enough that the mapping cannot be read off a
    // finished video.
    const around = (centre, window, lo, hi) =>
        Math.max(lo, Math.min(hi, centre + rng.range(-window, window)));

    const shape = {
        // 0 sides means round. Everything else is a polygon the whole track
        // shares — the single most recognisable thing here. Noise and transients
        // earn corners; tonal, smooth material stays round.
        sides: rng.pickWeighted(
            [0, 3, 4, 5, 6, 8],
            [3 + (1 - noisy) * 4, 1 + noisy * 2, 2 + noisy * 2,
             1 + noisy * 1.5, 2 + noisy * 2, 0.5 + noisy * 1.5]),
        roundness: around(0.28 - noisy * 0.15, 0.18, 0.05, 0.5),
        elongation: around(1.15, 0.3, 0.85, 1.45),
        tilt: rng.range(0, Math.PI),
    };

    const camera = {
        driftAngle: rng.range(0, Math.PI * 2),
        // A slow track should not be filmed from a moving car.
        driftRate: rng.range(0.01, 0.06) * (0.6 + fast * 0.8),
        sway: around(0.01 + fast * 0.035, 0.02, 0, 0.06),
        swayRate: around(0.08 + fast * 0.1, 0.06, 0.05, 0.22),
        spin: around((rng.bool() ? 1 : -1) * fast * 0.03, 0.025, -0.05, 0.05),
        // Breathing is locked to the bar, so it is the one camera move that
        // reads as musical rather than as drifting. A dynamic track breathes.
        breathe: around(dynamic * 0.03, 0.02, 0, 0.05),
    };

    const space = {
        // A bright track sits high in its frame and a dark one sits low; a
        // dynamic one has depth to fall away into.
        horizon: around(0.36 + bright * 0.2, 0.1, 0.32, 0.62),
        depth: around(0.25 + dynamic * 0.5, 0.25, 0.2, 0.9),
        washAngle: rng.range(0, Math.PI * 2),
        wash: around(0.18 + (1 - bright) * 0.2, 0.15, 0.1, 0.5),
    };

    const style = {
        lineWeight: 0.4 + bright * 0.4 + rng.range(-0.15, 0.25),
        softness: 0.25 + (1 - bright) * 0.4 + rng.range(-0.1, 0.2),
        // Surface grain is a texture trait, not a default, and most tracks have
        // none at all. Every scene adds sigGrain and the grade can add its own,
        // so anything short of a hard gate here reads as "grainy by default" —
        // which is exactly what it read as when this was a floor of ~0.12 and
        // then again when it was a small unconditional amount. A scene can also
        // opt out entirely with `texture: 0` in its module.
        texture: rng.bool(0.25 + noisy * 0.4) ? noisy * 0.35 + rng.range(0.02, 0.12) : 0,
        // Fold counts stay low and are usually off. Symmetry is the fastest way
        // to make a library look like one series and also the fastest way to
        // make every track look like a screensaver.
        symmetry: rng.pickWeighted([1, 1, 2, 3, 4, 6],
            [6, 4, 1 + (1 - noisy) * 2, 1 + (1 - noisy) * 2, 1 + (1 - noisy) * 2, 0.5 + (1 - noisy)]),
    };

    // How this track MOVES, as a character rather than a rate.
    //
    // Motion was the weakest block in every measurement — 47% of achievable
    // across seeds — because the only lever on it was a tempo-derived rate
    // multiplier, and tempo compresses. Two tracks at 124 and 138bpm got
    // essentially the same movement. Stillness is the missing axis: some music
    // wants an image that hangs almost motionless and some wants one that never
    // settles, and that is not the same question as how fast it animates.
    const motion = {
        // 0 = hangs, 1 = never settles. Dynamic, spacious material earns the
        // stillness; dense, fast material does not get it.
        stillness: clamp01(around(0.5 + dynamic * 0.35 - fast * 0.45, 0.3, 0, 1)),
        // Whether the movement is steady or agitated, independent of its speed.
        churn: clamp01(around(0.25 + noisy * 0.5, 0.3, 0, 1)),
    };

    // How hard this track pushes every scene it casts. Deliberately wide, and
    // deliberately not derived from the section: two tracks must be able to
    // disagree about what "a drop" means.
    const temperament = {
        // Up or down on the energy/density dials.
        intensity: rng.range(-0.85, 0.85) * (0.5 + loud * 0.9),
        // Up or down on anything that moves.
        pace: rng.range(-0.8, 0.8) * (0.55 + fast * 0.8),
        // Fine and busy, or few and large. Independent of loudness on purpose —
        // a quiet track can be intricate and a loud one can be blunt.
        detail: rng.range(-0.6, 0.6),
        // How far toward the ends of a range this track is willing to sample.
        // The single most effective knob against "every video looks average",
        // so the floor sits well above timid: a track at 0.25 sampled almost
        // uniformly and produced the library's average look, and enough tracks
        // did that to make the average look like the house style.
        extremity: rng.range(0.45, 1.0),
    };

    const signature = pickSignature(rng, countEligible, signatureTilt(summary, sections));

    // The song's cast and ink. Generated here so everything downstream — layer
    // uniforms, the HUD, presets — reaches it the same way it reaches the rest
    // of the production design. See look/Identity.js.
    const identity = generateIdentity(summary, rng.fork('identity'), sections);

    return { signature, shape, camera, space, style, motion, temperament, identity };
}

/**
 * Choose the one or two traits this track is BUILT on.
 *
 * Two is the target: one trait alone is not enough to recognise, and three
 * disqualifies most of the library. If the pair leaves too few scenes to build
 * rosters from, fall back to the stronger of the two rather than shipping a
 * track whose every section is forced onto the same two scenes.
 */
function pickSignature(rng, countEligible, tilt = null) {
    const weightOf = (t) => SIGNATURE_WEIGHTS[t] * (tilt ? tilt[t] : 1);
    const primary = rng.pickWeighted(TRAITS, TRAITS.map(weightOf));
    const rest = TRAITS.filter((t) => t !== primary);
    const secondary = rng.pickWeighted(rest, rest.map(weightOf));

    const pair = [primary, secondary];
    if (!countEligible || countEligible(pair) >= MIN_ELIGIBLE_SCENES) return pair;

    const single = [primary];
    if (countEligible(single) >= MIN_ELIGIBLE_SCENES) return single;
    return [];
}

/** Does a scene honour everything this track is built on? */
export function sceneHonours(module, signature) {
    const traits = module.traits || [];
    return signature.every((t) => traits.includes(t));
}

/**
 * How well a scene fits the signature, 0..1 — and the casting WEIGHT that
 * follows from it.
 *
 * `sceneHonours` was a hard filter, and as a filter it was the single largest
 * cause of sameness in the generator. Measured across the library: a scene
 * declaring all four traits is eligible for every track and opens half of all
 * videos, while a scene declaring two is eligible for one track in fourteen.
 * Eleven scenes out of sixty-one carried nearly every video, five of them from
 * the same family, and that was the house style — not a decision anyone made,
 * just an artefact of which scenes happened to declare four traits.
 *
 * A lean rather than a wall. Honouring the whole signature is worth six times
 * the weight of honouring none of it, which is more than enough for the track to
 * read as one production, while leaving the rest of the library reachable
 * instead of disqualified.
 */
export function signatureAffinity(module, signature) {
    if (!signature || !signature.length) return 1;
    const traits = module.traits || [];
    let hit = 0;
    for (const t of signature) if (traits.includes(t)) hit++;
    return hit / signature.length;
}

export function signatureWeight(module, signature) {
    return 1 + signatureAffinity(module, signature) * 5;
}

/**
 * Flatten to the uniform values the shader contract expects.
 *
 * Neutral defaults matter: a layer with no personality attached must render
 * exactly what it rendered before this existed, because the range sweeps and
 * the library regression checks build layers directly and would otherwise all
 * shift at once.
 */
export function signatureUniforms(personality, module = null) {
    if (!personality) return NEUTRAL_UNIFORMS;
    const { shape, camera, space, style } = personality;
    // A scene may scale — or refuse — the track's surface grain. A clean vector
    // look has no business being speckled just because the track is gritty.
    const textureAffinity = module && module.texture !== undefined ? module.texture : 1;
    return {
        u_sigSides: shape.sides,
        u_sigRound: shape.roundness,
        u_sigElong: shape.elongation,
        u_sigTilt: shape.tilt,

        u_sigDrift: [Math.cos(camera.driftAngle) * camera.driftRate,
                     Math.sin(camera.driftAngle) * camera.driftRate],
        u_sigSway: camera.sway,
        u_sigSwayRate: camera.swayRate,
        u_sigSpin: camera.spin,
        u_sigBreathe: camera.breathe,

        u_sigHorizon: space.horizon,
        u_sigDepth: space.depth,
        u_sigWash: [Math.cos(space.washAngle) * space.wash,
                    Math.sin(space.washAngle) * space.wash],

        u_sigLine: style.lineWeight,
        u_sigSoft: style.softness,
        u_sigTexture: style.texture * textureAffinity,
        u_sigFold: style.symmetry,

        // Framing is per shot, not per track: the arc driver overwrites these
        // every frame. Neutral here so a layer built without one is unframed.
        u_sigFrameScale: 1,
        u_sigFrameShift: [0, 0],

        ...identityUniforms(personality.identity, personality.shape),
    };
}

export const NEUTRAL_UNIFORMS = {
    ...NEUTRAL_IDENTITY_UNIFORMS,
    u_sigSides: 0,
    u_sigRound: 0.25,
    u_sigElong: 1,
    u_sigTilt: 0,
    u_sigDrift: [0, 0],
    u_sigSway: 0,
    u_sigSwayRate: 0.1,
    u_sigSpin: 0,
    u_sigBreathe: 0,
    u_sigHorizon: 0.5,
    u_sigDepth: 0,
    u_sigWash: [0, 0],
    u_sigLine: 0.5,
    u_sigSoft: 0.5,
    u_sigTexture: 0,
    u_sigFold: 1,
    u_sigFrameScale: 1,
    u_sigFrameShift: [0, 0],
};

const SHAPE_NAMES = { 0: 'round', 3: 'triangular', 4: 'square', 5: 'pentagonal', 6: 'hexagonal', 8: 'octagonal' };

/** One line for the HUD, the look panel and check output. */
export function describePersonality(personality) {
    if (!personality) return 'no personality';
    const { signature, shape, style } = personality;
    const parts = [
        `on ${signature.length ? signature.join('+') : 'nothing'}`,
        SHAPE_NAMES[shape.sides] || `${shape.sides}-sided`,
    ];
    if (style.symmetry > 1) parts.push(`${style.symmetry}-fold`);
    if (personality.identity) parts.push(describeIdentity(personality.identity));
    if (personality.motion) {
        const m = personality.motion;
        parts.push(`${m.stillness > 0.6 ? 'still' : m.stillness < 0.3 ? 'restless' : 'moving'}` +
            `${m.churn > 0.6 ? '+churn' : ''}`);
    }
    if (personality.temperament) {
        const t = personality.temperament;
        parts.push(`${t.intensity >= 0 ? 'hot' : 'cool'} ${t.extremity.toFixed(2)} bold`);
    }
    return parts.join(' · ');
}
