// Standalone tests for reasampler::instrument::bake::bake_reset — no VST3, no REAPER, no
// framework. Same fast assert loop as the sibling pure tests.
//
// The ratified reset scope itself is bake/CLAUDE.md's; this sweep checks it field by field
// (never struct equality, which would pass while silently resetting a survivor) over TWO
// independently dialed fixtures, so a pass is a property of the survivors, not of one input.

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

// The loop's neutral is loop_marks' definition of it, not this test's reading of it: what a
// reset loop LOOKS like on the band is the thing worth asserting, and only that module says.
#include "../src/core/instrument/ui/loop_marks.h"

#include <cstdint>
#include <cstdio>
#include <optional>

using namespace reasampler;
using namespace reasampler::instrument::bake;
using reasampler::instrument::map::InstrumentParams;
using reasampler::instrument::map::PlaySeconds;
using reasampler::instrument::ui::LoopMarks;
using reasampler::instrument::ui::StoredLoop;
using reasampler::instrument::ui::resolveLoopMarks;

namespace note = reasampler::instrument::note;
namespace filter = reasampler::instrument::engine::filter;

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

namespace {

// Every field moved off its default, so a reset that misses one is visible.
InstrumentParams dialed() {
    InstrumentParams p;
    p.rootOverride = 43;
    p.loopOverride = SampleLoop{true, 111, 222};
    p.startPoint = 4321;
    p.loopCrossfadeFrames = 512;
    p.keyTrack = 0.5;
    p.velocityCurve = VelocityCurve::linear();

    p.play.playMode = PlayMode::Trigger;
    p.play.adsr.attackSeconds = 0.4;
    p.play.adsr.holdSeconds = 0.3;
    p.play.adsr.decaySeconds = 0.2;
    p.play.adsr.sustainLevel = 0.1;
    p.play.adsr.releaseSeconds = 0.9;
    p.play.adsr.attackCurve = 2.5;
    p.play.adsr.decayCurve = 0.4;
    p.play.adsr.releaseCurve = 3.0;
    p.play.trigger.lengthFraction = 0.25;
    p.play.trigAhd.attackSeconds = 0.11;
    p.play.trigAhd.decaySeconds = 0.22;
    p.play.trigAhd.holdFraction = 0.33;
    p.play.trigAhd.attackCurve = 1.7;
    p.play.trigAhd.decayCurve = 2.2;
    p.play.pitchEngine = PitchEngine::Varispeed;
    p.play.pitchEnv.enabled = true;
    p.play.pitchEnv.peakSemitones = -7.0;
    p.play.pitchEnv.shape.attackSeconds = 0.05;
    p.play.pitchEnv.shape.decaySeconds = 0.15;
    p.play.pitchEnv.shape.holdFraction = 0.6;
    p.play.pitchEnv.shape.attackCurve = 2.1;
    p.play.pitchEnv.shape.decayCurve = 0.4;
    p.play.pitchVelocityCurve = VelocityCurve::linear();
    p.play.playRate = 0.5;
    p.play.pitchOffsetSemitones = -7.5;
    p.play.filter.enabled = true;
    p.play.filter.settings = filter::FilterSettings{0.3f, 0.8f, 0.1f, 0.7f,
                                                    filter::MorphLaw::HighNotchLow};
    p.play.filter.modAmount = -0.8;
    p.play.filter.velAmount = 0.6;
    p.play.filter.keyTrack = 1.5;
    p.play.filter.env.attackSeconds = 0.7;
    p.play.filter.env.holdSeconds = 0.15;
    p.play.filter.env.decaySeconds = 0.35;
    p.play.filter.env.sustainLevel = 0.25;
    p.play.filter.env.releaseSeconds = 0.55;
    p.play.filter.env.attackCurve = 2.3;
    p.play.filter.env.decayCurve = 0.5;
    p.play.filter.env.releaseCurve = 2.8;
    p.play.filter.trigEnv.attackSeconds = 0.12;
    p.play.filter.trigEnv.decaySeconds = 0.8;
    p.play.filter.trigEnv.holdFraction = 0.44;
    p.play.filter.trigEnv.attackCurve = 1.6;
    p.play.filter.trigEnv.decayCurve = 0.6;
    p.play.filter.velocityCurve = VelocityCurve::linear();
    p.play.ampSpline.mode = EnvMode::Spline;
    p.play.ampSpline.contour = VelocityCurve::linear();
    p.play.pitchSpline.mode = EnvMode::Spline;
    p.play.pitchSpline.contour = VelocityCurve::linear();
    p.play.filterSpline.mode = EnvMode::Spline;
    p.play.filterSpline.contour = VelocityCurve::linear();
    p.bakeHold = note::makeDivision(0, note::DivisionModifier::Dotted);
    p.limiterEnabled = true;
    return p;
}

// A SECOND dialed instrument, agreeing with the first on the SURVIVORS and disagreeing on
// every other field — including play mode, which is a chosen neutral rather than a survivor.
// Both run the same neutral sweep: if the reset ever inverted into "copy the dialed set, then
// clear a blacklist", a non-survivor would come through and at most one fixture could still
// land on the defaults. The inherent limit this does NOT cover: nothing forces a newly added
// `InstrumentParams` field to be dialled in either fixture at all, let alone asserted.
InstrumentParams dialedOther() {
    InstrumentParams p;
    p.rootOverride = 43;   // survivor — same as dialed()
    p.keyTrack = 0.5;      // survivor — same as dialed()
    p.loopOverride = SampleLoop{true, 7, 909};
    p.startPoint = 12;
    p.loopCrossfadeFrames = 64;
    p.velocityCurve = VelocityCurve::rampDown();

    p.play.playMode = PlayMode::Gate;
    p.play.adsr.attackSeconds = 1.4;
    p.play.adsr.holdSeconds = 1.3;
    p.play.adsr.decaySeconds = 1.2;
    p.play.adsr.sustainLevel = 0.7;
    p.play.adsr.releaseSeconds = 1.9;
    p.play.adsr.attackCurve = 0.3;
    p.play.adsr.decayCurve = 3.4;
    p.play.adsr.releaseCurve = 0.6;
    p.play.trigger.lengthFraction = 0.75;
    p.play.trigAhd.attackSeconds = 0.91;
    p.play.trigAhd.decaySeconds = 0.82;
    p.play.trigAhd.holdFraction = 0.13;
    p.play.trigAhd.attackCurve = 0.7;
    p.play.trigAhd.decayCurve = 0.9;
    p.play.pitchEngine = PitchEngine::Preserve;
    p.play.pitchEnv.enabled = true;
    p.play.pitchEnv.peakSemitones = 11.0;
    p.play.pitchEnv.shape.attackSeconds = 0.25;
    p.play.pitchEnv.shape.decaySeconds = 0.35;
    p.play.pitchEnv.shape.holdFraction = 0.2;
    p.play.pitchEnv.shape.attackCurve = 0.5;
    p.play.pitchEnv.shape.decayCurve = 2.4;
    p.play.pitchVelocityCurve = VelocityCurve::rampDown();
    p.play.playRate = 1.75;
    p.play.pitchOffsetSemitones = 3.25;
    p.play.filter.enabled = true;
    p.play.filter.settings = filter::FilterSettings{0.9f, 0.2f, 0.4f, 0.05f,
                                                    filter::MorphLaw::HighNotchLow};
    p.play.filter.modAmount = 0.45;
    p.play.filter.velAmount = -0.35;
    p.play.filter.keyTrack = -0.9;
    p.play.filter.env.attackSeconds = 1.7;
    p.play.filter.env.holdSeconds = 0.95;
    p.play.filter.env.decaySeconds = 0.75;
    p.play.filter.env.sustainLevel = 0.85;
    p.play.filter.env.releaseSeconds = 0.15;
    p.play.filter.env.attackCurve = 0.4;
    p.play.filter.env.decayCurve = 2.9;
    p.play.filter.env.releaseCurve = 0.35;
    p.play.filter.trigEnv.attackSeconds = 0.62;
    p.play.filter.trigEnv.decaySeconds = 1.8;
    p.play.filter.trigEnv.holdFraction = 0.77;
    p.play.filter.trigEnv.attackCurve = 0.3;
    p.play.filter.trigEnv.decayCurve = 2.5;
    p.play.filter.velocityCurve = VelocityCurve::rampDown();
    p.play.ampSpline.mode = EnvMode::Spline;
    p.play.ampSpline.contour = VelocityCurve::flat();
    p.play.pitchSpline.mode = EnvMode::Spline;
    p.play.pitchSpline.contour = VelocityCurve::flat();
    p.play.filterSpline.mode = EnvMode::Spline;
    p.play.filterSpline.contour = VelocityCurve::flat();
    p.bakeHold = note::makeDivision(2, note::DivisionModifier::Triplet);
    p.limiterEnabled = true;
    return p;
}

bool sameCurve(const VelocityCurve& a, const VelocityCurve& b) {
    if (a.domain() != b.domain() || a.size() != b.size()) return false;
    for (std::size_t i = 0; i < a.size(); ++i) {
        if (a.points()[i].velocity != b.points()[i].velocity) return false;
        if (a.points()[i].value != b.points()[i].value) return false;
    }
    return true;
}

// THE reset neutral, field by field. Run against every fixture, so the two can never be
// asserted against two different notions of neutral.
void checkNeutral(const BakeReset& reset) {
    const InstrumentParams& after = reset.params;
    const InstrumentParams fresh;      // the defaults every reset control must land on
    const PlaySeconds freshPlay;

    // --- RESET: loop points, start point, crossfade ---------------------------------
    CHECK(!after.loopOverride.has_value());
    CHECK(!after.startPoint.has_value());
    CHECK(after.loopCrossfadeFrames == 0);

    // --- RESET: the velocity transfer curves ----------------------------------------
    CHECK(sameCurve(after.velocityCurve, VelocityCurve::flat()));
    CHECK(sameCurve(after.play.pitchVelocityCurve, VelocityCurve::zero()));
    CHECK(sameCurve(after.play.filter.velocityCurve, VelocityCurve::zero()));

    // --- RESET: play mode, to TRIGGER rather than to the struct's Gate default -------
    // bake_reset.cpp carries the argument at the assignment.
    CHECK(after.play.playMode == PlayMode::Trigger);
    CHECK(freshPlay.playMode == PlayMode::Gate);  // and that really is NOT the default
    // The Trigger face it lands on plays the whole file flat: full span, unity throughout.
    CHECK(after.play.trigger.lengthFraction == 1.0);
    CHECK(after.play.trigAhd.attackSeconds == 0.0);
    CHECK(after.play.trigAhd.decaySeconds == 0.0);

    // --- RESET: the amp envelope, staged, every stage and every curve exponent ------
    CHECK(after.play.adsr.attackSeconds == freshPlay.adsr.attackSeconds);
    CHECK(after.play.adsr.holdSeconds == freshPlay.adsr.holdSeconds);
    CHECK(after.play.adsr.decaySeconds == freshPlay.adsr.decaySeconds);
    CHECK(after.play.adsr.sustainLevel == freshPlay.adsr.sustainLevel);
    CHECK(after.play.adsr.releaseSeconds == freshPlay.adsr.releaseSeconds);
    CHECK(after.play.adsr.attackCurve == freshPlay.adsr.attackCurve);
    CHECK(after.play.adsr.decayCurve == freshPlay.adsr.decayCurve);
    CHECK(after.play.adsr.releaseCurve == freshPlay.adsr.releaseCurve);
    CHECK(after.play.trigger.lengthFraction == freshPlay.trigger.lengthFraction);
    CHECK(after.play.trigAhd.attackSeconds == freshPlay.trigAhd.attackSeconds);
    CHECK(after.play.trigAhd.decaySeconds == freshPlay.trigAhd.decaySeconds);
    CHECK(after.play.trigAhd.holdFraction == freshPlay.trigAhd.holdFraction);
    CHECK(after.play.trigAhd.attackCurve == freshPlay.trigAhd.attackCurve);
    CHECK(after.play.trigAhd.decayCurve == freshPlay.trigAhd.decayCurve);

    // --- RESET: pitch engine + pitch envelope ---------------------------------------
    CHECK(after.play.pitchEngine == freshPlay.pitchEngine);
    CHECK(after.play.pitchEngine == PitchEngine::Preserve);  // the product default
    CHECK(!after.play.pitchEnv.enabled);
    CHECK(after.play.pitchEnv.peakSemitones == 0.0);
    CHECK(after.play.pitchEnv.shape.attackSeconds == freshPlay.pitchEnv.shape.attackSeconds);
    CHECK(after.play.pitchEnv.shape.decaySeconds == freshPlay.pitchEnv.shape.decaySeconds);
    CHECK(after.play.pitchEnv.shape.holdFraction == freshPlay.pitchEnv.shape.holdFraction);
    CHECK(after.play.pitchEnv.shape.attackCurve == freshPlay.pitchEnv.shape.attackCurve);
    CHECK(after.play.pitchEnv.shape.decayCurve == freshPlay.pitchEnv.shape.decayCurve);

    // --- RESET: Rate and the baseline Pitch offset -----------------------------------
    // Both are processing the bake already printed, so the whitelist leaves them at their
    // defaults — the safe direction. A second bake of the result at a still-dialled rate would
    // otherwise re-stretch what the first one baked in.
    CHECK(after.play.playRate == 1.0);
    CHECK(after.play.pitchOffsetSemitones == 0.0);
    CHECK(after.play.playRate == freshPlay.playRate);
    CHECK(after.play.pitchOffsetSemitones == freshPlay.pitchOffsetSemitones);

    // --- RESET: the filter, its control positions, and its velocity/key-tracking mod --
    CHECK(!after.play.filter.enabled);
    CHECK(after.play.filter.modAmount == 0.0);
    CHECK(after.play.filter.velAmount == 0.0);
    CHECK(after.play.filter.keyTrack == 0.0);
    CHECK(after.play.filter.settings.cutoffNorm == freshPlay.filter.settings.cutoffNorm);
    CHECK(after.play.filter.settings.resonanceNorm == freshPlay.filter.settings.resonanceNorm);
    CHECK(after.play.filter.settings.morphNorm == freshPlay.filter.settings.morphNorm);
    CHECK(after.play.filter.settings.driveNorm == freshPlay.filter.settings.driveNorm);
    CHECK(after.play.filter.settings.morphLaw == freshPlay.filter.settings.morphLaw);
    CHECK(after.play.filter.env.attackSeconds == freshPlay.filter.env.attackSeconds);
    CHECK(after.play.filter.env.holdSeconds == freshPlay.filter.env.holdSeconds);
    CHECK(after.play.filter.env.decaySeconds == freshPlay.filter.env.decaySeconds);
    CHECK(after.play.filter.env.sustainLevel == freshPlay.filter.env.sustainLevel);
    CHECK(after.play.filter.env.releaseSeconds == freshPlay.filter.env.releaseSeconds);
    CHECK(after.play.filter.env.attackCurve == freshPlay.filter.env.attackCurve);
    CHECK(after.play.filter.env.decayCurve == freshPlay.filter.env.decayCurve);
    CHECK(after.play.filter.env.releaseCurve == freshPlay.filter.env.releaseCurve);
    CHECK(after.play.filter.trigEnv.attackSeconds == freshPlay.filter.trigEnv.attackSeconds);
    CHECK(after.play.filter.trigEnv.decaySeconds == freshPlay.filter.trigEnv.decaySeconds);
    CHECK(after.play.filter.trigEnv.holdFraction == freshPlay.filter.trigEnv.holdFraction);
    CHECK(after.play.filter.trigEnv.attackCurve == freshPlay.filter.trigEnv.attackCurve);
    CHECK(after.play.filter.trigEnv.decayCurve == freshPlay.filter.trigEnv.decayCurve);

    // --- RESET: the three spline contours AND their mode flags ----------------------
    // The flag selects which shape ran, so the shape it selected is in the audio; with
    // both contours reset it also has nothing left to preserve.
    CHECK(after.play.ampSpline.mode == EnvMode::Staged);
    CHECK(after.play.pitchSpline.mode == EnvMode::Staged);
    CHECK(after.play.filterSpline.mode == EnvMode::Staged);
    CHECK(sameCurve(after.play.ampSpline.contour, VelocityCurve::rampDown()));
    CHECK(sameCurve(after.play.pitchSpline.contour, VelocityCurve::rampDown()));
    CHECK(sameCurve(after.play.filterSpline.contour, VelocityCurve::rampDown()));

    // --- RESET: the bake's own Hold division -----------------------------------------
    // It sizes the render window for the one case that cannot derive one, so the length it
    // chose is in the printed file, and the next bake re-derives from that file.
    CHECK(after.bakeHold == fresh.bakeHold);

    // --- RESET: the master-bus limiter enable ----------------------------------------
    // Read the asymmetry recorded at BakeReset before reasoning about this one: it is not
    // the master stage resetting as a unit.
    CHECK(!after.limiterEnabled);
    CHECK(!fresh.limiterEnabled);

    // --- RESET: master gain ----------------------------------------------------------
    CHECK(reset.masterGainLinear == 1.0);
}

}  // namespace

int main() {
    const InstrumentParams before = dialed();
    const BakeReset reset = resetAfterBake(before);
    const InstrumentParams& after = reset.params;
    const InstrumentParams fresh;

    // A sample of the fields checkNeutral asserts, confirming the fixtures actually moved them
    // off default — not the whole sweep, but enough spot checks that a fixture regressing to
    // the defaults (making the sweep vacuous) would show here first.
    CHECK(fresh.keyTrack != before.keyTrack);
    CHECK(before.limiterEnabled != fresh.limiterEnabled);
    CHECK(before.bakeHold != fresh.bakeHold);
    CHECK(dialedOther().bakeHold != fresh.bakeHold);
    CHECK(dialedOther().bakeHold != before.bakeHold);
    CHECK(!sameCurve(before.velocityCurve, VelocityCurve::flat()));
    CHECK(!sameCurve(before.play.ampSpline.contour, VelocityCurve::rampDown()));
    CHECK(!sameCurve(dialedOther().play.ampSpline.contour, VelocityCurve::rampDown()));

    // --- SURVIVE: mapping facts, absent from the printed audio ----------------------
    CHECK(after.rootOverride.has_value());
    CHECK(after.rootOverride == before.rootOverride);
    CHECK(after.keyTrack == before.keyTrack);
    CHECK(after.keyTrack == 0.5);      // and it is the dialed value, not the default 1.0

    // --- The neutral, over both fixtures --------------------------------------------
    g_ctx = "dialed";
    checkNeutral(reset);
    g_ctx = "dialedOther";
    {
        const BakeReset other = resetAfterBake(dialedOther());
        checkNeutral(other);
        // The survivors travel from the OTHER fixture too, so the sweep above is agreeing
        // with a reset that read its input rather than one that ignores it wholesale.
        CHECK(other.params.rootOverride == before.rootOverride);
        CHECK(other.params.keyTrack == before.keyTrack);
    }
    g_ctx = "";

    // --- A GATE-dialed instrument lands on Trigger too -------------------------------
    // This is a reset to a chosen neutral, not the dialed value surviving.
    {
        InstrumentParams gated = dialed();
        gated.play.playMode = PlayMode::Gate;
        CHECK(resetAfterBake(gated).params.play.playMode == PlayMode::Trigger);
    }

    // --- The loop enable and its points come back together ---------------------------
    // What the band shows after a bake is the reset override read against the newly banked
    // entry's loop intrinsic — assumed std::nullopt below, which is what the shell lands
    // today; if it ever populated one, the enable would have something to fall back to and
    // this assumption, not just this test, would need revisiting.
    {
        constexpr std::int64_t kFrames = 1000;
        const LoopMarks dialedMarks =
            resolveLoopMarks(StoredLoop{before.loopOverride, std::nullopt,
                                        before.loopCrossfadeFrames, before.startPoint},
                             kFrames);
        CHECK(dialedMarks.hasLoop);        // the fixture really did dial a loop on…
        CHECK(!dialedMarks.parked);        // …at its own positions
        CHECK(dialedMarks.crossfade == 512);

        const LoopMarks neutral =
            resolveLoopMarks(StoredLoop{after.loopOverride, std::nullopt,
                                        after.loopCrossfadeFrames, after.startPoint},
                             kFrames);
        CHECK(!neutral.hasLoop);
        CHECK(neutral.parked);             // re-offered on the defaults, not left coincident
        CHECK(neutral.loopStart < neutral.loopEnd);
        CHECK(neutral.crossfade == 0);
        CHECK(neutral.start == 0);
    }

    // --- An absent root override stays absent (nothing is invented) ------------------
    {
        InstrumentParams noRoot = dialed();
        noRoot.rootOverride.reset();
        CHECK(!resetAfterBake(noRoot).params.rootOverride.has_value());
    }

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