// Standalone tests for reasampler::instrument::bake::bake_plan — no VST3, no REAPER, no
// framework. Same fast assert loop as the sibling pure tests.
//
// Covers: the default program's window derived from the dialed sound (Gate's hold to source
// exhaustion plus its release, a start point, a Trigger play span, and the Varispeed
// read-stretch bound on both branches that take it);
// the frame window and both event frames against hand-computed values; a capture opening
// BEFORE note-on and one opening AFTER it; the refusals and what each one reports — a
// collapsed window, a non-positive rate, a window that rounds to nothing, and one past the
// frame ceiling; and root/velocity clamping.

#include "../src/core/instrument/bake/bake_plan.h"
#include "../src/core/instrument/engine/voice.h"  // kDeclickFrames (the window's tail pad)

#include <cstdio>

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)

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

namespace {

constexpr int kRate = 48000;

// Every derived window carries the voice's terminal declick ramp as trailing silence.
const double kPadSeconds = static_cast<double>(kDeclickFrames) / kRate;

SampleData dialedSample(std::size_t frames = 96000) {
    SampleData s;
    s.frames.assign(frames, 0.5f);
    s.sampleRate = kRate;
    s.rootNote = 60;
    return s;
}

// The Hold default; read only where the window is underivable, which is nowhere in this file.
Division oneBar() { return makeDivision(2, DivisionModifier::Straight); }

// `t` is unused by the derivation itself — it takes no tempo — but every caller here resolves
// against the same one, so threading it keeps each case's two halves visibly paired.
NoteProgram derived(const SampleData& s, Tempo) {
    return defaultBakeProgram(s, kRate, oneBar(), Velocity{});
}

bool near(double a, double b) { return a > b - 1e-6 && a < b + 1e-6; }

}  // namespace

int main() {
    // --- Gate with no loop: held to source exhaustion, then the DIALED release ----------
    {
        SampleData s = dialedSample();                  // 2 s of source == 4 beats
        s.play.playMode = PlayMode::Gate;
        s.play.adsr.releaseFrames = kRate * 3 / 2;      // 1.5 s — past any fixed tail
        const ResolvedNote r = resolveNote(derived(s, at(120.0)), at(120.0));
        // The note is held to exhaustion — 2 s exactly — instead of the constant quarter note
        // that released it early.
        CHECK(near(r.noteOffSeconds, 2.0));
        CHECK(r.captureStartSeconds == 0.0);
        CHECK(near(r.captureEndSeconds, 2.0 + 1.5 + kPadSeconds));
        CHECK(!r.windowCollapsed);

        // A shorter release yields a shorter window — the derivation really reads the knob.
        s.play.adsr.releaseFrames = kRate / 10;  // 0.1 s
        const ResolvedNote shorter = resolveNote(derived(s, at(120.0)), at(120.0));
        CHECK(near(shorter.captureEndSeconds, 2.0 + 0.1 + kPadSeconds));

        // The length is EXACT, not rounded onto the ladder: 2.5 s is 5 beats, which no rung
        // hits — the ladder's nearest never-short answer is the 2/1 triplet at 5.33 beats, and
        // taking it would buy a third of a second of silence for nothing. The tempo is not an
        // input to a derived length at all, so the same sound derives the same seconds at any.
        SampleData odd = dialedSample(static_cast<std::size_t>(kRate * 5 / 2));  // 2.5 s
        odd.play.playMode = PlayMode::Gate;
        CHECK(near(resolveNote(derived(odd, at(120.0)), at(120.0)).noteOffSeconds, 2.5));
        CHECK(near(resolveNote(derived(odd, at(97.0)), at(97.0)).noteOffSeconds, 2.5));
    }

    // --- Gate with no loop, a START POINT set: only the post-start span is held ----------
    // effectiveStart's whole reason to exist. A start marker means the head begins there, so
    // holding the note for the WHOLE source would pad the window with silence the voice
    // already finished; holding it for the source minus the start is exact.
    {
        SampleData s = dialedSample(/*frames=*/kRate * 2);  // 2 s
        s.play.playMode = PlayMode::Gate;
        s.play.adsr.releaseFrames = 0;
        s.startFrame = kRate / 2;  // start half a second in -> 1.5 s of sound left
        CHECK(near(resolveNote(derived(s, at(120.0)), at(120.0)).noteOffSeconds, 1.5));

        // Voice::start's own degenerate rule: a start at or past the end plays from the top,
        // so the window covers the whole source rather than nothing.
        s.startFrame = kRate * 2;
        CHECK(near(resolveNote(derived(s, at(120.0)), at(120.0)).noteOffSeconds, 2.0));
        s.startFrame = -1;
        CHECK(near(resolveNote(derived(s, at(120.0)), at(120.0)).noteOffSeconds, 2.0));
    }

    // --- Gate with no loop under Varispeed: the read-stretch bound applies here too -------
    // The Trigger branch has its own coverage below; this pins that the Gate exhaustion length
    // takes the same bound, which is the branch a downward offset would otherwise truncate.
    {
        SampleData s = dialedSample(/*frames=*/kRate);  // 1 s
        s.play.playMode = PlayMode::Gate;
        s.play.adsr.releaseFrames = 0;
        s.play.pitchEngine = PitchEngine::Varispeed;
        s.play.pitchEnv.enabled = true;
        s.play.pitchEnv.peakSemitones = -12.0;  // an octave down == half speed at the peak
        CHECK(near(resolveNote(derived(s, at(120.0)), at(120.0)).noteOffSeconds, 2.0));

        // Preserve reads at the source rate, so the same dial bounds nothing.
        s.play.pitchEngine = PitchEngine::Preserve;
        CHECK(near(resolveNote(derived(s, at(120.0)), at(120.0)).noteOffSeconds, 1.0));
    }

    // --- Trigger: the window is the play span, which ignores the note's length ----------
    {
        SampleData s = dialedSample(/*frames=*/kRate * 2);  // 2 s of source
        s.play.playMode = PlayMode::Trigger;
        s.play.trigger.lengthFraction = 0.75;  // 1.5 s of it
        const ResolvedNote r = resolveNote(derived(s, at(120.0)), at(120.0));
        CHECK(r.captureStartSeconds == 0.0);
        CHECK(near(r.captureEndSeconds, 1.5 + kPadSeconds));

        // A shorter %-length is a shorter window, with no floor under it: the sound is over
        // when the span is, and note-off means nothing to a Trigger voice.
        s.play.trigger.lengthFraction = 0.1;  // 0.2 s
        const ResolvedNote brief = resolveNote(derived(s, at(120.0)), at(120.0));
        CHECK(!brief.windowCollapsed);
        CHECK(near(brief.captureEndSeconds, 0.2 + kPadSeconds));
    }

    // --- Trigger under Varispeed: a downward pitch offset stretches the read -----------
    {
        SampleData s = dialedSample(/*frames=*/kRate);  // 1 s, played whole
        s.play.playMode = PlayMode::Trigger;
        s.play.pitchEngine = PitchEngine::Varispeed;
        s.play.pitchEnv.enabled = true;
        s.play.pitchEnv.peakSemitones = -12.0;  // an octave down = half speed at the peak
        const ResolvedNote r = resolveNote(derived(s, at(120.0)), at(120.0));
        // Bounded at the deepest offset: 1 s of source can take up to 2 s to cross.
        CHECK(near(r.captureEndSeconds, 2.0 + kPadSeconds));

        // Preserve decouples pitch from the read rate, so the same dial bounds nothing.
        s.play.pitchEngine = PitchEngine::Preserve;
        const ResolvedNote kept = resolveNote(derived(s, at(120.0)), at(120.0));
        CHECK(near(kept.captureEndSeconds, 1.0 + kPadSeconds));
    }

    // --- The bake fires at the velocity it is handed, and the Varispeed bound reads it ---
    {
        SampleData s = dialedSample(/*frames=*/kRate);
        s.play.playMode = PlayMode::Trigger;
        s.play.pitchEngine = PitchEngine::Varispeed;
        // A bipolar velocity->pitch curve pulling a full octave down at velocity 0 and
        // nothing at 127: the two velocities must therefore derive different windows.
        s.play.pitchVelocityCurve = VelocityCurve::fromPoints(
            {{0.0, -0.5}, {127.0, 0.0}}, reasampler::instrument::engine::CurveDomain::Bipolar);

        const NoteProgram soft =
            defaultBakeProgram(s, kRate, oneBar(), Velocity::of(1));
        const NoteProgram hard =
            defaultBakeProgram(s, kRate, oneBar(), Velocity::of(127));
        CHECK(soft.velocity.value() == 1);
        CHECK(hard.velocity.value() == 127);
        const ResolvedNote softR = resolveNote(soft, at(120.0));
        const ResolvedNote hardR = resolveNote(hard, at(120.0));
        CHECK(near(hardR.captureEndSeconds, 1.0 + kPadSeconds));   // no offset at 127
        CHECK(softR.captureEndSeconds > hardR.captureEndSeconds * 1.9);  // ~an octave down
        // …and the velocity reaches the plan, which is what the render fires.
        CHECK(planBake(softR, kRate, 60).plan->velocity == 1);
    }

    // --- Gate with an ACTIVE sustain loop is the one case that needs Hold ---------------
    {
        SampleData s = dialedSample(/*frames=*/kRate);
        s.play.playMode = PlayMode::Gate;
        s.loop = SampleLoop{true, 0, kRate / 2};
        CHECK(bakeWindowNeedsHold(s));
        // The window follows Hold rather than the source, so a longer Hold is a longer file.
        const ResolvedNote bar = resolveNote(
            defaultBakeProgram(s, kRate, oneBar(), Velocity{}), at(120.0));
        const ResolvedNote twoBars = resolveNote(
            defaultBakeProgram(s, kRate,
                               makeDivision(3, DivisionModifier::Straight), Velocity{}),
            at(120.0));
        CHECK(near(bar.noteOffSeconds, 2.0));
        CHECK(near(twoBars.noteOffSeconds, 4.0));
        CHECK(near(twoBars.captureEndSeconds - bar.captureEndSeconds, 2.0));
    }

    // --- The frame window and both event frames ----------------------------------
    {
        NoteProgram p;                             // 1/4 straight, velocity 100
        p.end = EndOffset(offsetFromMs(250.0));
        const ResolvedNote r = resolveNote(p, at(120.0));  // note-off 0.5 s, end 0.75 s
        const auto plan = planBake(r, 48000, 60).plan;
        CHECK(plan.has_value());
        CHECK(plan->totalFrames == 36000);   // 0.75 s * 48 kHz
        CHECK(plan->leadInFrames == 0);
        CHECK(plan->renderFrames() == 36000);
        CHECK(plan->noteOnFrame == 0);
        CHECK(plan->noteOffFrame == 24000);  // 0.5 s * 48 kHz
        CHECK(plan->sampleRate == 48000);
        CHECK(plan->note == 60);
        CHECK(plan->velocity == 100);
    }

    // --- A capture that opens BEFORE note-on -------------------------------------
    {
        NoteProgram p;
        p.start = StartOffset(offsetFromMs(-100.0));  // negative = earlier
        p.end = EndOffset(offsetFromMs(100.0));
        const ResolvedNote r = resolveNote(p, at(120.0));
        const auto plan = planBake(r, 44100, 60).plan;
        CHECK(plan.has_value());
        // Window is [-0.1, 0.6] s = 0.7 s; note-on sits 0.1 s in, note-off 0.5 s after it.
        CHECK(plan->totalFrames == 30870);
        CHECK(plan->leadInFrames == 0);   // nothing to discard: the file opens first
        CHECK(plan->noteOnFrame == 4410);
        CHECK(plan->noteOffFrame == 26460);
        CHECK(plan->noteOffFrame - plan->noteOnFrame == 22050);  // the note's own length
    }

    // --- A capture that opens AFTER note-on (a positive start trims the attack) ---
    {
        NoteProgram p;
        p.start = StartOffset(offsetFromMs(100.0));  // positive = later: the head is cut
        p.end = EndOffset(offsetFromMs(100.0));
        const ResolvedNote r = resolveNote(p, at(120.0));
        const auto plan = planBake(r, 48000, 60).plan;
        CHECK(plan.has_value());
        // Window is [0.1, 0.6] s = 0.5 s of FILE, but the note starts 0.1 s before it, so
        // the render must produce that head and throw it away rather than shift the note.
        CHECK(plan->totalFrames == 24000);
        CHECK(plan->leadInFrames == 4800);
        CHECK(plan->renderFrames() == 28800);
        CHECK(plan->noteOnFrame == 0);       // the note is at the START of the render
        CHECK(plan->noteOffFrame == 24000);  // still its full 0.5 s length
        CHECK(plan->noteOffFrame - plan->noteOnFrame == 24000);
    }

    // --- Refusals, and which one each condition reports -----------------------------
    {
        NoteProgram p;
        // An end offset more negative than the note length inverts the window.
        p.end = EndOffset(offsetFromMs(-10000.0));
        const ResolvedNote r = resolveNote(p, at(120.0));
        CHECK(r.windowCollapsed);
        CHECK(planBake(r, 48000, 60).refusal == BakeRefusal::EmptyWindow);
    }
    {
        NoteProgram plain;
        const ResolvedNote r = resolveNote(plain, at(120.0));
        CHECK(planBake(r, 0, 60).refusal == BakeRefusal::EmptyWindow);
        CHECK(planBake(r, -48000, 60).refusal == BakeRefusal::EmptyWindow);
    }
    {
        // A legal but sub-frame window rounds to nothing and is refused rather than
        // rendered as a degenerate buffer.
        NoteProgram p;
        p.end = EndOffset(offsetFromMs(-500.0));  // exactly cancels the 0.5 s note
        const ResolvedNote r = resolveNote(p, at(120.0));
        CHECK(!r.windowCollapsed);
        CHECK(r.captureLengthSeconds() == 0.0);
        CHECK(planBake(r, 48000, 60).refusal == BakeRefusal::EmptyWindow);
    }
    {
        // A legal offset magnitude reaches days: refused at the ceiling, not attempted as
        // an allocation (and never narrowed out of int64's range on the way there). This
        // refusal reads differently to the user — a real sound that will not fit, not an
        // empty window — so it must be a distinct value, not just a nullopt.
        const double overSeconds =
            (static_cast<double>(kMaxBakeFrames) / 48000.0) + 1.0;
        NoteProgram p;
        p.end = EndOffset(offsetFromMs(overSeconds * 1000.0));
        const ResolvedNote big = resolveNote(p, at(120.0));
        CHECK(!big.windowCollapsed);
        CHECK(planBake(big, 48000, 60).refusal == BakeRefusal::PastFrameCeiling);

        // The extreme a legal OffsetAmount can hold, in both directions.
        NoteProgram huge;
        huge.end = EndOffset(offsetFromMs(kMaxConvertibleMagnitude));
        CHECK(planBake(resolveNote(huge, at(120.0)), 48000, 60).refusal ==
              BakeRefusal::PastFrameCeiling);
        NoteProgram far;
        far.start = StartOffset(offsetFromMs(-kMaxConvertibleMagnitude));
        CHECK(planBake(resolveNote(far, at(120.0)), 48000, 60).refusal ==
              BakeRefusal::PastFrameCeiling);

        // And just under it still plans, so the ceiling is a bound, not a blanket refusal.
        NoteProgram fits;
        fits.end = EndOffset(offsetFromMs(
            (static_cast<double>(kMaxBakeFrames) / 48000.0 - 1.0) * 1000.0));
        const PlannedBake planned = planBake(resolveNote(fits, at(120.0)), 48000, 60);
        CHECK(planned.plan.has_value());
        CHECK(planned.refusal == BakeRefusal::None);
        CHECK(planned.plan && planned.plan->renderFrames() <= kMaxBakeFrames);
    }

    // --- Domain clamps -------------------------------------------------------------
    {
        NoteProgram p;
        p.end = EndOffset(offsetFromMs(100.0));
        const ResolvedNote r = resolveNote(p, at(120.0));
        const auto low = planBake(r, 48000, -5).plan;
        const auto high = planBake(r, 48000, 900).plan;
        const auto mid = planBake(r, 48000, 60).plan;
        CHECK(low.has_value() && high.has_value() && mid.has_value());
        if (low && high && mid) {
            CHECK(low->note == 0);
            CHECK(high->note == 127);
            CHECK(mid->note == 60);
        }
    }

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