// Standalone tests for reasampler::bank_model — no REAPER, no test framework.
// This is the point of splitting the model out: iterate the hard logic here with
// a fast build/run loop instead of restarting REAPER.
//
// Covers (PLAN.md M1 test cases): full-field round-trip lossless (optionals
// present AND absent), dedup-by-hash collapse, tier filter + tier move,
// relative-path invariant, empty-index round-trip, malformed/truncated JSON.

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

#include <cstdio>
#include <string>

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 fully-populated sample with every optional PRESENT. `seed` disambiguates
// id/hash so multiple can coexist in one index.
static Sample fullSample(const std::string& seed) {
    Sample s;
    s.id = "id-" + seed;
    s.displayName = "Kick \"punchy\"\n\t/ take " + seed;  // exercises escaping
    s.relativePath = "bank/" + seed + "/kick.wav";
    s.sourceMode = SourceMode::SelectedItems;
    s.sourceRange = {12.3456789012345, 98.7654321098765, 1234.5, 9876.5};
    s.trackGuids = {"{GUID-A}", "{GUID-B}"};
    s.wetDry = 0.6180339887498949;
    s.channelCount = 2;
    s.sampleRate = 48000;
    s.lengthSeconds = 3.141592653589793;
    s.lengthBeats = 4.0;
    s.captureTempo = 128.5;
    s.captureTimeSigNum = 6;    // L7 F1 meter stamp (non-4/4 to prove it round-trips)
    s.captureTimeSigDenom = 8;
    s.key = "F#m";
    s.rootNote = 60;                 // Phase S seam field (present)
    s.loop = LoopPoints{4096, 65536}; // Phase S seam field (present)
    s.levels = {-0.3, -12.7, -14.2};
    s.clipped = true;
    s.tier = Tier::Archive;
    s.contentHash = "hash-" + seed;
    Provenance p;
    p.parentSampleId = "parent-" + seed;
    p.fxChainSnapshot = "<FXCHAIN\n  BYPASS 0 0 0\n>";
    s.provenance = p;
    s.createdTimestamp = 1753080000LL;
    return s;
}

// Same but with every optional ABSENT and empty collections.
static Sample minimalSample(const std::string& seed) {
    Sample s;
    s.id = "min-" + seed;
    s.relativePath = "bank/min.wav";
    s.sourceMode = SourceMode::MasterMix;
    s.channelCount = 1;
    s.sampleRate = 44100;
    s.tier = Tier::Scratch;
    s.contentHash = "minhash-" + seed;
    // key absent, provenance absent, trackGuids empty, displayName empty.
    return s;
}

static void testFullFieldRoundTrip() {
    BankModel idx;
    CHECK(idx.add(fullSample("a")) == AddResult::Added);
    CHECK(idx.add(minimalSample("b")) == AddResult::Added);

    std::string json = idx.serialize();
    auto back = BankModel::deserialize(json);
    CHECK(back.has_value());
    CHECK(back && *back == idx);

    // Round-trip is idempotent on the string form too.
    if (back) CHECK(back->serialize() == json);

    // Spot-check optionals survived exactly.
    if (back) {
        const Sample* full = back->query("id-a");
        CHECK(full && full->key.has_value() && *full->key == "F#m");
        // L7 F1 meter stamp survived exactly.
        CHECK(full && full->captureTimeSigNum == 6 && full->captureTimeSigDenom == 8);
        // The minimal sample never stamped a meter -> 0/0 (the unstamped default).
        const Sample* minMeter = back->query("min-b");
        CHECK(minMeter && minMeter->captureTimeSigNum == 0 && minMeter->captureTimeSigDenom == 0);
        CHECK(full && full->provenance.has_value());
        CHECK(full && full->provenance->fxChainSnapshot == "<FXCHAIN\n  BYPASS 0 0 0\n>");
        // Phase S seam fields survive round-trip exactly.
        CHECK(full && full->rootNote.has_value() && *full->rootNote == 60);
        CHECK(full && full->loop.has_value());
        CHECK(full && full->loop && full->loop->start == 4096 && full->loop->end == 65536);

        const Sample* min = back->query("min-b");
        CHECK(min && !min->key.has_value());
        CHECK(min && !min->provenance.has_value());
        CHECK(min && min->trackGuids.empty());
        // Seam fields absent on the minimal sample and stay absent.
        CHECK(min && !min->rootNote.has_value());
        CHECK(min && !min->loop.has_value());
    }
}

// Golden byte-literal (Q-W1 T?-05 follow-up): pins the EXACT serialized bytes for
// a small fixture, not just self-consistent re-serialization — a format drift
// that round-trips losslessly (e.g. a renamed key both writer and reader agree
// on) would slip past testFullFieldRoundTrip but not this. The format is frozen
// as-shipped; the literal below is the captured current output.
static void testSerializeGoldenLiteral() {
    BankModel idx;
    Sample s;
    s.id = "g1";
    s.relativePath = "bank/g1.wav";
    s.contentHash = "hash-g1";
    CHECK(idx.add(s) == AddResult::Added);
    CHECK(idx.serialize() ==
          "{\"version\":1,\"samples\":[{\"id\":\"g1\",\"displayName\":\"\","
          "\"relativePath\":\"bank/g1.wav\",\"sourceMode\":0,\"sourceRange\":{"
          "\"startSeconds\":0,\"endSeconds\":0,\"startPpq\":0,\"endPpq\":0},"
          "\"trackGuids\":[],\"wetDry\":1,\"channelCount\":0,\"sampleRate\":0,"
          "\"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\":0}]}");
}

static void testDedupByHash() {
    BankModel idx;
    Sample a = fullSample("x");
    CHECK(idx.add(a) == AddResult::Added);

    // Same content hash, different id/name — must collapse, not duplicate.
    Sample dup = minimalSample("y");
    dup.contentHash = a.contentHash;
    CHECK(idx.add(dup) == AddResult::Collapsed);
    CHECK(idx.size() == 1);
    // Original entry is the one kept.
    CHECK(idx.query("id-x") != nullptr);
    CHECK(idx.query("min-y") == nullptr);
    CHECK(idx.findByHash(a.contentHash) != nullptr);

    // Empty hashes do NOT participate in dedup (two empty-hash adds coexist).
    Sample e1 = minimalSample("e1"); e1.contentHash.clear();
    Sample e2 = minimalSample("e2"); e2.contentHash.clear();
    CHECK(idx.add(e1) == AddResult::Added);
    CHECK(idx.add(e2) == AddResult::Added);
    CHECK(idx.size() == 3);
    CHECK(idx.findByHash("") == nullptr);
}

static void testTierFilterAndMove() {
    BankModel idx;
    Sample scratch = minimalSample("s"); scratch.tier = Tier::Scratch;
    Sample archive = fullSample("a");    archive.tier = Tier::Archive;
    CHECK(idx.add(scratch) == AddResult::Added);
    CHECK(idx.add(archive) == AddResult::Added);

    CHECK(idx.byTier(Tier::Scratch).size() == 1);
    CHECK(idx.byTier(Tier::Archive).size() == 1);
    CHECK(idx.byTier(Tier::Scratch)[0].id == "min-s");

    // scratch is auto-prunable, archive is not.
    CHECK(idx.query("min-s")->isAutoPrunable());
    CHECK(!idx.query("id-a")->isAutoPrunable());

    // Move scratch -> archive relocates it.
    CHECK(idx.moveTier("min-s", Tier::Archive));
    CHECK(idx.byTier(Tier::Scratch).empty());
    CHECK(idx.byTier(Tier::Archive).size() == 2);
    CHECK(!idx.query("min-s")->isAutoPrunable());

    // Moving a nonexistent id fails.
    CHECK(!idx.moveTier("nope", Tier::Scratch));
}

static void testRelativePathInvariant() {
    BankModel idx;

    // POSIX absolute, Windows drive, Windows backslash, UNC — all rejected.
    const char* absolutes[] = {
        "/etc/passwd.wav",
        "C:/Users/x/kick.wav",
        "C:\\Users\\x\\kick.wav",
        "\\\\host\\share\\kick.wav",
    };
    for (const char* abs : absolutes) {
        Sample s = minimalSample(abs);
        s.relativePath = abs;
        CHECK(idx.add(s) == AddResult::RejectedAbsolutePath);
    }
    CHECK(idx.empty());  // nothing absolute was ever stored

    // A relative path is accepted.
    Sample ok = minimalSample("ok");
    ok.relativePath = "bank/sub/kick.wav";
    CHECK(idx.add(ok) == AddResult::Added);

    // Empty id is rejected (collection is id-keyed).
    Sample noId = minimalSample("noid");
    noId.id.clear();
    CHECK(idx.add(noId) == AddResult::RejectedEmptyId);
}

// M10: updateInPlace refreshes an entry while preserving its slot + identity, and
// bypasses dedup (an in-place refresh is not a new insert). The relative-paths-only
// invariant still guards the replacement.
static void testUpdateInPlace() {
    BankModel idx;
    CHECK(idx.add(minimalSample("a")) == AddResult::Added);   // id "min-a"
    CHECK(idx.add(minimalSample("b")) == AddResult::Added);   // id "min-b"
    CHECK(idx.add(minimalSample("c")) == AddResult::Added);   // id "min-c"

    // Refresh the MIDDLE entry: new file/hash/name, same id — position must be kept.
    Sample updated = minimalSample("b");
    updated.relativePath = "reasampler_bank/regenerated.wav";
    updated.contentHash  = "new-hash-b";
    updated.displayName  = "regenerated";
    CHECK(idx.updateInPlace("min-b", updated));

    CHECK(idx.size() == 3);                         // no new entry, no removal
    CHECK(idx.all()[1].id == "min-b");             // slot preserved (still middle)
    CHECK(idx.all()[1].relativePath == "reasampler_bank/regenerated.wav");
    CHECK(idx.all()[1].contentHash == "new-hash-b");
    CHECK(idx.query("min-b")->displayName == "regenerated");

    // An updated hash colliding with ANOTHER entry does NOT collapse (updateInPlace
    // is not an insert): the refreshed entry keeps its slot even sharing a hash.
    Sample collide = minimalSample("b");
    collide.contentHash = idx.query("min-a")->contentHash;  // same as entry "min-a"
    CHECK(idx.updateInPlace("min-b", collide));
    CHECK(idx.size() == 3);                         // still three; no collapse

    // Updating an absent id fails without mutation.
    CHECK(!idx.updateInPlace("nope", minimalSample("x")));
    CHECK(idx.size() == 3);

    // An absolute replacement path is rejected (invariant preserved).
    Sample bad = minimalSample("b");
    bad.relativePath = "C:/evil.wav";
    CHECK(!idx.updateInPlace("min-b", bad));
    CHECK(idx.query("min-b")->relativePath != "C:/evil.wav");
}

static void testEmptyIndexRoundTrip() {
    BankModel idx;
    CHECK(idx.empty());
    std::string json = idx.serialize();
    auto back = BankModel::deserialize(json);
    CHECK(back.has_value());
    CHECK(back && back->empty());
    CHECK(back && *back == idx);
}

static void testMalformedJson() {
    const char* bad[] = {
        "",
        "{",
        "not json at all",
        "{\"samples\":[",
        "{\"samples\":[{\"id\":\"x\"",       // truncated sample object
        "{\"samples\":[{\"id\":\"x\",}]}",    // dangling comma -> bad key
        "{\"samples\":[{]}",                   // garbage inside array
        "{\"samples\":{}}",                    // samples not an array
        "{\"samples\":[]}trailing",           // trailing garbage
        "{\"samples\":[{\"createdTimestamp\":notanumber}]}",
    };
    for (const char* j : bad) {
        auto r = BankModel::deserialize(j);
        CHECK(!r.has_value());  // signaled as nullopt, no crash / UB
    }

    // A well-formed empty object deserializes to an empty index (lenient root).
    auto ok = BankModel::deserialize("{}");
    CHECK(ok.has_value() && ok->empty());
}

static void testRemoveAndQuery() {
    BankModel idx;
    CHECK(idx.add(fullSample("1")) == AddResult::Added);
    CHECK(idx.add(fullSample("2")) == AddResult::Added);
    CHECK(idx.query("id-1") != nullptr);
    CHECK(idx.query("missing") == nullptr);
    CHECK(idx.remove("id-1"));
    CHECK(idx.query("id-1") == nullptr);
    CHECK(!idx.remove("id-1"));  // second remove is a no-op
    CHECK(idx.size() == 1);
}

// Fix 1: drive-relative and bare-drive forms must be rejected by add().
static void testAbsolutePathDriveRelative() {
    BankModel idx;

    // Drive-relative: resolves against the drive's CWD, not the project root.
    Sample dr = minimalSample("dr");
    dr.contentHash = "hash-dr";
    dr.relativePath = "C:foo.wav";
    CHECK(idx.add(dr) == AddResult::RejectedAbsolutePath);

    // Bare drive letter + colon: also drive-relative / ambiguous.
    Sample bare = minimalSample("bare");
    bare.contentHash = "hash-bare";
    bare.relativePath = "C:";
    CHECK(idx.add(bare) == AddResult::RejectedAbsolutePath);

    // UNC path — belt-and-suspenders alongside the existing test.
    Sample unc = minimalSample("unc");
    unc.contentHash = "hash-unc";
    unc.relativePath = "\\\\server\\share\\kick.wav";
    CHECK(idx.add(unc) == AddResult::RejectedAbsolutePath);

    // Nothing was stored.
    CHECK(idx.empty());
}

// Fix 2: \uXXXX escape sequences decode to correct UTF-8 bytes.
static void testUnicodeEscapeDecoding() {
    // é  = U+00E9 → 2-byte UTF-8: 0xC3 0xA9
    // JSON: "é"
    auto r1 = BankModel::deserialize(
        "{\"samples\":[{\"id\":\"u1\",\"relativePath\":\"bank/u.wav\","
        "\"displayName\":\"\\u00e9\","
        "\"sourceMode\":0,\"sourceRange\":{\"startSeconds\":0.0,\"endSeconds\":0.0,"
        "\"startPpq\":0.0,\"endPpq\":0.0},\"trackGuids\":[],"
        "\"wetDry\":1.0,\"channelCount\":1,\"sampleRate\":44100,"
        "\"lengthSeconds\":0.0,\"lengthBeats\":0.0,\"captureTempo\":0.0,"
        "\"key\":null,\"levels\":{\"peakDb\":0.0,\"rmsDb\":0.0,\"lufs\":0.0},"
        "\"clipped\":false,\"tier\":0,\"contentHash\":\"h-u1\","
        "\"provenance\":null,\"createdTimestamp\":0}]}");
    CHECK(r1.has_value());
    if (r1) {
        const Sample* s = r1->query("u1");
        CHECK(s != nullptr);
        if (s) {
            // UTF-8 for U+00E9: 0xC3 0xA9 (2 bytes)
            CHECK(s->displayName.size() == 2);
            CHECK(static_cast<unsigned char>(s->displayName[0]) == 0xC3);
            CHECK(static_cast<unsigned char>(s->displayName[1]) == 0xA9);
        }
    }

    // 中 = U+4E2D → 3-byte UTF-8: 0xE4 0xB8 0xAD
    // JSON: "中"
    auto r2 = BankModel::deserialize(
        "{\"samples\":[{\"id\":\"u2\",\"relativePath\":\"bank/u.wav\","
        "\"displayName\":\"\\u4e2d\","
        "\"sourceMode\":0,\"sourceRange\":{\"startSeconds\":0.0,\"endSeconds\":0.0,"
        "\"startPpq\":0.0,\"endPpq\":0.0},\"trackGuids\":[],"
        "\"wetDry\":1.0,\"channelCount\":1,\"sampleRate\":44100,"
        "\"lengthSeconds\":0.0,\"lengthBeats\":0.0,\"captureTempo\":0.0,"
        "\"key\":null,\"levels\":{\"peakDb\":0.0,\"rmsDb\":0.0,\"lufs\":0.0},"
        "\"clipped\":false,\"tier\":0,\"contentHash\":\"h-u2\","
        "\"provenance\":null,\"createdTimestamp\":0}]}");
    CHECK(r2.has_value());
    if (r2) {
        const Sample* s = r2->query("u2");
        CHECK(s != nullptr);
        if (s) {
            // UTF-8 for U+4E2D: 0xE4 0xB8 0xAD (3 bytes)
            CHECK(s->displayName.size() == 3);
            CHECK(static_cast<unsigned char>(s->displayName[0]) == 0xE4);
            CHECK(static_cast<unsigned char>(s->displayName[1]) == 0xB8);
            CHECK(static_cast<unsigned char>(s->displayName[2]) == 0xAD);
        }
    }

    // 😀 = U+1F600 → surrogate pair 😀 → 4-byte UTF-8: 0xF0 0x9F 0x98 0x80
    auto r3 = BankModel::deserialize(
        "{\"samples\":[{\"id\":\"u3\",\"relativePath\":\"bank/u.wav\","
        "\"displayName\":\"\\uD83D\\uDE00\","
        "\"sourceMode\":0,\"sourceRange\":{\"startSeconds\":0.0,\"endSeconds\":0.0,"
        "\"startPpq\":0.0,\"endPpq\":0.0},\"trackGuids\":[],"
        "\"wetDry\":1.0,\"channelCount\":1,\"sampleRate\":44100,"
        "\"lengthSeconds\":0.0,\"lengthBeats\":0.0,\"captureTempo\":0.0,"
        "\"key\":null,\"levels\":{\"peakDb\":0.0,\"rmsDb\":0.0,\"lufs\":0.0},"
        "\"clipped\":false,\"tier\":0,\"contentHash\":\"h-u3\","
        "\"provenance\":null,\"createdTimestamp\":0}]}");
    CHECK(r3.has_value());
    if (r3) {
        const Sample* s = r3->query("u3");
        CHECK(s != nullptr);
        if (s) {
            // UTF-8 for U+1F600: 0xF0 0x9F 0x98 0x80 (4 bytes)
            CHECK(s->displayName.size() == 4);
            CHECK(static_cast<unsigned char>(s->displayName[0]) == 0xF0);
            CHECK(static_cast<unsigned char>(s->displayName[1]) == 0x9F);
            CHECK(static_cast<unsigned char>(s->displayName[2]) == 0x98);
            CHECK(static_cast<unsigned char>(s->displayName[3]) == 0x80);
        }
    }

    // Unpaired high surrogate (no following \uDCxx) → nullopt.
    auto r4 = BankModel::deserialize(
        "{\"samples\":[{\"id\":\"u4\",\"relativePath\":\"bank/u.wav\","
        "\"displayName\":\"\\uD83D\","
        "\"sourceMode\":0,\"sourceRange\":{\"startSeconds\":0.0,\"endSeconds\":0.0,"
        "\"startPpq\":0.0,\"endPpq\":0.0},\"trackGuids\":[],"
        "\"wetDry\":1.0,\"channelCount\":1,\"sampleRate\":44100,"
        "\"lengthSeconds\":0.0,\"lengthBeats\":0.0,\"captureTempo\":0.0,"
        "\"key\":null,\"levels\":{\"peakDb\":0.0,\"rmsDb\":0.0,\"lufs\":0.0},"
        "\"clipped\":false,\"tier\":0,\"contentHash\":\"h-u4\","
        "\"provenance\":null,\"createdTimestamp\":0}]}");
    CHECK(!r4.has_value());
}

// Fix 3: strtoll overflow must reject the value, not clamp it silently.
static void testIntegerOverflow() {
    // A timestamp value that overflows int64_t (> 9223372036854775807).
    auto r = BankModel::deserialize(
        "{\"samples\":[{\"id\":\"ov1\",\"relativePath\":\"bank/ov.wav\","
        "\"displayName\":\"\","
        "\"sourceMode\":0,\"sourceRange\":{\"startSeconds\":0.0,\"endSeconds\":0.0,"
        "\"startPpq\":0.0,\"endPpq\":0.0},\"trackGuids\":[],"
        "\"wetDry\":1.0,\"channelCount\":1,\"sampleRate\":44100,"
        "\"lengthSeconds\":0.0,\"lengthBeats\":0.0,\"captureTempo\":0.0,"
        "\"key\":null,\"levels\":{\"peakDb\":0.0,\"rmsDb\":0.0,\"lufs\":0.0},"
        "\"clipped\":false,\"tier\":0,\"contentHash\":\"h-ov1\","
        "\"provenance\":null,\"createdTimestamp\":99999999999999999999}]}");
    CHECK(!r.has_value());
}

// Fix 4: out-of-range enum values must reject the sample, not produce invalid enum.
static void testEnumRangeValidation() {
    // tier: 99 is not a valid Tier enumerator.
    auto r1 = BankModel::deserialize(
        "{\"samples\":[{\"id\":\"en1\",\"relativePath\":\"bank/en.wav\","
        "\"displayName\":\"\","
        "\"sourceMode\":0,\"sourceRange\":{\"startSeconds\":0.0,\"endSeconds\":0.0,"
        "\"startPpq\":0.0,\"endPpq\":0.0},\"trackGuids\":[],"
        "\"wetDry\":1.0,\"channelCount\":1,\"sampleRate\":44100,"
        "\"lengthSeconds\":0.0,\"lengthBeats\":0.0,\"captureTempo\":0.0,"
        "\"key\":null,\"levels\":{\"peakDb\":0.0,\"rmsDb\":0.0,\"lufs\":0.0},"
        "\"clipped\":false,\"tier\":99,\"contentHash\":\"h-en1\","
        "\"provenance\":null,\"createdTimestamp\":0}]}");
    CHECK(!r1.has_value());

    // sourceMode: 99 is not a valid SourceMode enumerator.
    auto r2 = BankModel::deserialize(
        "{\"samples\":[{\"id\":\"en2\",\"relativePath\":\"bank/en.wav\","
        "\"displayName\":\"\","
        "\"sourceMode\":99,\"sourceRange\":{\"startSeconds\":0.0,\"endSeconds\":0.0,"
        "\"startPpq\":0.0,\"endPpq\":0.0},\"trackGuids\":[],"
        "\"wetDry\":1.0,\"channelCount\":1,\"sampleRate\":44100,"
        "\"lengthSeconds\":0.0,\"lengthBeats\":0.0,\"captureTempo\":0.0,"
        "\"key\":null,\"levels\":{\"peakDb\":0.0,\"rmsDb\":0.0,\"lufs\":0.0},"
        "\"clipped\":false,\"tier\":0,\"contentHash\":\"h-en2\","
        "\"provenance\":null,\"createdTimestamp\":0}]}");
    CHECK(!r2.has_value());
}

// S2 test case 2: a legacy Sample JSON — written before the Phase S seam fields
// existed, so it has NO "rootNote" or "loop" keys at all — parses to clean empty
// optionals (no loss, no migration) and re-serializes without inventing values.
// (The parser's forward-compat unknown-key skipping is what makes the reverse case
// — new keys ignored by an old parser — safe too; here we test old-JSON→new-parser.)
static void testLegacyJsonDefaults() {
    const char* legacy =
        "{\"samples\":[{\"id\":\"leg1\",\"relativePath\":\"bank/leg.wav\","
        "\"displayName\":\"legacy\","
        "\"sourceMode\":0,\"sourceRange\":{\"startSeconds\":1.5,\"endSeconds\":2.5,"
        "\"startPpq\":0.0,\"endPpq\":0.0},\"trackGuids\":[],"
        "\"wetDry\":1.0,\"channelCount\":2,\"sampleRate\":48000,"
        "\"lengthSeconds\":1.0,\"lengthBeats\":0.0,\"captureTempo\":120.0,"
        "\"key\":null,\"levels\":{\"peakDb\":0.0,\"rmsDb\":0.0,\"lufs\":0.0},"
        "\"clipped\":false,\"tier\":0,\"contentHash\":\"h-leg1\","
        "\"provenance\":null,\"createdTimestamp\":0}]}";
    auto r = BankModel::deserialize(legacy);
    CHECK(r.has_value());
    if (r) {
        const Sample* s = r->query("leg1");
        CHECK(s != nullptr);
        CHECK(s && !s->rootNote.has_value());  // clean default, not a guessed value
        CHECK(s && !s->loop.has_value());

        // Re-serialize is lossless: parsing it again yields an equal index. This
        // proves the absent fields did not silently gain values on the way out.
        std::string out = r->serialize();
        auto again = BankModel::deserialize(out);
        CHECK(again.has_value());
        CHECK(again && *again == *r);
        if (again) {
            const Sample* s2 = again->query("leg1");
            CHECK(s2 && !s2->rootNote.has_value());
            CHECK(s2 && !s2->loop.has_value());
        }
    }
}

// A bank written before captures were named after their source track carries the literal
// "item"/"track" label. Nothing migrates or relabels it: the label must survive load and
// re-serialize byte-identically, so an old bank reads exactly as it did.
static void testPreNamingSchemeLabelsSurviveUntouched() {
    const char* old =
        "{\"version\":1,\"samples\":["
        "{\"id\":\"cap-1700000000-1-item_1700000000-1.wav\","
        "\"displayName\":\"item\",\"relativePath\":\"reasampler_bank/item_1700000000-1.wav\","
        "\"contentHash\":\"h-old-a\"},"
        "{\"id\":\"cap-1700000000-2-track_1700000000-2.wav\","
        "\"displayName\":\"track\",\"relativePath\":\"reasampler_bank/track_1700000000-2.wav\","
        "\"contentHash\":\"h-old-b\"}]}";
    auto r = BankModel::deserialize(old);
    CHECK(r.has_value());
    if (!r) return;

    const Sample* a = r->query("cap-1700000000-1-item_1700000000-1.wav");
    const Sample* b = r->query("cap-1700000000-2-track_1700000000-2.wav");
    CHECK(a && a->displayName == "item");
    CHECK(b && b->displayName == "track");

    auto again = BankModel::deserialize(r->serialize());
    CHECK(again.has_value());
    CHECK(again && *again == *r);
    if (again) {
        const Sample* a2 = again->query("cap-1700000000-1-item_1700000000-1.wav");
        CHECK(a2 && a2->displayName == "item");
    }
}

// S2 test case 4: boundary values for the seam fields are representable and
// round-trip. rootNote 0 and 127 (the MIDI edges); loopStart == loopEnd (a valid
// zero-length marker); a loop whose end sits at the file's last frame. Also asserts
// the deserialize-boundary validation rules reject out-of-range input rather than
// storing a bogus value.
static void testSeamFieldBoundaries() {
    // rootNote at both MIDI edges + equal-and-end-anchored loop points round-trip.
    BankModel idx;
    Sample lo = minimalSample("lo"); lo.contentHash = "h-lo";
    lo.rootNote = 0;
    lo.loop = LoopPoints{0, 0};            // zero-length marker at frame 0
    Sample hi = minimalSample("hi"); hi.contentHash = "h-hi";
    hi.rootNote = 127;
    hi.loop = LoopPoints{100, 100};        // start == end elsewhere
    Sample end = minimalSample("end"); end.contentHash = "h-end";
    end.loop = LoopPoints{0, 9223372036854775807LL};  // end at max int64 frame
    CHECK(idx.add(lo) == AddResult::Added);
    CHECK(idx.add(hi) == AddResult::Added);
    CHECK(idx.add(end) == AddResult::Added);

    auto back = BankModel::deserialize(idx.serialize());
    CHECK(back.has_value());
    CHECK(back && *back == idx);
    if (back) {
        CHECK(back->query("min-lo")->rootNote == 0);
        CHECK(back->query("min-hi")->rootNote == 127);
        // Named local: a brace-init with a comma inside CHECK(...) would be parsed
        // as two macro arguments by the preprocessor.
        const LoopPoints zeroLen{0, 0};
        CHECK(back->query("min-lo")->loop == zeroLen);
        CHECK(back->query("min-end")->loop->end == 9223372036854775807LL);
    }

    // Validation rule (chosen for this design, surfaced in the handoff):
    //   rootNote must be 0..127; loop must satisfy 0 <= start <= end.
    // Out-of-range input is rejected at the deserialize boundary (nullopt), mirroring
    // the existing enum-range and integer-overflow rejections — never clamped.
    const char* head =
        "{\"samples\":[{\"id\":\"bad\",\"relativePath\":\"bank/b.wav\","
        "\"displayName\":\"\",\"sourceMode\":0,"
        "\"sourceRange\":{\"startSeconds\":0.0,\"endSeconds\":0.0,"
        "\"startPpq\":0.0,\"endPpq\":0.0},\"trackGuids\":[],"
        "\"wetDry\":1.0,\"channelCount\":1,\"sampleRate\":44100,"
        "\"lengthSeconds\":0.0,\"lengthBeats\":0.0,\"captureTempo\":0.0,"
        "\"key\":null,";
    const char* tail =
        "\"levels\":{\"peakDb\":0.0,\"rmsDb\":0.0,\"lufs\":0.0},"
        "\"clipped\":false,\"tier\":0,\"contentHash\":\"h-bad\","
        "\"provenance\":null,\"createdTimestamp\":0}]}";

    CHECK(!BankModel::deserialize(std::string(head) + "\"rootNote\":128," + tail).has_value());
    CHECK(!BankModel::deserialize(std::string(head) + "\"rootNote\":-1," + tail).has_value());
    CHECK(!BankModel::deserialize(
        std::string(head) + "\"loop\":{\"start\":10,\"end\":5}," + tail).has_value());  // start > end
    CHECK(!BankModel::deserialize(
        std::string(head) + "\"loop\":{\"start\":-1,\"end\":5}," + tail).has_value());  // negative start
}

// S2 test case 3: the seam-field addition is purely additive — dedup-by-hash, tier
// moves/filtering, and BankModel ordering are byte-for-byte unchanged by the
// presence (or absence) of rootNote/loop. Two samples differing ONLY in seam fields
// but sharing a content hash still collapse; a seam-populated sample tiers exactly
// like any other.
static void testSeamFieldsAdditiveInvariant() {
    BankModel idx;
    Sample a = fullSample("z");   // has rootNote + loop populated
    CHECK(idx.add(a) == AddResult::Added);

    // Same hash, seam fields cleared — dedup keys off contentHash only, so this
    // still collapses. Seam fields do NOT enter the dedup identity.
    Sample dup = fullSample("z2");
    dup.contentHash = a.contentHash;
    dup.rootNote.reset();
    dup.loop.reset();
    CHECK(idx.add(dup) == AddResult::Collapsed);
    CHECK(idx.size() == 1);

    // Tier move on a seam-populated sample behaves exactly as before.
    CHECK(idx.query("id-z")->tier == Tier::Archive);
    CHECK(idx.moveTier("id-z", Tier::Scratch));
    CHECK(idx.query("id-z")->tier == Tier::Scratch);
    CHECK(idx.query("id-z")->rootNote == 60);  // move did not disturb seam fields
}

// A collapsed capture is a 1-channel entry, and the JSON is the only thing carrying
// that count across a project reload — the instrument's mono/stereo default reads it.
static void testMonoChannelCountRoundTrip() {
    BankModel idx;
    Sample s = fullSample("mono");
    s.channelCount = 1;
    CHECK(idx.add(s) == AddResult::Added);

    const std::string json = idx.serialize();
    CHECK(json.find("\"channelCount\":1") != std::string::npos);

    auto back = BankModel::deserialize(json);
    CHECK(back.has_value());
    CHECK(back && back->query("id-mono") &&
          back->query("id-mono")->channelCount == 1);
}

int main() {
    testFullFieldRoundTrip();
    testMonoChannelCountRoundTrip();
    testSerializeGoldenLiteral();
    testDedupByHash();
    testTierFilterAndMove();
    testRelativePathInvariant();
    testUpdateInPlace();
    testEmptyIndexRoundTrip();
    testMalformedJson();
    testRemoveAndQuery();
    testAbsolutePathDriveRelative();
    testUnicodeEscapeDecoding();
    testIntegerOverflow();
    testEnumRangeValidation();
    testLegacyJsonDefaults();
    testPreNamingSchemeLabelsSurviveUntouched();
    testSeamFieldBoundaries();
    testSeamFieldsAdditiveInvariant();

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