// Standalone tests for reasampler::instrument::map::bank_sync — no REAPER, no VST3, no framework.
// The S9 bank-generation change-detection + the S8 assignment-request consume DECISION
// (the yes/no maths the instrument's off-audio-thread poll runs). The shell owns the
// cadence + side effects; this proves the decision rules without a host.
//
// Covers: parseBankGeneration (absent/malformed/overflow/negative/valid whole-string),
// formatBankGeneration round-trip, bankGenerationChanged, and every consumeDecision rule
// (no request / not-newer / non-target / unresolvable-drop / apply), asserting both the
// apply flag AND the advanced-marker value so a stale request is never re-evaluated.

#include "../src/core/instrument/map/bank_sync.h"

#include <cstdint>
#include <cstdio>
#include <optional>
#include <string>

using namespace reasampler;
using namespace reasampler::instrument::map;

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

// --- parseBankGeneration -----------------------------------------------------

static void testParseAbsentAndMalformed() {
    // Absent / empty -> generation 0 (the pre-S9 default; a project with no stamp).
    CHECK(parseBankGeneration("") == 0);
    CHECK(parseBankGeneration("") == kBankGenerationAbsent);
    // Malformed -> 0, never a crash, never a partial value.
    CHECK(parseBankGeneration("abc") == 0);
    CHECK(parseBankGeneration("12x") == 0);     // trailing garbage rejects whole
    CHECK(parseBankGeneration("x12") == 0);     // leading garbage
    CHECK(parseBankGeneration("1 2") == 0);     // embedded space
    CHECK(parseBankGeneration("+5") == 0);      // sign rejected
    CHECK(parseBankGeneration("-5") == 0);      // negative rejected
    CHECK(parseBankGeneration(" 5") == 0);      // leading space
    CHECK(parseBankGeneration("5.0") == 0);     // decimal point
}

static void testParseValid() {
    CHECK(parseBankGeneration("0") == 0);
    CHECK(parseBankGeneration("1") == 1);
    CHECK(parseBankGeneration("42") == 42);
    CHECK(parseBankGeneration("00042") == 42);  // leading zeros are still digits -> 42
    CHECK(parseBankGeneration("9007199254740993") == 9007199254740993LL);  // > 2^53
}

static void testParseOverflow() {
    // A 19-digit int64-max is fine; anything past it rejects to 0 (never wraps).
    CHECK(parseBankGeneration("9223372036854775807") == 9223372036854775807LL);  // INT64_MAX
    CHECK(parseBankGeneration("9223372036854775808") == 0);  // INT64_MAX + 1 -> reject
    CHECK(parseBankGeneration("99999999999999999999") == 0);  // 20 nines -> reject
}

static void testFormatRoundTrip() {
    CHECK(formatBankGeneration(0) == "0");
    CHECK(formatBankGeneration(1) == "1");
    CHECK(formatBankGeneration(123456789) == "123456789");
    // Round-trips: format then parse yields the original for the valid domain.
    for (std::int64_t g : {std::int64_t{0}, std::int64_t{1}, std::int64_t{7},
                           std::int64_t{9007199254740993LL}}) {
        CHECK(parseBankGeneration(formatBankGeneration(g)) == g);
    }
}

// --- bankGenerationChanged ---------------------------------------------------

static void testGenerationChanged() {
    CHECK(!bankGenerationChanged(0, 0));   // pre-S9 idle: no stamp seen, no stamp now
    CHECK(bankGenerationChanged(0, 1));    // first bump after a pre-S9 baseline -> change
    CHECK(bankGenerationChanged(5, 6));    // normal increment
    CHECK(!bankGenerationChanged(6, 6));   // idle poll (coalesced): no change
    CHECK(bankGenerationChanged(6, 3));    // a project switch/reload can lower it -> change
}

// --- consumeDecision ---------------------------------------------------------

static AssignmentRequest makeReq(const std::string& bank, const std::string& sample,
                                 std::int64_t gen) {
    AssignmentRequest r;
    r.bankId = bank;
    r.sampleId = sample;
    r.generation = gen;
    return r;
}

// Rule 1a: no pending request -> nothing to do, marker unchanged.
static void testNoRequest() {
    const auto d = consumeDecision(std::nullopt, /*lastConsumed*/ 5,
                                   /*resolves*/ true, /*isFocusedTarget*/ true);
    CHECK(!d.apply);
    CHECK(d.consumedGeneration == 5);  // marker held
}

// Rule 1b: a request no newer than what we already consumed (re-open case) -> no re-apply.
static void testNotNewerNotReapplied() {
    // The persisted marker equals the request generation: the user already got this assign,
    // possibly changed away from it. It MUST NOT re-apply on re-open.
    const auto same = consumeDecision(makeReq("b", "s", 100), 100, true, true);
    CHECK(!same.apply);
    CHECK(same.consumedGeneration == 100);  // unchanged
    // An older request (a stale value lingering) is likewise ignored.
    const auto older = consumeDecision(makeReq("b", "s", 90), 100, true, true);
    CHECK(!older.apply);
    CHECK(older.consumedGeneration == 100);
}

// Rule 2: a NEW request but this instance is not the target -> do not apply AND do not
// advance the marker (must stay eligible if focus later lands here — no thundering herd).
static void testNonTargetStaysEligible() {
    const auto d = consumeDecision(makeReq("b", "s", 200), /*lastConsumed*/ 100,
                                   /*resolves*/ true, /*isFocusedTarget*/ false);
    CHECK(!d.apply);
    CHECK(d.consumedGeneration == 100);  // marker NOT advanced -> still eligible later
}

// Rule 3: a NEW request, target, but unresolvable -> DROP silently. Marker advances so it
// is never re-evaluated, but no selection change (assignment_request.h reader requirement).
static void testUnresolvableDroppedSilently() {
    const auto d = consumeDecision(makeReq("b", "deleted-sample", 200),
                                   /*lastConsumed*/ 100, /*resolves*/ false,
                                   /*isFocusedTarget*/ true);
    CHECK(!d.apply);                       // no selection change
    CHECK(d.consumedGeneration == 200);    // consumed-as-seen: never re-evaluated
    CHECK(d.sampleId.empty());             // nothing to apply
}

// Rule 4: a NEW request, target, resolvable -> APPLY selection + advance the marker.
static void testAppliedWhenNewTargetResolvable() {
    const auto d = consumeDecision(makeReq("bank-7", "cap-42", 200),
                                   /*lastConsumed*/ 100, /*resolves*/ true,
                                   /*isFocusedTarget*/ true);
    CHECK(d.apply);
    CHECK(d.bankId == "bank-7");
    CHECK(d.sampleId == "cap-42");
    CHECK(d.consumedGeneration == 200);
}

// Re-assigning the SAME sample id under a NEW generation must re-apply (the generation is
// the disambiguator; a recapture/re-drop of the same id is a fresh assign, not a no-op).
static void testSameIdNewGenerationReapplies() {
    // First consume at gen 100.
    const auto first = consumeDecision(makeReq("b", "s", 100), 50, true, true);
    CHECK(first.apply);
    CHECK(first.consumedGeneration == 100);
    // Same id, higher generation, marker now at 100 -> applies again.
    const auto second = consumeDecision(makeReq("b", "s", 150), 100, true, true);
    CHECK(second.apply);
    CHECK(second.sampleId == "s");
    CHECK(second.consumedGeneration == 150);
}

// A fresh instance (lastConsumed == 0) applies a first assign — the default marker must not
// swallow the first request.
static void testFreshInstanceAppliesFirst() {
    const auto d = consumeDecision(makeReq("b", "s", 1), 0, true, true);
    CHECK(d.apply);
    CHECK(d.consumedGeneration == 1);
}

// Re-import / dedup-collapse path: the extension ingests a sample that already exists in
// the bank (dedup collapse: the bank_generation counter does NOT advance because no new
// sample was added), but a new assign_request is still written with a HIGHER assign
// generation (the ingest disambiguator, independent of bank_generation).
//
// The consumeDecision must apply the request — its own generation is the "is this new?"
// discriminator, and it is strictly greater than lastConsumed. bank_generation does not
// enter consumeDecision at all; this test proves the two counters are fully independent.
static void testDedupCollapseAssignAppliesWhenBankGenerationUnchanged() {
    // Simulate: bank_generation is 5 both before and after the dedup ingest (unchanged).
    // The assign_request generation is 300 (new; lastConsumed was 200 from the prior assign).
    // The existing sample resolves (it is in the bank — dedup kept it there).
    const auto d = consumeDecision(makeReq("pool", "existing-sample-id", 300),
                                   /*lastConsumed*/ 200, /*resolves*/ true,
                                   /*isFocusedTarget*/ true);
    CHECK(d.apply);
    CHECK(d.bankId == "pool");
    CHECK(d.sampleId == "existing-sample-id");
    CHECK(d.consumedGeneration == 300);  // marker advanced to the new assign generation
    // bank_generation (5) is not a parameter here — this test documents its absence from
    // consumeDecision: only the assign_request's own generation drives the consume decision.
}

int main() {
    testParseAbsentAndMalformed();
    testParseValid();
    testParseOverflow();
    testFormatRoundTrip();
    testGenerationChanged();
    testNoRequest();
    testNotNewerNotReapplied();
    testNonTargetStaysEligible();
    testUnresolvableDroppedSilently();
    testAppliedWhenNewTargetResolvable();
    testSameIdNewGenerationReapplies();
    testFreshInstanceAppliesFirst();
    testDedupCollapseAssignAppliesWhenBankGenerationUnchanged();

    if (g_fail == 0) std::printf("All tests passed.\n");
    return g_fail ? 1 : 0;
}
