// capture.cpp — REAPER-facing offline-render backend (OfflineRenderBackend).
//
// Compiled into the reaper_reasampler MODULE. Includes
// reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU
// that defines the API pointers; here they are extern (CLAUDE.md §contract).
//
// Renders a CaptureRequest's source over its requested range. The full three-scope
// capture family (item / track / master, each over a razor-else-time range) is
// driven here — all wet-only with optional tail. FX scope is enforced by the
// caller (via FX-bypass-around-render / FxBypassGuard) before invoking capture;
// this backend is source-agnostic and does not itself read the DAW selection.
// Drives the RENDER_* project settings via GetSetProjectInfo / _String
// (the source-selection bits come from render_settings.cpp, the pure mapping),
// snapshots and restores every setting it changes (non-destructive), triggers a
// render, then populates a Sample. It NEVER inserts into the arrange
// (load-bearing principle) — RENDER_ADDTOPROJ&1 is cleared on every path.
//
// The backend is SOURCE-AGNOSTIC: it does NOT read the DAW selection. The action
// layer (main.cpp) resolves each source mode to a concrete time range (+ track
// GUIDs for track captures) and hands it in via the CaptureRequest. This keeps
// the render-driving here and the selection-reading testable/visible up in the
// actions layer.
//
// RENDER PROGRESS WINDOW (Item 2 finding — not suppressible via stock API):
// Triggering kActionRenderUsingMostRecentSettings (42230) causes REAPER to show
// its offline-render progress dialog (progress bar + waveform view) for the
// duration of the render. The RENDER_SETTINGS bits documented in
// reaper_plugin_functions.h (line ~3041) contain no "no-dialog", "headless", or
// "suppress-progress-window" flag. No GetSetProjectInfo desc documents such a
// flag either. There is no stock, header-verifiable mechanism to prevent REAPER
// from showing this UI for an offline file render triggered via Main_OnCommand.
// This is inherent to REAPER's offline render path. The dialog-free alternative
// is the realtime-record backend (M8), which captures the master bus output to a
// temp track during playback and never invokes the offline render pipeline.

#include "capture.h"

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

#include "capture_paths.h"
#include "render_settings.h"

#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_GetSetProjectInfo
#define REAPERAPI_WANT_GetSetProjectInfo_String
#define REAPERAPI_WANT_GetSet_LoopTimeRange
#define REAPERAPI_WANT_Main_OnCommand
#define REAPERAPI_WANT_Main_SaveProject
#define REAPERAPI_WANT_Master_GetTempo
#define REAPERAPI_WANT_TimeMap_GetTimeSigAtTime
#include "reaper_plugin_functions.h"

namespace reasampler {

namespace {

// --- Render command / setting constants -------------------------------------
//
// DAW-ONLY ASSUMPTION (open question, CONTEXT.md §Open questions): the no-dialog
// render is triggered by the built-in action "File: Render project, using the
// most recent render settings" — command id 42230. This is a stock REAPER main
// action id, NOT part of reaper_plugin_functions.h, so it CANNOT be verified
// against the SDK header; it must be confirmed in a running REAPER. It renders
// headlessly (no dialog) using whatever RENDER_* settings are currently on the
// project — which is exactly why we set them all explicitly first.
constexpr int kActionRenderUsingMostRecentSettings = 42230;

// RENDER_BOUNDSFLAG value 0 = custom time bounds (we set STARTPOS/ENDPOS
// ourselves for exact, unrounded bounds). Verified: SDK header line ~3042.
constexpr double kBoundsCustom = 0.0;

// RENDER_TAILFLAG / RENDER_TAILMS / RENDER_NORMALIZE / RENDER_TRIMEND for the tail
// are driven from the pure tailRenderSettingsFor mapping (render_settings.h),
// unit-tested outside the DAW. See the tail-driving block in capture() below.

// RENDER_DITHER disable-all: &16 = disable all dither/noise-shaping.
// Verified: SDK header line ~3050: "&16=disable all".
// Float-32 output does not need dither, but if the user's project has dither
// enabled the render would obey it, breaking bit-identical repeats. Force off.
constexpr double kDitherDisableAll = 16.0;

// --- WAV render sink configuration ------------------------------------------
//
// FORMAT CHOICE (CONTEXT.md open question — surfaced for Daniel to confirm):
// 32-bit IEEE float. Rationale: float is lossless and needs NO dither, so
// identical inputs render bit-identically (enables the M10 null test) and a dry
// capture nulls exactly against its source. 16/24-bit int paths require dither
// for correctness, which is nondeterministic — unacceptable for a precision tool.
//
// API FACT (SDK header line ~3114): GetSetProjectInfo_String("RENDER_FORMAT", ...)
// uses the BASE64-ENCODED string form of the sink config — NOT raw binary bytes.
// Writing raw bytes causes REAPER to silently reject the value and fall back to
// the project's default render format (typically 16-bit/44.1 kHz). This was the
// confirmed root cause of the M3 offline-capture regression.
//
// GROUND TRUTH: base64 string captured from a live REAPER configured to
// WAV / 32-bit float.  Decodes to 7 bytes: 65 76 61 77 20 00 00
//   = "evaw" (WAV fourcc, little-endian) + 0x20 (=32, the float bit-depth field)
//   + 0x00 0x00 (flags: little-endian, no BWF/loop metadata).
constexpr const char* kRenderFormatWavFloat32 = "ZXZhdyAAAA==";

// Int16 / Int24 blob strings are NOT implemented in M3 — their byte encoding
// was not captured from a live REAPER and must not be guessed. If M7+ adds
// them, capture the ground-truth base64 from a running REAPER first.
//
// Returns nullptr for unsupported depths.
const char* wavSinkConfigBase64(WavBitDepth depth) {
    switch (depth) {
        case WavBitDepth::Float32: return kRenderFormatWavFloat32;
        case WavBitDepth::Int16:   return nullptr;  // M7+: capture ground-truth blob first
        case WavBitDepth::Int24:   return nullptr;  // M7+: capture ground-truth blob first
    }
    return nullptr;
}

// --- RENDER_* snapshot / restore --------------------------------------------
//
// The RENDER_* settings are project-GLOBAL: clobbering them would destroy the
// user's render configuration. We snapshot every value we are about to change,
// then restore all of them in the reverse order on the way out (non-destructive
// invariant). Modeled as a small RAII guard so early returns cannot leak a
// half-restored state.
struct RenderSettingsSnapshot {
    ReaProject* proj = nullptr;

    // Numeric settings (GetSetProjectInfo).
    double boundsFlag   = 0.0;
    double startPos     = 0.0;
    double endPos       = 0.0;
    double tailFlag     = 0.0;
    double tailMs       = 0.0;
    double srate        = 0.0;
    double channels     = 0.0;
    double renderSettings = 0.0;
    double addToProj    = 0.0;
    double dither       = 0.0;  // RENDER_DITHER — snapshotted so user's setting is restored
    double normalize    = 0.0;  // RENDER_NORMALIZE — snapshotted so user's setting is restored
    double trimEnd      = 0.0;  // RENDER_TRIMEND — snapshotted so the Auto trim threshold is restored

    // String settings (GetSetProjectInfo_String). Big buffers: REAPER writes the
    // full value in, and RENDER_FORMAT is a base64 blob that can be long.
    std::string renderFile;
    std::string renderPattern;
    std::string renderFormat;

    bool captured = false;
};

std::string getProjString(ReaProject* proj, const char* desc) {
    std::vector<char> buf(4096, '\0');
    GetSetProjectInfo_String(proj, desc, buf.data(), false);
    return std::string(buf.data());
}

void setProjString(ReaProject* proj, const char* desc, const std::string& value) {
    // GetSetProjectInfo_String takes a non-const char*; copy into a mutable buf.
    std::vector<char> buf(value.begin(), value.end());
    buf.push_back('\0');
    GetSetProjectInfo_String(proj, desc, buf.data(), true);
}

void snapshotRenderSettings(RenderSettingsSnapshot& s, ReaProject* proj) {
    s.proj = proj;
    s.boundsFlag     = GetSetProjectInfo(proj, "RENDER_BOUNDSFLAG", 0.0, false);
    s.startPos       = GetSetProjectInfo(proj, "RENDER_STARTPOS", 0.0, false);
    s.endPos         = GetSetProjectInfo(proj, "RENDER_ENDPOS", 0.0, false);
    s.tailFlag       = GetSetProjectInfo(proj, "RENDER_TAILFLAG", 0.0, false);
    s.tailMs         = GetSetProjectInfo(proj, "RENDER_TAILMS", 0.0, false);
    s.srate          = GetSetProjectInfo(proj, "RENDER_SRATE", 0.0, false);
    s.channels       = GetSetProjectInfo(proj, "RENDER_CHANNELS", 0.0, false);
    s.renderSettings = GetSetProjectInfo(proj, "RENDER_SETTINGS", 0.0, false);
    s.addToProj      = GetSetProjectInfo(proj, "RENDER_ADDTOPROJ", 0.0, false);
    s.dither         = GetSetProjectInfo(proj, "RENDER_DITHER", 0.0, false);
    s.normalize      = GetSetProjectInfo(proj, "RENDER_NORMALIZE", 0.0, false);
    s.trimEnd        = GetSetProjectInfo(proj, "RENDER_TRIMEND", 0.0, false);
    s.renderFile     = getProjString(proj, "RENDER_FILE");
    s.renderPattern  = getProjString(proj, "RENDER_PATTERN");
    s.renderFormat   = getProjString(proj, "RENDER_FORMAT");
    s.captured = true;
}

void restoreRenderSettings(const RenderSettingsSnapshot& s) {
    if (!s.captured) return;
    // Restore strings first, then numerics — order is not load-bearing since the
    // fields are independent, but we mirror snapshot order for readability.
    setProjString(s.proj, "RENDER_FILE", s.renderFile);
    setProjString(s.proj, "RENDER_PATTERN", s.renderPattern);
    setProjString(s.proj, "RENDER_FORMAT", s.renderFormat);
    GetSetProjectInfo(s.proj, "RENDER_BOUNDSFLAG", s.boundsFlag, true);
    GetSetProjectInfo(s.proj, "RENDER_STARTPOS", s.startPos, true);
    GetSetProjectInfo(s.proj, "RENDER_ENDPOS", s.endPos, true);
    GetSetProjectInfo(s.proj, "RENDER_TAILFLAG", s.tailFlag, true);
    GetSetProjectInfo(s.proj, "RENDER_TAILMS", s.tailMs, true);
    GetSetProjectInfo(s.proj, "RENDER_SRATE", s.srate, true);
    GetSetProjectInfo(s.proj, "RENDER_CHANNELS", s.channels, true);
    GetSetProjectInfo(s.proj, "RENDER_SETTINGS", s.renderSettings, true);
    GetSetProjectInfo(s.proj, "RENDER_ADDTOPROJ", s.addToProj, true);
    GetSetProjectInfo(s.proj, "RENDER_DITHER", s.dither, true);
    GetSetProjectInfo(s.proj, "RENDER_NORMALIZE", s.normalize, true);
    GetSetProjectInfo(s.proj, "RENDER_TRIMEND", s.trimEnd, true);
}

// RAII wrapper: guarantees restore on every return path from capture().
struct ScopedRenderSettings {
    RenderSettingsSnapshot snap;
    explicit ScopedRenderSettings(ReaProject* proj) {
        snapshotRenderSettings(snap, proj);
    }
    ~ScopedRenderSettings() { restoreRenderSettings(snap); }
    ScopedRenderSettings(const ScopedRenderSettings&) = delete;
    ScopedRenderSettings& operator=(const ScopedRenderSettings&) = delete;
};

// A monotonic, filesystem-safe timestamp tag so repeated captures in one session
// do not collide on the file name. NOTE: the tag varies the file NAME, not the
// audio bytes — bit-identical-repeat is about identical *content* for identical
// requests; two deliberate captures naturally live in two files.
std::string makeUniqueTag() {
    std::time_t now = std::time(nullptr);
    return std::to_string(static_cast<long long>(now));
}

// Reads the whole file into a byte buffer. Returns an empty vector on any I/O
// failure (the caller then leaves contentHash empty — the safe, confirm-eliciting
// direction for an unreadable file).
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::streamoff size = f.tellg();
    if (size <= 0) return {};
    std::vector<std::uint8_t> bytes(static_cast<std::size_t>(size));
    f.seekg(0);
    f.read(reinterpret_cast<char*>(bytes.data()), size);
    if (!f) return {};
    return bytes;
}

}  // namespace

CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
    CaptureResult result;

    // Resolve the RENDER_SETTINGS source/processing bits for this mode + wet/dry
    // (pure mapping, unit-tested in render_settings). An unsupported mode (only
    // SourceMode::Realtime — that is the M8 realtime backend) is refused here so
    // the offline path never silently renders the wrong thing.
    const RenderSettingsChoice choice =
        renderSettingsFor(request.sourceMode, request.wetDry);
    if (!choice.supported) {
        result.status  = CaptureStatus::UnsupportedMode;
        result.message = "OfflineRenderBackend does not render this source mode "
                         "(realtime capture is the M8 backend).";
        return result;
    }

    // Exact bounds: reject an empty/inverted range rather than render silence.
    if (!(request.endSeconds > request.startSeconds)) {
        result.status  = CaptureStatus::EmptyRange;
        result.message = "Capture range is empty (end <= start).";
        return result;
    }

    // Current project (idx -1 == the active project tab). Verified: SDK header
    // line ~1264, EnumProjects(int idx, char*, int).
    ReaProject* proj = EnumProjects(-1, nullptr, 0);
    if (!proj) {
        result.status  = CaptureStatus::NoProject;
        result.message = "No active project.";
        return result;
    }

    // Resolve the project directory from the .rpp file path.
    //
    // Unsaved-project detection: we use EnumProjects(-1, buf, bufsz) to read
    // the project's .rpp filename. Per SDK header line ~1262:
    //   EnumProjects(int idx, char* projfnOutOptional, int sz)
    //   "idx=-1 for current project, projfn can be NULL if not interested in filename."
    // The out-parameter is the full path to the .rpp file, and is EMPTY for a
    // project that has never been saved — making it a reliable unsaved sentinel.
    //
    // WHY NOT GetProjectPathEx: that function returns the project *recording path*
    // (SDK header line ~2548: "Get the project recording path."), NOT the .rpp
    // location. For an unsaved project it returns REAPER's default media/recording
    // directory — never empty — so it cannot detect the unsaved state. Using it
    // caused the original bug: the guard never fired, and captures landed in
    // REAPER's default media location rather than alongside the .rpp.
    //
    // WHY NOT GetProjectPathEx for the saved-project dir: even for a saved project,
    // GetProjectPathEx returns the recording path (which may be a media subfolder),
    // not the .rpp parent directory. We need the .rpp parent so reasampler_bank/
    // sits alongside the .rpp and travels with the project.
    //
    // FLOW:
    //   1. Read .rpp path via EnumProjects(-1, buf, bufsz).
    //   2. If non-empty (saved) -> derive project dir as parent of the .rpp.
    //   3. If empty (unsaved) -> Main_SaveProject(proj, true) prompts Save-As.
    //      Re-read. If now non-empty -> proceed. If still empty (user cancelled) ->
    //      refuse CaptureStatus::NoProject, write nothing.
    //
    // DAW-ONLY ASSUMPTION: Main_SaveProject(proj, true) opens a Save/Save-As
    // dialog and blocks until the user dismisses it. "true" = forceSaveAsIn.
    // Verified SDK header line ~4599:
    //   void Main_SaveProject(ReaProject* proj, bool forceSaveAsInOptional)
    // The blocking behaviour and dialog appearance can only be confirmed in a
    // running REAPER.
    auto readRppPath = [&]() -> std::string {
        std::vector<char> buf(4096, '\0');
        // EnumProjects(-1, ...) returns the active project and writes the .rpp
        // path into buf. We already have the ReaProject* from the earlier call
        // (nullptr-checked above), but calling EnumProjects again is the only
        // stock, header-documented way to read the .rpp filename.
        EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
        return std::string(buf.data());
    };

    std::string rppPath = readRppPath();
    if (rppPath.empty()) {
        // Project is unsaved. Prompt the user to choose a save location.
        Main_SaveProject(proj, true);
        // Re-read: non-empty if the user confirmed, still empty if cancelled.
        rppPath = readRppPath();
    }
    if (rppPath.empty()) {
        // User cancelled the save dialog — refuse, write nothing.
        result.status  = CaptureStatus::NoProject;
        result.message = "Project must be saved before capture — nothing captured.";
        return result;
    }

    // Derive the project directory as the parent folder of the .rpp file.
    // std::filesystem::path handles both forward- and back-slash paths; .parent_path()
    // gives the containing directory. Convert to forward-slash string so the rest
    // of the capture pipeline (deriveBankPaths, RENDER_FILE) sees a clean path.
    const std::string projectDir = [&]() -> std::string {
        namespace fs = std::filesystem;
        std::string dir = fs::path(rppPath).parent_path().string();
        // normalizeSlashes is in capture_paths (pure); replicate the transform
        // inline here to avoid a cross-module dependency for a one-liner.
        for (char& c : dir) { if (c == '\\') c = '/'; }
        // Strip a single trailing slash (defensive; parent_path usually omits it).
        if (dir.size() > 1 && dir.back() == '/') dir.pop_back();
        return dir;
    }();

    // Compute the unique tag ONCE so the file stem and Sample.id carry the same
    // timestamp. Calling makeUniqueTag() twice could yield different values if a
    // second boundary crosses between the two calls (bug: id and filename diverge).
    const std::string uniqueTag = makeUniqueTag();
    const BankPaths paths =
        deriveBankPaths(projectDir, request.baseName, uniqueTag);

    // Snapshot + auto-restore ALL render settings we are about to touch.
    ScopedRenderSettings guard(proj);

    // --- Drive the render settings (exact, deterministic) -------------------
    // Custom time bounds so the rendered length equals the requested range with
    // NO rounding and NO added silence (unless a tail was explicitly requested).
    GetSetProjectInfo(proj, "RENDER_BOUNDSFLAG", kBoundsCustom, true);
    GetSetProjectInfo(proj, "RENDER_STARTPOS", request.startSeconds, true);
    GetSetProjectInfo(proj, "RENDER_ENDPOS", request.endSeconds, true);

    // Tail: TAILFLAG / TAILMS / NORMALIZE / TRIMEND all come from the pure mapping
    // (render_settings.h, unit-tested). None -> exact bounds + disable-all normalize
    // (byte-identical to the pre-tail path); Auto -> 8 s tail + surgical trim-end
    // normalize + -72 dB TRIMEND; Manual -> clamped fixed tail + disable-all, no trim.
    // RENDER_NORMALIZE is driven HERE from the mapping (not the determinism block
    // below) so the Auto surgical value is not clobbered — the snapshot guard restores
    // the user's original RENDER_NORMALIZE / RENDER_TRIMEND on every exit path.
    const TailRenderSettings tail =
        tailRenderSettingsFor(request.tailMode, request.tailMs);
    GetSetProjectInfo(proj, "RENDER_TAILFLAG",
                      static_cast<double>(tail.tailFlag), true);
    GetSetProjectInfo(proj, "RENDER_TAILMS", tail.tailMs, true);

    // Source-selection bits for this mode, from the pure render_settings mapping
    // (verified against SDK header ~3041). All M7 actions are wet-only:
    // master mix = 0; tracks = &128; items = &32|single-file; razor = &4096|single-file.
    GetSetProjectInfo(proj, "RENDER_SETTINGS",
                      static_cast<double>(choice.settings), true);

    // Resolve the effective sample rate. When the request carries 0 ("follow
    // project"), read PROJECT_SRATE explicitly so RENDER_SRATE is set to the
    // actual value — not left as 0 for REAPER to interpret. SDK header line ~3064:
    // PROJECT_SRATE = sample rate (ignored unless PROJECT_SRATE_USE set); the
    // value is still readable via GetSetProjectInfo even when _USE is clear.
    const int effectiveSampleRate = (request.sampleRate > 0)
        ? request.sampleRate
        : static_cast<int>(GetSetProjectInfo(proj, "PROJECT_SRATE", 0.0, false));

    // Pin RENDER_SRATE only when the resolved rate is known (> 0). PROJECT_SRATE
    // can read 0 on a project that has never explicitly pinned a sample rate (e.g.
    // brand-new projects before the user has visited the project settings). Forcing
    // RENDER_SRATE = 0 would re-introduce the "0 as literal" trap we fixed by
    // moving away from blind passthrough. When the rate is unknown, leave
    // RENDER_SRATE unset so REAPER follows its own project-rate default — which is
    // correct behaviour for that project — rather than pinning a bogus 0.
    if (effectiveSampleRate > 0) {
        GetSetProjectInfo(proj, "RENDER_SRATE",
                          static_cast<double>(effectiveSampleRate), true);
    }
    GetSetProjectInfo(proj, "RENDER_CHANNELS",
                      static_cast<double>(request.channelCount), true);

    // Load-bearing principle: do NOT add the rendered file to the project as an
    // item. Clearing RENDER_ADDTOPROJ&1 keeps capture out of the arrange.
    GetSetProjectInfo(proj, "RENDER_ADDTOPROJ", 0.0, true);

    // Determinism: disable dither so identical inputs produce bit-identical files
    // and a dry capture nulls to silence. RENDER_DITHER &16 = disable all dither/
    // noise-shaping (SDK header line ~3050). Snapshotted above; restored by the guard.
    GetSetProjectInfo(proj, "RENDER_DITHER", kDitherDisableAll, true);

    // RENDER_NORMALIZE + RENDER_TRIMEND come from the tail mapping (above). None /
    // Manual -> disable-all (byte-identical to the pre-tail path); Auto -> surgical
    // trim-end (only &32768) + the -72 dB TRIMEND. A fixed-threshold trailing-silence
    // trim scales/limits/fades nothing, so Auto stays deterministic and un-coloring
    // (spec §surgical normalize). TRIMEND is only consulted when the trim bit is set,
    // but we write it unconditionally (harmless when clear) so the value is explicit.
    GetSetProjectInfo(proj, "RENDER_NORMALIZE",
                      static_cast<double>(tail.normalize), true);
    GetSetProjectInfo(proj, "RENDER_TRIMEND", tail.trimEnd, true);

    // Output location: directory (RENDER_FILE) + file stem (RENDER_PATTERN).
    // RENDER_PATTERN with no wildcards is a literal stem; REAPER appends the
    // format extension. Use paths.fileStem — capture_paths owns the .wav suffix
    // knowledge; re-stripping here would duplicate that coupling.
    setProjString(proj, "RENDER_FILE", paths.absoluteDir);
    setProjString(proj, "RENDER_PATTERN", paths.fileStem);

    // Pin the WAV format using the ground-truth base64 blob for the chosen depth.
    // Int16/Int24 are not implemented (no live-captured blob) — fail explicitly
    // rather than silently mis-render at the wrong bit depth.
    const char* fmtBase64 = wavSinkConfigBase64(request.bitDepth);
    if (!fmtBase64) {
        result.status  = CaptureStatus::UnsupportedFormat;
        result.message = "Requested bit depth has no verified RENDER_FORMAT blob "
                         "(M3 supports Float32 only; Int16/Int24 are M7+).";
        return result;
        // guard's dtor restores every RENDER_* setting here.
    }
    setProjString(proj, "RENDER_FORMAT", fmtBase64);

    // --- Trigger the render -------------------------------------------------
    // DAW-ONLY ASSUMPTION (see kActionRenderUsingMostRecentSettings): this runs
    // the render synchronously on the current build. REAPER will show its
    // offline-render progress window for the duration (see file-top comment —
    // the progress UI is not suppressible via stock API).
    Main_OnCommand(kActionRenderUsingMostRecentSettings, 0);

    // --- Verify the output file exists ---------------------------------------
    // Main_OnCommand returns void, so a failed render is silent. Stat the
    // expected output path; if the file does not exist the render failed.
    // Note: std::filesystem is used only in this REAPER-facing .cpp — the pure
    // libs (capture_paths, bank_model) remain filesystem-free.
    const std::string expectedPath = paths.absoluteDir + "/" + paths.fileName;
    if (!std::filesystem::exists(expectedPath)) {
        result.status  = CaptureStatus::RenderFailed;
        result.message = "Render produced no output file (expected: " +
                         expectedPath + "). Check the REAPER console for errors.";
        return result;
        // guard's dtor restores every RENDER_* setting here.
    }

    // --- Populate the Sample -------------------------------------------------
    // We record the request's own bounds (exact) rather than re-measuring the
    // file, so the Sample's range is precisely what was asked for.
    Sample s;
    // Use the same uniqueTag that named the file — calling makeUniqueTag() again
    // here would risk a different timestamp if a second boundary crosses between
    // the two calls, making Sample.id inconsistent with the file name.
    s.id            = "cap-" + uniqueTag + "-" + paths.fileName;
    s.displayName   = request.baseName;
    s.relativePath  = paths.relativePath;  // project-relative (invariant)
    s.sourceMode    = request.sourceMode;
    s.sourceRange.startSeconds = request.startSeconds;
    s.sourceRange.endSeconds   = request.endSeconds;
    // DEFERRED (M6/M7): startPpq, endPpq, and lengthBeats are left at 0.
    // PPQ mapping via TimeMap2_timeToBeats is a musical-placement concern for the
    // insert milestone; the model refuses to re-derive one bound from the other.
    // Seconds are the authoritative source for the render. Do NOT add DAW-
    // unverifiable PPQ resolution here — it requires a live REAPER to validate.
    s.wetDry        = request.wetDry;
    // Track GUIDs for track-scoped captures (empty for master/items/razor). The
    // caller resolved the selection to canonical GUID strings; we record them so a
    // "re-capture from source" (M10) knows which tracks the sample came from.
    s.trackGuids    = request.trackGuids;
    s.channelCount  = request.channelCount;
    // Store the resolved sample rate only when it is known (> 0). If the project
    // never pinned a rate (PROJECT_SRATE read 0), we did not force RENDER_SRATE
    // either, so the render ran at REAPER's project default — an unknown value from
    // this code's perspective. Leave sampleRate at 0 (the Sample zero-value) rather
    // than store a bogus literal; M6/M7 can fill it in by probing the rendered file.
    s.sampleRate    = effectiveSampleRate;  // 0 when project rate was unknown
    s.lengthSeconds = request.endSeconds - request.startSeconds;
    s.captureTempo  = Master_GetTempo();   // BPM at capture time (verified ~4651)
    // Time signature at the capture's START time (L7 F1 stamp). TimeMap_GetTimeSigAtTime
    // (verified reaper_plugin_functions.h:7130 — void(ReaProject*, double time,
    // int* numOut, int* denomOut, double* tempoOut)) reads the meter effective at that
    // project time, so a sample captured under 3/4 keeps a 3/4 read-out even if the
    // project later switches to 4/4. proj=nullptr => the active project (matches the
    // Master_GetTempo() call above, which is also active-project). The tempoOut is
    // ignored — captureTempo already carries the master tempo. Leaves 0/0 (unstamped)
    // if the API is somehow unavailable; the formatter renders a blank musical read-out.
    {
        int tsNum = 0, tsDenom = 0;
        double tsTempo = 0.0;
        TimeMap_GetTimeSigAtTime(nullptr, request.startSeconds, &tsNum, &tsDenom, &tsTempo);
        s.captureTimeSigNum   = tsNum;
        s.captureTimeSigDenom = tsDenom;
    }
    s.tier          = Tier::Scratch;       // captures land in scratch by default
    // Content hash: WAV-aware FNV-1a over the rendered file's fmt+data chunks so
    // hashReferencedElsewhere can identify copies in other banks and suppress the
    // last-reference confirm when another bank still holds the same file. Using
    // hashWavContent (not the raw hashBytes) skips render-varying metadata chunks
    // (bext origination timestamp, iXML, LIST/INFO, etc.) so two renders of identical
    // audio collapse to the same hash. Best-effort: an unreadable file leaves
    // contentHash empty — the safe, confirm-eliciting direction (bank_model treats
    // "" as non-participating in dedup, which is the existing fallback semantics).
    {
        const std::vector<std::uint8_t> fileBytes = readFileBytes(expectedPath);
        if (!fileBytes.empty()) {
            s.contentHash = hashWavContent(fileBytes);
        }
    }
    s.createdTimestamp = static_cast<std::int64_t>(std::time(nullptr));
    // Phase S seam fields (rootNote / loop) left empty (D-B). An offline render of a
    // master mix / track / time-selection is not a single played note, so no root
    // note is derivable here — we do NOT guess one. Loop points are set later by an
    // explicit user action, not at capture. Leaving them empty is the honest default;
    // the instrument (Phase S) treats an absent root note as "not a pitched sample".

    result.status  = CaptureStatus::Ok;
    result.sample  = s;
    result.message = "Captured [" +
                     std::to_string(request.startSeconds) + "s, " +
                     std::to_string(request.endSeconds) + "s] -> " +
                     paths.relativePath;
    return result;
    // guard's dtor restores every RENDER_* setting here.
}

}  // namespace reasampler
