// The corpus driven through both verbs — no REAPER, no framework. Where
// test_package_compat asserts what the frozen bytes DECODE to, this file asserts what
// the import and export verbs DO with them: the payload bytes survive a full
// export -> import -> export, and every refusal in the corpus refuses before a planner
// or a filesystem write is reached.

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

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

#include "../src/core/package/bank_package.h"
#include "../src/shell/package/export_bank.h"
#include "../src/shell/package/package_path.h"
#include "../src/shell/persist/session.h"
#include "package_fixtures.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)

static const char* kTag = "1754000000";
static const char* kImportedBankId = "bank-imported";

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

// One project directory per suite, torn down after, so no suite observes another's
// bank folder.
class Scratch {
public:
    explicit Scratch(const std::string& name) {
        std::error_code ec;
        dir_ = pathToUtf8(fs::temp_directory_path(ec) /
                          utf8Path("reasampler_compat_" + name));
        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 package::bankFolderDir(dir_); }
    std::string importPath() const { return dir_ + "/in.rsbank"; }
    std::string exportPath() const { return dir_ + "/out.rsbank"; }

private:
    std::string dir_;
};

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>());
}

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()));
}

// Copies a committed fixture to the path the verb will be pointed at. Fails loudly on a
// size mismatch (empty included): an unreadable OR mangled corpus would otherwise let
// every "must refuse" suite pass, since a garbled fixture still refuses, just not for
// the reason under test.
static bool stageFixture(const Scratch& scratch, const char* fixture,
                         std::size_t expectedSize) {
    const std::vector<std::uint8_t> bytes = packageFixtureBytes(fixture);
    if (bytes.size() != expectedSize) {
        std::printf("FAIL: fixture %s is %zu bytes, expected %zu (path: %s)\n", fixture,
                    bytes.size(), expectedSize, packageFixturePath(fixture).c_str());
        ++g_fail;
        return false;
    }
    writeBytes(scratch.importPath(), bytes);
    return true;
}

// Each entry's payload bytes, sliced out of a whole package file by its own layout.
static std::vector<std::vector<std::uint8_t>> payloadsOf(const std::vector<std::uint8_t>& file) {
    std::vector<std::vector<std::uint8_t>> out;
    const package::DecodedPackage dec = package::decodePackage(file, file.size());
    if (dec.status != package::PackageReadability::Readable) return out;
    for (const package::PackageEntrySpan& span : dec.layout) {
        const auto begin = file.begin() + static_cast<std::ptrdiff_t>(span.offset);
        out.emplace_back(begin, begin + static_cast<std::ptrdiff_t>(span.length));
    }
    return out;
}

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

static ImportLanding runImport(const Scratch& scratch, ReaSamplerSession& session,
                               int* outBirths = nullptr) {
    LandedFileJournal journal;
    ImportLanding landing = landPackage(scratch.importPath(), scratch.projectDir(),
                                        session.book(), kTag, journal);
    if (landing.outcome != ImportOutcome::Landed) return landing;
    int births = 0;
    const bool applied = applyImportedBank(session.book(), kImportedBankId, landing.plan,
                                           [&births](const model::Sample&) { ++births; });
    CHECK(applied);
    if (applied) journal.markIndexCommitted();
    if (outBirths) *outBirths = births;
    return landing;
}

// --- the round-trip anchor ---------------------------------------------------

// The fixture IS the first export (written by the shipping build's own export verb), so
// importing and re-exporting it closes export -> import -> export over frozen bytes.
static void testV1FixtureReExportsByteIdenticalPayloads() {
    Scratch scratch("roundtrip");
    if (!stageFixture(scratch, "v1_shipping.rsbank", 1207)) return;

    ReaSamplerSession session;
    int births = 0;
    const ImportLanding landing = runImport(scratch, session, &births);
    CHECK(landing.outcome == ImportOutcome::Landed);
    CHECK(births == 1);
    CHECK(landing.plan.landCount == 1);
    if (landing.plan.entries.size() != 1) { CHECK(false); return; }

    const std::vector<std::vector<std::uint8_t>> sourcePayloads =
        payloadsOf(packageFixtureBytes("v1_shipping.rsbank"));
    if (sourcePayloads.size() != 1) { CHECK(false); return; }

    // The landed file is the package's payload verbatim — the first half of the claim.
    const std::string landed = scratch.bankDir() + "/" + landing.plan.entries[0].destFileName;
    CHECK(readBytes(landed) == sourcePayloads[0]);

    ExportRequest req;
    req.projectDir = scratch.projectDir();
    req.bankId = kImportedBankId;
    req.destAbsPath = scratch.exportPath();
    req.exportTimestamp = 1754200000;
    const ExportOutcome out = exportBank(session, req);
    CHECK(out.status == ExportStatus::Written);
    CHECK(out.entriesWritten == 1);

    // The second half: the re-export's payloads, byte for byte. Entry NAMES may legally
    // differ across the trip — the importer re-spells a bank file and the exporter mints
    // its own transport name (src/core/package/CLAUDE.md) — the payload bytes may not.
    CHECK(payloadsOf(readBytes(scratch.exportPath())) == sourcePayloads);
}

// --- the refusals, at the verb rather than the codec -------------------------

static void testRefuseFixtureRefusesTheWholeImportAndNamesTheWriter() {
    Scratch scratch("refuse");
    if (!stageFixture(scratch, "refuse_structural.rsbank", 1207)) return;

    ReaSamplerSession session;
    const BankBook before = session.book();
    LandedFileJournal journal;
    const ImportLanding landing = landPackage(scratch.importPath(), scratch.projectDir(),
                                              session.book(), kTag, journal);

    CHECK(landing.outcome == ImportOutcome::TooNew);
    // Literal, not kPackageFormatVersion-relative: this is the FIXTURE's frozen pair
    // (2/2), not this build's (see test_package_compat.cpp's file header note).
    CHECK(landing.header.formatVersion == 2);
    CHECK(landing.header.minReaderVersion == 2);
    CHECK(landing.header.writerVersion == "1.9.0");
    // Nothing planned, nothing on disk, nothing in the index.
    CHECK(landing.plan.entries.empty());
    CHECK(!fs::exists(utf8Path(scratch.bankDir())));
    CHECK(session.book() == before);
}

// The same eight cuts test_package_compat classifies, driven through the verb's
// incremental prefix reader — the one caller that can ask requiredPrefixSize for more
// bytes than the file holds. Sizes match test_package_compat.cpp's kTruncations.
struct TruncationFixture {
    const char* file;
    std::size_t size;
};

static const TruncationFixture kTruncationFixtures[] = {
    {"trunc_magic.rsbank", 2},
    {"trunc_version_pair.rsbank", 10},
    {"trunc_writer_semver.rsbank", 18},
    {"trunc_manifest_length.rsbank", 23},
    {"trunc_manifest_body.rsbank", 466},
    {"trunc_payload_start.rsbank", 907},
    {"trunc_payload_middle.rsbank", 1057},
    {"trunc_one_short.rsbank", 1206},
};

static void testEveryTruncationRefusesTheImportAsMalformed() {
    for (const TruncationFixture& fixture : kTruncationFixtures) {
        Scratch scratch(std::string("trunc_") + fixture.file);
        if (!stageFixture(scratch, fixture.file, fixture.size)) continue;

        ReaSamplerSession session;
        const BankBook before = session.book();
        LandedFileJournal journal;
        const ImportLanding landing = landPackage(scratch.importPath(), scratch.projectDir(),
                                                  session.book(), kTag, journal);
        if (landing.outcome != ImportOutcome::Malformed) {
            std::printf("FAIL: %s imported as outcome %d, expected Malformed\n", fixture.file,
                        static_cast<int>(landing.outcome));
            ++g_fail;
        }
        CHECK(landing.plan.entries.empty());
        CHECK(!fs::exists(utf8Path(scratch.bankDir())));
        CHECK(session.book() == before);
    }
}

// Sizes match test_package_compat.cpp's kHostiles.
struct HostileFixtureFile {
    const char* file;
    std::size_t size;
};

static const HostileFixtureFile kHostileFixtures[] = {
    {"hostile_name_dotdot.rsbank", 624},
    {"hostile_name_parent_slash.rsbank", 633},
    {"hostile_name_parent_backslash.rsbank", 634},
    {"hostile_name_subdir_slash.rsbank", 634},
    {"hostile_name_drive_absolute.rsbank", 643},
    {"hostile_name_unc_absolute.rsbank", 646},
    {"hostile_path_dotdot_slash.rsbank", 641},
    {"hostile_path_dotdot_backslash.rsbank", 640},
    {"hostile_path_rooted.rsbank", 635},
    {"hostile_path_drive_absolute.rsbank", 643},
    {"hostile_path_unc_absolute.rsbank", 646},
};

static void testEveryHostileNameIsRefusedBeforeThePlannerRuns() {
    for (const HostileFixtureFile& fixture : kHostileFixtures) {
        Scratch scratch(std::string("hostile_") + fixture.file);
        if (!stageFixture(scratch, fixture.file, fixture.size)) continue;

        ReaSamplerSession session;
        const BankBook before = session.book();
        LandedFileJournal journal;
        const ImportLanding landing = landPackage(scratch.importPath(), scratch.projectDir(),
                                                  session.book(), kTag, journal);
        if (landing.outcome != ImportOutcome::Malformed) {
            std::printf("FAIL: %s imported as outcome %d, expected Malformed\n", fixture.file,
                        static_cast<int>(landing.outcome));
            ++g_fail;
        }
        // planImport is the ONLY producer of a non-empty plan and it runs after the
        // decode — an empty one is how "refused before any planner" is observed here.
        CHECK(landing.plan.entries.empty());
        CHECK(landing.plan.bankDisplayName.empty());
        CHECK(!fs::exists(utf8Path(scratch.bankDir())));
        CHECK(session.book() == before);
    }
}

int main() {
    testV1FixtureReExportsByteIdenticalPayloads();
    testRefuseFixtureRefusesTheWholeImportAndNamesTheWriter();
    testEveryTruncationRefusesTheImportAsMalformed();
    testEveryHostileNameIsRefusedBeforeThePlannerRuns();

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