// Standalone tests for the SPLINE EG system — no VST3, no REAPER, no framework. One file for
// the whole feature because its seams span three modules that only mean something together: the
// shared spline (engine), the dual Staged/Spline state and its wire format (map), and the
// point-editing grammar (ui).
//
// The eleven cases below are the spec's own test list, in its order. Each is named for the rule
// it pins, so a failure names the behaviour rather than the module.

#include "../src/core/instrument/engine/voice_engine.h"
#include "../src/core/instrument/map/component_state_io.h"
#include "../src/core/instrument/ui/deck_groups.h"
#include "../src/core/instrument/ui/spline_edit.h"

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

using namespace reasampler;
using namespace reasampler::instrument::engine;
using reasampler::instrument::map::ComponentState;
using reasampler::instrument::map::InstrumentParams;
using reasampler::instrument::map::PlaySeconds;
using reasampler::instrument::map::deserializeComponentState;
using reasampler::instrument::map::kParamsFormatMarker;
using reasampler::instrument::map::kParamsPayloadVersion;
using reasampler::instrument::map::resolvePlay;
using reasampler::instrument::map::serializeComponentState;

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 near(double a, double b, double eps = 1e-9) { return std::fabs(a - b) <= eps; }

// x runs over the curve's canonical span; a spline EG's phase maps onto it linearly.
static double xAt(double phase) { return kCurveXMin + phase * (kCurveXMax - kCurveXMin); }

// A contour that rises then falls, with a peak at x=64 the caller can make hard or smooth.
static VelocityCurve peakContour(bool hardPeak) {
    VelocityCurve c = VelocityCurve::fromPoints(
        {{0.0, 0.0}, {32.0, 0.2}, {64.0, 1.0}, {96.0, 0.3}, {127.0, 0.0}},
        CurveDomain::Unipolar);
    if (hardPeak) c.setHard(2, true);
    return c;
}

// --- 1. A hard point is a genuine slope discontinuity -------------------------

// The whole hard-point enhancement in one assertion: at a hard knot each side's one-sided slope
// is that SEGMENT'S OWN secant — no smoothing was applied on either side — so the two disagree
// and the contour has a real corner. The same knot left smooth pins to a shared tangent.
static void testHardPointGivesDifferingOneSidedSlopes() {
    const VelocityCurve hard = peakContour(/*hardPeak=*/true);
    const double h = 1e-4;
    const double left = (hard.eval(64.0) - hard.eval(64.0 - h)) / h;
    const double right = (hard.eval(64.0 + h) - hard.eval(64.0)) / h;
    // The two adjacent secants: (1.0-0.2)/32 rising, (0.3-1.0)/32 falling.
    const double secantIn = (1.0 - 0.2) / 32.0;
    const double secantOut = (0.3 - 1.0) / 32.0;
    CHECK(near(left, secantIn, 1e-4));
    CHECK(near(right, secantOut, 1e-4));
    CHECK(left > 0.0 && right < 0.0);  // a genuine corner, not a slope that merely changes rate

    // Neither adjacent segment is straightened by the hard knot — each is still a curve, which
    // is what "one or more monotone splines joined at their angles" means. A straight segment
    // would put its midpoint exactly on the chord.
    CHECK(!near(hard.eval(48.0), (0.2 + 1.0) / 2.0, 1e-6));
    CHECK(!near(hard.eval(80.0), (1.0 + 0.3) / 2.0, 1e-6));

    // Left smooth, the same knot is a local extremum: Fritsch-Carlson pins the tangent to 0 on
    // BOTH sides, so the slope is continuous there.
    const VelocityCurve smooth = peakContour(/*hardPeak=*/false);
    const double sLeft = (smooth.eval(64.0) - smooth.eval(64.0 - h)) / h;
    const double sRight = (smooth.eval(64.0 + h) - smooth.eval(64.0)) / h;
    CHECK(near(sLeft, 0.0, 1e-4));
    CHECK(near(sRight, 0.0, 1e-4));
}

// --- 2. Per-segment monotonicity, on a contour that is not globally monotone ---

static void testNoOvershootBetweenAnyAdjacentPairOnARiseAndFallContour() {
    VelocityCurve c = VelocityCurve::fromPoints(
        {{0.0, 0.2}, {20.0, 0.9}, {50.0, 0.1}, {90.0, 0.85}, {110.0, 0.15}, {127.0, 0.6}},
        CurveDomain::Unipolar);
    c.setHard(2, true);  // one hard knot, so the guarantee is asserted across a joint too
    const std::vector<VelocityPoint>& pts = c.points();
    for (std::size_t i = 0; i + 1 < pts.size(); ++i) {
        const double lo = (std::min)(pts[i].value, pts[i + 1].value);
        const double hi = (std::max)(pts[i].value, pts[i + 1].value);
        for (int s = 0; s <= 200; ++s) {
            const double x = pts[i].velocity +
                             (pts[i + 1].velocity - pts[i].velocity) * (s / 200.0);
            const double y = c.eval(x);
            CHECK(y >= lo - 1e-12);
            CHECK(y <= hi + 1e-12);
        }
    }
    // ...and it genuinely rises AND falls, so the assertion above is not vacuously about a
    // monotone curve.
    CHECK(c.eval(20.0) > c.eval(0.0));
    CHECK(c.eval(50.0) < c.eval(20.0));
}

// --- 3. The 128-point ceiling refuses without disturbing the contour ----------

static void testAddingAtTheCeilingIsRefusedAndLeavesTheContourBitIdentical() {
    VelocityCurve c = VelocityCurve::rampDown();
    for (std::size_t i = 0; c.size() < kMaxCurvePoints; ++i) {
        const double x = 1.0 + static_cast<double>(i);
        CHECK(c.addPoint(x, 0.5) >= 0);
    }
    CHECK(c.size() == kMaxCurvePoints);
    const std::vector<VelocityPoint> before = c.points();
    CHECK(c.addPoint(63.5, 0.25) == -1);
    const std::vector<VelocityPoint>& after = c.points();
    CHECK(after.size() == before.size());
    for (std::size_t i = 0; i < before.size(); ++i) {
        // Bit-identical, not merely close: a refused add must not perturb a drawn shape at all.
        CHECK(after[i].velocity == before[i].velocity);
        CHECK(after[i].value == before[i].value);
        CHECK(after[i].hard == before[i].hard);
    }
}

// --- 4. The two full-length endpoints always survive --------------------------

static void testEndpointDeletionIsRefused() {
    VelocityCurve c = peakContour(false);
    const std::size_t n = c.size();
    CHECK(!c.deletePoint(0));
    CHECK(!c.deletePoint(n - 1));
    CHECK(c.size() == n);
    CHECK(c.points().front().velocity == kCurveXMin);
    CHECK(c.points().back().velocity == kCurveXMax);
    // An interior point still deletes, so the refusal is about the endpoints and not about
    // deletion being broken.
    CHECK(c.deletePoint(2));
    CHECK(c.size() == n - 1);
}

// --- 5. The hard/smooth toggle round-trips ------------------------------------

static void testTogglingHardThenSmoothRestoresTheEvaluatedContour() {
    VelocityCurve c = peakContour(false);
    std::vector<double> baseline;
    for (int i = 0; i <= 127; ++i) baseline.push_back(c.eval(i));

    CHECK(c.toggleHard(2));
    bool changedSomewhere = false;
    for (int i = 0; i <= 127; ++i) {
        if (!near(c.eval(i), baseline[static_cast<std::size_t>(i)], 1e-12)) changedSomewhere = true;
    }
    CHECK(changedSomewhere);  // the toggle must actually do something, or the round trip is empty
    CHECK(c.points()[2].hard);

    CHECK(c.toggleHard(2));
    CHECK(!c.points()[2].hard);
    for (int i = 0; i <= 127; ++i) {
        CHECK(c.eval(i) == baseline[static_cast<std::size_t>(i)]);  // exact, not approximate
    }
}

// --- 6. Both states survive a mode flip, in memory and across save/reload -----

// Distinctive staged values on all three envelopes, so "exactly as left" is checkable rather
// than accidentally equal to a default.
static InstrumentParams paramsWithBothStates() {
    InstrumentParams p;
    p.play.playMode = PlayMode::Gate;
    p.play.adsr.attackSeconds = 0.37;
    p.play.adsr.decaySeconds = 0.21;
    p.play.adsr.sustainLevel = 0.42;
    p.play.adsr.releaseSeconds = 0.66;
    p.play.pitchEnv.enabled = true;
    p.play.pitchEnv.peakSemitones = -7.5;
    p.play.pitchEnv.shape.attackSeconds = 0.11;
    p.play.filter.enabled = true;
    p.play.filter.env.decaySeconds = 0.29;
    p.play.filter.env.sustainLevel = 0.33;

    VelocityCurve drawn = peakContour(/*hardPeak=*/true);
    p.play.ampSpline.mode = EnvMode::Spline;
    p.play.ampSpline.contour = drawn;
    p.play.pitchSpline.contour = drawn;   // stored, but left Staged: the inactive half
    p.play.filterSpline.contour = drawn;
    return p;
}

static void testStagedAndSplineStatesBothSurviveAFlipAndASaveReload() {
    InstrumentParams p = paramsWithBothStates();
    const VelocityCurve drawn = p.play.ampSpline.contour;

    // In memory: flipping the amp back to Staged keeps the contour, and forward again keeps the
    // staged values. Neither converts into the other.
    p.play.ampSpline.mode = EnvMode::Staged;
    CHECK(p.play.ampSpline.contour.equals(drawn));
    CHECK(p.play.adsr.attackSeconds == 0.37);
    p.play.ampSpline.mode = EnvMode::Spline;
    CHECK(p.play.adsr.sustainLevel == 0.42);

    ComponentState st;
    st.selectionId = "cap-1";
    st.params = p;
    const ComponentState back = deserializeComponentState(serializeComponentState(st), 48000.0);
    const PlaySeconds& r = back.params.play;

    CHECK(r.adsr.attackSeconds == 0.37);
    CHECK(r.adsr.decaySeconds == 0.21);
    CHECK(r.adsr.sustainLevel == 0.42);
    CHECK(r.adsr.releaseSeconds == 0.66);
    CHECK(r.pitchEnv.peakSemitones == -7.5);
    CHECK(r.pitchEnv.shape.attackSeconds == 0.11);
    CHECK(r.filter.env.decaySeconds == 0.29);
    CHECK(r.filter.env.sustainLevel == 0.33);

    CHECK(r.ampSpline.mode == EnvMode::Spline);
    CHECK(r.pitchSpline.mode == EnvMode::Staged);
    CHECK(r.filterSpline.mode == EnvMode::Staged);
    // The contour itself, hard flags included, on all three — the inactive ones too.
    CHECK(r.ampSpline.contour.equals(drawn));
    CHECK(r.pitchSpline.contour.equals(drawn));
    CHECK(r.filterSpline.contour.equals(drawn));
    CHECK(r.ampSpline.contour.points()[2].hard);
}

// --- 7. A v12 payload still loads ---------------------------------------------

// Rewrites the params-payload version field to 12, leaving the v12 prefix byte-identical (v13
// is a strict suffix, so the prefix IS what a v12 writer emitted). The reader is positional and
// bounded, so it stops before the appended tail and never sees it.
static std::vector<std::uint8_t> asV12Payload(std::vector<std::uint8_t> bytes) {
    int patched = 0;
    for (std::size_t i = 0; i + 8 <= bytes.size(); ++i) {
        const std::uint32_t marker = static_cast<std::uint32_t>(bytes[i]) |
                                     (static_cast<std::uint32_t>(bytes[i + 1]) << 8) |
                                     (static_cast<std::uint32_t>(bytes[i + 2]) << 16) |
                                     (static_cast<std::uint32_t>(bytes[i + 3]) << 24);
        const std::uint32_t ver = static_cast<std::uint32_t>(bytes[i + 4]) |
                                  (static_cast<std::uint32_t>(bytes[i + 5]) << 8) |
                                  (static_cast<std::uint32_t>(bytes[i + 6]) << 16) |
                                  (static_cast<std::uint32_t>(bytes[i + 7]) << 24);
        if (marker != kParamsFormatMarker || ver != kParamsPayloadVersion) continue;
        bytes[i + 4] = 12;
        ++patched;
    }
    CHECK(patched == 1);  // exactly one payload header, or the rewrite is meaningless
    return bytes;
}

static void testAV12PayloadLoadsWithoutLoss() {
    InstrumentParams p = paramsWithBothStates();
    p.play.playMode = PlayMode::Trigger;  // a v12 project can hold any mode
    p.velocityCurve.addPoint(70.0, 0.4);
    p.velocityCurve.setHard(1, true);
    ComponentState st;
    st.selectionId = "cap-legacy";
    st.params = p;

    const ComponentState back =
        deserializeComponentState(asV12Payload(serializeComponentState(st)), 48000.0);
    const PlaySeconds& r = back.params.play;

    // Everything v12 carried comes through untouched.
    CHECK(back.selectionId == "cap-legacy");
    CHECK(r.playMode == PlayMode::Trigger);
    CHECK(r.adsr.attackSeconds == 0.37);
    CHECK(r.adsr.sustainLevel == 0.42);
    CHECK(r.pitchEnv.peakSemitones == -7.5);
    CHECK(r.filter.env.decaySeconds == 0.29);
    CHECK(back.params.velocityCurve.size() == 3);
    CHECK(near(back.params.velocityCurve.eval(70.0), 0.4));

    // Everything v13 added lifts to its default: Staged on all three, the y = 1 - x contour,
    // and no hard flag anywhere (v12 had nowhere to store one).
    CHECK(r.ampSpline.mode == EnvMode::Staged);
    CHECK(r.pitchSpline.mode == EnvMode::Staged);
    CHECK(r.filterSpline.mode == EnvMode::Staged);
    CHECK(r.ampSpline.contour.equals(VelocityCurve::rampDown()));
    CHECK(!back.params.velocityCurve.points()[1].hard);
}

// --- 8. A stored contour rescales to a different-length sample ---------------

static SampleData splineAmpSample(std::size_t frames, const VelocityCurve& contour) {
    SampleData s;
    s.frames.assign(frames, 1.0f);  // DC: the rendered value IS the envelope
    s.rootNote = 60;
    s.sampleRate = 48000;
    s.play.playMode = PlayMode::Trigger;
    s.play.ampSpline.mode = EnvMode::Spline;
    s.play.ampSpline.contour = contour;
    return s;
}

static std::vector<double> renderVoice(const SampleData& s, std::size_t frames) {
    Voice v;
    v.start(60, 100, s);
    std::vector<double> out;
    out.reserve(frames);
    for (std::size_t i = 0; i < frames; ++i) out.push_back(v.renderFrame());
    return out;
}

static void testAContourReplaysProportionallyOnADifferentLengthSample() {
    const VelocityCurve contour = peakContour(/*hardPeak=*/true);
    const std::size_t shortLen = 1000;
    const std::size_t longLen = 3000;
    const SampleData s1 = splineAmpSample(shortLen, contour);
    const SampleData s2 = splineAmpSample(longLen, contour);
    const std::vector<double> a = renderVoice(s1, shortLen);
    const std::vector<double> b = renderVoice(s2, longLen);

    // The rendered value at frame i of the short sample is the contour at phase i/shortLen; the
    // long sample reaches the SAME phase at frame 3i. Shape preserved, proportionally.
    for (std::size_t i = 1; i + 1 < shortLen; ++i) {
        const double phase = static_cast<double>(i) / static_cast<double>(shortLen);
        CHECK(near(a[i], contour.eval(xAt(phase)), 1e-6));
        CHECK(near(b[i * 3], a[i], 1e-6));
    }
    // Not a flat contour, so the agreement above is a real shape match.
    CHECK(a[shortLen / 2] > a[10] + 0.2);
}

// --- Regression: a 2-point contour starting at 0 is not a terminus at frame 0 -----------
//
// onFinalSegment() (seg_+2==n_) is trivially true for a 2-point contour. Gating the amp
// spline's early-free on that alone reads a contour's OWN opening value as the note's end,
// so the simplest fade-in (left knot dragged to the box floor) went silent at frame 0. The
// fix requires the TERMINAL value (the segment's right endpoint) to be 0, not just the
// segment index.
static void testTwoPointContourRisingFromZeroSoundsForItsFullSpan() {
    const VelocityCurve contour =
        VelocityCurve::fromPoints({{kVelMin, 0.0}, {kVelMax, 1.0}}, CurveDomain::Unipolar);
    const std::size_t frames = 1000;
    const SampleData s = splineAmpSample(frames, contour);

    Voice v;
    v.start(60, 100, s);
    CHECK(v.soundingNote());              // fresh note: sounding before anything is rendered
    const double y0 = v.renderFrame();
    CHECK(near(y0, 0.0, 1e-9));           // the contour's own value at phase 0 IS 0 ...
    CHECK(v.soundingNote());              // ...but the note itself must not be over yet
    for (std::size_t i = 1; i < frames / 2; ++i) v.renderFrame();
    CHECK(v.soundingNote());              // still sounding at the midpoint, rising toward 1
}

// The positive direction of the fix above: a contour whose final segment is flat at 0 (here,
// the whole two-point span) DOES free the voice early, on its very first tick. Nothing exercises
// this without it — a future tightening of the gate (e.g. requiring more than onFinalSegment() +
// segmentEndValue()) could silently turn the early-free off, which is a performance regression
// (a ringing but silent voice) rather than an audible one, so nothing else would catch it.
static void testFlatZeroFinalSegmentStillFreesTheVoiceEarly() {
    const VelocityCurve contour =
        VelocityCurve::fromPoints({{kVelMin, 0.0}, {kVelMax, 0.0}}, CurveDomain::Unipolar);
    const std::size_t frames = 1000;
    const SampleData s = splineAmpSample(frames, contour);

    Voice v;
    v.start(60, 100, s);
    CHECK(v.soundingNote());              // fresh note: sounding before anything is rendered
    const double y0 = v.renderFrame();
    CHECK(near(y0, 0.0, 1e-9));
    CHECK(!v.soundingNote());             // a genuine permanent terminus, not a mid-contour dip
}

// The timing, not just the fact: the case above is trivially "early" (a wholly-flat contour
// frees on frame 0), which can't distinguish "frees early" from "frees at the right frame." This
// fixture's final segment starts MID-sample, so the free must land there, not at frame 0 and not
// at the sample's natural end. The breakpoint (phase 0.5495) is deliberately off every sampled
// frame's exact phase (k/1000), so no sampled frame lands on the segment boundary itself and
// which segment "owns" that frame is never ambiguous.
static void testFlatZeroFinalSegmentFreesTheVoiceWhereItBeginsNotAtFrameZero() {
    const double breakpointPhase = 0.5495;
    const VelocityCurve contour = VelocityCurve::fromPoints(
        {{xAt(0.0), 1.0}, {xAt(breakpointPhase), 0.0}, {xAt(1.0), 0.0}}, CurveDomain::Unipolar);
    const std::size_t frames = 1000;
    const SampleData s = splineAmpSample(frames, contour);

    Voice v;
    v.start(60, 100, s);
    // Frames 0..549 (phase < breakpoint) sit on the declining first segment: still sounding.
    for (std::size_t i = 0; i < 550; ++i) {
        v.renderFrame();
        CHECK(v.soundingNote());
    }
    // Frame 550 (phase 0.55) is the first sampled frame past the breakpoint, on the flat-zero
    // final segment — this is where the early-free fires.
    const double y550 = v.renderFrame();
    CHECK(near(y550, 0.0, 1e-9));
    CHECK(!v.soundingNote());
}

// --- 9. A fresh spline EG opens on the smooth y = 1 - x ----------------------

static void testAFreshSplineEgDefaultsToTheSmoothDownwardSlope() {
    const SplineEnv fresh;
    CHECK(fresh.mode == EnvMode::Staged);  // drawn is opt-in; the CONTOUR is what defaults here
    const VelocityCurve& c = fresh.contour;
    CHECK(c.size() == 2);
    CHECK(!c.points()[0].hard);
    CHECK(!c.points()[1].hard);
    // Two collinear knots reduce the Hermite tangents to the shared secant, so it is an exact
    // straight line — and a straight line is smooth.
    for (int i = 0; i <= 127; ++i) {
        CHECK(near(c.eval(i), 1.0 - static_cast<double>(i) / 127.0, 1e-12));
    }
}

// --- 10. Gate is unavailable while a spline EG is active ---------------------

// The rule has one home (splineActive) and one enforcement point on the way to the engine
// (resolvePlay). The editor's Gate segment refuses and paints Disabled off the same predicate.
//
// Pitch and filter additionally gate on their own `enabled` flag, matching Voice::start's
// binder (voice.cpp only binds pitchSplineCur_/filterSplineCur_ under that same condition): a
// Spline mode flip on a still-disabled envelope produces no modulation, so it must not cost
// Gate either — the predicate and the binder must agree on one enable rule. Amp has no such
// flag and counts on its mode alone.
static void testGateIsUnavailableWhileASplineEgIsActiveAndReturnsAfterwards() {
    PlaySeconds stored;
    stored.playMode = PlayMode::Gate;
    CHECK(!splineActive(stored));
    CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Gate);

    stored.ampSpline.mode = EnvMode::Spline;
    CHECK(splineActive(stored));
    CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Trigger);
    stored.ampSpline.mode = EnvMode::Staged;
    CHECK(!splineActive(stored));
    CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Gate);

    stored.pitchSpline.mode = EnvMode::Spline;
    CHECK(!splineActive(stored));  // pitchEnv.enabled is still false: no modulation, no cost
    CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Gate);
    stored.pitchEnv.enabled = true;
    CHECK(splineActive(stored));
    CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Trigger);
    stored.pitchSpline.mode = EnvMode::Staged;
    stored.pitchEnv.enabled = false;
    CHECK(!splineActive(stored));
    CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Gate);

    stored.filterSpline.mode = EnvMode::Spline;
    CHECK(!splineActive(stored));  // filter.enabled is still false: the filter is fully off
    CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Gate);
    stored.filter.enabled = true;
    CHECK(splineActive(stored));
    CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Trigger);
    stored.filterSpline.mode = EnvMode::Staged;
    stored.filter.enabled = false;
    CHECK(!splineActive(stored));
    CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Gate);

    // And the staged knobs of a drawn envelope go inert — drawn-but-dead, not removed — while
    // its depth knob, which scales either shape, stays live.
    using namespace reasampler::instrument::ui;
    DeckEnableState gates;
    gates.pitchEnvEnabled = true;
    gates.filterEnabled = true;
    CHECK(!deckKnobInert(DeckParam::kAttack, gates));
    gates.ampSpline = true;
    CHECK(deckKnobInert(DeckParam::kAttack, gates));
    CHECK(deckKnobInert(DeckParam::kSustain, gates));
    CHECK(deckKnobInert(DeckParam::kTrigDecay, gates));
    gates.pitchSpline = true;
    CHECK(deckKnobInert(DeckParam::kPitchEnvAttack, gates));
    CHECK(!deckKnobInert(DeckParam::kPitchEnvDepth, gates));
    gates.filterSpline = true;
    CHECK(deckKnobInert(DeckParam::kFilterEnvRelease, gates));
    CHECK(!deckKnobInert(DeckParam::kFilterModAmt, gates));
}

// enforceGateUnavailableWhileDrawn (play_params.h) is the ONE enforcement resolvePlay and the
// editor's applyControl both call — resolvePlay's own coverage above only exercises it through
// the frames mirror; pin it directly over BOTH representations it is shared between, closing the
// coverage gap the extraction was for (applyControl has no shell test target of its own).
static void testEnforceGateUnavailableWhileDrawnForcesTriggerOnBothRepresentations() {
    PlaySeconds seconds;
    seconds.playMode = PlayMode::Gate;
    enforceGateUnavailableWhileDrawn(seconds);
    CHECK(seconds.playMode == PlayMode::Gate);  // not splineActive -> untouched
    seconds.ampSpline.mode = EnvMode::Spline;
    enforceGateUnavailableWhileDrawn(seconds);
    CHECK(seconds.playMode == PlayMode::Trigger);

    PlayParams frames;
    frames.playMode = PlayMode::Gate;
    frames.filter.enabled = true;
    frames.filterSpline.mode = EnvMode::Spline;
    enforceGateUnavailableWhileDrawn(frames);
    CHECK(frames.playMode == PlayMode::Trigger);
}

// effectiveLengthFraction is the third member of the same rule family, and the one every
// consumer of the Trigger span (the voice, the overlay, the bake's window) must fold through:
// a drawn contour covers the FULL sample length, so the stored %-knob goes inert. It is NOT
// cleared — a pre-spline value survives in the record, and a reader that takes it raw plays,
// draws or bakes a fraction of the take. Gated on the same per-envelope `enabled` flags for the
// same reason the predicate above is.
static void testEffectiveLengthFractionFoldsToOneOnlyWhileAnEgIsActuallyDrawn() {
    PlayParams p;
    p.trigger.lengthFraction = 0.25;
    CHECK(effectiveLengthFraction(p) == 0.25);   // staged: the knob is what it says

    p.ampSpline.mode = EnvMode::Spline;
    CHECK(effectiveLengthFraction(p) == 1.0);    // drawn: the whole take
    CHECK(p.trigger.lengthFraction == 0.25);     // …and the stored value is untouched

    // Flipping back restores the stored fraction, which is why the fold cannot be a write.
    p.ampSpline.mode = EnvMode::Staged;
    CHECK(effectiveLengthFraction(p) == 0.25);

    p.pitchSpline.mode = EnvMode::Spline;
    CHECK(effectiveLengthFraction(p) == 0.25);   // pitchEnv.enabled is still false
    p.pitchEnv.enabled = true;
    CHECK(effectiveLengthFraction(p) == 1.0);

    p.pitchEnv.enabled = false;
    p.pitchSpline.mode = EnvMode::Staged;
    p.filterSpline.mode = EnvMode::Spline;
    CHECK(effectiveLengthFraction(p) == 0.25);   // filter.enabled is still false
    p.filter.enabled = true;
    CHECK(effectiveLengthFraction(p) == 1.0);
}

// effectivePlayMode is what a READ-ONLY consumer asks instead of re-testing the fields — the
// editor's bake-Hold predicate is one. It must answer exactly what the enforcement writes.
static void testEffectivePlayModeAgreesWithTheEnforcementItShares() {
    PlaySeconds stored;
    stored.playMode = PlayMode::Gate;
    CHECK(effectivePlayMode(stored) == PlayMode::Gate);
    stored.ampSpline.mode = EnvMode::Spline;
    CHECK(effectivePlayMode(stored) == PlayMode::Trigger);
    CHECK(stored.playMode == PlayMode::Gate);  // a read, never a write
    PlaySeconds enforced = stored;
    enforceGateUnavailableWhileDrawn(enforced);
    CHECK(enforced.playMode == effectivePlayMode(stored));
}

// --- 11. The velocity->amp curve is the same grammar -------------------------

static void testTheVelocityAmpCurveGainsTheToggleAndKeepsItsDelete() {
    using namespace reasampler::instrument::ui;
    const VelocityCurve::Box box{100, 50, 127, 101};
    VelocityCurve amp = VelocityCurve::flat();
    CHECK(amp.addPoint(64.0, 0.25) == 1);
    const auto px = amp.pixelFromPoint(box, amp.points()[1]);

    // Control-click resolves to the toggle — the identical resolution the spline EG overlay
    // gets, because it is the identical function.
    const SplineEdit toggle =
        resolveSplineEdit(amp, box, SplineGesture::kControlLeft, px.x, px.y);
    CHECK(toggle.kind == SplineEditKind::kToggleHard);
    CHECK(toggle.index == 1);
    const double smoothMid = amp.eval(48.0);
    CHECK(amp.toggleHard(1));
    CHECK(amp.points()[1].hard);
    CHECK(!near(amp.eval(48.0), smoothMid, 1e-9));  // the hard flag reaches the amp response

    // Right-click delete is unchanged: it resolves on an interior node and the endpoint guard
    // still refuses the two ends.
    const SplineEdit del = resolveSplineEdit(amp, box, SplineGesture::kRight, px.x, px.y);
    CHECK(del.kind == SplineEditKind::kDelete);
    CHECK(del.index == 1);
    CHECK(amp.deletePoint(1));
    CHECK(amp.size() == 2);
    CHECK(!amp.deletePoint(0));
    CHECK(!amp.deletePoint(1));
}

// --- 12. SplineCursor's binary-search branch agrees with the cold reader ------

// Test 8 only walks a monotone forward read, which never leaves SplineCursor::locate's
// select(seg_+1) fast path. A backwards/jumping read forces the actual binary search — and at
// a duplicate-X knot (a drawn step) the RT cursor must resolve to the SAME point the cold
// VelocityCurve::eval() would, or a backwards read audibly steps to the wrong side of the step.
static void testSplineCursorBinarySearchAgreesWithTheColdReaderOnAJumpingRead() {
    // A step at x=64: two knots sharing an X but different Y.
    VelocityCurve c = VelocityCurve::fromPoints(
        {{0.0, 0.1}, {32.0, 0.3}, {64.0, 0.9}, {64.0, 0.2}, {96.0, 0.6}, {127.0, 0.4}},
        CurveDomain::Unipolar);
    SplineCursor cur;
    cur.bind(c);

    // Deliberately out of order, so every eval but the first forces locate()'s binary search
    // rather than the forward-walk fast path.
    const double xs[] = {100.0, 10.0, 64.0, 40.0, 64.0, 5.0, 127.0, 20.0, 0.0, 90.0};
    for (double x : xs) {
        const double phase = x / kCurveXMax;
        CHECK(near(cur.eval(phase), c.eval(x), 1e-6));
    }
    // The duplicate knot itself: both readers resolve to the SAME one (the first, per
    // VelocityCurve::eval's "first containing segment" rule).
    CHECK(near(cur.eval(64.0 / kCurveXMax), 0.9, 1e-6));
}

int main() {
    testHardPointGivesDifferingOneSidedSlopes();
    testNoOvershootBetweenAnyAdjacentPairOnARiseAndFallContour();
    testAddingAtTheCeilingIsRefusedAndLeavesTheContourBitIdentical();
    testEndpointDeletionIsRefused();
    testTogglingHardThenSmoothRestoresTheEvaluatedContour();
    testStagedAndSplineStatesBothSurviveAFlipAndASaveReload();
    testAV12PayloadLoadsWithoutLoss();
    testAContourReplaysProportionallyOnADifferentLengthSample();
    testTwoPointContourRisingFromZeroSoundsForItsFullSpan();
    testFlatZeroFinalSegmentStillFreesTheVoiceEarly();
    testFlatZeroFinalSegmentFreesTheVoiceWhereItBeginsNotAtFrameZero();
    testAFreshSplineEgDefaultsToTheSmoothDownwardSlope();
    testGateIsUnavailableWhileASplineEgIsActiveAndReturnsAfterwards();
    testEnforceGateUnavailableWhileDrawnForcesTriggerOnBothRepresentations();
    testEffectiveLengthFractionFoldsToOneOnlyWhileAnEgIsActuallyDrawn();
    testEffectivePlayModeAgreesWithTheEnforcementItShares();
    testTheVelocityAmpCurveGainsTheToggleAndKeepsItsDelete();
    testSplineCursorBinarySearchAgreesWithTheColdReaderOnAJumpingRead();
    if (g_fail == 0) std::printf("spline_egs: all tests passed\n");
    return g_fail == 0 ? 0 : 1;
}
