// Phase 10 gate — variety.
//
// Phase 8 gave a track more cuts, Phase 9 gave it a coherent identity. Watching
// several tracks side by side exposed what neither addressed: the SAME scene
// cast in two different videos looked like the same footage twice. Section bias
// is nearly identical between two tracks' drops, so both sampled their params
// around the same centre, and the library's own averageness did the rest.
//
// Three answers, and this gate is what holds them honest:
//
//   temperament — a per-track hand on every parameter dial (Personality.js)
//   overlays    — a second full scene composited over the first, sometimes
//   palette     — a wider reachable colour space, so two tracks differ on
//                 colour before they differ on anything else
//
// Two later additions are held here for the same reason. GRAIN was in every
// video — applied unconditionally per scene and again in the grade, with only
// its amount varying — so it is now a treatment most tracks go without and no
// two grainy tracks wear the same way (look/grain.js). And TEMPO now dominates
// how fast a scene animates, because a slow song was getting scenes that
// skittered over it.
//
// The hard part of testing "variety" is that it is a property of a POPULATION,
// not of one render. Every check here therefore samples many tracks and asks
// about the spread, never about a single value.

import { check, expect } from './framework.js';
import { Engine } from '../engine/Engine.js';
import { scenes } from '../scenes/registry.js';
import { generateLook } from '../look/LookGenerator.js';
import { AudioPalette, generateUsablePalette, relativeLuminance } from '../look/palette.js';
import { Rng } from '../engine/rng.js';
import { FeatureTrack, featureProviderFor } from '../audio/FeatureTrack.js';
import { synthesizeSectioned } from '../audio/synth.js';
import { sampleValues } from '../params/schema.js';
import { frameDistance, frameLuminance } from '../engine/hash.js';
import { battery as timbreBattery } from './phase3.js';
import { grainEnvelope } from '../look/grain.js';
import { signatureUniforms } from '../look/Personality.js';

/**
 * Several tracks that genuinely differ in what they sound like — the population
 * every check here measures. Synthetic, so the gate does not depend on assets.
 */
let cachedBattery = null;
function battery() {
    if (!cachedBattery) {
        cachedBattery = [
            { name: 'slow ambient', buffer: synthesizeSectioned({ bpm: 84, duration: 110, changeAt: 55 }) },
            { name: 'mid house', buffer: synthesizeSectioned({ bpm: 122, duration: 110, changeAt: 50 }) },
            { name: 'fast techno', buffer: synthesizeSectioned({ bpm: 148, duration: 110, changeAt: 45 }) },
            { name: 'broken beat', buffer: synthesizeSectioned({ bpm: 104, duration: 110, changeAt: 70 }) },
        ].map((t) => ({ ...t, track: FeatureTrack.fromAudioBuffer(t.buffer, { fps: 60 }) }));
    }
    return cachedBattery;
}

function makeEngine(track, width = 160, height = 90) {
    const engine = new Engine({ width, height });
    engine.timeline.setDuration(track.duration);
    engine.setFeatureProvider(featureProviderFor(track));
    return engine;
}

/** Every layer stack in a look, flattened. */
const stacksOf = (look) => look.sections.flatMap((s) => s.variants || [s.layers]);

check(10, 'two tracks do not share a temperament', () => {
    // The per-track hand on the dials. If these collapsed toward one value the
    // whole mechanism would be decorative, and the symptom — every video
    // sampling around the library average — is exactly what it was built for.
    // Sampled over several seeds per track rather than one. Four samples of a
    // ±0.8 draw can land close together by luck, and a gate that fails on that
    // is measuring the seeds, not the mechanism. What the mechanism has to
    // deliver is both: a wide population AND no two tracks landing on the same
    // hand — so both are asserted.
    const t = [];
    battery().forEach(({ track }, i) => {
        for (let s = 0; s < 3; s++) t.push(generateLook(track, { seed: 100 + i * 7919 + s * 104729 }).personality.temperament);
    });

    // Measured as a fraction of each dial's own range, because they are not the
    // same width — extremity spans 0.5 in total and intensity over three times
    // that, so one absolute floor would be either trivial or unreachable.
    const dialRange = { intensity: 1.7, pace: 1.6, extremity: 0.5 };
    const keys = Object.keys(dialRange);
    const spread = (key) => Math.max(...t.map((x) => x[key])) - Math.min(...t.map((x) => x[key]));
    const worst = Math.min(...keys.map((k) => spread(k) / dialRange[k]));

    let closest = Infinity;
    for (let a = 0; a < t.length; a++) {
        for (let b = a + 1; b < t.length; b++) {
            closest = Math.min(closest, Math.max(...keys.map((k) => Math.abs(t[a][k] - t[b][k]))));
        }
    }

    return expect(worst > 0.5 && closest > 0.02,
        `${t.length} tracks · worst dial covers ${(worst * 100).toFixed(0)}% of its range · ` +
        `spread intensity ${spread('intensity').toFixed(2)} · ` +
        `pace ${spread('pace').toFixed(2)} · detail ${spread('detail').toFixed(2)} · ` +
        `extremity ${spread('extremity').toFixed(2)} · closest pair ${closest.toFixed(3)}`);
});

check(10, 'one scene looks different in two different videos', () => {
    // The complaint, as a number. Take a scene, render it with the parameters
    // and personality two different tracks gave it, and require the images to
    // actually differ — far above the 1/255 the determinism checks treat as
    // noise, but far below "unrecognisable". The same scene should still be
    // itself; it just should not be the same footage.
    const looks = battery().map(({ track }, i) => ({
        track,
        look: generateLook(track, { seed: 500 + i * 6841 }),
    }));

    const distances = [];
    const problems = [];

    for (const module of scenes.filter((m) => m.kind === 'fragment').slice(0, 12)) {
        const engine = makeEngine(looks[0].track);
        try {
            const frames = looks.map(({ look }) => {
                // Sample this scene the way each track's own generator would.
                // If the track did not cast it, sample it anyway with that
                // track's temperament — the question is what this scene WOULD
                // look like in that video, and falling back to defaults would
                // compare two identical parameter sets and prove nothing.
                const stack = stacksOf(look).find((s) => s[0].module === module);
                const params = stack ? stack[0].params : sampleValues(
                    module,
                    new Rng(look.seed ^ 0x51ed270b),
                    look.sections[0].bias,
                    look.personality.temperament,
                );
                engine.setLayerSpecs([{
                    module,
                    params,
                    seed: look.seed & 0x7fffffff,
                    opacity: 1,
                    blend: 'normal',
                    palette: look.palette,
                    personality: look.personality,
                }]);
                engine.prime(300);
                engine.compositor.reset();
                return Uint8Array.from(engine.readPixels(engine.renderFrame(300)));
            });

            for (let i = 0; i < frames.length; i++) {
                for (let j = i + 1; j < frames.length; j++) {
                    // Relative to how much image there is, not absolute.
                    // frameDistance averages over every pixel, and most scenes
                    // here are mostly dark — two genuinely different renders of
                    // a sparse scene (25 bars versus 53) score under 0.02 in
                    // absolute terms simply because the black background agrees
                    // with itself. Dividing by the images' own brightness asks
                    // the question that was meant: how different is this, as a
                    // fraction of what is actually on screen.
                    const brightness = Math.max(1e-3,
                        (frameLuminance(frames[i]) + frameLuminance(frames[j])) * 0.5);
                    const d = frameDistance(frames[i], frames[j]) / brightness;
                    distances.push(d);
                    if (d < 0.12) problems.push(`${module.name}: ${d.toFixed(3)}`);
                }
            }
        } finally {
            engine.dispose();
        }
    }

    const mean = distances.reduce((a, b) => a + b, 0) / Math.max(1, distances.length);
    return expect(problems.length === 0,
        problems.slice(0, 4).join(' · ') ||
        `${distances.length} cross-track pairs, mean relative distance ${mean.toFixed(2)}, ` +
        `closest ${Math.min(...distances).toFixed(2)} (floor 0.12)`);
}, { slow: true });

check(10, 'overlays happen sometimes and not always', () => {
    // A second scene over the first is the variation valve. Always-on would read
    // as permanently cluttered and never-on is the state this fixed, so the
    // check is on the RATE across many tracks rather than on any one stack.
    let stacks = 0;
    let withOverlay = 0;
    const blends = new Set();

    for (const { track } of battery()) {
        for (let s = 0; s < 6; s++) {
            const look = generateLook(track, { seed: 900 + s * 5231 });
            for (const stack of stacksOf(look)) {
                stacks++;
                const overlay = stack.slice(1).find((l) => l.module.role !== 'accent');
                if (overlay) {
                    withOverlay++;
                    blends.add(overlay.blend);
                    if (overlay.blend === 'normal') {
                        // 'normal' would hide the shot entirely rather than
                        // compositing over it — that is what a cut is for.
                        return expect(false, `${overlay.module.name} overlaid with 'normal'`);
                    }
                }
            }
        }
    }

    const rate = withOverlay / Math.max(1, stacks);
    return expect(rate > 0.05 && rate < 0.55,
        `${withOverlay}/${stacks} stacks carry an overlay (${(rate * 100).toFixed(0)}%), ` +
        `blends: ${[...blends].join(', ')}`);
});

check(10, 'an overlay never hides the shot underneath it', () => {
    // Opacity is the other half of "composited over" — a 0.9 overlay is a
    // replacement with extra steps.
    const problems = [];
    for (const { track } of battery()) {
        for (let s = 0; s < 4; s++) {
            for (const stack of stacksOf(generateLook(track, { seed: 1300 + s * 8641 }))) {
                for (const layer of stack.slice(1)) {
                    if (layer.module.role === 'accent') continue;
                    if (layer.opacity > 0.6) {
                        problems.push(`${layer.module.name} at ${layer.opacity.toFixed(2)}`);
                    }
                }
            }
        }
    }
    return expect(problems.length === 0, problems.slice(0, 3).join(' · ') || 'all overlays stay under 0.6');
});

check(10, 'the palette reaches the whole colour wheel', () => {
    // Hue used to be a one-way sweep from red down to blue, which made violet,
    // magenta and pink unreachable for every track ever generated — a third of
    // the wheel the tool simply could not produce. Sampled across many tracks,
    // every sixth of the wheel should now show up.
    const buckets = new Array(6).fill(0);
    const samples = 40;

    for (let i = 0; i < samples; i++) {
        const rng = new Rng(2000 + i * 7919);
        const summary = {
            meanCentroid: 0.2 + (i % 7) * 0.1,
            meanFlatness: 0.1 + (i % 5) * 0.08,
            dynamicRange: 0.3 + (i % 4) * 0.15,
            bpm: 80 + (i % 9) * 12,
            bandBalance: {
                sub: 0.2 + (i % 5) * 0.12, low: 0.3 + (i % 3) * 0.2, mid: 0.25 + (i % 4) * 0.1,
                high: 0.2 + (i % 6) * 0.1, air: 0.15 + (i % 7) * 0.09,
            },
        };
        for (const [r, g, b] of generateUsablePalette(new AudioPalette(summary, rng), 6)) {
            const max = Math.max(r, g, b);
            const min = Math.min(r, g, b);
            if (max - min < 0.08) continue;           // greys carry no hue
            let h;
            if (max === r) h = ((g - b) / (max - min) + 6) % 6;
            else if (max === g) h = (b - r) / (max - min) + 2;
            else h = (r - g) / (max - min) + 4;
            buckets[Math.floor(h) % 6]++;
        }
    }

    const empty = buckets.filter((n) => n === 0).length;
    const names = ['red', 'yellow', 'green', 'cyan', 'blue', 'magenta'];
    return expect(empty === 0,
        buckets.map((n, i) => `${names[i]}:${n}`).join(' ') +
        (empty ? ` — ${empty} sixth(s) unreachable` : ''));
});

check(10, 'two tracks do not get the same palette', () => {
    // Distinctness, not just coverage: a wheel that is fully reachable but where
    // every track lands in the same place would pass the check above.
    //
    // Measured on the Phase 3 battery rather than this file's, because those
    // four tracks differ in TIMBRE and this file's differ mostly in tempo.
    // Palette follows timbre by design, so two tracks that sound alike SHOULD
    // get similar colour — asking otherwise would be asking the generator to
    // ignore its own input.
    const palettes = timbreBattery().map(({ track }) =>
        generateLook(track, { seed: 61 }).palette);   // fixed seed: difference must come from the audio

    let closest = Infinity;
    for (let i = 0; i < palettes.length; i++) {
        for (let j = i + 1; j < palettes.length; j++) {
            let sum = 0;
            for (let k = 0; k < palettes[i].length; k++) {
                const a = palettes[i][k], b = palettes[j][k];
                sum += Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
            }
            closest = Math.min(closest, sum / palettes[i].length);
        }
    }
    const lums = palettes.map((p) => p.map(relativeLuminance).join(','));
    return expect(closest > 0.08 && new Set(lums).size === palettes.length,
        `closest pair mean channel distance ${closest.toFixed(3)} (floor 0.08), ` +
        `${new Set(lums).size}/${palettes.length} distinct`);
});

// --- grain -----------------------------------------------------------------
// The complaint these answer: grain was in every video. It was applied twice
// unconditionally — once per scene, once in the grade — so the only thing that
// varied between tracks was how much. See look/grain.js.

check(10, 'most tracks carry no grain at all', () => {
    const modes = [];
    for (let i = 0; i < 60; i++) {
        modes.push(generateLook(battery()[i % 4].track, { seed: 900 + i * 5779 }).grain.mode);
    }
    const off = modes.filter((m) => m === 'off').length / modes.length;
    const distinct = new Set(modes).size;

    // A quarter to two thirds. Never grainy is as much a failure as always
    // grainy — the treatment has to remain available.
    return expect(off > 0.25 && off < 0.7 && distinct >= 4,
        `${(off * 100).toFixed(0)}% of 60 tracks have no grain · ${distinct} modes used: ` +
        `${[...new Set(modes)].join(', ')}`);
});

check(10, 'two grainy tracks are not grainy the same way', () => {
    // Amount alone was never the difference that mattered. Cell size, refresh
    // rate, mask and chroma are, so the population has to spread across them.
    const specs = [];
    for (let i = 0; i < 80 && specs.length < 30; i++) {
        const g = generateLook(battery()[i % 4].track, { seed: 4100 + i * 7717 }).grain;
        if (g.mode !== 'off') specs.push(g);
    }
    const uniq = (key) => new Set(specs.map((s) => s[key])).size;
    const signatures = new Set(specs.map((s) => `${s.mode}/${s.scale}/${s.rate}/${s.mask}`)).size;

    return expect(uniq('scale') >= 3 && uniq('rate') >= 3 && uniq('mask') >= 3
        && signatures >= specs.length * 0.5,
        `${specs.length} grainy tracks · ${uniq('scale')} cell sizes · ${uniq('rate')} rates · ` +
        `${uniq('mask')} masks · ${signatures} distinct treatments`);
});

check(10, 'grain that is not constant actually comes and goes', () => {
    // A 'swell' or 'sections' grain that never reaches zero is just constant
    // grain with extra steps, and one that never reaches full is decoration.
    const { track } = battery()[1];
    const problems = [];
    let tested = 0;

    for (let i = 0; i < 120 && tested < 6; i++) {
        const look = generateLook(track, { seed: 7000 + i * 3571 });
        const g = look.grain;
        if (g.mode === 'off' || g.mode === 'constant') continue;
        tested++;

        let lo = Infinity, hi = -Infinity;
        for (let f = 0; f < track.frameCount; f += 7) {
            const env = grainEnvelope(g, {
                time: f / 60,
                sectionKind: track.sectionAt(f).kind,
                features: track.at(f),
            });
            lo = Math.min(lo, env);
            hi = Math.max(hi, env);
        }
        if (lo > 0.05 || hi < 0.4) problems.push(`${g.mode}: ${lo.toFixed(2)}..${hi.toFixed(2)}`);
    }

    return expect(tested > 0 && problems.length === 0,
        problems.length ? problems.join(' · ') : `${tested} time-varying grains all reach 0 and full`);
});

check(10, 'a scene that refuses grain never gets any', () => {
    // `texture: 0` on a module has to survive a track that wants maximum grit,
    // because the point of it is that crisp line work stays crisp.
    const gritty = { style: { lineWeight: 0.5, softness: 0.5, texture: 1, symmetry: 1 },
                     shape: { sides: 0, roundness: 0, elongation: 1, tilt: 0 },
                     camera: { driftAngle: 0, driftRate: 0, sway: 0, swayRate: 0, spin: 0, breathe: 0 },
                     space: { horizon: 0.5, depth: 0, washAngle: 0, wash: 0 } };

    const refusing = scenes.filter((m) => m.texture === 0);
    const leaked = refusing.filter((m) => signatureUniforms(gritty, m).u_sigTexture > 0);
    const takesIt = signatureUniforms(gritty, scenes.find((m) => m.texture === undefined));

    return expect(refusing.length > 0 && leaked.length === 0 && takesIt.u_sigTexture === 1,
        `${refusing.length} scenes opt out, ${leaked.length} leaked · ` +
        `an opted-in scene still gets ${takesIt.u_sigTexture}`);
});

// --- tempo -----------------------------------------------------------------

check(10, 'a slow track animates slower than a fast one', () => {
    // Motion used to be mostly energy with tempo as a small correction, so a
    // 70bpm ballad's drop asked its scenes for nearly as much speed as a
    // 150bpm track's — and the scenes obliged, over a song that was not moving.
    const slow = generateLook(battery()[0].track, { seed: 31 });   // 84bpm
    const fast = generateLook(battery()[2].track, { seed: 31 });   // 148bpm

    const rateMean = (look) => {
        const vals = [];
        for (const stack of stacksOf(look)) {
            for (const layer of stack) {
                for (const [name, def] of Object.entries(layer.module.params || {})) {
                    if (!def.rate || typeof layer.params[name] !== 'number') continue;
                    const [lo, hi] = def.range;
                    vals.push((layer.params[name] - lo) / (hi - lo));   // normalised
                }
            }
        }
        return vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : 0;
    };

    const s = rateMean(slow), f = rateMean(fast);
    return expect(f > s * 1.25,
        `mean normalised rate: 84bpm ${s.toFixed(3)} vs 148bpm ${f.toFixed(3)} ` +
        `(${(f / Math.max(1e-6, s)).toFixed(2)}×)`);
});
