// Standalone tests for reasampler::tracking's consolidated answers — no REAPER, no
// framework. This is the safety-critical file in the territory: it proves that the
// prune's protected set and the resample's replace-vs-add decision come out of ONE
// state, that neither ever answers destructively from ambiguity, and that the
// replace-vs-add universe is a strict subset of the prune-protection universe.
//
// The prune half is composed end-to-end against the real pure core
// (prune_reconcile + BankBook) rather than a mock, so "a tied usage can never reach
// the orphan set" is proven at the layer that actually deletes.

#include "../src/core/tracking/tracking_authority.h"

#include <algorithm>
#include <cstdio>
#include <string>
#include <unordered_set>
#include <vector>

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

using namespace reasampler;
using namespace reasampler::tracking;
using reasampler::wire::DecodedUsage;
using reasampler::wire::UsageFoldResult;
using reasampler::wire::UsageHold;
using reasampler::wire::UsageRecord;

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

// -- fixtures ----------------------------------------------------------------

static OriginRecord originOf(const std::string& path, OriginKind kind,
                             const std::string& id = "",
                             const std::string& parent = "") {
    OriginRecord r;
    r.relativePath = path;
    r.kind = kind;
    r.sampleId = id;
    r.parentSampleId = parent;
    return r;
}

static DecodedUsage usage(const std::string& key, const std::string& trackGuid,
                          const std::vector<UsageHold>& holds, bool unioned = false) {
    UsageRecord rec;
    rec.trackGuid = trackGuid;
    rec.ownerNonce = key + "-nonce";
    rec.unioned = unioned;
    rec.holds = holds;
    return DecodedUsage{key, rec};
}

static DecodedUsage unreadable(const std::string& key) {
    return DecodedUsage{key, std::nullopt};
}

static UsageFoldResult foldLive(const std::vector<DecodedUsage>& decoded,
                                const std::vector<std::string>& liveTracks) {
    const std::unordered_set<std::string> live(liveTracks.begin(), liveTracks.end());
    return wire::foldUsageRecords(decoded, live, !live.empty());
}

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

// Adds a bank entry so the path lands in BankBook::referencedPaths().
static void addToBank(BankBook& book, const std::string& id, const std::string& path) {
    model::Sample s;
    s.id = id;
    s.displayName = id;
    s.relativePath = path;
    book.activeIndex().add(s);
}

// -- 1. the prune protected set ----------------------------------------------
// Live-held protected; project-referenced protected; foreign untouchable;
// system-owned orphan reclaimable — all four from one computation.

static void testPruneProtectedSet() {
    // On disk: a referenced capture, a live-held capture, a foreign file, and a
    // system-owned orphan.
    const std::vector<std::string> present = {
        "bank/referenced.wav", "bank/held.wav", "bank/foreign.wav", "bank/orphan.wav"};

    BankBook book;
    addToBank(book, "S-ref", "bank/referenced.wav");

    OriginLedger ledger;
    ledger.record(originOf("bank/referenced.wav", OriginKind::Capture, "S-ref"));
    ledger.record(originOf("bank/held.wav", OriginKind::Capture, "S-held"));
    ledger.record(originOf("bank/orphan.wav", OriginKind::Capture, "S-orphan"));
    // "bank/foreign.wav" is deliberately absent — the system did not create it.

    const UsageFoldResult fold = foldLive(
        {usage("rsusage_A", "{T1}", {UsageHold{"S-held", "bank/held.wav"}})}, {"{T1}"});

    const TrackingState state{LedgerStatus::Loaded, ledger, fold};
    const ProtectionAnswer answer = pruneProtection(state);
    CHECK(!answer.blocked);

    const std::vector<std::string> orphans = reclaim::pruneOrphans(
        present, reclaim::mergeReferenced(book.referencedPaths(), answer.heldPaths),
        answer.ownedPaths);

    CHECK(orphans.size() == 1);
    CHECK(contains(orphans, "bank/orphan.wav"));       // system-owned, unreferenced
    CHECK(!contains(orphans, "bank/referenced.wav"));  // a bank references it
    CHECK(!contains(orphans, "bank/held.wav"));        // a live instance holds it
    CHECK(!contains(orphans, "bank/foreign.wav"));     // never system-created
}

// A capture whose bank entry was REMOVED while an instance kept playing it is still
// protected — the held-path union, not the index, is what saves it.
static void testLiveHoldProtectsDeReferencedCapture() {
    const std::vector<std::string> present = {"bank/held.wav"};
    BankBook book;  // no entry at all

    OriginLedger ledger;
    ledger.record(originOf("bank/held.wav", OriginKind::Capture, "S-held"));

    const UsageFoldResult fold = foldLive(
        {usage("rsusage_A", "{T1}", {UsageHold{"S-held", "bank/held.wav"}})}, {"{T1}"});
    const TrackingState state{LedgerStatus::Loaded, ledger, fold};
    const ProtectionAnswer answer = pruneProtection(state);

    const std::vector<std::string> orphans = reclaim::pruneOrphans(
        present, reclaim::mergeReferenced(book.referencedPaths(), answer.heldPaths),
        answer.ownedPaths);
    CHECK(orphans.empty());
}

// -- 2. unreadable tracking state blocks the prune ---------------------------

static void testUnreadableUsageBlocksPruneAndNamesIt() {
    OriginLedger ledger;
    ledger.record(originOf("bank/orphan.wav", OriginKind::Capture, "S-1"));

    const UsageFoldResult fold = foldLive(
        {usage("rsusage_A", "{T1}", {UsageHold{"S-x", "bank/x.wav"}}),
         unreadable("rsusage_BROKEN")},
        {"{T1}"});

    const TrackingState state{LedgerStatus::Loaded, ledger, fold};
    const ProtectionAnswer answer = pruneProtection(state);
    CHECK(answer.blocked);
    CHECK(!answer.ledgerUnreadable);
    CHECK(!answer.ledgerFutureVersion);
    CHECK(answer.unreadableUsageKeys.size() == 1);
    CHECK(answer.unreadableUsageKeys[0] == "rsusage_BROKEN");

    // The belt-and-braces guard is symmetric: a usage-only block ALSO withholds
    // ownedPaths, even though the ledger itself read fine, so a caller that ignored
    // `blocked` computes an empty orphan set rather than deleting with degraded
    // protection.
    CHECK(answer.ownedPaths.empty());
    CHECK(reclaim::pruneOrphans({"bank/orphan.wav"}, {}, answer.ownedPaths).empty());

    // heldPaths is the one list that stays populated on a block — it only ever widens
    // the protected set, so withholding it would be the unsafe direction.
    CHECK(contains(answer.heldPaths, "bank/x.wav"));
}

// A ledger written by a NEWER build blocks exactly like a corrupt one, but is
// reported separately: the operator advice differs (clearing a corrupt blob is
// repair; clearing a newer build's is destruction).
static void testFutureVersionLedgerBlocksAndIsReportedSeparately() {
    const LedgerLoad load = loadLedger("{\"v\":99,\"records\":[]}");
    CHECK(load.status == LedgerStatus::FutureVersion);

    OriginLedger populated;
    populated.record(originOf("bank/would-be-orphan.wav", OriginKind::Capture, "S-1"));
    const UsageFoldResult fold = foldLive({}, {});

    const TrackingState state{load.status, populated, fold};
    const ProtectionAnswer answer = pruneProtection(state);
    CHECK(answer.blocked);
    CHECK(answer.ledgerFutureVersion);
    CHECK(!answer.ledgerUnreadable);
    CHECK(answer.ownedPaths.empty());
    CHECK(reclaim::pruneOrphans({"bank/would-be-orphan.wav"}, {},
                                answer.ownedPaths).empty());

    // And the replace-vs-add question abstains rather than allowing a replace.
    CHECK(tiedUsageExists(state, "bank/would-be-orphan.wav", "") == Answer::Indeterminate);
}

// An unreadable ledger blocks too, AND leaves ownedPaths empty — so a caller that
// ignored `blocked` still computes an empty orphan set rather than deleting.
static void testUnreadableLedgerBlocksAndYieldsNoOrphans() {
    // Deliberately NON-empty: loadLedger() hands back an empty ledger on Unreadable,
    // so an empty fixture here would assert nothing. The guard must suppress records
    // it was given, not merely pass an empty vector through.
    OriginLedger populated;
    populated.record(originOf("bank/would-be-orphan.wav", OriginKind::Capture, "S-1"));
    const UsageFoldResult fold = foldLive({}, {});

    const TrackingState state{LedgerStatus::Unreadable, populated, fold};
    const ProtectionAnswer answer = pruneProtection(state);
    CHECK(answer.blocked);
    CHECK(answer.ledgerUnreadable);
    CHECK(answer.ownedPaths.empty());

    // Belt-and-braces: the orphan set computed from this answer is empty even
    // though the file is present, unreferenced, and recorded as owned.
    const std::vector<std::string> orphans = reclaim::pruneOrphans(
        {"bank/would-be-orphan.wav"}, {}, answer.ownedPaths);
    CHECK(orphans.empty());
}

// Both blockers at once must both be reported — the operator needs to fix both.
static void testBothBlockersReported() {
    // Deliberately NON-empty, like testUnreadableLedgerBlocksAndYieldsNoOrphans: an
    // empty fixture asserts nothing about ownedPaths being withheld, only that it
    // started empty.
    OriginLedger populated;
    populated.record(originOf("bank/would-be-orphan.wav", OriginKind::Capture, "S-1"));
    const UsageFoldResult fold = foldLive(
        {usage("rsusage_A", "{T1}", {UsageHold{"S-held", "bank/held.wav"}}),
         unreadable("rsusage_BROKEN")},
        {"{T1}"});
    const TrackingState state{LedgerStatus::Unreadable, populated, fold};
    const ProtectionAnswer answer = pruneProtection(state);
    CHECK(answer.blocked);
    CHECK(answer.ledgerUnreadable);
    CHECK(answer.unreadableUsageKeys.size() == 1);
    // heldPaths survives a double block: the fold's protect-all set only ever widens
    // what prune protects, so withholding it would be the unsafe direction.
    CHECK(contains(answer.heldPaths, "bank/held.wav"));
    CHECK(answer.ownedPaths.empty());

    // Belt-and-braces: the orphan set computed from this answer is empty even though
    // the file is present, unreferenced, and recorded as owned.
    const std::vector<std::string> orphans = reclaim::pruneOrphans(
        {"bank/would-be-orphan.wav"}, {}, answer.ownedPaths);
    CHECK(orphans.empty());
}

// A record that exists but whose track hosts no identified instance still protects
// everything (the zero-identified net) — the prune runs, but reclaims nothing held.
static void testZeroIdentifiedInstancesStillProtects() {
    OriginLedger ledger;
    ledger.record(originOf("bank/held.wav", OriginKind::Capture, "S-held"));

    const UsageFoldResult fold = foldLive(
        {usage("rsusage_A", "{T-GONE}", {UsageHold{"S-held", "bank/held.wav"}})}, {});
    const TrackingState state{LedgerStatus::Loaded, ledger, fold};
    const ProtectionAnswer answer = pruneProtection(state);
    CHECK(!answer.blocked);
    CHECK(contains(answer.heldPaths, "bank/held.wav"));

    const std::vector<std::string> orphans =
        reclaim::pruneOrphans({"bank/held.wav"},
                              reclaim::mergeReferenced({}, answer.heldPaths),
                              answer.ownedPaths);
    CHECK(orphans.empty());
}

// -- 3. unreadable state never takes the replace branch ----------------------

static void testUnreadableStateNeverAnswersNo() {
    OriginLedger ledger;
    ledger.record(originOf("bank/a.wav", OriginKind::Capture, "S-a"));

    // Unreadable usage record.
    const UsageFoldResult brokenUsage = foldLive({unreadable("rsusage_BROKEN")}, {"{T1}"});
    const TrackingState usageBad{LedgerStatus::Loaded, ledger, brokenUsage};
    CHECK(tiedUsageExists(usageBad, "bank/a.wav", "") == Answer::Indeterminate);

    // Unreadable ledger.
    const OriginLedger empty;
    const UsageFoldResult okUsage = foldLive({}, {});
    const TrackingState ledgerBad{LedgerStatus::Unreadable, empty, okUsage};
    CHECK(tiedUsageExists(ledgerBad, "bank/a.wav", "") == Answer::Indeterminate);

    // An empty capture path is a caller error, not a licence to replace.
    const TrackingState good{LedgerStatus::Loaded, ledger, okUsage};
    CHECK(tiedUsageExists(good, "", "") == Answer::Indeterminate);
}

// -- 4. the tie itself --------------------------------------------------------

static void testTiedUsageYesNoAndSelfExclusion() {
    OriginLedger ledger;
    ledger.record(originOf("bank/src.wav", OriginKind::Capture, "S-src"));
    ledger.record(originOf("bank/lonely.wav", OriginKind::Capture, "S-lonely"));

    const UsageFoldResult fold = foldLive(
        {usage("rsusage_ME", "{T1}", {UsageHold{"S-src", "bank/src.wav"}}),
         usage("rsusage_OTHER", "{T2}", {UsageHold{"S-src", "bank/src.wav"}})},
        {"{T1}", "{T2}"});
    const TrackingState state{LedgerStatus::Loaded, ledger, fold};

    // Counting every holder: a tie exists.
    CHECK(tiedUsageExists(state, "bank/src.wav", "") == Answer::Yes);
    // Excluding my own key still leaves the other instance's tie.
    CHECK(tiedUsageExists(state, "bank/src.wav", "rsusage_ME") == Answer::Yes);
    // Nobody holds this one.
    CHECK(tiedUsageExists(state, "bank/lonely.wav", "") == Answer::No);
}

// Sole holder excluding itself: definitively No, so the bake may replace in place.
static void testSoleHolderExcludingItselfAnswersNo() {
    OriginLedger ledger;
    ledger.record(originOf("bank/src.wav", OriginKind::Capture, "S-src"));

    const UsageFoldResult fold = foldLive(
        {usage("rsusage_ME", "{T1}", {UsageHold{"S-src", "bank/src.wav"}})}, {"{T1}"});
    const TrackingState state{LedgerStatus::Loaded, ledger, fold};
    CHECK(tiedUsageExists(state, "bank/src.wav", "rsusage_ME") == Answer::No);
}

static void testUnionedRecordIsNeverExcludedAsOwn() {
    OriginLedger ledger;
    ledger.record(originOf("bank/src.wav", OriginKind::Capture, "S-src"));

    const UsageFoldResult fold = foldLive(
        {usage("rsusage_ME", "{T1}", {UsageHold{"S-src", "bank/src.wav"}},
               /*unioned=*/true)},
        {"{T1}"});
    const TrackingState state{LedgerStatus::Loaded, ledger, fold};
    CHECK(tiedUsageExists(state, "bank/src.wav", "rsusage_ME") == Answer::Yes);
}

// -- 5. no silent gaps --------------------------------------------------------
// A system-created file simulated through the same recordCreated path is tracked
// the instant it exists: an immediately-following prune sees it as owned (and so
// reclaimable, not foreign), and an immediately-following second creation is
// unaffected. Its lineage is queryable with no intervening save/load.

static void testCreateThenImmediatePruneAndSecondCreate() {
    OriginLedger ledger;
    const UsageFoldResult noUsage = foldLive({}, {});

    // Create #1 — a resample carrying lineage from birth.
    ledger.record(originOf("bank/bake-1.wav", OriginKind::Resample, "S-1", "S-src"));

    {
        const TrackingState state{LedgerStatus::Loaded, ledger, noUsage};
        const ProtectionAnswer answer = pruneProtection(state);
        CHECK(!answer.blocked);
        // Tracked instantly: it is owned, so it is reclaimable rather than foreign.
        CHECK(contains(answer.ownedPaths, "bank/bake-1.wav"));
        const std::vector<std::string> orphans =
            reclaim::pruneOrphans({"bank/bake-1.wav"}, {}, answer.ownedPaths);
        CHECK(orphans.size() == 1);
        // Lineage present with nothing in between.
        CHECK(ledger.find("bank/bake-1.wav")->parentSampleId == "S-src");
    }

    // Create #2 immediately after — both records intact, neither disturbed.
    ledger.record(originOf("bank/bake-2.wav", OriginKind::Resample, "S-2", "S-1"));
    {
        const TrackingState state{LedgerStatus::Loaded, ledger, noUsage};
        const ProtectionAnswer answer = pruneProtection(state);
        CHECK(contains(answer.ownedPaths, "bank/bake-1.wav"));
        CHECK(contains(answer.ownedPaths, "bank/bake-2.wav"));
        CHECK(ledger.find("bank/bake-1.wav")->parentSampleId == "S-src");
        CHECK(ledger.find("bank/bake-2.wav")->parentSampleId == "S-1");
    }
}

// -- 6. the pre-existing bank -------------------------------------------------
// Lifted from a legacy path-only manifest: protections intact, no spurious lineage,
// and the tie query gives a definite answer rather than abstaining.

static void testPreExistingBankKeepsProtectionsAndAnswersDefinitely() {
    const LedgerLoad load = loadLedger("{\"owned\":[\"bank/legacy.wav\"]}");
    CHECK(load.status == LedgerStatus::Loaded);
    CHECK(load.ledger.find("bank/legacy.wav")->parentSampleId.empty());

    // Foreign files stay untouchable and the legacy file is still reclaimable when
    // nothing references it — exactly the protections it had before the lift.
    const UsageFoldResult noUsage = foldLive({}, {});
    const TrackingState state{load.status, load.ledger, noUsage};
    const ProtectionAnswer answer = pruneProtection(state);
    CHECK(!answer.blocked);

    const std::vector<std::string> orphans = reclaim::pruneOrphans(
        {"bank/legacy.wav", "bank/handdropped.wav"}, {}, answer.ownedPaths);
    CHECK(orphans.size() == 1);
    CHECK(contains(orphans, "bank/legacy.wav"));
    CHECK(!contains(orphans, "bank/handdropped.wav"));

    // Never-recorded is decidable, not indeterminate.
    CHECK(tiedUsageExists(state, "bank/legacy.wav", "") == Answer::No);
    CHECK(tiedUsageExists(state, "bank/handdropped.wav", "") == Answer::No);

    // And a live hold on the legacy file still ties, lineage record or not.
    const UsageFoldResult held = foldLive(
        {usage("rsusage_A", "{T1}", {UsageHold{"S-legacy", "bank/legacy.wav"}})},
        {"{T1}"});
    const TrackingState heldState{load.status, load.ledger, held};
    CHECK(tiedUsageExists(heldState, "bank/legacy.wav", "") == Answer::Yes);
}

// -- 7. the consumers cannot disagree ----------------------------------------
// Every Yes from the tie query is a path the prune protects, over a constructed
// record set that mixes live, dead, self-held and unheld paths. The reverse does
// NOT hold — that asymmetry is the point, so it is asserted too.

static void testTiedUniverseIsStrictSubsetOfProtectedUniverse() {
    BankBook book;
    addToBank(book, "S-ref", "bank/bank-only.wav");

    OriginLedger ledger;
    for (const char* p : {"bank/live-a.wav", "bank/live-b.wav", "bank/self.wav",
                          "bank/dead.wav", "bank/bank-only.wav", "bank/unused.wav"})
        ledger.record(originOf(p, OriginKind::Capture, std::string("S") + p));

    const UsageFoldResult fold = foldLive(
        {usage("rsusage_A", "{T1}", {UsageHold{"S1", "bank/live-a.wav"},
                                     UsageHold{"S2", "bank/live-b.wav"}}),
         usage("rsusage_ME", "{T1}", {UsageHold{"S3", "bank/self.wav"}}),
         usage("rsusage_DEAD", "{T-GONE}", {UsageHold{"S4", "bank/dead.wav"}})},
        {"{T1}"});

    const TrackingState state{LedgerStatus::Loaded, ledger, fold};
    const ProtectionAnswer answer = pruneProtection(state);
    CHECK(!answer.blocked);

    const std::vector<std::string> referenced =
        reclaim::mergeReferenced(book.referencedPaths(), answer.heldPaths);

    const std::vector<std::string> universe = {
        "bank/live-a.wav", "bank/live-b.wav", "bank/self.wav",
        "bank/dead.wav",   "bank/bank-only.wav", "bank/unused.wav"};

    std::size_t tiedCount = 0;
    for (const std::string& path : universe) {
        const Answer tied = tiedUsageExists(state, path, "rsusage_ME");
        if (tied != Answer::Yes) continue;
        ++tiedCount;
        // Yes => protected: the path is in the referenced union, so pruneOrphans
        // cannot emit it even though it is present and owned.
        CHECK(contains(referenced, path));
        CHECK(reclaim::pruneOrphans({path}, referenced, answer.ownedPaths).empty());
    }
    CHECK(tiedCount == 2);  // live-a, live-b — self is excluded, dead is not live

    // Strictly narrower: bank-only.wav is protected (a bank references it) and
    // self.wav is protected (a live instance holds it), yet neither is a tie.
    CHECK(contains(referenced, "bank/bank-only.wav"));
    CHECK(tiedUsageExists(state, "bank/bank-only.wav", "rsusage_ME") == Answer::No);
    CHECK(contains(referenced, "bank/self.wav"));
    CHECK(tiedUsageExists(state, "bank/self.wav", "rsusage_ME") == Answer::No);
}

// The resample's own branch off the three answers. Indeterminate must land where Yes does:
// AddDistinct disturbs no existing holder, so it is the non-destructive side.
static void testResampleLandingTakesReplaceOnlyOnADefiniteNo() {
    CHECK(resampleLanding(Answer::No) == Landing::Replace);
    CHECK(resampleLanding(Answer::Yes) == Landing::AddDistinct);
    CHECK(resampleLanding(Answer::Indeterminate) == Landing::AddDistinct);

    // Composed with the query itself, over the three states a real bake meets.
    OriginLedger ledger;
    ledger.record(originOf("bank/src.wav", OriginKind::Capture, "S-src"));

    // Sole holder, excluding itself -> nothing is tied -> replace.
    const UsageFoldResult sole = foldLive(
        {usage("rsusage_ME", "{T1}", {UsageHold{"S-src", "bank/src.wav"}})}, {"{T1}"});
    const TrackingState soleState{LedgerStatus::Loaded, ledger, sole};
    CHECK(resampleLanding(tiedUsageExists(soleState, "bank/src.wav", "rsusage_ME")) ==
          Landing::Replace);

    // A second live instance holds it -> add distinct, so that holder is undisturbed.
    const UsageFoldResult shared = foldLive(
        {usage("rsusage_ME", "{T1}", {UsageHold{"S-src", "bank/src.wav"}}),
         usage("rsusage_OTHER", "{T2}", {UsageHold{"S-src", "bank/src.wav"}})},
        {"{T1}", "{T2}"});
    const TrackingState sharedState{LedgerStatus::Loaded, ledger, shared};
    CHECK(resampleLanding(tiedUsageExists(sharedState, "bank/src.wav", "rsusage_ME")) ==
          Landing::AddDistinct);

    // A degraded ledger cannot answer -> add distinct rather than take over an entry
    // whose holders are unknown.
    const TrackingState degradedState{LedgerStatus::Unreadable, ledger, sole};
    CHECK(tiedUsageExists(degradedState, "bank/src.wav", "rsusage_ME") ==
          Answer::Indeterminate);
    CHECK(resampleLanding(tiedUsageExists(degradedState, "bank/src.wav", "rsusage_ME")) ==
          Landing::AddDistinct);
}

int main() {
    testResampleLandingTakesReplaceOnlyOnADefiniteNo();
    testPruneProtectedSet();
    testLiveHoldProtectsDeReferencedCapture();
    testUnreadableUsageBlocksPruneAndNamesIt();
    testUnreadableLedgerBlocksAndYieldsNoOrphans();
    testFutureVersionLedgerBlocksAndIsReportedSeparately();
    testBothBlockersReported();
    testZeroIdentifiedInstancesStillProtects();
    testUnreadableStateNeverAnswersNo();
    testTiedUsageYesNoAndSelfExclusion();
    testSoleHolderExcludingItselfAnswersNo();
    testUnionedRecordIsNeverExcludedAsOwn();
    testCreateThenImmediatePruneAndSecondCreate();
    testPreExistingBankKeepsProtectionsAndAnswersDefinitely();
    testTiedUniverseIsStrictSubsetOfProtectedUniverse();

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