// Build the test song bank: synthesize every song in src/audio/songbank.js,
// write it out as audio, and verify that the bank covers what it claims to.
//
// The bank is generated rather than stored. It is deterministic, it is a few
// hundred megabytes as audio, and it is derived from a table that will keep
// changing — all three say "rebuild it" rather than "commit it". The output
// directory is gitignored; this tool is the thing that is versioned.
//
//   node tools/build-song-bank.js                 → test/songs/, 120s each
//   node tools/build-song-bank.js --duration 60   → shorter, for a quick pass
//   node tools/build-song-bank.js --check         → verify coverage, write nothing
//   node tools/build-song-bank.js --out /tmp/bank
//
// Coverage is measured, never assumed. Each song declares the corner of the
// feature space it exists to occupy; this analyses what was actually produced
// and fails loudly if an axis has collapsed — a synth change that quietly stops
// producing noisy tracks would otherwise leave every downstream measurement
// looking fine while testing half the space.

import { writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { synthesizeSong } from '../src/audio/synth.js';
import { FeatureTrack } from '../src/audio/FeatureTrack.js';
import { SONGS, AXES } from '../src/audio/songbank.js';

const here = dirname(fileURLToPath(import.meta.url));

function arg(name, fallback = null) {
    const i = process.argv.indexOf(`--${name}`);
    return i >= 0 && process.argv[i + 1] && !process.argv[i + 1].startsWith('--')
        ? process.argv[i + 1] : fallback;
}
const has = (name) => process.argv.includes(`--${name}`);

const DURATION = Number(arg('duration', 120));
const OUT = resolve(arg('out', resolve(here, '../test/songs')));
const CHECK_ONLY = has('check');

/**
 * 16-bit PCM WAV, mono.
 *
 * Mono halves the size and loses nothing: the analyser sums to mono before it
 * does anything, and the synth writes the same signal to both channels anyway.
 */
function wav(samples, sampleRate) {
    const bytes = samples.length * 2;
    const buffer = Buffer.alloc(44 + bytes);
    buffer.write('RIFF', 0);
    buffer.writeUInt32LE(36 + bytes, 4);
    buffer.write('WAVE', 8);
    buffer.write('fmt ', 12);
    buffer.writeUInt32LE(16, 16);            // PCM chunk size
    buffer.writeUInt16LE(1, 20);             // PCM
    buffer.writeUInt16LE(1, 22);             // mono
    buffer.writeUInt32LE(sampleRate, 24);
    buffer.writeUInt32LE(sampleRate * 2, 28);
    buffer.writeUInt16LE(2, 32);             // block align
    buffer.writeUInt16LE(16, 34);            // bits
    buffer.write('data', 36);
    buffer.writeUInt32LE(bytes, 40);

    // Peak-normalise to -1 dBFS. Without it the quiet ambient entries clip down
    // into 16-bit noise and their measured flatness stops being what the spec
    // asked for — the bank would then be testing the encoder, not the generator.
    let peak = 0;
    for (let i = 0; i < samples.length; i++) peak = Math.max(peak, Math.abs(samples[i]));
    const gain = peak > 1e-9 ? 0.89 / peak : 1;

    for (let i = 0; i < samples.length; i++) {
        const v = Math.max(-1, Math.min(1, samples[i] * gain));
        buffer.writeInt16LE(Math.round(v * 32767), 44 + i * 2);
    }
    return buffer;
}

if (!CHECK_ONLY && !existsSync(OUT)) mkdirSync(OUT, { recursive: true });

console.log(`\nbuilding ${SONGS.length} songs · ${DURATION}s each` +
    (CHECK_ONLY ? ' · check only, writing nothing' : ` · → ${OUT}`) + '\n');

const built = [];
for (const spec of SONGS) {
    const buffer = synthesizeSong({
        bpm: spec.bpm,
        duration: DURATION,
        arrangement: spec.arrangement,
        brightness: spec.brightness,
        noise: spec.noise,
        noiseColour: spec.noiseColour ?? null,
        dynamics: spec.dynamics,
        transition: spec.transition ?? 0.5,
        seed: 1 + SONGS.indexOf(spec),
    });
    const track = FeatureTrack.fromAudioBuffer(buffer, { fps: 60 });

    if (!CHECK_ONLY) {
        writeFileSync(resolve(OUT, `${spec.name}.wav`),
            wav(buffer.getChannelData(0), buffer.sampleRate));
    }

    built.push({
        ...spec,
        file: `${spec.name}.wav`,
        measured: {
            bpm: track.summary.bpm,
            centroid: track.summary.meanCentroid,
            flatness: track.summary.meanFlatness,
            dynamics: track.summary.dynamicRange,
            loudness: track.summary.meanLoudness,
            sections: track.sections.length,
            kinds: track.sections.map((s) => s.kind),
        },
    });
    process.stdout.write(`  ${spec.name.padEnd(10)} ${built.at(-1).measured.kinds.join(' ')}\n`);
}

// --- what was actually produced -------------------------------------------
const n2 = (x) => (typeof x === 'number' ? x.toFixed(2) : String(x)).padStart(7);

console.log('\n  MEASURED — what the generator will actually read\n');
console.log('  song         tempo  bright   noisy dynamic    loud  sections  arrangement    cuts');
console.log('  ' + '-'.repeat(86));
for (const b of built) {
    console.log(`  ${b.name.padEnd(11)}${n2(b.measured.bpm)}${n2(b.measured.centroid)}` +
        `${n2(b.measured.flatness)}${n2(b.measured.dynamics)}${n2(b.measured.loudness)}` +
        `${String(b.measured.sections).padStart(10)}  ${b.arrangement.padEnd(9)}` +
        `  ${(b.transition ?? 0.5) >= 0.7 ? 'hard' : (b.transition ?? 0.5) <= 0.3 ? 'soft' : 'mid '}`);
}

// --- coverage --------------------------------------------------------------
// An axis is covered when the bank spreads across it AND reaches both ends. A
// bank can have a wide range and still be two clusters with a hole in the
// middle, so occupancy is measured in quintiles rather than as min-to-max.
console.log('\n  COVERAGE — measured, not claimed\n');
const problems = [];
for (const axis of AXES) {
    const values = built.map((b) => b.measured[axis.key === 'bpm' ? 'bpm' : axis.key]);
    const lo = Math.min(...values), hi = Math.max(...values);
    const [rLo, rHi] = axis.range;
    const bins = new Set(values.map(
        (v) => Math.max(0, Math.min(4, Math.floor((v - rLo) / (rHi - rLo) * 5)))));
    const filled = [0, 1, 2, 3, 4].map((i) => (bins.has(i) ? '█' : '·')).join('');
    const span = (hi - lo) / (rHi - rLo);

    console.log(`  ${axis.label.padEnd(15)} ${filled}  ${lo.toFixed(2)} → ${hi.toFixed(2)}` +
        `   spans ${(span * 100).toFixed(0)}% of ${rLo}–${rHi}`);
    console.log(`  ${''.padEnd(15)}        reads: ${axis.reads}`);
    if (bins.size < 3) problems.push(`${axis.label} occupies only ${bins.size}/5 of its range`);
}

// --- what the bank CANNOT decouple ----------------------------------------
// Spread on every axis is not the same as covering the space. Two axes that
// move together mean whole combinations are unrepresentable, and a downstream
// test would then attribute to the generator what is really a hole in the bank.
//
// One correlation here is a property of the ANALYSER and cannot be engineered
// away. Flatness is the geometric mean of the spectrum over its arithmetic
// mean, taken across the whole band, so a signal only measures flat if it has
// energy everywhere — which is the same thing as measuring bright. Adding a
// noise bed raises both, and band-limiting it to keep the centroid down empties
// the top of the spectrum and drops the flatness with it. Two attempts at
// decoupling (a steeper noise filter, then noise colour as its own axis) moved
// r from 0.95 to 0.94. Real material behaves the same way.
//
// So it is allowlisted with its reason rather than failed on, and anything NOT
// on the list still fails — the check is here to catch a bank that has
// collapsed, not to relitigate physics on every run.
console.log('\n  AXIS CORRELATION — |r| near 1 means the bank cannot vary these independently\n');
const corr = (a, b) => {
    const ma = a.reduce((x, y) => x + y, 0) / a.length;
    const mb = b.reduce((x, y) => x + y, 0) / b.length;
    let num = 0, da = 0, db = 0;
    for (let i = 0; i < a.length; i++) {
        num += (a[i] - ma) * (b[i] - mb);
        da += (a[i] - ma) ** 2;
        db += (b[i] - mb) ** 2;
    }
    return num / (Math.sqrt(da * db) || 1e-9);
};
const series = Object.fromEntries(AXES.map(
    (a) => [a.key, built.map((b) => b.measured[a.key])]));

const EXPECTED = {
    'brightness↔noisiness':
        'spectral flatness is measured across the whole band, so flat implies bright',
};
for (let i = 0; i < AXES.length; i++) {
    for (let j = i + 1; j < AXES.length; j++) {
        const r = corr(series[AXES[i].key], series[AXES[j].key]);
        if (Math.abs(r) < 0.6) continue;
        const key = `${AXES[i].label}↔${AXES[j].label}`;
        const expected = EXPECTED[key];
        console.log(`  ${AXES[i].label} ↔ ${AXES[j].label}: r = ${r.toFixed(2)}` +
            (expected ? `   expected — ${expected}` : Math.abs(r) > 0.85 ? '   ← effectively one axis' : ''));
        if (Math.abs(r) > 0.85 && !expected) {
            problems.push(`${AXES[i].label} and ${AXES[j].label} are collinear (r ${r.toFixed(2)})`);
        }
    }
}

// Transition hardness is a spec input rather than a summary statistic, so it is
// reported rather than binned: how abruptly one section becomes the next is a
// genre difference the bank has to contain — an ambient record dissolves where a
// club record cuts — and a bank where every song cut would be testing the
// generator against one kind of music.
const soft = built.filter((b) => (b.transition ?? 0.5) <= 0.3).length;
const hard = built.filter((b) => (b.transition ?? 0.5) >= 0.7).length;
console.log(`\n  transitions     ${soft} soft · ${built.length - soft - hard} mid · ${hard} hard`);
if (!soft || !hard) problems.push('the bank has no ' + (soft ? 'hard' : 'soft') + ' transitions');

// Section kinds are the casting gate, so coverage of them is not optional: a
// bank with no drop cannot reach the geometric, glitch or structural families
// no matter how widely its tempo spreads.
const kinds = new Map();
for (const b of built) for (const k of b.measured.kinds) kinds.set(k, (kinds.get(k) || 0) + 1);
const ALL_KINDS = ['intro', 'build', 'drop', 'sustain', 'breakdown', 'outro'];
console.log(`\n  section kinds   ${ALL_KINDS.map((k) => (kinds.get(k) ? '█' : '·')).join('')}  ` +
    ALL_KINDS.map((k) => `${k} ${kinds.get(k) || 0}`).join(' · '));
const missing = ALL_KINDS.filter((k) => !kinds.get(k));
if (missing.length) problems.push(`no song produces: ${missing.join(', ')}`);

if (!CHECK_ONLY) {
    writeFileSync(resolve(OUT, 'manifest.json'), JSON.stringify({
        built: new Date().toISOString(),
        duration: DURATION,
        songs: built,
    }, null, 2));
}

console.log('');
if (problems.length) {
    console.log('  GAPS');
    for (const p of problems) console.log(`    ${p}`);
    console.log('');
    process.exitCode = 1;
} else {
    console.log(`  all ${AXES.length} axes and all ${ALL_KINDS.length} section kinds covered`);
    if (!CHECK_ONLY) console.log(`  wrote ${built.length} wav files + manifest.json to ${OUT}`);
    console.log('');
}
