// main.cpp — the SINGLE translation unit that OWNS the REAPER API pointers.
//
// This file is the entire contract between REAPER and the extension:
//   * At startup REAPER scans UserPlugins/ for reaper_*.dll|dylib|so and
//     dlopen()s each one, then looks up ONE exported symbol: ReaperPluginEntry
//     (that name is produced by the REAPER_PLUGIN_ENTRYPOINT macro).
//   * REAPER calls it, handing over `rec` — a small dispatch struct.
//       - rec->GetFunc(name)      resolves any REAPER API function to a pointer
//       - rec->Register(what,ptr) plugs OUR callbacks into REAPER
//   * REAPERAPI_LoadAPI(rec->GetFunc) walks reaper_plugin_functions.h and
//     fills in every global function pointer (ShowConsoleMsg, InsertMedia...).
//
// Exactly ONE .cpp defines REAPERAPI_IMPLEMENT (this one) — that allocates
// storage for those global pointers. Every other .cpp includes
// reaper_plugin_functions.h WITHOUT the define and gets `extern` declarations.

#define REAPERAPI_IMPLEMENT
#include "reaper_plugin.h"
#include "reaper_plugin_functions.h"

#include <cstddef>
#include <deque>
#include <memory>
#include <string>
#include <utility>

#include <vector>

#include "actions.h"
#include "app_version.h"
#include "bank_model.h"
#include "bank_panel.h"
#include "batch_capture.h"
#include "capture.h"
#include "ingest.h"
#include "insert.h"
#include "persist.h"
#include "provenance.h"
#include "provenance_shell.h"
#include "render_settings.h"
#include "track_guid.h"
#include "view.h"

#include <filesystem>  // project-dir derivation for provenance parent resolution

// Persistent action-id family (Phase V, V4 — channel-qualified). Every bindable action
// mints its command id from commandIdPrefix() + a per-action SUFFIX, and its Actions-list
// name from actionDisplayPrefix() + a phrase, both derived from the ONE channel bit in the
// pure app_version module (channelCommandId / channelActionName). Stable rebuilds the exact
// shipped id ("CEREBELLUM_REASAMPLER_CAPTURE_TRACK"); beta yields the isolated forever-
// family id ("CEREBELLUM_REASAMPLER_BETA_CAPTURE_TRACK"). FOREVER-STABLE per channel: a
// shipped suffix is as permanent as the prefix; user keybindings key off the composed id.
//
// The composed id strings are held here for the module's lifetime (idStore) so both the
// register call and the mirroring '-command_id' unregister pass the SAME stable pointer.
// A std::deque (NOT vector) is used deliberately: it never invalidates references to
// existing elements on push_back, so a c_str() handed out early stays valid after later
// interning — the unload path re-presents these same pointers.
static std::deque<std::string> g_idStore;

// Interns a composed command-id string for the module lifetime and returns its C string.
// Appended-to only during startup registration and read on unload; never cleared until
// process exit, and deque guarantees the returned pointer stays valid.
static const char* internCmdId(const std::string& suffix) {
    g_idStore.push_back(reasampler::channelCommandId(suffix));
    return g_idStore.back().c_str();
}

// Globals other files reference via `extern`.
REAPER_PLUGIN_HINSTANCE g_hInst = nullptr;  // this module's instance handle
reaper_plugin_info_t*   g_rec   = nullptr;  // REAPER's dispatch struct

// ---- Capture action family (two FX scopes) ---------------------------------
// Two bindable SCOPE actions from captureActionTable() (render_settings, pure):
// capture item / track. Each infers its range (razor-else-time) and enforces the
// FX-scope invariant via FX-bypass-around-render (FxBypassGuard):
//   Item   -> take/item FX only  (bypass the item's track + ancestors + master).
//   Track  -> item FX + track's own FX (bypass ancestors + master).
// There is NO master scope — to capture the master you render a track. (The master
// track's FX/gain/pan are STILL neutralized for both scopes as the out-of-scope
// chain — master is a bypass target, not a capture scope.) The retired M7
// CAPTURE_TRACKS_WET / CAPTURE_ITEMS_WET / CAPTURE_RAZOR_WET ids AND the removed
// CAPTURE_MASTER / CAPTURE_MASTER_REALTIME ids are mirror-unregistered on unload so
// old keybindings clear cleanly.
//
// The minted command ids parallel the table rows 1:1 (same index). gaccel storage
// must outlive registration (REAPER holds each pointer), so both vectors are file-
// scope and sized to the table. FOREVER-STABLE id strings live in the table.
static std::vector<int>                g_captureCmdIds;
static std::vector<gaccel_register_t>  g_captureAccels;
// Channel-qualified capture-action labels, one per table row. REAPER holds each gaccel's
// `desc` pointer, so the composed strings live here for the module lifetime (parallel to
// g_captureAccels; never resized after the registration loop sets it).
static std::vector<std::string>        g_captureDescs;

// Retired capture-action command-id SUFFIXES. Kept ONLY to mirror-unregister them on
// unload so a user's stale keybindings are cleaned up. Never re-register these. Composed
// through the channel prefix at unload (channelCommandId) so a beta unload clears beta-
// qualified retired ids and a stable unload clears stable's — each channel cleans up only
// its own family.
//   * The M7 four-mode ids (tracks/items/razor WET).
//   * CAPTURE_MASTER and CAPTURE_MASTER_REALTIME — the master offline scope and the
//     master realtime action are REMOVED (capture is now item + track only; realtime
//     taps the selected track). Their shipped ids are retired so old keybindings clear.
//   * CAPTURE_ITEM_TAIL and CAPTURE_TRACK_TAIL — the former per-action tail variants
//     are REMOVED; tail is now a panel-setting toggle, not a paired action. Retired so
//     old keybindings clear.
static const char* const kRetiredCaptureCmdSuffixes[] = {
    "CAPTURE_TRACKS_WET",
    "CAPTURE_ITEMS_WET",
    "CAPTURE_RAZOR_WET",
    "CAPTURE_MASTER",
    "CAPTURE_MASTER_REALTIME",
    "CAPTURE_ITEM_TAIL",
    "CAPTURE_TRACK_TAIL",
};

// Command id for "ReaSampler: toggle bank panel" (M5). FOREVER-STABLE string.
// The docked grid window is display-only this wave (Wave A) — the action just
// shows/hides it; it never captures, inserts, or mutates the bank.
static int g_cmdToggleBankPanel = 0;

// Command ids for the M6 insert actions. FOREVER-STABLE strings. Two variants that
// differ ONLY in the InsertOptions they build: the default inserts at native length
// (no stretch, no conform); the "conform" variant is the EXPLICIT opt-in to REAPER's
// try-to-match-project-tempo path (CONTEXT.md §insert: conform is opt-in, never
// silent). Both read the bank panel's current selection and place at the edit cursor.
static int g_cmdInsertSelected = 0;
static int g_cmdInsertSelectedConform = 0;

// Command ids for the M11 batch-capture actions. NEW FOREVER-STABLE strings. One action
// fires N captures: CAPTURE_BATCH_ITEMS -> one bank sample per selected item (item scope);
// CAPTURE_BATCH_RAZOR -> one bank sample per razor area (track scope, each area's range).
// Each unit honors every precision invariant; the original selection is restored on every
// exit path. Bank-only, never places on the timeline (load-bearing principle).
static int g_cmdCaptureBatchItems = 0;
static int g_cmdCaptureBatchRazor = 0;

// Command id for the "capture selected track (realtime)" action. NEW FOREVER-STABLE
// string. Records the selected track's OWN output in realtime (transport-driven) into
// a hidden temp track via RealtimeRecordBackend, then moves the recorded file into the
// bank. The realtime SIBLING of the offline CAPTURE_TRACK scope action: same range
// logic (razor-else-time), same track selection, same bank/persist path, different
// backend. Dialog-free. (Replaces the removed CAPTURE_MASTER_REALTIME action.)
static int g_cmdCaptureTrackRealtime = 0;

// Command id for the M10 "re-capture from source" action. NEW FOREVER-STABLE string
// (suffix RECAPTURE_FROM_SOURCE). Regenerates the bank panel's selected PROVENANCED
// sample from its recorded source's current state and updates the Sample in place —
// BANK-ONLY, never places on the timeline (load-bearing principle). Reports the no-
// provenance / vanished-source / drift cases to the console (a direct response to an
// explicit action, allowed by the console policy).
static int g_cmdRecaptureFromSource = 0;

// Command id for the S8 "capture selected item / time-selection into bank + assign"
// action. NEW FOREVER-STABLE string (suffix CAPTURE_ITEM_ASSIGN). Reuses the offline
// Item-scope capture path (RunCapture) verbatim — same razor-else-time range, same
// FX-scope neutralize, same bank/persist landing — then writes an S8 assignment request
// so the active sampler instance plays the just-captured sample on its next reload. NEVER
// inserts a timeline item (the capture/placement separation holds; assign is a bank-index
// + instance-selection act). Lives in the capture family (not the ingest family) because
// it leans on main.cpp's capture render machinery, which is not exposed cross-module.
static int g_cmdCaptureItemAssign = 0;

// Command id for the M8 "cancel realtime capture" action. FOREVER-STABLE string.
// Aborts the in-flight realtime capture (stop + restore, non-destructive) so a user
// who started a long capture can bail without waiting for the range end or hunting for
// the transport-stop. No-op (with a note) when nothing is in flight.
static int g_cmdCancelRealtime = 0;

// Command id for the Phase V "show version" action. FOREVER-STABLE string. On demand
// ONLY — prints the CMake-sourced version string to the console when fired. This is the
// SOLE new console output the versioning wave adds; there is no unconditional startup
// version print (routine console chatter was deliberately removed — it pops the console
// window). The user copies this line into a bug report.
static int g_cmdShowVersion = 0;

// The persistence session (M4): owns the in-memory BankIndex and bridges it to
// project ext state. A timer tick drives g_session.poll() to detect project
// load / Save-As; capture adds Samples to g_session.bank() — which (B2) resolves to
// the ACTIVE bank's index inside the session's BankBook; after a capture we serialize
// the book back into the active project's ext state (the `banks` key) so it travels
// with the .rpp. Replaces the M3 session-only g_bank.
static reasampler::ReaSamplerSession g_session;

// --- M8 in-flight realtime capture (async, timer-driven) --------------------
// A realtime record spans many timer ticks (it takes end-start wall-clock seconds
// and must NOT block REAPER's UI). The action STARTS it (g_rtBackend.begin), which
// returns immediately with the in-flight state owned here; OnTimer drives it
// (g_rtBackend.tick) each tick until a terminal verdict; then this pointer is
// cleared. Non-null == a capture is in progress (used to reject a second one, and to
// abort on project switch / unload).
static reasampler::RealtimeRecordBackend    g_rtBackend;
static reasampler::RealtimeCaptureHandle    g_rtCapture;

// The ReaProject* the in-flight capture belongs to (opaque, compare-only) — lets
// OnTimer detect a project switch mid-capture and abort+restore rather than leak the
// temp track/arm/transport into or across projects. Only meaningful when
// g_rtCapture != nullptr.
static ReaProject* g_rtCaptureProject = nullptr;

// Commit a finished realtime capture (a Done tick/abort with an Ok result): add the
// Sample to the ACTIVE bank (g_session.bank() resolves to book.activeIndex() — B2),
// persist + MarkProjectDirty. Shared by the tick-completion path and the abort
// paths. On a non-Ok result, logs the failure only.
static void CommitRealtimeResult(const reasampler::CaptureResult& res)
{
    if (res.status != reasampler::CaptureStatus::Ok)
    {
        ShowConsoleMsg(("ReaSampler realtime capture failed: " + res.message + "\n").c_str());
        return;
    }
    g_session.bank().add(res.sample);
    // B-cap: record the file the capture created in the owned-file manifest, at the same
    // point the Sample is added and before the same persist. Recorded regardless of the
    // index AddResult — even a hash-collapse still WROTE a file the tool owns, and the
    // manifest dedups a repeat path itself (Phase R prune reconciles manifest vs index).
    g_session.owned().add(res.sample.relativePath);
    // S9: a capture add changes what a live instance could play (a new sample landed in the
    // active bank) -> bump before the persist so the stamped generation refreshes instances.
    g_session.bumpBankGeneration();
    g_session.saveToActiveProject();  // persist book + manifest + generation + MarkProjectDirty (travels with .rpp)
}

// Advance any in-flight realtime capture one tick. Cheap when none is running (a
// null check) and fast even mid-record (tick() only reads the transport until the
// terminal tick). Detects a project switch mid-capture and aborts+restores so the
// capture never leaks across projects. Called from OnTimer BEFORE session.poll() so
// poll's project-switch handling sees a cleaned-up project.
static void DriveRealtimeCapture()
{
    if (!g_rtCapture) return;

    // Project switch guard: if the active project is no longer the one the capture
    // belongs to, a new/other project became active mid-record — abort + restore
    // (into the ORIGINAL project the state is bound to) and drop it. Do NOT finalize
    // into the new project.
    ReaProject* active = EnumProjects(-1, nullptr, 0);
    if (active != g_rtCaptureProject)
    {
        reasampler::RealtimeTickResult r = g_rtBackend.abort(*g_rtCapture);
        // Only commit if the ORIGINAL project is still open and active would be it —
        // on a switch we restored into the original but must not persist into the
        // now-active foreign project. Log the outcome without persisting. On a Failed
        // abort surface abort()'s own message — it distinguishes a clean tab-switch
        // abort from the closed-project DROP (the captured project was closed mid-record,
        // review §1: nothing restored because the pointers were already freed).
        if (r.status == reasampler::RealtimeTickStatus::Done)
            ShowConsoleMsg("ReaSampler realtime capture: project switched mid-record -- "
                           "captured audio restored into the original project; not "
                           "persisted to avoid crossing projects.\n");
        else
            ShowConsoleMsg(("ReaSampler realtime capture: project changed mid-record -- " +
                            r.result.message + "\n").c_str());
        g_rtCapture.reset();
        g_rtCaptureProject = nullptr;
        return;
    }

    reasampler::RealtimeTickResult r = g_rtBackend.tick(*g_rtCapture);
    if (r.status == reasampler::RealtimeTickStatus::InProgress) return;

    // Terminal (Done or Failed): commit/log and drop the in-flight state.
    CommitRealtimeResult(r.result);
    g_rtCapture.reset();
    g_rtCaptureProject = nullptr;
}

// The timer callback REAPER runs periodically (registered via "timer"). It only
// forwards to the session poll — cheap per tick (reads the active project id and
// its .rpp path, acts only on a change).
static void OnTimer()
{
    // Advance any in-flight realtime capture FIRST, so a project switch is caught and
    // the capture torn down/restored before session.poll() reacts to that switch.
    DriveRealtimeCapture();

    g_session.poll();

    // D4 reapply-on-open glue. persist stays MODEL-ONLY (it loads the saved view
    // model but deliberately does NOT apply visibility — that would couple persist
    // to the view shell). Instead poll() raises a one-shot load signal; here — the
    // integration layer that already drives both persist and the view shell — we
    // drain it and reapply the SAVED active mode's visibility/processing so opening a
    // project saved in Design mode parks the Arrange tracks automatically, no manual
    // toggle. Fires exactly once per load (consumeLoadSignal clears it); idle ticks
    // skip it. proj = nullptr -> REAPER's active project (the one poll just loaded).
    //
    // The SAME signal re-arms the bank panel's new-content detector: a load must
    // re-baseline the detector against the just-loaded project's content so its
    // pre-existing tracks are never mis-detected as "new" and mass-tagged into the
    // active mode (the reload-mis-tag bug). Notify BEFORE the reapply so the detector's
    // re-arm and the model restore ride the one authoritative load event.
    if (g_session.consumeLoadSignal()) {
        reasampler::bankPanelNotifyProjectLoaded();
        // Reconcile the restored lane-ownership index against the live project's lanes
        // FIRST (via REAPER's durable P_LANENAME — the cross-session source of truth),
        // so a saved lane-split project's managed/manual classification is correct
        // before the active mode's lane visibility is reapplied. Never re-mints, never
        // mass-tags — it only records managed ownership recovered from lane names.
        reasampler::reconcileManagedLanes(g_session.view(), nullptr);
        reasampler::applyMode(g_session.view(), g_session.view().activeModeId(), nullptr);
    }

    // Reflect a live bank change (capture / project load) in the docked grid.
    // Cheap when the bank is unchanged (a fingerprint compare); repaints only on
    // an actual change. No-op when the panel is closed.
    reasampler::bankPanelRefresh();
}

// --- projectconfig hook: reload the session on undo/redo (R-B) ---------------
// A Ctrl-Z / Ctrl-Shift-Z rolls back / forward the "reasampler" project ext state on
// disk but keeps the SAME project identity (ReaProject*/GUID/.rpp path), so the timer's
// identity poll reads it as NoOp and never re-reads ext state — the in-memory book/view
// would stay stale until close+reopen. REAPER's projectconfig extension fires
// BeginLoadProjectState on every project-state (re)load, INCLUDING an undo/redo restore
// (isUndo == true for both). We hook it to drive a session reload.
//
// TIMING (the crux): BeginLoadProjectState is documented (reaper_plugin.h ~1203) as
// firing BEFORE any state restore. Reading GetProjExtState synchronously here would
// return the PRE-undo value. So we do NOT read here — we raise a one-shot reload request
// (g_session.requestReload()) that OnTimer's poll() drains on the NEXT tick, by which
// point REAPER has finished restoring the <EXTSTATE> block and GetProjExtState returns
// the POST-undo value. Deterministic, event-driven — NOT ext-state content polling.
//
// GATED ON isUndo: a normal project open also fires BeginLoadProjectState (isUndo=false);
// we ignore that here so a normal open flows solely through the timer's identity-transition
// Load path (no double load). Only undo/redo (isUndo=true) requests the reload.
static void OnBeginLoadProjectState(bool isUndo, project_config_extension_t* /*reg*/)
{
    if (isUndo)
        g_session.requestReload();
}

// ProcessExtensionLine / SaveExtensionConfig are intentional no-ops: ReaSampler stores
// its state via project EXT STATE (SetProjExtState/GetProjExtState under "reasampler"),
// which REAPER persists in its own <EXTSTATE> RPP block — NOT via this extension's own
// project lines. We register the struct ONLY for the BeginLoadProjectState undo/redo
// notification. Returning false from ProcessExtensionLine means "not our line" so REAPER
// keeps dispatching (we claim none). SaveExtensionConfig writes nothing.
static bool OnProcessExtensionLine(const char* /*line*/, ProjectStateContext* /*ctx*/,
                                   bool /*isUndo*/, project_config_extension_t* /*reg*/)
{
    return false;  // we own no project lines — ext state carries our data
}

static void OnSaveExtensionConfig(ProjectStateContext* /*ctx*/, bool /*isUndo*/,
                                  project_config_extension_t* /*reg*/)
{
    // Nothing to write: our data rides in ext state, not project lines.
}

// Storage must outlive registration — REAPER holds this pointer until we unregister it.
static project_config_extension_t g_projectConfig{
    &OnProcessExtensionLine,
    &OnSaveExtensionConfig,
    &OnBeginLoadProjectState,
    nullptr,  // userData
};

// --- Scope-action source resolution -----------------------------------------
// The three scope actions (item / track / master) each resolve to (1) an exact
// render range in project seconds — razor-else-time, inferred here — and (2) the
// set of source TRACKS whose ancestor chains drive the FX-bypass plan. All reads
// are non-destructive: selection, razor, and time selection are read, never
// mutated. Returning false means "nothing to capture" (empty selection / no
// range); the caller reports it and writes nothing.

// The resolved source: exact bounds + the source tracks (for FX-bypass + Sample
// provenance GUIDs). `sourceTracks` holds the item-owning tracks (Item scope) or the
// selected tracks (Track scope).
struct ResolvedSource
{
    double                    startSeconds = 0.0;
    double                    endSeconds   = 0.0;
    std::vector<MediaTrack*>  sourceTracks;  // item-owning tracks / selected tracks
    std::vector<std::string>  trackGuids;    // canonical GUIDs of sourceTracks
};

// Time selection -> exact bounds (no rounding). GetSet_LoopTimeRange(isSet=false,
// isLoop=false) reads the current time selection.
static bool resolveTimeSelection(double& start, double& end)
{
    start = 0.0; end = 0.0;
    GetSet_LoopTimeRange(false, false, &start, &end, false);
    return end > start;
}

// Reads every track's P_RAZOREDITS (SDK header ~2899: space-separated triples of
// start, end, envGuidString), parses the track-audio areas (pure parseRazorEdits),
// and returns the union bound. Reads only — never clears the razor selection.
// Returns false when no track-audio razor area exists on any track.
static bool resolveRazorRange(double& start, double& end)
{
    std::vector<reasampler::RazorRange> allRanges;
    const int n = CountTracks(nullptr);
    for (int i = 0; i < n; ++i)
    {
        MediaTrack* tr = GetTrack(nullptr, i);
        if (!tr) continue;
        std::vector<char> buf(8192, '\0');
        if (!GetSetMediaTrackInfo_String(tr, "P_RAZOREDITS", buf.data(), false))
            continue;
        std::vector<reasampler::RazorRange> ranges =
            reasampler::parseRazorEdits(std::string(buf.data()));
        for (auto& r : ranges) allRanges.push_back(r);
    }
    if (allRanges.empty()) return false;
    reasampler::RazorRange u = reasampler::razorUnionBounds(allRanges);
    start = u.startSeconds;
    end   = u.endSeconds;
    return end > start;
}

// Infers the render RANGE for any scope: razor union when a razor area is present,
// else the time selection (pure inferRangeSource decides which). Orthogonal to
// scope. Returns false (with a reason) when neither yields a non-empty range.
static bool resolveRange(double& start, double& end, std::string& why)
{
    double rzStart = 0.0, rzEnd = 0.0;
    const bool hasRazor = resolveRazorRange(rzStart, rzEnd);
    if (reasampler::inferRangeSource(hasRazor) == reasampler::RangeSource::Razor)
    {
        start = rzStart; end = rzEnd;
        return true;  // resolveRazorRange already verified end > start
    }
    if (resolveTimeSelection(start, end)) return true;
    why = "make a razor area or a time selection first";
    return false;
}

// Current project's directory (parent of its .rpp), forward-slashed, no trailing
// slash — the same derivation capture.cpp does internally, needed here so M10 can
// resolve the bank's relative paths to absolute for parent detection. Empty for an
// unsaved project (EnumProjects writes an empty .rpp path), which makes every bank
// file resolve empty -> no false parentage. Read-only; mutates nothing.
static std::string currentProjectDir()
{
    std::vector<char> buf(4096, '\0');
    EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
    const std::string rpp(buf.data());
    if (rpp.empty()) return {};
    namespace fs = std::filesystem;
    std::string dir = fs::path(rpp).parent_path().string();
    for (char& c : dir) if (c == '\\') c = '/';
    if (dir.size() > 1 && dir.back() == '/') dir.pop_back();
    return dir;
}

// Maps a capture FX scope onto the pure provenance scope (kept decoupled so the
// pure provenance module does not depend on render_settings).
static reasampler::ProvenanceScope provenanceScopeFor(reasampler::CaptureScope scope)
{
    return scope == reasampler::CaptureScope::Item ? reasampler::ProvenanceScope::Item
                                                   : reasampler::ProvenanceScope::Track;
}

// Builds the M10 provenance for a capture IF it genuinely resamples from a bank
// sample, else returns nullopt (the common, non-resample case). Detection rule
// (stated honestly): the capture's source item media file(s) must all resolve, by
// exact normalized absolute path, to ONE bank sample's file (detectParent). On a
// match, records that sample's id as the parent plus a THIN capture-recipe
// fingerprint (P1=a) — scope + source mode + exact range + tail + rate + channels +
// source track GUIDs + the in-scope source FX-chain identity — so "re-capture from
// source" can replay the request and report drift. NEVER a serialized chain to
// restore. Item scope reads the active take's TakeFX chain (via TakeFX_*) per
// selected item, combined in item order; Track scope reads the track FX chain.
static std::optional<reasampler::Provenance> buildCaptureProvenance(
    const reasampler::CaptureRequest& req,
    reasampler::CaptureScope scope,
    const ResolvedSource& src)
{
    const std::string projectDir = currentProjectDir();
    const std::vector<reasampler::BankFileRef> bankFiles =
        reasampler::bankFileRefs(g_session.book(), projectDir);

    // The "what audio is being captured" source set depends on scope: item scope uses
    // the SELECTED items (the user picked them); track scope uses the range-overlapping
    // items ON the source tracks (the user picked the track, not the item).
    const std::vector<std::string> sourceFiles =
        scope == reasampler::CaptureScope::Item
            ? reasampler::selectedItemSourceFiles()
            : reasampler::trackItemSourceFiles(src.sourceTracks, req.startSeconds,
                                               req.endSeconds);

    const std::optional<std::string> parentId =
        reasampler::detectParent(sourceFiles, bankFiles);
    if (!parentId) return std::nullopt;  // not a resample-from-sample — no provenance

    reasampler::CaptureRecipe recipe;
    recipe.scope        = provenanceScopeFor(scope);
    recipe.sourceMode   = static_cast<int>(req.sourceMode);
    recipe.startSeconds = req.startSeconds;
    recipe.endSeconds   = req.endSeconds;
    recipe.tailMode     = static_cast<int>(req.tailMode);
    recipe.tailMs       = req.tailMs;
    recipe.sampleRate   = req.sampleRate;
    recipe.channelCount = req.channelCount;
    recipe.trackGuids   = req.trackGuids;
    // The in-scope FX-chain identity:
    //   Track scope — per-track chains combined in track order (TrackFX_*).
    //   Item scope  — per-item active-take chains combined in item order (TakeFX_*);
    //     the owning track's FX chain is OUT OF SCOPE for an item capture and must
    //     not be fingerprinted here (it is bypassed during render, not heard).
    if (scope == reasampler::CaptureScope::Item) {
        const int n = CountSelectedMediaItems(nullptr);
        std::vector<MediaItem*> items;
        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) items.push_back(it);
        }
        recipe.fxChainIdentity = reasampler::fxChainIdentityForItems(items);
    } else {
        std::vector<std::string> perTrack;
        perTrack.reserve(src.sourceTracks.size());
        for (MediaTrack* tr : src.sourceTracks)
            perTrack.push_back(reasampler::fxChainIdentityForTrack(tr));
        recipe.fxChainIdentity = reasampler::combineChainIdentities(perTrack);
    }

    reasampler::Provenance prov;
    prov.parentSampleId  = *parentId;
    prov.fxChainSnapshot = reasampler::buildFingerprint(recipe);
    return prov;
}

// Collects the selected tracks (Track scope) into out.sourceTracks + GUIDs.
static bool collectSelectedTracks(ResolvedSource& out)
{
    const int n = CountSelectedTracks(nullptr);  // nullptr = active project
    if (n <= 0) return false;
    for (int i = 0; i < n; ++i)
    {
        MediaTrack* tr = GetSelectedTrack(nullptr, i);
        if (!tr) continue;
        out.sourceTracks.push_back(tr);
        std::string g = reasampler::guidString(tr);
        if (!g.empty()) out.trackGuids.push_back(std::move(g));
    }
    return !out.sourceTracks.empty();
}

// Collects the tracks that own the selected items (Item scope) into
// out.sourceTracks (deduped) — these are the tracks whose FX must be bypassed so an
// item capture hears take/item FX only. GetMediaItem_Track(item) gives the owning
// track (SDK header, verify). GUIDs recorded for provenance.
static bool collectSelectedItemTracks(ResolvedSource& out)
{
    const int n = CountSelectedMediaItems(nullptr);
    if (n <= 0) return false;
    for (int i = 0; i < n; ++i)
    {
        MediaItem* it = GetSelectedMediaItem(nullptr, i);
        if (!it) continue;
        MediaTrack* tr = GetMediaItem_Track(it);
        if (!tr) continue;
        // Dedup: several selected items can share a track.
        bool seen = false;
        for (MediaTrack* t : out.sourceTracks) if (t == tr) { seen = true; break; }
        if (seen) continue;
        out.sourceTracks.push_back(tr);
        std::string g = reasampler::guidString(tr);
        if (!g.empty()) out.trackGuids.push_back(std::move(g));
    }
    return !out.sourceTracks.empty();
}

// Resolves the source for a scope: the selection tracks (item/track), plus the
// inferred range. Returns false with a reason on nothing to do.
static bool ResolveScopeSource(reasampler::CaptureScope scope,
                               ResolvedSource& out, std::string& why)
{
    using reasampler::CaptureScope;
    switch (scope)
    {
        case CaptureScope::Item:
            if (!collectSelectedItemTracks(out)) {
                why = "select at least one media item"; return false;
            }
            break;
        case CaptureScope::Track:
            if (!collectSelectedTracks(out)) {
                why = "select at least one track"; return false;
            }
            break;
    }
    return resolveRange(out.startSeconds, out.endSeconds, why);
}

// --- FX-bypass + full parent-chain neutralize around render (RAII, non-destr.) --
// For every track a scope must NOT hear the FX of, this ALSO neutralizes that
// track's fader gain AND its full pan chain (pan/width/law/mode) for the render —
// because a Track/Item capture renders via master and would otherwise sum through
// the parent/folder/master FADERS and PAN/WIDTH/LAW, printing their gain and pan
// coloring into the file (Daniel: the capture is likely re-routed through that
// same chain later, so parent/master level and pan must not be baked in). The
// neutralize set is IDENTICAL to the FX-bypass set:
//   Item  -> own track + all ancestors + master  (take vol/pan kept: item content).
//   Track -> all ancestors + master              (selected track's OWN vol/pan kept).
// (Master is a bypass TARGET for both scopes — never a scope of its own.)
//
// Per track in that set we snapshot & set the full parent-chain-independence set,
// so a Track/Item capture is uncolored by the parent/folder/master it renders
// through — no FX, no fader, and no pan/width/law/mode coloring:
//   I_FXEN    -> 0   (FX bypassed; SDK ~2194)
//   D_VOL     -> 1.0 (unity trim volume; SDK ~2226 "1=+0dB")
//   D_PAN     -> 0.0 (center; SDK ~2227 "trim pan of track, -1..1")
//   D_WIDTH   -> 1.0 (full/neutral stereo width; SDK ~2228 "width, -1..1",
//                     1.0 = full width = no narrowing/collapse)
//   D_PANLAW  -> 1.0 (no coloring; SDK ~2232 "1=+0dB" — pan-law applies no gain)
//   I_PANMODE -> 5   (stereo pan; SDK ~2231 "0=classic,3=balance,5=stereo,6=dual")
// All are restored to their ORIGINAL values on EVERY exit path (RAII).
//
// Why also force I_PANMODE (pan mode). D_PAN's effect is mode-dependent. In modes
// 0/3/5, D_PAN=0 + D_WIDTH=1 is a provable pass-through. But in mode 6 (dual pan)
// D_PAN/D_WIDTH are ignored — routing is governed instead by D_DUALPANL/D_DUALPANR
// (SDK ~2229-2230, live only when I_PANMODE==6), whose neutral pass-through the
// header does not state as such. Rather than snapshot two more mode-conditional
// params and infer their neutral values, we force I_PANMODE=5 (stereo pan) for the
// render, where D_PAN=0 + D_WIDTH=1 is unambiguously uncolored, then restore the
// original mode. This fully neutralizes pan for every original mode with no
// residual — the "handle it fully" the brief requires. (See Snap dual-pan note.)
//
// Structurally non-destructive: no takes, no items, no project restructuring —
// only transient FX-enable + trim-volume toggles, always restored.
class FxBypassGuard
{
public:
    // scope drives fxBypassPlanFor; sourceTracks are the captured tracks whose
    // ancestor chains (walked via GetParentTrack) + the master are bypassed per the
    // plan. proj is the active project (for GetMasterTrack).
    FxBypassGuard(reasampler::CaptureScope scope,
                  const std::vector<MediaTrack*>& sourceTracks,
                  ReaProject* proj)
    {
        const reasampler::FxBypassPlan plan = reasampler::fxBypassPlanFor(scope);

        for (MediaTrack* tr : sourceTracks)
        {
            if (!tr) continue;
            if (plan.bypassSelfFx) bypass(tr);
            if (plan.bypassAncestorFx)
            {
                // Walk parents to the top: GetParentTrack returns the immediate
                // parent (folder) track, nullptr at the outermost level (SDK
                // header ~2407). The master is NOT returned here — handled below.
                for (MediaTrack* p = GetParentTrack(tr); p; p = GetParentTrack(p))
                    bypass(p);
            }
        }
        if (plan.bypassMaster)
        {
            // GetMasterTrack(proj) -> the master track (SDK header ~1925). bypass()
            // neutralizes its FX (I_FXEN), gain (D_VOL) AND pan/width/law/mode on it
            // just like any other in-scope track; only the master's summing/routing
            // topology (the mix bus itself) remains — that is not a per-track param.
            if (MediaTrack* master = GetMasterTrack(proj)) bypass(master);
        }
    }

    ~FxBypassGuard()
    {
        // Restore in reverse for symmetry (order is not load-bearing — each track
        // appears once, snapshots are independent). EVERY snapshotted param is
        // restored to its ORIGINAL value on this (every) exit path. Restore
        // I_PANMODE before the pan values so any mode-conditional params (e.g. dual
        // pan) settle under the original mode.
        for (auto it = snapshots_.rbegin(); it != snapshots_.rend(); ++it)
        {
            SetMediaTrackInfo_Value(it->track, "I_FXEN", it->fxen);
            SetMediaTrackInfo_Value(it->track, "D_VOL", it->vol);
            SetMediaTrackInfo_Value(it->track, "I_PANMODE", it->panmode);
            SetMediaTrackInfo_Value(it->track, "D_PAN", it->pan);
            SetMediaTrackInfo_Value(it->track, "D_WIDTH", it->width);
            SetMediaTrackInfo_Value(it->track, "D_PANLAW", it->panlaw);
        }
    }

    FxBypassGuard(const FxBypassGuard&)            = delete;
    FxBypassGuard& operator=(const FxBypassGuard&) = delete;

private:
    // One snapshot per bypassed track: all params we neutralize, at their originals.
    // panmode captures I_PANMODE so we can force stereo-pan for the render and put
    // the original mode back — which also makes D_DUALPANL/D_DUALPANR (live only when
    // I_PANMODE==6, SDK ~2229-2230) irrelevant during the render without us having to
    // touch or guess neutral values for them.
    struct Snap
    {
        MediaTrack* track;
        double fxen;
        double vol;
        double pan;
        double width;
        double panlaw;
        double panmode;
    };
    std::vector<Snap> snapshots_;

    // Snapshot every neutralized param once per track (dedup: an ancestor shared by
    // two selected tracks must be restored to its ORIGINAL values, not to a
    // re-snapshot of the already-neutralized state), then read ALL originals, push
    // one Snap, and set all to neutral — bypass FX, unity gain, uncolored pan chain.
    void bypass(MediaTrack* tr)
    {
        for (const Snap& s : snapshots_) if (s.track == tr) return;  // already done
        // Read ALL originals first (atomic snapshot), then push, then neutralize.
        const double fxen    = GetMediaTrackInfo_Value(tr, "I_FXEN");
        const double vol     = GetMediaTrackInfo_Value(tr, "D_VOL");
        const double pan     = GetMediaTrackInfo_Value(tr, "D_PAN");
        const double width   = GetMediaTrackInfo_Value(tr, "D_WIDTH");
        const double panlaw  = GetMediaTrackInfo_Value(tr, "D_PANLAW");
        const double panmode = GetMediaTrackInfo_Value(tr, "I_PANMODE");
        snapshots_.push_back({tr, fxen, vol, pan, width, panlaw, panmode});
        SetMediaTrackInfo_Value(tr, "I_FXEN", 0.0);     // 0 = bypassed (SDK ~2194)
        SetMediaTrackInfo_Value(tr, "D_VOL", 1.0);      // 1.0 = unity gain (SDK ~2226)
        SetMediaTrackInfo_Value(tr, "I_PANMODE", 5.0);  // 5 = stereo pan (SDK ~2231)
        SetMediaTrackInfo_Value(tr, "D_PAN", 0.0);      // 0.0 = center (SDK ~2227)
        SetMediaTrackInfo_Value(tr, "D_WIDTH", 1.0);    // 1.0 = full width (SDK ~2228)
        SetMediaTrackInfo_Value(tr, "D_PANLAW", 1.0);   // 1.0 = +0dB, no law (SDK ~2232)
    }
};

// Renders one CaptureRequest through the offline backend under the scope's
// FX-bypass guard, returning the backend's CaptureResult. Shared by RunCapture and
// RunRecaptureFromSource so the FX-scope neutralize + render recipe lives in ONE
// place: the out-of-scope FX / fader / pan chain is snapshotted, neutralized for the
// render, and fully restored on every path (RAII). Non-destructive; touches no
// timeline item (load-bearing principle) — it writes a file only.
static reasampler::CaptureResult renderOffline(
    reasampler::CaptureScope scope,
    const std::vector<MediaTrack*>& sourceTracks,
    const reasampler::CaptureRequest& req)
{
    ReaProject* proj = EnumProjects(-1, nullptr, 0);
    FxBypassGuard fxGuard(scope, sourceTracks, proj);
    reasampler::OfflineRenderBackend backend;
    return backend.capture(req);
}

// Renders ONE capture request under the scope's FX-bypass guard, stamps provenance,
// and adds the resulting Sample to the ACTIVE bank + records the created file in the
// owned-file manifest — WITHOUT persisting. The caller persists once (single-capture:
// right after; batch: once at the end) so a batch does not write ext state N times.
//
// Provenance is read from the LIVE selection here, so a batch that transiently
// selects exactly one item per unit gets per-unit-correct provenance. `src` supplies
// the source tracks (FX bypass + Sample GUIDs); `scope` drives the bypass plan and
// provenance scope. Returns the backend's CaptureResult (status + message) so the
// caller can report success/failure. Load-bearing principle holds: writes a file +
// a bank index entry ONLY; never touches the arrange/timeline. Non-destructive: the
// out-of-scope FX/fader/pan chain is fully restored on every path (FxBypassGuard),
// and the backend restores every RENDER_* setting.
//
// On success, res.sample.id carries the LANDED bank-index id (S8): the newly-added id
// on a fresh add, or the EXISTING entry's id on a hash-dedup collapse — so the S8
// capture+assign path can target the sample actually in the bank. Batch callers ignore
// it; the plain capture actions are unaffected.
static reasampler::CaptureResult captureAndIndexOne(
    reasampler::CaptureScope scope,
    const ResolvedSource& src,
    const std::string& baseName,
    double startSeconds,
    double endSeconds)
{
    // The tail mode is a PANEL SETTING (docked bank panel's toggle), not a per-action
    // variant: the capture actions apply whatever the panel is set to. Default is None
    // (exact bounds / byte-identical to today) until the user opts in via the toggle.
    const reasampler::TailSetting tail = reasampler::bankPanelTailSetting();

    reasampler::CaptureRequest req;
    req.sourceMode   = reasampler::sourceModeForScope(scope);
    req.startSeconds = startSeconds;        // exact bounds — no rounding
    req.endSeconds   = endSeconds;
    req.wetDry       = 1.0;                 // wet post the FX left enabled by the scope
    req.tailMode     = tail.mode;           // None / Auto / Manual, from the panel toggle
    req.tailMs       = tail.manualMs;       // Manual-only (clamped); ignored for None/Auto
    req.sampleRate   = 0;                   // follow project rate
    req.channelCount = 2;
    req.bitDepth     = reasampler::WavBitDepth::Float32;  // deterministic, no dither
    req.baseName     = baseName;
    req.trackGuids   = src.trackGuids;      // recorded on the Sample (provenance)

    // M10: compute provenance BEFORE the FxBypassGuard neutralizes the in-scope chain —
    // the source FX-chain identity must be read from the LIVE (un-bypassed) chain, and
    // the source selection is still live here. Returns nullopt unless this capture
    // genuinely resamples from a bank sample (detectParent). Read-only.
    const std::optional<reasampler::Provenance> prov =
        buildCaptureProvenance(req, scope, src);

    // Render under the scope's FX-bypass guard (out-of-scope FX / fader / pan chain
    // neutralized for the render, fully restored on every path). Writes a file only.
    reasampler::CaptureResult res = renderOffline(scope, src.sourceTracks, req);
    if (res.status != reasampler::CaptureStatus::Ok)
        return res;

    // Stamp provenance onto the captured Sample (only set when this was a genuine
    // resample-from-sample; otherwise the optional stays empty, per M1's contract).
    res.sample.provenance = prov;

    // Add to the ACTIVE bank: g_session.bank() resolves to book.activeIndex() (B2). The
    // AddResult tells a fresh add from a hash-dedup collapse, so the assign path (S8) can
    // target the sample actually in the bank (the existing entry on a collapse).
    const reasampler::AddResult addResult = g_session.bank().add(res.sample);
    // B-cap: record the created file in the owned-file manifest, at the same point the
    // Sample is added. Recorded regardless of the index AddResult — even a hash-collapse
    // still WROTE a file the tool owns, and the manifest dedups a repeat path itself
    // (Phase R prune reconciles manifest vs index later).
    g_session.owned().add(res.sample.relativePath);

    // Resolve the LANDED bank-index id into res.sample.id for the S8 assign path: the new
    // id on a fresh Added (already in res.sample.id); the EXISTING entry's id on a
    // Collapsed (the file we just rendered deduped onto an already-present sample — assign
    // THAT one). Batch/plain-capture callers ignore this field; behaviour unchanged.
    if (addResult == reasampler::AddResult::Collapsed && !res.sample.contentHash.empty())
    {
        if (const reasampler::Sample* existing =
                g_session.bank().findByHash(res.sample.contentHash))
            res.sample.id = existing->id;
    }
    return res;
}

// Runs one capture-action-table row: resolve its scope source + range, render + add +
// record via captureAndIndexOne, then persist + mark dirty. The load-bearing principle
// holds structurally — this path writes a file + a bank index entry ONLY; it never
// calls InsertMedia or touches the arrange/timeline.
// Returns the bank-index id of the sample the capture landed on: the newly-added id on a
// fresh capture, or the EXISTING id on a hash-dedup collapse (so an ingest-with-assign
// targets the sample actually in the bank). Empty on any failure / no-op. The S8 arrange
// capture+assign path reads this to write an assignment request; the plain capture actions
// ignore it (their behaviour is unchanged — capture still writes a file + index entry only).
static std::string RunCapture(const reasampler::CaptureActionDef& def)
{
    ResolvedSource src;
    std::string why;
    if (!ResolveScopeSource(def.scope, src, why))
    {
        ShowConsoleMsg(("ReaSampler capture: " + why + ".\n").c_str());
        return {};
    }

    reasampler::CaptureResult res =
        captureAndIndexOne(def.scope, src, def.baseName, src.startSeconds, src.endSeconds);
    if (res.status != reasampler::CaptureStatus::Ok)
    {
        ShowConsoleMsg(("ReaSampler capture failed: " + res.message + "\n").c_str());
        return {};
    }

    // captureAndIndexOne has already stamped provenance, added the Sample to the ACTIVE
    // bank, and recorded the created file in the owned-file manifest (WITHOUT persisting).
    // Persist the updated book AND manifest into the active project's ext state (the
    // `banks` + `owned_files` keys) so the capture survives Save / close+reopen (M4) and
    // travels with the .rpp. saveToActiveProject also clears the retired legacy key and
    // calls MarkProjectDirty. Non-destructive: writes only our own ext-state keys.
    // S9: a capture add is a bank-content change -> bump before the persist so an assigned
    // live instance refreshes hands-free (the S8 capture+assign path builds on this).
    g_session.bumpBankGeneration();
    g_session.saveToActiveProject();

    // Hand the LANDED bank-index id back to the assign path (S8): captureAndIndexOne
    // resolved res.sample.id to the fresh id on a new add or the existing entry's id on a
    // hash-dedup collapse. Empty on any reject (unreachable here — status was Ok above).
    return res.sample.id;
}

// S8 arrange ingest: capture the selected item / time-selection into the active bank
// (reusing the Item-scope capture path verbatim) and, on success, write an assignment
// request so the active sampler instance plays the new sample on its next reload. The
// capture itself is unchanged — RunCapture writes a file + an index entry and NEVER
// inserts a timeline item (load-bearing principle); the only addition here is the
// bank-index-id -> assignment-request write after the sample lands. If the capture
// failed / no-op'd (empty id), no assignment is written (nothing to assign).
//
// UNDO GROUPING: both the bank mutation (RunCapture -> saveToActiveProject) AND the
// assignment-request write (ingestAssignActiveInstance -> writeAssignmentRequest) are
// wrapped in a single undo block so Ctrl-Z rolls back both ext-state keys atomically.
// An undo that removes the captured sample also clears the assign_request that named it,
// preventing a stale request from pointing at a removed sample. The block uses the house
// pattern (UNDO_STATE_MISCCFG, discarded on an unsaved project with empty label + zero
// flag) matching the bank-op family in actions.cpp.
static void RunCaptureItemAssign()
{
    // Reuse the Item-scope def from the capture table (index 0) — same range logic, same
    // FX-scope neutralize, same bank/persist landing as the plain "capture item" action.
    Undo_BeginBlock2(nullptr);

    const std::string sampleId =
        RunCapture(reasampler::captureActionTable()[0]);
    if (sampleId.empty())
    {
        // Capture failed or no-op'd — RunCapture already reported. Discard the empty point.
        Undo_EndBlock2(nullptr, "", 0);
        return;
    }

    // Assign inside the same block so undo clears both keys together.
    reasampler::ingestAssignActiveInstance(g_session.book().activeBankId(), sampleId);
    Undo_EndBlock2(nullptr, "ReaSampler: capture + assign to active instance",
                   UNDO_STATE_MISCCFG);

    reasampler::bankPanelRefresh();
    ShowConsoleMsg("ReaSampler ingest: captured into the bank and assigned to the active "
                   "instance.\n");
}

// --- M11: batch capture (per selected item / per razor area) ----------------
//
// One action fires N captures — one bank sample per selected item (item scope) or per
// razor area (track scope, each area's own range). Each individual capture honors every
// precision invariant via captureAndIndexOne (exact bounds, non-destructive FX/fader/pan
// neutralize, relative paths, channel preservation) and M10 provenance stamping applies
// per capture where its detection rule matches. The load-bearing principle holds: each
// unit writes a file + a bank index entry ONLY; nothing lands in the arrange.
//
// Per-unit FILE NAMING: the offline backend's unique tag is 1-second-granular. The
// per-item render already takes real wall-clock time (REAPER's offline-render dialog per
// unit), so consecutive units naturally land in distinct seconds; belt-and-braces, each
// unit's baseName also carries its ordinal ("item-1", "item-2", ...) so two units are
// never asked to write the same stem within one batch. (Residual, DAW-verify: two BATCHES
// fired within the same wall-clock second with identical ordinals could still collide —
// unreachable in practice given the per-unit render latency, noted for completeness.)

// RAII snapshot/restore of the project's media-item selection. Batch item capture must
// transiently select exactly one item per render (RENDER_SETTINGS &32 renders whatever is
// selected); the user's ORIGINAL selection must be restored on EVERY exit path — including
// a mid-batch failure or early return — because selection restoration is part of the
// non-destructive invariant. Snapshot on construct (the currently-selected item set),
// restore on destruct (deselect everything, then re-select exactly the snapshot).
class ItemSelectionGuard
{
public:
    ItemSelectionGuard()
    {
        const int n = CountSelectedMediaItems(nullptr);
        for (int i = 0; i < n; ++i)
            if (MediaItem* it = GetSelectedMediaItem(nullptr, i))
                selected_.push_back(it);
    }

    ~ItemSelectionGuard()
    {
        // Deselect every item in the project, then re-select the snapshot — restoring the
        // exact original set regardless of what the batch selected in between. Iterate ALL
        // items (not just the currently-selected) so any transient selection is cleared.
        const int total = CountMediaItems(nullptr);
        for (int i = 0; i < total; ++i)
            if (MediaItem* it = GetMediaItem(nullptr, i))
                SetMediaItemSelected(it, false);
        for (MediaItem* it : selected_)
            SetMediaItemSelected(it, true);
        UpdateArrange();  // reflect the restored selection in the arrange view
    }

    ItemSelectionGuard(const ItemSelectionGuard&)            = delete;
    ItemSelectionGuard& operator=(const ItemSelectionGuard&) = delete;

private:
    std::vector<MediaItem*> selected_;
};

// Selects exactly `item` (deselect-all then select-one) so the offline render's
// selected-items bit (&32) captures a single item. Used inside the batch loop under the
// ItemSelectionGuard, which restores the user's original selection afterward.
static void selectOnlyItem(MediaItem* item)
{
    const int total = CountMediaItems(nullptr);
    for (int i = 0; i < total; ++i)
        if (MediaItem* it = GetMediaItem(nullptr, i))
            SetMediaItemSelected(it, it == item);
}

// Batch item capture: one bank sample per SELECTED item, item scope. Snapshots the
// selection (RAII restore on every path), then for each selected item transiently selects
// only it, renders its exact [pos, pos+len] range under item-scope FX neutralize, adds the
// Sample, and records a per-unit verdict. Persists ONCE at the end (one ext-state write for
// the whole batch). Reports a mixed-result summary (explicit-action response — allowed).
static void RunBatchCaptureItems()
{
    // Read the selected items up front (pointers stay valid — batch mutates only selection
    // flags, never adds/removes items). Also capture each item's exact bounds and owning
    // track NOW, while the full selection is live, before any transient re-selection.
    struct ItemUnit { MediaItem* item; MediaTrack* track; double start; double end; };
    std::vector<ItemUnit> itemUnits;
    {
        const int n = CountSelectedMediaItems(nullptr);
        for (int i = 0; i < n; ++i)
        {
            MediaItem* it = GetSelectedMediaItem(nullptr, i);
            if (!it) continue;
            MediaTrack* tr = GetMediaItem_Track(it);
            if (!tr) continue;
            const double pos = GetMediaItemInfo_Value(it, "D_POSITION");
            const double len = GetMediaItemInfo_Value(it, "D_LENGTH");
            itemUnits.push_back({it, tr, pos, pos + len});
        }
    }
    if (itemUnits.empty())
    {
        ShowConsoleMsg("ReaSampler batch capture: select at least one media item.\n");
        return;
    }

    // Plan the exact source ranges -> validated, ordinal-assigned units (pure). Empty/
    // inverted item ranges (a zero-length item) are dropped here so no stray render runs.
    std::vector<reasampler::BatchRange> ranges;
    ranges.reserve(itemUnits.size());
    for (const ItemUnit& u : itemUnits)
        ranges.push_back({u.start, u.end});
    const std::vector<reasampler::CaptureUnit> plan = reasampler::planCaptureUnits(ranges);

    reasampler::BatchOutcome outcome;
    bool anyAdded = false;
    {
        // Restore the user's ORIGINAL item selection on every exit path (incl. early
        // return / mid-batch failure) — non-destructive invariant.
        ItemSelectionGuard selGuard;

        // The plan and itemUnits are parallel over the KEPT units. Walk itemUnits, but only
        // for those whose range survived planning (same drop rule), matching by ordinal.
        std::size_t planIdx = 0;
        for (const ItemUnit& u : itemUnits)
        {
            if (!(u.end > u.start)) continue;  // dropped by planCaptureUnits — skip in lockstep
            const reasampler::CaptureUnit& unit = plan[planIdx++];

            // Transiently select ONLY this item so the item-scope render captures exactly it.
            selectOnlyItem(u.item);

            ResolvedSource src;
            src.startSeconds = unit.startSeconds;
            src.endSeconds   = unit.endSeconds;
            src.sourceTracks.push_back(u.track);
            if (std::string g = reasampler::guidString(u.track); !g.empty())
                src.trackGuids.push_back(std::move(g));

            const std::string baseName = "item-" + std::to_string(unit.ordinal);
            reasampler::CaptureResult res = captureAndIndexOne(
                reasampler::CaptureScope::Item, src, baseName,
                unit.startSeconds, unit.endSeconds);

            const bool ok = (res.status == reasampler::CaptureStatus::Ok);
            outcome.record(unit.ordinal, ok, ok ? std::string{} : res.message);
            if (ok) anyAdded = true;
        }
    }  // selGuard restores the original selection here, on every path

    // Persist ONCE for the whole batch (one ext-state write) — only if something landed.
    // S9: one bump for the whole batch (coalesced) — the counter is monotonic, not per-sample,
    // so a single increment past the last-seen value is enough to trigger one instance reload.
    if (anyAdded) {
        g_session.bumpBankGeneration();
        g_session.saveToActiveProject();
    }

    ShowConsoleMsg((outcome.summaryLine("item") + "\n").c_str());
}

// Collects every track's razor AUDIO areas as (owning track, range) pairs, preserving
// track order then area order — the batch analog of resolveRazorRange, which unions them.
// Read-only (never clears the razor selection). Reuses the pure parseRazorEdits parser.
static std::vector<std::pair<MediaTrack*, reasampler::RazorRange>> collectRazorAreas()
{
    std::vector<std::pair<MediaTrack*, reasampler::RazorRange>> areas;
    const int n = CountTracks(nullptr);
    for (int i = 0; i < n; ++i)
    {
        MediaTrack* tr = GetTrack(nullptr, i);
        if (!tr) continue;
        std::vector<char> buf(8192, '\0');
        if (!GetSetMediaTrackInfo_String(tr, "P_RAZOREDITS", buf.data(), false))
            continue;
        for (const reasampler::RazorRange& r :
             reasampler::parseRazorEdits(std::string(buf.data())))
            areas.push_back({tr, r});
    }
    return areas;
}

// RAII snapshot/restore of the project's TRACK selection. Batch razor capture must
// transiently select exactly the area's owning track per render (track scope's &128 bit
// renders whatever TRACKS are selected); the user's original track selection is restored
// on EVERY exit path (part of the non-destructive invariant). Mirror of ItemSelectionGuard.
class TrackSelectionGuard
{
public:
    TrackSelectionGuard()
    {
        const int n = CountSelectedTracks(nullptr);
        for (int i = 0; i < n; ++i)
            if (MediaTrack* tr = GetSelectedTrack(nullptr, i))
                selected_.push_back(tr);
    }

    ~TrackSelectionGuard()
    {
        // Deselect every track, then re-select the snapshot — the exact original set.
        const int total = CountTracks(nullptr);
        for (int i = 0; i < total; ++i)
            if (MediaTrack* tr = GetTrack(nullptr, i))
                SetTrackSelected(tr, false);
        for (MediaTrack* tr : selected_)
            SetTrackSelected(tr, true);
    }

    TrackSelectionGuard(const TrackSelectionGuard&)            = delete;
    TrackSelectionGuard& operator=(const TrackSelectionGuard&) = delete;

private:
    std::vector<MediaTrack*> selected_;
};

// Batch razor capture: one bank sample per razor AREA, track scope over that area's own
// range (the area's owning track is the source track). Track scope renders the selected
// TRACKS via master (&128), so each unit transiently selects ONLY its owning track
// (SetOnlyTrackSelected) under the TrackSelectionGuard, which restores the user's original
// track selection on every path. The razor selection itself is read-only and left intact.
// Persists ONCE at the end. Reports a mixed-result summary.
static void RunBatchCaptureRazor()
{
    const std::vector<std::pair<MediaTrack*, reasampler::RazorRange>> areas =
        collectRazorAreas();
    if (areas.empty())
    {
        ShowConsoleMsg("ReaSampler batch capture: make at least one razor area first.\n");
        return;
    }

    std::vector<reasampler::BatchRange> ranges;
    ranges.reserve(areas.size());
    for (const auto& a : areas)
        ranges.push_back({a.second.startSeconds, a.second.endSeconds});
    const std::vector<reasampler::CaptureUnit> plan = reasampler::planCaptureUnits(ranges);

    reasampler::BatchOutcome outcome;
    bool anyAdded = false;
    {
        // Restore the user's ORIGINAL track selection on every exit path.
        TrackSelectionGuard selGuard;

        std::size_t planIdx = 0;
        for (const auto& a : areas)
        {
            if (!(a.second.endSeconds > a.second.startSeconds)) continue;  // dropped — lockstep
            const reasampler::CaptureUnit& unit = plan[planIdx++];
            MediaTrack* tr = a.first;

            // Transiently select ONLY this track so the track-scope render (&128) captures
            // exactly it via master (over the custom time bounds we set per unit).
            SetOnlyTrackSelected(tr);

            ResolvedSource src;
            src.startSeconds = unit.startSeconds;
            src.endSeconds   = unit.endSeconds;
            src.sourceTracks.push_back(tr);
            if (std::string g = reasampler::guidString(tr); !g.empty())
                src.trackGuids.push_back(std::move(g));

            const std::string baseName = "razor-" + std::to_string(unit.ordinal);
            reasampler::CaptureResult res = captureAndIndexOne(
                reasampler::CaptureScope::Track, src, baseName,
                unit.startSeconds, unit.endSeconds);

            const bool ok = (res.status == reasampler::CaptureStatus::Ok);
            outcome.record(unit.ordinal, ok, ok ? std::string{} : res.message);
            if (ok) anyAdded = true;
        }
    }  // selGuard restores the original track selection here, on every path

    // S9: one coalesced bump for the whole razor batch (see the item-batch note above).
    if (anyAdded) {
        g_session.bumpBankGeneration();
        g_session.saveToActiveProject();
    }

    ShowConsoleMsg((outcome.summaryLine("razor area") + "\n").c_str());
}

// --- M10: re-capture from source --------------------------------------------
//
// Regenerates a PROVENANCED bank sample's file from its recorded source's CURRENT
// state, then updates the bank Sample IN PLACE. BANK-ONLY — it renders a file and
// refreshes the index entry; it NEVER calls InsertMedia / touches the timeline (the
// load-bearing capture-never-places line, structurally visible: this function has no
// insert path at all). Non-destructive to the source (FxBypassGuard snapshot/restore
// via renderOffline). Fork P2=a: refresh the bank entry only; the user re-places
// manually if they want the new version on the timeline.
//
// Failure modes are handled explicitly and reported to the user (a direct response
// to an explicit action is allowed by the console policy):
//   * the selected sample has no provenance (not a resample) -> reported, no-op.
//   * the recorded fingerprint is unparseable (legacy/corrupt) -> reported, no-op.
//   * the recorded source track(s) no longer exist -> reported, no-op.
//   * the render itself fails to satisfy the recorded request -> reported, no-op.
// On success, if the source FX chain drifted since capture (recorded vs current
// identity differ) the user is told — the re-capture still reflects the source AS IT
// IS NOW (P1=a: the fingerprint detects drift, it does not freeze the source).
static void RunRecaptureFromSource()
{
    const std::vector<std::string> selected = reasampler::bankPanelSelectedSampleIds();
    if (selected.empty())
    {
        ShowConsoleMsg("ReaSampler re-capture: select a sample in the bank panel first.\n");
        return;
    }
    if (selected.size() > 1)
    {
        ShowConsoleMsg("ReaSampler re-capture: select a single sample to re-capture.\n");
        return;
    }
    const std::string sampleId = selected.front();

    // Resolve the sample from the bank it lives in (the focused region's displayed bank).
    const std::string srcBankId = reasampler::bankPanelSelectedSourceBankId();
    const reasampler::Bank* bank = g_session.book().bank(srcBankId);
    const reasampler::Sample* orig = bank ? bank->index.query(sampleId) : nullptr;
    if (!orig)
    {
        ShowConsoleMsg("ReaSampler re-capture: the selected sample is no longer in the bank.\n");
        return;
    }
    if (!orig->provenance)
    {
        ShowConsoleMsg("ReaSampler re-capture: this sample has no provenance "
                       "(it was not resampled from a bank sample).\n");
        return;
    }

    // Parse the recorded capture recipe from the fingerprint. A legacy / corrupt
    // string fails gracefully — never a partial re-capture.
    const std::string recordedParentId = orig->provenance->parentSampleId;
    const std::string recordedFingerprint = orig->provenance->fxChainSnapshot;
    const std::optional<reasampler::CaptureRecipe> recipe =
        reasampler::parseFingerprint(recordedFingerprint);
    if (!recipe)
    {
        ShowConsoleMsg("ReaSampler re-capture: this sample's provenance is unreadable "
                       "(recorded by an older/incompatible build); cannot re-capture.\n");
        return;
    }

    // Resolve the recorded source track GUID(s) to live tracks. Any missing track is a
    // hard failure — we will not silently re-capture a different source.
    std::vector<MediaTrack*> sourceTracks;
    for (const std::string& g : recipe->trackGuids)
    {
        MediaTrack* tr = reasampler::trackByGuid(g);
        if (!tr)
        {
            ShowConsoleMsg("ReaSampler re-capture: a recorded source track no longer "
                           "exists in this project; cannot re-capture from source.\n");
            return;
        }
        sourceTracks.push_back(tr);
    }
    if (sourceTracks.empty())
    {
        // The recipe recorded no source tracks (e.g. an item-scope capture whose source
        // tracks were not track-scoped). Without a resolvable source we cannot re-run.
        ShowConsoleMsg("ReaSampler re-capture: no resolvable recorded source for this "
                       "sample; cannot re-capture from source.\n");
        return;
    }

    const reasampler::CaptureScope scope =
        recipe->scope == reasampler::ProvenanceScope::Item
            ? reasampler::CaptureScope::Item
            : reasampler::CaptureScope::Track;

    // Rebuild the capture request verbatim from the recorded recipe — the SAME request,
    // re-run against the source's CURRENT state (P1=a). Exact bounds, tail, rate,
    // channels, bit depth all match the original so an unchanged source produces a
    // byte-identical file (bit-identical-repeats invariant, consumed as a feature).
    reasampler::CaptureRequest req;
    req.sourceMode   = static_cast<reasampler::SourceMode>(recipe->sourceMode);
    req.startSeconds = recipe->startSeconds;
    req.endSeconds   = recipe->endSeconds;
    req.wetDry       = 1.0;
    req.tailMode     = static_cast<reasampler::TailMode>(recipe->tailMode);
    req.tailMs       = recipe->tailMs;
    req.sampleRate   = recipe->sampleRate;
    req.channelCount = recipe->channelCount;
    req.bitDepth     = reasampler::WavBitDepth::Float32;
    req.baseName     = orig->displayName.empty() ? "recapture" : orig->displayName;
    req.trackGuids   = recipe->trackGuids;

    // Read the CURRENT source FX-chain identity BEFORE the render bypasses it, to
    // compare against the recorded identity for drift reporting. Mirror the same
    // scope split as buildCaptureProvenance: item scope reads take FX via TakeFX_*;
    // track scope reads the track FX chain via TrackFX_*.
    std::string currentIdentity;
    if (scope == reasampler::CaptureScope::Item) {
        const int n = CountSelectedMediaItems(nullptr);
        std::vector<MediaItem*> items;
        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) items.push_back(it);
        }
        currentIdentity = reasampler::fxChainIdentityForItems(items);
    } else {
        std::vector<std::string> perTrackNow;
        perTrackNow.reserve(sourceTracks.size());
        for (MediaTrack* tr : sourceTracks)
            perTrackNow.push_back(reasampler::fxChainIdentityForTrack(tr));
        currentIdentity = reasampler::combineChainIdentities(perTrackNow);
    }
    const bool drifted = (currentIdentity != recipe->fxChainIdentity);

    // Render (bank-only; renderOffline never touches the timeline).
    reasampler::CaptureResult res = renderOffline(scope, sourceTracks, req);
    if (res.status != reasampler::CaptureStatus::Ok)
    {
        ShowConsoleMsg(("ReaSampler re-capture failed: " + res.message + "\n").c_str());
        return;
    }

    // Update the Sample IN PLACE: keep its identity (id) and its provenance thread
    // (same parent + a REFRESHED fingerprint reflecting the source as re-captured), but
    // adopt the regenerated file's path / hash / length / rate / timestamp. The
    // fingerprint is rebuilt from the recipe with the CURRENT FX identity so a
    // subsequent re-capture measures drift from this point, not the original.
    reasampler::CaptureRecipe refreshed = *recipe;
    refreshed.fxChainIdentity = currentIdentity;

    reasampler::Sample updated = *orig;              // copy: preserves id, displayName, tier, key
    updated.relativePath  = res.sample.relativePath;
    updated.contentHash   = res.sample.contentHash;
    updated.sourceMode    = res.sample.sourceMode;
    updated.sourceRange   = res.sample.sourceRange;
    updated.channelCount  = res.sample.channelCount;
    updated.sampleRate    = res.sample.sampleRate;
    updated.lengthSeconds = res.sample.lengthSeconds;
    updated.captureTempo  = res.sample.captureTempo;
    updated.captureTimeSigNum   = res.sample.captureTimeSigNum;   // L7 F1: refresh meter stamp
    updated.captureTimeSigDenom = res.sample.captureTimeSigDenom; // to the re-capture's meter
    updated.trackGuids    = res.sample.trackGuids;
    updated.createdTimestamp = res.sample.createdTimestamp;
    // NOTE: levels, clipped, and lengthBeats are carried from the original (via the
    // *orig copy above) because the offline backend does not populate them today
    // (res.sample leaves them at defaults). If a later milestone populates these
    // fields at capture time, refresh them here from res.sample instead.
    reasampler::Provenance prov;
    prov.parentSampleId  = recordedParentId;
    prov.fxChainSnapshot = reasampler::buildFingerprint(refreshed);
    updated.provenance   = prov;

    // Single batched undo point around the in-place bank mutation (mirrors the bank
    // action family's R-B pattern). The mutation is index-only ext-state; the render
    // wrote a new file but placed nothing on the timeline.
    Undo_BeginBlock2(nullptr);
    const bool changed = g_session.book().updateSampleInPlace(sampleId, updated);
    if (changed)
    {
        // Record the regenerated file in the owned manifest (a new file the tool wrote);
        // the superseded old file becomes an orphan reclaimed by Phase R prune.
        g_session.owned().add(updated.relativePath);
        // S9: re-capture-in-place regenerates the SAME id's audio — the exact case the
        // hands-free refresh exists for (an instance referencing this id keeps playing the
        // OLD audio until it reloads). Bump inside the undo block so undo rolls back the
        // generation with the rest of the blob.
        g_session.bumpBankGeneration();
        const bool persisted = g_session.saveToActiveProject();  // book + manifest + generation + MarkProjectDirty
        Undo_EndBlock2(nullptr, persisted ? "ReaSampler: re-capture from source" : "",
                       persisted ? UNDO_STATE_MISCCFG : 0);
    }
    else
    {
        Undo_EndBlock2(nullptr, "", 0);  // nothing mutated -> discard the empty point
    }

    reasampler::bankPanelRefresh();  // reflect the regenerated file in the docked grid

    if (drifted)
        ShowConsoleMsg("ReaSampler re-capture: the source FX chain changed since the "
                       "original capture -- the sample was regenerated from the source's "
                       "current state.\n");
}

// STARTS the REALTIME track capture and returns immediately — the record runs across
// timer ticks (DriveRealtimeCapture), so REAPER's UI stays responsive. Resolves the
// selected tracks + the range (razor-else-time, the same orthogonal range logic as the
// offline scopes) and starts recording each selected track's OWN output into a hidden
// temp track via RealtimeRecordBackend::begin (a send FROM each source track INTO the
// temp — see capture_realtime.cpp §TAP); OnTimer drives it to completion, then adds the
// Sample and persists. TRACK scope only this increment (item realtime is deferred).
// Dialog-free. Non-bit-identical by nature (it is realtime) — offline stays the
// deterministic default. FxBypassGuard is NOT used here — the track-output tap is
// PRE-parent by construction (§TAP), so there is no live chain to neutralize. The
// load-bearing principle holds structurally — this writes a file + a bank entry ONLY;
// the temp track is a transient sink removed by the backend, nothing lands in arrange.
//
// A SECOND realtime capture requested while one is in progress is REJECTED — the
// first keeps running (we own the transport for its window; starting a second would
// collide on the transport and the temp-track/arm snapshot).
static void RunCaptureRealtimeTrack()
{
    if (g_rtCapture)
    {
        ShowConsoleMsg("ReaSampler realtime capture: a capture is already in "
                       "progress -- let it finish (or stop the transport) first.\n");
        return;
    }

    // Resolve the selected tracks + range exactly as the offline Track scope does.
    // No track selected -> refuse (same no-op as offline track scope).
    ResolvedSource src;
    std::string why;
    if (!ResolveScopeSource(reasampler::CaptureScope::Track, src, why))
    {
        ShowConsoleMsg(("ReaSampler realtime capture: " + why + ".\n").c_str());
        return;
    }

    // The tail mode is the SAME panel setting the offline capture actions read (the
    // docked bank panel's toggle). Realtime honors it via a parallel path: the backend
    // records a generous window past the range end, then trims by PCM decay-scan (T2 /
    // capture-tail.md §The realtime path) — it does NOT drive RENDER_*. Default None
    // keeps realtime exact-bounds / byte-identical to today.
    const reasampler::TailSetting tail = reasampler::bankPanelTailSetting();

    reasampler::CaptureRequest req;
    req.sourceMode   = reasampler::SourceMode::SelectedTracks;  // realtime track scope
    req.startSeconds = src.startSeconds;                        // exact bounds — no rounding
    req.endSeconds   = src.endSeconds;
    req.wetDry       = 1.0;                                     // fully wet (post-fader tap)
    req.tailMode     = tail.mode;           // None / Auto / Manual, from the panel toggle
    req.tailMs       = tail.manualMs;       // Manual-only (pre-clamped); ignored for None/Auto
    req.sampleRate   = 0;                                       // follow project rate
    req.channelCount = 2;
    req.bitDepth     = reasampler::WavBitDepth::Float32;
    req.baseName     = "realtime";
    req.trackGuids   = src.trackGuids;                          // provenance on the Sample

    reasampler::CaptureResult failure;
    reasampler::RealtimeCaptureHandle st =
        g_rtBackend.begin(req, src.sourceTracks, failure);
    if (!st)
    {
        // begin() validated/failed and already restored anything it touched.
        ShowConsoleMsg(("ReaSampler realtime capture failed: " + failure.message + "\n").c_str());
        return;
    }

    // Started. Store the in-flight state + its project; OnTimer drives it to
    // completion across ticks (UI stays responsive).
    g_rtCaptureProject = EnumProjects(-1, nullptr, 0);
    g_rtCapture = std::move(st);
}

// Cancels the in-flight realtime capture on demand (bindable action). Force-terminates
// via abort() — stop the transport + restore ALL snapshotted state (non-destructive),
// committing whatever audio was already captured (best effort) so a cancel near the end
// still keeps the take. Runs only against the record's OWN project (abort() self-guards
// the closed-project case, review §1). No-op with a note when nothing is in flight.
static void RunCancelRealtime()
{
    if (!g_rtCapture)
    {
        ShowConsoleMsg("ReaSampler: no realtime capture in progress to cancel.\n");
        return;
    }
    reasampler::RealtimeTickResult r = g_rtBackend.abort(*g_rtCapture);
    if (r.status == reasampler::RealtimeTickStatus::Done)
        CommitRealtimeResult(r.result);  // Ok: keep what was captured up to the cancel
    else
        ShowConsoleMsg(("ReaSampler realtime capture cancelled -- " +
                        r.result.message + "\n").c_str());
    g_rtCapture.reset();
    g_rtCaptureProject = nullptr;
}

// Runs the M6 insert: place the bank panel's selected sample(s) at the edit cursor
// via InsertMedia, undo-wrapped. `conform` selects the explicit opt-in tempo-match
// variant (never silent — it fires only from the distinct "conform" action). This
// is the INTENDED placement path: it adds items to the arrange on purpose
// (CONTEXT.md §load-bearing principle) and runs only from a user-invoked action.
static void RunInsertSelected(bool conform)
{
    reasampler::InsertRequest req;
    // target defaults to CurrentTrack (InsertOptions::target) — inserts onto the
    // user's currently-selected track(s) at the edit cursor.
    req.options.conform =
        conform ? reasampler::TempoConform::Ratio1x : reasampler::TempoConform::None;
    // preservePitch stays true: a tempo conform matches tempo without varispeeding
    // pitch. (A pitch-shifting variant is a later opt-in if wanted — YAGNI now.)

    reasampler::InsertResult res = reasampler::runInsert(&g_session, req);

    switch (res.status)
    {
        case reasampler::InsertStatus::Ok:
            break;  // success — no console chatter
        case reasampler::InsertStatus::NoSelection:
            // "select a track first" is printed by runInsert when no track is
            // selected; this branch covers the no-panel-selection case.
            ShowConsoleMsg("ReaSampler insert: nothing selected in the bank panel.\n");
            break;
        case reasampler::InsertStatus::NoProject:
            ShowConsoleMsg("ReaSampler insert: no saved project, so the bank has no location.\n");
            break;
        case reasampler::InsertStatus::NothingResolved:
            ShowConsoleMsg("ReaSampler insert: selected sample(s) could not be resolved to a file.\n");
            break;
    }
}

// REAPER calls this for EVERY action fired anywhere; claim only our own id,
// return false otherwise so REAPER keeps looking.
static bool OnHookCommand(int command, int /*flag*/)
{
    if (command == 0) return false;
    // Three-scope capture family: command ids parallel captureActionTable() 1:1 by index.
    // Claim the fired id if it is one of ours and route to its table row.
    for (std::size_t i = 0; i < g_captureCmdIds.size(); ++i)
        if (command == g_captureCmdIds[i])
        {
            RunCapture(reasampler::captureActionTable()[i]);
            return true;
        }
    if (command == g_cmdToggleBankPanel)         { reasampler::bankPanelToggle(); return true; }
    if (command == g_cmdCaptureItemAssign)       { RunCaptureItemAssign(); return true; }
    if (command == g_cmdInsertSelected)          { RunInsertSelected(false); return true; }
    if (command == g_cmdInsertSelectedConform)   { RunInsertSelected(true);  return true; }
    if (command == g_cmdCaptureBatchItems)       { RunBatchCaptureItems(); return true; }
    if (command == g_cmdCaptureBatchRazor)       { RunBatchCaptureRazor(); return true; }
    if (command == g_cmdCaptureTrackRealtime)    { RunCaptureRealtimeTrack(); return true; }
    if (command == g_cmdCancelRealtime)          { RunCancelRealtime(); return true; }
    if (command == g_cmdRecaptureFromSource)     { RunRecaptureFromSource(); return true; }
    if (command == g_cmdShowVersion)
    {
        // On-demand version readout — the ONLY version output on any path.
        ShowConsoleMsg(("ReaSampler " + reasampler::appVersion() + "\n").c_str());
        return true;
    }
    // Design View action family (D4). Claims only its own ids; returns false for the
    // rest so this hook keeps looking (per the contract).
    if (reasampler::designViewHandleCommand(command)) return true;
    // Multi-bank action family (B3). Same contract: claims only its own ids.
    if (reasampler::bankHandleCommand(command)) return true;
    // S8 ingest action family (Media-Explorer import). Same contract.
    if (reasampler::ingestHandleCommand(command)) return true;
    return false;
}

// REAPER polls this to render each of OUR actions' checked state in menus/toolbars.
// Return 1 (on) / 0 (off) for ids we own, -1 for everything else (per the contract).
static int OnToggleAction(int command)
{
    if (command == g_cmdToggleBankPanel)
        return reasampler::bankPanelIsOpen() ? 1 : 0;
    return -1;  // not ours / non-toggling
}

// gaccel storage must outlive registration — REAPER holds the pointer.
// (The capture family's accels live in g_captureAccels, sized to the table.)
static gaccel_register_t g_accelToggleBankPanel{};
static gaccel_register_t g_accelCaptureItemAssign{};
static gaccel_register_t g_accelInsertSelected{};
static gaccel_register_t g_accelInsertSelectedConform{};
static gaccel_register_t g_accelCaptureBatchItems{};
static gaccel_register_t g_accelCaptureBatchRazor{};
static gaccel_register_t g_accelCaptureTrackRealtime{};
static gaccel_register_t g_accelCancelRealtime{};
static gaccel_register_t g_accelRecaptureFromSource{};
static gaccel_register_t g_accelShowVersion{};

// gaccel desc storage. The Actions-list label is channel-qualified at runtime
// (channelActionName) so it cannot be a string literal; REAPER holds the gaccel's `desc`
// pointer, so each label lives here for the module lifetime. Composed once at registration.
static std::string g_descToggleBankPanel;
static std::string g_descCaptureItemAssign;
static std::string g_descInsertSelected;
static std::string g_descInsertSelectedConform;
static std::string g_descCaptureBatchItems;
static std::string g_descCaptureBatchRazor;
static std::string g_descCaptureTrackRealtime;
static std::string g_descCancelRealtime;
static std::string g_descRecaptureFromSource;
static std::string g_descShowVersion;

// Composed command-id strings (channel-qualified), interned so register and the mirroring
// '-command_id' unregister pass the SAME pointer. Set during registration; read on unload.
static const char* g_idToggleBankPanel = nullptr;
static const char* g_idCaptureItemAssign = nullptr;
static const char* g_idInsertSelected = nullptr;
static const char* g_idInsertSelectedConform = nullptr;
static const char* g_idCaptureBatchItems = nullptr;
static const char* g_idCaptureBatchRazor = nullptr;
static const char* g_idCaptureTrackRealtime = nullptr;
static const char* g_idCancelRealtime = nullptr;
static const char* g_idRecaptureFromSource = nullptr;
static const char* g_idShowVersion = nullptr;

extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
    REAPER_PLUGIN_HINSTANCE hInstance, reaper_plugin_info_t* rec)
{
    if (!rec)
    {
        // rec == nullptr  =>  REAPER is UNLOADING us. Mirror-unregister every
        // callback with the same strings prefixed '-' (per the contract).
        if (g_rec)
        {
            // Abort any in-flight realtime capture FIRST, while the API pointers are
            // still live — finalize-or-abort + restore so we never leave a temp track,
            // an armed track, or an altered transport/cursor in the user's project on
            // unload. Commit whatever was captured (best effort) before tearing down.
            if (g_rtCapture)
            {
                reasampler::RealtimeTickResult r = g_rtBackend.abort(*g_rtCapture);
                CommitRealtimeResult(r.result);
                g_rtCapture.reset();
                g_rtCaptureProject = nullptr;
            }

            g_rec->Register("-timer", (void*)&OnTimer);
            g_rec->Register("-projectconfig", (void*)&g_projectConfig);
            g_rec->Register("-toggleaction", (void*)&OnToggleAction);
            g_rec->Register("-hookcommand", (void*)&OnHookCommand);
            // Tear down the Design View action family (D4) — mirror-unregisters each
            // gaccel + command_id with '-'-prefixed strings. After the hook is gone.
            reasampler::designViewUnregisterActions(g_rec);
            // Tear down the multi-bank action family (B3) — same mirror-unregister.
            reasampler::bankUnregisterActions(g_rec);
            // Tear down the S8 ingest action family — same mirror-unregister.
            reasampler::ingestUnregisterActions(g_rec);
            // Each '-command_id' re-presents the SAME interned, channel-qualified pointer
            // used at register (g_id*), so the mirror-unregister matches exactly.
            g_rec->Register("-gaccel", (void*)&g_accelShowVersion);
            g_rec->Register("-command_id", (void*)g_idShowVersion);
            g_rec->Register("-gaccel", (void*)&g_accelRecaptureFromSource);
            g_rec->Register("-command_id", (void*)g_idRecaptureFromSource);
            g_rec->Register("-gaccel", (void*)&g_accelCancelRealtime);
            g_rec->Register("-command_id", (void*)g_idCancelRealtime);
            g_rec->Register("-gaccel", (void*)&g_accelCaptureTrackRealtime);
            g_rec->Register("-command_id", (void*)g_idCaptureTrackRealtime);
            g_rec->Register("-gaccel", (void*)&g_accelCaptureBatchRazor);
            g_rec->Register("-command_id", (void*)g_idCaptureBatchRazor);
            g_rec->Register("-gaccel", (void*)&g_accelCaptureBatchItems);
            g_rec->Register("-command_id", (void*)g_idCaptureBatchItems);
            g_rec->Register("-gaccel", (void*)&g_accelInsertSelectedConform);
            g_rec->Register("-command_id", (void*)g_idInsertSelectedConform);
            g_rec->Register("-gaccel", (void*)&g_accelInsertSelected);
            g_rec->Register("-command_id", (void*)g_idInsertSelected);
            g_rec->Register("-gaccel", (void*)&g_accelCaptureItemAssign);
            g_rec->Register("-command_id", (void*)g_idCaptureItemAssign);
            g_rec->Register("-gaccel", (void*)&g_accelToggleBankPanel);
            g_rec->Register("-command_id", (void*)g_idToggleBankPanel);
            // Mirror-unregister the capture family: gaccel + command_id per row, with
            // '-'-prefixed strings (per the contract). The command id is re-composed from
            // the same suffix + channel prefix used at register — identical string.
            {
                const auto& table = reasampler::captureActionTable();
                for (std::size_t i = 0; i < table.size(); ++i)
                {
                    if (i < g_captureAccels.size())
                        g_rec->Register("-gaccel", (void*)&g_captureAccels[i]);
                    const std::string id =
                        reasampler::channelCommandId(table[i].commandSuffix);
                    g_rec->Register("-command_id", (void*)id.c_str());
                }
            }
            // Retire the removed M7 command ids (command_id only — we never held a gaccel
            // for them this session). Clears stale user keybindings on unload. Composed
            // per channel so a beta clears beta-qualified retired ids, stable clears its own.
            for (const char* suffix : kRetiredCaptureCmdSuffixes)
            {
                const std::string id = reasampler::channelCommandId(suffix);
                g_rec->Register("-command_id", (void*)id.c_str());
            }
        }
        // Destroy the docked window and release cached thumbnails before we drop
        // the API pointers (DockWindowRemove/DestroyWindow need them live).
        reasampler::bankPanelShutdown();
        g_rec = nullptr;
        return 0;
    }

    // ABI guard: the struct layout we compiled against must match this REAPER.
    if (rec->caller_version != REAPER_PLUGIN_VERSION)
        return 0;

    // Resolve every REAPER API function pointer. Returns the number that FAILED
    // to load; 0 == success. Non-zero usually means REAPER is older than our SDK.
    if (REAPERAPI_LoadAPI(rec->GetFunc) != 0)
        return 0;

    g_hInst = hInstance;
    g_rec   = rec;

    // Register the three-scope capture action family (command_id -> gaccel per table row).
    // The single hookcommand below routes every fired id back to its row by index.
    // g_captureAccels must be sized BEFORE the loop and never reallocated after —
    // REAPER holds a pointer to each element until we mirror-unregister it.
    {
        const auto& table = reasampler::captureActionTable();
        g_captureCmdIds.assign(table.size(), 0);
        g_captureAccels.assign(table.size(), gaccel_register_t{});
        g_captureDescs.assign(table.size(), std::string{});
        for (std::size_t i = 0; i < table.size(); ++i)
        {
            // Compose the channel-qualified id (prefix + suffix) and label
            // ("ReaSampler[ beta]: " + phrase). The id is interned so unregister re-presents
            // the same pointer; the label lives in g_captureDescs for the gaccel's lifetime.
            const int cmd =
                rec->Register("command_id", (void*)internCmdId(table[i].commandSuffix));
            g_captureCmdIds[i] = cmd;
            if (cmd)
            {
                g_captureDescs[i] =
                    reasampler::channelActionName(table[i].descriptionPhrase);
                g_captureAccels[i].accel.cmd = cmd;
                g_captureAccels[i].desc      = g_captureDescs[i].c_str();
                rec->Register("gaccel", (void*)&g_captureAccels[i]);
            }
        }
    }

    // Point the bank panel at the live session BEFORE registering its action, so
    // a toggle firing immediately has a session to read (M5). Does not open the
    // window — only stores the session pointer.
    reasampler::bankPanelInit(&g_session);

    // Register the M5 "toggle bank panel" action (command_id -> gaccel ->
    // hookcommand + toggleaction for the checked state). Id + label are channel-qualified.
    g_idToggleBankPanel = internCmdId("TOGGLE_BANK_PANEL");
    g_cmdToggleBankPanel = rec->Register("command_id", (void*)g_idToggleBankPanel);
    if (g_cmdToggleBankPanel)
    {
        g_descToggleBankPanel = reasampler::channelActionName("toggle bank panel");
        g_accelToggleBankPanel.accel.cmd = g_cmdToggleBankPanel;
        g_accelToggleBankPanel.desc      = g_descToggleBankPanel.c_str();
        rec->Register("gaccel", (void*)&g_accelToggleBankPanel);
        rec->Register("toggleaction", (void*)&OnToggleAction);
    }

    // Register the S8 "capture selected item / time-selection into bank + assign" action
    // (command_id -> gaccel -> hookcommand). Reuses the Item-scope offline capture path and
    // writes an assignment request so the active instance plays the new sample. Channel-
    // qualified FOREVER-STABLE id (suffix CAPTURE_ITEM_ASSIGN). MIDI-bindable like every
    // capture action. Registered in the capture family (main.cpp) because it leans on the
    // capture render machinery here; the other two ingest surfaces live in the ingest family
    // (Media-Explorer import) and the panel drop callback.
    g_idCaptureItemAssign = internCmdId("CAPTURE_ITEM_ASSIGN");
    g_cmdCaptureItemAssign = rec->Register("command_id", (void*)g_idCaptureItemAssign);
    if (g_cmdCaptureItemAssign)
    {
        g_descCaptureItemAssign = reasampler::channelActionName(
            "capture selected item into bank + assign to active instance");
        g_accelCaptureItemAssign.accel.cmd = g_cmdCaptureItemAssign;
        g_accelCaptureItemAssign.desc      = g_descCaptureItemAssign.c_str();
        rec->Register("gaccel", (void*)&g_accelCaptureItemAssign);
    }

    // Register the M6 insert actions (command_id -> gaccel -> hookcommand). Two
    // variants: native-length (default, no stretch) and the EXPLICIT conform-to-
    // tempo opt-in. Both read the bank panel selection and place at the edit cursor.
    g_idInsertSelected = internCmdId("INSERT_SELECTED");
    g_cmdInsertSelected = rec->Register("command_id", (void*)g_idInsertSelected);
    if (g_cmdInsertSelected)
    {
        g_descInsertSelected =
            reasampler::channelActionName("insert selected sample at edit cursor");
        g_accelInsertSelected.accel.cmd = g_cmdInsertSelected;
        g_accelInsertSelected.desc      = g_descInsertSelected.c_str();
        rec->Register("gaccel", (void*)&g_accelInsertSelected);
    }

    g_idInsertSelectedConform = internCmdId("INSERT_SELECTED_CONFORM");
    g_cmdInsertSelectedConform = rec->Register("command_id", (void*)g_idInsertSelectedConform);
    if (g_cmdInsertSelectedConform)
    {
        g_descInsertSelectedConform = reasampler::channelActionName(
            "insert selected sample at edit cursor (conform to tempo)");
        g_accelInsertSelectedConform.accel.cmd = g_cmdInsertSelectedConform;
        g_accelInsertSelectedConform.desc      = g_descInsertSelectedConform.c_str();
        rec->Register("gaccel", (void*)&g_accelInsertSelectedConform);
    }

    // Register the M11 batch-capture actions (command_id -> gaccel -> hookcommand). Each
    // fires N captures (one bank sample per selected item / per razor area), honoring every
    // precision invariant per unit and restoring the original selection on every path.
    // Channel-qualified FOREVER-STABLE ids.
    g_idCaptureBatchItems = internCmdId("CAPTURE_BATCH_ITEMS");
    g_cmdCaptureBatchItems = rec->Register("command_id", (void*)g_idCaptureBatchItems);
    if (g_cmdCaptureBatchItems)
    {
        g_descCaptureBatchItems =
            reasampler::channelActionName("batch capture selected items (one per item)");
        g_accelCaptureBatchItems.accel.cmd = g_cmdCaptureBatchItems;
        g_accelCaptureBatchItems.desc      = g_descCaptureBatchItems.c_str();
        rec->Register("gaccel", (void*)&g_accelCaptureBatchItems);
    }

    g_idCaptureBatchRazor = internCmdId("CAPTURE_BATCH_RAZOR");
    g_cmdCaptureBatchRazor = rec->Register("command_id", (void*)g_idCaptureBatchRazor);
    if (g_cmdCaptureBatchRazor)
    {
        g_descCaptureBatchRazor =
            reasampler::channelActionName("batch capture razor areas (one per area)");
        g_accelCaptureBatchRazor.accel.cmd = g_cmdCaptureBatchRazor;
        g_accelCaptureBatchRazor.desc      = g_descCaptureBatchRazor.c_str();
        rec->Register("gaccel", (void*)&g_accelCaptureBatchRazor);
    }

    // Register the "capture selected track (realtime)" action (command_id -> gaccel ->
    // hookcommand). Realtime sibling of the offline CAPTURE_TRACK scope: records the
    // selected track's own output in realtime into a hidden temp track, moves it into
    // the bank. Dialog-free. Channel-qualified FOREVER-STABLE id.
    g_idCaptureTrackRealtime = internCmdId("CAPTURE_TRACK_REALTIME");
    g_cmdCaptureTrackRealtime = rec->Register("command_id", (void*)g_idCaptureTrackRealtime);
    if (g_cmdCaptureTrackRealtime)
    {
        g_descCaptureTrackRealtime =
            reasampler::channelActionName("capture selected track (realtime)");
        g_accelCaptureTrackRealtime.accel.cmd = g_cmdCaptureTrackRealtime;
        g_accelCaptureTrackRealtime.desc      = g_descCaptureTrackRealtime.c_str();
        rec->Register("gaccel", (void*)&g_accelCaptureTrackRealtime);
    }

    // Cancel-in-flight sibling: aborts a running realtime capture (stop + restore).
    // Channel-qualified FOREVER-STABLE id.
    g_idCancelRealtime = internCmdId("CANCEL_REALTIME_CAPTURE");
    g_cmdCancelRealtime = rec->Register("command_id", (void*)g_idCancelRealtime);
    if (g_cmdCancelRealtime)
    {
        g_descCancelRealtime = reasampler::channelActionName("cancel realtime capture");
        g_accelCancelRealtime.accel.cmd = g_cmdCancelRealtime;
        g_accelCancelRealtime.desc      = g_descCancelRealtime.c_str();
        rec->Register("gaccel", (void*)&g_accelCancelRealtime);
    }

    // Register the M10 "re-capture from source" action (command_id -> gaccel ->
    // hookcommand). Regenerates the selected provenanced sample from its recorded
    // source's current state; bank-only, never places on the timeline. Channel-
    // qualified FOREVER-STABLE id (suffix RECAPTURE_FROM_SOURCE).
    g_idRecaptureFromSource = internCmdId("RECAPTURE_FROM_SOURCE");
    g_cmdRecaptureFromSource = rec->Register("command_id", (void*)g_idRecaptureFromSource);
    if (g_cmdRecaptureFromSource)
    {
        g_descRecaptureFromSource = reasampler::channelActionName("re-capture from source");
        g_accelRecaptureFromSource.accel.cmd = g_cmdRecaptureFromSource;
        g_accelRecaptureFromSource.desc      = g_descRecaptureFromSource.c_str();
        rec->Register("gaccel", (void*)&g_accelRecaptureFromSource);
    }

    // Register the Phase V "show version" action (command_id -> gaccel -> hookcommand).
    // On-demand only — prints the CMake-sourced version to the console when fired; no
    // startup print. Channel-qualified FOREVER-STABLE id; label carries the channel prefix
    // so a beta's "show version" is distinguishable from stable's in the Actions list.
    g_idShowVersion = internCmdId("SHOW_VERSION");
    g_cmdShowVersion = rec->Register("command_id", (void*)g_idShowVersion);
    if (g_cmdShowVersion)
    {
        g_descShowVersion = reasampler::channelActionName("show version");
        g_accelShowVersion.accel.cmd = g_cmdShowVersion;
        g_accelShowVersion.desc      = g_descShowVersion.c_str();
        rec->Register("gaccel", (void*)&g_accelShowVersion);
    }

    // Register the Design View action family (D4): toggle/activate mode, tag/untag/
    // show-both selected tracks. Each mints its own command_id + gaccel; the single
    // hookcommand below routes them via designViewHandleCommand. Registered before
    // the hook so every id is minted first.
    reasampler::designViewRegisterActions(rec, &g_session);

    // Register the multi-bank action family (B3): create/rename/delete/evacuate bank,
    // activate (cycle + pool), move/copy selected samples to a bank, and the two
    // full-height layout toggles. Shares g_session with the Design View family; routed
    // by the same hookcommand via bankHandleCommand. Registered before the hook.
    reasampler::bankRegisterActions(rec, &g_session);

    // Register the S8 ingest action family: the Media-Explorer import-into-bank+assign
    // action. Shares g_session with the other families; routed by the same hookcommand via
    // ingestHandleCommand. (The arrange capture+assign action is registered in the capture
    // family above; the drop path is a bank_panel callback, not a bindable action.)
    reasampler::ingestRegisterActions(rec, &g_session);

    // One hookcommand routes every ReaSampler action (spike + toggle + Design View).
    // Registered once, after all command ids are minted.
    rec->Register("hookcommand", (void*)&OnHookCommand);

    // Drive project-load / Save-As detection (M4 persist). The timer polls the
    // active project each tick; on a project load it reloads the bank from ext
    // state, on a Save-As it relocates the bank folder under the new .rpp.
    rec->Register("timer", (void*)&OnTimer);

    // Register the projectconfig hook so an UNDO/REDO state restore reloads the
    // session's book + view from the restored ext state (R-B). The timer's identity
    // poll cannot see an undo (same project identity), so this hook owns undo/redo; it
    // requests a deferred reload that the next timer tick drains (see the hook comment).
    rec->Register("projectconfig", (void*)&g_projectConfig);

    return 1;  // success — REAPER keeps us loaded
}
