// Standalone tests for reasampler::instrument::bake::bake_render — no VST3, no REAPER, no
// framework. Same fast assert loop as the sibling pure tests.
//
// Covers: Gate termination WITH a sustain loop active (the render must end at the window,
// and the tail must be silent because the gate actually released — not merely because the
// buffer ran out); Trigger termination on its own play span; channel-count preservation
// with no stereo fold; the master stage — gain and limiter — being PRINTED into the output;
// a lead-in rendered and discarded; byte-identical repeats; and the refusals (unplayable
// sample, empty window, a window past the frame ceiling).

#include "../src/core/instrument/bake/bake_render.h"

#include "../src/core/instrument/engine/limiter.h"
#include "../src/core/instrument/engine/live_params.h"

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

using namespace reasampler;
using namespace reasampler::instrument::bake;

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;

// Distinct per-channel DC so a downmix or a channel duplication is visible in the output
// rather than hidden behind two identical channels.
SampleData makeSample(bool stereo, std::size_t frames = 1000) {
    SampleData s;
    s.frames.assign(frames, 0.5f);
    if (stereo) s.framesR.assign(frames, -0.25f);
    s.sampleRate = kRate;
    s.rootNote = 60;
    return s;
}

// A ramp, not DC: an off-by-one read, a reversed span or an output shifted in time is
// visible in it and invisible in a constant. Trigger at its own root under Varispeed reads
// at ratio exactly 1 and hits no filter, so a neutral render prints the source frame for
// frame — which is what makes this fixture an exact expectation rather than a range.
SampleData makeRamp(std::size_t frames = 4000) {
    SampleData s;
    s.frames.resize(frames);
    for (std::size_t i = 0; i < frames; ++i)
        s.frames[i] = static_cast<float>(i) / static_cast<float>(frames) - 0.5f;
    s.sampleRate = kRate;
    s.rootNote = 60;
    s.play.playMode = PlayMode::Trigger;
    s.play.pitchEngine = PitchEngine::Varispeed;
    return s;
}

// Peak magnitude of channel 0 over [from, to) output frames.
double peakAt(const BakeAudio& audio, std::int64_t from, std::int64_t to) {
    double peak = 0.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;
}

// Peak magnitude over EVERY channel — a ceiling is a property of the file, not of one leg.
double peakAll(const BakeAudio& audio) {
    double peak = 0.0;
    for (AudioSample v : audio.interleaved) {
        const double m = std::fabs(static_cast<double>(v));
        if (m > peak) peak = m;
    }
    return peak;
}

bool sameSamples(const BakeAudio& a, const BakeAudio& b) {
    if (a.interleaved.size() != b.interleaved.size() || a.interleaved.empty()) return false;
    for (std::size_t i = 0; i < a.interleaved.size(); ++i)
        if (a.interleaved[i] != b.interleaved[i]) return false;
    return true;
}

BakePlan planOf(std::int64_t total, std::int64_t noteOn, std::int64_t noteOff,
                std::int64_t leadIn = 0) {
    BakePlan p;
    p.totalFrames = total;
    p.leadInFrames = leadIn;
    p.noteOnFrame = noteOn;
    p.noteOffFrame = noteOff;
    p.note = 60;
    p.velocity = 100;
    p.sampleRate = kRate;
    return p;
}

constexpr double kUnity = 1.0;
constexpr bool kNoLimiter = false;
constexpr bool kLimiter = true;

}  // namespace

int main() {
    // --- Gate, sustain loop active: the render terminates and the gate really released --
    {
        SampleData s = makeSample(/*stereo=*/false, /*frames=*/200);
        // A 100-frame loop over a 200-frame sample: held past the sample end it would cycle
        // forever, which is exactly the runaway the window has to bound.
        s.loop = SampleLoop{true, 0, 100};
        s.play.playMode = PlayMode::Gate;
        s.play.adsr.releaseFrames = 480;  // 10 ms — short enough to finish inside the tail

        const BakePlan plan = planOf(/*total=*/9600, /*noteOn=*/0, /*noteOff=*/4800);
        const BakeAudio audio = renderBake(s, plan, kUnity, kNoLimiter);

        CHECK(audio.frameCount() == 9600);          // bounded, not a runaway
        CHECK(audio.channelCount == 1);
        // Sounding right up to the release…
        CHECK(peakAt(audio, 4700, 4800) > 0.4);
        // …and silent well after it, which only holds if the note-off was honoured: the
        // loop would otherwise still be cycling at full level here.
        CHECK(peakAt(audio, 6000, 9600) < 1e-6);
    }

    // --- Trigger: note-off is ignored, the play span ends the sound -------------------
    {
        SampleData s = makeSample(/*stereo=*/false, /*frames=*/1000);
        s.play.playMode = PlayMode::Trigger;
        s.play.trigger.lengthFraction = 0.5;  // 500 source frames at unity ratio

        const BakePlan plan = planOf(/*total=*/2000, /*noteOn=*/0, /*noteOff=*/100);
        const BakeAudio audio = renderBake(s, plan, kUnity, kNoLimiter);

        CHECK(audio.frameCount() == 2000);
        // Still sounding past the note-off Trigger ignores…
        CHECK(peakAt(audio, 200, 400) > 0.4);
        // …and finished at its own span end, well before the window closes.
        CHECK(peakAt(audio, 700, 2000) < 1e-6);
    }

    // --- Channel count preserved; no stereo fold --------------------------------------
    {
        SampleData s = makeSample(/*stereo=*/true, /*frames=*/1000);
        s.play.playMode = PlayMode::Trigger;

        const BakePlan plan = planOf(/*total=*/500, /*noteOn=*/0, /*noteOff=*/500);
        const BakeAudio audio = renderBake(s, plan, kUnity, kNoLimiter);

        CHECK(audio.channelCount == 2);
        CHECK(audio.frameCount() == 500);
        CHECK(audio.interleaved.size() == 1000u);
        // The two channels carry the source's two distinct signals: a downmix would make
        // them equal, a duplication would make R equal L.
        CHECK(audio.interleaved[200] > 0.4f);
        CHECK(audio.interleaved[201] < -0.2f);
        CHECK(audio.interleaved[201] > -0.3f);
    }

    // --- The master gain is PRINTED into the file ---------------------------------------
    // The reset hands the control back at unity, so a render that summed voices alone would
    // shift every iteration by 1/gain — and a gain dialed to silence would come back loud.
    // Every render here is limiter-bypassed, so the exact scaling below is also the guard
    // that the limiter never engages on its own: +4x over this DC is far past the ceiling.
    {
        SampleData s = makeSample(/*stereo=*/true, /*frames=*/1000);
        s.play.playMode = PlayMode::Trigger;
        const BakePlan plan = planOf(/*total=*/500, /*noteOn=*/0, /*noteOff=*/500);

        const BakeAudio unity = renderBake(s, plan, kUnity, kNoLimiter);
        const BakeAudio quiet = renderBake(s, plan, 0.25, kNoLimiter);
        const BakeAudio loud = renderBake(s, plan, 4.0, kNoLimiter);
        const BakeAudio silent = renderBake(s, plan, 0.0, kNoLimiter);

        CHECK(unity.interleaved.size() == quiet.interleaved.size());
        bool scaled = !unity.interleaved.empty();
        for (std::size_t i = 0; scaled && i < unity.interleaved.size(); ++i) {
            scaled = std::fabs(quiet.interleaved[i] - unity.interleaved[i] * 0.25f) < 1e-6f &&
                     std::fabs(loud.interleaved[i] - unity.interleaved[i] * 4.0f) < 1e-5f;
        }
        CHECK(scaled);
        // Both channels, not just the one the peak helper reads.
        CHECK(quiet.interleaved[201] < 0.f && quiet.interleaved[201] > -0.1f);
        // A gain of zero prints silence rather than returning the sound at full level.
        CHECK(peakAt(silent, 0, 500) == 0.0);
        CHECK(peakAt(unity, 0, 500) > 0.4);
    }

    // --- The limiter is PRINTED when engaged: the file holds the ceiling -----------------
    // DC at 0.5 through +4x of gain is a constant 2.0 — over twice the ceiling for every
    // frame asserted, not a transient that a quiet fixture would let slide.
    {
        const double ceiling = instrument::engine::limiterCeilingLinear();
        SampleData s = makeSample(/*stereo=*/false, /*frames=*/4000);
        s.play.playMode = PlayMode::Trigger;
        const BakePlan plan = planOf(/*total=*/2000, /*noteOn=*/0, /*noteOff=*/2000);

        const BakeAudio unlimited = renderBake(s, plan, 4.0, kNoLimiter);
        const BakeAudio limited = renderBake(s, plan, 4.0, kLimiter);

        CHECK(unlimited.frameCount() == 2000);
        CHECK(limited.frameCount() == 2000);       // the lookahead does not shorten the file
        // The fixture really drives it: bypassed, the same render sits at twice the ceiling.
        CHECK(peakAt(unlimited, 0, 2000) > ceiling * 1.9);
        // …and engaged, not one printed sample is over it.
        CHECK(peakAll(limited) <= ceiling + 1e-6);
        // Held AT the ceiling once settled, not ducked to silence — a limiter that muted
        // everything would pass the bound above.
        CHECK(peakAt(limited, 1000, 2000) > ceiling * 0.9);

        // Repeat bakes are bit-identical with the limiter engaged too: the render builds its
        // own Limiter, and prepare() zeroes every one of its state fields.
        CHECK(sameSamples(limited, renderBake(s, plan, 4.0, kLimiter)));
    }

    // --- Engaged but below the ceiling: the render is the bypassed one, frame for frame ---
    // The limiter delays its output by its lookahead, so this is where a missing or wrong
    // compensation shows: an uncompensated render would print ~96 frames of silence at the
    // head and shift the whole capture late. Nothing here reaches the ceiling, so the
    // limiter's gain is exactly 1 at every sample and the two renders must agree bit for bit
    // — which also pins that the engaged render skips the transition mute (it would fade the
    // first 10 ms up from silence).
    {
        SampleData s = makeRamp();
        const BakePlan plan = planOf(/*total=*/1000, /*noteOn=*/0, /*noteOff=*/1000);

        const BakeAudio bypassed = renderBake(s, plan, kUnity, kNoLimiter);
        const BakeAudio engaged = renderBake(s, plan, kUnity, kLimiter);
        CHECK(sameSamples(bypassed, engaged));
        // And it is the SOURCE they both agree on, so this cannot pass by both being wrong
        // the same way.
        bool identity = engaged.frameCount() == 1000;
        for (std::size_t f = 0; identity && f < 1000; ++f)
            identity = (engaged.interleaved[f] == s.frames[f]);
        CHECK(identity);
    }

    // --- The limiter is stereo-LINKED, and both legs are printed -------------------------
    {
        const double ceiling = instrument::engine::limiterCeilingLinear();
        SampleData s = makeSample(/*stereo=*/true, /*frames=*/4000);  // L 0.5, R -0.25
        s.play.playMode = PlayMode::Trigger;
        const BakePlan plan = planOf(/*total=*/2000, /*noteOn=*/0, /*noteOff=*/2000);

        const BakeAudio limited = renderBake(s, plan, 4.0, kLimiter);
        CHECK(limited.channelCount == 2);
        CHECK(peakAll(limited) <= ceiling + 1e-6);
        // The quieter leg is limited by the louder one's peak rather than by its own, so the
        // source's exact 2:1 level ratio survives — one gain, not two. Both are exact: the
        // gain multiplies 2.0 and 1.0, and doubling a float is exact.
        bool linked = limited.frameCount() == 2000;
        for (std::size_t f = 1000; linked && f < 2000; ++f)
            linked = (limited.interleaved[f * 2] == -2.f * limited.interleaved[f * 2 + 1]);
        CHECK(linked);
        // Non-vacuous: the right leg is really sounding, so the ratio is not 0 == -0.
        CHECK(std::fabs(static_cast<double>(limited.interleaved[3001])) > 0.1);
    }

    // --- A lead-in is rendered and then discarded ---------------------------------------
    // A positive start offset trims the note's head: the frames before the window must be
    // produced (so the envelope really is mid-flight when the file opens) and dropped.
    {
        SampleData s = makeSample(/*stereo=*/false, /*frames=*/4000);
        s.play.playMode = PlayMode::Trigger;
        s.play.trigAhd.attackFrames = 1000;  // still climbing when the window opens

        const BakePlan trimmed = planOf(/*total=*/1000, /*noteOn=*/0, /*noteOff=*/2000,
                                        /*leadIn=*/1000);
        const BakeAudio audio = renderBake(s, trimmed, kUnity, kNoLimiter);
        CHECK(audio.frameCount() == 1000);  // the FILE is the window, not the render

        // Frame 0 of the file is frame 1000 of the render — the attack's end, not its
        // start. A clamped-away lead-in would put the attack's silent onset here instead.
        const BakeAudio whole = renderBake(s, planOf(/*total=*/2000, 0, 2000), kUnity,
                                           kNoLimiter);
        CHECK(peakAt(audio, 0, 1) > peakAt(whole, 0, 1));
        CHECK(std::fabs(static_cast<double>(audio.interleaved[0]) -
                        static_cast<double>(whole.interleaved[1000])) < 1e-6);

        // The lead-in and the lookahead are two independent offsets into one buffer: with the
        // limiter engaged under the ceiling, the trimmed window is still the same frames.
        CHECK(sameSamples(audio, renderBake(s, trimmed, kUnity, kLimiter)));
    }

    // --- Bit-identical repeats ---------------------------------------------------------
    {
        SampleData s = makeSample(/*stereo=*/true, /*frames=*/1000);
        s.loop = SampleLoop{true, 0, 333};
        s.play.adsr.attackFrames = 97;   // a shape whose per-frame state must replay exactly
        s.play.adsr.releaseFrames = 211;

        const BakePlan plan = planOf(/*total=*/4096, /*noteOn=*/13, /*noteOff=*/2731);
        const BakeAudio a = renderBake(s, plan, kUnity, kNoLimiter);
        const BakeAudio b = renderBake(s, plan, kUnity, kNoLimiter);

        CHECK(a.interleaved.size() == b.interleaved.size());
        CHECK(!a.interleaved.empty());
        CHECK(sameSamples(a, b));
        // The window opened before the note: those frames must be untouched silence.
        CHECK(peakAt(a, 0, 13) == 0.0);
        CHECK(peakAt(a, 200, 400) > 0.0);
    }

    // --- The bake is off the audio thread's block, structurally ------------------------
    // The only thing process() and a bake could share is the live-parameter block. This
    // pins that they do not: the caller's SampleData is untouched (renderBake took a
    // copy), and the render ignores what is published in the block — the bake prints the
    // dialed parameter set, not whatever the audio thread is currently observing.
    {
        instrument::engine::LiveParams block;
        SampleData s = makeSample(/*stereo=*/false, /*frames=*/1000);
        s.play.playMode = PlayMode::Trigger;
        s.play.trigAhd.attackFrames = 0;  // dialed: instant attack

        // Publish a MUCH slower attack into the block. A render that observed it would be
        // near-silent at the point the dialed shape is already at full level.
        s.live = &block;
        {
            PlayParams slow = s.play;
            slow.trigAhd.attackFrames = 900;
            block.publish(instrument::engine::foldLive(slow, s.keyTrack));
        }

        const BakePlan plan = planOf(/*total=*/1000, /*noteOn=*/0, /*noteOff=*/1000);
        const BakeAudio audio = renderBake(s, plan, kUnity, kNoLimiter);

        CHECK(s.live == &block);  // the caller's own snapshot was not detached
        // At frame 100 the dialed instant attack is at full level; the published 900-frame
        // attack would be barely a ninth of the way up.
        CHECK(peakAt(audio, 90, 110) > 0.4);

        // And it matches a render from a block-free copy exactly.
        SampleData detached = s;
        detached.live = nullptr;
        const BakeAudio reference = renderBake(detached, plan, kUnity, kNoLimiter);
        CHECK(sameSamples(audio, reference));
    }

    // --- Regression baseline: the neutral render is the source, sample for sample --------
    // A Trigger voice at its own root under Varispeed reads at ratio exactly 1 and hits no
    // filter, so every printed frame equals its source frame PROVIDED the amp curve's gain at
    // the plan's velocity is exactly 1.0 too (asserted below rather than assumed) — that exact
    // value is a property of flat()'s two endpoints cancelling at velocity 100, not a
    // guarantee of eval() at an arbitrary velocity. An added stage, a moved default, or a lost
    // early-out anywhere in the chain moves a sample here.
    {
        SampleData s = makeRamp();

        // Shorter than the play span, so the window closes before any note-end shaping.
        const BakePlan plan = planOf(/*total=*/1000, /*noteOn=*/0, /*noteOff=*/1000);
        CHECK(s.velocityCurve.eval(100.0) == 1.0);  // names the real cause if this ever fails
        const BakeAudio audio = renderBake(s, plan, kUnity, kNoLimiter);
        CHECK(audio.channelCount == 1);
        CHECK(audio.frameCount() == 1000);

        bool identity = audio.frameCount() == 1000;
        for (std::size_t f = 0; identity && f < 1000; ++f)
            identity = (audio.interleaved[f] == s.frames[f]);
        CHECK(identity);

        // …and the gain rides that as an exact scalar, which is the only other thing the
        // render is permitted to do to the signal with the limiter bypassed.
        const BakeAudio halved = renderBake(s, plan, 0.5, kNoLimiter);
        bool scaled = halved.frameCount() == 1000;
        for (std::size_t f = 0; scaled && f < 1000; ++f)
            scaled = (halved.interleaved[f] == s.frames[f] * 0.5f);
        CHECK(scaled);
    }

    // --- Refusals -----------------------------------------------------------------------
    {
        SampleData empty;  // nothing decoded
        empty.sampleRate = kRate;
        CHECK(renderBake(empty, planOf(1000, 0, 500), kUnity, kNoLimiter).empty());

        SampleData s = makeSample(false);
        CHECK(renderBake(s, planOf(0, 0, 0), kUnity, kNoLimiter).empty());
        // The ceiling planBake enforces is re-checked here: a hand-built plan must not be
        // able to walk the render into an allocation it cannot hold.
        CHECK(renderBake(s, planOf(kMaxBakeFrames, 0, 0, /*leadIn=*/1), kUnity, kNoLimiter)
                  .empty());
        CHECK(renderBake(s, planOf(1000, 0, 500, /*leadIn=*/-1), kUnity, kNoLimiter).empty());
        // A hand-built plan can carry a lead-in near the int64 ceiling; the guard must trip
        // on that field alone rather than signed-overflowing inside renderFrames()'s sum.
        CHECK(renderBake(s,
                          planOf(1000, 0, 500,
                                 /*leadIn=*/std::numeric_limits<std::int64_t>::max() - 10),
                          kUnity, kNoLimiter)
                  .empty());
    }

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