// ingest.cpp — the S8 "ingest through the bank" shell (extension side). See ingest.h.
//
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h WITHOUT
// REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are extern
// (CLAUDE.md §contract). REAPER-facing, DAW-verified; the pure serialization it drives
// (assignment_request) is CTest-tested.

#include "ingest.h"

#include <cstdint>
#include <cstring>
#include <ctime>
#include <filesystem>
#include <fstream>
#include <string>
#include <vector>

#include "actions.h"             // persistBankOp — shared undo-block wrapper (R-B path)
#include "app_version.h"         // channelCommandId / channelActionName
#include "assignment_request.h"  // pure (bankId, sampleId, generation) encode
#include "bank_book.h"           // BankBook, Bank, activeBankId / activeIndex
#include "bank_model.h"          // Sample, AddResult, findByHash
#include "bank_panel.h"          // bankPanelRefresh
#include "capture_paths.h"       // deriveBankPaths / projectDirOfRpp / hashWavContent
#include "persist.h"             // ReaSamplerSession

#include "wav_trim.h"             // parseWavLayout — 32f-float WAV validator for the fast path

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

#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_ShowConsoleMsg
#define REAPERAPI_WANT_MediaExplorerGetLastPlayedFileInfo
#define REAPERAPI_WANT_PCM_Source_CreateFromFile
#define REAPERAPI_WANT_PCM_Source_Destroy
#define REAPERAPI_WANT_GetMediaSourceNumChannels
#define REAPERAPI_WANT_GetMediaSourceSampleRate
#define REAPERAPI_WANT_GetMediaSourceLength
#define REAPERAPI_WANT_Undo_BeginBlock2
#define REAPERAPI_WANT_Undo_EndBlock2
#include "reaper_plugin_functions.h"

namespace reasampler {

namespace {

// The live session the ingest paths mutate. Set once by ingestRegisterActions and read by
// every ingest body. Not owned here (main.cpp owns g_session).
ReaSamplerSession* g_session = nullptr;

// FOREVER-STABLE ingest action-id SUFFIX (Phase V, V4). The channel prefix is prepended at
// register via channelCommandId; NEVER change a shipped suffix. Only the Media-Explorer
// import registers here — the arrange capture+assign action lives in the capture family in
// main.cpp (it reuses the capture render machinery there), and the drop path is a panel
// callback (ingestDroppedFiles), not a bindable action.
constexpr const char* kIdImportMediaExplorer = "INGEST_IMPORT_MEDIA_EXPLORER";

int               g_cmdImportMediaExplorer = 0;
gaccel_register_t g_accelImportMediaExplorer{};

// Durable store of the composed, channel-qualified command-id + label strings. Two scalar
// std::string globals (one action); their c_str() pointers are handed to REAPER at register
// and re-presented at unregister, so these strings must not be mutated after registration.
// Populated once by ingestRegisterActions; stable for the extension lifetime.
std::string g_idImportStr;
std::string g_labelImportStr;

// --- Project directory --------------------------------------------------------

// The current project's directory (parent of its .rpp), forward-slashed, no trailing
// slash — the M4 convention (projectDirOfRpp). Empty for an unsaved/no-active project,
// which makes the import refuse to place a file (no default-location fallback — the
// relative-paths invariant). Read-only.
std::string currentProjectDir() {
    std::vector<char> buf(4096, '\0');
    EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
    return projectDirOfRpp(std::string(buf.data()));
}

// Reads a whole file's bytes. Empty vector on any failure (missing / unreadable). Mirror
// of capture.cpp's readFileBytes — used to read the source and validate/hash the bank copy.
std::vector<std::uint8_t> readFileBytes(const std::string& path) {
    std::ifstream f(path, std::ios::binary | std::ios::ate);
    if (!f) return {};
    const std::streamsize n = f.tellg();
    if (n <= 0) return {};
    std::vector<std::uint8_t> bytes(static_cast<std::size_t>(n));
    f.seekg(0);
    f.read(reinterpret_cast<char*>(bytes.data()), n);
    if (!f) return {};
    return bytes;
}

// Writes a byte buffer to a file. Returns true on success. The caller is responsible for
// ensuring the directory exists before calling.
bool writeFileBytes(const std::string& path, const std::vector<std::uint8_t>& bytes) {
    std::ofstream f(path, std::ios::binary | std::ios::trunc);
    if (!f) return false;
    f.write(reinterpret_cast<const char*>(bytes.data()),
            static_cast<std::streamsize>(bytes.size()));
    return f.good();
}

// Builds a minimal 32-bit-float RIFF/WAVE byte buffer from interleaved double samples.
// The output is a canonical WAV the bank and wav_trim can read:
//   RIFF chunk, WAVE form, fmt chunk (tag 3 = WAVE_FORMAT_IEEE_FLOAT, 16-byte body),
//   data chunk (interleaved little-endian float32, one float per sample per channel).
// `nch` channels, `rate` Hz sample rate, `frameCount` frames (total samples = frameCount*nch).
// Each ReaSample (double) is narrowed to float by assignment — the instrument expects
// 32-bit float; the reduction is intentional and matches how the bank contract is defined
// (capture.cpp kRenderFormatWavFloat32; wav_trim.h FORMAT ASSUMPTION).
std::vector<std::uint8_t> buildFloat32Wav(int nch, std::uint32_t rate,
                                          std::size_t frameCount,
                                          const std::vector<ReaSample>& interleaved) {
    const std::size_t sampleCount     = frameCount * static_cast<std::size_t>(nch);
    const std::size_t dataBytesCount  = sampleCount * 4u;  // 4 bytes per float32

    // The WAV is: RIFF(4)+size(4)+WAVE(4) = 12, fmt (4)+size(4)+16 body = 24, data (4)+size(4)+payload.
    // Total = 12 + 24 + 8 + dataBytesCount = 44 + dataBytesCount.
    const std::uint32_t riffSize =
        static_cast<std::uint32_t>(36u + dataBytesCount);  // 4("WAVE")+24(fmt chunk)+8(data hdr)+data

    std::vector<std::uint8_t> out;
    out.reserve(44u + dataBytesCount);

    auto putU16 = [&](std::uint16_t v) {
        out.push_back(static_cast<std::uint8_t>(v & 0xFF));
        out.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
    };
    auto putU32 = [&](std::uint32_t v) {
        out.push_back(static_cast<std::uint8_t>(v & 0xFF));
        out.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
        out.push_back(static_cast<std::uint8_t>((v >> 16) & 0xFF));
        out.push_back(static_cast<std::uint8_t>((v >> 24) & 0xFF));
    };
    auto putTag = [&](const char* t) {
        for (int i = 0; i < 4; ++i)
            out.push_back(static_cast<std::uint8_t>(t[i]));
    };
    auto putF32 = [&](float f) {
        std::uint8_t tmp[4];
        std::memcpy(tmp, &f, 4);
        for (int i = 0; i < 4; ++i) out.push_back(tmp[i]);
    };

    // RIFF header
    putTag("RIFF");
    putU32(riffSize);
    putTag("WAVE");

    // fmt chunk (16-byte body, WAVE_FORMAT_IEEE_FLOAT = 0x0003)
    putTag("fmt ");
    putU32(16u);                                // chunk body size
    putU16(0x0003u);                            // WAVE_FORMAT_IEEE_FLOAT
    putU16(static_cast<std::uint16_t>(nch));
    putU32(rate);
    putU32(rate * static_cast<std::uint32_t>(nch) * 4u);  // avgBytesPerSec
    putU16(static_cast<std::uint16_t>(nch * 4));           // blockAlign
    putU16(32u);                                            // bitsPerSample

    // data chunk
    putTag("data");
    putU32(static_cast<std::uint32_t>(dataBytesCount));
    for (std::size_t i = 0; i < sampleCount && i < interleaved.size(); ++i)
        putF32(static_cast<float>(interleaved[i]));

    return out;
}

// Decodes ALL samples from `src` into interleaved double-precision frames.
// Returns empty on a zero-length or silent source (sampleRate < 1, channelCount == 0).
// Uses GetSamples in blocks; advances time_s monotonically. The caller has already
// queried channelCount and sampleRate from the same source; those values are passed in
// to avoid re-querying after GetSamples mutates decoder state.
std::vector<ReaSample> decodePcmSource(PCM_source* src, int nch, double sampleRate,
                                       double lengthSeconds) {
    if (!src || nch <= 0 || sampleRate < 1.0 || lengthSeconds <= 0.0) return {};

    const std::size_t totalFrames =
        static_cast<std::size_t>(lengthSeconds * sampleRate + 0.5);
    if (totalFrames == 0) return {};

    std::vector<ReaSample> out;
    out.reserve(totalFrames * static_cast<std::size_t>(nch));

    // Pull samples in blocks of ~4096 frames; loop until source is exhausted.
    constexpr int kBlockFrames = 4096;
    std::vector<ReaSample> block(static_cast<std::size_t>(kBlockFrames * nch));

    PCM_source_transfer_t t{};
    t.samplerate = sampleRate;
    t.nch        = nch;
    t.time_s     = 0.0;
    t.midi_events = nullptr;

    while (true) {
        t.samples    = block.data();
        t.length     = kBlockFrames;
        t.samples_out = 0;
        src->GetSamples(&t);
        if (t.samples_out <= 0) break;
        const std::size_t got = static_cast<std::size_t>(t.samples_out) *
                                static_cast<std::size_t>(nch);
        out.insert(out.end(), block.data(), block.data() + got);
        t.time_s += static_cast<double>(t.samples_out) / sampleRate;
    }
    return out;
}

// The result of an import-into-bank: the sample id to assign (the existing id on a
// hash-dedup collapse, the new id otherwise) and whether anything was added to the index
// (so the caller opens an undo point only for a real mutation).
struct ImportResult {
    std::string sampleId;      // "" on failure (nothing to assign)
    bool        added = false; // true iff a NEW index entry was created (not a collapse)
    std::string message;       // human-readable outcome for the console
};

// Imports one OS-native source file into the ACTIVE bank: convert-if-needed to 32-bit-
// float WAV, write to the project-relative bank folder, index-add, hash-dedup applied.
//
// BANK CONTRACT: the instrument (wav_trim) expects every bank file to be a canonical
// 32-bit-float WAV (WAVE_FORMAT_IEEE_FLOAT, 32 bits). A verbatim copy of a non-WAV (or
// an integer-PCM or double-float WAV) would be unplayable. This function therefore:
//   1. Checks whether the source IS already a valid 32f WAV (parseWavLayout fast path).
//   2. If yes: copies it verbatim — one I/O, content unchanged.
//   3. If no: decodes via PCM_source::GetSamples and writes a fresh 32f WAV, preserving
//      the source's channel count and sample rate.
//
// DEDUP ORDERING: the content hash is taken from the CONVERTED (bank-format) bytes AFTER
// building the file buffer but BEFORE writing to disk. This means:
//   * Re-importing the same source file yields the same converted bytes → same hash →
//     dedup fires → no redundant disk write (matching the DEDUP-BEFORE-DISK design).
//   * An imported WAV whose audio-content hash matches a captured WAV also deduplicates
//     correctly (hashWavContent is chunk-aware for both).
//   * The pre-conversion hash shortcut (hash the raw source bytes) is not used: a non-WAV
//     source's bytes would produce a different hash from the converted WAV bytes, so two
//     imports of the same mp3 would NOT dedup — which is wrong. Hashing post-conversion
//     is correct.
//
// NON-DESTRUCTIVE: the source file is never modified or moved — only read.
// Records the written file in the owned-file manifest (Phase B B-cap) so Phase R prune can
// attribute it. Does NOT persist or open an undo point — the caller batches that (a
// multi-file drop is one undo point, one persist).
ImportResult importFileIntoActiveBank(const std::string& absoluteSourcePath) {
    ImportResult out;

    if (absoluteSourcePath.empty()) {
        out.message = "empty file path";
        return out;
    }
    namespace fs = std::filesystem;
    std::error_code ec;
    if (!fs::exists(absoluteSourcePath, ec) || ec) {
        out.message = "file not found: " + absoluteSourcePath;
        return out;
    }

    const std::string projectDir = currentProjectDir();
    if (projectDir.empty()) {
        out.message = "no saved project, so the bank has no location -- save the "
                      "project first";
        return out;
    }

    // Read source bytes; needed to check whether it is already a 32f WAV.
    const std::vector<std::uint8_t> srcBytes = readFileBytes(absoluteSourcePath);
    if (srcBytes.empty()) {
        out.message = "file is empty or unreadable: " + absoluteSourcePath;
        return out;
    }

    // Probe the source's audio geometry via PCM_source. Needed for conversion AND for
    // populating the Sample's metadata. A file REAPER cannot open leaves geometry at
    // zero — the sample still imports if the WAV-fast-path succeeds; the geometry
    // is simply unknown, the honest default.
    int    channelCount  = 0;
    int    sampleRate    = 0;
    double lengthSeconds = 0.0;
    PCM_source* srcHandle = PCM_Source_CreateFromFile(absoluteSourcePath.c_str());
    if (srcHandle) {
        channelCount  = GetMediaSourceNumChannels(srcHandle);
        sampleRate    = GetMediaSourceSampleRate(srcHandle);
        bool isQN = false;
        lengthSeconds = GetMediaSourceLength(srcHandle, &isQN);
        if (isQN) lengthSeconds = 0.0;  // QN-length source has no seconds length to store
    }

    // Determine whether a verbatim copy suffices (fast path) or a conversion is needed.
    // parseWavLayout validates that the source is a canonical 32-bit-float RIFF/WAVE; any
    // other format (mp3, aiff, integer PCM, 16-bit WAV, etc.) takes the decode+rewrite path.
    const WavLayout layout = parseWavLayout(srcBytes);
    const bool isFloat32Wav = layout.valid;

    // Build the bank-format bytes in memory (the "converted" bytes), which we hash for dedup
    // BEFORE writing to disk so a re-import of the same source skips the disk write.
    std::vector<std::uint8_t> bankBytes;
    if (isFloat32Wav) {
        // Fast path: already canonical — bank bytes ARE the source bytes.
        bankBytes = srcBytes;
        if (srcHandle) PCM_Source_Destroy(srcHandle);
    } else {
        // Conversion path: decode all samples then write a fresh 32f WAV.
        // PCM_source is opened on the source path (not a copy); we already have srcHandle.
        std::vector<ReaSample> decoded;
        if (srcHandle && channelCount > 0 && sampleRate > 0 && lengthSeconds > 0.0) {
            decoded = decodePcmSource(srcHandle, channelCount,
                                      static_cast<double>(sampleRate), lengthSeconds);
        }
        if (srcHandle) PCM_Source_Destroy(srcHandle);

        if (decoded.empty()) {
            // No decodable audio. The source is on disk (valid path, REAPER could open it)
            // but yielded no samples — e.g. a MIDI file, a zero-length audio file, or a
            // format REAPER does not support. Fail loudly: we must not write a silent WAV
            // and pretend the import succeeded.
            out.message = "could not decode audio samples from: " +
                          fs::path(absoluteSourcePath).filename().string() +
                          " (unsupported format or no audio data)";
            return out;
        }

        const std::size_t frameCount =
            decoded.size() / static_cast<std::size_t>(channelCount > 0 ? channelCount : 1);
        bankBytes = buildFloat32Wav(channelCount,
                                   static_cast<std::uint32_t>(sampleRate),
                                   frameCount, decoded);
    }
    // srcHandle is destroyed above in both branches.

    // Hash the converted (bank-format) bytes for dedup. WAV-aware hash (hashWavContent)
    // so a re-import of the same source deduplicates against a previously-captured or
    // previously-imported sample with identical audio content, even if non-audio RIFF
    // chunks differ. Empty hash (unhashable) is treated as "not dedupable" (safe direction:
    // copies + adds rather than silently collapsing onto an unrelated entry).
    const std::string contentHash = hashWavContent(bankBytes);

    BankBook& book = g_session->book();

    // Dedup-before-disk: if the active bank already holds this audio content, assign the
    // existing sample's id and skip the disk write (no redundant on-disk duplicate).
    // Empty hashes never match (findByHash treats "" as non-participating).
    if (!contentHash.empty()) {
        if (const Sample* existing = book.activeIndex().findByHash(contentHash)) {
            out.sampleId = existing->id;
            out.added    = false;  // already present — no index mutation, no undo point
            out.message  = "already in the active bank (assigned existing sample)";
            return out;
        }
    }

    // Derive the destination path. The stem comes from the source file name; a timestamp
    // uniqueTag avoids collision with a prior import of a same-named file.
    const std::string sourceStem = fs::path(absoluteSourcePath).stem().string();
    const std::int64_t nowSec = static_cast<std::int64_t>(std::time(nullptr));
    const std::string uniqueTag = std::to_string(nowSec);
    const BankPaths paths = deriveBankPaths(projectDir, sourceStem, uniqueTag);

    // Ensure the bank folder exists, then write the (converted) bank bytes.
    fs::create_directories(paths.absoluteDir, ec);  // idempotent; ec ignored (write reports)
    const std::string destPath = paths.absoluteDir + "/" + paths.fileName;
    if (!writeFileBytes(destPath, bankBytes)) {
        out.message = "could not write converted file to the bank folder";
        return out;
    }

    // Build the Sample. Import is NOT a capture — sourceMode/range/tail do not apply; we
    // record what we know (path, hash, geometry, name) and leave capture-only fields at
    // their defaults. rootNote/loop stay empty: an imported file is not a single played
    // note, so we do not guess a root note.
    Sample s;
    s.id            = "imp-" + uniqueTag + "-" + paths.fileName;
    s.displayName   = sourceStem.empty() ? std::string("import") : sourceStem;
    s.relativePath  = paths.relativePath;  // project-relative (invariant)
    s.channelCount  = channelCount;
    s.sampleRate    = sampleRate;
    s.lengthSeconds = lengthSeconds;
    s.tier          = Tier::Scratch;       // imports land in scratch, like captures
    s.contentHash   = contentHash;
    s.createdTimestamp = nowSec;

    const AddResult r = book.activeIndex().add(s);
    // Record the written file as owned regardless of the add outcome — the tool WROTE it, so
    // Phase R prune must attribute it. (A Collapsed result here would mean another sample in
    // the active bank matched the hash after we passed the pre-write dedup check — a narrow
    // race window. Record + handle both honestly.)
    g_session->owned().add(paths.relativePath);

    switch (r) {
        case AddResult::Added:
            out.sampleId = s.id;
            out.added    = true;
            out.message  = (isFloat32Wav ? "imported -> " : "converted + imported -> ") +
                           paths.relativePath;
            break;
        case AddResult::Collapsed: {
            // The hash matched an existing entry (a race against our pre-write dedup check,
            // or an empty-hash edge). Assign the existing entry's id.
            const Sample* existing =
                contentHash.empty() ? nullptr : book.activeIndex().findByHash(contentHash);
            out.sampleId = existing ? existing->id : std::string{};
            out.added    = false;
            out.message  = "collapsed onto an existing bank sample";
            break;
        }
        case AddResult::RejectedAbsolutePath:
        case AddResult::RejectedEmptyId:
            // deriveBankPaths always yields a relative path and a non-empty id above, so
            // these are unreachable in practice — reported honestly rather than silently.
            out.message = "index rejected the import (internal path/id error)";
            break;
    }
    return out;
}

// --- Media-Explorer import action --------------------------------------------

// Import the Media Explorer's current last-played/selected file into the active bank and
// assign it to the active instance (S8 surface 2). Single-file, pull-on-action:
// MediaExplorerGetLastPlayedFileInfo returns the ONE last-played file (the whole ME
// contract — no enumerate-selected API). The selection RANGE it reports is deliberately
// IGNORED here: an import brings the whole file into the bank (the range is a preview
// hint, and the fields are [0,1] fractions, not seconds — see the DAW-verify note); a
// user wanting a sub-range captures it via the arrange path instead. Undo-wrapped.
void doImportFromMediaExplorer() {
    // filemode/sel/pitch/vol/rate/bpm/extrainfo are read but only the filename is used for
    // the import. selstart/selend are [0,1] fractions (SDK header) — a preview hint, not a
    // bank-relevant range; left unused. extrainfo is documented "currently unused".
    std::vector<char> nameBuf(4096, '\0');
    int    filemode = 0;
    double selStart = 0.0, selEnd = 0.0;
    double pitch = 0.0, vol = 0.0, rate = 0.0, srcbpm = 0.0;
    std::vector<char> extra(256, '\0');  // documented unused; sized generously to be safe
    const bool ok = MediaExplorerGetLastPlayedFileInfo(
        nameBuf.data(), static_cast<int>(nameBuf.size()), &filemode, &selStart, &selEnd,
        &pitch, &vol, &rate, &srcbpm, extra.data(), static_cast<int>(extra.size()));

    const std::string path(nameBuf.data());
    if (!ok || path.empty()) {
        ShowConsoleMsg("ReaSampler ingest: no Media Explorer file to import -- open the "
                       "Media Explorer and select (or preview) a file first.\n");
        return;
    }

    const ImportResult r = importFileIntoActiveBank(path);
    if (r.sampleId.empty()) {
        ShowConsoleMsg(("ReaSampler ingest: Media Explorer import failed -- " + r.message +
                        ".\n").c_str());
        return;
    }

    // Persist the bank add AND the assign request inside ONE undo block so Ctrl-Z rolls
    // back both keys atomically: undo restores `banks` (removing the new sample) AND
    // clears the `assign_request` that named it, so no stale request can survive.
    // The block is opened only when the index mutated (a dedup collapse changed nothing).
    // If saveToActiveProject() no-ops (unsaved project), we close with an empty label +
    // zero flag so REAPER discards the undo entry (the house pattern from actions.cpp).
    if (r.added) {
        Undo_BeginBlock2(nullptr);
        // S9: an ingest import adds a sample to the active bank -> bump inside the block so
        // the stamped generation refreshes the assigned instance hands-free (and undo rolls
        // the generation back with the banks/assign_request keys).
        g_session->bumpBankGeneration();
        const bool persisted = g_session->saveToActiveProject();
        // Assign request inside the same block: undo rolls back both keys together.
        ingestAssignActiveInstance(g_session->book().activeBankId(), r.sampleId);
        if (persisted)
            Undo_EndBlock2(nullptr, "ReaSampler: import from Media Explorer",
                           UNDO_STATE_MISCCFG);
        else
            Undo_EndBlock2(nullptr, "", 0);
    } else {
        // Dedup collapse: index unchanged, no undo point. Assign request still written
        // (the user explicitly re-imported; they want the instance updated).
        ingestAssignActiveInstance(g_session->book().activeBankId(), r.sampleId);
    }
    bankPanelRefresh();
    ShowConsoleMsg(("ReaSampler ingest: " + r.message + " (assigned to the active "
                    "instance).\n").c_str());
}

}  // namespace

// --- Assignment-request write ------------------------------------------------

void ingestAssignActiveInstance(const std::string& bankId, const std::string& sampleId) {
    if (!g_session || sampleId.empty()) return;  // nothing to assign

    AssignmentRequest req;
    req.bankId     = bankId;
    req.sampleId   = sampleId;
    // Monotonic disambiguator: a wall-clock unix-epoch stamp so the reader tells a fresh
    // assign (even re-assigning the SAME id) from a stale value. NOT the S9 bank-generation
    // counter (a separate point) — this field is self-contained to the request.
    req.generation = static_cast<std::int64_t>(std::time(nullptr));

    g_session->writeAssignmentRequest(encodeAssignmentRequest(req));
}

// --- Drop-onto-panel ingest --------------------------------------------------

void ingestDroppedFiles(const std::vector<std::string>& absolutePaths) {
    if (!g_session || absolutePaths.empty()) return;

    // Import ALL dropped files; assign the FIRST successfully-imported one (documented
    // multi-file policy). Batch the persist + undo point: many imports are ONE undo entry.
    std::string firstAssignId;
    std::string firstAssignBank;
    int importedNew = 0;
    int importedTotal = 0;  // includes dedup collapses that still yielded an id to assign
    std::string lastFailure;

    for (const std::string& path : absolutePaths) {
        const ImportResult r = importFileIntoActiveBank(path);
        if (r.sampleId.empty()) {
            lastFailure = r.message;
            continue;
        }
        ++importedTotal;
        if (r.added) ++importedNew;
        if (firstAssignId.empty()) {
            firstAssignId   = r.sampleId;
            firstAssignBank = g_session->book().activeBankId();
        }
    }

    // One undo point for the whole drop, opened only if a NEW index entry was created (a
    // drop that only re-hit existing content mutated nothing on the index). The assign
    // request is written INSIDE the same block so Ctrl-Z rolls back both keys together:
    // undo restores `banks` (removing the new samples) AND clears the `assign_request` that
    // named one of them, so no stale request survives pointing to a removed sample.
    // If saveToActiveProject() no-ops (unsaved project), we close with an empty label + zero
    // flag so REAPER discards the undo entry (house pattern from actions.cpp).
    if (!firstAssignId.empty()) {
        if (importedNew > 0) {
            Undo_BeginBlock2(nullptr);
            // S9: one coalesced bump for the whole drop (>=1 new sample landed) inside the
            // block so the generation refreshes the assigned instance and undo rolls it back.
            g_session->bumpBankGeneration();
            const bool persisted = g_session->saveToActiveProject();
            // Assign inside the block: undo restores both keys atomically.
            ingestAssignActiveInstance(firstAssignBank, firstAssignId);
            if (persisted)
                Undo_EndBlock2(nullptr, "ReaSampler: import dropped file(s)",
                               UNDO_STATE_MISCCFG);
            else
                Undo_EndBlock2(nullptr, "", 0);
        } else {
            // All dropped files deduplicated: index unchanged, no undo point needed. Still
            // assign so the user sees the sample is already in the bank.
            ingestAssignActiveInstance(firstAssignBank, firstAssignId);
        }
        bankPanelRefresh();
        const std::string msg =
            "ReaSampler ingest: imported " + std::to_string(importedTotal) +
            (importedTotal == 1 ? " file" : " files") +
            " and assigned the first to the active instance.\n";
        ShowConsoleMsg(msg.c_str());
    } else {
        ShowConsoleMsg(("ReaSampler ingest: nothing imported from the drop -- " +
                        (lastFailure.empty() ? std::string("no usable files") : lastFailure) +
                        ".\n").c_str());
    }
}

// --- Action registration ------------------------------------------------------

void ingestRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session) {
    g_session = session;  // shared with the capture / bank / Design-View families

    g_idImportStr = channelCommandId(kIdImportMediaExplorer);
    g_cmdImportMediaExplorer = rec->Register("command_id", (void*)g_idImportStr.c_str());
    if (g_cmdImportMediaExplorer) {
        g_labelImportStr = channelActionName("import Media Explorer file into bank + assign");
        g_accelImportMediaExplorer.accel.cmd = g_cmdImportMediaExplorer;
        g_accelImportMediaExplorer.desc      = g_labelImportStr.c_str();
        rec->Register("gaccel", (void*)&g_accelImportMediaExplorer);
    }
}

bool ingestHandleCommand(int command) {
    if (command == 0 || !g_session) return false;
    if (command == g_cmdImportMediaExplorer) { doImportFromMediaExplorer(); return true; }
    return false;  // not ours — caller's hookcommand keeps looking
}

void ingestUnregisterActions(reaper_plugin_info_t* rec) {
    // Mirror-unregister with '-'-prefixed strings; the '-command_id' re-presents the SAME
    // interned channel-qualified id used at register (g_idImportStr).
    rec->Register("-gaccel", (void*)&g_accelImportMediaExplorer);
    rec->Register("-command_id", (void*)g_idImportStr.c_str());
    g_session = nullptr;
}

}  // namespace reasampler
