// Standalone tests for reasampler::wire::bake_wire — no VST3, no REAPER, no framework.
// Same fast assert loop as the sibling wire tests.
//
// Covers: the exact bytes each record encodes to (the two artifacts ship independently, so
// a field reorder or an inserted field must fail here rather than pass a round-trip and
// break a mixed-version pair); request + outcome round-trips including bytes that would
// break a delimiter-based format; the refusals every house wire record shares (wrong tag,
// truncation, trailing garbage, a swapped record kind); an unrecognized status integer
// degrading to Failed rather than to Ok; the action lookup name's leading underscore and
// its channel fork; the two key classifiers each end reads the shared key through; the
// write-back verdict, driven by a modelled key store that accepts or drops the write; and
// the persist/upgrade state machine that is the ONLY route to a Banked landing, at both
// persist outcomes.

#include "../src/core/wire/bake_wire.h"

#include "../src/core/version/app_version.h"
#include "../src/core/wire/ext_state_read.h"  // extStateWriteLanded (the write-proof peer)

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

using namespace reasampler::wire;

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

// The one answer bake_land emits for a landing its pass could not persist. Pinned once
// here and used by both blocks below that need it, so the test suite does not become a
// third place the sentence lives.
static const std::string kUnpersistedAnswer =
    "the bake reached the bank in memory, but this pass's persist did not report success, "
    "so this answer cannot promise a reload will find it";

int main() {
    // --- The exact bytes on the wire -------------------------------------------------
    // A round-trip alone would pass a reordered or inserted field; the tags exist to guard
    // the LAYOUT, so the layout is what is pinned. Changing either literal below means an
    // already-shipped pair of artifacts can no longer talk — bump the tag, don't edit it.
    {
        BakeRequest req;
        req.instanceGuid = "abcd";
        req.stagedFilePath = "T/b.wav";
        req.sourceSampleId = "cap-1";
        req.sourceRelativePath = "bank/k.wav";
        req.sourceDisplayName = "Kick";
        req.ownUsageKey = "rsusage_abcd";
        req.rootNote = 36;
        req.generation = 1893456000;
        CHECK(encodeBakeRequest(req) ==
              "rsbakereq1"
              "4:abcd"
              "7:T/b.wav"
              "5:cap-1"
              "10:bank/k.wav"
              "4:Kick"
              "12:rsusage_abcd"
              "2:36"
              "10:1893456000");

        BakeOutcome out;
        out.status = BakeStatus::Ok;
        out.sampleId = "bake-1";
        out.relativePath = "bank/k2.wav";
        out.displayName = "Kick r2";
        out.rootNote = 36;
        out.channelCount = 2;
        out.replaced = true;
        out.message = "replaced";
        out.generation = 1893456000;
        CHECK(encodeBakeOutcome(out) ==
              "rsbakeout1"
              "1:0"
              "6:bake-1"
              "11:bank/k2.wav"
              "7:Kick r2"
              "2:36"
              "1:2"
              "1:1"
              "8:replaced"
              "10:1893456000");
    }

    // --- Request round-trip, with hostile field content -----------------------------
    {
        BakeRequest req;
        req.instanceGuid = "0123abcd";
        req.stagedFilePath = "C:/Temp/re:sampler 9000/bake 12:34.wav";  // colons + spaces
        req.sourceSampleId = "cap-1";
        req.sourceRelativePath = "reasampler_bank/kick.wav";
        req.sourceDisplayName = "Kick r2";
        req.ownUsageKey = "rsusage_0123abcd";
        req.rootNote = 36;
        req.generation = 1893456000;

        const std::string encoded = encodeBakeRequest(req);
        const auto decoded = decodeBakeRequest(encoded);
        CHECK(decoded.has_value());
        CHECK(*decoded == req);

        // Empty strings and a zero generation survive too (a first, un-named source).
        BakeRequest bare;
        CHECK(decodeBakeRequest(encodeBakeRequest(bare)) == bare);
    }

    // --- Outcome round-trip -----------------------------------------------------------
    {
        BakeOutcome out;
        out.status = BakeStatus::Ok;
        out.sampleId = "bake-1893456000-kick_1893456000.wav";
        out.relativePath = "reasampler_bank/kick_1893456000.wav";
        out.displayName = "Kick r3";
        out.rootNote = 36;
        out.channelCount = 2;
        out.replaced = true;
        out.message = "replaced the bank entry";
        out.generation = 1893456000;

        const auto decoded = decodeBakeOutcome(encodeBakeOutcome(out));
        CHECK(decoded.has_value());
        CHECK(*decoded == out);
        CHECK(decoded->replaced);

        out.replaced = false;
        CHECK(decodeBakeOutcome(encodeBakeOutcome(out))->replaced == false);
    }

    // --- Malformed input is refused, never half-parsed ---------------------------------
    {
        BakeRequest req;
        req.instanceGuid = "abc";
        req.rootNote = 60;
        const std::string good = encodeBakeRequest(req);

        CHECK(!decodeBakeRequest("").has_value());
        CHECK(!decodeBakeRequest("rsbakereq0" + good.substr(10)).has_value());  // wrong tag
        CHECK(!decodeBakeRequest(good.substr(0, good.size() - 3)).has_value()); // truncated
        CHECK(!decodeBakeRequest(good + "junk").has_value());                   // trailing
        // The two records share a key; each must refuse the other's bytes outright.
        CHECK(!decodeBakeOutcome(good).has_value());
        CHECK(!decodeBakeRequest(encodeBakeOutcome(BakeOutcome{})).has_value());
    }

    // --- A status integer this build does not know reads as a FAILURE ------------------
    {
        // Hand-built with a future status value; every other field is well-formed, so only
        // the vocabulary gap is under test.
        BakeOutcome out;
        out.status = BakeStatus::Ok;
        out.generation = 7;
        std::string wire = encodeBakeOutcome(out);
        // The status field is the first after the tag: "<len>':'<digits>".
        const std::string okField = "1:0";
        const std::size_t at = wire.find(okField);
        CHECK(at != std::string::npos);
        wire.replace(at, okField.size(), "2:99");

        const auto decoded = decodeBakeOutcome(wire);
        CHECK(decoded.has_value());
        CHECK(decoded->status == BakeStatus::Failed);  // never Ok
        CHECK(decoded->generation == 7);

        // Every status this build DOES know survives its own round trip — including the
        // most recently appended one, which an older reader will see as Failed.
        for (const BakeStatus s :
             {BakeStatus::Ok, BakeStatus::Failed, BakeStatus::NoProject,
              BakeStatus::StagedMissing, BakeStatus::NoSource, BakeStatus::IndexRejected,
              BakeStatus::WrongProject}) {
            BakeOutcome one;
            one.status = s;
            const auto back = decodeBakeOutcome(encodeBakeOutcome(one));
            CHECK(back.has_value() && back->status == s);
        }
    }

    // --- The lookup name carries the underscore the registration string does not -------
    // The load-bearing half is the REGISTRATION string: main.cpp registers that spelling
    // verbatim, and NamedCommandLookup needs exactly one underscore in front of it. If
    // channelCommandId ever grew one of its own, the lookup would carry two and resolve to
    // nothing.
    {
        const std::string registered =
            reasampler::version::channelCommandId(kBakeActionSuffix);
        const std::string lookup = bakeActionLookupName();
        const std::size_t suffixLen = std::string(kBakeActionSuffix).size();
        CHECK(!registered.empty());
        CHECK(registered.front() != '_');
        CHECK(lookup.size() == registered.size() + 1);
        CHECK(lookup.front() == '_' && lookup[1] != '_');
        CHECK(lookup.compare(1, std::string::npos, registered) == 0);
        // The suffix is the TAIL of the id — the channel prefix goes in front of it, and a
        // suffix that drifted into the middle would name a different action.
        CHECK(lookup.size() > suffixLen);
        CHECK(lookup.compare(lookup.size() - suffixLen, suffixLen, kBakeActionSuffix) == 0);
    }

    // --- The action id and the ext-state namespace fork on the SAME channel bit ---------
    // Both frozen families are spelled out, so this holds whichever channel the test binary
    // was built for. Consequence for a cross-channel pair (stable VST + beta extension or
    // the reverse): NamedCommandLookup resolves nothing, bakeAvailable paints the affordance
    // Disabled, and the click cannot reach the no-answer path at all.
    {
        const std::string suffix = kBakeActionSuffix;
        const std::string stableLookup = "_CEREBELLUM_REASAMPLER_" + suffix;
        const std::string betaLookup = "_CEREBELLUM_REASAMPLER_BETA_" + suffix;
        const bool beta = reasampler::version::isBeta();

        CHECK(stableLookup != betaLookup);
        CHECK(bakeActionLookupName() == (beta ? betaLookup : stableLookup));
        CHECK(bakeActionLookupName() != (beta ? stableLookup : betaLookup));
        CHECK(reasampler::version::extStateNamespace() ==
              (beta ? "reasampler_beta" : "reasampler"));
    }

    // --- What the instrument finds under its own key, after the action returned ---------
    {
        BakeRequest sent;
        sent.instanceGuid = "0123abcd";
        sent.stagedFilePath = "C:/Temp/reasampler_bake_0123abcd_1893456000.wav";
        sent.sourceSampleId = "cap-1";
        sent.sourceRelativePath = "reasampler_bank/kick.wav";
        sent.sourceDisplayName = "Kick";
        sent.ownUsageKey = "rsusage_0123abcd";
        sent.rootNote = 36;
        sent.generation = 1893456000;

        BakeOutcome answered;
        answered.status = BakeStatus::Ok;
        answered.sampleId = "bake-1";
        answered.message = "added as a distinct capture";
        answered.generation = sent.generation;

        const BakeAnswer ok =
            classifyBakeAnswer(std::optional<std::string>(encodeBakeOutcome(answered)), sent);
        CHECK(ok.kind == BakeAnswerKind::Answered);
        CHECK(ok.outcome.has_value() && ok.outcome->sampleId == "bake-1");

        // A refusal is an ANSWER — it must never fold into a no-answer kind, or the user is
        // sent to reinstall a binary that in fact answered them.
        BakeOutcome refused;
        refused.status = BakeStatus::WrongProject;
        refused.message = "this bake's project tab is not the one the extension has loaded";
        refused.generation = sent.generation;
        const BakeAnswer refusal =
            classifyBakeAnswer(std::optional<std::string>(encodeBakeOutcome(refused)), sent);
        CHECK(refusal.kind == BakeAnswerKind::Answered);
        CHECK(refusal.outcome.has_value() &&
              refusal.outcome->status == BakeStatus::WrongProject);

        BakeOutcome older = answered;
        older.generation = sent.generation - 1;
        const BakeAnswer foreignOut =
            classifyBakeAnswer(std::optional<std::string>(encodeBakeOutcome(older)), sent);
        CHECK(foreignOut.kind == BakeAnswerKind::ForeignOutcome);
        CHECK(foreignOut.outcome.has_value());

        // Nothing on the extension side read the key: the request is still sitting there
        // byte-for-byte. THE diagnostic that separates a stale/absent landing from a refusal.
        CHECK(classifyBakeAnswer(std::optional<std::string>(encodeBakeRequest(sent)), sent)
                  .kind == BakeAnswerKind::Unanswered);

        // A request that is not ours: two instances copied from one another share a
        // persisted instanceGuid, so they name one key.
        BakeRequest sibling = sent;
        sibling.sourceSampleId = "cap-2";
        sibling.generation = sent.generation + 1;
        CHECK(classifyBakeAnswer(std::optional<std::string>(encodeBakeRequest(sibling)), sent)
                  .kind == BakeAnswerKind::ForeignRequest);

        // Cleared folds two genuinely different bridge outcomes -- key absent, and key
        // present but empty -- into ONE kind, because the bridge cannot label which
        // occurred. Both must land on the identical kind: a caller's message may claim
        // no more than what the fold actually preserves.
        const BakeAnswer clearedFromAbsent = classifyBakeAnswer(std::nullopt, sent);
        const BakeAnswer clearedFromEmpty =
            classifyBakeAnswer(std::optional<std::string>(""), sent);
        CHECK(clearedFromAbsent.kind == BakeAnswerKind::Cleared);
        CHECK(clearedFromEmpty.kind == BakeAnswerKind::Cleared);
        CHECK(clearedFromAbsent.kind == clearedFromEmpty.kind);
        CHECK(classifyBakeAnswer(std::optional<std::string>("rsbakeout9 whatever"), sent)
                  .kind == BakeAnswerKind::Undecodable);

        // No non-Answered kind may carry an outcome the caller could read as a landing.
        CHECK(!classifyBakeAnswer(std::nullopt, sent).outcome.has_value());
        CHECK(!classifyBakeAnswer(std::optional<std::string>(encodeBakeRequest(sent)), sent)
                   .outcome.has_value());
        CHECK(!classifyBakeAnswer(std::optional<std::string>("garbage"), sent)
                   .outcome.has_value());
    }

    // --- answeredOutcome: the guarded read, not switch exhaustiveness alone -------------
    {
        BakeRequest sent;
        sent.instanceGuid = "abcd";
        sent.generation = 42;

        BakeOutcome landed;
        landed.status = BakeStatus::Ok;
        landed.sampleId = "bake-1";
        landed.generation = sent.generation;

        // The one state that may yield a non-null pointer, and it points at the SAME
        // outcome classifyBakeAnswer decoded.
        const BakeAnswer answered =
            classifyBakeAnswer(std::optional<std::string>(encodeBakeOutcome(landed)), sent);
        const BakeOutcome* ptr = answeredOutcome(answered);
        CHECK(ptr != nullptr);
        CHECK(ptr->sampleId == "bake-1");

        // Every other real classification nulls out.
        CHECK(answeredOutcome(classifyBakeAnswer(std::nullopt, sent)) == nullptr);
        CHECK(answeredOutcome(classifyBakeAnswer(
                  std::optional<std::string>(encodeBakeRequest(sent)), sent)) == nullptr);
        CHECK(answeredOutcome(classifyBakeAnswer(std::optional<std::string>("garbage"), sent))
                  == nullptr);
        BakeOutcome foreign = landed;
        foreign.generation = sent.generation + 1;
        CHECK(answeredOutcome(classifyBakeAnswer(
                  std::optional<std::string>(encodeBakeOutcome(foreign)), sent)) == nullptr);

        // The defensive arm: a hand-built Answered with no outcome set (what a future
        // BakeAnswerKind enumerator falling through an un-updated switch would look like
        // to this accessor) fails closed rather than being dereferenced.
        BakeAnswer malformed;
        malformed.kind = BakeAnswerKind::Answered;
        CHECK(!malformed.outcome.has_value());
        CHECK(answeredOutcome(malformed) == nullptr);
    }

    // --- kMaxRequestAgeSeconds: a regression pin on the bound itself --------------------
    // The bound's meaning depends on the instrument stamping `generation` immediately
    // before publishing (AFTER staging the WAV) -- a shell-side ordering fix this pure
    // suite cannot observe directly. Pinning the literal at least catches a silent
    // widen/narrow of the budget itself.
    CHECK(kMaxRequestAgeSeconds == 30);

    // --- The extension's per-key verdict over the open tabs -----------------------------
    {
        const std::int64_t now = 1893456000;
        // The session has polled a project and that project is REAPER's active tab — the
        // steady state a project opened from disk reaches on the next timer tick.
        const BakeScanContext loadedAndActive{true};

        BakeScanKey own{true, true, now, true};
        CHECK(classifyBakeScan(loadedAndActive, own, now) == BakeScanVerdict::Land);

        // A request found in a tab the extension has NOT loaded — the multi-tab case. It is
        // REFUSED, which is an answer the asking instance can read; it is never landed into
        // the loaded tab's bank, and never dropped silently.
        BakeScanKey otherTab{true, true, now, false};
        CHECK(classifyBakeScan(loadedAndActive, otherTab, now) ==
              BakeScanVerdict::RefuseWrongProject);

        // The loaded tab is no longer the active one, so a persist would write elsewhere:
        // even the loaded tab's own request is refused rather than half-landed.
        CHECK(classifyBakeScan(BakeScanContext{false}, own, now) ==
              BakeScanVerdict::RefuseWrongProject);
        // The window before any project is loaded: matchesLoadedProject cannot be true for
        // ANY key here — a key can only match a project that is loaded — so this is the
        // shell-reachable stand-in for "no book yet", not `own` (which the shell could
        // never actually pair with an unloaded session).
        CHECK(classifyBakeScan(BakeScanContext{false}, BakeScanKey{true, true, now, false},
                               now) == BakeScanVerdict::RefuseWrongProject);

        // Stale in EITHER direction (a clock that moved backwards counts too).
        BakeScanKey old{true, true, now - kMaxRequestAgeSeconds - 1, true};
        BakeScanKey future{true, true, now + kMaxRequestAgeSeconds + 1, true};
        CHECK(classifyBakeScan(loadedAndActive, old, now) == BakeScanVerdict::ClearStale);
        CHECK(classifyBakeScan(loadedAndActive, future, now) == BakeScanVerdict::ClearStale);
        // Exactly at the bound is still landable — the ceiling is inclusive.
        BakeScanKey atBound{true, true, now - kMaxRequestAgeSeconds, true};
        CHECK(classifyBakeScan(loadedAndActive, atBound, now) == BakeScanVerdict::Land);
        // Staleness outranks the project check: a request nobody can read is cleared, not
        // answered, wherever it sits.
        BakeScanKey oldElsewhere{true, true, now - kMaxRequestAgeSeconds - 1, false};
        CHECK(classifyBakeScan(loadedAndActive, oldElsewhere, now) ==
              BakeScanVerdict::ClearStale);

        // A value that is not a request (an outcome the writer has not collected) is left
        // alone — answering it would clobber an answer in flight.
        BakeScanKey notARequest{true, false, 0, true};
        CHECK(classifyBakeScan(loadedAndActive, notARequest, now) ==
              BakeScanVerdict::IgnoreNotARequest);
        CHECK(classifyBakeScan(BakeScanContext{false}, notARequest, now) ==
              BakeScanVerdict::IgnoreNotARequest);

        // A key the enumerator listed but the reader could not return whole is its OWN
        // verdict, not folded into the one above: the extension failing to read a request
        // that may well be there is a different fault from a key holding something else,
        // and neither is visible from the instrument's end.
        BakeScanKey unreadable{false, false, 0, true};
        CHECK(classifyBakeScan(loadedAndActive, unreadable, now) ==
              BakeScanVerdict::IgnoreUnreadable);
        CHECK(classifyBakeScan(BakeScanContext{false}, unreadable, now) ==
              BakeScanVerdict::IgnoreUnreadable);
        // Unreadable outranks even staleness — an age read off a request that was never
        // decoded would be a made-up number.
        BakeScanKey unreadableOld{false, false, now - kMaxRequestAgeSeconds - 1, true};
        CHECK(classifyBakeScan(loadedAndActive, unreadableOld, now) ==
              BakeScanVerdict::IgnoreUnreadable);
    }

    // --- The scan summary: silent ONLY when nothing went unanswered ---------------------
    // `answered` is pass-wide and counts a QUEUED write, so gating on it alone let two real
    // faults print nothing: a pass that answered another key while skipping ours, and a
    // pass whose own answer write was rejected. Those two are what the gate below pins.
    {
        BakeScanTally clean;
        clean.tabsScanned = 1;
        clean.keysFound = 1;
        clean.activeTabKeys = 1;
        clean.answered = 1;
        CHECK(describeBakeScan(clean).empty());

        // THE regression: some OTHER key was answered while ours was skipped. Under the old
        // `answered > 0` gate this printed nothing at all, and the instrument then told the
        // user a silent console proved the action never ran.
        for (int BakeScanTally::*skip :
             {&BakeScanTally::unreadable, &BakeScanTally::notARequest,
              &BakeScanTally::staleCleared}) {
            BakeScanTally mixed = clean;
            mixed.keysFound = 2;
            mixed.*skip = 1;
            const std::string said = describeBakeScan(mixed);
            CHECK(!said.empty());
            CHECK(said.find("left 1 unanswered") != std::string::npos);
        }

        // THE other regression: our answer was queued (so `answered` counted it) and the
        // SetProjExtState write was rejected. Nothing else went wrong, and it must still
        // speak.
        BakeScanTally rejected = clean;
        rejected.writeFailed = 1;
        const std::string wrote = describeBakeScan(rejected);
        CHECK(!wrote.empty());
        CHECK(wrote.find("1 answer could not be written back") != std::string::npos);
        CHECK(wrote.find("sees no answer at all") != std::string::npos);
        // Plural agrees, and the count is the tally's own, not a hardcoded 1.
        BakeScanTally rejectedTwo = clean;
        rejectedTwo.answered = 2;
        rejectedTwo.writeFailed = 2;
        CHECK(describeBakeScan(rejectedTwo).find("2 answers could not be written back") !=
              std::string::npos);

        // An answer whose write-and-check THREW is neither landed nor known-failed, and it
        // must break the silence too: the whole point of the gate is that no fault in the
        // pass leaves the asking instance with a no-answer and an empty console.
        BakeScanTally unproven = clean;
        unproven.writeUnproven = 1;
        const std::string unsure = describeBakeScan(unproven);
        CHECK(!unsure.empty());
        CHECK(unsure.find("1 answer could not be checked after writing") !=
              std::string::npos);
        CHECK(unsure.find("is unknown") != std::string::npos);
        // ...and it is NOT reported as a rejection, which is a different claim.
        CHECK(unsure.find("could not be written back") == std::string::npos);
        BakeScanTally unprovenTwo = clean;
        unprovenTwo.answered = 2;
        unprovenTwo.writeUnproven = 2;
        CHECK(describeBakeScan(unprovenTwo).find("2 answers could not be checked") !=
              std::string::npos);

        // Nothing found at all: the observation is stated and the candidates listed, with
        // no winner picked — the enumeration's visibility of a key set but not yet saved is
        // undocumented, so "not the same project's ext state" may not name it.
        BakeScanTally nothing;
        nothing.tabsScanned = 2;
        const std::string none = describeBakeScan(nothing);
        CHECK(!none.empty());
        CHECK(none.find("2 project tabs") != std::string::npos);
        CHECK(none.find("No rsbake_ key was visible") != std::string::npos);
        CHECK(none.find("this pass cannot tell which apart") != std::string::npos);
        CHECK(none.find("are not reading the same project") == std::string::npos);
        CHECK(none.back() == '\n');

        // One tab is singular, not "1 project tabs".
        BakeScanTally oneTab;
        oneTab.tabsScanned = 1;
        CHECK(describeBakeScan(oneTab).find("1 project tab.") != std::string::npos);

        // Found but unreadable — the fact the old scan discarded.
        BakeScanTally unreadable;
        unreadable.tabsScanned = 1;
        unreadable.keysFound = 1;
        unreadable.activeTabKeys = 1;
        unreadable.unreadable = 1;
        const std::string unread = describeBakeScan(unreadable);
        CHECK(unread.find("1 could not be read back") != std::string::npos);
        CHECK(unread.find("held something other than a request") == std::string::npos);
        // keysFound counts EVERY rsbake_ key, not pending requests only — a label that
        // said otherwise produced "3 pending request keys ... 3 held something else".
        CHECK(unread.find("1 rsbake_ key") != std::string::npos);
        CHECK(unread.find("pending request key") == std::string::npos);

        // Found, readable, but not a request.
        BakeScanTally foreign;
        foreign.tabsScanned = 1;
        foreign.keysFound = 1;
        foreign.activeTabKeys = 1;
        foreign.notARequest = 1;
        const std::string other = describeBakeScan(foreign);
        CHECK(other.find("1 held something other than a request") != std::string::npos);
        CHECK(other.find("could not be read back") == std::string::npos);

        // Cleared as stale: an answer was never written, so this too must report. The
        // summary may NOT say the clears were written — whether each one took is a per-key
        // read-back, and only describeBakeKey has seen it.
        BakeScanTally stale;
        stale.tabsScanned = 1;
        stale.keysFound = 1;
        stale.activeTabKeys = 1;
        stale.staleCleared = 1;
        const std::string aged = describeBakeScan(stale);
        CHECK(aged.find("past the age bound") != std::string::npos);
        CHECK(aged.find("were cleared") == std::string::npos);

        // activeTabKeys is REAPER's ACTIVE tab and nothing more. It cannot discriminate the
        // multi-tab mis-target — a genuine background-tab request classifies as
        // RefuseWrongProject, which is an ANSWER — so no sentence may claim it does, and
        // none may call it "the tab this bake was fired against" (whether REAPER makes the
        // invoking instance's project current is DAW-unverified).
        BakeScanTally elsewhere;
        elsewhere.tabsScanned = 2;
        elsewhere.keysFound = 1;
        elsewhere.activeTabKeys = 0;
        elsewhere.notARequest = 1;
        const std::string away = describeBakeScan(elsewhere);
        CHECK(away.find("0 of them in REAPER's active tab") != std::string::npos);
        CHECK(away.find("fired against") == std::string::npos);
    }

    // --- The per-key line: the only thing that names WHICH key ---------------------------
    // The tally counts; it cannot say whose key was skipped. This line is printed for every
    // enumerated key, answered or not, and the key carries the asking instance's guid — so
    // it is what lets one instance find its own verdict in a multi-instance session.
    {
        const std::string key = "rsbake_0123abcd";

        BakeKeyOutcome landed;
        landed.verdict = BakeScanVerdict::Land;
        landed.landing = BakeLanding::Banked;
        landed.proof = BakeWriteProof::Confirmed;
        landed.detail = "added as a distinct capture";
        const std::string ok = describeBakeKey(key, landed);
        CHECK(ok.find(key) != std::string::npos);
        CHECK(ok.find("landed into the bank") != std::string::npos);
        CHECK(ok.find("The answer was written back") != std::string::npos);
        CHECK(ok.back() == '\n');
        // The outcome's own reason rides the SAME line: one self-contained line per key,
        // rather than a keyed verdict and an unkeyed reason the reader has to pair up.
        CHECK(ok.find("(added as a distinct capture)") != std::string::npos);
        // The sentence names the OBSERVATION behind the flag (see BakeLanding) and stops
        // there — the write was ISSUED into a saved project. It may not claim REAPER took
        // the value, nor that the .rpp on disk already holds the entry.
        CHECK(ok.find("issued its bank write into the saved project") != std::string::npos);
        CHECK(ok.find("read back") == std::string::npos);
        CHECK(ok.find(".rpp") == std::string::npos);
        CHECK(ok.find("carries it") == std::string::npos);

        // A landing whose answer write was REJECTED — the state the instrument reads as
        // Unanswered. This line is the only place it is ever named.
        BakeKeyOutcome lost = landed;
        lost.proof = BakeWriteProof::Rejected;
        const std::string dropped = describeBakeKey(key, lost);
        CHECK(dropped.find("landed into the bank") != std::string::npos);
        CHECK(dropped.find("could NOT be written back") != std::string::npos);
        CHECK(dropped.find("will report no answer") != std::string::npos);

        // The write-and-check itself failing is a THIRD state, not a rejection: claiming
        // the answer did not land would be a fact this pass never observed.
        BakeKeyOutcome unchecked = landed;
        unchecked.proof = BakeWriteProof::Unknown;
        const std::string unsure = describeBakeKey(key, unchecked);
        CHECK(unsure.find("is unknown") != std::string::npos);
        CHECK(unsure.find("could NOT be written back") == std::string::npos);
        CHECK(unsure.find("The answer was written back") == std::string::npos);
        CHECK(unsure != ok && unsure != dropped);

        // The four landing states are four different lines: a bake the project never
        // persisted, and one that threw mid-write, may not read as a clean success or as a
        // clean refusal.
        BakeKeyOutcome unpersisted = landed;
        unpersisted.landing = BakeLanding::Unpersisted;
        const std::string memoryOnly = describeBakeKey(key, unpersisted);
        CHECK(memoryOnly.find("IN MEMORY ONLY") != std::string::npos);
        CHECK(memoryOnly.find("persist did not report success") != std::string::npos);
        CHECK(memoryOnly != ok);
        // It may NOT claim what the project's saved state holds: the persist may never
        // have run at all, and a dedup hit's target may have been in the project since
        // long before this pass.
        CHECK(memoryOnly.find("does not carry it") == std::string::npos);

        BakeKeyOutcome partial = landed;
        partial.landing = BakeLanding::Partial;
        partial.detail = "the landing failed while writing: bad allocation";
        const std::string half = describeBakeKey(key, partial);
        CHECK(half.find("after it had begun writing") != std::string::npos);
        CHECK(half.find("may have left a file") != std::string::npos);
        CHECK(half.find("the landing was refused") == std::string::npos);

        BakeKeyOutcome bankRefused;
        bankRefused.verdict = BakeScanVerdict::Land;
        bankRefused.landing = BakeLanding::Refused;
        bankRefused.proof = BakeWriteProof::Confirmed;
        bankRefused.detail = "the bank refused the new capture";
        const std::string refusedLine = describeBakeKey(key, bankRefused);
        CHECK(refusedLine.find("the landing was refused") != std::string::npos);
        // The refusal's REASON is what the keyed line used to lack entirely.
        CHECK(refusedLine.find("(the bank refused the new capture)") != std::string::npos);

        BakeKeyOutcome wrongTab;
        wrongTab.verdict = BakeScanVerdict::RefuseWrongProject;
        wrongTab.proof = BakeWriteProof::Confirmed;
        wrongTab.detail =
            "this bake's project tab is not the one the extension has loaded -- focus that "
            "tab and try again";
        const std::string refused = describeBakeKey(key, wrongTab);
        CHECK(refused.find("not the one the extension has loaded") != std::string::npos);
        CHECK(refused.find("focus that tab and try again") != std::string::npos);
        CHECK(refused.find("The answer was written back") != std::string::npos);

        // A clear is a write too: it may be claimed only where it was read back.
        BakeKeyOutcome cleared;
        cleared.verdict = BakeScanVerdict::ClearStale;
        cleared.proof = BakeWriteProof::Confirmed;
        CHECK(describeBakeKey(key, cleared).find("cleared unanswered") != std::string::npos);
        BakeKeyOutcome clearLost = cleared;
        clearLost.proof = BakeWriteProof::Rejected;
        const std::string stuck = describeBakeKey(key, clearLost);
        CHECK(stuck.find("the clear did NOT take") != std::string::npos);
        CHECK(stuck.find("next pass will see it again") != std::string::npos);
        CHECK(stuck.find("it was cleared") == std::string::npos);
        // Rejected also covers a clear that was never issued, so the line may not describe
        // a read that did not happen.
        CHECK(stuck.find("read back") == std::string::npos);
        // And the clear's third state, same as an answer's: unchecked is not disproven.
        BakeKeyOutcome clearUnchecked = cleared;
        clearUnchecked.proof = BakeWriteProof::Unknown;
        const std::string maybeCleared = describeBakeKey(key, clearUnchecked);
        CHECK(maybeCleared.find("whether the clear took is unknown") != std::string::npos);
        CHECK(maybeCleared.find("it was cleared unanswered") == std::string::npos);
        CHECK(maybeCleared.find("could NOT be read back") == std::string::npos);

        BakeKeyOutcome notRequest;
        notRequest.verdict = BakeScanVerdict::IgnoreNotARequest;
        CHECK(describeBakeKey(key, notRequest).find("other than a pending request") !=
              std::string::npos);

        // Absent and Overflow both yield IgnoreUnreadable, but they are different faults to
        // go fix, so the line keeps them apart where the verdict cannot.
        BakeKeyOutcome empty;
        empty.verdict = BakeScanVerdict::IgnoreUnreadable;
        BakeKeyOutcome huge = empty;
        huge.oversized = true;
        const std::string emptyLine = describeBakeKey(key, empty);
        const std::string hugeLine = describeBakeKey(key, huge);
        CHECK(emptyLine.find("read back empty") != std::string::npos);
        CHECK(hugeLine.find("too large to read back whole") != std::string::npos);
        CHECK(emptyLine != hugeLine);

        // Every line names its own key, so two instances' lines are never confusable.
        CHECK(describeBakeKey("rsbake_ffff0000", landed).find("rsbake_ffff0000") !=
              std::string::npos);
        CHECK(describeBakeKey("rsbake_ffff0000", landed) != ok);

        // A LANDING this build has no word for must not be reported as a VERDICT gap: the
        // two are different enums and send a reader to two different places.
        BakeKeyOutcome futureLanding = landed;
        futureLanding.landing = static_cast<BakeLanding>(99);
        const std::string unnamed = describeBakeKey(key, futureLanding);
        CHECK(unnamed.find("a landing state this build has no word for") !=
              std::string::npos);
        CHECK(unnamed.find("a verdict this build has no word for") == std::string::npos);
        // A VERDICT gap still reports as one.
        BakeKeyOutcome futureVerdict;
        futureVerdict.verdict = static_cast<BakeScanVerdict>(99);
        CHECK(describeBakeKey(key, futureVerdict).find(
                  "a verdict this build has no word for") != std::string::npos);
    }

    // --- The persist/upgrade state machine: the ONE route to Banked -----------------------
    // The shell assigns every Land verdict's landing without knowing whether the pass's
    // persist ran, then threads the flag through here. The bug this pins: a dedup hit used
    // to be answered Banked directly, on the grounds that it changed nothing — false
    // exactly when the entry it deduped against was one the SAME pass had just added and
    // then failed to persist, which answers Ok for an entry the project does not carry.
    //
    // Both limbs are live in the shell, not just in this table: it assigns the landing
    // inside a guarded scan and sets its `persisted` local only in the block after it, so a
    // throw between the two reaches Unpersisted with a real landing behind it.
    {
        // A dedup hit and a fresh add are INDISTINGUISHABLE here, by construction: the shell
        // assigns Unpersisted to both, so both need the same observation to be promoted.
        CHECK(bakeLandingAfterPersist(BakeLanding::Unpersisted, true) == BakeLanding::Banked);
        CHECK(bakeLandingAfterPersist(BakeLanding::Unpersisted, false) ==
              BakeLanding::Unpersisted);

        // Exhaustive over both arguments: (Unpersisted, persisted) is the ONLY pair that
        // produces Banked from anything else, and nothing else is altered at all — so a
        // persist that ran cannot launder a refusal or a half-written landing into a
        // success, and a persist that did not cannot demote one.
        for (const BakeLanding from : {BakeLanding::Refused, BakeLanding::Partial,
                                       BakeLanding::Unpersisted, BakeLanding::Banked}) {
            for (const bool persisted : {false, true}) {
                const BakeLanding to = bakeLandingAfterPersist(from, persisted);
                const bool promotes = from == BakeLanding::Unpersisted && persisted;
                CHECK(to == (promotes ? BakeLanding::Banked : from));
                CHECK((to == BakeLanding::Banked) ==
                      (promotes || from == BakeLanding::Banked));
            }
        }

        // A dedup hit driven end to end at both persist outcomes: what the instrument is
        // told and what the console prints both follow the flag, and they never disagree.
        for (const bool persisted : {true, false}) {
            BakeKeyOutcome deduped;
            deduped.verdict = BakeScanVerdict::Land;
            deduped.landing = bakeLandingAfterPersist(BakeLanding::Unpersisted, persisted);

            // A landing the pass could not persist is re-encoded as a FAILURE, not the Ok
            // it was headed for, so no instance adopts an entry a reload may not find.
            BakeOutcome answer;
            answer.status = BakeStatus::Ok;
            answer.sampleId = "bake-1";
            answer.generation = 1893456000;
            if (deduped.landing == BakeLanding::Unpersisted) {
                answer.status = BakeStatus::Failed;
                answer.message = kUnpersistedAnswer;
            }
            const auto back = decodeBakeOutcome(encodeBakeOutcome(answer));
            CHECK(back.has_value());
            CHECK(back.has_value() &&
                  back->status == (persisted ? BakeStatus::Ok : BakeStatus::Failed));

            // The console line and the wire answer never disagree about the same key.
            deduped.proof = BakeWriteProof::Confirmed;
            const std::string line = describeBakeKey("rsbake_0123abcd", deduped);
            CHECK((line.find("IN MEMORY ONLY") != std::string::npos) == !persisted);
        }
    }

    // --- A rejected write is REACHABLE, and it is what the tally and the line come from ---
    // Judging this by SetProjExtState's return made `writeFailed` unproducible on the
    // landing path and its sentence dead code (extStateWriteLanded owns why). The verdict is
    // the read-back, which a store that drops the write does produce. The store is modelled
    // here; the two shells that bind the verdict make these same two calls, to
    // SetProjExtState and the grow-loop read.
    {
        struct FakeKeyStore {
            std::map<std::string, std::string> values;
            bool dropWrites = false;

            void write(const std::string& k, const std::string& v) {
                if (dropWrites) return;                 // REAPER rejected it
                if (v.empty()) values.erase(k);         // SetProjExtState("") deletes
                else values[k] = v;
            }
            std::optional<std::string> read(const std::string& k) const {
                const auto it = values.find(k);
                return it == values.end() ? std::nullopt
                                          : std::optional<std::string>(it->second);
            }
        };

        const std::string key = "rsbake_0123abcd";
        BakeRequest pending;
        pending.instanceGuid = "0123abcd";
        pending.generation = 1893456000;
        BakeOutcome landedOk;
        landedOk.status = BakeStatus::Ok;
        landedOk.sampleId = "bake-1";
        landedOk.message = "added as a distinct capture";
        landedOk.generation = pending.generation;
        const std::string answer = encodeBakeOutcome(landedOk);

        // Rejected: the key still holds the request the instrument left there.
        FakeKeyStore rejecting;
        rejecting.values[key] = encodeBakeRequest(pending);
        rejecting.dropWrites = true;
        rejecting.write(key, answer);
        CHECK(!extStateWriteLanded(answer, rejecting.read(key)));
        // ...which is exactly what the ASKING end reads as a no-answer. The two classifiers
        // agree about this one state, which is why the extension has to name it.
        CHECK(classifyBakeAnswer(rejecting.read(key), pending).kind ==
              BakeAnswerKind::Unanswered);

        // The shell's write loop from that verdict through to both strings it prints.
        BakeScanTally tally;
        tally.tabsScanned = 1;
        tally.keysFound = 1;
        tally.activeTabKeys = 1;
        tally.answered = 1;
        BakeKeyOutcome report;
        report.verdict = BakeScanVerdict::Land;
        // The shell reaches Banked only through the upgrade — never by assignment.
        report.landing = bakeLandingAfterPersist(BakeLanding::Unpersisted, true);
        report.detail = landedOk.message;
        report.proof = extStateWriteLanded(answer, rejecting.read(key))
                           ? BakeWriteProof::Confirmed
                           : BakeWriteProof::Rejected;
        if (report.proof == BakeWriteProof::Rejected) ++tally.writeFailed;
        CHECK(tally.writeFailed == 1);
        CHECK(describeBakeKey(key, report).find("could NOT be written back") !=
              std::string::npos);
        CHECK(describeBakeScan(tally).find("1 answer could not be written back") !=
              std::string::npos);

        // The same store ACCEPTING the write: the verdict flips, the tally stays at zero and
        // the summary goes silent — so neither answer above is a constant.
        FakeKeyStore accepting;
        accepting.values[key] = encodeBakeRequest(pending);
        accepting.write(key, answer);
        CHECK(extStateWriteLanded(answer, accepting.read(key)));
        CHECK(classifyBakeAnswer(accepting.read(key), pending).kind ==
              BakeAnswerKind::Answered);
        BakeScanTally clean = tally;
        clean.writeFailed = 0;
        CHECK(describeBakeScan(clean).empty());

        // A CLEAR is proven the other way round: it lands as an ABSENT key, and a dropped
        // clear leaves the stale request standing for the next pass to find.
        FakeKeyStore clearing;
        clearing.values[key] = encodeBakeRequest(pending);
        clearing.write(key, "");
        CHECK(extStateWriteLanded("", clearing.read(key)));
        FakeKeyStore clearDropped;
        clearDropped.values[key] = encodeBakeRequest(pending);
        clearDropped.dropWrites = true;
        clearDropped.write(key, "");
        CHECK(!extStateWriteLanded("", clearDropped.read(key)));

        // Byte equality is the claim the line makes: a truncated or foreign value under the
        // key is not the answer we wrote, and an unreadable key proves nothing at all.
        CHECK(!extStateWriteLanded(answer, std::optional<std::string>(
                                           answer.substr(0, answer.size() - 1))));
        CHECK(!extStateWriteLanded(answer, std::optional<std::string>(
                                           encodeBakeRequest(pending))));
        CHECK(!extStateWriteLanded(answer, std::nullopt));
        CHECK(!extStateWriteLanded("", std::optional<std::string>("leftover")));
    }

    // --- Every outcome bake_land actually emits survives the key round trip -------------
    // The landing writes these and the instrument reads them back; a field the encoder and
    // the decoder disagreed about would strand exactly the bake that produced it.
    {
        BakeOutcome added;
        added.status = BakeStatus::Ok;
        added.sampleId = "bake-1893456000-a1b2c3d4-kick_1893456000.wav";
        added.relativePath = "reasampler_bank/kick_1893456000-a1b2c3d4.wav";
        added.displayName = "Kick 2";
        added.rootNote = 36;
        added.channelCount = 2;
        added.replaced = false;
        added.message = "added as a distinct capture";
        added.generation = 1893456000;

        BakeOutcome replaced = added;
        replaced.replaced = true;
        replaced.displayName = "Kick";
        replaced.message = "replaced the bank entry";

        BakeOutcome deduped = added;
        deduped.message = "identical to an existing capture -- pointed at it";

        BakeOutcome noProject;
        noProject.status = BakeStatus::NoProject;
        noProject.message = "no saved project, so the bank has no location";
        noProject.generation = added.generation;

        BakeOutcome stagedMissing = noProject;
        stagedMissing.status = BakeStatus::StagedMissing;
        stagedMissing.message = "the staged render is not a usable WAV";

        BakeOutcome noSource = noProject;
        noSource.status = BakeStatus::NoSource;
        noSource.message = "the resampled capture is not in any bank";

        BakeOutcome indexRejected = noProject;
        indexRejected.status = BakeStatus::IndexRejected;
        indexRejected.message = "the bank refused the new capture";

        BakeOutcome wrongProject = noProject;
        wrongProject.status = BakeStatus::WrongProject;
        wrongProject.message =
            "this bake's project tab is not the one the extension has "
            "loaded -- focus that tab and try again";

        BakeOutcome writeFailed = noProject;
        writeFailed.status = BakeStatus::Failed;
        writeFailed.message = "could not write the bake into the bank folder";

        // A landing the project would not persist is answered as a FAILURE, not as the Ok
        // it was on its way to being: the entry exists in memory only, so an instance that
        // adopted it would be pointing at something no reload will have.
        BakeOutcome unpersisted = noProject;
        unpersisted.status = BakeStatus::Failed;
        unpersisted.message = kUnpersistedAnswer;

        BakeOutcome partial = noProject;
        partial.status = BakeStatus::Failed;
        partial.message = "the landing failed while writing: bad allocation";

        for (const BakeOutcome& emitted :
             {added, replaced, deduped, noProject, stagedMissing, noSource, indexRejected,
              wrongProject, writeFailed, unpersisted, partial}) {
            const auto back = decodeBakeOutcome(encodeBakeOutcome(emitted));
            CHECK(back.has_value());
            CHECK(back.has_value() && *back == emitted);
            // Field for field as well: operator== is hand-written, so a field missing from
            // BOTH the codec and the comparison would pass the aggregate check above.
            CHECK(back.has_value() && back->status == emitted.status &&
                  back->sampleId == emitted.sampleId &&
                  back->relativePath == emitted.relativePath &&
                  back->displayName == emitted.displayName &&
                  back->rootNote == emitted.rootNote &&
                  back->channelCount == emitted.channelCount &&
                  back->replaced == emitted.replaced &&
                  back->message == emitted.message &&
                  back->generation == emitted.generation);
        }
    }

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