// The DERIVED bake window, end to end: does defaultBakeProgram's window hold the whole
// audible result of the dialed sound? Every case renders through the real chain
// (defaultBakeProgram -> resolveNote -> planBake -> renderBake) and then re-renders the SAME
// sound with a longer window, so "what fell outside" is measured rather than argued.
// Trailing silence is a pass; signal past the derived end is a truncation.

#include "../src/core/instrument/bake/bake_plan.h"
#include "../src/core/instrument/bake/bake_render.h"
#include "../src/core/instrument/engine/voice.h"  // kDeclickFrames (the pad under test)

#include <cmath>
#include <cstdio>
#include <optional>

using namespace reasampler;
using namespace reasampler::instrument::bake;
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)

namespace {

constexpr int kRate = 48000;
constexpr double kBpm = 120.0;   // a quarter note is 0.5 s == 24000 frames
constexpr double kUnity = 1.0;
constexpr double kSilence = 1e-6;
// The derived window is a property of the voice chain, not of the master stage: every
// measurement here reads the render with the limiter bypassed.
constexpr bool kNoLimiter = false;

// The declick pad every derived window carries. Read off the engine's own constants, so a
// retuned ramp moves this file's expectations with it rather than against them.
constexpr std::int64_t kPad = kDeclickFrames;

// One bar at 120 BPM == 2 s == 96000 frames. The Hold default, spelled out so the
// expectations below read as arithmetic rather than as magic.
Division oneBar() { return makeDivision(2, DivisionModifier::Straight); }

Tempo tempoOf(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());
}

Tempo tempo() { return tempoOf(kBpm); }

// Flat DC so a level reading is unambiguous: any departure from 0.5 is the envelope, the
// filter or a ring-out, never the source's own shape.
SampleData dcSample(std::size_t frames) {
    SampleData s;
    s.frames.assign(frames, 0.5f);
    s.sampleRate = kRate;
    s.rootNote = 60;
    return s;
}

double peakAt(const BakeAudio& audio, std::int64_t from, std::int64_t to) {
    double peak = 0.0;
    if (from < 0) from = 0;
    for (std::int64_t f = from; f < to && f < audio.frameCount(); ++f) {
        const double v = std::fabs(static_cast<double>(
            audio.interleaved[static_cast<std::size_t>(f * audio.channelCount)]));
        if (v > peak) peak = v;
    }
    return peak;
}

// The last frame of the file that carries any signal at all — where the voice ACTUALLY stopped.
// A measurement of the engine, never a second evaluation of the derivation under test. -1 when
// the render is silent throughout.
std::int64_t lastSoundingFrame(const BakeAudio& audio) {
    for (std::int64_t f = audio.frameCount() - 1; f >= 0; --f) {
        if (std::fabs(static_cast<double>(
                audio.interleaved[static_cast<std::size_t>(f * audio.channelCount)])) > kSilence) {
            return f;
        }
    }
    return -1;
}

// The derived program, optionally lengthened: `extraMs` widens ONLY the end offset (the same
// sound, a longer window). It leaves the derivation itself untouched, which is what makes the
// comparison a measurement of the derived end rather than of a second derivation.
NoteProgram derivedProgram(const SampleData& s, double extraMs, Division hold = oneBar(),
                           int velocity = 100) {
    NoteProgram p = defaultBakeProgram(s, kRate, hold, Velocity::of(velocity));
    if (extraMs != 0.0)
        p.end = EndOffset(offsetFromMs(offsetMs(p.end.amount(), tempo()) + extraMs));
    return p;
}

std::optional<BakePlan> planOf(const NoteProgram& p) {
    return planBake(resolveNote(p, tempo()), kRate, 60).plan;
}

// The render the shell would produce, plus `extraMs` of extra window.
BakeAudio bakeWith(const SampleData& s, double extraMs, Division hold = oneBar(),
                   int velocity = 100) {
    const std::optional<BakePlan> plan = planOf(derivedProgram(s, extraMs, hold, velocity));
    if (!plan) { std::printf("FAIL: fixture window refused\n"); ++g_fail; return BakeAudio{}; }
    return renderBake(s, *plan, kUnity, kNoLimiter);
}

std::int64_t derivedFrames(const SampleData& s, Division hold = oneBar()) {
    const std::optional<BakePlan> plan = planOf(derivedProgram(s, 0.0, hold));
    return plan ? plan->totalFrames : -1;
}

// Where the dialed sound stops when NOTHING cuts it: the same sound programmed with a
// deliberately long note and a window to match. This is the reference a derived window is
// judged against, and it has to be measured rather than recomputed — an under-derived Gate
// window truncates by releasing the note EARLY, which leaves no signal outside the file at all
// and so is invisible to "nothing past the end".
std::int64_t freeRunningEnd(const SampleData& s, double heldSeconds) {
    NoteProgram p = defaultBakeProgram(s, kRate, oneBar(), Velocity::of(100));
    p.length = lengthOfSeconds(heldSeconds);
    p.end = EndOffset(offsetFromMs(200.0));
    const std::optional<BakePlan> plan = planOf(p);
    if (!plan) { std::printf("FAIL: fixture reference window refused\n"); ++g_fail; return -1; }
    return lastSoundingFrame(renderBake(s, *plan, kUnity, kNoLimiter));
}

// The last frame of the file, which is where a hard cut shows up.
double lastFrameLevel(const BakeAudio& audio) {
    return audio.frameCount() > 0 ? peakAt(audio, audio.frameCount() - 1, audio.frameCount())
                                  : 0.0;
}

}  // namespace

int main() {
    // ================================ GATE ==========================================

    // --- Gate, no loop: the note is held until the SOURCE runs out ---------------------
    // The read head frees the voice at exhaustion whether or not the gate is still down, so
    // the maximal sound is the whole take. 2 s of source == 4 beats, exactly one bar.
    {
        SampleData s = dcSample(96000);
        s.play.playMode = PlayMode::Gate;
        s.play.adsr.releaseFrames = 4800;  // 100 ms

        CHECK(derivedFrames(s) == 96000 + 4800 + kPad);

        const BakeAudio wide = bakeWith(s, /*extraMs=*/200.0);
        // Full level right up to exhaustion, and nothing at all past it — the note outlived
        // its own release, so the release window is trailing silence, not a truncated tail.
        CHECK(peakAt(wide, 95000, 96000) > 0.4);
        CHECK(peakAt(wide, 96000, wide.frameCount()) == 0.0);
    }

    // --- Gate, no loop, a SLOW ATTACK: the derived note reaches the dialed peak ---------
    // The window used to be a constant quarter note, which released a 2 s attack a quarter of
    // the way up. Deriving the hold from source exhaustion is what closes that.
    {
        SampleData s = dcSample(192000);   // 4 s
        s.play.playMode = PlayMode::Gate;
        s.play.adsr.attackFrames = 96000;  // 2 s
        s.play.adsr.releaseFrames = 0;

        CHECK(derivedFrames(s) == 192000 + kPad);
        const BakeAudio derived = bakeWith(s, 0.0);
        // The whole attack, at full level — under the old constant quarter note this peaked
        // between 0.10 and 0.14.
        CHECK(peakAt(derived, 0, derived.frameCount()) > 0.49);
        // …and a longer window adds nothing: the derivation already held everything.
        const BakeAudio wide = bakeWith(s, /*extraMs=*/1000.0);
        CHECK(peakAt(wide, 192000, wide.frameCount()) == 0.0);
    }

    // --- Gate, no loop, a source LONGER than the note-length ladder ---------------------
    // The regression the exact-duration seam exists for. A derived length used to be rounded
    // onto the musical-division ladder, whose longest rung is kMaxDivisionBeats (384 beats):
    // a source past that took the top rung, so note-off — and the window with it — landed
    // INSIDE the sound. Rendered at a low rate and a fast tempo so the case is 400 beats long
    // without being twenty million frames; nothing here depends on either number but the
    // beats it puts the source at.
    {
        constexpr int kSlowRate = 8000;
        const Tempo fast = tempoOf(480.0);            // 384 beats == 48 s at this tempo
        constexpr std::int64_t kFrames = kSlowRate * 50;  // 50 s == 400 beats: past the ladder
        SampleData s = dcSample(static_cast<std::size_t>(kFrames));
        s.sampleRate = kSlowRate;
        s.play.playMode = PlayMode::Gate;
        s.play.adsr.releaseFrames = 0;

        const NoteProgram p =
            defaultBakeProgram(s, kSlowRate, oneBar(), Velocity::of(100));
        const ResolvedNote r = resolveNote(p, fast);
        CHECK(r.noteOffSeconds > 49.9 && r.noteOffSeconds < 50.1);  // 50 s, not the 48 s rung
        const std::optional<BakePlan> plan = planBake(r, kSlowRate, 60).plan;
        CHECK(plan.has_value());
        if (plan) {
            CHECK(plan->totalFrames == kFrames + kPad);
            const BakeAudio whole = renderBake(s, *plan, kUnity, kNoLimiter);
            // Full level across the two seconds the saturated rung used to cut, and the file
            // still ends on the declick ramp rather than on a hard edge.
            CHECK(peakAt(whole, kSlowRate * 48, kFrames) > 0.4);
            CHECK(lastFrameLevel(whole) < 1e-3);
        }
    }

    // --- Gate, no loop, a START POINT under Varispeed: both terms of the exhaustion ------
    // The two derivation terms this branch has that the cases above do not exercise: the
    // window covers frameCount MINUS the start, and that remainder is stretched by the
    // deepest downward offset. Getting either wrong shortens the window.
    {
        SampleData s = dcSample(48000);              // 1 s of source
        s.play.playMode = PlayMode::Gate;
        s.play.adsr.releaseFrames = 0;
        s.startFrame = 24000;                        // half of it left to play
        s.play.pitchEngine = PitchEngine::Varispeed;
        // A flat velocity->pitch curve at half depth: an octave down (the range is 24
        // semitones) for the note's whole lifetime, so the read really does run at half rate
        // to the end rather than for one envelope stage.
        s.play.pitchVelocityCurve = VelocityCurve::fromPoints(
            {{0.0, -0.5}, {127.0, -0.5}}, reasampler::instrument::engine::CurveDomain::Bipolar);

        // (48000 - 24000) source frames at half rate == 48000 output frames. Reading either
        // term wrong halves or doubles this.
        CHECK(derivedFrames(s) == 48000 + kPad);
        const BakeAudio derived = bakeWith(s, 0.0);
        CHECK(peakAt(derived, 47000, 48000) > 0.4);  // still sounding at the derived end
        const BakeAudio wide = bakeWith(s, /*extraMs=*/200.0);
        CHECK(peakAt(wide, 48000 + kPad, wide.frameCount()) == 0.0);  // and nothing past it
    }

    // --- Gate WITH a sustain loop: Hold is the note length, and the ONLY user input ------
    // A looped Gate voice sounds for as long as it is held, so no derivation supplies a
    // duration — this is the one case the predicate names, and the window follows Hold.
    {
        SampleData s = dcSample(48000);
        s.loop = SampleLoop{true, 0, 24000};
        s.play.playMode = PlayMode::Gate;
        s.play.adsr.releaseFrames = 4800;

        CHECK(bakeWindowNeedsHold(s));

        // One bar at 120 BPM == 96000 frames, plus the release, plus the pad.
        CHECK(derivedFrames(s) == 96000 + 4800 + kPad);
        const BakeAudio bar = bakeWith(s, 0.0);
        CHECK(peakAt(bar, 90000, 96000) > 0.4);           // still cycling at note-off
        CHECK(peakAt(bar, 96000 + 4790, 96000 + 4800) < 0.01);  // released to its own floor

        // A different Hold is a different window, in the same proportion — the knob really is
        // what the derivation reads here.
        const Division twoBars = makeDivision(3, DivisionModifier::Straight);
        CHECK(derivedFrames(s, twoBars) == 192000 + 4800 + kPad);
        const BakeAudio held = bakeWith(s, 0.0, twoBars);
        CHECK(peakAt(held, 96000, 192000) > 0.4);  // full level well past one bar
    }

    // --- The Hold predicate is the ENGINE's loop fold, not a reading of the fields -------
    {
        SampleData s = dcSample(48000);
        s.play.playMode = PlayMode::Gate;
        CHECK(!bakeWindowNeedsHold(s));                      // no loop at all
        s.loop = SampleLoop{true, 0, 24000};
        CHECK(bakeWindowNeedsHold(s));
        s.loop = SampleLoop{true, 0, 48001};                 // reaches past the PCM
        CHECK(!bakeWindowNeedsHold(s));                      // …which resolveLoop refuses
        s.loop = SampleLoop{true, 24000, 12000};             // inverted
        CHECK(!bakeWindowNeedsHold(s));
        s.loop = SampleLoop{true, 0, 24000};
        s.play.playMode = PlayMode::Trigger;                 // Trigger has no sustain loop
        CHECK(!bakeWindowNeedsHold(s));
    }

    // --- Gate + Preserve: the terminal ring-out is INSIDE the window ---------------------
    // Preserve rings its last real output out instead of hard-cutting it; the derived window
    // is padded by exactly that ramp, so the file ends at silence.
    {
        SampleData s = dcSample(24000);  // exhausts at half a bar
        s.play.playMode = PlayMode::Gate;
        s.play.pitchEngine = PitchEngine::Preserve;
        s.play.adsr.releaseFrames = 0;

        CHECK(derivedFrames(s) == 24000 + kPad);
        const BakeAudio derived = bakeWith(s, 0.0);
        CHECK(peakAt(derived, 23990, 24000) > 0.4);       // full level while the source lasts
        CHECK(peakAt(derived, 24000, 24000 + kPad) > 0.05);  // the ramp, inside the file
        CHECK(lastFrameLevel(derived) < 1e-3);            // and the file ends at silence
        // Nothing at all past the pad: the window is not merely long, it is exactly enough.
        const BakeAudio wide = bakeWith(s, /*extraMs=*/50.0);
        CHECK(peakAt(wide, 24000 + kPad, wide.frameCount()) == 0.0);
    }

    // --- The resonant filter cannot ring past the amp gate ------------------------------
    // pitch -> filter -> amp: the amp multiply is last, so a high-Q filter's ring-out is
    // gated by the envelope the window already holds. Not a tail contributor.
    {
        SampleData s = dcSample(96000);
        s.play.playMode = PlayMode::Gate;
        s.play.adsr.releaseFrames = 4800;
        s.play.filter.enabled = true;
        s.play.filter.settings.cutoffNorm = 0.05f;
        s.play.filter.settings.resonanceNorm = 1.0f;

        const BakeAudio wide = bakeWith(s, /*extraMs=*/500.0);
        CHECK(peakAt(wide, 0, 96000) > kSilence);              // the filtered note sounded
        CHECK(peakAt(wide, 96000, wide.frameCount()) == 0.0);  // and nothing rang past it
    }

    // ================================ TRIGGER =======================================

    // --- Trigger, Varispeed, a constant deep downward pitch offset: UPPER BOUND ---------
    // The window is scaled by the deepest reachable offset, so a shallower excursion leaves
    // trailing silence — long, but never short.
    {
        SampleData s = dcSample(48000);
        s.play.playMode = PlayMode::Trigger;
        s.play.pitchEngine = PitchEngine::Varispeed;
        s.play.pitchEnv.enabled = true;
        s.play.pitchEnv.peakSemitones = -24.0;   // two octaves down
        s.play.pitchEnv.shape.holdFraction = 1.0;  // held down for the whole span

        // 1 s of source stretched by 2^(24/12) == 4.
        CHECK(derivedFrames(s) == 192000 + kPad);
        const BakeAudio derived = bakeWith(s, 0.0);
        // The offset only holds for the envelope's own span, so the read finishes near
        // 84000 frames — inside the window, with the balance as trailing silence.
        CHECK(peakAt(derived, 80000, 84000) > 0.4);
        CHECK(peakAt(derived, 90000, 192000) < kSilence);
    }

    // --- Trigger + Preserve (the product-default engine): the ring-out is HELD -----------
    // Preserve's read reaches playEnd where the un-padded window used to close, leaving the
    // whole terminal declick outside it — a hard cut at full level, the very click the ramp
    // exists to remove. The pad is what closes that.
    {
        SampleData s = dcSample(48000);
        s.play.playMode = PlayMode::Trigger;
        s.play.pitchEngine = PitchEngine::Preserve;

        CHECK(derivedFrames(s) == 48000 + kPad);
        const BakeAudio derived = bakeWith(s, 0.0);
        CHECK(peakAt(derived, 47990, 48000) > 0.4);  // full level at the source's own end
        CHECK(peakAt(derived, 48000, 48001) > 0.49);  // the ramp opens at that same level…
        CHECK(lastFrameLevel(derived) < 1e-3);        // …and the file ends at silence
        const BakeAudio wide = bakeWith(s, /*extraMs=*/50.0);
        CHECK(peakAt(wide, 48000 + kPad, wide.frameCount()) == 0.0);
    }

    // --- Trigger + a DRAWN amp EG: the window holds the WHOLE take -----------------------
    // A drawn contour covers the full sample length, so the engine folds lengthFraction to
    // 1.0 (effectiveLengthFraction, play_params.h). The stored %-knob is inert but still
    // saved, and reading it raw here cut this window to a quarter of the take.
    {
        SampleData s = dcSample(48000);
        s.play.playMode = PlayMode::Trigger;
        s.play.trigger.lengthFraction = 0.25;  // inert in the engine, and now in the window
        s.play.ampSpline.mode = EnvMode::Spline;
        s.play.ampSpline.contour = VelocityCurve::flat();  // full level across the sample

        CHECK(derivedFrames(s) == 48000 + kPad);  // the whole take, not 12000

        const BakeAudio derived = bakeWith(s, 0.0);
        // Full level across the 36000 frames a raw read of the stored knob used to cut. Both
        // spans start past the old 12000-frame end, so a truncated window reads 0 here.
        CHECK(peakAt(derived, 36000, 40000) > 0.4);
        CHECK(peakAt(derived, 47000, 48000) > 0.4);
        // A %-knob that IS live still shortens the window — the fold is conditional, not a
        // blanket ignore.
        SampleData staged = s;
        staged.play.ampSpline.mode = EnvMode::Staged;
        CHECK(derivedFrames(staged) == 12000 + kPad);
    }

    // ============================ RATE AND PITCH ====================================

    // The one judgement every case below makes: the derived window holds the WHOLE free-running
    // sound (the derived render stops exactly where the uncut one does), and it is exactly
    // enough rather than merely long. `heldSeconds` only has to exceed the free-running length.
    const auto windowHoldsTheWholeNote = [&](const SampleData& s, double heldSeconds,
                                             const char* what) {
        const std::int64_t trueEnd = freeRunningEnd(s, heldSeconds);
        const std::int64_t derived = derivedFrames(s);
        const std::int64_t got = lastSoundingFrame(bakeWith(s, 0.0));
        const bool held = trueEnd >= 0 && derived > trueEnd && got == trueEnd;
        CHECK(held);
        CHECK(held && derived - trueEnd <= kPad + 8);
        if (!(held && derived - trueEnd <= kPad + 8)) {
            std::printf("  %s: free-running end %lld, derived render end %lld, window %lld\n",
                        what, static_cast<long long>(trueEnd), static_cast<long long>(got),
                        static_cast<long long>(derived));
        }
    };

    // --- Rate scales the window under BOTH engines, in both derived branches --------------
    // Rate IS the read rate: Varispeed folds it into the read increment, Preserve feeds the
    // stretcher at it. Either way a 50 % rate doubles how long the source takes to play out and
    // a 200 % one halves it, so a window blind to Rate truncates by half at the slow end and
    // prints a file of trailing silence at the fast one.
    {
        for (PitchEngine eng : {PitchEngine::Varispeed, PitchEngine::Preserve}) {
            for (PlayMode mode : {PlayMode::Trigger, PlayMode::Gate}) {
                for (double rate : {0.5, 0.75, 1.0, 1.5, 2.0}) {
                    SampleData s = dcSample(48000);  // 1 s; 2 s at the slowest rate
                    s.play.playMode = mode;
                    s.play.pitchEngine = eng;
                    s.play.adsr.releaseFrames = 0;
                    s.play.playRate = rate;
                    char what[64];
                    std::snprintf(what, sizeof(what), "eng %d mode %d rate %.2f",
                                  static_cast<int>(eng), static_cast<int>(mode), rate);
                    windowHoldsTheWholeNote(s, 3.0, what);
                }
            }
        }
    }

    // --- A downward Pitch offset stretches the window under VARISPEED only ---------------
    // It is a factor of the read increment there and a shifter transpose under Preserve, so the
    // window follows it in one engine and not the other. Both must still hold the whole note.
    {
        SampleData s = dcSample(48000);
        s.play.playMode = PlayMode::Trigger;
        s.play.pitchEngine = PitchEngine::Varispeed;
        s.play.pitchOffsetSemitones = -12.0;  // half rate for the note's whole lifetime

        CHECK(derivedFrames(s) == 96000 + kPad);
        windowHoldsTheWholeNote(s, 3.0, "varispeed pitch -12");

        SampleData p = s;
        p.play.pitchEngine = PitchEngine::Preserve;
        CHECK(derivedFrames(p) == 48000 + kPad);  // the read rate never moved
        windowHoldsTheWholeNote(p, 3.0, "preserve pitch -12");

        // An UPWARD offset bounds nothing — the read only gets faster — so the window keeps the
        // un-stretched span and the balance is trailing silence, on the same asymmetry the
        // velocity->pitch term already takes.
        SampleData up = s;
        up.play.pitchOffsetSemitones = 12.0;
        CHECK(derivedFrames(up) == 48000 + kPad);
        const BakeAudio wideUp = bakeWith(up, /*extraMs=*/500.0);
        CHECK(peakAt(wideUp, 48000 + kPad, wideUp.frameCount()) == 0.0);
    }

    // --- Rate and Pitch COMPOUND, because the voice folds them into one multiply ----------
    {
        SampleData s = dcSample(48000);
        s.play.playMode = PlayMode::Trigger;
        s.play.pitchEngine = PitchEngine::Varispeed;
        s.play.playRate = 0.5;
        s.play.pitchOffsetSemitones = -12.0;  // together: a quarter-speed read

        CHECK(derivedFrames(s) == 192000 + kPad);
        windowHoldsTheWholeNote(s, 5.0, "varispeed rate 0.5 x pitch -12");
    }

    // --- Gate over a sustain loop is Hold's, and Rate does not touch it -------------------
    // The note length there is the user's Hold in wall clock and the release is ticked per
    // output frame, so neither term of the stretch applies — the one derived branch that must
    // NOT move when Rate does.
    {
        SampleData s = dcSample(48000);
        s.loop = SampleLoop{true, 0, 24000};
        s.play.playMode = PlayMode::Gate;
        s.play.adsr.releaseFrames = 4800;
        CHECK(bakeWindowNeedsHold(s));

        const std::int64_t unity = derivedFrames(s);
        for (double rate : {0.5, 2.0}) {
            SampleData r = s;
            r.play.playRate = rate;
            CHECK(derivedFrames(r) == unity);
        }
    }

    // ============================== VELOCITY ========================================

    // --- The bake renders at the velocity it is handed ----------------------------------
    // Three velocity curves are live, so a sound auditioned at 120 does not bake as one
    // auditioned at 40. linear() maps velocity/127 onto amp gain.
    {
        SampleData s = dcSample(24000);
        s.play.playMode = PlayMode::Trigger;
        s.velocityCurve = VelocityCurve::linear();

        const BakeAudio soft = bakeWith(s, 0.0, oneBar(), /*velocity=*/40);
        const BakeAudio hard = bakeWith(s, 0.0, oneBar(), /*velocity=*/120);
        const double softPeak = peakAt(soft, 0, 24000);
        const double hardPeak = peakAt(hard, 0, 24000);
        // 0.5 * 40/127 == 0.157, 0.5 * 120/127 == 0.472.
        CHECK(softPeak > 0.15 && softPeak < 0.17);
        CHECK(hardPeak > 0.46 && hardPeak < 0.48);
        CHECK(hardPeak > softPeak * 2.0);
    }

    // ============================== REFUSALS ========================================

    // --- A legitimate dialed sound past the frame ceiling is REFUSED, and says so --------
    {
        SampleData s = dcSample(1'100'000);
        s.play.playMode = PlayMode::Trigger;
        s.play.pitchEngine = PitchEngine::Varispeed;
        s.play.pitchEnv.enabled = true;
        s.play.pitchEnv.peakSemitones = -48.0;
        s.play.pitchEnv.shape.holdFraction = 1.0;
        const PlannedBake tooLong =
            planBake(resolveNote(derivedProgram(s, 0.0), tempo()), kRate, 60);
        CHECK(!tooLong.plan.has_value());
        CHECK(tooLong.refusal == BakeRefusal::PastFrameCeiling);

        // One octave shallower is inside the ceiling — the refusal above is the window, not
        // the fixture.
        s.play.pitchEnv.peakSemitones = -24.0;
        CHECK(planOf(derivedProgram(s, 0.0)).has_value());
    }

    // --- An empty window is a DIFFERENT refusal, so the shell can say a different thing ---
    {
        NoteProgram p;
        p.end = EndOffset(offsetFromMs(-5000.0));  // ends long before note-off: collapsed
        const PlannedBake empty = planBake(resolveNote(p, tempo()), kRate, 60);
        CHECK(!empty.plan.has_value());
        CHECK(empty.refusal == BakeRefusal::EmptyWindow);
    }

    if (g_fail == 0) std::printf("bake_window: all tests passed\n");
    return g_fail ? 1 : 0;
}
