// Standalone tests for shell/package/package_rollback — no REAPER, no framework.
// Pins both halves of the deletion discriminator: the structural half (only an
// exclusively-created path is recorded, and the record is absolute so a CWD change
// cannot re-aim it) and the contract half (markIndexCommitted disarms rollback).

#include "../src/shell/package/package_rollback.h"
#include "../src/shell/package/package_path.h"

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

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)

// The journal records absolute paths, so every expectation is built the same way.
static std::string scratch(const std::string& name) {
    return pathToUtf8(fs::current_path() / utf8Path(name));
}

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

static void removeQuietly(const std::string& path) {
    std::error_code ec;
    fs::remove(utf8Path(path), ec);
}

static void testLandRecordsOnSuccessOnly() {
    LandedFileJournal journal;
    const std::string path = scratch("rb_land.bin");
    const std::vector<std::uint8_t> bytes = patternBytes(32, 1);
    CHECK(journal.writeLandedFile(path, PayloadBuffer(bytes)));
    CHECK(readAll(path) == bytes);
    CHECK(journal.landedPaths().size() == 1);
    CHECK(!exists(path + ".rsbanktmp"));  // the land is a direct exclusive create
    journal.rollback();
    CHECK(!exists(path));  // the recorded path denoted the file we asked for
}

static void testLandNonAsciiPathRoundTripsAsUtf8() {
    // The fs::absolute -> u8string round trip at writeLandedFile is otherwise
    // untested with a non-ASCII path.
    LandedFileJournal journal;
    const std::string path = scratch("rb_caf\xC3\xA9.bin");
    const std::vector<std::uint8_t> bytes = patternBytes(16, 2);
    CHECK(journal.writeLandedFile(path, PayloadBuffer(bytes)));
    CHECK(readAll(path) == bytes);
    journal.rollback();
    CHECK(!exists(path));
}

static void testRelativeInputIsRecordedAbsolute() {
    // The hazard: a bare name recorded verbatim, then a CWD change, and rollback
    // unlinks whatever now sits at that name in the new directory.
    LandedFileJournal journal;
    CHECK(journal.writeLandedFile("rb_relative.bin", PayloadBuffer(patternBytes(8, 4))));
    CHECK(journal.landedPaths().size() == 1);
    const std::string recorded = journal.landedPaths().front();
    CHECK(utf8Path(recorded).is_absolute());
    std::error_code ec;
    // Absolute AND still the same file — a spelling check alone would not prove that.
    CHECK(fs::equivalent(utf8Path(recorded), utf8Path(scratch("rb_relative.bin")), ec));
    CHECK(!ec);
    journal.rollback();
    CHECK(!exists(scratch("rb_relative.bin")));
}

static void testExistingDestinationRefusedUntouched() {
    LandedFileJournal journal;
    const std::string path = scratch("rb_existing.bin");
    const std::vector<std::uint8_t> original = patternBytes(16, 0x60);
    writeScratchFile(path, original);

    CHECK(!journal.writeLandedFile(path, PayloadBuffer(patternBytes(8, 1))));
    CHECK(readAll(path) == original);  // never overwritten
    CHECK(journal.empty());            // a refused write is not recorded

    const RollbackResult result = journal.rollback();
    CHECK(result.deletedCount == 0);
    CHECK(exists(path));  // rollback cannot touch a file it did not write
    removeQuietly(path);
}

static void testEmptyPayloadRefused() {
    LandedFileJournal journal;
    const std::string path = scratch("rb_empty.bin");
    CHECK(!journal.writeLandedFile(path, PayloadBuffer{}));
    CHECK(!exists(path));
    CHECK(journal.empty());
}

static void testRollbackDeletesExactlyTheRecordedSet() {
    LandedFileJournal journal;
    const std::string a = scratch("rb_a.bin");
    const std::string c = scratch("rb_c.bin");
    const std::string bystander = scratch("rb_bystander.bin");
    CHECK(journal.writeLandedFile(a, PayloadBuffer(patternBytes(8, 1))));
    CHECK(journal.writeLandedFile(c, PayloadBuffer(patternBytes(8, 2))));
    writeScratchFile(bystander, patternBytes(8, 3));  // not journal-written

    const RollbackResult result = journal.rollback();
    CHECK(result.deletedCount == 2);
    CHECK(result.alreadyAbsentCount == 0);
    CHECK(result.failedCount == 0);
    CHECK(!result.refused);
    CHECK(!exists(a));
    CHECK(!exists(c));
    CHECK(exists(bystander));  // exactly the given files, nothing else
    CHECK(journal.empty());

    const RollbackResult second = journal.rollback();  // cleared: a no-op
    CHECK(second.deletedCount == 0);
    CHECK(exists(bystander));
    removeQuietly(bystander);
}

static void testVanishedFileIsToleratedNotFailed() {
    LandedFileJournal journal;
    const std::string path = scratch("rb_gone.bin");
    CHECK(journal.writeLandedFile(path, PayloadBuffer(patternBytes(8, 1))));
    removeQuietly(path);  // vanished between land and rollback
    CHECK(!exists(path));

    const RollbackResult result = journal.rollback();
    CHECK(result.deletedCount == 0);
    CHECK(result.alreadyAbsentCount == 1);
    CHECK(result.failedCount == 0);
}

static void testIndexCommitDisarmsRollback() {
    // Once the index references these files the carve-out no longer covers them, so a
    // late failure in the verb must not be able to delete indexed bytes.
    LandedFileJournal journal;
    const std::string path = scratch("rb_committed.bin");
    const std::vector<std::uint8_t> bytes = patternBytes(8, 1);
    CHECK(journal.writeLandedFile(path, PayloadBuffer(bytes)));

    journal.markIndexCommitted();
    CHECK(journal.indexCommitted());

    const RollbackResult result = journal.rollback();
    CHECK(result.refused);
    CHECK(result.deletedCount == 0);
    CHECK(readAll(path) == bytes);          // untouched
    CHECK(!journal.empty());                // the record survives the refusal

    // Landing more files after the commit would produce unrollbackable state.
    const std::string late = scratch("rb_late.bin");
    CHECK(!journal.writeLandedFile(late, PayloadBuffer(patternBytes(8, 2))));
    CHECK(!exists(late));

    removeQuietly(path);
}

int main() {
    testLandRecordsOnSuccessOnly();
    testLandNonAsciiPathRoundTripsAsUtf8();
    testRelativeInputIsRecordedAbsolute();
    testExistingDestinationRefusedUntouched();
    testEmptyPayloadRefused();
    testRollbackDeletesExactlyTheRecordedSet();
    testVanishedFileIsToleratedNotFailed();
    testIndexCommitDisarmsRollback();

    if (g_fail == 0) std::printf("package_rollback: all tests passed\n");
    else             std::printf("package_rollback: %d CHECK(s) FAILED\n", g_fail);
    return g_fail == 0 ? 0 : 1;
}
