// Standalone tests for shell/package/export_bank — no REAPER, no framework. The
// export reads the session through inline accessors only, so a real ReaSamplerSession
// and a real bank folder on disk are both constructible here.
//
// Mid-stream failure is INJECTED rather than simulated: the digest pass and the
// stream pass are separate public calls, so a source file removed or rewritten
// between them is exactly the concurrent-edit case the stream pass re-checks for.

#include "../src/shell/package/export_bank.h"

#include <algorithm>
#include <cctype>
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <optional>
#include <string>
#include <vector>

#include "../src/core/capture/wav_codec.h"
#include "../src/core/model/bank_book.h"
#include "../src/core/package/bank_package.h"
#include "../src/shell/package/package_io.h"
#include "../src/shell/package/package_path.h"
#include "../src/shell/persist/session.h"

using namespace reasampler;
namespace fs = std::filesystem;

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

// --- scratch filesystem -------------------------------------------------------

static std::string g_root;

static std::string scratchRoot() {
    if (g_root.empty()) {
        std::error_code ec;
        const fs::path p = fs::temp_directory_path(ec) / "reasampler_export_tests";
        fs::remove_all(p, ec);
        fs::create_directories(p, ec);
        g_root = p.generic_string();
    }
    return g_root;
}

static std::vector<std::uint8_t> patternBytes(std::size_t n, std::uint8_t seed) {
    std::vector<std::uint8_t> v(n);
    for (std::size_t i = 0; i < n; ++i) v[i] = static_cast<std::uint8_t>(seed + i * 7u);
    return v;
}

static void writeFile(const std::string& path, const std::vector<std::uint8_t>& bytes) {
    std::ofstream f(utf8Path(path), std::ios::binary | std::ios::trunc);
    f.write(reinterpret_cast<const char*>(bytes.data()),
            static_cast<std::streamsize>(bytes.size()));
}

static std::vector<std::uint8_t> readFile(const std::string& path) {
    std::ifstream f(utf8Path(path), std::ios::binary);
    return std::vector<std::uint8_t>(std::istreambuf_iterator<char>(f),
                                     std::istreambuf_iterator<char>());
}

static bool exists(const std::string& path) { return fs::exists(utf8Path(path)); }

// --- fixture project ----------------------------------------------------------

// One scratch project directory with a bank folder, plus the session whose book
// names its contents. `fileNames` are written into the bank folder with distinct
// byte patterns; a name in `omit` gets an index entry but NO file on disk.
struct Fixture {
    std::string projectDir;
    ReaSamplerSession session;
    std::string bankId = "bank-1";

    explicit Fixture(const std::string& tag) {
        projectDir = scratchRoot() + "/" + tag;
        std::error_code ec;
        fs::create_directories(utf8Path(projectDir + "/reasampler_bank"), ec);
        session.book().createBank(bankId, "Drums " + tag);
    }

    void addSample(const std::string& id, const std::string& fileName,
                   std::size_t bytes, std::uint8_t seed, bool writeToDisk = true,
                   const std::string& displayName = "") {
        model::Sample s;
        s.id = id;
        s.displayName = displayName.empty() ? id : displayName;
        s.relativePath = std::string("reasampler_bank/") + fileName;
        s.sampleRate = 48000;
        s.channelCount = 2;
        s.contentHash = "hash-" + id;
        CHECK(session.book().index(bankId)->add(s) == model::AddResult::Added);
        session.book().reconcileSlots();
        if (writeToDisk) writeFile(absPathOf(fileName), patternBytes(bytes, seed));
    }

    std::string absPathOf(const std::string& fileName) const {
        return projectDir + "/reasampler_bank/" + fileName;
    }
    std::string destPath() const { return projectDir + "/out.rsbank"; }

    ExportRequest request() const {
        ExportRequest req;
        req.projectDir = projectDir;
        req.bankId = bankId;
        req.destAbsPath = destPath();
        req.exportTimestamp = 1234567890;
        return req;
    }
};

// --- package readback ---------------------------------------------------------

// Decodes an emitted package straight off disk, growing the prefix read the way the
// format's own requiredPrefixSize seam asks callers to.
static package::DecodedPackage decodeFromDisk(const std::string& path) {
    PackageFileReader reader(path);
    const std::uint64_t size = reader.fileSize();
    std::vector<std::uint8_t> prefix;
    for (int guard = 0; guard < 8; ++guard) {
        const std::optional<std::uint64_t> need = package::requiredPrefixSize(prefix);
        if (!need) break;
        if (*need <= prefix.size()) break;
        PayloadBuffer buf = reader.readRange(0, *need);
        if (buf.empty()) break;
        prefix.assign(buf.data(), buf.data() + buf.size());
    }
    return package::decodePackage(prefix, size);
}

// --- tests --------------------------------------------------------------------

static void testHealthyExportCarriesEveryPayloadByteExact() {
    Fixture fx("healthy");
    fx.addSample("s1", "kick.wav", 800, 1);
    fx.addSample("s2", "snare.wav", 1300, 60);
    fx.addSample("s3", "hat.wav", 97, 200);

    const ExportOutcome out = exportBank(fx.session, fx.request());
    CHECK(out.status == ExportStatus::Written);
    CHECK(out.entriesWritten == 3);
    CHECK(out.excluded.empty());
    CHECK(PayloadBuffer::alive() == 0);
    // Point-in-time alive() == 0 alone cannot fail on a whole-package-in-memory
    // shape (N buffers allocated and freed one at a time still ends at 0); the
    // high-water mark can, across this three-entry export and everything the test
    // binary ran before it — it must never exceed the "at most one payload" claim.
    CHECK(PayloadBuffer::highWaterMark() == 1);

    const package::DecodedPackage decoded = decodeFromDisk(fx.destPath());
    CHECK(decoded.status == package::PackageReadability::Readable);
    CHECK(decoded.manifest.entries.size() == 3);
    CHECK(decoded.layout.size() == 3);
    CHECK(decoded.manifest.bankDisplayName == "Drums healthy");
    CHECK(decoded.manifest.exportTimestamp == 1234567890);

    // PER ENTRY, not in aggregate: the digest the package records, the digest of the
    // payload actually stored at that entry's span, and the digest of the source file
    // on disk must all be the same string.
    PackageFileReader reader(fx.destPath());
    const std::vector<std::string> sourceNames = {"kick.wav", "snare.wav", "hat.wav"};
    CHECK(decoded.manifest.entries.size() == sourceNames.size());
    for (std::size_t i = 0; i < decoded.manifest.entries.size(); ++i) {
        const package::PackageEntry& entry = decoded.manifest.entries[i];
        const PayloadBuffer stored = reader.readRange(decoded.layout[i].offset,
                                                      decoded.layout[i].length);
        CHECK(!stored.empty());
        const std::vector<std::uint8_t> source = readFile(fx.absPathOf(sourceNames[i]));
        const std::string sourceDigest = capture::hashBytes(source.data(), source.size());
        CHECK(entry.byteLength == source.size());
        CHECK(entry.byteHash == sourceDigest);
        CHECK(capture::hashBytes(stored.data(), stored.size()) == sourceDigest);
        CHECK(stored.size() == source.size());
        CHECK(std::equal(source.begin(), source.end(), stored.data()));
    }
    CHECK(PayloadBuffer::alive() == 0);
}

// Every occurrence of `"key":"value"` in `json`, value returned raw (escape-aware
// only enough to not stop early on an escaped quote — this file's own writer output
// never nests an unescaped quote, so that is sufficient here).
static std::vector<std::string> jsonStringValuesForKey(const std::string& json,
                                                        const std::string& key) {
    std::vector<std::string> values;
    const std::string marker = "\"" + key + "\":\"";
    std::size_t pos = 0;
    while ((pos = json.find(marker, pos)) != std::string::npos) {
        std::size_t i = pos + marker.size();
        while (i < json.size() && json[i] != '"') {
            if (json[i] == '\\') ++i;  // skip the escaped char too
            ++i;
        }
        values.push_back(json.substr(pos + marker.size(), i - (pos + marker.size())));
        pos = i;
    }
    return values;
}

// True for a value opening with an <alpha>':' drive-relative prefix (":" alone is
// JSON's own key separator, so this is checked on isolated VALUES, never on raw text).
static bool looksLikeDriveForm(const std::string& value) {
    return value.size() >= 2 && std::isalpha(static_cast<unsigned char>(value[0])) &&
          value[1] == ':';
}

static void testEmittedManifestBytesCarryNoPath() {
    Fixture fx("nopath");
    // The bank's own display name AND a sample's displayName each carry a literal
    // '/' — free text, unlike the entry `name` / nested `relativePath` fields this
    // test actually polices (docs/product/bank-package.md:282-289: the destination is
    // derived from the entry name, never from free text). Present in the fixture so
    // the scan below proves it is scoped correctly rather than merely holding by
    // accident on names that happen not to collide with the rule.
    CHECK(fx.session.book().renameBank(fx.bankId, "Drums/Bus"));
    // Both source records carry a directory component; neither may reach the file.
    fx.addSample("s1", "kick.wav", 200, 3, /*writeToDisk=*/true, "Kick / alt take");
    fx.addSample("s2", "snare take 2.wav", 200, 9);
    CHECK(exportBank(fx.session, fx.request()).status == ExportStatus::Written);

    // Locate the MANIFEST REGION of the emitted file by walking the frozen header the
    // way a reader does: magic | fv | minReader | len+semver | len+JSON. The binary
    // length fields are deliberately excluded — a length whose byte happens to be
    // 0x2F is not a separator.
    const std::vector<std::uint8_t> file = readFile(fx.destPath());
    CHECK(file.size() > 20);
    auto le32 = [&](std::size_t at) {
        return static_cast<std::uint32_t>(file[at]) |
               (static_cast<std::uint32_t>(file[at + 1]) << 8) |
               (static_cast<std::uint32_t>(file[at + 2]) << 16) |
               (static_cast<std::uint32_t>(file[at + 3]) << 24);
    };
    const std::uint32_t semverLen = le32(12);
    const std::size_t manifestLenAt = 16 + semverLen;
    const std::uint32_t manifestLen = le32(manifestLenAt);
    const std::size_t manifestAt = manifestLenAt + 4;
    CHECK(manifestAt + manifestLen <= file.size());
    const std::string manifest(reinterpret_cast<const char*>(file.data() + manifestAt),
                               manifestLen);
    CHECK(manifest.find("kick.wav") != std::string::npos);  // the scan is looking at the manifest

    // The boundary, pinned rather than assumed: free text legitimately carries '/'.
    CHECK(manifest.find("Drums/Bus") != std::string::npos);
    CHECK(manifest.find("Kick / alt take") != std::string::npos);

    // The rule itself: scoped to the two fields the importer derives a destination
    // from — the entry `name` and the nested Sample's own `relativePath` — never to
    // `displayName` or the manifest's `bankDisplayName`.
    const std::vector<std::string> names = jsonStringValuesForKey(manifest, "name");
    const std::vector<std::string> relPaths = jsonStringValuesForKey(manifest, "relativePath");
    CHECK(!names.empty());
    CHECK(!relPaths.empty());
    for (const std::string& v : names) {
        CHECK(v.find('/') == std::string::npos);
        CHECK(v.find('\\') == std::string::npos);
        CHECK(v.find("..") == std::string::npos);
        CHECK(!looksLikeDriveForm(v));
    }
    for (const std::string& v : relPaths) {
        CHECK(v.find('/') == std::string::npos);
        CHECK(v.find('\\') == std::string::npos);
        CHECK(v.find("..") == std::string::npos);
        CHECK(!looksLikeDriveForm(v));
    }
}

static void testFailureInjectedMidWriteLeavesNoPackageAndSparesThePriorFile() {
    Fixture fx("midwrite");
    fx.addSample("s1", "kick.wav", 500, 1);
    fx.addSample("s2", "snare.wav", 500, 2);

    const std::vector<std::uint8_t> prior = patternBytes(64, 99);
    writeFile(fx.destPath(), prior);

    ExportSurvey survey = surveyBankExport(fx.session, fx.projectDir, fx.bankId);
    CHECK(survey.plan.verdict == package::ExportVerdict::Ready);
    package::PackageManifest manifest = survey.plan.manifest;
    const std::vector<std::string> sources = {fx.absPathOf("kick.wav"),
                                              fx.absPathOf("snare.wav")};
    std::string failed;
    CHECK(digestSources(manifest, sources, failed));
    const std::optional<package::EncodedPackage> encoded = package::encodePackage(manifest);
    CHECK(encoded.has_value());

    // Injection: the second payload vanishes after the framing that claims it was
    // already encoded, so the failure lands with the prefix and one payload written.
    std::error_code ec;
    fs::remove(utf8Path(fx.absPathOf("snare.wav")), ec);

    const ExportOutcome out =
        writePackageFile(*encoded, manifest, sources, fx.destPath());
    CHECK(out.status == ExportStatus::SourceReadFailed);
    CHECK(out.offendingName == "snare.wav");
    CHECK(readFile(fx.destPath()) == prior);        // the prior file is untouched
    CHECK(!exists(fx.destPath() + ".rsbanktmp"));   // and no debris is left behind
    CHECK(PayloadBuffer::alive() == 0);
}

static void testCommitFailureIsAGenuineMidWriteAbandon() {
    // The mid-READ injection above (a source vanishing between digest and stream) is
    // not what "mid-write" names in export_bank.cpp:115-118/122-125 — those guard a
    // failure IN the write itself: appendPayload's stream going bad, or commit's
    // rename failing. A directory squatting on the destination (test_package_io.cpp's
    // own precedent for a real, not simulated, commit failure) makes every payload
    // stream fine and only the final rename fail.
    Fixture fx("commitfail");
    fx.addSample("s1", "kick.wav", 300, 1);
    fx.addSample("s2", "snare.wav", 300, 2);

    std::error_code ec;
    fs::create_directory(utf8Path(fx.destPath()), ec);
    CHECK(!ec);

    ExportSurvey survey = surveyBankExport(fx.session, fx.projectDir, fx.bankId);
    CHECK(survey.plan.verdict == package::ExportVerdict::Ready);
    package::PackageManifest manifest = survey.plan.manifest;
    const std::vector<std::string> sources = {fx.absPathOf("kick.wav"), fx.absPathOf("snare.wav")};
    std::string failed;
    CHECK(digestSources(manifest, sources, failed));
    const std::optional<package::EncodedPackage> encoded = package::encodePackage(manifest);
    CHECK(encoded.has_value());

    const ExportOutcome out = writePackageFile(*encoded, manifest, sources, fx.destPath());
    CHECK(out.status == ExportStatus::WriteFailed);
    CHECK(fs::is_directory(utf8Path(fx.destPath())));    // the squatting dir is untouched
    CHECK(!exists(fx.destPath() + ".rsbanktmp"));        // commit()'s own self-clean ran
    CHECK(PayloadBuffer::alive() == 0);

    fs::remove(utf8Path(fx.destPath()), ec);
}

static void testPayloadChangedBetweenDigestAndStreamAborts() {
    Fixture fx("changed");
    fx.addSample("s1", "kick.wav", 500, 1);
    CHECK(!exists(fx.destPath()));

    ExportSurvey survey = surveyBankExport(fx.session, fx.projectDir, fx.bankId);
    package::PackageManifest manifest = survey.plan.manifest;
    const std::vector<std::string> sources = {fx.absPathOf("kick.wav")};
    std::string failed;
    CHECK(digestSources(manifest, sources, failed));
    const std::optional<package::EncodedPackage> encoded = package::encodePackage(manifest);
    CHECK(encoded.has_value());

    // Same length, different bytes — only the digest re-check can catch this.
    writeFile(fx.absPathOf("kick.wav"), patternBytes(500, 77));

    const ExportOutcome out =
        writePackageFile(*encoded, manifest, sources, fx.destPath());
    CHECK(out.status == ExportStatus::SourceChanged);
    CHECK(out.offendingName == "kick.wav");
    CHECK(!exists(fx.destPath()));
}

static void testExportTouchesNoProjectState() {
    Fixture fx("readonly");
    fx.addSample("s1", "kick.wav", 400, 5);
    fx.addSample("s2", "snare.wav", 400, 6);

    // saveToActiveProject writes seven keys (shell/persist/ext_state_io.cpp): `banks`,
    // the legacy-key clear, `view_state`, the tail setting, the tracking ledger, the
    // version stamp, and the bank-generation counter. This asserts byte-identity of
    // the three that have an in-memory string to diff (`banks`, `view_state`, the tail
    // setting) plus bankGeneration() (the bank-generation-counter key IS its
    // serialization). The legacy-key clear and the version stamp are session-external,
    // nothing here to diff against. The tracking ledger has no public accessor to diff
    // either, but needs none: exportBank/digestSources/writePackageFile all take the
    // session by `const&`, and ReaSamplerSession::recordCreated — the ledger's one
    // writer (session.h) — is non-const, so it is not reachable through this call at
    // all; the compiler enforces "untouched" here rather than a runtime check proving it.
    const std::string bankBookBefore = fx.session.book().serialize();
    const std::string viewBefore = fx.session.view().serialize();
    const std::string tailBefore = capture::serializeTailSetting(fx.session.tail());
    const std::int64_t generationBefore = fx.session.bankGeneration();

    CHECK(exportBank(fx.session, fx.request()).status == ExportStatus::Written);

    CHECK(fx.session.book().serialize() == bankBookBefore);
    CHECK(fx.session.view().serialize() == viewBefore);
    CHECK(capture::serializeTailSetting(fx.session.tail()) == tailBefore);
    CHECK(fx.session.bankGeneration() == generationBefore);
}

static void testEmptyBankExportsAsAValidZeroEntryPackage() {
    Fixture fx("empty");
    const ExportOutcome out = exportBank(fx.session, fx.request());
    CHECK(out.status == ExportStatus::Written);
    CHECK(out.entriesWritten == 0);

    const package::DecodedPackage decoded = decodeFromDisk(fx.destPath());
    CHECK(decoded.status == package::PackageReadability::Readable);
    CHECK(decoded.manifest.entries.empty());
    CHECK(decoded.layout.empty());
    CHECK(decoded.manifest.bankDisplayName == "Drums empty");
    // The size proof is decodePackage's, and it ran against the real on-disk size.
    CHECK(decoded.prefixSize == readFile(fx.destPath()).size());
}

static void testIncompleteBankRefusesUntilConfirmed() {
    Fixture fx("incomplete");
    fx.addSample("s1", "kick.wav", 300, 1);
    fx.addSample("s2", "gone.wav", 300, 2, /*writeToDisk=*/false);

    ExportRequest req = fx.request();
    const ExportOutcome refused = exportBank(fx.session, req);
    CHECK(refused.status == ExportStatus::RefusedIncomplete);
    CHECK(refused.excluded.size() == 1);
    CHECK(refused.excluded[0].sampleId == "s2");
    CHECK(refused.excluded[0].reason == package::ExclusionReason::FileMissing);
    CHECK(!exists(fx.destPath()));

    req.allowIncomplete = true;
    const ExportOutcome allowed = exportBank(fx.session, req);
    CHECK(allowed.status == ExportStatus::Written);
    CHECK(allowed.entriesWritten == 1);
    CHECK(allowed.excluded.size() == 1);  // the report survives into the summary
    CHECK(decodeFromDisk(fx.destPath()).manifest.entries.size() == 1);
}

static void testExistingDestinationRefusesUntilConfirmed() {
    Fixture fx("overwrite");
    fx.addSample("s1", "kick.wav", 300, 1);
    const std::vector<std::uint8_t> prior = patternBytes(32, 11);
    writeFile(fx.destPath(), prior);

    ExportRequest req = fx.request();
    const ExportOutcome refused = exportBank(fx.session, req);
    CHECK(refused.status == ExportStatus::RefusedDestinationExists);
    CHECK(readFile(fx.destPath()) == prior);

    req.allowOverwrite = true;
    CHECK(exportBank(fx.session, req).status == ExportStatus::Written);
    CHECK(readFile(fx.destPath()) != prior);
}

static void testUnknownBankAndUnsavedProjectAreNamedSeparately() {
    Fixture fx("guards");
    ExportRequest req = fx.request();
    req.bankId = "no-such-bank";
    CHECK(exportBank(fx.session, req).status == ExportStatus::NoSuchBank);

    ExportRequest unsaved = fx.request();
    unsaved.projectDir.clear();
    CHECK(exportBank(fx.session, unsaved).status == ExportStatus::NoProjectDir);
    CHECK(!exists(fx.destPath()));
}

int main() {
    testHealthyExportCarriesEveryPayloadByteExact();
    testEmittedManifestBytesCarryNoPath();
    testFailureInjectedMidWriteLeavesNoPackageAndSparesThePriorFile();
    testCommitFailureIsAGenuineMidWriteAbandon();
    testPayloadChangedBetweenDigestAndStreamAborts();
    testExportTouchesNoProjectState();
    testEmptyBankExportsAsAValidZeroEntryPackage();
    testIncompleteBankRefusesUntilConfirmed();
    testExistingDestinationRefusesUntilConfirmed();
    testUnknownBankAndUnsavedProjectAreNamedSeparately();

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