// ingest.cpp — see ingest.h. main.cpp owns the API pointers; this TU gets them extern.
// REAPER-facing, DAW-verified; the pure serialization it drives (assignment_request)
// is CTest-tested.

#include "shell/actions/ingest.h"

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

#include "core/version/app_version.h"         // channelCommandId / channelActionName
#include "core/wire/assignment_request.h"  // pure (bankId, sampleId, generation) encode
#include "core/model/bank_book.h"           // BankBook, Bank, activeBankId / activeIndex
#include "core/model/bank_model.h"          // Sample, AddResult, findByHash
#include "shell/panel/panel_input.h"  // bankPanelRefresh
#include "core/capture/capture_paths.h"       // deriveBankPaths / projectDirOfRpp
#include "core/util/file_bytes.h"  // shared whole-file loader
#include "core/wire/instrument_drop.h"     // pure buildInstrumentDropPreset (.vstpreset image for a sampleId)
#include "shell/actions/instrument_drop_win.h" // shell loadInstrumentOntoTrack (FX add+apply, no own undo block)
#include "shell/persist/session.h"             // ReaSamplerSession

#include "core/capture/wav_codec.h"            // parseWavLayout (32f fast-path validator), buildFloat32Wav, hashWavContent

#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
#define REAPERAPI_WANT_GetSelectedTrack
#include "reaper_plugin_functions.h"

namespace reasampler {

using capture::BankPaths;
using capture::buildFloat32Wav;
using capture::deriveBankPaths;
using capture::hashWavContent;
using capture::parseWavLayout;
using capture::projectDirOfRpp;
using capture::WavLayout;
using util::readFileBytes;
using version::channelActionName;
using version::channelCommandId;
using wire::AssignmentRequest;
using wire::buildInstrumentDropPreset;
using wire::encodeAssignmentRequest;

namespace {

// Not owned here (main.cpp owns g_session).
ReaSamplerSession* g_session = nullptr;

// Only the Media-Explorer import registers here — the arrange capture+assign action lives
// in the capture family in main.cpp, and the drop path is a panel callback
// (ingestDroppedFiles), not a bindable action. Its two FOREVER-STABLE suffixes are in
// ingest.h.
constexpr int kSectionMediaExplorer = 32063;

int                       g_cmdImportMediaExplorer   = 0;
int                       g_cmdImportMediaExplorerMx = 0;
gaccel_register_t         g_accelImportMediaExplorer{};
custom_action_register_t  g_customImportMediaExplorer{};

// c_str() pointers are handed to REAPER at register and re-presented at unregister,
// so these strings must not be mutated after registration. The label backs BOTH the
// gaccel desc and the custom_action name.
std::string g_idImportStr;
std::string g_idImportMxStr;
std::string g_labelImportStr;

// Forward-slashed, no trailing slash. Empty for an unsaved/no-active project, which
// makes the import refuse to place a file (relative-paths invariant, no fallback).
std::string currentProjectDir() {
    std::vector<char> buf(4096, '\0');
    EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
    return projectDirOfRpp(std::string(buf.data()));
}

// 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();
}

// buildFloat32Wav (wav_codec) takes the interleaved ReaSample (double) frames
// decoded below and yields the canonical bank-format bytes — the double->float
// narrowing is the intentional bank contract.

// Returns empty on a zero-length or silent source. The caller has already queried
// channelCount/sampleRate from the same source (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));

    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;
}

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 (the bank contract — a verbatim copy of anything else would be
// unplayable), write to the project-relative bank folder, index-add, hash-dedup.
//
// DEDUP ORDERING: the content hash is taken from the CONVERTED bytes AFTER building
// the buffer but BEFORE writing to disk, so a re-import of the same source (or of a
// WAV matching a captured file's content) collapses without a redundant disk write.
// Hashing the raw source bytes instead would miss this for non-WAV sources, since
// their bytes differ from the converted WAV bytes.
//
// NON-DESTRUCTIVE: the source file is only read. 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;
    }

    const std::vector<std::uint8_t> srcBytes = readFileBytes(absoluteSourcePath);
    if (srcBytes.empty()) {
        out.message = "file is empty or unreadable: " + absoluteSourcePath;
        return out;
    }

    // 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
    }

    // parseWavLayout validates 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;

    std::vector<std::uint8_t> bankBytes;
    if (isFloat32Wav) {
        bankBytes = srcBytes;
        if (srcHandle) PCM_Source_Destroy(srcHandle);
    } else {
        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()) {
            // e.g. a MIDI file, zero-length audio, or an unsupported format. Fail
            // loudly rather than 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);
    }

    // WAV-aware hash so a re-import deduplicates against a previously-captured or
    // previously-imported sample with identical audio content, even if non-audio
    // RIFF chunks differ. Empty (unhashable) is "not dedupable" — copies + adds
    // rather than silently collapsing onto an unrelated entry.
    const std::string contentHash = hashWavContent(bankBytes);

    BankBook& book = g_session->book();

    // Dedup-before-disk: skip the write entirely if the active bank already holds
    // this content.
    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;
        }
    }

    // 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);

    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;
    }

    // Import is NOT a capture — capture-only fields stay at defaults. rootNote/loop
    // stay empty: an imported file is not a single played note, so we do not guess.
    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);
    g_session->recordCreated(s, tracking::OriginKind::Ingest);

    switch (r) {
        case AddResult::Added:
            out.sampleId = s.id;
            out.added    = true;
            out.message  = (isFloat32Wav ? "imported -> " : "converted + imported -> ") +
                           paths.relativePath;
            break;
        case AddResult::Collapsed: {
            // A race against the pre-write dedup check (or an empty-hash edge).
            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:
            // Unreachable in practice (deriveBankPaths always yields a relative path
            // and non-empty id) — reported honestly rather than silently.
            out.message = "index rejected the import (internal path/id error)";
            break;
    }
    return out;
}

// The clause an ingest success line needs when the persist no-opped. saveToActiveProject
// returns false for exactly two reasons, no active project and an unsaved one, and in both
// the copied file is on disk with its entry in memory while NOTHING stored the index — so
// no message may report the import flatly.
std::string unsavedProjectNote(bool persisted) {
    if (persisted) return {};
    return " The bank index is IN MEMORY ONLY -- there is no saved project to store it in, "
           "so save the project to keep it.";
}

// Imports the Media Explorer's last-played/selected file into the active bank, then
// adds a ReaSampler 9000 instrument to the FIRST SELECTED TRACK pre-loaded with that
// sound — no new track, no routing changes. No assignment_request write.
//
// Single-file, pull-on-action (MediaExplorerGetLastPlayedFileInfo — no
// enumerate-selected API). The selection RANGE it reports is deliberately IGNORED:
// an import brings the whole file in ([0,1] fraction fields are a preview hint, not
// seconds); a sub-range user captures via the arrange path instead.
//
// LOAD-BEARING: NEVER inserts a timeline item, NEVER creates a track. Persist
// ordering is critical — the fresh instance's setState -> reloadInstrument reads the
// bank from project ext-state, so the sample MUST be persisted BEFORE
// loadInstrumentOntoTrack adds the FX, or the instance cannot resolve the sampleId.
void doImportFromMediaExplorer() {
    // Only the filename is used; selstart/selend are [0,1] fractions (a preview
    // hint, not a bank-relevant range), 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;
    }

    // GetSelectedTrack ignores the master; null means nothing selected — existing
    // track only, never alter the graph.
    MediaTrack* target = GetSelectedTrack(nullptr, 0);
    if (!target) {
        // Bank import is kept (sound is in the bank browser); generation is bumped
        // so any open VST3 browser instances refresh to show the new sound.
        bool persisted = true;  // true when nothing needed persisting (dedup)
        if (r.added) {
            Undo_BeginBlock2(nullptr);
            g_session->bumpBankGeneration();
            persisted = g_session->saveToActiveProject();
            if (persisted)
                Undo_EndBlock2(nullptr, "ReaSampler: import Media Explorer file into bank",
                               UNDO_STATE_MISCCFG);
            else
                Undo_EndBlock2(nullptr, "", 0);
            bankPanelRefresh();
        }
        ShowConsoleMsg(("ReaSampler ingest: " + r.message +
                        " -- select a track first, then import into it "
                        "(sound is in the bank but no instrument was placed because "
                        "no track was selected)." + unsavedProjectNote(persisted) +
                        "\n").c_str());
        return;
    }

    // Valid for BOTH the fresh import and the dedup case (added == false but a real
    // sampleId is sufficient to pre-select the sound).
    const std::vector<std::uint8_t> preset = buildInstrumentDropPreset(r.sampleId);

    // Persist happens INSIDE the block and BEFORE the FX add so the new instance's
    // setState -> reloadInstrument sees the just-persisted sample.
    Undo_BeginBlock2(nullptr);
    bool persisted = true;  // true when nothing needed persisting (dedup)
    if (r.added) {
        g_session->bumpBankGeneration();
        persisted = g_session->saveToActiveProject();
    }
    const bool placed = loadInstrumentOntoTrack(target, preset);
    if (placed && persisted)
        Undo_EndBlock2(nullptr, "ReaSampler: import from Media Explorer into selected track",
                       UNDO_STATE_MISCCFG);
    else
        // Either the FX add/inject failed (already rolled back, no orphan) or the
        // project was unsaved (persist no-op): discard so no empty point is recorded.
        Undo_EndBlock2(nullptr, "", 0);

    bankPanelRefresh();
    if (placed)
        ShowConsoleMsg(("ReaSampler ingest: " + r.message +
                        " (loaded into a new instrument on the selected track)." +
                        unsavedProjectNote(persisted) + "\n").c_str());
    else
        ShowConsoleMsg(("ReaSampler ingest: imported to the bank (" + r.message +
                        ") but could not add the instrument to the selected track." +
                        unsavedProjectNote(persisted) + "\n").c_str());
}

}  // namespace

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 wall-clock stamp so the reader tells a fresh assign (even
    // re-assigning the SAME id) from a stale value — self-contained to the request,
    // not the bank-generation counter.
    req.generation = static_cast<std::int64_t>(std::time(nullptr));

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

// Bank-fill only; no assignment_request is written (the drop has no effect on what
// any live instance plays). Batch the persist + undo point: many imports are ONE
// undo entry.
void ingestDroppedFiles(const std::vector<std::string>& absolutePaths) {
    if (!g_session || absolutePaths.empty()) return;

    int importedNew   = 0;
    int importedTotal = 0;
    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;
    }

    // One undo point for the whole drop, opened only if a NEW index entry was created.
    if (importedNew > 0) {
        Undo_BeginBlock2(nullptr);
        g_session->bumpBankGeneration();
        const bool persisted = g_session->saveToActiveProject();
        if (persisted)
            Undo_EndBlock2(nullptr, "ReaSampler: import dropped file(s)", UNDO_STATE_MISCCFG);
        else
            Undo_EndBlock2(nullptr, "", 0);
        bankPanelRefresh();
        const std::string msg =
            "ReaSampler ingest: imported " + std::to_string(importedTotal) +
            (importedTotal == 1 ? " file" : " files") + " into the bank." +
            unsavedProjectNote(persisted) + "\n";
        ShowConsoleMsg(msg.c_str());
    } else if (importedTotal > 0) {
        bankPanelRefresh();
        ShowConsoleMsg("ReaSampler ingest: all dropped files already in the bank.\n");
    } else {
        ShowConsoleMsg(("ReaSampler ingest: nothing imported from the drop -- " +
                        (lastFailure.empty() ? std::string("no usable files") : lastFailure) +
                        ".\n").c_str());
    }
}

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

    g_labelImportStr = channelActionName("import Media Explorer file into selected track");

    g_idImportStr = channelCommandId(kIngestImportMediaExplorerId);
    g_cmdImportMediaExplorer = rec->Register("command_id", (void*)g_idImportStr.c_str());
    if (g_cmdImportMediaExplorer) {
        g_accelImportMediaExplorer.accel.cmd = g_cmdImportMediaExplorer;
        g_accelImportMediaExplorer.desc      = g_labelImportStr.c_str();
        rec->Register("gaccel", (void*)&g_accelImportMediaExplorer);
    }

    // Second publication of the SAME action, into the Media Explorer section, so it can
    // be put on that window's toolbar. gaccel cannot express this — it registers into the
    // main keyboard section only — so custom_action is the one mechanism. It carries no
    // ACCEL, hence no default keybinding for this entry; toolbar reach is the point.
    g_idImportMxStr = channelCommandId(kIngestImportMediaExplorerMxId);
    g_customImportMediaExplorer.uniqueSectionId = kSectionMediaExplorer;
    g_customImportMediaExplorer.idStr           = g_idImportMxStr.c_str();
    g_customImportMediaExplorer.name            = g_labelImportStr.c_str();
    g_customImportMediaExplorer.extra           = nullptr;
    g_cmdImportMediaExplorerMx =
        rec->Register("custom_action", (void*)&g_customImportMediaExplorer);
    if (!g_cmdImportMediaExplorerMx)
        ShowConsoleMsg("ReaSampler: could not publish the Media Explorer import action into "
                       "the Media Explorer action section -- it is still available in the "
                       "Main section.\n");
}

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
}

bool ingestHandleSectionCommand(int command) {
    if (command == 0 || !g_session) return false;
    // ONLY the Media Explorer id — partitioning contract: root `CLAUDE.md`
    // §"REAPER extension contract".
    if (command == g_cmdImportMediaExplorerMx) { doImportFromMediaExplorer(); return true; }
    return false;
}

void ingestUnregisterActions(reaper_plugin_info_t* rec) {
    // A 0 return from "custom_action" means REAPER holds no registration of ours (a dupe
    // idStr is one documented cause) — mirroring it anyway could retire someone else's.
    if (g_cmdImportMediaExplorerMx)
        rec->Register("-custom_action", (void*)&g_customImportMediaExplorer);
    // '-command_id' re-presents the SAME interned id used at register (g_idImportStr).
    rec->Register("-gaccel", (void*)&g_accelImportMediaExplorer);
    rec->Register("-command_id", (void*)g_idImportStr.c_str());
    g_cmdImportMediaExplorer   = 0;
    g_cmdImportMediaExplorerMx = 0;
    g_session = nullptr;
}

}  // namespace reasampler
