// Phase 2 gate — parameter schema, automatic binding, generated UI.
//
// The range sweep is the check that earns its keep: it renders every scene at
// several points across every declared range and rejects frames that are black,
// blown out or flat. Those are exactly the states a look generator will wander
// into on some seed, and finding them here is far cheaper than finding them at
// minute four of an export.

import { check, expect } from './framework.js';
import { Engine } from '../engine/Engine.js';
import { scenes, FAMILIES } from '../scenes/registry.js';
import { defaultValues, sweepValues, validateModule } from '../params/schema.js';
import { serializeParams, deserializeParams } from '../params/serialize.js';
import { ParamPanel } from '../ui/ParamPanel.js';
import { frameLuminance, frameVariance } from '../engine/hash.js';
import {
    AUDIO_UNIFORMS, SIGNATURE_UNIFORMS, IDENTITY_UNIFORMS,
} from '../engine/shader-contract.js';
import { featureProviderFor } from '../audio/FeatureTrack.js';
import { testTrack } from './phase1.js';

const PALETTE = [
    [0.06, 0.03, 0.16], [0.85, 0.15, 0.55], [0.15, 0.75, 0.95],
    [0.98, 0.85, 0.35], [0.55, 0.25, 0.85], [0.2, 0.95, 0.6],
];

function makeEngine(module, params, track) {
    const engine = new Engine({ width: 192, height: 108 });
    engine.timeline.setDuration(track.duration);
    engine.setFeatureProvider(featureProviderFor(track));
    engine.compositor.postEnabled = false;   // sweep the scene, not the grade
    engine.setLayerSpecs([{
        module, params, seed: 777, opacity: 1, blend: 'normal', palette: PALETTE,
    }]);
    return engine;
}

check(2, 'every scene schema validates', () => {
    const problems = [];
    for (const module of scenes) {
        problems.push(...validateModule(module));
        if (!FAMILIES[module.family]) problems.push(`${module.name}: unknown family`);
    }
    return expect(problems.length === 0,
        problems.length ? problems.join(' · ') : `${scenes.length} scenes valid`);
});

check(2, 'declared uniforms and shader sources agree both ways', () => {
    // Derived from the contract rather than retyped: the signature uniforms
    // arrived in Phase 9 and a hand-maintained copy of this list would have
    // reported every scene that reads one as reading an undeclared uniform.
    const CONTRACT = new Set([
        'u_resolution', 'u_aspect', 'u_pixelScale', 'u_time', 'u_frame', 'u_progress',
        'u_seed', 'u_opacity', 'u_colors', 'u_colorCount', 'u_prev', 'u_hasPrev',
        ...AUDIO_UNIFORMS,
        ...Object.keys(SIGNATURE_UNIFORMS),
        ...Object.keys(IDENTITY_UNIFORMS),
    ]);
    const problems = [];
    for (const module of scenes) {
        if (module.kind !== 'fragment') continue;
        const declared = new Map();
        for (const [name, def] of Object.entries(module.params || {})) {
            if (def.uniform) declared.set(def.uniform, name);
        }
        for (const [uniform, param] of declared) {
            if (!new RegExp(`\\b${uniform}\\b`).test(module.shader)) {
                problems.push(`${module.name}: ${param} declares ${uniform}, never read`);
            }
        }
        for (const uniform of new Set(module.shader.match(/\bu_[A-Za-z0-9_]+\b/g) || [])) {
            if (!CONTRACT.has(uniform) && !declared.has(uniform)) {
                problems.push(`${module.name}: reads undeclared ${uniform}`);
            }
        }
    }
    return expect(problems.length === 0,
        problems.length ? problems.join(' · ') : 'all uniforms accounted for');
});

check(2, 'every scene compiles and renders', () => {
    const track = testTrack();
    const problems = [];
    for (const module of scenes) {
        const engine = makeEngine(module, defaultValues(module), track);
        try {
            const pixels = engine.readPixels(engine.renderFrame(1200));
            const lum = frameLuminance(pixels);
            const variance = frameVariance(pixels);
            // Accent scenes composite over a background; most of their frame is
            // legitimately black, so only variance is meaningful for them.
            if (module.role !== 'accent' && !(lum > 0.001)) problems.push(`${module.name}: black frame`);
            if (variance < 0.002) problems.push(`${module.name}: flat (var ${variance.toFixed(4)})`);
        } catch (err) {
            problems.push(`${module.name}: ${err.message}`);
        } finally {
            engine.dispose();
        }
    }
    return expect(problems.length === 0,
        problems.length ? problems.join(' · ') : `${scenes.length} scenes render`);
});

check(2, 'param range sweep produces no dead or blown frames', () => {
    const track = testTrack();
    const problems = [];
    let rendered = 0;

    for (const module of scenes) {
        const base = defaultValues(module);
        for (const [name, def] of Object.entries(module.params || {})) {
            if (def.type === 'palette') continue;
            for (const value of sweepValues(def, 4)) {
                const params = { ...base, [name]: value };
                const engine = makeEngine(module, params, track);
                try {
                    const pixels = engine.readPixels(engine.renderFrame(1200));
                    rendered++;
                    const lum = frameLuminance(pixels);
                    const variance = frameVariance(pixels);
                    const label = `${module.name}.${name}=${JSON.stringify(value)}`;
                    const accent = module.role === 'accent';
                    // An accent at brightness 0 really is black, and that is a
                    // legitimate value — judge those on variance alone.
                    if (lum > 0.985) problems.push(`${label} blown (lum ${lum.toFixed(3)})`);
                    if (!accent && lum < 0.0008) problems.push(`${label} black (lum ${lum.toFixed(5)})`);
                    if (!accent && variance < 0.0015) problems.push(`${label} flat (var ${variance.toFixed(4)})`);
                } catch (err) {
                    problems.push(`${module.name}.${name}: ${err.message}`);
                } finally {
                    engine.dispose();
                }
            }
        }
    }
    const shown = problems.slice(0, 6).join(' · ');
    return expect(problems.length === 0,
        problems.length
            ? shown + (problems.length > 6 ? ` · +${problems.length - 6} more` : '')
            : `${rendered} sweep frames across ${scenes.length} scenes, all live`);
}, { slow: true });

check(2, 'every declared param gets a generated control', () => {
    const host = document.createElement('div');
    const problems = [];
    for (const module of scenes) {
        const panel = new ParamPanel(host, () => {});
        panel.build(module, defaultValues(module));
        const expected = Object.entries(module.params || {})
            .filter(([, def]) => def.type !== 'palette')
            .map(([name]) => name);
        const actual = panel.controlNames();
        for (const name of expected) {
            if (!actual.includes(name)) problems.push(`${module.name}.${name} has no control`);
        }
    }
    return expect(problems.length === 0,
        problems.length ? problems.join(' · ') : 'all params exposed in the UI');
});

check(2, 'control edits emit clamped values', () => {
    const host = document.createElement('div');
    const module = scenes.find((s) => Object.values(s.params).some((d) => d.type === 'float'));
    const received = [];
    const panel = new ParamPanel(host, (name, value) => received.push([name, value]));
    panel.build(module, defaultValues(module));

    const [name, control] = [...panel.controls.entries()][0];
    control.input.value = control.input.max;
    control.input.dispatchEvent(new Event('input'));

    const def = module.params[name];
    const hi = def.range[1];
    const last = received[received.length - 1];
    const value = last && (Array.isArray(last[1]) ? last[1][0] : last[1]);
    return expect(last && last[0] === name && Math.abs(value - hi) < 1e-6,
        `emitted ${JSON.stringify(last)}, expected ${name}=${hi}`);
});

check(2, 'params round-trip through serialisation unchanged', () => {
    const problems = [];
    for (const module of scenes) {
        const values = defaultValues(module);
        const stored = JSON.parse(JSON.stringify(serializeParams(module, values)));
        const restored = deserializeParams(module, stored);
        for (const [name, def] of Object.entries(module.params || {})) {
            if (def.type === 'palette') continue;
            const a = values[name], b = restored[name];
            const same = def.type === 'vec2' ? a[0] === b[0] && a[1] === b[1] : a === b;
            if (!same) problems.push(`${module.name}.${name}: ${JSON.stringify(a)} → ${JSON.stringify(b)}`);
        }
    }
    return expect(problems.length === 0,
        problems.length ? problems.join(' · ') : `${scenes.length} scenes round-trip cleanly`);
});

check(2, 'a preset missing or gaining keys still loads', () => {
    // Presets outlive schema edits; failing to load one is worse than losing a value.
    const module = scenes[0];
    const partial = deserializeParams(module, { __gone__: 5 });
    const defaults = defaultValues(module);
    const same = Object.keys(defaults).every((k) => {
        const a = defaults[k], b = partial[k];
        return Array.isArray(a) ? a[0] === b[0] : a === b;
    });
    return expect(same, 'unknown keys ignored, missing keys defaulted');
});
