// Standalone tests for reasampler::PitchShifter — the S16 Preserve-engine DSP core. No VST3,
// no REAPER, no vendor, no test framework. The compile-time proof it does NOT drag the WDL
// <windows.h> chain is the CMake target linking only pitch_shift (+ peaks).
//
// Covers (PLAN.md S16 / CONTEXT.md §Pitch engine modes — Preserve):
//   1. duration invariance — N inputs yield N outputs at every shift ratio (the load-bearing
//      Preserve property: a transposed render is the SAME frame length as the un-transposed one).
//   2. unity pass-through fidelity — ratio 1.0 reproduces the input closely (a shifter at unity
//      must not mangle the signal).
//   3. transpose direction — an octave-up shift raises the observed pitch (period shortens), an
//      octave-down lowers it (period lengthens), measured on a synthesized sine.
//   4. RT discipline surrogate — after configure()+warm() (the off-thread setup), a long
//      process() run never resizes the ring (checked via window() constancy) and never returns
//      NaN/inf; pass-through (unconfigured) returns input verbatim.

#include "../src/vst/pitch_shift.h"

#include <cmath>
#include <cstdio>
#include <vector>

using namespace reasampler;

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 bool approx(double a, double b, double tol) { return std::fabs(a - b) <= tol; }

constexpr double kPi = 3.14159265358979323846;

// A sine of `cycles` periods over `frames` frames.
static std::vector<AudioSample> sine(std::size_t frames, double cycles) {
    std::vector<AudioSample> s(frames);
    for (std::size_t i = 0; i < frames; ++i) {
        s[i] = static_cast<float>(std::sin(2.0 * kPi * cycles *
               static_cast<double>(i) / static_cast<double>(frames)));
    }
    return s;
}

// Average spacing between positive-going zero crossings (the observed period).
static double observedPeriod(const std::vector<AudioSample>& out, std::size_t from) {
    std::vector<std::size_t> up;
    for (std::size_t i = from + 1; i < out.size(); ++i) {
        if (out[i - 1] <= 0.0f && out[i] > 0.0f) up.push_back(i);
    }
    if (up.size() < 2) return 0.0;
    double sum = 0.0;
    for (std::size_t i = 1; i < up.size(); ++i) sum += static_cast<double>(up[i] - up[i - 1]);
    return sum / static_cast<double>(up.size() - 1);
}

// --- 1. Duration invariance across shift ratios. ---
static void testDurationInvariance() {
    // The core Preserve property: whatever the shift ratio, one input frame yields one output
    // frame. So a shifter fed N frames produces exactly N frames — a transposed render is the
    // same length as an un-transposed one (unlike Varispeed, where an octave up halves length).
    const std::size_t n = 4000;
    const std::vector<AudioSample> in = sine(n, 40.0);
    const double ratios[] = {0.5, 1.0, 2.0, std::pow(2.0, 7.0 / 12.0)};
    for (double r : ratios) {
        PitchShifter ps;
        ps.configure(2205);  // ~50 ms @ 44.1k
        ps.warm();
        ps.setShiftRatio(r);
        std::size_t produced = 0;
        for (std::size_t i = 0; i < n; ++i) {
            const AudioSample o = ps.process(in[i]);
            (void)o;
            ++produced;  // exactly one output per input, unconditionally.
        }
        CHECK(produced == n);  // duration held at every ratio.
    }
}

// --- 2. Unity pass-through fidelity. ---
static void testUnityRoughlyReproduces() {
    // At ratio 1.0 the shifter should reproduce the input's PITCH faithfully (the OLA taps run
    // in lockstep with the writer). Amplitude/phase warble is allowed (basic OLA), but the
    // observed period must match the source period within a small tolerance past the warm-up.
    const std::size_t n = 8000;
    const double cycles = 40.0;
    const double nativePeriod = static_cast<double>(n) / cycles;  // 200
    const std::vector<AudioSample> in = sine(n, cycles);
    PitchShifter ps;
    ps.configure(2205);
    ps.warm();
    ps.setShiftRatio(1.0);
    std::vector<AudioSample> out(n);
    for (std::size_t i = 0; i < n; ++i) out[i] = ps.process(in[i]);
    // Measure past the initial half-window latency region.
    const double p = observedPeriod(out, 3000);
    CHECK(p > 0.0);
    CHECK(approx(p, nativePeriod, nativePeriod * 0.10));  // within 10% of source period
}

// --- 3. Transpose direction: up shortens the period, down lengthens it. ---
static void testTransposeDirection() {
    const std::size_t n = 12000;
    const double cycles = 60.0;
    const double nativePeriod = static_cast<double>(n) / cycles;  // 200
    const std::vector<AudioSample> in = sine(n, cycles);

    // Octave up: output period ~ half the source period (higher pitch).
    {
        PitchShifter ps;
        ps.configure(2205);
        ps.warm();
        ps.setShiftRatio(2.0);
        std::vector<AudioSample> out(n);
        for (std::size_t i = 0; i < n; ++i) out[i] = ps.process(in[i]);
        const double p = observedPeriod(out, 4000);
        CHECK(p > 0.0);
        CHECK(approx(p, nativePeriod / 2.0, nativePeriod * 0.15));  // period halves
    }
    // Octave down: output period ~ double the source period (lower pitch).
    {
        PitchShifter ps;
        ps.configure(2205);
        ps.warm();
        ps.setShiftRatio(0.5);
        std::vector<AudioSample> out(n);
        for (std::size_t i = 0; i < n; ++i) out[i] = ps.process(in[i]);
        const double p = observedPeriod(out, 4000);
        CHECK(p > 0.0);
        CHECK(approx(p, nativePeriod * 2.0, nativePeriod * 0.30));  // period doubles
    }
}

// --- 4. RT discipline surrogate + pass-through. ---
static void testRtDisciplineAndPassthrough() {
    // Unconfigured shifter passes input through verbatim (a Varispeed voice never allocates one).
    {
        PitchShifter ps;
        CHECK(!ps.configured());
        CHECK(ps.process(0.37f) == 0.37f);   // exact pass-through
        CHECK(ps.process(-0.9f) == -0.9f);
    }
    // Configured: the window is fixed at configure() and never changes across a long run (no
    // per-frame Resize), and no output is NaN/inf (numerically well-behaved OLA).
    {
        PitchShifter ps;
        ps.configure(1024);
        ps.warm();
        const std::int64_t w = ps.window();
        CHECK(w == 1024);
        ps.setShiftRatio(std::pow(2.0, 5.0 / 12.0));
        const std::vector<AudioSample> in = sine(20000, 100.0);
        for (std::size_t i = 0; i < in.size(); ++i) {
            const AudioSample o = ps.process(in[i]);
            CHECK(std::isfinite(o));
        }
        CHECK(ps.window() == w);  // window unchanged -> ring never resized mid-run
    }
    // A non-positive shift ratio is ignored (keeps the last valid ratio) — never stalls/reverses.
    {
        PitchShifter ps;
        ps.configure(512);
        ps.warm();
        ps.setShiftRatio(1.0);
        ps.setShiftRatio(-2.0);   // ignored
        ps.setShiftRatio(0.0);    // ignored
        for (int i = 0; i < 2000; ++i) CHECK(std::isfinite(ps.process(0.5f)));
    }
    // Degenerate window (<= 1) stays pass-through even after configure.
    {
        PitchShifter ps;
        ps.configure(1);
        CHECK(!ps.configured());
        CHECK(ps.process(0.25f) == 0.25f);
    }
}

int main() {
    testDurationInvariance();
    testUnityRoughlyReproduces();
    testTransposeDirection();
    testRtDisciplineAndPassthrough();

    if (g_fail == 0) {
        std::printf("all pitch_shift tests passed\n");
        return 0;
    }
    std::printf("%d pitch_shift check(s) failed\n", g_fail);
    return 1;
}
