// Standalone tests for the block-boundary merge — the AUTHORITY LIFETIME of a host automation
// point. The load-bearing one is the RELEASE: a point outranks the model only until the model
// carries it. Held forever, one point defeats every later state restore, bake reset and knob
// move; released too eagerly, a lane in flight reverts for a block.

#include "../src/core/instrument/param/param_merge.h"

#include "../src/core/instrument/param/param_id.h"
#include "../src/core/instrument/param/param_live.h"
#include "../src/core/instrument/param/param_units.h"
#include "../src/core/instrument/map/sample_map.h"
#include "../src/core/instrument/ui/deck_values.h"

#include <cstdio>


using namespace reasampler;
using namespace reasampler::instrument::param;
using reasampler::instrument::engine::LiveValues;
using reasampler::instrument::engine::foldLive;
using reasampler::instrument::map::InstrumentParams;
using reasampler::instrument::map::resolvePlay;
using reasampler::instrument::ui::DeckParam;

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

namespace {

constexpr int kRate = 48000;
constexpr std::size_t kCutoff = static_cast<std::size_t>(DeckParam::kFilterCutoff);

LiveValues modelBlock(const InstrumentParams& params) {
    return foldLive(resolvePlay(params.play, kRate), params.keyTrack);
}

// A slot array with one lane driving `deck`.
struct Slots {
    AutomationSlot s[kDeckParamSlots] = {};
    AutomationSlot* operator()() { return s; }
};

}  // namespace

// THE test this module exists for. A point lands, the merge applies it over the model; the model
// is then rewritten to something else while the hold is STILL outstanding, and the point must win
// — that is the ≤one-tick window the hold is for. Once the fold has caught the model up and the
// slot is marked folded, the merge releases it and the MODEL wins, permanently.
static void testAHeldPointOutranksTheModelOnlyUntilTheModelCarriesIt() {
    InstrumentParams automated;
    automated.play.filter.settings.cutoffNorm = 0.9f;

    // 1. The point is held: a model that says 0.9 loses to the lane's 0.2.
    Slots slots;
    slots.s[kCutoff] = AutomationSlot{0.2, /*held=*/true, /*folded=*/false};
    LiveValues block = modelBlock(automated);
    mergeAutomation(block, slots(), kDeckParamSlots, kRate);
    CHECK(block.filterSettings.cutoffNorm == 0.2f);
    CHECK(slots.s[kCutoff].held);  // still outstanding — nothing has folded it

    // 2. A state restore lands a different value while the hold is outstanding. Still the lane's:
    //    this is the window the hold exists for, and it is the ONLY window.
    InstrumentParams restored;
    restored.play.filter.settings.cutoffNorm = 0.55f;
    block = modelBlock(restored);
    mergeAutomation(block, slots(), kDeckParamSlots, kRate);
    CHECK(block.filterSettings.cutoffNorm == 0.2f);

    // 3. The UI folds the point into the model and republishes; the merge sees the release.
    InstrumentParams folded;
    folded.play.filter.settings.cutoffNorm = 0.2f;
    slots.s[kCutoff].folded = true;
    block = modelBlock(folded);
    mergeAutomation(block, slots(), kDeckParamSlots, kRate);
    CHECK(block.filterSettings.cutoffNorm == 0.2f);
    CHECK(!slots.s[kCutoff].held);  // RELEASED — this is the whole fix

    // 4. And now a later writer — a preset load, a bake reset, a knob — actually reaches the
    //    audio. This is what a latch with no release makes impossible.
    block = modelBlock(restored);
    mergeAutomation(block, slots(), kDeckParamSlots, kRate);
    CHECK(block.filterSettings.cutoffNorm == 0.55f);
}

// The release must not leak across points: a lane that sent a NEW point after the fold read the
// previous one is still driving, and its new value must survive the release of the old.
static void testANewPointAfterTheFoldIsNotReleasedByIt() {
    Slots slots;
    // The audio thread re-holds at 0.7; the UI's fold was of the earlier 0.2, so the sequence
    // comparison the shell runs leaves `folded` false for this newer point.
    slots.s[kCutoff] = AutomationSlot{0.7, /*held=*/true, /*folded=*/false};
    InstrumentParams foldedModel;
    foldedModel.play.filter.settings.cutoffNorm = 0.2f;
    LiveValues block = modelBlock(foldedModel);
    mergeAutomation(block, slots(), kDeckParamSlots, kRate);
    CHECK(block.filterSettings.cutoffNorm == 0.7f);
    CHECK(slots.s[kCutoff].held);
}

// A slot that never took a point leaves the block exactly as the model folded it.
static void testAnUnheldSlotLeavesTheBlockAlone() {
    Slots slots;
    InstrumentParams p;
    p.play.adsr.attackSeconds = 0.25;
    const LiveValues expected = modelBlock(p);
    LiveValues block = modelBlock(p);
    mergeAutomation(block, slots(), kDeckParamSlots, kRate);
    CHECK(expected == block);
}

// The dirty gate at its source. A lane resending the value it already sent — the steady state of
// a flat segment in read mode — must report that nothing moved, so the block is never re-read,
// re-merged or republished. A lane that MOVED must report that it did, and so must a repeat that
// arrives after the hold was released (some other writer may have moved the model since).
static void testARepeatOfAStandingHoldMovesNothing() {
    AutomationSlot slot{0.4, /*held=*/true, /*folded=*/false};
    CHECK(!automationPointMoves(slot, 0.4));
    CHECK(automationPointMoves(slot, 0.41));
    slot.held = false;
    CHECK(automationPointMoves(slot, 0.4));
}

// The gate the merge publishes through. Two blocks folded from the same parameter set and merged
// with the same slot state compare EQUAL — which a byte compare does not reliably report, since
// LiveValues carries padding no copy is required to preserve. This is the assertion that fails if
// operator== is ever "simplified" back into a memcmp.
static void testTwoIdenticalMergesCompareEqual() {
    InstrumentParams p;
    p.play.adsr.attackSeconds = 0.13;
    p.play.trigger.lengthFraction = 0.6;
    Slots slots;
    slots.s[kCutoff] = AutomationSlot{0.4, /*held=*/true, /*folded=*/false};

    LiveValues first = modelBlock(p);
    mergeAutomation(first, slots(), kDeckParamSlots, kRate);
    LiveValues again = modelBlock(p);
    mergeAutomation(again, slots(), kDeckParamSlots, kRate);
    CHECK(first == again);

    slots.s[kCutoff].norm = 0.41;
    LiveValues moved = modelBlock(p);
    mergeAutomation(moved, slots(), kDeckParamSlots, kRate);
    CHECK(first != moved);
}

// The merge addresses a slot by DeckParam ORDINAL, which is the one thing it does that
// param_live's equivalence test cannot see: a slot recovered as the wrong enumerator would patch
// a neighbouring control. Swept over every exposed control for that reason, not to re-assert the
// value laws param_live already owns.
static void testEverySlotResolvesToItsOwnControl() {
    const double kPositions[] = {0.0, 0.29, 0.5, 0.77, 1.0};
    for (const ParamRow& row : exposedParams()) {
        if (row.deck == DeckParam::kMasterGain) continue;  // reaches the audio beside the block
        for (double norm : kPositions) {
            InstrumentParams written;
            if (row.deck == DeckParam::kKeyTrack) {
                written.keyTrack = reasampler::instrument::ui::keyTrackFromNorm(norm);
            } else {
                writeHostParam(row.deck, written.play, norm);
            }
            const LiveValues expected = modelBlock(written);

            Slots slots;
            slots.s[static_cast<std::size_t>(row.deck)] =
                AutomationSlot{norm, /*held=*/true, /*folded=*/false};
            LiveValues merged = modelBlock(InstrumentParams{});
            mergeAutomation(merged, slots(), kDeckParamSlots, kRate);
            CHECK_ID(expected == merged, row.id);
        }
    }
}

int main() {
    testAHeldPointOutranksTheModelOnlyUntilTheModelCarriesIt();
    testANewPointAfterTheFoldIsNotReleasedByIt();
    testAnUnheldSlotLeavesTheBlockAlone();
    testARepeatOfAStandingHoldMovesNothing();
    testTwoIdenticalMergesCompareEqual();
    testEverySlotResolvesToItsOwnControl();
    if (g_fail == 0) std::printf("param_merge: all tests passed\n");
    return g_fail == 0 ? 0 : 1;
}
