// Standalone tests for reasampler::instrument::engine::loop — no VST3, no REAPER, no test
// framework. Covers the loop's validity/clamp rule, the pre-seam crossfade geometry, and the
// editor's default handle placement.

#include "../src/core/instrument/engine/loop/loop_span.h"

#include <cstdio>

using namespace reasampler;
using namespace reasampler::instrument::engine::loop;

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 SampleLoop span(std::int64_t start, std::int64_t end, bool has = true) {
    SampleLoop l;
    l.hasLoop = has;
    l.start = start;
    l.end = end;
    return l;
}

// --- Validity -----------------------------------------------------------------

static void testValidGateLoopResolvesToItsOwnSpan() {
    const ResolvedLoop lp = resolveLoop(span(100, 400), 0, 1000, /*gateMode=*/true);
    CHECK(lp.active);
    CHECK(lp.start == 100);
    CHECK(lp.end == 400);
    CHECK(lp.length == 300);
    CHECK(lp.crossfade == 0);
}

static void testTriggerModeNeverLoops() {
    const ResolvedLoop lp = resolveLoop(span(100, 400), 32, 1000, /*gateMode=*/false);
    CHECK(!lp.active);
    CHECK(lp.length == 0);
    CHECK(lp.crossfade == 0);
}

static void testUnsetLoopIsInactive() {
    const ResolvedLoop lp = resolveLoop(span(100, 400, /*has=*/false), 32, 1000, true);
    CHECK(!lp.active);
}

// An inverted, empty, negative, or out-of-range span is REFUSED rather than repaired: a
// wrong loop the user can hear beats a wrong loop the engine invented, and refusing is what
// keeps the read path from indexing outside the PCM.
static void testMalformedSpansAreRefusedNotRepaired() {
    CHECK(!resolveLoop(span(400, 100), 0, 1000, true).active);     // inverted
    CHECK(!resolveLoop(span(200, 200), 0, 1000, true).active);     // empty
    CHECK(!resolveLoop(span(-5, 400), 0, 1000, true).active);      // negative start
    CHECK(!resolveLoop(span(100, 1001), 0, 1000, true).active);    // end past the PCM
    CHECK(resolveLoop(span(100, 1000), 0, 1000, true).active);     // end AT the PCM is fine
}

// --- Crossfade clamping -------------------------------------------------------

static void testCrossfadeClampsToTheMaterialAheadOfTheLoop() {
    // The incoming tap reads [start - xf, start), so the fade cannot outrun `start`.
    const ResolvedLoop lp = resolveLoop(span(50, 400), 500, 1000, true);
    CHECK(lp.active);
    CHECK(lp.crossfade == 50);
    CHECK(lp.fadeBegin == 350.0);
}

static void testCrossfadeClampsToTheLoopLength() {
    const ResolvedLoop lp = resolveLoop(span(500, 600), 400, 1000, true);
    CHECK(lp.active);
    CHECK(lp.crossfade == 100);  // loop length, not the 400 asked for or the 500 before it
}

static void testLoopAtFrameZeroGetsNoCrossfade() {
    const ResolvedLoop lp = resolveLoop(span(0, 400), 64, 1000, true);
    CHECK(lp.active);
    CHECK(lp.crossfade == 0);  // nothing precedes the loop to fade in from
    CHECK(lp.fadeInv == 0.0);
}

static void testNegativeCrossfadeIsZero() {
    const ResolvedLoop lp = resolveLoop(span(100, 400), -20, 1000, true);
    CHECK(lp.active);
    CHECK(lp.crossfade == 0);
}

// --- Crossfade weight ---------------------------------------------------------

static void testZeroCrossfadeWeighsNothingAnywhere() {
    const ResolvedLoop lp = resolveLoop(span(100, 400), 0, 1000, true);
    CHECK(crossfadeWeight(lp, 100.0) == 0.0);
    CHECK(crossfadeWeight(lp, 399.0) == 0.0);
    CHECK(crossfadeWeight(lp, 399.999) == 0.0);
}

// The weight rises from exactly 0 at the region's start to exactly 1 at the LAST rendered
// frame (end - 1, d == crossfade - 1) — normalizing over crossfade - 1 rather than crossfade is
// what lands the ceiling exactly there instead of merely approaching it, which is what makes
// the seam continuous: at that frame the incoming tap has fully replaced the raw read, and the
// wrap hands over exactly that value.
static void testWeightRunsZeroToOneAcrossTheFadeRegion() {
    // xf = 101 so xf - 1 = 100, a clean denominator (loopStart 200 keeps the clamp out of the
    // way: max is min(200, 200)).
    const ResolvedLoop lp = resolveLoop(span(200, 400), 101, 1000, true);
    CHECK(lp.crossfade == 101);
    CHECK(lp.fadeBegin == 299.0);
    CHECK(crossfadeWeight(lp, 298.0) == 0.0);
    CHECK(crossfadeWeight(lp, 299.0) == 0.0);
    CHECK(crossfadeWeight(lp, 349.0) == 0.5);   // d = 50
    CHECK(crossfadeWeight(lp, 374.0) == 0.75);  // d = 75
    CHECK(crossfadeWeight(lp, 399.0) == 1.0);   // d = 100 == xf - 1, the ceiling's own threshold
    CHECK(crossfadeWeight(lp, 400.0) == 1.0);   // past it too (the unwrapped-caller belt)
}

// xf - 1 == 0 would divide by zero; the guard parks fadeInv at 0 instead. Unreachable via the
// multiply branch anyway (the region's only frame has d == 0, caught by the d <= 0 check
// first), but fadeInv must still be a sane value rather than +inf.
static void testCrossfadeOfOneNeedsNoDivisionGuard() {
    const ResolvedLoop lp = resolveLoop(span(100, 400), 1, 1000, true);
    CHECK(lp.crossfade == 1);
    CHECK(lp.fadeInv == 0.0);
    CHECK(crossfadeWeight(lp, 399.0) == 0.0);  // d == 0, the region's one frame
    CHECK(crossfadeWeight(lp, 400.0) == 1.0);  // past it, the ceiling belt still holds
}

static void testWeightIsMonotoneAndBoundedAcrossTheRegion() {
    const ResolvedLoop lp = resolveLoop(span(100, 400), 60, 1000, true);
    double prev = -1.0;
    for (int i = 0; i <= 400; ++i) {
        const double pos = 300.0 + static_cast<double>(i) * 0.25;  // sweeps 300..400
        const double w = crossfadeWeight(lp, pos);
        CHECK(w >= prev);
        CHECK(w >= 0.0 && w <= 1.0);
        prev = w;
    }
    CHECK(prev == 1.0);
}

// The ceiling is a belt for an unwrapped caller: without it the linear ramp would extrapolate
// past the incoming tap and amplify it.
static void testWeightSaturatesPastTheLoopEnd() {
    const ResolvedLoop lp = resolveLoop(span(100, 400), 50, 1000, true);
    CHECK(crossfadeWeight(lp, 500.0) == 1.0);
}

// --- Default handle placement -------------------------------------------------

static void testDefaultBoundsSitInTheLastQuarterAndClearFrameZero() {
    const LoopBounds d = defaultLoopBounds(1000);
    CHECK(d.start == 750);
    CHECK(d.end == 1000);
    CHECK(d.start > 0);  // the whole point: it does not land under the start marker
    CHECK(defaultLoopBounds(0).start == 0 && defaultLoopBounds(0).end == 0);
    CHECK(defaultLoopBounds(-5).end == 0);
}

// A default span is itself a valid loop, so the handles a user is offered describe a loop the
// engine will actually accept.
static void testDefaultBoundsResolveActive() {
    const LoopBounds d = defaultLoopBounds(888);
    const ResolvedLoop lp = resolveLoop(span(d.start, d.end), 0, 888, true);
    CHECK(lp.active);
    CHECK(lp.start == d.start && lp.end == d.end);
}

int main() {
    testValidGateLoopResolvesToItsOwnSpan();
    testTriggerModeNeverLoops();
    testUnsetLoopIsInactive();
    testMalformedSpansAreRefusedNotRepaired();
    testCrossfadeClampsToTheMaterialAheadOfTheLoop();
    testCrossfadeClampsToTheLoopLength();
    testLoopAtFrameZeroGetsNoCrossfade();
    testNegativeCrossfadeIsZero();
    testZeroCrossfadeWeighsNothingAnywhere();
    testWeightRunsZeroToOneAcrossTheFadeRegion();
    testCrossfadeOfOneNeedsNoDivisionGuard();
    testWeightIsMonotoneAndBoundedAcrossTheRegion();
    testWeightSaturatesPastTheLoopEnd();
    testDefaultBoundsSitInTheLastQuarterAndClearFrameZero();
    testDefaultBoundsResolveActive();
    if (g_fail == 0) std::printf("loop_span_tests: all passed\n");
    return g_fail == 0 ? 0 : 1;
}
