// Phase 12 — the SEED VARIETY TEST.
//
// Every other phase asks whether one video is correct. This one asks whether
// two videos are different, which is the failure the rest of the suite is
// structurally unable to see: a generator that ignores its seed passes
// determinism, flash safety, liveness and end-to-end rendering perfectly.
//
// The measurement lives in checks/variety/. The two checks that come first here
// are not about the generator at all — they are about the instrument. A
// structural variety score is worthless unless it can be shown to ignore the
// cheap axes (recolour, rotate) and to react to the expensive one (a different
// scene), so those are gated before any number derived from them is trusted.
//
// The library sweep in the middle is the map the rest is drawn on: the seed can
// only produce as much variety as the library holds, so "how many structurally
// distinct looks are there" is measured before "how many does a seed reach".

import { check, expect } from './framework.js';
import { song } from '../audio/songbank.js';
import { Show } from '../Show.js';
import { generateLook } from '../look/LookGenerator.js';
import { scenes } from '../scenes/registry.js';
import { canBackground } from '../params/schema.js';
import {
    GROUND_MIN, canGround, isMeasured, surfaceOf, METADATA,
    coverageOf as declaredCoverage, structuralDistance,
} from '../scenes/surface.js';
import { metadataIsFresh, metricsFingerprint } from './metadata.js';
import { RESTFUL_FAMILIES } from '../look/directors.js';
import { stackCoverage } from '../look/stack.js';
import { frameDescriptor, rotate90, recolour } from './variety/descriptors.js';
import { descriptorDistance, signatureDistance } from './variety/signature.js';
import {
    measureVariety, measureSpecDiversity, signatureForScene, librarySweep,
} from './variety/report.js';

// `centre` is the bank's null hypothesis: the middle of every axis, a full
// six-stage arrangement. The gates used to run on a two-section synthetic whose
// only kinds were intro and outro, which left half the scene library unreachable
// and made every number here a measurement of that rather than of the seed.
export const varietyTrack = () => song('centre').track;

// The library sweep is the most expensive thing in the suite — every scene
// rendered — and three checks read it. Computed once per page load.
let cachedSweep = null;
export function librarySweepCached() {
    if (!cachedSweep) cachedSweep = librarySweep(varietyTrack(), { probes: 3 });
    return cachedSweep;
}

/** One frame of one seed, rendered the normal way. */
function sampleFrame(seed = 12001, width = 160, height = 90) {
    const track = varietyTrack();
    const show = new Show({ width, height });
    try {
        show.useTrack(track, generateLook(track, { seed }));
        const section = show.look.sections[Math.floor(show.look.sections.length / 2)];
        const frame = section.startFrame + Math.floor((section.endFrame - section.startFrame) / 2);
        show.engine.compositor.reset();
        for (let f = Math.max(0, frame - 20); f < frame; f++) show.renderFrame(f);
        return Uint8Array.from(show.readPixels(show.renderFrame(frame)));
    } finally {
        show.dispose();
    }
}

check(12, 'seed variety · the metric ignores colour and rotation', () => {
    const w = 160, h = 90;
    const pixels = sampleFrame(12001, w, h);
    const base = frameDescriptor(pixels, w, h);

    // Hue-rotated and brightened: the same picture in a different palette, which
    // is precisely the difference we refuse to count as variety.
    const graded = descriptorDistance(base, frameDescriptor(recolour(pixels), w, h));
    // Turned ninety degrees. Layout is EXCLUDED here by design — where structure
    // sits in the frame is real information, and a metric blind to it could not
    // see a library that centres everything.
    const spun = rotate90(pixels, w, h);
    const turned = descriptorDistance(base, frameDescriptor(spun.pixels, spun.width, spun.height));

    const structural = ['scale', 'orient', 'texture'];
    const worstGrade = Math.max(...structural.concat('layout').map((b) => graded[b]));
    const worstTurn = Math.max(...structural.map((b) => turned[b]));

    return expect(worstGrade < 0.05 && worstTurn < 0.02 && graded.colour > 0.2,
        `recolour moves structure ${worstGrade.toFixed(4)} (colour block ${graded.colour.toFixed(3)}) · ` +
        `rotate moves structure ${worstTurn.toFixed(4)}`);
});

check(12, 'seed variety · the metric separates different scenes from the same scene', () => {
    // The instrument's other half: it must react to the thing that IS a
    // difference. Two renders of one scene must land far below two renders of
    // two scenes, or a low variety score would just be a blind metric.
    const track = varietyTrack();
    const pool = scenes.filter((m) => canBackground(m) && m.kind === 'fragment');
    const a = pool[0], b = pool[Math.floor(pool.length / 2)], c = pool[pool.length - 1];

    const sigA = signatureForScene(track, a, 777, { probes: 2 });
    const sigA2 = signatureForScene(track, a, 777, { probes: 2 });
    const sigB = signatureForScene(track, b, 777, { probes: 2 });
    const sigC = signatureForScene(track, c, 777, { probes: 2 });

    const same = signatureDistance(sigA, sigA2).total;
    const diff = Math.min(
        signatureDistance(sigA, sigB).total,
        signatureDistance(sigA, sigC).total,
        signatureDistance(sigB, sigC).total,
    );

    return expect(same < 0.01 && diff > same * 8,
        `same scene ${same.toFixed(4)} · different scenes ${diff.toFixed(4)} ` +
        `(${a.name} / ${b.name} / ${c.name})`);
}, { slow: true });

check(12, 'seed variety · every visualization in the library is a distinct look', () => {
    // Runs against ALL of them, not a sample. The existing per-scene gate does
    // a version of this on raw pixels, where two scenes that are the same image
    // in different colours pass comfortably; here colour is not counted, so a
    // structural twin is visible as one.
    //
    // Twins are reported rather than failed on a fixed distance: what matters is
    // that the library does not contain a CLUSTER of scenes that are one look
    // wearing several names, because the casting code will happily "vary" a
    // video by rotating between them.
    const sweep = librarySweepCached();
    const bigTwins = sweep.twins.filter((g) => g.length >= 3);

    return expect(bigTwins.length === 0,
        (bigTwins.length ? `structural twin groups: ${bigTwins.map((g) => g.join('≈')).join(' · ')} — ` : '') +
        `${sweep.scenes.length} scenes · median distance ${sweep.median.toFixed(3)} · ` +
        `closest ${sweep.closestPairs[0].name}≈${sweep.closestPairs[0].nearest} ` +
        `at ${sweep.closestPairs[0].distance.toFixed(3)}`);
}, { slow: true });

check(12, 'seed variety · the generator casts a different show for a different seed', () => {
    // Cheap, GPU-free, and the first thing to read when the rendered score
    // drops: this says whether the generator ever DECIDED to make two different
    // videos, before asking whether the pixels came out different.
    const d = measureSpecDiversity(varietyTrack(), { seeds: 24 });
    const problems = [];
    if (d.sceneSetDistance < 0.6) problems.push(`scene sets only ${d.sceneSetDistance.toFixed(2)} apart`);
    if (d.identicalCasts > 0) problems.push(`${d.identicalCasts} seed pairs cast identically`);
    if (d.libraryCoverage < 0.6) {
        problems.push(`only ${(d.libraryCoverage * 100).toFixed(0)}% of the library used ` +
            `(never cast: ${d.uncast.slice(0, 6).join(', ')})`);
    }
    if (d.director.unique < 2) problems.push('one director for every seed');

    return expect(problems.length === 0,
        problems.length ? problems.join(' · ')
            : `cast distance ${d.sceneSetDistance.toFixed(2)} · ` +
              `library ${(d.libraryCoverage * 100).toFixed(0)}% · ` +
              `${d.director.unique} directors · ${d.paletteScheme.unique} schemes · ` +
              `${d.signature.unique} signatures · ${d.grain.unique} grain modes`);
});

check(12, 'seed variety · different seeds render structurally different videos', () => {
    // THE gate. The threshold is a target the generator does not currently meet
    // — a failure here is the known open defect, not a flaky check. The detail
    // line carries the full breakdown so a run can be compared against the last
    // one while the number is being moved.
    const r = measureVariety(varietyTrack(), { seeds: 6, refScenes: 4, probes: 4 });
    const problems = [];

    // Against the floor: two seeds must differ by more than one seed differs
    // from itself across its own sections. Below this the seed is decoration.
    if (r.separation < 0.35) {
        problems.push(`separation ${r.separation.toFixed(2)} (floor ${r.floor.toFixed(3)}, ` +
            `observed ${r.observed.toFixed(3)}, ceiling ${r.ceiling.toFixed(3)})`);
    }
    // No seed may be shadowed by another. A healthy mean hides pairs that are
    // the same video, and a viewer only ever sees the pair.
    const shadowed = r.nearest.filter((d) => d < r.floor * 0.75).length;
    if (shadowed) problems.push(`${shadowed} seeds shadowed by another seed`);

    // Per block: it is not enough for the total to pass on colour-adjacent
    // motion while every frame is composed the same way.
    for (const [name, b] of Object.entries(r.byBlock)) {
        if (name === 'colour') continue;
        if (b.ratio < 0.25) problems.push(`${name} at ${(b.ratio * 100).toFixed(0)}% of achievable`);
    }

    const blocks = Object.entries(r.byBlock)
        .map(([n, b]) => `${n} ${(b.ratio * 100).toFixed(0)}%`).join(' · ');
    return expect(problems.length === 0,
        (problems.length ? problems.join(' · ') + ' — ' : '') +
        `separation ${r.separation.toFixed(2)} · ${blocks}`);
}, { slow: true });


/**
 * The metadata is measured from the code that is actually here.
 *
 * scenes/metadata.json is a cache of a render — how much frame each scene
 * paints, how much it changes between songs, what it looks like structurally —
 * and the generator composes with it: which scenes may ground a section, and
 * which pairs are unalike enough to be worth stacking. A stale file therefore
 * does not produce a stale REPORT, it produces wrong videos, silently.
 *
 * So the file carries a fingerprint of everything that can move a number in it
 * — the scenes, the shader contract, the identities and palettes they are
 * handed, and the metric definitions — and this fails when it stops matching.
 * The fix is not code: open gallery.html and press "refresh metadata".
 */
check(12, 'metadata · the measurements match the code that produced them', () => {
    const fresh = metadataIsFresh();
    const rows = Object.keys(METADATA.scenes || {}).length;
    const unmeasured = scenes
        .filter((m) => m.kind === 'fragment' && !isMeasured(m))
        .map((m) => m.name);

    return expect(fresh && unmeasured.length === 0,
        (fresh ? '' : `stale: measured against ${METADATA.fingerprint}, code is ${metricsFingerprint()} — ` +
            'rebuild it from gallery.html → refresh metadata · ') +
        (unmeasured.length ? `never measured: ${unmeasured.slice(0, 5).join(', ')} · ` : '') +
        `${rows} scenes measured ${METADATA.measured} · ` +
        `${scenes.filter(canGround).length} can ground a section`);
});

/**
 * The labels the measurements produce are usable ones.
 *
 * Not a re-measurement — the check above covers staleness. This asks whether
 * the library, as labelled, can still cast a video: every section needs a
 * ground, quiet sections are held to the restful families, so a threshold that
 * left one ground in the whole library would pass every other gate here and
 * make every video identical underneath.
 */
check(12, 'metadata · the measured labels leave enough grounds to cast with', () => {
    const grounds = scenes.filter(canGround);
    const byFamily = {};
    for (const m of grounds) byFamily[m.family] = (byFamily[m.family] || 0) + 1;
    const restful = RESTFUL_FAMILIES.reduce((n, f) => n + (byFamily[f] || 0), 0);

    const problems = [];
    // Low bars, and they are where the library actually is rather than where it
    // ought to be. Seven scenes can ground a section — five geometric, two
    // organic — and the two organic ones carry every quiet section of every
    // video, because no minimal or flow canvas in the library fills half the
    // frame without reading prev(). That is the library's largest hole and it
    // is scene work, not generator work. The numbers are printed on every run
    // so it stays visible instead of becoming the way things are.
    if (grounds.length < 6) problems.push(`only ${grounds.length} grounds in the library`);
    if (restful < 2) problems.push(`only ${restful} restful grounds for quiet sections`);

    return expect(problems.length === 0,
        (problems.length ? problems.join(' · ') + ' — ' : '') +
        `${grounds.length} grounds · ` +
        Object.entries(byFamily).map(([f, n]) => `${f} ${n}`).join(' · '));
});

/**
 * COMPOSITION — what every section's frame is made of.
 *
 * Two rules, and they are the two ends of the same idea: a section always has a
 * filled picture underneath it, and it never stacks up more than two frames'
 * worth of material on top of that. Between them they rule out both failures
 * the layer stack can produce — a shot floating on black, and four passes of
 * texture over each other.
 *
 * Measured across the song bank rather than one track, because both rules are
 * decided per director, per kind and per point in the story, and a single song
 * exercises one director's opinion about six sections.
 */
check(12, 'composition · every section stands on a canvas, under a budget', () => {
    const problems = [];
    const budgets = [];
    let stacks = 0;
    let grounded = 0;

    for (const name of ['centre', 'drone', 'lattice']) {
        const track = song(name).track;
        for (let s = 0; s < 4; s++) {
            const look = generateLook(track, { seed: 4000 + s * 7717 });
            for (const section of look.sections) {
                for (const stack of section.variants || [section.layers]) {
                    stacks++;
                    const base = stack[0];
                    const cover = declaredCoverage(base.module);
                    if (surfaceOf(base.module) !== 'canvas' || cover < GROUND_MIN) {
                        problems.push(`${section.kind} stands on ${base.module.name} at ${(cover * 100).toFixed(0)}%`);
                    } else {
                        grounded++;
                    }
                    if (base.opacity < 1 || base.blend !== 'normal') {
                        problems.push(`${section.kind} ground ${base.module.name} is ${base.blend} at ${base.opacity.toFixed(2)}`);
                    }
                    const total = stackCoverage(stack);
                    budgets.push(total);
                    if (total > 2.0001) {
                        problems.push(`${section.kind} paints ${(total * 100).toFixed(0)}% — over budget`);
                    }
                }
            }
        }
    }

    const mean = budgets.reduce((a, b) => a + b, 0) / Math.max(1, budgets.length);
    const max = Math.max(...budgets);
    return expect(problems.length === 0,
        (problems.length ? problems.slice(0, 4).join(' · ') + ' — ' : '') +
        `${grounded}/${stacks} grounded · coverage mean ${(mean * 100).toFixed(0)}% ` +
        `· peak ${(max * 100).toFixed(0)}% of the 200% ceiling`);
});

/**
 * The rendered frame, at BOTH ends.
 *
 * The spec-level check above says the generator intended a filled frame. This
 * renders the middle of every section of twelve videos and looks at what came
 * out, which is the only statement that matters to someone watching.
 *
 * Two failures, and they are opposites that arrive by the same route:
 *
 *   too black   a section that is a few bright things on nothing. Measured at
 *               9 of 40 sampled frames under 20% painted before grounds, the
 *               darkest at 0.3%.
 *   too white   a section clipped to paper. Measured at a median 24% of pixels
 *               at full white and entire sections at 100% when the shot was
 *               screened over its ground and the feedback loop was still an
 *               accumulator. Clipped white is not brightness, it is missing
 *               information: every difference inside it has been deleted.
 *
 * `ink` is counted at a low threshold on purpose — the question is whether the
 * frame has anything IN it, not whether it is bright, and a legitimately dark
 * scene is not a failure. `blown` is counted at near-full white on all three
 * channels, which no grade should produce over a quarter of a frame.
 */
check(12, 'composition · a rendered section is neither black nor blown out', () => {
    const dark = [];
    const white = [];
    const ink = [];
    const blown = [];
    const lum = [];

    for (const name of ['centre', 'drone', 'lattice', 'glare', 'murk', 'runner']) {
        const track = song(name).track;
        for (const seed of [4001, 9931]) {
            const show = new Show({ width: 160, height: 90 });
            try {
                show.useTrack(track, generateLook(track, { seed }));
                for (const section of show.look.sections) {
                    const f = Math.round(section.startFrame
                        + (section.endFrame - section.startFrame) * 0.5);
                    show.engine.compositor.reset();
                    for (let k = Math.max(0, f - 12); k < f; k++) show.renderFrame(k);
                    const px = Uint8Array.from(show.readPixels(show.renderFrame(f)));

                    let inked = 0, clipped = 0, light = 0;
                    const n = px.length / 4;
                    for (let i = 0; i < px.length; i += 4) {
                        const r = px[i], g = px[i + 1], b = px[i + 2];
                        const l = 0.2126 * r + 0.7152 * g + 0.0722 * b;
                        if (l > 13) inked++;
                        if (r > 238 && g > 238 && b > 238) clipped++;
                        light += l;
                    }
                    const where = `${name}/${seed.toString(16)} ${section.kind}`;
                    ink.push(inked / n);
                    blown.push(clipped / n);
                    lum.push(light / n / 255);
                    if (inked / n < 0.3) dark.push(`${where} only ${((inked / n) * 100).toFixed(0)}% painted`);
                    // Per section: the hard cap only. A deliberate blaze is
                    // allowed to be bright; nothing is allowed to be gone.
                    if (clipped / n > 0.5) white.push(`${where} ${((clipped / n) * 100).toFixed(0)}% clipped white`);
                    if (light / n / 255 > 0.9) white.push(`${where} mean luminance ${((light / n / 255) * 100).toFixed(0)}%`);
                }
            } finally {
                show.dispose();
            }
        }
    }

    // A BLAZE is allowed; blazing by default is not.
    //
    // The ceiling is therefore two numbers rather than one. No single section
    // may be wholly gone — past about half the frame at full white there is no
    // picture left to read — and only a minority of them may be hot at all. The
    // second is the one that matters: screening every shot over its ground blew
    // a median quarter of EVERY frame, which is not a director choosing to peak,
    // it is a pipeline with no headroom. See blazeOf in look/directors.js.
    const hot = blown.filter((b) => b > 0.25).length;
    const hotShare = hot / Math.max(1, blown.length);
    if (hotShare > 0.2) {
        white.push(`${(hotShare * 100).toFixed(0)}% of sections clipped past 25% — blazing is the default, not a choice`);
    }

    const problems = [...dark, ...white];
    const mean = (a) => a.reduce((x, y) => x + y, 0) / Math.max(1, a.length);
    return expect(problems.length === 0,
        (problems.length ? problems.slice(0, 4).join(' · ') + ' — ' : '') +
        `${ink.length} sections · painted mean ${(mean(ink) * 100).toFixed(0)}% ` +
        `darkest ${(Math.min(...ink) * 100).toFixed(0)}% · ` +
        `clipped mean ${(mean(blown) * 100).toFixed(0)}% worst ${(Math.max(...blown) * 100).toFixed(0)}% · ` +
        `${hot} of ${blown.length} sections blazing`);
}, { slow: true });

/**
 * A stack is two things happening, not one thing twice.
 *
 * The point of the measured profiles: family labels say a `flow` scene and an
 * `organic` scene are different, and the render can disagree — two of them can
 * sit 0.04 apart, which stacked is one texture at double density. This asks
 * whether the generator's stacks are actually made of unalike material.
 *
 * A mean rather than a per-stack floor, because a lean is what the generator
 * applies. Demanding every pair clear a bar would be demanding a particular
 * draw, and the seed is supposed to be able to make an ordinary choice.
 */
check(12, 'composition · stacked layers are structurally unalike', () => {
    const pairs = [];
    for (const name of ['centre', 'drone', 'lattice']) {
        const track = song(name).track;
        for (let s = 0; s < 4; s++) {
            const look = generateLook(track, { seed: 5200 + s * 3931 });
            for (const section of look.sections) {
                for (const stack of section.variants || [section.layers]) {
                    for (let i = 0; i < stack.length; i++) {
                        for (let j = i + 1; j < stack.length; j++) {
                            const d = structuralDistance(stack[i].module, stack[j].module);
                            if (d !== null) pairs.push(d);
                        }
                    }
                }
            }
        }
    }

    const mean = pairs.reduce((a, b) => a + b, 0) / Math.max(1, pairs.length);
    const twins = pairs.filter((d) => d < 0.03).length;
    // Against the library's own median distance, so this measures the CHOOSING
    // rather than the library — a bar in absolute units would drift every time
    // a scene was added.
    return expect(pairs.length > 0 && mean > 0.08 && twins / pairs.length < 0.05,
        `${pairs.length} stacked pairs · mean distance ${mean.toFixed(3)} · ` +
        `${twins} near-twins (${((twins / Math.max(1, pairs.length)) * 100).toFixed(0)}%)`);
});
