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

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

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.
 */
export function sampleValues(module, rng, bias = {}) {
    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; }

        const b = def.bias && bias[def.bias] !== undefined ? bias[def.bias] : 0.5;

        if (def.type === 'bool') {
            out[name] = rng.bool(0.25 + b * 0.5);
            continue;
        }
        const [lo, hi] = def.range || [0, 1];
        // Triangular-ish blend of a uniform draw with the bias target: keeps the
        // full range reachable (so the seed contact sheet stays wide) while still
        // letting track character shift the centre of mass.
        const u = rng.next();
        const target = lo + (hi - lo) * b;
        const mixAmount = def.biasStrength !== undefined ? def.biasStrength : 0.45;
        let v = (lo + (hi - lo) * u) * (1 - mixAmount) + target * mixAmount;

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

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

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