// Declarative parameter schema.
//
// This is the load-bearing abstraction for library scale. One declaration drives:
//   1. uniform binding            (Layer)
//   2. generated UI controls      (ui/ParamPanel)
//   3. seeded per-track sampling  (look/LookGenerator)
//   4. arc automation             (look/ArcDriver)
//   5. save/load of presets
//
// Adding a scene therefore costs a shader plus a params block, and nothing else.
// tools/lint-scenes.js machine-checks every declaration against its shader source.

export const PARAM_TYPES = ['float', 'int', 'bool', 'vec2', 'palette'];

// RATE PARAMS
//
// A param flagged `rate: true` is one the shader multiplies absolute time by —
// `u_time * u_speed` and friends. Those must never be modulated per frame, by
// audio reactivity or by LFO drift, and the engine enforces it.
//
// The reason is that phase is `u_time * rate`, so changing the rate at time T
// jumps the phase by `T * Δrate`. Sixty seconds into a track, a wobble of 0.05
// throws the phase by three whole units between one frame and the next — and it
// gets worse the longer the track runs. The visible result is high-frequency
// flicker that looks like the scene is broken, and which measured 6 flashes per
// second on Classic Wave, twice the WCAG 2.3.1 ceiling.
//
// A scene that wants audio-driven motion should add a bounded term rather than
// scaling the clock: `u_time * u_speed + u_bandLow * 2.0` is continuous;
// `u_time * (u_speed + u_bandLow)` is not.
export const RATE_FLAG = 'rate';

// A param flagged `slowAxis: true` is the one the arc driver walks from one end
// of its range to the other across the WHOLE track — the scene's long journey,
// as opposed to the drift LFO's wobble. See ArcDriver._slowAxisFor.
//
// It has to be declared rather than guessed. Measured across the churning
// scenes, which param you pick decides everything: moving Moiré Grid's `width`
// changes its time-averaged structure by 0.110, and moving its `offset` by
// 0.002. A randomly chosen param is overwhelmingly likely to be the second kind,
// which is why the first version of the slow axis measured as doing nothing at
// all.
export const SLOW_AXIS_FLAG = 'slowAxis';

/** Valid feature names a `reactive` entry may reference. Lint enforces this. */
export const REACTIVE_FEATURES = [
    'loudness', 'rms',
    'bandSub', 'bandLow', 'bandMid', 'bandHigh', 'bandAir',
    'flux', 'centroid', 'flatness', 'width',
    'beat', 'beatPhase', 'barPhase', 'phrasePhase',
    'sectionProgress', 'sectionEnergy', 'buildSlope',
];

export const REACTIVE_RESPONSES = ['linear', 'spike', 'smooth', 'inverse'];

/**
 * Identity artifacts a scene can consume. See look/Identity.js and EPIC-3.md.
 *
 * A trait is a modifier a scene may honour; an artifact is CONTENT the scene
 * draws. Declaring one is a commitment the gate enforces: swap the song's
 * identity and a scene that claims `cast` must produce a different picture.
 */
export const ARTIFACT_NAMES = ['cast', 'ink', 'staging'];

/**
 * Personality traits a scene can honour. See look/Personality.js.
 *
 * This is a CONTRACT, not a hint: a track built on `shape` will only cast scenes
 * that declare `shape`, and it will cast them believing they actually stamp the
 * signature form. Declaring a trait a scene ignores is worse than declaring
 * none, because the disqualification rule is the only thing keeping off-design
 * scenes out of a track.
 */
export const TRAIT_NAMES = ['shape', 'camera', 'space', 'style'];

export function defaultValue(def) {
    if (def.default !== undefined) return def.default;
    switch (def.type) {
        case 'bool': return false;
        case 'int': return Math.round(def.range ? def.range[0] : 0);
        case 'vec2': return [0, 0];
        case 'palette': return null; // supplied by the look, not sampled here
        default: return def.range ? def.range[0] : 0;
    }
}

export function defaultValues(module) {
    const out = {};
    for (const [name, def] of Object.entries(module.params || {})) {
        out[name] = defaultValue(def);
    }
    return out;
}

export function clampValue(def, value) {
    if (def.type === 'bool') return !!value;
    if (def.type === 'palette') return value;
    if (def.type === 'vec2') {
        const [lo, hi] = def.range || [0, 1];
        return [Math.min(hi, Math.max(lo, value[0])), Math.min(hi, Math.max(lo, value[1]))];
    }
    const [lo, hi] = def.range || [0, 1];
    let v = Math.min(hi, Math.max(lo, value));
    if (def.type === 'int') v = Math.round(v);
    return v;
}

/**
 * Sample a full parameter set from the declared ranges.
 *
 * `bias` (0..1 per key, optional) nudges sampling toward the top of a range —
 * this is how a track's measured character reaches the parameters without every
 * scene needing to know about audio features. `energy: 0.8` on a hard track
 * pushes density-ish params up without pinning them, so seed variation survives.
 *
 * `temperament` is the track's own hand on the same dials — see
 * look/Personality.js. Bias comes from the SECTION and is therefore nearly the
 * same for every track's drop; temperament comes from the TRACK and is not.
 * Without it, one scene cast in two different videos sampled around the same
 * centre both times and the two videos looked like the same video, which is
 * exactly the complaint temperament exists to answer.
 */
export function sampleValues(module, rng, bias = {}, temperament = null) {
    const out = {};
    for (const [name, def] of Object.entries(module.params || {})) {
        if (def.type === 'palette') { out[name] = null; continue; }
        if (def.fixed) { out[name] = defaultValue(def); continue; }

        let b = def.bias && bias[def.bias] !== undefined ? bias[def.bias] : 0.5;
        if (temperament) b = clamp01(b + temperamentShift(def.bias, temperament));

        if (def.type === 'bool') {
            out[name] = rng.bool(0.25 + b * 0.5);
            continue;
        }
        const [lo, hi] = def.range || [0, 1];

        // How far this track is willing to push a param toward its limits. A
        // timid track samples near the middle of everything and reads as the
        // library's average; a bold one commits. This is the difference between
        // "the same scene again" and "that scene, but this video's version".
        const extremity = temperament ? temperament.extremity : 0.5;

        // Bias still moves the centre of mass, but a bold track overrides more
        // of it — otherwise every drop in every video converges on one point.
        const mixAmount = (def.biasStrength !== undefined ? def.biasStrength : 0.45)
                        * (1 - extremity * 0.45);

        const u = boldUniform(rng.next(), extremity);
        const target = lo + (hi - lo) * b;
        let v = (lo + (hi - lo) * u) * (1 - mixAmount) + target * mixAmount;

        if (def.type === 'vec2') {
            const u2 = boldUniform(rng.next(), extremity);
            const v2 = (lo + (hi - lo) * u2) * (1 - mixAmount) + target * mixAmount;
            out[name] = [clampValue(def, [v, v2])[0], clampValue(def, [v, v2])[1]];
            continue;
        }

        // Absolute animation speed follows the song, not the scene's taste. A
        // rate param sampled at 0.7 of its range means the same visual speed
        // whether the track is 70bpm or 170, which is how slow songs ended up
        // with scenes skittering over them. See look/LookGenerator biasFor.
        if (def[RATE_FLAG] && bias.rateScale) v *= bias.rateScale;

        if (def.type === 'int') v = Math.round(v);
        out[name] = clampValue(def, v);
    }
    return out;
}

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

/**
 * Reshape a uniform draw so a bold track reaches the ends of a range.
 *
 * At extremity 0 this is unchanged. As it rises the distribution hollows out:
 * the same draw lands further from the centre, so a track that wants density
 * gets scenes at their dense end rather than at a polite 60%.
 *
 * The exponent floor is low on purpose. A param range is the scene author's
 * statement of what the scene can survive, so the ends of it are supposed to be
 * usable — a library that samples the middle of every range is a library where
 * every scene shows its default.
 *
 * There is a ceiling on this, found by overshooting it. Pushed harder (0.82),
 * enough draws piled onto the range ends that two different seeds started
 * producing near-identical frames — the Phase 3 look-space check caught a
 * closest pair at 0.0096 against a floor of 0.01 — and a sparse scene sampled
 * at its low end rendered as effectively black. Extremes are where the variety
 * is; the extremes are also where every scene collapses onto the same extreme.
 */
function boldUniform(u, extremity) {
    const signed = (u - 0.5) * 2;
    const shaped = Math.sign(signed) * Math.pow(Math.abs(signed), 1 - clamp01(extremity) * 0.72);
    return clamp01(0.5 + shaped * 0.5);
}

/** Which way this track leans on each of the three bias axes. */
function temperamentShift(axis, temperament) {
    switch (axis) {
        case 'energy':  return temperament.intensity * 0.3;
        case 'density': return temperament.intensity * 0.2 + temperament.detail * 0.3;
        case 'motion':  return temperament.pace * 0.35;
        default:        return 0;
    }
}

/** Evenly spaced probe values across a param's range, for the range-sweep check. */
export function sweepValues(def, steps = 5) {
    if (def.type === 'bool') return [false, true];
    if (def.type === 'palette') return [null];
    const [lo, hi] = def.range || [0, 1];
    const out = [];
    for (let i = 0; i < steps; i++) {
        let v = lo + ((hi - lo) * i) / (steps - 1);
        if (def.type === 'int') v = Math.round(v);
        out.push(def.type === 'vec2' ? [v, v] : v);
    }
    return out;
}

/**
 * Structural validation of a scene module. Returns an array of human-readable
 * problems; empty means clean. Shared by the lint tool and the runtime registry,
 * so a malformed scene can't reach the compositor.
 */
export function validateModule(module) {
    const errors = [];
    const id = module?.name || '<unnamed>';

    if (!module.name) errors.push('missing `name`');
    if (!module.family) errors.push(`${id}: missing \`family\``);
    if (!module.kind) errors.push(`${id}: missing \`kind\``);
    if (!Array.isArray(module.traits)) {
        errors.push(`${id}: missing \`traits\` — declare which personality traits it honours ` +
            `(any of ${TRAIT_NAMES.join(', ')}, or [] for none)`);
    } else {
        for (const t of module.traits) {
            if (!TRAIT_NAMES.includes(t)) errors.push(`${id}: unknown trait '${t}'`);
        }
    }
    if (module.consumes !== undefined) {
        if (!Array.isArray(module.consumes)) {
            errors.push(`${id}: \`consumes\` must be an array of ${ARTIFACT_NAMES.join('/')}`);
        } else {
            for (const a of module.consumes) {
                if (!ARTIFACT_NAMES.includes(a)) errors.push(`${id}: unknown artifact '${a}'`);
            }
        }
    }
    if (module.texture !== undefined
        && (typeof module.texture !== 'number' || module.texture < 0 || module.texture > 2)) {
        errors.push(`${id}: \`texture\` must be a number 0..2 — how much of the track's ` +
            `surface grain this scene takes (1 = all, 0 = none)`);
    }
    if (module.kind === 'fragment' && !module.shader) errors.push(`${id}: kind 'fragment' but no \`shader\``);
    if (module.kind === 'fragment' && module.shader && !/vec4\s+scene\s*\(/.test(module.shader)) {
        errors.push(`${id}: shader must define \`vec4 scene(vec2 uv, vec2 p)\``);
    }

    const params = module.params || {};
    const uniformNames = new Set();

    for (const [name, def] of Object.entries(params)) {
        const where = `${id}.${name}`;
        if (!def.type) errors.push(`${where}: missing \`type\``);
        else if (!PARAM_TYPES.includes(def.type)) errors.push(`${where}: unknown type '${def.type}'`);

        if (def.type !== 'palette' && def.type !== 'bool' && !def.range) {
            errors.push(`${where}: numeric param needs a \`range\``);
        }
        if (def.range && (def.range.length !== 2 || def.range[0] >= def.range[1])) {
            errors.push(`${where}: \`range\` must be [min, max] with min < max`);
        }
        if (def.default !== undefined && def.range && typeof def.default === 'number') {
            if (def.default < def.range[0] || def.default > def.range[1]) {
                errors.push(`${where}: default ${def.default} outside range [${def.range}]`);
            }
        }
        if (def.uniform) {
            if (uniformNames.has(def.uniform)) errors.push(`${where}: duplicate uniform '${def.uniform}'`);
            uniformNames.add(def.uniform);
            if (!/^u_[A-Za-z0-9_]+$/.test(def.uniform)) {
                errors.push(`${where}: uniform '${def.uniform}' should be named u_*`);
            }
        }
        if (def.bias && typeof def.bias !== 'string') errors.push(`${where}: \`bias\` must be a key name`);
        if (def[SLOW_AXIS_FLAG]) {
            if (def[RATE_FLAG]) {
                errors.push(`${where}: cannot be both \`rate\` and \`slowAxis\` — walking a rate ` +
                    `param jumps the animation phase (see the RATE_FLAG note above)`);
            }
            if (def.type === 'palette' || def.type === 'bool' || def.fixed) {
                errors.push(`${where}: \`slowAxis\` needs a numeric range to walk`);
            }
        }
    }

    for (const [name, r] of Object.entries(module.reactive || {})) {
        const where = `${id}.reactive.${name}`;
        if (!params[name]) errors.push(`${where}: no such param`);
        if (!r.feature) errors.push(`${where}: missing \`feature\``);
        else if (!REACTIVE_FEATURES.includes(r.feature)) {
            errors.push(`${where}: unknown feature '${r.feature}'`);
        }
        if (r.response && !REACTIVE_RESPONSES.includes(r.response)) {
            errors.push(`${where}: unknown response '${r.response}'`);
        }
        if (typeof r.amount !== 'number') errors.push(`${where}: missing numeric \`amount\``);
        if (params[name] && params[name].rate) {
            errors.push(`${where}: '${name}' is a rate param and cannot be reactive — ` +
                `modulating it jumps phase by elapsed*delta (see RATE_FLAG)`);
        }
    }

    return errors;
}
