// Standalone tests for reasampler::tracking::OriginLedger — no REAPER, no framework.
//
// Covers: relative-paths-only, exact-string ownership, dedup, insertion order, the
// JSON round-trip (incl. golden bytes over every persisted enum value), the append-a-
// kind rules, the no-backfill rule, the legacy path-only lift, and the Fresh / Loaded
// / Unreadable / FutureVersion classification.

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

#include <cstdio>
#include <string>

using namespace reasampler::tracking;

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 OriginRecord rec(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;
}

// --- empty ledger ------------------------------------------------------------

static void testEmptyLedger() {
    OriginLedger l;
    CHECK(l.empty());
    CHECK(l.size() == 0);
    CHECK(l.ownedPaths().empty());
    CHECK(!l.contains("anything.wav"));
    CHECK(l.find("anything.wav") == nullptr);

    const std::string json = l.serialize();
    auto back = OriginLedger::deserialize(json);
    CHECK(back.has_value());
    CHECK(*back == l);
    CHECK(back->empty());
}

// --- the relative-paths-only invariant ---------------------------------------

static void testRejectsEmptyAndAbsolutePaths() {
    OriginLedger l;
    CHECK(l.record(rec("", OriginKind::Capture)) == RecordResult::RejectedEmptyPath);
    CHECK(l.record(rec("/abs/a.wav", OriginKind::Capture)) ==
          RecordResult::RejectedAbsolutePath);
    CHECK(l.record(rec("\\\\server\\share\\a.wav", OriginKind::Capture)) ==
          RecordResult::RejectedAbsolutePath);
    CHECK(l.record(rec("C:/bank/a.wav", OriginKind::Capture)) ==
          RecordResult::RejectedAbsolutePath);
    CHECK(l.record(rec("C:rel.wav", OriginKind::Capture)) ==
          RecordResult::RejectedAbsolutePath);
    CHECK(l.empty());  // no mutation on any rejection

    CHECK(l.record(rec("reasampler_bank/a.wav", OriginKind::Capture)) ==
          RecordResult::Recorded);
    CHECK(l.size() == 1);
}

// --- dedup + insertion order -------------------------------------------------

static void testDedupPreservesInsertionOrder() {
    OriginLedger l;
    CHECK(l.record(rec("b.wav", OriginKind::Capture)) == RecordResult::Recorded);
    CHECK(l.record(rec("a.wav", OriginKind::Ingest)) == RecordResult::Recorded);
    CHECK(l.record(rec("b.wav", OriginKind::Capture)) == RecordResult::AlreadyPresent);

    const std::vector<std::string> paths = l.ownedPaths();
    CHECK(paths.size() == 2);
    CHECK(paths[0] == "b.wav");   // insertion order, not sorted
    CHECK(paths[1] == "a.wav");
}

// --- lineage is written at birth and NEVER backfilled ------------------------
// A second record() for the same path must leave the stored record untouched, so a
// later, less-informed (or differently-informed) writer can never rewrite history.

static void testLineageIsNeverBackfilled() {
    OriginLedger l;
    CHECK(l.record(rec("child.wav", OriginKind::Resample, "S-child", "S-parent")) ==
          RecordResult::Recorded);

    // A later write with different lineage is refused outright.
    CHECK(l.record(rec("child.wav", OriginKind::Capture, "S-other", "S-wrong")) ==
          RecordResult::AlreadyPresent);

    const OriginRecord* stored = l.find("child.wav");
    CHECK(stored != nullptr);
    CHECK(stored->kind == OriginKind::Resample);
    CHECK(stored->sampleId == "S-child");
    CHECK(stored->parentSampleId == "S-parent");

    // The same holds in the other direction: a never-recorded (Unknown) record is
    // not upgraded by a later lineage-bearing write.
    OriginLedger legacy;
    CHECK(legacy.record(rec("old.wav", OriginKind::Unknown)) == RecordResult::Recorded);
    CHECK(legacy.record(rec("old.wav", OriginKind::Resample, "S1", "S0")) ==
          RecordResult::AlreadyPresent);
    CHECK(legacy.find("old.wav")->parentSampleId.empty());
}

// --- lineage present at creation, queryable immediately ----------------------
// The no-silent-gaps property at the record layer: nothing intervenes between
// record() and a successful find().

static void testLineageQueryableImmediatelyAfterRecording() {
    OriginLedger l;
    l.record(rec("bake-1.wav", OriginKind::Resample, "S-1", "S-src"));
    const OriginRecord* r = l.find("bake-1.wav");
    CHECK(r != nullptr);
    CHECK(r->parentSampleId == "S-src");

    // A second creation immediately after does not disturb the first.
    l.record(rec("bake-2.wav", OriginKind::Resample, "S-2", "S-1"));
    CHECK(l.find("bake-1.wav")->parentSampleId == "S-src");
    CHECK(l.find("bake-2.wav")->parentSampleId == "S-1");
    CHECK(l.ownedPaths().size() == 2);
}

// --- JSON round-trip ---------------------------------------------------------

static void testRoundTripWithLineage() {
    OriginLedger l;
    l.record(rec("reasampler_bank/a.wav", OriginKind::Capture, "S-a"));
    l.record(rec("reasampler_bank/b.wav", OriginKind::Resample, "S-b", "S-a"));
    l.record(rec("reasampler_bank/c.wav", OriginKind::Ingest, "S-c"));
    l.record(rec("reasampler_bank/d.wav", OriginKind::Recapture, "S-d", "S-a"));

    auto back = OriginLedger::deserialize(l.serialize());
    CHECK(back.has_value());
    CHECK(*back == l);
}

// Metacharacters must survive: a path or id is written through the escaper, so a
// quote or backslash cannot break the surrounding document.
static void testRoundTripWithJsonMetacharacters() {
    OriginLedger l;
    l.record(rec("bank/quote\".wav", OriginKind::Capture, "id\\with\\slashes"));
    l.record(rec("bank/tab\tnewline\n.wav", OriginKind::Capture, "", "par\"ent"));

    auto back = OriginLedger::deserialize(l.serialize());
    CHECK(back.has_value());
    CHECK(*back == l);
}

// Golden byte literal: pins the EXACT serialized bytes, so a format drift both
// writer and reader agree on still fails here. EVERY OriginKind appears, because the
// integers are persisted — a swap of two values in both the enum and kindFromInt
// round-trips perfectly and would mislabel every existing project. This literal is
// the only thing standing in the way of that.
static void testSerializeGoldenLiteralPinsEveryPersistedKind() {
    OriginLedger l;
    l.record(rec("bank/unknown.wav", OriginKind::Unknown));
    l.record(rec("bank/capture.wav", OriginKind::Capture, "S-a"));
    l.record(rec("bank/ingest.wav", OriginKind::Ingest, "S-b"));
    l.record(rec("bank/recapture.wav", OriginKind::Recapture, "S-c", "S-a"));
    l.record(rec("bank/resample.wav", OriginKind::Resample, "S-d", "S-a"));
    l.record(rec("bank/import.wav", OriginKind::PackageImport, "S-e"));
    const std::string expected =
        "{\"v\":2,\"records\":["
        "{\"path\":\"bank/unknown.wav\",\"kind\":0,\"sample\":\"\",\"parent\":\"\"},"
        "{\"path\":\"bank/capture.wav\",\"kind\":1,\"sample\":\"S-a\",\"parent\":\"\"},"
        "{\"path\":\"bank/ingest.wav\",\"kind\":2,\"sample\":\"S-b\",\"parent\":\"\"},"
        "{\"path\":\"bank/recapture.wav\",\"kind\":3,\"sample\":\"S-c\",\"parent\":\"S-a\"},"
        "{\"path\":\"bank/resample.wav\",\"kind\":4,\"sample\":\"S-d\",\"parent\":\"S-a\"},"
        "{\"path\":\"bank/import.wav\",\"kind\":5,\"sample\":\"S-e\",\"parent\":\"\"}"
        "]}";
    CHECK(l.serialize() == expected);

    // And the reader agrees with the writer on the same bytes, kind by kind.
    auto back = OriginLedger::deserialize(expected);
    CHECK(back.has_value());
    CHECK(back->find("bank/unknown.wav")->kind == OriginKind::Unknown);
    CHECK(back->find("bank/capture.wav")->kind == OriginKind::Capture);
    CHECK(back->find("bank/ingest.wav")->kind == OriginKind::Ingest);
    CHECK(back->find("bank/recapture.wav")->kind == OriginKind::Recapture);
    CHECK(back->find("bank/resample.wav")->kind == OriginKind::Resample);
    CHECK(back->find("bank/import.wav")->kind == OriginKind::PackageImport);
    CHECK(*back == l);  // every field, not just the kind, survives the trip
}

// Pins the emitted "v" byte, not the internal constant — an older build reads bytes.
static void testAppendingAKindDoesNotMoveTheDocumentVersion() {
    OriginLedger l;
    l.record(rec("bank/import.wav", OriginKind::PackageImport, "S-e"));
    const std::string json = l.serialize();
    CHECK(json.rfind("{\"v\":2,", 0) == 0);
    CHECK(loadLedger(json).status == LedgerStatus::Loaded);

    // The ceiling did not move with it: v3 is still a future document shape.
    CHECK(loadLedger("{\"v\":3,\"records\":[]}").status == LedgerStatus::FutureVersion);
}

// Pins three unrecognized kind values (future, far-future, negative) all landing on
// Unknown with the ledger still Loaded and the path still owned.
static void testUnknownKindDegradesWithoutBlockingTheLedger() {
    const std::string blob =
        "{\"v\":2,\"records\":["
        "{\"path\":\"bank/next.wav\",\"kind\":6,\"sample\":\"S-1\",\"parent\":\"\"},"
        "{\"path\":\"bank/far.wav\",\"kind\":99,\"sample\":\"S-2\",\"parent\":\"\"},"
        "{\"path\":\"bank/bogus.wav\",\"kind\":-1,\"sample\":\"S-3\",\"parent\":\"\"}]}";

    const LedgerLoad load = loadLedger(blob);
    CHECK(load.status == LedgerStatus::Loaded);
    CHECK(!ledgerDegraded(load.status));
    CHECK(load.ledger.size() == 3);
    CHECK(load.ledger.find("bank/next.wav")->kind == OriginKind::Unknown);
    CHECK(load.ledger.find("bank/far.wav")->kind == OriginKind::Unknown);
    CHECK(load.ledger.find("bank/bogus.wav")->kind == OriginKind::Unknown);

    // Every path still owned, in order — the protection prune reads is untouched.
    const std::vector<std::string> paths = load.ledger.ownedPaths();
    CHECK(paths.size() == 3);
    CHECK(paths[0] == "bank/next.wav");
    CHECK(paths[1] == "bank/far.wav");
    CHECK(paths[2] == "bank/bogus.wav");

    // The rest of the record survives the degrade; only the kind is lost.
    CHECK(load.ledger.find("bank/next.wav")->sampleId == "S-1");
}

// ownedPaths() is the ONLY thing pruneProtection reads out of a ledger, and it is
// kind-blind: two ledgers agreeing on paths and differing on every kind, new value
// included, yield identical protection input. Adding a kind therefore cannot change
// a prune decision for any existing kind.
static void testOwnedPathsAreKindIndependent() {
    const OriginKind kinds[] = {OriginKind::Unknown,   OriginKind::Capture,
                                OriginKind::Ingest,    OriginKind::Recapture,
                                OriginKind::Resample,  OriginKind::PackageImport};
    const std::size_t n = sizeof(kinds) / sizeof(kinds[0]);

    OriginLedger baseline;
    OriginLedger varied;
    for (std::size_t i = 0; i < n; ++i) {
        const std::string path = "bank/f" + std::to_string(i) + ".wav";
        baseline.record(rec(path, OriginKind::Unknown, "S"));
        varied.record(rec(path, kinds[i], "S"));
    }

    CHECK(baseline.ownedPaths() == varied.ownedPaths());
    CHECK(baseline.ownedPaths().size() == n);
    CHECK(!(baseline == varied));  // the ledgers really do differ, kind by kind
}

// contains() is an EXACT-string predicate, never a prefix or substring match — the
// prune's whole ownership attribution rests on it, and a looser match would attribute
// (and so expose to deletion) a file the system never created.
static void testContainsIsExactStringNotPrefixOrSubstring() {
    OriginLedger l;
    l.record(rec("reasampler_bank/a.wav", OriginKind::Capture));

    CHECK(l.contains("reasampler_bank/a.wav"));
    CHECK(!l.contains("reasampler_bank/a"));      // prefix of the stored path
    CHECK(!l.contains("a.wav"));                  // suffix of the stored path
    CHECK(!l.contains("reasampler_bank/a.wave")); // stored path is a prefix of this
    CHECK(!l.contains("REASAMPLER_BANK/A.WAV"));  // no case folding
}

// --- malformed input ---------------------------------------------------------

static void testMalformedParsesToNullopt() {
    CHECK(!OriginLedger::deserialize("").has_value());
    CHECK(!OriginLedger::deserialize("not json").has_value());
    CHECK(!OriginLedger::deserialize("{\"v\":2,\"records\":[").has_value());
    CHECK(!OriginLedger::deserialize("{\"records\":[{\"path\":]}").has_value());
    CHECK(!OriginLedger::deserialize("[]").has_value());

    // Unknown keys are tolerated (forward-compat), unknown kind values degrade to
    // Unknown rather than failing the whole ledger — a vocabulary gap must not halt
    // the prune.
    auto tolerated = OriginLedger::deserialize(
        "{\"v\":2,\"future\":{\"x\":1},\"records\":[{\"path\":\"a.wav\",\"kind\":99}]}");
    CHECK(tolerated.has_value());
    CHECK(tolerated->size() == 1);
    CHECK(tolerated->find("a.wav")->kind == OriginKind::Unknown);
}

// Trailing garbage is rejected outright: accepting it would turn a detectably-corrupt
// blob into a silently-partial ledger, and the records it dropped would then be lost
// on the next save.
static void testTrailingGarbageIsRejected() {
    CHECK(!OriginLedger::deserialize("{\"v\":2,\"records\":[]}JUNK").has_value());
    CHECK(!OriginLedger::deserialize("{\"owned\":[\"a.wav\"]} {\"owned\":[]}").has_value());
    // ... but trailing whitespace alone is not garbage.
    CHECK(OriginLedger::deserialize("{\"v\":2,\"records\":[]}  \n").has_value());
}

// The legacy shape's own error cases: a type error is a REJECTION (blocking, blob
// preserved), never a silent degrade to empty — while a genuinely empty legacy list
// is valid and yields an empty, non-blocking ledger.
static void testLegacyShapeTypeErrorsAreRejectedButEmptyIsValid() {
    CHECK(loadLedger("{\"owned\":[1,2]}").status == LedgerStatus::Unreadable);
    CHECK(loadLedger("{\"owned\":\"x\"}").status == LedgerStatus::Unreadable);

    const LedgerLoad emptyLegacy = loadLedger("{\"owned\":[]}");
    CHECK(emptyLegacy.status == LedgerStatus::Loaded);
    CHECK(emptyLegacy.ledger.empty());
}

// A blob written by a newer build is its own status: this build cannot trust records
// it parsed under v2 rules, so it must neither answer a destructive question from
// them nor overwrite the blob with its own truncation.
static void testFutureVersionIsNeitherLoadedNorMalformed() {
    const std::string v3 =
        "{\"v\":3,\"records\":[{\"path\":\"a.wav\",\"kind\":1}],\"newshape\":[1]}";
    const LedgerLoad load = loadLedger(v3);
    CHECK(load.status == LedgerStatus::FutureVersion);
    CHECK(load.ledger.empty());          // never a partial value
    CHECK(ledgerDegraded(load.status));  // blocks answers AND suppresses the write
    CHECK(!OriginLedger::deserialize(v3).has_value());

    // Key order must not matter — "v" is validated after the object closes.
    CHECK(loadLedger("{\"records\":[{\"path\":\"a.wav\"}],\"v\":9}").status ==
          LedgerStatus::FutureVersion);

    // The versions this build does read stay readable, and a nonsense version is
    // corruption rather than a future shape.
    CHECK(loadLedger("{\"v\":2,\"records\":[]}").status == LedgerStatus::Loaded);
    CHECK(loadLedger("{\"v\":1,\"owned\":[\"a.wav\"]}").status == LedgerStatus::Loaded);
    CHECK(loadLedger("{\"v\":-1,\"records\":[]}").status == LedgerStatus::Unreadable);

    // A future version whose record shape actually changed — not just grew — fails
    // parseRecord under these v2 rules ("kind" retyped from int to string). That must
    // still classify as FutureVersion, not Unreadable: this is the exact "changed
    // record shape" case the version ladder comment says v3 means, and Unreadable
    // would steer the operator into clearing a newer build's ledger.
    CHECK(loadLedger("{\"v\":3,\"records\":[{\"path\":\"a.wav\",\"kind\":\"loud\"}]}")
              .status == LedgerStatus::FutureVersion);
    // Same, with the array itself retyped to an object.
    CHECK(loadLedger("{\"v\":3,\"records\":{\"path\":\"a.wav\"}}").status ==
          LedgerStatus::FutureVersion);
}

// A hand-edited or corrupt blob cannot smuggle an absolute or duplicate path past
// the load — record() re-asserts the invariants on the way in.
static void testLoadReassertsInvariants() {
    auto loaded = OriginLedger::deserialize(
        "{\"v\":2,\"records\":["
        "{\"path\":\"a.wav\",\"kind\":1},"
        "{\"path\":\"/etc/passwd\",\"kind\":1},"
        "{\"path\":\"a.wav\",\"kind\":2},"
        "{\"path\":\"\",\"kind\":1}]}");
    CHECK(loaded.has_value());
    CHECK(loaded->size() == 1);
    CHECK(loaded->contains("a.wav"));
    CHECK(!loaded->contains("/etc/passwd"));
    CHECK(loaded->find("a.wav")->kind == OriginKind::Capture);  // first wins
}

// --- the pre-existing bank lifts in ------------------------------------------
// A legacy path-only manifest keeps every protection it had (the paths stay owned,
// so prune still reclaims them and still refuses hand-dropped files) and gains NO
// invented lineage.

static void testLegacyPathOnlyManifestLiftsIn() {
    const std::string legacy =
        "{\"owned\":[\"reasampler_bank/a.wav\",\"reasampler_bank/b.wav\"]}";
    LedgerLoad load = loadLedger(legacy);
    CHECK(load.status == LedgerStatus::Loaded);
    CHECK(load.ledger.size() == 2);

    const std::vector<std::string> paths = load.ledger.ownedPaths();
    CHECK(paths.size() == 2);
    CHECK(paths[0] == "reasampler_bank/a.wav");
    CHECK(paths[1] == "reasampler_bank/b.wav");

    for (const OriginRecord& r : load.ledger.records()) {
        CHECK(r.kind == OriginKind::Unknown);   // no invented origin
        CHECK(r.sampleId.empty());
        CHECK(r.parentSampleId.empty());        // no spurious lineage
    }

    // Re-saving upgrades the shape without losing or inventing anything — pinned as a
    // byte literal, not just a round-trip, so the lifted record's persisted integers
    // (kind 0) and empty ids are fixed rather than merely self-consistent.
    const std::string expected =
        "{\"v\":2,\"records\":["
        "{\"path\":\"reasampler_bank/a.wav\",\"kind\":0,\"sample\":\"\",\"parent\":\"\"},"
        "{\"path\":\"reasampler_bank/b.wav\",\"kind\":0,\"sample\":\"\",\"parent\":\"\"}"
        "]}";
    CHECK(load.ledger.serialize() == expected);
    auto resaved = OriginLedger::deserialize(expected);
    CHECK(resaved.has_value());
    CHECK(*resaved == load.ledger);
}

// A recapture regenerates the audio behind ONE bank id under a NEW file name
// (makeUniqueTag guarantees it), so the ledger legitimately ends up holding two
// records with the same sampleId and different paths. This proves that shape and
// that take-1's record is untouched by take-2's insert (record() never overwrites —
// see testLineageIsNeverBackfilled for the same-path case). It does NOT exercise the
// ledger-wins-over-the-mutable-Sample claim: nothing here reads Sample.provenance, and
// no production code reads OriginRecord.parentSampleId today.
static void testRecaptureAddsSecondRecordUnderTheSameSampleId() {
    OriginLedger l;
    l.record(rec("bank/take-1.wav", OriginKind::Capture, "S-1", "S-parent"));
    CHECK(l.record(rec("bank/take-2.wav", OriginKind::Recapture, "S-1", "S-other")) ==
          RecordResult::Recorded);

    CHECK(l.size() == 2);
    CHECK(l.find("bank/take-1.wav")->sampleId == "S-1");
    CHECK(l.find("bank/take-2.wav")->sampleId == "S-1");
    CHECK(l.find("bank/take-1.wav")->parentSampleId == "S-parent");
    CHECK(l.find("bank/take-1.wav")->kind == OriginKind::Capture);
    // Both stay owned, so the superseded file is reclaimable rather than foreign.
    CHECK(l.ownedPaths().size() == 2);
}

// --- never-recorded vs unreadable --------------------------------------------
// The two absences demand opposite treatment, so they must be distinguishable at
// the load boundary — this is the only place that distinction is made.

static void testFreshLoadedUnreadableAreDistinct() {
    // Absent key: an empty string is not valid JSON, so only the loader can tell
    // "no key yet" from "corrupt value".
    CHECK(loadLedger("").status == LedgerStatus::Fresh);
    CHECK(loadLedger("").ledger.empty());

    // A well-formed empty ledger is Loaded, not Fresh — it is a positive record
    // that nothing has been created, not an absence.
    const LedgerLoad emptyButStored = loadLedger(OriginLedger{}.serialize());
    CHECK(emptyButStored.status == LedgerStatus::Loaded);
    CHECK(emptyButStored.ledger.empty());

    const LedgerLoad broken = loadLedger("{\"records\":[{oops");
    CHECK(broken.status == LedgerStatus::Unreadable);
    CHECK(broken.ledger.empty());  // never a partial value

    OriginLedger real;
    real.record(rec("a.wav", OriginKind::Capture, "S-a"));
    const LedgerLoad good = loadLedger(real.serialize());
    CHECK(good.status == LedgerStatus::Loaded);
    CHECK(good.ledger == real);
}

int main() {
    testEmptyLedger();
    testRejectsEmptyAndAbsolutePaths();
    testDedupPreservesInsertionOrder();
    testContainsIsExactStringNotPrefixOrSubstring();
    testLineageIsNeverBackfilled();
    testLineageQueryableImmediatelyAfterRecording();
    testRoundTripWithLineage();
    testRoundTripWithJsonMetacharacters();
    testSerializeGoldenLiteralPinsEveryPersistedKind();
    testAppendingAKindDoesNotMoveTheDocumentVersion();
    testUnknownKindDegradesWithoutBlockingTheLedger();
    testOwnedPathsAreKindIndependent();
    testMalformedParsesToNullopt();
    testTrailingGarbageIsRejected();
    testLegacyShapeTypeErrorsAreRejectedButEmptyIsValid();
    testFutureVersionIsNeitherLoadedNorMalformed();
    testLoadReassertsInvariants();
    testLegacyPathOnlyManifestLiftsIn();
    testRecaptureAddsSecondRecordUnderTheSameSampleId();
    testFreshLoadedUnreadableAreDistinct();

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