// Standalone tests for reasampler::bank_book — no REAPER, no test framework.
// The heart of the multi-bank phase (Phase B1); the third instance of the pure
// "registry + JSON round-trip, unit-tested outside the DAW" pattern.
//
// Covers (PLAN.md B1 test cases): pool privileges (delete/rename/evacuate rejected,
// never zero banks); create / rename / reorder named banks; move source-loses /
// dest-gains; copy source-retained / dest-gains; evacuate empties source into pool
// with dest collapse; cross-bank same-hash coexistence; destination collapse on
// move/copy into a bank already holding the hash; active-bank get/set (defaults to
// pool, set named, invalid id); JSON round-trip lossless (full book); legacy
// bank_index → pool migration.

#include "../src/core/model/bank_book.h"

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

using namespace reasampler;
using namespace reasampler::model;

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

// A minimal, valid sample. `seed` disambiguates id + hash; `hash` overrides the
// content hash so tests can force collapses. relativePath is always relative.
static Sample sampleWith(const std::string& seed, const std::string& hash) {
    Sample s;
    s.id = "id-" + seed;
    s.displayName = "sample " + seed;
    s.relativePath = "bank/" + seed + ".wav";
    s.sourceMode = SourceMode::MasterMix;
    s.channelCount = 2;
    s.sampleRate = 48000;
    s.tier = Tier::Scratch;
    s.contentHash = hash;
    s.createdTimestamp = 1753080000LL;
    return s;
}
static Sample sampleWith(const std::string& seed) { return sampleWith(seed, "hash-" + seed); }

// ---------------------------------------------------------------------------

// Golden byte-literal (Q-W1 follow-up): pins the EXACT serialized bytes for a
// small fixture (a freshly-seeded book: pool only, one sample), not just
// self-consistent re-serialization — a format drift that both writer and
// reader agree on would slip past the round-trip tests but not this. The
// format is frozen as-shipped; the literal below is the captured current
// output.
static void testSerializeGoldenLiteral() {
    BankBook book;
    CHECK(book.pool().index.add(sampleWith("g1")) == AddResult::Added);
    CHECK(book.serialize() ==
          "{\"version\":1,\"activeBank\":\"pool\",\"banks\":[{\"id\":\"pool\","
          "\"displayName\":\"Pool\",\"ordinal\":0,\"index\":{\"version\":1,"
          "\"samples\":[{\"id\":\"id-g1\",\"displayName\":\"sample g1\","
          "\"relativePath\":\"bank/g1.wav\",\"sourceMode\":0,\"sourceRange\":{"
          "\"startSeconds\":0,\"endSeconds\":0,\"startPpq\":0,\"endPpq\":0},"
          "\"trackGuids\":[],\"wetDry\":1,\"channelCount\":2,\"sampleRate\":48000,"
          "\"lengthSeconds\":0,\"lengthBeats\":0,\"captureTempo\":0,"
          "\"captureTimeSigNum\":0,\"captureTimeSigDenom\":0,\"key\":null,"
          "\"rootNote\":null,\"loop\":null,\"levels\":{\"peakDb\":0,\"rmsDb\":0,"
          "\"lufs\":0},\"clipped\":false,\"tier\":0,\"contentHash\":\"hash-g1\","
          "\"provenance\":null,\"createdTimestamp\":1753080000}]},\"slots\":[]}]}");
}

static void testPoolSeededAndDefaults() {
    BankBook book;
    // Pool present as bank-zero with fixed id + name + ordinal 0.
    CHECK(book.size() == 1);
    CHECK(book.banks()[0].id == kPoolBankId);
    CHECK(book.banks()[0].displayName == std::string(kPoolBankName));
    CHECK(book.banks()[0].ordinal == 0);
    CHECK(book.pool().id == kPoolBankId);
    // Active bank defaults to the pool and resolves the pool's index.
    CHECK(book.activeBankId() == std::string(kPoolBankId));
    CHECK(&book.activeIndex() == &book.pool().index);
}

static void testPoolPrivileges() {
    BankBook book;
    CHECK(book.createBank("drums", "Drums"));

    // Delete-pool rejected; rename-pool rejected; evacuate-pool rejected.
    CHECK(!book.deleteBank(kPoolBankId));
    CHECK(!book.renameBank(kPoolBankId, "NotPool"));
    CHECK(!book.evacuate(kPoolBankId));
    CHECK(book.pool().displayName == std::string(kPoolBankName));  // unchanged

    // Reserved pool id cannot be minted as a named bank.
    CHECK(!book.createBank(kPoolBankId, "Imposter"));

    // Deleting the only named bank still leaves the pool — never zero banks.
    CHECK(book.deleteBank("drums"));
    CHECK(book.size() == 1);
    CHECK(book.pool().id == kPoolBankId);
}

static void testCreateRenameReorder() {
    BankBook book;
    CHECK(book.createBank("a", "Alpha"));
    CHECK(book.createBank("b", "Beta"));
    CHECK(book.createBank("c", "Gamma"));
    CHECK(book.size() == 4);  // pool + 3

    // Duplicate id rejected; empty id rejected.
    CHECK(!book.createBank("a", "dup"));
    CHECK(!book.createBank("", "empty"));

    // Ordinals: pool 0, named 1..3 in creation order.
    CHECK(book.bank("a")->ordinal == 1);
    CHECK(book.bank("b")->ordinal == 2);
    CHECK(book.bank("c")->ordinal == 3);

    // Rename a named bank; pool rename still rejected.
    CHECK(book.renameBank("b", "Beta-renamed"));
    CHECK(book.bank("b")->displayName == "Beta-renamed");
    CHECK(!book.renameBank("missing", "x"));
    // Renaming back to a non-colliding name keeps working.
    CHECK(book.renameBank("b", "Beta"));

    // Reorder: move "c" to the front of the named region (ordinal 1).
    CHECK(book.reorderBank("c", 1));
    CHECK(book.pool().ordinal == 0);
    CHECK(book.bank("c")->ordinal == 1);
    CHECK(book.bank("a")->ordinal == 2);
    CHECK(book.bank("b")->ordinal == 3);
    // banks() is ordinal order, pool first.
    CHECK(book.banks()[0].id == kPoolBankId);
    CHECK(book.banks()[1].id == "c");
    CHECK(book.banks()[2].id == "a");
    CHECK(book.banks()[3].id == "b");

    // Reorder past the end clamps to the last named slot.
    CHECK(book.reorderBank("c", 999));
    CHECK(book.banks()[3].id == "c");
    // Reorder the pool is rejected; unknown id rejected.
    CHECK(!book.reorderBank(kPoolBankId, 2));
    CHECK(!book.reorderBank("missing", 1));
}

static void testDisplayNameUniqueness() {
    BankBook book;
    CHECK(book.createBank("a", "Drums"));

    // A unique name is accepted.
    CHECK(book.createBank("b", "Bass"));

    // Exact duplicate rejected, no mutation (size unchanged, the collided id absent).
    CHECK(!book.createBank("c", "Drums"));
    CHECK(book.bank("c") == nullptr);
    CHECK(book.size() == 3);  // pool + a + b only

    // Trimmed + case-insensitive collisions: "drums", " Drums ", "DRUMS" all collide.
    CHECK(!book.createBank("c", "drums"));
    CHECK(!book.createBank("c", " Drums "));
    CHECK(!book.createBank("c", "DRUMS"));
    CHECK(book.bank("c") == nullptr);

    // The pool's reserved name "Pool" (and its variants) cannot be taken by a new bank.
    CHECK(!book.createBank("c", "Pool"));
    CHECK(!book.createBank("c", "  pool  "));
    CHECK(book.bank("c") == nullptr);

    // -- renameBank uniqueness --------------------------------------------------
    // Rename to a name used by ANOTHER bank is rejected (no mutation).
    CHECK(!book.renameBank("b", "Drums"));
    CHECK(book.bank("b")->displayName == "Bass");  // unchanged
    CHECK(!book.renameBank("b", "drums"));         // case-insensitive collision too
    CHECK(!book.renameBank("b", " Drums "));       // trimmed collision too

    // Renaming a bank to its OWN current name is a no-op success (not a rejection).
    CHECK(book.renameBank("a", "Drums"));
    CHECK(book.bank("a")->displayName == "Drums");
    // Re-casing/-spacing its own name is likewise allowed (it collides only with self).
    CHECK(book.renameBank("a", "  drums "));
    CHECK(book.bank("a")->displayName == "  drums ");

    // Renaming to the pool's reserved name is rejected (pool is the "other" bank here).
    CHECK(!book.renameBank("b", "Pool"));
    CHECK(book.bank("b")->displayName == "Bass");

    // A genuinely fresh unique name still renames fine.
    CHECK(book.renameBank("b", "Low End"));
    CHECK(book.bank("b")->displayName == "Low End");

    // After the rejections, the previously-freed name is now reusable by a new bank.
    CHECK(book.createBank("c", "Bass"));
    CHECK(book.bank("c")->displayName == "Bass");
}

static void testMoveSourceLosesDestGains() {
    BankBook book;
    CHECK(book.createBank("drums", "Drums"));
    CHECK(book.pool().index.add(sampleWith("kick")) == AddResult::Added);

    // Move kick pool -> drums: source loses it, destination gains it.
    CHECK(book.moveSample("id-kick", kPoolBankId, "drums") == TransferResult::Moved);
    CHECK(book.pool().index.query("id-kick") == nullptr);       // source lost it
    CHECK(book.bank("drums")->index.query("id-kick") != nullptr); // dest gained it
    CHECK(book.pool().index.empty());
    CHECK(book.bank("drums")->index.size() == 1);

    // Rejections: unknown bank, absent sample, same bank.
    CHECK(book.moveSample("id-kick", "drums", "nope") == TransferResult::RejectedUnknownBank);
    CHECK(book.moveSample("missing", "drums", kPoolBankId) == TransferResult::RejectedSampleAbsent);
    CHECK(book.moveSample("id-kick", "drums", "drums") == TransferResult::RejectedSameBank);
    // After the rejected ops the sample is still only in drums (state uncorrupted).
    CHECK(book.bank("drums")->index.query("id-kick") != nullptr);
}

static void testCopySourceRetainedDestGains() {
    BankBook book;
    CHECK(book.createBank("drums", "Drums"));
    CHECK(book.pool().index.add(sampleWith("snare")) == AddResult::Added);

    // Copy: source retained, destination gains it — same hash in both banks (no
    // cross-bank dedup: that is the point of copy).
    CHECK(book.copySample("id-snare", kPoolBankId, "drums") == TransferResult::Copied);
    CHECK(book.pool().index.query("id-snare") != nullptr);        // source retained
    CHECK(book.bank("drums")->index.query("id-snare") != nullptr); // dest gained it
    CHECK(book.pool().index.findByHash("hash-snare") != nullptr);
    CHECK(book.bank("drums")->index.findByHash("hash-snare") != nullptr);
}

static void testMoveDestCollapse() {
    BankBook book;
    CHECK(book.createBank("drums", "Drums"));
    // Same hash already in the destination under a DIFFERENT id.
    Sample inPool = sampleWith("kick-a", "shared-hash");
    Sample inDrums = sampleWith("kick-b", "shared-hash");
    CHECK(book.pool().index.add(inPool) == AddResult::Added);
    CHECK(book.bank("drums")->index.add(inDrums) == AddResult::Added);

    // Move the pool entry into drums: destination collapses onto its existing entry,
    // but the source STILL loses the entry (move semantics).
    CHECK(book.moveSample("id-kick-a", kPoolBankId, "drums") == TransferResult::Collapsed);
    CHECK(book.pool().index.query("id-kick-a") == nullptr);        // source lost it
    CHECK(book.bank("drums")->index.size() == 1);                  // no duplicate
    CHECK(book.bank("drums")->index.query("id-kick-b") != nullptr);// original kept
    CHECK(book.bank("drums")->index.query("id-kick-a") == nullptr);// collapsed away
}

static void testCopyDestCollapse() {
    BankBook book;
    CHECK(book.createBank("drums", "Drums"));
    Sample inPool = sampleWith("hat-a", "hat-hash");
    Sample inDrums = sampleWith("hat-b", "hat-hash");
    CHECK(book.pool().index.add(inPool) == AddResult::Added);
    CHECK(book.bank("drums")->index.add(inDrums) == AddResult::Added);

    // Copy into a bank already holding the hash: collapse; source retained.
    CHECK(book.copySample("id-hat-a", kPoolBankId, "drums") == TransferResult::Collapsed);
    CHECK(book.pool().index.query("id-hat-a") != nullptr);     // source retained
    CHECK(book.bank("drums")->index.size() == 1);              // collapsed, no dup
    CHECK(book.bank("drums")->index.query("id-hat-b") != nullptr);
}

static void testCrossBankSameHashCoexistence() {
    BankBook book;
    CHECK(book.createBank("drums", "Drums"));
    CHECK(book.createBank("hits", "Hits"));
    CHECK(book.pool().index.add(sampleWith("clap", "clap-hash")) == AddResult::Added);

    // Copy the same sample into two named banks — all three banks hold the hash.
    CHECK(book.copySample("id-clap", kPoolBankId, "drums") == TransferResult::Copied);
    CHECK(book.copySample("id-clap", kPoolBankId, "hits") == TransferResult::Copied);
    CHECK(book.pool().index.findByHash("clap-hash") != nullptr);
    CHECK(book.bank("drums")->index.findByHash("clap-hash") != nullptr);
    CHECK(book.bank("hits")->index.findByHash("clap-hash") != nullptr);
}

static void testEvacuate() {
    BankBook book;
    CHECK(book.createBank("drums", "Drums"));
    CHECK(book.bank("drums")->index.add(sampleWith("k1")) == AddResult::Added);
    CHECK(book.bank("drums")->index.add(sampleWith("k2")) == AddResult::Added);
    // A hash that ALSO lives in the pool already, to exercise destination collapse
    // during evacuate.
    CHECK(book.pool().index.add(sampleWith("dup-pool", "dup-hash")) == AddResult::Added);
    CHECK(book.bank("drums")->index.add(sampleWith("dup-drums", "dup-hash")) == AddResult::Added);

    CHECK(book.evacuate("drums"));
    // Source emptied.
    CHECK(book.bank("drums")->index.empty());
    // Pool gained the two unique members; the dup collapsed onto the pool's existing.
    CHECK(book.pool().index.query("id-k1") != nullptr);
    CHECK(book.pool().index.query("id-k2") != nullptr);
    CHECK(book.pool().index.query("id-dup-pool") != nullptr);   // original kept
    CHECK(book.pool().index.query("id-dup-drums") == nullptr);  // collapsed away
    CHECK(book.pool().index.size() == 3);  // k1, k2, dup-pool

    // Evacuate an empty bank is a valid no-op success; unknown id rejected.
    CHECK(book.evacuate("drums"));
    CHECK(!book.evacuate("missing"));
}

static void testActiveBank() {
    BankBook book;
    CHECK(book.createBank("drums", "Drums"));
    // Defaults to pool.
    CHECK(book.activeBankId() == std::string(kPoolBankId));

    // Set to a named bank; activeIndex resolves it.
    CHECK(book.setActiveBank("drums"));
    CHECK(book.activeBankId() == "drums");
    CHECK(&book.activeIndex() == &book.bank("drums")->index);

    // Invalid id: rejected, state unchanged.
    CHECK(!book.setActiveBank("missing"));
    CHECK(book.activeBankId() == "drums");

    // Deleting the active bank falls back to the pool.
    CHECK(book.deleteBank("drums"));
    CHECK(book.activeBankId() == std::string(kPoolBankId));
    CHECK(&book.activeIndex() == &book.pool().index);
}

static void testJsonRoundTripFullBook() {
    BankBook book;
    CHECK(book.createBank("drums", "Drums \"kit\"\n"));   // exercises escaping
    CHECK(book.createBank("hits", "One-Shots"));
    CHECK(book.setActiveBank("hits"));

    // Populate per-bank indices with distinct + shared-hash samples.
    CHECK(book.pool().index.add(sampleWith("p1")) == AddResult::Added);
    CHECK(book.bank("drums")->index.add(sampleWith("d1")) == AddResult::Added);
    CHECK(book.bank("drums")->index.add(sampleWith("d2")) == AddResult::Added);
    CHECK(book.bank("hits")->index.add(sampleWith("h1")) == AddResult::Added);

    std::string json = book.serialize();
    auto back = BankBook::deserialize(json);
    CHECK(back.has_value());
    CHECK(back && *back == book);
    // String form is idempotent too.
    if (back) CHECK(back->serialize() == json);

    // Spot-check the reconstructed structure.
    if (back) {
        CHECK(back->activeBankId() == "hits");
        CHECK(back->size() == 3);
        CHECK(back->bank("drums") != nullptr);
        CHECK(back->bank("drums")->displayName == "Drums \"kit\"\n");
        CHECK(back->bank("drums")->index.size() == 2);
        CHECK(back->bank("hits")->index.query("id-h1") != nullptr);
        CHECK(back->pool().displayName == std::string(kPoolBankName));
        // Ordinals survived: pool 0, then named contiguously.
        CHECK(back->banks()[0].ordinal == 0);
        CHECK(back->banks()[1].ordinal == 1);
        CHECK(back->banks()[2].ordinal == 2);
    }
}

static void testJsonEmptyBookRoundTrip() {
    BankBook book;  // pool only, empty index, active = pool
    std::string json = book.serialize();
    auto back = BankBook::deserialize(json);
    CHECK(back.has_value());
    CHECK(back && *back == book);
    CHECK(back && back->size() == 1);
    CHECK(back && back->pool().index.empty());
}

static void testLegacyMigration() {
    // A bare legacy bank_index JSON (BankModel::serialize output — has "samples", no
    // "banks") must promote into the pool: a book of { pool } with zero named banks.
    BankModel legacy;
    CHECK(legacy.add(sampleWith("old1")) == AddResult::Added);
    CHECK(legacy.add(sampleWith("old2")) == AddResult::Added);
    std::string legacyJson = legacy.serialize();

    auto back = BankBook::deserialize(legacyJson);
    CHECK(back.has_value());
    if (back) {
        CHECK(back->size() == 1);                              // pool only
        CHECK(back->pool().id == kPoolBankId);
        CHECK(back->pool().displayName == std::string(kPoolBankName));
        CHECK(back->activeBankId() == std::string(kPoolBankId));
        CHECK(back->pool().index.size() == 2);                // samples migrated
        CHECK(back->pool().index.query("id-old1") != nullptr);
        CHECK(back->pool().index.query("id-old2") != nullptr);
        // The migrated index equals the legacy index (lossless).
        CHECK(back->pool().index == legacy);
    }

    // An EMPTY legacy index ("{\"samples\":[]}" style via serialize) also migrates.
    BankModel emptyLegacy;
    auto back2 = BankBook::deserialize(emptyLegacy.serialize());
    CHECK(back2.has_value());
    CHECK(back2 && back2->size() == 1 && back2->pool().index.empty());
}

static void testMalformedJson() {
    const char* bad[] = {
        "",
        "{",
        "not json",
        "{}",                                   // neither shape marker
        "{\"banks\":[",                          // truncated array
        "{\"banks\":[{\"id\":\"x\"}]}",          // bank missing its index
        "{\"banks\":[{\"index\":{\"samples\":[]}}]}",  // bank missing its id
        "{\"banks\":[{\"id\":\"drums\",\"index\":{\"samples\":[]}}]}",  // no pool
        "{\"activeBank\":\"pool\"}",             // no banks + no samples marker
        "{\"banks\":[]}trailing",               // trailing garbage
    };
    for (const char* j : bad) {
        auto r = BankBook::deserialize(j);
        CHECK(!r.has_value());
    }

    // Duplicate bank ids are malformed (ids key the registry).
    const char* dup =
        "{\"activeBank\":\"pool\",\"banks\":["
        "{\"id\":\"pool\",\"displayName\":\"Pool\",\"ordinal\":0,\"index\":{\"samples\":[]}},"
        "{\"id\":\"x\",\"displayName\":\"X\",\"ordinal\":1,\"index\":{\"samples\":[]}},"
        "{\"id\":\"x\",\"displayName\":\"X2\",\"ordinal\":2,\"index\":{\"samples\":[]}}]}";
    CHECK(!BankBook::deserialize(dup).has_value());
}

// --- B2: persist-load source-precedence decision (pure) --------------------
// loadFromPersisted picks the load source the persist shell will feed it from the
// two ext-state values a project may carry: the authoritative `banks` blob and the
// retired legacy `bank_index` blob. Precedence: banks > legacy > empty; a malformed
// banks blob degrades to empty WITHOUT falling back to the stale legacy key.

static void testLoadPrefersBanksBlob() {
    // A full book + a stale legacy index both present: `banks` wins, legacy ignored.
    BankBook src;
    CHECK(src.createBank("drums", "Drums"));
    CHECK(src.setActiveBank("drums"));
    CHECK(src.bank("drums")->index.add(sampleWith("new1")) == AddResult::Added);
    const std::string banksJson = src.serialize();

    BankModel stale;
    CHECK(stale.add(sampleWith("stale-old")) == AddResult::Added);
    const std::string legacyJson = stale.serialize();

    BankBook loaded = BankBook::loadFromPersisted(banksJson, legacyJson);
    // The book equals the source book — the legacy key had NO effect.
    CHECK(loaded == src);
    CHECK(loaded.activeBankId() == "drums");
    CHECK(loaded.bank("drums") != nullptr);
    CHECK(loaded.bank("drums")->index.query("id-new1") != nullptr);
    // The stale legacy sample must NOT have leaked into the pool.
    CHECK(loaded.pool().index.query("id-stale-old") == nullptr);
}

static void testLoadMigratesLegacyWhenNoBanks() {
    // No `banks` key, a legacy `bank_index` present: migrate into the pool, zero named.
    BankModel legacy;
    CHECK(legacy.add(sampleWith("l1")) == AddResult::Added);
    CHECK(legacy.add(sampleWith("l2")) == AddResult::Added);
    const std::string legacyJson = legacy.serialize();

    BankBook loaded = BankBook::loadFromPersisted(std::string{}, legacyJson);
    CHECK(loaded.size() == 1);                                   // pool only
    CHECK(loaded.pool().id == kPoolBankId);
    CHECK(loaded.activeBankId() == std::string(kPoolBankId));
    CHECK(loaded.pool().index.size() == 2);
    CHECK(loaded.pool().index == legacy);                       // lossless
}

static void testLoadEmptyWhenNeither() {
    // Both absent: a fresh empty book (pool only, empty index, active = pool).
    BankBook loaded = BankBook::loadFromPersisted(std::string{}, std::string{});
    CHECK(loaded == BankBook{});
    CHECK(loaded.size() == 1);
    CHECK(loaded.pool().index.empty());
    CHECK(loaded.activeBankId() == std::string(kPoolBankId));
}

static void testLoadMalformedBanksDegradesWithoutLegacyFallback() {
    // A present-but-malformed `banks` blob must degrade to an empty book and must NOT
    // resurrect the stale legacy key (that would revive superseded single-bank state).
    BankModel stale;
    CHECK(stale.add(sampleWith("stale")) == AddResult::Added);
    const std::string legacyJson = stale.serialize();

    BankBook loaded = BankBook::loadFromPersisted("{\"banks\":[", legacyJson);
    CHECK(loaded == BankBook{});                                // empty, NOT the legacy
    CHECK(loaded.pool().index.query("id-stale") == nullptr);   // legacy did not leak
}

// --- B3: active-bank cycle ordering (pure free function) -------------------
// nextBankId(orderedIds, current) is the pure decision behind the "cycle active
// bank" action: given the book's ordered bank ids (pool-first) + the current active
// id, return the next id in ordinal order, wrapping pool -> named -> ... -> pool.

static void testCycleOrderingWrapAround() {
    // pool -> drums -> hits -> (wrap) pool. Exercises every step + the wrap.
    const std::vector<std::string> ids = {kPoolBankId, "drums", "hits"};
    CHECK(nextBankId(ids, kPoolBankId) == "drums");
    CHECK(nextBankId(ids, "drums") == "hits");
    CHECK(nextBankId(ids, "hits") == std::string(kPoolBankId));  // wrap past the last
}

static void testCyclePoolOnlyStaysPool() {
    // A pool-only book (no named banks) cycles to itself — the single id wraps to
    // itself. The action becomes a no-op activation, which is correct.
    const std::vector<std::string> ids = {kPoolBankId};
    CHECK(nextBankId(ids, kPoolBankId) == std::string(kPoolBankId));
}

static void testCycleUnknownActiveResolvesToFirst() {
    // A stale/unknown active id (e.g. the active bank was just deleted and the
    // ordered list already dropped it) resolves to the first id — a sane home to jump
    // to rather than "" — matching nextModeId's fallback.
    const std::vector<std::string> ids = {kPoolBankId, "drums"};
    CHECK(nextBankId(ids, "ghost") == std::string(kPoolBankId));
}

static void testCycleEmptyListYieldsEmpty() {
    // Degenerate guard: an empty list has nothing to cycle to. (A real BankBook always
    // seeds the pool, so this cannot arise from the book — but the pure helper must not
    // index into an empty vector.)
    const std::vector<std::string> ids;
    CHECK(nextBankId(ids, kPoolBankId).empty());
}

static void testCycleMatchesBookOrdinalOrder() {
    // Integration-flavoured but still pure: drive the cycle off a real book's banks()
    // order and confirm one full loop lands back on the pool, activating each bank in
    // ordinal order. This is exactly what the action does (build ids from banks(),
    // call nextBankId, setActiveBank).
    BankBook book;
    CHECK(book.createBank("a", "A"));
    CHECK(book.createBank("b", "B"));  // ordinals: pool 0, a 1, b 2

    std::vector<std::string> ids;
    for (const Bank& bk : book.banks()) ids.push_back(bk.id);

    std::string cur = book.activeBankId();  // pool
    cur = nextBankId(ids, cur); CHECK(cur == "a");
    cur = nextBankId(ids, cur); CHECK(cur == "b");
    cur = nextBankId(ids, cur); CHECK(cur == std::string(kPoolBankId));  // full loop
}

static void testActiveBankResolveAfterCorruptPersistedId() {
    // A book blob whose activeBank names no bank resolves to the pool (defensive).
    const char* json =
        "{\"activeBank\":\"ghost\",\"banks\":["
        "{\"id\":\"pool\",\"displayName\":\"Pool\",\"ordinal\":0,\"index\":{\"samples\":[]}}]}";
    auto back = BankBook::deserialize(json);
    CHECK(back.has_value());
    CHECK(back && back->activeBankId() == std::string(kPoolBankId));
}

// --- B4 fold-in: deserialize coalesces duplicate folded display names --------
//
// The in-model create/rename path enforces unique display names under the trimmed +
// case-insensitive fold, but a hand-edited .rpp blob can carry two banks whose names
// fold to the same key. deserialize must NOT reject the whole book (that would drop
// the user's entire library over one collision) — it AUTO-DISAMBIGUATES the later
// duplicate deterministically so the book loads intact with unique names, all banks
// and samples preserved, and ids untouched.

// Rewrites the first occurrence of `from` in `s` to `to` (test helper: injects a
// colliding display name into a serialized blob to simulate a hand-edit).
static std::string replaceFirst(std::string s, const std::string& from,
                                const std::string& to) {
    const auto pos = s.find(from);
    if (pos != std::string::npos) s.replace(pos, from.size(), to);
    return s;
}

static void testDeserializeCoalescesDuplicateFoldedNames() {
    // Build a real book with two distinctly-named banks each holding a sample, then
    // corrupt the second bank's display name so it folds to the first's key
    // (" drums " folds to "drums", same as "Drums"). This is exactly what a
    // hand-edited blob would look like.
    BankBook book;
    CHECK(book.createBank("a", "Drums"));
    CHECK(book.createBank("b", "Bass"));
    CHECK(book.bank("a")->index.add(sampleWith("a1")) == AddResult::Added);
    CHECK(book.bank("b")->index.add(sampleWith("b1")) == AddResult::Added);

    const std::string json = book.serialize();
    // Rename bank "b" from "Bass" to " drums " (folds to "drums") — a duplicate of "a".
    const std::string corrupted =
        replaceFirst(json, "\"displayName\":\"Bass\"", "\"displayName\":\" drums \"");
    CHECK(corrupted != json);  // the substitution landed

    auto back = BankBook::deserialize(corrupted);
    CHECK(back.has_value());
    if (!back) return;

    // The book loaded intact: pool + 2 named banks, no bank lost.
    CHECK(back->size() == 3);
    // Ids are preserved (disambiguation touches names only, never ids).
    CHECK(back->bank("a") != nullptr);
    CHECK(back->bank("b") != nullptr);
    // The FIRST bank to carry the folded key keeps its name; the later one is
    // suffixed to a unique name.
    CHECK(back->bank("a")->displayName == "Drums");
    CHECK(back->bank("b")->displayName != back->bank("a")->displayName);

    // The disambiguated names are genuinely unique under the model's own fold — the
    // book can now round-trip through the in-model uniqueness invariant. Prove it by
    // re-serializing and re-parsing: idempotent, no further renames.
    const std::string json2 = back->serialize();
    auto back2 = BankBook::deserialize(json2);
    CHECK(back2.has_value());
    if (back2) CHECK(back2->serialize() == json2);

    // No sample was lost across the coalesce.
    CHECK(back->bank("a")->index.size() == 1);
    CHECK(back->bank("b")->index.size() == 1);
    CHECK(back->bank("a")->index.query("id-a1") != nullptr);
    CHECK(back->bank("b")->index.query("id-b1") != nullptr);
}

static void testDeserializeCoalescesMultipleCollisions() {
    // Three banks all folding to the same key: the first keeps its name, the next two
    // get distinct suffixes so all three end unique (no two disambiguate to the same).
    BankBook book;
    CHECK(book.createBank("a", "Drums"));
    CHECK(book.createBank("b", "Bass"));
    CHECK(book.createBank("c", "Keys"));

    std::string json = book.serialize();
    json = replaceFirst(json, "\"displayName\":\"Bass\"", "\"displayName\":\"drums\"");
    json = replaceFirst(json, "\"displayName\":\"Keys\"", "\"displayName\":\"DRUMS\"");

    auto back = BankBook::deserialize(json);
    CHECK(back.has_value());
    if (!back) return;
    CHECK(back->size() == 4);  // pool + 3, none lost

    // All three named banks carry distinct folded keys after coalesce.
    const std::string na = back->bank("a")->displayName;
    const std::string nb = back->bank("b")->displayName;
    const std::string nc = back->bank("c")->displayName;
    CHECK(na != nb);
    CHECK(na != nc);
    CHECK(nb != nc);

    // Re-parse proves the result satisfies the round-trip (unique keys throughout).
    auto back2 = BankBook::deserialize(back->serialize());
    CHECK(back2.has_value());
    if (back2) CHECK(back2->serialize() == back->serialize());
}

static void testDeserializeNamedBankCollidingWithPoolIsDisambiguated() {
    // A named bank whose name folds to the pool's reserved "Pool" key is renamed away
    // from the pool (never the reverse — the pool's name is fixed and reserved).
    BankBook book;
    CHECK(book.createBank("a", "Drums"));
    std::string json = book.serialize();
    json = replaceFirst(json, "\"displayName\":\"Drums\"", "\"displayName\":\"pool\"");

    auto back = BankBook::deserialize(json);
    CHECK(back.has_value());
    if (!back) return;
    CHECK(back->size() == 2);
    // The pool keeps its authoritative name; the named bank is disambiguated off it.
    CHECK(back->pool().displayName == std::string(kPoolBankName));
    CHECK(back->bank("a") != nullptr);
    CHECK(back->bank("a")->displayName != std::string(kPoolBankName));
    // And it is not any case/space variant that would re-collide with "Pool".
    auto back2 = BankBook::deserialize(back->serialize());
    CHECK(back2.has_value());
    if (back2) CHECK(back2->serialize() == back->serialize());
}

// --- B5: sample-remove (this-bank + latent all-banks) + last-reference query ------
//
// removeSample drops a Sample's index entry (index-only, non-destructive to the file).
// ThisBank (default, surfaced) drops from one named source bank; AllBanks (latent seam)
// purges the id book-wide. hashReferencedElsewhere backs the confirm-on-last-reference
// guardrail: does any OTHER bank still hold the content hash?

static void testRemoveDropsTargetEntry() {
    BankBook book;
    CHECK(book.createBank("drums", "Drums"));
    CHECK(book.bank("drums")->index.add(sampleWith("kick")) == AddResult::Added);
    CHECK(book.bank("drums")->index.add(sampleWith("snare")) == AddResult::Added);

    // Remove the kick from drums: dropped from the target bank, the sibling survives.
    CHECK(book.removeSample("id-kick", "drums") == RemoveResult::Removed);
    CHECK(book.bank("drums")->index.query("id-kick") == nullptr);   // dropped
    CHECK(book.bank("drums")->index.query("id-snare") != nullptr);  // sibling kept
    CHECK(book.bank("drums")->index.size() == 1);
}

static void testRemoveThisBankLeavesSameHashInAnotherBank() {
    // Copy a sample into two banks (same hash in both), then remove from one under the
    // default this-bank scope: the OTHER bank's entry survives — no cross-bank cascade.
    BankBook book;
    CHECK(book.createBank("drums", "Drums"));
    CHECK(book.pool().index.add(sampleWith("clap", "clap-hash")) == AddResult::Added);
    CHECK(book.copySample("id-clap", kPoolBankId, "drums") == TransferResult::Copied);

    CHECK(book.removeSample("id-clap", kPoolBankId, RemoveScope::ThisBank) ==
          RemoveResult::Removed);
    CHECK(book.pool().index.query("id-clap") == nullptr);            // removed from pool
    CHECK(book.bank("drums")->index.query("id-clap") != nullptr);    // drums copy survives
    CHECK(book.bank("drums")->index.findByHash("clap-hash") != nullptr);
}

static void testRemoveFromPoolAllowedContainerPrivilegesHold() {
    // Pool CONTENTS are removable (the pool must not be a roach-motel); the pool
    // CONTAINER privileges (un-deletable / un-renamable / un-evacuable) are untouched.
    BankBook book;
    CHECK(book.pool().index.add(sampleWith("loop")) == AddResult::Added);

    CHECK(book.removeSample("id-loop", kPoolBankId) == RemoveResult::Removed);
    CHECK(book.pool().index.empty());  // content removed

    // Container privileges still enforced.
    CHECK(!book.deleteBank(kPoolBankId));
    CHECK(!book.renameBank(kPoolBankId, "NotPool"));
    CHECK(!book.evacuate(kPoolBankId));
    CHECK(book.size() == 1);
    CHECK(book.pool().displayName == std::string(kPoolBankName));
}

static void testRemoveRejectionsNoMutation() {
    BankBook book;
    CHECK(book.createBank("drums", "Drums"));
    CHECK(book.bank("drums")->index.add(sampleWith("kick")) == AddResult::Added);

    // Unknown bank → honest rejection, no mutation.
    CHECK(book.removeSample("id-kick", "ghost") == RemoveResult::RejectedUnknownBank);
    CHECK(book.bank("drums")->index.query("id-kick") != nullptr);  // untouched

    // Absent sample (right bank, wrong id) → honest rejection, no mutation.
    CHECK(book.removeSample("id-missing", "drums") == RemoveResult::RejectedSampleAbsent);
    CHECK(book.bank("drums")->index.size() == 1);
}

static void testHashReferencedElsewhere() {
    BankBook book;
    CHECK(book.createBank("drums", "Drums"));
    CHECK(book.createBank("hits", "Hits"));

    // Same-bank-only: the hash lives ONLY in drums → not referenced elsewhere (true
    // last-reference; removing from drums would orphan the file).
    CHECK(book.bank("drums")->index.add(sampleWith("solo", "solo-hash")) == AddResult::Added);
    CHECK(!book.hashReferencedElsewhere("solo-hash", "drums"));

    // Copied-to-two-banks: the hash lives in drums AND hits → referenced elsewhere from
    // either vantage (removing from one leaves the other's reference intact).
    CHECK(book.bank("drums")->index.add(sampleWith("dup-d", "dup-hash")) == AddResult::Added);
    CHECK(book.bank("hits")->index.add(sampleWith("dup-h", "dup-hash")) == AddResult::Added);
    CHECK(book.hashReferencedElsewhere("dup-hash", "drums"));   // hits still holds it
    CHECK(book.hashReferencedElsewhere("dup-hash", "hits"));    // drums still holds it

    // A hash present in NO bank is referenced nowhere.
    CHECK(!book.hashReferencedElsewhere("absent-hash", "drums"));

    // An empty hash never matches (mirrors findByHash) → reads as not-referenced-else,
    // the safe confirm-eliciting direction for an unhashed sample.
    CHECK(book.pool().index.add(sampleWith("nohash", "")) == AddResult::Added);
    CHECK(!book.hashReferencedElsewhere("", "drums"));
}

static void testRemoveAllBanksLatentScope() {
    // The latent all-banks seam (fork R-A): unsurfaced in the UI but live at the model
    // level. Purges the id from EVERY bank that holds it in one act; fromBankId ignored.
    BankBook book;
    CHECK(book.createBank("drums", "Drums"));
    CHECK(book.createBank("hits", "Hits"));
    CHECK(book.pool().index.add(sampleWith("clap", "clap-hash")) == AddResult::Added);
    CHECK(book.copySample("id-clap", kPoolBankId, "drums") == TransferResult::Copied);
    CHECK(book.copySample("id-clap", kPoolBankId, "hits") == TransferResult::Copied);
    // The id now lives in all three banks.
    CHECK(book.pool().index.query("id-clap") != nullptr);
    CHECK(book.bank("drums")->index.query("id-clap") != nullptr);
    CHECK(book.bank("hits")->index.query("id-clap") != nullptr);

    // AllBanks purge — fromBankId is ignored (pass a nonexistent bank to prove it).
    CHECK(book.removeSample("id-clap", "ignored-bank", RemoveScope::AllBanks) ==
          RemoveResult::Removed);
    CHECK(book.pool().index.query("id-clap") == nullptr);
    CHECK(book.bank("drums")->index.query("id-clap") == nullptr);
    CHECK(book.bank("hits")->index.query("id-clap") == nullptr);

    // A second all-banks purge of the now-absent id is an honest no-op rejection.
    CHECK(book.removeSample("id-clap", "ignored-bank", RemoveScope::AllBanks) ==
          RemoveResult::RejectedSampleAbsent);
}

// M10: updateSampleInPlace refreshes a sample wherever it lives, order-preserving,
// no dedup, and reports no-op honestly for an absent id.
static void testUpdateSampleInPlace() {
    BankBook book;
    CHECK(book.createBank("drums", "Drums"));
    CHECK(book.pool().index.add(sampleWith("kick")) == AddResult::Added);
    CHECK(book.bank("drums")->index.add(sampleWith("snare")) == AddResult::Added);
    CHECK(book.bank("drums")->index.add(sampleWith("hat")) == AddResult::Added);

    // Refresh the middle entry of the NAMED bank: new path/hash, same id + slot.
    Sample updated = sampleWith("snare");
    updated.relativePath = "bank/snare-recaptured.wav";
    updated.contentHash  = "recaptured-hash";
    CHECK(book.updateSampleInPlace("id-snare", updated));
    CHECK(book.bank("drums")->index.all()[0].id == "id-snare");  // slot preserved
    CHECK(book.bank("drums")->index.query("id-snare")->relativePath ==
          "bank/snare-recaptured.wav");
    CHECK(book.bank("drums")->index.size() == 2);                // no new entry

    // A sample in the POOL is found and updated too.
    Sample pk = sampleWith("kick");
    pk.contentHash = "kick-recaptured";
    CHECK(book.updateSampleInPlace("id-kick", pk));
    CHECK(book.pool().index.query("id-kick")->contentHash == "kick-recaptured");

    // An absent id is an honest no-op.
    CHECK(!book.updateSampleInPlace("id-nope", sampleWith("nope")));
}

// ===========================================================================
// L7 — SlotMap (gap-preserving display positions) + BankBook ordering/reorder/replace
// ===========================================================================
//
// Pure SlotMap-only unit behaviour (add/remove/query, reorder gap-preservation,
// resetDense/reconcile, equality/fromEntries, serialize golden literal + round
// trip) now lives in test_slot_map.cpp (Q-W1 follow-up), extracted per the house
// every-pure-module-has-a-_tests rule. This file keeps the BankBook-level
// integration coverage below: reorderSample / reconcileSlots / JSON round-trip
// WITH a full book.

// --- BankBook L7: JSON round-trip WITH positions -----------------------------

static void testBankBookSlotsRoundTrip() {
    BankBook book;
    CHECK(book.pool().index.add(sampleWith("p1")) == AddResult::Added);
    CHECK(book.pool().index.add(sampleWith("p2")) == AddResult::Added);
    CHECK(book.pool().index.add(sampleWith("p3")) == AddResult::Added);
    book.reconcileSlots();                 // seed dense: p1@0, p2@1, p3@2
    CHECK(book.reorderSample("id-p3", kPoolBankId, 0));  // p3 -> 0, p1->1, p2->2
    CHECK(book.removeSample("id-p1", kPoolBankId) == RemoveResult::Removed);  // gap at 1

    const std::string json = book.serialize();
    auto back = BankBook::deserialize(json);
    CHECK(back.has_value());
    CHECK(back && *back == book);               // positions (incl. the gap) survive
    if (back) CHECK(back->serialize() == json); // idempotent
    if (back) {
        // p3 kept slot 0; p2 kept slot 2; slot 1 (where p1's shifted position was) is a gap.
        CHECK(back->pool().slots.slotOf("id-p3") == 0);
        CHECK(back->pool().slots.slotOf("id-p2") == 2);
        CHECK(back->pool().slots.idAt(1).empty());
    }
}

// --- BankBook L7: migration default (pre-L7 blob, no slots) -------------------

static void testMigrationDefaultsToInsertionOrderDense() {
    // A pre-L7 legacy bank_index blob carries no slot data. On load -> reconcileSlots
    // seeds dense insertion order (no gaps), so it is visually identical.
    BankModel legacy;
    CHECK(legacy.add(sampleWith("o1")) == AddResult::Added);
    CHECK(legacy.add(sampleWith("o2")) == AddResult::Added);
    CHECK(legacy.add(sampleWith("o3")) == AddResult::Added);
    auto back = BankBook::deserialize(legacy.serialize());
    CHECK(back.has_value());
    if (back) {
        back->reconcileSlots();  // the persist load path calls this
        CHECK((back->orderedSampleIds(kPoolBankId) ==
               std::vector<std::string>{"id-o1", "id-o2", "id-o3"}));
        CHECK(back->pool().slots.maxSlot() == 2);  // dense, no gaps
    }
}

static void testOrderedSampleIdsReconcilesLazily() {
    // Samples added straight to the index (capture path) without touching slots are
    // reconciled on the first orderedSampleIds query (dense append in insertion order).
    BankBook book;
    CHECK(book.pool().index.add(sampleWith("c1")) == AddResult::Added);
    CHECK(book.pool().index.add(sampleWith("c2")) == AddResult::Added);
    CHECK((book.orderedSampleIds(kPoolBankId) ==
           std::vector<std::string>{"id-c1", "id-c2"}));
    // Unknown bank -> empty.
    CHECK(book.orderedSampleIds("no-such-bank").empty());
}

// --- BankBook L7: reorder mutator --------------------------------------------

static void testReorderSampleRejectsUnknown() {
    BankBook book;
    CHECK(book.pool().index.add(sampleWith("r1")) == AddResult::Added);
    book.reconcileSlots();
    CHECK(!book.reorderSample("id-r1", "no-bank", 0));   // unknown bank
    CHECK(!book.reorderSample("id-ghost", kPoolBankId, 0));  // not a member
    CHECK(book.pool().slots.slotOf("id-r1") == 0);       // unchanged
}

// Same-slot reorder is a true no-op: returns false, no undo point triggered,
// JSON byte-identical before/after (the invariant that blocks spurious dirty-state).
static void testReorderSampleSameSlotIsNoOp() {
    BankBook book;
    CHECK(book.pool().index.add(sampleWith("a")) == AddResult::Added);
    CHECK(book.pool().index.add(sampleWith("b")) == AddResult::Added);
    CHECK(book.pool().index.add(sampleWith("c")) == AddResult::Added);
    book.reconcileSlots();  // a@0, b@1, c@2
    const std::string jsonBefore = book.serialize();

    // Drop each card onto its own current slot — must return false every time.
    CHECK(!book.reorderSample("id-a", kPoolBankId, 0));
    CHECK(!book.reorderSample("id-b", kPoolBankId, 1));
    CHECK(!book.reorderSample("id-c", kPoolBankId, 2));

    // Slots and JSON are byte-identical — no mutation occurred.
    CHECK(book.pool().slots.slotOf("id-a") == 0);
    CHECK(book.pool().slots.slotOf("id-b") == 1);
    CHECK(book.pool().slots.slotOf("id-c") == 2);
    CHECK(book.serialize() == jsonBefore);
}

// Reorder to a slot BEYOND the current maxSlot — the trailing-drop case. The model
// places the card at the exact target slot (no shift, slot is empty), leaving the card's
// old slot empty. This is the pure-layer gate for the DAW beyond-extent drop repro.
static void testReorderSampleBeyondMaxSlot() {
    BankBook book;
    CHECK(book.pool().index.add(sampleWith("a")) == AddResult::Added);
    CHECK(book.pool().index.add(sampleWith("b")) == AddResult::Added);
    book.reconcileSlots();  // a@0, b@1 -> maxSlot = 1
    CHECK(book.pool().slots.maxSlot() == 1);

    // Drag "b" to slot 5 (well beyond maxSlot=1). Should succeed: slot 5 is empty,
    // no insert-shift needed, b just takes slot 5.
    CHECK(book.reorderSample("id-b", kPoolBankId, 5));
    CHECK(book.pool().slots.slotOf("id-b") == 5);
    CHECK(book.pool().slots.slotOf("id-a") == 0);  // a untouched
    // maxSlot is now 5; slot 1 is empty (gap).
    CHECK(book.pool().slots.maxSlot() == 5);
    CHECK(book.pool().slots.idAt(1).empty());  // b's old slot is now a gap
}

// --- BankBook L7: Alt-replace mutator ----------------------------------------

static void testReplaceSampleTakesSlotAndRemovesOccupant() {
    BankBook book;
    CHECK(book.createBank("drums", "Drums"));
    CHECK(book.bank("drums")->index.add(sampleWith("a")) == AddResult::Added);
    CHECK(book.bank("drums")->index.add(sampleWith("b")) == AddResult::Added);
    CHECK(book.bank("drums")->index.add(sampleWith("c")) == AddResult::Added);
    book.reconcileSlots();  // a@0, b@1, c@2
    // Drag c (the newId) onto b (the occupant/oldId) with Alt -> c takes slot 1, b removed.
    CHECK(book.replaceSample("id-c", "id-b", "drums"));
    CHECK(book.bank("drums")->index.query("id-b") == nullptr);  // occupant removed from index
    CHECK(book.bank("drums")->index.query("id-c") != nullptr);  // dragged sample survives
    CHECK(book.bank("drums")->slots.slotOf("id-c") == 1);       // took the vacated slot
    CHECK(book.bank("drums")->slots.slotOf("id-a") == 0);       // untouched
    CHECK(book.bank("drums")->slots.idAt(2).empty());           // c's old slot emptied
}

static void testReplaceSampleNonDestructiveFileStays() {
    // Replace is INDEX-ONLY: the removed occupant's FILE is never touched. We assert the
    // model does not mutate relativePath / does not report a disk op — the removed entry's
    // hash can still be referenced elsewhere (the last-reference story is unchanged).
    BankBook book;
    CHECK(book.createBank("drums", "Drums"));
    // Same-hash sample lives in BOTH the pool and drums (a copy). Replacing it out of drums
    // leaves the pool's reference intact -> hashReferencedElsewhere still true for the pool.
    Sample shared = sampleWith("shared", "shared-hash");
    CHECK(book.pool().index.add(shared) == AddResult::Added);
    CHECK(book.bank("drums")->index.add(shared) == AddResult::Added);
    CHECK(book.bank("drums")->index.add(sampleWith("dragged")) == AddResult::Added);
    book.reconcileSlots();  // drums: shared@0, dragged@1
    CHECK(book.replaceSample("id-dragged", "id-shared", "drums"));
    CHECK(book.bank("drums")->index.query("id-shared") == nullptr);   // gone from drums
    CHECK(book.pool().index.query("id-shared") != nullptr);           // pool copy survives
    CHECK(book.hashReferencedElsewhere("shared-hash", "drums"));      // last-ref story intact
}

static void testReplaceSampleRejectionsNoMutation() {
    BankBook book;
    CHECK(book.createBank("drums", "Drums"));
    CHECK(book.bank("drums")->index.add(sampleWith("a")) == AddResult::Added);
    CHECK(book.bank("drums")->index.add(sampleWith("b")) == AddResult::Added);
    book.reconcileSlots();
    const BankBook snapshot = book;  // capture full state to prove no-mutation

    CHECK(!book.replaceSample("id-a", "id-a", "drums"));      // newId == oldId
    CHECK(!book.replaceSample("id-a", "id-b", "no-bank"));    // unknown bank
    CHECK(!book.replaceSample("id-ghost", "id-b", "drums"));  // newId not a member
    CHECK(!book.replaceSample("id-a", "id-ghost", "drums"));  // oldId not a member
    CHECK(book == snapshot);  // every rejection left the book byte-identical
}

static void testReplaceSampleInPoolPassesGuard() {
    // The pool guard: per-sample remove from the pool is permitted, so Alt-replace over a
    // pool occupant succeeds whenever the occupant exists (no pool-only rejection path).
    BankBook book;  // pool only
    CHECK(book.pool().index.add(sampleWith("a")) == AddResult::Added);
    CHECK(book.pool().index.add(sampleWith("b")) == AddResult::Added);
    book.reconcileSlots();  // a@0, b@1
    CHECK(book.replaceSample("id-b", "id-a", kPoolBankId));  // b replaces a in the pool
    CHECK(book.pool().index.query("id-a") == nullptr);
    CHECK(book.pool().slots.slotOf("id-b") == 0);            // took a's slot
}

int main() {
    testSerializeGoldenLiteral();
    testPoolSeededAndDefaults();
    testPoolPrivileges();
    testCreateRenameReorder();
    testDisplayNameUniqueness();
    testMoveSourceLosesDestGains();
    testCopySourceRetainedDestGains();
    testMoveDestCollapse();
    testCopyDestCollapse();
    testCrossBankSameHashCoexistence();
    testEvacuate();
    testActiveBank();
    testJsonRoundTripFullBook();
    testJsonEmptyBookRoundTrip();
    testLegacyMigration();
    testMalformedJson();
    testLoadPrefersBanksBlob();
    testLoadMigratesLegacyWhenNoBanks();
    testLoadEmptyWhenNeither();
    testLoadMalformedBanksDegradesWithoutLegacyFallback();
    testActiveBankResolveAfterCorruptPersistedId();
    testCycleOrderingWrapAround();
    testCyclePoolOnlyStaysPool();
    testCycleUnknownActiveResolvesToFirst();
    testCycleEmptyListYieldsEmpty();
    testCycleMatchesBookOrdinalOrder();
    testDeserializeCoalescesDuplicateFoldedNames();
    testDeserializeCoalescesMultipleCollisions();
    testDeserializeNamedBankCollidingWithPoolIsDisambiguated();
    testRemoveDropsTargetEntry();
    testRemoveThisBankLeavesSameHashInAnotherBank();
    testRemoveFromPoolAllowedContainerPrivilegesHold();
    testRemoveRejectionsNoMutation();
    testHashReferencedElsewhere();
    testRemoveAllBanksLatentScope();
    testUpdateSampleInPlace();

    // L7 — BankBook ordering/reorder/replace + slot round-trip/migration.
    // (Pure SlotMap-only unit behaviour lives in slot_map_tests.)
    testBankBookSlotsRoundTrip();
    testMigrationDefaultsToInsertionOrderDense();
    testOrderedSampleIdsReconcilesLazily();
    testReorderSampleRejectsUnknown();
    testReorderSampleSameSlotIsNoOp();
    testReorderSampleBeyondMaxSlot();
    testReplaceSampleTakesSlotAndRemovesOccupant();
    testReplaceSampleNonDestructiveFileStays();
    testReplaceSampleRejectionsNoMutation();
    testReplaceSampleInPoolPassesGuard();

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