// Standalone tests for shell/package/import_landing — no REAPER, no framework, real
// package bytes on a real filesystem. Packages are FRAMED BY HAND (not by
// encodePackage) so the version-ladder suites can dial formatVersion and
// minReaderVersion independently, and so an encode-side regression cannot hide the
// import's behaviour from itself.

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

#include <cstdint>
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <string>
#include <vector>

#include "../src/core/capture/wav_codec.h"
#include "../src/core/package/bank_package.h"
#include "../src/core/tracking/origin_ledger.h"
#include "../src/core/version/app_version.h"
#include "../src/shell/package/package_path.h"

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

static const char* kTag = "1754000000";
static const char* kBankId = "import-test-bank";

// --- scratch project ---------------------------------------------------------

// One project directory per suite, torn down after, so no suite can observe another's
// bank folder in listFolderFileNames.
class Scratch {
public:
    explicit Scratch(const std::string& name)
        : dir_(pathToUtf8(fs::current_path() / utf8Path("import_scratch_" + name))) {
        std::error_code ec;
        fs::remove_all(utf8Path(dir_), ec);
        fs::create_directories(utf8Path(dir_), ec);
    }
    ~Scratch() {
        std::error_code ec;
        fs::remove_all(utf8Path(dir_), ec);
    }
    const std::string& projectDir() const { return dir_; }
    std::string bankDir() const { return bankFolderDir(dir_); }
    std::string packagePath() const { return dir_ + "/bank.rsbank"; }

    std::vector<std::string> bankFiles() const {
        std::vector<std::string> out;
        std::error_code ec;
        for (const auto& e : fs::directory_iterator(utf8Path(bankDir()), ec))
            if (e.is_regular_file(ec)) out.push_back(pathToUtf8(e.path().filename()));
        return out;
    }

private:
    std::string dir_;
};

static void writeBytes(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> readBytes(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>());
}

// --- hand-rolled package framing --------------------------------------------

static void putU32(std::vector<std::uint8_t>& out, std::uint32_t v) {
    for (int b = 0; b < 4; ++b) out.push_back(static_cast<std::uint8_t>((v >> (b * 8)) & 0xFFu));
}

static std::vector<std::uint8_t> frame(std::uint32_t formatVersion,
                                       std::uint32_t minReaderVersion,
                                       const std::string& writerSemver,
                                       const std::string& manifestJson,
                                       const std::vector<std::vector<std::uint8_t>>& payloads) {
    std::vector<std::uint8_t> out(kPackageMagic, kPackageMagic + 4);
    putU32(out, formatVersion);
    putU32(out, minReaderVersion);
    putU32(out, static_cast<std::uint32_t>(writerSemver.size()));
    out.insert(out.end(), writerSemver.begin(), writerSemver.end());
    putU32(out, static_cast<std::uint32_t>(manifestJson.size()));
    out.insert(out.end(), manifestJson.begin(), manifestJson.end());
    for (const auto& p : payloads) out.insert(out.end(), p.begin(), p.end());
    return out;
}

static std::vector<std::uint8_t> payloadOf(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 * 13u);
    return v;
}

struct Fixture {
    PackageManifest manifest;
    std::vector<std::vector<std::uint8_t>> payloads;
};

static void addEntry(Fixture& f, const std::string& fileName, const std::string& id,
                     const std::string& contentHash, std::uint8_t seed) {
    std::vector<std::uint8_t> payload = payloadOf(48 + seed, seed);
    PackageEntry e;
    e.fileName = fileName;
    e.byteLength = payload.size();
    e.byteHash = capture::hashBytes(payload.data(), payload.size());
    e.sample.id = id;
    e.sample.displayName = id;
    e.sample.relativePath = "reasampler_bank/" + fileName;
    e.sample.contentHash = contentHash;
    f.manifest.entries.push_back(std::move(e));
    f.payloads.push_back(std::move(payload));
}

static Fixture twoEntryFixture(const std::string& bankName) {
    Fixture f;
    f.manifest.bankDisplayName = bankName;
    addEntry(f, "kick.wav", "cap-kick", "h-kick", 1);
    addEntry(f, "snare.wav", "cap-snare", "h-snare", 2);
    return f;
}

// Frames `f` with this build's own ladder pair unless overridden.
static std::vector<std::uint8_t> packageBytes(const Fixture& f,
                                              std::uint32_t formatVersion = kPackageFormatVersion,
                                              std::uint32_t minReader = kPackageMinReaderVersion,
                                              const std::string& manifestOverride = {}) {
    const auto json = serializeManifest(f.manifest);
    const std::string body = manifestOverride.empty() ? *json : manifestOverride;
    return frame(formatVersion, minReader, version::stampVersion(), body, f.payloads);
}

// --- the sequence the verb runs, minus REAPER --------------------------------

struct RunResult {
    ImportLanding landing;
    bool applied = false;
    tracking::OriginLedger ledger;
};

static RunResult runImport(const Scratch& scratch, BankBook& book,
                           const std::string& bankId = kBankId) {
    RunResult r;
    LandedFileJournal journal;
    r.landing = landPackage(scratch.packagePath(), scratch.projectDir(), book, kTag, journal);
    if (r.landing.outcome != ImportOutcome::Landed) return r;
    r.applied = applyImportedBank(book, bankId, r.landing.plan, [&r](const Sample& s) {
        tracking::OriginRecord rec;
        rec.relativePath = s.relativePath;
        rec.kind = tracking::OriginKind::PackageImport;
        rec.sampleId = s.id;
        r.ledger.record(rec);
    });
    if (r.applied) journal.markIndexCommitted();
    return r;
}

// --- suites ------------------------------------------------------------------

static void testCleanImportLandsEveryPayloadByteExact() {
    Scratch scratch("clean");
    const Fixture f = twoEntryFixture("Drums");
    writeBytes(scratch.packagePath(), packageBytes(f));

    BankBook book;
    const RunResult r = runImport(scratch, book);

    CHECK(r.landing.outcome == ImportOutcome::Landed);
    CHECK(r.applied);
    CHECK(scratch.bankFiles().size() == 2);
    for (std::size_t i = 0; i < 2; ++i) {
        const std::string name = r.landing.plan.entries[i].destFileName;
        CHECK(readBytes(scratch.bankDir() + "/" + name) == f.payloads[i]);
    }
    const Bank* bank = book.bank(kBankId);
    CHECK(bank != nullptr && bank->displayName == "Drums");
    CHECK(bank->index.size() == 2);
    CHECK(PayloadBuffer::alive() == 0);
}

static void testEveryLandedFileHasAPackageImportBirthRecord() {
    Scratch scratch("births");
    const Fixture f = twoEntryFixture("Drums");
    writeBytes(scratch.packagePath(), packageBytes(f));

    BankBook book;
    const RunResult r = runImport(scratch, book);
    CHECK(r.applied);

    // Read the ledger back rather than counting calls.
    CHECK(r.ledger.size() == 2);
    for (const std::string& name : scratch.bankFiles()) {
        const tracking::OriginRecord* rec =
            r.ledger.find("reasampler_bank/" + name);
        CHECK(rec != nullptr);
        if (rec) CHECK(rec->kind == tracking::OriginKind::PackageImport);
    }
}

static void testACollapsedEntryLeavesNoFileBehind() {
    Scratch scratch("collapse");
    Fixture f;
    f.manifest.bankDisplayName = "Drums";
    addEntry(f, "kick.wav", "cap-a", "same-hash", 1);
    addEntry(f, "kick_copy.wav", "cap-b", "same-hash", 2);
    writeBytes(scratch.packagePath(), packageBytes(f));

    BankBook book;
    const RunResult r = runImport(scratch, book);
    CHECK(r.landing.outcome == ImportOutcome::Landed);
    // One file, one entry, one birth record — the dedup never manufactured an orphan.
    CHECK(scratch.bankFiles().size() == 1);
    CHECK(book.bank(kBankId)->index.size() == 1);
    CHECK(r.ledger.size() == 1);
}

static void testHashMismatchLandsNothingAndMutatesNothing() {
    Scratch scratch("integrity");
    const Fixture f = twoEntryFixture("Drums");
    std::vector<std::uint8_t> bytes = packageBytes(f);
    bytes.back() ^= 0xFFu;  // corrupt the LAST entry's payload
    writeBytes(scratch.packagePath(), bytes);

    BankBook book;
    const BankBook before = book;
    const RunResult r = runImport(scratch, book);

    CHECK(r.landing.outcome == ImportOutcome::IntegrityFailed);
    CHECK(r.landing.failedEntryName == "snare.wav");
    // Refused BEFORE landing anything, not landed-then-rolled-back: the bank folder is
    // created on the way into the write loop, so its absence dates the refusal.
    CHECK(!fs::exists(utf8Path(scratch.bankDir())));
    CHECK(r.landing.rollback.deletedCount == 0);
    CHECK(book == before);  // zero index mutation
    CHECK(!r.applied);
}

static void testWriteFailureAtEntryTwoRollsBackEntryOne() {
    Scratch scratch("rollback");
    const Fixture f = twoEntryFixture("Drums");
    writeBytes(scratch.packagePath(), packageBytes(f));

    // Occupy the second entry's destination with a DIRECTORY: listFolderFileNames sees
    // regular files only, so the plan does not rename around it, and the exclusive
    // create then fails exactly where the injection wants it.
    std::error_code ec;
    fs::create_directories(utf8Path(scratch.bankDir()), ec);
    fs::create_directory(utf8Path(scratch.bankDir() + "/snare.wav"), ec);

    BankBook book;
    const BankBook before = book;
    const RunResult r = runImport(scratch, book);

    CHECK(r.landing.outcome == ImportOutcome::WriteFailed);
    CHECK(r.landing.failedEntryName == "snare.wav");
    CHECK(r.landing.rollback.deletedCount == 1);   // entry one was rolled back
    CHECK(scratch.bankFiles().empty());            // nothing from this import survives
    CHECK(book == before);
    CHECK(PayloadBuffer::alive() == 0);
}

static void testTooNewRefusesWholeAndNamesTheWriter() {
    Scratch scratch("toonew");
    const Fixture f = twoEntryFixture("Drums");
    writeBytes(scratch.packagePath(),
               packageBytes(f, kPackageFormatVersion + 1, kPackageFormatVersion + 1));

    BankBook book;
    const BankBook before = book;
    const RunResult r = runImport(scratch, book);

    CHECK(r.landing.outcome == ImportOutcome::TooNew);
    // All three facts the refusal must name are available from the landing.
    CHECK(r.landing.header.minReaderVersion == kPackageFormatVersion + 1);
    CHECK(r.landing.header.writerVersion == version::stampVersion());
    // The refusal returns before the bank folder is even created.
    CHECK(!fs::exists(utf8Path(scratch.bankDir())));
    CHECK(book == before);
}

static void testNewerFormatVersionStillImportsWhenTheReaderIsReachable() {
    Scratch scratch("additive");
    const Fixture f = twoEntryFixture("Drums");
    // An additive newer writer: formatVersion moved, minReaderVersion did not, and the
    // manifest carries a key this build has never heard of.
    std::string json = *serializeManifest(f.manifest);
    CHECK(!json.empty() && json.front() == '{');
    json.insert(1, "\"futureKey\":{\"nested\":[1,2,3]},");
    writeBytes(scratch.packagePath(),
               packageBytes(f, kPackageFormatVersion + 1, kPackageMinReaderVersion, json));

    BankBook book;
    const RunResult r = runImport(scratch, book);

    CHECK(r.landing.outcome == ImportOutcome::Landed);
    CHECK(r.landing.header.formatVersion == kPackageFormatVersion + 1);
    CHECK(book.bank(kBankId)->index.size() == 2);
    CHECK(scratch.bankFiles().size() == 2);
}

static void testTruncationAndGarbageReportMalformedNotTooNew() {
    {
        Scratch scratch("truncated");
        const Fixture f = twoEntryFixture("Drums");
        std::vector<std::uint8_t> bytes = packageBytes(f);
        bytes.pop_back();
        writeBytes(scratch.packagePath(), bytes);

        BankBook book;
        const BankBook before = book;
        const RunResult r = runImport(scratch, book);
        CHECK(r.landing.outcome == ImportOutcome::Malformed);
        CHECK(book == before);
    }
    {
        Scratch scratch("garbage");
        writeBytes(scratch.packagePath(), payloadOf(200, 9));
        BankBook book;
        CHECK(runImport(scratch, book).landing.outcome == ImportOutcome::Malformed);
    }
}

static void testMissingPackageFileIsUnreadableNotMalformed() {
    Scratch scratch("absent");
    BankBook book;
    CHECK(runImport(scratch, book).landing.outcome == ImportOutcome::Unreadable);
}

static void testUnsavedProjectRefusesBeforeAnythingIsRead() {
    Scratch scratch("noproject");
    writeBytes(scratch.packagePath(), packageBytes(twoEntryFixture("Drums")));
    BankBook book;
    LandedFileJournal journal;
    const ImportLanding landing =
        landPackage(scratch.packagePath(), std::string{}, book, kTag, journal);
    CHECK(landing.outcome == ImportOutcome::NoProject);
}

static void testReimportingIntoTheSourceProjectLandsBesideAnUntouchedOriginal() {
    Scratch scratch("roundtrip");
    const Fixture f = twoEntryFixture("B");
    writeBytes(scratch.packagePath(), packageBytes(f));

    // A project that already holds bank "B" with the package's own entries.
    BankBook book;
    book.createBank("bank-b", "B");
    for (const PackageEntry& e : f.manifest.entries) book.index("bank-b")->add(e.sample);
    const BankModel originalB = *book.index("bank-b");

    const RunResult second = runImport(scratch, book, "bank-b2");
    CHECK(second.landing.outcome == ImportOutcome::Landed);
    CHECK(book.bank("bank-b2")->displayName == "B 2");
    CHECK(book.bank("bank-b2")->index.size() == 2);
    CHECK(*book.index("bank-b") == originalB);  // B itself unmutated
    for (const Sample& s : book.index("bank-b2")->all()) {
        CHECK(s.id.rfind(kImportIdPrefix, 0) == 0);
        CHECK(s.id != "cap-kick" && s.id != "cap-snare");
    }

    const RunResult third = runImport(scratch, book, "bank-b3");
    CHECK(third.landing.outcome == ImportOutcome::Landed);
    CHECK(book.bank("bank-b3")->displayName == "B 3");
    CHECK(*book.index("bank-b") == originalB);
    // Four distinct files: two per re-import, never overwritten. Bank "B"'s own two
    // entries were seeded index-only above (book.index("bank-b")->add), never written
    // to disk, so they don't add to this count.
    CHECK(scratch.bankFiles().size() == 4);
}

int main() {
    testCleanImportLandsEveryPayloadByteExact();
    testEveryLandedFileHasAPackageImportBirthRecord();
    testACollapsedEntryLeavesNoFileBehind();
    testHashMismatchLandsNothingAndMutatesNothing();
    testWriteFailureAtEntryTwoRollsBackEntryOne();
    testTooNewRefusesWholeAndNamesTheWriter();
    testNewerFormatVersionStillImportsWhenTheReaderIsReachable();
    testTruncationAndGarbageReportMalformedNotTooNew();
    testMissingPackageFileIsUnreadableNotMalformed();
    testUnsavedProjectRefusesBeforeAnythingIsRead();
    testReimportingIntoTheSourceProjectLandsBesideAnUntouchedOriginal();

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