// actions.cpp — the Design View action family (Phase D4). See actions.h.
//
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API pointers
// (CLAUDE.md §contract). The action ids are minted from FOREVER-STABLE strings (the
// same CEREBELLUM_REASAMPLER_ family prefix main.cpp uses); user keybindings key off
// them, so they must never change after ship.
//
// Each action:
//   1. mutates the session's ViewModeModel (membership tag/untag/show-both, or the
//      active mode via toggle/activate) — the pure D1 state,
//   2. reapplies the active mode through the D2 view shell (applyMode) so the change
//      takes visible effect immediately (tagging a track into Design while in Arrange
//      parks it right away; a mode change re-partitions and re-parks in one step).
//
// Selection-driven mutations iterate the CURRENT REAPER track selection
// (CountSelectedTracks/GetSelectedTrack — both ignore the master, which is correct:
// the master is never tagged) and resolve each track to its canonical GUID key via
// the shared guidString helper, so the keys match exactly what the D2 shell / view
// tree key on (the cross-module key contract).

#include "actions.h"

#include <deque>
#include <string>
#include <vector>

#include "app_version.h"  // channelCommandId / channelActionName — one channel-identity point

#include "bank_book.h"     // BankBook, nextBankId, TransferResult, kPoolBankId (B1)
#include "bank_panel.h"    // selection seam + full-height toggles (B3/B4)
#include "item_read.h"     // shared MediaItem* -> GUID + fixed-lane-name reads (D2 W3-B)
#include "lane_keys.h"     // isOnManualLane — the single managed/manual predicate
#include "persist.h"       // ReaSamplerSession (owns book() + view() model)
#include "track_guid.h"    // shared MediaTrack* -> canonical GUID key
#include "view.h"          // applyMode + mintManagedLanes (D2 shell)
#include "view_mode_model.h"

#include "reaper_plugin.h"  // reaper_plugin_info_t, gaccel_register_t (full defs)

#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_CountSelectedTracks
#define REAPERAPI_WANT_GetSelectedTrack
#define REAPERAPI_WANT_CountSelectedMediaItems
#define REAPERAPI_WANT_GetSelectedMediaItem
#define REAPERAPI_WANT_GetMediaItemTrack
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_Main_SaveProject
#define REAPERAPI_WANT_ShowConsoleMsg
#define REAPERAPI_WANT_GetUserInputs
#define REAPERAPI_WANT_ShowMessageBox
#define REAPERAPI_WANT_genGuid
#define REAPERAPI_WANT_guidToString
#define REAPERAPI_WANT_Undo_BeginBlock2
#define REAPERAPI_WANT_Undo_EndBlock2
#include "reaper_plugin_functions.h"

namespace reasampler {

namespace {

// FOREVER-STABLE action-id SUFFIXES (Phase V, V4). The channel family prefix is prepended
// at register time via channelCommandId (app_version), so stable rebuilds the exact shipped
// id ("CEREBELLUM_REASAMPLER_VIEW_TOGGLE_MODE") and beta yields the isolated forever-family
// id ("CEREBELLUM_REASAMPLER_BETA_VIEW_TOGGLE_MODE"). Each composed id is minted into a
// persistent command id user keybindings key off — NEVER change a shipped suffix after ship.
constexpr const char* kIdToggleMode      = "VIEW_TOGGLE_MODE";
constexpr const char* kIdActivateArrange = "VIEW_ACTIVATE_ARRANGE";
constexpr const char* kIdActivateDesign  = "VIEW_ACTIVATE_DESIGN";
constexpr const char* kIdTagDesign       = "VIEW_TAG_DESIGN";
constexpr const char* kIdTagArrange      = "VIEW_TAG_ARRANGE";
constexpr const char* kIdUntag           = "VIEW_UNTAG";
constexpr const char* kIdShowBoth        = "VIEW_SHOW_BOTH";
// D2 Wave 3-B item-level mode moves — the item analog of the track tag family. Same
// FOREVER-STABLE contract (suffix composed with the channel prefix) — NEVER change these.
constexpr const char* kIdMoveItemsDesign  = "VIEW_MOVE_ITEMS_DESIGN";
constexpr const char* kIdMoveItemsArrange = "VIEW_MOVE_ITEMS_ARRANGE";
constexpr const char* kIdUntagItems       = "VIEW_UNTAG_ITEMS";

// The live session the actions mutate. Set once by designViewRegisterActions and
// read by the hookcommand handler. Not owned here (main.cpp owns g_session).
ReaSamplerSession* g_session = nullptr;

// Minted command ids (0 until registration succeeds). Compared in the handler.
int g_cmdToggleMode = 0;
int g_cmdActivateArrange = 0;
int g_cmdActivateDesign = 0;
int g_cmdTagDesign = 0;
int g_cmdTagArrange = 0;
int g_cmdUntag = 0;
int g_cmdShowBoth = 0;
int g_cmdMoveItemsDesign = 0;
int g_cmdMoveItemsArrange = 0;
int g_cmdUntagItems = 0;

// gaccel storage must outlive registration — REAPER holds each pointer until we
// mirror-unregister it. One per action.
gaccel_register_t g_accelToggleMode{};
gaccel_register_t g_accelActivateArrange{};
gaccel_register_t g_accelActivateDesign{};
gaccel_register_t g_accelTagDesign{};
gaccel_register_t g_accelTagArrange{};
gaccel_register_t g_accelUntag{};
gaccel_register_t g_accelShowBoth{};
gaccel_register_t g_accelMoveItemsDesign{};
gaccel_register_t g_accelMoveItemsArrange{};
gaccel_register_t g_accelUntagItems{};

// Durable store of composed, channel-qualified strings (ids + labels). A std::deque never
// invalidates references on push_back, so a c_str() handed to REAPER (a command_id at
// register, a gaccel desc for its lifetime) stays valid until process exit. Memoized by
// suffix so register and the mirror-unregister get the SAME id pointer for a given action.
std::deque<std::string> g_strStore;

// Returns the channel-qualified command id for `suffix`, interning it once. Called by BOTH
// registerAction and the unregister path, so a '-command_id' presents the identical string.
const char* channelIdFor(const char* suffix) {
    const std::string composed = channelCommandId(suffix);
    for (const std::string& s : g_strStore)
        if (s == composed) return s.c_str();
    g_strStore.push_back(composed);
    return g_strStore.back().c_str();
}

// Mints a command id from a channel-qualified SUFFIX and registers its gaccel (Actions-list
// entry with a channel-qualified label PHRASE). Returns the command id (0 on failure). Both
// the composed id and label are interned durably (g_strStore) — REAPER holds the desc
// pointer, and the id must survive to the mirror-unregister. The gaccel storage itself is
// caller-owned (the file-scope g_accel* above).
int registerAction(reaper_plugin_info_t* rec, const char* suffix,
                   gaccel_register_t& accel, const char* phrase) {
    const char* id = channelIdFor(suffix);
    const int cmd = rec->Register("command_id", (void*)id);
    if (cmd) {
        g_strStore.push_back(channelActionName(phrase));
        accel.accel.cmd = cmd;
        accel.desc      = g_strStore.back().c_str();
        rec->Register("gaccel", (void*)&accel);
    }
    return cmd;
}

// Collects the canonical GUID keys of the current track selection. Empty if nothing
// is selected. CountSelectedTracks/GetSelectedTrack ignore the master (SDK), which is
// exactly right — the master is never a tagged leaf.
std::vector<std::string> selectedTrackGuids() {
    std::vector<std::string> guids;
    const int n = CountSelectedTracks(nullptr);  // nullptr = active project
    guids.reserve(static_cast<std::size_t>(n < 0 ? 0 : n));
    for (int i = 0; i < n; ++i) {
        MediaTrack* tr = GetSelectedTrack(nullptr, i);
        if (!tr) continue;
        std::string g = guidString(tr);
        if (!g.empty()) guids.push_back(std::move(g));
    }
    return guids;
}

// Reapplies the model's CURRENT active mode to the active project so a membership
// mutation takes visible effect immediately (park/unpark/re-derive parents). Called
// after every tag/untag/show-both. `proj = nullptr` -> REAPER's active project.
void reapplyActiveMode() {
    applyMode(g_session->view(), g_session->view().activeModeId(), nullptr);
}

// Track fixed-lane mode value (I_FREEMODE=2). Mirrors the shell's constant; used only to
// decide whether an item's lane name is meaningful for the manual-lane read.
constexpr int kFreeModeFixedLanes = 2;

// Collects the current media-item selection as the pure decision's input: each selected
// item's GUID plus whether it sits on a MANUAL lane (⇒ EXEMPT — never retagged/re-laned).
// The manual-lane read follows the shared pure predicate exactly as the shell's readers
// do: only on a fixed-lane track (I_FREEMODE==2) is the item's lane name read; on a normal
// track isOnManualLane returns false for the empty name, so the P_LANENAME read is skipped.
// Items whose GUID cannot be read are dropped (an empty GUID must never be retagged).
std::vector<RetagItem> selectedRetagItems() {
    std::vector<RetagItem> items;
    const int n = CountSelectedMediaItems(nullptr);  // nullptr = active project
    items.reserve(static_cast<std::size_t>(n < 0 ? 0 : n));
    for (int i = 0; i < n; ++i) {
        MediaItem* it = GetSelectedMediaItem(nullptr, i);
        if (!it) continue;
        std::string g = itemGuid(it);
        if (g.empty()) continue;

        MediaTrack* tr = GetMediaItemTrack(it);
        const bool fixedLane =
            tr && static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FREEMODE")) == kFreeModeFixedLanes;
        // Only read the lane name on a fixed-lane track; the pure predicate handles the
        // normal-track case (returns false) so we pass an empty name and skip the read.
        const std::string laneNm = fixedLane ? itemLaneName(tr, it) : std::string{};
        items.push_back(RetagItem{std::move(g), isOnManualLane(fixedLane, laneNm)});
    }
    return items;
}

// Persists both the bank and the Design-View model to the active project's ext
// state. Called after every state-changing Design View action so the view model
// is not lost across save/close/reopen. Marking the project dirty is correct —
// a Design View mutation is a project-level change the user should be prompted
// to save.
//
// When the membership index is non-empty AND the project is unsaved, we prompt
// the user to Save-As before persisting — mirroring the flow capture uses.
// Gate: if membership is empty (no tracks tagged), skip the prompt entirely;
// saveToActiveProject will no-op for an unsaved project, which is correct.
//
// DAW-ONLY ASSUMPTION: Main_SaveProject(proj, true) opens a Save-As dialog and
// blocks until the user dismisses it. The blocking behaviour and dialog
// appearance can only be confirmed in a running REAPER (same caveat as capture).
void persistViewState() {
    if (!g_session->view().membership().empty()) {
        // At least one track is tagged — worth persisting. Check whether the
        // project is saved and, if not, prompt Save-As so saveToActiveProject
        // can write ext state. Mirrors capture's readRppPath idiom exactly.
        ReaProject* proj = EnumProjects(-1, nullptr, 0);
        if (proj) {
            auto readRppPath = [&]() -> std::string {
                std::vector<char> buf(4096, '\0');
                EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
                return std::string(buf.data());
            };

            if (readRppPath().empty()) {
                // Project is unsaved — prompt Save-As.
                Main_SaveProject(proj, true);
                // Re-read: still empty means the user cancelled.
                if (readRppPath().empty()) {
                    ShowConsoleMsg(
                        "ReaSampler: Design View state will not persist until "
                        "the project is saved.\n");
                    // The in-session tag state is left as-is — the mode change
                    // already applied and remains valid for this session.
                    return;
                }
            }
        }
    }
    g_session->saveToActiveProject();
}

// -- Action bodies ---------------------------------------------------------

// Toggle: cycle to the next mode in ordinal order (Arrange <-> Design with two
// seeds; scales to cycle-through-all for >2 modes with no change here). applyMode
// itself sets the model's active mode, so we only compute the target and apply.
void doToggleMode() {
    const std::string target =
        nextModeId(g_session->view().modes(), g_session->view().activeModeId());
    if (target.empty()) return;  // no modes to cycle to (degenerate)
    applyMode(g_session->view(), target, nullptr);
    persistViewState();
}

// Direct jump to a named mode. applyMode is a no-op (returns false, no mutation) if
// the id is unregistered, so an absent mode fails safe.
void doActivateMode(const std::string& modeId) {
    applyMode(g_session->view(), modeId, nullptr);
    persistViewState();
}

// Tag the selection's leaves into `modeId`, then reapply so the change is immediate.
// tag() replaces any prior single-mode membership (a leaf lives in one mode; the
// cross-mode case is show-both), matching the D1 contract.
void doTag(const std::string& modeId) {
    for (const std::string& g : selectedTrackGuids())
        g_session->view().membership().tag(g, modeId);
    reapplyActiveMode();
    persistViewState();
}

// Untag the selection entirely (return each to the Arrange default). This is the
// shared body behind both "Untag selected" and "Tag -> Arrange" (Arrange = the
// absence of a tag), so the two actions are the same act by definition.
void doUntag() {
    for (const std::string& g : selectedTrackGuids())
        g_session->view().membership().untag(g);
    reapplyActiveMode();
    persistViewState();
}

// Toggle the per-track show-both pin for the selection. Read the CURRENT pin of each
// track and flip it independently (a mixed selection converges toward "all on" then
// "all off" only if uniform; per-track flip is the honest semantics of a toggle on a
// multi-selection). show-both leaves are never parked (D1), so reapply reflects the
// change immediately.
void doShowBoth() {
    MembershipIndex& m = g_session->view().membership();
    for (const std::string& g : selectedTrackGuids())
        m.setShowBoth(g, !m.isShowBoth(g));
    reapplyActiveMode();
    persistViewState();
}

// -- Item-level mode moves (D2 Wave 3-B) -----------------------------------
//
// Retag the current ITEM selection to `targetMode` (empty ⇒ untag → Arrange default),
// then re-drive the minting + apply path so each moved item lands on its target mode's
// managed lane and the active-mode lane visibility is reasserted. The pure planItemRetag
// decides which selected items to retag (manual-lane items are EXEMPT — never retagged,
// never re-laned), upholding the managed-lanes-only invariant even under this explicit
// user action. The whole structural act is wrapped in ONE Undo block with a descriptive
// label (the inner blocks mintManagedLanes / applyMode open nest harmlessly under it).
//
// UNDO/SAVE ordering: persistViewState may pop a Save-As dialog (Main_SaveProject) which
// must NOT sit inside the Undo block, so we close the block first, then persist — the same
// separation the track actions rely on (they persist outside applyMode's own block).
void doMoveItems(const std::string& targetMode) {
    const std::vector<RetagItem> selected = selectedRetagItems();
    const std::vector<ItemRetagOp> ops = planItemRetag(selected, targetMode);
    if (ops.empty()) return;  // nothing selected, or every selected item was exempt/empty

    MembershipIndex& membership = g_session->view().membership();

    Undo_BeginBlock2(nullptr);
    // Apply the pure decision's membership writes: tag into targetMode, or untag.
    for (const ItemRetagOp& op : ops) {
        if (op.untag) membership.untag(op.guid);
        else          membership.tag(op.guid, op.modeId);
    }
    // Re-drive the SAME minting/apply path auto-tag uses: mint/split lanes for any track
    // whose items now span modes and assign each moved item to its mode's managed lane,
    // then reassert the active mode's lane visibility. Manual lanes stay untouched
    // (mintManagedLanes reports their items exempt and never mints over them).
    mintManagedLanes(g_session->view(), nullptr);
    reapplyActiveMode();

    const std::string label =
        targetMode.empty()
            ? std::string("ReaSampler: untag selected items")
            : std::string("ReaSampler: move selected items -> ") + targetMode;
    Undo_EndBlock2(nullptr, label.c_str(), -1);

    persistViewState();
}

}  // namespace

void designViewRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session) {
    g_session = session;

    // command_id -> gaccel for each. The single hookcommand that routes these lives
    // in main.cpp (one hook per extension); designViewHandleCommand services them.
    g_cmdToggleMode = registerAction(rec, kIdToggleMode, g_accelToggleMode,
                                     "toggle Design View mode");
    g_cmdActivateArrange = registerAction(rec, kIdActivateArrange, g_accelActivateArrange,
                                          "activate mode Arrange");
    g_cmdActivateDesign = registerAction(rec, kIdActivateDesign, g_accelActivateDesign,
                                         "activate mode Design");
    g_cmdTagDesign = registerAction(rec, kIdTagDesign, g_accelTagDesign,
                                    "tag selected tracks -> Design");
    g_cmdTagArrange = registerAction(rec, kIdTagArrange, g_accelTagArrange,
                                     "tag selected tracks -> Arrange");
    g_cmdUntag = registerAction(rec, kIdUntag, g_accelUntag,
                                "untag selected tracks");
    g_cmdShowBoth = registerAction(rec, kIdShowBoth, g_accelShowBoth,
                                   "show both for selected tracks");

    // Item-level mode moves (D2 W3-B): the item analog of the track tag family.
    g_cmdMoveItemsDesign = registerAction(rec, kIdMoveItemsDesign, g_accelMoveItemsDesign,
                                          "move selected items -> Design");
    g_cmdMoveItemsArrange = registerAction(rec, kIdMoveItemsArrange, g_accelMoveItemsArrange,
                                           "move selected items -> Arrange");
    g_cmdUntagItems = registerAction(rec, kIdUntagItems, g_accelUntagItems,
                                     "untag selected items");
}

bool designViewHandleCommand(int command) {
    if (command == 0 || !g_session) return false;

    if (command == g_cmdToggleMode)      { doToggleMode(); return true; }
    if (command == g_cmdActivateArrange) { doActivateMode(kArrangeModeId); return true; }
    if (command == g_cmdActivateDesign)  { doActivateMode(kDesignModeId);  return true; }
    if (command == g_cmdTagDesign)       { doTag(kDesignModeId);  return true; }
    // Tag -> Arrange and Untag are the same act (Arrange = the absence of a tag).
    if (command == g_cmdTagArrange)      { doUntag(); return true; }
    if (command == g_cmdUntag)           { doUntag(); return true; }
    if (command == g_cmdShowBoth)        { doShowBoth(); return true; }

    // Item-level moves. Move -> Arrange and Untag items collapse to the same act (an
    // empty target ⇒ untag ⇒ Arrange default), mirroring the track-level pairing above.
    if (command == g_cmdMoveItemsDesign)  { doMoveItems(kDesignModeId); return true; }
    if (command == g_cmdMoveItemsArrange) { doMoveItems(std::string{}); return true; }
    if (command == g_cmdUntagItems)       { doMoveItems(std::string{}); return true; }

    return false;  // not ours — caller's hookcommand keeps looking
}

void designViewUnregisterActions(reaper_plugin_info_t* rec) {
    // Mirror-unregister with '-'-prefixed strings, per the contract's unload rule.
    // gaccel first, then the command_id string (reverse of registration order — the item
    // moves registered last, so they tear down first).
    // Each '-command_id' re-presents the SAME interned, channel-qualified id (channelIdFor
    // returns the memoized pointer registered above), so the unregister matches exactly.
    rec->Register("-gaccel", (void*)&g_accelUntagItems);
    rec->Register("-command_id", (void*)channelIdFor(kIdUntagItems));
    rec->Register("-gaccel", (void*)&g_accelMoveItemsArrange);
    rec->Register("-command_id", (void*)channelIdFor(kIdMoveItemsArrange));
    rec->Register("-gaccel", (void*)&g_accelMoveItemsDesign);
    rec->Register("-command_id", (void*)channelIdFor(kIdMoveItemsDesign));
    rec->Register("-gaccel", (void*)&g_accelShowBoth);
    rec->Register("-command_id", (void*)channelIdFor(kIdShowBoth));
    rec->Register("-gaccel", (void*)&g_accelUntag);
    rec->Register("-command_id", (void*)channelIdFor(kIdUntag));
    rec->Register("-gaccel", (void*)&g_accelTagArrange);
    rec->Register("-command_id", (void*)channelIdFor(kIdTagArrange));
    rec->Register("-gaccel", (void*)&g_accelTagDesign);
    rec->Register("-command_id", (void*)channelIdFor(kIdTagDesign));
    rec->Register("-gaccel", (void*)&g_accelActivateDesign);
    rec->Register("-command_id", (void*)channelIdFor(kIdActivateDesign));
    rec->Register("-gaccel", (void*)&g_accelActivateArrange);
    rec->Register("-command_id", (void*)channelIdFor(kIdActivateArrange));
    rec->Register("-gaccel", (void*)&g_accelToggleMode);
    rec->Register("-command_id", (void*)channelIdFor(kIdToggleMode));

    g_session = nullptr;
}

// ===========================================================================
// Multi-bank action family (Phase B3)
// ===========================================================================
//
// Each action drives the B1 model on g_session->book() and persists via
// g_session->saveToActiveProject() so the change travels with the .rpp — exactly as
// the capture path persists a new Sample (main.cpp RunCapture). The book's rules
// (pool privileges, collapse-by-hash, active-fallback-to-pool) all live in bank_book;
// these handlers only call the model and react to the boolean / TransferResult.
//
// REFERENCE-INVALIDATION GUARDRAIL (B2 review): book().activeIndex() / bank()->index
// return a reference INTO the book's internal vector, which a create/delete can
// reallocate. No handler here caches a BankIndex& (or a Bank*) across a structural
// mutation — each resolves ids to strings up front and re-resolves after any
// create/delete. Move/copy pass ids (not references) straight to moveSample/copySample.

namespace {

// FOREVER-STABLE multi-bank action-id SUFFIXES (Phase V, V4). The channel family prefix is
// prepended at register via channelCommandId (as with the Design View family above) —
// stable rebuilds the shipped id, beta the isolated one. NEVER change a shipped suffix.
// Each suffix + the stable prefix must byte-match the pre-V4 shipped literal exactly
// (e.g. "BANK_REMOVE_SELECTED" -> "CEREBELLUM_REASAMPLER_BANK_REMOVE_SELECTED").
constexpr const char* kIdBankCreate       = "BANK_CREATE";
constexpr const char* kIdBankRename       = "BANK_RENAME";
constexpr const char* kIdBankDelete       = "BANK_DELETE";
constexpr const char* kIdBankEvacuate     = "BANK_EVACUATE";
constexpr const char* kIdBankActivateNext = "BANK_ACTIVATE_NEXT";
constexpr const char* kIdBankActivatePool = "BANK_ACTIVATE_POOL";
constexpr const char* kIdBankMoveSel      = "BANK_MOVE_SELECTED";
constexpr const char* kIdBankCopySel      = "BANK_COPY_SELECTED";
constexpr const char* kIdBankRemoveSel    = "BANK_REMOVE_SELECTED";
constexpr const char* kIdBankPoolFull     = "BANK_POOL_FULLHEIGHT";
constexpr const char* kIdBankBanksFull    = "BANK_BANKS_FULLHEIGHT";
// Phase R (Reclaim), R2: the FOREVER-STABLE "Prune bank folder" id. Registered NOW so
// in-DAW dry-run verification is possible; R2 behaviour is REPORT-ONLY (no deletion),
// and R3 extends the confirm-and-delete step behind this SAME id — never a throwaway id.
constexpr const char* kIdBankPruneFolder  = "BANK_PRUNE_FOLDER";

int g_cmdBankCreate = 0;
int g_cmdBankRename = 0;
int g_cmdBankDelete = 0;
int g_cmdBankEvacuate = 0;
int g_cmdBankActivateNext = 0;
int g_cmdBankActivatePool = 0;
int g_cmdBankMoveSel = 0;
int g_cmdBankCopySel = 0;
int g_cmdBankRemoveSel = 0;
int g_cmdBankPoolFull = 0;
int g_cmdBankBanksFull = 0;
int g_cmdBankPruneFolder = 0;

gaccel_register_t g_accelBankCreate{};
gaccel_register_t g_accelBankRename{};
gaccel_register_t g_accelBankDelete{};
gaccel_register_t g_accelBankEvacuate{};
gaccel_register_t g_accelBankActivateNext{};
gaccel_register_t g_accelBankActivatePool{};
gaccel_register_t g_accelBankMoveSel{};
gaccel_register_t g_accelBankCopySel{};
gaccel_register_t g_accelBankRemoveSel{};
gaccel_register_t g_accelBankPoolFull{};
gaccel_register_t g_accelBankBanksFull{};
gaccel_register_t g_accelBankPruneFolder{};

// Persists the book after a bank mutation. Mirrors the CAPTURE path (main.cpp
// RunCapture), NOT the Design-View path: a bank change is held in-session and written
// to the active project's ext state so it travels with the .rpp. Deliberately no
// Save-As prompt — saveToActiveProject no-ops on an unsaved project (the change stays
// valid for the session and persists on the user's next save), exactly as capture
// persists. This is an intentional divergence from persistViewState (above), which
// DOES prompt Save-As on an unsaved project; do not "align" the two — a bank mutation
// follows capture's quiet-persist idiom, a Design-View mutation follows the prompt idiom.
// Returns whether a persist actually happened (false on an unsaved/no-active project),
// so persistBankOp can skip its undo block when nothing was written.
bool persistBook() { return g_session->saveToActiveProject(); }

// Persists a completed bank index verb (create/rename/reorder/delete/evacuate/
// move/copy) as a SINGLE batched REAPER undo point (R-B) — one bank op = one Ctrl-Z.
//
// WHY THIS WRAPS AND persistBook() DOES NOT: a bank verb mutates ONLY our project
// ext-state (SetProjExtState under "reasampler"), which REAPER's undo system captures
// iff UNDO_STATE_MISCCFG is set in the Undo_EndBlock2 flags — the SDK documents
// MISCCFG as covering "extensions!" project ext-state (reaper_plugin.h ~1544, ~1199).
// We pass exactly UNDO_STATE_MISCCFG (not -1 / UNDO_STATE_ALL as the item-move family
// does): a bank verb touches no tracks, FX, items, or envelopes, so snapshotting them
// would be both heavier and semantically wrong. persistBook() (= SetProjExtState) runs
// INSIDE the block so the post-mutation ext-state is the block's "after" image.
//
// NO-OP GUARDRAIL: callers invoke this ONLY after the model mutation succeeded — a
// rejected op (duplicate name, un-deletable pool, etc.) returns before reaching here,
// so no dangling/empty undo point is ever opened for a rejected op.
//
// Prompts the user for a single line of text via REAPER's stock input dialog.
// GetUserInputs(title, num_inputs=1, captions_csv, retvals_csv, sz) -> false on
// cancel (SDK ~3808). `initial` pre-fills the field. Returns false (leaving `out`
// untouched) on cancel or an empty entry. Self-contained bindable-action name entry;
// B4's panel affordances supersede this with in-panel editing.
//
// COMMA GUARD: GetUserInputs splits the returned values on a separator that defaults
// to ',', so a bank name containing a comma would be truncated at the comma. We
// override the return separator to \x1f (ASCII unit separator, un-typeable in the
// dialog) via the documented `separator=X` extra caption field (SDK ~3806), so any
// printable name — commas included — round-trips whole. The captions_csv itself stays
// comma-joined: the single field caption, then the `separator=` directive as a
// trailing pseudo-caption (the directive redefines only the RETURN separator).
bool promptText(const char* title, const char* caption, const std::string& initial,
                std::string& out) {
    std::vector<char> buf(512, '\0');
    // Pre-fill: GetUserInputs seeds the field from the retvals buffer's initial value.
    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;  // user cancelled
    std::string s(buf.data());
    if (s.empty()) return false;  // an empty name is not a valid bank name
    out = std::move(s);
    return true;
}

// Mints a fresh, genuine REAPER GUID string as a stable bank id (the B2/model design:
// ids are caller-supplied and stable; the model stays pure and mints none). Distinct
// from a track GUID by origin only — both are canonical guidToString output.
std::string mintBankId() {
    GUID g{};
    genGuid(&g);
    char buf[64] = {0};  // guidToString needs >=64 chars (SDK contract)
    guidToString(&g, buf);
    return std::string(buf);
}

// Resolves a user-typed bank reference (a display name) to a bank id, scanning the
// book's banks in ordinal order. Exact match on displayName; "Pool" resolves the pool.
// Returns "" when no bank carries that name. Kept in the action layer (not the model)
// — it is UI name-resolution, not a model rule. First-match is unambiguous BY
// CONSTRUCTION: the model enforces unique display names (trimmed + case-insensitive),
// so at most one bank can carry a given name — no duplicate can shadow another here.
std::string bankIdByDisplayName(const std::string& name) {
    for (const Bank& b : g_session->book().banks())
        if (b.displayName == name) return b.id;
    return {};
}

// -- Action bodies ---------------------------------------------------------

// Create a named bank: prompt for a display name, mint a stable GUID id, create it in
// the model, persist. The new bank is NOT auto-activated (create and activate are
// distinct acts — mirrors capture/placement separation). The model rejects a display
// name that duplicates an existing bank's (trimmed + case-insensitive, incl. "Pool");
// the create then fails and the user is told the name is taken.
void doBankCreate() {
    std::string name;
    if (!promptText("ReaSampler: create bank", "Bank name:", "", name)) return;
    const std::string id = mintBankId();
    if (!g_session->book().createBank(id, name)) {
        ShowConsoleMsg(
            ("ReaSampler: could not create bank \"" + name +
             "\" (a bank with that name already exists).\n")
                .c_str());
        return;
    }
    persistBankOp("ReaSampler: create bank");
}

// Rename a bank: prompt for which bank (by current display name) and the new name.
// The pool is un-renamable (the model rejects it). Two prompts keep the bindable form
// self-contained; B4's panel renames in place on a tab.
void doBankRename() {
    std::string which;
    if (!promptText("ReaSampler: rename bank", "Bank to rename (current name):", "",
                    which))
        return;
    const std::string id = bankIdByDisplayName(which);
    if (id.empty()) {
        ShowConsoleMsg(("ReaSampler: no bank named \"" + which + "\".\n").c_str());
        return;
    }
    std::string newName;
    if (!promptText("ReaSampler: rename bank", "New name:", which, newName)) return;
    if (!g_session->book().renameBank(id, newName)) {
        // renameBank rejects the pool (un-renamable) or a name already used by another
        // bank (unique display names, trimmed + case-insensitive).
        ShowConsoleMsg("ReaSampler: cannot rename that bank (the pool is un-renamable, "
                       "or another bank already uses that name).\n");
        return;
    }
    persistBankOp("ReaSampler: rename bank");
}

// Delete a named bank. Bindable safe-form of the confirm-on-non-empty guardrail:
// prompt for the bank; if it holds members, a YESNO ShowMessageBox names evacuate as
// the alternative before dropping them (a plain delete orphans those members' files
// until prune — CONTEXT.md §delete). An empty bank deletes with no prompt. The richer
// panel confirm (naming evacuate inline, with a one-click evacuate) arrives in B4.
void doBankDelete() {
    std::string which;
    if (!promptText("ReaSampler: delete bank", "Bank to delete:", "", which)) return;
    const std::string id = bankIdByDisplayName(which);
    if (id.empty()) {
        ShowConsoleMsg(("ReaSampler: no bank named \"" + which + "\".\n").c_str());
        return;
    }
    // Pool early-out: the pool is un-deletable (the model rejects it). Catch it here,
    // BEFORE the non-empty confirm, so typing "Pool" never shows a misleading
    // "delete anyway?" prompt for an operation the model will refuse regardless.
    if (id == kPoolBankId) {
        ShowConsoleMsg("ReaSampler: the pool cannot be deleted.\n");
        return;
    }
    // Read member count BEFORE deleting (the Bank* is invalidated by deleteBank; we do
    // not cache it — resolve size to an int up front).
    const Bank* b = g_session->book().bank(id);
    if (!b) return;  // race-safe: id resolved above but re-check
    const std::size_t members = b->index.size();
    if (members > 0) {
        const std::string msg =
            "\"" + which + "\" holds " + std::to_string(members) +
            (members == 1 ? " sample" : " samples") +
            ".\n\nDeleting drops them from every bank (their files are NOT deleted, "
            "but no bank will reference them until prune).\n\nTo keep the samples, "
            "cancel and Evacuate the bank to the pool first.\n\nDelete anyway?";
        const int r = ShowMessageBox(msg.c_str(), "ReaSampler: delete non-empty bank", 4);
        if (r != 6) return;  // 6 == YES; anything else cancels (SDK ~6544)
    }
    if (!g_session->book().deleteBank(id)) {
        ShowConsoleMsg("ReaSampler: cannot delete that bank (the pool is un-deletable).\n");
        return;
    }
    // S9: bump only when the deleted bank held samples — dropping them changes what a live
    // instance referencing one could play. Deleting an EMPTY bank is purely organizational.
    persistBankOp("ReaSampler: delete bank", /*bumpGeneration=*/members > 0);
}

// Evacuate a named bank: move every member back to the pool (index-only, collapse by
// hash), leaving the bank empty. The pool is un-evacuable (the model rejects it). The
// intended "keep the samples" companion to delete.
void doBankEvacuate() {
    std::string which;
    if (!promptText("ReaSampler: evacuate bank", "Bank to evacuate to the pool:", "",
                    which))
        return;
    const std::string id = bankIdByDisplayName(which);
    if (id.empty()) {
        ShowConsoleMsg(("ReaSampler: no bank named \"" + which + "\".\n").c_str());
        return;
    }
    if (!g_session->book().evacuate(id)) {
        ShowConsoleMsg("ReaSampler: cannot evacuate that bank (the pool is the "
                       "destination, not a source).\n");
        return;
    }
    // S9: evacuate moves members between banks (bank membership changes) -> bump.
    persistBankOp("ReaSampler: evacuate bank", /*bumpGeneration=*/true);
}

// Cycle the active bank forward in ordinal order (pool -> named -> ... -> pool),
// via the pure nextBankId helper. Activating a bank changes the CAPTURE TARGET (the
// next capture lands in the newly-active bank — B2's book().activeIndex() seam) and
// never touches the timeline. Persist so the active id travels with the .rpp.
void doBankActivateNext() {
    std::vector<std::string> ids;
    ids.reserve(g_session->book().size());
    for (const Bank& b : g_session->book().banks()) ids.push_back(b.id);
    const std::string target = nextBankId(ids, g_session->book().activeBankId());
    if (target.empty()) return;  // degenerate (no banks) — cannot happen (pool seeded)
    if (!g_session->book().setActiveBank(target)) return;
    persistBankOp("ReaSampler: activate bank");
}

// Activate the pool directly (the common "back to the default target" jump). Bindable
// direct-by-id form; a general activate-bank-by-name/menu is a B4 affordance.
void doBankActivatePool() {
    if (!g_session->book().setActiveBank(kPoolBankId)) return;
    persistBankOp("ReaSampler: activate bank");
}

// Move or copy the panel's selected samples into a named destination bank (prompted
// by display name). The SOURCE is the bank the selection lives in — the focused
// region's displayed bank (bankPanelSelectedSourceBankId), which under B4's vertical
// split is NOT necessarily the active/capture-target bank (active ≠ shown). Both are
// index-only (files never relocate); move removes the source entry, copy retains it;
// both observe destination collapse-by-hash (bank_book). B4's "move to bank" menu
// drives moveSample/copySample directly with a menu-chosen destination — this bindable
// form is the same operation with a text-prompt destination.
void doBankTransferSelected(bool copy) {
    const std::vector<std::string> selected = bankPanelSelectedSampleIds();
    if (selected.empty()) {
        ShowConsoleMsg("ReaSampler: nothing selected in the bank panel to "
                       "move/copy.\n");
        return;
    }
    const char* verb = copy ? "copy" : "move";
    const std::string title = std::string("ReaSampler: ") + verb + " selected samples";
    std::string destName;
    if (!promptText(title.c_str(), "Destination bank:", "", destName)) return;
    const std::string destId = bankIdByDisplayName(destName);
    if (destId.empty()) {
        ShowConsoleMsg(("ReaSampler: no bank named \"" + destName + "\".\n").c_str());
        return;
    }
    // Source = the bank the selection lives in (the focused region's displayed bank).
    // Pass ids by value — no BankIndex& is cached across the loop's mutations.
    const std::string srcId = bankPanelSelectedSourceBankId();
    if (srcId == destId) {
        ShowConsoleMsg("ReaSampler: source and destination are the same bank.\n");
        return;
    }

    // Tally per-sample transfer outcomes so the no-op guardrail below can decide whether
    // the index actually mutated (R-B). The console summary m11 stripped is gone; the
    // counts remain because the verb-aware undo guardrail is driven by them.
    int ok = 0, collapsed = 0;
    for (const std::string& sampleId : selected) {
        const TransferResult r =
            copy ? g_session->book().copySample(sampleId, srcId, destId)
                 : g_session->book().moveSample(sampleId, srcId, destId);
        switch (r) {
            case TransferResult::Moved:
            case TransferResult::Copied:    ++ok; break;
            case TransferResult::Collapsed: ++collapsed; break;
            // RejectedSampleAbsent and unknown-bank / same-bank (pre-checked above) are
            // no-ops for the guardrail; nothing mutated for those ids.
            case TransferResult::RejectedSampleAbsent:
            case TransferResult::RejectedUnknownBank:
            case TransferResult::RejectedSameBank: break;
        }
    }
    // No-op guardrail — VERB-AWARE (a collapse means different things per verb):
    //   * MOVE collapse: the source entry WAS removed (bank_book moveSample removes
    //     unconditionally before the dest add collapses on hash), so the index DID
    //     mutate — it counts toward opening an undo point.
    //   * COPY collapse: the source is left intact AND the dest already held the hash,
    //     so NOTHING changed — a true index no-op. It must NOT open an undo point.
    // Hence: copy counts only real gains (ok); move counts gains OR collapses.
    const bool mutated = copy ? (ok > 0) : (ok > 0 || collapsed > 0);
    if (mutated) {
        const std::string label =
            std::string("ReaSampler: ") + verb + " sample(s)";
        // S9: a move/copy changes bank membership (a sample arrives in / leaves a bank an
        // instance may reference) -> bump so assigned instances refresh hands-free.
        persistBankOp(label.c_str(), /*bumpGeneration=*/true);
    }
}

// Remove the panel's selected samples from the SOURCE bank (the focused region's
// displayed bank — bankPanelSelectedSourceBankId, same source as move/copy). Index-only
// and 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).
//
// SCOPE (fork R-A): this-bank only — the sole surfaced verb. The RemoveScope::AllBanks
// seam stays latent in the model; nothing here reaches for it.
//
// SILENT REMOVE: removes proceed without a confirm dialog. Recoverability is provided
// by the batched REAPER undo (R-B) — one Ctrl-Z restores the index entry. Files are
// never deleted by remove (orphaned-until-prune is unchanged). hashReferencedElsewhere
// is a tested model API retained for Phase R prune; it has no shell caller here.
void doBankRemoveSelected() {
    const std::vector<std::string> selected = bankPanelSelectedSampleIds();
    if (selected.empty()) {
        ShowConsoleMsg("ReaSampler: nothing selected in the bank panel to remove.\n");
        return;
    }
    const std::string srcId = bankPanelSelectedSourceBankId();
    BankBook& book = g_session->book();
    if (book.bank(srcId) == nullptr) {
        ShowConsoleMsg("ReaSampler: the selection's bank no longer exists.\n");
        return;
    }

    // Perform the removes (this-bank scope). Pass ids by value — no BankIndex& is cached
    // across the loop's mutations. Count real drops so the no-op guardrail can skip the
    // undo point when nothing was removed (every id was already absent).
    int removed = 0;
    for (const std::string& sampleId : selected) {
        if (book.removeSample(sampleId, srcId, RemoveScope::ThisBank) ==
            RemoveResult::Removed)
            ++removed;
        // RejectedSampleAbsent / RejectedUnknownBank are no-ops for the guardrail.
        // (Unknown bank cannot occur — srcId was resolved to a live bank above.)
    }

    // No-op guardrail (R-B): open an undo point only if the index actually mutated.
    // S9: a remove drops a sample from a bank (an instance referencing it must refresh — it
    // will resolve to silence, per the stale-id policy) -> bump.
    if (removed > 0) persistBankOp("ReaSampler: remove sample(s)", /*bumpGeneration=*/true);
}

// Prune bank folder — Phase R (Reclaim), R3: the guarded DESTRUCTIVE step, and the SOLE
// file-deletion entry in ReaSampler. Dry-run FIRST (compute the orphan set, read-only),
// then — only when orphans exist — a blocking CONFIRM showing the SPECIFIC manifest
// (count + reclaimable bytes + the file list, truncated consistent with the 64-cap), then
// on explicit Yes delete EXACTLY that set (session->pruneReclaim, which recomputes the
// pure core fresh and deletes confirmed ∩ freshOrphans — trash-preferred, unlink fallback).
// Zero orphans => informational only, NO confirm ever shown. Cancel deletes nothing.
//
// The full (untruncated) orphan set is captured here for the delete; the dry-run's
// truncated list is only the confirm's readout. No ext-state is written and no undo point
// is opened (file deletion is not REAPER-undoable and pruneReclaim mutates no project
// state) — a Ctrl-Z after a prune correctly cannot claim to restore deleted files.
void doBankPruneFolder() {
    const PruneReport report = g_session->pruneDryRun();

    if (report.count == 0) {
        ShowConsoleMsg("ReaSampler prune: no orphaned files to reclaim.\n");
        return;
    }

    // The EXACT set the delete will target — full, untruncated, so what the confirm
    // summarises (count + bytes) matches what pruneReclaim reclaims. Captured before the
    // confirm so the confirm and the delete reason about the same enumeration.
    const std::vector<std::string> orphanSet = g_session->pruneOrphanSet();

    // Confirm-with-manifest: count + bytes exact; the file list is the dry-run's 64-capped
    // list (the same clip the R2 readout used), with a "N more not shown" tail when clipped.
    std::string msg =
        "ReaSampler prune will PERMANENTLY reclaim " + std::to_string(report.count) +
        " orphaned file(s), freeing " + std::to_string(report.totalBytes) + " bytes.\n\n"
        "These files are no longer referenced by any bank and were created by ReaSampler.\n"
        "They will be moved to the Recycle Bin on Windows (recoverable), or deleted on "
        "other platforms.\n\n";
    for (const std::string& rel : report.orphans) msg += "  " + rel + "\n";
    if (report.truncated) {
        msg += "  ... (" + std::to_string(report.count - report.orphans.size()) +
               " more not shown)\n";
    }
    msg += "\nReclaim these files now?";

    const int r = ShowMessageBox(msg.c_str(), "ReaSampler: prune bank folder", 4);
    if (r != 6) {  // 6 == YES; anything else cancels -> delete NOTHING (SDK ~6544)
        ShowConsoleMsg("ReaSampler prune: cancelled -- nothing deleted.\n");
        return;
    }

    // Confirmed -> delete exactly the confirmed set (recomputed fresh, stale entries skipped).
    const PruneDeletionResult del = g_session->pruneReclaim(orphanSet);

    std::string done = "ReaSampler prune: reclaimed " +
                       std::to_string(del.reclaimedCount) + " file(s), " +
                       std::to_string(del.reclaimedBytes) + " bytes" +
                       (del.usedTrash ? " (to Recycle Bin)" : " (deleted)") + ".";
    if (del.skippedCount > 0) {
        done += " " + std::to_string(del.skippedCount) +
                " file(s) skipped (locked, or changed since the report).";
    }
    done += "\n";
    ShowConsoleMsg(done.c_str());
}

}  // namespace

// Persists a completed bank-index verb as a SINGLE batched REAPER undo point (R-B) —
// one bank op = one Ctrl-Z. Declared in actions.h so bank_panel.cpp can call it
// without duplicating the undo logic.
//
// WHY THIS WRAPS AND persistBook() DOES NOT: a bank verb mutates ONLY our project
// ext-state (SetProjExtState under "reasampler"), which REAPER's undo system captures
// iff UNDO_STATE_MISCCFG is set in the Undo_EndBlock2 flags — the SDK documents
// MISCCFG as covering "extensions!" project ext-state (reaper_plugin.h ~1544, ~1199).
// We pass exactly UNDO_STATE_MISCCFG (not -1 / UNDO_STATE_ALL as the item-move family
// does): a bank verb touches no tracks, FX, items, or envelopes, so snapshotting them
// would be both heavier and semantically wrong. persistBook() (= SetProjExtState) runs
// INSIDE the block so the post-mutation ext-state is the block's "after" image.
//
// NO-OP GUARDRAIL: callers invoke this ONLY after the model mutation succeeded — a
// rejected op (duplicate name, un-deletable pool, etc.) returns before reaching here,
// so no dangling/empty undo point is ever opened for a rejected op.
//
// UNSAVED-PROJECT GUARDRAIL: on an unsaved / no-active project persistBook() no-ops
// (nothing is written to ext state). We must still CLOSE the block we opened, but with
// an EMPTY label and a zero flag so REAPER DISCARDS the point instead of recording a
// no-effect undo entry — mirroring view.cpp's empty-plan close. The in-session model
// change stands and persists on the user's next save; it just earns no undo point until
// there is a project to persist into (undo of an unsaved bank op has nothing to roll
// back to anyway). The Begin/End must still be balanced, hence the close-either-way.
void persistBankOp(const char* label, bool bumpGeneration) {
    Undo_BeginBlock2(nullptr);
    // S9: bump the bank-generation counter INSIDE the block, before persistBook(), so the
    // fresh generation rides the same ext-state write the persist makes (persistBook() ->
    // saveToActiveProject() stamps bankGeneration()). Bumped only for content-changing verbs
    // (the caller decides); a pure-organizational verb passes false and leaves the counter be,
    // so a rename/activate does not needlessly refresh live instances.
    if (bumpGeneration) g_session->bumpBankGeneration();
    const bool persisted = persistBook();
    if (persisted)
        Undo_EndBlock2(nullptr, label, UNDO_STATE_MISCCFG);
    else
        Undo_EndBlock2(nullptr, "", 0);  // no ext-state write -> discard the empty point
}

void bankRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session) {
    g_session = session;  // shared with the Design View family; same live session

    g_cmdBankCreate = registerAction(rec, kIdBankCreate, g_accelBankCreate,
                                     "create bank");
    g_cmdBankRename = registerAction(rec, kIdBankRename, g_accelBankRename,
                                     "rename bank");
    g_cmdBankDelete = registerAction(rec, kIdBankDelete, g_accelBankDelete,
                                     "delete bank");
    g_cmdBankEvacuate = registerAction(rec, kIdBankEvacuate, g_accelBankEvacuate,
                                       "evacuate bank to pool");
    g_cmdBankActivateNext = registerAction(rec, kIdBankActivateNext, g_accelBankActivateNext,
                                           "activate next bank (cycle)");
    g_cmdBankActivatePool = registerAction(rec, kIdBankActivatePool, g_accelBankActivatePool,
                                           "activate pool");
    g_cmdBankMoveSel = registerAction(rec, kIdBankMoveSel, g_accelBankMoveSel,
                                      "move selected samples to bank");
    g_cmdBankCopySel = registerAction(rec, kIdBankCopySel, g_accelBankCopySel,
                                      "copy selected samples to bank");
    g_cmdBankRemoveSel = registerAction(rec, kIdBankRemoveSel, g_accelBankRemoveSel,
                                        "remove selected samples");
    g_cmdBankPoolFull = registerAction(rec, kIdBankPoolFull, g_accelBankPoolFull,
                                       "toggle pool full-height");
    g_cmdBankBanksFull = registerAction(rec, kIdBankBanksFull, g_accelBankBanksFull,
                                        "toggle banks full-height");
    // Phase R, R2: the "Prune bank folder" action (report-only in this wave; R3 extends
    // the confirm-and-delete step behind this SAME forever-stable id).
    g_cmdBankPruneFolder = registerAction(rec, kIdBankPruneFolder, g_accelBankPruneFolder,
                                          "prune bank folder");
}

bool bankHandleCommand(int command) {
    if (command == 0 || !g_session) return false;

    if (command == g_cmdBankCreate)       { doBankCreate(); return true; }
    if (command == g_cmdBankRename)       { doBankRename(); return true; }
    if (command == g_cmdBankDelete)       { doBankDelete(); return true; }
    if (command == g_cmdBankEvacuate)     { doBankEvacuate(); return true; }
    if (command == g_cmdBankActivateNext) { doBankActivateNext(); return true; }
    if (command == g_cmdBankActivatePool) { doBankActivatePool(); return true; }
    if (command == g_cmdBankMoveSel)      { doBankTransferSelected(false); return true; }
    if (command == g_cmdBankCopySel)      { doBankTransferSelected(true);  return true; }
    if (command == g_cmdBankRemoveSel)    { doBankRemoveSelected(); return true; }
    if (command == g_cmdBankPoolFull)     { bankPanelToggledPoolFullHeight(); return true; }
    if (command == g_cmdBankBanksFull)    { bankPanelToggledBanksFullHeight(); return true; }
    if (command == g_cmdBankPruneFolder)  { doBankPruneFolder(); return true; }

    return false;  // not ours — caller's hookcommand keeps looking
}

int bankPruneCommandId() { return g_cmdBankPruneFolder; }

void bankUnregisterActions(reaper_plugin_info_t* rec) {
    // Mirror-unregister with '-'-prefixed strings, reverse of registration order. Each
    // '-command_id' re-presents the same interned channel-qualified id (channelIdFor).
    rec->Register("-gaccel", (void*)&g_accelBankPruneFolder);
    rec->Register("-command_id", (void*)channelIdFor(kIdBankPruneFolder));
    rec->Register("-gaccel", (void*)&g_accelBankBanksFull);
    rec->Register("-command_id", (void*)channelIdFor(kIdBankBanksFull));
    rec->Register("-gaccel", (void*)&g_accelBankPoolFull);
    rec->Register("-command_id", (void*)channelIdFor(kIdBankPoolFull));
    rec->Register("-gaccel", (void*)&g_accelBankRemoveSel);
    rec->Register("-command_id", (void*)channelIdFor(kIdBankRemoveSel));
    rec->Register("-gaccel", (void*)&g_accelBankCopySel);
    rec->Register("-command_id", (void*)channelIdFor(kIdBankCopySel));
    rec->Register("-gaccel", (void*)&g_accelBankMoveSel);
    rec->Register("-command_id", (void*)channelIdFor(kIdBankMoveSel));
    rec->Register("-gaccel", (void*)&g_accelBankActivatePool);
    rec->Register("-command_id", (void*)channelIdFor(kIdBankActivatePool));
    rec->Register("-gaccel", (void*)&g_accelBankActivateNext);
    rec->Register("-command_id", (void*)channelIdFor(kIdBankActivateNext));
    rec->Register("-gaccel", (void*)&g_accelBankEvacuate);
    rec->Register("-command_id", (void*)channelIdFor(kIdBankEvacuate));
    rec->Register("-gaccel", (void*)&g_accelBankDelete);
    rec->Register("-command_id", (void*)channelIdFor(kIdBankDelete));
    rec->Register("-gaccel", (void*)&g_accelBankRename);
    rec->Register("-command_id", (void*)channelIdFor(kIdBankRename));
    rec->Register("-gaccel", (void*)&g_accelBankCreate);
    rec->Register("-command_id", (void*)channelIdFor(kIdBankCreate));

    // g_session is shared with the Design View family; designViewUnregisterActions
    // also nulls it. Nulling twice is harmless. Leave it to whichever runs last.
    g_session = nullptr;
}

}  // namespace reasampler
