// reasampler_processor.h — VST3 SingleComponentEffect wiring the pure sampler core into
// a playable instrument: event-input + stereo output bus, MIDI -> VoiceEngine, render.
// Self-contained playback: component state owns per-sample WAV path + decode intrinsics
// (SampleRefs); the bank blob is an opportunistic browser source, never a playback
// dependency. Audio thread (process()) does no allocation/file-IO/bridge calls/locks;
// loading happens off-thread (reloadInstrument) and hands off via one atomic pointer swap.

#pragma once

#include <atomic>
#include <cstdint>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <vector>

#include "public.sdk/source/vst/vstsinglecomponenteffect.h"

#include "shell/instrument/automation_channel.h"  // the automation hold + its release protocol
#include "shell/instrument/processor_snapshot.h"  // LoadedInstrument + MasterBusMeter
#include "shell/instrument/reaper_bridge.h"
#include "core/instrument/map/sample_map.h"     // InstrumentParams (the one parameter set)
#include "core/instrument/map/component_state_io.h"  // ComponentState codec
#include "core/instrument/engine/limiter.h"      // the master bus's post-gain limiter
#include "core/instrument/engine/live_params.h"  // LiveParams (the live-parameter block)
#include "core/instrument/engine/meter_accumulate.h"  // the meter's block-rate folds + consume
#include "core/instrument/param/param_id.h"      // the frozen ParamId space + DeckParam binding

namespace reasampler::vst {

using instrument::map::ComponentState;
using instrument::map::InstrumentParams;
using instrument::map::SampleRefEntry;
using instrument::map::SampleRefs;
using instrument::map::kPreviewVelocityDefault;

class ReaSamplerEmbed;  // embedded TCP/MCP UI shell (owned below; see queryInterface)

class ReaSamplerProcessor : public Steinberg::Vst::SingleComponentEffect {
public:
    ReaSamplerProcessor() = default;
    // Out-of-line so the owned ReaSamplerEmbed (unique_ptr, forward-declared here) is
    // complete at the destruction point (defined in the .cpp).
    ~ReaSamplerProcessor() override;

    // The factory create function (registered in vst_entry.cpp).
    static Steinberg::FUnknown* createInstance(void* /*context*/);

    //--- from IComponent / IPluginBase -------------------------------------
    // Connects the REAPER bridge (context is REAPER's IHostApplication) and declares
    // the instrument bus topology.
    Steinberg::tresult PLUGIN_API initialize(Steinberg::FUnknown* context) override;
    Steinberg::tresult PLUGIN_API terminate() override;
    Steinberg::tresult PLUGIN_API setActive(Steinberg::TBool state) override;

    // Instance state = the selected bank sample id (a performance choice the instrument
    // owns; never written back to the bank). Component-state, so a saved project restores
    // which sample each instance plays.
    Steinberg::tresult PLUGIN_API setState(Steinberg::IBStream* state) override;
    Steinberg::tresult PLUGIN_API getState(Steinberg::IBStream* state) override;

    //--- from IAudioProcessor ----------------------------------------------
    Steinberg::tresult PLUGIN_API setupProcessing(
        Steinberg::Vst::ProcessSetup& setup) override;
    // Marshals MIDI -> VoiceEngine -> audio output. Real-time safe (no alloc/IO/lock).
    Steinberg::tresult PLUGIN_API process(
        Steinberg::Vst::ProcessData& data) override;

    // The plugin's PDC report: 0 with the limiter bypassed, the limiter's lookahead with it
    // engaged. Read from the PERSISTED enable, never from a transient — the SDK's contract
    // (pluginterfaces/vst/ivsteditcontroller.h, kLatencyChanged) is that the host asks this
    // AFTER the deactivate/reactivate it performs, and setActive(false) clears the engine.
    Steinberg::uint32 PLUGIN_API getLatencySamples() override;

    // Fixed stereo output bus — channel mode is a decode policy, never a bus fact; mono
    // renders dual-mono through it. Do not reintroduce per-instance bus renegotiation.
    // Accepts only a single stereo output proposal; otherwise rejects and keeps stereo.
    Steinberg::tresult PLUGIN_API setBusArrangements(
        Steinberg::Vst::SpeakerArrangement* inputs, Steinberg::int32 numIns,
        Steinberg::Vst::SpeakerArrangement* outputs, Steinberg::int32 numOuts) override;

    //--- from IEditController -----------------------------------------------
    // Hands the host our LICE IPlugView editor.
    Steinberg::IPlugView* PLUGIN_API createView(Steinberg::FIDString name) override;

    // The CONTROLLER-side write — a host GUI gesture on the generic panel, and whatever a host
    // mirrors here for display. Applies it to THE model through that control's existing commit
    // tier and never through a fourth route. Nothing reachable from here touches reloadInstrument
    // or rebuildVoiceEngine, which is structural rather than careful: every reload- and
    // rebuild-tier control is omitted from the parameter list, so no id maps to one.
    // NOT the automation channel; this directory's CLAUDE.md owns which channel is which.
    Steinberg::tresult PLUGIN_API setParamNormalized(
        Steinberg::Vst::ParamID tag, Steinberg::Vst::ParamValue value) override;

    // Pushes every exposed parameter's normalized value from the model into the controller
    // WITHOUT notifying the host — the load direction, which the SDK forbids reflecting back
    // through IComponentHandler. UI/main thread.
    void syncParamsFromModel();

    // Folds what the audio thread took from the host's parameter queue back into the model and
    // the controller cache, suppressing the host notification (those values came FROM it), then
    // RELEASES each folded point's hold — which is what bounds a lane's authority to the window
    // where it is actually driving. UI/main thread; a no-op when nothing was automated. Called
    // wherever the model is about to be READ as authoritative — getState, the bake, the editor's
    // tick. This directory's CLAUDE.md owns the authority model.
    void drainAutomationToModel();

    // A drag's host-edit bracket, so a host in touch or latch mode records ONE continuous edit
    // per parameter rather than a burst of one-point ones. Every parameter the drag notifies
    // opens its bracket on first touch and holds it until endParamGesture — LATCHING rather than
    // declared up front, because an envelope-node drag moves a set the grab cannot name. The
    // deck-knob form additionally opens its own id at once, which is the id the host sees a touch
    // on even if the drag produces no move. Idempotent. UI thread only.
    void beginParamGesture(instrument::ui::DeckParam deck);
    void beginParamGestureLatch();
    void endParamGesture();

    // Additionally exposes REAPER's IReaperUIEmbedInterface (queried by REAPER to drive the
    // inline TCP/MCP embed); all other iids delegate to SingleComponentEffect unchanged.
    Steinberg::tresult PLUGIN_API queryInterface(const Steinberg::TUID iid,
                                                 void** obj) override;

    // The embedded-strip activity level (0..1) for the embed shell, UI thread. The last block's
    // loudest channel — a non-consuming read, so it stays correct however often the editor's
    // meter drains its own accumulators (or never does, with no editor open).
    double embedActivityLevel() const {
        return static_cast<double>(advisoryPeak_.load(std::memory_order_relaxed));
    }

    // What the audio thread published since the LAST call: peaks maxed and minGain minimised
    // across every block in that window. CONSUMING — it resets the accumulators as it reads, so
    // exactly one reader may call it, and that reader is the editor's meter tick. Two live
    // editors would each consume half the windows and both meters would read low; the single
    // reader rests on the HOST calling createView once per instance `[verify — DAW]`, not on
    // anything this plugin enforces — createView allocates a new editor on every call. UI thread.
    MasterBusMeter masterBusMeter();
    void clearMasterBusClip();

    // Resolves the selection against the instance-owned SampleRefs, decodes its WAV
    // off-thread, and publishes the built instrument via atomic swap — no bank read
    // required. When the bank blob is readable it's first folded into the refs table
    // (refreshRefsFromBank; the browser's copy-the-ref-in + recapture-sync mechanism). A
    // missing/unreadable WAV is the defined no-play (silence, no retry). Returns the
    // resolved selection id ("" if nothing loaded).
    std::string reloadInstrument();

    // The dialed sound as plain data for the offline bake: the SAME refs resolve + decode +
    // build reloadInstrument runs, with no live block attached. Rebuilt rather than copied
    // off the live snapshot because a tier-3 live edit leaves that snapshot's own play
    // params stale on purpose — copying it would bake the pre-drag values. nullopt when
    // nothing is loaded or the WAV is unreadable. Off the audio thread (file I/O).
    std::optional<SampleData> bakeSnapshot();

    // Adopt a landed bake in ONE act: the new capture becomes this instance's ref and
    // selection, the parameter set and master gain go neutral, and a single reload
    // publishes all three together. The ordering is the whole point — anything published
    // ahead of the re-point would apply neutral settings to the OLD capture, which is the
    // one thing they are meaningless against. UI thread only.
    void adoptBakedCapture(const SampleRefEntry& entry, const InstrumentParams& reset,
                           double masterGainLinear);

    // What pollBankSync did this tick, so the editor can react only when something changed.
    struct BankSyncResult {
        bool reloaded = false;    // bank generation changed (or a legacy lift landed) -> reloaded
        bool applied = false;     // a new assignment request was applied -> selection changed
    };

    // Off-thread poll (editor's UI timer only) of the bank generation + assignment request;
    // playback never depends on it. Generation change -> reload; a resolvable NEW assignment
    // targeting this instance (isFocusedTarget) -> apply as selection + reload (unresolvable
    // ones drop silently, marker still advances); pre-v10 legacy blobs retry the bank read
    // until the refs lift in, then stop (legacyLiftShouldRun). The consumed marker persists
    // so a re-open does not re-apply. Idempotent on an idle tick.
    BankSyncResult pollBankSync(bool isFocusedTarget);

    // The bridge, for the editor's live-state readout + sample list. Owned here; the
    // editor borrows it (outlives the editor).
    ReaperBridge& bridge() { return bridge_; }

    // The live host sample rate latched from setupProcessing; the editor's envelope overlay
    // shares this time base. 0.0 before setupProcessing runs.
    double sampleRate() const { return sampleRate_; }
    // The loaded capture's id (guarded by selectionMutex_, never read on the audio thread).
    // Empty id -> silence, no first-sample fallback.
    std::string selectedSampleId();
    void setSelectedSampleId(const std::string& id);

    // The one parameter set governing that capture. UI thread, guarded by paramsMutex_;
    // never read on the audio thread — reloadInstrument bakes it into the SampleData
    // off-thread.
    InstrumentParams instrumentParams();
    std::uint32_t setInstrumentParams(const InstrumentParams& params);  // adopt THIS generation, never a second instrumentParamsGeneration() call

    // The model's edit counter, bumped by every setInstrumentParams. A holder of a COPY (the
    // editor's snapshot) re-seeds when this moves under it; the overload answers both under one
    // lock, because reading them apart would let the pair disagree.
    std::uint32_t instrumentParamsGeneration();
    InstrumentParams instrumentParams(std::uint32_t& generation);

    // Republishes the live-parameter block from the stored parameter set, resolved against the
    // rate the loaded capture was built at so an unmoved value folds to exactly the frames the
    // voices already latched. THE tier-3 commit (the three tiers are listed in this
    // directory's CLAUDE.md). Callers pair this with setInstrumentParams exactly as they
    // paired it with reloadInstrument. No-op before anything has been decoded (the next reload
    // bakes and publishes). UI thread; serialized against reloadInstrument's own publish. Returns drainAutomationToModel's release proof (automation_channel.h); other callers ignore it.
    AutomationChannel::ReleaseProof publishLiveParams();

    // Per-instance channel mode (mono | stereo), guarded by channelModeMutex_, never read
    // on the audio thread. Decode policy only (downmix vs L/R split) — the output bus is
    // fixed stereo, so a mode change never renegotiates host I/O.
    ChannelMode channelMode();
    // Editor toggle: latches the mode explicit (auto-default stops fighting it) and
    // reloads so the next block decodes the new channel count. UI thread only.
    void setChannelMode(ChannelMode mode);

    // Per-instance preview-trigger velocity (MIDI 1..127), guarded by previewMutex_, not
    // read on the audio thread.
    std::uint8_t previewVelocity();
    void setPreviewVelocity(std::uint8_t velocity);

    // Voice-system parameters (per-instance), guarded by voiceParamsMutex_, not read on the
    // audio thread — each setter rebuilds via rebuildVoiceEngine (already-decoded SampleData,
    // no bridge/WAV re-read) through the same drain-slot swap, so a change never cuts a tail.
    int voiceCount();
    void setVoiceCount(int count);        // clamped to kMinVoiceCount..kMaxVoiceCount
    VoiceMode voiceMode();
    void setVoiceMode(VoiceMode mode);
    MonoTrigger monoTrigger();
    void setMonoTrigger(MonoTrigger trigger);

    // Post-mixer master gain, linear in [0, masterGainMaxLinear()] (0 = true silence, 1 =
    // unity, cap +24 dB). Atomic — the audio thread applies it as a per-block post-sum
    // multiply, no lock, no rebuild.
    double masterGainLinear() const {
        return static_cast<double>(masterGain_.load(std::memory_order_relaxed));
    }
    void setMasterGainLinear(double linear);  // clamped to [0, masterGainMaxLinear()]
    // The mirror alone, with no host notification: for the one writer that runs under
    // reloadMutex_ and must arm rather than emit. True when the value actually moved.
    bool publishMasterGainLinear(double linear);

    // The master-bus limiter's single enable (persisted in the parameter set). UI thread only:
    // a thin wrapper over setInstrumentParams, the one funnel that mirrors the flag onto the
    // audio thread INLINE — the sound follows the click — and only ARMS the host's latency
    // restart. Setting the value it already holds is a no-op, so repeated clicks on one segment
    // cost no restart.
    bool limiterEnabled() const {
        return limiterEnabled_.load(std::memory_order_relaxed);
    }
    void setLimiterEnabled(bool on);

    // Delivers the armed kLatencyChanged restart, at most once per armed window, and does
    // nothing when none is armed. Split off the commit because the SDK requires this on the UI
    // thread AND a host may service it synchronously — deactivate/reactivate, which reaches our
    // setActive(true) — so it must never run nested inside a mouse handler. The editor's sync
    // tick is the general drain; setState flushes at its own tail because it can commit with no
    // editor open, and adoptBakedCapture does so only to save a tick (its chain runs from that
    // same tick, so its arm would drain on the next one regardless).
    void flushLatencyRestart();

    // Delivers the master-gain host notification a reload deferred (the bake's reset gain lands
    // under reloadMutex_, and performEdit may re-enter this object). Drained beside the latency
    // restart, from the same sites and for the same reason.
    void flushGainNotify();

    // Fires a one-shot preview note-on/off through the live VoiceEngine — the same
    // noteOn/noteOff host MIDI uses, so a preview is a real voice (counts against voice
    // count, can steal/be stolen, respects Poly/Mono + Retrigger/Legato). Off the audio
    // thread; handed to process() via a lock-free single-slot mailbox drained at block
    // start. Never captures, never inserts a timeline item.
    void previewNoteOn(int note);
    void previewNoteOff(int note);

    // Snapshot copy of the instance-owned sample refs, for the editor's waveform/loop
    // fallback when the bank blob is unreadable. Guarded by refsMutex_.
    SampleRefs sampleRefs();

    // This instance's usage identity, minted here if it has never published. It names BOTH
    // the "rsusage_" record the bake's tie query must exclude and the "rsbake_" request
    // key, so the two can never name different instances. Off the audio thread.
    std::string usageInstanceGuid();

private:
    // Builds the unit and parameter lists from the derived exposed set. Called once, from
    // initialize().
    void buildParameterList();

    // THE automation read, on the audio thread, at the BLOCK BOUNDARY: the last point of each
    // queue wins. RT-safe — relaxed/release atomic stores only. Sample-accurate application would
    // put a per-sample "did anything change" question on the per-voice-per-sample path, which the
    // phase-wide guardrail forbids. True when at least one point landed ON A CONTROL THE BLOCK
    // CARRIES, which is what makes the merge below it conditional.
    bool drainInputParameterChanges(Steinberg::Vst::IParameterChanges* changes);

    // The normalized value a control reads at, from the model — a projection of it, never a
    // cached shadow. Master gain reads the processor's own atomic, pitch key-track the scalar
    // beside the play bundle; everything else reads the parameter set through the deck's binding.
    double modelParamNormalized(const InstrumentParams& params,
                                instrument::ui::DeckParam deck) const;

    // The write peer of that read, and the ONE host-side write of a control's value: both the
    // controller's setParamNormalized and the audio thread's automation fold go through it, so
    // neither can miss a control whose value lives outside the parameter set.
    static void writeDeckParamToModel(InstrumentParams& params, instrument::ui::DeckParam deck,
                                      double normalized);

    // Notifies the host of every exposed control whose normalized value moved. `beforeNorms` is
    // indexed by DeckParam ordinal. Called from setInstrumentParams — the ONE funnel every writer
    // already goes through — so no internal write can leave the host displaying, and on next
    // touch re-imposing, a superseded value. The bake's reset is the first non-gesture writer
    // this covers, and the reason the emission is grouped.
    void notifyParamsFromModel(const double* beforeNorms, const InstrumentParams& after);
    void notifyParamChanged(instrument::param::ParamId id, double normalized);
    bool gestureIsOpen(instrument::param::ParamId id) const;

    // If process() published that the drain instrument is fully idle, move it into the
    // graveyard and prune — so an edited-away snapshot stops costing memory as soon as its
    // tails die. Off the audio thread only (driven by pollBankSync); safe against a racing
    // process() because idleness is monotone and the publication names the drain's own
    // installedAt (a stale value can never retire a newer occupant).
    void retireIdleDrain();

    // Light voice-param rebuild: rebuilds the engine around a copy of the live instrument's
    // already-decoded SampleData (no bridge/disk) and publishes through the same drain-slot
    // swap as a full reload. No-op when nothing is loaded. Off the audio thread only.
    void rebuildVoiceEngine();

    // The reactivation half of the activation/decode lifetime split (see dormantSample_):
    // folds the live bank blob into the refs, rebuilds the voice state around the parked
    // sample, publishes it through the same drain-slot swap, and republishes usage. True also
    // when a publish landed while inactive, which needs no rebuild. False when there is nothing
    // to activate from, or when that fold moved what the park was decoded from — the caller
    // then falls back to a full reload, which decodes the new file and is also where the pre-v10
    // legacy lift lives. Off the audio thread only.
    bool resumeDormantInstrument();

    // Builds a playable snapshot around `sample` at the current voice-system parameters and
    // host rate, stamped with a fresh generation. Requires reloadMutex_ held; the ONE
    // construction site shared by the reload, the voice-param rebuild and the reactivation.
    std::unique_ptr<LoadedInstrument> buildInstrumentLocked(SampleData sample);

    // Pre-v10 legacy-lift gate: true when a lift attempt this tick could make progress
    // (see legacyLiftConcluded_). Off the audio thread only (bridge read + bank parse).
    bool legacyLiftShouldRun();

    // Publishes `built` (null = install silence) into live_: prunes the graveyard by the
    // last process()-published generation, swaps `built` into live_, displaces the previous
    // live into the drain slot, and parks the evicted drain instrument in the graveyard.
    // Requires reloadMutex_ held — shared by reloadInstrument, rebuildVoiceEngine and
    // resumeDormantInstrument.
    void publishBuiltLocked(std::unique_ptr<LoadedInstrument> built);

    // Publishes a silent block. EVERY process() path that emits no audio calls this. It clears
    // only the ADVISORY level, which is a last-block reading: the meter accumulators need
    // nothing here, because a block that emitted no audio contributes no peak and no gain
    // reduction, and the UI's own read is what resets them.
    void publishSilentMeterBlock() {
        advisoryPeak_.store(0.f, std::memory_order_relaxed);
    }

    // Mirrors the persisted limiter enable onto the audio thread and the latency reader. Called
    // from every writer of the parameter set, so the three views can never disagree.
    void publishLimiterEnabled(bool on);

    // Publishes this instance's held captures to its per-instance ext-state key
    // ("rsusage_<instanceGuid>") so the extension's prune can never reclaim them. Called at
    // the tail of every reloadInstrument, off the audio thread. Mints instanceGuid_ on
    // first need; re-mints on a detected clone (FX copy / track duplication).
    void publishUsage(const SampleRefs& refs, const std::vector<std::string>& ids);

    ReaperBridge bridge_;

    // The parameters whose drag bracket is currently open, and whether a drag is in flight at
    // all. UI thread only. Past the cap a write degrades to a one-point gesture rather than
    // dropping — the pre-bracket behaviour, not a new failure mode.
    static constexpr std::size_t kMaxOpenGestures = 8;
    instrument::param::ParamId openGestureIds_[kMaxOpenGestures] = {};
    std::size_t openGestureCount_ = 0;
    bool gestureLatching_ = false;
    // Set across setState so a LOAD is not reflected back to the host as an edit. Main thread
    // only, and non-atomic on purpose: the SDK calls setState there and nowhere else.
    bool paramNotifySuppressed_ = false;

    // The ONE live-parameter block for this instance, declared ahead of the instrument slots
    // so it outlives every snapshot that points at it (members destruct in reverse order).
    // Both live_ and draining_ observe this same block — a block owned by a snapshot would
    // leave the drain's still-sounding voices deaf to the knob under them.
    instrument::engine::LiveParams liveParams_;
    // The block the ENGINE reads, and the ONE thing SampleData::live points at. Written only by
    // the audio thread, which merges liveParams_ with the host's automation points once per block
    // and republishes ONLY when the RESULT moved — so neither an unchanged model nor a lane
    // resending the value it already sent reaches the per-voice fan-out. A block carrying no
    // automation and no model change costs one relaxed load plus, when the host passed a non-null
    // IParameterChanges (`[verify — DAW]` REAPER's normal case), one cross-module
    // getParameterCount(). Two blocks because the seqlock's single-writer contract is load-bearing
    // and the two writers differ in thread; this directory's CLAUDE.md owns the argument.
    instrument::engine::LiveParams automationLive_;
    // Audio thread only. The last liveParams_ generation merged, the last block published (the
    // republish gate compares against it), and the automation slots themselves.
    std::uint32_t seenModelGeneration_ = 0;
    instrument::engine::LiveValues lastMergedLive_{};
    bool haveMergedLive_ = false;
    // The host lane's state and its release protocol; automation_channel.h owns the mechanism and
    // this directory's CLAUDE.md the authority model it enforces.
    AutomationChannel automation_;
    // Serializes liveParams_.publish's two writer sites (reloadInstrument, publishLiveParams)
    // only — separate from reloadMutex_ so a knob drag's publish never blocks behind a
    // reload's WAV decode. The audio thread never takes this; process() only reads via
    // LiveParams::read's lock-free seqlock retry.
    std::mutex livePublishMutex_;
    // The rate the loaded capture was decoded/built at, so a live republish resolves the
    // stored wall-clock seconds to exactly the frames the built SampleData carries. 0 = nothing
    // built yet.
    //
    // ONE BLOCK, ONE RATE: this is stamped by whichever capture built last, and a reload
    // publishes the new block before installing the new instrument. Swapping to a capture at a
    // different rate therefore hands drain voices still ringing from the old-rate capture
    // envelope frame counts resolved at the NEW rate (~8.8% timing shift on a 48k->44.1k swap).
    // Unavoidable while one block sits above every snapshot, and it touches a release tail
    // only.
    std::atomic<int> builtSampleRate_{0};

    // --- The audio-thread handoff (drain slot) ---
    // process() atomically loads live_ + draining_ at block start (two acquires, no lock).
    // reloadInstrument() (off-thread, serialized by reloadMutex_) swaps a new build into
    // live_; the displaced instrument moves to draining_, where process() keeps rendering
    // its already-sounding voices (and routes note-offs to it) so a reload never cuts a
    // ringing note — new note-ons go only to live_. The instrument evicted from draining_
    // (two reloads old) parks in graveyard_ for reclaim.
    //
    // Reclaim: process() publishes the minimum installedAt it holds via processGeneration_
    // (one relaxed store); the reload path frees graveyard entries older than that. Safe
    // because both slots are monotone in installedAt, so the published minimum is monotone
    // and an entry only reaches the graveyard after leaving both slots under reloadMutex_ —
    // an entry below the published minimum can never be loaded again.
    //
    // Graveyard upper bound: reloads since process last ran (typically 0-1). Remaining
    // entries drain at setActive(false) / terminate(), when process is guaranteed stopped.
    std::atomic<LoadedInstrument*> live_{nullptr};
    std::atomic<LoadedInstrument*> draining_{nullptr};  // displaced instrument still rendering its tails
    std::atomic<std::uint64_t> reloadGeneration_{0};   // incremented by each reload (off-thread, under reloadMutex_; read atomically by process)
    std::atomic<std::uint64_t> processGeneration_{0};  // min installedAt held by process (written on audio thread, read off-thread)
    // The installedAt of the drain instrument process() last observed fully idle (0 = none /
    // still sounds). Written relaxed on the audio thread each block; read by retireIdleDrain()
    // off-thread. Naming the generation (not a bool) closes the swap race: a publication about
    // an old drain can never retire its successor.
    std::atomic<std::uint64_t> drainIdleGeneration_{0};
    std::vector<std::unique_ptr<LoadedInstrument>> graveyard_;  // drained on reclaim + setActive(false) + terminate
    std::mutex reloadMutex_;  // serializes off-thread reloads + graveyard access
    // A master gain that must reach the audio thread in the SAME swap as the next publish —
    // the bake's reset, which the old capture would be the wrong thing to apply it to.
    // Guarded by reloadMutex_, consumed by publishBuiltLocked.
    std::optional<double> gainAtNextPublish_;
    // Armed when that deferred gain lands, delivered by flushGainNotify once reloadMutex_ is
    // released. Same shape and same reason as latencyRestartPending_ (see it): a host handler
    // callback must never run inside this mutex.
    std::atomic<bool> gainNotifyPending_{false};

    // The decoded PCM parked across a deactivate, so an activation cycle costs no disk read
    // and no WAV decode: activation is "the audio thread may run", not "the sample is
    // rebuilt". Holds a value only while inactive, and any publish drops it (publishBuiltLocked
    // owns why). Voice state is deliberately NOT parked with it; see setActive. Guarded by
    // reloadMutex_.
    std::optional<SampleData> dormantSample_;

    // Whether the host has us active, so setActive can treat a repeat of the state it already
    // holds as a no-op (it owns why). Main/UI thread only, like setActive itself.
    bool active_ = false;

    // The loaded capture's id ("" = no pick -> silence). Off-thread only, not read on the
    // audio thread.
    std::mutex selectionMutex_;
    std::string selectedSampleId_;

    // The one parameter set — THE model, and the authority every other holder of these values
    // defers to. Off-thread only; reloadInstrument bakes it into the SampleData under the reload
    // lock, never read directly on the audio thread. paramsGeneration_ moves with every write, so
    // a holder of a copy can tell that it has.
    std::mutex paramsMutex_;
    InstrumentParams params_;
    std::uint32_t paramsGeneration_ = 0;

    // Instance-owned sample refs: path + intrinsics per referenced sample. Refreshed
    // opportunistically from the bank blob when readable; never a bank dependency for
    // playback. Off-thread only.
    std::mutex refsMutex_;
    SampleRefs sampleRefs_;

    // Usage-publish identity (see publishUsage). instanceGuid_ is the persisted per-instance
    // identity; usageNonce_ is this incarnation's per-lifetime owner nonce (never persisted —
    // a persisted nonce would clone with the state on FX copy, letting a sibling clean-
    // replace over another's held paths). Minted lazily; cleared on setState.
    std::mutex usageMutex_;
    std::string instanceGuid_;
    std::string usageNonce_;

    // Per-instance channel mode, default Mono; not read on the audio thread (process
    // renders against the host's negotiated channel count). channelModeExplicit_: false =
    // reloadInstrument may auto-default the mode from the loaded capture; true = the user
    // deliberately toggled it (never fought thereafter).
    std::mutex channelModeMutex_;
    ChannelMode channelMode_ = ChannelMode::Mono;
    bool channelModeExplicit_ = false;

    // The last assignment-request generation consumed, persisted so a re-open does not
    // re-apply a stale request. Default 0 -> a genuinely new first assign (gen >= 1) applies.
    std::mutex assignMarkerMutex_;
    std::int64_t lastConsumedAssignGeneration_ = 0;

    // The bank generation this instance last saw. UI/timer-thread only (pollBankSync's sole
    // reader/writer), not persisted. -1 sentinel baselines the first poll without a
    // redundant reload; a later generation change then drives the reload.
    std::int64_t lastSeenBankGeneration_ = -1;

    // Legacy-lift terminating latch: set once legacyLiftShouldRun proves the referenced ids
    // stale against a readable bank blob, so the lift stops re-firing every tick. Reset by
    // setState (a new blob = new facts).
    std::atomic<bool> legacyLiftConcluded_{false};

    // Preview-trigger velocity (MIDI 1..127, persisted). Default kPreviewVelocityDefault
    // (64). Not read on the audio thread.
    std::mutex previewMutex_;
    std::uint8_t previewVelocity_ = kPreviewVelocityDefault;

    // Voice-system parameters (per-instance, persisted). Defaults {16, Poly, Retrigger}.
    // Not read on the audio thread — reloadInstrument bakes them into the engine off-thread.
    std::mutex voiceParamsMutex_;
    int voiceCount_ = kDefaultVoiceCount;
    VoiceMode voiceMode_ = VoiceMode::Poly;
    MonoTrigger monoTrigger_ = MonoTrigger::Retrigger;

    // Post-mixer master gain (linear, persisted). Lock-free atomic target; the audio thread
    // ramps gainCurrent_ toward it per-sample (~20 ms wall-clock at every host rate) so
    // knob moves produce no zipper noise.
    std::atomic<float> masterGain_{1.0f};
    // Audio-thread running gain value, stepping at most gainRampStep_ per sample toward the
    // target. Written/read exclusively on the audio thread — no atomics needed.
    float gainCurrent_ = 1.0f;
    // Per-sample ramp step derived from kGainRampSeconds against the live host rate in
    // setupProcessing — never a baked-in rate. Default is the 48 kHz value.
    float gainRampStep_ = 1.0f / 960.0f;

    // --- Preview-trigger mailbox (off-thread -> audio thread, lock-free) -----------------
    // One slot per direction, packed as (seq << 16) | (velocity << 8) | note [on] or
    // (seq << 16) | note [off]. process() detects a new request by comparing the packed
    // sequence against the last one consumed — a single relaxed atomic load per block,
    // RT-safe (no alloc, no lock).
    std::atomic<std::uint32_t> previewOnRequest_{0};   // 0 = no request posted yet
    std::atomic<std::uint32_t> previewOffRequest_{0};
    std::uint16_t previewOnSeq_ = 0;   // UI-thread post counter (never 0 after first post)
    std::uint16_t previewOffSeq_ = 0;
    std::uint16_t previewOnConsumed_ = 0;   // audio-thread: last on-seq fired
    std::uint16_t previewOffConsumed_ = 0;  // audio-thread: last off-seq fired

    // Latched from setupProcessing; 0.0 is explicitly invalid (reloadInstrument guards on it).
    double sampleRate_ = 0.0;
    Steinberg::int32 maxBlockSize_ = 4096;

    // The embed shell, created lazily on the first queryInterface and owned here for the
    // processor's lifetime; REAPER's borrowed AddRef'd references are outlived by this
    // unique_ptr, so its own refcount is a no-op.
    std::unique_ptr<ReaSamplerEmbed> embed_;

    // The master-bus limiter, applied post-gain over the summed output. Its own enable target
    // is the mirror of params_.limiterEnabled; limiterEnabled_ is the lock-free copy
    // getLatencySamples answers from.
    instrument::engine::Limiter limiter_;
    std::atomic<bool> limiterEnabled_{false};
    // Raised (and lowered) by the commit funnel from the difference between the enable and
    // latencyAnnounced_, cleared by flushLatencyRestart on delivery and re-raised there if the
    // host refuses. A bool and not a count on purpose: the host is being told to re-ASK, so N
    // changes before one flush need exactly one restart, and whatever getLatencySamples answers
    // at that moment is the truth announced.
    std::atomic<bool> latencyRestartPending_{false};
    // The enable the host has ACCEPTED — false initially, which is what an instance that has
    // announced nothing reports. Every arm is judged against this, so a change that returns to
    // the announced state costs no restart.
    std::atomic<bool> latencyAnnounced_{false};

    // What the audio thread publishes about the output bus each block, relaxed. The peaks and
    // minGain ACCUMULATE (max / min) across every block since the UI last read, and
    // masterBusMeter() consumes them as it reads — the fix for a bar that displayed roughly one
    // block in fifty. The folds and the identity elements below are meter_accumulate's; no dB,
    // no ballistics, no hold timer here — the UI runs those off these values and its own
    // elapsed time.
    std::atomic<float> meterPeakL_{instrument::engine::kMeterPeakIdentity};
    std::atomic<float> meterPeakR_{instrument::engine::kMeterPeakIdentity};
    std::atomic<float> meterMinGain_{instrument::engine::kMeterGainIdentity};
    std::atomic<bool> meterClip_{false};

    // The embed strip's activity level: the LAST block's loudest channel, plainly overwritten.
    // Separate from the accumulators above because it answers a different question ("how loud
    // is it now") and has a different reader — sharing one would make each reader's reset
    // silently truncate the other's window.
    std::atomic<float> advisoryPeak_{0.f};
};

}  // namespace reasampler::vst
