// Standalone tests for reasampler::instrument::ui::deck_groups' commit-tier routing and
// overlay-selection state machine — no VST3, no REAPER, no framework. Split from
// test_deck_groups.cpp on the seam those fixtures already had: nothing here touches
// layoutDeck, DeckGroupWidth, or any other geometry API — deckParamCommit/liveCommitFor (which
// controls are live, and which drags take the live tier) and the overlay-selection state
// machine (exclusivity, the none resting state, and which selections are inert) are pure
// control-id/enum predicates. test_deck_groups.cpp keeps the geometry/row/width fixtures.

#include "../src/core/instrument/ui/deck_groups.h"

#include <cstdio>

using namespace reasampler;
using namespace reasampler::instrument::ui;

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 void testEveryDeckControlIsClassifiedIntoOneOfTheThreeCommitTiers() {
    // The live set: the seven filter tone/modulation knobs, the baseline pitch offset, plus
    // every stage time, stage level, hold fraction and curve exponent on all three envelopes —
    // in BOTH mode shapes.
    const DeckParam live[] = {
        DeckParam::kPitch,
        DeckParam::kFilterMorph, DeckParam::kFilterCutoff, DeckParam::kFilterQ,
        DeckParam::kFilterDrive, DeckParam::kFilterModAmt, DeckParam::kFilterVel,
        DeckParam::kFilterKeyTrack,
        DeckParam::kAttack, DeckParam::kHold, DeckParam::kDecay, DeckParam::kSustain,
        DeckParam::kRelease,
        DeckParam::kTrigAttack, DeckParam::kTrigHold, DeckParam::kTrigDecay,
        DeckParam::kFilterEnvAttack, DeckParam::kFilterEnvHold, DeckParam::kFilterEnvDecay,
        DeckParam::kFilterEnvSustain, DeckParam::kFilterEnvRelease,
        DeckParam::kFilterTrigAttack, DeckParam::kFilterTrigHold, DeckParam::kFilterTrigDecay,
        DeckParam::kPitchEnvAttack, DeckParam::kPitchEnvHold, DeckParam::kPitchEnvDecay,
        DeckParam::kPitchEnvDepth,
        DeckParam::kAttackCurve, DeckParam::kDecayCurve, DeckParam::kReleaseCurve,
        DeckParam::kTrigAttackCurve, DeckParam::kTrigDecayCurve,
        DeckParam::kPitchEnvAttackCurve, DeckParam::kPitchEnvDecayCurve,
        DeckParam::kFilterEnvAttackCurve, DeckParam::kFilterEnvDecayCurve,
        DeckParam::kFilterEnvReleaseCurve,
        DeckParam::kFilterTrigAttackCurve, DeckParam::kFilterTrigDecayCurve,
        // The one live control that does not ride the live block: a lock-free atomic the audio
        // thread applies as a post-sum multiply. The tier answers "does an edit reach the audio
        // without a reload", not "which mechanism carries it".
        DeckParam::kMasterGain,
    };
    for (DeckParam p : live) CHECK(deckParamCommit(p) == LiveCommit::Live);

    // The note-on-latched tier: published like a live control, read only at note-on. Asserted as
    // its OWN state rather than as "not Reload" — the whole point of widening the predicate is
    // that none of these falls back into either neighbour, and the VST3 parameter surface reads
    // this classification to decide what it exposes to the host.
    const DeckParam latched[] = {DeckParam::kRate, DeckParam::kKeyTrack,
                                 DeckParam::kTrigLength};
    for (DeckParam p : latched) CHECK(deckParamCommit(p) == LiveCommit::NoteOnLatched);

    // Everything else reloads or rebuilds; deck_groups.h is the home for why each exclusion
    // is excluded.
    const DeckParam reloads[] = {
        DeckParam::kPlayMode, DeckParam::kPitchEngine, DeckParam::kPitchEnvEnable,
        DeckParam::kFilterEnable, DeckParam::kFilterLaw,
        DeckParam::kAmpVelCurve, DeckParam::kPitchVelCurve, DeckParam::kFilterVelCurve,
        DeckParam::kAmpEnvSelect, DeckParam::kPitchEnvSelect, DeckParam::kFilterEnvSelect,
        DeckParam::kAmpEnvMode, DeckParam::kPitchEnvMode, DeckParam::kFilterEnvMode,
        DeckParam::kVoiceCount, DeckParam::kVoiceMode,
        DeckParam::kMonoTrigger, DeckParam::kLimiterEnable,
        DeckParam::kMasterMeter, DeckParam::kMasterGr,
    };
    for (DeckParam p : reloads) CHECK(deckParamCommit(p) == LiveCommit::Reload);

    // COVERAGE, not cardinality: every id appears in EXACTLY ONE of the three lists. A sum check
    // would stay green if an edit duplicated one id and dropped another, leaving that one
    // unclassified.
    for (int i = 0; i < static_cast<int>(DeckParam::kCount); ++i) {
        const DeckParam p = static_cast<DeckParam>(i);
        int seen = 0;
        for (DeckParam q : live) if (q == p) ++seen;
        for (DeckParam q : latched) if (q == p) ++seen;
        for (DeckParam q : reloads) if (q == p) ++seen;
        if (seen != 1) std::printf("  (deck id %d classified %d times)\n", i, seen);
        CHECK(seen == 1);
    }
}

static void testOnlyALiveControlsDragTakesTheLiveTier() {
    // deckParamCommit alone is not what a user experiences — liveCommitFor is, at the editor's
    // commit site. Inverting it has to FAIL a test rather than merely read wrong.
    const auto knob = [](DeckParam p) {
        return liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(p));
    };
    CHECK(knob(DeckParam::kFilterCutoff) == LiveCommit::Live);
    CHECK(knob(DeckParam::kAttack) == LiveCommit::Live);
    CHECK(knob(DeckParam::kPitch) == LiveCommit::Live);
    // The Trigger amp is live now that the fade pair folded into the AHD — the one behavioural
    // consequence of that consolidation.
    CHECK(knob(DeckParam::kTrigAttack) == LiveCommit::Live);
    CHECK(knob(DeckParam::kTrigDecayCurve) == LiveCommit::Live);
    // Rate keeps its own tier through the drag site: it must not arrive as Live (which would let
    // it move a sounding note) nor as Reload (which would re-decode the WAV under a swept knob).
    CHECK(knob(DeckParam::kRate) == LiveCommit::NoteOnLatched);
    // Trigger length resolves playEnd_ and key-track the pitch ratio — both facts a voice fixes
    // at note-on, which is the latched tier's own definition rather than the reload tier's.
    CHECK(knob(DeckParam::kTrigLength) == LiveCommit::NoteOnLatched);
    CHECK(knob(DeckParam::kKeyTrack) == LiveCommit::NoteOnLatched);
    // Master gain is Live and reaches the audio BESIDE the live block rather than through it —
    // one atomic the audio thread applies as a post-sum multiply. Classifying it Reload would
    // claim a gain move re-decodes the WAV, which it never did.
    CHECK(knob(DeckParam::kMasterGain) == LiveCommit::Live);
    CHECK(knob(DeckParam::kAmpEnvSelect) == LiveCommit::Reload);
    // The shell's processor-side sentinels (preview velocity is -2) and any out-of-range id
    // are not parameter-set controls, so they must never reach the enum.
    CHECK(liveCommitFor(LiveDragKind::kDeckKnob, -2) == LiveCommit::Reload);
    CHECK(liveCommitFor(LiveDragKind::kDeckKnob, -1) == LiveCommit::Reload);
    CHECK(knob(DeckParam::kCount) == LiveCommit::Reload);
    // Every stage value an envelope node can reach is live, in either mode shape.
    CHECK(liveCommitFor(LiveDragKind::kEnvNode, -1) == LiveCommit::Live);
    // Every other drag (markers, scrollbar, curve nodes) commits through a reload.
    CHECK(liveCommitFor(LiveDragKind::kOther, static_cast<int>(DeckParam::kFilterCutoff)) ==
          LiveCommit::Reload);
}

// --- The overlay selection state machine ---------------------------------------

static int radio(DeckParam p) { return static_cast<int>(p); }

// EXCLUSIVITY: picking another deck's radio switches to it outright — two envelopes can never
// be overlay-active at once, whatever the previous selection was.
static void testOverlaySelectionIsExclusiveAcrossTheThreeEnvelopeDecks() {
    const OverlayEnv states[] = {OverlayEnv::kNone, OverlayEnv::kAmp, OverlayEnv::kPitch,
                                 OverlayEnv::kFilter};
    for (OverlayEnv from : states) {
        if (from != OverlayEnv::kAmp) {
            CHECK(nextOverlaySelection(from, radio(DeckParam::kAmpEnvSelect)) == OverlayEnv::kAmp);
        }
        if (from != OverlayEnv::kPitch) {
            CHECK(nextOverlaySelection(from, radio(DeckParam::kPitchEnvSelect)) ==
                  OverlayEnv::kPitch);
        }
        if (from != OverlayEnv::kFilter) {
            CHECK(nextOverlaySelection(from, radio(DeckParam::kFilterEnvSelect)) ==
                  OverlayEnv::kFilter);
        }
    }
}

// kNone is a RESTING STATE the user can get back to: clicking the active radio clears it.
static void testClickingTheActiveOverlayRadioClearsToNone() {
    CHECK(nextOverlaySelection(OverlayEnv::kAmp, radio(DeckParam::kAmpEnvSelect)) ==
          OverlayEnv::kNone);
    CHECK(nextOverlaySelection(OverlayEnv::kPitch, radio(DeckParam::kPitchEnvSelect)) ==
          OverlayEnv::kNone);
    CHECK(nextOverlaySelection(OverlayEnv::kFilter, radio(DeckParam::kFilterEnvSelect)) ==
          OverlayEnv::kNone);
}

// A control that is not one of the three radios selects nothing and clears nothing.
static void testANonRadioIdLeavesTheOverlaySelectionAlone() {
    CHECK(overlayEnvForRadio(radio(DeckParam::kFilterCutoff)) == OverlayEnv::kNone);
    CHECK(overlayEnvForRadio(-1) == OverlayEnv::kNone);
    CHECK(nextOverlaySelection(OverlayEnv::kFilter, radio(DeckParam::kFilterCutoff)) ==
          OverlayEnv::kFilter);
    CHECK(nextOverlaySelection(OverlayEnv::kAmp, 9999) == OverlayEnv::kAmp);
}

// The two group gates, spelled the way the predicates read them. Spline flags default off, so
// a case that says nothing about them is asserting the staged behaviour.
static DeckEnableState gates(bool pitchEnv, bool filter) {
    DeckEnableState s;
    s.pitchEnvEnabled = pitchEnv;
    s.filterEnabled = filter;
    return s;
}

// An overlay whose deck group is switched OFF is inert, matching the drawn-but-dead knobs on
// the same params: a node drag must not reach a value the knob refuses.
static void testOverlayIsInertExactlyWhenItsGroupToggleIsOff() {
    CHECK(overlayEnvInert(OverlayEnv::kPitch, gates(/*pitchEnv=*/false, /*filter=*/true)));
    CHECK(!overlayEnvInert(OverlayEnv::kPitch, gates(true, true)));
    CHECK(overlayEnvInert(OverlayEnv::kFilter, gates(true, /*filter=*/false)));
    CHECK(!overlayEnvInert(OverlayEnv::kFilter, gates(true, true)));
    // Amp has no enable toggle, so it is never inert; kNone draws nothing to grab.
    CHECK(!overlayEnvInert(OverlayEnv::kAmp, gates(false, false)));
    CHECK(!overlayEnvInert(OverlayEnv::kNone, gates(false, false)));

    // The enable gate alone, which the SPLINE overlay reads: it survives a mode switch, so a
    // disabled group's contour is as dead as its knobs.
    CHECK(!overlayEnvEnabled(OverlayEnv::kPitch, gates(false, true)));
    CHECK(overlayEnvEnabled(OverlayEnv::kAmp, gates(false, false)));
    // ...while the staged overlay additionally goes inert once the envelope is drawn: its
    // nodes are no longer what the overlay is editing.
    DeckEnableState drawn = gates(true, true);
    drawn.ampSpline = true;
    CHECK(overlayEnvInert(OverlayEnv::kAmp, drawn));
    CHECK(overlayEnvEnabled(OverlayEnv::kAmp, drawn));
}

// A deck knob goes inert exactly with its group's own enable toggle — including the filter's
// VELOCITY cell, which sits in the VELOCITY group visually but is a filter parameter and must
// go inert with the rest of the filter (the reachable-through-the-deck route mouseDownDeck
// checks before ever routing a curve-cell click to the popup).
static void testDeckKnobIsInertExactlyWithItsGroupsEnableToggle() {
    CHECK(deckKnobInert(DeckParam::kFilterVelCurve, gates(/*pitchEnv=*/true, /*filter=*/false)));
    CHECK(!deckKnobInert(DeckParam::kFilterVelCurve, gates(true, true)));
    CHECK(deckKnobInert(DeckParam::kFilterCutoff, gates(true, false)));
    CHECK(!deckKnobInert(DeckParam::kFilterCutoff, gates(true, true)));
    CHECK(deckKnobInert(DeckParam::kPitchEnvDepth, gates(/*pitchEnv=*/false, true)));
    CHECK(!deckKnobInert(DeckParam::kPitchEnvDepth, gates(true, true)));
    // The amp's own velocity cell and every ordinary control are never inert here — inertness
    // is a filter/pitch-env-group-only concept until an envelope is drawn.
    CHECK(!deckKnobInert(DeckParam::kAmpVelCurve, gates(false, false)));
    CHECK(!deckKnobInert(DeckParam::kAttack, gates(false, false)));
}

// A drawn envelope's STAGED segment knobs go inert; the mode toggle itself and the depth knobs
// that scale either shape stay live. (Which segment knobs, per envelope, is pinned in
// spline_egs_tests alongside the rest of the spline rules.)
static void testAModeToggleIsNeitherLiveNorAnOverlayRadio() {
    CHECK(deckParamCommit(DeckParam::kAmpEnvMode) == LiveCommit::Reload);
    CHECK(deckParamCommit(DeckParam::kPitchEnvMode) == LiveCommit::Reload);
    CHECK(deckParamCommit(DeckParam::kFilterEnvMode) == LiveCommit::Reload);
    CHECK(overlayEnvForModeToggle(radio(DeckParam::kAmpEnvMode)) == OverlayEnv::kAmp);
    CHECK(overlayEnvForModeToggle(radio(DeckParam::kPitchEnvMode)) == OverlayEnv::kPitch);
    CHECK(overlayEnvForModeToggle(radio(DeckParam::kFilterEnvMode)) == OverlayEnv::kFilter);
    // A mode toggle must not be mistaken for the overlay-select radio beside it.
    CHECK(overlayEnvForRadio(radio(DeckParam::kAmpEnvMode)) == OverlayEnv::kNone);
    CHECK(overlayEnvForModeToggle(radio(DeckParam::kAmpEnvSelect)) == OverlayEnv::kNone);
}

int main() {
    testOverlaySelectionIsExclusiveAcrossTheThreeEnvelopeDecks();
    testClickingTheActiveOverlayRadioClearsToNone();
    testANonRadioIdLeavesTheOverlaySelectionAlone();
    testOverlayIsInertExactlyWhenItsGroupToggleIsOff();
    testDeckKnobIsInertExactlyWithItsGroupsEnableToggle();
    testAModeToggleIsNeitherLiveNorAnOverlayRadio();
    testEveryDeckControlIsClassifiedIntoOneOfTheThreeCommitTiers();
    testOnlyALiveControlsDragTakesTheLiveTier();
    if (g_fail == 0) std::printf("deck_groups_state: all tests passed\n");
    return g_fail == 0 ? 0 : 1;
}
