// Standalone tests for reasampler::drag_out — no REAPER, no test framework. Same fast loop
// as the sibling pure tests (mode_switch et al.): assert the gesture-
// boundary decision and the path-list assembly directly.
//
// Covers (M11 drag-out brief §test cases):
//   * Gesture boundary: inside-panel drag stays Internal; leaving the client area with
//     armed samples -> OsDrag; no armed samples (or not dragging) -> None; half-open edge
//     behavior; re-entry back inside returns to Internal (position-only decision).
//   * Path-list assembly: single, multi, dedupe (cross-bank copy case), skip-missing,
//     skip-unresolved, empty selection, order preservation, mixed tallies.

#include "../src/core/ui/drag_out.h"

#include <cstdio>
#include <string>
#include <vector>

using namespace reasampler;
using namespace reasampler::ui;

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

// --- Gesture boundary ---------------------------------------------------------

static const PanelClientRect kPanel{0, 0, 400, 300};

// A drag with samples, pointer well inside the client rect -> the existing internal drag
// (invariant #4: inside-panel drag stays internal, unchanged).
static void testInsidePanelStaysInternal() {
    DragState s{/*dragging=*/true, /*hasArmedSamples=*/true};
    CHECK(decideGesture(200, 150, kPanel, s) == DragGesture::Internal);
    CHECK(decideGesture(0, 0, kPanel, s) == DragGesture::Internal);       // top-left corner
    CHECK(decideGesture(399, 299, kPanel, s) == DragGesture::Internal);   // last inside px
}

// A drag with samples whose pointer has left the client rect (any edge) -> OS drag.
static void testLeavingClientAreaIsOsDrag() {
    DragState s{/*dragging=*/true, /*hasArmedSamples=*/true};
    CHECK(decideGesture(-1, 150, kPanel, s) == DragGesture::OsDrag);   // left of panel
    CHECK(decideGesture(400, 150, kPanel, s) == DragGesture::OsDrag);  // right edge (x+w)
    CHECK(decideGesture(200, -5, kPanel, s) == DragGesture::OsDrag);   // above
    CHECK(decideGesture(200, 300, kPanel, s) == DragGesture::OsDrag);  // below (y+h)
    CHECK(decideGesture(1000, 1000, kPanel, s) == DragGesture::OsDrag);// far outside
}

// The half-open boundary: x+width and y+height are OUTSIDE (OsDrag), the pixel just inside
// is Internal — matches the panel's other hit-tests so the edge is claimed consistently.
static void testBoundaryHalfOpen() {
    DragState s{true, true};
    CHECK(decideGesture(399, 150, kPanel, s) == DragGesture::Internal);
    CHECK(decideGesture(400, 150, kPanel, s) == DragGesture::OsDrag);
    CHECK(decideGesture(200, 299, kPanel, s) == DragGesture::Internal);
    CHECK(decideGesture(200, 300, kPanel, s) == DragGesture::OsDrag);
}

// No armed samples -> None regardless of position (an empty-payload drag never goes to the
// OS). Not dragging -> None even with samples (the shell asks only mid-drag, but the guard
// is explicit).
static void testNoDragOrNoSamplesIsNone() {
    CHECK(decideGesture(1000, 1000, kPanel, DragState{true, false}) == DragGesture::None);
    CHECK(decideGesture(200, 150, kPanel, DragState{true, false}) == DragGesture::None);
    CHECK(decideGesture(1000, 1000, kPanel, DragState{false, true}) == DragGesture::None);
    CHECK(decideGesture(200, 150, kPanel, DragState{false, false}) == DragGesture::None);
}

// Re-entry: the decision is position-only, so a pointer that left (OsDrag) and came back
// inside reads Internal again. (The shell, having handed off to the modal OS loop, simply
// stops asking — but the pure function must not be stateful.)
static void testReentryReturnsInternal() {
    DragState s{true, true};
    CHECK(decideGesture(500, 150, kPanel, s) == DragGesture::OsDrag);   // left
    CHECK(decideGesture(200, 150, kPanel, s) == DragGesture::Internal); // re-entered
}

// A non-zero panel origin (the client rect need not sit at 0,0) — the boundary tracks the
// rect, not the absolute axes.
static void testOffsetPanelRect() {
    PanelClientRect p{50, 20, 100, 80};   // spans x[50,150) y[20,100)
    DragState s{true, true};
    CHECK(decideGesture(100, 60, p, s) == DragGesture::Internal);
    CHECK(decideGesture(49, 60, p, s) == DragGesture::OsDrag);   // just left of origin
    CHECK(decideGesture(150, 60, p, s) == DragGesture::OsDrag);  // x+width
    CHECK(decideGesture(100, 19, p, s) == DragGesture::OsDrag);  // just above origin
}

// --- S17 InstrumentDrop gesture (single-capture over REAPER UI) ---------------
//
// M11 REGRESSION GUARD (load-bearing): every M11 case above uses DragState{true, true},
// which leaves singleCapture=overReaperUi=false — so an M11-era payload outside the client
// rect still decides OsDrag exactly as before. The tests above ARE the M11 non-regression
// proof; these add the new middle case.

// A SINGLE-capture drag that has left the panel but is still over REAPER's own UI is an
// instrument drop (heading for a track's FX button), NOT an OS drag.
static void testSingleCaptureOverReaperUiIsInstrumentDrop() {
    DragState s{/*dragging=*/true, /*hasArmedSamples=*/true,
                /*singleCapture=*/true, /*overReaperUi=*/true};
    CHECK(decideGesture(500, 150, kPanel, s) == DragGesture::InstrumentDrop);  // right of panel
    CHECK(decideGesture(-5, 150, kPanel, s) == DragGesture::InstrumentDrop);   // left of panel
    CHECK(decideGesture(200, 400, kPanel, s) == DragGesture::InstrumentDrop);  // below
}

// InstrumentDrop is an OUTSIDE-only refinement: the same single-capture state INSIDE the
// client rect is still the unchanged Internal bank-to-bank drag (invariant #4).
static void testSingleCaptureInsidePanelStaysInternal() {
    DragState s{true, true, /*singleCapture=*/true, /*overReaperUi=*/true};
    CHECK(decideGesture(200, 150, kPanel, s) == DragGesture::Internal);
}

// A single-capture drag that has left REAPER ENTIRELY (overReaperUi=false) falls through to
// OsDrag — the M11 OS drag-out to Explorer/another DAW, unchanged. This is the boundary
// refinement's other half: leaving the client rect no longer immediately means OS-bound.
static void testSingleCaptureOffReaperIsOsDrag() {
    DragState s{true, true, /*singleCapture=*/true, /*overReaperUi=*/false};
    CHECK(decideGesture(500, 150, kPanel, s) == DragGesture::OsDrag);
}

// A MULTI-capture drag over REAPER's UI is REJECTED for InstrumentDrop (the S17 open-question
// lean): it is NOT a single instrument placement, so it falls through to OsDrag even while
// over REAPER's UI — the multi-file drag-out is the natural gesture for a multi payload.
static void testMultiCaptureOverReaperUiIsOsDrag() {
    DragState s{true, true, /*singleCapture=*/false, /*overReaperUi=*/true};
    CHECK(decideGesture(500, 150, kPanel, s) == DragGesture::OsDrag);
}

// Not-dragging / no-armed-samples still short-circuits to None regardless of the S17 fields.
static void testS17FieldsIgnoredWhenNotDragging() {
    CHECK(decideGesture(500, 150, kPanel,
                        DragState{false, true, true, true}) == DragGesture::None);
    CHECK(decideGesture(500, 150, kPanel,
                        DragState{true, false, true, true}) == DragGesture::None);
}

// --- Path-list assembly -------------------------------------------------------

static ResolvedSample ok(const std::string& p) { return ResolvedSample{p, true}; }
static ResolvedSample missing(const std::string& p) { return ResolvedSample{p, false}; }
static ResolvedSample unresolved() { return ResolvedSample{"", false}; }

static void testSinglePath() {
    PathList l = assemblePathList({ok("C:/proj/bank/a.wav")});
    CHECK(l.paths.size() == 1);
    CHECK(l.paths[0] == "C:/proj/bank/a.wav");
    CHECK(l.skippedMissing == 0);
    CHECK(l.skippedUnresolved == 0);
    CHECK(l.skippedDuplicate == 0);
}

// Multiple distinct paths pass through in selection order (order preserved).
static void testMultiPreservesOrder() {
    PathList l = assemblePathList({ok("b.wav"), ok("a.wav"), ok("c.wav")});
    CHECK(l.paths.size() == 3);
    CHECK(l.paths[0] == "b.wav");
    CHECK(l.paths[1] == "a.wav");
    CHECK(l.paths[2] == "c.wav");
}

// Two index entries resolving to the SAME file (the cross-bank copy case — one file, two
// entries) yield ONE CF_HDROP path; the extra is counted, first occurrence wins.
static void testDedupeSamePath() {
    PathList l = assemblePathList({ok("x.wav"), ok("y.wav"), ok("x.wav")});
    CHECK(l.paths.size() == 2);
    CHECK(l.paths[0] == "x.wav");
    CHECK(l.paths[1] == "y.wav");
    CHECK(l.skippedDuplicate == 1);
}

// A stale index entry (file gone from disk) is skipped — never a dangling path on the OS
// clipboard.
static void testSkipMissing() {
    PathList l = assemblePathList({ok("a.wav"), missing("gone.wav"), ok("b.wav")});
    CHECK(l.paths.size() == 2);
    CHECK(l.paths[0] == "a.wav");
    CHECK(l.paths[1] == "b.wav");
    CHECK(l.skippedMissing == 1);
}

// An unresolvable entry (empty path — no project dir / empty relative) is skipped; note an
// empty path is skippedUnresolved, NOT skippedMissing, even though fileExists is false.
static void testSkipUnresolved() {
    PathList l = assemblePathList({ok("a.wav"), unresolved(), ok("b.wav")});
    CHECK(l.paths.size() == 2);
    CHECK(l.skippedUnresolved == 1);
    CHECK(l.skippedMissing == 0);
}

// Empty selection -> empty list, all tallies zero (the shell reads paths.empty() and does
// not start a drag).
static void testEmptySelection() {
    PathList l = assemblePathList({});
    CHECK(l.paths.empty());
    CHECK(l.skippedMissing == 0);
    CHECK(l.skippedUnresolved == 0);
    CHECK(l.skippedDuplicate == 0);
}

// All-skipped selection -> empty list with the right tallies (nothing draggable).
static void testAllSkippedYieldsEmpty() {
    PathList l = assemblePathList({missing("g1.wav"), unresolved(), missing("g2.wav")});
    CHECK(l.paths.empty());
    CHECK(l.skippedMissing == 2);
    CHECK(l.skippedUnresolved == 1);
}

// Mixed: every skip class at once, plus a survivor, with independent tallies.
static void testMixedTallies() {
    PathList l = assemblePathList({
        ok("keep.wav"),        // survives
        missing("gone.wav"),   // skippedMissing
        unresolved(),          // skippedUnresolved
        ok("keep.wav"),        // skippedDuplicate
        ok("also.wav"),        // survives
    });
    CHECK(l.paths.size() == 2);
    CHECK(l.paths[0] == "keep.wav");
    CHECK(l.paths[1] == "also.wav");
    CHECK(l.skippedMissing == 1);
    CHECK(l.skippedUnresolved == 1);
    CHECK(l.skippedDuplicate == 1);
}

// --- OS hand-off ordering -----------------------------------------------------
//
// The empty-payload teardown regression: the shell used to wind its internal drag down
// (release capture, clear drag state) BEFORE checking whether the payload had resolved to any
// on-disk file. For an unresolvable payload, initiateDragOut is never reached — no OS drop
// happens at all — but the teardown ran anyway, so the drag just silently stopped mid-gesture
// with no highlight and no drop. The user has to press and start an entirely new drag; retrying
// the SAME stale selection resolves to the same empty payload and fails identically. These pin
// the fix: the two side effects (teardown, hand-off) are one decision.

// Nothing draggable -> hand off nothing AND keep the internal drag alive.
static void testEmptyPathsHandsOffNothingAndKeepsInternalDrag() {
    const OsHandoff h = decideOsHandoff({});
    CHECK(!h.handOffToOs);
}

// A resolvable payload -> hand off (release the internal drag, then start the OS drag).
static void testResolvablePayloadHandsOff() {
    const OsHandoff one = decideOsHandoff({"C:/proj/bank/a.wav"});
    CHECK(one.handOffToOs);
    const OsHandoff many = decideOsHandoff({"a.wav", "b.wav", "c.wav"});
    CHECK(many.handOffToOs);
}

// End to end through the assembler: a selection whose every entry is stale/unresolvable
// yields the keep-the-drag verdict, which is exactly the case the shell used to mishandle.
static void testAllSkippedSelectionKeepsInternalDrag() {
    const PathList l = assemblePathList({missing("g1.wav"), unresolved()});
    const OsHandoff h = decideOsHandoff(l.paths);
    CHECK(!h.handOffToOs);
}

int main() {
    testInsidePanelStaysInternal();
    testLeavingClientAreaIsOsDrag();
    testBoundaryHalfOpen();
    testNoDragOrNoSamplesIsNone();
    testReentryReturnsInternal();
    testOffsetPanelRect();

    testSingleCaptureOverReaperUiIsInstrumentDrop();
    testSingleCaptureInsidePanelStaysInternal();
    testSingleCaptureOffReaperIsOsDrag();
    testMultiCaptureOverReaperUiIsOsDrag();
    testS17FieldsIgnoredWhenNotDragging();

    testSinglePath();
    testMultiPreservesOrder();
    testDedupeSamePath();
    testSkipMissing();
    testSkipUnresolved();
    testEmptySelection();
    testAllSkippedYieldsEmpty();
    testMixedTallies();

    testEmptyPathsHandsOffNothingAndKeepsInternalDrag();
    testResolvablePayloadHandsOff();
    testAllSkippedSelectionKeepsInternalDrag();

    if (g_fail == 0) std::printf("All tests passed.\n");
    return g_fail ? 1 : 0;
}
