// reasampler_editor.h — VST3 IPlugView LICE editor for the ReaSampler 9000 UI. Thin shell:
// hosts a LICE child window, routing host paint/mouse into the pure geometry modules. The
// Sample face is a three-band stack — chrome, waveform, decks — and the shell TUs split on
// that same axis; Browse is a modal picker over it. All layout/hit-test/drag math lives in
// the pure modules; every edit commits off the audio thread via reloadInstrument.

#pragma once

#include <chrono>
#include <optional>
#include <string>
#include <unordered_map>
#include <vector>

#include "public.sdk/source/common/pluginview.h"

#include "core/instrument/ui/deck_groups.h"      // DeckParam / DeckGroupId / sampleDeckGroups
#include "core/instrument/ui/editor_geometry.h"  // Rect (shared sub-rect type)
#include "core/instrument/ui/envelope_edit.h"    // EnvClampBounds / NodeHit (envelope node hit-test/edit)
#include "core/instrument/ui/envelope_overlay.h" // StageEnvelope / EnvNode (envelope overlay draw seam)
#include "core/instrument/ui/knob_deck.h"        // DeckGroupDesc / DeckLayout (the deck band)
#include "core/instrument/ui/loop_marks.h"       // LoopMarks (the loop enable's state machine)
#include "core/instrument/ui/master_meter.h"     // MasterMeterUi (the bus meter's UI-side state)
#include "core/instrument/ui/sample_bands.h"     // SampleBands (the band-stack allocator)
#include "core/instrument/ui/waveform_view.h"    // WaveMark / WaveMarks (the overlay's marks)
#include "core/instrument/ui/spline_edit.h"      // the shared point-editing grammar
#include "core/instrument/ui/sample_chrome.h"    // ChromeRects (chrome-band interior)
#include "core/audio/peaks.h"            // Envelope (the cached peak thumbnail)
#include "core/instrument/map/sample_map.h"       // SampleChoice, BankChoice, InstrumentParams
#include "core/instrument/engine/velocity_curve.h"   // VelocityCurve (transfer-curve editor state)
#include "shell/instrument/editor_interaction.h"  // DragKind / HoverKind / HoverTarget

#ifdef _WIN32
#include <windows.h>
#endif

class LICE_IBitmap;  // fwd: the paint helpers take one; lice.h is included only in the .cpp

namespace reasampler::vst {

using audio::AudioSample;
using audio::Envelope;
using instrument::map::BankChoice;
using instrument::map::InstrumentParams;
using instrument::map::PlaySeconds;
using instrument::map::SampleChoice;
using instrument::map::SampleRefEntry;
using instrument::map::SampleRefs;
using instrument::ui::ChromeRects;
using instrument::ui::DeckGroupDesc;
using instrument::ui::EnvClampBounds;
using instrument::ui::EnvNode;
using instrument::ui::OverlayArea;
using instrument::ui::Rect;
using instrument::ui::SampleBands;
using instrument::ui::StageEnvelope;

class ReaSamplerProcessor;

class ReaSamplerEditor : public Steinberg::CPluginView {
public:
    // `processor` outlives this editor; the editor reads the live bank through it and drives
    // selection/parameter edits + reload on user input. May be null (defensive; a real host
    // always supplies one).
    explicit ReaSamplerEditor(ReaSamplerProcessor* processor);
    ~ReaSamplerEditor() override;
    // backBuffer_ owns a raw LICE bitmap with no refcount behind it — a copy would double-free.
    ReaSamplerEditor(const ReaSamplerEditor&) = delete;
    ReaSamplerEditor& operator=(const ReaSamplerEditor&) = delete;

    Steinberg::tresult PLUGIN_API isPlatformTypeSupported(
        Steinberg::FIDString type) override;
    Steinberg::tresult PLUGIN_API canResize() override;
    Steinberg::tresult PLUGIN_API checkSizeConstraint(Steinberg::ViewRect* rect) override;

protected:
    void attachedToParent() override;
    void removedFromParent() override;
    Steinberg::tresult PLUGIN_API onSize(Steinberg::ViewRect* newSize) override;

private:
    // Sample is the home/default face (the three-band stack). Browse is a full-window modal
    // picker overlaid on it.
    enum class View { kSample, kBrowse };

    // Which envelope the waveform overlay is drawing and editing. The selection type and its
    // whole state machine are the pure deck_groups module's; this alias keeps the shell's
    // spelling.
    using OverlayEnv = instrument::ui::OverlayEnv;

    // Which of the three velocity curves a deck cell edits — also the popup's open state.
    using CurveTarget = instrument::ui::CurveTarget;

    // Controls on the setup surface. The int value is the opaque control id the pure
    // knob_deck hit-test returns; the shell maps it to the one parameter set or a
    // processor-side per-instance setter. The id space and the deck's group composition are
    // the pure deck_groups module's — this alias keeps the shell's spelling.
    using ParamControl = instrument::ui::DeckParam;

    // The chrome knobs are drags of kind kDeckKnob with no place in the deck's id space, so
    // they take negative sentinels (-2 is the preview velocity). Being outside
    // [0, DeckParam::kCount) is what keeps liveCommitFor answering "not a live control".
    static constexpr int kBakeHoldKnobId = -3;

    // The waveform band's four marks, in draw + hit order. The crossfade is NOT part of the
    // full-height column hit-test — it answers only in its cap (waveform_view's
    // markerHandleRect), because at a zero fade it sits exactly on the loop start.
    using WaveMarker = instrument::ui::WaveMark;

    // What those four marks are showing — see pickedMarkers.
    using SetupMarkers = instrument::ui::LoopMarks;

    // The three-band stack for the current client size, plus the chrome interior. Every
    // paint/hit-test path derives both through this one call so draw and hit-test can never
    // disagree about where a band is.
    struct FaceLayout {
        SampleBands bands;
        ChromeRects chrome;
        std::vector<DeckGroupDesc> deckDescs;
    };
    FaceLayout faceLayout(int w, int h) const;

#ifdef _WIN32
    // `dirty` is the host's WM_PAINT update rect: a region inside the meter's bar field redraws
    // that field alone, anything else re-composes the face, and either way only the dirty region
    // is blitted out of the retained back buffer (a per-paint client-area LICE_SysBitmap is what
    // the meter's rate would otherwise cost).
    void paint(HDC hdc, const RECT& dirty);
    LICE_IBitmap* ensureBackBuffer(int w, int h);
    void releaseBackBuffer();
    void paintSample(LICE_IBitmap* bmp, int w, int h);        // home face (band composition)
    void paintBrowse(LICE_IBitmap* bmp, int w, int h);        // modal picker overlay
    void paintEmptyState(LICE_IBitmap* bmp, const Rect& area);

    // --- Band painters (one TU each, mirroring the input side) ---
    // Chrome: title band + Browse nav + the control row (root strip, preview, velocity knob,
    // channel toggle).
    void paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool empty,
                     const SetupMarkers& marks);
    // The hovered piano key's note-name chip. Drawn after every band — it overhangs the
    // chrome into whatever is below it.
    void paintChromeTooltip(LICE_IBitmap* bmp, const FaceLayout& fl, int w, int h);
    // Waveform: the channel lane(s), the four marks, and the envelope overlay. `marks` is
    // resolved once per paint by paintSample — see there.
    void paintWaveform(LICE_IBitmap* bmp, const Rect& band, const SetupMarkers& marks);
    // Decks: the group fence + caption + compact caption toggles + radial knobs with
    // label<->value swap on hover/drag.
    void paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl);
    void paintMeterField(LICE_IBitmap* bmp);  // the bar field alone, off meterRects_

    // A deck cell's mini curve thumbnail (the VELOCITY group) and the modal editor it summons.
    void paintCurveButton(LICE_IBitmap* bmp, const Rect& r, CurveTarget target, bool disabled,
                          bool hovered);
    void paintCurvePopup(LICE_IBitmap* bmp, int w, int h);
    // The velocity transfer-curve editor (X = velocity 0-127, Y = the curve's own domain); its
    // only host is the popup sheet. `r` empty -> draws nothing.
    void paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r);
    // Traces the overlay-active envelope + its draggable handles over `waveArea`, ONCE at
    // full band height (never per lane). Dispatches on the envelope's mode: the staged
    // polyline, or the drawn contour below.
    void paintEnvelopeOverlay(LICE_IBitmap* bmp, const OverlayArea& waveArea, std::int64_t frames);
    // The spline EG's contour + point handles, spanning the overlay 1:1 with the sample's time
    // axis. Hard points draw hollow so a corner is legible before it is steep.
    void paintSplineOverlay(LICE_IBitmap* bmp, const OverlayArea& waveArea);

    // A click on the spline overlay, routed through the shared grammar; true when it consumed
    // the click. `addOnEmptySpace` false resolves node actions only — the waveform band calls
    // it that way BEFORE the start/loop markers and again after, since the contour's box is the
    // whole band and an unconditional add would make every marker unreachable.
    bool splineOverlayClick(const OverlayArea& waveArea, int x, int y,
                            instrument::ui::SplineGesture gesture, bool addOnEmptySpace);

    // --- Input: the mouse-down dispatch and its per-band branches ---
    void onMouseDown(int x, int y);
    // Each returns true when it consumed the click. Called in band order by onMouseDown.
    bool mouseDownChrome(const FaceLayout& fl, int x, int y);
    bool mouseDownWaveform(const FaceLayout& fl, int x, int y);
    bool mouseDownDeck(const FaceLayout& fl, int x, int y);
    void mouseDownBrowse(int w, int h, int x, int y);

    // Double-click = reset the radial knob under the pointer to its default. Returns false when
    // no knob claims the point, and the caller then replays it as an ordinary mouse-down (the
    // platform note at the window class explains why that fall-through is load-bearing).
    bool onMouseDoubleClick(int x, int y);
    bool doubleClickChrome(const FaceLayout& fl, int x, int y);
    bool doubleClickDeck(const FaceLayout& fl, int x, int y);

    // Whether deck knob `id` belongs to a group whose enable toggle is off. The ONE predicate
    // behind both the Disabled paint and the inert grab, so they cannot disagree.
    bool deckKnobDisabled(int id) const;

    // Live drag resolution, split on the same axis; each handles only its own DragKind
    // values and is called from onMouseMove's router.
    void dragChrome(const FaceLayout& fl, int x, int y);   // kRootMarker
    void dragWaveform(const FaceLayout& fl, int x, int y); // kEnvNode / kWaveMarker
    void dragDeck(int x, int y);                           // kDeckKnob
    void dragBrowse(int x, int y);                         // kScrollThumb
    void dragCurve(int x, int y);                          // kCurveNode

    void onMouseMove(int x, int y);
    void onMouseUp(int x, int y);
    // Right-click is the primary node-delete affordance on both spline surfaces — the popup
    // while it is open, else the spline EG overlay (deletePoint's endpoint guard makes an
    // endpoint right-click a no-op).
    void onMouseRDown(int x, int y);

    // Mouse-down inside curve-editor box `r`, resolved through the shared point-editing
    // grammar (spline_edit): a node grab starts a kCurveNode drag, an empty-space click adds a
    // point and grabs it, control-click toggles a node hard/smooth.
    void handleCurveMouseDown(const Rect& r, int x, int y);

    // Left-click while the curve popup is open (modal over the Sample face): Close /
    // outside-wash dismiss, in-box clicks route to the curve machinery, else swallowed.
    // Returns true whenever the popup is open (it consumed the click).
    bool handlePopupMouseDown(int w, int h, int x, int y);

    // Resolves the interactive element under (x, y) into hover_, called from WM_MOUSEMOVE.
    // Repaints only on change, so an idle move is free. The per-band resolvers mirror the
    // mouse-down branches but are read-only. Windows-only.
    void resolveHover(int x, int y);
    HoverTarget hoverChrome(const FaceLayout& fl, int x, int y) const;
    // Marks only — the cap strip. Everything else in the band already reports its own state
    // through the drag, so nothing else on the overlay needs a hover.
    HoverTarget hoverWaveform(const FaceLayout& fl, int x, int y);
    HoverTarget hoverDeck(const FaceLayout& fl, int x, int y) const;
    HoverTarget hoverBrowse(int w, int h, int x, int y) const;
    HoverTarget hoverCurvePopup(int w, int h, int x, int y) const;
    bool isHovered(HoverKind kind, int index) const {
        return hover_.kind == kind && hover_.index == index;
    }
    void onMouseWheel(int delta);           // browser scroll (wheel)
    void onSearchChar(unsigned int ch);     // type-to-filter search keystroke

    // An OS file drop landed on the editor window. We do NOT ingest (read-only bank
    // consumer) — flash a "drop on the ReaSampler panel to add" affordance instead of
    // silently swallowing it. Never inserts a timeline item.
    void onFilesDropped(int droppedCount);

    // The change-detection tick (WM_TIMER, UI thread only): polls the processor's bank-sync
    // and re-snapshots + repaints when anything changed. Suppressed mid-drag so a reload
    // never yanks the edit surface.
    void onSyncTimer();

    // The meter tick (its own WM_TIMER id, UI thread only): drains the bus, advances the
    // ballistics, repaints the bar field. Its own clock — see editor_platform.cpp's timer ids.
    void onMeterTimer();

    static LRESULT CALLBACK wndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam);
    void invalidate();
    void invalidateMeter();  // the bar field only; falls back to invalidate() before first paint

    HWND childHwnd_ = nullptr;
    LICE_IBitmap* backBuffer_ = nullptr;  // owned; see ensureBackBuffer
#endif

    // Re-read the bank (samples + banks) from the live bridge and snapshot the instrument's
    // selection + parameter set. Main/UI thread only. Called on attach and after any edit.
    void refreshFromBank();

    // Publishes the edited selection + parameters to the processor, then rebuilds the
    // instrument off the audio thread. UI thread only.
    void commitAndReload();

    // The live peer of commitAndReload for a continuously-valued control (deckParamCommit):
    // the same parameter-set write — so a saved project carries the edit exactly as before —
    // followed by a live publish instead of a rebuild, so the note already sounding follows
    // the knob. Does not repaint; callers already do. UI thread only.
    void commitLive();

    // Whether an in-flight drag commits live rather than through a reload. A deck knob is
    // live per deckParamCommit; an envelope-node drag is live in EITHER mode — see
    // liveCommitFor (deck_groups.h) for why.
    bool dragCommitsLive(DragKind kind, int paramId = -1) const;

    // The deck knobs whose value lives on the processor or on the editor rather than in the
    // parameter set: the preview-velocity sentinel, voice count, master gain. applyDeckKnob
    // writes each straight through, so params_ is not a rollback target for them and
    // commitLive has nothing of theirs to push. Every drag path that ends a gesture — release,
    // reset, capture-lost, per-move — asks this same question.
    static bool deckKnobIsProcessorSide(int paramId);

    // Commits `id` as the loaded capture. The one parameter set carries over — it governs
    // whatever is loaded, so a load swaps the sound, not the settings.
    void loadSelection(const std::string& id);

    // Recomputes the visible capture cards (samples_ narrowed by activeFilterBankId_ then
    // search) into visible_. Called on refresh + filter change.
    void rebuildVisible();

    // The peak thumbnail for a bank sample id at `binCount` bins, cached by (id, binCount).
    // Empty envelope when the WAV can't be resolved/decoded. UI thread only (file I/O).
    const Envelope& thumbnailFor(const std::string& sampleId, int binCount);

    // The decoded mono PCM for a bank sample id, cached by id — feeds both the binned
    // waveform envelope and the zero-crossing snap. Empty vector on decode failure. UI
    // thread only (file I/O); cleared with the thumbnail cache on refresh.
    const std::vector<AudioSample>& monoPcmFor(const std::string& sampleId);

    // The hero waveform's binned envelope: the band's most expensive draw, and a function of the
    // capture and the bin count alone. UI thread only (may decode).
    const Envelope& heroEnvelope(std::size_t bins, int laneCount);

    // The interleaved source PCM behind the stereo waveform lanes.
    struct ChannelPcm {
        std::vector<AudioSample> interleaved;  // frame-interleaved source frames
        int channelCount = 0;                  // 0 = nothing decoded
        Envelope heroEnv;          // binned from `interleaved`; cleared whenever it is
        std::size_t heroBins = 0;  // 0 = not computed
        std::int64_t frameCount() const {
            return channelCount > 0
                       ? static_cast<std::int64_t>(interleaved.size()) / channelCount
                       : 0;
        }
    };

    // The interleaved PCM + channel count for a bank sample id. SINGLE-SLOT by design: the
    // waveform band draws one capture at a time, while monoPcmFor's cache spans every
    // browsed card — holding interleaved PCM there would pin a whole bank at multi-channel
    // size. A miss re-decodes (only on selection change or a bank refresh; an edit commit
    // does not clear it). UI thread only (file I/O).
    const ChannelPcm& channelPcmFor(const std::string& sampleId);

    // The project-relative WAV path for a bank sample id: the live bank blob first, the
    // instance's own SampleRefs as the self-contained fallback. "" when unresolvable.
    std::string samplePathFor(const std::string& sampleId) const;

    // The effective loop + start markers for the loaded capture; the state machine behind them
    // is the pure loop_marks module's. With no override set this costs a bridge read plus a bank
    // parse — hoverWaveform calls it on every WM_MOUSEMOVE, so the answer is memoized (see
    // HoldNeedKey below, which this shares) rather than paid per move.
    SetupMarkers pickedMarkers(std::int64_t frames) const;

    // Whether the loop controls answer at all: the sustain loop is Gate-only, so in Trigger the
    // marks and the chrome enable draw Disabled and inert. Reads the mode AFTER the drawn-EG
    // fold, so a drawn envelope disables them through the same predicate.
    bool loopControlsLive() const;

    // Which marks the band DRAWS, and which of those accept a grab. They differ in exactly one
    // place — Trigger, where the loop marks stay drawn (hiding a set loop on a mode flip would
    // destroy information the user put there) but refuse every gesture, because that refusal
    // comes from the engine and no drag can talk it out of it.
    instrument::ui::WaveMarks waveMarksFor(const SetupMarkers& m) const;
    instrument::ui::WaveMarks grabbableMarks(const SetupMarkers& m) const;

    // Flips the enable. `on` false retains the span and the crossfade — that retention is the
    // whole difference between a toggle and a delete button.
    void setLoopEnabled(bool on);

    // Whether the loaded sound's bake window needs the user's Hold — the pure predicate
    // (bake_plan.h) answered against the markers this face is showing. Decodes and reads the
    // bank, so it is called on the sync tick, not per paint, and memoized against the inputs
    // below on top of that.
    bool resolveBakeHoldNeeded();

    // What that answer was last computed against. Invalidated wholesale by refreshFromBank,
    // which is where the bank half of the input changes. pickedMarkers' own memo (below) shares
    // this key shape rather than declaring a second one; resolveBakeHoldNeeded leaves startPoint
    // at its default (nullopt) since its own answer doesn't depend on it.
    struct HoldNeedKey {
        std::string sampleId;
        std::optional<SampleLoop> loopOverride;
        std::int64_t crossfade = 0;
        std::optional<std::int64_t> startPoint;
        bool operator==(const HoldNeedKey& other) const;
    };
    HoldNeedKey holdNeedKey_;
    bool holdNeedValid_ = false;
    bool holdNeedAnswer_ = false;
    mutable HoldNeedKey marksKey_;
    mutable SetupMarkers marksCache_;
    mutable bool marksValid_ = false;

    // Writes `m` into params_ as the loop/start override. Does NOT call commitAndReload —
    // callers decide live-drag vs final commit.
    void applyMarkers(const SetupMarkers& m);

    // Arms a waveform-marker drag: the grabbed marker plus the snapshot the pixel-delta
    // resolver and the inter-marker clamps measure from.
    void beginMarkerDrag(WaveMarker which, const SetupMarkers& m, std::int64_t frames, int x);

    // Deck knobs edit the parameter set's PlaySeconds (play mode + AHDSR; pitch engine + AD
    // pitch envelope) — wall-clock seconds, rate-free; the build resolves to frames. The three
    // adapters below are thin int-id wrappers over the pure `deck_values` module, which owns
    // what each control's value means; see its header rather than restating the domains here.
    double controlValue(int id, const PlaySeconds& play) const;
    void applyControl(int id, PlaySeconds& play, double value, int segment) const;
    void resetParamControl(int id);

    // Applies a knob/toggle interaction to the ONE parameter set for control `id`: ordinary
    // controls route through applyControl; kKeyTrack writes the keyTrack scalar (0..200%
    // over the knob's 0..1).
    void applyParamControl(int id, double value, int segment);

    // The overlay speaks one StageEnvelope whichever envelope is active; pack/unpack are the
    // only place that knows which stored struct each `which` maps onto, so the drawn shape and
    // a committed node drag can never disagree about it. AHDSR seconds are rate-free and copy
    // 1-to-1; an AHD additionally needs the wall-clock span its Hold fraction is taken against,
    // which is where `frames`/`startFrame` and the live rate come in.

    // PACK (draw): play params -> StageEnvelope. `startFrame` is the effective start point.
    StageEnvelope packEnvelope(OverlayEnv which, const PlaySeconds& play, std::int64_t frames,
                               std::int64_t startFrame) const;

    // UNPACK (commit): an edited StageEnvelope -> the play params, in place.
    void unpackEnvelope(OverlayEnv which, const StageEnvelope& env, PlaySeconds& play) const;

    // Clamp bounds envelope_edit uses, matching the sliders' own domains so a node drag can
    // never produce a param a slider couldn't.
    EnvClampBounds envClampBounds() const;

    // The effective root: params_.rootOverride, else the bank intrinsic, else middle C.
    int effectiveRoot() const;
    // The live sample rate from the bridge, or 0 when unavailable (caller guards).
    double liveSampleRate() const;
    // Persisted preview velocity as a 0..1 slider value (MIDI 1..127 -> [0,1]).
    double previewVelocity01() const;
    // The bake Hold division as a 0..1 knob position, via the pure bake_hold map.
    double bakeHoldNorm() const;

    // The normalized [0,1] value a deck knob shows — parameter-set ids route through
    // controlValue/keyTrack; processor-side ids (voice count, master gain, preview velocity
    // via the -2 sentinel) read the processor's live value.
    double deckControlNorm(int id) const;

    // Applies a deck-knob value: parameter-set ids write params_ (commit on release);
    // processor params write through the processor setters immediately (transient — no
    // params edit, no reload).
    void applyDeckKnob(int id, double norm);

    // The knob's value in the REAL unit the host is told about (core/instrument/param).
    double deckPlainValue(int id) const;

    // The knob's live value label shown during hover/drag: the ONE per-category formatter's
    // digits plus this surface's own static chrome (the unit suffix, a curve dial's caret).
    // Empty for a control with no plain-value layer.
    std::string deckValueLabel(int id) const;

    ReaSamplerProcessor* processor_ = nullptr;

    // --- Snapshot of the live bank (drawn each paint; refreshed off the audio thread) ---
    std::vector<SampleChoice> samples_;   // every bank sample, bank order
    std::vector<BankChoice> banks_;       // the named banks, for the filter tab strip
    std::vector<SampleChoice> visible_;   // samples_ narrowed by the active bank filter
    std::string selectedId_;              // the loaded capture ("" = empty state)
    InstrumentParams params_;             // a CACHE of the processor's model (see below)
    // The model generation params_ was taken at. That copy is authoritative only for the
    // duration of a gesture; between gestures the sync tick re-seeds it whenever this differs
    // from the processor's, because a commit writes the WHOLE set back.
    std::uint32_t seenParamsGeneration_ = 0;
    ChannelMode channelMode_ = ChannelMode::Mono;  // mono/stereo toggle snapshot

    // Mirrors of the processor's persisted voice-system params, refreshed with the rest of the
    // live snapshot; every edit writes through the processor setters (which rebuild the engine
    // off-thread via the drain-slot swap).
    int voiceCount_ = kDefaultVoiceCount;
    VoiceMode voiceMode_ = VoiceMode::Poly;
    MonoTrigger monoTrigger_ = MonoTrigger::Retrigger;

    // Transient UI state (not persisted; component state carries selection + parameters).
    View view_ = View::kSample;           // default face is the loaded-sample home
    std::string activeFilterBankId_;      // "" = All; else a bank id from banks_

    // The Browse overlay is a select-then-confirm picker: a click marks a pending pick;
    // Confirm/double-click commits it + reloads; Cancel discards it. "" = nothing picked.
    std::string browsePendingId_;
    int lastBrowseClickCard_ = -1;        // for double-click-to-load detection (visible_ index)

    // The MIDI note the preview button is currently sounding (held Gate voice), or -1 when
    // up. One note at a time — a fresh press releases the prior.
    int previewingNote_ = -1;

    // Resample bake — the click only ARMS it (see this directory's CLAUDE.md Gotchas for why).
    bool bakePending_ = false;
    // Whether the extension's bake action is registered, resolved on the same tick that
    // governs the button's paint, so the control is never enabled and then refusing.
    bool bakeAvailable_ = false;
    // Whether the bake's window still needs the Hold control (bake_plan.h's
    // bakeWindowNeedsHold). Resolved on the same tick as bakeAvailable_ rather than per paint:
    // answering it costs a bridge read + bank parse whenever no loop override is set.
    bool bakeHoldNeeded_ = false;
    std::string bakeMessage_;   // last outcome, shown in the title band
    int bakeMessageTicks_ = 0;  // sync ticks the message survives

    // The editor-drop -> extension-ingest relay is not shipped (the bridge is read-only):
    // an OS drop just flashes a "drop on the panel instead" banner (dropHintTicks_ counts
    // down via the sync tick). Never ingests, never inserts a timeline item.
    int dropHintTicks_ = 0;               // remaining sync ticks to show the drop affordance

    // Browser scroll + search (transient UI state, never persisted).
    int scrollOffset_ = 0;                // vertical px offset into the card grid (clamped)
    std::string searchQuery_;             // type-to-filter narrow; "" = no search
    bool searchFocused_ = false;          // whether the search box has keyboard focus

    // The MASTER deck's meter, advanced from the published block magnitudes on its own timer
    // (see onMeterTimer for why it runs mid-drag too, and why the first read is discarded).
    // A default-constructed meterTick_ means never ticked. meterRects_ is the column interior
    // resolved at the last FULL paint; an empty field is ambiguous alone (no layout yet, or
    // genuinely off screen) — invalidateMeter asks view_/curvePopup_/selectedId_ to tell which.
    instrument::ui::MasterMeterUi masterMeter_;
    std::chrono::steady_clock::time_point meterTick_{};
    instrument::ui::MeterRects meterRects_;

    // Hover state (transient, never persisted).
    HoverTarget hover_;                   // the interactive element under the pointer
#ifdef _WIN32
    bool mouseTracking_ = false;          // TrackMouseEvent armed for WM_MOUSELEAVE this "over" cycle
#endif

    // Drag-state machine.
    DragKind drag_ = DragKind::kNone;
    int dragStartX_ = 0;                  // grab x (px), for the pixel-delta resolver
    int dragStartY_ = 0;                  // grab y (px), for the vertical scrollbar-thumb drag
    int dragCurX_ = 0;                    // live cursor x (px) during a drag — updated in onMouseMove
    int dragCurY_ = 0;                    // live cursor y (px) during a drag — updated in onMouseMove
    InstrumentParams dragStartParams_;    // params_ snapshotted at grab; restored on capture-loss

    // Waveform-marker drag: which marker + the marker set snapshotted at grab time, so the
    // pixel-delta resolver shifts from the grab-time value and inter-marker clamps use the
    // sibling markers.
    WaveMarker waveMarker_ = WaveMarker::kStart;
    SetupMarkers dragStartMarkers_;
    std::int64_t dragSampleFrames_ = 0;   // decoded length of the sample under the drag

    // Scrollbar-thumb drag: the offset at grab time. kDeckKnob drag: which control id.
    int dragStartScrollOffset_ = 0;
    int dragParamId_ = -1;           // control id under a kDeckKnob drag; -2 = preview-vel knob
    // The cell whose INNER dial is under a kDeckKnob drag (dragParamId_ then holds the curve
    // control), so the paint side can light the right ring. -1 when the grab was the outer knob.
    int dragInnerCellId_ = -1;

    // Which envelope the overlay draws and edits (kNone = none, the opening state).
    OverlayEnv overlayEnv_ = OverlayEnv::kNone;

    // Envelope-node drag: which node + the StageEnvelope snapshotted at grab (absolute-delta
    // contract, per envelope_edit's grabEnv).
    EnvNode envNode_ = EnvNode::Origin;
    StageEnvelope dragStartEnv_{};

    // Velocity-curve node drag: which point, the curve snapshotted at grab
    // (resolvePointDrag's absolute-delta contract), and the grab-time box rect.
    int curvePointIndex_ = -1;
    VelocityCurve dragStartCurve_ = VelocityCurve::flat();
    Rect dragCurveRect_{};

    // Deck-knob drag: the normalized value at grab — knobDragValue maps the vertical pixel
    // delta from this anchor, so a grab never jumps the value.
    double dragKnobStartValue_ = 0.0;

    // The modifier state the in-flight drag is anchored to. Every transition of it — press OR
    // release — RE-ANCHORS the drag: current value and current cursor become the new origin, so
    // the value is continuous across the flip and only the rate changes. Without that, rescaling
    // an accumulated absolute delta in place jumps by (1 - kFineDragScale) x the accumulation.
    instrument::ui::DragModifiers dragMods_{};

    // Which velocity curve the popup is editing; kNone = closed. Never persisted. Every writer
    // of kNone must also cancel a live curve-node drag (closeCurvePopup does both) — an Esc
    // mid-drag that closed the popup without cancelling the drag used to leave editedCurve()'s
    // mutable overload aliasing the amp curve underneath an in-flight pitch/filter drag.
    CurveTarget curvePopup_ = CurveTarget::kNone;
    void closeCurvePopup();

    // The group-gate state the two pure inert predicates read, built once from the parameter
    // set so paint and hit-test can never assemble it differently.
    instrument::ui::DeckEnableState deckEnableState() const;

    // THE one switch from an overlay selection to the drawn contour it names, mirroring
    // curveFor. kNone reads as amp — harmless for a target-agnostic caller, and every mutating
    // path is gated on overlayIsSpline() first.
    const VelocityCurve& splineFor(OverlayEnv which) const;
    VelocityCurve& splineFor(OverlayEnv which);

    // Whether the overlay-active envelope is in Spline mode — the branch every overlay paint,
    // hit-test and drag path takes before touching either model.
    bool overlayIsSpline() const;

    // THE one switch from a CurveTarget to the parameter-set curve it names — paint (button
    // thumbnails, for every target) and edit (editedCurve, for curvePopup_ specifically) both
    // route through it, so a fourth curve or a moved field is a one-place edit.
    const VelocityCurve& curveFor(CurveTarget target) const;
    VelocityCurve& curveFor(CurveTarget target);

    // The curve curvePopup_ names — curveFor(curvePopup_), typed as its own pair because every
    // edit path needs the mutable overload and paint needs the const one. The mutable overload
    // refuses kNone (a closed popup has nothing open to edit) rather than aliasing amp.
    VelocityCurve& editedCurve();
    const VelocityCurve& editedCurve() const;

    // Peak-thumbnail cache (mirror of bank_panel), keyed by "id|binCount" so a resize
    // recomputes at the new width. Cleared on refresh so a stale sample never shows.
    std::unordered_map<std::string, Envelope> thumbCache_;

    // Decoded mono-PCM cache, keyed by id (width-independent). Feeds the waveform envelope
    // binning + zero-crossing snap. Cleared alongside thumbCache_ on refresh.
    std::unordered_map<std::string, std::vector<AudioSample>> pcmCache_;

    // The single-slot interleaved-PCM cache behind channelPcmFor (see its note on why this
    // is not keyed into pcmCache_). Cleared alongside pcmCache_ on refresh.
    std::string channelPcmId_;
    ChannelPcm channelPcm_;
};

}  // namespace reasampler::vst
