// processor_reload.cpp — ReaSamplerProcessor's off-audio-thread instrument lifecycle:
// reloadInstrument (self-contained refs resolve + WAV decode + SampleData build), the
// safety-critical publishBuiltLocked drain-slot swap, the voice-param light rebuild,
// idle-drain retirement, the pre-v10 legacy-lift gate, the bank-sync poll, and the
// usage publish. Nothing here runs on the audio thread — process() only touches the
// atomics this family publishes; the atomic-pointer-swap pattern gains no virtual seam.

#include "shell/instrument/reasampler_processor.h"

#include <algorithm>
#include <cstdint>
#include <cstdio>
#include <mutex>
#include <optional>
#include <random>
#include <utility>
#include <vector>

#include "core/capture/capture_paths.h"       // resolveBankFile (shared path resolution)
#include "core/capture/wav_codec.h"            // parseWavLayout, extractFloatFrames (shared WAV parse)
#include "core/instrument/map/bank_sync.h"    // pure decisions: parseBankGeneration, consumeDecision
#include "core/instrument/map/sample_map.h"   // refs resolve, buildSampleData (self-contained)
#include "core/util/file_bytes.h"             // shared whole-file loader
#include "core/wire/assignment_request.h"     // decodeAssignmentRequest (request wire parse)
#include "core/wire/sample_usage.h"           // usage publish plan + wire (prune-protection seam)
#include "ext_keys.h"                         // kProjExtBanksKey / kProjExtBankGenKey / kProjExtAssignKey

namespace reasampler::vst {

using namespace instrument::map;   // resolution + bank-sync vocabulary this TU drives
using namespace reasampler::wire;  // assignment_request + sample_usage wire records
using capture::extractFloatFrames;
using capture::parseWavLayout;
using capture::resolveBankFile;
using capture::WavLayout;
using util::readFileBytes;

namespace {

// Preserve-mode voice cap: a Preserve voice runs a per-voice OLA pitch shifter and is
// materially heavier than a Varispeed voice, so a note-on past the cap is dropped rather
// than glitching. 8 is conservative pending DAW profiling; fixed regardless of the
// user-set voiceCount (1..32) so raising polyphony never multiplies shifter CPU past budget.
constexpr std::size_t kPreserveVoiceCap = 8;

// Mints a fresh publish identity (32 lowercase hex chars) for either the persisted
// instanceGuid_ or the in-memory usageNonce_. Uniqueness, not cryptographic strength, is
// the requirement — planUsagePublish resolves a collision fail-safe anyway.
std::string mintUsageInstanceGuid() {
    std::random_device rd;
    std::mt19937_64 gen((static_cast<std::uint64_t>(rd()) << 32) ^ rd());
    std::uniform_int_distribution<std::uint64_t> dist;
    char buf[33] = {0};
    std::snprintf(buf, sizeof(buf), "%016llx%016llx",
                  static_cast<unsigned long long>(dist(gen)),
                  static_cast<unsigned long long>(dist(gen)));
    return std::string(buf);
}

// Resolves a project-relative WAV path, reads + decodes it (file I/O, off-thread only),
// and applies the cross-mode channel policy for `mode` (mono downmix; stereo -> dual-mono
// for a mono source, L/R for a stereo source — see decodeChannels). Returns nullopt on any
// resolve/read/decode failure — the caller plays silence.
std::optional<DecodedPcm> decodeRelative(const std::string& projectDir,
                                         const std::string& relativePath,
                                         ChannelMode mode) {
    const std::string abs = resolveBankFile(projectDir, relativePath);
    if (abs.empty()) return std::nullopt;
    const std::vector<std::uint8_t> bytes = readFileBytes(abs);
    const WavLayout layout = parseWavLayout(bytes);
    if (!layout.valid) return std::nullopt;
    std::vector<AudioSample> interleaved =
        extractFloatFrames(bytes, layout, 0, layout.frameCount());
    DecodedPcm out = decodeChannels(interleaved, layout.channelCount, mode,
                                    static_cast<int>(layout.sampleRate));
    if (out.monoFrames.empty()) return std::nullopt;
    return out;
}

// The ONE decode + build the reload and the bake snapshot share, so a baked render can
// never be built from a different fold than the one the user is hearing.
std::optional<SampleData> buildFromRef(const SelectedSample& sel,
                                       const InstrumentParams& params,
                                       const std::string& projectDir, ChannelMode mode) {
    std::optional<DecodedPcm> pcm = decodeRelative(projectDir, sel.relativePath, mode);
    if (!pcm) return std::nullopt;
    SampleData sample = buildSampleData(resolveCapture(sel, params), std::move(*pcm));
    if (!sample.playable()) return std::nullopt;
    return sample;
}

}  // namespace

std::string ReaSamplerProcessor::reloadInstrument() {
    // OFF THE AUDIO THREAD. Serializes concurrent reloads (editor click + setState) so the
    // retired-slot free is single-writer; never taken on the audio thread.
    std::lock_guard<std::mutex> lock(reloadMutex_);

    // 1. Self-contained resolution: the instance-owned refs table is the source of truth.
    //    The live bank blob, when readable, is folded in first (refreshRefsFromBank — the
    //    browser's copy-the-ref-in + recapture-sync mechanism), but its absence changes
    //    nothing below — a project restored before PROJEXTSTATE parses (or with the
    //    extension absent) resolves + plays from the persisted refs.
    const std::string selId = selectedSampleId();
    const InstrumentParams params = instrumentParams();
    const std::vector<std::string> ids = referencedSampleIds(selId);
    SampleRefs refs;
    {
        std::optional<std::string> banksJson =
            bridge_.readReasamplerExtState(kProjExtBanksKey);
        std::lock_guard<std::mutex> rl(refsMutex_);
        if (banksJson) refreshRefsFromBank(sampleRefs_, *banksJson, ids);
        // The LOAD path never prunes the owned table: dropping entries on a transient bank
        // miss could destroy the owned intrinsics of the previous selection — the ONE copy
        // that survives with the extension absent. Hygiene lives at the PERSIST boundary
        // (getState filters via retainRefs to what the instance plays).
        refs = sampleRefs_;  // snapshot for the decode below (outside the refs lock)
    }
    const std::string projectDir = bridge_.activeProjectDir();
    // Governs how the WAV decodes (mono downmix vs 2-channel); auto-defaulted from the
    // capture's own channel count below, before the decode.
    ChannelMode mode = channelMode();

    std::string resolvedId;
    std::unique_ptr<LoadedInstrument> built;
    SampleData sample;
    bool havePlayable = false;

    // 2. Resolve + decode the ONE loaded capture, which plays across the whole keyboard
    //    repitched from its effective root. No first-sample fallback: an empty selection
    //    (or one with no ref) resolves to nothing, so an un-picked instrument stays silent
    //    rather than auto-playing sample #1. A missing/unreadable WAV is the same defined
    //    no-play — no crash, no retry loop.
    if (const SelectedSample* sel = findRef(refs, selId)) {
        // Auto-default: channelModeFor computes the mode from the loaded capture's channel
        // count — 1 for an ingested mono file or a capture whose channels came out
        // bit-identical and collapsed, 2 otherwise. An unknown count (0) or an explicit
        // user choice keeps the mode.
        {
            std::lock_guard<std::mutex> cm(channelModeMutex_);
            channelMode_ = channelModeFor(sel->channelCount, channelMode_,
                                           channelModeExplicit_);
            mode = channelMode_;
        }
        if (std::optional<SampleData> decoded =
                buildFromRef(*sel, params, projectDir, mode)) {
            sample = std::move(*decoded);
            havePlayable = true;
            // Point the built snapshot at the instance's ONE engine-facing block and seed the
            // MODEL block from the very PlayParams the voices latch, so an untouched knob folds
            // to the same frames the build resolved and a note-on with a live block sounds
            // identical to one without. process() merges the seed into automationLive_ at the
            // top of the first block after this, which is where the swap below becomes visible
            // too — so the new snapshot's first note reads it.
            sample.live = &automationLive_;
            // BEFORE the publish: the publish is what makes the audio thread re-merge, and the
            // merge resolves every held automation stage time against this rate. Stored after,
            // a merge in the window between them would resolve them against the previous
            // capture's rate — or, on the first-ever build, against 0, where secondsToFrames
            // collapses every automated envelope stage to zero frames.
            builtSampleRate_.store(sample.sampleRate, std::memory_order_relaxed);
            {
                // reloadMutex_ (held for this whole function) nests livePublishMutex_ here;
                // publishLiveParams never holds reloadMutex_, so this is the only nesting.
                std::lock_guard<std::mutex> lp(livePublishMutex_);
                liveParams_.publish(instrument::engine::foldLive(sample.play, sample.keyTrack));
            }
            resolvedId = selId;  // the concrete pick that resolved
        }
    }

    if (havePlayable) built = buildInstrumentLocked(std::move(sample));

    // 3. Publish: atomically install the new instrument via the drain-slot swap (see the
    //    header). A null `built` (no ref / unreadable WAV) installs silence while any
    //    displaced tails still ring out via the drain.
    publishBuiltLocked(std::move(built));

    // 4. Publish this instance's held captures so the extension's prune can never reclaim
    //    them. Regardless of decode success: the holds are the refs the instance retains
    //    (its play-set), not what decoded — a transiently unreadable WAV stays protected.
    publishUsage(refs, ids);
    return resolvedId;
}

std::string ReaSamplerProcessor::usageInstanceGuid() {
    std::lock_guard<std::mutex> lock(usageMutex_);
    if (instanceGuid_.empty()) instanceGuid_ = mintUsageInstanceGuid();
    return instanceGuid_;
}

std::optional<SampleData> ReaSamplerProcessor::bakeSnapshot() {
    const std::string selId = selectedSampleId();
    if (selId.empty()) return std::nullopt;
    const InstrumentParams params = instrumentParams();
    SampleRefs refs;
    {
        std::lock_guard<std::mutex> rl(refsMutex_);
        refs = sampleRefs_;
    }
    const SelectedSample* sel = findRef(refs, selId);
    if (!sel) return std::nullopt;
    // No live block is attached: the bake renders the stored parameter set, which is what
    // every knob has already written, rather than whatever the audio thread is observing.
    return buildFromRef(*sel, params, bridge_.activeProjectDir(), channelMode());
}

void ReaSamplerProcessor::adoptBakedCapture(const SampleRefEntry& entry,
                                            const InstrumentParams& reset,
                                            double masterGainLinear) {
    {
        std::lock_guard<std::mutex> rl(refsMutex_);
        bool replaced = false;
        for (SampleRefEntry& existing : sampleRefs_) {
            if (existing.sampleId == entry.sampleId) { existing = entry; replaced = true; break; }
        }
        if (!replaced) sampleRefs_.push_back(entry);
    }
    setSelectedSampleId(entry.sampleId);
    setInstrumentParams(reset);
    {
        // ARMED, not stored: publishBuiltLocked applies it inside the same locked section as
        // the pointer swap. Storing it here instead would put the reset gain under the OLD
        // capture for the whole bridge-read-plus-WAV-decode the reload below runs first.
        std::lock_guard<std::mutex> lock(reloadMutex_);
        gainAtNextPublish_ = masterGainLinear;
    }
    // ONE reload for the re-point and the reset together: it decodes the new file and
    // publishes the neutral parameters in the same swap.
    reloadInstrument();
    // The bake's reset may have flipped the limiter; delivering the restart here rather than
    // leaving it to the editor's tick is a LATENCY improvement, not a correctness one — the
    // bake chain only ever runs from that tick, so the arm would be drained on the next one
    // anyway. At the tail for the same reason setState's is (see there).
    flushLatencyRestart();
    // The reset's gain notification, armed under reloadMutex_ inside that reload.
    flushGainNotify();
}

void ReaSamplerProcessor::publishUsage(const SampleRefs& refs,
                                       const std::vector<std::string>& ids) {
    if (!bridge_.isConnected()) return;  // non-REAPER host / no ext-state — nothing to do

    UsageRecord mine;
    mine.trackGuid = bridge_.currentTrackGuid();
    for (const std::string& id : ids) {
        if (const SelectedSample* ref = findRef(refs, id)) {
            if (!ref->relativePath.empty()) {
                mine.holds.push_back(UsageHold{id, ref->relativePath});
            }
        }
    }

    std::lock_guard<std::mutex> lock(usageMutex_);
    // A never-published instance with nothing held writes nothing (no key litter); once an
    // identity exists, empties do publish (releasing protected holds).
    if (instanceGuid_.empty() && mine.holds.empty()) return;
    if (instanceGuid_.empty()) instanceGuid_ = mintUsageInstanceGuid();
    // The per-lifetime owner nonce (UsageRecord.ownerNonce) lets planUsagePublish prove
    // "exactly this incarnation wrote the key" — a same-track sibling's byte-identical hold
    // set can never pass as ours, so siblings always union rather than clean-replace.
    if (usageNonce_.empty()) usageNonce_ = mintUsageInstanceGuid();
    mine.ownerNonce = usageNonce_;

    const std::optional<std::string> existing =
        bridge_.readReasamplerExtState(usageKeyFor(instanceGuid_));
    const UsagePublishPlan plan = planUsagePublish(existing, mine);
    if (plan.remint) {
        // Cloned onto another track (FX copy / track duplication): take a fresh identity;
        // the abandoned old record dies by the extension's liveness rule once its track no
        // longer hosts an instance.
        instanceGuid_ = mintUsageInstanceGuid();
    } else if (plan.skipWrite) {
        return;  // idle tick, or a union that adds nothing — no ext-state churn
    }
    bridge_.writeUsageExtState(usageKeyFor(instanceGuid_), plan.wire);
}

void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr<LoadedInstrument> built) {
    // REQUIRES reloadMutex_ held (see the header's drain-slot proof).
    const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire);
    graveyard_.erase(
        std::remove_if(graveyard_.begin(), graveyard_.end(),
                       [seen](const std::unique_ptr<LoadedInstrument>& e) {
                           return e->installedAt < seen;
                       }),
        graveyard_.end());
    // Any publish supersedes the deactivate's park: whatever is installed here is the newer
    // truth, and a park surviving it would be reinstalled over this instrument at the next
    // activation (the setState-while-inactive case).
    dormantSample_.reset();
    LoadedInstrument* prev = live_.exchange(built.release());
    // A bake's reset gain lands here rather than at its call site, so the gain and the
    // capture it belongs to become audible to process() within one block of each other.
    // live_.exchange above and the gain store below are two independent relaxed atomics
    // with no ordering between them, so the honest bound is "within one block", not "the
    // same instant" — a tail still ringing out of the drain does take the new gain, one
    // gain sitting above every snapshot, the same shape as ONE BLOCK, ONE RATE (see
    // builtSampleRate_).
    if (gainAtNextPublish_) {
        // The MIRROR is inline (that is the sound this publish belongs to); the host
        // notification is ARMED and delivered after reloadMutex_ is released. performEdit
        // reaches the host handler, a host may re-enter this object synchronously from it, and
        // setActive(false) takes this same non-recursive mutex — the identical hazard the
        // latency restart is deferred for.
        const bool moved = publishMasterGainLinear(*gainAtNextPublish_);
        gainAtNextPublish_.reset();
        // Armed only on a real change, so a reset that lands on the gain already set writes
        // nothing into a host's automation lane — the same compare setMasterGainLinear makes.
        if (moved) gainNotifyPending_.store(true, std::memory_order_release);
    }
    LoadedInstrument* evicted = draining_.exchange(prev);
    if (evicted) graveyard_.push_back(std::unique_ptr<LoadedInstrument>(evicted));
}

std::unique_ptr<LoadedInstrument>
ReaSamplerProcessor::buildInstrumentLocked(SampleData sample) {
    // REQUIRES reloadMutex_ held. The ONE construction of a playable snapshot, so the three
    // callers (full reload, voice-param rebuild, reactivation) cannot drift on the generation
    // stamp, the voice-system snapshot or the ring size.
    int builtVoiceCount = kDefaultVoiceCount;
    VoiceMode builtVoiceMode = VoiceMode::Poly;
    MonoTrigger builtMonoTrigger = MonoTrigger::Retrigger;
    {
        // Baked into the engine's construction (immutable config; a later change rebuilds).
        std::lock_guard<std::mutex> vp(voiceParamsMutex_);
        builtVoiceCount = voiceCount_;
        builtVoiceMode = voiceMode_;
        builtMonoTrigger = monoTrigger_;
    }
    // Preserve OLA window in output frames from the host rate (kPreserveWindowMs), pre-sized
    // here so process()-time note-on never allocates. Floored at 2 so a valid window is always
    // a real ring, covering a pathological host rate <= 0 too. Re-derived per build, so a
    // reactivation after the host changed its rate gets a ring sized for the new one.
    std::int64_t preserveWindow = static_cast<std::int64_t>(
        kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
    if (preserveWindow < 2) preserveWindow = 2;
    const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1;
    return std::make_unique<LoadedInstrument>(
        std::move(sample), static_cast<std::size_t>(builtVoiceCount), gen,
        kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger);
}

void ReaSamplerProcessor::rebuildVoiceEngine() {
    // Off the audio thread. A voice-param change touches no audio data, so this rebuilds
    // the engine around a copy of the live instrument's already-decoded SampleData — no
    // bridge, no disk — and publishes through the same drain-slot swap.
    std::lock_guard<std::mutex> lock(reloadMutex_);
    LoadedInstrument* cur = live_.load(std::memory_order_acquire);
    if (!cur) return;  // nothing loaded: the new params bake into the next real reload.
    // Deep-copy the decoded sample: safe to read concurrently with process() because the
    // SampleData is immutable after construction and reloadMutex_ prevents `cur` from being
    // freed.
    publishBuiltLocked(buildInstrumentLocked(cur->sample));
}

bool ReaSamplerProcessor::resumeDormantInstrument() {
    // Off the audio thread (setActive only). The reactivation half of the lifetime split: the
    // voice state the deactivate destroyed is rebuilt, the PCM it parked is reused as-is.
    std::lock_guard<std::mutex> lock(reloadMutex_);
    // A publish that landed while inactive (setState's reload, a bake's adopt) IS the
    // activation state — its voices have never rendered, and it has already superseded the
    // park. Rebuilding here would displace a correct instrument into the drain slot.
    if (live_.load(std::memory_order_acquire)) return true;
    if (!dormantSample_) return false;

    // The activation is still where a bank change made with NO EDITOR OPEN is picked up:
    // pollBankSync, the only other route to either of the two calls below, runs off the
    // editor's sync tick and nothing else. Both are a GetProjExtState plus a parse — no disk
    // and no decode, which is what lets them stay on a path whose whole point is skipping
    // those two.
    const std::string selId = selectedSampleId();
    const std::vector<std::string> ids = referencedSampleIds(selId);
    SampleRefs refs;
    bool sourceMoved = false;
    {
        std::optional<std::string> banksJson =
            bridge_.readReasamplerExtState(kProjExtBanksKey);
        std::lock_guard<std::mutex> rl(refsMutex_);
        if (banksJson) {
            const SelectedSample* before = findRef(sampleRefs_, selId);
            const std::optional<SelectedSample> was =
                before ? std::optional<SelectedSample>(*before) : std::nullopt;
            refreshRefsFromBank(sampleRefs_, *banksJson, ids);
            const SelectedSample* now = findRef(sampleRefs_, selId);
            sourceMoved = !was || !now || !sameDecodeSource(*was, *now);
        }
        refs = sampleRefs_;
    }
    // A recapture that landed while this instance was inactive makes the park the WRONG audio,
    // and refreshing the refs without re-decoding would leave the table naming one file while
    // the voices play another. Hand back to the full reload, which decodes the new one.
    if (sourceMoved) return false;

    // MOVED, not copied: the park exists for this one handoff, and keeping it would hold a
    // second copy of the PCM for the whole active lifetime. Disengaged BEFORE the build so a
    // throwing build leaves nothing to resume — the next activation then takes the reload
    // rather than publishing an empty sample as permanent silence.
    SampleData resumed = std::move(*dormantSample_);
    dormantSample_.reset();
    publishBuiltLocked(buildInstrumentLocked(std::move(resumed)));
    // The prune-protection republish reloadInstrument owes on every publish: it is also what
    // heals an rsusage_ key whose write failed when this instance last set its state.
    publishUsage(refs, ids);
    return true;
}

void ReaSamplerProcessor::retireIdleDrain() {
    // Cheap early-out before the lock: 0 means "no drain, or it still sounds".
    const std::uint64_t idleGen = drainIdleGeneration_.load(std::memory_order_acquire);
    if (idleGen == 0) return;
    std::lock_guard<std::mutex> lock(reloadMutex_);
    LoadedInstrument* drain = draining_.load(std::memory_order_acquire);
    // Retire only if the publication names the drain currently in the slot — a stale value
    // (an already-evicted, older drain) can never match the newer occupant's installedAt
    // (monotone in generation), closing a mid-swap race by identity rather than timing.
    if (!drain || drain->installedAt != idleGen) return;
    draining_.store(nullptr, std::memory_order_release);
    graveyard_.push_back(std::unique_ptr<LoadedInstrument>(drain));
    // Prune what is now provably unreachable (same monotone-generation proof as
    // reloadInstrument's reclaim).
    const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire);
    graveyard_.erase(
        std::remove_if(graveyard_.begin(), graveyard_.end(),
                       [seen](const std::unique_ptr<LoadedInstrument>& e) {
                           return e->installedAt < seen;
                       }),
        graveyard_.end());
}

bool ReaSamplerProcessor::legacyLiftShouldRun() {
    // Terminating guard for the pre-v10 legacy lift (caller has already established
    // refs-empty + intent). Once concluded, the steady state is one relaxed load — no bank
    // read, no parse, no reload churn.
    if (legacyLiftConcluded_.load(std::memory_order_relaxed)) return false;
    const LegacyLiftDecision decision = legacyLiftDecision(
        bridge_.readReasamplerExtState(kProjExtBanksKey),
        referencedSampleIds(selectedSampleId()));
    if (decision == LegacyLiftDecision::Stale) {
        // Provably stale: give up permanently. A later bank change that re-introduces an
        // id bumps the generation, and genChanged refreshes the refs without this latch.
        legacyLiftConcluded_.store(true, std::memory_order_relaxed);
        return false;
    }
    return true;  // Retry (blob not readable yet) or Lift (a ref can be copied in)
}

ReaSamplerProcessor::BankSyncResult
ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
    // Off the audio thread (editor's UI timer only). A disconnected bridge yields nullopt
    // for both reads, so this no-ops cleanly.
    BankSyncResult result;

    // Park an idle drain snapshot in the graveyard on the same cadence that drives
    // reloads, so an edited-away instrument stops costing memory as soon as tails die.
    retireIdleDrain();

    // --- Assignment-request consume first -------------------------------------------
    // Decodes the pending assignment request (nullopt if absent/malformed), resolves its
    // sampleId against the live bank blob (an unresolvable pair is dropped), then runs the
    // pure consume decision against this instance's persisted marker.
    std::optional<AssignmentRequest> request;
    if (auto raw = bridge_.readReasamplerExtState(kProjExtAssignKey)) {
        request = decodeAssignmentRequest(*raw);
    }

    bool resolves = false;
    if (request) {
        // Resolve against the CURRENT bank blob (a fresh read, so a request whose sample
        // was rolled back by an extension undo resolves to nullopt -> dropped).
        if (auto banksJson = bridge_.readReasamplerExtState(kProjExtBanksKey)) {
            resolves = selectSample(*banksJson, request->sampleId).has_value();
        }
    }

    // Read + conditionally write lastConsumed under one lock scope so a concurrent
    // getState cannot observe a stale marker between two separate acquisitions.
    std::int64_t lastConsumed = 0;
    const AssignConsumeDecision decision = [&] {
        std::lock_guard<std::mutex> lock(assignMarkerMutex_);
        lastConsumed = lastConsumedAssignGeneration_;
        const AssignConsumeDecision d =
            consumeDecision(request, lastConsumed, resolves, isFocusedTarget);
        // Advance the persisted marker whenever the decision consumed the request
        // (applied or dropped-as-seen); a non-target instance leaves it unchanged so it
        // stays eligible if focus later lands here.
        if (d.consumedGeneration != lastConsumed) {
            lastConsumedAssignGeneration_ = d.consumedGeneration;
        }
        return d;
    }();

    if (decision.apply) {
        // Apply as this instance's own selection (the instrument updates its own state,
        // never the bank); reloadInstrument below rebuilds against it. The parameter set
        // carries over to the new capture — there is only one, and it governs whatever is
        // loaded (the peer of the editor's Browse Load).
        setSelectedSampleId(decision.sampleId);
        result.applied = true;
    }

    // --- Bank-generation change-detection -------------------------------------------
    // First poll (lastSeenBankGeneration_ == -1 sentinel) baselines without a reload —
    // setState already loaded from owned refs, so a redundant reload on open would only
    // churn. A later generation change (recapture/ingest/remove/undo) drives the reload.
    std::int64_t currentGen = kBankGenerationAbsent;
    if (auto rawGen = bridge_.readReasamplerExtState(kProjExtBankGenKey)) {
        currentGen = parseBankGeneration(*rawGen);
    }
    const bool firstPoll = (lastSeenBankGeneration_ < 0);
    const bool genChanged =
        !firstPoll && bankGenerationChanged(lastSeenBankGeneration_, currentGen);
    lastSeenBankGeneration_ = currentGen;

    // Legacy lift (pre-v10 blob): restored state carries intent but no owned refs (old
    // blobs had no path table). Reload on this tick until reloadInstrument folds the bank
    // blob into the refs (after which this never fires again — the next save is
    // self-contained). legacyLiftShouldRun concludes permanently once the bank parses and
    // no referenced id resolves — a migration convenience only, never a playback
    // dependency (a v10 blob plays from its refs with no poll at all).
    bool legacyLift = false;
    if (!genChanged && !result.applied && sampleRefs().empty()) {
        legacyLift = !selectedSampleId().empty() && legacyLiftShouldRun();
    }

    if (genChanged || result.applied || legacyLift) {
        reloadInstrument();  // atomic pointer-swap handoff — glitch-free mid-play
        // Reported distinctly from an applied assignment so the editor re-snapshots its
        // bank view; a legacy lift counts only when it actually landed an instrument.
        result.reloaded =
            genChanged ||
            (legacyLift && live_.load(std::memory_order_acquire) != nullptr);
    }
    return result;
}

}  // namespace reasampler::vst
