// Standalone tests for reasampler::pruneOrphans (Phase R, Wave 1) and the additive
// BankBook::referencedPaths() union query it consumes — no REAPER, no framework.
//
// The safety-critical computation: orphans = (owned ∩ present) − referenced. Every
// test below would FAIL if the set algebra were wrong (a missing ∩ present, a missing
// − referenced, or a non-union referenced-set) — see the negative assertions.
//
// Covers (brief-named):
//   1. Prune null test: all present files referenced -> empty orphan set.
//   2. Formula: orphans = (owned ∩ present) − referenced, mixed populations.
//   3. Copied file referenced by a SECOND bank survives (union semantics).
//   4. Present-but-unowned (hand-dropped) file is never reclaimed.
//   5. Edge cases: empty folder, empty book/referenced-set, empty manifest.
//   6. Owned-but-absent file (manifest entry, no file on disk) -> no orphan, no error.
// Plus: determinism (present-order output), duplicate-`present` de-dup, exact-string
// (non-normalizing) match, and the referencedPaths() union query directly.

#include "../src/core/model/bank_book.h"
#include "../src/core/reclaim/prune_reconcile.h"

#include <algorithm>
#include <cstdint>
#include <cstdio>
#include <string>
#include <unordered_map>
#include <vector>

using namespace reasampler;
using namespace reasampler::model;
using namespace reasampler::reclaim;

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

// --- helpers -----------------------------------------------------------------

static bool contains(const std::vector<std::string>& v, const std::string& s) {
    return std::find(v.begin(), v.end(), s) != v.end();
}

// A minimal Sample carrying just the fields prune cares about (id + relativePath).
static Sample sampleAt(const std::string& id, const std::string& relPath,
                       const std::string& hash = "") {
    Sample s;
    s.id = id;
    s.relativePath = relPath;
    s.contentHash = hash;
    return s;
}

// --- 1. prune null test: all present referenced -> empty --------------------

static void testNullTestAllReferenced() {
    const std::vector<std::string> present   = {"bank/a.wav", "bank/b.wav", "bank/c.wav"};
    const std::vector<std::string> owned     = {"bank/a.wav", "bank/b.wav", "bank/c.wav"};
    const std::vector<std::string> referenced= {"bank/a.wav", "bank/b.wav", "bank/c.wav"};

    const auto orphans = pruneOrphans(present, referenced, owned);
    // The core invariant: a folder whose every file is referenced deletes NOTHING.
    CHECK(orphans.empty());
}

// --- 2. formula: (owned ∩ present) − referenced, mixed populations ----------

static void testFormulaMixedPopulations() {
    // present : a b c d      (on disk)
    // owned   : a b   d e    (system created; e is owned-but-absent)
    // ref'd   : a            (still in an index)
    // expected orphans: owned ∩ present = {a,b,d}; minus referenced {a} = {b,d}
    //   - c present but NOT owned  -> excluded (hand-dropped)
    //   - e owned but NOT present  -> excluded (no file on disk)
    //   - a owned+present but referenced -> excluded
    const std::vector<std::string> present    = {"bank/a.wav", "bank/b.wav",
                                                 "bank/c.wav", "bank/d.wav"};
    const std::vector<std::string> owned      = {"bank/a.wav", "bank/b.wav",
                                                 "bank/d.wav", "bank/e.wav"};
    const std::vector<std::string> referenced = {"bank/a.wav"};

    const auto orphans = pruneOrphans(present, referenced, owned);
    CHECK(orphans.size() == 2);
    CHECK(contains(orphans, "bank/b.wav"));
    CHECK(contains(orphans, "bank/d.wav"));
    // Negative assertions — each would fire if a clause of the formula were dropped:
    CHECK(!contains(orphans, "bank/a.wav"));  // − referenced
    CHECK(!contains(orphans, "bank/c.wav"));  // ∩ owned (hand-dropped)
    CHECK(!contains(orphans, "bank/e.wav"));  // ∩ present (absent)
}

// --- 3. copied file referenced by a second bank survives (union) ------------

static void testCopiedFileSurvivesViaUnion() {
    // A file present + owned, whose ONLY index reference lives in a second (non-active)
    // bank via a copy. The union across the whole book must keep it out of the orphan
    // set. Built through the real BankBook so referencedPaths()'s union is exercised.
    BankBook book;
    book.createBank("b1", "Drums");
    // Add to the pool, then copy into b1 (same file referenced by two banks).
    book.index(std::string(kPoolBankId))->add(sampleAt("s1", "bank/shared.wav", "h1"));
    CHECK(book.copySample("s1", kPoolBankId, "b1") == TransferResult::Copied);

    const auto referenced = book.referencedPaths();
    // Union de-dups: one path though two banks reference it.
    CHECK(referenced.size() == 1);
    CHECK(referenced[0] == "bank/shared.wav");

    const std::vector<std::string> present = {"bank/shared.wav"};
    const std::vector<std::string> owned   = {"bank/shared.wav"};
    const auto orphans = pruneOrphans(present, referenced, owned);
    // Referenced by b1 (the copy) -> never an orphan even if the pool later dropped it.
    CHECK(orphans.empty());
}

// A sharper union test: remove the file from the pool but keep the copy in b1. The
// file must STILL survive (referenced by b1 alone) — the whole point of union safety.
static void testUnionSurvivesWhenOnlySecondBankReferences() {
    BankBook book;
    book.createBank("b1", "Drums");
    book.index(std::string(kPoolBankId))->add(sampleAt("s1", "bank/shared.wav", "h1"));
    CHECK(book.copySample("s1", kPoolBankId, "b1") == TransferResult::Copied);
    // Drop the pool's reference; b1 still references the file.
    CHECK(book.removeSample("s1", kPoolBankId) == RemoveResult::Removed);

    const auto referenced = book.referencedPaths();
    CHECK(referenced.size() == 1);
    CHECK(contains(referenced, "bank/shared.wav"));

    const auto orphans = pruneOrphans({"bank/shared.wav"}, referenced, {"bank/shared.wav"});
    CHECK(orphans.empty());  // still referenced by b1 -> not reclaimable
}

// --- 4. present-but-unowned (hand-dropped) file never reclaimed -------------

static void testHandDroppedNeverReclaimed() {
    // A file on disk that no index references AND the manifest does not own. It is a
    // hand-dropped file — prune must never reclaim it (ownership attribution, fork R-D).
    const std::vector<std::string> present    = {"bank/user_drop.wav", "bank/ours.wav"};
    const std::vector<std::string> owned      = {"bank/ours.wav"};       // NOT user_drop
    const std::vector<std::string> referenced = {};                      // neither referenced

    const auto orphans = pruneOrphans(present, referenced, owned);
    CHECK(orphans.size() == 1);
    CHECK(contains(orphans, "bank/ours.wav"));       // ours, unreferenced -> orphan
    CHECK(!contains(orphans, "bank/user_drop.wav")); // hand-dropped -> protected
}

// --- 5. edge cases: empty folder / book / manifest --------------------------

static void testEmptyFolder() {
    // No files present -> nothing to reclaim regardless of owned/referenced.
    const auto orphans = pruneOrphans({}, {"bank/a.wav"}, {"bank/a.wav"});
    CHECK(orphans.empty());
}

static void testEmptyReferencedSet() {
    // Empty book (nothing referenced): every present ∩ owned file is an orphan.
    const std::vector<std::string> present = {"bank/a.wav", "bank/b.wav"};
    const std::vector<std::string> owned   = {"bank/a.wav", "bank/b.wav"};
    const auto orphans = pruneOrphans(present, /*referenced*/ {}, owned);
    CHECK(orphans.size() == 2);
    CHECK(contains(orphans, "bank/a.wav"));
    CHECK(contains(orphans, "bank/b.wav"));
}

static void testEmptyManifest() {
    // Empty manifest (owns nothing): nothing is reclaimable even if present+unreferenced.
    const std::vector<std::string> present    = {"bank/a.wav", "bank/b.wav"};
    const std::vector<std::string> referenced = {};
    const auto orphans = pruneOrphans(present, referenced, /*owned*/ {});
    CHECK(orphans.empty());
}

static void testAllEmpty() {
    const auto orphans = pruneOrphans({}, {}, {});
    CHECK(orphans.empty());
}

// --- 6. owned-but-absent file -> no orphan, no error ------------------------

static void testOwnedButAbsentNoOrphan() {
    // A manifest entry whose file is NOT on disk (e.g. deleted out-of-band). It must
    // yield no orphan entry (∩ present excludes it) and no error/crash.
    const std::vector<std::string> present    = {"bank/here.wav"};
    const std::vector<std::string> owned      = {"bank/here.wav", "bank/gone.wav"};
    const std::vector<std::string> referenced = {};
    const auto orphans = pruneOrphans(present, referenced, owned);
    CHECK(orphans.size() == 1);
    CHECK(contains(orphans, "bank/here.wav"));
    CHECK(!contains(orphans, "bank/gone.wav"));  // owned but absent -> not an orphan
}

// --- determinism + duplicate-present + exact-string match -------------------

static void testOutputPreservesPresentOrder() {
    // Output order follows `present` order (deterministic dry-run file list), NOT the
    // owned/referenced order.
    const std::vector<std::string> present    = {"bank/z.wav", "bank/y.wav", "bank/x.wav"};
    const std::vector<std::string> owned      = {"bank/x.wav", "bank/y.wav", "bank/z.wav"};
    const std::vector<std::string> referenced = {};
    const auto orphans = pruneOrphans(present, referenced, owned);
    CHECK(orphans.size() == 3);
    CHECK(orphans[0] == "bank/z.wav");
    CHECK(orphans[1] == "bank/y.wav");
    CHECK(orphans[2] == "bank/x.wav");
}

static void testDuplicatePresentDeduped() {
    // A defensive property: a duplicated `present` spelling appears once in the output.
    const std::vector<std::string> present    = {"bank/a.wav", "bank/a.wav"};
    const std::vector<std::string> owned      = {"bank/a.wav"};
    const auto orphans = pruneOrphans(present, /*referenced*/ {}, owned);
    CHECK(orphans.size() == 1);
    CHECK(orphans[0] == "bank/a.wav");
}

static void testExactStringMatchNotNormalized() {
    // Safety-critical: the core matches VERBATIM (mirror of the model's exact-string
    // convention). A referenced file spelled with backslashes is a DIFFERENT string
    // from the forward-slash present spelling — the core does NOT normalize them to
    // equal. This documents the invariant: consistent spelling is the R2 shell's
    // contract. If the core silently normalized, this would (wrongly) treat the file
    // as referenced and the assertion below would fail.
    const std::vector<std::string> present    = {"bank/a.wav"};
    const std::vector<std::string> owned      = {"bank/a.wav"};
    const std::vector<std::string> referenced = {"bank\\a.wav"};  // different spelling
    const auto orphans = pruneOrphans(present, referenced, owned);
    CHECK(orphans.size() == 1);       // not matched -> still an orphan
    CHECK(orphans[0] == "bank/a.wav");
}

// --- BankBook::referencedPaths() union query directly -----------------------

static void testReferencedPathsUnionAcrossBanksAndPool() {
    BankBook book;
    book.createBank("b1", "Drums");
    book.createBank("b2", "Bass");
    book.index(std::string(kPoolBankId))->add(sampleAt("p1", "bank/pool.wav", "hp"));
    book.index("b1")->add(sampleAt("d1", "bank/drum.wav", "hd"));
    book.index("b2")->add(sampleAt("s1", "bank/bass.wav", "hb"));

    const auto refs = book.referencedPaths();
    // Union includes the pool AND every named bank.
    CHECK(refs.size() == 3);
    CHECK(contains(refs, "bank/pool.wav"));
    CHECK(contains(refs, "bank/drum.wav"));
    CHECK(contains(refs, "bank/bass.wav"));
    // Pool-first ordinal order.
    CHECK(refs[0] == "bank/pool.wav");
}

static void testReferencedPathsEmptyBook() {
    BankBook book;  // pool only, no samples
    CHECK(book.referencedPaths().empty());
}

static void testReferencedPathsSkipsEmptyPath() {
    // A sample with an empty relativePath references no file — it must not appear.
    BankBook book;
    book.index(std::string(kPoolBankId))->add(sampleAt("p1", "", "hp"));
    book.index(std::string(kPoolBankId))->add(sampleAt("p2", "bank/real.wav", "hr"));
    const auto refs = book.referencedPaths();
    CHECK(refs.size() == 1);
    CHECK(refs[0] == "bank/real.wav");
}

// --- buildPruneReport: count / byte-sum / truncation (Phase R, R2) ----------

static void testReportCountSizeAndFullList() {
    // A known orphan set with known sizes -> exact count, exact byte sum, full list
    // (under the cap). Order follows the orphan input order (deterministic).
    const std::vector<std::string> orphans = {"bank/a.wav", "bank/b.wav", "bank/c.wav"};
    const std::unordered_map<std::string, std::uint64_t> sizes = {
        {"bank/a.wav", 100}, {"bank/b.wav", 250}, {"bank/c.wav", 50}};
    const PruneReport r = buildPruneReport(orphans, sizes, /*displayCap=*/64);
    CHECK(r.count == 3);
    CHECK(r.totalBytes == 400);         // 100 + 250 + 50 — would fail if sizes mis-summed
    CHECK(r.orphans.size() == 3);
    CHECK(!r.truncated);
    CHECK(r.orphans[0] == "bank/a.wav");  // input order preserved
    CHECK(r.orphans[1] == "bank/b.wav");
    CHECK(r.orphans[2] == "bank/c.wav");
}

static void testReportMissingSizeCountsZeroNotDropped() {
    // An orphan with no stat'd size contributes 0 to the sum but is STILL listed/counted.
    const std::vector<std::string> orphans = {"bank/a.wav", "bank/nosize.wav"};
    const std::unordered_map<std::string, std::uint64_t> sizes = {{"bank/a.wav", 10}};
    const PruneReport r = buildPruneReport(orphans, sizes, 64);
    CHECK(r.count == 2);            // both counted (missing size must not drop the orphan)
    CHECK(r.totalBytes == 10);     // nosize contributes 0
    CHECK(r.orphans.size() == 2);
}

static void testReportTruncatesListButKeepsExactCountAndSize() {
    // More orphans than the display cap: list clips to the cap, but count and byte sum
    // stay EXACT over the whole set, and truncated flags the clip.
    std::vector<std::string> orphans;
    std::unordered_map<std::string, std::uint64_t> sizes;
    for (int i = 0; i < 10; ++i) {
        const std::string p = "bank/f" + std::to_string(i) + ".wav";
        orphans.push_back(p);
        sizes[p] = 5;  // 10 files * 5 bytes = 50 total
    }
    const PruneReport r = buildPruneReport(orphans, sizes, /*displayCap=*/3);
    CHECK(r.count == 10);            // exact, not clipped
    CHECK(r.totalBytes == 50);       // summed over ALL 10, not just the shown 3
    CHECK(r.orphans.size() == 3);    // list clipped to the cap
    CHECK(r.truncated);
    CHECK(r.orphans[0] == "bank/f0.wav");  // first-N in input order
}

static void testReportUncappedWhenCapZero() {
    // displayCap == 0 means "no cap": the whole list is emitted, truncated stays false.
    const std::vector<std::string> orphans = {"bank/a.wav", "bank/b.wav"};
    const PruneReport r = buildPruneReport(orphans, /*sizes*/ {}, /*displayCap=*/0);
    CHECK(r.count == 2);
    CHECK(r.orphans.size() == 2);
    CHECK(!r.truncated);
}

static void testReportEmptyOrphanSet() {
    const PruneReport r = buildPruneReport({}, {}, 64);
    CHECK(r.count == 0);
    CHECK(r.totalBytes == 0);
    CHECK(r.orphans.empty());
    CHECK(!r.truncated);
}

// --- pruneDeletePlan (R3 staleness guard) ------------------------------------
//
// plan = confirmed ∩ freshOrphans, in confirmed order. Guards BOTH directions so
// "what was shown is what is deleted" holds after a recompute at delete time.

// No change between confirm and delete: the plan is the confirmed set exactly, in order.
static void testDeletePlanStableEqualsConfirmed() {
    const std::vector<std::string> confirmed{"b/a.wav", "b/b.wav", "b/c.wav"};
    const std::vector<std::string> fresh{"b/a.wav", "b/b.wav", "b/c.wav"};
    const std::vector<std::string> plan = pruneDeletePlan(confirmed, fresh);
    CHECK((plan == confirmed));  // exact set AND order
}

// A confirmed file that VANISHED (gone from fresh present -> not a fresh orphan) is a
// skip: it drops out of the plan. Would FAIL if the plan ignored freshOrphans.
static void testDeletePlanSkipsVanishedFile() {
    const std::vector<std::string> confirmed{"b/a.wav", "b/gone.wav", "b/c.wav"};
    const std::vector<std::string> fresh{"b/a.wav", "b/c.wav"};  // gone.wav disappeared
    const std::vector<std::string> plan = pruneDeletePlan(confirmed, fresh);
    CHECK((plan == std::vector<std::string>{"b/a.wav", "b/c.wav"}));
    CHECK(!contains(plan, "b/gone.wav"));
}

// A confirmed file that became REFERENCED between confirm and delete drops OUT of the
// fresh orphan set (pruneOrphans excludes it), so the plan skips it — never deletes a
// now-referenced file. Modelled here as its absence from `fresh`. Load-bearing safety.
static void testDeletePlanSkipsNowReferencedFile() {
    const std::vector<std::string> confirmed{"b/x.wav", "b/y.wav"};
    const std::vector<std::string> fresh{"b/x.wav"};  // y.wav now referenced -> not a fresh orphan
    const std::vector<std::string> plan = pruneDeletePlan(confirmed, fresh);
    CHECK((plan == std::vector<std::string>{"b/x.wav"}));
}

// A NEWLY-APPEARED orphan (in fresh, NOT in confirmed) is NEVER swept: it was not shown,
// so it must not be deleted without its own confirm. Would FAIL if plan = fresh.
static void testDeletePlanNeverSweepsUnconfirmed() {
    const std::vector<std::string> confirmed{"b/a.wav"};
    const std::vector<std::string> fresh{"b/a.wav", "b/new_orphan.wav"};
    const std::vector<std::string> plan = pruneDeletePlan(confirmed, fresh);
    CHECK((plan == std::vector<std::string>{"b/a.wav"}));
    CHECK(!contains(plan, "b/new_orphan.wav"));
}

// Empty inputs: an empty confirmed (nothing shown) -> empty plan regardless of fresh; an
// empty fresh (everything went stale) -> empty plan (all skipped).
static void testDeletePlanEmptyInputs() {
    CHECK(pruneDeletePlan({}, {"b/a.wav"}).empty());
    CHECK(pruneDeletePlan({"b/a.wav"}, {}).empty());
    CHECK(pruneDeletePlan({}, {}).empty());
}

// Duplicate spellings in confirmed are de-duplicated in the plan (mirror of pruneOrphans).
static void testDeletePlanDeduplicatesConfirmed() {
    const std::vector<std::string> confirmed{"b/a.wav", "b/a.wav", "b/b.wav"};
    const std::vector<std::string> fresh{"b/a.wav", "b/b.wav"};
    const std::vector<std::string> plan = pruneDeletePlan(confirmed, fresh);
    CHECK((plan == std::vector<std::string>{"b/a.wav", "b/b.wav"}));
}

// --- mergeReferenced: the pS-usage referenced-union (bank refs ∪ instance holds) -----

// The load-bearing property: a file held ONLY by a sampler instance (not referenced by
// any bank — e.g. its bank entry was deleted while the instance kept its v10 ref) joins
// `referenced` through the union, so pruneOrphans can never emit it. Removing the hold
// (the instance went away — liveness filtered its record out upstream) reclaims it again.
static void testMergeReferencedProtectsInstanceHeldFile() {
    const std::vector<std::string> present{"b/held.wav", "b/orphan.wav"};
    const std::vector<std::string> owned{"b/held.wav", "b/orphan.wav"};
    const std::vector<std::string> bankRefs{};  // no bank references either file

    const std::vector<std::string> withHold =
        pruneOrphans(present, mergeReferenced(bankRefs, {"b/held.wav"}), owned);
    CHECK((withHold == std::vector<std::string>{"b/orphan.wav"}));

    const std::vector<std::string> withoutHold =
        pruneOrphans(present, mergeReferenced(bankRefs, {}), owned);
    CHECK(withoutHold.size() == 2);  // no live hold -> both reclaim (no permanent block)
}

// Order-preserving exact-string de-dup: primary first, then the extras not already seen.
static void testMergeReferencedOrderDedupAndExactMatch() {
    const std::vector<std::string> merged = mergeReferenced(
        {"b/a.wav", "b/b.wav", "b/a.wav"}, {"b/b.wav", "b/c.wav", "B/A.WAV"});
    // Case differs -> distinct entry (exact-string convention, never case-folded here).
    CHECK((merged == std::vector<std::string>{"b/a.wav", "b/b.wav", "b/c.wav", "B/A.WAV"}));
    CHECK(mergeReferenced({}, {}).empty());
}

int main() {
    testNullTestAllReferenced();
    testFormulaMixedPopulations();
    testCopiedFileSurvivesViaUnion();
    testUnionSurvivesWhenOnlySecondBankReferences();
    testHandDroppedNeverReclaimed();
    testEmptyFolder();
    testEmptyReferencedSet();
    testEmptyManifest();
    testAllEmpty();
    testOwnedButAbsentNoOrphan();
    testOutputPreservesPresentOrder();
    testDuplicatePresentDeduped();
    testExactStringMatchNotNormalized();
    testReferencedPathsUnionAcrossBanksAndPool();
    testReferencedPathsEmptyBook();
    testReferencedPathsSkipsEmptyPath();
    testReportCountSizeAndFullList();
    testReportMissingSizeCountsZeroNotDropped();
    testReportTruncatesListButKeepsExactCountAndSize();
    testReportUncappedWhenCapZero();
    testReportEmptyOrphanSet();
    testDeletePlanStableEqualsConfirmed();
    testDeletePlanSkipsVanishedFile();
    testDeletePlanSkipsNowReferencedFile();
    testDeletePlanNeverSweepsUnconfirmed();
    testDeletePlanEmptyInputs();
    testDeletePlanDeduplicatesConfirmed();
    testMergeReferencedProtectsInstanceHeldFile();
    testMergeReferencedOrderDedupAndExactMatch();

    if (g_fail == 0) std::printf("prune_reconcile_tests: ALL PASS\n");
    else             std::printf("prune_reconcile_tests: %d FAILURE(S)\n", g_fail);
    return g_fail == 0 ? 0 : 1;
}
