// Standalone tests for reasampler's per-FX offline restore resolution — no
// REAPER, no test framework.
//
// The property under test: a captured FX-offline state lands on the FX it was
// captured FROM, whatever that FX's slot has become while the track was parked.
// Every scenario below is a chain mutation performed while parked.

#include "../src/core/view/fx_offline.h"

#include <cstdio>
#include <string>
#include <utility>
#include <vector>

using namespace reasampler;

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

// -- helpers -----------------------------------------------------------------

// The ops the restore planner emits for one track from an identity-keyed
// snapshot: capture-order entries, each carrying the FX's own identity.
static std::vector<FxOfflineOp> identityOps(
        const std::vector<std::pair<std::string, bool>>& captured) {
    std::vector<FxOfflineOp> ops;
    for (std::size_t i = 0; i < captured.size(); ++i) {
        ops.push_back(FxOfflineOp{"{TRACK}", FxKeying::Identity, captured[i].first,
                                  static_cast<int>(i), captured[i].second});
    }
    return ops;
}

// The ops a snapshot lifted from a pre-identity project yields: no identities,
// position is the slot.
static std::vector<FxOfflineOp> slotOps(const std::vector<bool>& captured) {
    std::vector<FxOfflineOp> ops;
    for (std::size_t i = 0; i < captured.size(); ++i) {
        ops.push_back(FxOfflineOp{"{TRACK}", FxKeying::Slot, {},
                                  static_cast<int>(i), captured[i]});
    }
    return ops;
}

static bool hasWrite(const FxRestoreResolution& res, int fxIndex, bool offline) {
    for (const FxOfflineWrite& w : res.writes)
        if (w.fxIndex == fxIndex) return w.offline == offline;
    return false;
}

static bool writesTouch(const FxRestoreResolution& res, int fxIndex) {
    for (const FxOfflineWrite& w : res.writes)
        if (w.fxIndex == fxIndex) return true;
    return false;
}

// -- 1. Chain reordered while parked -----------------------------------------

static void testReorderedChainRestoresEachPluginItsOwnState() {
    // Captured with A, B, C in slots 0,1,2 — B was already offline pre-park.
    const std::vector<FxOfflineOp> ops =
        identityOps({{"{A}", false}, {"{B}", true}, {"{C}", false}});

    // While parked the user dragged C to the front: the chain is now C, A, B.
    const FxRestoreResolution res = resolveFxRestore(ops, {"{C}", "{A}", "{B}"});

    CHECK(res.writes.size() == 3);
    CHECK(res.drops.total() == 0);
    CHECK(hasWrite(res, 1, false));  // A, now at slot 1
    CHECK(hasWrite(res, 2, true));   // B's offline state followed B to slot 2
    CHECK(hasWrite(res, 0, false));  // C, now at slot 0

    // The slot-keyed reading of the SAME capture is what the old code did: it
    // would have written B's `true` to slot 1, which is now A. Pinning the
    // divergence keeps a well-meant "just use the index" from coming back.
    const FxRestoreResolution bySlot =
        resolveFxRestore(slotOps({false, true, false}), {"{C}", "{A}", "{B}"});
    CHECK(hasWrite(bySlot, 1, true));  // the defect, reproduced deliberately
}

// -- 2. FX deleted while parked ----------------------------------------------

static void testDeletedFxDropsExplicitlyAndTouchesNothingElse() {
    // B (true) and C (false) carry OPPOSITE captured states — the discriminator.
    // A test where both carried `true` couldn't tell "C's own state followed it
    // to slot 1" apart from "B's dropped state leaked onto whatever moved there".
    const std::vector<FxOfflineOp> ops =
        identityOps({{"{A}", false}, {"{B}", true}, {"{C}", false}});

    // B was deleted while parked; A and C closed the gap.
    const FxRestoreResolution res = resolveFxRestore(ops, {"{A}", "{C}"});

    CHECK(res.writes.size() == 2);
    CHECK(res.drops.missingIdentity == 1);
    CHECK(res.drops.unidentified == 0);
    CHECK(res.drops.slotOutOfRange == 0);
    CHECK(hasWrite(res, 0, false));  // A
    // C's OWN captured `false` landed at slot 1, not B's dropped `true`.
    CHECK(hasWrite(res, 1, false));

    // The drop is reportable, not silent.
    const std::string msg = describeFxRestoreDrops(res.drops, /*trackCount=*/1);
    CHECK(!msg.empty());
    CHECK(msg.find("1 captured FX offline state(s) on 1 track(s)") != std::string::npos);
    CHECK(msg.find("no longer in the chain") != std::string::npos);
    CHECK(msg.back() == '\n');

    // Nothing dropped ⇒ nothing said.
    CHECK(describeFxRestoreDrops(FxRestoreDrops{}, 0).empty());
}

static void testDescribeNamesAllThreeDropKinds() {
    FxRestoreDrops drops;
    drops.missingIdentity = 2;
    drops.unidentified = 1;
    drops.slotOutOfRange = 3;
    CHECK(drops.total() == 6);

    const std::string msg = describeFxRestoreDrops(drops, /*trackCount=*/2);
    CHECK(msg.find("6 captured FX offline state(s) on 2 track(s)") != std::string::npos);
    CHECK(msg.find("2 FX no longer in the chain") != std::string::npos);
    CHECK(msg.find("1 FX REAPER could not identify at capture time") != std::string::npos);
    CHECK(msg.find("3 from a project saved before FX identity was recorded")
          != std::string::npos);
}

// -- 3. FX added while parked -------------------------------------------------

static void testAddedFxIsNotTouched() {
    const std::vector<FxOfflineOp> ops = identityOps({{"{A}", false}, {"{B}", true}});

    // D was inserted at the FRONT while parked — the case where an index-keyed
    // restore would have written every captured state onto the wrong plugin.
    const FxRestoreResolution res = resolveFxRestore(ops, {"{D}", "{A}", "{B}"});

    CHECK(res.writes.size() == 2);
    CHECK(res.drops.total() == 0);
    CHECK(hasWrite(res, 1, false));   // A
    CHECK(hasWrite(res, 2, true));    // B
    CHECK(!writesTouch(res, 0));      // D is never written at all
}

// -- 4. Two instances of the SAME plugin type --------------------------------

static void testTwoInstancesOfOnePluginKeyIndependently() {
    // Two copies of one plugin: distinct instances, distinct identities, and the
    // two carry OPPOSITE captured states — a scheme keyed on plugin type or name
    // could not tell them apart and would restore both the same way.
    const std::vector<FxOfflineOp> ops =
        identityOps({{"{EQ-1}", true}, {"{EQ-2}", false}, {"{COMP}", false}});

    // Swapped while parked: EQ-2, COMP, EQ-1.
    const FxRestoreResolution res = resolveFxRestore(ops, {"{EQ-2}", "{COMP}", "{EQ-1}"});

    CHECK(res.writes.size() == 3);
    CHECK(res.drops.total() == 0);
    CHECK(hasWrite(res, 2, true));    // EQ-1's offline=true followed it to slot 2
    CHECK(hasWrite(res, 0, false));   // EQ-2 stayed online at slot 0
    CHECK(hasWrite(res, 1, false));   // COMP
}

// -- 5. Slot-keyed (pre-identity) snapshots ----------------------------------

static void testSlotKeyedSnapshotRestoresByPositionAndBoundsChecks() {
    // All a pre-identity blob's bytes can support: position addressing.
    const FxRestoreResolution res =
        resolveFxRestore(slotOps({false, true}), {"{A}", "{B}", "{C}"});
    CHECK(res.writes.size() == 2);
    CHECK(res.drops.total() == 0);
    CHECK(hasWrite(res, 0, false));
    CHECK(hasWrite(res, 1, true));
    CHECK(!writesTouch(res, 2));  // an FX the snapshot never covered stays untouched

    // A slot that no longer exists is dropped and counted, never clamped.
    const FxRestoreResolution shrunk =
        resolveFxRestore(slotOps({false, true, true}), {"{A}"});
    CHECK(shrunk.writes.size() == 1);
    CHECK(shrunk.drops.slotOutOfRange == 2);
    CHECK(shrunk.drops.missingIdentity == 0);
}

// -- 6. Degenerate inputs -----------------------------------------------------

static void testUnresolvableIdentityNeverFallsBackToItsSlot() {
    // An identity-keyed entry with NO identity (REAPER reported none at capture)
    // is a drop — the slot it happens to carry must not be used as a substitute.
    // Counted as `unidentified`, not `missingIdentity`: unlike a real captured
    // identity going missing, this FX may still be sitting right there — we
    // simply never had a name for it, and the report must say that, not "no
    // longer in the chain".
    std::vector<FxOfflineOp> ops = identityOps({{"{A}", false}});
    ops.push_back(FxOfflineOp{"{TRACK}", FxKeying::Identity, "", /*slot=*/1, /*offline=*/true});

    const FxRestoreResolution res = resolveFxRestore(ops, {"{A}", "{B}"});
    CHECK(res.writes.size() == 1);
    CHECK(hasWrite(res, 0, false));
    CHECK(!writesTouch(res, 1));  // {B} would have been the slot-1 victim
    CHECK(res.drops.missingIdentity == 0);
    CHECK(res.drops.unidentified == 1);

    const std::string msg = describeFxRestoreDrops(res.drops, /*trackCount=*/1);
    CHECK(msg.find("1 FX REAPER could not identify at capture time") != std::string::npos);
    CHECK(msg.find("no longer in the chain") == std::string::npos);  // not this FX's story
}

static void testLiveFxWithNoIdentityIsNeverARestoreTarget() {
    // The mirror case: a live FX REAPER reports no GUID for cannot be matched by
    // an empty captured identity either.
    const FxRestoreResolution res =
        resolveFxRestore(identityOps({{"{A}", true}}), {"", "{A}"});
    CHECK(res.writes.size() == 1);
    CHECK(hasWrite(res, 1, true));
    CHECK(!writesTouch(res, 0));
    CHECK(res.drops.total() == 0);
}

static void testEmptyInputsProduceNoWrites() {
    CHECK(resolveFxRestore({}, {"{A}"}).writes.empty());
    CHECK(resolveFxRestore({}, {}).drops.total() == 0);

    // Every FX gone (the whole chain cleared while parked): all dropped, none written.
    const FxRestoreResolution res =
        resolveFxRestore(identityOps({{"{A}", true}, {"{B}", false}}), {});
    CHECK(res.writes.empty());
    CHECK(res.drops.missingIdentity == 2);
}

static void testDuplicateLiveIdentityResolvesToTheFirstSlotOnly() {
    // Not producible by REAPER (one GUID per instance) — pinned so a corrupt or
    // hand-edited chain writes once, deterministically, instead of twice.
    const FxRestoreResolution res =
        resolveFxRestore(identityOps({{"{A}", true}}), {"{A}", "{A}"});
    CHECK(res.writes.size() == 1);
    CHECK(hasWrite(res, 0, true));
    CHECK(!writesTouch(res, 1));
}

int main() {
    testReorderedChainRestoresEachPluginItsOwnState();
    testDeletedFxDropsExplicitlyAndTouchesNothingElse();
    testDescribeNamesAllThreeDropKinds();
    testAddedFxIsNotTouched();
    testTwoInstancesOfOnePluginKeyIndependently();
    testSlotKeyedSnapshotRestoresByPositionAndBoundsChecks();
    testUnresolvableIdentityNeverFallsBackToItsSlot();
    testLiveFxWithNoIdentityIsNeverARestoreTarget();
    testEmptyInputsProduceNoWrites();
    testDuplicateLiveIdentityResolvesToTheFirstSlotOnly();

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