// Standalone tests for reasampler::capture_realtime (renamed from realtime_record
// in Q-W3 — the Q-9 naming rider) — no REAPER, no framework.
// Covers the two pure pieces behind the realtime-record backend (M8): the
// record-mode/recipe bookkeeping (channel count + tap -> I_RECMODE / I_RECMODE_FLAGS)
// and the wet/dry -> tap decision, plus the recorded-file -> Sample mapping.

#include "../src/core/capture/capture_realtime.h"

#include <cstdio>
#include <string>

using namespace reasampler;
using namespace reasampler::capture;

static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
    std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)

// --- recordModePlanFor: channel count -> stereo/mono, latency-compensated -----

static void testStereoOutForTwoChannels() {
    // A 2-channel request records stereo-out, latency-compensated (I_RECMODE 3).
    RecordModePlan p = recordModePlanFor(2, OutputTap::PostFader);
    CHECK(p.recMode == kRecModeStereoOutLatComp);
    CHECK(p.recMode == 3);
}

static void testMonoOutForOneChannel() {
    // A 1-channel request records mono-out, latency-compensated (I_RECMODE 6).
    RecordModePlan p = recordModePlanFor(1, OutputTap::PostFader);
    CHECK(p.recMode == kRecModeMonoOutLatComp);
    CHECK(p.recMode == 6);
    // Zero/negative channel counts also fall to mono-out (defensive, <= 1).
    CHECK(recordModePlanFor(0, OutputTap::PostFader).recMode == kRecModeMonoOutLatComp);
}

static void testMoreThanTwoChannelsStillStereoOut() {
    // >2 channels still record stereo-out — REAPER's output-record modes are
    // mono/stereo only. (A >2ch realtime capture is out of this increment's scope.)
    CHECK(recordModePlanFor(4, OutputTap::PostFader).recMode == kRecModeStereoOutLatComp);
}

// --- recordModePlanFor: tap -> I_RECMODE_FLAGS &3 bits -------------------------

static void testPostFaderTapFlags() {
    // PostFader = fully wet, &3==0.
    CHECK(recordModePlanFor(2, OutputTap::PostFader).recModeFlags == kRecOutPostFader);
    CHECK((recordModePlanFor(2, OutputTap::PostFader).recModeFlags & 3) == 0);
}

static void testPreFxTapFlags() {
    // PreFx = true dry, &3==1 — the only documented pre-FX tap in the SDK.
    CHECK(recordModePlanFor(2, OutputTap::PreFx).recModeFlags == kRecOutPreFx);
    CHECK((recordModePlanFor(2, OutputTap::PreFx).recModeFlags & 3) == 1);
}

static void testPostFxPreFaderTapFlags() {
    // PostFxPreFader = wet FX, pre-fader, &3==2.
    CHECK(recordModePlanFor(2, OutputTap::PostFxPreFader).recModeFlags == kRecOutPostFxPreFader);
    CHECK((recordModePlanFor(2, OutputTap::PostFxPreFader).recModeFlags & 3) == 2);
}

static void testTapIsIndependentOfChannelCount() {
    // The tap bits do not vary with channel count; the record mode does not vary
    // with the tap. The two axes are orthogonal.
    CHECK(recordModePlanFor(1, OutputTap::PreFx).recModeFlags == kRecOutPreFx);
    CHECK(recordModePlanFor(4, OutputTap::PreFx).recModeFlags == kRecOutPreFx);
    CHECK(recordModePlanFor(1, OutputTap::PreFx).recMode == kRecModeMonoOutLatComp);
    CHECK(recordModePlanFor(2, OutputTap::PreFx).recMode == kRecModeStereoOutLatComp);
}

// --- outputTapForWetDry: wet -> PostFader, dry -> PreFx ------------------------

static void testFullyWetTapsPostFader() {
    CHECK(outputTapForWetDry(1.0) == OutputTap::PostFader);
}

static void testDryTapsPreFx() {
    // Any value below fully-wet is the true pre-FX dry tap.
    CHECK(outputTapForWetDry(0.0) == OutputTap::PreFx);
    CHECK(outputTapForWetDry(0.5) == OutputTap::PreFx);
    // 0.999 (just under wet) still taps pre-FX — there is no blend, it is a switch.
    CHECK(outputTapForWetDry(0.999) == OutputTap::PreFx);
}

// --- sampleFromRecordedCapture: exact bounds, scratch tier, no dedup ----------

static RecordedCapture makeCapture() {
    RecordedCapture cap;
    cap.relativePath = "reasampler_bank/realtime_1700000000.wav";
    cap.uniqueTag    = "1700000000";
    cap.sourceMode   = SourceMode::Realtime;
    cap.startSeconds = 4.0;
    cap.endSeconds   = 6.5;
    cap.wetDry       = 1.0;
    cap.displayName  = "realtime";
    cap.trackGuids   = {"{GUID-A}"};
    cap.channelCount = 2;
    cap.sampleRate   = 48000;
    cap.captureTempo = 120.0;
    cap.createdTimestamp = 1700000000;
    return cap;
}

static void testSampleExactBoundsNoRounding() {
    Sample s = sampleFromRecordedCapture(makeCapture());
    // Bounds are echoed exactly — no re-measuring, no rounding.
    CHECK(s.sourceRange.startSeconds == 4.0);
    CHECK(s.sourceRange.endSeconds   == 6.5);
    CHECK(s.lengthSeconds == 2.5);  // end - start, computed here
}

static void testSampleMetadataCarriedThrough() {
    Sample s = sampleFromRecordedCapture(makeCapture());
    CHECK(s.sourceMode   == SourceMode::Realtime);
    CHECK(s.relativePath == "reasampler_bank/realtime_1700000000.wav");
    CHECK(s.channelCount == 2);
    CHECK(s.sampleRate   == 48000);
    CHECK(s.captureTempo == 120.0);
    CHECK(s.wetDry       == 1.0);
    CHECK(s.trackGuids.size() == 1);
    CHECK(s.trackGuids[0] == "{GUID-A}");
    CHECK(s.createdTimestamp == 1700000000);
    CHECK(s.displayName  == "realtime");
}

static void testSampleLandsInScratchWithNoHash() {
    Sample s = sampleFromRecordedCapture(makeCapture());
    // Captures land in scratch by default (auto-prunable), same as offline.
    CHECK(s.tier == Tier::Scratch);
    CHECK(s.isAutoPrunable());
    // Empty content hash so a realtime capture never collapses (bank_model treats
    // "" as non-participating in dedup) — realtime is not bit-identical, so it must
    // never dedup against a prior capture.
    CHECK(s.contentHash.empty());
}

static void testSampleIdIsStableAndUnique() {
    Sample s = sampleFromRecordedCapture(makeCapture());
    // The id carries the unique tag so repeated captures do not collide, and is
    // consistent with the file that produced it (same discipline as offline).
    CHECK(s.id.find("1700000000") != std::string::npos);
    CHECK(!s.id.empty());
}

static void testUnknownSampleRateStaysZero() {
    // When the shell could not resolve the project rate it passes 0; the mapping
    // must not invent a value (mirror of the offline "unknown rate -> 0" behavior).
    RecordedCapture cap = makeCapture();
    cap.sampleRate = 0;
    Sample s = sampleFromRecordedCapture(cap);
    CHECK(s.sampleRate == 0);
}

// --- advanceRecordPhase: the async completion state machine -------------------
//
// The machine now has two waits: Recording (transport running) and Finalizing (stopped,
// waiting for the recorded file to flush). Inputs bundle the transport reading, the
// wall-clock ceilings, and the file-flush readiness. Helpers keep the tests terse.

static const double kStart = 4.0;
static const double kEnd   = 6.5;

// Recording-phase inputs: transport recording flag + play position + elapsed wall clock.
static RecordTickInputs recTick(bool rec, double pos, double elapsed) {
    RecordTickInputs in;
    in.transport.recording    = rec;
    in.transport.playPosition = pos;
    in.elapsedSeconds         = elapsed;
    return in;
}
// Finalizing-phase inputs: file readiness + time spent flushing.
static RecordTickInputs finTick(bool fileReady, double finalizing) {
    RecordTickInputs in;
    in.transport.recording = false;  // stopped by the time we are finalizing
    in.fileReady           = fileReady;
    in.finalizingSeconds   = finalizing;
    return in;
}

static RecordPhase advance(RecordPhase cur, const RecordTickInputs& in) {
    return advanceRecordPhase(cur, in, kStart, kEnd);
}

// -- Recording -> keep waiting / reached end / stopped early --------------------

static void testStaysRecordingBeforeRangeEnd() {
    // Cursor short of the end, well under the wall-clock ceiling -> keep waiting.
    RecordPhase p = advance(RecordPhase::Recording, recTick(true, 5.0, 1.0));
    CHECK(p == RecordPhase::Recording);
    CHECK(!isTerminalPhase(p));
    CHECK(!isStopRequested(p));
}

static void testReachesEndAtOrPastRangeEnd() {
    // Cursor exactly on the end goes to Finalizing (>=, not >), and past the end too.
    CHECK(advance(RecordPhase::Recording, recTick(true, 6.5, 3.0))
          == RecordPhase::Finalizing);
    CHECK(advance(RecordPhase::Recording, recTick(true, 7.0, 3.0))
          == RecordPhase::Finalizing);
    // Finalizing is the shell's stop-and-flush signal, not yet terminal.
    CHECK(isStopRequested(RecordPhase::Finalizing));
    CHECK(!isTerminalPhase(RecordPhase::Finalizing));
}

static void testStopsEarlyGoesToFinalizing() {
    // Transport no longer recording (user hit stop) before the end -> Finalizing,
    // regardless of where the cursor was.
    RecordPhase p = advance(RecordPhase::Recording, recTick(false, 5.0, 1.0));
    CHECK(p == RecordPhase::Finalizing);
    CHECK(isStopRequested(p));
}

static void testStopBeatsCursorPositionCheck() {
    // NOT recording is the signal even if the cursor sits past the end — a stop that
    // raced the end is still a stop; both routes converge on Finalizing anyway.
    CHECK(advance(RecordPhase::Recording, recTick(false, 9.0, 1.0))
          == RecordPhase::Finalizing);
}

static void testReachedEndImmediatelyOnFirstTick() {
    // A degenerate range where the cursor is already at/past end on the first tick
    // moves to Finalizing at once rather than waiting a full transport lap.
    CHECK(advance(RecordPhase::Recording, recTick(true, 6.5, 0.1))
          == RecordPhase::Finalizing);
}

// -- Recording safety ceiling (review §3): stuck/non-advancing transport --------

static void testStuckTransportTripsWallClockCeiling() {
    // Transport reports recording, but the cursor never advances to the end. Before the
    // ceiling: keep waiting. Past (end-start)+margin of wall clock: force Finalizing so
    // the temp track + armed sink are not leaked for the session.
    const double duration = kEnd - kStart;                 // 2.5s nominal
    const double underCeiling = duration + kRecordMarginSeconds - 0.5;
    const double overCeiling  = duration + kRecordMarginSeconds + 0.5;
    // Cursor stuck at start the whole time.
    CHECK(advance(RecordPhase::Recording, recTick(true, kStart, underCeiling))
          == RecordPhase::Recording);
    CHECK(advance(RecordPhase::Recording, recTick(true, kStart, overCeiling))
          == RecordPhase::Finalizing);
}

// -- Finalizing (review §2): deferred finalize, flush wait ----------------------

static void testFinalizingWaitsUntilFileReady() {
    // File not yet flushed/stable, within the flush ceiling -> keep waiting in
    // Finalizing (do NOT move the file mid-write).
    RecordPhase p = advance(RecordPhase::Finalizing, finTick(false, 1.0));
    CHECK(p == RecordPhase::Finalizing);
    CHECK(!isTerminalPhase(p));
}

static void testFinalizingCompletesWhenFileReady() {
    // File exists AND is stable -> Done (the shell now moves it + builds the Sample).
    RecordPhase p = advance(RecordPhase::Finalizing, finTick(true, 1.0));
    CHECK(p == RecordPhase::Done);
    CHECK(isTerminalPhase(p));
}

static void testFinalizingFailsWhenFlushCeilingTrips() {
    // File never stabilizes; past the flush ceiling -> Failed (give up, RenderFailed).
    const double overCeiling = kFinalizeFlushCeilingSeconds + 0.5;
    RecordPhase p = advance(RecordPhase::Finalizing, finTick(false, overCeiling));
    CHECK(p == RecordPhase::Failed);
    CHECK(isTerminalPhase(p));
}

static void testFinalizingReadyBeatsCeiling() {
    // If the file is ready ON the same tick the ceiling trips, ready wins -> Done
    // (we do not discard a capture that just became available).
    const double overCeiling = kFinalizeFlushCeilingSeconds + 0.5;
    CHECK(advance(RecordPhase::Finalizing, finTick(true, overCeiling))
          == RecordPhase::Done);
}

// -- Terminal stickiness + classification --------------------------------------

static void testTerminalPhasesAreSticky() {
    // Feeding a terminal phase back returns it unchanged — a stray late tick before
    // teardown finishes cannot flip the verdict (the idempotence the shell relies on).
    CHECK(advance(RecordPhase::Done,   recTick(true, 2.0, 1.0)) == RecordPhase::Done);
    CHECK(advance(RecordPhase::Done,   finTick(false, 1.0))     == RecordPhase::Done);
    CHECK(advance(RecordPhase::Failed, recTick(true, 8.0, 1.0)) == RecordPhase::Failed);
    CHECK(advance(RecordPhase::Failed, finTick(true, 1.0))      == RecordPhase::Failed);
}

static void testStopRequestedClassification() {
    // isStopRequested fires for every phase past Recording (drives the one-shot stop).
    CHECK(!isStopRequested(RecordPhase::Recording));
    CHECK(isStopRequested(RecordPhase::Finalizing));
    CHECK(isStopRequested(RecordPhase::Done));
    CHECK(isStopRequested(RecordPhase::Failed));
}

static void testIsTerminalPhaseClassification() {
    CHECK(!isTerminalPhase(RecordPhase::Recording));
    CHECK(!isTerminalPhase(RecordPhase::Finalizing));
    CHECK(isTerminalPhase(RecordPhase::Done));
    CHECK(isTerminalPhase(RecordPhase::Failed));
}

int main() {
    testStereoOutForTwoChannels();
    testMonoOutForOneChannel();
    testMoreThanTwoChannelsStillStereoOut();
    testPostFaderTapFlags();
    testPreFxTapFlags();
    testPostFxPreFaderTapFlags();
    testTapIsIndependentOfChannelCount();
    testFullyWetTapsPostFader();
    testDryTapsPreFx();
    testSampleExactBoundsNoRounding();
    testSampleMetadataCarriedThrough();
    testSampleLandsInScratchWithNoHash();
    testSampleIdIsStableAndUnique();
    testUnknownSampleRateStaysZero();
    testStaysRecordingBeforeRangeEnd();
    testReachesEndAtOrPastRangeEnd();
    testStopsEarlyGoesToFinalizing();
    testStopBeatsCursorPositionCheck();
    testReachedEndImmediatelyOnFirstTick();
    testStuckTransportTripsWallClockCeiling();
    testFinalizingWaitsUntilFileReady();
    testFinalizingCompletesWhenFileReady();
    testFinalizingFailsWhenFlushCeilingTrips();
    testFinalizingReadyBeatsCeiling();
    testTerminalPhasesAreSticky();
    testStopRequestedClassification();
    testIsTerminalPhaseClassification();

    if (g_fail == 0) std::printf("capture_realtime: all tests passed\n");
    else             std::printf("capture_realtime: %d CHECK(s) FAILED\n", g_fail);
    return g_fail == 0 ? 0 : 1;
}
