// Standalone tests for reasampler::package's manifest codec — no REAPER, no
// test framework. The round-trip fixture exercises every manifest field and
// every Sample optional in both present and absent states; the rejection suite
// pins the naming, case-folding and traversal rules on encode AND decode.

#include "../src/core/package/package_manifest.h"

#include <cstdio>
#include <optional>
#include <string>

using namespace reasampler::package;
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)

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

// Every Sample field populated, every optional PRESENT.
static Sample fullSample() {
    Sample s;
    s.id = "smp-full";
    s.displayName = "Kick (wet)";
    s.relativePath = "reasampler_bank/kick.wav";
    s.sourceMode = SourceMode::RazorArea;
    s.sourceRange = {1.25, 3.5, 480.0, 1920.0};
    s.trackGuids = {"{AAA}", "{BBB}"};
    s.wetDry = 0.75;
    s.channelCount = 2;
    s.sampleRate = 48000;
    s.lengthSeconds = 2.25;
    s.lengthBeats = 4.5;
    s.captureTempo = 120.5;
    s.captureTimeSigNum = 7;
    s.captureTimeSigDenom = 8;
    s.key = "F#m";
    s.rootNote = 60;
    s.loop = LoopPoints{100, 4800};
    s.levels = {-0.3, -12.7, -14.0};
    s.clipped = true;
    s.tier = Tier::Archive;
    s.contentHash = "W0123456789abcdef";
    s.provenance = Provenance{"smp-parent", "fx-snapshot"};
    s.createdTimestamp = 1754000000;
    return s;
}

// Every Sample optional ABSENT (key, rootNote, loop, provenance).
static Sample bareSample() {
    Sample s;
    s.id = "smp-bare";
    s.displayName = "Snare";
    s.relativePath = "reasampler_bank/snare.wav";
    s.sourceMode = SourceMode::Realtime;
    s.contentHash = "Wfedcba9876543210";
    s.createdTimestamp = 1754000001;
    return s;
}

static PackageManifest fixture() {
    PackageManifest m;
    m.bankDisplayName = "Drums \"live\"";  // escaping exercised
    m.exportTimestamp = 1754100000;
    m.entries.push_back({"kick.wav", 96000, "1111222233334444", fullSample()});
    m.entries.push_back({"snare.wav", 48000, "5555666677778888", bareSample()});
    m.slots.append("smp-full");
    m.slots.append("smp-bare");
    m.slots.remove("smp-full");  // leaves a gap: slots round-trip must keep it
    return m;
}

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

static void testRoundTripEveryField() {
    const PackageManifest m = fixture();
    auto json = serializeManifest(m);
    CHECK(json.has_value());
    auto back = deserializeManifest(*json);
    CHECK(back.has_value());
    CHECK(*back == m);
    // Spot-check both optional states survived (== above proves it; these name
    // the claim so a failure reads directly).
    CHECK(back->entries[0].sample.loop.has_value());
    CHECK(back->entries[0].sample.provenance.has_value());
    CHECK(!back->entries[1].sample.key.has_value());
    CHECK(!back->entries[1].sample.rootNote.has_value());
    CHECK(back->slots.idAt(0).empty());  // the slot gap survived
    CHECK(back->slots.slotOf("smp-bare") == 1);
}

static void testEmptyManifestRoundTrips() {
    PackageManifest m;
    auto json = serializeManifest(m);
    CHECK(json.has_value());
    auto back = deserializeManifest(*json);
    CHECK(back.has_value());
    CHECK(*back == m);
}

// --- forward compatibility ---------------------------------------------------

static void testUnknownKeysSkippedAtEveryLevel() {
    // A future additive manifest: unknown keys at the root, inside an entry,
    // and inside the nested index blob itself.
    const std::string json =
        "{\"bankName\":\"B\",\"exported\":7,"
        "\"instrumentState\":{\"nested\":[1,2,{\"x\":\"y\"}]},"
        "\"entries\":[{\"name\":\"a.wav\",\"length\":10,\"hash\":\"h\","
        "\"futureField\":\"ignored\","
        "\"index\":{\"version\":1,\"futureIndexField\":42,\"samples\":[{\"id\":\"s1\","
        "\"relativePath\":\"bank/a.wav\"}]}}],"
        "\"slots\":[],\"trailingUnknown\":null}";
    auto m = deserializeManifest(json);
    CHECK(m.has_value());
    CHECK(m->bankDisplayName == "B");
    CHECK(m->exportTimestamp == 7);
    CHECK(m->entries.size() == 1);
    CHECK(m->entries[0].fileName == "a.wav");
    CHECK(m->entries[0].byteLength == 10);
    CHECK(m->entries[0].sample.id == "s1");
}

// --- rejection: entry names, both directions ---------------------------------

static void testEncodeRejectsBadEntryName() {
    for (const char* bad : {"..\\evil.wav", "../evil.wav", "dir/a.wav", "C:\\a.wav", "", ".."}) {
        PackageManifest m = fixture();
        m.entries[0].fileName = bad;
        CHECK(!serializeManifest(m).has_value());
    }
}

// One entry, `name` spliced in as raw manifest text so a hostile spelling
// (escapes included) is expressible — a package is not limited to what encode
// emits.
static std::string oneEntryJson(const std::string& name,
                                const std::string& relativePath = "bank/a.wav") {
    return "{\"entries\":[{\"name\":\"" + name +
           "\",\"length\":1,\"hash\":\"h\","
           "\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\","
           "\"relativePath\":\"" + relativePath + "\"}]}}]}";
}

static void testDecodeRejectsBadEntryName() {
    for (const char* bad : {"..\\\\evil.wav", "../evil.wav", "dir/a.wav", "C:\\\\a.wav", ".."})
        CHECK(!deserializeManifest(oneEntryJson(bad)).has_value());

    // The rest of the rule set through decode — the direction that matters,
    // since a package can arrive from anywhere.
    CHECK(!deserializeManifest(oneEntryJson("CON.wav")).has_value());       // DOS device
    CHECK(!deserializeManifest(oneEntryJson("COM\xC2\xB2.wav")).has_value());  // COM²
    CHECK(!deserializeManifest(oneEntryJson("a.wav ")).has_value());       // trailing space
    CHECK(!deserializeManifest(oneEntryJson("a.wav.")).has_value());       // trailing dot
    CHECK(!deserializeManifest(oneEntryJson("a*b.wav")).has_value());      // reserved char
    // A NUL smuggled in as a JSON escape: the manifest text is legal, the
    // decoded name is not.
    CHECK(!deserializeManifest(oneEntryJson("a\\u0000b.wav")).has_value());
    // Ill-formed UTF-8 as raw bytes.
    CHECK(!deserializeManifest(oneEntryJson("a\xC3.wav")).has_value());
    // A lone surrogate never reaches the name rule — the JSON reader refuses
    // the unpaired \uD800 first. Pinned so that refusal cannot silently become
    // "decoded to U+FFFD and accepted".
    CHECK(!deserializeManifest(oneEntryJson("a\\ud800b.wav")).has_value());
}

static void testDecodeRejectsTraversalInNestedPath() {
    // The one field in the format that CAN express a path. BankModel::add
    // catches the absolute forms only, so ".." arrives unless the package layer
    // refuses it.
    CHECK(!deserializeManifest(oneEntryJson("a.wav", "../../evil.wav")).has_value());
    CHECK(!deserializeManifest(oneEntryJson("a.wav", "bank/../evil.wav")).has_value());
    CHECK(!deserializeManifest(oneEntryJson("a.wav", "..")).has_value());
    // A ".." that is not a whole component still reads.
    CHECK(deserializeManifest(oneEntryJson("a.wav", "take..final/a.wav")).has_value());
}

static void testEncodeRejectsTraversalInNestedPath() {
    PackageManifest m = fixture();
    m.entries[0].sample.relativePath = "../../evil.wav";
    CHECK(!serializeManifest(m).has_value());

    PackageManifest m2 = fixture();
    m2.entries[0].sample.relativePath = "bank/../evil.wav";
    CHECK(!serializeManifest(m2).has_value());
}

static void testDuplicateEntryNamesRejectedBothWays() {
    PackageManifest m = fixture();
    m.entries[1].fileName = m.entries[0].fileName;
    CHECK(!serializeManifest(m).has_value());

    // Case-folded: two names one case-insensitive filesystem extracts onto a
    // single file are one name here too, in both directions.
    PackageManifest folded = fixture();
    folded.entries[1].fileName = "KICK.WAV";  // entries[0] is "kick.wav"
    CHECK(!serializeManifest(folded).has_value());

    // Decode side, from a hand-built duplicate (a hostile package is not
    // limited to what encode emits).
    const std::string dup =
        "{\"entries\":["
        "{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\","
        "\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}]}},"
        "{\"name\":\"A.WAV\",\"length\":2,\"hash\":\"i\","
        "\"index\":{\"version\":1,\"samples\":[{\"id\":\"s2\",\"relativePath\":\"q\"}]}}]}";
    CHECK(!deserializeManifest(dup).has_value());

    // Two names that differ outside the ASCII letters are still two names.
    const std::string distinct =
        "{\"entries\":["
        "{\"name\":\"a1.wav\",\"length\":1,\"hash\":\"h\","
        "\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}]}},"
        "{\"name\":\"a2.wav\",\"length\":2,\"hash\":\"i\","
        "\"index\":{\"version\":1,\"samples\":[{\"id\":\"s2\",\"relativePath\":\"q\"}]}}]}";
    CHECK(deserializeManifest(distinct).has_value());
}

static void testRepeatedRootKeyRejected() {
    // A repeated "entries" must not accumulate into two arrays' worth of
    // entries; the other three assign rather than append, but "which duplicate
    // keys are legal" is one format answer, not four.
    const std::string entriesTwice =
        "{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\","
        "\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}]}}],"
        "\"entries\":[{\"name\":\"b.wav\",\"length\":1,\"hash\":\"h\","
        "\"index\":{\"version\":1,\"samples\":[{\"id\":\"s2\",\"relativePath\":\"q\"}]}}]}";
    CHECK(!deserializeManifest(entriesTwice).has_value());

    CHECK(!deserializeManifest("{\"bankName\":\"A\",\"bankName\":\"B\"}").has_value());
    CHECK(!deserializeManifest("{\"exported\":1,\"exported\":2}").has_value());
    CHECK(!deserializeManifest("{\"slots\":[],\"slots\":[]}").has_value());
    // Unknown keys stay repeatable: they are skipped, and a future format must
    // be free to add them.
    CHECK(deserializeManifest("{\"future\":1,\"future\":2}").has_value());

    // The same answer one level down, so a hostile manifest cannot make two
    // readers disagree about which spelling of an entry field is the real one.
    CHECK(!deserializeManifest(
        "{\"entries\":[{\"name\":\"a.wav\",\"name\":\"b.wav\",\"length\":1,\"hash\":\"h\","
        "\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}]}}]}")
        .has_value());
    CHECK(!deserializeManifest(
        "{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"length\":2,\"hash\":\"h\","
        "\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}]}}]}")
        .has_value());
    CHECK(!deserializeManifest(
        "{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\",\"hash\":\"i\","
        "\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}]}}]}")
        .has_value());
    CHECK(!deserializeManifest(
        "{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\","
        "\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}]},"
        "\"index\":{\"version\":1,\"samples\":[{\"id\":\"s2\",\"relativePath\":\"q\"}]}}]}")
        .has_value());
}

// --- rejection: structural ---------------------------------------------------

// See src/core/package/CLAUDE.md for the shell seam that forces this.
static void testEncodeRejectsZeroLengthEntry() {
    PackageManifest m = fixture();
    m.entries[0].byteLength = 0;
    CHECK(!serializeManifest(m).has_value());
}

// The asymmetry is deliberate: refusing a zero-length entry is an obligation on
// what this layer WRITES, not a claim about what a package may declare. Pinned
// so it is not "fixed" into a decode-side rejection.
static void testDecodeAcceptsZeroLengthEntry() {
    auto m = deserializeManifest(
        "{\"entries\":[{\"name\":\"a.wav\",\"length\":0,\"hash\":\"h\","
        "\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}]}}]}");
    CHECK(m.has_value());
    CHECK(m->entries.size() == 1);
    CHECK(m->entries[0].byteLength == 0);
}

static void testEncodeRejectsUnrepresentableSample() {
    PackageManifest m = fixture();
    m.entries[0].sample.id.clear();  // BankModel::add rejects an empty id
    CHECK(!serializeManifest(m).has_value());

    PackageManifest m2 = fixture();
    m2.entries[0].sample.relativePath = "C:/abs/kick.wav";  // and an absolute path
    CHECK(!serializeManifest(m2).has_value());
}

static void testDecodeRejectsMalformedShapes() {
    CHECK(!deserializeManifest("").has_value());
    CHECK(!deserializeManifest("not json").has_value());
    CHECK(!deserializeManifest("[]").has_value());
    CHECK(!deserializeManifest("{\"entries\":[{}]}").has_value());  // entry missing fields
    // Missing one required entry field apiece.
    CHECK(!deserializeManifest(
        "{\"entries\":[{\"length\":1,\"hash\":\"h\","
        "\"index\":{\"version\":1,\"samples\":[{\"id\":\"s\",\"relativePath\":\"p\"}]}}]}")
        .has_value());
    CHECK(!deserializeManifest(
        "{\"entries\":[{\"name\":\"a.wav\",\"hash\":\"h\","
        "\"index\":{\"version\":1,\"samples\":[{\"id\":\"s\",\"relativePath\":\"p\"}]}}]}")
        .has_value());
    CHECK(!deserializeManifest(
        "{\"entries\":[{\"name\":\"a.wav\",\"length\":1,"
        "\"index\":{\"version\":1,\"samples\":[{\"id\":\"s\",\"relativePath\":\"p\"}]}}]}")
        .has_value());
    CHECK(!deserializeManifest(
        "{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\"}]}").has_value());
    // Negative length.
    CHECK(!deserializeManifest(
        "{\"entries\":[{\"name\":\"a.wav\",\"length\":-1,\"hash\":\"h\","
        "\"index\":{\"version\":1,\"samples\":[{\"id\":\"s\",\"relativePath\":\"p\"}]}}]}")
        .has_value());
    // A nested index that is not exactly one sample (zero and two).
    CHECK(!deserializeManifest(
        "{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\","
        "\"index\":{\"version\":1,\"samples\":[]}}]}").has_value());
    CHECK(!deserializeManifest(
        "{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\","
        "\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"},"
        "{\"id\":\"s2\",\"relativePath\":\"q\"}]}}]}").has_value());
    // A nested sample BankModel::add drops (absolute path) fails the entry —
    // the silent drop must not half-parse into an empty index.
    CHECK(!deserializeManifest(
        "{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\","
        "\"index\":{\"version\":1,\"samples\":[{\"id\":\"s\","
        "\"relativePath\":\"C:/abs.wav\"}]}}]}").has_value());
    // An out-of-range enum inside the nested Sample blob fails the entry: the
    // manifest defines no enum of its own, and bank_model's codec REJECTS an
    // unknown sourceMode/tier rather than degrading — so growing one of those
    // vocabularies is a minReaderVersion bump, not an additive change (see this
    // directory's CLAUDE.md).
    CHECK(!deserializeManifest(
        "{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\","
        "\"index\":{\"version\":1,\"samples\":[{\"id\":\"s\",\"relativePath\":\"p\","
        "\"sourceMode\":99}]}}]}").has_value());
    // Trailing garbage after the root object.
    auto json = serializeManifest(fixture());
    CHECK(json.has_value());
    CHECK(!deserializeManifest(*json + "x").has_value());
    // Trailing garbage after the EMPTY-object shortcut specifically: this path
    // returned early before reaching the eof check, so "{}JUNK" parsed valid.
    CHECK(!deserializeManifest("{}JUNK").has_value());
    CHECK(deserializeManifest("{}").has_value());
    // Truncation at a few JSON-level offsets (byte-level truncation of the whole
    // package is bank_package's suite).
    CHECK(!deserializeManifest(json->substr(0, json->size() / 2)).has_value());
    CHECK(!deserializeManifest(json->substr(0, 1)).has_value());
}

int main() {
    testRoundTripEveryField();
    testEmptyManifestRoundTrips();
    testUnknownKeysSkippedAtEveryLevel();
    testEncodeRejectsBadEntryName();
    testDecodeRejectsBadEntryName();
    testDecodeRejectsTraversalInNestedPath();
    testEncodeRejectsTraversalInNestedPath();
    testDuplicateEntryNamesRejectedBothWays();
    testRepeatedRootKeyRejected();
    testEncodeRejectsZeroLengthEntry();
    testDecodeAcceptsZeroLengthEntry();
    testEncodeRejectsUnrepresentableSample();
    testDecodeRejectsMalformedShapes();

    if (g_fail == 0) {
        std::printf("package_manifest_tests: all passed\n");
        return 0;
    }
    std::printf("package_manifest_tests: %d failure(s)\n", g_fail);
    return 1;
}
