// The variety report as text.
//
// Kept apart from the measurement so the gate and the diagnostic read the same
// numbers. The layout is chosen so the first three lines answer "is this bad",
// and everything below answers "where did the variety go" — which is the only
// question worth printing a table for.

import { song } from '../../audio/songbank.js';
import {
    measureVariety, measureSpecDiversity, librarySweep,
    measureSongVariety, measureFingerprint, measureDecomposition,
} from './report.js';
import { generateLook, describeLook } from '../../look/LookGenerator.js';

const bar = (v, width = 24) => {
    const n = Math.max(0, Math.min(width, Math.round(v * width)));
    return '█'.repeat(n) + '·'.repeat(width - n);
};

const pct = (v) => `${(v * 100).toFixed(0)}%`.padStart(4);

export async function varietyReportLines({ seeds = 8, probes = 5, library = true } = {}) {
    // A real bank entry, not the two-section synthetic this used to run on.
    // That one segmented into intro and outro only, and quiet kinds are
    // restricted to the restful families for every director — a third of the
    // library was unreachable and the score was measuring that, not the seed.
    const track = song('centre').track;

    const lines = [];
    const spec = measureSpecDiversity(track, { seeds: Math.max(seeds, 24) });

    // Yield so the "measuring…" line paints before the GPU work blocks.
    await new Promise((r) => setTimeout(r, 0));
    const r = measureVariety(track, { seeds, refScenes: 5, probes });

    const ok = r.ceilingValid && r.separation >= 0.35;
    const headline = r.ceilingValid
        ? `seed separation ${r.separation.toFixed(2)} (${ok ? 'acceptable' : 'TOO LOW'}) · ` +
          `${pct(r.identity)} of what the library can express`
        : `observed ${r.observed.toFixed(3)} vs floor ${r.floor.toFixed(3)} · ` +
          'reference below the floor, separation not computable';

    lines.push('SEED VARIETY — one track, one analysis, only the seed changes');
    lines.push('');
    lines.push(`  floor    ${r.floor.toFixed(4)}   one video against itself, across its own sections`);
    lines.push(`  observed ${r.observed.toFixed(4)}   two seeds against each other`);
    lines.push(`  ceiling  ${r.ceiling.toFixed(4)}   same pipeline, casts that share no scenes at all`);
    lines.push('');
    lines.push(r.ceilingValid
        ? `  separation  ${bar(r.separation)}  ${r.separation.toFixed(2)}`
        : '  separation  —  not computable: the reference landed below the floor,\n' +
          '              which means real seeds already differ by more than videos\n' +
          '              built from casts that share no scenes.');
    lines.push('              0 = the seed changes nothing a viewer could name');
    lines.push('              1 = two seeds as unalike as two randomly assembled videos');
    lines.push('');
    lines.push('WHERE THE VARIETY IS — per structural block, as a fraction of achievable');
    lines.push('');
    for (const [name, b] of Object.entries(r.byBlock)) {
        const note = name === 'colour' ? '  (not counted — this is the axis that lies)' : '';
        lines.push(`  ${name.padEnd(8)} ${bar(b.ratio)}  ${pct(b.ratio)}` +
            `   ${b.between.toFixed(3)} of ${b.ceiling.toFixed(3)}${note}`);
    }
    lines.push('');
    lines.push('    scale    feature size — fine texture vs large soft forms');
    lines.push('    orient   grid vs radial vs stripes, measured rotation-blind');
    lines.push('    layout   where in the frame the structure sits');
    lines.push('    texture  element count, sparsity, mirror and radial symmetry');
    lines.push('    motion   what moves and where, not how much');
    lines.push('');

    lines.push('CLOSEST SIBLING PER SEED — a seed below the floor is a duplicate video');
    lines.push('');
    r.nearest.forEach((d, i) => {
        const flag = d < r.floor * 0.75 ? '  ← shadowed' : d < r.floor ? '  ← thin' : '';
        lines.push(`  seed ${r.seeds[i].toString(16).padStart(8, '0')}  ${bar(d / Math.max(r.ceiling, 1e-6))}  ${d.toFixed(3)}${flag}`);
    });
    if (r.worstPair) {
        const w = r.worstPair;
        const blocks = Object.entries(w.byBlock)
            .map(([n, v]) => `${n} ${v.toFixed(3)}`).join(' · ');
        lines.push('');
        lines.push(`  closest pair: seeds ${w.a} and ${w.b} at ${w.distance.toFixed(3)} — ${blocks}`);
    }
    lines.push('');

    lines.push('THE DECISIONS BEHIND IT — spec-level, no rendering');
    lines.push('  (high here + low above = the generator decides freely and the render flattens it;');
    lines.push('   low here = casting is the bottleneck, and shader work will not fix it)');
    lines.push('');
    lines.push(`  scene-set distance   ${bar(spec.sceneSetDistance)}  ${spec.sceneSetDistance.toFixed(2)}   how differently ${spec.seeds} seeds cast`);
    lines.push(`  library coverage     ${bar(spec.libraryCoverage)}  ${pct(spec.libraryCoverage)}   of non-accent scenes ever chosen`);
    lines.push(`  identical casts      ${spec.identicalCasts} seed pairs`);
    for (const key of ['director', 'paletteScheme', 'signature', 'grain', 'framing', 'paletteArc', 'anchorScenes']) {
        const e = spec[key];
        lines.push(`  ${key.padEnd(20)} ${bar(e.normalized)}  ${String(e.unique).padStart(3)} distinct  (entropy ${e.entropy.toFixed(2)} bits)`);
    }
    lines.push('');

    if (library) {
        // The map the whole test is drawn on: a seed cannot reach more variety
        // than the library holds, so a low separation is only the generator's
        // fault once the library is known to hold distinct looks.
        const sweep = librarySweep(track, { probes: 3 });
        lines.push('THE LIBRARY — all ' + sweep.scenes.length + ' visualizations, every pair, colour not counted');
        lines.push('');
        lines.push(`  median pair distance  ${sweep.median.toFixed(3)}   ` +
            `(seed test ceiling for reference: ${r.ceiling.toFixed(3)})`);
        lines.push(`  closest 5% under      ${sweep.p05.toFixed(3)}`);
        lines.push('');
        lines.push(`  structural twins — every pair inside a group is under ${sweep.twinAt.toFixed(3)}:`);
        if (sweep.twins.length === 0) {
            lines.push('    no group of three or more; see the closest pairs below');
        } else {
            for (const group of sweep.twins) lines.push(`    ${group.join('  ≈  ')}`);
        }
        lines.push('');
        lines.push('  closest pairs in the library:');
        for (const p of sweep.closestPairs) {
            lines.push(`    ${p.distance.toFixed(3)}  ${p.name.padEnd(22)} ≈ ${p.nearest}   (${p.family})`);
        }
        lines.push('');
        if (spec.uncast.length) {
            lines.push(`  never cast in ${spec.seeds} seeds: ${spec.uncast.join(', ')}`);
            lines.push('');
        }
    }

    lines.push('SAMPLE LOOKS');
    lines.push('');
    for (const seed of r.seeds.slice(0, 6)) {
        lines.push(`  ${describeLook(generateLook(track, { seed }))}`);
    }

    return { lines, ok, headline };
}


/**
 * The SONG variety report.
 *
 * Deliberately laid out in the order the questions have to be answered. Are two
 * songs different at all; is that difference DERIVED from the music or merely
 * random; and what does every output have in common regardless — which is the
 * one that speaks to "you can tell what made it".
 */
export async function songVarietyReportLines({ songs = 6, probes = 5 } = {}) {
    const lines = [];
    await new Promise((r) => setTimeout(r, 0));
    const r = measureSongVariety({ songs, probes });
    const f = measureFingerprint({ songs: Math.min(songs, 5), probes: 3 });

    const ok = r.ceilingValid && r.separation >= 0.45 && r.coupling >= 0.3;
    const headline = (r.ceilingValid ? `song separation ${r.separation.toFixed(2)}` :
        `observed ${r.observed.toFixed(3)} vs floor ${r.floor.toFixed(3)} (no valid ceiling)`) +
        ` · coupling ${r.coupling.toFixed(2)} (${ok ? 'acceptable' : 'TOO LOW'})`;

    lines.push('SONG VARIETY — different songs, each with its own audio-derived seed');
    lines.push('');
    for (const b of r.bank) lines.push(`  ${b.name.padEnd(9)} ${b.kinds.join(' ')}`);
    lines.push('');
    lines.push(`  floor    ${r.floor.toFixed(4)}   one video against itself, across its own sections`);
    lines.push(`  observed ${r.observed.toFixed(4)}   two songs against each other`);
    lines.push(`  ceiling  ${r.ceiling.toFixed(4)}   same pipeline, casts that share no scenes at all`);
    lines.push('');
    lines.push(r.ceilingValid
        ? `  separation  ${bar(r.separation)}  ${r.separation.toFixed(2)}`
        : '  separation  —  not computable: reference below the floor');
    lines.push(`  coupling    ${bar(Math.max(0, r.coupling))}  ${r.coupling.toFixed(2)}`);
    lines.push('              coupling is how strongly musical distance predicts visual distance.');
    lines.push('              near 0 means the look is unrelated to the song — separation without');
    lines.push('              it is not variety, it is noise with a different seed per file.');
    lines.push('');
    lines.push('WHERE THE VARIETY IS');
    lines.push('');
    for (const [name, b] of Object.entries(r.byBlock)) {
        const note = name === 'colour' ? '  (not counted)' : '';
        lines.push(`  ${name.padEnd(8)} ${bar(b.ratio)}  ${pct(b.ratio)}` +
            `   ${b.between.toFixed(3)} of ${b.ceiling.toFixed(3)}${note}`);
    }
    lines.push('');
    lines.push('CLOSEST SONG PAIRS — two songs that came out as one video');
    lines.push('');
    for (const p of r.pairs.slice(0, 6)) {
        lines.push(`  ${p.total.toFixed(3)}  ${p.a.padEnd(9)} ≈ ${p.b.padEnd(9)}` +
            `   (they sound ${p.musical < 0.25 ? 'alike' : p.musical > 0.5 ? 'nothing alike' : 'somewhat alike'}` +
            `, musical distance ${p.musical.toFixed(2)})`);
    }
    lines.push('');
    lines.push('THE HOUSE FINGERPRINT — what every output has in common');
    lines.push('  (variance across our videos as a fraction of variance across random ones;');
    lines.push('   a low number is a constant the generator imposes on everything it makes)');
    lines.push('');
    for (const t of f.tells) {
        lines.push(`  ${t.block.padEnd(8)} ${bar(Math.min(1, t.ratio))}  ${pct(Math.min(1, t.ratio))}` +
            `   ${t.frozen}/${t.dims} dimensions effectively frozen`);
    }
    lines.push('');

    return { lines, ok, headline };
}

/**
 * The Epic 3 experiment: does a video built from STAGES beat one built from
 * comparable legacy scenes?
 *
 * Same songs, same instrument, same number of scenes available — the only
 * difference is whether those scenes draw the song's cast in the song's ink, or
 * their own content. Three arms, because two would not distinguish "stages are
 * better" from "a small pool is better".
 */
export async function experimentReportLines({ songs = 6, probes = 4, repeats = 3 } = {}) {
    const { scenes } = await import('../../scenes/registry.js');
    const byName = (n) => scenes.find((m) => m.name === n);

    const arms = [
        {
            label: 'STAGES — draw the song\'s cast and ink',
            pool: ['Procession', 'Constellation', 'Soloist', 'Swarm'].map(byName),
        },
        {
            label: 'LEGACY — four comparable element-placing scenes',
            pool: ['Floating Geometry', 'Firefly Drift', 'Scale Mosaic', 'Metaballs'].map(byName),
        },
        {
            label: 'FULL — the unrestricted generator, for reference',
            pool: null,
        },
    ];

    const lines = [];
    lines.push('EPIC 3 EXPERIMENT — container vs content');
    lines.push('');
    lines.push('  Same songs, same instrument, same pool size. The stages own arrangement');
    lines.push('  and nothing else: what is on screen comes from the song. The legacy arm');
    lines.push('  is four scenes that each invent their own content.');
    lines.push('');
    lines.push('  Lower floor = a video that looks like itself over its own length.');
    lines.push('  Higher observed = two songs that look like different work.');
    lines.push('');

    // Repeats, with error bars. The first run of this comparison used seven
    // songs and put stages 14% ahead; at twelve songs the ordering flipped. A
    // difference that changes sign with the sample is a difference that has to
    // be reported with its spread or not at all.
    const results = [];
    for (const arm of arms) {
        const runs = [];
        for (let k = 0; k < repeats; k++) {
            await new Promise((r) => setTimeout(r, 0));
            runs.push(measureSongVariety({
                songs, probes, pool: arm.pool, seedSalt: k * 7919,
            }));
        }
        results.push({ arm, runs, r: runs[0] });
    }

    const mean = (a) => a.reduce((x, y) => x + y, 0) / a.length;
    const half = (a) => (Math.max(...a) - Math.min(...a)) / 2;

    lines.push(`  arm                                          floor  observed      spread (${repeats} runs)`);
    lines.push('  ' + '-'.repeat(80));
    for (const { arm, runs } of results) {
        const spreads = runs.map((x) => x.observed - x.floor);
        lines.push(`  ${arm.label.padEnd(44)}${mean(runs.map((x) => x.floor)).toFixed(4)}` +
            `    ${mean(runs.map((x) => x.observed)).toFixed(4)}` +
            `   ${mean(spreads) >= 0 ? '+' : ''}${mean(spreads).toFixed(4)} ±${half(spreads).toFixed(4)}`);
    }
    lines.push('');

    const gap = mean(results[0].runs.map((x) => x.observed - x.floor))
              - mean(results[1].runs.map((x) => x.observed - x.floor));
    const noise = Math.max(
        half(results[0].runs.map((x) => x.observed - x.floor)),
        half(results[1].runs.map((x) => x.observed - x.floor)));
    lines.push(`  stages minus legacy: ${gap >= 0 ? '+' : ''}${gap.toFixed(4)} against a noise band of ±${noise.toFixed(4)}`);
    lines.push(`  → ${Math.abs(gap) > noise * 2 ? 'a real difference' : 'INDISTINGUISHABLE at this sample size'}`);
    lines.push('');

    for (const { arm, runs, r } of results) {
        lines.push(`  ${arm.label}`);
        for (const [name] of Object.entries(r.byBlock)) {
            const v = runs.map((x) => x.byBlock[name].between);
            lines.push(`    ${name.padEnd(8)} between ${mean(v).toFixed(3)} ±${half(v).toFixed(3)}`);
        }
        const c = runs.map((x) => x.coupling);
        lines.push(`    coupling ${mean(c).toFixed(2)} ±${half(c).toFixed(2)}`);
        lines.push('');
    }

    const ok = gap > noise * 2;
    const headline = `stages ${gap >= 0 ? '+' : ''}${gap.toFixed(4)} vs legacy, noise ±${noise.toFixed(4)} — ` +
        (Math.abs(gap) > noise * 2 ? (gap > 0 ? 'the inversion helps' : 'the inversion hurts')
                                   : 'indistinguishable');
    return { lines, ok, headline };
}

/**
 * How big should a track's casting pool be?
 *
 * The Epic 3 experiment found the roster size, not the content sharing, was
 * carrying most of the improvement — so the number deserves to be measured
 * rather than picked. Reported as spread, because both ends of it matter: a
 * one-scene pool would score perfectly on the floor and be unwatchable.
 */
export async function poolSweepLines({ songs = 6, probes = 4, sizes = null, repeats = 1 } = {}) {
    const list = sizes || [3, 4, 6, 8, 12, 18, 24, 40];
    const lines = [];
    lines.push('CASTING POOL SWEEP — how many scenes one track may draw on');
    lines.push('');
    lines.push('  floor     one video against itself, across its own sections');
    lines.push('  observed  two songs against each other');
    lines.push('  spread    the gap between them — the thing worth maximising');
    lines.push('');
    lines.push('  pool    floor   observed    spread   ratio   distinct scenes/video');
    lines.push('  ' + '-'.repeat(66));

    const { generateLook } = await import('../../look/LookGenerator.js');
    const { song } = await import('../../audio/songbank.js');
    const track = song('centre').track;

    // Repeats exist because the first run of this sweep was pure noise: spread
    // was non-monotonic in pool size and peaked at the LARGEST pool, which is
    // the opposite of what the Epic 3 arms suggested. Each size draws a
    // different random pool per song, so a single run measures which scenes
    // happened to come up as much as it measures the size. Repeating with a
    // different draw and reporting the range is how to tell those apart.
    let best = null;
    for (const size of list) {
        await new Promise((r) => setTimeout(r, 0));
        const runs = [];
        for (let k = 0; k < repeats; k++) {
            const rk = measureSongVariety({
                songs, probes, poolSize: size, seedSalt: k * 7919,
            });
            runs.push(rk);
        }
        const r = runs[0];
        const spreads = runs.map((x) => x.observed - x.floor);
        const spread = spreads.reduce((a, b) => a + b, 0) / spreads.length;
        const range = repeats > 1
            ? `  ±${((Math.max(...spreads) - Math.min(...spreads)) / 2).toFixed(4)}` : '';

        // How many distinct scenes a video actually ends up showing, which is
        // the number a viewer experiences rather than the pool it was drawn from.
        let distinct = 0;
        for (let s = 0; s < 4; s++) {
            const look = generateLook(track, { seed: 900 + s * 7919, poolSize: size });
            distinct += new Set(look.sections.flatMap(
                (sec) => sec.variants.flatMap((v) => v.map((l) => l.module.name)))).size / 4;
        }

        lines.push(`  ${String(size).padStart(4)}   ${r.floor.toFixed(4)}    ${r.observed.toFixed(4)}` +
            `   ${spread >= 0 ? '+' : ''}${spread.toFixed(4)}   ${(r.observed / r.floor).toFixed(3)}` +
            `        ${distinct.toFixed(1)}${range}`);
        if (!best || spread > best.spread) best = { size, spread };
    }

    lines.push('');
    lines.push(`  widest spread at pool ${best.size} (+${best.spread.toFixed(4)})`);
    lines.push('');
    lines.push('  Read the whole column, not the winner. A very small pool wins this');
    lines.push('  metric by making every video repetitive, which the metric cannot see');
    lines.push('  and a viewer cannot miss — pick the knee, not the peak.');

    return { lines, ok: true, headline: `widest spread at pool ${best.size}` };
}


/**
 * Identity versus container, measured separately.
 *
 * The one experiment that says whether Epic 3 can work at all: hold the stage
 * fixed and vary only the song's identity, then hold the identity fixed and
 * vary only the stage.
 */
export async function decomposeReportLines({ songs = 6, probes = 3 } = {}) {
    await new Promise((r) => setTimeout(r, 0));
    const d = measureDecomposition({ songs, probes });

    const lines = [];
    lines.push('IDENTITY vs CONTAINER — where visual difference actually comes from');
    lines.push('');
    lines.push(`  ${d.songs} songs · one fixed track · ${d.stageCount} cast-consuming scenes`);
    lines.push('');
    lines.push(`  identity only   ${bar(Math.min(1, d.identityOnly * 5))}  ${d.identityOnly.toFixed(4)}`);
    lines.push(`      one stage (${d.stage}), each song's cast, ink and lattice`);
    lines.push('');
    lines.push(`  container only  ${bar(Math.min(1, d.containerOnly * 5))}  ${d.containerOnly.toFixed(4)}`);
    lines.push('      one identity, four different stages');
    lines.push('');
    lines.push(`  both            ${bar(Math.min(1, d.both * 5))}  ${d.both.toFixed(4)}`);
    lines.push('      what the generator actually does');
    lines.push('');
    lines.push(`  neither         ${bar(Math.min(1, d.sameBoth * 5))}  ${d.sameBoth.toFixed(4)}`);
    lines.push('      the same thing rendered twice — the instrument\'s own noise floor');
    lines.push('');

    const ratio = d.containerOnly > 1e-6 ? d.identityOnly / d.containerOnly : 0;
    lines.push(`  identity is worth ${(ratio * 100).toFixed(0)}% of what the container is worth.`);
    lines.push('');
    if (d.identityOnly < d.sameBoth * 3) {
        lines.push('  IDENTITY IS NOT REACHING THE FRAME. Swapping every design decision a song');
        lines.push('  makes moves the picture barely more than rendering the same thing twice.');
        lines.push('  More registers cannot help until this number moves.');
    } else if (ratio < 0.5) {
        lines.push('  Identity reaches the frame but the container still dominates. The stages');
        lines.push('  express what they are given only weakly — worth fixing the stages before');
        lines.push('  adding registers for them to ignore.');
    } else {
        lines.push('  Identity carries as much as the container does. The inversion is working');
        lines.push('  at the frame level, and the place to look next is whether the harness');
        lines.push('  aggregates it away.');
    }
    lines.push('');
    return { lines, ok: ratio >= 0.5, headline: `identity ${d.identityOnly.toFixed(4)} vs container ${d.containerOnly.toFixed(4)} (${(ratio * 100).toFixed(0)}%)` };
}
