// reasampler_editor.cpp — see reasampler_editor.h. The IPlugView<->LICE bridge for the
// ReaSampler 9000 capture-first editor (Phase S10). Windows-only (D5); the whole file is
// guarded so a non-Windows build (not a target) degrades to the CPluginView defaults.

#include "reasampler_editor.h"

#include <algorithm>
#include <cstdint>
#include <fstream>
#include <string>
#include <vector>

#include "browser_scroll.h"    // S12 scroll-window + thumb + type-to-filter search geometry
#include "capture_browser.h"
#include "capture_paths.h"     // resolveBankFile (shared M4 path resolution)
#include "component_geometry.h"  // KitBox — the kit text/fill draw box (Phase L, L3)
#include "draw_kit.h"          // the L1 draw kit: fillSurface/drawButton/text/drawWaveform (L3)
#include "editor_geometry.h"   // Rect, contains
#include "ext_keys.h"
#include "keyboard_strip.h"
#include "theme.h"             // Role / InteractionState / KitColor / spectralColor (L3)
#include "note_entry.h"        // S12 direct numeric note-entry parse
#include "param_slider.h"      // S12/S15/S16 control-surface layout + value<->pixel mapping
#include "peaks.h"             // computeEnvelope
#include "reaper_bridge.h"
#include "reasampler_processor.h"
#include "app_version.h"       // vstPluginName (channel-derived editor title band, S18)
#include "sample_map.h"
#include "wav_trim.h"          // parseWavLayout, extractFloatFrames
#include "waveform_view.h"     // frame<->pixel markers + zero-crossing snap (S11)

#ifdef _WIN32
#include <windowsx.h>  // GET_X_LPARAM / GET_Y_LPARAM
#include <shellapi.h>  // DragAcceptFiles / DragQueryFile / DragFinish — S13 editor drop-accept

#include "wdltypes.h"
#include "lice/lice.h"
#endif

using namespace Steinberg;

namespace reasampler::vst {

namespace {
#ifdef _WIN32
constexpr const wchar_t* kChildClassName = L"ReaSampler9000VstEditor";

// The S9/S8 change-detection poll (WM_TIMER on the child window). A low-frequency UI-thread
// timer: responsive enough that a recapture/ingest/assign refreshes "within a bounded cadence"
// (the S9 verify criterion) yet cheap — three small ext-state reads per tick, coalescing many
// bumps between ticks into one reload. 500 ms is a deliberate build-time residual: fast enough
// to feel hands-free, slow enough to be free. The id is a per-window SetTimer id (any nonzero).
constexpr UINT_PTR kSyncTimerId = 1;
constexpr UINT     kSyncTimerIntervalMs = 500;

// Top-level band metrics (shell arithmetic — the load-bearing card/tab/key/zone geometry
// is in capture_browser / keyboard_strip). The title band names the plugin + a live
// readout; the toggle band carries the Browser/Zones switch; the setup band (single-
// capture face) hosts the keyboard strip + level readout under the browser.
constexpr int kTitleHeight = 24;
constexpr int kToggleHeight = 22;
constexpr int kSetupHeight = 176;  // the single-capture setup surface (labels + waveform + strip)
constexpr int kStripBandHeight = 40;
constexpr int kWaveformHeight = 72;  // the S11 waveform band inside the setup surface

// Marker roles (Phase L, L3) — semantic, drawn through the kit's palette. The waveform's
// start point + the sustain-loop ends are CATEGORICAL kinds (a distinct affordance class,
// §2.1), not the live/active layer, so they take the categorical accents: start = teal
// (secondary), loop start/end = purple (tertiary). The loop-span fill is a faint purple.
constexpr Role kRoleStartMarker = Role::AccentSecondary;
constexpr Role kRoleLoopMarker  = Role::AccentTertiary;

// --- Rect <-> kit adapters (Phase L, L3) -------------------------------------
//
// The editor's own sub-rect type is `Rect` (editor_geometry); the kit draws against `KitBox`
// (component_geometry). This is the single boundary that bridges them so every draw routes
// through the L1 kit (theme roles + draw_kit), retiring the shell's raw LICE_RGBA palette +
// GDI DrawTextA path.
KitBox toKitBox(const Rect& r) {
    return KitBox{r.left, r.top, r.width(), r.height()};
}

// Kit text in a palette ROLE (the common case). Left/Right/Center via Align.
void kitText(LICE_IBitmap* bmp, const Rect& r, const char* s, Font font, Role role,
             Align align = Align::Left) {
    text(bmp, toKitBox(r), s, font, role, align);
}

void kitTextCentered(LICE_IBitmap* bmp, const Rect& r, const char* s, Font font, Role role) {
    text(bmp, toKitBox(r), s, font, role, Align::Center);
}

// A short MIDI-note label ("C4", "F#3") for the root badge. Middle C (60) is C4 (the
// common DAW convention REAPER uses).
std::string noteLabel(int note) {
    static const char* kNames[12] = {"C",  "C#", "D",  "D#", "E",  "F",
                                     "F#", "G",  "G#", "A",  "A#", "B"};
    if (note < 0) note = 0;
    if (note > 127) note = 127;
    const int octave = note / 12 - 1;  // MIDI 0 = C-1; 60 = C4
    return std::string(kNames[note % 12]) + std::to_string(octave);
}

// Draw a peak envelope in `r` through the kit's shared waveform primitive (Phase L, L3):
// midline + accent-primary min/max columns with the same dB display compression the dock
// panel thumbnail uses, so a waveform reads identically wherever it is drawn. The caller has
// already filled the surface behind it (bg/panel), matching drawWaveform's contract.
void drawEnvelope(LICE_IBitmap* bmp, const Rect& r, const Envelope& env) {
    drawWaveform(bmp, toKitBox(r), env);
}

// A display name for a bank sample id from the snapshotted list ("?" if the id no longer
// resolves — e.g. a zone naming a deleted sample).
std::string sampleLabel(const std::vector<SampleChoice>& samples, const std::string& id) {
    for (const SampleChoice& c : samples) {
        if (c.id == id) return c.displayName.empty() ? c.id : c.displayName;
    }
    return "?";
}

// The bin count a card's thumbnail is computed at: the card thumbnail width, so one bin
// per horizontal pixel.
int thumbBins(const BrowserLayout& layout) {
    return (std::max)(1, cardThumbnailRect(layout, 0).width());
}
#endif
}  // namespace

ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor)
    : CPluginView(nullptr), processor_(processor) {
    // Default view size — 840×560 gives comfortable room for the three-band Sample face
    // on a 1080p screen (an interim canvas; Wave 2 tunes final band-height numbers).
    ViewRect r(0, 0, 840, 560);
    setRect(r);
}

void ReaSamplerEditor::refreshFromBank() {
    // Main/UI thread only — reads the live bank over the bridge (allocates, calls REAPER).
    thumbCache_.clear();  // a bank edit may have re-captured/removed a sample; drop stale peaks
    pcmCache_.clear();    // and its decoded PCM (the S11 waveform + snap source)
    if (!processor_) {
        samples_.clear();
        banks_.clear();
        visible_.clear();
        selectedId_.clear();
        map_.zones.clear();
        selectedZone_ = -1;
        return;
    }
    auto banksJson = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey);
    samples_ = banksJson ? listSamples(*banksJson) : std::vector<SampleChoice>{};
    banks_ = banksJson ? listBanks(*banksJson) : std::vector<BankChoice>{};
    selectedId_ = processor_->selectedSampleId();
    map_ = processor_->performanceMap();
    channelMode_ = processor_->channelMode();
    if (selectedZone_ >= static_cast<int>(map_.zones.size())) selectedZone_ = -1;
    // Drop a filter that names a bank no longer present.
    if (!activeFilterBankId_.empty()) {
        bool found = false;
        for (const BankChoice& b : banks_) if (b.id == activeFilterBankId_) found = true;
        if (!found) activeFilterBankId_.clear();
    }
    rebuildVisible();
}

void ReaSamplerEditor::rebuildVisible() {
    // S12 composition: the bank filter picks the bank FIRST, then the type-to-filter search
    // narrows the survivors by name substring (nameMatchesQuery — empty query is the identity).
    visible_.clear();
    for (const SampleChoice& s : samples_) {
        const bool inBank = activeFilterBankId_.empty() || s.bankId == activeFilterBankId_;
        if (!inBank) continue;
        const std::string& name = s.displayName.empty() ? s.id : s.displayName;
        if (nameMatchesQuery(name, searchQuery_)) visible_.push_back(s);
    }
    // NOTE: scrollOffset_ is clamped at paint + wheel time (where the browser layout / panel
    // height is known); rebuildVisible runs cross-platform + on the sync-timer refresh, so it
    // must not reset the user's scroll here.
}

#ifdef _WIN32
// Windows-only (the WM_TIMER cadence + invalidate() are the Win32 child-window path). Declared
// under the same _WIN32 guard in the header; keep the definition guarded to match (D5 makes
// Windows the only build target, but the TU must still compile elsewhere).
void ReaSamplerEditor::onSyncTimer() {
    // UI thread (WM_TIMER). Poll the S9 bank generation + the S8 assignment request via the
    // processor (off the audio thread — the poll itself never touches process()). NEVER while a
    // drag is in flight: a reload mid-drag would rebuild the instrument and repaint under the
    // user's cursor, yanking the edit. The next tick (500 ms) picks up the change after release.
    if (!processor_) return;
    if (drag_ != DragKind::kNone) return;  // defer past the in-flight edit

    // An open editor marks THIS instance the focused assignment target (the thundering-herd
    // policy — only an editor-open instance applies a pending assign; see the handoff). Pass
    // true so this instance consumes the request; instances with no editor open do not poll at
    // all (the timer is bound to the child window), so they never contend for the request.
    const ReaSamplerProcessor::BankSyncResult r = processor_->pollBankSync(/*isFocusedTarget=*/true);

    // Re-snapshot the editor's own view only when something changed (a reload from a bank
    // content change, or an applied assignment). refreshFromBank re-reads the bank blob + the
    // processor's (possibly just-updated) selection/map and drops the stale thumbnail/PCM
    // caches, then repaints — so the browser + setup surface reflect the new bank hands-free.
    if (r.reloaded || r.applied) {
        refreshFromBank();
        invalidate();
    }

    // S13: decay the drop-affordance banner so it auto-dismisses a few ticks after a drop.
    if (dropHintTicks_ > 0) {
        --dropHintTicks_;
        invalidate();
    }
}
#endif  // _WIN32

void ReaSamplerEditor::commitAndReload() {
    // UI thread only. Publish the edited selection + zones to the processor, then rebuild
    // the instrument off the audio thread (reloadFromBank bakes them into the live Keymap).
    if (!processor_) return;
    processor_->setSelectedSampleId(selectedId_);
    processor_->setPerformanceMap(map_);
    processor_->reloadFromBank();
#ifdef _WIN32
    invalidate();
#endif
}

ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t frames) const {
    SetupMarkers m;
    // Seed from the bank's S2 intrinsic loop (fact about the file), then let a per-zone override
    // for the picked id win (the instrument's performance choice, D-B). Read the loop intrinsic
    // from the live bank blob (the same path selectSample uses); the override lives in map_.
    if (processor_) {
        auto banksJson =
            processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey);
        if (banksJson) {
            if (auto sel = selectSample(*banksJson, selectedId_)) {
                if (sel->loop.hasLoop) {
                    m.hasLoop = true;
                    m.loopStart = sel->loop.start;
                    m.loopEnd = sel->loop.end;
                }
            }
        }
    }
    // The override (loop + start) on a zone for the picked id supersedes the intrinsic.
    for (const PerformanceZone& z : map_.zones) {
        if (z.sampleId != selectedId_) continue;
        if (z.loopOverride) {
            m.hasLoop = z.loopOverride->hasLoop;
            m.loopStart = z.loopOverride->start;
            m.loopEnd = z.loopOverride->end;
        }
        if (z.startPoint) m.start = *z.startPoint;
        break;
    }
    // Default an unset loop's end to the sample length so the loop markers have somewhere sane
    // to sit before the user drags (loopStart stays 0). The "no loop" state is m.hasLoop==false;
    // the markers are still drawn (drag one to CREATE a loop).
    if (!m.hasLoop && m.loopEnd == 0) m.loopEnd = frames > 0 ? frames : 0;
    return m;
}

int ReaSamplerEditor::upsertPickedOverride(const SetupMarkers& m) {
    // Find-or-append the zone for selectedId_ and write the loop/start override fields.
    // The bank intrinsic is NEVER written (read-only bank consumer, D-B). selectedId_ must
    // be non-empty; callers are responsible for that guard.
    // Returns the zone index (0-based) so callers can update selectedZone_.
    SampleLoop loop;
    loop.hasLoop = m.hasLoop;
    loop.start = m.loopStart;
    loop.end = m.loopEnd;
    for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
        PerformanceZone& z = map_.zones[static_cast<std::size_t>(i)];
        if (z.sampleId == selectedId_) {
            z.loopOverride = loop;
            z.startPoint = m.start;
            return i;
        }
    }
    PerformanceZone z;
    z.sampleId = selectedId_;
    z.lowNote = 0;
    z.highNote = 127;
    z.loopOverride = loop;
    z.startPoint = m.start;
    map_.zones.push_back(z);
    return static_cast<int>(map_.zones.size()) - 1;
}

namespace {
// The S12/S15/S16 control-surface value DOMAINS (the shell owns these — param_slider is
// engine-free and maps only 0..1). WALL-CLOCK time sliders (AHDSR A/H/D/R, pitch env A/D) span
// [0, kEnvTimeMaxSeconds] SECONDS — rate-free, exactly what the zone stores; the keymap build
// resolves seconds->frames at the live rate. SOURCE-timeline fade sliders (Trigger fade-in/out)
// span [0, kFadeMaxFrames] SOURCE frames (a source-timeline quantity, PLAN.md §S15 — never a
// wall-clock second). Build-time residual — one place to retune; not persisted.
constexpr double kEnvTimeMaxSeconds = 2.0;            // AHDSR A/H/D/R + pitch A/D throw ceiling (seconds)
constexpr double kFadeMaxFrames = 88200.0;           // Trigger fade throw ceiling (source frames)
constexpr double kPitchDepthMaxSemis = 24.0;          // AD pitch depth throw: +/-24 st, centered

double clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); }
}  // namespace

std::vector<ControlDesc> ReaSamplerEditor::controlDescs(const ZonePlaySeconds& play) const {
    std::vector<ControlDesc> out;
    // Always: the two mode toggles.
    out.push_back({static_cast<int>(ParamControl::kPlayMode), ControlKind::Toggle});
    out.push_back({static_cast<int>(ParamControl::kPitchEngine), ControlKind::Toggle});
    // Mode-relevant amplitude sliders.
    if (play.playMode == PlayMode::Gate) {
        out.push_back({static_cast<int>(ParamControl::kAttack), ControlKind::Slider});
        out.push_back({static_cast<int>(ParamControl::kHold), ControlKind::Slider});
        out.push_back({static_cast<int>(ParamControl::kDecay), ControlKind::Slider});
        out.push_back({static_cast<int>(ParamControl::kSustain), ControlKind::Slider});
        out.push_back({static_cast<int>(ParamControl::kRelease), ControlKind::Slider});
    } else {  // Trigger
        out.push_back({static_cast<int>(ParamControl::kTrigLength), ControlKind::Slider});
        out.push_back({static_cast<int>(ParamControl::kTrigFadeIn), ControlKind::Slider});
        out.push_back({static_cast<int>(ParamControl::kTrigFadeOut), ControlKind::Slider});
    }
    // The AD pitch envelope: an enable toggle + its three sliders (drawn always; inert until on).
    out.push_back({static_cast<int>(ParamControl::kPitchEnvEnable), ControlKind::Toggle});
    out.push_back({static_cast<int>(ParamControl::kPitchEnvAttack), ControlKind::Slider});
    out.push_back({static_cast<int>(ParamControl::kPitchEnvDecay), ControlKind::Slider});
    out.push_back({static_cast<int>(ParamControl::kPitchEnvDepth), ControlKind::Slider});
    return out;
}

double ReaSamplerEditor::controlValue(int id, const ZonePlaySeconds& play) const {
    // Wall-clock seconds -> normalized over the seconds ceiling; source frames -> normalized over
    // the frames ceiling. Two domains, kept explicit so neither leaks a rate.
    const auto secToNorm = [](double s) { return clamp01(s / kEnvTimeMaxSeconds); };
    const auto framesToNorm = [](std::int64_t f) {
        return clamp01(static_cast<double>(f) / kFadeMaxFrames);
    };
    switch (static_cast<ParamControl>(id)) {
        case ParamControl::kPlayMode:      return play.playMode == PlayMode::Trigger ? 1.0 : 0.0;
        case ParamControl::kPitchEngine:   return play.pitchEngine == PitchEngine::Preserve ? 1.0 : 0.0;
        case ParamControl::kAttack:        return secToNorm(play.adsr.attackSeconds);
        case ParamControl::kHold:          return secToNorm(play.adsr.holdSeconds);
        case ParamControl::kDecay:         return secToNorm(play.adsr.decaySeconds);
        case ParamControl::kSustain:       return clamp01(play.adsr.sustainLevel);
        case ParamControl::kRelease:       return secToNorm(play.adsr.releaseSeconds);
        case ParamControl::kTrigLength:    return clamp01(play.trigger.lengthFraction);
        case ParamControl::kTrigFadeIn:    return framesToNorm(play.trigger.fadeInFrames);
        case ParamControl::kTrigFadeOut:   return framesToNorm(play.trigger.fadeOutFrames);
        case ParamControl::kPitchEnvEnable:return play.pitchEnv.enabled ? 1.0 : 0.0;
        case ParamControl::kPitchEnvAttack:return secToNorm(play.pitchEnv.attackSeconds);
        case ParamControl::kPitchEnvDecay: return secToNorm(play.pitchEnv.decaySeconds);
        case ParamControl::kPitchEnvDepth:
            // Signed depth centered at 0.5 (0.5 == 0 semitones).
            return clamp01(0.5 + play.pitchEnv.peakSemitones / (2.0 * kPitchDepthMaxSemis));
        default: return 0.0;
    }
}

void ReaSamplerEditor::applyControl(int id, ZonePlaySeconds& play, double value,
                                    int segment) const {
    const auto normToSec = [](double v) { return clamp01(v) * kEnvTimeMaxSeconds; };
    const auto normToFrames = [](double v) {
        return static_cast<std::int64_t>(clamp01(v) * kFadeMaxFrames + 0.5);
    };
    switch (static_cast<ParamControl>(id)) {
        case ParamControl::kPlayMode:
            play.playMode = (segment == 1) ? PlayMode::Trigger : PlayMode::Gate;
            break;
        case ParamControl::kPitchEngine:
            play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed;
            break;
        case ParamControl::kAttack:   play.adsr.attackSeconds = normToSec(value); break;
        case ParamControl::kHold:     play.adsr.holdSeconds = normToSec(value); break;
        case ParamControl::kDecay:    play.adsr.decaySeconds = normToSec(value); break;
        case ParamControl::kSustain:  play.adsr.sustainLevel = clamp01(value); break;
        case ParamControl::kRelease:  play.adsr.releaseSeconds = normToSec(value); break;
        case ParamControl::kTrigLength:
            // lengthFraction is (0,1]; keep a small floor so a zero-length trigger never plays nothing.
            play.trigger.lengthFraction = (std::max)(0.01, clamp01(value));
            break;
        case ParamControl::kTrigFadeIn:  play.trigger.fadeInFrames = normToFrames(value); break;
        case ParamControl::kTrigFadeOut: play.trigger.fadeOutFrames = normToFrames(value); break;
        case ParamControl::kPitchEnvEnable:
            play.pitchEnv.enabled = (segment == 1);
            break;
        case ParamControl::kPitchEnvAttack: play.pitchEnv.attackSeconds = normToSec(value); break;
        case ParamControl::kPitchEnvDecay:  play.pitchEnv.decaySeconds = normToSec(value); break;
        case ParamControl::kPitchEnvDepth:
            play.pitchEnv.peakSemitones = (clamp01(value) - 0.5) * 2.0 * kPitchDepthMaxSemis;
            break;
        default: break;
    }
}

void ReaSamplerEditor::commitPickedMarkers(const SetupMarkers& m) {
    // Materialize the edited markers as a per-zone loop/start override on the picked id (upsert,
    // mirror of the root-marker path): a full-keyboard zone carrying the override. This plays
    // identically to the un-zoned single capture (one chromatic zone) and round-trips through
    // the component state; the zone becomes visible if the user opens the Zones panel. The bank
    // intrinsic is NEVER written (read-only bank consumer, D-B).
    if (selectedId_.empty()) return;
    upsertPickedOverride(m);
    commitAndReload();
}

const std::vector<AudioSample>& ReaSamplerEditor::monoPcmFor(const std::string& sampleId) {
    auto it = pcmCache_.find(sampleId);
    if (it != pcmCache_.end()) return it->second;

    // SampleChoice is the browser's metadata projection and does NOT carry the WAV path, so
    // resolve the path from the live bank blob (selectSample) and decode via the shared WAV
    // parse — the mirror of the processor's decodeRelative. Every failure path caches an EMPTY
    // vector so a broken/missing file is not re-decoded on every paint. Keyed by id (width-
    // independent) — the thumbnail bins this at whatever width, the snap scans it directly.
    std::string relativePath;
    std::vector<AudioSample> mono;
    if (processor_) {
        auto banksJson =
            processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey);
        if (banksJson) {
            if (auto sel = selectSample(*banksJson, sampleId)) relativePath = sel->relativePath;
        }
        if (!relativePath.empty()) {
            const std::string projectDir = processor_->bridge().activeProjectDir();
            const std::string abs = resolveBankFile(projectDir, relativePath);
            std::vector<std::uint8_t> bytes;
            std::ifstream f(abs, std::ios::binary | std::ios::ate);
            if (f) {
                const std::streamoff size = f.tellg();
                if (size > 0) {
                    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();
                }
            }
            const WavLayout layout = parseWavLayout(bytes);
            if (layout.valid) {
                std::vector<AudioSample> interleaved =
                    extractFloatFrames(bytes, layout, 0, layout.frameCount());
                mono = downmixToMono(interleaved, layout.channelCount);
            }
        }
    }
    auto ins = pcmCache_.emplace(sampleId, std::move(mono));
    return ins.first->second;
}

const Envelope& ReaSamplerEditor::thumbnailFor(const std::string& sampleId, int binCount) {
    const std::string key = sampleId + "|" + std::to_string(binCount);
    auto it = thumbCache_.find(key);
    if (it != thumbCache_.end()) return it->second;

    // Bin the (cached) decoded mono PCM at the requested width — one decode per id, reused by
    // every thumbnail width AND the S11 waveform surface + snap.
    const std::vector<AudioSample>& mono = monoPcmFor(sampleId);
    Envelope env;
    if (!mono.empty()) {
        env = computeEnvelope(mono, 1, mono.size(),
                              static_cast<std::size_t>((std::max)(1, binCount)));
    }
    auto ins = thumbCache_.emplace(key, std::move(env));
    return ins.first->second;
}

ReaSamplerEditor::~ReaSamplerEditor() {
#ifdef _WIN32
    if (childHwnd_) {
        DestroyWindow(childHwnd_);
        childHwnd_ = nullptr;
    }
#endif
}

tresult PLUGIN_API ReaSamplerEditor::isPlatformTypeSupported(FIDString type) {
#ifdef _WIN32
    if (type && std::string(type) == kPlatformTypeHWND) return kResultTrue;
#endif
    return kResultFalse;
}

tresult PLUGIN_API ReaSamplerEditor::canResize() {
    return kResultTrue;
}

tresult PLUGIN_API ReaSamplerEditor::checkSizeConstraint(ViewRect* rect) {
    // Enforce a minimum usable floor: at least 560 wide and 380 tall. The host calls this
    // before every resize; clamp the proposed rect in place and return kResultTrue so the
    // host applies the (possibly adjusted) rect rather than the raw user drag.
    // 560×380 keeps the title band + toggle band + a couple of card rows + the setup strip
    // visible; anything smaller would clip essential UI. The default 840×560 is above this
    // floor — Wave 2 tunes final numbers once the three-band layout is in.
    constexpr int kMinW = 560;
    constexpr int kMinH = 380;
    if (!rect) return kResultFalse;
    if (rect->getWidth() < kMinW) rect->right = rect->left + kMinW;
    if (rect->getHeight() < kMinH) rect->bottom = rect->top + kMinH;
    return kResultTrue;
}

#ifdef _WIN32

void ReaSamplerEditor::invalidate() {
    if (childHwnd_) InvalidateRect(childHwnd_, nullptr, FALSE);
}

void ReaSamplerEditor::attachedToParent() {
    HWND parent = static_cast<HWND>(systemWindow);
    if (!parent) return;

    HINSTANCE hInst =
        reinterpret_cast<HINSTANCE>(GetWindowLongPtr(parent, GWLP_HINSTANCE));
    if (!hInst) hInst = GetModuleHandle(nullptr);

    static bool classRegistered = false;
    if (!classRegistered) {
        WNDCLASSW wc{};
        wc.lpfnWndProc = &ReaSamplerEditor::wndProc;
        wc.hInstance = hInst;
        wc.lpszClassName = kChildClassName;
        wc.hCursor = LoadCursor(nullptr, IDC_ARROW);
        wc.style = CS_HREDRAW | CS_VREDRAW;
        RegisterClassW(&wc);
        classRegistered = true;
    }

    // Create the kit's cached AA fonts before the first paint (Phase L, L3). Idempotent, so a
    // reopen (or a co-resident embed strip that also inits) is a cheap no-op. NOT torn down on
    // editor close: the embed strip in the SAME binary shares the kit's process-global font
    // set, so a per-view shutdown could free fonts still in use by the other view. The tiny
    // static HFONT set is reclaimed by the OS at module unload. See the L3 handoff note.
    kitFontsInit();

    refreshFromBank();

    const ViewRect& r = getRect();
    childHwnd_ = CreateWindowExW(0, kChildClassName, L"", WS_CHILD | WS_VISIBLE, 0, 0,
                                 r.getWidth(), r.getHeight(), parent, nullptr, hInst, nullptr);
    if (childHwnd_) {
        SetWindowLongPtr(childHwnd_, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(this));
        // S13: accept OS file drops on the editor window (WM_DROPFILES). The drop is NOT
        // ingested here (the relay is degraded — see onFilesDropped); accepting it lets us show
        // the "drop on the panel" affordance instead of the OS bouncing the drop silently.
        DragAcceptFiles(childHwnd_, TRUE);
        // Start the S9/S8 change-detection poll (UI thread). Tied to the child window's
        // lifetime — created here, killed in removedFromParent — so an instance whose editor
        // is closed does NOT poll (the editor-open-only cadence; see the handoff limitation).
        SetTimer(childHwnd_, kSyncTimerId, kSyncTimerIntervalMs, nullptr);
        // Poll ONCE immediately so a pending assignment (an S8 ingest fired while this editor
        // was closed) or a bank change applies the instant the editor opens, rather than waiting
        // up to one timer interval. refreshFromBank above already primed the view; this folds in
        // any pending assign/generation so the just-opened editor shows the assigned capture.
        onSyncTimer();
    }
}

void ReaSamplerEditor::removedFromParent() {
    if (childHwnd_) {
        KillTimer(childHwnd_, kSyncTimerId);  // stop the poll before the window goes away
        DestroyWindow(childHwnd_);
        childHwnd_ = nullptr;
    }
}

tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) {
    tresult res = CPluginView::onSize(newSize);
    if (childHwnd_ && newSize) {
        MoveWindow(childHwnd_, 0, 0, newSize->getWidth(), newSize->getHeight(), TRUE);
        thumbCache_.clear();  // thumbnails are width-bound; a resize invalidates them
    }
    return res;
}

// The client bands: title (top), toggle (below title), then the mode content. In the
// browser view the content is the browser grid on top of the single-capture setup band
// (when a capture is picked); in the zones view the content is the zones strip + list.
namespace {
struct EditorBands {
    Rect title;
    Rect toggleBrowser;  // left half of the toggle band
    Rect toggleZones;    // right half
    Rect content;        // below the toggle band: the mode's own area
};
EditorBands computeBands(int w, int h) {
    EditorBands b;
    const int titleH = (std::min)(kTitleHeight, h);
    b.title = Rect{0, 0, w, titleH};
    const int toggleTop = titleH;
    const int toggleBot = (std::min)(h, toggleTop + kToggleHeight);
    b.toggleBrowser = Rect{0, toggleTop, w / 2, toggleBot};
    b.toggleZones = Rect{w / 2, toggleTop, w, toggleBot};
    b.content = Rect{0, toggleBot, w, h};
    return b;
}

// The keyboard strip rectangle inside the setup area (single-capture root-drag face).
// `area` is the full setup Rect; the strip is anchored at the bottom with an 8px horizontal
// pad. All three call sites (paintSetup, onMouseDown, onMouseMove) use this single formula.
Rect setupStripArea(const Rect& area) {
    constexpr int pad = 8;
    const int stripTop = area.bottom - kStripBandHeight;
    return Rect{area.left + pad, stripTop, area.right - pad, area.bottom - 4};
}

// The S11 waveform rectangle inside the setup area: a band above the keyboard strip, below the
// header/hint labels. `area` is the full setup Rect; the waveform is padded 8px horizontally and
// anchored above the strip band. All call sites (paintSetup, onMouseDown, onMouseMove) use this
// single formula so the draw and the hit-test never drift.
Rect setupWaveformArea(const Rect& area) {
    constexpr int pad = 8;
    const int waveBottom = area.bottom - kStripBandHeight - 6;  // 6px gap above the strip
    const int waveTop = waveBottom - kWaveformHeight;
    return Rect{area.left + pad, waveTop, area.right - pad, waveBottom};
}

// The keyboard strip rectangle inside the Zones panel content area. `bands.content` is the
// mode-content Rect; the strip sits below the "+ Add Zone" affordance (top+4, height 20)
// with a 12px gap, padded 8px horizontally. All three call sites (paintZones, onMouseDown,
// onMouseMove) use this single formula — the inline arithmetic in onMouseMove was the drift.
Rect zonesStripArea(const EditorBands& bands) {
    constexpr int pad = 8;
    const int stripTop = bands.content.top + 4 + 20 + 12;  // addR.bottom + 12
    return Rect{bands.content.left + pad, stripTop, bands.content.right - pad,
                stripTop + kStripBandHeight};
}

// The S12 numeric-entry field ROW area inside the Zones legend: a band to the right of the
// sample label on the legend row. Three equal fields (low/high/root) tile it. Both draw +
// hit-test use this single formula so they never drift. Anchored off zonesStripArea.bottom so
// the legend top tracks the strip bottom without re-inlining the strip arithmetic here.
Rect noteEntryFieldsArea(const EditorBands& bands) {
    const int stripBottom = zonesStripArea(bands).bottom;
    const int top = stripBottom + 8;  // legendTop (== zonesStripArea.bottom + 8)
    return Rect{bands.content.left + 8 + 128, top, bands.content.right - 8, top + 18};
}

// The rect of note-entry field `f` (0=low, 1=high, 2=root) within the fields area: three equal
// segments left-to-right. An out-of-range index yields an empty rect.
Rect noteEntryFieldRect(const Rect& fields, int f) {
    if (f < 0 || f > 2 || fields.width() <= 0) return Rect{};
    const int segW = fields.width() / 3;
    const int left = fields.left + f * segW + (f > 0 ? 4 : 0);  // small inter-field gap
    const int right = (f == 2) ? fields.right : fields.left + (f + 1) * segW;
    return Rect{left, fields.top, right, fields.bottom};
}

// The S12/S15/S16 parameter-control panel rect inside the Zones content: below the strip +
// the one-line selected-zone legend, running to the content bottom. `bands.content` is the
// Zones mode-content area. Both draw + hit-test use this single formula so they never drift.
Rect zonesControlPanel(const EditorBands& bands) {
    constexpr int pad = 8;
    const Rect strip = zonesStripArea(bands);
    const int panelTop = strip.bottom + 8 + 18 + 8;  // strip + the 18px legend row + gap
    return Rect{bands.content.left + pad, panelTop, bands.content.right - pad,
                bands.content.bottom - 4};
}

// The S7 mono/stereo toggle, a two-segment control anchored to the RIGHT of the setup band's
// header row (same y as the sample-name header, so it reads as "this capture's output mode").
// `area` is the full setup Rect. Returns {mono-segment, stereo-segment}; each is kSegW wide,
// kSegH tall, side by side. Kept to a small fenced block (S11 owns the waveform region).
constexpr int kChanSegW = 52;
constexpr int kChanSegH = 18;
struct ChannelToggleRects { Rect mono; Rect stereo; };
ChannelToggleRects channelToggleRects(const Rect& area) {
    constexpr int pad = 8;
    const int top = area.top + 4;
    const int right = area.right - pad;
    const Rect stereo{right - kChanSegW, top, right, top + kChanSegH};
    const Rect mono{stereo.left - kChanSegW, top, stereo.left, top + kChanSegH};
    return {mono, stereo};
}

// Draw the pastel spectral keyboard-strip background (Phase L, L3) — the signature surface.
// Fills each MIDI key column with its spectral hue (spectralColor over note/127), then draws
// faint per-octave hairline ticks for orientation. Shared by the setup face + the Zones strip
// so both read as the same spectrum. `stripArea` is the absolute strip rect.
void drawSpectralStrip(LICE_IBitmap* bmp, const Rect& stripArea) {
    if (stripArea.width() <= 0 || stripArea.height() <= 0) return;
    const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height());
    const int sx = stripArea.left;
    const int sy = stripArea.top;
    const int h = stripArea.height();
    // A pastel spectral column per key. Each key's local x from keyRect; fill from this key's
    // left to the next key's left so the sweep tiles with no gaps. Low alpha keeps it a quiet
    // backdrop the root/zone marks sit over.
    for (int n = 0; n <= 127; ++n) {
        const Rect k = keyRect(sl, n);
        const int x0 = k.left + sx;
        const int x1 = (n < 127) ? keyRect(sl, n + 1).left + sx : stripArea.right;
        const int cw = (std::max)(1, x1 - x0);
        const KitColor hue = spectralColor(static_cast<double>(n) / 127.0);
        LICE_FillRect(bmp, x0, sy, cw, h, toLice(hue), 0.55f, 0);
    }
    // Faint per-octave key ticks (hairline role) for orientation.
    const LICE_pixel tick = toLice(roleColor(Role::LineHairline));
    for (int n = 0; n <= 127; n += 12) {
        const Rect k = keyRect(sl, n);
        LICE_Line(bmp, k.left + sx, sy, k.left + sx, sy + h, tick, 1.0f, 0, false);
    }
}

// Draw the single-capture root marker on the strip: an accent-primary bar with a soft STATIC
// glow (a wider, lower-alpha accent bar behind it) — the "this is live" mark. Never animated.
void drawRootMarker(LICE_IBitmap* bmp, const Rect& stripArea, const StripLayout& sl, int root) {
    const int sx = stripArea.left;
    const int sy = stripArea.top;
    const int h = stripArea.height();
    const Rect marker = rootMarkerRect(sl, root);
    const int mw = (std::max)(2, marker.width());
    const LICE_pixel accent = toLice(roleColor(Role::AccentPrimary));
    const LICE_pixel glow = toLice(roleColor(Role::AccentHot));
    // Static glow: a wider low-alpha halo behind the crisp bar (a drawn state, not a pulse).
    LICE_FillRect(bmp, marker.left + sx - 3, sy, mw + 6, h, glow, 0.30f, 0);
    LICE_FillRect(bmp, marker.left + sx, sy, mw, h, accent, 1.0f, 0);
}
}  // namespace

void ReaSamplerEditor::paint(HDC hdc) {
    RECT cr{};
    GetClientRect(childHwnd_, &cr);
    const int w = cr.right - cr.left;
    const int h = cr.bottom - cr.top;
    if (w <= 0 || h <= 0) return;

    LICE_SysBitmap bmp(w, h);
    LICE_Clear(&bmp, toLice(roleColor(Role::BgBase)));

    const EditorBands bands = computeBands(w, h);

    // Title band: product name + live readout. Standard B palette — the beta channel gets NO
    // distinct accent (settled 2026-07-27); the channel-derived vstPluginName is the only
    // beta-vs-stable signal. bg/panel one step up from the canvas, primary-role title text.
    fillSurface(&bmp, toKitBox(bands.title), Role::BgPanel, InteractionState::Rest);
    std::string title = reasampler::vstPluginName();  // channel-derived (S18)
    if (processor_ && processor_->bridge().isConnected()) {
        if (samples_.empty()) title += "  [bank empty]";
        else if (selectedId_.empty() && map_.zones.empty()) title += "  [pick a capture]";
        else if (!map_.zones.empty()) title += "  [" + std::to_string(map_.zones.size()) + " zone(s)]";
        else title += "  [" + sampleLabel(samples_, selectedId_) + "]";
    } else {
        title += "  [host: no bridge]";
    }
    Rect titleText{bands.title.left + 8, bands.title.top, bands.title.right - 8,
                   bands.title.bottom};
    kitText(&bmp, titleText, title.c_str(), Font::Title, Role::TextPrimary);

    // Toggle band: Browser | Zones — two segmented switches. Active = accent-primary fill
    // ("this is live"); hover lightens the inactive segment toward accent/hot.
    const bool inZones = (view_ == View::kZones);
    const InteractionState browserState =
        !inZones ? InteractionState::Active
                 : (isHovered(HoverKind::kToggleBrowser, -1) ? InteractionState::Hover
                                                             : InteractionState::Rest);
    const InteractionState zonesState =
        inZones ? InteractionState::Active
                : (isHovered(HoverKind::kToggleZones, -1) ? InteractionState::Hover
                                                          : InteractionState::Rest);
    fillSurface(&bmp, toKitBox(bands.toggleBrowser), Role::BgCell, browserState);
    fillSurface(&bmp, toKitBox(bands.toggleZones), Role::BgCell, zonesState);
    // Active segment's label sits on the accent fill — draw it in bg/base for contrast
    // (the tight text-on-pastel-fill pair, §4); the inactive label stays text/primary.
    kitTextCentered(&bmp, bands.toggleBrowser, "Browser", Font::Label,
                    !inZones ? Role::BgBase : Role::TextPrimary);
    kitTextCentered(&bmp, bands.toggleZones, "Zones", Font::Label,
                    inZones ? Role::BgBase : Role::TextPrimary);

    if (view_ == View::kZones) {
        paintZones(&bmp, w, h);
    } else {
        paintBrowser(&bmp, w, h);
    }

    // S13 (relay degraded): a transient banner flashed after a file was dropped ON THIS window.
    // It reiterates the shipped ingest gesture rather than swallowing the drop silently. Drawn
    // LAST so it overlays the mode content; decays via onSyncTimer (dropHintTicks_).
    if (dropHintTicks_ > 0) {
        const int bannerH = (std::min)(kTitleHeight + 8, h);
        Rect banner{0, bands.toggleZones.bottom, w, bands.toggleZones.bottom + bannerH};
        // A transient notice, not the live layer — draw it on the accent-tertiary categorical
        // hue with a dark label so it reads as "attention, not action".
        fillSurface(&bmp, toKitBox(banner), Role::AccentTertiary, InteractionState::Rest);
        kitTextCentered(&bmp, banner,
                        "Dropped here isn't loaded yet - drop files onto the ReaSampler bank panel to add them.",
                        Font::Label, Role::BgBase);
    }

    BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY);
}

void ReaSamplerEditor::paintEmptyState(LICE_IBitmap* bmp, const Rect& area) {
    // Shown when no card is drawn (nothing to pick): distinguish a genuinely empty bank from
    // a bank filter that hides everything. Either way it is the "pick a capture" empty state.
    const char* msg = samples_.empty()
        ? "No captures in this project yet - capture audio into the bank to play it here."
        : "No captures in this bank filter. Choose another bank tab above.";
    // Split the area so the primary line sits centered and the S13 ingest affordance sits just
    // below it. The affordance is the SHIPPED ingest gesture (drop onto the docked panel) — kept
    // discoverable here regardless of whether a drop ever lands on THIS window.
    Rect primary{area.left, area.top, area.right, area.top + area.height() / 2};
    Rect hint{area.left, primary.bottom, area.right, area.bottom};
    kitTextCentered(bmp, primary, msg, Font::Label, Role::TextDim);
    kitTextCentered(bmp, hint,
                    "To add a sample: drop a file onto the ReaSampler bank panel (the docked window).",
                    Font::Micro, Role::TextDim);
}

void ReaSamplerEditor::paintBrowser(LICE_IBitmap* bmp, int w, int h) {
    const EditorBands bands = computeBands(w, h);
    // When a capture is picked, the setup band takes the bottom; the browser gets the rest.
    const bool havePick = !selectedId_.empty();
    const int setupTop = havePick ? (std::max)(bands.content.top, bands.content.bottom - kSetupHeight)
                                  : bands.content.bottom;
    const Rect fullBrowserArea{bands.content.left, bands.content.top, bands.content.right, setupTop};

    // S12: reserve a type-to-filter search box at the top of the browser area; the tabs + grid
    // sit below it. The search box spans the browser width.
    const Rect searchBox = searchBoxRect(fullBrowserArea.width());
    const Rect searchAbs{fullBrowserArea.left + searchBox.left, fullBrowserArea.top + searchBox.top,
                         fullBrowserArea.left + searchBox.right, fullBrowserArea.top + searchBox.bottom};
    // A focused search box lifts to the focus state (a nudge toward the primary accent + a
    // text/primary ring drawn below); hover lightens; else the resting cell surface.
    const InteractionState searchState =
        searchFocused_ ? InteractionState::Focus
                       : (isHovered(HoverKind::kSearchBox, -1) ? InteractionState::Hover
                                                              : InteractionState::Rest);
    fillSurface(bmp, toKitBox(searchAbs), Role::BgCell, searchState);
    if (searchFocused_) {
        const KitColor ring = roleColor(Role::TextPrimary);
        LICE_DrawRect(bmp, searchAbs.left, searchAbs.top, searchAbs.width() - 1,
                      searchAbs.height() - 1, toLice(ring), 1.0f, 0);
    }
    {
        std::string sb = searchQuery_.empty()
            ? std::string("Search captures...")
            : ("Search: " + searchQuery_ + (searchFocused_ ? "_" : ""));
        Rect sbText{searchAbs.left + 6, searchAbs.top, searchAbs.right - 6, searchAbs.bottom};
        kitText(bmp, sbText, sb.c_str(), Font::Label,
                searchQuery_.empty() ? Role::TextDim : Role::TextPrimary);
    }

    const Rect browserArea{fullBrowserArea.left, searchAbs.bottom, fullBrowserArea.right, setupTop};

    // The browser tabs + card grid, laid out by the pure module over the browser sub-area.
    // capture_browser lays out from (0,0); offset the draw by browserArea's origin.
    const BrowserLayout bl = layoutBrowser(browserArea.width(), browserArea.height());
    const int ox = browserArea.left;
    const int oy = browserArea.top;
    scrollOffset_ = clampScrollOffset(bl, static_cast<int>(visible_.size()), scrollOffset_);

    // Filter tabs: an "All" tab (index 0) + one per named bank. The active tab highlights.
    const int tabCount = static_cast<int>(banks_.size()) + 1;
    for (int i = 0; i < tabCount; ++i) {
        Rect t = filterTabRect(bl, tabCount, i);
        t = Rect{t.left + ox, t.top + oy, t.right + ox, t.bottom + oy};
        const std::string label = (i == 0) ? "All" : banks_[static_cast<std::size_t>(i - 1)].displayName;
        const bool active = (i == 0) ? activeFilterBankId_.empty()
                                     : (banks_[static_cast<std::size_t>(i - 1)].id == activeFilterBankId_);
        const InteractionState state =
            active ? InteractionState::Active
                   : (isHovered(HoverKind::kFilterTab, i) ? InteractionState::Hover
                                                          : InteractionState::Rest);
        fillSurface(bmp, toKitBox(t), Role::BgCell, state);
        kitTextCentered(bmp, t, label.c_str(), Font::Label,
                        active ? Role::BgBase : Role::TextPrimary);
    }

    // Cards: only the S12 visible window at the current scroll offset (a bank longer than the
    // panel is reachable by wheel/thumb drag). scrolledCardCellRect shifts each cell up by the
    // offset; we clip to the grid region so a partially-scrolled row is trimmed at the edges.
    const int bins = thumbBins(bl);
    const int cardCount = static_cast<int>(visible_.size());
    const VisibleRange vr = visibleCardRange(bl, cardCount, scrollOffset_);
    for (int i = vr.first; i < vr.last; ++i) {
        // The scrolled CELL, then the same gutter/thumbnail/label insets the pure module derives,
        // shifted by the scroll offset (they share the cell's top, so subtract the offset).
        Rect content = cardContentRect(bl, i);
        Rect thumb = cardThumbnailRect(bl, i);
        Rect labelR = cardLabelRect(bl, i);
        content = Rect{content.left + ox, content.top + oy - scrollOffset_,
                       content.right + ox, content.bottom + oy - scrollOffset_};
        thumb = Rect{thumb.left + ox, thumb.top + oy - scrollOffset_,
                     thumb.right + ox, thumb.bottom + oy - scrollOffset_};
        labelR = Rect{labelR.left + ox, labelR.top + oy - scrollOffset_,
                      labelR.right + ox, labelR.bottom + oy - scrollOffset_};

        const SampleChoice& s = visible_[static_cast<std::size_t>(i)];
        const bool sel = (s.id == selectedId_);
        // Card surface: a plain cell (hover lightens), with the SELECTED pick marked by an
        // accent-primary border (the "this is live" signal, §2.1) — the same convention the
        // dock panel's L7 selection uses (normal cell + accent border, no inversion). The
        // waveform thumbnail draws over bg/panel so its accent columns read against the cell.
        const InteractionState cardState =
            isHovered(HoverKind::kCard, i) ? InteractionState::Hover : InteractionState::Rest;
        fillSurface(bmp, toKitBox(content), Role::BgCell, cardState);
        const KitColor cardBorder =
            sel ? roleColor(Role::AccentPrimary) : roleColor(Role::LineHairline);
        LICE_DrawRect(bmp, content.left, content.top, content.width() - 1, content.height() - 1,
                      toLice(cardBorder), 1.0f, 0);
        drawEnvelope(bmp, thumb, thumbnailFor(s.id, bins));

        // Name + root/key badge under the thumbnail.
        std::string caption = s.displayName.empty() ? s.id : s.displayName;
        Rect nameR{labelR.left + 3, labelR.top, labelR.right - 3, labelR.top + labelR.height() / 2};
        Rect badgeR{labelR.left + 3, nameR.bottom, labelR.right - 3, labelR.bottom};
        kitText(bmp, nameR, caption.c_str(), Font::Label, Role::TextPrimary);
        std::string badge;
        if (s.rootNote) badge = "root " + noteLabel(*s.rootNote);
        else if (s.key) badge = *s.key;
        else badge = "root -";
        kitText(bmp, badgeR, badge.c_str(), Font::Micro, Role::TextDim);
    }

    // S12 scrollbar: a thumb in the grid's right-edge gutter, sized/positioned by the pure
    // module (empty when the content fits — the shell simply draws nothing then). Offset by the
    // browser origin like every other card rect.
    {
        const Rect thumb = scrollThumbRect(bl, cardCount, scrollOffset_);
        if (thumb.height() > 0) {
            const bool dragging = (drag_ == DragKind::kScrollThumb);
            const KitColor tc = roleColor(dragging ? Role::AccentHot : Role::AccentPrimary);
            LICE_FillRect(bmp, thumb.left + ox, thumb.top + oy, thumb.width(), thumb.height(),
                          toLice(tc), 0.8f, 0);
        }
    }

    if (havePick) {
        paintSetup(bmp, Rect{bands.content.left, setupTop, bands.content.right, bands.content.bottom});
    } else if (visible_.empty()) {
        paintEmptyState(bmp, browserArea);
    }
}

void ReaSamplerEditor::paintSetup(LICE_IBitmap* bmp, const Rect& area) {
    // The guided single-capture setup: the picked capture's name + root/level, and a
    // keyboard strip with its root marker (drag to set root). A raised bg/panel region.
    fillSurface(bmp, toKitBox(area), Role::BgPanel, InteractionState::Rest);

    // Effective root: the picked sample's rootNote intrinsic (or middle C when unset).
    // Read from samples_ (the full unfiltered list) so a bank-filter that hides the
    // picked sample's bank doesn't mask its intrinsic root with the C4 default.
    int root = 60;
    for (const SampleChoice& s : samples_) {
        if (s.id == selectedId_ && s.rootNote) root = *s.rootNote;
    }
    // If a matching one-zone override exists (opt-in from Zones), prefer it as the shown root.
    for (const PerformanceZone& z : map_.zones) {
        if (z.sampleId == selectedId_ && z.rootOverride) root = *z.rootOverride;
    }

    const int pad = 8;
    // The mono/stereo toggle sits at the right of the header row; keep the name text clear of it.
    const ChannelToggleRects chan = channelToggleRects(area);
    Rect headerR{area.left + pad, area.top + 4, chan.mono.left - 8, area.top + 22};
    std::string header = sampleLabel(samples_, selectedId_) + "   root " + noteLabel(root);
    kitText(bmp, headerR, header.c_str(), Font::Label, Role::TextPrimary);

    // S7 mono | stereo output-mode toggle. Active segment = accent-primary fill (the live
    // mode); inactive lightens on hover — the same visual grammar as the Browser/Zones toggle.
    const bool isStereo = (channelMode_ == ChannelMode::Stereo);
    const InteractionState monoState =
        !isStereo ? InteractionState::Active
                  : (isHovered(HoverKind::kChanMono, -1) ? InteractionState::Hover
                                                        : InteractionState::Rest);
    const InteractionState stereoState =
        isStereo ? InteractionState::Active
                 : (isHovered(HoverKind::kChanStereo, -1) ? InteractionState::Hover
                                                         : InteractionState::Rest);
    fillSurface(bmp, toKitBox(chan.mono), Role::BgCell, monoState);
    fillSurface(bmp, toKitBox(chan.stereo), Role::BgCell, stereoState);
    kitTextCentered(bmp, chan.mono, "Mono", Font::Label,
                    !isStereo ? Role::BgBase : Role::TextPrimary);
    kitTextCentered(bmp, chan.stereo, "Stereo", Font::Label,
                    isStereo ? Role::BgBase : Role::TextPrimary);

    Rect hintR{area.left + pad, headerR.bottom, area.right - pad, headerR.bottom + 16};
    kitText(bmp, hintR,
            "Drag the waveform markers to set start + loop; drag the keyboard to set root.",
            Font::Micro, Role::TextDim);

    // --- S11 waveform surface: the picked capture's envelope + draggable markers ----------
    const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
    const std::int64_t frames = static_cast<std::int64_t>(pcm.size());
    const Rect waveArea = setupWaveformArea(area);
    // The waveform pane is a recessed surface (bg/base, one step DOWN from the setup panel)
    // so the accent-primary envelope reads against it.
    fillSurface(bmp, toKitBox(waveArea), Role::BgBase, InteractionState::Rest);
    if (frames > 0 && waveArea.width() > 0) {
        // Envelope at one bin per pixel (full-res view of the decoded PCM, S10 read-only view
        // reused). computeEnvelope over the cached mono frames — no new decode.
        const int bins = (std::max)(1, waveArea.width());
        const Envelope env = computeEnvelope(pcm, 1, pcm.size(), static_cast<std::size_t>(bins));
        drawEnvelope(bmp, waveArea, env);  // kit drawWaveform — accent-primary columns

        const SetupMarkers m = pickedMarkers(frames);
        // Faint loop-region fill between the loop markers (only when a loop is set) — the
        // categorical loop-marker hue (tertiary purple) at low alpha.
        if (m.hasLoop && m.loopEnd > m.loopStart) {
            const int lx = frameToX(waveArea, frames, m.loopStart);
            const int rx = frameToX(waveArea, frames, m.loopEnd);
            if (rx > lx) {
                LICE_FillRect(bmp, lx, waveArea.top, rx - lx, waveArea.height(),
                              toLice(roleColor(kRoleLoopMarker)), 0.20f, 0);
            }
        }
        // The three markers: start (teal, secondary) + loop start/end (purple, tertiary) —
        // categorical affordance hues (§2.1), 2px vertical lines the full waveform height.
        // Loop markers dim when no loop is set (the "no loop" state — draggable to CREATE one).
        const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd};
        const Role markerRoles[3] = {kRoleStartMarker, kRoleLoopMarker, kRoleLoopMarker};
        for (int i = 0; i < 3; ++i) {
            const int mx = frameToX(waveArea, frames, markerFrames[i]);
            const bool loopMarker = (i != 0);
            const float alpha = (loopMarker && !m.hasLoop) ? 0.4f : 1.0f;
            LICE_FillRect(bmp, mx - 1, waveArea.top, 2, waveArea.height(),
                          toLice(roleColor(markerRoles[i])), alpha, 0);
        }
    } else {
        kitTextCentered(bmp, waveArea, "(decoding...)", Font::Label, Role::TextDim);
    }

    // Keyboard strip with the root marker — the signature Direction-C PASTEL SPECTRAL surface
    // (§4). Each key column is hue-mapped low->high across the accent trio (spectralColor:
    // lime -> teal -> purple), so the strip reads as an extension of the accent system. The
    // root marker lifts to accent-primary with a STATIC glow (never a pulse — §3.5).
    const Rect stripArea = setupStripArea(area);
    drawSpectralStrip(bmp, stripArea);
    const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height());
    drawRootMarker(bmp, stripArea, sl, root);
}

void ReaSamplerEditor::paintZones(LICE_IBitmap* bmp, int w, int h) {
    const EditorBands bands = computeBands(w, h);
    const int pad = 8;

    // A single "+ Add Zone" affordance at the top of the content, then the keyboard strip
    // with one bar per zone. Delete is a small × on the selected zone (keystroke also).
    Rect addR{bands.content.left + pad, bands.content.top + 4, bands.content.left + pad + 96,
              bands.content.top + 4 + 20};
    {
        const KitButtonBox box{toKitBox(addR)};
        const InteractionState state =
            isHovered(HoverKind::kAddZone, -1) ? InteractionState::Hover : InteractionState::Rest;
        drawButton(bmp, box, "+ Add Zone", state, /*warn=*/false);
    }

    Rect delR{addR.right + 8, addR.top, addR.right + 8 + 64, addR.bottom};
    if (selectedZone_ >= 0) {
        const KitButtonBox box{toKitBox(delR)};
        const InteractionState state =
            isHovered(HoverKind::kDeleteZone, -1) ? InteractionState::Hover : InteractionState::Rest;
        // Deleting a zone is not a byte-destroying act (no file removed — the bank is
        // read-only here), so it is a normal button, not `warn`.
        drawButton(bmp, box, "Delete", state, /*warn=*/false);
    }

    // The zones strip — the same PASTEL SPECTRAL surface as the setup face (§4), with one
    // bar per zone over the spectrum. The SELECTED zone lifts to accent-primary + a static
    // glow ("which zone is live"); the rest take the categorical secondary hue at low alpha.
    const Rect stripArea = zonesStripArea(bands);
    drawSpectralStrip(bmp, stripArea);
    const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height());
    const int sx = stripArea.left;
    const int sy = stripArea.top;
    for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
        const PerformanceZone& z = map_.zones[static_cast<std::size_t>(i)];
        Rect bar = zoneBarRect(sl, z.lowNote, z.highNote);
        const int bw = (std::max)(2, bar.width());
        const bool sel = (i == selectedZone_);
        if (sel) {
            // Static glow halo behind the live zone, then the crisp accent-primary bar.
            LICE_FillRect(bmp, bar.left + sx - 2, sy, bw + 4, stripArea.height(),
                          toLice(roleColor(Role::AccentHot)), 0.30f, 0);
            LICE_FillRect(bmp, bar.left + sx, sy, bw, stripArea.height(),
                          toLice(roleColor(Role::AccentPrimary)), 1.0f, 0);
        } else {
            LICE_FillRect(bmp, bar.left + sx, sy, bw, stripArea.height(),
                          toLice(roleColor(Role::AccentSecondary)), 0.55f, 0);
        }
    }

    // A one-line legend of the selected zone below the strip, with three click-to-type numeric
    // entry fields (low / high / root) — S12 direct numeric entry. Clicking a field focuses it
    // (entryField_) and typed text commits via parseNoteEntry on Enter.
    const int legendTop = stripArea.bottom + 8;
    Rect infoR{stripArea.left, legendTop, stripArea.right, legendTop + 18};
    if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
        const PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
        kitText(bmp, Rect{infoR.left, infoR.top, infoR.left + 120, infoR.bottom},
                sampleLabel(samples_, z.sampleId).c_str(), Font::Label, Role::TextPrimary);
        // Three fields laid out left-to-right after the sample label. A focused field lifts to
        // the Focus state (accent nudge + ring); values in tabular mono so digits don't jitter.
        const Rect fields = noteEntryFieldsArea(bands);
        const char* names[3] = {"Low", "High", "Root"};
        const std::string vals[3] = {
            noteLabel(z.lowNote), noteLabel(z.highNote),
            z.rootOverride ? noteLabel(*z.rootOverride) : std::string("(bank)")};
        for (int f = 0; f < 3; ++f) {
            const Rect fr = noteEntryFieldRect(fields, f);
            const bool editing = (entryField_ == f);
            fillSurface(bmp, toKitBox(fr), Role::BgCell,
                        editing ? InteractionState::Focus : InteractionState::Rest);
            const KitColor border =
                editing ? roleColor(Role::TextPrimary) : roleColor(Role::LineHairline);
            LICE_DrawRect(bmp, fr.left, fr.top, fr.width() - 1, fr.height() - 1,
                          toLice(border), 1.0f, 0);
            std::string cap = std::string(names[f]) + ": " +
                              (editing ? (entryText_ + "_") : vals[f]);
            kitText(bmp, Rect{fr.left + 4, fr.top, fr.right - 2, fr.bottom}, cap.c_str(),
                    Font::ValueMono, Role::TextPrimary);
        }
    } else if (map_.zones.empty()) {
        kitText(bmp, infoR,
                "No zones. Add Zone maps the picked capture across the keyboard.",
                Font::Label, Role::TextDim);
    }

    // The S12/S15/S16 parameter surface for the selected zone (play mode + AHDSR / Trigger +
    // pitch engine + AD pitch envelope). Shown for an explicit zone selection OR for the
    // single-capture face when the map is empty but a capture is picked (S15-F2 lean: the
    // single capture is already a one-zone map — one storage site serves both).
    const bool haveControlTarget =
        (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) ||
        (map_.zones.empty() && !selectedId_.empty());
    if (haveControlTarget) {
        paintControls(bmp, zonesControlPanel(computeBands(w, h)));
    }
}

// The label + the two toggle-segment captions for a control (member so it can name the private
// ParamControl enum). Segments are only read for a ControlKind::Toggle.
namespace {
struct ControlLabels { const char* label; const char* seg0; const char* seg1; };
}  // namespace

void ReaSamplerEditor::paintControls(LICE_IBitmap* bmp, const Rect& panel) {
    // Resolve the play params: from the selected zone when one is chosen, or from the
    // PerformanceZone product defaults when the map is empty but a capture is picked
    // (S15-F2 lean: the single-capture face shares the same storage site as a one-zone map;
    // see paintZones for the gate that reaches here).
    ZonePlaySeconds play;
    if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
        play = map_.zones[static_cast<std::size_t>(selectedZone_)].play;
    } else if (map_.zones.empty() && !selectedId_.empty()) {
        play = PerformanceZone{}.play;  // product defaults (Gate + Preserve + tier-0 ADSR)
    } else {
        return;  // no control target
    }
    const std::vector<ControlDesc> descs = controlDescs(play);
    const std::vector<ControlRow> rows = layoutControls(panel, descs);

    const auto labelsFor = [](ParamControl c) -> ControlLabels {
        switch (c) {
            case ParamControl::kPlayMode:       return {"Mode", "Gate", "Trigger"};
            case ParamControl::kPitchEngine:    return {"Pitch eng", "Varisp", "Preserve"};
            case ParamControl::kAttack:         return {"Attack", "", ""};
            case ParamControl::kHold:           return {"Hold", "", ""};
            case ParamControl::kDecay:          return {"Decay", "", ""};
            case ParamControl::kSustain:        return {"Sustain", "", ""};
            case ParamControl::kRelease:        return {"Release", "", ""};
            case ParamControl::kTrigLength:     return {"Length %", "", ""};
            case ParamControl::kTrigFadeIn:     return {"Fade in", "", ""};
            case ParamControl::kTrigFadeOut:    return {"Fade out", "", ""};
            case ParamControl::kPitchEnvEnable: return {"Pitch env", "Off", "On"};
            case ParamControl::kPitchEnvAttack: return {"P.Attack", "", ""};
            case ParamControl::kPitchEnvDecay:  return {"P.Decay", "", ""};
            case ParamControl::kPitchEnvDepth:  return {"P.Depth", "", ""};
            default:                            return {"", "", ""};
        }
    };

    for (const ControlRow& r : rows) {
        if (r.row.top >= panel.bottom) break;  // clip at the panel bottom
        const ControlLabels lab = labelsFor(static_cast<ParamControl>(r.id));
        kitText(bmp, r.label, lab.label, Font::Micro, Role::TextDim);
        const double v = controlValue(r.id, play);
        const bool hov = isHovered(HoverKind::kControl, r.id);
        if (r.kind == ControlKind::Toggle) {
            const bool seg1 = (v >= 0.5);
            const Rect s0 = toggleSegmentRect(r.control, 0);
            const Rect s1 = toggleSegmentRect(r.control, 1);
            // The lit segment carries the primary accent (Active); the unlit segment hovers
            // toward accent/hot when the whole control is under the pointer.
            const InteractionState s0State =
                !seg1 ? InteractionState::Active : (hov ? InteractionState::Hover : InteractionState::Rest);
            const InteractionState s1State =
                seg1 ? InteractionState::Active : (hov ? InteractionState::Hover : InteractionState::Rest);
            fillSurface(bmp, toKitBox(s0), Role::BgCell, s0State);
            fillSurface(bmp, toKitBox(s1), Role::BgCell, s1State);
            kitTextCentered(bmp, s0, lab.seg0, Font::Micro, !seg1 ? Role::BgBase : Role::TextPrimary);
            kitTextCentered(bmp, s1, lab.seg1, Font::Micro, seg1 ? Role::BgBase : Role::TextPrimary);
        } else {
            // Track groove (recessed cell) + accent filled portion up to the handle + a raised
            // handle. Dragging THIS control brightens the fill/handle (accent/hot).
            const bool dragging = (drag_ == DragKind::kParamSlider && dragParamId_ == r.id);
            const Rect track = sliderTrackRect(r.control);
            fillSurface(bmp, toKitBox(Rect{track.left, track.top + track.height() / 2 - 1,
                                           track.right, track.top + track.height() / 2 + 1}),
                        Role::BgCell, InteractionState::Pressed);
            const Rect handle = sliderHandleRect(r.control, v);
            // Filled portion: track-left to the handle center.
            const int fillW = (std::max)(0, (handle.left + handle.width() / 2) - track.left);
            if (fillW > 0) {
                LICE_FillRect(bmp, track.left, track.top + track.height() / 2 - 1, fillW, 2,
                              toLice(roleColor(dragging ? Role::AccentHot : Role::AccentPrimary)),
                              1.0f, 0);
            }
            const KitButtonBox knob{toKitBox(Rect{handle.left, handle.top + 2, handle.right,
                                                  handle.bottom - 2})};
            drawButton(bmp, knob, nullptr,
                       dragging ? InteractionState::Dragging
                                : (hov ? InteractionState::Hover : InteractionState::Rest),
                       /*warn=*/false);
        }
    }
}

// --- Hover resolution (Phase L, L3) ------------------------------------------
//
// Resolve the interactive element under (x, y) into hover_ and repaint only on change (an
// idle move is free — the "sub-frame feedback, zero cost when nothing changed" discipline).
// Mirrors onMouseDown's hit-test order, but read-only: it never mutates selection/map. Only
// the frequently-touched interactive surfaces light on hover; a purely decorative region
// resolves to kNone (clearing any prior hover). Windows-only.
void ReaSamplerEditor::resolveHover(int x, int y) {
    HoverTarget h;  // kNone by default
    RECT cr{};
    GetClientRect(childHwnd_, &cr);
    const int w = cr.right - cr.left;
    const int hgt = cr.bottom - cr.top;
    const EditorBands bands = computeBands(w, hgt);

    if (contains(bands.toggleBrowser, x, y)) {
        h = {HoverKind::kToggleBrowser, -1};
    } else if (contains(bands.toggleZones, x, y)) {
        h = {HoverKind::kToggleZones, -1};
    } else if (view_ == View::kBrowser) {
        const bool havePick = !selectedId_.empty();
        const int setupTop = havePick
            ? (std::max)(bands.content.top, bands.content.bottom - kSetupHeight)
            : bands.content.bottom;
        const Rect fullBrowserArea{bands.content.left, bands.content.top, bands.content.right, setupTop};
        const Rect searchBox = searchBoxRect(fullBrowserArea.width());
        const Rect searchAbs{fullBrowserArea.left + searchBox.left, fullBrowserArea.top + searchBox.top,
                             fullBrowserArea.left + searchBox.right, fullBrowserArea.top + searchBox.bottom};
        if (contains(searchAbs, x, y)) {
            h = {HoverKind::kSearchBox, -1};
        } else {
            const Rect browserArea{fullBrowserArea.left, searchAbs.bottom, fullBrowserArea.right, setupTop};
            const BrowserLayout bl = layoutBrowser(browserArea.width(), browserArea.height());
            const int bx = x - browserArea.left;
            const int by = y - browserArea.top;
            const int tabCount = static_cast<int>(banks_.size()) + 1;
            const int tab = filterTabHitTest(bl, tabCount, bx, by);
            const int card = (tab >= 0)
                ? -1
                : cardHitTest(bl, static_cast<int>(visible_.size()), bx, by + scrollOffset_);
            if (tab >= 0) {
                h = {HoverKind::kFilterTab, tab};
            } else if (card >= 0) {
                h = {HoverKind::kCard, card};
            } else if (havePick) {
                const Rect area{bands.content.left, setupTop, bands.content.right, bands.content.bottom};
                const ChannelToggleRects chan = channelToggleRects(area);
                if (contains(chan.mono, x, y)) h = {HoverKind::kChanMono, -1};
                else if (contains(chan.stereo, x, y)) h = {HoverKind::kChanStereo, -1};
            }
        }
    } else {  // Zones view
        const int pad = 8;
        Rect addR{bands.content.left + pad, bands.content.top + 4, bands.content.left + pad + 96,
                  bands.content.top + 4 + 20};
        Rect delR{addR.right + 8, addR.top, addR.right + 8 + 64, addR.bottom};
        if (contains(addR, x, y)) {
            h = {HoverKind::kAddZone, -1};
        } else if (selectedZone_ >= 0 && contains(delR, x, y)) {
            h = {HoverKind::kDeleteZone, -1};
        } else {
            // The param control panel (a selected zone, or the single-capture face's defaults).
            ZonePlaySeconds play;
            bool haveTarget = false;
            if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
                play = map_.zones[static_cast<std::size_t>(selectedZone_)].play;
                haveTarget = true;
            } else if (map_.zones.empty() && !selectedId_.empty()) {
                play = PerformanceZone{}.play;
                haveTarget = true;
            }
            if (haveTarget) {
                const Rect panel = zonesControlPanel(bands);
                const std::vector<ControlDesc> descs = controlDescs(play);
                const std::vector<ControlRow> rows = layoutControls(panel, descs);
                const int id = controlAtPoint(rows, x, y);
                if (id >= 0) h = {HoverKind::kControl, id};
            }
        }
    }

    if (h != hover_) {
        hover_ = h;
        invalidate();
    }
}

// --- Input: the drag-state machine -------------------------------------------

void ReaSamplerEditor::onMouseDown(int x, int y) {
    if (!processor_) return;
    RECT cr{};
    GetClientRect(childHwnd_, &cr);
    const int w = cr.right - cr.left;
    const int h = cr.bottom - cr.top;
    const EditorBands bands = computeBands(w, h);

    // Toggle band: switch views.
    if (contains(bands.toggleBrowser, x, y)) { view_ = View::kBrowser; invalidate(); return; }
    if (contains(bands.toggleZones, x, y)) { view_ = View::kZones; invalidate(); return; }

    if (view_ == View::kBrowser) {
        const bool havePick = !selectedId_.empty();
        const int setupTop = havePick ? (std::max)(bands.content.top, bands.content.bottom - kSetupHeight)
                                      : bands.content.bottom;
        const Rect fullBrowserArea{bands.content.left, bands.content.top, bands.content.right, setupTop};

        // S12 search box (mirror of paintBrowser): a click focuses it; the browser sits below.
        const Rect searchBox = searchBoxRect(fullBrowserArea.width());
        const Rect searchAbs{fullBrowserArea.left + searchBox.left, fullBrowserArea.top + searchBox.top,
                             fullBrowserArea.left + searchBox.right, fullBrowserArea.top + searchBox.bottom};
        if (contains(searchAbs, x, y)) {
            searchFocused_ = true;
            invalidate();
            return;
        }
        searchFocused_ = false;  // any other browser click defocuses the search box

        const Rect browserArea{fullBrowserArea.left, searchAbs.bottom, fullBrowserArea.right, setupTop};
        const BrowserLayout bl = layoutBrowser(browserArea.width(), browserArea.height());
        const int bx = x - browserArea.left;
        const int by = y - browserArea.top;

        // Filter tabs.
        const int tabCount = static_cast<int>(banks_.size()) + 1;
        const int tab = filterTabHitTest(bl, tabCount, bx, by);
        if (tab >= 0) {
            activeFilterBankId_ = (tab == 0) ? std::string()
                                             : banks_[static_cast<std::size_t>(tab - 1)].id;
            rebuildVisible();
            invalidate();
            return;
        }
        // S12 scrollbar thumb: grab to drag-scroll (checked before cards — the thumb overlays the
        // grid's right gutter). scrollThumbRect is empty when the content fits, so this is inert then.
        const Rect thumb = scrollThumbRect(bl, static_cast<int>(visible_.size()), scrollOffset_);
        if (thumb.height() > 0 &&
            contains(Rect{thumb.left + browserArea.left, thumb.top + browserArea.top,
                          thumb.right + browserArea.left, thumb.bottom + browserArea.top}, x, y)) {
            drag_ = DragKind::kScrollThumb;
            dragStartY_ = y;
            dragStartScrollOffset_ = scrollOffset_;
            return;
        }
        // Cards: pick a capture -> load it (this is the whole time-to-first-note gesture). The
        // hit-test adds the scroll offset back so a scrolled card maps to the right index.
        const int card = cardHitTest(bl, static_cast<int>(visible_.size()), bx, by + scrollOffset_);
        if (card >= 0) {
            selectedId_ = visible_[static_cast<std::size_t>(card)].id;
            commitAndReload();  // publishes the pick + reloads; process() plays it repitched
            return;
        }
        // The setup band: the mono/stereo toggle (header row), the S11 waveform markers,
        // then the root-marker strip.
        if (havePick) {
            const Rect area{bands.content.left, setupTop, bands.content.right, bands.content.bottom};
            // S7: a click on a channel-mode segment sets the instance mode (setChannelMode
            // re-negotiates the bus + reloads; a no-op set for the already-active mode is ignored
            // by the processor). Snapshot the new mode locally so the paint reflects it at once.
            const ChannelToggleRects chan = channelToggleRects(area);
            if (contains(chan.mono, x, y)) {
                channelMode_ = ChannelMode::Mono;
                processor_->setChannelMode(ChannelMode::Mono);
                invalidate();
                return;
            }
            if (contains(chan.stereo, x, y)) {
                channelMode_ = ChannelMode::Stereo;
                processor_->setChannelMode(ChannelMode::Stereo);
                invalidate();
                return;
            }

            // S11 waveform markers: grab start / loop-start / loop-end to drag. Hit-test the
            // waveform band first (it sits above the keyboard strip). markerAtPoint resolves
            // which marker under the grab; a miss falls through to the keyboard strip.
            const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
            const std::int64_t frames = static_cast<std::int64_t>(pcm.size());
            if (frames > 0) {
                const Rect waveArea = setupWaveformArea(area);
                const SetupMarkers m = pickedMarkers(frames);
                const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd};
                const int hit = markerAtPoint(waveArea, frames, markerFrames, 3, x, y);
                if (hit >= 0) {
                    drag_ = DragKind::kWaveMarker;
                    waveMarker_ = static_cast<WaveMarker>(hit);
                    dragStartX_ = x;
                    dragStartMarkers_ = m;
                    dragSampleFrames_ = frames;
                    dragStartMap_ = map_;
                    return;  // no immediate set — the marker only moves once the cursor drags
                }
            }

            // The setup strip: grab the root marker (drag to set the picked capture's root).
            const Rect stripArea = setupStripArea(area);
            const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height());
            const int note = keyAtPoint(sl, x - stripArea.left, y - stripArea.top);
            if (note >= 0) {
                drag_ = DragKind::kRootMarker;
                dragStartX_ = x;
                dragStartRoot_ = note;
                dragStartMap_ = map_;
                // A click sets the root immediately (drag then refines); the override lives on
                // a one-zone map entry for the picked capture (D-B, never written to the bank).
                onMouseMove(x, y);  // apply the click position as the first delta==0 set
                return;
            }
        }
        return;
    }

    // Zones view.
    const int pad = 8;
    Rect addR{bands.content.left + pad, bands.content.top + 4, bands.content.left + pad + 96,
              bands.content.top + 4 + 20};
    if (contains(addR, x, y)) {
        // Add a full-keyboard zone for the picked capture (or the first visible sample as a
        // sensible seed). No pick -> nothing to add. If a full-keyboard zone for the seed id
        // already exists, select it rather than appending a duplicate (mirrors the upsert the
        // root-marker drag path already performs, preventing overlapping identical zones).
        std::string seed = !selectedId_.empty() ? selectedId_
                           : (!visible_.empty() ? visible_.front().id : std::string());
        if (seed.empty()) return;
        for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
            const PerformanceZone& z = map_.zones[static_cast<std::size_t>(i)];
            if (z.sampleId == seed && z.lowNote == 0 && z.highNote == 127) {
                selectedZone_ = i;
                invalidate();
                return;
            }
        }
        PerformanceZone z;
        z.sampleId = seed;
        z.lowNote = 0;
        z.highNote = 127;
        map_.zones.push_back(z);
        selectedZone_ = static_cast<int>(map_.zones.size()) - 1;
        commitAndReload();
        return;
    }
    Rect delR{addR.right + 8, addR.top, addR.right + 8 + 64, addR.bottom};
    if (selectedZone_ >= 0 && contains(delR, x, y)) {
        map_.zones.erase(map_.zones.begin() + selectedZone_);
        selectedZone_ = -1;
        commitAndReload();
        return;
    }

    // The zones strip: hit-test a bar edge/body to start a drag, or a bare key to set the
    // selected zone's root.
    const Rect stripArea = zonesStripArea(bands);
    const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height());
    const int lx = x - stripArea.left;
    const int ly = y - stripArea.top;

    std::vector<int> lows, highs;
    lows.reserve(map_.zones.size());
    highs.reserve(map_.zones.size());
    for (const PerformanceZone& z : map_.zones) { lows.push_back(z.lowNote); highs.push_back(z.highNote); }
    const ZoneBarHit hit = zoneBarAtPoint(sl, lows.empty() ? nullptr : lows.data(),
                                          highs.empty() ? nullptr : highs.data(),
                                          static_cast<int>(map_.zones.size()), lx, ly);
    if (hit.zoneIndex >= 0) {
        selectedZone_ = hit.zoneIndex;
        const PerformanceZone& z = map_.zones[static_cast<std::size_t>(hit.zoneIndex)];
        dragStartX_ = x;
        dragStartLow_ = z.lowNote;
        dragStartHigh_ = z.highNote;
        dragStartMap_ = map_;
        switch (hit.grab) {
            case ZoneGrab::kLowEdge: drag_ = DragKind::kZoneLow; break;
            case ZoneGrab::kHighEdge: drag_ = DragKind::kZoneHigh; break;
            case ZoneGrab::kBody: drag_ = DragKind::kZoneBody; break;
            default: drag_ = DragKind::kNone; break;
        }
        invalidate();
        return;
    }
    // A bare key-click inside the strip sets the selected zone's root override.
    if (contains(stripArea, x, y) && selectedZone_ >= 0 &&
        selectedZone_ < static_cast<int>(map_.zones.size())) {
        const int note = keyAtPoint(sl, lx, ly);
        if (note >= 0) {
            map_.zones[static_cast<std::size_t>(selectedZone_)].rootOverride = note;
            commitAndReload();
        }
        return;
    }

    // S12 numeric-entry fields (low/high/root): a click focuses the field for typing. Only when a
    // zone is selected. entryText_ starts empty (the user types the full value).
    if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
        const Rect fields = noteEntryFieldsArea(bands);
        for (int f = 0; f < 3; ++f) {
            if (contains(noteEntryFieldRect(fields, f), x, y)) {
                entryField_ = f;
                entryText_.clear();
                invalidate();
                return;
            }
        }
    }
    entryField_ = -1;  // a click elsewhere in the Zones view cancels an in-progress entry

    // The S12/S15/S16 parameter panel: a toggle segment flips at once (commit); a slider grab
    // starts a live drag (commit on release). Reachable for an explicit zone selection OR for
    // the single-capture face when the map is empty but a capture is picked (S15-F2 lean).
    // In the empty-map+picked case, auto-create a full-keyboard zone for selectedId_ on first
    // control interaction (same path as "+ Add Zone"), then apply the control — the zone is
    // committed as part of the control edit.
    if (selectedZone_ < 0 && map_.zones.empty() && !selectedId_.empty()) {
        // Synthesize a probe layout with the product defaults to see if the click is in the
        // panel before committing to creating the zone.
        const Rect panel = zonesControlPanel(bands);
        const ZonePlaySeconds defaultPlay = PerformanceZone{}.play;
        const std::vector<ControlDesc> probeDescs = controlDescs(defaultPlay);
        const std::vector<ControlRow> probeRows = layoutControls(panel, probeDescs);
        if (controlAtPoint(probeRows, x, y) >= 0) {
            // The click lands in the control panel — materialize the zone now.
            PerformanceZone z;
            z.sampleId = selectedId_;
            z.lowNote = 0;
            z.highNote = 127;
            map_.zones.push_back(z);
            selectedZone_ = 0;
            // Fall through to the control handler below which will process the click.
        }
    }
    if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
        PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
        const Rect panel = zonesControlPanel(bands);
        const std::vector<ControlDesc> descs = controlDescs(z.play);
        const std::vector<ControlRow> rows = layoutControls(panel, descs);
        const int id = controlAtPoint(rows, x, y);
        if (id >= 0) {
            // Find the row to know its kind + control rect.
            for (const ControlRow& r : rows) {
                if (r.id != id) continue;
                if (r.kind == ControlKind::Toggle) {
                    const int seg = toggleSegmentHitTest(r.control, x, y);
                    if (seg >= 0) {
                        applyControl(id, z.play, 0.0, seg);
                        commitAndReload();  // a toggle is a discrete, final edit
                    }
                } else {
                    // Grab the slider: set the value at the grab x immediately, then live-drag.
                    drag_ = DragKind::kParamSlider;
                    dragParamId_ = id;
                    dragParamPanel_ = panel;
                    dragStartMap_ = map_;
                    applyControl(id, z.play, valueAtPoint(r.control, x), 0);
                    invalidate();  // live feedback; commit on WM_LBUTTONUP
                }
                break;
            }
        }
    }
}

void ReaSamplerEditor::onMouseMove(int x, int y) {
    if (drag_ == DragKind::kNone) return;
    RECT cr{};
    GetClientRect(childHwnd_, &cr);
    const int w = cr.right - cr.left;
    const int h = cr.bottom - cr.top;
    const EditorBands bands = computeBands(w, h);
    const int dx = x - dragStartX_;

    if (drag_ == DragKind::kRootMarker) {
        // The single-capture root strip lives in the setup band.
        const int setupTop = (std::max)(bands.content.top, bands.content.bottom - kSetupHeight);
        const Rect area{bands.content.left, setupTop, bands.content.right, bands.content.bottom};
        const Rect stripArea = setupStripArea(area);
        const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height());
        const int note = resolveDragNote(sl, dragStartRoot_, dx);
        // The performance map is the ONLY D-B override vehicle (rootOverride lives on a zone),
        // so setting the single capture's root materializes a full-keyboard zone carrying the
        // override. This plays identically to the un-zoned single-capture path (one chromatic
        // zone over the whole keyboard) and round-trips through the v3 component state; the
        // zone becomes visible if the user opens the Zones panel. Upsert by the picked id so a
        // repeated drag edits the same zone rather than stacking duplicates.
        // Upsert the root override on the picked id; track the zone index so the control panel
        // stays visible after the zone is materialized on the single-capture face (fix: without
        // setting selectedZone_ here, selectedZone_==-1 with a non-empty map hides controls).
        bool found = false;
        for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
            PerformanceZone& z = map_.zones[static_cast<std::size_t>(i)];
            if (z.sampleId == selectedId_) {
                z.rootOverride = note;
                selectedZone_ = i;
                found = true;
                break;
            }
        }
        if (!found) {
            PerformanceZone z;
            z.sampleId = selectedId_;
            z.lowNote = 0;
            z.highNote = 127;
            z.rootOverride = note;
            map_.zones.push_back(z);
            selectedZone_ = static_cast<int>(map_.zones.size()) - 1;
        }
        invalidate();  // live feedback; the commit lands on WM_LBUTTONUP
        return;
    }

    if (drag_ == DragKind::kWaveMarker) {
        // S11: resolve the grabbed marker's new frame from the pixel delta, zero-crossing-snap
        // it against the decoded PCM, apply the inter-marker clamps, and write the override live.
        const int setupTop = (std::max)(bands.content.top, bands.content.bottom - kSetupHeight);
        const Rect area{bands.content.left, setupTop, bands.content.right, bands.content.bottom};
        const Rect waveArea = setupWaveformArea(area);
        const std::int64_t frames = dragSampleFrames_;
        if (frames <= 0) return;

        // Grabbed frame at grab time, from the snapshot (so the delta is measured from grab).
        const int idx = static_cast<int>(waveMarker_);
        const std::int64_t startVals[3] = {dragStartMarkers_.start, dragStartMarkers_.loopStart,
                                           dragStartMarkers_.loopEnd};
        std::int64_t newFrame = resolveDragFrame(waveArea, frames, startVals[idx], dx);

        // Snap to the nearest zero crossing in the decoded PCM (the S2 zero-crossing-aware
        // requirement). Pure over the cached mono frames — no host types, no file I/O.
        const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
        if (!pcm.empty()) {
            newFrame = nearestZeroCrossing(pcm.data(), static_cast<std::int64_t>(pcm.size()),
                                           newFrame);
        }

        // Build the edited marker set from the snapshot, moving only the grabbed marker, then
        // clamp: loopStart <= loopEnd, start in [0, frames-1]. Dragging a loop marker MAKES a loop.
        SetupMarkers m = dragStartMarkers_;
        if (waveMarker_ == WaveMarker::kStart) {
            m.start = newFrame;
        } else if (waveMarker_ == WaveMarker::kLoopStart) {
            m.loopStart = (std::min)(newFrame, m.loopEnd);
            m.hasLoop = true;
        } else {  // kLoopEnd
            m.loopEnd = (std::max)(newFrame, m.loopStart);
            m.hasLoop = true;
        }
        if (m.start < 0) m.start = 0;
        if (m.start > frames - 1) m.start = frames - 1;

        // Upsert the override on the picked id (mirror of the root-marker path); commit lands on
        // release, this is live feedback. Set selectedZone_ so the control panel stays visible
        // after the zone is materialized (fix: without this, selectedZone_==-1 with a non-empty
        // map hides controls after the first marker drag on the single-capture face).
        selectedZone_ = upsertPickedOverride(m);
        invalidate();
        return;
    }

    if (drag_ == DragKind::kScrollThumb) {
        // S12: map the thumb-drag pixel delta to a new (clamped) scroll offset. The visible-card
        // window recomputes at paint from scrollOffset_. The browser sub-area matches paintBrowser
        // when a capture is picked (the setup band takes the bottom).
        const int dyThumb = y - dragStartY_;
        const bool havePick = !selectedId_.empty();
        const int setupTop = havePick
            ? (std::max)(bands.content.top, bands.content.bottom - kSetupHeight)
            : bands.content.bottom;
        const BrowserLayout bl = layoutBrowser(bands.content.width(), setupTop - bands.content.top);
        scrollOffset_ = thumbDragToOffset(bl, static_cast<int>(visible_.size()),
                                          dragStartScrollOffset_, dyThumb);
        invalidate();
        return;
    }

    if (drag_ == DragKind::kParamSlider) {
        // S12/S15/S16: re-lay the panel and map x -> value against the grabbed control's live
        // track rect (the panel geometry is stable during the drag; re-laying keeps the value
        // mapping exact even if a mode toggle changed the row set — it did not, mid-drag).
        if (selectedZone_ < 0 || selectedZone_ >= static_cast<int>(map_.zones.size())) return;
        PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
        const std::vector<ControlDesc> descs = controlDescs(z.play);
        const std::vector<ControlRow> rows = layoutControls(dragParamPanel_, descs);
        for (const ControlRow& r : rows) {
            if (r.id == dragParamId_) {
                applyControl(dragParamId_, z.play, valueAtPoint(r.control, x), 0);
                break;
            }
        }
        invalidate();
        return;
    }

    // Zone edits: recompute the grabbed field(s) against the pure resolver, live.
    if (selectedZone_ < 0 || selectedZone_ >= static_cast<int>(map_.zones.size())) return;
    const Rect stripArea = zonesStripArea(bands);
    const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height());
    PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
    if (drag_ == DragKind::kZoneLow) {
        z.lowNote = (std::min)(resolveDragNote(sl, dragStartLow_, dx), z.highNote);
    } else if (drag_ == DragKind::kZoneHigh) {
        z.highNote = (std::max)(resolveDragNote(sl, dragStartHigh_, dx), z.lowNote);
    } else if (drag_ == DragKind::kZoneBody) {
        // Move the whole span: apply the SAME delta to both edges so the span is preserved,
        // clamping so neither edge escapes [0,127] (the span shifts, never shrinks).
        const int newLow = resolveDragNote(sl, dragStartLow_, dx);
        const int newHigh = resolveDragNote(sl, dragStartHigh_, dx);
        const int span = dragStartHigh_ - dragStartLow_;
        if (newLow < 0) { z.lowNote = 0; z.highNote = span; }
        else if (newHigh > 127) { z.highNote = 127; z.lowNote = 127 - span; }
        else { z.lowNote = newLow; z.highNote = newHigh; }
    }
    invalidate();
}

void ReaSamplerEditor::onMouseUp(int /*x*/, int /*y*/) {
    if (drag_ == DragKind::kNone) return;
    const DragKind kind = drag_;
    drag_ = DragKind::kNone;
    // A scrollbar drag is transient UI (no map change) — repaint but do NOT reload. Every other
    // drag is a coherent map edit: publish the in-flight map + reload off-thread on release.
    if (kind == DragKind::kScrollThumb) {
        invalidate();
        return;
    }
    commitAndReload();
}

void ReaSamplerEditor::onMouseWheel(int delta) {
    // S12 browser scroll (only in the browser view). One wheel notch (WHEEL_DELTA==120) scrolls
    // roughly one card row; the offset is clamped at paint (the layout/panel height is known
    // there). A positive delta (wheel up) scrolls toward the top (smaller offset).
    if (view_ != View::kBrowser) return;
    const int rows = delta / 120;
    if (rows == 0) return;
    scrollOffset_ -= rows * kBrowserCardHeight;
    if (scrollOffset_ < 0) scrollOffset_ = 0;  // paint clamps the upper bound to the content
    invalidate();
}

void ReaSamplerEditor::onSearchChar(unsigned int ch) {
    // S12 numeric note-entry (Zones view): a focused low/high/root field accumulates keystrokes
    // and commits via parseNoteEntry on Enter. Handled before the search box (a field, when
    // focused, owns the keystrokes).
    if (view_ == View::kZones && entryField_ >= 0) {
        if (ch == 13) {  // Enter: parse + commit
            if (auto note = parseNoteEntry(entryText_)) {
                if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
                    PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
                    if (entryField_ == 0) z.lowNote = (std::min)(*note, z.highNote);
                    else if (entryField_ == 1) z.highNote = (std::max)(*note, z.lowNote);
                    else z.rootOverride = *note;
                    commitAndReload();
                }
            }
            entryField_ = -1;
            entryText_.clear();
            invalidate();
        } else if (ch == 27) {  // Escape cancels
            entryField_ = -1;
            entryText_.clear();
            invalidate();
        } else if (ch == 8) {  // backspace
            if (!entryText_.empty()) entryText_.pop_back();
            invalidate();
        } else if (ch >= 32 && ch < 127) {
            entryText_.push_back(static_cast<char>(ch));
            invalidate();
        }
        return;
    }

    // S12 type-to-filter search. Only when the search box has focus (a click focuses it). Backspace
    // deletes; a printable ASCII char appends; the visible list recomposes (bank filter, then search).
    if (view_ != View::kBrowser || !searchFocused_) return;
    if (ch == 8) {  // backspace
        if (!searchQuery_.empty()) searchQuery_.pop_back();
    } else if (ch == 27) {  // escape clears + defocuses
        searchQuery_.clear();
        searchFocused_ = false;
    } else if (ch >= 32 && ch < 127) {
        searchQuery_.push_back(static_cast<char>(ch));
    } else {
        return;  // ignore other control chars
    }
    scrollOffset_ = 0;   // a new filter resets the scroll to the top of the narrowed list
    rebuildVisible();
    invalidate();
}

void ReaSamplerEditor::onFilesDropped(int droppedCount) {
    // S13 relay DEGRADED. The instrument is a read-only bank consumer and the cross-artifact
    // ingest relay (editor drop -> extension) is not shipped (see the header note + the handoff
    // decision point), so we do NOT ingest the dropped files and — load-bearing — NEVER insert a
    // timeline item. Instead of silently swallowing the drop, flash a clear affordance pointing
    // at the shipped ingest gesture. dropHintTicks_ counts sync ticks (kSyncTimerIntervalMs
    // each); ~6 ticks keeps the banner up a few seconds, then onSyncTimer decays it to 0.
    (void)droppedCount;  // count is informational; the banner text is drop-count-agnostic
    dropHintTicks_ = 6;
#ifdef _WIN32
    invalidate();
#endif
}

LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
                                           LPARAM lParam) {
    auto* self =
        reinterpret_cast<ReaSamplerEditor*>(GetWindowLongPtr(hwnd, GWLP_USERDATA));
    switch (msg) {
        case WM_PAINT: {
            PAINTSTRUCT ps{};
            HDC hdc = BeginPaint(hwnd, &ps);
            if (self) self->paint(hdc);
            EndPaint(hwnd, &ps);
            return 0;
        }
        case WM_LBUTTONDOWN:
            if (self) {
                SetCapture(hwnd);  // keep receiving moves/up if the cursor leaves the child
                SetFocus(hwnd);    // take keyboard focus so WM_CHAR reaches the search box (S12)
                self->onMouseDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
            }
            return 0;
        case WM_MOUSEMOVE:
            if (self) {
                const int mx = GET_X_LPARAM(lParam);
                const int my = GET_Y_LPARAM(lParam);
                // Hover feedback (Phase L, L3): resolve the element under the pointer and
                // repaint on change. Arm WM_MOUSELEAVE once per "over" cycle so the hover
                // clears when the pointer leaves the child (TrackMouseEvent is one-shot).
                if (!self->mouseTracking_) {
                    TRACKMOUSEEVENT tme{};
                    tme.cbSize = sizeof(tme);
                    tme.dwFlags = TME_LEAVE;
                    tme.hwndTrack = hwnd;
                    TrackMouseEvent(&tme);
                    self->mouseTracking_ = true;
                }
                // While a drag is in flight the drag owns the surface; skip hover resolution
                // (a hover repaint mid-drag would fight the live drag feedback).
                if (self->drag_ == DragKind::kNone) self->resolveHover(mx, my);
                self->onMouseMove(mx, my);
            }
            return 0;
        case WM_MOUSELEAVE:
            if (self) {
                self->mouseTracking_ = false;
                if (self->hover_.kind != HoverKind::kNone) {
                    self->hover_ = HoverTarget{};
                    self->invalidate();
                }
            }
            return 0;
        case WM_MOUSEWHEEL:
            // S12 browser scroll. GET_WHEEL_DELTA_WPARAM is signed; positive == wheel up.
            if (self) self->onMouseWheel(GET_WHEEL_DELTA_WPARAM(wParam));
            return 0;
        case WM_CHAR:
            // S12 type-to-filter search keystrokes (only acted on when the search box is focused).
            if (self) self->onSearchChar(static_cast<unsigned int>(wParam));
            return 0;
        case WM_GETDLGCODE:
            // Claim all keys (incl. chars) so the host doesn't eat them before WM_CHAR (S12 search).
            return DLGC_WANTCHARS | DLGC_WANTARROWS;
        case WM_LBUTTONUP:
            if (self) {
                self->onMouseUp(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
                ReleaseCapture();
            }
            return 0;
        case WM_CAPTURECHANGED:
            // Capture stolen mid-drag (modal dialog, alt-tab, etc.) — restore map_ to its
            // pre-grab snapshot so the in-flight live-drag mutation is rolled back, then reset
            // the drag state machine so stale capture-less WM_MOUSEMOVEs don't keep editing.
            // Mirror of bank_panel.cpp's WM_CAPTURECHANGED handler.
            if (self && self->drag_ != DragKind::kNone) {
                // A scrollbar drag is transient (no map mutation + dragStartMap_ was not
                // snapshotted for it) — reset the drag state only, never touch map_. Every
                // map-editing drag rolls its live mutation back to the pre-grab snapshot.
                if (self->drag_ != DragKind::kScrollThumb) self->map_ = self->dragStartMap_;
                self->drag_ = DragKind::kNone;
                self->invalidate();
            }
            return 0;
        case WM_DROPFILES: {
            // S13 (relay degraded): count the dropped files and flash the affordance. We do NOT
            // read/ingest the paths (the instrument never ingests — the relay to the extension is
            // unshipped); DragQueryFile with 0xFFFFFFFF just returns the count for the banner.
            HDROP drop = reinterpret_cast<HDROP>(wParam);
            const UINT count = DragQueryFileW(drop, 0xFFFFFFFF, nullptr, 0);
            DragFinish(drop);
            if (self) self->onFilesDropped(static_cast<int>(count));
            return 0;
        }
        case WM_TIMER:
            if (self && wParam == kSyncTimerId) self->onSyncTimer();
            return 0;
        case WM_ERASEBKGND:
            return 1;  // fully repaint in WM_PAINT; skip the flicker-inducing erase
        default:
            return DefWindowProcW(hwnd, msg, wParam, lParam);
    }
}

#else  // non-Windows: not a build target (D5), but keep the TU compilable.

void ReaSamplerEditor::attachedToParent() {}
void ReaSamplerEditor::removedFromParent() {}
tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) {
    return CPluginView::onSize(newSize);
}

#endif  // _WIN32

}  // namespace reasampler::vst
