// reasampler_processor.cpp — see reasampler_processor.h.

#include "reasampler_processor.h"

#include <algorithm>
#include <cstdint>
#include <fstream>
#include <optional>
#include <utility>
#include <vector>

#include "pluginterfaces/base/ibstream.h"
#include "pluginterfaces/vst/ivstaudioprocessor.h"
#include "pluginterfaces/vst/ivsteditcontroller.h"  // RestartFlags::kIoChanged (S7 re-negotiate)
#include "pluginterfaces/vst/ivstevents.h"
#include "pluginterfaces/vst/vstspeaker.h"

#include "public.sdk/source/vst/vstbus.h"  // Vst::AudioBus::setArrangement (S7 output arr)

#include "assignment_request.h"  // decodeAssignmentRequest (S8 request wire parse)
#include "bank_sync.h"       // S9/S8 pure decisions: parseBankGeneration, consumeDecision
#include "capture_paths.h"   // resolveBankFile (shared M4 path resolution)
#include "ext_keys.h"        // kProjExtBanksKey / kProjExtBankGenKey / kProjExtAssignKey (shared wire contract)
#include "reasampler_editor.h"
#include "reasampler_embed.h"  // S6 embed shell + IReaperUIEmbedInterface (its iid DEF'd there)
#include "sample_map.h"       // selectSample, resolvePerformance, buildZonedKeymap, state (de)ser
#include "wav_trim.h"         // parseWavLayout, extractFloatFrames (shared WAV parse)

using namespace Steinberg;
using namespace Steinberg::Vst;

namespace reasampler::vst {

namespace {

// Tier-0 fixed instrument shape (Tier 2 makes these editable). A gentle amp envelope so
// notes neither click on nor cut off abruptly; sustain at unity (velocity does the
// dynamics), a short release for a natural tail. Times are in seconds, converted to
// frames against the live sample rate at build time.
constexpr std::size_t kMaxVoices = 16;

// S16 Preserve-mode voice cap: a Preserve voice runs a per-voice OLA pitch shifter and is
// materially heavier than a Varispeed voice. Below the Varispeed polyphony bound so a chord of
// Preserve notes stays within the RT budget; a Preserve note-on past the cap is dropped rather
// than glitching (Varispeed notes are unaffected). Set from the measured/estimated per-voice
// cost — see the handoff CPU note. 8 is a conservative half of kMaxVoices pending DAW profiling.
constexpr std::size_t kPreserveVoiceCap = 8;

// Read a whole file into a byte buffer. Off-thread only (blocking file I/O). Empty on
// any failure — the caller treats an unreadable WAV as "nothing to play".
std::vector<std::uint8_t> readFileBytes(const std::string& path) {
    std::vector<std::uint8_t> bytes;
    std::ifstream f(path, std::ios::binary | std::ios::ate);
    if (!f) return bytes;
    const std::streamoff size = f.tellg();
    if (size <= 0) return bytes;
    f.seekg(0, std::ios::beg);
    bytes.resize(static_cast<std::size_t>(size));
    if (!f.read(reinterpret_cast<char*>(bytes.data()), size)) bytes.clear();
    return bytes;
}

// Resolve a project-relative WAV path (the M4 way persist does), read + decode it (file
// I/O — off-thread only), and apply the S7 cross-mode channel policy for `mode`: mono mode
// downmixes to one channel (existing policy); stereo mode yields two channels (dual-mono for
// a mono source, L/R for a stereo source) — see decodeChannels. Returns nullopt when the path
// fails to resolve, the file is unreadable, the WAV is malformed, or the decode yields no
// frames — the caller drops the zone (zoned map) or plays silence (single capture). Shared by
// the zoned build and the single-capture path so both decode identically for the active mode.
std::optional<DecodedZonePcm> 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());
    DecodedZonePcm out = decodeChannels(interleaved, layout.channelCount, mode,
                                        static_cast<int>(layout.sampleRate));
    if (out.monoFrames.empty()) return std::nullopt;
    return out;
}

}  // namespace

FUnknown* ReaSamplerProcessor::createInstance(void* /*context*/) {
    // The host owns the returned reference. Cast up to the combined interface the SDK
    // exposes (IAudioProcessor) so the FUnknown refcount is correctly rooted.
    return static_cast<IAudioProcessor*>(new ReaSamplerProcessor());
}

// Out-of-line so unique_ptr<ReaSamplerEmbed> sees the complete type here.
ReaSamplerProcessor::~ReaSamplerProcessor() = default;

tresult PLUGIN_API ReaSamplerProcessor::queryInterface(const TUID iid, void** obj) {
    // S6: expose REAPER's inline-embed interface. REAPER queries the IEditController for
    // IReaperUIEmbedInterface (reaper_vst3_interfaces.h); hand it our lazily-created embed
    // shell. We own the shell (unique_ptr); the borrowed reference is valid because the
    // processor outlives it. All other iids fall through to the SDK's queryInterface.
    if (FUnknownPrivate::iidEqual(iid, IReaperUIEmbedInterface::iid)) {
        if (!embed_) embed_ = std::make_unique<ReaSamplerEmbed>(this);
        embed_->addRef();
        *obj = static_cast<IReaperUIEmbedInterface*>(embed_.get());
        return kResultOk;
    }
    return SingleComponentEffect::queryInterface(iid, obj);
}

tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) {
    tresult result = SingleComponentEffect::initialize(context);
    if (result != kResultOk) return result;

    // Connect the REAPER bridge. Non-fatal if it fails (non-REAPER host): the
    // instrument still loads, it just has no live bank to play.
    bridge_.connect(context);

    // Instrument bus topology: one event input (MIDI in, 16 channels), one audio output, no
    // audio input. The output arrangement follows the instance's channel mode (S7) — mono by
    // default (kMono), stereo (kStereo) when the mode is stereo. addAudioOutput needs an initial
    // arrangement; seed it at the mode's arrangement so getBusInfo is correct from the first
    // query. (setState may later flip the mode and re-negotiate via setChannelMode.)
    addEventInput(STR16("MIDI In"), 16);
    const ChannelMode mode = channelMode();
    addAudioOutput(STR16("Audio Out"),
                   mode == ChannelMode::Stereo ? SpeakerArr::kStereo : SpeakerArr::kMono);

    return kResultOk;
}

tresult PLUGIN_API ReaSamplerProcessor::terminate() {
    // process() is not running at terminate. Free the live instrument and drain the
    // graveyard. Take the pointer out of the atomic first so nothing else races it.
    std::lock_guard<std::mutex> lock(reloadMutex_);
    delete live_.exchange(nullptr);
    graveyard_.clear();
    return SingleComponentEffect::terminate();
}

tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) {
    // Activating: build the instrument from the currently-selected sample so the first
    // block after activation can play. Deactivating: process is now GUARANTEED stopped by
    // the host, so this is the safe point to reclaim the graveyard (the displaced engines
    // no reload could free while active). The build/drain are off the audio thread —
    // setActive is a main/UI-thread call.
    if (state) {
        reloadFromBank();
    } else {
        std::lock_guard<std::mutex> lock(reloadMutex_);
        graveyard_.clear();
    }
    return kResultOk;
}

tresult PLUGIN_API ReaSamplerProcessor::setupProcessing(ProcessSetup& setup) {
    sampleRate_ = setup.sampleRate;
    maxBlockSize_ = setup.maxSamplesPerBlock;
    return SingleComponentEffect::setupProcessing(setup);
}

tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
    if (!state) return kResultFalse;
    // Read the whole component-state blob (the performance map, versioned). The blob is
    // small; read in one shot into a growable buffer.
    std::vector<std::uint8_t> bytes;
    std::uint8_t chunk[256];
    int32 got = 0;
    while (state->read(chunk, sizeof(chunk), &got) == kResultOk && got > 0) {
        bytes.insert(bytes.end(), chunk, chunk + got);
    }
    // Component state (v3, S10) is {single-capture selection id, opt-in zones}. The
    // selection and the zones are DISTINCT — the default face is one picked capture, zones
    // are a demoted overlay — so both are restored explicitly (no more inferring a selection
    // from a lone zone). deserializeComponentState lifts older blobs cleanly: a v2 zones-only
    // blob restores {"", zones}; a v1 S4 single-selection blob restores {id, one-zone map} so
    // the old pick survives as both; an empty/unknown blob restores {"", no zones} — the S10
    // silent empty state (no first-sample fallback in reloadFromBank).
    // Pass sampleRate_ as the project rate for legacy v3 blob conversion (frames -> seconds at
    // the v3 read boundary). sampleRate_ is set by setupProcessing; REAPER calls setupProcessing
    // before setState on project load, so sampleRate_ is the real host rate here. A v3 blob on a
    // pre-setup call would assert inside readZonesPayload (a programming error, not a field case).
    const ComponentState cs = deserializeComponentState(bytes, sampleRate_);
    setSelectedSampleId(cs.selectionId);
    setPerformanceMap(cs.map);
    // S8: restore the last-consumed assignment generation so a re-open does not re-apply a
    // stale assign_request (the user may have manually changed the selection after the assign).
    {
        std::lock_guard<std::mutex> lock(assignMarkerMutex_);
        lastConsumedAssignGeneration_ = cs.lastConsumedAssignGeneration;
    }
    // Restore the S7 channel mode and point the output bus at its arrangement so a reopened
    // project comes back in the saved mode. setState runs before the host queries bus info, so
    // seeding the arrangement here (rather than re-negotiating) is enough — no restartComponent.
    {
        std::lock_guard<std::mutex> lock(channelModeMutex_);
        channelMode_ = cs.channelMode;
    }
    applyOutputArrangement(cs.channelMode);
    // Rebuild from the restored state (off-thread — setState is a load-time call).
    reloadFromBank();
    return kResultOk;
}

tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) {
    if (!state) return kResultFalse;
    // Persist the full instance state (v3, S10): the single-capture selection id AND the
    // opt-in zones — the instrument's own state (D-B), NEVER written to the "reasampler"
    // bank ext-state. An instance with no pick and no zones serializes to {"", no zones}
    // and restores as the S10 empty state (silence + "pick a capture"), never auto-playing
    // sample #1.
    ComponentState state_out;
    state_out.selectionId = selectedSampleId();
    state_out.map = performanceMap();
    state_out.channelMode = channelMode();  // S7: persist the per-instance mono/stereo mode
    {
        std::lock_guard<std::mutex> lock(assignMarkerMutex_);
        state_out.lastConsumedAssignGeneration = lastConsumedAssignGeneration_;  // S8 reader marker
    }
    const std::vector<std::uint8_t> bytes = serializeComponentState(state_out);
    if (!bytes.empty()) {
        const tresult wr = state->write(const_cast<std::uint8_t*>(bytes.data()),
                                        static_cast<int32>(bytes.size()), nullptr);
        if (wr != kResultOk) return wr;
    }
    return kResultOk;
}

std::string ReaSamplerProcessor::selectedSampleId() {
    std::lock_guard<std::mutex> lock(selectionMutex_);
    return selectedSampleId_;
}

void ReaSamplerProcessor::setSelectedSampleId(const std::string& id) {
    std::lock_guard<std::mutex> lock(selectionMutex_);
    selectedSampleId_ = id;
}

PerformanceMap ReaSamplerProcessor::performanceMap() {
    std::lock_guard<std::mutex> lock(performanceMutex_);
    return performanceMap_;
}

void ReaSamplerProcessor::setPerformanceMap(const PerformanceMap& map) {
    std::lock_guard<std::mutex> lock(performanceMutex_);
    performanceMap_ = map;
}

ChannelMode ReaSamplerProcessor::channelMode() {
    std::lock_guard<std::mutex> lock(channelModeMutex_);
    return channelMode_;
}

void ReaSamplerProcessor::applyOutputArrangement(ChannelMode mode) {
    // Set the single output bus's SpeakerArrangement to the mode's arrangement so getBusInfo /
    // getBusArrangement report the right channel count. The default getBusArrangement (from the
    // base) reads back exactly what we store here. No re-negotiation — the caller drives that.
    BusList* outs = getBusList(kAudio, kOutput);
    if (!outs || outs->empty()) return;
    if (auto* bus = FCast<AudioBus>(outs->at(0))) {
        bus->setArrangement(mode == ChannelMode::Stereo ? SpeakerArr::kStereo
                                                        : SpeakerArr::kMono);
    }
}

void ReaSamplerProcessor::setChannelMode(ChannelMode mode) {
    {
        std::lock_guard<std::mutex> lock(channelModeMutex_);
        if (channelMode_ == mode) return;  // no-op: don't churn the bus / re-negotiate
        channelMode_ = mode;
    }
    // The mode changed: repoint the output bus and ask the host to re-negotiate I/O so REAPER's
    // routing follows (mono<->stereo). restartComponent is a main/UI-thread call; setChannelMode
    // is driven from the editor, so this is safe. Then reload so the next block decodes the new
    // channel count into the LoadedInstrument (off-thread, RT path untouched).
    applyOutputArrangement(mode);
    if (componentHandler) componentHandler->restartComponent(kIoChanged);
    reloadFromBank();
}

tresult PLUGIN_API ReaSamplerProcessor::setBusArrangements(
    SpeakerArrangement* inputs, int32 numIns,
    SpeakerArrangement* outputs, int32 numOuts) {
    // The instrument has ONE canonical arrangement per its channel mode (S7). We take NO audio
    // input, so any inputs are rejected. For the single output bus: accept (kResultTrue) only
    // when the host proposes exactly the mode's arrangement; otherwise reject (kResultFalse) but
    // KEEP the mode's arrangement (per the VST3 contract, a plug-in that can't honor a proposal
    // keeps a valid arrangement of its own). getBusArrangement then still reports the mode's
    // channel count, so the host adapts its routing to us rather than forcing our channel count.
    if (numIns < 0 || numOuts < 0) return kInvalidArgument;
    if (numIns > 0) return kResultFalse;  // no audio input bus to arrange

    const SpeakerArrangement want =
        channelMode() == ChannelMode::Stereo ? SpeakerArr::kStereo : SpeakerArr::kMono;
    applyOutputArrangement(channelMode());  // keep the bus pinned to the mode's arrangement
    if (numOuts == 1 && outputs && outputs[0] == want) return kResultTrue;
    return kResultFalse;
}

std::string ReaSamplerProcessor::reloadFromBank() {
    // OFF THE AUDIO THREAD. Serialize concurrent reloads (editor click + setState) so
    // the retired-slot free is single-writer. This mutex is NEVER taken on the audio
    // thread — process() only touches the atomic.
    std::lock_guard<std::mutex> lock(reloadMutex_);

    // Mint this reload's generation number first so we can stamp the built instrument
    // with it before publishing. Under reloadMutex_ no other reload races here.
    const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1;

    // 1. Read the live bank + resolve the project dir over the bridge (allocates,
    //    calls REAPER — fine here, off-thread).
    std::optional<std::string> banksJson =
        bridge_.readReasamplerExtState(kProjExtBanksKey);
    const std::string projectDir = bridge_.activeProjectDir();
    // The active channel mode (S7) governs how each WAV decodes (mono downmix vs 2-channel).
    // Read once under its mutex, off the audio thread, before the decode loop.
    const ChannelMode mode = channelMode();

    std::string resolvedId;
    std::unique_ptr<LoadedInstrument> built;

    if (banksJson) {
        // 2. Tier 1 first: if the instrument's performance map is non-empty, resolve its
        //    zones against the live bank (STALE ids drop cleanly), decode each zone's WAV
        //    off-thread, and build the ZONED keymap. Each surviving zone plays its bank
        //    sample repitched from its effective root note (override > bank intrinsic > C4).
        //    A zone whose WAV fails to decode is dropped (not the whole map).
        const PerformanceMap map = performanceMap();
        Keymap km;
        bool haveKeymap = false;

        if (!map.empty()) {
            const ResolvedPerformance resolved = resolvePerformance(*banksJson, map);
            if (!resolved.zones.empty()) {
                std::vector<DecodedZonePcm> decoded;
                std::vector<ResolvedZone> kept;
                decoded.reserve(resolved.zones.size());
                kept.reserve(resolved.zones.size());
                for (const ResolvedZone& rz : resolved.zones) {
                    std::optional<DecodedZonePcm> pcm =
                        decodeRelative(projectDir, rz.relativePath, mode);
                    if (!pcm) continue;  // unreadable WAV -> drop this zone
                    kept.push_back(rz);
                    decoded.push_back(std::move(*pcm));
                }
                km = buildZonedKeymap(kept, decoded);
                haveKeymap = !km.zones.empty();
            }
        }

        // 3. Single-capture fast path (S10): an empty performance map plays the ONE
        //    deliberately-selected capture chromatically across the whole keyboard. This is
        //    the default face — one picked capture, repitched from its root. NO first-
        //    sample fallback: an EMPTY selection (or a stale id) resolves to nullopt in
        //    selectSample, so an un-picked instrument stays SILENT (the editor shows its
        //    "pick a capture" empty state) rather than auto-playing sample #1 (S10 policy
        //    reversal of the S4 convenience default).
        if (!haveKeymap) {
            std::optional<SelectedSample> sel =
                selectSample(*banksJson, selectedSampleId());
            if (sel) {
                std::optional<DecodedZonePcm> pcm =
                    decodeRelative(projectDir, sel->relativePath, mode);
                if (pcm) {
                    km = buildTier0Keymap(std::move(pcm->monoFrames), pcm->sampleRate,
                                          sel->rootNote, sel->loop,
                                          std::move(pcm->framesR));
                    haveKeymap = true;
                    resolvedId = selectedSampleId();  // the concrete pick that resolved
                }
            }
        }

        if (haveKeymap) {
            // Preserve OLA window in OUTPUT frames from the host sample rate (kPreserveWindowMs).
            // Every voice's shifter is pre-sized to this off-thread here, so process()-time
            // note-on never allocates. Floored at 2 so a valid window is always a real ring
            // (which also covers a pathological host rate <= 0 — no rate literal needed).
            std::int64_t preserveWindow = static_cast<std::int64_t>(
                kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
            if (preserveWindow < 2) preserveWindow = 2;
            built = std::make_unique<LoadedInstrument>(
                std::move(km), kMaxVoices, gen, kPreserveVoiceCap,
                preserveWindow);
        }
    }

    // 4. Publish. Atomically install the new instrument; the DISPLACED one goes to the
    //    graveyard tagged with this generation (process may still be mid-block reading
    //    it). A null `built` (no bank / unreadable WAV) installs silence.
    //    `built` is heap-owned; release() hands ownership to the atomic, and the
    //    exchanged pointer is re-owned by the graveyard.
    //
    //    Bounded reclaim: prune graveyard entries where displacedAt <= seen, where seen
    //    is the last generation process() published. process() publishes inst->installedAt
    //    (not a re-read of reloadGeneration_), so seen == D means process holds the
    //    instrument installed at gen D. An entry with displacedAt == D was displaced by
    //    reload D, which installed that very successor — process cannot be holding the
    //    displaced entry. The pruning condition is therefore <= (see header for the full
    //    proof). Remaining entries drain at setActive(false) / terminate() when process
    //    is guaranteed stopped.
    const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire);
    graveyard_.erase(
        std::remove_if(graveyard_.begin(), graveyard_.end(),
                       [seen](const GraveyardEntry& e) { return e.displacedAt <= seen; }),
        graveyard_.end());
    LoadedInstrument* prev = live_.exchange(built.release());
    if (prev) graveyard_.push_back({gen, std::unique_ptr<LoadedInstrument>(prev)});
    return resolvedId;
}

ReaSamplerProcessor::BankSyncResult
ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
    // OFF THE AUDIO THREAD (the editor's UI timer calls this). Both reads allocate and call
    // REAPER via the bridge — never invoked from process(). A disconnected bridge (non-REAPER
    // host, or before connect) yields nullopt for both reads, so this no-ops cleanly.
    BankSyncResult result;

    // --- S8: assignment-request consume FIRST -------------------------------------
    // Decode the pending assignment request (nullopt when absent/malformed). Resolve its
    // (bankId, sampleId) against the live bank blob: selectSample returns non-nullopt only when
    // the sampleId names an existing sample (the reader requirement — an unresolvable pair is
    // dropped). Then run 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 the assigned sample 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 lastConsumed and conditionally write it back under a single lock scope so there
    // is no interleave window between the read and the write (a concurrent getState could
    // otherwise observe a stale marker between the two separate lock 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 consumed marker whenever the decision consumed the request
        // (applied OR dropped-as-seen). getState will persist it on the next project save so
        // a re-open does not re-apply. A non-target instance leaves the marker (decision
        // returns it unchanged) so it stays eligible if focus later lands here.
        if (d.consumedGeneration != lastConsumed) {
            lastConsumedAssignGeneration_ = d.consumedGeneration;
        }
        return d;
    }();

    if (decision.apply) {
        // Apply the assignment as this instance's own selection (the same path a user card-pick
        // takes) — the instrument updates its OWN state, never the bank. reloadFromBank below
        // rebuilds against the new selection, so skip a redundant reload here.
        setSelectedSampleId(decision.sampleId);
        result.applied = true;
    }

    // --- S9: bank-generation change-detection -------------------------------------
    // Read the generation stamp; parse (absent/malformed -> 0, the pre-S9 default). FIRST poll
    // (lastSeenBankGeneration_ == -1 sentinel): BASELINE the seen value without a reload — setState
    // already loaded the current bank, so a redundant reload on open would only churn. A later
    // generation CHANGE (a recapture/ingest/remove, or an undo that lowers it) then drives the
    // reload. An assignment we just applied also needs a reload; fold both into ONE (coalesced).
    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;

    if (genChanged || result.applied) {
        reloadFromBank();  // atomic pointer-swap handoff — glitch-free mid-play (S4 graveyard)
        result.reloaded = genChanged;  // report S9 vs S8 distinctly for the editor's reaction
    }
    return result;
}

tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
    // REAL-TIME: no allocation, no IO, no locks. Load the live instrument once for the
    // whole block (a single atomic acquire), then publish inst->installedAt so the off-
    // thread graveyard pruner knows exactly which generation this block is holding.
    //
    // We publish installedAt — not a fresh re-read of reloadGeneration_ — to close an
    // ordering race: reading reloadGeneration_ after live_ could observe a generation
    // newer than the pointer we actually hold, causing the pruner to free an instrument
    // process is still reading. installedAt was set on the reload path before the atomic
    // exchange that made the instrument visible, so it is always <= the generation of any
    // instrument that could have been loaded after our acquire above.
    LoadedInstrument* inst = live_.load(std::memory_order_acquire);
    const std::uint64_t heldGen = inst ? inst->installedAt : 0;
    processGeneration_.store(heldGen, std::memory_order_release);

    // Marshal MIDI note-on/off from the event input into the voice engine. Tier 0 maps
    // events at block granularity (no per-event sample-offset split) — audible timing is
    // within one block, adequate for Tier 0; sample-accurate scheduling is a later tier.
    if (inst && data.inputEvents) {
        const int32 count = data.inputEvents->getEventCount();
        for (int32 i = 0; i < count; ++i) {
            Event e;
            if (data.inputEvents->getEvent(i, e) != kResultOk) continue;
            if (e.type == Event::kNoteOnEvent) {
                // A note-on with velocity 0 is a note-off by MIDI convention.
                const int vel = static_cast<int>(e.noteOn.velocity * 127.0f + 0.5f);
                if (vel <= 0) {
                    inst->engine.noteOff(e.noteOn.pitch);
                } else {
                    inst->engine.noteOn(e.noteOn.pitch, vel);
                }
            } else if (e.type == Event::kNoteOffEvent) {
                inst->engine.noteOff(e.noteOff.pitch);
            }
        }
    }

    if (data.numOutputs <= 0 || !data.outputs || data.numSamples <= 0) {
        embedPeak_.store(0.f, std::memory_order_relaxed);
        return kResultOk;
    }
    AudioBusBuffers& out = data.outputs[0];
    const int32 frames = data.numSamples;

    // 64-bit host processing is not supported by the mono float core; emit silence
    // rather than mis-render. REAPER runs 32-bit float by default.
    if (data.symbolicSampleSize != kSample32) {
        embedPeak_.store(0.f, std::memory_order_relaxed);
        for (int32 ch = 0; ch < out.numChannels; ++ch) {
            if (double* buf = out.channelBuffers64[ch]) {
                for (int32 i = 0; i < frames; ++i) buf[i] = 0.0;
            }
        }
        out.silenceFlags = (out.numChannels >= 64)
                               ? ~0ULL
                               : ((1ULL << out.numChannels) - 1);
        return kResultOk;
    }

    // Render per the host's NEGOTIATED output channel count (S7). The channel mode was baked
    // into the LoadedInstrument's decode + negotiated onto the output bus off-thread, so here
    // we simply match the buffers the host handed us: >=2 channels -> true stereo render into
    // ch0/ch1 (then replicate any extra channels); exactly 1 -> the mono render. Either way the
    // render ADDS into a cleared buffer — RT-safe (no alloc/IO/lock). NEVER reads the mode here.
    float* ch0 = out.numChannels > 0 ? out.channelBuffers32[0] : nullptr;
    float* ch1 = out.numChannels > 1 ? out.channelBuffers32[1] : nullptr;
    if (ch0 && ch1) {
        // Stereo: clear both, render L/R. A mono sample plays dual-mono via the engine's stereo
        // path (both channels equal), so a mono capture in stereo mode is centered, not silent.
        for (int32 i = 0; i < frames; ++i) { ch0[i] = 0.f; ch1[i] = 0.f; }
        if (inst) {
            inst->engine.render(ch0, ch1, static_cast<std::size_t>(frames));
        }
        // Any channels beyond the first two mirror ch0 (defensive — REAPER negotiates 1 or 2).
        for (int32 ch = 2; ch < out.numChannels; ++ch) {
            if (float* buf = out.channelBuffers32[ch]) {
                for (int32 i = 0; i < frames; ++i) buf[i] = ch0[i];
            }
        }
        // Block peak (max across L/R) for the embed strip's level indicator; RT-safe.
        float peak = 0.f;
        for (int32 i = 0; i < frames; ++i) {
            const float a0 = ch0[i] < 0.f ? -ch0[i] : ch0[i];
            const float a1 = ch1[i] < 0.f ? -ch1[i] : ch1[i];
            if (a0 > peak) peak = a0;
            if (a1 > peak) peak = a1;
        }
        embedPeak_.store(peak, std::memory_order_relaxed);
    } else if (ch0) {
        // Mono: render into channel 0, replicate to any extra channels (mono bus is 1 channel;
        // the replicate is defensive for a host that still hands >1 channel on a mono bus).
        for (int32 i = 0; i < frames; ++i) ch0[i] = 0.f;
        if (inst) {
            inst->engine.render(ch0, static_cast<std::size_t>(frames));
        }
        float peak = 0.f;
        for (int32 i = 0; i < frames; ++i) {
            const float a = ch0[i] < 0.f ? -ch0[i] : ch0[i];
            if (a > peak) peak = a;
        }
        embedPeak_.store(peak, std::memory_order_relaxed);
        for (int32 ch = 1; ch < out.numChannels; ++ch) {
            if (float* buf = out.channelBuffers32[ch]) {
                for (int32 i = 0; i < frames; ++i) buf[i] = ch0[i];
            }
        }
    }

    // Report silence only when nothing is loaded (lets the host optimize when idle).
    // With an instrument loaded we clear the flag so a ringing voice is not skipped.
    out.silenceFlags = inst ? 0 : ((out.numChannels >= 64)
                                       ? ~0ULL
                                       : ((1ULL << out.numChannels) - 1));
    return kResultOk;
}

IPlugView* PLUGIN_API ReaSamplerProcessor::createView(FIDString name) {
    if (name && FIDStringsEqual(name, ViewType::kEditor)) {
        return new ReaSamplerEditor(this);
    }
    return nullptr;
}

}  // namespace reasampler::vst
