// Standalone tests for reasampler::instrument::note::note_program — no VST3, no REAPER, no
// framework. Same fast assert loop as the sibling pure tests.
//
// Covers: velocity clamping; the picked/exact note-length seam and its bounded door; the
// ms/beats denomination seam and its round-trip; anchoring
// (start to note-on, end to note-off); the resolved window against hand-computed values and
// its windowCollapsed flag, including the zero-length window the flag exists to distinguish;
// every division resolving to its duration in seconds; proportionality across two tempos;
// record equality and copy round-trip; editing an offset via its non-stored view
// (withMsView/withBeatsView); what `offsetOf` does to a corrupt magnitude or denomination,
// asserted through EVERY function that branches on one; and the module's headline claim —
// that resolveNote returns finite times for every constructible input.

#include "../src/core/instrument/note/note_program.h"

#include <cmath>
#include <cstdio>
#include <limits>

using namespace reasampler::instrument::note;

static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
    std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)

static bool almostEqual(double a, double b, double eps = 1e-9) {
    return std::fabs(a - b) < eps;
}

static Tempo at(double bpm) {
    const std::optional<Tempo> t = Tempo::fromBpm(bpm);
    if (!t) { std::printf("FAIL: fixture tempo %f rejected\n", bpm); ++g_fail; }
    return t.value_or(Tempo::fromBpm(120.0).value());
}

// Beats per straight rung, written out rather than computed — see test_musical_division.
static const double kStraightBeats[kRungCount] = {
    0.0625, 0.125, 0.25, 0.5, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 128.0, 256.0,
};

static NoteProgram program(Division length, OffsetAmount start, OffsetAmount end, int velocity) {
    NoteProgram p;
    p.length = lengthOfDivision(length);
    p.start = StartOffset(start);
    p.end = EndOffset(end);
    p.velocity = Velocity::of(velocity);
    return p;
}

// --- Velocity ------------------------------------------------------------------

static void testVelocityCarriesInRange() {
    CHECK(Velocity::of(1).value() == 1);
    CHECK(Velocity::of(96).value() == 96);
    CHECK(Velocity::of(127).value() == 127);
}

static void testVelocityClampsOutOfRange() {
    // 0 is note-off in MIDI: a programmed note that does not sound is never the intent.
    CHECK(Velocity::of(0).value() == 1);
    CHECK(Velocity::of(-40).value() == 1);
    CHECK(Velocity::of(128).value() == 127);
    CHECK(Velocity::of(9000).value() == 127);
}

static void testResolvedNoteCarriesTheProgrammedVelocity() {
    const Tempo t = at(120.0);
    CHECK(resolveNote(program(makeDivision(0, DivisionModifier::Straight), offsetFromMs(0.0),
                              offsetFromMs(0.0), 96),
                      t)
              .velocity
          == 96);
    CHECK(resolveNote(program(makeDivision(0, DivisionModifier::Straight), offsetFromMs(0.0),
                              offsetFromMs(0.0), 0),
                      t)
              .velocity
          == 1);
}

// --- The denomination seam -----------------------------------------------------

static void testMsOffsetReadsBackInBothDenominations() {
    // 120 BPM: one beat is 500 ms, so 250 ms is half a beat.
    const Tempo t = at(120.0);
    const OffsetAmount a = offsetFromMs(250.0);
    CHECK(almostEqual(offsetMs(a, t), 250.0));
    CHECK(almostEqual(offsetBeats(a, t), 0.5));
    CHECK(almostEqual(offsetSeconds(a, t), 0.25));
}

static void testBeatsOffsetReadsBackInBothDenominations() {
    // 80 BPM: one beat is 750 ms.
    const Tempo t = at(80.0);
    const OffsetAmount a = offsetFromBeats(2.0);
    CHECK(almostEqual(offsetBeats(a, t), 2.0));
    CHECK(almostEqual(offsetMs(a, t), 1500.0, 1e-6));
    CHECK(almostEqual(offsetSeconds(a, t), 1.5));
}

static void testRedenominationRoundTripsLosslessly() {
    const double bpms[] = {44.0, 91.7, 120.0, 200.0};
    const double magnitudes[] = {-500.0, -20.0, 0.0, 0.25, 333.0};
    for (double bpm : bpms) {
        const Tempo t = at(bpm);
        for (double ms : magnitudes) {
            const OffsetAmount original = offsetFromMs(ms);
            const OffsetAmount there = redenominate(original, Denomination::Beats, t);
            const OffsetAmount back = redenominate(there, Denomination::Milliseconds, t);
            CHECK(there.denomination() == Denomination::Beats);
            CHECK(back.denomination() == Denomination::Milliseconds);
            CHECK(almostEqual(back.magnitude(), ms, 1e-9 + 1e-9 * std::fabs(ms)));
            // Re-denominating never moves the instant it names.
            CHECK(almostEqual(offsetSeconds(there, t), offsetSeconds(original, t)));
        }
        for (double beats : magnitudes) {
            const OffsetAmount original = offsetFromBeats(beats);
            const OffsetAmount back =
                redenominate(redenominate(original, Denomination::Milliseconds, t),
                             Denomination::Beats, t);
            CHECK(almostEqual(back.magnitude(), beats, 1e-9 + 1e-9 * std::fabs(beats)));
        }
    }
}

static void testRedenominatingToTheSameUnitIsIdentity() {
    const Tempo t = at(120.0);
    const OffsetAmount a = offsetFromMs(37.0);
    CHECK(redenominate(a, Denomination::Milliseconds, t) == a);
}

static void testStoredDenominationDecidesWhetherAnOffsetFollowsTheTempo() {
    // The whole reason the denomination is stored: at half the tempo the beats offset is
    // twice as long in seconds, the ms offset unchanged.
    const OffsetAmount inMs = offsetFromMs(500.0);
    const OffsetAmount inBeats = offsetFromBeats(1.0);
    const Tempo fast = at(120.0);
    const Tempo slow = at(60.0);
    CHECK(almostEqual(offsetSeconds(inMs, fast), offsetSeconds(inMs, slow)));
    CHECK(almostEqual(offsetSeconds(inBeats, slow), 2.0 * offsetSeconds(inBeats, fast)));
}

// --- Note length in seconds ----------------------------------------------------

static void testEveryDivisionResolvesToItsDuration() {
    // 120 BPM: one beat is 0.5 s, so a division's length in seconds is half its beats.
    const Tempo t = at(120.0);
    const OffsetAmount none = offsetFromMs(0.0);
    for (int rung = 0; rung < kRungCount; ++rung) {
        const int e = kMinQuarterExponent + rung;
        const double straight = kStraightBeats[rung] * 0.5;
        CHECK(almostEqual(
            resolveNote(program(makeDivision(e, DivisionModifier::Straight), none, none, 100), t)
                .noteOffSeconds,
            straight, 1e-9 + 1e-9 * straight));
        CHECK(almostEqual(
            resolveNote(program(makeDivision(e, DivisionModifier::Dotted), none, none, 100), t)
                .noteOffSeconds,
            straight * 1.5, 1e-9 + 1e-9 * straight));
        CHECK(almostEqual(
            resolveNote(program(makeDivision(e, DivisionModifier::Triplet), none, none, 100), t)
                .noteOffSeconds,
            straight * 2.0 / 3.0, 1e-9 + 1e-9 * straight));
    }
}

static void testExtremeAndNamedDivisionsInSeconds() {
    // 120 BPM: one beat is 0.5 s.
    const Tempo t = at(120.0);
    const OffsetAmount none = offsetFromMs(0.0);
    struct Case { Division d; double seconds; };
    const Case cases[] = {
        {makeDivision(kMinQuarterExponent, DivisionModifier::Straight), 0.03125},  // 1/64
        {makeDivision(kMaxQuarterExponent, DivisionModifier::Straight), 128.0},    // 64/1
        {makeDivision(-1, DivisionModifier::Dotted), 0.375},                       // 1/8.
        {makeDivision(0, DivisionModifier::Triplet), 1.0 / 3.0},                   // 1/4t
        {makeDivision(-2, DivisionModifier::Straight), 0.125},                     // 1/16
        {makeDivision(4, DivisionModifier::Straight), 8.0},                        // 4/1
    };
    for (const Case& c : cases) {
        CHECK(almostEqual(resolveNote(program(c.d, none, none, 100), t).noteOffSeconds,
                          c.seconds, 1e-9 + 1e-9 * c.seconds));
    }
}

static void testNoteLengthIsProportionalToTempo() {
    // Ratio only — no seconds value is asserted here, so the module's tempo-freedom is what
    // is under test rather than any particular rate.
    const OffsetAmount none = offsetFromMs(0.0);
    const Tempo fast = at(160.0);
    const Tempo slow = at(40.0);
    for (int i = 0; i < kDivisionCount; ++i) {
        const NoteProgram p = program(divisionAt(i), none, none, 100);
        CHECK(almostEqual(resolveNote(p, slow).noteOffSeconds,
                          4.0 * resolveNote(p, fast).noteOffSeconds, 1e-9));
    }
}

// --- NoteLength: the two denominations -----------------------------------------

// A picked length follows the tempo (that is what a picker means); a derived one does not,
// because it was computed against a concrete sound and a tempo change did not lengthen that
// sound. Both resolve through the one function, so no reader chooses.
static void testAnExactLengthIsTempoIndependentAndAPickedOneIsNot() {
    const NoteLength picked = lengthOfDivision(makeDivision(0, DivisionModifier::Straight));
    const NoteLength exact = lengthOfSeconds(0.5);
    CHECK(almostEqual(noteLengthSeconds(picked, at(120.0)), 0.5));
    CHECK(almostEqual(noteLengthSeconds(picked, at(60.0)), 1.0));
    CHECK(almostEqual(noteLengthSeconds(exact, at(120.0)), 0.5));
    CHECK(almostEqual(noteLengthSeconds(exact, at(60.0)), 0.5));
    // Equal at one tempo is not equal as records — the denomination IS part of the value.
    CHECK(picked != exact);
    CHECK(lengthOfSeconds(0.5) == exact);
    CHECK(lengthOfSeconds(0.5) != lengthOfSeconds(0.5000001));
}

// The whole point of the exact denomination: a duration past the longest ladder rung is
// representable, where quantizing onto the ladder would saturate at kMaxDivisionBeats.
static void testAnExactLengthCarriesDurationsPastTheLaddersTopRung() {
    const Tempo t = at(120.0);
    const double pastTheLadder = t.beatsToSeconds(kMaxDivisionBeats) * 3.0;
    CHECK(almostEqual(noteLengthSeconds(lengthOfSeconds(pastTheLadder), t), pastTheLadder));
    // …and the ladder really does saturate there, which is what makes the seam load-bearing:
    // its longest rung (the dotted top) IS kMaxDivisionBeats, so nothing on it reaches
    // pastTheLadder.
    CHECK(almostEqual(divisionBeats(makeDivision(kMaxQuarterExponent, DivisionModifier::Dotted)),
                      kMaxDivisionBeats));
}

// The door establishes the domain, like every other value type here: a corrupt or absurd
// duration becomes a representable one rather than reaching resolveNote as a poison value.
static void testTheExactLengthDoorBoundsItsDomain() {
    const Tempo t = at(120.0);
    CHECK(almostEqual(noteLengthSeconds(lengthOfSeconds(-4.0), t), 0.0));
    CHECK(almostEqual(noteLengthSeconds(lengthOfSeconds(std::nan("")), t), 0.0));
    CHECK(almostEqual(noteLengthSeconds(lengthOfSeconds(kMaxLengthSeconds * 10.0), t),
                      kMaxLengthSeconds));
    CHECK(std::isfinite(
        noteLengthSeconds(lengthOfSeconds(std::numeric_limits<double>::infinity()), t)));
    // And the whole resolve stays finite over it, which is the module's headline claim.
    NoteProgram p;
    p.length = lengthOfSeconds(std::numeric_limits<double>::infinity());
    const ResolvedNote r = resolveNote(p, t);
    CHECK(std::isfinite(r.noteOffSeconds) && std::isfinite(r.captureEndSeconds));
}

// --- The resolved window -------------------------------------------------------

static void testWindowAnchorsStartToNoteOnAndEndToNoteOff() {
    // 120 BPM, 1/4 note = 0.5 s. Daniel's case: open 20 ms before note-on, close 500 ms
    // after note-off.
    const Tempo t = at(120.0);
    const ResolvedNote r = resolveNote(program(makeDivision(0, DivisionModifier::Straight),
                                               offsetFromMs(-20.0), offsetFromMs(500.0), 96),
                                       t);
    CHECK(almostEqual(r.noteOffSeconds, 0.5));
    CHECK(almostEqual(r.captureStartSeconds, -0.020));  // note-on is 0, so the pre-roll is negative
    CHECK(almostEqual(r.captureEndSeconds, 1.0));       // 0.5 note-off + 0.5 tail
    CHECK(almostEqual(r.captureLengthSeconds(), 1.02));
    CHECK(r.velocity == 96);
}

static void testEndOffsetMovesWithTheNoteLength() {
    // The end offset anchors to note-off, so lengthening the note moves the window's end by
    // the same amount and leaves its start alone.
    const Tempo t = at(120.0);
    const OffsetAmount start = offsetFromMs(-20.0);
    const OffsetAmount end = offsetFromMs(500.0);
    const ResolvedNote quarter =
        resolveNote(program(makeDivision(0, DivisionModifier::Straight), start, end, 100), t);
    const ResolvedNote half =
        resolveNote(program(makeDivision(1, DivisionModifier::Straight), start, end, 100), t);
    CHECK(almostEqual(half.captureStartSeconds, quarter.captureStartSeconds));
    CHECK(almostEqual(half.captureEndSeconds - quarter.captureEndSeconds, 0.5));
}

static void testBeatsDenominatedOffsetsResolveAgainstTheSuppliedTempo() {
    // 1/4 note, start -1/2 beat, end +1 beat. At 120 BPM (0.5 s/beat): note-off 0.5,
    // window -0.25 .. 1.0. At 60 BPM every one of those doubles.
    const NoteProgram p = program(makeDivision(0, DivisionModifier::Straight),
                                  offsetFromBeats(-0.5), offsetFromBeats(1.0), 100);
    const ResolvedNote fast = resolveNote(p, at(120.0));
    CHECK(almostEqual(fast.captureStartSeconds, -0.25));
    CHECK(almostEqual(fast.captureEndSeconds, 1.0));

    const ResolvedNote slow = resolveNote(p, at(60.0));
    CHECK(almostEqual(slow.captureStartSeconds, -0.5));
    CHECK(almostEqual(slow.captureEndSeconds, 2.0));
}

static void testMixedDenominationsResolveIndependently() {
    // A ms pre-roll and a beats tail on one record: halving the tempo moves the tail only.
    const NoteProgram p = program(makeDivision(0, DivisionModifier::Straight),
                                  offsetFromMs(-20.0), offsetFromBeats(1.0), 100);
    const ResolvedNote fast = resolveNote(p, at(120.0));
    const ResolvedNote slow = resolveNote(p, at(60.0));
    CHECK(almostEqual(fast.captureStartSeconds, -0.020));
    CHECK(almostEqual(slow.captureStartSeconds, -0.020));
    CHECK(almostEqual(fast.captureEndSeconds, 1.0));
    CHECK(almostEqual(slow.captureEndSeconds, 2.0));
}

static void testNegativeEndOffsetTruncatesBeforeRelease() {
    // 1/2 note at 120 BPM is 1.0 s; closing 200 ms early ends the window at 0.8 s.
    const Tempo t = at(120.0);
    const ResolvedNote r = resolveNote(program(makeDivision(1, DivisionModifier::Straight),
                                               offsetFromMs(0.0), offsetFromMs(-200.0), 100),
                                       t);
    CHECK(almostEqual(r.noteOffSeconds, 1.0));
    CHECK(almostEqual(r.captureEndSeconds, 0.8));
    CHECK(almostEqual(r.captureLengthSeconds(), 0.8));
}

static void testWindowNeverInverts() {
    // An end offset past the window's own start collapses the window rather than inverting it.
    const Tempo t = at(120.0);
    const ResolvedNote r = resolveNote(program(makeDivision(0, DivisionModifier::Straight),
                                               offsetFromMs(0.0), offsetFromMs(-5000.0), 100),
                                       t);
    CHECK(almostEqual(r.captureStartSeconds, 0.0));
    CHECK(almostEqual(r.captureEndSeconds, 0.0));
    CHECK(r.captureLengthSeconds() >= 0.0);
}

// --- The record ----------------------------------------------------------------

static void testRecordRoundTripsAsAWhole() {
    const NoteProgram original = program(makeDivision(-1, DivisionModifier::Dotted),
                                         offsetFromMs(-20.0), offsetFromBeats(2.0), 96);
    const NoteProgram copy = original;
    CHECK(copy == original);
    CHECK(copy.length == lengthOfDivision(makeDivision(-1, DivisionModifier::Dotted)));
    CHECK(copy.start.amount() == offsetFromMs(-20.0));
    CHECK(copy.end.amount() == offsetFromBeats(2.0));
    CHECK(copy.velocity.value() == 96);

    // Resolving reads the record and leaves it alone, so a preview cannot drift the state a
    // later bake reads.
    resolveNote(original, at(120.0));
    CHECK(copy == original);
}

static void testRecordEqualityIsSensitiveToEveryField() {
    const NoteProgram base = program(makeDivision(0, DivisionModifier::Straight),
                                     offsetFromMs(-20.0), offsetFromMs(500.0), 96);
    CHECK(base != program(makeDivision(0, DivisionModifier::Dotted), offsetFromMs(-20.0),
                          offsetFromMs(500.0), 96));
    CHECK(base != program(makeDivision(0, DivisionModifier::Straight), offsetFromMs(-21.0),
                          offsetFromMs(500.0), 96));
    CHECK(base != program(makeDivision(0, DivisionModifier::Straight), offsetFromMs(-20.0),
                          offsetFromMs(501.0), 96));
    CHECK(base != program(makeDivision(0, DivisionModifier::Straight), offsetFromMs(-20.0),
                          offsetFromMs(500.0), 97));
    // Same magnitude, different denomination is a different record even where one tempo
    // makes them resolve alike.
    CHECK(base != program(makeDivision(0, DivisionModifier::Straight), offsetFromBeats(-20.0),
                          offsetFromMs(500.0), 96));
}

static void testRedenominatedRecordDescribesTheSameWindow() {
    const Tempo t = at(133.0);
    const NoteProgram original = program(makeDivision(-2, DivisionModifier::Triplet),
                                         offsetFromMs(-35.0), offsetFromMs(420.0), 64);
    NoteProgram restated = original;
    restated.start = StartOffset(redenominate(original.start.amount(), Denomination::Beats, t));
    restated.end = EndOffset(redenominate(original.end.amount(), Denomination::Beats, t));

    const ResolvedNote a = resolveNote(original, t);
    const ResolvedNote b = resolveNote(restated, t);
    CHECK(restated != original);  // the record changed...
    CHECK(almostEqual(a.captureStartSeconds, b.captureStartSeconds));  // ...the window did not
    CHECK(almostEqual(a.captureEndSeconds, b.captureEndSeconds));
}

static void testDefaultRecordIsAQuarterNoteWithNoOffsets() {
    const Tempo t = at(120.0);
    const ResolvedNote r = resolveNote(NoteProgram{}, t);
    CHECK(almostEqual(r.noteOffSeconds, 0.5));
    CHECK(almostEqual(r.captureStartSeconds, 0.0));
    CHECK(almostEqual(r.captureEndSeconds, 0.5));
    CHECK(r.velocity == 100);  // NoteProgram{}'s default Velocity, documented in note_program.h
}

// --- The door: what a corrupt persisted field becomes ----------------------------

static void testUnnamedDenominationBecomesMilliseconds() {
    // A denomination byte outside {Milliseconds, Beats} is well-defined but unnamed. The
    // door pins it, so it is not merely that the readers agree — the value they read from
    // is already Milliseconds by the time any of them sees it.
    const OffsetAmount corrupt = offsetOf(250.0, static_cast<Denomination>(7));
    CHECK(corrupt.denomination() == Denomination::Milliseconds);
    const Tempo t = at(120.0);
    CHECK(almostEqual(offsetMs(corrupt, t), 250.0));
    CHECK(almostEqual(offsetSeconds(corrupt, t), 0.25));
    CHECK(almostEqual(offsetBeats(corrupt, t), 0.5));
}

static void testEveryDenominationBranchingFunctionAgreesWithThePin() {
    // The pin is worth nothing if one branching function disagrees with it: an editor that
    // flipped a corrupt record to beats would silently change whether it follows the tempo,
    // and an equality that saw the raw byte would report a diff on every reload. All six.
    const Tempo t = at(120.0);  // one beat is 500 ms
    const OffsetAmount corrupt = offsetOf(250.0, static_cast<Denomination>(7));
    const OffsetAmount asMs = offsetFromMs(250.0);

    CHECK(corrupt == asMs);  // offsetMs / offsetBeats / offsetSeconds covered above
    CHECK(!(corrupt != asMs));
    CHECK(redenominate(corrupt, Denomination::Milliseconds, t) == corrupt);
    // Measured (mutate offsetOf to a pass-through): still passes. offsetFromBeats always
    // tags its result Beats, so this holds regardless of whether corrupt was pinned — it
    // does not discriminate the pin.
    CHECK(redenominate(corrupt, Denomination::Beats, t).denomination() == Denomination::Beats);
    // Reported measured (withMsView reverted to its pre-domain-closure form): still passes.
    // corrupt already equals asMs by this point, and withMsView is a pure function of its
    // argument, so this line cannot discriminate anything withMsView-specific — it is a
    // restatement of the equality above.
    CHECK(withMsView(corrupt, 40.0, t) == withMsView(asMs, 40.0, t));
    CHECK(withMsView(corrupt, 40.0, t).denomination() == Denomination::Milliseconds);
    // Measured (mutate offsetOf to a pass-through): still passes. withBeatsView only branches
    // on `== Beats`; any non-Beats value — pinned or raw corrupt — takes the same ms-based
    // else branch, so this does not discriminate the pin either.
    CHECK(withBeatsView(corrupt, 1.0, t) == withBeatsView(asMs, 1.0, t));
    CHECK(withBeatsView(corrupt, 1.0, t).denomination() == Denomination::Milliseconds);
    CHECK(almostEqual(withBeatsView(corrupt, 1.0, t).magnitude(), 500.0, 1e-6));

    // Reported measured (offsetOf(0.0, to) replaced with `= to;`): still passes, for the
    // same reason as above — `target == Beats` is false whether target is pinned or raw, so
    // this always takes the ms branch and cannot discriminate the door (see the comment on
    // that line in note_program.cpp).
    CHECK(redenominate(offsetFromBeats(1.0), static_cast<Denomination>(7), t)
          == offsetFromMs(500.0));
}

static void testCorruptMagnitudeIsBoundedAtTheDoor() {
    const double inf = std::numeric_limits<double>::infinity();
    const double nan = std::numeric_limits<double>::quiet_NaN();
    // NaN names no value to clamp toward, so it takes the field's own default; an infinity
    // does have a nearest representable magnitude, so it clamps like any other overshoot.
    CHECK(almostEqual(offsetFromMs(nan).magnitude(), 0.0));
    CHECK(almostEqual(offsetFromBeats(nan).magnitude(), 0.0));
    CHECK(almostEqual(offsetFromMs(inf).magnitude(), kMaxConvertibleMagnitude));
    CHECK(almostEqual(offsetFromBeats(-inf).magnitude(), -kMaxConvertibleMagnitude));
    CHECK(almostEqual(offsetFromMs(1e300).magnitude(), kMaxConvertibleMagnitude));
    // A NaN offset is a value, not a hole: it equals itself, so it is not a spurious diff.
    CHECK(offsetFromMs(nan) == offsetFromMs(0.0));
    // Anything inside the domain passes through untouched.
    CHECK(almostEqual(offsetFromMs(-12345.678).magnitude(), -12345.678));
}

static void testANanMagnitudeCannotReachTheResolvedWindow() {
    // The witness the door exists for: at an unremarkable tempo, a NaN magnitude used to
    // make captureStart, rawEnd and captureEnd all NaN, and windowCollapsed read false.
    const Tempo t = at(120.0);
    const double nan = std::numeric_limits<double>::quiet_NaN();
    const ResolvedNote r = resolveNote(
        program(makeDivision(0, DivisionModifier::Straight), offsetOf(nan, Denomination::Beats),
                offsetOf(nan, Denomination::Milliseconds), 100),
        t);
    CHECK(almostEqual(r.captureStartSeconds, 0.0));
    CHECK(almostEqual(r.captureEndSeconds, 0.5));
    CHECK(!r.windowCollapsed);
}

// --- windowCollapsed -------------------------------------------------------------

static void testWindowCollapsedFlagsAnInvertedWindow() {
    const Tempo t = at(120.0);
    const ResolvedNote inverted = resolveNote(
        program(makeDivision(0, DivisionModifier::Straight), offsetFromMs(0.0),
               offsetFromMs(-5000.0), 100),
        t);
    CHECK(inverted.windowCollapsed);

    const ResolvedNote normal = resolveNote(
        program(makeDivision(0, DivisionModifier::Straight), offsetFromMs(-20.0),
               offsetFromMs(500.0), 100),
        t);
    CHECK(!normal.windowCollapsed);
}

static void testWindowCollapsedIsFalseForAGenuinelyZeroLengthWindow() {
    // The discrimination the flag exists for. A 1/4 at 120 BPM is 500 ms, so an end offset
    // of -500 ms puts the raw end EXACTLY on the start: zero-length, but programmed that way
    // rather than collapsed, and a popup must be able to tell the two apart.
    const Tempo t = at(120.0);
    const ResolvedNote r = resolveNote(program(makeDivision(0, DivisionModifier::Straight),
                                               offsetFromMs(0.0), offsetFromMs(-500.0), 100),
                                       t);
    CHECK(almostEqual(r.captureLengthSeconds(), 0.0));
    CHECK(!r.windowCollapsed);
    // One millisecond further in is the same zero length, but collapsed.
    const ResolvedNote collapsed = resolveNote(
        program(makeDivision(0, DivisionModifier::Straight), offsetFromMs(0.0),
                offsetFromMs(-501.0), 100),
        t);
    CHECK(almostEqual(collapsed.captureLengthSeconds(), 0.0));
    CHECK(collapsed.windowCollapsed);
}

// --- Totality --------------------------------------------------------------------

static void testResolveNoteIsFiniteForEveryConstructibleInput() {
    // The claim that lets resolveNote have no failure path, swept rather than argued: every
    // division, both denominations, the magnitude extremes the door admits plus the garbage
    // it normalizes, across tempos from rejected-subnormal to rejected-astronomical.
    const double inf = std::numeric_limits<double>::infinity();
    const double nan = std::numeric_limits<double>::quiet_NaN();
    const double magnitudes[] = {-inf, -kMaxConvertibleMagnitude, -1e300, 0.0, 1e300,
                                 kMaxConvertibleMagnitude, inf, nan};
    int accepted = 0, rejected = 0;
    // 1e-294/1e-295 bracket the accept/reject edge (measured ~3.34e-295) so the sweep
    // actually approaches it rather than jumping past it by ~95 orders of magnitude.
    for (double bpm :
         {1e-320, 1e-306, 1e-295, 1e-294, 1e-200, 1e-6, 0.5, 120.0, 1e6, 1e100, 1e308}) {
        const std::optional<Tempo> tempo = Tempo::fromBpm(bpm);
        if (!tempo) { ++rejected; continue; }
        ++accepted;
        for (int i = 0; i < kDivisionCount; ++i) {
            for (double m : magnitudes) {
                for (Denomination d : {Denomination::Milliseconds, Denomination::Beats}) {
                    const ResolvedNote r = resolveNote(
                        program(divisionAt(i), offsetOf(m, d), offsetOf(-m, d), 100), *tempo);
                    CHECK(std::isfinite(r.noteOffSeconds));
                    CHECK(std::isfinite(r.captureStartSeconds));
                    CHECK(std::isfinite(r.captureEndSeconds));
                    CHECK(std::isfinite(r.captureLengthSeconds()));
                    CHECK(r.captureLengthSeconds() >= 0.0);
                }
            }
        }
    }
    // Neither half of the tempo sweep may be empty, or the loop above proves nothing.
    CHECK(accepted > 0);
    CHECK(rejected > 0);
}

// --- Editing via the non-stored view ---------------------------------------------

static void testWithMsViewPreservesTheStoredDenomination() {
    const Tempo t = at(120.0);  // one beat is 500 ms
    const OffsetAmount msOffset = offsetFromMs(10.0);
    const OffsetAmount editedMs = withMsView(msOffset, 40.0, t);
    CHECK(editedMs.denomination() == Denomination::Milliseconds);
    CHECK(almostEqual(editedMs.magnitude(), 40.0));

    const OffsetAmount beatsOffset = offsetFromBeats(1.0);
    const OffsetAmount editedBeats = withMsView(beatsOffset, 250.0, t);
    CHECK(editedBeats.denomination() == Denomination::Beats);  // stays beats-denominated
    CHECK(almostEqual(offsetMs(editedBeats, t), 250.0, 1e-6));  // but reads back as 250 ms
}

static void testWithBeatsViewPreservesTheStoredDenomination() {
    const Tempo t = at(120.0);  // one beat is 500 ms
    const OffsetAmount beatsOffset = offsetFromBeats(0.5);
    const OffsetAmount editedBeats = withBeatsView(beatsOffset, 2.0, t);
    CHECK(editedBeats.denomination() == Denomination::Beats);
    CHECK(almostEqual(editedBeats.magnitude(), 2.0));

    const OffsetAmount msOffset = offsetFromMs(100.0);
    const OffsetAmount editedMs = withBeatsView(msOffset, 1.0, t);
    CHECK(editedMs.denomination() == Denomination::Milliseconds);  // stays ms-denominated
    CHECK(almostEqual(offsetBeats(editedMs, t), 1.0));           // but reads back as 1 beat
}

int main() {
    testVelocityCarriesInRange();
    testVelocityClampsOutOfRange();
    testResolvedNoteCarriesTheProgrammedVelocity();

    testMsOffsetReadsBackInBothDenominations();
    testBeatsOffsetReadsBackInBothDenominations();
    testRedenominationRoundTripsLosslessly();
    testRedenominatingToTheSameUnitIsIdentity();
    testStoredDenominationDecidesWhetherAnOffsetFollowsTheTempo();

    testEveryDivisionResolvesToItsDuration();
    testExtremeAndNamedDivisionsInSeconds();
    testNoteLengthIsProportionalToTempo();
    testAnExactLengthIsTempoIndependentAndAPickedOneIsNot();
    testAnExactLengthCarriesDurationsPastTheLaddersTopRung();
    testTheExactLengthDoorBoundsItsDomain();

    testWindowAnchorsStartToNoteOnAndEndToNoteOff();
    testEndOffsetMovesWithTheNoteLength();
    testBeatsDenominatedOffsetsResolveAgainstTheSuppliedTempo();
    testMixedDenominationsResolveIndependently();
    testNegativeEndOffsetTruncatesBeforeRelease();
    testWindowNeverInverts();

    testRecordRoundTripsAsAWhole();
    testRecordEqualityIsSensitiveToEveryField();
    testRedenominatedRecordDescribesTheSameWindow();
    testDefaultRecordIsAQuarterNoteWithNoOffsets();

    testUnnamedDenominationBecomesMilliseconds();
    testEveryDenominationBranchingFunctionAgreesWithThePin();
    testCorruptMagnitudeIsBoundedAtTheDoor();
    testANanMagnitudeCannotReachTheResolvedWindow();

    testWindowCollapsedFlagsAnInvertedWindow();
    testWindowCollapsedIsFalseForAGenuinelyZeroLengthWindow();

    testWithMsViewPreservesTheStoredDenomination();
    testWithBeatsViewPreservesTheStoredDenomination();

    testResolveNoteIsFiniteForEveryConstructibleInput();

    if (g_fail == 0) std::printf("note_program: all tests passed\n");
    else std::printf("note_program: %d FAILED\n", g_fail);
    return g_fail == 0 ? 0 : 1;
}
