// REAPER-facing offline-render backend (OfflineRenderBackend) plus the shared
// backend helpers (makeUniqueTag / captureNameFor / collapseCapturedFileToMono /
// stampCaptureSample).
//
// Includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT — main.cpp is
// the one TU that defines the API pointers; here they are extern.
//
// Drives the RENDER_* project settings via GetSetProjectInfo/_String (source-
// selection bits come from the pure render_settings mapping) plus the project time
// selection, which is where the render window itself travels; snapshots and restores
// every one of them, triggers a render, then populates a Sample.
// Source-agnostic: never reads the DAW selection itself, only the CaptureRequest
// the caller resolved. RENDER_ADDTOPROJ&1 is cleared on every path — never
// inserts into the arrange.
//
// RENDER PROGRESS WINDOW: triggering kActionRenderUsingMostRecentSettings (42230)
// shows REAPER's offline-render progress dialog for the render's duration; no
// RENDER_SETTINGS bit or GetSetProjectInfo desc suppresses it — inherent to
// REAPER's offline render path. The dialog-free alternative is the realtime-
// record backend, which captures the master bus to a temp track during playback
// and never invokes the offline render pipeline.

#include "shell/capture/capture.h"

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

#include "core/capture/capture_paths.h"
#include "core/capture/wav_codec.h"   // hashWavContent / collapseToMono — the one WAV/RIFF owner
#include "core/util/file_bytes.h"
#include "core/capture/render_settings.h"
#include "shell/capture/render_bounds_gate.h"  // the exact-bounds verdict on the landed render

#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_GetProjectPathEx
#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_ShowConsoleMsg
#define REAPERAPI_WANT_TimeMap_GetTimeSigAtTime
#include "reaper_plugin_functions.h"

namespace reasampler::capture {

namespace {

// The no-dialog render is the built-in action "File: Render project, using the
// most recent render settings" — command id 42230. Stock main action id, not in
// reaper_plugin_functions.h, so it cannot be verified against the SDK header
// (offline capture shipping as the default is the empirical evidence it holds).
// Renders headlessly using whatever RENDER_* settings are currently on the
// project — why we set them all explicitly first.
constexpr int kActionRenderUsingMostRecentSettings = 42230;

// RENDER_TAILFLAG/TAILMS/NORMALIZE/TRIMEND are driven from the pure
// tailRenderSettingsFor mapping (render_settings.h) in the tail-driving block below.

// RENDER_DITHER &16 = disable all dither/noise-shaping (SDK header ~3050).
// Float32 doesn't need dither, but an enabled project dither setting would
// otherwise apply and break bit-identical repeats. Force off.
constexpr double kDitherDisableAll = 16.0;

// 32-bit IEEE float: lossless, needs no dither, so identical inputs render
// bit-identically and a dry capture nulls exactly against its source. 16/24-bit
// int paths need dither for correctness, which is nondeterministic.
//
// GetSetProjectInfo_String("RENDER_FORMAT", ...) takes the BASE64-ENCODED sink
// config, not raw bytes (SDK header ~3114) — raw bytes are silently rejected and
// REAPER falls back to its project default format.
//
// Ground truth captured from a live REAPER set to WAV/32-bit float. Decodes to
// 7 bytes: "evaw" (WAV fourcc, LE) + 0x20 (float bit-depth) + 0x00 0x00 (flags).
constexpr const char* kRenderFormatWavFloat32 = "ZXZhdyAAAA==";

// Int16/Int24 blobs aren't implemented — no live-captured ground truth exists;
// do not guess the encoding. Returns nullptr for unsupported depths.
const char* wavSinkConfigBase64(WavBitDepth depth) {
    switch (depth) {
        case WavBitDepth::Float32: return kRenderFormatWavFloat32;
        case WavBitDepth::Int16:   return nullptr;  // capture ground-truth blob first
        case WavBitDepth::Int24:   return nullptr;  // capture ground-truth blob first
    }
    return nullptr;
}

// RENDER_* settings are project-GLOBAL; snapshot every value we touch and
// restore on the way out via RAII so early returns can't 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;
    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);
}

// The project time selection, snapshotted and restored around the render that carries
// its window in it. Separate from ScopedRenderSettings because it is project state
// rather than a RENDER_* setting.
// GetSet_LoopTimeRange has no project parameter (SDK header ~2670) — it acts on the
// active project, which is the one capture() already resolved and renders into.
struct ScopedTimeSelection {
    double start = 0.0;
    double end   = 0.0;

    ScopedTimeSelection() { GetSet_LoopTimeRange(false, false, &start, &end, false); }
    ~ScopedTimeSelection() {
        // Copies: the setter takes non-const pointers, so the snapshot must not be
        // what it writes through.
        double s = start, e = end;
        GetSet_LoopTimeRange(true, false, &s, &e, false);
    }
    ScopedTimeSelection(const ScopedTimeSelection&) = delete;
    ScopedTimeSelection& operator=(const ScopedTimeSelection&) = delete;
};

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

}  // namespace

std::string makeUniqueTag(const std::string& prefix) {
    // Timestamp + per-session monotonic counter: the timestamp alone has one-second
    // resolution, so two captures of the same baseName within a second (batch
    // capture) collided on file stem and Sample id. This varies the file NAME, not
    // the audio bytes — bit-identical-repeat is about identical content per request.
    // Residual: the counter resets per-process, so a same-second collision across
    // two REAPER instances (or a mid-session reload) remains theoretically possible;
    // scoped deliberately to the reachable single-process case.
    static std::atomic<unsigned long long> counter{0};
    const std::time_t now = std::time(nullptr);
    return prefix + std::to_string(static_cast<long long>(now)) + "-" +
           std::to_string(++counter);
}

CaptureName captureNameFor(const std::vector<std::string>& sourceNames,
                           int ordinal, const std::string& fallback) {
    CaptureNameInputs in;
    in.sourceNames = sourceNames;
    in.ordinal     = ordinal;
    in.fallback    = fallback;

    // localtime, not gmtime: the discriminator is read by the person who made the
    // capture, so it must match the clock on their wall. A failed conversion leaves the
    // stamp zeroed, which composeCaptureName renders as no discriminator at all.
    const std::time_t now = std::time(nullptr);
    std::tm local{};
#ifdef _WIN32
    const bool ok = (localtime_s(&local, &now) == 0);
#else
    const bool ok = (localtime_r(&now, &local) != nullptr);
#endif
    if (ok) {
        in.stamp.month  = local.tm_mon + 1;   // tm_mon is 0-based
        in.stamp.day    = local.tm_mday;
        in.stamp.hour   = local.tm_hour;
        in.stamp.minute = local.tm_min;
    }
    return composeCaptureName(in);
}

namespace {

// One console line per genuine collapse failure — silence here is what made a failed
// rewrite read exactly like a legitimately stereo capture. Deliberately does NOT claim
// the captured bytes are intact: a 0-byte render can reach this branch too (it passes the
// exists/bounds gates upstream; see docs/TODO.md), and this path never verified the bytes
// it's reporting on.
void reportCollapseFailure(const std::string& absolutePath, const char* what,
                           const char* consoleLabel) {
    ShowConsoleMsg((std::string(consoleLabel) + ": the lossless mono collapse " +
                    std::string(what) + " -- " + absolutePath +
                    " already reached the bank; only the size win from the collapse "
                    "was lost.\n").c_str());
}

}  // namespace

MonoCollapseOutcome collapseCapturedFileToMono(const std::string& absolutePath,
                                               const char* consoleLabel) {
    const std::vector<std::uint8_t> bytes = util::readFileBytes(absolutePath);
    if (bytes.empty()) {
        // Failed, not Declined: the read that would have decided never happened, so
        // "the channels differ" is a claim this path cannot make.
        reportCollapseFailure(absolutePath, "could not read the captured file", consoleLabel);
        return MonoCollapseOutcome::Failed;
    }

    const MonoCollapse collapse = collapseToMono(bytes);
    if (!collapse.collapsed) return MonoCollapseOutcome::Declined;

    // Sibling temp + rename, NOT an in-place truncating write: this runs unconditionally
    // on the deterministic offline path (which never reopened its render for write before
    // this step existed), so a mid-write failure here must not land a truncated file that
    // stampCaptureSample then hashes as a false CaptureStatus::Ok. rename() replaces the
    // destination in one step, so the original bytes are never destroyed until the
    // replacement is known-complete; a failed write or rename leaves the original file
    // untouched and self-cleans the temp rather than littering it.
    const std::string tempPath = absolutePath + ".moncollapse.tmp";
    {
        std::ofstream out(tempPath, std::ios::binary | std::ios::trunc);
        if (!out) {
            reportCollapseFailure(absolutePath, "could not open its temporary file", consoleLabel);
            return MonoCollapseOutcome::Failed;
        }
        out.write(reinterpret_cast<const char*>(collapse.bytes.data()),
                  static_cast<std::streamsize>(collapse.bytes.size()));
        const bool wroteOk = static_cast<bool>(out);
        out.close();
        if (!wroteOk) {
            std::error_code ec;
            std::filesystem::remove(tempPath, ec);
            reportCollapseFailure(absolutePath, "could not write the rebuilt file", consoleLabel);
            return MonoCollapseOutcome::Failed;
        }
    }
    std::error_code ec;
    std::filesystem::rename(tempPath, absolutePath, ec);
    if (ec) {
        std::filesystem::remove(tempPath, ec);  // don't leave litter on a failed rename
        reportCollapseFailure(absolutePath, "could not replace the captured file", consoleLabel);
        return MonoCollapseOutcome::Failed;
    }
    return MonoCollapseOutcome::Collapsed;
}

void stampCaptureSample(Sample& s, const CaptureRequest& req,
                        ReaProject* rateProj, ReaProject* timeSigProj,
                        const std::string& absolutePath) {
    // Track GUIDs echoed from the request (the caller resolved the selection; the
    // backends stay source-agnostic). channelCount starts at 0 (unknown, the same
    // sentinel bank_model already uses for a pre-field entry) rather than the
    // request's value — the request always asks for 2, so echoing it would claim a
    // measurement that never happened for the unparseable-file case below. The
    // produced FILE overrides it below whenever it parses.
    s.trackGuids   = req.trackGuids;
    s.channelCount = 0;

    // PROJECT_SRATE can read 0 on a project that never pinned a rate — stays 0
    // (honest "unknown") rather than a bogus literal.
    s.sampleRate = (req.sampleRate > 0)
        ? req.sampleRate
        : static_cast<int>(GetSetProjectInfo(rateProj, "PROJECT_SRATE", 0.0, false));

    s.captureTempo = Master_GetTempo();   // BPM at capture time

    // Time signature effective at the capture's START time, so a sample captured
    // under 3/4 keeps a 3/4 read-out even if the project later switches to 4/4.
    // `timeSigProj` is the caller's project pin — offline passes nullptr (active
    // project); realtime pins the record's own project. tempoOut is ignored —
    // captureTempo already carries it.
    {
        int tsNum = 0, tsDenom = 0;
        double tsTempo = 0.0;
        TimeMap_GetTimeSigAtTime(timeSigProj, req.startSeconds, &tsNum, &tsDenom, &tsTempo);
        s.captureTimeSigNum   = tsNum;
        s.captureTimeSigDenom = tsDenom;
    }

    // hashWavContent (not raw hashBytes) skips render-varying metadata chunks
    // (bext timestamp, iXML, LIST/INFO) so identical audio from two renders/records
    // collapses to the same hash, letting dedup find copies across banks.
    // Unreadable file leaves contentHash empty (bank_model treats "" as non-dedup).
    {
        const std::vector<std::uint8_t> fileBytes = util::readFileBytes(absolutePath);
        if (!fileBytes.empty()) {
            s.contentHash = hashWavContent(fileBytes);
            // The one authority for the entry's channel count is the file's own `fmt`
            // — never the render request, which asks for 2 on every capture path.
            const WavLayout layout = parseWavLayout(fileBytes);
            if (layout.valid) s.channelCount = static_cast<int>(layout.channelCount);
        }
    }

    s.createdTimestamp = static_cast<std::int64_t>(std::time(nullptr));
}

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

    // SourceMode::Realtime is refused here — that's the realtime backend's job —
    // 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 realtime 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;
    }

    // idx -1 == the active project tab.
    ReaProject* proj = EnumProjects(-1, nullptr, 0);
    if (!proj) {
        result.status  = CaptureStatus::NoProject;
        result.message = "No active project.";
        return result;
    }

    // Unsaved-project detection via EnumProjects(-1, buf, bufsz): the .rpp path
    // out-param is empty for a project that has never been saved — a reliable
    // unsaved sentinel. NOT GetProjectPathEx: that returns the recording path, not
    // the .rpp location, and is never empty even when unsaved (the original bug —
    // captures landed in REAPER's default media location instead of by the .rpp).
    //
    // Flow: read .rpp path; if empty, Main_SaveProject(proj, true) — true =
    // forceSaveAsIn — prompts Save-As and (per SDK header) blocks until dismissed,
    // though that blocking behaviour cannot be verified from the header itself;
    // re-read; if still empty (cancelled), refuse with NoProject and write nothing.
    auto readRppPath = [&]() -> std::string {
        std::vector<char> buf(4096, '\0');
        EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
        return std::string(buf.data());
    };

    std::string rppPath = readRppPath();
    if (rppPath.empty()) {
        Main_SaveProject(proj, true);
        rppPath = readRppPath();
    }
    if (rppPath.empty()) {
        result.status  = CaptureStatus::NoProject;
        result.message = "Project must be saved before capture — nothing captured.";
        return result;
    }

    // Project dir = parent of the .rpp; forward-slash-normalized 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();
        for (char& c : dir) { if (c == '\\') c = '/'; }
        if (dir.size() > 1 && dir.back() == '/') dir.pop_back();
        return dir;
    }();

    // Compute the tag ONCE — calling makeUniqueTag() twice would let the file
    // stem and Sample.id diverge (the counter advances per call).
    const std::string uniqueTag = makeUniqueTag("");

    // Destination resolves HERE, after the save gate above, so an unsaved project is
    // still prompted before any path arithmetic runs. ProjectMedia lands outside the
    // bank folder and leaves relativePath empty — the Sample it produces indexes
    // nothing (docs/product/render-in-place.md §"Where the file goes").
    RenderPaths paths;
    std::string relativePath;
    if (request.destination == CaptureDestination::Bank) {
        const BankPaths bank =
            deriveBankPaths(projectDir, request.baseName, uniqueTag);
        paths        = RenderPaths{bank.absoluteDir, bank.fileName, bank.fileStem};
        relativePath = bank.relativePath;
    } else {
        std::vector<char> recDir(4096, '\0');
        GetProjectPathEx(proj, recDir.data(), static_cast<int>(recDir.size()));
        paths = deriveRenderPaths(std::string(recDir.data()), request.baseName,
                                  uniqueTag);
        if (paths.absoluteDir.empty()) {
            result.status  = CaptureStatus::NoProject;
            result.message = "Could not resolve the project's recording path.";
            return result;
        }
    }

    ScopedRenderSettings guard(proj);
    ScopedTimeSelection  tsGuard;

    // The window travels in the project's own time selection, which is what makes the
    // rendered length the requested range with NO rounding and NO added silence (unless
    // a tail was explicitly requested) — the custom-bounds field floors it to the
    // millisecond (render_settings.h's kRenderBoundsTimeSelection).
    //
    // RENDER_STARTPOS/ENDPOS are written anyway, to the same window. The header
    // (~3045-3046) documents them as mode-0-only, so on mode 2 this is a cheap,
    // fully-restored (ScopedRenderSettings) defensive write against that
    // documentation being an incomplete account of what the renderer reads.
    GetSetProjectInfo(proj, "RENDER_BOUNDSFLAG",
                      static_cast<double>(kRenderBoundsTimeSelection), true);
    GetSetProjectInfo(proj, "RENDER_STARTPOS", request.startSeconds, true);
    GetSetProjectInfo(proj, "RENDER_ENDPOS", request.endSeconds, true);
    {
        double s = request.startSeconds, e = request.endSeconds;
        GetSet_LoopTimeRange(true, false, &s, &e, false);
    }

    // TAILFLAG/TAILMS/NORMALIZE/TRIMEND from the pure mapping: None -> exact
    // bounds + disable-all normalize; Auto -> 8s tail + surgical trim-end
    // normalize + -72 dB TRIMEND; Manual -> clamped fixed tail + disable-all, no
    // trim. NORMALIZE is driven here (not the determinism block below) so the
    // Auto surgical value isn't clobbered.
    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 (SDK header ~3041), all 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);

    // request 0 = "follow project"; PROJECT_SRATE is still readable via
    // GetSetProjectInfo even when PROJECT_SRATE_USE is clear.
    const int effectiveSampleRate = (request.sampleRate > 0)
        ? request.sampleRate
        : static_cast<int>(GetSetProjectInfo(proj, "PROJECT_SRATE", 0.0, false));

    // Only pin RENDER_SRATE when known (>0) — a brand-new project can read 0 for
    // PROJECT_SRATE, and forcing RENDER_SRATE=0 would be a bogus literal; leave
    // it unset so REAPER follows its own project-rate default.
    if (effectiveSampleRate > 0) {
        GetSetProjectInfo(proj, "RENDER_SRATE",
                          static_cast<double>(effectiveSampleRate), true);
    }

    GetSetProjectInfo(proj, "RENDER_CHANNELS",
                      static_cast<double>(request.channelCount), true);

    // Load-bearing: never add the rendered file to the project as an item.
    GetSetProjectInfo(proj, "RENDER_ADDTOPROJ", 0.0, true);

    GetSetProjectInfo(proj, "RENDER_DITHER", kDitherDisableAll, true);

    // None/Manual -> disable-all (byte-identical to pre-tail); Auto -> surgical
    // trim-end (only &32768) + -72 dB TRIMEND — a fixed-threshold trailing-silence
    // trim scales/limits/fades nothing, so Auto stays deterministic. TRIMEND is
    // only consulted when the trim bit is set but written unconditionally for clarity.
    GetSetProjectInfo(proj, "RENDER_NORMALIZE",
                      static_cast<double>(tail.normalize), true);
    GetSetProjectInfo(proj, "RENDER_TRIMEND", tail.trimEnd, true);

    // RENDER_PATTERN with no wildcards is a literal stem; REAPER appends the
    // format extension. paths.fileStem already owns the .wav suffix knowledge.
    setProjString(proj, "RENDER_FILE", paths.absoluteDir);
    setProjString(proj, "RENDER_PATTERN", paths.fileStem);

    // Int16/Int24 have no captured ground-truth 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 "
                         "(Float32 only; Int16/Int24 not yet supported).";
        return result;
    }
    setProjString(proj, "RENDER_FORMAT", fmtBase64);

    Main_OnCommand(kActionRenderUsingMostRecentSettings, 0);

    // Main_OnCommand returns void, so a failed render is silent — stat the
    // expected output path to detect it.
    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;
    }

    // A 0-byte render is refused on every tail mode, ahead of and independent from the
    // TailMode::None-only bounds gate below — Auto/Manual add frames by design but never
    // legitimately produce zero (docs/TODO.md "0-byte render" entry: before this check,
    // Auto/Manual landed an empty file as CaptureStatus::Ok with channelCount == 0).
    const BoundsVerdict emptyVerdict =
        checkRenderedFileNotEmpty(expectedPath, projectDir, request.destination);
    if (emptyVerdict.refused) {
        result.status  = CaptureStatus::BoundsMismatch;
        result.message = emptyVerdict.message;
        return result;
    }

    // Exact bounds, made structural: render_bounds_gate judges the landed file against
    // the requested window and owns what becomes of a render that fails.
    // (On TailMode::None the landed file is read three times on this path — that gate,
    // the mono collapse, and stampCaptureSample — plus one rewrite when the collapse
    // fires; Auto/Manual are not gated, so they read it twice. A once-per-capture cost
    // on an already-warm file, judged acceptable.)
    const BoundsVerdict bounds =
        checkRenderedBounds(expectedPath, projectDir, request);
    if (bounds.refused) {
        result.status  = CaptureStatus::BoundsMismatch;
        result.message = bounds.message;
        return result;
    }

    // Lossless mono collapse, deliberately AFTER the bounds gate: the gate measures
    // REAPER's own render against the requested window, so nothing of ours may sit
    // between the render and that measurement, and the file a refusal retains must be
    // the renderer's own rather than one this step had already rewritten. The collapse
    // preserves the frame count, so the two are order-independent in outcome — only
    // in what each is measuring.
    const MonoCollapseOutcome collapseOutcome =
        collapseCapturedFileToMono(expectedPath);

    // Record the request's own bounds (exact) rather than re-measuring the file.
    Sample s;
    // Same uniqueTag that named the file — calling makeUniqueTag() again could
    // yield a different value and desync Sample.id from the file name.
    s.id            = "cap-" + uniqueTag + "-" + paths.fileName;
    s.displayName   = request.label();
    s.relativePath  = relativePath;  // project-relative (invariant); empty off the bank
    s.sourceMode    = request.sourceMode;
    s.sourceRange.startSeconds = request.startSeconds;
    s.sourceRange.endSeconds   = request.endSeconds;
    // startPpq/endPpq/lengthBeats left at 0 — PPQ mapping is a placement-time
    // concern; seconds are the authoritative source for the render and we don't
    // re-derive one bound from the other.
    s.wetDry        = request.wetDry;
    s.lengthSeconds = request.endSeconds - request.startSeconds;
    s.tier          = model::Tier::Scratch;
    stampCaptureSample(s, request, proj, /*timeSigProj=*/nullptr, expectedPath);
    // rootNote/loop left empty — a master/track/time-selection render isn't a
    // single played note, so no root note is derivable; loop points are set
    // later by an explicit user action.

    result.status       = CaptureStatus::Ok;
    result.sample       = s;
    result.absolutePath = expectedPath;
    result.message = "Captured [" +
                     std::to_string(request.startSeconds) + "s, " +
                     std::to_string(request.endSeconds) + "s] -> " +
                     (relativePath.empty() ? expectedPath : relativePath) +
                     monoCollapseSuffix(collapseOutcome);
    return result;
}

}  // namespace reasampler::capture
