// capture_realtime.cpp — REAPER-facing realtime-record backend (RealtimeRecordBackend).
//
// 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).
//
// Captures the requested scope over the requested range by RECORDING in realtime
// (transport-driven) into a hidden temp track, then moves the recorded file into
// the bank as a Sample — non-destructively. This increment implements the TRACK
// scope only (records the selected track's own output). Item realtime is deferred
// (UnsupportedMode) rather than silently half-built.
//
// ============================================================================
// §ASYNC — timer-driven, no UI block (M8 rework — Daniel: "do it right")
// ============================================================================
// A realtime record takes (end - start) wall-clock seconds. The earlier spike ran a
// bounded MAIN-THREAD wait for the transport to reach the range end — which FREEZES
// REAPER's UI for the whole record. That is gone. The record is now driven across
// timer ticks:
//   begin()  — validate, snapshot ALL state to restore, create the temp track,
//              route the source-track tap, arm, CSurf_OnRecord, RETURN IMMEDIATELY.
//   tick()   — (from OnTimer, the same tick as session.poll()) read the transport,
//              and on a terminal verdict stop + finalize/abort + RESTORE everything.
//   abort()  — force-terminate now (shutdown / project switch) + RESTORE everything.
//
// The snapshot + restore live on RealtimeCaptureState (below), NOT a function-scope
// RAII guard — because the record spans ticks, no single stack frame outlives it.
// restore() is idempotent (a restored_ latch): every terminal path — normal
// completion, user stop, error, second-capture reject, project switch, unload —
// funnels through the SAME single restore, safe to call once from whichever fires.
// The pure record-mode bookkeeping, the recorded-file->Sample mapping, and the
// completion state machine (advanceRecordPhase) all live in realtime_record.{h,cpp}
// (unit-tested outside the DAW). This TU owns only the REAPER-bound recipe.
//
// ============================================================================
// §TAP — track-output tap (selected track's own output, PRE-parent)
// ============================================================================
// The recipe: the hidden temp track RECEIVES a send FROM each selected source track
// (CreateTrackSend(source, temp)). The temp track records its OWN output
// (I_RECMODE 3/6, latency-compensated) with B_MAINSEND=0 (it does NOT sum back into
// the master — no feedback, no monitoring double). Multiple selected tracks each get
// a send into the one temp track, so their outputs SUM in the temp track — matching
// how offline track scope handles a multi-track selection.
//
// WHY THIS FAITHFULLY CAPTURES THE TRACK'S OUTPUT — and why NO FxBypassGuard:
//   A CreateTrackSend defaults to I_SENDMODE=0 (post-fader) with I_SRCCHAN=0
//   (channel offset 0, (srcchan>>10)==0 => full stereo — SDK ~3302/3304). Post-fader
//   taps the source track AFTER its own FX and AFTER its own fader/pan — i.e. exactly
//   the track's OWN OUTPUT — but BEFORE the parent/folder/master sums it. The send is
//   a branch off the signal at the track's output stage; the parent chain downstream
//   of that branch is not in the tapped path AT ALL. So the tap is chain-independent
//   BY CONSTRUCTION: there is nothing to neutralize, and FxBypassGuard (which mutates
//   the live chain, altering the user's monitoring) is deliberately NOT used. This is
//   the realtime analogue of offline track scope (item + the track's own FX + its own
//   fader/pan; parent/folder/master excluded), reached without touching any live FX.
//
//   This ALSO fixes the earlier silent-file bug: that spike sent FROM the master INTO
//   a temp track, which REAPER refuses to carry (master->track is a feedback loop), so
//   the temp recorded silence. A regular track->track send has no feedback — it works.
//
// Non-destructive: the temp track is deleted on teardown, which removes every send we
// created INTO it (REAPER cannot leave a send dangling to a deleted destination) — so
// NO source track retains any routing change. We never mutate any existing track's
// persistent state; we only add sends FROM the source tracks that vanish with the
// temp track. The selected source tracks are UNCHANGED after capture.
//
// Item realtime is deferred (UnsupportedMode): item scope would need per-item take
// isolation on top of the tap, which is a separate increment.

#include "capture.h"

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

#include "capture_paths.h"   // hashBytes, deriveBankPaths
#include "peaks.h"           // lastFrameAboveThreshold, AudioSample
#include "realtime_record.h"
#include "render_settings.h" // autoTrimEndRatio, realtimeRecordWindowEnd
#include "wav_trim.h"        // parseWavLayout, extractFloatFrames, planWavTruncate

#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_Main_SaveProject
#define REAPERAPI_WANT_Master_GetTempo
#define REAPERAPI_WANT_TimeMap_GetTimeSigAtTime
#define REAPERAPI_WANT_GetSetProjectInfo
#define REAPERAPI_WANT_InsertTrackAtIndex
#define REAPERAPI_WANT_DeleteTrack
#define REAPERAPI_WANT_CountTracks
#define REAPERAPI_WANT_GetTrack
#define REAPERAPI_WANT_CreateTrackSend
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
#define REAPERAPI_WANT_SetMediaTrackInfo_Value
#define REAPERAPI_WANT_GetTrackNumMediaItems
#define REAPERAPI_WANT_GetTrackMediaItem
#define REAPERAPI_WANT_GetMediaItemTake
#define REAPERAPI_WANT_GetMediaItemTake_Source
#define REAPERAPI_WANT_GetMediaSourceFileName
#define REAPERAPI_WANT_CSurf_OnRecord
#define REAPERAPI_WANT_OnStopButtonEx
#define REAPERAPI_WANT_GetPlayStateEx
#define REAPERAPI_WANT_GetPlayPositionEx
#define REAPERAPI_WANT_GetSet_LoopTimeRange
#define REAPERAPI_WANT_GetCursorPosition
#define REAPERAPI_WANT_SetEditCurPos
#define REAPERAPI_WANT_ValidatePtr2
#include "reaper_plugin_functions.h"

namespace reasampler {

namespace {

// A monotonic, filesystem-safe timestamp tag so repeated captures do not collide.
std::string makeUniqueTag() {
    std::time_t now = std::time(nullptr);
    return "rt-" + std::to_string(static_cast<long long>(now));
}

std::string normSlashes(std::string s) {
    for (char& c : s) if (c == '\\') c = '/';
    if (s.size() > 1 && s.back() == '/') s.pop_back();
    return s;
}

// Reads the ACTIVE project's .rpp path (empty if unsaved). Only needed at begin()
// time, when the record's project IS the active project.
std::string readRppPath() {
    std::vector<char> buf(4096, '\0');
    EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
    return std::string(buf.data());
}

// Discovers the file REAPER actually recorded onto the temp track: the first media
// item's active take's source file. Empty string if nothing was recorded.
std::string recordedFilePath(MediaTrack* temp) {
    if (!temp) return {};
    if (GetTrackNumMediaItems(temp) <= 0) return {};
    MediaItem* item = GetTrackMediaItem(temp, 0);
    if (!item) return {};
    MediaItem_Take* take = GetMediaItemTake(item, 0);
    if (!take) return {};
    PCM_source* src = GetMediaItemTake_Source(take);
    if (!src) return {};
    std::vector<char> buf(4096, '\0');
    GetMediaSourceFileName(src, buf.data(), static_cast<int>(buf.size()));
    return std::string(buf.data());
}

// The recorded file's current size in bytes, or -1 if it cannot be resolved yet (no
// item/take/source, or the file does not exist on disk this tick). Used by the flush
// wait to detect stability (size unchanged across a tick) BEFORE moving the file — a
// take REAPER is still flushing on the audio thread grows tick over tick.
std::int64_t recordedFileSize(MediaTrack* temp) {
    const std::string path = recordedFilePath(temp);
    if (path.empty()) return -1;
    std::error_code ec;
    const auto sz = std::filesystem::file_size(path, ec);
    if (ec) return -1;
    return static_cast<std::int64_t>(sz);
}

}  // namespace

// ============================================================================
// RealtimeCaptureState — the in-flight snapshot + idempotent restore
// ============================================================================
// Holds EVERYTHING to restore across the many ticks the record spans (temp track +
// its receive-sum sends, other tracks' I_RECARM, transport, edit cursor, time selection),
// plus the request echo needed to finalize the Sample. restore() is idempotent
// (restored_ latch) and is the single teardown every terminal path calls.
class RealtimeCaptureState {
public:
    // Bound at begin(): the record's OWN project (transport reads use *Ex(proj_) so
    // a project switch mid-record cannot read the wrong transport), the request
    // echo, and the resolved bank paths + tag for finalize.
    ReaProject*    proj_ = nullptr;
    CaptureRequest request_;
    BankPaths      paths_;
    std::string    uniqueTag_;

    // The RECORDED window end in project seconds (>= request_.endSeconds). For a tail
    // mode the transport runs PAST the range end (Auto: +8 s cap; Manual: +the set
    // length), so this — not request_.endSeconds — is the end the completion state
    // machine waits for. Equals request_.endSeconds for TailMode::None (exact bounds).
    double recordWindowEnd_ = 0.0;

    // The transient sink. The sends we create (from each selected source track INTO
    // temp_) live on those source tracks pointing AT temp_, and are removed automatically
    // when temp_ is deleted — REAPER cannot leave a send dangling to a deleted
    // destination. So there is no separate send handle to track here.
    MediaTrack* temp_    = nullptr;

    // The record phase (pure state machine drives the transition). Starts Recording.
    RecordPhase phase_ = RecordPhase::Recording;

    // Wall-clock anchors for the pure machine's safety ceilings (a steady clock — not
    // the play cursor — so a stuck/looping transport is still caught, review §3).
    // begunAt_ is set at begin(); finalizingAt_ is set on the Recording->Finalizing
    // edge (the transport stop) so the flush wait is bounded from the stop, not begin.
    std::chrono::steady_clock::time_point begunAt_{};
    std::chrono::steady_clock::time_point finalizingAt_{};

    // Deferred-finalize (review §2) flush tracking: the recorded file's size the
    // previous tick, so "size unchanged across a tick" signals REAPER finished
    // flushing/closing the take. -1 = not yet seen.
    std::int64_t lastFileSize_ = -1;

    void markElapsedStart() { begunAt_ = std::chrono::steady_clock::now(); }
    double elapsedSeconds() const {
        return std::chrono::duration<double>(
                   std::chrono::steady_clock::now() - begunAt_).count();
    }
    // Set the flush-wait anchor once, on the first Finalizing tick.
    void markFinalizingStartOnce() {
        if (finalizingAt_.time_since_epoch().count() == 0)
            finalizingAt_ = std::chrono::steady_clock::now();
    }
    double finalizingSeconds() const {
        if (finalizingAt_.time_since_epoch().count() == 0) return 0.0;
        return std::chrono::duration<double>(
                   std::chrono::steady_clock::now() - finalizingAt_).count();
    }

    // Snapshot of state to restore. Filled at begin(), replayed once by restore().
    double curPos_    = 0.0;
    double tsStart_   = 0.0;
    double tsEnd_     = 0.0;
    struct ArmSnap { MediaTrack* track; double recarm; };
    std::vector<ArmSnap> armSnaps_;

    // Snapshot the transport-adjacent state (cursor + time selection) and every
    // OTHER track's arm, disarming them so only our sink records. Call ONCE, before
    // the temp track exists (so the temp track is never in the arm snapshot).
    void snapshotAndDisarmOthers() {
        curPos_ = GetCursorPosition();
        GetSet_LoopTimeRange(false, false, &tsStart_, &tsEnd_, false);
        const int n = CountTracks(proj_);
        for (int i = 0; i < n; ++i) {
            MediaTrack* tr = GetTrack(proj_, i);
            if (!tr) continue;
            const double armed = GetMediaTrackInfo_Value(tr, "I_RECARM");
            if (armed != 0.0) {
                armSnaps_.push_back({tr, armed});
                SetMediaTrackInfo_Value(tr, "I_RECARM", 0.0);
            }
        }
    }

    // The single, idempotent teardown. Called on EVERY terminal path (normal
    // completion, user stop, error, project switch, unload). Safe to call more than
    // once — the restored_ latch makes every call after the first a no-op. Order:
    //   1. stop the transport if anything is still running (we own it),
    //   2. delete the temp track (drops its receive-sum sends + the recorded item),
    //   3. restore every other track's arm,
    //   4. restore the time selection + edit cursor.
    // Stop the record's OWN project transport if it is still playing/recording. Uses
    // the project-scoped OnStopButtonEx(proj_) (not the global CSurf_OnStop) so a
    // project switch mid-record — where proj_ is no longer the ACTIVE project — stops
    // OUR project's transport, never the foreign now-active one. &1=playing,
    // &4=recording. Idempotent to call (the playstate guard makes a repeat a no-op).
    void stopOwnTransport() {
        if (GetPlayStateEx(proj_) & (1 | 4)) OnStopButtonEx(proj_);
    }

    // Is the captured project STILL OPEN? (review §1 — CRITICAL). If the captured
    // project was CLOSED mid-record, proj_/temp_ point at freed memory;
    // touching them (stopOwnTransport, DeleteTrack, arm restore) is a use-after-free.
    // ValidatePtr2 with a null project validates the ReaProject* itself (the header:
    // "proj is ignored if pointer is itself a project"). Every teardown that
    // dereferences a captured REAPER object MUST gate on this first.
    bool captureProjectStillOpen() const {
        return proj_ && ValidatePtr2(nullptr, proj_, "ReaProject*");
    }

    // Drop the handle WITHOUT touching any REAPER state — for the closed-project case
    // (review §1). A closed project already reclaimed its temp track, arms, and
    // transport; there is nothing to restore and the pointers are freed. Latch
    // restored_ so any later terminal path is a no-op (idempotent), but skip every
    // REAPER call restore() would make.
    void dropWithoutRestore() {
        restored_ = true;
        temp_     = nullptr;
        armSnaps_.clear();
    }

    void restore() {
        if (restored_) return;
        restored_ = true;

        // 1. Transport: stop OUR project's if still running (usually already stopped
        //    by the terminal path's explicit stop-before-finalize — a safe no-op then).
        stopOwnTransport();

        // 2. Temp track: deleting it drops the source-track sends (REAPER removes every
        //    send whose destination is deleted — no source track is left mutated) AND the
        //    recorded arrange item in one move — nothing stays behind (load-bearing).
        if (temp_) { DeleteTrack(temp_); temp_ = nullptr; }

        // 3. Other tracks' record-arm.
        for (const ArmSnap& s : armSnaps_)
            SetMediaTrackInfo_Value(s.track, "I_RECARM", s.recarm);
        armSnaps_.clear();

        // 4. Time selection + edit cursor (no view move, no seek).
        GetSet_LoopTimeRange(true, false, &tsStart_, &tsEnd_, false);
        SetEditCurPos(curPos_, false, false);
    }

    bool restored() const { return restored_; }
    bool finalized() const { return finalized_; }
    void markFinalized() { finalized_ = true; }

private:
    bool restored_  = false;
    bool finalized_ = false;
};

namespace {

// Reads the whole file into a byte buffer. Empty vector on any I/O failure — the
// caller treats an unreadable file as "skip the trim" (keep the untrimmed window),
// never as a corruption of the recorded audio.
std::vector<std::uint8_t> readAllBytes(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;
}

// Patches a little-endian uint32 into a byte buffer at `off` (the header size fields).
void writeU32LE(std::vector<std::uint8_t>& bytes, std::size_t off, std::uint32_t v) {
    bytes[off + 0] = static_cast<std::uint8_t>(v & 0xFF);
    bytes[off + 1] = static_cast<std::uint8_t>((v >> 8) & 0xFF);
    bytes[off + 2] = static_cast<std::uint8_t>((v >> 16) & 0xFF);
    bytes[off + 3] = static_cast<std::uint8_t>((v >> 24) & 0xFF);
}

// ============================================================================
// §TAIL — Auto-mode PCM decay-scan trim (docs/product/capture-tail.md §realtime)
// ============================================================================
// After the recorded file is stable and moved into the bank (the file we OWN — never
// the project), Auto mode trims the trailing decay: read the WAV, scan the tail
// region (frames AFTER the original range end) backward for the last frame above
// -72 dB, and truncate the file there. Rules (spec):
//   * no frame in the tail window above -72 dB  -> trim back to the original range end
//   * signal never falls below -72 dB in window -> keep the full window (cap did its job)
//   * otherwise                                 -> trim one frame past the last audible
//
// Returns the trimmed length in SECONDS (for the Sample), or a negative value to
// signal "no trim applied" (caller keeps the pre-trim length). Best-effort and
// non-fatal: any unreadable/unknown/short file skips the trim (keeps the full window)
// rather than risk corrupting the capture — realtime tail is a convenience path.
//
// FORMAT / FLUSH ASSUMPTIONS (DAW-verify): the recorded file is a canonical 32-bit
// float WAV (REAPER project record format — the manual procedure sets it) and is fully
// flushed/closed before this runs (the tick() Finalizing size-stable wait guarantees
// that for the normal path; abort()'s best-effort finalize races it, documented).
double trimAutoTailInPlace(const std::string& path,
                           double rangeStartSeconds,
                           double rangeEndSeconds) {
    constexpr double kNoTrim = -1.0;

    std::vector<std::uint8_t> bytes = readAllBytes(path);
    if (bytes.empty()) return kNoTrim;

    const reasampler::WavLayout layout = parseWavLayout(bytes);
    if (!layout.valid || layout.sampleRate == 0) return kNoTrim;  // not a WAV we trim

    const std::size_t totalFrames = layout.frameCount();
    if (totalFrames == 0) return kNoTrim;

    // The original range end as a frame index within the file (frame 0 == start). Use
    // the FILE's own sample rate (authoritative) — the request rate may be 0 (=follow
    // project). Clamp to the file so a rounding overshoot cannot exceed it.
    const double rangeSeconds = rangeEndSeconds - rangeStartSeconds;
    if (rangeSeconds <= 0.0) return kNoTrim;
    std::size_t rangeEndFrame = static_cast<std::size_t>(
        rangeSeconds * static_cast<double>(layout.sampleRate) + 0.5);
    if (rangeEndFrame > totalFrames) rangeEndFrame = totalFrames;

    // Nothing recorded past the range end (the tail window was empty) -> nothing to
    // trim; keep as-is. (Shouldn't happen for Auto, but total by construction.)
    if (rangeEndFrame >= totalFrames) return kNoTrim;

    // Scan ONLY the tail region (frames after the original range end). The trim never
    // eats into the range body — the scan starts at rangeEndFrame.
    const std::size_t tailFrames = totalFrames - rangeEndFrame;
    const std::vector<reasampler::AudioSample> tailPcm =
        extractFloatFrames(bytes, layout, rangeEndFrame, tailFrames);
    if (tailPcm.empty()) return kNoTrim;

    const float threshold = static_cast<float>(reasampler::autoTrimEndRatio());
    const std::size_t lastAbove = reasampler::lastFrameAboveThreshold(
        tailPcm, layout.channelCount, tailFrames, threshold);

    // keptFrames: the total frame count the trimmed file retains.
    //   no audible tail frame -> trim back to the range end (rangeEndFrame frames)
    //   an audible frame at idx -> keep range body + up to and including that frame
    // The "signal never falls below threshold" case falls out naturally: lastAbove is
    // the final tail frame, so keptFrames == totalFrames (the full window is kept).
    std::size_t keptFrames;
    if (lastAbove == reasampler::kNoFrameAboveThreshold) {
        keptFrames = rangeEndFrame;
    } else {
        keptFrames = rangeEndFrame + (lastAbove + 1);
    }
    if (keptFrames >= totalFrames) return kNoTrim;  // full window kept -> no truncate

    const reasampler::WavTruncatePlan plan = planWavTruncate(layout, keptFrames);
    if (!plan.valid) return kNoTrim;

    // Patch the RIFF + data size fields in the in-memory buffer so they describe the
    // kept frame count, then rewrite the file as exactly the first newFileByteLength
    // bytes (header + patched sizes + retained PCM). A single truncating write is the
    // simplest correct truncate — no separate resize step, no partial-write window
    // where the on-disk sizes and length disagree. The result is a valid, playable WAV
    // of the kept frames (verified by the wav_trim re-parse test).
    writeU32LE(bytes, plan.dataSizeFieldOffset, plan.newDataSize);
    writeU32LE(bytes, plan.riffSizeFieldOffset, plan.newRiffSize);

    // NOTE (DAW-verify, best-effort): a truncating write that fails MID-write (a full
    // disk, a yanked drive) would leave a short file while we return kNoTrim, so the
    // Sample length would overstate the file. Vanishingly unlikely for a just-recorded
    // local bank file, and realtime tail is a convenience path, so a temp-file+atomic-
    // rename is not warranted here; flagged rather than built.
    std::ofstream out(path, std::ios::binary | std::ios::trunc);
    if (!out) return kNoTrim;  // could not reopen to rewrite — leave the full file
    out.write(reinterpret_cast<const char*>(bytes.data()),
              static_cast<std::streamsize>(plan.newFileByteLength));
    if (!out) return kNoTrim;
    out.close();

    // The trimmed length in seconds for the Sample metadata.
    return static_cast<double>(keptFrames) / static_cast<double>(layout.sampleRate);
}

// Builds a CaptureResult for a finalized recording: discover the recorded file,
// move it into the bank, populate the Sample via the pure mapping. Returns Ok +
// Sample on success, or a RenderFailed result. Does NOT restore — the caller
// restores unconditionally afterward (finalize + restore are separate steps so a
// finalize failure still restores).
CaptureResult finalizeRecording(RealtimeCaptureState& st) {
    CaptureResult result;

    const std::string recorded = normSlashes(recordedFilePath(st.temp_));
    if (recorded.empty() || !std::filesystem::exists(recorded)) {
        result.status  = CaptureStatus::RenderFailed;
        result.message = "Realtime record produced no file (check transport/record "
                         "settings in the DAW).";
        return result;
    }

    std::error_code ec;
    std::filesystem::create_directories(st.paths_.absoluteDir, ec);
    const std::string destPath = st.paths_.absoluteDir + "/" + st.paths_.fileName;
    std::filesystem::rename(recorded, destPath, ec);
    if (ec) {
        // Cross-volume rename can fail; fall back to copy+remove.
        ec.clear();
        std::filesystem::copy_file(
            recorded, destPath,
            std::filesystem::copy_options::overwrite_existing, ec);
        if (ec) {
            result.status  = CaptureStatus::RenderFailed;
            result.message = "Recorded file could not be moved into the bank: " +
                             ec.message();
            return result;
        }
        std::error_code rmEc;
        std::filesystem::remove(recorded, rmEc);  // best-effort
    }

    // TAIL (Auto): trim the trailing decay of the recorded window in place — on the
    // BANK file we now own (destPath), never the project. Best-effort: an unreadable /
    // unknown-format / short file skips the trim (keeps the full window) rather than
    // corrupt the capture. Only Auto trims; None recorded exact bounds and Manual is a
    // fixed window (spec §The realtime path). Returns the trimmed length in seconds,
    // or < 0 for "no trim applied".
    double trimmedLenSeconds = -1.0;
    if (st.request_.tailMode == TailMode::Auto) {
        trimmedLenSeconds = trimAutoTailInPlace(destPath,
                                                st.request_.startSeconds,
                                                st.request_.endSeconds);
    }

    RecordedCapture cap;
    cap.relativePath = st.paths_.relativePath;
    cap.uniqueTag    = st.uniqueTag_;
    cap.sourceMode   = SourceMode::Realtime;
    cap.startSeconds = st.request_.startSeconds;
    cap.endSeconds   = st.request_.endSeconds;
    cap.wetDry       = st.request_.wetDry;
    cap.displayName  = st.request_.baseName;
    cap.trackGuids   = st.request_.trackGuids;
    cap.channelCount = st.request_.channelCount;
    cap.sampleRate   = (st.request_.sampleRate > 0)
        ? st.request_.sampleRate
        : static_cast<int>(GetSetProjectInfo(st.proj_, "PROJECT_SRATE", 0.0, false));
    cap.captureTempo = Master_GetTempo();
    // Time signature at the record range's START (L7 F1). TimeMap_GetTimeSigAtTime
    // (reaper_plugin_functions.h:7130) reads the meter effective at that project time;
    // proj=st.proj_ pins the recording's own project. tempoOut ignored (captureTempo is
    // the master tempo above). Leaves 0/0 (unstamped) on any failure.
    {
        int tsNum = 0, tsDenom = 0;
        double tsTempo = 0.0;
        TimeMap_GetTimeSigAtTime(st.proj_, st.request_.startSeconds, &tsNum, &tsDenom, &tsTempo);
        cap.captureTimeSigNum   = tsNum;
        cap.captureTimeSigDenom = tsDenom;
    }
    cap.createdTimestamp = static_cast<std::int64_t>(std::time(nullptr));

    result.status  = CaptureStatus::Ok;
    result.sample  = sampleFromRecordedCapture(cap);

    // Content hash: WAV-aware FNV-1a over the (possibly trimmed) bank 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 records 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).
    {
        const std::vector<std::uint8_t> fileBytes = readAllBytes(destPath);
        if (!fileBytes.empty()) {
            result.sample.contentHash = hashWavContent(fileBytes);
        }
    }

    // The recorded file's true length differs from the request range when a tail was
    // recorded, so the Sample length must reflect the FILE, not the range:
    //   Auto with a trim applied -> the trimmed length trimAutoTailInPlace returned.
    //   Auto with no trim, or Manual -> the full recorded window (end - start).
    //   None -> the exact range (unchanged; recordWindowEnd_ == endSeconds).
    // sampleFromRecordedCapture already set lengthSeconds = end - start; override it
    // to the recorded/trimmed length so downstream (thumbnail, placement) matches disk.
    if (trimmedLenSeconds >= 0.0) {
        result.sample.lengthSeconds = trimmedLenSeconds;
    } else {
        result.sample.lengthSeconds =
            st.recordWindowEnd_ - st.request_.startSeconds;
    }

    result.message = "Realtime-captured [" +
                     std::to_string(st.request_.startSeconds) + "s, " +
                     std::to_string(st.request_.endSeconds) + "s] (recorded " +
                     std::to_string(result.sample.lengthSeconds) + "s) -> " +
                     st.paths_.relativePath;
    return result;
}

}  // namespace

// ============================================================================
// begin — start the record, snapshot, return immediately (no UI block)
// ============================================================================
void RealtimeCaptureStateDeleter::operator()(RealtimeCaptureState* p) const noexcept {
    delete p;  // full type is visible here — keeps capture.h REAPER-free
}

RealtimeCaptureHandle
RealtimeRecordBackend::begin(const CaptureRequest& request,
                            const std::vector<MediaTrack*>& sourceTracks,
                            CaptureResult& outFailure) {
    // Only the track scope is implemented this increment (see §TAP). Item realtime
    // is deferred — it needs per-item take isolation on top of the track-output tap.
    if (request.sourceMode != SourceMode::SelectedTracks) {
        outFailure.status  = CaptureStatus::UnsupportedMode;
        outFailure.message = "RealtimeRecordBackend implements TRACK scope only this "
                             "increment (item realtime is deferred).";
        return nullptr;
    }

    // Track scope needs at least one source track to tap. No selection -> refuse
    // (matching offline track scope's no-op on an empty selection).
    if (sourceTracks.empty()) {
        outFailure.status  = CaptureStatus::UnsupportedMode;
        outFailure.message = "No track selected — realtime track capture needs at least "
                             "one selected track to tap.";
        return nullptr;
    }

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

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

    // Refuse if the transport is already playing/recording — we own the transport for
    // the capture window and must not hijack a user's live take.
    if (GetPlayStateEx(proj) & (1 | 4)) {
        outFailure.status  = CaptureStatus::TransportBusy;
        outFailure.message = "Transport is already playing/recording — realtime capture "
                             "refused. Stop the transport first.";
        return nullptr;
    }

    // Saved-project gate (same as offline): the bank folder resolves against the
    // .rpp parent. Prompt Save-As once when unsaved; refuse if still unsaved.
    std::string rppPath = readRppPath();
    if (rppPath.empty()) {
        Main_SaveProject(proj, true);   // DAW-only: opens Save-As, blocks (verify)
        rppPath = readRppPath();
    }
    if (rppPath.empty()) {
        outFailure.status  = CaptureStatus::NoProject;
        outFailure.message = "Project must be saved before capture — nothing captured.";
        return nullptr;
    }
    const std::string projectDir =
        normSlashes(std::filesystem::path(rppPath).parent_path().string());

    // --- Build the in-flight state (owns the snapshot + teardown) ---------------
    RealtimeCaptureHandle st(new RealtimeCaptureState());
    st->proj_      = proj;
    st->request_   = request;
    st->uniqueTag_ = makeUniqueTag();
    st->paths_     = deriveBankPaths(projectDir, request.baseName, st->uniqueTag_);

    // The recorded window end: extended past the range end for a tail mode (Auto/Manual),
    // exact for None. This — not request.endSeconds — is what the completion machine
    // waits for; the extra window past the range end is trimmed later (Auto) or kept
    // (Manual). Pure mapping (render_settings), shared caps with the offline tail.
    st->recordWindowEnd_ = realtimeRecordWindowEnd(request.tailMode,
                                                   request.endSeconds,
                                                   request.tailMs);

    // DELIBERATE: the transient temp-track / arm / send / transport mutations are NOT
    // wrapped in an Undo_BeginBlock/Undo_EndBlock — divergence from the insert/view
    // shells is intentional. This backend fully restores its own state across every
    // terminal path (the restore() latch); an undo point would surface an internal,
    // fully-reversed scaffold in the user's undo history for no user-meaningful action.
    // Snapshot cursor + time selection, and disarm every OTHER track BEFORE the temp
    // track exists (so it is never in the arm snapshot and keeps the arm we set).
    st->snapshotAndDisarmOthers();

    // Hidden temp track at the end: no default FX/envelopes (clean sink), hidden from
    // both panels, B_MAINSEND=0 so it does NOT sum back into the master (monitoring
    // invariant — it would otherwise double the tapped tracks in the user's monitoring).
    const int idx = CountTracks(proj);
    InsertTrackAtIndex(idx, false);
    st->temp_ = GetTrack(proj, idx);
    if (!st->temp_) {
        outFailure.status  = CaptureStatus::RenderFailed;
        outFailure.message = "Could not create the hidden temp record track.";
        st->restore();  // undo the disarm + cursor/time-sel snapshot
        return nullptr;
    }
    SetMediaTrackInfo_Value(st->temp_, "B_SHOWINTCP", 0.0);
    SetMediaTrackInfo_Value(st->temp_, "B_SHOWINMIXER", 0.0);
    SetMediaTrackInfo_Value(st->temp_, "B_MAINSEND", 0.0);

    // Route the TRACK-OUTPUT tap: a send FROM each selected source track INTO the temp
    // track (CreateTrackSend(source, temp)). The temp records its OWN output, so the
    // sends' outputs SUM in it — multiple selected tracks are captured together (same as
    // offline track scope). See §TAP for why this faithfully captures each track's own
    // output and needs no FxBypassGuard.
    //
    // Sends default to post-fader (I_SENDMODE 0) and full-stereo (I_SRCCHAN default,
    // (srcchan>>10)==0 — SDK ~3302/3304): post-fader = after the source track's FX and
    // fader/pan = the track's OWN output, tapped BEFORE the parent sums it. Left at
    // defaults deliberately — that IS the track-scope tap point.
    //
    // DAW-ONLY ASSUMPTION (flag): that a post-fader track->temp send + output-record
    // reproduces the track's own output sample-for-sample (latency comp, pan law,
    // mono/stereo folding) is the crux to verify live.
    int sendsMade = 0;
    for (MediaTrack* src : sourceTracks) {
        if (!src || src == st->temp_) continue;
        if (CreateTrackSend(src, st->temp_) >= 0) ++sendsMade;
    }
    if (sendsMade == 0) {
        // Every send failed (should not happen for valid selected tracks). Refuse
        // rather than record a guaranteed-silent file.
        outFailure.status  = CaptureStatus::RenderFailed;
        outFailure.message = "Could not route any selected track into the record tap — "
                             "nothing to capture.";
        st->restore();  // deleting the temp track drops any partial sends too
        return nullptr;
    }

    // Record-mode values from the pure planner. The temp track records its OWN output;
    // it has no FX and unity fader, so its post-fader output equals the summed sends.
    // Track scope is fully wet -> PostFader. (The actual track-scope tap point is the
    // source sends' default post-fader mode; the temp's recmode only records the sum.)
    const OutputTap tap = outputTapForWetDry(request.wetDry);
    const RecordModePlan rec = recordModePlanFor(request.channelCount, tap);
    SetMediaTrackInfo_Value(st->temp_, "I_RECMODE", static_cast<double>(rec.recMode));
    SetMediaTrackInfo_Value(st->temp_, "I_RECMODE_FLAGS",
                            static_cast<double>(rec.recModeFlags));
    SetMediaTrackInfo_Value(st->temp_, "I_RECARM", 1.0);  // arm ONLY the sink
    SetMediaTrackInfo_Value(st->temp_, "I_RECMON", 0.0);  // no input monitoring

    // Record range: time selection over [start, recordWindowEnd], play cursor at start.
    // recordWindowEnd extends past the request's range end for a tail mode so the
    // transport captures the decaying tail; it equals the range end for None (exact
    // bounds). Both cursor + time selection were snapshotted and are restored by
    // restore().
    double rs = request.startSeconds, re = st->recordWindowEnd_;
    GetSet_LoopTimeRange(true, false, &rs, &re, false);
    SetEditCurPos(request.startSeconds, false, false);

    // Start the transport and RETURN. tick() drives the rest across timer ticks.
    //
    // DAW-ONLY ASSUMPTION (flag): CSurf_OnRecord starts recording and the exact
    // range/auto-punch/stop behavior depends on the user's transport settings — not
    // header-guaranteed. tick() detects completion via the play cursor reaching the
    // range end (the pure state machine), independent of REAPER's auto-punch.
    CSurf_OnRecord();

    // Anchor the wall-clock safety ceiling from here (steady clock — independent of the
    // play cursor, so a transport that starts but never advances is still bounded).
    st->markElapsedStart();

    return st;
}

// ============================================================================
// tick — advance the in-flight record; on terminal, finalize/abort + restore
// ============================================================================
RealtimeTickResult RealtimeRecordBackend::tick(RealtimeCaptureState& state) {
    RealtimeTickResult out;

    // If a prior terminal path already tore this down (e.g. abort() then a stray
    // tick), do nothing — the state is spent.
    if (state.restored()) { out.status = RealtimeTickStatus::Failed; return out; }

    const RecordPhase prevPhase = state.phase_;

    // Read the transport bound to the record's OWN project (a project switch cannot
    // point these reads at the wrong transport). &4 = recording. Gather everything the
    // pure machine needs (transport + wall-clock ceilings + file-flush readiness).
    RecordTickInputs inputs;
    inputs.transport.recording    = (GetPlayStateEx(state.proj_) & 4) != 0;
    inputs.transport.playPosition = GetPlayPositionEx(state.proj_);
    inputs.elapsedSeconds         = state.elapsedSeconds();

    // Deferred-finalize flush check (review §2), only meaningful once stopped. The
    // recorded file is READY when its size is a valid positive value AND unchanged
    // from the previous tick — REAPER finished flushing/closing the take on the audio
    // thread. Comparing across a tick avoids moving a file mid-write (truncated take).
    if (prevPhase == RecordPhase::Finalizing) {
        state.markFinalizingStartOnce();
        inputs.finalizingSeconds = state.finalizingSeconds();
        const std::int64_t sz = recordedFileSize(state.temp_);
        inputs.fileReady = (sz > 0 && sz == state.lastFileSize_);
        state.lastFileSize_ = sz;
    }

    // Wait for the transport to reach the RECORDED window end (extended past the
    // range end for a tail mode), not the request's range end — the extra tail window
    // is part of the record. The record safety ceiling scales with it (window - start
    // + margin) inside the pure machine.
    state.phase_ = advanceRecordPhase(state.phase_, inputs,
                                      state.request_.startSeconds,
                                      state.recordWindowEnd_);

    // On the Recording -> Finalizing edge, stop OUR project's transport ONCE so REAPER
    // begins closing/flushing the recorded take. Project-scoped (OnStopButtonEx(proj_))
    // — never the global CSurf_OnStop, which would stop whatever project is ACTIVE (a
    // foreign one during a project switch), not the record's own. The flush wait then
    // proceeds across subsequent ticks before the file is moved.
    if (prevPhase == RecordPhase::Recording &&
        isStopRequested(state.phase_)) {
        state.stopOwnTransport();
        state.markFinalizingStartOnce();  // anchor the flush ceiling from the stop
    }

    if (!isTerminalPhase(state.phase_)) {
        out.status = RealtimeTickStatus::InProgress;
        return out;  // keep the OnTimer tick fast — recording or flushing
    }

    // Terminal (Done: file flushed + stable; Failed: flush ceiling tripped). On Done,
    // finalize moves the now-stable file into the bank + builds the Sample. On Failed
    // (the flush timeout) there is nothing usable — report RenderFailed. Then restore
    // ALL snapshotted state — the non-destructive gate, idempotent + unconditional.
    CaptureResult res;
    if (state.phase_ == RecordPhase::Done) {
        res = finalizeRecording(state);
    } else {
        res.status  = CaptureStatus::RenderFailed;
        res.message = "Realtime record timed out waiting for the recorded file to "
                      "flush/close (nothing captured).";
    }
    state.markFinalized();
    state.restore();

    out.result = res;
    out.status = (res.status == CaptureStatus::Ok)
                     ? RealtimeTickStatus::Done
                     : RealtimeTickStatus::Failed;
    return out;
}

// ============================================================================
// abort — force-terminate now (shutdown / project switch) + restore
// ============================================================================
RealtimeTickResult RealtimeRecordBackend::abort(RealtimeCaptureState& state) {
    RealtimeTickResult out;

    // Already torn down (idempotent): report Failed and leave it.
    if (state.restored()) { out.status = RealtimeTickStatus::Failed; return out; }

    // CRITICAL (review §1): if the captured project was CLOSED mid-record, proj_ /
    // temp_ point at freed memory. The closed project already reclaimed its
    // temp track, arms, and transport — so DROP the handle WITHOUT touching any REAPER
    // state (no stop, no finalize, no DeleteTrack, no arm restore). Touching those
    // freed pointers is the use-after-free bug this guard exists to prevent. This is
    // the ONE terminal path that can run against a possibly-closed project (tick() only
    // runs while proj_ is the active — hence still-open — project); guarding here covers
    // both the project-switch and unload callers.
    if (!state.captureProjectStillOpen()) {
        state.dropWithoutRestore();
        out.result.status  = CaptureStatus::RenderFailed;
        out.result.message = "Realtime capture dropped — the captured project was closed "
                             "mid-record (nothing to restore; no capture persisted).";
        out.status = RealtimeTickStatus::Failed;
        return out;
    }

    // The project is still open (a tab-switch, or a clean unload with the project
    // present): stop the transport, then TRY to finalize whatever was captured so a
    // near-complete record still keeps the audio; if nothing was recorded (or the file
    // has not flushed yet), finalize returns RenderFailed and we abort clean.
    // Project-scoped stop (OnStopButtonEx(proj_)) — on a project switch proj_ is no
    // longer active, so the global CSurf_OnStop would stop the wrong (foreign) project.
    //
    // NOTE (residual timing — DAW-verify): abort is the force-terminate path (unload /
    // switch); it cannot span ticks to wait for the flush the way tick() does, so its
    // finalize still races REAPER's audio-thread take close. That is inherent to a
    // best-effort terminal grab and is acceptable — the normal completion path (tick)
    // is the one that must be flush-safe.
    state.stopOwnTransport();

    CaptureResult res = finalizeRecording(state);
    state.markFinalized();
    state.restore();  // the non-destructive gate — always runs

    out.result = res;
    out.status = (res.status == CaptureStatus::Ok)
                     ? RealtimeTickStatus::Done
                     : RealtimeTickStatus::Failed;
    return out;
}

}  // namespace reasampler
