// Standalone tests for the filter's place in the VOICE PATH — no VST3, no REAPER, no
// framework. The filter's own numerical behaviour is filter_tests / filter_state_tests /
// filter_morph_tests / filter_params_tests; this file asserts only the integration: that a
// disengaged filter is bit-inert, that it sits between the pitch stage and the amp stage,
// that each voice runs its own, that the three cutoff-modulation sources reach it, and that
// the modulated corner moves continuously rather than in steps.

#include "../src/core/instrument/engine/voice.h"

#include <cmath>
#include <cstdio>
#include <type_traits>
#include <utility>
#include <vector>

using namespace reasampler;
namespace flt = reasampler::instrument::engine::filter;

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

constexpr double kPi = 3.14159265358979323846;
constexpr int kRate = 48000;

// The port's whole point, asserted from the CALL SITE as well as inside the module: neither
// the voice nor the envelope it drives may grow a vtable, and the filter stays a plain value
// member (trivially copyable => nothing heap-owned, so process() cannot allocate).
static_assert(!std::is_polymorphic_v<Voice>, "no vtable on the per-sample path");
static_assert(!std::is_polymorphic_v<AdsrEnvelope>, "the filter envelope must inline");
static_assert(std::is_trivially_copyable_v<flt::VoiceFilter>, "filter state is plain values");

// A sine whose period is EXACTLY `period` frames, so two voices started `period` apart read
// identical values at every absolute frame — that is what isolates envelope phase from read
// position in the per-voice-independence test.
static SampleData periodicSine(std::size_t frames, double period, int rootNote = 60) {
    SampleData s;
    s.frames.resize(frames);
    for (std::size_t i = 0; i < frames; ++i) {
        s.frames[i] = static_cast<float>(std::sin(2.0 * kPi * static_cast<double>(i) / period));
    }
    s.sampleRate = kRate;
    s.rootNote = rootNote;
    return s;
}

// Amp envelope held wide open, so a rendered frame is exactly the (filtered) source.
static AdsrParams flatAdsr() {
    AdsrParams a;
    a.sustainLevel = 1.0;
    return a;
}

static FilterParams engagedFilter(float cutoffNorm, float resNorm, float morphNorm) {
    FilterParams f;
    f.enabled = true;
    f.settings.cutoffNorm = cutoffNorm;
    f.settings.resonanceNorm = resNorm;
    f.settings.morphNorm = morphNorm;
    f.env.sustainLevel = 1.0;
    return f;
}

static std::vector<double> render(SampleData& sample, int note, int velocity, int frames) {
    Voice v;
    v.start(note, velocity, sample);
    std::vector<double> out(static_cast<std::size_t>(frames), 0.0);
    for (int i = 0; i < frames; ++i) out[static_cast<std::size_t>(i)] = v.renderFrame();
    return out;
}

// Renders via renderFrameStereo — exercises voice.h's stereo filter block (both the
// filter_.process(1, ...) call for a genuinely stereo source and the dual-mono mirror for a
// mono one), never touched by the mono `render` helper above.
static std::vector<std::pair<double, double>> renderStereo(SampleData& sample, int note,
                                                            int velocity, int frames) {
    Voice v;
    v.start(note, velocity, sample);
    std::vector<std::pair<double, double>> out(static_cast<std::size_t>(frames));
    for (int i = 0; i < frames; ++i) {
        AudioSample l = 0.0f, r = 0.0f;
        v.renderFrameStereo(l, r);
        out[static_cast<std::size_t>(i)] = {static_cast<double>(l), static_cast<double>(r)};
    }
    return out;
}

static double rms(const std::vector<double>& x, std::size_t from, std::size_t to) {
    double acc = 0.0;
    for (std::size_t i = from; i < to; ++i) acc += x[i] * x[i];
    return std::sqrt(acc / static_cast<double>(to - from));
}

// ---------------------------------------------------------------------------
// Off by default.
// ---------------------------------------------------------------------------

static void testDisengagedFilterIsBitInertEvenWithExtremeSettingsStored() {
    SampleData plain = periodicSine(2000, 64);
    plain.play.adsr = flatAdsr();

    // Independent reference: with a flat amp envelope, unity velocity curve and the note at
    // the root, the rendered frame IS the source frame. Asserting against this rather than
    // against another render of the same code proves the un-filtered path is still correct,
    // not merely self-consistent.
    const std::vector<double> bare = render(plain, 60, 100, 1500);
    for (std::size_t i = 0; i < bare.size(); ++i) {
        CHECK(static_cast<float>(bare[i]) == plain.frames[i]);
    }

    // Now store the most violent filter settings available and leave `enabled` false. A blob
    // that carries filter parameters must sound exactly as it did before they existed.
    SampleData stored = periodicSine(2000, 64);
    stored.play.adsr = flatAdsr();
    stored.play.filter = engagedFilter(0.0f, 1.0f, 1.0f);
    stored.play.filter.enabled = false;
    stored.play.filter.settings.driveNorm = 1.0f;
    stored.play.filter.modAmount = 1.0;
    stored.play.filter.velAmount = -1.0;
    stored.play.filter.velocityCurve = VelocityCurve::fromPoints(
        {{0.0, 0.0}, {127.0, -1.0}}, instrument::engine::CurveDomain::Bipolar);
    stored.play.filter.keyTrack = 2.0;
    const std::vector<double> inert = render(stored, 60, 100, 1500);
    for (std::size_t i = 0; i < inert.size(); ++i) CHECK(inert[i] == bare[i]);

    // And engaging it must actually do something — otherwise the bit-identity above would be
    // satisfied by a filter that never runs.
    SampleData live = periodicSine(2000, 64);
    live.play.adsr = flatAdsr();
    live.play.filter = engagedFilter(0.0f, 0.0f, 1.0f);  // low-pass, corner at 20 Hz
    const std::vector<double> filtered = render(live, 60, 100, 1500);
    CHECK(rms(filtered, 500, 1500) < 0.05 * rms(bare, 500, 1500));
}

// ---------------------------------------------------------------------------
// Pipeline position: pitch -> filter -> amp.
// ---------------------------------------------------------------------------

static void testFilterSeesThePreAmpSignalSoAmpGainScalesTheResultLinearly() {
    // With a NONLINEAR drive stage engaged, the two orderings are distinguishable: a filter
    // fed the post-amp signal would see a half-level input and shape it differently, so the
    // two renders could not be an exact factor of two apart. Filter-before-amp makes them
    // exactly that, because the amp multiply is the last thing to happen.
    const auto renderAtSustain = [](double sustain) {
        SampleData s = periodicSine(2000, 64);
        s.play.adsr = flatAdsr();
        s.play.adsr.sustainLevel = sustain;
        s.play.filter = engagedFilter(0.35f, 0.9f, 1.0f);
        s.play.filter.settings.driveNorm = 1.0f;  // hard nonlinearity in the resonance path
        return render(s, 60, 100, 1200);
    };
    const std::vector<double> half = renderAtSustain(0.5);
    const std::vector<double> full = renderAtSustain(1.0);

    bool sawSignal = false;
    // Frame 0 is the AHDSR's attack peak (1.0) in BOTH runs; the sustain level takes over
    // from frame 1.
    for (std::size_t i = 1; i < half.size(); ++i) {
        CHECK(half[i] == 0.5 * full[i]);
        if (std::fabs(full[i]) > 1e-6) sawSignal = true;
    }
    CHECK(sawSignal);
}

// ---------------------------------------------------------------------------
// Per-voice, not instance-wide.
// ---------------------------------------------------------------------------

static void testTwoVoicesAtDifferentEnvelopePhasesFilterIndependently() {
    // Both voices read the SAME source value at every absolute frame (voice B starts exactly
    // one sine period late), and both amp envelopes are wide open — so the only thing that
    // can separate their outputs is their own filter-envelope phase. A single shared filter
    // would hand both voices one cutoff and one integrator pair, and they would match.
    constexpr int kPeriod = 64;
    SampleData s = periodicSine(20000, kPeriod);
    s.play.adsr = flatAdsr();
    s.play.filter = engagedFilter(0.0f, 0.2f, 1.0f);
    s.play.filter.modAmount = 1.0;                 // envelope sweeps cutoff over the full range
    s.play.filter.env.attackFrames = 8000;         // slow, so the two phases stay far apart
    s.play.filter.env.sustainLevel = 1.0;

    Voice a;
    Voice b;
    a.start(60, 100, s);
    for (int i = 0; i < kPeriod; ++i) a.renderFrame();
    b.start(60, 100, s);

    int differing = 0;
    for (int i = 0; i < 4000; ++i) {
        const double fa = a.renderFrame();
        const double fb = b.renderFrame();
        if (std::fabs(fa - fb) > 1e-6) ++differing;
    }
    CHECK(differing > 3000);

    // The same staggered pair with the MODULATION removed converges: each voice still owns
    // its own integrators, so the stagger costs a short start-up transient, but with no
    // envelope reaching cutoff the two settle onto the identical steady state. That is what
    // makes the count above evidence of per-voice ENVELOPE phase rather than of the stagger.
    SampleData flat = periodicSine(20000, kPeriod);
    flat.play.adsr = flatAdsr();
    flat.play.filter = engagedFilter(0.5f, 0.2f, 1.0f);
    Voice c;
    Voice d;
    c.start(60, 100, flat);
    for (int i = 0; i < kPeriod; ++i) c.renderFrame();
    d.start(60, 100, flat);
    int settledDiffering = 0;
    for (int i = 0; i < 4000; ++i) {
        const double fc = c.renderFrame();
        const double fd = d.renderFrame();
        if (i >= 2000 && fc != fd) ++settledDiffering;
    }
    CHECK(settledDiffering == 0);
}

// ---------------------------------------------------------------------------
// Modulation: envelope depth, velocity, key tracking.
// ---------------------------------------------------------------------------

static void testModAmountPolarityDrivesCutoffFromOppositeEnds() {
    // A low-pass over a 750 Hz tone: energy tracks cutoff, so the sweep direction is readable
    // straight off the output's RMS. +100% from a closed filter opens it; -100% from an open
    // one closes it; 0% leaves it where the knob is.
    const auto sweep = [](float cutoffNorm, double modAmount) {
        SampleData s = periodicSine(20000, 64);
        s.play.adsr = flatAdsr();
        s.play.filter = engagedFilter(cutoffNorm, 0.0f, 1.0f);
        s.play.filter.modAmount = modAmount;
        s.play.filter.env.attackFrames = 12000;
        s.play.filter.env.sustainLevel = 1.0;
        return render(s, 60, 100, 12000);
    };

    const std::vector<double> rising = sweep(0.0f, 1.0);
    CHECK(rms(rising, 10000, 12000) > 20.0 * rms(rising, 0, 2000));

    const std::vector<double> falling = sweep(1.0f, -1.0);
    CHECK(rms(falling, 10000, 12000) < 0.05 * rms(falling, 0, 2000));

    // Zero depth: tickFilterCutoff's early-out skips the envelope entirely, so cutoff must
    // never move regardless of how long the (unread) envelope stage runs.
    const std::vector<double> steady = sweep(0.5f, 0.0);
    const double early = rms(steady, 2000, 4000);
    const double late = rms(steady, 10000, 12000);
    CHECK(std::fabs(late - early) < 1e-6 * early + 1e-9);

    // The two poles land at opposite ends of the same range: at full sweep the rising run
    // ends open and the falling run ends closed.
    CHECK(rms(rising, 10000, 12000) > rms(falling, 10000, 12000));
}

// The corner must track the envelope CONTINUOUSLY. A retired revision gated the re-solve on the
// modulated position crossing one step of a 2048-step quantization of the sweep, which
// staircased the corner in ~5.8-cent jumps; these two assertions fail if any such quantizer
// comes back, at either end of the path.
static void testTheSolvedCornerIsContinuousUnderSubQuantumCutoffSteps() {
    // The SOLVE end: positions a tenth of the retired quantum apart must each land on their own
    // corner, strictly ordered. A quantizer anywhere in the solve collapses neighbours onto one g.
    flt::VoiceFilter f;
    flt::FilterSettings s;
    s.cutoffNorm = 0.5f;
    f.prepare(s, static_cast<double>(kRate));

    const float step = 1.0f / 20480.0f;  // a tenth of the retired 1/2048 quantum
    float prev = f.coeffs().g;
    for (int i = 1; i <= 200; ++i) {
        f.setCutoffNorm(0.5f + static_cast<float>(i) * step, static_cast<double>(kRate));
        const float g = f.coeffs().g;
        CHECK(g > prev);  // strictly monotone: every sub-quantum step moves the corner
        prev = g;
    }
    // Q, morph and drive are untouched by a cutoff-only re-solve, so k and the folded mix must
    // read exactly what prepare() left — that equality is what makes the cheap path legitimate.
    flt::VoiceFilter full;
    flt::FilterSettings s2 = s;
    s2.cutoffNorm = 0.5f + 200.0f * step;
    full.prepare(s2, static_cast<double>(kRate));
    CHECK(f.coeffs().g == full.coeffs().g);
    CHECK(f.coeffs().k == full.coeffs().k);
    CHECK(f.mix().m0 == full.mix().m0);
    CHECK(f.mix().m1 == full.mix().m1);
    CHECK(f.mix().m2 == full.mix().m2);
}

static void testAModulationTooSmallToCrossTheRetiredQuantumStillMovesTheVoice() {
    // The VOICE end: a depth of 1/8192 sweeps the cutoff by an eighth of the retired quantum
    // from a position that sits exactly on a quantum boundary — under the old gate `step` never
    // changed, so the whole sweep rendered bit-identically to a static filter. It must not now.
    const auto sweep = [](double modAmount) {
        SampleData s = periodicSine(8000, 64);
        s.play.adsr = flatAdsr();
        s.play.filter = engagedFilter(0.5f, 0.6f, 1.0f);
        s.play.filter.modAmount = modAmount;
        s.play.filter.env.attackFrames = 6000;
        s.play.filter.env.sustainLevel = 1.0;
        return render(s, 60, 100, 6000);
    };

    const std::vector<double> stat = sweep(0.0);
    const std::vector<double> tiny = sweep(1.0 / 8192.0);
    std::size_t differing = 0;
    for (std::size_t i = 0; i < stat.size(); ++i) {
        if (stat[i] != tiny[i]) ++differing;
    }
    CHECK(differing > stat.size() / 2);
}

// The steady-state guard for the unquantized re-solve: with no modulation the voice must be
// EXACTLY one prepare() at the base cutoff over the source, bit for bit. A note at its root with
// unity key-track, a flat amp envelope and a flat velocity curve reduces the whole voice path to
// that, so any drift in what start() solves shows up as a bit difference here.
static void testAnUnmodulatedVoiceIsBitIdenticalToASinglePreparedFilter() {
    SampleData s = periodicSine(4000, 64);
    s.play.adsr = flatAdsr();
    s.play.filter = engagedFilter(0.35f, 0.7f, 0.25f);
    s.play.filter.settings.driveNorm = 0.4f;
    const std::vector<double> got = render(s, 60, 100, 4000);

    flt::VoiceFilter ref;
    ref.prepare(s.play.filter.settings, static_cast<double>(kRate));
    bool sawSignal = false;
    for (std::size_t i = 0; i < got.size(); ++i) {
        const double want = static_cast<double>(ref.process(0, s.frames[i]));
        CHECK(got[i] == want);
        if (std::fabs(want) > 1e-6) sawSignal = true;
    }
    CHECK(sawSignal);  // bit-equality over silence would prove nothing
}

static void testVelocityAndKeyTrackingReachCutoffAndAreNoOpsAtTheirDefaults() {
    // Playback key-tracking off, so both notes read the source at the SAME rate and the only
    // note-dependent difference left is the filter's own key-tracking.
    const auto tone = [](double velAmount, double velTop, double keyTrack) {
        SampleData s = periodicSine(8000, 64);
        s.play.adsr = flatAdsr();
        s.keyTrack = 0.0;
        s.play.filter = engagedFilter(0.25f, 0.0f, 1.0f);
        s.play.filter.velAmount = velAmount;
        if (velTop != 0.0) {
            s.play.filter.velocityCurve = VelocityCurve::fromPoints(
                {{0.0, 0.0}, {127.0, velTop}}, instrument::engine::CurveDomain::Bipolar);
        }
        s.play.filter.keyTrack = keyTrack;
        return s;
    };

    // Velocity: a curve rising to +1 at full depth opens the filter for a hard hit. The amp's
    // own velocity curve is flat, so amplitude is unaffected.
    SampleData vel = tone(1.0, 1.0, 0.0);
    const std::vector<double> soft = render(vel, 60, 1, 6000);
    const std::vector<double> hard = render(vel, 60, 127, 6000);
    CHECK(rms(hard, 2000, 6000) > 2.0 * rms(soft, 2000, 6000));

    // Key tracking: two octaves up opens it by two octaves of cutoff.
    SampleData key = tone(0.0, 0.0, 1.0);
    const std::vector<double> low = render(key, 60, 100, 6000);
    const std::vector<double> high = render(key, 84, 100, 6000);
    CHECK(rms(high, 2000, 6000) > 2.0 * rms(low, 2000, 6000));

    // Both neutral: neither velocity nor note may move the filter. `tone(_,0,0)` leaves the
    // DEFAULT velocity curve, so this is the off-by-default contract itself — and it holds at
    // EVERY depth setting, since the curve is what says "nothing", not the knob.
    for (const double depth : {-1.0, -0.5, 0.0, 0.5, 1.0}) {
        SampleData neutral = tone(depth, 0.0, 0.0);
        const std::vector<double> a = render(neutral, 60, 1, 6000);
        const std::vector<double> b = render(neutral, 60, 127, 6000);
        const std::vector<double> c = render(neutral, 84, 100, 6000);
        for (std::size_t i = 0; i < a.size(); ++i) {
            CHECK(a[i] == b[i]);
            CHECK(a[i] == c[i]);
        }
        // And the modulation VALUE itself is exactly zero at every velocity, not merely small
        // enough that the render came out equal — the render check alone would still pass
        // under a cutoff offset too small to survive the coefficient solve's float rounding.
        const FilterParams def;
        for (int v = 0; v <= 127; ++v) CHECK(depth * def.velocityCurve.eval(v) == 0.0);
    }
}

// The depth knob and the bipolar curve BOTH apply, as a product: flipping the depth's sign is
// the same modulation as flipping the curve's, and it flips the audible sense with it.
static void testFilterVelocityDepthAndCurveComposeMultiplicatively() {
    using instrument::engine::CurveDomain;
    using instrument::engine::VelocityPoint;
    const std::vector<VelocityPoint> rising = {{0.0, 0.0}, {64.0, 0.4}, {127.0, 1.0}};
    std::vector<VelocityPoint> mirrored = rising;
    for (VelocityPoint& p : mirrored) p.value = -p.value;

    const auto rig = [](double depth, const std::vector<VelocityPoint>& pts) {
        SampleData s = periodicSine(8000, 64);
        s.play.adsr = flatAdsr();
        s.keyTrack = 0.0;
        s.play.filter = engagedFilter(0.25f, 0.0f, 1.0f);
        s.play.filter.velAmount = depth;
        s.play.filter.velocityCurve = VelocityCurve::fromPoints(pts, CurveDomain::Bipolar);
        return s;
    };

    // A negative depth over a rising curve is the SAME cutoff offset as a positive depth over
    // the curve's mirror — frame for frame, not approximately.
    SampleData byDepth = rig(-0.75, rising);
    SampleData byCurve = rig(0.75, mirrored);
    const std::vector<double> viaDepth = render(byDepth, 60, 120, 6000);
    const std::vector<double> viaCurve = render(byCurve, 60, 120, 6000);
    for (std::size_t i = 0; i < viaDepth.size(); ++i) CHECK(viaDepth[i] == viaCurve[i]);

    // And the sense really inverts: over the same rising curve a positive depth opens the
    // filter for a hard hit while the negative one closes it.
    SampleData up = rig(0.75, rising);
    const std::vector<double> upSoft = render(up, 60, 1, 6000);
    const std::vector<double> upHard = render(up, 60, 120, 6000);
    CHECK(rms(upHard, 2000, 6000) > 2.0 * rms(upSoft, 2000, 6000));
    const std::vector<double> downSoft = render(byDepth, 60, 1, 6000);
    const std::vector<double> downHard = render(byDepth, 60, 120, 6000);
    CHECK(rms(downSoft, 2000, 6000) > 2.0 * rms(downHard, 2000, 6000));
}

static void testNoteOnResetsTheFilterSoAPreviousNoteCannotLeak() {
    SampleData s = periodicSine(8000, 64);
    s.play.adsr = flatAdsr();
    s.play.filter = engagedFilter(0.2f, 0.95f, 1.0f);  // high Q: a long, obvious ring

    Voice v;
    v.start(60, 100, s);
    for (int i = 0; i < 4000; ++i) v.renderFrame();  // fill the integrators
    v.start(60, 100, s);                             // restart: reset() must clear them
    std::vector<double> restarted(1000);
    for (int i = 0; i < 1000; ++i) restarted[static_cast<std::size_t>(i)] = v.renderFrame();

    const std::vector<double> fresh = render(s, 60, 100, 1000);
    for (std::size_t i = 0; i < fresh.size(); ++i) CHECK(restarted[i] == fresh[i]);
}

// ---------------------------------------------------------------------------
// Stereo: the two filter paths renderFrame() (mono) never reaches.
// ---------------------------------------------------------------------------

static void testStereoRenderOfAMonoSampleMirrorsTheMonoResultExactly() {
    // A mono SampleData in a stereo render never calls filter_.process(1, ...) — voice.h's
    // dual-mono block copies channel 0's already-filtered result to channel 1 instead. Pin
    // both channels bit-identical to the plain mono render.
    SampleData s = periodicSine(4000, 64);
    s.play.adsr = flatAdsr();
    s.play.filter = engagedFilter(0.3f, 0.85f, 1.0f);
    s.play.filter.settings.driveNorm = 0.6f;

    SampleData monoSrc = s;
    const std::vector<double> mono = render(monoSrc, 60, 100, 2000);

    SampleData stereoSrc = s;
    const auto stereo = renderStereo(stereoSrc, 60, 100, 2000);
    for (std::size_t i = 0; i < stereo.size(); ++i) {
        CHECK(stereo[i].first == mono[i]);
        CHECK(stereo[i].second == mono[i]);
    }
}

static void testStereoFilterChannel1MatchesChannel0ForIdenticalLRInput() {
    // A genuinely stereo sample whose channels are identical drives filter_.process(1, ...)
    // for real (haveR true). Identical input plus identical reset() state must give identical
    // output on both channels — the mirror's correctness argument, pinned against the actual
    // per-channel path instead of only asserted in a comment.
    SampleData s = periodicSine(4000, 64);
    s.framesR = s.frames;  // genuinely stereo, L == R
    s.play.adsr = flatAdsr();
    s.play.filter = engagedFilter(0.3f, 0.85f, 1.0f);
    s.play.filter.settings.driveNorm = 0.6f;

    const auto stereo = renderStereo(s, 60, 100, 2000);
    bool sawSignal = false;
    for (const auto& lr : stereo) {
        CHECK(lr.first == lr.second);
        if (std::fabs(lr.first) > 1e-6) sawSignal = true;
    }
    CHECK(sawSignal);
}

int main() {
    testDisengagedFilterIsBitInertEvenWithExtremeSettingsStored();
    testFilterSeesThePreAmpSignalSoAmpGainScalesTheResultLinearly();
    testTwoVoicesAtDifferentEnvelopePhasesFilterIndependently();
    testModAmountPolarityDrivesCutoffFromOppositeEnds();
    testTheSolvedCornerIsContinuousUnderSubQuantumCutoffSteps();
    testAModulationTooSmallToCrossTheRetiredQuantumStillMovesTheVoice();
    testAnUnmodulatedVoiceIsBitIdenticalToASinglePreparedFilter();
    testVelocityAndKeyTrackingReachCutoffAndAreNoOpsAtTheirDefaults();
    testFilterVelocityDepthAndCurveComposeMultiplicatively();
    testNoteOnResetsTheFilterSoAPreviousNoteCannotLeak();
    testStereoRenderOfAMonoSampleMirrorsTheMonoResultExactly();
    testStereoFilterChannel1MatchesChannel0ForIdenticalLRInput();
    if (g_fail == 0) std::printf("sampler_filter: all tests passed\n");
    return g_fail == 0 ? 0 : 1;
}
