import { test } from 'node:test';
import assert from 'node:assert/strict';

import { FFT, hannWindow } from '../src/audio/fft.js';
import { analyzeBuffer, RAW_FEATURES } from '../src/audio/analyze.js';
import { detectTempo, buildBeatTracks } from '../src/audio/tempo.js';
import { segment } from '../src/audio/segment.js';
import { synthesizeBeat, synthesizeSectioned, synthesizeSilence } from '../src/audio/synth.js';

const FPS = 60;

test('FFT resolves a pure tone into the right bin', () => {
    const size = 2048;
    const sampleRate = 44100;
    const fft = new FFT(size);
    const window = hannWindow(size);
    const freq = 1000;

    const input = new Float32Array(size);
    for (let i = 0; i < size; i++) {
        input[i] = Math.sin((2 * Math.PI * freq * i) / sampleRate) * window[i];
    }
    const mag = fft.forward(input);

    let peakBin = 0;
    for (let i = 1; i < mag.length; i++) if (mag[i] > mag[peakBin]) peakBin = i;
    const peakHz = (peakBin * sampleRate) / size;
    assert.ok(Math.abs(peakHz - freq) < sampleRate / size,
        `peak at ${peakHz.toFixed(1)}Hz, expected ~${freq}Hz`);
});

test('analysis produces finite, in-range features', () => {
    const buffer = synthesizeBeat({ bpm: 128, duration: 20 });
    const analysis = analyzeBuffer(buffer, { fps: FPS });

    assert.equal(analysis.frameCount, Math.ceil(buffer.duration * FPS));

    for (const name of RAW_FEATURES) {
        const arr = analysis.raw[name];
        assert.ok(arr, `missing feature ${name}`);
        for (let i = 0; i < arr.length; i++) {
            assert.ok(Number.isFinite(arr[i]), `${name}[${i}] is not finite`);
            assert.ok(arr[i] >= 0 && arr[i] <= 1, `${name}[${i}] = ${arr[i]} out of range`);
        }
    }
});

test('no feature is constant or pinned on real-ish material', () => {
    // A feature stuck at 0 or 1 contributes nothing to the visuals and usually
    // means a wrong band split — the thing the feature scope exists to catch.
    const buffer = synthesizeBeat({ bpm: 124, duration: 30 });
    const analysis = analyzeBuffer(buffer, { fps: FPS });

    for (const name of ['loudness', 'bandSub', 'bandLow', 'bandMid', 'flux', 'centroid']) {
        const arr = analysis.raw[name];
        let min = Infinity, max = -Infinity, sum = 0;
        for (let i = 0; i < arr.length; i++) {
            min = Math.min(min, arr[i]);
            max = Math.max(max, arr[i]);
            sum += arr[i];
        }
        const spread = max - min;
        assert.ok(spread > 0.15, `${name} spread only ${spread.toFixed(3)} — feature is inert`);
        const mean = sum / arr.length;
        assert.ok(mean > 0.005 && mean < 0.995, `${name} mean ${mean.toFixed(3)} is pinned`);
    }
});

test('tempo detection lands on the true BPM', () => {
    for (const bpm of [90, 110, 124, 128, 140, 174]) {
        const buffer = synthesizeBeat({ bpm, duration: 40 });
        const analysis = analyzeBuffer(buffer, { fps: FPS });
        const tempo = detectTempo(analysis.onsetEnvelope, FPS);

        const error = Math.abs(tempo.bpm - bpm) / bpm;
        assert.ok(error < 0.02,
            `bpm ${tempo.bpm.toFixed(2)} vs true ${bpm} (${(error * 100).toFixed(2)}% off)`);
    }
});

test('beat grid aligns with the true beat positions', () => {
    const bpm = 128;
    const buffer = synthesizeBeat({ bpm, duration: 40 });
    const analysis = analyzeBuffer(buffer, { fps: FPS });
    const tempo = detectTempo(analysis.onsetEnvelope, FPS);

    const trueBeat = 60 / bpm;
    let worst = 0;
    for (const t of tempo.beats) {
        if (t > 30) break;
        const nearest = Math.round(t / trueBeat) * trueBeat;
        worst = Math.max(worst, Math.abs(t - nearest));
    }
    // Within half a video frame: any more would be visible as the visuals
    // sitting off the beat.
    assert.ok(worst < 0.5 / FPS, `worst beat offset ${(worst * 1000).toFixed(1)}ms`);
});

test('downbeats land on the accented beat', () => {
    const bpm = 128;
    const buffer = synthesizeBeat({ bpm, duration: 40 });
    const analysis = analyzeBuffer(buffer, { fps: FPS });
    const tempo = detectTempo(analysis.onsetEnvelope, FPS);

    const trueBar = (60 / bpm) * 4;
    let aligned = 0;
    for (const t of tempo.downbeats) {
        const offset = Math.abs(t / trueBar - Math.round(t / trueBar));
        if (offset < 0.05) aligned++;
    }
    assert.ok(aligned / tempo.downbeats.length > 0.9,
        `only ${aligned}/${tempo.downbeats.length} downbeats on the bar line`);
});

test('beat tracks are bounded and phase-continuous', () => {
    const buffer = synthesizeBeat({ bpm: 128, duration: 20 });
    const analysis = analyzeBuffer(buffer, { fps: FPS });
    const tempo = detectTempo(analysis.onsetEnvelope, FPS);
    const tracks = buildBeatTracks(tempo, analysis.frameCount, FPS, analysis.raw.loudness);

    for (const name of ['beat', 'beatPhase', 'barPhase', 'phrasePhase']) {
        const arr = tracks[name];
        for (let i = 0; i < arr.length; i++) {
            assert.ok(Number.isFinite(arr[i]) && arr[i] >= 0 && arr[i] <= 1,
                `${name}[${i}] = ${arr[i]}`);
        }
    }
    // Phase must wrap exactly once per beat, never jump mid-beat.
    let wraps = 0;
    for (let i = 1; i < tracks.beatPhase.length; i++) {
        if (tracks.beatPhase[i] < tracks.beatPhase[i - 1]) wraps++;
    }
    const expected = 20 / (60 / 128);
    assert.ok(Math.abs(wraps - expected) <= 2, `${wraps} phase wraps, expected ~${expected.toFixed(0)}`);
});

test('segmentation finds a real structural boundary', () => {
    const changeAt = 60;
    const buffer = synthesizeSectioned({ bpm: 128, duration: 120, changeAt });
    const analysis = analyzeBuffer(buffer, { fps: FPS });
    const tempo = detectTempo(analysis.onsetEnvelope, FPS);
    const sections = segment(analysis.raw, analysis.frameCount, FPS, tempo);

    assert.ok(sections.length >= 2, `expected at least 2 sections, got ${sections.length}`);

    const boundaries = sections.slice(1).map((s) => s.start);
    const nearest = boundaries.reduce(
        (best, b) => (Math.abs(b - changeAt) < Math.abs(best - changeAt) ? b : best),
        Infinity,
    );
    assert.ok(Math.abs(nearest - changeAt) <= 2.0,
        `nearest boundary ${nearest.toFixed(2)}s, true change at ${changeAt}s`);
});

test('sections tile the track with no gaps or overlaps', () => {
    const buffer = synthesizeSectioned({ duration: 120, changeAt: 60 });
    const analysis = analyzeBuffer(buffer, { fps: FPS });
    const tempo = detectTempo(analysis.onsetEnvelope, FPS);
    const sections = segment(analysis.raw, analysis.frameCount, FPS, tempo);

    assert.equal(sections[0].start, 0);
    for (let i = 1; i < sections.length; i++) {
        assert.equal(sections[i].start, sections[i - 1].end, `gap before section ${i}`);
    }
    assert.ok(Math.abs(sections[sections.length - 1].end - analysis.duration) < 0.05);
    for (const s of sections) {
        assert.ok(s.duration > 0, `section ${s.index} has non-positive duration`);
        assert.ok(['intro', 'build', 'drop', 'sustain', 'breakdown', 'outro'].includes(s.kind),
            `bad kind ${s.kind}`);
    }
});

test('silence degrades gracefully instead of producing NaN', () => {
    const buffer = synthesizeSilence({ duration: 10 });
    const analysis = analyzeBuffer(buffer, { fps: FPS });
    for (const name of RAW_FEATURES) {
        for (const v of analysis.raw[name]) assert.ok(Number.isFinite(v), `${name} not finite on silence`);
    }
    const tempo = detectTempo(analysis.onsetEnvelope, FPS);
    assert.ok(Number.isFinite(tempo.bpm), 'bpm not finite on silence');
    const sections = segment(analysis.raw, analysis.frameCount, FPS, tempo);
    assert.ok(sections.length >= 1, 'no sections produced for silence');
});

test('analysis of a six-minute track completes within budget', () => {
    const buffer = synthesizeBeat({ bpm: 128, duration: 360, hats: true, pad: true });
    const started = Date.now();
    const analysis = analyzeBuffer(buffer, { fps: FPS });
    detectTempo(analysis.onsetEnvelope, FPS);
    const elapsed = (Date.now() - started) / 1000;
    // The plan's budget is ~3s. Node is a fair proxy for browser JS here.
    assert.ok(elapsed < 6, `analysis took ${elapsed.toFixed(2)}s`);
    console.log(`      (6-minute analysis: ${elapsed.toFixed(2)}s)`);
});
