// bank_panel.cpp — REAPER-facing docked grid (M5 Wave A/B + Phase B4). See
// bank_panel.h.
//
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are
// extern (CLAUDE.md §contract).
//
// What this file owns (all REAPER/SWELL/LICE-bound, hence DAW-verified, not unit
// tested):
//   * a SWELL dialog (IDD_BANK_PANEL) docked via DockWindowAddEx / undocked via
//     DockWindowRemove; toggled open/closed.
//   * WM_PAINT: a VERTICAL SPLIT (Phase B4) — the pool grid region on top, a
//     LICE-drawn named-banks tab-page region below (one tab per named bank, an
//     overflow/scroll strip), and two full-height toggles that collapse the split.
//     Each region reuses the M5 grid render loop (waveform thumbnails / empty state).
//   * per-sample PCM read via PCM_source fed to peaks::computeEnvelope at cell width.
//   * an in-memory thumbnail cache keyed by (sample id, draw width, bank generation).
//   * id-keyed bank management (create / rename / delete / evacuate / activate) and
//     sample move/copy — driven from a tab context menu and a drag — against the B1
//     BankBook model on g_session.book(), persisted via g_session.saveToActiveProject().
//
// READ-ONLY of the TIMELINE (load-bearing principle): this panel never inserts into
// the arrange. It DOES mutate the bank BOOK (create/rename/move/etc.) — that is the
// whole point of B4 — but only the index/model + ext-state, never the arrange, never
// a sample file on disk (bank ops are index-only; files stay put — CONTEXT.md §Multi-bank).
//
// THE PURE SEAMS: grid tiling / hit-test / selection math live in bank_grid; the
// mode-switch geometry in mode_switch; the named-banks TAB-STRIP layout, overflow/
// scroll, and hit-test in tab_strip. All three are unit-tested outside the DAW; only
// draw + input routing + the model calls live here.
//
// REFERENCE-INVALIDATION GUARDRAIL (CONTEXT.md §Multi-bank): a bank-structural
// mutation (create/delete/evacuate/activate/move) can reallocate the book's vector,
// so a BankIndex& / Bank* must NEVER be cached across one. Every handler below
// resolves fresh AFTER any mutation and passes bank IDS (not references) into the
// model ops.

#include "bank_panel.h"

#include <cstdint>
#include <cstdlib>  // std::abs (drag threshold)
#include <filesystem>
#include <map>
#include <set>
#include <string>
#include <unordered_map>
#include <vector>

#include "action_bar.h"      // pure TASK-GROUPED action-bar layout + hit-test (L2)
#include "actions.h"    // persistBankOp — shared undo-block wrapper (R-B panel path)
#include "drag_out.h"        // pure gesture-boundary decision + path-list assembly (M11)
#include "drag_out_win.h"    // OLE / SWELL drag-out initiation seam (M11)
#include "app_version.h"     // channelCommandId — compose the named-command lookup string (M11)
#include "bank_book.h"
#include "bank_grid.h"
#include "bank_model.h"
#include "card_drag.h"    // L7 pure gesture precedence + sparse slot layout/hit-test
#include "card_meta.h"    // L7 decorative overlay formatters: bars.beats + s.ms (pure)
#include "capture_paths.h"
#include "component_geometry.h"  // KitBox — the kit text()'s draw box (L1)
#include "draw_kit.h"      // kit text() over cached AA fonts — retires GDI DrawText (L1)
#include "footer_bar.h"    // pure footer LEFT-group layout: toggle + count + Tail button (L4)
#include "guid_diff.h"     // GuidBaseline — new-content detection (D2 Wave 2)
#include "ingest.h"        // ingestDroppedFiles — S8 drop-onto-panel ingest
#include "instrument_drop.h"      // pure buildInstrumentDropChunk — the vst_chunk blob (S17)
#include "instrument_drop_win.h"  // resolveFxDropTarget / performInstrumentDrop shell (S17)
#include "item_read.h"     // itemGuid / itemLaneName — shared item-read seam (D2 W3-B)
#include "lane_keys.h"     // managed/manual lane heuristic (D2 Wave 2)
#include "mode_enable.h"   // opposite-mode tag-button enablement predicate (pure, L5)
#include "mode_switch.h"
#include "overflow_menu.h" // top-toolbar More-button geometry + reserve (pure, L5)
#include "peaks.h"
#include "persist.h"
#include "prune_button.h"  // footer prune-button layout + hit-test (pure, R3)
#include "tooltip.h"       // tooltip placement + prefix-strip (pure, L5)
#include "render_settings.h"  // captureActionTable — the table-driven button rows (M11)
#include "tab_strip.h"
#include "tail_control.h"  // TailSetting, cycleTailMode, tailToggleLabel (pure)
#include "track_guid.h"    // guidString — canonical track GUID key (D2 Wave 2)
#include "view.h"  // applyMode — the D2/D4 mode-activation entrypoint the switch fires
#include "view_mode_model.h"  // autoTagNewContent / NewItem (D2 Wave 2)

// SWELL / LICE. On macOS/Linux SWELL is provided by the host (SWELL_PROVIDED_BY_APP);
// on Windows we use native Win32 (windows.h first, then swell.h no-ops on _WIN32).
#ifdef _WIN32
#include <windows.h>
#include <windowsx.h>  // GET_X_LPARAM / GET_Y_LPARAM (SWELL supplies them on mac/linux)
#include <shellapi.h>  // DragAcceptFiles / DragQueryFile / DragFinish — S8 drop ingest
#else
#include <pthread.h>
#endif
#include "wdltypes.h"
#include "swell/swell.h"
#include "lice/lice.h"

#include "resource.h"

#include "reaper_plugin.h"

#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_DockWindowAddEx
#define REAPERAPI_WANT_DockWindowActivate
#define REAPERAPI_WANT_DockWindowRemove
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_MarkProjectDirty  // mark dirty when the tail toggle changes (saves with the project)
#define REAPERAPI_WANT_GetMainHwnd
#define REAPERAPI_WANT_PCM_Source_CreateFromFile
#define REAPERAPI_WANT_PCM_Source_Destroy
// New-content detection (D2 Wave 2): enumerate live tracks + items and read fixed-lane
// state to classify an item's lane as managed vs manual.
#define REAPERAPI_WANT_CountTracks
#define REAPERAPI_WANT_GetTrack
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
#define REAPERAPI_WANT_CountTrackMediaItems
#define REAPERAPI_WANT_GetTrackMediaItem
// Stock preview API (verified against reaper_plugin.h / reaper_plugin_functions.h):
// PlayPreview/StopPreview drive a caller-owned preview_register_t. These are the
// STOCK symbols (not SWS-only) — see the audition section below.
#define REAPERAPI_WANT_PlayPreview
#define REAPERAPI_WANT_StopPreview
#define REAPERAPI_WANT_GetUserInputs
#define REAPERAPI_WANT_ShowMessageBox
#define REAPERAPI_WANT_Main_OnCommand  // fire the prune action by command id (R3 button)
#define REAPERAPI_WANT_genGuid
#define REAPERAPI_WANT_guidToString
// Action-trigger buttons (M11): resolve each button's command id at runtime from the
// composed named-command string, fire it through the existing action contract, and read
// its current key binding for the reminder label. All main-section (SectionFromUniqueID(0)).
#define REAPERAPI_WANT_NamedCommandLookup
#define REAPERAPI_WANT_Main_OnCommand
#define REAPERAPI_WANT_kbd_getTextFromCmd
#define REAPERAPI_WANT_SectionFromUniqueID
#include "reaper_plugin_functions.h"

// main.cpp owns the module instance handle and REAPER's dispatch struct.
extern REAPER_PLUGIN_HINSTANCE g_hInst;
extern reaper_plugin_info_t*   g_rec;

namespace reasampler {

namespace {

namespace fs = std::filesystem;

// --- Layout constants ---------------------------------------------------------
//
// L2: every panel COLOR now comes from the pure `theme` module by ROLE (drawn through the L1
// kit — fillSurface / drawButton / kit text). The former flat LICE_RGBA / RGB palette blocks
// are retired; only the pixel LAYOUT metrics (band heights, grid/tab specs, insets) live here.

const GridSpec kGrid{/*cellWidth=*/140, /*cellHeight=*/84, /*gap=*/10};

constexpr int kMaxThumbnailFrames = 1 << 20;  // ~1M frames (~22s @ 48k)

// --- Footer (Phase L, L4) -----------------------------------------------------
// The footer now carries a task-cluster of small persistent controls: the narrowed
// [Arrange|Design] mode toggle, a compact per-mode count, the Tail BUTTON (L4 §4 —
// a real kit button, no longer a click-zone), and the set-apart Prune button at the
// right. Taller than the L2 footer to host the toggle segments + button chrome cleanly.
// Layout is the pure footer_bar (left group) + prune_button (right); this is the band height.
constexpr int kFooterHeight = 30;

// --- Toolbars (Phase L, L4) ---------------------------------------------------
// TWO task-grouped toolbars, both drawn through the pure action_bar module:
//   * kTopToolbarHeight — the TOP toolbar (capture + placement clusters) at the very top of
//     the client, where the eye lands (L4 §1). Replaces the L2 mode-switch header there.
//   * kBottomToolbarHeight — the BOTTOM toolbar (Design-View tag/switch verbs) directly above
//     the footer (L4 §2). This is the L2 action-bar band, repurposed.
// The kBarSpec metrics the bars consume live near the draw below; only heights live here.
constexpr int kTopToolbarHeight = 28;     // single-row label face (L6: keybinding sub-row removed)
constexpr int kBottomToolbarHeight = 28;  // same shape — both bars consistent

// --- Tooltip (Phase L, L5) ----------------------------------------------------
// The custom hover-delay tooltip's timing + approximate text metrics. The delay matches the
// platform convention (~0.5 s) so the tooltip is deliberate, not twitchy; it is driven off the
// OnTimer poll (bankPanelRefresh) + WM_MOUSEMOVE, so no dedicated timer is added. The kit font
// is AA and proportional, so the width is estimated from a per-char average (the tooltip box is
// generous — a slight over/under-estimate only pads the box, never clips the text).
constexpr unsigned int kTooltipDelayMs = 500;
constexpr int kTooltipCharPx = 7;   // approx px per char at Font::Label (generous)
constexpr int kTooltipTextH  = 14;  // approx line height at Font::Label

// --- Vertical split + region headers + tab strip (Phase B4; L4 re-home) -------
//
// The client area, top to bottom (L4): TOP toolbar (kTopToolbarHeight, capture + placement) |
// split body | BOTTOM toolbar (kBottomToolbarHeight, Design-View verbs) | footer
// (kFooterHeight — mode toggle + count + Tail button + Prune). The split body holds the pool
// region (top) and the named-banks region (bottom). Each region opens with a REGION HEADER
// band: a title, the active-bank readout, and a full-height toggle button. The named-banks
// region's header ALSO hosts the LICE tab strip and a "+" create button.
constexpr int kRegionHeaderHeight = 24;  // per-region title/toggle band
constexpr int kTabStripHeight     = 26;  // the named-banks tab strip band
constexpr int kSplitDividerHeight = 3;   // the horizontal divider between regions
constexpr int kFullHtBtnWidth     = 22;  // the square full-height toggle button
constexpr int kCreateBtnWidth     = 22;  // the "+" create-bank button

// Tab strip metrics (the pure tab_strip owns the math; these are its inputs).
const TabStripSpec kTabSpec{/*tabWidth=*/96, /*chevronWidth=*/20};

// --- Panel state --------------------------------------------------------------

struct CachedThumbnail {
    Envelope envelope;
    int width = 0;
};

// Which of the two split regions currently owns the selection / receives keyboard
// input. The move/copy source is the focused region's displayed bank.
enum class Region { Pool, Banks };

// What a drag is dropping onto, resolved live under the pointer during a drag.
// BanksRegion fires when the pointer is anywhere in the named-banks grid that is NOT
// on a specific tab (tab takes precedence — more specific wins). The resolved bank is
// always shownBankId.
enum class DropKind { None, PoolRegion, Tab, BanksRegion };

// --- Hover model (Phase L, L2) ------------------------------------------------
//
// The hovered interactive element, resolved live in WM_MOUSEMOVE so the kit draws its
// hover state on that element only (the "hover on every interactive element" + "sub-frame
// feedback = the perception of speed" L2 constraint). SWELL exposes no WM_MOUSELEAVE (grep
// of vendor/WDL/WDL/swell — none), so hover is cleared by a move that resolves to None
// rather than a leave message; the panel is Windows-only (D5) but this stays portable-safe.
// `index` disambiguates within a kind (action-bar button index, tab index); -1 when N/A.
enum class HoverKind {
    None,
    TopBarButton,     // a button in the TOP toolbar (index = flat action index into topBarRows)
    BottomBarButton,  // a button in the BOTTOM toolbar (index = flat action index into bottomBarRows)
    MoreButton,       // the TOP toolbar's far-right "⋯" overflow-menu button (L5)
    PruneButton,
    FullHtPool,       // pool region full-height toggle
    FullHtBanks,      // banks region full-height toggle
    CreateBank,       // the "+" create-bank button
    Tab,              // a named-bank tab (index = tab ordinal)
    TailButton,       // the footer Tail button (L4 §4 — a real button, was a click-zone)
    ModeSegment,      // a footer mode-toggle segment (index = segment ordinal)
};

struct Hover {
    HoverKind kind = HoverKind::None;
    int index = -1;

    bool operator==(const Hover& o) const { return kind == o.kind && index == o.index; }
    bool operator!=(const Hover& o) const { return !(*this == o); }
};

// The kit interaction state for an interactive element: Hover when this (kind,index) is the
// live hovered element, else Rest. Active/Pressed are decided per-element by the caller (e.g.
// an active tab draws Active regardless of hover); this is the base rest/hover resolver.
InteractionState hoverState(const Hover& hovered, HoverKind kind, int index) {
    return (hovered.kind == kind && hovered.index == index) ? InteractionState::Hover
                                                            : InteractionState::Rest;
}

struct PanelState {
    ReaSamplerSession* session = nullptr;

    HWND hwnd = nullptr;
    bool open = false;

    std::string bankFingerprint;
    std::uint64_t generation = 0;

    std::unordered_map<std::string, CachedThumbnail> cache;

    // --- Selection (per focused region) ---------------------------------------
    // One live selection, scoped to `focusedRegion`. Switching regions moves the
    // selection with the focus (a click in the other region reseeds it there).
    Selection selection;
    int       selItemCount = 0;
    Region    focusedRegion = Region::Pool;

    // --- Hover (Phase L, L2) --------------------------------------------------
    // The live hovered interactive element (WM_MOUSEMOVE resolves it; the kit draws its
    // hover state). Repaint fires only when this changes (sub-frame, no per-move jank).
    Hover hovered;

    // --- Tooltip (Phase L, L5) ------------------------------------------------
    // A custom LICE-kit hover-delay tooltip (NOT the native Win32/SWELL tooltip control): when a
    // TOOLTIP-capable element (a toolbar button) stays hovered past kTooltipDelayMs, the panel
    // draws a small overlay carrying the full, prefix-stripped action name. hoverSinceTick is the
    // GetTickCount() at which the CURRENT hovered element was first entered (reset on every hover
    // change); tooltipShown latches once the delay elapses so the OnTimer poll repaints exactly
    // once when the tooltip appears. The last-seen pointer pos anchors nothing (the anchor is the
    // hovered button's rect), but is kept so the OnTimer path can re-resolve without a live event.
    unsigned int hoverSinceTick = 0;
    bool tooltipShown = false;

    // --- Vertical-split state -------------------------------------------------
    BankPanelFullHeight fullHeight = BankPanelFullHeight::Split;

    // The named bank whose grid the banks region shows (the SHOWN tab) — DISTINCT
    // from the active/capture-target bank (book().activeBankId()). Empty when there
    // are no named banks. Reconciled each fingerprint pass so it always names a live
    // named bank (or is empty).
    std::string shownBankId;

    // Tab-strip horizontal scroll offset (px), clamped to the strip's max each frame.
    int tabScroll = 0;

    // --- Drag (sample move between regions/onto a tab) ------------------------
    // A drag begins only after the pointer moves past a threshold from a press that
    // landed on a SELECTED grid cell — this is how it is disambiguated from the M5
    // multi-select drag (which begins immediately on any grid press). See onLBtnDown/
    // onMouseMove. dragging is true once the threshold is crossed.
    bool dragArmed = false;      // pressed on a selected cell; watching for threshold
    bool dragging = false;       // threshold crossed; a move-drag is in progress
    int  dragStartX = 0, dragStartY = 0;
    Region dragSourceRegion = Region::Pool;
    std::string dragSourceBankId;          // the bank the dragged samples come from
    std::vector<std::string> dragSampleIds;// snapshot of the selection at drag start
    std::string dragPrimaryId;             // the single card grabbed (the focus) — the L7
                                           // reorder/replace subject (see onLBtnUp dispatch)
    DropKind dropKind = DropKind::None;    // live drop target under the pointer
    std::string dropBankId;                // destination bank id when dropKind==Tab

    // --- L7 in-grid reorder/replace drag --------------------------------------
    // The live card gesture resolved by the pure card_drag::decideCardGesture each mouse-
    // move (drives the cursor cue AND the drop dispatch), plus the same-bank target slot the
    // pointer sits over (>= 0 only for a Reorder/Replace over the source bank's own grid; -1
    // otherwise). A Reorder highlights dragTargetSlot's cell; Replace + a live cursor cue
    // signal the Alt-over-occupied case. Reset with the rest of the drag state on drop/cancel.
    CardGesture cardGesture = CardGesture::None;
    int         dragTargetSlot = -1;

    // --- S17 drop-and-load (InstrumentDrop) -----------------------------------
    // While a SINGLE-capture drag is over REAPER's own UI outside the panel, the drag is an
    // InstrumentDrop heading for a track's TCP FX button. The shell hover-tracks the FX
    // hotspot; on release over a valid target it adds a ReaSampler 9000 preloaded with the
    // dragged capture (no OS drag, no timeline insert). instrumentDropTrack is the last
    // resolved FX-hotspot track (null when the pointer is not over an FX button) — read on
    // release. Only set/used on Windows (D5); the M11 OsDrag and internal drag are untouched.
    MediaTrack* instrumentDropTrack = nullptr;

    // --- Tail-mode toggle -----------------------------------------------------
    // The authoritative tail setting now lives in ReaSamplerSession (session->tail()),
    // NOT in panel state, so it travels inside the .rpp (persist serializes it on save,
    // restores it on project load). The panel reads it for drawing and mutates it via
    // the footer click (cycle mode) and scroll-wheel (Manual fine-adjust), marking the
    // project dirty so the choice saves. bankPanelTailSetting is the read seam for the
    // capture actions. Held here only through the session pointer above.

    // --- Audition preview -----------------------------------------------------
    preview_register_t preview{};
    PCM_source* previewSrc = nullptr;
    bool previewActive = false;
    bool previewInited = false;  // guards double init / deinit

    // --- New-content detection (D2 Wave 2) ------------------------------------
    //
    // Each timer tick diffs the live track+item GUID set against the previous tick to
    // auto-tag content created SINCE the last tick into the then-active mode. The
    // baseline carries the first-poll-after-open guard (GuidBaseline self-arms on its
    // first observe()) so pre-existing content is never mass-tagged (it stays Arrange).
    //
    // Project-load re-arm is driven by persist's AUTHORITATIVE load lifecycle, NOT by a
    // pointer compare here. main.cpp calls bankPanelNotifyProjectLoaded() on the exact
    // tick persist restores a project's membership + active mode (the same tick it
    // reapplies the active mode); that sets reloadPending so the NEXT detect tick this
    // same tick re-baselines against the fully-loaded set and reports nothing new. This
    // replaces the former `proj != lastProject` re-arm, which used a WEAKER signal than
    // persist (pointer-only vs persist's GUID-primary identity) and so missed a load onto
    // a RECYCLED ReaProject* address — the just-loaded project's pre-existing tracks then
    // diffed against the previous project's stale baseline and were mass-tagged into the
    // active mode (the reload-mis-tag bug). Coordinating with persist's signal makes the
    // two identity checks agree by construction.
    //
    // Lives for the extension's lifetime alongside the session, independent of panel
    // open/close — detection must run whether or not the dock is visible (content is
    // created in the arrange, not the panel).
    GuidBaseline contentBaseline;
    bool         reloadPending = false;  // set by bankPanelNotifyProjectLoaded; drained next detect tick
};

PanelState g_panel;

void stopAudition();

// --- Current-project directory (mirrors persist.cpp's derivation) -------------
std::string currentProjectDir() {
    std::vector<char> buf(4096, '\0');
    EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
    std::string rpp(buf.data());
    if (rpp.empty()) return {};
    return normalizeSlashes(fs::path(rpp).parent_path().string());
}

// --- Book / bank accessors ----------------------------------------------------

BankBook* book() { return g_panel.session ? &g_panel.session->book() : nullptr; }

// The BankIndex a region currently displays. Pool region -> the pool; banks region ->
// the shown tab's bank (or nullptr when no named banks / the id went stale). Resolved
// FRESH every call (never cached across a mutation).
const BankIndex* indexForRegion(Region r) {
    BankBook* b = book();
    if (!b) return nullptr;
    if (r == Region::Pool) return &b->pool().index;
    if (g_panel.shownBankId.empty()) return nullptr;
    return b->index(g_panel.shownBankId);
}

// The bank id a region displays (pool id, or the shown tab's id; "" when none).
std::string bankIdForRegion(Region r) {
    if (r == Region::Pool) return std::string(kPoolBankId);
    return g_panel.shownBankId;
}

// The named banks in ordinal order (pool excluded) — the tabs. Resolved fresh.
std::vector<const Bank*> namedBanks() {
    std::vector<const Bank*> out;
    BankBook* b = book();
    if (!b) return out;
    for (const Bank& bk : b->banks())
        if (!bk.isPool()) out.push_back(&bk);
    return out;
}

// --- Thumbnail computation (unchanged from M5) --------------------------------

Envelope computeThumbnail(const std::string& absPath, int width) {
    if (width <= 0 || absPath.empty()) return {};

    PCM_source* src = PCM_Source_CreateFromFile(absPath.c_str());
    if (!src) return {};

    const int nch = src->GetNumChannels();
    const double srate = src->GetSampleRate();
    const double lengthSec = src->GetLength();
    if (nch <= 0 || srate < 1.0 || lengthSec <= 0.0) {
        PCM_Source_Destroy(src);
        return {};
    }

    std::int64_t totalFrames = static_cast<std::int64_t>(lengthSec * srate);
    if (totalFrames <= 0) { PCM_Source_Destroy(src); return {}; }
    int frames = totalFrames > kMaxThumbnailFrames
                     ? kMaxThumbnailFrames
                     : static_cast<int>(totalFrames);

    std::vector<ReaSample> buf(static_cast<std::size_t>(frames) * nch, 0.0);
    PCM_source_transfer_t block{};
    block.time_s = 0.0;
    block.samplerate = srate;
    block.nch = nch;
    block.length = frames;
    block.samples = buf.data();
    block.samples_out = 0;
    src->GetSamples(&block);

    PCM_Source_Destroy(src);

    const int got = block.samples_out;
    if (got <= 0) return {};

    const std::size_t sampleCount = static_cast<std::size_t>(got) * nch;
    std::vector<float> pcm(sampleCount);
    for (std::size_t i = 0; i < sampleCount; ++i)
        pcm[i] = static_cast<float>(buf[i]);

    return computeEnvelope(pcm, static_cast<std::size_t>(nch),
                           static_cast<std::size_t>(got),
                           static_cast<std::size_t>(width));
}

const Envelope& thumbnailFor(const Sample& sample, int width,
                             const std::string& projectDir) {
    ThumbnailKey key{sample.id, width, g_panel.generation};
    const std::string ks = thumbnailKeyString(key);

    auto it = g_panel.cache.find(ks);
    if (it != g_panel.cache.end()) return it->second.envelope;

    const std::string abs = resolveBankFile(projectDir, sample.relativePath);
    CachedThumbnail thumb;
    thumb.width = width;
    thumb.envelope = computeThumbnail(abs, width);
    auto ins = g_panel.cache.emplace(ks, std::move(thumb));
    return ins.first->second.envelope;
}

// --- Drawing: thumbnails (unchanged from M5) ----------------------------------

// Draws the L7 decorative metadata overlay on a card: bars.beats.subdivisions bottom-LEFT
// (musical, from the capture-time tempo + meter stamp) and seconds.milliseconds bottom-RIGHT
// (wall-clock). Decorative + non-interactive (no hit-test, no hover). Drawn in the kit's
// Micro / ValueMono classes in text/dim, subordinate to the waveform. A blank musical
// read-out (unstamped meter / unknown tempo) simply omits the bottom-left string.
void drawCardMeta(LICE_IBitmap* bmp, const CellRect& rect, const Sample& s) {
    MusicalLength ml;
    ml.lengthSeconds = s.lengthSeconds;
    ml.tempoBpm      = s.captureTempo;
    ml.timeSigNum    = s.captureTimeSigNum;
    ml.timeSigDenom  = s.captureTimeSigDenom;
    const std::string bars = formatBarsBeats(ml);      // "" when unstamped/no-tempo
    const std::string secs = formatSecondsMs(s.lengthSeconds);

    // A short strip along the card's bottom edge. Left/right halves; text/dim so the
    // waveform stays the centerpiece. Micro on the left (musical), ValueMono on the right
    // (tabular numbers that must not jitter).
    const int stripH = 12;
    const int pad = 3;
    const int y = rect.y + rect.height - stripH;
    if (!bars.empty()) {
        const KitBox left{rect.x + pad, y, rect.width / 2 - pad, stripH};
        text(bmp, left, bars.c_str(), Font::Micro, Role::TextDim, Align::Left);
    }
    const KitBox right{rect.x + rect.width / 2, y, rect.width / 2 - pad, stripH};
    text(bmp, right, secs.c_str(), Font::ValueMono, Role::TextDim, Align::Right);
}

void drawThumbnail(LICE_IBitmap* bmp, const CellRect& rect, const Envelope& env,
                   bool selected, bool focused, bool hovered, const Sample* sample) {
    // Cell surface through the kit (L7 selection restyle): a selected card draws the NORMAL
    // cell surface (Rest, or Hover when hovered) — NOT the inverted accent-fill. Selection is
    // marked purely by an accent/tertiary (pastel purple) border below; hover stays a fill-
    // state change orthogonal to that border, so a hovered selected card still reads selected.
    const KitBox cell{rect.x, rect.y, rect.width, rect.height};
    const InteractionState state = hovered ? InteractionState::Hover : InteractionState::Rest;
    fillSurface(bmp, cell, Role::BgCell, state);

    // Border (L7): accent/TERTIARY purple when selected (the sole selection signal), else
    // hairline. Focus is a distinct text/primary inner ring so a focused-AND-selected card
    // reads BOTH — the purple outer border + the inner focus ring — kept visually separate.
    const KitColor border = selected ? roleColor(Role::AccentTertiary) : roleColor(Role::LineHairline);
    LICE_DrawRect(bmp, rect.x, rect.y, rect.width, rect.height, toLice(border), 1.0f, 0);
    if (focused) {
        const LICE_pixel ring = toLice(roleColor(Role::TextPrimary));
        LICE_DrawRect(bmp, rect.x + 1, rect.y + 1, rect.width - 2, rect.height - 2, ring, 1.0f, 0);
    }

    // Waveform plot (peaks invariant: min<=max). The wave keeps its NORMAL accent color in
    // every state (L7 dropped the inverted bg/base wave on the selected cell — the cell fill
    // is no longer inverted, so no contrast swap is needed).
    const LICE_pixel midCol = toLice(roleColor(Role::LineHairline));
    const LICE_pixel waveCol = toLice(roleColor(Role::AccentPrimary));

    if (env.empty()) {
        const int midY = rect.y + rect.height / 2;
        LICE_Line(bmp, rect.x + 2, midY, rect.x + rect.width - 2, midY, midCol, 1.0f, 0, false);
        if (sample) drawCardMeta(bmp, rect, *sample);  // L7 overlay even on an empty envelope
        return;
    }

    const int channels = static_cast<int>(env.size());
    const int bandH = rect.height / channels;

    for (int ch = 0; ch < channels; ++ch) {
        const ChannelEnvelope& bins = env[ch];
        const int bandTop = rect.y + ch * bandH;
        const int midY = bandTop + bandH / 2;
        const double halfSpan = (bandH / 2) - 2;

        LICE_Line(bmp, rect.x + 2, midY, rect.x + rect.width - 2, midY, midCol, 1.0f, 0, false);

        const int nbins = static_cast<int>(bins.size());
        if (nbins <= 0) continue;

        const int innerW = rect.width - 4;
        for (int i = 0; i < nbins; ++i) {
            const int x = rect.x + 2 + (nbins > 1 ? (i * (innerW - 1)) / (nbins - 1) : 0);
            // min<=max always (peaks invariant). Draw a vertical line from the
            // min sample to the max sample, clamped to the band.
            int yMax = midY - static_cast<int>(compressAmplitudeForDisplay(bins[i].max) * halfSpan);  // max -> up
            int yMin = midY - static_cast<int>(compressAmplitudeForDisplay(bins[i].min) * halfSpan);  // min -> down
            if (yMax < bandTop) yMax = bandTop;
            if (yMin > bandTop + bandH - 1) yMin = bandTop + bandH - 1;
            LICE_Line(bmp, x, yMin, x, yMax, waveCol, 1.0f, 0, false);
        }
    }

    // L7 decorative metadata overlay, drawn last so it sits over the waveform.
    if (sample) drawCardMeta(bmp, rect, *sample);
}

// --- Kit draw adapters (Phase L) ----------------------------------------------
//
// All panel text draws through the kit's cached AA font (draw_kit::text), NOT raw GDI DrawText
// (retired at L1). L2 re-roles every color through the pure `theme` module and draws surfaces
// via the kit (fillSurface / drawButton). These thin adapters bridge the panel's RECT-based
// geometry helpers to the kit's KitBox and give the panel a KitColor->LICE_pixel boundary for
// the few raw borders it still draws over kit surfaces. The kit owns the font lifecycle
// (kitFontsInit/Shutdown, wired at panel open/close below).

KitBox toKitBox(const RECT& r) {
    return KitBox{r.left, r.top, r.right - r.left, r.bottom - r.top};
}

// KitColor -> LICE_pixel: all sites use the kit's toLice() from draw_kit.h — the single
// conversion boundary the kit enforces. No local alias needed.

// L2 role/font-aware text: draws through the kit in a palette ROLE color and a chosen kit
// Font (the action bar uses Micro for the keybinding sub-label, Label for the name, Title for
// region headings). Takes a KitBox directly (the pure geometry the L2 modules return).
void kitText(LICE_IBitmap* bmp, const KitBox& box, const char* txt,
             Font font, Role role, Align align) {
    text(bmp, box, txt, font, role, align);
}

// --- Mode toggle (D5; relocated to the footer at L4) --------------------------

int modeCount() {
    if (!g_panel.session) return 0;
    return static_cast<int>(g_panel.session->view().modes().size());
}

// --- Top toolbar band (L4; L5 overflow-menu reserve) --------------------------
//
// The TOP toolbar (capture + placement) occupies the very top of the client. Degenerate
// (height 0) when the client is too short to host it above the split body. The WHOLE band
// (topToolbarRect) is what the far-right More button anchors into; the action_bar's frequent
// buttons tile into the band MINUS the menu reserve (topToolbarActionRect), so they never run
// under the menu button (L5 refinement 1).

// The spec for the far-right More ("⋯") overflow-menu button. One source of truth for its
// geometry + the reserve the action_bar leaves for it.
const MenuButtonSpec kMenuBtnSpec{/*buttonWidth=*/28, /*rightInset=*/6,
                                  /*verticalInset=*/3, /*minLeftInset=*/40};

ActionBarRect topToolbarRect(int w) {
    ActionBarRect s;
    s.x = 0;
    s.y = 0;
    s.width = w;
    s.height = kTopToolbarHeight;
    return s;
}

// The band the More button occupies (the whole top toolbar band as a MenuBarRect).
MenuBarRect topMenuBarRect(int w) {
    const ActionBarRect bar = topToolbarRect(w);
    return MenuBarRect{bar.x, bar.y, bar.width, bar.height};
}

// The More button's rect (right-anchored in the top band). Empty when the band is too narrow
// to place it clear of its left inset — the three variants stay reachable via their bindable
// commands (graceful suppression).
MenuButtonRect topMenuButtonRect(int w) {
    return computeMenuButton(topMenuBarRect(w), kMenuBtnSpec);
}

// The rect the TOP toolbar's action_bar tiles into: the whole band MINUS the reserve for the
// far-right More button, so the frequent buttons never overlap it. When the More button is
// suppressed (band too narrow) the reserve is still subtracted (the reserve is 0 only for a
// degenerate band), which keeps draw and hit-test consistent whether or not the button shows.
ActionBarRect topToolbarActionRect(int w) {
    ActionBarRect bar = topToolbarRect(w);
    const int reserve = menuButtonReserve(topMenuBarRect(w), kMenuBtnSpec);
    bar.width -= reserve;
    if (bar.width < 0) bar.width = 0;
    return bar;
}

// --- Footer (L4) --------------------------------------------------------------

RECT panelFooter(int w, int h) {
    RECT rc{};
    rc.left = 0;
    rc.right = w;
    rc.top = h - kFooterHeight;
    rc.bottom = h;
    // Keep the footer below the top toolbar; if the client is too short, collapse it.
    if (rc.top < kTopToolbarHeight) rc.top = rc.bottom;
    return rc;
}

// The session's live tail setting (default None / 2 s when no session). Single read
// point so draw, wheel-adjust, and the capture read seam all agree on the source.
TailSetting currentTail() {
    return g_panel.session ? g_panel.session->tail() : TailSetting{};
}

// The footer LEFT-group layout (mode toggle + count + Tail button), derived from the client
// size. SINGLE source of truth for draw and hit-test. All-empty when the footer is degenerate.
FooterBarLayout footerBarLayoutFor(int w, int h) {
    const RECT f = panelFooter(w, h);
    if (f.top >= f.bottom) return FooterBarLayout{};
    const FooterRect footer{f.left, f.top, f.right - f.left, f.bottom - f.top};
    return computeFooterBar(footer, FooterBarSpec{});
}

// The per-mode membership count that travels with the toggle (L4 §3): the number of leaves
// tagged into the currently ACTIVE mode. A compact readout beside the toggle. 0 when no
// session. (The Arrange default — untagged — is not counted; membership tracks tagged leaves.)
// A display-only tally over the model's public membership map — no model semantics duplicated.
int activeModeMemberCount() {
    if (!g_panel.session) return 0;
    const ViewModeModel& view = g_panel.session->view();
    const std::string& active = view.activeModeId();
    if (active.empty()) return 0;
    int n = 0;
    for (const auto& [guid, m] : view.membership().all())
        if (m.modeIds.count(active) != 0) ++n;
    return n;
}

// The prune button's rect within the footer, derived from the client size. SINGLE source
// of truth for both draw and hit-test (they never drift). Empty when the footer is degenerate
// or too narrow to place the button clear of the footer-left group / version readout — the
// action stays reachable via its bindable command, so a suppressed button is graceful. Kept
// set apart at the RIGHT (footer_bar reserves the matching space at its right so the two
// groups never overlap). See prune_button.h §Placement contract.
ButtonRect pruneButtonRectFor(int w, int h) {
    const RECT f = panelFooter(w, h);
    if (f.top >= f.bottom) return ButtonRect{};  // degenerate footer -> no button
    const FooterRect footer{f.left, f.top, f.right - f.left, f.bottom - f.top};
    return computePruneButton(footer, PruneButtonSpec{});
}

// Draws the footer: the band + top divider, then the LEFT group (the narrow [Arrange|Design]
// toggle drawn as mode_switch segments over footer_bar's toggle box, the per-mode count, and
// the Tail BUTTON — L4 §4), the right-aligned version readout, and finally the Prune button
// set apart at the far right (warn). READ-ONLY: reads session state; input handlers mutate it.
void drawFooter(LICE_IBitmap* bmp, int w, int h) {
    const RECT f = panelFooter(w, h);
    if (f.top >= f.bottom) return;

    // Footer band + hairline top divider (the base persistent-controls strip).
    fillSurface(bmp, KitBox{f.left, f.top, w, kFooterHeight}, Role::BgPanel,
                InteractionState::Rest);
    LICE_Line(bmp, f.left, f.top, f.right, f.top,
              toLice(roleColor(Role::LineHairline)), 1.0f, 0, false);

    const FooterBarLayout fb = footerBarLayoutFor(w, h);

    // [Arrange|Design] toggle — drawn as N mode_switch segments inside footer_bar's toggle box
    // (the segment geometry stays owned by the pure mode_switch; footer_bar owns the box). The
    // active mode's segment carries the accent; others hover-or-rest bg/cell.
    if (!fb.toggle.empty() && g_panel.session) {
        const ViewModeModel& view = g_panel.session->view();
        const std::vector<Mode>& modes = view.modes().all();
        const int n = static_cast<int>(modes.size());
        const HeaderRect th{fb.toggle.x, fb.toggle.y, fb.toggle.width, fb.toggle.height};
        const std::vector<SegmentRect> segs = computeSegmentRects(th, n);
        const std::string& activeId = view.activeModeId();
        for (int i = 0; i < static_cast<int>(segs.size()); ++i) {
            const SegmentRect& s = segs[static_cast<std::size_t>(i)];
            const Mode& mode = modes[static_cast<std::size_t>(i)];
            const bool active = mode.id == activeId;
            const InteractionState state =
                active ? InteractionState::Active
                       : hoverState(g_panel.hovered, HoverKind::ModeSegment, i);
            fillSurface(bmp, KitBox{s.x, s.y, s.width, s.height}, Role::BgCell, state);
            LICE_DrawRect(bmp, s.x, s.y, s.width, s.height,
                          toLice(roleColor(Role::LineHairline)), 1.0f, 0);
            const Role tr = active ? Role::BgBase : Role::TextPrimary;
            kitText(bmp, KitBox{s.x, s.y, s.width, s.height}, mode.displayName.c_str(),
                    Font::Label, tr, Align::Center);
        }
    }

    // Per-mode member count, a compact dim readout beside the toggle (L4 §3 — "the count
    // travels with the toggle"). Passive text, not a control.
    if (!fb.count.empty()) {
        const int members = activeModeMemberCount();
        const std::string countLabel =
            std::to_string(members) + (members == 1 ? " track" : " tracks");
        kitText(bmp, KitBox{fb.count.x, fb.count.y, fb.count.width, fb.count.height},
                countLabel.c_str(), Font::Micro, Role::TextDim, Align::Center);
    }

    // Tail BUTTON (L4 §4) — a real kit button with rest/hover states; its click cycles the
    // tail mode exactly as the old click-zone did. Label is the pure tailToggleLabel.
    if (!fb.tail.empty()) {
        const InteractionState state = hoverState(g_panel.hovered, HoverKind::TailButton, -1);
        const std::string label = tailToggleLabel(currentTail());
        const KitButtonBox box{KitBox{fb.tail.x, fb.tail.y, fb.tail.width, fb.tail.height}};
        drawButton(bmp, box, label.c_str(), state, /*warn=*/false);
    }

    // Version/channel readout (Phase V, V3/V4), right-aligned, unobtrusive. appVersion()
    // renders "0.9.01" on stable and "0.9.01-beta" on beta so a beta panel self-identifies.
    // It sits inside the space footer_bar reserves at the right (rightReserve) and clears the
    // prune button (prune_button::rightInset). Dim, passive identification (V3).
    kitText(bmp, KitBox{f.left, f.top, (f.right - f.left) - 8, f.bottom - f.top},
            reasampler::appVersion().c_str(), Font::Micro, Role::TextDim, Align::Right);

    // Prune button — set apart at the far RIGHT (the ONLY warn-colored, byte-deleting control),
    // honoring hover. No-op when suppressed (footer too narrow). Order reads left (benign,
    // frequent) -> right (destructive, rare) per the L4 footer contract.
    const ButtonRect pb = pruneButtonRectFor(w, h);
    if (!pb.empty()) {
        const InteractionState state = hoverState(g_panel.hovered, HoverKind::PruneButton, -1);
        const KitButtonBox box{KitBox{pb.x, pb.y, pb.width, pb.height}};
        drawButton(bmp, box, "Prune", state, /*warn=*/true);
    }
}

// True iff client-relative (x, y) falls inside the (non-degenerate) footer strip. Used by the
// scroll-wheel (Manual tail fine-adjust) so a wheel notch over the footer is claimed. The
// Tail-cycle CLICK no longer uses this — it now hits the Tail button rect (footer_bar).
bool pointInFooter(int x, int y) {
    if (!g_panel.hwnd) return false;
    RECT cr{};
    GetClientRect(g_panel.hwnd, &cr);
    const RECT f = panelFooter(cr.right - cr.left, cr.bottom - cr.top);
    return f.top < f.bottom && x >= f.left && x < f.right && y >= f.top && y < f.bottom;
}

// Commits the current tail setting to ext state and marks the active project dirty
// so the change travels inside the .rpp on Ctrl+S. saveToActiveProject() is the only
// path that calls SetProjExtState for the tail key — calling it here closes the gap
// where toggle/scroll would dirty the project but the new value was never written.
// On an unsaved project saveToActiveProject() no-ops cleanly (documented in persist.h).
// MarkProjectDirty runs unconditionally so REAPER knows a save is owed either way.
// NON-DESTRUCTIVE: touches nothing in the bank/arrange.
void markTailDirty() {
    if (g_panel.session) g_panel.session->saveToActiveProject();
    ReaProject* proj = EnumProjects(-1, nullptr, 0);
    if (proj) MarkProjectDirty(proj);
}

// === Task-grouped toolbars (Phase L, L2 + L4) =================================
//
// L4 re-homes the button inventory around frequency and intent (DS-3 layout, not a re-skin)
// across TWO toolbars, BOTH drawn through the pure action_bar module:
//   * the TOP toolbar (Capture + Placement) sits at the very top where the eye lands — the
//     two acts the tool exists for (L4 §1);
//   * the BOTTOM toolbar (the Design-View verbs: Tagging then Switching) sits above the
//     footer, in the space capture/placement vacated (L4 §2).
// Each button is drawn with its action name (Font::Label) and live key binding on a Micro
// sub-row (the L2 contract). action_bar owns the cluster tiling, the label/binding sub-rects,
// the whole-trailing-button overflow, and the hit-test; only the kit draw + SDK binding query
// + the NamedCommandLookup/Main_OnCommand dispatch live here.
//
// Each button resolves its command id at RUNTIME from the composed named-command string
// (NamedCommandLookup on "_" + channelCommandId(suffix)), so it is channel-correct on stable
// and beta and adds NO second registration. A cmd of 0 (action not registered on this channel)
// draws Disabled and no-ops on click. L4 is layout-only: the SAME existing actions fire via the
// SAME contract — no re-wiring, no command-id changes, and capture never auto-inserts.

// One action button: its channel-AGNOSTIC command-id suffix (composed with the channel prefix
// at fire time — never a hardcoded numeric id), its terse on-button FACE label, its full action
// NAME for the hover tooltip (already prefix-stripped — the "ReaSampler:" display prefix is
// dropped at build), and the task cluster it belongs to. The order of a toolbar's row list IS
// the flat action index the pure action_bar slots carry, so each list is built cluster-by-cluster
// in its toolbar's cluster order.
//
// L5: the FACE stays short (shortLabel, sized to never overflow the button width); the FULL name
// (fullName) is the hover tooltip content. fullName is sourced from the SAME phrase the action
// was registered with (render_settings' descriptionPhrase for the capture scopes; the literal
// registered phrase otherwise) so the tooltip matches the Actions-list entry exactly — the
// "ReaSampler:" prefix is not stored here (the face/tooltip never show it, per L5 refinement 2).
struct ActionBarRow {
    std::string suffix;
    std::string shortLabel;
    std::string fullName;
    ActionCluster cluster = ActionCluster::Capture;
    bool enabled = true;  // L5: opposite-mode gate for the bottom-bar tag buttons; always true
                          // for the top bar (its actions are unconditional triggers).
};

// The TOP toolbar inventory (L6 refinement): the FREQUENT acts only — Capture (item / track)
// then Re-capture (Maintenance, set between the two capture verbs and the placement verbs) then
// Placement (insert / insert-conform). The FOUR RARE variants (Batch Items / Batch Razor /
// Capture RT / Cancel RT) are ALL in the far-right "⋯" overflow menu (overflowMenuRows) —
// same registered actions, same command-id contract, just a different home. Capture scopes come
// from captureActionTable() (render_settings, pure); the rest are the registered M11/M10/M8
// commands. Built once per draw/click. Each row carries its full (prefix-stripped) action name
// for the hover tooltip.
std::vector<ActionBarRow> topBarRows() {
    std::vector<ActionBarRow> rows;
    // Capture cluster — the primary gesture, leftmost. Face is a terse "Capture Item/Track";
    // the tooltip carries the full descriptionPhrase the action was registered with.
    for (const CaptureActionDef& def : captureActionTable()) {
        std::string label = def.commandSuffix;
        if (label == "CAPTURE_ITEM")       label = "Capture Item";
        else if (label == "CAPTURE_TRACK") label = "Capture Track";
        rows.push_back({def.commandSuffix, label, def.descriptionPhrase,
                        ActionCluster::Capture, true});
    }
    // Maintenance cluster — Re-capture from source (M10), placed BETWEEN the capture group and
    // the placement group so its position reads "refine the last capture before placing it".
    // Cancel RT lives in the overflow menu (both realtime verbs share that home — L6).
    rows.push_back({"RECAPTURE_FROM_SOURCE", "Re-capture",
                    "re-capture from source", ActionCluster::Maintenance, true});
    // Placement cluster — the second act (still a distinct on-demand act; no auto-insert).
    rows.push_back({"INSERT_SELECTED", "Insert",
                    "insert selected sample at edit cursor", ActionCluster::Placement, true});
    rows.push_back({"INSERT_SELECTED_CONFORM", "Insert Conform",
                    "insert selected sample at edit cursor (conform to tempo)",
                    ActionCluster::Placement, true});
    return rows;
}

// The TOP-toolbar OVERFLOW menu inventory (L6): four items pulled off the visible bar into the
// far-right "⋯" menu button's popup — the three rare batch/realtime capture variants plus
// Cancel RT (both realtime verbs share the menu home). Each fires the SAME existing registered
// command id via the SAME NamedCommandLookup/Main_OnCommand contract — no action changes. The
// fullName is the popup entry text (the terse shortLabel is unused for menu items; the popup has
// room for the full name). Batch entries first, then the two realtime verbs.
std::vector<ActionBarRow> overflowMenuRows() {
    return {
        {"CAPTURE_BATCH_ITEMS", "Batch Items",
         "batch capture selected items (one per item)", ActionCluster::Capture, true},
        {"CAPTURE_BATCH_RAZOR", "Batch Razor",
         "batch capture razor areas (one per area)", ActionCluster::Capture, true},
        {"CAPTURE_TRACK_REALTIME", "Capture RT",
         "capture selected track (realtime)", ActionCluster::Capture, true},
        {"CANCEL_REALTIME_CAPTURE", "Cancel RT",
         "cancel realtime capture", ActionCluster::Maintenance, true},
    };
}

// The active mode id the opposite-mode gate + footer toggle both read (ONE source of truth for
// "which mode is active"). Empty when no session (every button then falls to fail-open live).
std::string activeModeIdOrEmpty() {
    if (!g_panel.session) return {};
    return g_panel.session->view().activeModeId();
}

// The BOTTOM toolbar inventory (L5 refinement 3): FOUR Item/Track x Arrange/Design tag buttons
// then a set-apart Show Both. The suffixes are the ACTUAL registered command-id strings from
// actions.cpp (VIEW_MOVE_ITEMS_ARRANGE / VIEW_MOVE_ITEMS_DESIGN for the item moves;
// VIEW_TAG_ARRANGE / VIEW_TAG_DESIGN for the track tags; VIEW_SHOW_BOTH) — grepped, not
// paraphrased. "…: Arrange" routes through the untag/arrange path (Arrange = absence of a tag).
// The Toggle + both Activate buttons are REMOVED (L5 refinement 4 / settled inventory): the
// footer [Arrange|Design] toggle owns mode switching.
//
// OPPOSITE-MODE ENABLEMENT (L5): a tag button is LIVE only for the OPPOSITE of the active mode
// (you tag into the mode you are not in). The pure mode_enable::tagButtonEnabled decides it from
// the active mode id; Show Both is unconditional (not a tag target). enabled=false rows draw
// Disabled and no-op on click. The Item/Track axis is display-only here — both the Item and the
// Track button for a target share the target's enablement.
std::vector<ActionBarRow> bottomBarRows() {
    const std::string active = activeModeIdOrEmpty();
    const bool arrangeLive = tagButtonEnabled(active, TagTarget::Arrange);
    const bool designLive = tagButtonEnabled(active, TagTarget::Design);

    std::vector<ActionBarRow> rows;
    // Tagging cluster — the four Item/Track x Arrange/Design tag buttons.
    rows.push_back({"VIEW_MOVE_ITEMS_ARRANGE", "Item: Arrange",
                    "move selected items -> Arrange", ActionCluster::Tagging, arrangeLive});
    rows.push_back({"VIEW_MOVE_ITEMS_DESIGN", "Item: Design",
                    "move selected items -> Design", ActionCluster::Tagging, designLive});
    rows.push_back({"VIEW_TAG_ARRANGE", "Track: Arrange",
                    "tag selected tracks -> Arrange", ActionCluster::Tagging, arrangeLive});
    rows.push_back({"VIEW_TAG_DESIGN", "Track: Design",
                    "tag selected tracks -> Design", ActionCluster::Tagging, designLive});
    // Switching cluster — Show Both, set apart (the only survivor of the old switching group).
    rows.push_back({"VIEW_SHOW_BOTH", "Show Both",
                    "show both for selected tracks", ActionCluster::Switching, true});
    return rows;
}

// The cluster button-count specs for a given row set, in the row list's cluster order (so the
// pure action_bar's flat index lines up with the row list). Handles all five cluster kinds;
// empty clusters contribute a 0-count spec (action_bar skips them, emitting no gap). The spec
// order follows each toolbar's fixed layout order (top: Capture, Maintenance, Placement —
// Re-capture sits between the two capture verbs and the placement verbs; bottom: Tagging,
// Switching). The bottom bar's Maintenance count is 0, so the order change is transparent there.
std::vector<ClusterSpec> actionBarClusters(const std::vector<ActionBarRow>& rows) {
    int nCap = 0, nPlace = 0, nMaint = 0, nTag = 0, nSwitch = 0;
    for (const ActionBarRow& r : rows) {
        switch (r.cluster) {
            case ActionCluster::Capture:      ++nCap;    break;
            case ActionCluster::Placement:    ++nPlace;  break;
            case ActionCluster::Maintenance:  ++nMaint;  break;
            case ActionCluster::Tagging:      ++nTag;    break;
            case ActionCluster::Switching:    ++nSwitch; break;
        }
    }
    return {
        {ActionCluster::Capture, nCap},
        {ActionCluster::Maintenance, nMaint},
        {ActionCluster::Placement, nPlace},
        {ActionCluster::Tagging, nTag},
        {ActionCluster::Switching, nSwitch},
    };
}

// The toolbar layout spec (the panel's 8px-grid density decision). One source of truth shared
// by both toolbars' draw and hit-test (identical button shape top and bottom). L5 refinement 5:
// clusterGap widened 16 -> 24 (a 6:1 inter/intra ratio) so semantic groups read AS groups. L6:
// bindingHeight / minSplitHeight removed — buttons are single-row label-only faces now.
const ActionBarSpec kBarSpec{/*buttonWidth=*/108, /*buttonGap=*/4, /*clusterGap=*/24,
                             /*sidePad=*/8, /*verticalInset=*/3};

// The BOTTOM toolbar band: a fixed-height band directly above the footer (below the split
// body). Degenerate (height 0) when the client is too short to host it above the footer.
ActionBarRect bottomToolbarRect(int w, int h) {
    ActionBarRect s;
    const RECT footer = panelFooter(w, h);
    const int footerTop = (footer.top < footer.bottom) ? footer.top : h;
    s.x = 0;
    s.width = w;
    s.height = kBottomToolbarHeight;
    s.y = footerTop - kBottomToolbarHeight;
    // Keep the bar below the top toolbar; if the client is too short, collapse it.
    if (s.y < kTopToolbarHeight) { s.y = footerTop; s.height = 0; }
    return s;
}

// Resolves a row's composed named command to its runtime command id (0 if not registered).
// The named-command lookup string is "_" + the channel-qualified id (REAPER's convention).
int resolveBarCommandId(const ActionBarRow& row) {
    if (!NamedCommandLookup) return 0;
    const std::string named = "_" + channelCommandId(row.suffix);
    return NamedCommandLookup(named.c_str());
}

// The current key binding string for a command in the MAIN section, or "" (unbound / not
// registered). Queried via kbd_getTextFromCmd (SectionFromUniqueID(0)).
std::string barBindingText(int cmd) {
    if (cmd != 0 && kbd_getTextFromCmd && SectionFromUniqueID) {
        const char* t = kbd_getTextFromCmd(cmd, SectionFromUniqueID(0));
        if (t) return std::string(t);
    }
    return {};
}

// Draws one task-grouped toolbar through the L1 kit: a bg/panel band, then each visible button
// as a kit drawButton (rest/hover/disabled) with the action short label on the single-row face.
// Overflow drops WHOLE trailing buttons (the pure layout returns only the buttons that fit), so
// nothing is drawn clipped. `hoverKind` selects which HoverKind this bar's buttons use
// (TopBarButton / BottomBarButton) so the two toolbars' hover states never cross. `topDivider`
// draws a hairline at the band's top edge (the bottom toolbar's elevation over the split body);
// the top toolbar draws it at its bottom edge instead. Key binding help is in the hover tooltip
// (L6), not on the button face — the face shows only shortLabel.
void drawToolbar(LICE_IBitmap* bmp, const ActionBarRect& bar,
                 const std::vector<ActionBarRow>& rows, HoverKind hoverKind, bool topDivider) {
    if (bar.height <= 0 || bar.width <= 0) return;

    const KitBox band{bar.x, bar.y, bar.width, bar.height};
    fillSurface(bmp, band, Role::BgPanel, InteractionState::Rest);
    const int dividerY = topDivider ? bar.y : bar.y + bar.height - 1;
    LICE_Line(bmp, bar.x, dividerY, bar.x + bar.width, dividerY,
              toLice(roleColor(Role::LineHairline)), 0.5f, 0, false);

    const std::vector<ClusterSpec> clusters = actionBarClusters(rows);
    const std::vector<ActionBarSlot> slots = computeBarSlots(bar, clusters, kBarSpec);

    for (const ActionBarSlot& s : slots) {
        if (s.index < 0 || s.index >= static_cast<int>(rows.size())) continue;
        const ActionBarRow& row = rows[static_cast<std::size_t>(s.index)];
        const int cmd = resolveBarCommandId(row);

        // State: Disabled when the action is not registered on this channel OR the row is gated
        // off (L5 opposite-mode enablement — the tag buttons for the ACTIVE mode); else Hover
        // when hovered, else Rest. (The bar's actions are stateless triggers — no Active/Pressed.)
        InteractionState state = InteractionState::Rest;
        if (cmd == 0 || !row.enabled) state = InteractionState::Disabled;
        else if (g_panel.hovered.kind == hoverKind && g_panel.hovered.index == s.index)
            state = InteractionState::Hover;

        // The button surface (drawButton draws the micro-gradient + rounded border + honors
        // the state). The label is drawn separately so the text role tracks the state correctly;
        // pass no label to drawButton.
        const KitButtonBox box{KitBox{s.x, s.y, s.width, s.height}};
        drawButton(bmp, box, /*label=*/nullptr, state, /*warn=*/false);

        const Role textRole =
            (state == InteractionState::Disabled) ? Role::TextDim : Role::TextPrimary;
        const KitBox labelBox{s.labelX, s.labelY, s.labelW, s.labelH};
        kitText(bmp, labelBox, row.shortLabel.c_str(), Font::Label, textRole, Align::Center);
    }
}

// The flat action index under (x, y) in `bar` for the given row set, or -1 (miss). Pure hit-test.
int toolbarHit(int x, int y, const ActionBarRect& bar, const std::vector<ActionBarRow>& rows) {
    if (bar.height <= 0) return -1;
    return hitTestActionBar(x, y, bar, actionBarClusters(rows), kBarSpec);
}

// Routes a click in a toolbar to the hit button's action, fired through the command-id contract
// (Main_OnCommand — REAPER runs the SAME action a keybinding would). Returns true iff the click
// was inside the bar band (handled, or a harmless gap/overflow/unregistered no-op), so the
// caller stops before grid handling. `rows` is the toolbar's inventory.
bool handleToolbarClick(int x, int y, const ActionBarRect& bar,
                        const std::vector<ActionBarRow>& rows) {
    if (bar.height <= 0) return false;
    const int hit = toolbarHit(x, y, bar, rows);
    if (hit < 0) {
        // Inside the band but in a gap / overflow dead-zone: claim it so it never falls through
        // to the grid. Outside the band: not ours.
        return y >= bar.y && y < bar.y + bar.height &&
               x >= bar.x && x < bar.x + bar.width;
    }
    const ActionBarRow& row = rows[static_cast<std::size_t>(hit)];
    // A disabled button (L5 opposite-mode gate) is claimed but no-ops — the click never fires the
    // action and never falls through to the grid (a dead button reads as inert, not absent).
    if (!row.enabled) return true;
    const int cmd = resolveBarCommandId(row);
    if (cmd != 0 && Main_OnCommand) Main_OnCommand(cmd, 0);
    return true;
}

// --- Top-toolbar overflow ("⋯" More) menu (L5 refinement 1) -------------------
//
// The three rare capture variants live only in this popup. The button is drawn kit-style (rest/
// hover) at the far right of the top band; a click opens a REAPER/host TrackPopupMenu listing the
// variants, each firing its existing registered command id via NamedCommandLookup/Main_OnCommand
// (the SAME contract the visible buttons use — no action changes). A transient OS menu is fine
// for panel-external chrome (brief §1); only the button geometry (overflow_menu) is pure.

// Draws the far-right More button (rest/hover). No-op when suppressed (band too narrow).
void drawMoreButton(LICE_IBitmap* bmp, int w) {
    const MenuButtonRect mb = topMenuButtonRect(w);
    if (mb.empty()) return;
    const InteractionState state = hoverState(g_panel.hovered, HoverKind::MoreButton, -1);
    const KitButtonBox box{KitBox{mb.x, mb.y, mb.width, mb.height}};
    drawButton(bmp, box, /*label=*/nullptr, state, /*warn=*/false);
    // The glyph: three ASCII dots (portable — no UTF-8/codepage dependency in the LICE text
    // path). Drawn as text so it picks up the kit font + AA. Reads as the conventional "More".
    kitText(bmp, KitBox{mb.x, mb.y, mb.width, mb.height}, "...",
            Font::Label, Role::TextPrimary, Align::Center);
}

// Opens the More popup: defined later (after the menuAppend/menuSeparator helpers), forward-
// declared here so drawMoreButton's neighbours read together. The click site (handleClick) sits
// after the definition, so no forward-declaration is strictly required — this documents intent.
void showMoreMenu();

// --- Tooltip (L5 refinement 2) ------------------------------------------------
//
// A custom hover-delay tooltip: the full, prefix-stripped action name of the hovered toolbar
// button. Resolves the hovered element to its (anchor rect, text); returns false when the current
// hover has no tooltip (grid / chrome / the More button — the More button's own popup is its
// affordance). The tooltip DRAW is below; timing (kTooltipDelayMs) is applied by the caller.

// The full (prefix-stripped) tooltip text for the currently hovered toolbar button, plus its
// anchor rect. Returns false when the hover is not a tooltip-bearing toolbar button.
bool currentTooltip(int w, int h, std::string& textOut, int& ax, int& ay, int& aw, int& ah) {
    const Hover& hv = g_panel.hovered;
    std::vector<ActionBarRow> rows;
    ActionBarRect bar{};
    if (hv.kind == HoverKind::TopBarButton) {
        rows = topBarRows();
        bar = topToolbarActionRect(w);
    } else if (hv.kind == HoverKind::BottomBarButton) {
        rows = bottomBarRows();
        bar = bottomToolbarRect(w, h);
    } else {
        return false;
    }
    if (hv.index < 0 || hv.index >= static_cast<int>(rows.size())) return false;

    // The hovered button's slot rect (the anchor). computeBarSlots is the same layout the draw +
    // hit-test use, so the anchor matches the drawn button exactly.
    const std::vector<ClusterSpec> clusters = actionBarClusters(rows);
    const std::vector<ActionBarSlot> slots = computeBarSlots(bar, clusters, kBarSpec);
    const ActionBarSlot* slot = nullptr;
    for (const ActionBarSlot& s : slots)
        if (s.index == hv.index) { slot = &s; break; }
    if (!slot) return false;

    // The full name is stored already prefix-free, but strip defensively in case a source ever
    // carries the "ReaSampler:" display prefix (the tooltip must never show it — L5 refinement 2).
    // L6: the keybinding sub-row was removed from the button face, so the tooltip now carries
    // both the name AND the binding (when bound) — e.g. "capture selected item — F5". When the
    // action is unbound the tooltip shows only the name (no "(unbound)" noise in the tooltip).
    const std::string phrase = stripActionPrefix(rows[static_cast<std::size_t>(hv.index)].fullName,
                                                 actionDisplayPrefix());
    const int cmd = resolveBarCommandId(rows[static_cast<std::size_t>(hv.index)]);
    const std::string binding = barBindingText(cmd);
    textOut = binding.empty() ? phrase : phrase + " \xe2\x80\x94 " + binding;  // " — " (em dash, UTF-8)
    ax = slot->x; ay = slot->y; aw = slot->width; ah = slot->height;
    return true;
}

// Draws the hover-delay tooltip over the given anchor button, if a tooltip is due (the current
// hover is a toolbar button AND it has been hovered past kTooltipDelayMs). Drawn LAST in the
// paint so it overlays the toolbars. The box is placed by the pure tooltip module (below the
// anchor, flipping above near the bottom edge, clamped to the client).
void drawTooltip(LICE_IBitmap* bmp, int w, int h) {
    if (!g_panel.tooltipShown) return;
    std::string txt;
    int ax = 0, ay = 0, aw = 0, ah = 0;
    if (!currentTooltip(w, h, txt, ax, ay, aw, ah) || txt.empty()) return;

    const int textW = static_cast<int>(txt.size()) * kTooltipCharPx;
    const TooltipBox tb =
        computeTooltip(ax, ay, aw, ah, textW, kTooltipTextH, w, h, TooltipSpec{});
    if (tb.empty()) return;

    // The tooltip surface: a raised bg/cell chip with a hairline border, then the AA text.
    const KitBox box{tb.x, tb.y, tb.width, tb.height};
    fillSurface(bmp, box, Role::BgCell, InteractionState::Hover);
    LICE_DrawRect(bmp, tb.x, tb.y, tb.width, tb.height,
                  toLice(roleColor(Role::LineHairline)), 1.0f, 0);
    kitText(bmp, box, txt.c_str(), Font::Label, Role::TextPrimary, Align::Center);
}

// --- Split geometry -----------------------------------------------------------
//
// Every rect below is derived from the client size + fullHeight state, and BOTH paint
// and hit-testing call these so they never drift. All are top-left origin.

// The body band between the TOP toolbar and the BOTTOM toolbar (L4). Its top edge is below the
// top toolbar; its bottom edge is the bottom toolbar's top. When the bottom bar collapses on a
// short client, bottomToolbarRect returns its y at the footer top, so the body still ends there.
RECT splitBody(int w, int h) {
    RECT rc{};
    rc.left = 0;
    rc.right = w;
    rc.top = kTopToolbarHeight;
    const ActionBarRect bar = bottomToolbarRect(w, h);
    rc.bottom = bar.y;
    if (rc.bottom < rc.top) rc.bottom = rc.top;
    return rc;
}

// True when both regions are shown (the split is live). Otherwise one region fills
// the body.
bool poolShown() { return g_panel.fullHeight != BankPanelFullHeight::BanksOnly; }
bool banksShown() { return g_panel.fullHeight != BankPanelFullHeight::PoolOnly; }

// The pool region's rect (whole-region: header band + grid). Empty when hidden.
RECT poolRegionRect(int w, int h) {
    const RECT body = splitBody(w, h);
    if (!poolShown()) return RECT{0, 0, 0, 0};
    if (!banksShown()) return body;  // pool full-height: the whole body
    // Split: pool gets the top half (minus the divider).
    RECT rc = body;
    rc.bottom = body.top + (body.bottom - body.top - kSplitDividerHeight) / 2;
    if (rc.bottom < rc.top) rc.bottom = rc.top;
    return rc;
}

// The named-banks region's rect (whole-region: header band + tab strip + grid).
RECT banksRegionRect(int w, int h) {
    const RECT body = splitBody(w, h);
    if (!banksShown()) return RECT{0, 0, 0, 0};
    if (!poolShown()) return body;  // banks full-height: the whole body
    RECT rc = body;
    rc.top = body.top + (body.bottom - body.top - kSplitDividerHeight) / 2 +
             kSplitDividerHeight;
    if (rc.top > rc.bottom) rc.top = rc.bottom;
    return rc;
}

// A region's header band (the top kRegionHeaderHeight of the region).
RECT regionHeaderRect(const RECT& region) {
    RECT rc = region;
    rc.bottom = region.top + kRegionHeaderHeight;
    if (rc.bottom > region.bottom) rc.bottom = region.bottom;
    return rc;
}

// The named-banks region's tab strip (below its header band).
TabStripRect banksTabStripRect(const RECT& region) {
    const RECT hdr = regionHeaderRect(region);
    TabStripRect s;
    s.x = region.left;
    s.y = hdr.bottom;
    s.width = region.right - region.left;
    s.height = kTabStripHeight;
    if (s.y + s.height > region.bottom) s.height = region.bottom - s.y;
    if (s.height < 0) s.height = 0;
    return s;
}

// A region's grid viewport (below the header band, and below the tab strip for the
// banks region). This is where cells tile.
RECT regionGridRect(const RECT& region, bool isBanks) {
    RECT rc = region;
    rc.top = region.top + kRegionHeaderHeight;
    if (isBanks) rc.top += kTabStripHeight;
    if (rc.top > rc.bottom) rc.top = rc.bottom;
    return rc;
}

// The full-height toggle button rect inside a region header (right-aligned).
RECT fullHtBtnRect(const RECT& region) {
    const RECT hdr = regionHeaderRect(region);
    RECT rc = hdr;
    rc.right = hdr.right - 4;
    rc.left = rc.right - kFullHtBtnWidth;
    rc.top = hdr.top + 2;
    rc.bottom = hdr.bottom - 2;
    return rc;
}

// The "+" create-bank button rect inside the named-banks region header (left of the
// full-height button).
RECT createBtnRect(const RECT& region) {
    RECT ft = fullHtBtnRect(region);
    RECT rc = ft;
    rc.right = ft.left - 4;
    rc.left = rc.right - kCreateBtnWidth;
    return rc;
}

// --- L7 slot-order display bridge ---------------------------------------------
//
// L7 re-maps cell index <-> sample identity: the grid draws in the bank's persisted
// SlotMap order (sparse, gap-preserving), NOT BankIndex insertion order. This one helper
// is the single place that resolves a region's display, composed purely from bank_book's
// slot order (orderedSampleIds) + card_drag's sparse slot rects (computeSlotRects) — the
// shell adds no layout math of its own.
//
// TWO INDEX SPACES the whole panel must keep straight:
//   * SLOT      — a display position 0..maxSlot; gaps are empty slots that draw as empty
//                 cells and are valid drop targets. This is what pixels/hit-tests speak.
//   * SELECTION — the DENSE occupied-ordinal [0, occupied) space the pure Selection /
//                 applyClick / navigate reason in. Selection index i <-> orderedIds[i].
//                 Keyboard navigation therefore traverses ONLY occupied cells and SKIPS
//                 gaps (spec: skip-vs-land-on-gap is unspecified -> skip, documented here).
// RegionDisplay carries both plus the translation between them, resolved FRESH each call
// (never cached across a mutation, per the reference-invalidation guardrail).
struct RegionDisplay {
    std::vector<std::string>  orderedIds;  // occupied ids in slot order (selection space)
    std::vector<SlotCellRect> slotRects;   // one rect per slot 0..maxSlot, viewport coords
    const Bank*               bank = nullptr;

    // The id occupying `slot`, or "" for an empty slot / out of range.
    std::string idAtSlot(int slot) const {
        return bank ? bank->slots.idAt(slot) : std::string{};
    }
    // The slot a selection ordinal `sel` maps to, or -1. orderedIds[sel] -> its slot.
    int slotForSelection(int sel) const {
        if (sel < 0 || sel >= static_cast<int>(orderedIds.size()) || !bank) return -1;
        return bank->slots.slotOf(orderedIds[static_cast<std::size_t>(sel)]);
    }
    // The selection ordinal for `slot` (index of its occupant in orderedIds), or -1 when
    // the slot is empty. Inverse of slotForSelection.
    int selectionForSlot(int slot) const {
        const std::string id = idAtSlot(slot);
        if (id.empty()) return -1;
        for (std::size_t i = 0; i < orderedIds.size(); ++i)
            if (orderedIds[i] == id) return static_cast<int>(i);
        return -1;
    }
    int occupiedCount() const { return static_cast<int>(orderedIds.size()); }
};

// Resolves a region's display for the currently-shown bank. Empty (no bank / no width)
// yields an empty display. orderedSampleIds reconciles the bank's SlotMap against live
// membership, so a freshly-migrated or out-of-band-mutated bank always yields a complete
// order (trailing empties are trimmed by the model — maxSlot walks only live occupants).
RegionDisplay regionDisplay(const RECT& region, bool isBanks, Region reg) {
    RegionDisplay d;
    BankBook* b = book();
    if (!b) return d;
    const std::string bankId = bankIdForRegion(reg);
    if (bankId.empty()) return d;
    d.bank = b->bank(bankId);
    if (!d.bank) return d;

    d.orderedIds = b->orderedSampleIds(bankId);  // occupied ids, slot order (reconciles)
    if (d.orderedIds.empty()) return d;

    const RECT grid = regionGridRect(region, isBanks);
    const int w = grid.right - grid.left;
    if (w <= 0) return d;
    d.slotRects = computeSlotRects(d.bank->slots.maxSlot(), w, kGrid);
    for (SlotCellRect& r : d.slotRects) { r.x += grid.left; r.y += grid.top; }
    return d;
}

// The FOCUSED region's display (the slot-order bridge for the region holding the live
// selection). Mirrors columnsForRegion's client read.
RegionDisplay focusedDisplay() {
    RECT cr{};
    GetClientRect(g_panel.hwnd, &cr);
    const int w = cr.right - cr.left, h = cr.bottom - cr.top;
    const bool isBanks = g_panel.focusedRegion == Region::Banks;
    const RECT region = isBanks ? banksRegionRect(w, h) : poolRegionRect(w, h);
    return regionDisplay(region, isBanks, g_panel.focusedRegion);
}

// --- Drawing: a grid region ---------------------------------------------------

// Draws one region's grid of thumbnails (or an empty-state line) clipped to its
// viewport. `selectionOwner` is true when this region holds the live selection, so
// its cells show selection/focus chrome; the other region draws plain.
void drawRegionGrid(LICE_IBitmap* bmp, const RECT& region, bool isBanks,
                    const BankIndex* index, const std::string& emptyMsg,
                    bool selectionOwner, const std::string& projectDir, Region reg) {
    const RECT grid = regionGridRect(region, isBanks);
    if (grid.bottom <= grid.top) return;

    if (!index || index->empty()) {
        kitText(bmp, toKitBox(grid), emptyMsg.c_str(), Font::Label, Role::TextDim, Align::Center);
        return;
    }

    // L7: iterate the bank's SPARSE slot layout (slot order, gaps included), not the dense
    // BankIndex insertion order. Selection/focus are keyed by the occupied-ordinal (selection
    // space); a slot maps back to its ordinal via selectionForSlot.
    const RegionDisplay disp = regionDisplay(region, isBanks, reg);
    const int binWidth = kGrid.cellWidth - 4;
    for (const SlotCellRect& r : disp.slotRects) {
        if (r.y >= grid.bottom) continue;   // below the viewport: skip (no scroll)
        const CellRect rect{r.x, r.y, r.width, r.height};
        const std::string id = disp.idAtSlot(r.slot);
        if (id.empty()) {
            // Interior gap slot: a subtle empty-slot treatment through the kit — a hairline
            // outline on bg/cell, clearly NOT a card (decorative, per the L7 spec). No
            // selection/focus/waveform, and not a hover or hit target (the grid never tracks
            // cell hover; a click on an empty slot clears selection like any grid miss).
            fillSurface(bmp, KitBox{rect.x, rect.y, rect.width, rect.height},
                        Role::BgCell, InteractionState::Rest);
            LICE_DrawRect(bmp, rect.x, rect.y, rect.width, rect.height,
                          toLice(roleColor(Role::LineHairline)), 1.0f, 0);
            continue;
        }
        const Sample* s = index->query(id);
        if (!s) continue;  // reconciled order should never name a stale id; defensive
        const int sel = disp.selectionForSlot(r.slot);
        const bool selected = selectionOwner && sel >= 0 && g_panel.selection.contains(sel);
        const bool focused  = selectionOwner && sel >= 0 && g_panel.selection.focus == sel;
        const Envelope& env = thumbnailFor(*s, binWidth, projectDir);
        // Grid-cell hover is intentionally not tracked: the cell already carries selection +
        // focus chrome (the centerpiece's "bones"); a third transient hover state on every
        // cell would add repaint churn + visual noise. Hover lights the chrome/buttons/tabs.
        drawThumbnail(bmp, rect, env, selected, focused, /*hovered=*/false, s);
    }
}

// L7: draws the per-slot reorder/replace drop-target highlight on the target slot's cell, but
// ONLY when a same-bank in-grid drag (Reorder or Replace) is live over THIS region (the drag
// source region). An accent/HOT outline (distinct from the accent/tertiary purple selection
// border, per the spec's "must not be confusable" constraint); Replace draws a doubled outline
// so an Alt-over-occupied replace reads as a stronger "swap" cue than a plain reorder. No-op
// for a move/copy/OS drag or when the pointer is off any slot (dragTargetSlot < 0).
void drawCardDropTarget(LICE_IBitmap* bmp, const RECT& region, bool isBanks, Region reg) {
    if (!g_panel.dragging) return;
    if (g_panel.cardGesture != CardGesture::Reorder &&
        g_panel.cardGesture != CardGesture::Replace)
        return;
    if (g_panel.dragSourceRegion != reg) return;  // highlight only the source bank's grid
    if (g_panel.dragTargetSlot < 0) return;

    const RECT grid = regionGridRect(region, isBanks);
    const RegionDisplay disp = regionDisplay(region, isBanks, reg);
    // Use the drop rects (includes the trailing row past maxSlot) so a beyond-extent
    // target slot gets a visible highlight cue, not silence.
    const int gridW = grid.right - grid.left;
    const int maxSlot = disp.bank ? disp.bank->slots.maxSlot() : -1;
    std::vector<SlotCellRect> dropRects = computeSlotRectsForDrop(maxSlot, gridW, kGrid);
    for (SlotCellRect& r : dropRects) { r.x += grid.left; r.y += grid.top; }
    for (const SlotCellRect& r : dropRects) {
        if (r.slot != g_panel.dragTargetSlot) continue;
        if (r.y >= grid.bottom) return;  // below the viewport (no scroll)
        const LICE_pixel hot = toLice(roleColor(Role::AccentHot));
        LICE_DrawRect(bmp, r.x, r.y, r.width, r.height, hot, 1.0f, 0);
        if (g_panel.cardGesture == CardGesture::Replace)
            LICE_DrawRect(bmp, r.x + 1, r.y + 1, r.width - 2, r.height - 2, hot, 1.0f, 0);
        return;
    }
}

// Draws a region header: title, the active-bank readout, and the full-height button.
void drawRegionHeader(LICE_IBitmap* bmp, const RECT& region, const char* title,
                      const std::string& activeName, bool poolBtnIsPool) {
    const RECT hdr = regionHeaderRect(region);
    // Region header band (kit bg/panel — a raised region title bar). A hairline underline.
    fillSurface(bmp, KitBox{hdr.left, hdr.top, hdr.right - hdr.left, hdr.bottom - hdr.top},
                Role::BgPanel, InteractionState::Rest);
    LICE_Line(bmp, hdr.left, hdr.bottom - 1, hdr.right, hdr.bottom - 1,
              toLice(roleColor(Role::LineHairline)), 1.0f, 0, false);

    // Title, left (Font::Title — a region heading). The two regions are distinct KINDS of
    // container, so the title carries a CATEGORICAL accent (DS-2 revised: secondary/tertiary
    // mark kinds, never intensity) — Pool = secondary teal, Banks = tertiary purple. This is
    // a category mark, NOT the "what's live" signal (that stays the primary-lime "Active:"
    // readout beside it), keeping primary reserved for the live/active layer.
    RECT titleRc = hdr;
    titleRc.left += 8;
    titleRc.right = titleRc.left + 120;
    const Role titleRole = poolBtnIsPool ? Role::AccentSecondary : Role::AccentTertiary;
    kitText(bmp, toKitBox(titleRc), title, Font::Title, titleRole, Align::Left);

    // Active-bank readout — the UNMISTAKABLE indicator (settled B4 constraint), in the PRIMARY
    // accent role in BOTH region headers so the active/capture-target bank is legible even when
    // it is not the shown tab and even when it is the pool. Primary = "what's live" (DS-2).
    const std::string readout = "Active: " + activeName;
    RECT actRc = hdr;
    actRc.left = titleRc.right + 6;
    actRc.right = createBtnRect(region).left - 6;
    if (actRc.right > actRc.left)
        kitText(bmp, toKitBox(actRc), readout.c_str(), Font::Label, Role::AccentPrimary, Align::Left);

    // Full-height toggle button: an arrow glyph. In split it means "maximize this region";
    // when this region is already full it means "restore the split". Kit drawButton + hover.
    const RECT btn = fullHtBtnRect(region);
    const bool thisFull =
        poolBtnIsPool ? (g_panel.fullHeight == BankPanelFullHeight::PoolOnly)
                      : (g_panel.fullHeight == BankPanelFullHeight::BanksOnly);
    const HoverKind hk = poolBtnIsPool ? HoverKind::FullHtPool : HoverKind::FullHtBanks;
    const InteractionState state =
        thisFull ? InteractionState::Active : hoverState(g_panel.hovered, hk, -1);
    const KitButtonBox box{KitBox{btn.left, btn.top, btn.right - btn.left,
                                  btn.bottom - btn.top}};
    drawButton(bmp, box, thisFull ? "v" : "^", state, /*warn=*/false);
}

// Draws the named-banks tab strip: one tab per named bank (ordinal order), the SHOWN
// tab highlighted, the ACTIVE bank's tab lit with the accent border, overflow
// chevrons when present, plus the "+" create button in the header. During a drag,
// the tab under the pointer gets the drop-target highlight.
void drawTabStrip(LICE_IBitmap* bmp, const RECT& region) {
    const TabStripRect strip = banksTabStripRect(region);
    if (strip.height <= 0) return;
    // Tab strip band (kit bg/base — recessed relative to the region header above it).
    fillSurface(bmp, KitBox{strip.x, strip.y, strip.width, strip.height},
                Role::BgBase, InteractionState::Rest);

    const std::vector<const Bank*> tabs = namedBanks();
    const int n = static_cast<int>(tabs.size());
    if (n == 0) {
        kitText(bmp, KitBox{strip.x + 8, strip.y, strip.width - 8, strip.height},
                "No named banks -- click + to create one.",
                Font::Label, Role::TextDim, Align::Left);
        return;
    }

    const TabStripLayout layout =
        computeTabStripLayout(strip, n, kTabSpec, g_panel.tabScroll);

    // Chevrons (drawn first so tabs sit above their inner edges).
    if (layout.overflow) {
        const KitBox lc{strip.x, strip.y, kTabSpec.chevronWidth, strip.height};
        const KitBox rc{strip.x + strip.width - kTabSpec.chevronWidth, strip.y,
                        kTabSpec.chevronWidth, strip.height};
        fillSurface(bmp, lc, Role::BgCell, InteractionState::Rest);
        fillSurface(bmp, rc, Role::BgCell, InteractionState::Rest);
        kitText(bmp, lc, "<", Font::Label, Role::TextPrimary, Align::Center);
        kitText(bmp, rc, ">", Font::Label, Role::TextPrimary, Align::Center);
    }

    const std::string activeId = book() ? book()->activeBankId() : std::string();
    const std::vector<TabRect> rects =
        computeTabRects(strip, n, kTabSpec, g_panel.tabScroll);
    for (const TabRect& tr : rects) {
        const Bank* bk = tabs[static_cast<std::size_t>(tr.index)];
        const bool shown = bk->id == g_panel.shownBankId;
        const bool active = bk->id == activeId;
        const bool dropHere = g_panel.dragging &&
                              g_panel.dropKind == DropKind::Tab &&
                              g_panel.dropBankId == bk->id;
        const bool hovered = g_panel.hovered.kind == HoverKind::Tab &&
                             g_panel.hovered.index == tr.index;

        // Surface state: the ACTIVE bank (capture target) carries the accent (Active); a drag
        // drop-target reads Dragging; the SHOWN (browsed) tab reads Pressed (recessed-lit);
        // else hover-or-rest bg/cell.
        const KitBox tb{tr.x, tr.y, tr.width, tr.height};
        InteractionState state = InteractionState::Rest;
        if (active)        state = InteractionState::Active;
        else if (dropHere) state = InteractionState::Dragging;
        else if (shown)    state = InteractionState::Pressed;
        else if (hovered)  state = InteractionState::Hover;
        fillSurface(bmp, tb, Role::BgCell, state);

        // The active bank's tab gets a bright accent border (unmistakable), distinct from the
        // shown tab's fill — active != shown, made visible (kit accent role).
        const KitColor border = active ? roleColor(Role::AccentPrimary) : roleColor(Role::LineHairline);
        LICE_DrawRect(bmp, tr.x, tr.y, tr.width, tr.height, toLice(border), 1.0f, 0);
        if (active)
            LICE_DrawRect(bmp, tr.x + 1, tr.y + 1, tr.width - 2, tr.height - 2,
                          toLice(border), 1.0f, 0);

        // Label: bg/base on the accent-active fill for contrast, else text/primary.
        const Role trole = active ? Role::BgBase : Role::TextPrimary;
        kitText(bmp, KitBox{tr.x + 4, tr.y, tr.width - 8, tr.height},
                bk->displayName.c_str(), Font::Label, trole, Align::Center);
    }
}

// The active bank's display name (for the readout). "Pool" when the pool is active.
std::string activeBankName() {
    BankBook* b = book();
    if (!b) return std::string(kPoolBankName);
    const Bank* bk = b->bank(b->activeBankId());
    return bk ? bk->displayName : std::string(kPoolBankName);
}

// --- Full paint ---------------------------------------------------------------

void paintPanel(HWND hwnd, HDC hdc) {
    RECT cr{};
    GetClientRect(hwnd, &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 std::string projectDir = currentProjectDir();
    const std::string activeName = activeBankName();

    // Pool region (top).
    if (poolShown()) {
        const RECT region = poolRegionRect(w, h);
        drawRegionHeader(&bmp, region, "Pool", activeName, /*poolBtnIsPool=*/true);
        drawRegionGrid(&bmp, region, /*isBanks=*/false, indexForRegion(Region::Pool),
                       "No samples in the pool yet. Capture one to see it here.",
                       g_panel.focusedRegion == Region::Pool, projectDir, Region::Pool);
        // Drop-target highlight for the pool region during a MOVE/COPY drag (a whole-grid
        // outline signalling "drop here to move/copy into this bank"). Suppressed for a
        // same-bank reorder (that shows a per-SLOT highlight below, not the whole grid).
        if (g_panel.dragging && g_panel.dropKind == DropKind::PoolRegion &&
            (g_panel.cardGesture == CardGesture::Move ||
             g_panel.cardGesture == CardGesture::Copy)) {
            const RECT grid = regionGridRect(region, false);
            LICE_DrawRect(&bmp, grid.left + 1, grid.top + 1,
                          grid.right - grid.left - 2, grid.bottom - grid.top - 2,
                          toLice(roleColor(Role::AccentHot)), 1.0f, 0);
        }
        // L7 per-slot reorder/replace target highlight (source = pool). An accent/hot outline
        // on the target slot's cell — distinct from the accent/tertiary purple selection
        // border, so it is never confusable with a selected card.
        drawCardDropTarget(&bmp, region, /*isBanks=*/false, Region::Pool);
    }

    // Split divider.
    if (poolShown() && banksShown()) {
        const RECT body = splitBody(w, h);
        const int dy = body.top + (body.bottom - body.top - kSplitDividerHeight) / 2;
        LICE_FillRect(&bmp, 0, dy, w, kSplitDividerHeight,
                      toLice(roleColor(Role::BgBase)), 1.0f, 0);
    }

    // Named-banks region (bottom).
    if (banksShown()) {
        const RECT region = banksRegionRect(w, h);
        drawRegionHeader(&bmp, region, "Banks", activeName, /*poolBtnIsPool=*/false);
        // "+" create button (drawn as part of the banks header) — kit drawButton + hover.
        const RECT cbtn = createBtnRect(region);
        const InteractionState createState =
            hoverState(g_panel.hovered, HoverKind::CreateBank, -1);
        drawButton(&bmp, KitButtonBox{KitBox{cbtn.left, cbtn.top, cbtn.right - cbtn.left,
                                             cbtn.bottom - cbtn.top}},
                   "+", createState, /*warn=*/false);

        drawTabStrip(&bmp, region);
        drawRegionGrid(&bmp, region, /*isBanks=*/true, indexForRegion(Region::Banks),
                       g_panel.shownBankId.empty()
                           ? "Select or create a named bank."
                           : "This bank is empty. Move samples here from the pool.",
                       g_panel.focusedRegion == Region::Banks, projectDir, Region::Banks);
        // Drop-target highlight for the banks region during a drag. BanksRegion fires
        // when the pointer is in the grid but not on a specific tab; Tab draws its own
        // highlight on the individual tab (drawTabStrip above handles that case).
        if (g_panel.dragging && g_panel.dropKind == DropKind::BanksRegion &&
            (g_panel.cardGesture == CardGesture::Move ||
             g_panel.cardGesture == CardGesture::Copy)) {
            const RECT grid = regionGridRect(region, true);
            LICE_DrawRect(&bmp, grid.left + 1, grid.top + 1,
                          grid.right - grid.left - 2, grid.bottom - grid.top - 2,
                          toLice(roleColor(Role::AccentHot)), 1.0f, 0);
        }
        // L7 per-slot reorder/replace target highlight (source = banks region).
        drawCardDropTarget(&bmp, region, /*isBanks=*/true, Region::Banks);
    }

    // L4 three-zone chrome + L5 refinements: TOP toolbar (frequent capture + placement) tiles
    // into the band MINUS the far-right More-button reserve; the More button is drawn over the
    // band's reserved right strip; the BOTTOM toolbar (four opposite-mode tag buttons + Show
    // Both); then the footer (mode toggle + count + Tail button + Prune). Drawn last so they sit
    // over the split body's edges. drawToolbar fills only its passed (action) rect, so fill the
    // WHOLE top band first — otherwise the reserved right strip behind the More button is bare.
    fillSurface(&bmp, KitBox{0, 0, w, kTopToolbarHeight}, Role::BgPanel, InteractionState::Rest);
    drawToolbar(&bmp, topToolbarActionRect(w), topBarRows(), HoverKind::TopBarButton,
                /*topDivider=*/false);
    drawMoreButton(&bmp, w);
    drawToolbar(&bmp, bottomToolbarRect(w, h), bottomBarRows(), HoverKind::BottomBarButton,
                /*topDivider=*/true);
    drawFooter(&bmp, w, h);

    // The custom hover-delay tooltip overlays everything (L5 refinement 2).
    drawTooltip(&bmp, w, h);

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

// --- Bank-change detection ----------------------------------------------------

// A fingerprint of the WHOLE BOOK: for each bank, its id + display name + active flag
// + per-sample id/path. Catches every mutation the panel must redraw for: capture,
// project load, and B4's own create/rename/delete/move/activate.
std::string bookFingerprint() {
    BankBook* b = book();
    if (!b) return {};
    std::string fp = std::to_string(b->size());
    fp += '\x1e'; fp += b->activeBankId();
    for (const Bank& bk : b->banks()) {
        fp += '\x1d';
        fp += bk.id;
        fp += '\x1c';
        fp += bk.displayName;
        for (const Sample& s : bk.index.all()) {
            fp += '\x1f';
            fp += s.id;
            fp += '\x1f';
            fp += s.relativePath;
        }
    }
    return fp;
}

// Reconciles shownBankId against the live named banks: keep it if it still names a
// named bank; otherwise fall to the first named bank (or empty when none). Keeps the
// banks region always showing a valid tab. Never touches the ACTIVE bank.
void reconcileShownBank() {
    BankBook* b = book();
    if (!b) { g_panel.shownBankId.clear(); return; }
    if (!g_panel.shownBankId.empty()) {
        const Bank* bk = b->bank(g_panel.shownBankId);
        if (bk && !bk->isPool()) return;  // still valid
    }
    const std::vector<const Bank*> named = namedBanks();
    g_panel.shownBankId = named.empty() ? std::string() : named.front()->id;
}

bool refreshFingerprint() {
    if (!book()) return false;
    std::string fp = bookFingerprint();
    if (fp == g_panel.bankFingerprint) return false;
    g_panel.bankFingerprint = std::move(fp);
    ++g_panel.generation;
    g_panel.cache.clear();
    // The selection indexes into the OLD order; a change can invalidate those, so
    // clear it and stop any audition.
    if (!g_panel.selection.empty() || g_panel.selection.focus >= 0) {
        g_panel.selection = Selection{};
        stopAudition();
    }
    reconcileShownBank();
    const BankIndex* idx = indexForRegion(g_panel.focusedRegion);
    g_panel.selItemCount = idx ? static_cast<int>(idx->size()) : 0;
    return true;
}

// --- New-content detection (D2 Wave 2) ----------------------------------------
//
// REAPER exposes no "item/track added" callback, so we diff live project state on the
// existing timer. Each tick: enumerate every track GUID and every item GUID, diff
// against the previous tick (GuidBaseline, first-poll-guarded), and auto-tag the new
// GUIDs into the active mode via the pure autoTagNewContent. An item on a MANUAL lane
// is exempt (design point #1) — its lane's durable name lacks the managed prefix. All
// enumeration is READ-ONLY on the project; the only mutation is to the in-memory
// membership index (persisted by persist on the next save, same as an action-driven tag).

// True iff `tr` has I_FREEMODE==2 (fixed lanes enabled). The SDK value is verified
// in view.cpp (kFreeModeFixedLanes=2); reproduced here as a local constant so
// bank_panel.cpp stays self-contained without pulling in view.cpp's private namespace.
constexpr int kFreeModeFixedLanes = 2;

bool isFixedLaneTrack(MediaTrack* tr) {
    return static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FREEMODE")) == kFreeModeFixedLanes;
}

// Item GUID + fixed-lane name reads come from the shared item_read seam (item_read.h):
// itemGuid(it) and itemLaneName(tr, it). bank_panel.cpp no longer carries its own copies.

// Enumerates the live project's track + item GUIDs. Fills `allGuids` (the full live set,
// baseline input) and, for each item, records whether it sits on a manual lane so a
// newly-detected item can be exempted from auto-tag without a second project walk.
// `trackItemGuids` additionally maps each track GUID to the item GUIDs it carries, so a
// newly-detected item's PRE-EXISTING siblings can be resolved (the adoption / strand
// guard) without a second project walk.
//
// Manual-lane classification uses the single pure predicate isOnManualLane(isFixedLaneTrack,
// laneName) from lane_keys — the same predicate the apply path consults — so the exemption
// rule is defined in exactly one place and is unit-tested there.
void enumerateLiveGuids(ReaProject* proj, std::set<std::string>& allGuids,
                        std::map<std::string, bool>& itemOnManualLane,
                        std::map<std::string, std::vector<std::string>>& trackItemGuids) {
    const int trackCount = CountTracks(proj);
    for (int t = 0; t < trackCount; ++t) {
        MediaTrack* tr = GetTrack(proj, t);
        if (!tr) continue;
        std::string tg = guidString(tr);
        if (!tg.empty()) allGuids.insert(tg);

        // Compute the fixed-lane status once per track (not per item) — I_FREEMODE is a
        // track-level attribute and is the same for every item on the track.
        const bool fixedLane = isFixedLaneTrack(tr);

        std::vector<std::string>& itemsOnTrack = trackItemGuids[tg];
        const int itemCount = CountTrackMediaItems(tr);
        for (int i = 0; i < itemCount; ++i) {
            MediaItem* it = GetTrackMediaItem(tr, i);
            if (!it) continue;
            std::string ig = itemGuid(it);
            if (ig.empty()) continue;
            allGuids.insert(ig);
            // Classify via the single shared predicate. For a fixed-lane track we read
            // the item's lane name; for a normal track we pass "" (isOnManualLane returns
            // false immediately for non-fixed-lane tracks regardless of name).
            const std::string ln = fixedLane ? itemLaneName(tr, it) : std::string{};
            itemOnManualLane[ig] = isOnManualLane(fixedLane, ln);
            itemsOnTrack.push_back(ig);
        }
    }
}

// One detection tick: diff live GUIDs against the baseline and auto-tag the new ones
// into the active mode. Runs every timer tick regardless of panel open/close (content
// is created in the arrange). READ-ONLY on the project; mutates only the in-memory
// membership index.
//
// INTENTIONAL: membership mutation happens OUTSIDE any Undo block. Auto-tag is a
// background metadata update (like setting a label), not a destructive project edit.
// persist.cpp writes it on the next project save alongside the bank and view state, the
// same way an action-driven tag is persisted. Wrapping this in an Undo block would flood
// the REAPER undo history with a new entry for every timer tick that sees new content.
// Returns true iff this tick tagged at least one new GUID into a mode — the signal the
// caller uses to decide whether to run the lane-minting pass (a track can only newly
// become multi-mode when auto-tag just placed content on it). No tag ⇒ nothing to mint.
bool detectNewContent() {
    if (!g_panel.session) return false;

    ReaProject* proj = EnumProjects(-1, nullptr, 0);

    // A project (re)load re-arms the first-poll guard so we never diff across two
    // projects. The signal is persist's — main.cpp calls bankPanelNotifyProjectLoaded()
    // on the tick persist restores the project's membership + active mode, which sets
    // reloadPending. Draining it here re-baselines against the fully-loaded set (that
    // same tick's reapply-active-mode enumerated those tracks, so they are present),
    // and the observe() below returns nothing new — pre-existing untagged tracks stay
    // Arrange. GuidBaseline self-arms on its first observe() for the very first tick, so
    // no separate first-tick handling is needed here. Using persist's GUID-primary load
    // signal (not a local pointer compare) is what fixes the reload-mis-tag: the two
    // identity checks can no longer diverge on a recycled ReaProject* address.
    if (g_panel.reloadPending) {
        g_panel.contentBaseline.reset();
        g_panel.reloadPending = false;
    }

    std::set<std::string> live;
    std::map<std::string, bool> itemOnManualLane;
    std::map<std::string, std::vector<std::string>> trackItemGuids;
    enumerateLiveGuids(proj, live, itemOnManualLane, trackItemGuids);

    const std::vector<std::string> added = g_panel.contentBaseline.observe(live);
    if (added.empty()) return false;  // first poll after open, or nothing new this tick

    ViewModeModel& model = g_panel.session->view();

    // Which of `added` are items (the manual-lane map keys every item; track GUIDs never
    // appear there). Used below to exclude sibling new items from a track's PRE-EXISTING
    // mode set — a drop plus its own new siblings must not count each other as prior.
    const std::set<std::string> newItemGuids = [&] {
        std::set<std::string> s;
        for (const std::string& g : added)
            if (itemOnManualLane.count(g)) s.insert(g);
        return s;
    }();

    // Item guid -> its track guid (reverse of trackItemGuids), so a new item's siblings
    // are found in one lookup.
    std::map<std::string, std::string> trackOfItem;
    for (const auto& [trackGuid, items] : trackItemGuids)
        for (const std::string& ig : items) trackOfItem[ig] = trackGuid;

    // The distinct modes the PRE-EXISTING (not-new-this-tick) MANAGED-ELIGIBLE items on
    // `trackGuid` resolve to. Untagged siblings resolve to Arrange (leafBelongsToMode's
    // default); new siblings are excluded; manual-lane siblings are EXEMPT — exactly as
    // planLaneMinting ignores them when computing a track's own-item mode span, so the
    // adoption guard's view of the track matches the split decision's. Drives the adoption
    // / strand guard in autoTagNewContent.
    const auto preExistingTrackModes =
        [&](const std::string& trackGuid) -> std::set<std::string> {
        std::set<std::string> modes;
        auto it = trackItemGuids.find(trackGuid);
        if (it == trackItemGuids.end()) return modes;
        for (const std::string& sib : it->second) {
            if (newItemGuids.count(sib)) continue;   // a sibling added THIS tick — not prior
            auto ml = itemOnManualLane.find(sib);
            if (ml != itemOnManualLane.end() && ml->second) continue;  // manual lane — exempt
            const std::set<std::string> m = model.membership().modesOf(sib);
            if (m.empty()) modes.insert(kArrangeModeId);  // untagged ⇒ Arrange default
            else modes.insert(m.begin(), m.end());
        }
        return modes;
    };

    // Split the new GUIDs into tracks vs items so the pure decision can apply the
    // manual-lane exemption to items only. A GUID present in the item-lane map is an
    // item; otherwise it is a track (track GUIDs never appear in that map).
    std::vector<std::string> newTracks;
    std::vector<NewItem> newItems;
    for (const std::string& g : added) {
        auto it = itemOnManualLane.find(g);
        if (it == itemOnManualLane.end()) {
            newTracks.push_back(g);              // a track GUID
        } else {
            NewItem ni{g, it->second, {}};
            auto tk = trackOfItem.find(g);
            if (tk != trackOfItem.end()) ni.trackModes = preExistingTrackModes(tk->second);
            newItems.push_back(std::move(ni));   // an item; carries exemption + track modes
        }
    }

    const std::vector<AutoTag> tags =
        autoTagNewContent(newTracks, newItems, model.activeModeId());
    for (const AutoTag& tag : tags)
        model.membership().tag(tag.guid, tag.modeId);
    return !tags.empty();
}

// --- Audition preview ---------------------------------------------------------
//
// READ-ONLY / NON-DESTRUCTIVE (load-bearing principle): audition is PREVIEW
// playback only. It NEVER inserts into the arrange, creates items/tracks, or
// mutates the project or bank. PlayPreview streams a caller-owned PCM_source
// through REAPER's preview bus and touches nothing in the project.
//
// FLAGGED RUNTIME ASSUMPTIONS (header does not specify these; verified only by
// signature/struct, not semantics — DAW-verify):
//   1. REAPER's audio thread reads the preview_register_t by POINTER while the
//      preview is active (the struct's own comment mandates a cs/mutex we init),
//      so the register must outlive playback — we hold it in g_panel (static),
//      never on the stack.
//   2. StopPreview is assumed to detach the source from the audio thread BEFORE it
//      returns, making it safe to PCM_Source_Destroy the source immediately after.
//      This is the conventional contract (SWS' preview helpers rely on it) but is
//      NOT documented in the header — flagged. If a rare race surfaced, the fix is
//      a StartPreviewFade + deferred free; not done now (YAGNI, no evidence).
//   3. m_out_chan == 0 routes to the first hardware output pair (stereo). We do not
//      set mono (&1024). volume 1.0, loop false, curpos 0.

void initPreview() {
    if (g_panel.previewInited) return;
#ifdef _WIN32
    InitializeCriticalSection(&g_panel.preview.cs);
#else
    pthread_mutex_init(&g_panel.preview.mutex, nullptr);
#endif
    g_panel.previewInited = true;
}

void stopAudition() {
    if (g_panel.previewActive) {
        StopPreview(&g_panel.preview);
        g_panel.previewActive = false;
    }
    if (g_panel.previewSrc) {
        PCM_Source_Destroy(g_panel.previewSrc);
        g_panel.previewSrc = nullptr;
    }
    g_panel.preview.src = nullptr;
}

void deinitPreview() {
    if (!g_panel.previewInited) return;
#ifdef _WIN32
    DeleteCriticalSection(&g_panel.preview.cs);
#else
    pthread_mutex_destroy(&g_panel.preview.mutex);
#endif
    g_panel.previewInited = false;
}

// Auditions the sample at selection ordinal `idx` of the FOCUSED region's displayed bank.
// L7: `idx` is a DISPLAY-order (slot) ordinal, resolved through orderedIds, not a raw
// BankIndex position.
void startAudition(int idx) {
    stopAudition();

    const BankIndex* index = indexForRegion(g_panel.focusedRegion);
    if (!index) return;
    const RegionDisplay disp = focusedDisplay();
    if (idx < 0 || idx >= disp.occupiedCount()) return;
    const Sample* s = index->query(disp.orderedIds[static_cast<std::size_t>(idx)]);
    if (!s) return;

    const std::string projectDir = currentProjectDir();
    const std::string abs = resolveBankFile(projectDir, s->relativePath);
    if (abs.empty()) return;

    PCM_source* src = PCM_Source_CreateFromFile(abs.c_str());
    if (!src) return;

    g_panel.preview.src = src;
    g_panel.preview.m_out_chan = 0;
    g_panel.preview.curpos = 0.0;
    g_panel.preview.loop = false;
    g_panel.preview.volume = 1.0;
    g_panel.preview.peakvol[0] = 0.0;
    g_panel.preview.peakvol[1] = 0.0;
    g_panel.preview.preview_track = nullptr;

    if (PlayPreview(&g_panel.preview) != 0) {
        g_panel.previewSrc = src;
        g_panel.previewActive = true;
    } else {
        PCM_Source_Destroy(src);
        g_panel.preview.src = nullptr;
    }
}

// --- Input helpers ------------------------------------------------------------

bool ctrlDown() { return (GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0; }
bool shiftDown() { return (GetAsyncKeyState(VK_SHIFT) & 0x8000) != 0; }
bool altDown() { return (GetAsyncKeyState(VK_MENU) & 0x8000) != 0; }  // Alt = replace modifier (L7)

void invalidatePanel() {
    if (g_panel.hwnd) InvalidateRect(g_panel.hwnd, nullptr, FALSE);
}

// The item count the SELECTION reasons over — the focused region's occupied-cell count.
// L7: selection/navigation traverse OCCUPIED cells only (empty slots are gaps, not
// selectable). Occupied count == index size by construction: every index member maps to
// exactly one occupied slot (gaps are empty slots, which the index never backs), so the
// raw index size IS the dense selection-space extent.
int focusedItemCount() {
    const BankIndex* idx = indexForRegion(g_panel.focusedRegion);
    return idx ? static_cast<int>(idx->size()) : 0;
}

// Which region (if any) contains client point (x, y); returns false via `out` set to
// Pool by default when the point is in neither region body.
bool regionAt(int x, int y, Region& out) {
    RECT cr{};
    GetClientRect(g_panel.hwnd, &cr);
    const int w = cr.right - cr.left, h = cr.bottom - cr.top;
    if (poolShown()) {
        const RECT r = poolRegionRect(w, h);
        if (x >= r.left && x < r.right && y >= r.top && y < r.bottom) {
            out = Region::Pool; return true;
        }
    }
    if (banksShown()) {
        const RECT r = banksRegionRect(w, h);
        if (x >= r.left && x < r.right && y >= r.top && y < r.bottom) {
            out = Region::Banks; return true;
        }
    }
    return false;
}

// --- Bank management ops (id-keyed; drive the B1 model + persist) --------------
//
// Each op mutates g_session.book() then persists via persistBankOp(). After a
// STRUCTURAL mutation (create/delete/evacuate) any Bank*/BankIndex& is invalid — we
// resolve fresh, pass ids, and let the next refreshFingerprint repaint. On an
// unsaved project the empty-close discard in persistBankOp ensures no stale state
// survives (matches the capture/B3 quiet-persist idiom).

// REAPER's stock single-line input (comma-safe via the \x1f return separator, as B3).
bool promptText(const char* title, const char* caption, const std::string& initial,
                std::string& out) {
    std::vector<char> buf(512, '\0');
    std::snprintf(buf.data(), buf.size(), "%s", initial.c_str());
    const std::string captions = std::string(caption) + ",separator=\x1f";
    if (!GetUserInputs(title, 1, captions.c_str(), buf.data(),
                       static_cast<int>(buf.size())))
        return false;
    std::string s(buf.data());
    if (s.empty()) return false;
    out = std::move(s);
    return true;
}

// Mints a genuine REAPER GUID string as a stable bank id (same as B3 mintBankId).
std::string mintBankId() {
    GUID g{};
    genGuid(&g);
    char buf[64] = {0};
    guidToString(&g, buf);
    return std::string(buf);
}

void doCreateBank() {
    if (!book()) return;
    std::string name;
    if (!promptText("ReaSampler: create bank", "Bank name:", "", name)) return;
    const std::string id = mintBankId();
    if (!book()->createBank(id, name)) {
        ShowMessageBox("A bank with that name already exists.",
                       "ReaSampler: create bank", 0);
        return;
    }
    g_panel.shownBankId = id;             // show the freshly-created bank
    g_panel.focusedRegion = Region::Banks;
    persistBankOp("ReaSampler: create bank");
    invalidatePanel();
}

void doRenameBank(const std::string& bankId) {
    if (!book()) return;
    const Bank* bk = book()->bank(bankId);
    if (!bk || bk->isPool()) return;
    const std::string current = bk->displayName;  // copy before any mutation
    std::string newName;
    if (!promptText("ReaSampler: rename bank", "New name:", current, newName)) return;
    if (!book()->renameBank(bankId, newName)) {
        ShowMessageBox("Another bank already uses that name.",
                       "ReaSampler: rename bank", 0);
        return;
    }
    persistBankOp("ReaSampler: rename bank");
    invalidatePanel();
}

// Delete with the RICHER confirm-on-non-empty affordance (B4): the confirm names the
// member count AND offers evacuate as the one-click alternative (Yes=delete anyway,
// No=evacuate-then-keep, Cancel=abort) — richer than B3's basic YESNO.
void doDeleteBank(const std::string& bankId) {
    if (!book()) return;
    const Bank* bk = book()->bank(bankId);
    if (!bk || bk->isPool()) return;
    const std::size_t members = bk->index.size();  // read BEFORE any mutation
    const std::string name = bk->displayName;

    if (members > 0) {
        const std::string msg =
            "\"" + name + "\" holds " + std::to_string(members) +
            (members == 1 ? " sample" : " samples") +
            ".\n\nYes  -- delete the bank AND drop its samples (files are kept on disk "
            "but no bank references them until prune).\nNo   -- Evacuate them to the "
            "pool first, then delete the empty bank (keeps the samples).\nCancel -- "
            "do nothing.";
        // 3 == MB_YESNOCANCEL. 6=Yes, 7=No, 2=Cancel (SDK).
        const int r = ShowMessageBox(msg.c_str(),
                                     "ReaSampler: delete non-empty bank", 3);
        if (r == 2) return;                 // Cancel
        if (r == 7) {                       // No -> evacuate, then delete empty
            if (!book()->evacuate(bankId)) return;
            // book() may have reallocated; re-resolve nothing (we pass the id again).
        }
        // r == 6 (Yes) falls through to a plain delete (drops members).
    }
    if (!book()->deleteBank(bankId)) return;
    // S9: bump when the bank held samples (either the Yes-drop path or the No-evacuate-then-
    // delete path moved/dropped members) — both change what a live instance could play. An
    // empty-bank delete is purely organizational, no bump.
    persistBankOp("ReaSampler: delete bank", /*bumpGeneration=*/members > 0);
    // shownBankId is reconciled by the next fingerprint pass. If no named banks remain,
    // nudge focus to the pool so the selection has a valid home.
    if (namedBanks().empty()) g_panel.focusedRegion = Region::Pool;
    invalidatePanel();
}

void doEvacuateBank(const std::string& bankId) {
    if (!book()) return;
    const Bank* bk = book()->bank(bankId);
    if (!bk || bk->isPool()) return;
    if (!book()->evacuate(bankId)) return;
    persistBankOp("ReaSampler: evacuate bank", /*bumpGeneration=*/true);  // S9: membership changed
    invalidatePanel();
}

void doActivateBank(const std::string& bankId) {
    if (!book()) return;
    if (!book()->setActiveBank(bankId)) return;  // rejects an unknown id
    persistBankOp("ReaSampler: activate bank");
    invalidatePanel();
}

// Move or copy `sampleIds` from `srcBankId` to `destBankId` (index-only). Both pass
// ids straight to the model op (no BankIndex& cached across the loop's mutations).
//
// NO-OP GUARDRAIL — VERB-AWARE (matches the action layer's doBankTransferSelected):
//   * MOVE collapse: the source entry WAS removed (bank_book removes unconditionally
//     before the dest add collapses on hash), so the index DID mutate — counts.
//   * COPY collapse: the source is left intact AND the dest already held the hash,
//     so NOTHING changed — a true index no-op. Must NOT open an undo point.
// Hence: copy counts only real gains (Copied); move counts gains OR collapses.
void transferSamples(const std::vector<std::string>& sampleIds,
                     const std::string& srcBankId, const std::string& destBankId,
                     bool copy) {
    if (!book()) return;
    if (sampleIds.empty() || srcBankId == destBankId) return;
    if (!book()->bank(srcBankId) || !book()->bank(destBankId)) return;
    int ok = 0, collapsed = 0;
    for (const std::string& sid : sampleIds) {
        const TransferResult r =
            copy ? book()->copySample(sid, srcBankId, destBankId)
                 : book()->moveSample(sid, srcBankId, destBankId);
        switch (r) {
            case TransferResult::Moved:
            case TransferResult::Copied:        ++ok; break;
            case TransferResult::Collapsed:     ++collapsed; break;
            case TransferResult::RejectedUnknownBank:
            case TransferResult::RejectedSampleAbsent:
            case TransferResult::RejectedSameBank: break;
        }
    }
    const bool mutated = copy ? (ok > 0) : (ok > 0 || collapsed > 0);
    if (!mutated) return;  // nothing changed — no persist, no undo point

    const char* label = copy ? "ReaSampler: copy sample(s)" : "ReaSampler: move sample(s)";
    persistBankOp(label, /*bumpGeneration=*/true);  // S9: bank membership changed
    // The selection indexed into the source; after a move those indices are stale, so
    // clear it (the fingerprint pass will also clear, but do it now for immediacy).
    g_panel.selection = Selection{};
    invalidatePanel();
}

// Remove `sampleIds` from `srcBankId` (index-only, this-bank scope). Non-destructive to
// the file: a last-reference remove leaves the file on disk, orphaned until Phase R
// prune — remove NEVER deletes bytes (the manifest is untouched). Removes are silent
// (no confirm dialog); recoverability is provided by the batched REAPER undo (R-B) —
// one Ctrl-Z restores the index entry. Ids passed by value — no BankIndex& cached
// across the loop's mutations.
void removeSamples(const std::vector<std::string>& sampleIds,
                   const std::string& srcBankId) {
    if (!book() || sampleIds.empty()) return;
    if (!book()->bank(srcBankId)) return;

    int removed = 0;
    for (const std::string& sid : sampleIds)
        if (book()->removeSample(sid, srcBankId, RemoveScope::ThisBank) ==
            RemoveResult::Removed)
            ++removed;
    if (removed == 0) return;  // nothing changed — no persist, no undo point

    persistBankOp("ReaSampler: remove sample(s)", /*bumpGeneration=*/true);  // S9: sample dropped
    // The selection indexed into the source; after a remove those indices are stale, so
    // clear it (the fingerprint pass will also clear, but do it now for immediacy).
    g_panel.selection = Selection{};
    invalidatePanel();
}

// The selection's sample ids resolved against the FOCUSED region's bank (source of a
// move/copy). Returns ids in bank order; empty when nothing selected.
std::vector<std::string> focusedSelectionIds() {
    // L7: selection ordinals index the DISPLAY (slot) order, not BankIndex insertion order.
    // orderedIds[i] is the id at selection ordinal i.
    std::vector<std::string> ids;
    const RegionDisplay disp = focusedDisplay();
    const int count = disp.occupiedCount();
    for (int i : g_panel.selection.indices)
        if (i >= 0 && i < count) ids.push_back(disp.orderedIds[static_cast<std::size_t>(i)]);
    return ids;
}

// Resolves the ARMED drag payload (g_panel.dragSampleIds, from g_panel.dragSourceBankId) to
// the absolute, existing-file path list for a native OS drag-out (M11). Reuses the SAME M4
// path machinery the panel uses for audition/insert (resolveBankFile over the current
// project dir) — no temp copies; the drag points straight at the on-disk bank files. Each
// id is looked up in its SOURCE bank's index (the payload's origin, not the focused region,
// which can differ once the pointer roams), resolved, stat'd, then handed to the pure
// drag_out::assemblePathList for dedupe + skip-missing/unresolved policy. Read-only: no
// mutation of sample / index / selection (invariant #2).
std::vector<std::string> resolveDragPathsForOs() {
    std::vector<ResolvedSample> resolved;
    BankBook* b = book();
    if (!b) return {};
    const BankIndex* idx = b->index(g_panel.dragSourceBankId);
    if (!idx) return {};

    const std::string projectDir = currentProjectDir();
    resolved.reserve(g_panel.dragSampleIds.size());
    for (const std::string& sid : g_panel.dragSampleIds) {
        const Sample* s = idx->query(sid);
        if (!s) continue;  // stale id — the pure layer would skip it anyway; nothing to resolve
        ResolvedSample rs;
        rs.absolutePath = resolveBankFile(projectDir, s->relativePath);
        rs.fileExists = !rs.absolutePath.empty() && fs::exists(fs::path(rs.absolutePath));
        resolved.push_back(std::move(rs));
    }
    return assemblePathList(resolved).paths;
}

// --- Popup menus --------------------------------------------------------------
//
// SWELL/Win32 both expose CreatePopupMenu / InsertMenu (SWELL aliases SWELL_InsertMenu
// -> InsertMenu) / TrackPopupMenu(TPM_RETURNCMD) / DestroyMenu. We build a menu of
// (label -> small int command), track it at screen coords, and switch on the return.
// Menu command ids are LOCAL to the popup (not REAPER action ids) — TPM_RETURNCMD
// hands the chosen id straight back, so no hookcommand routing is involved.

// Appends a string item (id) to `menu` at its end. Portable over Win32/SWELL: both
// accept InsertMenu(menu, pos, MF_BYPOSITION|MF_STRING, id, text) with a negative
// position appending. Win32 and SWELL both treat pos < 0 as an append.
void menuAppend(HMENU menu, unsigned int id, const char* text, bool grayed = false) {
    UINT flags = MF_BYPOSITION | MF_STRING;
    if (grayed) flags |= MF_GRAYED;
    InsertMenu(menu, -1, flags, id, text);
}
void menuSeparator(HMENU menu) {
    InsertMenu(menu, -1, MF_BYPOSITION | MF_SEPARATOR, 0, nullptr);
}

// Menu command ids (local to a popup).
enum : unsigned int {
    kMenuNone = 0,
    kMenuActivate = 100,
    kMenuRename,
    kMenuDelete,
    kMenuEvacuate,
    kMenuCreate,
    kMenuRemove,            // remove selected sample(s) from the source bank (B5)
    kMenuMoveBase = 1000,   // move-to-bank: kMenuMoveBase + destination index
    kMenuCopyBase = 2000,   // copy-to-bank: kMenuCopyBase + destination index
};

// Shows the right-click context menu for a named-bank TAB: activate / rename / delete
// / evacuate that bank, plus a create entry. Drives the id-keyed ops.
void showTabMenu(int screenX, int screenY, const std::string& bankId) {
    if (!book()) return;
    const Bank* bk = book()->bank(bankId);
    if (!bk || bk->isPool()) return;
    const bool isActive = book()->activeBankId() == bankId;
    const bool nonEmpty = !bk->index.empty();

    HMENU menu = CreatePopupMenu();
    menuAppend(menu, kMenuActivate,
               isActive ? "Active (capture target)" : "Activate (make capture target)",
               /*grayed=*/isActive);
    menuSeparator(menu);
    menuAppend(menu, kMenuRename, "Rename...");
    menuAppend(menu, kMenuEvacuate, "Evacuate to pool", /*grayed=*/!nonEmpty);
    menuAppend(menu, kMenuDelete, "Delete...");
    menuSeparator(menu);
    menuAppend(menu, kMenuCreate, "New bank...");

    const int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, screenX, screenY, 0,
                                   g_panel.hwnd, nullptr);
    DestroyMenu(menu);

    switch (cmd) {
        case kMenuActivate: doActivateBank(bankId); break;
        case kMenuRename:   doRenameBank(bankId); break;
        case kMenuEvacuate: doEvacuateBank(bankId); break;
        case kMenuDelete:   doDeleteBank(bankId); break;
        case kMenuCreate:   doCreateBank(); break;
        default: break;
    }
}

// Opens the top-toolbar overflow ("⋯" More) popup at the button's screen position and fires the
// chosen rare-capture variant's command (L5 refinement 1). Menu ids are LOCAL to the popup
// (1-based ordinal into overflowMenuRows); TPM_RETURNCMD hands the chosen id back, then we
// resolve + fire the corresponding registered command id via the SAME contract the visible
// buttons use. Defined here (after menuAppend/menuSeparator); forward-declared above.
void showMoreMenu() {
    if (!g_panel.hwnd) return;
    const std::vector<ActionBarRow> rows = overflowMenuRows();
    if (rows.empty()) return;

    RECT cr{};
    GetClientRect(g_panel.hwnd, &cr);
    const MenuButtonRect mb = topMenuButtonRect(cr.right - cr.left);
    if (mb.empty()) return;

    HMENU menu = CreatePopupMenu();
    for (std::size_t i = 0; i < rows.size(); ++i) {
        const int cmd = resolveBarCommandId(rows[i]);
        // Grey a variant not registered on this channel (defensive — all three are registered).
        menuAppend(menu, static_cast<unsigned int>(i + 1), rows[i].fullName.c_str(),
                   /*grayed=*/cmd == 0);
    }

    // Anchor the popup at the button's bottom-left, in screen coords.
    POINT pt{mb.x, mb.y + mb.height};
    ClientToScreen(g_panel.hwnd, &pt);
    const int chosen = TrackPopupMenu(menu, TPM_RETURNCMD, pt.x, pt.y, 0, g_panel.hwnd, nullptr);
    DestroyMenu(menu);

    if (chosen >= 1 && chosen <= static_cast<int>(rows.size())) {
        const int cmd = resolveBarCommandId(rows[static_cast<std::size_t>(chosen - 1)]);
        if (cmd != 0 && Main_OnCommand) Main_OnCommand(cmd, 0);
    }
}

// Shows the move/copy menu for the current selection (the SOURCE is the focused
// region's bank). Lists every OTHER bank (pool + named) as a move destination, then a
// copy submenu-free flat list (copy entries follow the move block). Move is the
// default (listed first); copy is the deliberate secondary act.
void showSelectionMenu(int screenX, int screenY) {
    const std::vector<std::string> sel = focusedSelectionIds();
    if (sel.empty()) return;
    const std::string srcId = bankIdForRegion(g_panel.focusedRegion);

    // Destinations: pool + named banks, excluding the source. Ordinal order.
    struct Dest { std::string id; std::string name; };
    std::vector<Dest> dests;
    if (srcId != std::string(kPoolBankId))
        dests.push_back({std::string(kPoolBankId), std::string(kPoolBankName)});
    for (const Bank* bk : namedBanks())
        if (bk->id != srcId) dests.push_back({bk->id, bk->displayName});

    const std::string label = std::to_string(sel.size()) +
                              (sel.size() == 1 ? " sample" : " samples");

    HMENU menu = CreatePopupMenu();
    // Move/copy blocks appear only when there is another bank to transfer to; Remove is
    // always offered (it needs no destination — it drops the entry from the source).
    if (!dests.empty()) {
        menuAppend(menu, kMenuNone, ("Move " + label + " to:").c_str(), /*grayed=*/true);
        for (std::size_t i = 0; i < dests.size(); ++i)
            menuAppend(menu, kMenuMoveBase + static_cast<unsigned int>(i),
                       ("  " + dests[i].name).c_str());
        menuSeparator(menu);
        menuAppend(menu, kMenuNone, ("Copy " + label + " to:").c_str(), /*grayed=*/true);
        for (std::size_t i = 0; i < dests.size(); ++i)
            menuAppend(menu, kMenuCopyBase + static_cast<unsigned int>(i),
                       ("  " + dests[i].name).c_str());
        menuSeparator(menu);
    }
    menuAppend(menu, kMenuRemove, ("Remove " + label + "...").c_str());

    const int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, screenX, screenY, 0,
                                   g_panel.hwnd, nullptr);
    DestroyMenu(menu);
    if (cmd == static_cast<int>(kMenuRemove)) {
        removeSamples(sel, srcId);
    } else if (cmd >= static_cast<int>(kMenuMoveBase) &&
        cmd < static_cast<int>(kMenuMoveBase + dests.size())) {
        transferSamples(sel, srcId, dests[cmd - kMenuMoveBase].id, /*copy=*/false);
    } else if (cmd >= static_cast<int>(kMenuCopyBase) &&
               cmd < static_cast<int>(kMenuCopyBase + dests.size())) {
        transferSamples(sel, srcId, dests[cmd - kMenuCopyBase].id, /*copy=*/true);
    }
}

// --- Click routing ------------------------------------------------------------

// Handles a header/tab-strip/button click for the banks region. Returns true if the
// click was consumed (a region-chrome hit), false to fall through to grid selection.
bool handleBanksChromeClick(int x, int y, const RECT& region) {
    // Full-height toggle button.
    const RECT ftb = fullHtBtnRect(region);
    if (x >= ftb.left && x < ftb.right && y >= ftb.top && y < ftb.bottom) {
        bankPanelToggledBanksFullHeight();
        return true;
    }
    // "+" create button.
    const RECT cb = createBtnRect(region);
    if (x >= cb.left && x < cb.right && y >= cb.top && y < cb.bottom) {
        doCreateBank();
        return true;
    }
    // Tab strip: chevrons scroll, a tab click SHOWS that bank (browse — NOT activate).
    const TabStripRect strip = banksTabStripRect(region);
    const std::vector<const Bank*> tabs = namedBanks();
    const int n = static_cast<int>(tabs.size());
    const TabHit hit = hitTestTabStrip(x, y, strip, n, kTabSpec, g_panel.tabScroll);
    if (hit.kind == TabHitKind::ScrollLeft || hit.kind == TabHitKind::ScrollRight) {
        const TabStripLayout layout =
            computeTabStripLayout(strip, n, kTabSpec, g_panel.tabScroll);
        const int step = kTabSpec.tabWidth;
        const int desired = g_panel.tabScroll +
                            (hit.kind == TabHitKind::ScrollLeft ? -step : step);
        g_panel.tabScroll = clampTabScroll(desired, layout);
        invalidatePanel();
        return true;
    }
    if (hit.kind == TabHitKind::Tab) {
        const Bank* bk = tabs[static_cast<std::size_t>(hit.index)];
        if (bk->id != g_panel.shownBankId) {
            g_panel.shownBankId = bk->id;          // browse: show this bank's grid
            g_panel.selection = Selection{};        // grid changed — reset selection
            stopAudition();
        }
        g_panel.focusedRegion = Region::Banks;
        invalidatePanel();
        return true;
    }
    return false;
}

// Handles the pool region's full-height toggle. Returns true if consumed.
bool handlePoolChromeClick(int x, int y, const RECT& region) {
    const RECT ftb = fullHtBtnRect(region);
    if (x >= ftb.left && x < ftb.right && y >= ftb.top && y < ftb.bottom) {
        bankPanelToggledPoolFullHeight();
        return true;
    }
    return false;
}

// The footer mode-toggle segment (Arrange|Design) under (x, y), or -1. Segments are tiled by
// mode_switch inside footer_bar's toggle box, so both draw and hit-test use the same box.
int footerToggleSegmentHit(int x, int y, int w, int h) {
    if (!g_panel.session) return -1;
    const FooterBarLayout fb = footerBarLayoutFor(w, h);
    if (fb.toggle.empty()) return -1;
    const HeaderRect th{fb.toggle.x, fb.toggle.y, fb.toggle.width, fb.toggle.height};
    return hitTestSegment(x, y, th, modeCount());
}

// Applies a left-click at (x, y): route to top toolbar / footer (toggle / Tail / Prune) /
// bottom toolbar / region chrome / grid selection, and arm a potential drag when the click
// lands on a selected cell. L4 order mirrors the three-zone layout top-to-bottom.
void handleClick(int x, int y) {
    RECT cr{};
    GetClientRect(g_panel.hwnd, &cr);
    const int w = cr.right - cr.left, h = cr.bottom - cr.top;

    // TOP toolbar: the far-right More button first (its rect sits in the band's reserved right
    // strip, outside the action rect), then the frequent capture/placement buttons. A button
    // fires its registered action via the command-id contract; the band is claimed whole (a
    // gap/overflow miss is a harmless no-op, never a fall-through). Capture never auto-inserts.
    {
        const MenuButtonRect mb = topMenuButtonRect(w);
        if (hitTestMenuButton(x, y, mb)) { showMoreMenu(); return; }
    }
    if (handleToolbarClick(x, y, topToolbarActionRect(w), topBarRows())) return;
    // Claim the WHOLE top band (including the reserved right strip between the last button and
    // the More button) so a click there is inert chrome, never a fall-through to the grid.
    if (y >= 0 && y < kTopToolbarHeight && x >= 0 && x < w) return;

    // Footer: mode toggle (left) -> Tail button -> Prune (right). The narrow [Arrange|Design]
    // toggle activates that mode; the Tail button cycles the tail setting (L4 §4 — was a
    // click-zone); Prune fires the guarded prune command. Checked before the bottom toolbar /
    // grid so a footer click never selects a cell.
    {
        const int seg = footerToggleSegmentHit(x, y, w, h);
        if (seg >= 0) {
            const std::vector<Mode>& modes = g_panel.session->view().modes().all();
            if (seg < static_cast<int>(modes.size())) {
                applyMode(g_panel.session->view(),
                          modes[static_cast<std::size_t>(seg)].id, nullptr);
                invalidatePanel();
            }
            return;
        }

        const FooterBarLayout fb = footerBarLayoutFor(w, h);
        if (g_panel.session && hitTestFooterBar(x, y, fb) == FooterHit::Tail) {
            // Tail button click cycles the tail mode (None -> Auto -> Manual -> None). Mutates
            // the SESSION's tail setting (capture reads it; persist saves it with the project)
            // and marks the project dirty — touches NOTHING in the bank/arrange.
            TailSetting& tail = g_panel.session->tail();
            tail.mode = cycleTailMode(tail.mode);
            markTailDirty();
            invalidatePanel();
            return;
        }

        // Prune button (R3): fires the "Prune bank folder" action THROUGH its registered
        // command id (fork R-E: dispatch the command, not the session directly) so the panel
        // affordance and the bindable action share the one guarded dry-run/confirm/delete path
        // in doBankPruneFolder. A 0 id (pre-registration) no-ops.
        const ButtonRect pb = pruneButtonRectFor(w, h);
        if (hitTestPruneButton(x, y, pb)) {
            const int cmd = bankPruneCommandId();
            if (cmd != 0) Main_OnCommand(cmd, 0);
            return;
        }
    }

    // BOTTOM toolbar (Design-View verbs): a button fires its registered action via the
    // command-id contract. Claimed whole like the top toolbar.
    if (handleToolbarClick(x, y, bottomToolbarRect(w, h), bottomBarRows())) return;

    // Region chrome (headers, tab strip, buttons).
    if (poolShown()) {
        const RECT pr = poolRegionRect(w, h);
        if (y >= pr.top && y < regionGridRect(pr, false).top) {
            if (handlePoolChromeClick(x, y, pr)) return;
        }
    }
    if (banksShown()) {
        const RECT br = banksRegionRect(w, h);
        if (y >= br.top && y < regionGridRect(br, true).top) {
            if (handleBanksChromeClick(x, y, br)) return;
        }
    }

    // Grid selection. Resolve which region's grid the point is in.
    Region reg = Region::Pool;
    if (!regionAt(x, y, reg)) return;
    const bool isBanks = reg == Region::Banks;
    const RECT region = isBanks ? banksRegionRect(w, h) : poolRegionRect(w, h);
    // L7: hit-test the SPARSE slot layout, then map the slot to a selection ordinal. An
    // empty (gap) slot hits selectionForSlot == -1, which reads as a grid miss below (a
    // click on a gap clears selection, exactly like a click in the margin) — empty slots
    // are decorative, not selectable.
    const RegionDisplay disp = regionDisplay(region, isBanks, reg);
    const int hitSlot = hitTestSlot(x, y, disp.slotRects);
    const int hit = hitSlot < 0 ? -1 : disp.selectionForSlot(hitSlot);
    const int count = disp.occupiedCount();

    // Switching focus region reseeds the selection there.
    if (g_panel.focusedRegion != reg) {
        g_panel.focusedRegion = reg;
        g_panel.selection = Selection{};
        stopAudition();
    }

    if (hit < 0) {
        if (!g_panel.selection.empty() || g_panel.selection.focus >= 0) {
            g_panel.selection = Selection{};
            stopAudition();
        }
        invalidatePanel();
        return;
    }

    // Drag-arm disambiguation for plain (no ctrl, no shift) presses on a grid cell:
    //
    // • Already-selected cell: defer the selection change to LBUTTONUP so a plain
    //   press on a multi-selection doesn't collapse it before we know whether a drag
    //   will happen. Arm the drag with the current (multi-)selection as the payload
    //   candidate; only the caret moves immediately.
    //
    // • Unselected cell: apply the plain-click selection immediately (collapses to
    //   the single pressed cell) THEN arm a drag from it — so the user can press-and-
    //   drag in one gesture without a prior selecting click. The selection is set
    //   before arming so that focusedSelectionIds() resolves the right payload when
    //   the threshold is crossed in onMouseMove.
    //
    // ctrl / shift presses are selection-only gestures — no drag arm in either case.
    const bool onSelected = g_panel.selection.contains(hit);
    if (!ctrlDown() && !shiftDown()) {
        if (!onSelected) {
            // Commit the single-cell selection now so the drag payload is correct.
            g_panel.selection = applyClick(g_panel.selection, hit, false, false, count);
            g_panel.selItemCount = count;
        } else {
            // Move the caret to the pressed cell; defer collapsing multi-selection.
            g_panel.selection.focus = hit;
        }
        g_panel.dragArmed = true;
        g_panel.dragStartX = x;
        g_panel.dragStartY = y;
        g_panel.dragSourceRegion = reg;
        invalidatePanel();
        return;
    }

    g_panel.selection = applyClick(g_panel.selection, hit, ctrlDown(), shiftDown(), count);
    g_panel.selItemCount = count;
    invalidatePanel();
}

// Handles a scroll-wheel notch over client (x, y) with signed wheel delta `delta`.
// Fine-adjusts the Manual tail length in kManualStepMs steps ONLY when the cursor is
// over the footer strip AND the mode is Manual — wheel up lengthens, down shortens,
// clamped to [0, kMaxTailMs]. In Off/Auto (or off the footer) it does nothing (returns
// false so the caller can let REAPER/the docker handle the wheel normally). On a real
// change it mutates the SESSION's tail setting, marks the project dirty (so it saves),
// and repaints the live length. Returns true iff the wheel was consumed.
bool handleWheel(int x, int y, int delta) {
    if (!g_panel.session) return false;
    if (!pointInFooter(x, y)) return false;

    TailSetting& tail = g_panel.session->tail();
    if (tail.mode != TailMode::Manual) return false;  // fine-adjust is Manual-only

    // One notch is WHEEL_DELTA (120); accumulate whole notches so a high-res trackpad
    // that sends fractional deltas still steps predictably. Sign carries direction.
    const int notches = delta / 120;
    if (notches == 0) return false;  // sub-notch movement — nothing to apply yet

    const double before = tail.manualMs;
    tail.manualMs = adjustManualMs(tail.manualMs, notches, kManualStepMs);
    if (tail.manualMs == before) return true;  // already at a bound — consumed, no change

    markTailDirty();
    invalidatePanel();  // label shows the new length live
    return true;
}

// The column count for a region's current grid width (nav needs the layout's wrap).
int columnsForRegion(Region reg) {
    RECT cr{};
    GetClientRect(g_panel.hwnd, &cr);
    const int w = cr.right - cr.left, h = cr.bottom - cr.top;
    const RECT region = reg == Region::Banks ? banksRegionRect(w, h)
                                             : poolRegionRect(w, h);
    const RECT grid = regionGridRect(region, reg == Region::Banks);
    return columnsForWidth(grid.right - grid.left, kGrid);
}

bool isOurWindow(HWND hwnd) {
    for (HWND w = hwnd; w; w = GetParent(w))
        if (w == g_panel.hwnd) return true;
    return false;
}

bool handleKey(int vk) {
    const int count = focusedItemCount();
    if (count <= 0) return false;

    switch (vk) {
        case VK_LEFT:
        case VK_RIGHT:
        case VK_UP:
        case VK_DOWN: {
            const NavKey nk = vk == VK_LEFT    ? NavKey::Left
                              : vk == VK_RIGHT ? NavKey::Right
                              : vk == VK_UP    ? NavKey::Up
                                               : NavKey::Down;
            g_panel.selection = navigate(g_panel.selection, nk,
                                         columnsForRegion(g_panel.focusedRegion),
                                         count, shiftDown());
            g_panel.selItemCount = count;
            invalidatePanel();
            return true;
        }
        case VK_RETURN:
        case VK_SPACE:
            if (g_panel.selection.focus >= 0)
                startAudition(g_panel.selection.focus);
            return true;
        case VK_ESCAPE:
            stopAudition();
            return true;
        case VK_DELETE: {
            // Remove the focused-region selection (B5). Silent; a no-op when nothing
            // is selected.
            const std::vector<std::string> sel = focusedSelectionIds();
            if (sel.empty()) return false;  // nothing selected — let the key fall through
            removeSamples(sel, bankIdForRegion(g_panel.focusedRegion));
            return true;
        }
        default:
            return false;
    }
}

int translateAccel(MSG* msg, accelerator_register_t* /*ctx*/) {
    if (!msg || msg->message != WM_KEYDOWN) return 0;
    if (!g_panel.open || !g_panel.hwnd) return 0;
    if (!isOurWindow(GetFocus())) return 0;
    return handleKey(static_cast<int>(msg->wParam)) ? 1 : 0;
}

accelerator_register_t g_accel{translateAccel, true, nullptr};
bool g_accelRegistered = false;

void registerAccel() {
    if (g_accelRegistered || !g_rec) return;
    g_rec->Register("accelerator", &g_accel);
    g_accelRegistered = true;
}

void unregisterAccel() {
    if (!g_accelRegistered || !g_rec) return;
    g_rec->Register("-accelerator", &g_accel);
    g_accelRegistered = false;
}

// --- Drag (move between regions/onto a tab) -----------------------------------

constexpr int kDragThreshold = 5;  // px the pointer must move to begin a drag

// Resolves the drop target under client (x, y) during a drag, updating dropKind /
// dropBankId. A drop onto the pool region -> the pool; onto a named tab -> that bank;
// anywhere else -> none.
void updateDropTarget(int x, int y) {
    RECT cr{};
    GetClientRect(g_panel.hwnd, &cr);
    const int w = cr.right - cr.left, h = cr.bottom - cr.top;

    g_panel.dropKind = DropKind::None;
    g_panel.dropBankId.clear();

    if (banksShown()) {
        const RECT br = banksRegionRect(w, h);
        const TabStripRect strip = banksTabStripRect(br);
        const std::vector<const Bank*> tabs = namedBanks();
        const TabHit hit = hitTestTabStrip(x, y, strip,
                                           static_cast<int>(tabs.size()), kTabSpec,
                                           g_panel.tabScroll);
        if (hit.kind == TabHitKind::Tab) {
            g_panel.dropKind = DropKind::Tab;
            g_panel.dropBankId = tabs[static_cast<std::size_t>(hit.index)]->id;
            return;
        }
        // Tab takes precedence over the region; if the point is in the banks region but
        // not on a specific tab, treat the whole grid as a drop zone for the shown bank.
        // No valid target when there are no named banks or no shown bank.
        if (!g_panel.shownBankId.empty() && book() && book()->bank(g_panel.shownBankId)) {
            if (x >= br.left && x < br.right && y >= br.top && y < br.bottom) {
                g_panel.dropKind = DropKind::BanksRegion;
                g_panel.dropBankId = g_panel.shownBankId;
                return;
            }
        }
    }
    if (poolShown()) {
        const RECT pr = poolRegionRect(w, h);
        const RECT grid = regionGridRect(pr, false);
        if (x >= grid.left && x < grid.right && y >= grid.top && y < grid.bottom) {
            g_panel.dropKind = DropKind::PoolRegion;
            return;
        }
    }
}

// The destination bank id under the current drop target (pool id for PoolRegion; the tab/
// shown-bank id for Tab/BanksRegion; "" for no target). Derived from updateDropTarget's
// dropKind/dropBankId — the single source of "what bank is under the pointer".
std::string dropTargetBankId() {
    switch (g_panel.dropKind) {
        case DropKind::PoolRegion: return std::string(kPoolBankId);
        case DropKind::Tab:
        case DropKind::BanksRegion: return g_panel.dropBankId;
        case DropKind::None: return {};
    }
    return {};
}

// L7: classifies the live in-grid drag into a pure CardGesture and (for a same-bank drop)
// the target slot, updating g_panel.cardGesture / dragTargetSlot. Call AFTER updateDropTarget
// so dropKind/dropBankId are current. The pure card_drag::decideCardGesture owns the
// precedence (leave-client -> OS; other-bank -> move/copy; same-bank grid -> reorder/replace);
// the shell only supplies the region verdict, the same-bank target slot + occupancy, and the
// live modifier state. The OS-drag-out boundary is handled by the existing decideGesture path
// in onMouseMove BEFORE this runs, so here the pointer is always inside the client.
void classifyCardDrag(int x, int y) {
    g_panel.cardGesture = CardGesture::None;
    g_panel.dragTargetSlot = -1;

    RECT cr{};
    GetClientRect(g_panel.hwnd, &cr);
    const int w = cr.right - cr.left, h = cr.bottom - cr.top;
    const PanelClientRect client{cr.left, cr.top, w, h};

    const std::string destBank = dropTargetBankId();
    DragModifiers mods;
    mods.ctrl = ctrlDown();
    mods.alt  = altDown();

    if (!destBank.empty() && destBank == g_panel.dragSourceBankId) {
        // Same-bank grid: a reorder/replace target. Resolve the slot the pointer sits over
        // in the SOURCE bank's own region display + whether it is occupied.
        // Uses computeSlotRectsForDrop (one trailing row past maxSlot) so a drop beyond
        // the last occupied card resolves to a valid trailing slot, not a -1 miss.
        mods.region = DropRegion::SameBankGrid;
        const bool isBanks = g_panel.dragSourceRegion == Region::Banks;
        const RECT region = isBanks ? banksRegionRect(w, h) : poolRegionRect(w, h);
        const RegionDisplay disp = regionDisplay(region, isBanks, g_panel.dragSourceRegion);
        const RECT grid = regionGridRect(region, isBanks);
        const int gridW = grid.right - grid.left;
        const std::vector<SlotCellRect> dropRects =
            computeSlotRectsForDrop(disp.bank ? disp.bank->slots.maxSlot() : -1,
                                    gridW, kGrid);
        // Translate the drop rects to client space (matching regionDisplay's translation).
        std::vector<SlotCellRect> dropRectsClient = dropRects;
        for (SlotCellRect& r : dropRectsClient) { r.x += grid.left; r.y += grid.top; }
        const int slot = hitTestSlot(x, y, dropRectsClient);
        mods.targetSlot   = slot;
        mods.slotOccupied = slot >= 0 && !disp.idAtSlot(slot).empty();
        g_panel.dragTargetSlot = slot;
    } else if (!destBank.empty()) {
        mods.region = DropRegion::OtherBankOrTab;  // move/copy to a different bank/tab
    } else {
        mods.region = DropRegion::DeadSpace;  // header/footer/gap — a no-op drop
    }

    const DragState st{/*dragging=*/true, /*hasArmedSamples=*/!g_panel.dragSampleIds.empty()};
    g_panel.cardGesture = decideCardGesture(x, y, client, st, mods);
}

// Maps the pure L7 cursor cue to a SWELL stock cursor and sets it. The cue DECISION is pure
// (card_drag::cursorForGesture); the shell owns only this SetCursor call + the resource choice.
// Stock SWELL cursors (vendor/WDL/WDL/swell/swell-types.h:1320-1329, mirroring the Win32 OCR_*
// set): Reorder -> IDC_SIZEALL (four-way move, the file-manager reorder idiom); Move ->
// IDC_HAND (grab-and-place to another bank/tab); Copy -> IDC_UPARROW (no stock copy cursor
// exists cross-platform — this is the closest distinct stock cue; a bespoke copy cursor would
// need a resource file, deliberately NOT added); Replace -> IDC_SIZEWE (a distinct "swap
// occupant" cue, shown ONLY when the pure result is Replace, i.e. Alt over an occupied slot);
// OsDragOut -> the OS drag loop owns the cursor once handed off, so leave it (arrow here is
// never seen — the handoff happens before this runs); Default/None -> IDC_ARROW.
void applyDragCursor(CardGesture g) {
    const char* idc = IDC_ARROW;
    switch (cursorForGesture(g)) {
        case CursorCue::Reorder:   idc = IDC_SIZEALL; break;
        case CursorCue::Move:      idc = IDC_HAND;    break;
        case CursorCue::Copy:      idc = IDC_UPARROW; break;
        case CursorCue::Replace:   idc = IDC_SIZEWE;  break;
        case CursorCue::OsDragOut: return;  // OS drag owns the cursor; do not fight it
        case CursorCue::Default:   idc = IDC_ARROW;   break;
    }
    SetCursor(LoadCursor(nullptr, idc));
}

// Resolves the topmost INTERACTIVE element under client (x, y) for hover feedback, mirroring
// handleClick's precedence exactly (so the element that lights on hover is the one a click
// would hit). Returns HoverKind::None for the grid / dead space / a point outside the client
// (the grid cells carry their own selection/focus chrome, not a kit hover surface). Pure
// resolution over the same pure geometry the click path uses.
Hover resolveHover(int x, int y) {
    if (!g_panel.hwnd) return Hover{};
    RECT cr{};
    GetClientRect(g_panel.hwnd, &cr);
    const int w = cr.right - cr.left, h = cr.bottom - cr.top;

    // TOP toolbar: the far-right More button, then the frequent buttons (matching the click
    // order — first zone top-to-bottom).
    {
        const MenuButtonRect mb = topMenuButtonRect(w);
        if (hitTestMenuButton(x, y, mb)) return Hover{HoverKind::MoreButton, -1};
        const int hit = toolbarHit(x, y, topToolbarActionRect(w), topBarRows());
        if (hit >= 0) return Hover{HoverKind::TopBarButton, hit};
    }
    // Footer: mode-toggle segments, Tail button, then Prune (matching the click order).
    {
        const int seg = footerToggleSegmentHit(x, y, w, h);
        if (seg >= 0) return Hover{HoverKind::ModeSegment, seg};
        const FooterBarLayout fb = footerBarLayoutFor(w, h);
        if (hitTestFooterBar(x, y, fb) == FooterHit::Tail) return Hover{HoverKind::TailButton, -1};
        const ButtonRect pb = pruneButtonRectFor(w, h);
        if (hitTestPruneButton(x, y, pb)) return Hover{HoverKind::PruneButton, -1};
    }
    // BOTTOM toolbar buttons.
    {
        const int hit = toolbarHit(x, y, bottomToolbarRect(w, h), bottomBarRows());
        if (hit >= 0) return Hover{HoverKind::BottomBarButton, hit};
    }
    // Region chrome: full-height toggles, create button, tabs.
    if (poolShown()) {
        const RECT pr = poolRegionRect(w, h);
        const RECT ftb = fullHtBtnRect(pr);
        if (x >= ftb.left && x < ftb.right && y >= ftb.top && y < ftb.bottom)
            return Hover{HoverKind::FullHtPool, -1};
    }
    if (banksShown()) {
        const RECT br = banksRegionRect(w, h);
        const RECT ftb = fullHtBtnRect(br);
        if (x >= ftb.left && x < ftb.right && y >= ftb.top && y < ftb.bottom)
            return Hover{HoverKind::FullHtBanks, -1};
        const RECT cb = createBtnRect(br);
        if (x >= cb.left && x < cb.right && y >= cb.top && y < cb.bottom)
            return Hover{HoverKind::CreateBank, -1};
        const TabStripRect strip = banksTabStripRect(br);
        const std::vector<const Bank*> tabs = namedBanks();
        const TabHit hit = hitTestTabStrip(x, y, strip, static_cast<int>(tabs.size()),
                                           kTabSpec, g_panel.tabScroll);
        if (hit.kind == TabHitKind::Tab) return Hover{HoverKind::Tab, hit.index};
    }
    return Hover{};
}

// Updates the live hover element and repaints ONLY on a change (sub-frame feedback, no
// per-move jank — the "speed is the selling point" repaint discipline). L5: a hover CHANGE also
// resets the tooltip timer (hoverSinceTick) and hides any shown tooltip, so the tooltip only
// appears after the pointer rests kTooltipDelayMs on ONE element (the delay is applied by the
// poll tick in maybeShowTooltip). A move within the SAME element leaves the timer running.
void updateHover(int x, int y) {
    const Hover next = resolveHover(x, y);
    if (next != g_panel.hovered) {
        g_panel.hovered = next;
        g_panel.hoverSinceTick = GetTickCount();
        if (g_panel.tooltipShown) { g_panel.tooltipShown = false; }
        invalidatePanel();
    }
}

// Applies the tooltip hover-delay: if a tooltip-bearing element has been hovered past
// kTooltipDelayMs and the tooltip is not yet shown, latch it and repaint once. Driven from the
// OnTimer poll (bankPanelRefresh) so the tooltip appears after a rest with no dedicated timer;
// WM_MOUSEMOVE's updateHover resets the timer, so a moving pointer never trips it. No-op when the
// current hover has no tooltip (grid / chrome / the More button).
void maybeShowTooltip() {
    if (g_panel.tooltipShown) return;
    const HoverKind k = g_panel.hovered.kind;
    if (k != HoverKind::TopBarButton && k != HoverKind::BottomBarButton) return;
    const unsigned int now = GetTickCount();
    if (now - g_panel.hoverSinceTick >= kTooltipDelayMs) {
        g_panel.tooltipShown = true;
        invalidatePanel();
    }
}

void onMouseMove(int x, int y) {
    // Hover feedback (L2): resolve + repaint-on-change, but NOT during a drag (the drag owns
    // the visual feedback then — a drop-target highlight, not a hover). Cleared to None when
    // the pointer is over the grid / dead space.
    if (!g_panel.dragging && !g_panel.dragArmed) updateHover(x, y);

    if (g_panel.dragArmed && !g_panel.dragging) {
        if (std::abs(x - g_panel.dragStartX) > kDragThreshold ||
            std::abs(y - g_panel.dragStartY) > kDragThreshold) {
            // Threshold crossed — begin the drag. Snapshot the payload NOW.
            g_panel.dragging = true;
            g_panel.dragSourceBankId = bankIdForRegion(g_panel.dragSourceRegion);
            g_panel.dragSampleIds = focusedSelectionIds();
            // The single card actually grabbed = the focus ordinal's id. This is the L7
            // in-grid reorder/replace subject (see onLBtnUp) — "drag a card" is a single-card
            // gesture, distinct from the multi-select move/copy payload in dragSampleIds.
            {
                const RegionDisplay disp = focusedDisplay();
                const int f = g_panel.selection.focus;
                g_panel.dragPrimaryId =
                    (f >= 0 && f < disp.occupiedCount())
                        ? disp.orderedIds[static_cast<std::size_t>(f)] : std::string{};
            }
            g_panel.hovered = Hover{};  // clear hover — the drag owns the visual feedback now
            g_panel.tooltipShown = false;  // a drag never shows a tooltip
            SetCapture(g_panel.hwnd);
        }
    }
    if (g_panel.dragging) {
        // M11 gesture boundary, REFINED by S17. While a drag with samples is under way and the
        // pointer is INSIDE the client rect it stays the internal bank-to-bank drag (invariant
        // #4, byte-identical). Once it LEAVES the client rect the pure drag_out::decideGesture
        // splits the outside case three ways: a single-capture drag over REAPER's OWN UI is an
        // InstrumentDrop (hover-track the FX button, drop on release); a multi-capture drag OR a
        // pointer that has left REAPER entirely is the unchanged M11 OsDrag; inside stays
        // Internal. The shell supplies the "over REAPER's UI" predicate via GetThingFromPoint.
        RECT cr{};
        GetClientRect(g_panel.hwnd, &cr);
        const PanelClientRect client{cr.left, cr.top, cr.right - cr.left, cr.bottom - cr.top};
        const bool inside = (x >= cr.left && x < cr.right && y >= cr.top && y < cr.bottom);

        DragState st{/*dragging=*/true, /*hasArmedSamples=*/!g_panel.dragSampleIds.empty()};
        st.singleCapture = (g_panel.dragSampleIds.size() == 1);

        // Resolve the FX drop target only when OUTSIDE the client rect (the S17 middle case can
        // only arise there) and only for a single-capture payload — the SDK hit-test is skipped
        // on the common internal-drag path so it costs nothing there. The screen conversion is
        // Windows-only (D5); resolveFxDropTarget owns the REAPER hit query.
        FxDropTarget fx;
        if (!inside && st.singleCapture) {
            POINT sp{x, y};
            ClientToScreen(g_panel.hwnd, &sp);
            fx = resolveFxDropTarget(sp.x, sp.y);
            st.overReaperUi = fx.overReaperUi;
        }

        const DragGesture gesture = decideGesture(x, y, client, st);

        if (gesture == DragGesture::InstrumentDrop) {
            // Track the FX hotspot for the release; the highlight is REAPER's own FX-button
            // hover feedback under the pointer (the drop is driven on button-up). We keep the
            // internal-drag capture alive so we keep receiving moves (unlike OsDrag, this does
            // NOT hand off to a modal OS loop). Clear any internal drop-target highlight so the
            // panel does not also paint a bank-drop cue while the drag is out over a track.
            g_panel.instrumentDropTrack = fx.valid() ? fx.track : nullptr;
            g_panel.dropKind = DropKind::None;
            g_panel.dropBankId.clear();
            invalidatePanel();
            return;
        }

        // Left InstrumentDrop territory (back inside, or over a non-FX area): drop the FX target.
        g_panel.instrumentDropTrack = nullptr;

        if (gesture == DragGesture::OsDrag) {
            // Resolve the payload to existing on-disk paths BEFORE tearing down internal
            // drag state (the resolver reads dragSourceBankId / dragSampleIds).
            const std::vector<std::string> paths = resolveDragPathsForOs();

            // Reset internal drag state and release capture NOW: DoDragDrop runs its own
            // modal loop and takes over mouse capture, so the internal drag must be fully
            // wound down first (no stale dragging/dropKind, no lingering SetCapture). A
            // cancelled/empty OS drag therefore leaves the panel in a clean, no-op state
            // (invariant #2 — nothing mutated).
            if (GetCapture() == g_panel.hwnd) ReleaseCapture();
            g_panel.dragArmed = false;
            g_panel.dragging = false;
            g_panel.dropKind = DropKind::None;
            g_panel.dropBankId.clear();
            g_panel.cardGesture = CardGesture::None;
            g_panel.dragTargetSlot = -1;
            g_panel.dragPrimaryId.clear();
            invalidatePanel();

            // Empty path list -> nothing draggable (all stale/missing); do not start a drag.
            if (!paths.empty())
                initiateDragOut(g_panel.hwnd, paths);  // COPY-ONLY; blocking on Windows
            return;
        }
        // Inside the client: classify the in-grid gesture (L7 reorder/replace vs the existing
        // move/copy) and reflect it as a cursor cue. updateDropTarget first so dropKind/
        // dropBankId are current for classifyCardDrag's same-vs-other-bank decision.
        updateDropTarget(x, y);
        classifyCardDrag(x, y);
        applyDragCursor(g_panel.cardGesture);
        invalidatePanel();
    }
}

// L7 in-grid REORDER drop: move the grabbed card to targetSlot within its bank (gap-
// preserving; onto a gap = place there, onto an occupant = insert-before-and-shift — the pure
// BankBook::reorderSample owns the semantics). One drop = one Ctrl-Z (persistBankOp opens the
// batched undo point + saves). A no-op reorder (already at the target, model returns false)
// opens no undo point. Selection reasons over slot order, so it is cleared after — the
// fingerprint pass rebuilds it against the new order.
void doReorderDrop(const std::string& id, const std::string& bankId, int targetSlot) {
    if (!book() || id.empty() || bankId.empty() || targetSlot < 0) return;
    if (!book()->reorderSample(id, bankId, targetSlot)) return;  // rejected/no-op: no undo point
    persistBankOp("ReaSampler: reorder sample");
    g_panel.selection = Selection{};
    invalidatePanel();
}

// L7 Alt-REPLACE drop: the grabbed card `newId` takes the occupant `oldId`'s slot; `oldId` is
// removed from the bank's index (index-only, file untouched — pool guard enforced in the pure
// BankBook::replaceSample). Rejected (pool guard / absent) = a true NO-OP: no fallback insert,
// no undo point (per spec). One drop = one Ctrl-Z on success.
void doReplaceDrop(const std::string& newId, const std::string& oldId,
                   const std::string& bankId) {
    if (!book() || newId.empty() || oldId.empty() || bankId.empty()) return;
    if (!book()->replaceSample(newId, oldId, bankId)) return;  // pool-guard reject: NO-OP
    persistBankOp("ReaSampler: replace sample");
    g_panel.selection = Selection{};
    invalidatePanel();
}

// Clears all drag-state fields to their resting values. Called from every exit path
// (button-up, WM_CAPTURECHANGED, WM_DESTROY, closePanel) so the set of cleared fields
// stays consistent across all four sites.
void resetDragState() {
    g_panel.dragArmed = false;
    g_panel.dragging = false;
    g_panel.dropKind = DropKind::None;
    g_panel.dropBankId.clear();
    g_panel.cardGesture = CardGesture::None;
    g_panel.dragTargetSlot = -1;
    g_panel.dragPrimaryId.clear();
    g_panel.instrumentDropTrack = nullptr;
}

// Commits (or abandons) a drag on button-up. The resolved pure CardGesture decides:
//   * Reorder / Replace -> in-grid, within the source bank (L7); one Ctrl-Z each.
//   * Move / Copy       -> the EXISTING cross-bank transfer (unchanged; Ctrl = copy).
//   * None              -> a drop over dead space / the source-bank gap = no-op.
// OsDragOut is never seen here: the pointer-left-client handoff happens live in onMouseMove.
void onLBtnUp(int x, int y) {
    if (g_panel.dragging) {
        // S17 drop-and-load: a release while hover-tracking a valid FX hotspot instantiates a
        // ReaSampler 9000 on that track preloaded with the dragged capture — NOT a bank move,
        // NOT an OS drag, NEVER a timeline insert. Takes priority over the L7 in-grid / cross-bank
        // drop (the pointer is out over a track, not over a bank region). Single-capture only (the
        // gesture never armed for a multi payload), so dragSampleIds.front() is the capture.
        if (g_panel.instrumentDropTrack && g_panel.dragSampleIds.size() == 1) {
            const std::string sampleId = g_panel.dragSampleIds.front();
            const std::string chunk = buildInstrumentDropChunk(sampleId);
            performInstrumentDrop(g_panel.instrumentDropTrack, chunk);
            // Read-only over the bank + arrange: the ONLY mutations are the new FX instance +
            // its state (both undoable in performInstrumentDrop). No book change, no ext-state,
            // no dirty-mark here.
        } else {
            updateDropTarget(x, y);
            classifyCardDrag(x, y);  // re-resolve at the drop point (modifiers may have changed)
            const CardGesture g = g_panel.cardGesture;

            if (g == CardGesture::Reorder) {
                doReorderDrop(g_panel.dragPrimaryId, g_panel.dragSourceBankId,
                              g_panel.dragTargetSlot);
            } else if (g == CardGesture::Replace) {
                // Replace targets the OCCUPANT of the target slot with the single grabbed card.
                const bool isBanks = g_panel.dragSourceRegion == Region::Banks;
                RECT cr{};
                GetClientRect(g_panel.hwnd, &cr);
                const int w = cr.right - cr.left, h = cr.bottom - cr.top;
                const RECT region = isBanks ? banksRegionRect(w, h) : poolRegionRect(w, h);
                const RegionDisplay disp = regionDisplay(region, isBanks, g_panel.dragSourceRegion);
                const std::string occupant = disp.idAtSlot(g_panel.dragTargetSlot);
                // Replace only makes sense for a single grabbed card over a DIFFERENT occupant.
                if (!occupant.empty() && occupant != g_panel.dragPrimaryId)
                    doReplaceDrop(g_panel.dragPrimaryId, occupant, g_panel.dragSourceBankId);
            } else if (g == CardGesture::Move || g == CardGesture::Copy) {
                const std::string destId = dropTargetBankId();
                if (!destId.empty() && destId != g_panel.dragSourceBankId &&
                    !g_panel.dragSampleIds.empty()) {
                    transferSamples(g_panel.dragSampleIds, g_panel.dragSourceBankId, destId,
                                    /*copy=*/g == CardGesture::Copy);
                }
            }
        }
        // CardGesture::None -> no-op drop (dead space, or same-bank gap resolved to None).
        SetCursor(LoadCursor(nullptr, IDC_ARROW));  // restore the arrow on drop
        if (GetCapture() == g_panel.hwnd) ReleaseCapture();
    } else if (g_panel.dragArmed) {
        // Press-release on a selected cell with no drag: treat as a plain click that
        // collapses the multi-selection to the pressed cell (standard behavior).
        const BankIndex* idx = indexForRegion(g_panel.focusedRegion);
        const int count = idx ? static_cast<int>(idx->size()) : 0;
        const int focus = g_panel.selection.focus;
        if (focus >= 0)
            g_panel.selection = applyClick(g_panel.selection, focus, false, false, count);
    }
    resetDragState();
    invalidatePanel();
}

// A right-click: on a named tab -> the tab management menu; on a grid cell of the
// focused region with a selection -> the move/copy menu.
void handleRightClick(int x, int y) {
    RECT cr{};
    GetClientRect(g_panel.hwnd, &cr);
    const int w = cr.right - cr.left, h = cr.bottom - cr.top;

    // Tab management menu.
    if (banksShown()) {
        const RECT br = banksRegionRect(w, h);
        const TabStripRect strip = banksTabStripRect(br);
        const std::vector<const Bank*> tabs = namedBanks();
        const TabHit hit = hitTestTabStrip(x, y, strip,
                                           static_cast<int>(tabs.size()), kTabSpec,
                                           g_panel.tabScroll);
        if (hit.kind == TabHitKind::Tab) {
            POINT pt{x, y};
            ClientToScreen(g_panel.hwnd, &pt);
            showTabMenu(pt.x, pt.y, tabs[static_cast<std::size_t>(hit.index)]->id);
            return;
        }
    }

    // Grid selection menu (move/copy). Only when the right-click lands in the focused
    // region's grid and there is a selection.
    Region reg = Region::Pool;
    if (regionAt(x, y, reg) && reg == g_panel.focusedRegion &&
        !g_panel.selection.empty()) {
        POINT pt{x, y};
        ClientToScreen(g_panel.hwnd, &pt);
        showSelectionMenu(pt.x, pt.y);
    }
}

// --- Dialog proc + docking ----------------------------------------------------

// Decodes a WM_DROPFILES HDROP into the dropped file paths (absolute, OS-native) and hands
// them to the S8 ingest path. Multi-file drop: ingestDroppedFiles imports all and assigns
// the first. Always DragFinish's the HDROP (frees the shell-allocated drop buffer) on every
// path. DragQueryFile(hDrop, 0xFFFFFFFF, ...) returns the file count; then each path is
// queried by index. Both Win32 and SWELL expose DragQueryFile/DragFinish with this contract.
void handleDropFiles(HDROP hDrop) {
    std::vector<std::string> paths;
    const UINT count = DragQueryFile(hDrop, 0xFFFFFFFF, nullptr, 0);
    paths.reserve(count);
    for (UINT i = 0; i < count; ++i) {
        // Query the required length first (excludes the NUL), then read into a sized buffer.
        const UINT len = DragQueryFile(hDrop, i, nullptr, 0);
        if (len == 0) continue;
        std::vector<char> buf(static_cast<std::size_t>(len) + 1, '\0');
        DragQueryFile(hDrop, i, buf.data(), static_cast<UINT>(buf.size()));
        std::string p(buf.data());
        if (!p.empty()) paths.push_back(std::move(p));
    }
    DragFinish(hDrop);
    if (!paths.empty()) ingestDroppedFiles(paths);
}

WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) {
    switch (msg) {
        case WM_DROPFILES:
            // S8 drop-onto-panel ingest: OS file drop on the docked panel HWND -> import
            // into the active bank + assign the first. wParam is the HDROP.
            handleDropFiles(reinterpret_cast<HDROP>(wParam));
            return 0;
        case WM_PAINT: {
            PAINTSTRUCT ps;
            HDC hdc = BeginPaint(hwnd, &ps);
            paintPanel(hwnd, hdc);
            EndPaint(hwnd, &ps);
            return 0;
        }
        case WM_LBUTTONDOWN: {
            SetFocus(hwnd);
            handleClick(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
            return 0;
        }
        case WM_MOUSEMOVE:
            onMouseMove(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
            return 0;
        case WM_LBUTTONUP:
            onLBtnUp(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
            return 0;
        case WM_RBUTTONDOWN:
            SetFocus(hwnd);
            handleRightClick(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
            return 0;
        case WM_CAPTURECHANGED:
            // Capture lost (pointer left window pre-threshold and released outside, or another
            // window stole capture mid-drag) — cancel the whole drag as a NO-OP so no stale
            // state lingers, mirroring onLBtnUp's reset (peer-path symmetry). Nothing is
            // mutated on a cancel; the cursor is restored to the arrow.
            if (g_panel.dragArmed || g_panel.dragging) {
                resetDragState();
                SetCursor(LoadCursor(nullptr, IDC_ARROW));
                invalidatePanel();
            }
            return 0;
        case WM_MOUSEWHEEL: {
            // Fine-adjust the Manual tail length when the wheel is over the footer.
            // UNLIKE the button messages, WM_MOUSEWHEEL carries SCREEN coordinates in
            // lParam (Win32 and SWELL agree — swell-generic-gdk.cpp §WM_MOUSEWHEEL), so
            // convert to client space before hit-testing the footer. The signed wheel
            // delta is the HIWORD of wParam (SWELL packs it as (delta<<16), delta=+/-120,
            // matching GET_WHEEL_DELTA_WPARAM). Consume (return 1) only when the footer
            // handler acts, so scrolling elsewhere in the dock still behaves normally.
            POINT pt{GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)};
            ScreenToClient(hwnd, &pt);
            const int delta = static_cast<short>(HIWORD(wParam));
            return handleWheel(pt.x, pt.y, delta) ? 1 : 0;
        }
        case WM_DESTROY:
            if (GetCapture() == hwnd) ReleaseCapture();
            stopAudition();
            g_panel.selection = Selection{};
            resetDragState();
            g_panel.hovered = Hover{};
            g_panel.tooltipShown = false;
            g_panel.hwnd = nullptr;
            g_panel.open = false;
            return 0;
        default:
            break;
    }
    return 0;
}

void openPanel() {
    if (g_panel.open && g_panel.hwnd) {
        DockWindowActivate(g_panel.hwnd);
        return;
    }
    initPreview();

    // Create the kit's cached AA fonts before the first paint (Phase L, L1). Idempotent, so
    // a reopen after closePanel (which leaves the fonts alive) is a cheap no-op; the fonts
    // are torn down once at bankPanelShutdown. All panel text draws through these.
    kitFontsInit();

    g_panel.hwnd = CreateDialogParam(g_hInst, MAKEINTRESOURCE(IDD_BANK_PANEL),
                                     GetMainHwnd(), dlgProc, 0);
    if (!g_panel.hwnd) return;

    // Channel-qualified dock identity (Phase V, V4). The title and the persisted-position
    // identstr both come from app_version, so a beta panel is distinguishable ("ReaSampler
    // Bank beta") and does not fight over stable's saved dock slot (the identstr is a
    // REAPER-global collision surface — it keys the persisted dock position).
    DockWindowAddEx(g_panel.hwnd, dockTitle().c_str(), dockIdent().c_str(), true);
    DockWindowActivate(g_panel.hwnd);
    g_panel.open = true;

    // S8: accept OS file drops on the panel HWND (WM_DROPFILES routes to handleDropFiles).
    // DragAcceptFiles is a native Win32 shell call (shellapi.h); SWELL does NOT expose it,
    // so the opt-in is Windows-only here. The primary/shipped platform is Windows (the VST3
    // instrument the drop assigns to is Windows-only, D5); a mac/linux drop-registration
    // surface is out of scope for this dispatch. WM_DROPFILES handling itself uses
    // DragQueryFile/DragFinish, which SWELL DOES provide, so a drop delivered by other means
    // would still ingest — only the accept opt-in is gated.
#ifdef _WIN32
    DragAcceptFiles(g_panel.hwnd, TRUE);
#endif

    registerAccel();

    reconcileShownBank();
    refreshFingerprint();
}

void closePanel() {
    if (GetCapture() == g_panel.hwnd) ReleaseCapture();
    stopAudition();
    g_panel.selection = Selection{};
    resetDragState();
    unregisterAccel();
    if (g_panel.hwnd) {
        DockWindowRemove(g_panel.hwnd);
        DestroyWindow(g_panel.hwnd);
        g_panel.hwnd = nullptr;
    }
    g_panel.open = false;
}

}  // namespace

// --- Public API ---------------------------------------------------------------

void bankPanelInit(ReaSamplerSession* session) {
    g_panel.session = session;
}

// Returns true only when the panel window is actually visible to the user right now.
// IsWindowVisible() returns false when the docker is hidden via Alt+D even though the
// HWND and g_panel.open are still live — the live query is the source of truth for
// toggle decisions and the Actions-list checkmark (OnToggleAction in main.cpp).
static bool panelEffectivelyVisible() {
    return g_panel.hwnd && IsWindowVisible(g_panel.hwnd);
}

void bankPanelToggle() {
    // Decide from live visibility, not the cached g_panel.open flag.
    // Alt+D hides the docker without destroying the window, leaving g_panel.open
    // stale (true) while the panel is gone. Using IsWindowVisible avoids the
    // double-fire needed to re-show the panel after a docker hide.
    if (panelEffectivelyVisible())
        closePanel();
    else
        openPanel();
}

bool bankPanelIsOpen() {
    // Derive from live window state so the Actions-list checkmark stays honest
    // even after Alt+D hides the docker without notifying the extension.
    return panelEffectivelyVisible();
}

std::vector<std::string> bankPanelSelectedSampleIds() {
    return focusedSelectionIds();
}

std::string bankPanelSelectedSourceBankId() {
    // The focused region's displayed bank is the move/copy source. Default to the
    // pool (a safe source) when nothing is selected / the panel never opened.
    if (g_panel.selection.empty()) return std::string(kPoolBankId);
    const std::string id = bankIdForRegion(g_panel.focusedRegion);
    return id.empty() ? std::string(kPoolBankId) : id;
}

void bankPanelNotifyProjectLoaded() {
    // Persist restored a project's membership + active mode this tick (main.cpp calls
    // this from the same consumeLoadSignal() branch that reapplies the active mode).
    // Arm the new-content detector to re-baseline on its next tick so the just-loaded
    // project's pre-existing content is treated as the baseline (nothing new) rather
    // than diffed against the previous project and mass-tagged into the active mode.
    // A flag (not an inline reset) because detectNewContent owns the baseline and runs
    // later in the SAME OnTimer tick — it drains this and re-baselines against the live
    // set in one place, keeping the reset and the observe() adjacent and ordered.
    g_panel.reloadPending = true;
}

void bankPanelRefresh() {
    // New-content auto-tag detection runs EVERY tick regardless of panel open/close:
    // tracks/items are created in the arrange view, not the panel, so detection must
    // not be gated on the dock being visible. READ-ONLY on the project; only mutates
    // the in-memory membership index (persist saves it like any action-driven tag).
    const bool tagged = detectNewContent();

    // Lane minting (D2 Wave 3) runs ONLY when detection just tagged new content — a
    // track can only newly become multi-mode when auto-tag placed content on it. Unlike
    // the invisible membership tag above, minting is a visible structural mutation
    // (I_FREEMODE/I_FIXEDLANE/P_LANENAME), so mintManagedLanes wraps it in its own Undo
    // block and only mints for tracks that hold >1 mode's content — a single-mode track
    // is left to D1 whole-track parking. Managed lanes only; manual lanes untouched.
    if (tagged && g_panel.session) {
        ReaProject* proj = EnumProjects(-1, nullptr, 0);
        mintManagedLanes(g_panel.session->view(), proj);
    }

    if (!g_panel.open || !g_panel.hwnd) return;

    // L5: the custom hover-delay tooltip is driven off this poll tick (no dedicated timer) — if a
    // toolbar button has rested under the pointer past the delay, latch + repaint the tooltip.
    maybeShowTooltip();

    if (refreshFingerprint())
        InvalidateRect(g_panel.hwnd, nullptr, FALSE);
}

TailSetting bankPanelTailSetting() {
    // The authoritative setting lives in the session (session->tail()) so it travels
    // inside the .rpp: it loads per project and saves with the project. This stays the
    // read seam for the capture actions. manualMs is clamped here so a caller always
    // receives a within-cap length regardless of what was stored/scrolled.
    TailSetting s = currentTail();
    s.manualMs = clampManualMs(s.manualMs);
    return s;
}

BankPanelFullHeight bankPanelFullHeight() {
    return g_panel.fullHeight;
}

static void setFullHeight(BankPanelFullHeight target) {
    g_panel.fullHeight =
        (g_panel.fullHeight == target) ? BankPanelFullHeight::Split : target;
    if (g_panel.hwnd) InvalidateRect(g_panel.hwnd, nullptr, FALSE);
}

void bankPanelToggledPoolFullHeight() {
    setFullHeight(BankPanelFullHeight::PoolOnly);
}

void bankPanelToggledBanksFullHeight() {
    setFullHeight(BankPanelFullHeight::BanksOnly);
}

void bankPanelShutdown() {
    closePanel();
    deinitPreview();
    kitFontsShutdown();  // free the kit's cached AA fonts + their owned HFONTs (L1)
    g_panel.cache.clear();
    g_panel.session = nullptr;
}

}  // namespace reasampler
