// Standalone tests for reasampler::capture_paths — no REAPER, no framework.
// The capture shell is DAW-bound and only verifiable in REAPER; this covers the
// one genuinely pure piece: the bank-folder / unique-name / project-relative
// path arithmetic that feeds BankModel::add's relative-only invariant.

#include "../src/core/capture/capture_paths.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)

static void testNormalizeSlashes() {
#ifdef _WIN32
    // On Windows paths are lowercased for case-insensitive comparison.
    CHECK(normalizeSlashes("C:\\a\\b") == "c:/a/b");
    CHECK(normalizeSlashes("a/b/c") == "a/b/c");
    CHECK(normalizeSlashes("a/b/") == "a/b");        // trailing slash stripped
    CHECK(normalizeSlashes("a\\b\\") == "a/b");       // backslash + trailing
    CHECK(normalizeSlashes("/") == "/");              // lone root preserved
    CHECK(normalizeSlashes("") == "");                // empty stays empty
#else
    CHECK(normalizeSlashes("C:\\a\\b") == "C:/a/b");
    CHECK(normalizeSlashes("a/b/c") == "a/b/c");
    CHECK(normalizeSlashes("a/b/") == "a/b");
    CHECK(normalizeSlashes("a\\b\\") == "a/b");
    CHECK(normalizeSlashes("/") == "/");
    CHECK(normalizeSlashes("") == "");
#endif
}

// Windows case-folding: paths differing only in casing must compare equal after
// normalizeSlashes, since Windows paths are case-insensitive. On non-Windows the
// function is case-preserving (filesystem is case-sensitive).
static void testNormalizeSlashesCaseFolding() {
#ifdef _WIN32
    // Drive letter and component casing differences are neutralized.
    CHECK(normalizeSlashes("C:/Foo/BAR.wav") == normalizeSlashes("c:/foo/bar.wav"));
    CHECK(normalizeSlashes("C:/Foo/BAR.wav") == "c:/foo/bar.wav");
    // Mixed-case input produces consistently lowercase output.
    CHECK(normalizeSlashes("C:\\Users\\Daniel\\Proj\\File.WAV")
          == "c:/users/daniel/proj/file.wav");
#else
    // Non-Windows: case is preserved exactly (case-sensitive filesystem).
    CHECK(normalizeSlashes("C:/Foo/BAR.wav") != normalizeSlashes("c:/foo/bar.wav"));
    CHECK(normalizeSlashes("C:/Foo/BAR.wav") == "C:/Foo/BAR.wav");
#endif
}

static void testSanitizeStem() {
    // Safe characters survive verbatim.
    CHECK(sanitizeStem("Kick_01.take-2") == "Kick_01.take-2");
    // Spaces, slashes, quotes, control chars become '_'.
    CHECK(sanitizeStem("my mix") == "my_mix");
    CHECK(sanitizeStem("a/b\\c") == "a_b_c");
    CHECK(sanitizeStem("q\"uote") == "q_uote");
    CHECK(sanitizeStem(std::string("nul\0byte", 8)) == "nul_byte");
    // Nothing usable -> stable default.
    CHECK(sanitizeStem("") == "capture");
    CHECK(sanitizeStem("   ") == "capture");
    // All-separator (no alphanumeric) -> default, so the name is meaningful.
    CHECK(sanitizeStem("...") == "capture");
    CHECK(sanitizeStem("-_-") == "capture");
}

static void testDeriveRelativePathIsProjectRelative() {
    BankPaths p = deriveBankPaths("C:\\Users\\d\\proj", "master mix", "1753080000");
    // Relative path is under the fixed bank subfolder, forward-slashed, .wav.
    CHECK(p.relativePath == "reasampler_bank/master_mix_1753080000.wav");
    // It must NOT be absolute by any of BankModel::add's rejection rules:
    // no leading '/', no drive letter, no backslash, no UNC prefix.
    CHECK(p.relativePath.find(':') == std::string::npos);
    CHECK(p.relativePath.find('\\') == std::string::npos);
    CHECK(!p.relativePath.empty() && p.relativePath[0] != '/');
    CHECK(p.relativePath.rfind("\\\\", 0) != 0);
}

static void testDeriveAbsoluteDirJoinsProjectDir() {
    BankPaths p = deriveBankPaths("C:\\Users\\d\\proj", "kick", "");
    // Backslashes normalized; bank subfolder appended; no trailing slash.
    // On Windows the drive-letter + components are lowercased by normalizeSlashes.
#ifdef _WIN32
    CHECK(p.absoluteDir == "c:/users/d/proj/reasampler_bank");
#else
    CHECK(p.absoluteDir == "C:/Users/d/proj/reasampler_bank");
#endif
    // No unique tag -> stem has no trailing "_".
    CHECK(p.fileName == "kick.wav");
    CHECK(p.relativePath == "reasampler_bank/kick.wav");
}

static void testDeriveHandlesTrailingSlashProjectDir() {
    // A project dir with a trailing slash must not double up in the join.
    BankPaths p = deriveBankPaths("/home/d/proj/", "mix", "7");
    CHECK(p.absoluteDir == "/home/d/proj/reasampler_bank");
    CHECK(p.fileName == "mix_7.wav");
}

static void testDeriveEmptyProjectDirIsRejected() {
    // Precondition: deriveBankPaths requires a non-empty projectDir.
    // In debug builds the assert(!dir.empty()) fires immediately and aborts
    // the process — that IS the check, so we don't call into it there.
    // In release/NDEBUG builds the assert is elided; we verify the fallback
    // contract: absoluteDir is left empty (not a bare "reasampler_bank") so
    // any caller that ignores the precondition fails loudly at the render/stat
    // step rather than silently writing to CWD.
#ifdef NDEBUG
    BankPaths p = deriveBankPaths("", "mix", "");
    CHECK(p.absoluteDir.empty());
    CHECK(p.relativePath == "reasampler_bank/mix.wav");
#endif
    // Debug: assert fires on the call above — contract verified by the crash.
}

static void testDeterministicForSameInputs() {
    // Same inputs -> same derived paths (feeds deterministic file naming).
    BankPaths a = deriveBankPaths("C:/p", "mix", "42");
    BankPaths b = deriveBankPaths("C:/p", "mix", "42");
    CHECK(a.absoluteDir == b.absoluteDir);
    CHECK(a.relativePath == b.relativePath);
    CHECK(a.fileName == b.fileName);
}

static void testFileStem() {
    // fileStem is the stem component of fileName (no extension). The capture
    // backend passes fileStem directly to RENDER_PATTERN because REAPER appends
    // the format extension itself — the backend must not re-derive or re-strip it.
    BankPaths p = deriveBankPaths("C:/p", "master mix", "123");
    CHECK(p.fileStem == "master_mix_123");
    CHECK(p.fileName == "master_mix_123.wav");
    // fileStem + ".wav" must equal fileName (the invariant the backend relies on).
    CHECK(p.fileStem + ".wav" == p.fileName);

    // No tag: stem only.
    BankPaths q = deriveBankPaths("C:/p", "kick", "");
    CHECK(q.fileStem == "kick");
    CHECK(q.fileName == "kick.wav");
    CHECK(q.fileStem + ".wav" == q.fileName);
}

// --- Persist-side path arithmetic (M4) --------------------------------------

static void testResolveBankFileAgainstProjectDir() {
    // A relative index entry resolves to <projectDir>/<relativePath>, forward-
    // slashed, regardless of the input slash style. On Windows the result is also
    // lowercased (Windows paths are case-insensitive; normalizeSlashes folds them).
#ifdef _WIN32
    CHECK(resolveBankFile("C:\\Users\\d\\proj", "reasampler_bank/kick.wav")
          == "c:/users/d/proj/reasampler_bank/kick.wav");
    // Backslashes in the stored relative path are normalized on resolution.
    CHECK(resolveBankFile("C:/p", "reasampler_bank\\a.wav")
          == "c:/p/reasampler_bank/a.wav");
#else
    CHECK(resolveBankFile("C:\\Users\\d\\proj", "reasampler_bank/kick.wav")
          == "C:/Users/d/proj/reasampler_bank/kick.wav");
    CHECK(resolveBankFile("/home/d/proj", "reasampler_bank/mix.wav")
          == "/home/d/proj/reasampler_bank/mix.wav");
    // Trailing slash on the project dir must not double up.
    CHECK(resolveBankFile("/home/d/proj/", "reasampler_bank/mix.wav")
          == "/home/d/proj/reasampler_bank/mix.wav");
    // Backslashes in the stored relative path are normalized on resolution.
    CHECK(resolveBankFile("/p", "reasampler_bank\\a.wav")
          == "/p/reasampler_bank/a.wav");
#endif
}

static void testResolveBankFileRejectsEmptyInputs() {
    // No default-location fallback (CLAUDE.md invariant): empty project dir or
    // empty relative path yields empty, never a bare relative resolved to CWD.
    CHECK(resolveBankFile("", "reasampler_bank/kick.wav").empty());
    CHECK(resolveBankFile("C:/p", "").empty());
    CHECK(resolveBankFile("", "").empty());
}

static void testResolveIsInverseOfDerive() {
    // The path a capture stored (relativePath) resolves back to the same file the
    // capture wrote (absoluteDir/fileName) when resolved against the SAME project
    // dir. This is the round-trip persist relies on.
    const std::string projectDir = "C:/Users/d/proj";
    BankPaths p = deriveBankPaths(projectDir, "master mix", "1753080000");
    const std::string absoluteFile = p.absoluteDir + "/" + p.fileName;
    CHECK(resolveBankFile(projectDir, p.relativePath) == absoluteFile);
}

static void testResolveAgainstNewProjectDirAfterSaveAs() {
    // The Save-As guarantee: the SAME stored relative path, resolved against a
    // NEW project dir, points into the new project's bank. The index does not
    // need rewriting — resolution against the current dir does the work.
    BankPaths p = deriveBankPaths("/old/proj", "kick", "7");
    CHECK(resolveBankFile("/new/place/proj", p.relativePath)
          == "/new/place/proj/reasampler_bank/kick_7.wav");
}

static void testRelocationPlanForSaveAs() {
    // Save-As to a different directory: relocation is needed; both bank dirs are
    // <projectDir>/reasampler_bank, forward-slashed, no trailing slash.
    // On Windows the drive-letter and path components are lowercased.
    BankRelocation r = deriveRelocationPlan("C:\\old\\proj", "C:/new/proj");
    CHECK(r.needed);
#ifdef _WIN32
    CHECK(r.oldBankDir == "c:/old/proj/reasampler_bank");
    CHECK(r.newBankDir == "c:/new/proj/reasampler_bank");
#else
    CHECK(r.oldBankDir == "C:/old/proj/reasampler_bank");
    CHECK(r.newBankDir == "C:/new/proj/reasampler_bank");
#endif
}

static void testRelocationPlanNotNeededForSaveInPlace() {
    // Save in place (same dir, any slash style) -> no relocation.
    BankRelocation r = deriveRelocationPlan("/home/d/proj", "/home/d/proj/");
    CHECK(!r.needed);
    // Dirs still computed (harmless), but needed=false is the load-bearing bit.
    CHECK(r.oldBankDir == "/home/d/proj/reasampler_bank");
    CHECK(r.newBankDir == "/home/d/proj/reasampler_bank");
}

static void testRelocationPlanEmptyInputsNoOp() {
    // First-ever save (no old dir) or missing new dir -> nothing to relocate.
    CHECK(!deriveRelocationPlan("", "/new/proj").needed);
    CHECK(!deriveRelocationPlan("/old/proj", "").needed);
    CHECK(!deriveRelocationPlan("", "").needed);
}

// --- Project-identity transition (W12 combined identity fix) ----------------
//
// Signature: classifyProjectTransition(sameProjectObject, lastGuid, lastPath,
//                                       currentGuid, currentPath).
// Identity is layered GUID-PRIMARY: the stored GUID (identity of record) leads;
// the pointer only disambiguates the same-GUID case. poll() computes
// sameProjectObject as `proj == lastProject_`. This matrix covers every branch —
// the classifier has regressed twice, so every case is pinned.

static void testTransitionRecycledPointerReopenDifferentProjectLoads() {
    // THE W12 REGRESSION. REAPER recycled the previous project's ReaProject* address
    // for a DIFFERENT reopened saved project, so sameProjectObject == true, but the
    // reopened project carries its OWN (different, non-empty) stored GUID. The old
    // pointer-primary classifier decided on path alone and returned NoOp (same path)
    // or SaveAsRelocate (new path) — the bank never reloaded. GUID-first makes this
    // a Load regardless of path.
    CHECK(classifyProjectTransition(/*sameProjectObject=*/true,
                                    "guidA", "/a/a.rpp", "guidB", "/b/b.rpp")
          == ProjectTransition::Load);
    // Same recycled-address regression, but the reopened project happens to sit at
    // the SAME path as the one we left (old code returned NoOp here). Still a Load.
    CHECK(classifyProjectTransition(/*sameProjectObject=*/true,
                                    "guidA", "/a/a.rpp", "guidB", "/a/a.rpp")
          == ProjectTransition::Load);
}

static void testTransitionOpenNewUnsavedFromSavedLoads() {
    // Open a new/unsaved project from a saved one, recycled onto the same address
    // (sameProjectObject == true): currentGuid empty, lastGuid non-empty -> the
    // record identity differs -> Load (so the bank clears to the new empty project).
    CHECK(classifyProjectTransition(/*sameProjectObject=*/true,
                                    "guidA", "/a/a.rpp", "", "")
          == ProjectTransition::Load);
}

static void testTransitionForkTabSwitchLoadsNeverRelocates() {
    // THE W10 CASE — must stay fixed. proj2 and proj3 are forked siblings (Save-As
    // copied the .rpp incl. our GUID), so BOTH carry the same non-empty GUID on disk
    // but sit at different paths. Tab-switching between them is a DIFFERENT project
    // object (sameProjectObject == false). Same GUID -> step 1 falls through; step 2
    // (!sameProjectObject) -> Load, so neither bank is ever relocated.
    CHECK(classifyProjectTransition(/*sameProjectObject=*/false,
                                    "guidShared", "/proj2/p.rpp",
                                    "guidShared", "/proj3/p.rpp")
          == ProjectTransition::Load);
    // Switching back the other way is likewise a different object -> Load.
    CHECK(classifyProjectTransition(/*sameProjectObject=*/false,
                                    "guidShared", "/proj3/p.rpp",
                                    "guidShared", "/proj2/p.rpp")
          == ProjectTransition::Load);
}

static void testTransitionGenuineSaveAsRelocates() {
    // The SAME project object (pointer unchanged) AND same GUID saved to a new .rpp
    // location -> the one case that legitimately relocates the bank (step 3).
    CHECK(classifyProjectTransition(/*sameProjectObject=*/true,
                                    "guidA", "/a/a.rpp", "guidA", "/b/b.rpp")
          == ProjectTransition::SaveAsRelocate);
}

static void testTransitionReopenSameProjectRecycledSameAddrIsNoOp() {
    // Reopen the SAME project, recycled onto the same address: same object, same
    // (non-empty) GUID, same path -> nothing changed -> NoOp (step 4).
    CHECK(classifyProjectTransition(/*sameProjectObject=*/true,
                                    "guidA", "/a/a.rpp", "guidA", "/a/a.rpp")
          == ProjectTransition::NoOp);
}

static void testTransitionTwoDistinctSavedProjectsDistinctPointersLoad() {
    // Ordinary tab-switch between two distinct (non-forked) saved projects: distinct
    // pointers, different GUIDs -> step 1 (GUID differs) -> Load, regardless of paths.
    CHECK(classifyProjectTransition(/*sameProjectObject=*/false,
                                    "guidA", "/a/a.rpp", "guidB", "/b/b.rpp")
          == ProjectTransition::Load);
    CHECK(classifyProjectTransition(/*sameProjectObject=*/false,
                                    "guidB", "/b/b.rpp", "guidA", "/a/a.rpp")
          == ProjectTransition::Load);
}

static void testTransitionTwoUnsavedProjectsSwitchLoads() {
    // Switch between two unsaved projects: both GUIDs empty (step 1 falls through:
    // equal), distinct objects -> step 2 (!sameProjectObject) -> Load. Installs the
    // right in-memory (empty) state for whichever unsaved project is now active.
    CHECK(classifyProjectTransition(/*sameProjectObject=*/false,
                                    "", "", "", "")
          == ProjectTransition::Load);
    // Distinct unsaved objects may even report distinct (untitled) paths -> Load.
    CHECK(classifyProjectTransition(/*sameProjectObject=*/false,
                                    "", "/untitled1", "", "/untitled2")
          == ProjectTransition::Load);
}

static void testTransitionFirstSaveOfUnsavedRelocatesButPlanNoOps() {
    // First save of an unsaved project: same object, both GUIDs empty (step 1 & 2
    // fall through), path appears (step 3) -> SaveAsRelocate. The empty-GUID safety
    // is preserved at execution: the old project dir is empty, so deriveRelocation-
    // Plan makes `needed` false and NOTHING is physically relocated; poll() mints a
    // GUID.
    CHECK(classifyProjectTransition(/*sameProjectObject=*/true,
                                    "", "", "", "/a/a.rpp")
          == ProjectTransition::SaveAsRelocate);
    // Prove the safety end-to-end: the relocation plan for an empty old dir no-ops.
    CHECK(deriveRelocationPlan(/*oldProjectDir=*/"", "/a").needed == false);
}

static void testTransitionInPlaceSaveIsNoOp() {
    // In-place save (or an idle tick): same object, same GUID, same path -> NoOp.
    CHECK(classifyProjectTransition(/*sameProjectObject=*/true,
                                    "guidA", "/a/a.rpp", "guidA", "/a/a.rpp")
          == ProjectTransition::NoOp);
    // Idle unsaved project (same object, both empty GUID, same empty path) -> NoOp.
    CHECK(classifyProjectTransition(/*sameProjectObject=*/true, "", "", "", "")
          == ProjectTransition::NoOp);
}

// NOTE (Q-W3, audit §4e): the hashBytes / hashWavContent tests moved to
// tests/test_wav_codec.cpp with the implementations — capture_paths is now path
// arithmetic only, with no content-hash / RIFF knowledge.

// --- bankRelativeForName spelling consistency (Phase R, R2) -----------------
//
// The safety-critical property: the relative spelling the prune shell derives for an
// ENUMERATED folder entry (bankRelativeForName) must be byte-identical to the spelling
// the capture path stored in the index (deriveBankPaths().relativePath) for the same
// file name. A divergence here could make a referenced file look like an orphan.

static void testBankRelativeForNameMatchesDerivePathSpelling() {
    // For a file the capture path created, deriveBankPaths produced relativePath;
    // a directory listing yields the bare file name. bankRelativeForName(name) must
    // reproduce the SAME string, or the pure core's exact-string match misfires.
    const BankPaths p = deriveBankPaths("/proj", "kick", "001");
    // p.fileName is the on-disk entry name a folder enumeration would return.
    CHECK(bankRelativeForName(p.fileName) == p.relativePath);
}

// --- deriveRenderPaths ------------------------------------------------------

static void testRenderPathsSpellTheStemExactlyAsTheBankPathDoes() {
    // The one owner claim, made checkable: for the same baseName + uniqueTag, the
    // bank path's stem and file name must BE the render path's. If these ever
    // diverge, bankRelativeForName's exact-string match against an enumerated
    // folder entry starts misfiring and prune misreads referenced files as orphans.
    const BankPaths  bank   = deriveBankPaths("/proj", "kick drum!", "001");
    const RenderPaths render = deriveRenderPaths("/proj/reasampler_bank",
                                                 "kick drum!", "001");
    CHECK(render.fileStem == bank.fileStem);
    CHECK(render.fileName == bank.fileName);
    CHECK(render.absoluteDir == bank.absoluteDir);
}

static void testRenderPathsTakeTheirDirectoryVerbatim() {
    // No bank subfolder is appended — a render outside the bank has none, which is
    // what makes "write into the bank folder" inexpressible through this call.
    const RenderPaths r = deriveRenderPaths("/proj/media/", "take", "");
    CHECK(r.absoluteDir == normalizeSlashes("/proj/media"));
    CHECK(r.fileName == "take.wav");
    CHECK(r.fileStem == "take");
    // Backslashes normalize and a trailing slash is stripped, same as everywhere.
    CHECK(deriveRenderPaths("C:\\proj\\media\\", "take", "").absoluteDir ==
          normalizeSlashes("C:/proj/media"));
}

static void testRenderPathsEmptyDirectoryStaysEmpty() {
    // No CWD fallback: an unresolvable directory must fail at the caller's own
    // guard, never silently render next to whatever the process happened to be in.
    CHECK(deriveRenderPaths("", "take", "001").absoluteDir.empty());
}

static void testBankRelativeForNameConventionAndEdge() {
    // The convention verbatim: "reasampler_bank/<name>" (the one place the spelling lives).
    CHECK(bankRelativeForName("a.wav") == "reasampler_bank/a.wav");
    // Empty in -> empty out (a defensive guard; a real enumeration never yields "").
    CHECK(bankRelativeForName("").empty());
}

int main() {
    testNormalizeSlashes();
    testNormalizeSlashesCaseFolding();
    testSanitizeStem();
    testDeriveRelativePathIsProjectRelative();
    testDeriveAbsoluteDirJoinsProjectDir();
    testDeriveHandlesTrailingSlashProjectDir();
    testDeriveEmptyProjectDirIsRejected();
    testDeterministicForSameInputs();
    testFileStem();
    testResolveBankFileAgainstProjectDir();
    testResolveBankFileRejectsEmptyInputs();
    testResolveIsInverseOfDerive();
    testResolveAgainstNewProjectDirAfterSaveAs();
    testRelocationPlanForSaveAs();
    testRelocationPlanNotNeededForSaveInPlace();
    testRelocationPlanEmptyInputsNoOp();
    testTransitionRecycledPointerReopenDifferentProjectLoads();
    testTransitionOpenNewUnsavedFromSavedLoads();
    testTransitionForkTabSwitchLoadsNeverRelocates();
    testTransitionGenuineSaveAsRelocates();
    testTransitionReopenSameProjectRecycledSameAddrIsNoOp();
    testTransitionTwoDistinctSavedProjectsDistinctPointersLoad();
    testTransitionTwoUnsavedProjectsSwitchLoads();
    testTransitionFirstSaveOfUnsavedRelocatesButPlanNoOps();
    testTransitionInPlaceSaveIsNoOp();
    testBankRelativeForNameMatchesDerivePathSpelling();
    testRenderPathsSpellTheStemExactlyAsTheBankPathDoes();
    testRenderPathsTakeTheirDirectoryVerbatim();
    testRenderPathsEmptyDirectoryStaysEmpty();
    testBankRelativeForNameConventionAndEdge();

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