// Standalone tests for the pure provenance core (M10) — no REAPER, no framework.
//
// Covers (brief-named test categories):
//   * fingerprint build -> encode -> parse round-trip (lossless).
//   * identical inputs -> equal fingerprints (byte-identical string).
//   * any single component change (scope, sourceMode, range, tail, rate, channels,
//     track GUIDs, FX-chain identity) -> a MISMATCH (different string / recipe).
//   * fxChainIdentity fold: order-sensitive, field-injection-proof, empty-stable.
//   * parse of malformed / wrong-version / truncated input -> nullopt (graceful).
//   * hardened wire cursor (Q-W0 T2-01a): hostile digit-run lengths, wrap-magnitude
//     lengths, and huge GUID counts -> nullopt with no overflow and no over-allocation.
//   * parent-detection decision: positive, negative, ambiguous, empty, and the
//     edge where a source file is not in the bank (missing-from-bank).
//
// The Sample-JSON round-trip of the fingerprint (leveraging M1's existing provenance
// round-trip) is exercised in test_bank_model.cpp — see the fingerprint case there.

#include "../src/core/model/provenance.h"

#include "../src/core/model/bank_model.h"  // recipe-through-Sample-JSON round-trip (M1 seam)

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

using namespace reasampler;
using namespace reasampler::model;

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

// A representative, fully-populated recipe used as the baseline for mutation tests.
static CaptureRecipe baseRecipe() {
    CaptureRecipe r;
    r.scope        = ProvenanceScope::Track;
    r.sourceMode   = 1;               // SelectedTracks
    r.startSeconds = 12.3456789012345;  // non-trivial doubles to exercise %.17g
    r.endSeconds   = 45.6789012345678;
    r.tailMode     = 2;               // Manual
    r.tailMs       = 1234.5;
    r.sampleRate   = 48000;
    r.channelCount = 2;
    r.trackGuids   = {"{11111111-1111-1111-1111-111111111111}",
                      "{22222222-2222-2222-2222-222222222222}"};
    r.fxChainIdentity = fxChainIdentity({
        {"ReaEQ", "{AAAA-1}", true},
        {"ReaComp", "{BBBB-2}", false},
    });
    return r;
}

// --- fingerprint round-trip --------------------------------------------------

static void testFingerprintRoundTrip() {
    const CaptureRecipe r = baseRecipe();
    const std::string fp = buildFingerprint(r);
    auto back = parseFingerprint(fp);
    CHECK(back.has_value());
    CHECK(*back == r);
    // Re-encode is byte-stable.
    CHECK(buildFingerprint(*back) == fp);
}

// A recipe with empty GUID list + empty FX identity (a no-FX, no-track-guid capture)
// still round-trips — the degenerate case must not corrupt the parse.
static void testFingerprintRoundTripEmptyFields() {
    CaptureRecipe r;
    r.scope = ProvenanceScope::Item;
    r.trackGuids.clear();
    r.fxChainIdentity = fxChainIdentity({});
    const std::string fp = buildFingerprint(r);
    auto back = parseFingerprint(fp);
    CHECK(back.has_value());
    CHECK(*back == r);
    CHECK(back->trackGuids.empty());
}

// A GUID or FX-name carrying the field separators (':' and digits) must survive —
// length-prefixing makes the encoding injection-proof.
static void testFingerprintRoundTripHostileStrings() {
    CaptureRecipe r = baseRecipe();
    r.trackGuids = {"7:not-a-real-guid", "12:another:evil:one"};
    r.fxChainIdentity = fxChainIdentity({
        {"FX with 3:colons: and stuff", "{gu:id}", true},
    });
    auto back = parseFingerprint(buildFingerprint(r));
    CHECK(back.has_value());
    CHECK(*back == r);
}

// --- identical inputs -> equal fingerprints ----------------------------------

static void testIdenticalInputsEqualFingerprints() {
    CHECK(buildFingerprint(baseRecipe()) == buildFingerprint(baseRecipe()));
    CHECK(baseRecipe() == baseRecipe());
}

// --- any single component change -> mismatch ---------------------------------

static void testSingleComponentChangesMismatch() {
    const std::string base = buildFingerprint(baseRecipe());

    { auto r = baseRecipe(); r.scope = ProvenanceScope::Item;
      CHECK(buildFingerprint(r) != base); CHECK(r != baseRecipe()); }
    { auto r = baseRecipe(); r.sourceMode = 3;
      CHECK(buildFingerprint(r) != base); }
    { auto r = baseRecipe(); r.startSeconds += 0.0000001;
      CHECK(buildFingerprint(r) != base); }
    { auto r = baseRecipe(); r.endSeconds += 0.0000001;
      CHECK(buildFingerprint(r) != base); }
    { auto r = baseRecipe(); r.tailMode = 0;
      CHECK(buildFingerprint(r) != base); }
    { auto r = baseRecipe(); r.tailMs += 1.0;
      CHECK(buildFingerprint(r) != base); }
    { auto r = baseRecipe(); r.sampleRate = 44100;
      CHECK(buildFingerprint(r) != base); }
    { auto r = baseRecipe(); r.channelCount = 1;
      CHECK(buildFingerprint(r) != base); }
    { auto r = baseRecipe(); r.trackGuids.pop_back();
      CHECK(buildFingerprint(r) != base); }
    { auto r = baseRecipe(); r.trackGuids[0] = "{99999999-9999-9999-9999-999999999999}";
      CHECK(buildFingerprint(r) != base); }
    // The drift component: a changed FX chain identity mismatches (this is exactly
    // the "source changed since capture" signal re-capture reports).
    { auto r = baseRecipe();
      r.fxChainIdentity = fxChainIdentity({{"ReaEQ", "{AAAA-1}", true}});
      CHECK(buildFingerprint(r) != base); CHECK(r != baseRecipe()); }
}

// --- fxChainIdentity fold ----------------------------------------------------

static void testFxChainIdentityOrderSensitive() {
    const std::string a = fxChainIdentity({
        {"ReaEQ", "{A}", true}, {"ReaComp", "{B}", true}});
    const std::string b = fxChainIdentity({
        {"ReaComp", "{B}", true}, {"ReaEQ", "{A}", true}});
    CHECK(a != b);  // chain order is part of identity
}

static void testFxChainIdentityFieldsMatter() {
    const std::string base = fxChainIdentity({{"ReaEQ", "{A}", true}});
    CHECK(fxChainIdentity({{"ReaEQ2", "{A}", true}}) != base);   // name
    CHECK(fxChainIdentity({{"ReaEQ", "{B}", true}})  != base);   // guid (instance)
    CHECK(fxChainIdentity({{"ReaEQ", "{A}", false}}) != base);   // enabled flag
}

static void testFxChainIdentityEmptyStable() {
    CHECK(fxChainIdentity({}) == fxChainIdentity({}));
    // Empty chain differs from a one-FX chain.
    CHECK(fxChainIdentity({}) != fxChainIdentity({{"X", "{Y}", true}}));
}

// Concatenation cannot forge equality: {"AB",""} vs {"A","B"} must differ despite
// sharing raw bytes — length-prefixing keeps boundaries honest.
static void testFxChainIdentityInjectionProof() {
    const std::string x = fxChainIdentity({{"AB", "", true}});
    const std::string y = fxChainIdentity({{"A", "B", true}});
    CHECK(x != y);
}

// --- combineChainIdentities (multi-track Track-scope fold) -------------------

static void testCombineChainIdentities() {
    const std::string idA = fxChainIdentity({{"ReaEQ", "{A}", true}});
    const std::string idB = fxChainIdentity({{"ReaComp", "{B}", true}});

    // Order of tracks matters, and distinct partitions cannot collide by concatenation.
    CHECK(combineChainIdentities({idA, idB}) != combineChainIdentities({idB, idA}));
    CHECK(combineChainIdentities({idA, ""}) != combineChainIdentities({"", idA}));
    // Empty vs single-track vs two-track are all distinct.
    CHECK(combineChainIdentities({}) != combineChainIdentities({idA}));
    CHECK(combineChainIdentities({idA}) != combineChainIdentities({idA, idB}));
    // Deterministic.
    CHECK(combineChainIdentities({idA, idB}) == combineChainIdentities({idA, idB}));
}

// --- malformed parse ---------------------------------------------------------

static void testMalformedFingerprint() {
    CHECK(!parseFingerprint("").has_value());               // empty
    CHECK(!parseFingerprint("garbage").has_value());        // wrong magic
    CHECK(!parseFingerprint("rsprov0...").has_value());     // wrong version tag
    // Right magic, truncated body (no fields).
    CHECK(!parseFingerprint("rsprov1").has_value());
    // A length prefix that runs past the end.
    CHECK(!parseFingerprint("rsprov199:short").has_value());
    // A valid fingerprint with trailing garbage appended is rejected.
    const std::string good = buildFingerprint(baseRecipe());
    CHECK(!parseFingerprint(good + "TRAILING").has_value());
    // An out-of-range scope value is rejected.
    CHECK(!parseFingerprint("rsprov11:9" "1:0" "1:0" "1:0" "1:0" "1:0" "1:0" "1:0"
                            "1:0" "0:").has_value());
}

// --- Q-W0 T2-01a: hardened wire cursor (backported from assignment_request /
//     sample_usage) — corrupt or crafted persisted fingerprints must fail the parse
//     cleanly (nullopt), never wrap an integer, never throw, never over-allocate. ----

// Mirrors buildFingerprint's field order with benign values, except the GUID-count
// field carries caller-supplied raw text — the attack surface under test.
static void putF(std::string& out, const std::string& f) {
    out += std::to_string(f.size());
    out += ':';
    out += f;
}
static std::string forgedFingerprint(const std::string& guidCountText) {
    std::string out = "rsprov1";
    putF(out, "0");      // scope = Item
    putF(out, "0");      // sourceMode
    putF(out, "0");      // startSeconds
    putF(out, "1");      // endSeconds
    putF(out, "0");      // tailMode
    putF(out, "0");      // tailMs
    putF(out, "48000");  // sampleRate
    putF(out, "2");      // channelCount
    putF(out, guidCountText);  // GUID count (no GUID fields follow)
    putF(out, "");       // fxChainIdentity (empty)
    return out;
}

static void testHardenedCursorRejectsHostileLengths() {
    // A 200-digit length run: pre-hardening the accumulate wrapped std::size_t silently
    // (the digit cap + overflow guard now reject it outright).
    CHECK(!parseFingerprint("rsprov1" + std::string(200, '9') + ":x").has_value());
    // A SIZE_MAX-magnitude length: the additive bounds check `start + len > size` could
    // itself wrap and pass; the subtraction-first form rejects.
    CHECK(!parseFingerprint("rsprov118446744073709551615:x").has_value());
    // One past SIZE_MAX: the per-digit overflow guard fires during the accumulate.
    CHECK(!parseFingerprint("rsprov118446744073709551616:x").has_value());
}

static void testHugeGuidCountRejectedBeforeReserve() {
    // A GUID count astronomically larger than the wire could hold must return nullopt
    // WITHOUT reaching trackGuids.reserve(count) — pre-fix this drove reserve(10^16)
    // into std::length_error / bad_alloc thrown through the shell.
    CHECK(!parseFingerprint(forgedFingerprint("9999999999999999")).has_value());
    // A count merely past the wire-size sanity bound (each GUID field needs >= 2 wire
    // bytes) is provably bogus and rejected before the field loop.
    CHECK(!parseFingerprint(forgedFingerprint("1000")).has_value());
    // A digit run past 20 fails the count parser's cap.
    CHECK(!parseFingerprint(forgedFingerprint(std::string(25, '9'))).has_value());
    // Sanity (non-vacuous forgery): the honest zero-count version of the same forged
    // shape parses fine — the rejections above are the count's doing, not the shape's.
    CHECK(parseFingerprint(forgedFingerprint("0")).has_value());
}

// --- recorded-recipe model round-trips through the Sample JSON ----------------
// The fingerprint rides in Provenance.fxChainSnapshot (one string), which M1's
// BankModel JSON already round-trips. Prove a real recipe survives that path intact.

static void testRecipeThroughSampleJson() {
    const CaptureRecipe r = baseRecipe();

    Sample s;
    s.id = "child-1";
    s.relativePath = "reasampler_bank/child.wav";
    s.contentHash = "hash-child";
    Provenance prov;
    prov.parentSampleId = "sample-A";
    prov.fxChainSnapshot = buildFingerprint(r);
    s.provenance = prov;

    BankModel idx;
    CHECK(idx.add(s) == AddResult::Added);

    auto back = BankModel::deserialize(idx.serialize());
    CHECK(back.has_value());
    const Sample* child = back ? back->query("child-1") : nullptr;
    CHECK(child != nullptr);
    CHECK(child && child->provenance.has_value());
    CHECK(child && child->provenance->parentSampleId == "sample-A");

    // The fingerprint string survived byte-for-byte AND re-parses to the recipe.
    if (child && child->provenance) {
        auto recovered = parseFingerprint(child->provenance->fxChainSnapshot);
        CHECK(recovered.has_value());
        CHECK(recovered && *recovered == r);
    }
}

// --- parent detection --------------------------------------------------------

static std::vector<BankFileRef> bank() {
    return {
        {"sample-A", "c:/proj/reasampler_bank/a.wav"},
        {"sample-B", "c:/proj/reasampler_bank/b.wav"},
    };
}

static void testDetectParentPositive() {
    // A single source item resolving to a bank file -> that sample is the parent.
    auto p = detectParent({"c:/proj/reasampler_bank/a.wav"}, bank());
    CHECK(p.has_value());
    CHECK(*p == "sample-A");
}

static void testDetectParentMultipleSameParent() {
    // Two source items both from the SAME bank sample -> still that parent (a track
    // capture whose items all came from one bank file).
    auto p = detectParent(
        {"c:/proj/reasampler_bank/b.wav", "c:/proj/reasampler_bank/b.wav"}, bank());
    CHECK(p.has_value());
    CHECK(*p == "sample-B");
}

static void testDetectParentNegativeNotInBank() {
    // A source file that is not a bank file -> no parent (a fresh, non-resample capture).
    auto p = detectParent({"c:/proj/audio/live-recording.wav"}, bank());
    CHECK(!p.has_value());
}

static void testDetectParentAmbiguous() {
    // Sources spanning two DIFFERENT bank samples -> ambiguous, record no parent
    // (honest: we will not guess which one is "the" parent).
    auto p = detectParent(
        {"c:/proj/reasampler_bank/a.wav", "c:/proj/reasampler_bank/b.wav"}, bank());
    CHECK(!p.has_value());
}

static void testDetectParentMixedBankAndNonBank() {
    // One source is a bank file, another is not -> not a clean resample -> no parent.
    auto p = detectParent(
        {"c:/proj/reasampler_bank/a.wav", "c:/proj/audio/other.wav"}, bank());
    CHECK(!p.has_value());
}

static void testDetectParentEmptySources() {
    CHECK(!detectParent({}, bank()).has_value());
}

static void testDetectParentEmptyBank() {
    // Edge: the bank has no files (e.g. the sample's file record is missing / bank
    // empty) -> nothing matches -> no parent.
    CHECK(!detectParent({"c:/proj/reasampler_bank/a.wav"}, {}).has_value());
    // A bank ref with an empty path never matches (guards against a null resolve).
    std::vector<BankFileRef> holey = {{"sample-X", ""}};
    CHECK(!detectParent({""}, holey).has_value());
    CHECK(!detectParent({"c:/proj/reasampler_bank/a.wav"}, holey).has_value());
}

int main() {
    testFingerprintRoundTrip();
    testFingerprintRoundTripEmptyFields();
    testFingerprintRoundTripHostileStrings();
    testIdenticalInputsEqualFingerprints();
    testSingleComponentChangesMismatch();
    testFxChainIdentityOrderSensitive();
    testFxChainIdentityFieldsMatter();
    testFxChainIdentityEmptyStable();
    testFxChainIdentityInjectionProof();
    testCombineChainIdentities();
    testMalformedFingerprint();
    testHardenedCursorRejectsHostileLengths();
    testHugeGuidCountRejectedBeforeReserve();
    testRecipeThroughSampleJson();
    testDetectParentPositive();
    testDetectParentMultipleSameParent();
    testDetectParentNegativeNotInBank();
    testDetectParentAmbiguous();
    testDetectParentMixedBankAndNonBank();
    testDetectParentEmptySources();
    testDetectParentEmptyBank();

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