// Integration smoke test for the ML note detection addon path.
//
// Loads the built native addon directly (no Electron), verifies the Basic
// Pitch model loads through loadNoteModel(), that isMlNoteDetection() reports
// the ML path, that getPitchDetection()/scoreChord() keep their shapes, and
// that a bad model path fails soft to the YIN fallback (Constitution VII).
//
// Run:  node --test tests/ml-note-detection.test.js
// Skips cleanly when the addon hasn't been built yet (npm run build:audio).
//
// WAV→MIDI detection accuracy is covered separately by the Phase 0 spike
// (tests/spike/) which exercises the identical model + ONNX Runtime.

const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const fs = require('node:fs');

const ADDON = path.join(__dirname, '..', 'build', 'Release', 'slopsmith_audio.node');
const MODEL = path.join(__dirname, '..', 'resources', 'models', 'basic_pitch.onnx');

let audio = null;
try {
    audio = require(ADDON);
} catch (e) {
    if (!fs.existsSync(ADDON)) {
        // Addon genuinely not built yet — skip cleanly.
        test('ml-note-detection (skipped — addon not built)', { skip: true }, () => {});
    } else {
        // The addon file IS present but failed to load — a real staging
        // regression (e.g. a missing/incompatible ONNX Runtime library next
        // to it). Surface it as a failure so this smoke test catches it.
        test('ml-note-detection addon loads', () => { throw e; });
    }
}

if (audio) {
    test('ML note detection addon path', async (t) => {
        audio.init();
        t.after(() => { try { audio.shutdown(); } catch { /* ignore */ } });

        await t.test('loadNoteModel loads the bundled Basic Pitch model', () => {
            assert.equal(typeof audio.loadNoteModel, 'function',
                'addon exposes loadNoteModel');
            assert.ok(fs.existsSync(MODEL), `model present at ${MODEL}`);
            const ok = audio.loadNoteModel(MODEL);
            // ok is false if ONNX support was compiled out — tolerate that.
            assert.equal(typeof ok, 'boolean');
            // isMlNoteDetection() reports *readiness* — that the detector has
            // published an inference snapshot — not merely that a model
            // loaded. Readiness needs a running audio device, which this
            // smoke test never starts, so it is false here regardless of the
            // load result (and also false when ONNX is compiled out).
            assert.equal(audio.isMlNoteDetection(), false,
                'isMlNoteDetection is false until the detector has run inference');
        });

        await t.test('loadNoteModel fails soft on a missing file', () => {
            // loadNoteModel returns "is ML available after this call" (a model
            // is loaded with a valid contract). A missing file never throws
            // and never tears down a model that was already loaded, so it
            // returns a boolean and leaves detector readiness unchanged.
            const readyBefore = audio.isMlNoteDetection();
            const ok = audio.loadNoteModel(path.join(__dirname, 'no-such-model.onnx'));
            assert.equal(typeof ok, 'boolean', 'missing model returns a boolean, does not throw');
            assert.equal(audio.isMlNoteDetection(), readyBefore,
                'a failed load must not change detector readiness');
        });

        await t.test('getPitchDetection keeps its shape', () => {
            const d = audio.getPitchDetection();
            assert.equal(typeof d, 'object');
            for (const k of ['frequency', 'confidence', 'midiNote', 'cents', 'noteName'])
                assert.ok(k in d, `detection has ${k}`);
        });

        await t.test('getRawAudioFrame returns a sized Float32Array', () => {
            assert.equal(typeof audio.getRawAudioFrame, 'function',
                'addon exposes getRawAudioFrame');
            // No device running, so the post-gate ring is empty: cold-start
            // returns a zero-filled frame of exactly the requested length.
            const frame = audio.getRawAudioFrame(2048);
            assert.ok(frame instanceof Float32Array, 'returns a Float32Array');
            assert.equal(frame.length, 2048, 'honours the requested sample count');
            // Default (no arg) matches the 4096 default.
            assert.equal(audio.getRawAudioFrame().length, 4096, 'defaults to 4096 samples');
            // Over-large requests clamp to the ring capacity (16384), never throw.
            assert.equal(audio.getRawAudioFrame(1 << 20).length, 16384,
                'clamps to the ring capacity');
            // Non-positive requests yield an empty frame.
            assert.equal(audio.getRawAudioFrame(0).length, 0, 'zero samples → empty');
        });

        await t.test('scoreChord keeps its shape (no device running)', () => {
            const res = audio.scoreChord({
                arrangement: 'guitar',
                stringCount: 6,
                offsets: [0, 0, 0, 0, 0, 0],
                notes: [{ s: 0, f: 3 }, { s: 1, f: 2 }, { s: 2, f: 0 }],
            });
            assert.ok(res && typeof res === 'object', 'scoreChord returns an object');
            for (const k of ['score', 'hitStrings', 'totalStrings', 'isHit', 'results'])
                assert.ok(k in res, `result has ${k}`);
            assert.equal(res.results.length, 3, 'one result entry per note');
        });

        await t.test('detectNotes returns the polyphonic shape or null', () => {
            assert.equal(typeof audio.detectNotes, 'function',
                'addon exposes detectNotes');
            const res = audio.detectNotes();
            // null when ONNX support is compiled out / no model — otherwise a
            // { notes: [], sampleRate } object.
            if (res !== null) {
                assert.ok('notes' in res && Array.isArray(res.notes),
                    'detectNotes result has a notes array');
                assert.equal(typeof res.sampleRate, 'number',
                    'detectNotes result has a numeric sampleRate');
            }
        });
    });
}
