// Standalone tests for reasampler::package::import_plan — no REAPER, no filesystem,
// no test framework. Every one of the four collision classes (sample id, bank-folder
// file name, content hash, bank display name) is exercised here, which is the point of
// the module: the whole collision rule set is decidable from strings and hashes.

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

#include <cstdio>
#include <string>
#include <vector>

#include "../src/core/tracking/tracking_authority.h"

using namespace reasampler;
using namespace reasampler::package;
using reasampler::model::Sample;
using reasampler::model::SlotMap;

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* kProjectDir = "/proj";
static const char* kTag = "1754000000";

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

static PackageEntry entry(const std::string& fileName, const std::string& id,
                          const std::string& hash) {
    PackageEntry e;
    e.fileName = fileName;
    e.byteLength = 64;
    e.byteHash = "0011223344556677";
    e.sample.id = id;
    e.sample.displayName = id;
    e.sample.relativePath = "reasampler_bank/" + fileName;
    e.sample.contentHash = hash;
    return e;
}

static PackageManifest manifestOf(std::vector<PackageEntry> entries,
                                  const std::string& bankName) {
    PackageManifest m;
    m.bankDisplayName = bankName;
    m.entries = std::move(entries);
    return m;
}

// A book carrying the named banks, in order, each with a caller-supplied id.
static BankBook bookWithBanks(const std::vector<std::string>& names) {
    BankBook book;
    for (std::size_t i = 0; i < names.size(); ++i)
        book.createBank("bank-" + std::to_string(i), names[i]);
    return book;
}

static const PlannedEntry& landed(const ImportPlan& plan, std::size_t manifestIndex) {
    return plan.entries[manifestIndex];
}

// --- the bank-name probe (collision class 4) ---------------------------------

static std::string plannedName(const std::vector<std::string>& existingBanks,
                               const std::string& packageBankName) {
    const BankBook book = bookWithBanks(existingBanks);
    return planImport(manifestOf({}, packageBankName), book, kProjectDir, {}, kTag)
        .bankDisplayName;
}

static void testFreeSeedIsKeptVerbatim() {
    CHECK(plannedName({"Percussion"}, "Drums") == "Drums");
    const ImportPlan plan =
        planImport(manifestOf({}, "Drums"), bookWithBanks({}), kProjectDir, {}, kTag);
    CHECK(!plan.bankNameAdjusted);
    CHECK(plan.seedBankName == "Drums");
}

static void testFoldedCollisionTakesTheFirstSuffix() {
    // The book's fold is case- and whitespace-insensitive, so "drums" blocks "Drums".
    CHECK(plannedName({"drums"}, "Drums") == "Drums 2");
    CHECK(plannedName({"  DRUMS  "}, "Drums") == "Drums 2");

    const ImportPlan plan =
        planImport(manifestOf({}, "Drums"), bookWithBanks({"drums"}), kProjectDir, {}, kTag);
    CHECK(plan.bankNameAdjusted);
    CHECK(plan.seedBankName == "Drums");  // the message needs what was asked for
}

static void testProbeFillsAGap() {
    // First-free-ascending, not highest-plus-one: "Drums 2" is free, so it wins.
    CHECK(plannedName({"Drums", "Drums 3"}, "Drums") == "Drums 2");
}

static void testSeedIsNeverReparsed() {
    // "Drums 2" colliding lands as "Drums 2 2", NOT "Drums 3" — a bare trailing integer
    // cannot be told from a name the user wrote.
    CHECK(plannedName({"Drums 2"}, "Drums 2") == "Drums 2 2");
    CHECK(plannedName({"Kit 808"}, "Kit 808") == "Kit 808 2");
}

static void testBlankRecordedNameFallsBackToTheDefault() {
    CHECK(plannedName({}, "") == kDefaultImportBankName);
    CHECK(plannedName({}, "   \t ") == kDefaultImportBankName);
    // And the fallback is a seed like any other, so a second one suffixes.
    CHECK(plannedName({kDefaultImportBankName}, "") ==
          std::string(kDefaultImportBankName) + " 2");
}

static void testPoolExportLandsAsANamedBank() {
    // The destination's pool always exists and always carries the protected name
    // "Pool", so a pool export imports as a NAMED bank "Pool 2" — intended, not a glitch.
    CHECK(plannedName({}, "Pool") == "Pool 2");
    const ImportPlan plan =
        planImport(manifestOf({}, "Pool"), bookWithBanks({}), kProjectDir, {}, kTag);
    CHECK(plan.bankNameAdjusted);
}

static void testRepeatedImportsWalkTheSuffixUpwards() {
    CHECK(plannedName({"B"}, "B") == "B 2");
    CHECK(plannedName({"B", "B 2"}, "B") == "B 3");
}

// --- sample ids (collision class 1) ------------------------------------------

static void testEveryIdIsRemintedUnderTheImportPrefix() {
    const PackageManifest m = manifestOf({entry("kick.wav", "cap-1-kick.wav", "h1"),
                                          entry("snare.wav", "cap-2-snare.wav", "h2")},
                                         "Drums");
    const ImportPlan plan = planImport(m, bookWithBanks({}), kProjectDir, {}, kTag);

    CHECK(plan.landCount == 2);
    for (const PlannedEntry& e : plan.entries) {
        CHECK(e.sample.id.rfind(kImportIdPrefix, 0) == 0);
        CHECK(e.sample.id != "cap-1-kick.wav");
        CHECK(e.sample.id != "cap-2-snare.wav");
    }
    CHECK(plan.entries[0].sample.id != plan.entries[1].sample.id);
}

static void testReimportingIntoTheSourceProjectRemintsRatherThanCollides() {
    // The package came FROM this project, so its ids are the ones already in use.
    BankBook book = bookWithBanks({"B"});
    Sample existing;
    existing.id = "cap-1-kick.wav";
    existing.relativePath = "reasampler_bank/kick.wav";
    existing.contentHash = "h1";
    book.index("bank-0")->add(existing);

    const PackageManifest m = manifestOf({entry("kick.wav", "cap-1-kick.wav", "h1")}, "B");
    const ImportPlan plan =
        planImport(m, book, kProjectDir, {"kick.wav"}, kTag);

    CHECK(plan.bankDisplayName == "B 2");
    CHECK(landed(plan, 0).sample.id != "cap-1-kick.wav");
    // The hash lives in another bank; cross-bank dedup is deliberately not enforced,
    // so the entry still lands rather than collapsing onto B's copy.
    CHECK(landed(plan, 0).action == EntryAction::Land);
    CHECK(landed(plan, 0).renamed);
}

static void testParentIsRemappedWhenItTravelledInThePackage() {
    PackageEntry parent = entry("kick.wav", "cap-parent", "h1");
    PackageEntry child = entry("kick_r2.wav", "cap-child", "h2");
    child.sample.provenance = model::Provenance{"cap-parent", "fx-snapshot"};

    const ImportPlan plan = planImport(manifestOf({parent, child}, "Drums"),
                                       bookWithBanks({}), kProjectDir, {}, kTag);

    CHECK(landed(plan, 1).sample.provenance.has_value());
    CHECK(landed(plan, 1).sample.provenance->parentSampleId ==
          landed(plan, 0).sample.id);
    CHECK(landed(plan, 1).sample.provenance->fxChainSnapshot == "fx-snapshot");
}

static void testParentIsRemappedEvenWhenItFollowsTheChild() {
    // Manifest order does not constrain lineage, so the remap runs after every id is minted.
    PackageEntry child = entry("kick_r2.wav", "cap-child", "h2");
    child.sample.provenance = model::Provenance{"cap-parent", ""};
    PackageEntry parent = entry("kick.wav", "cap-parent", "h1");

    const ImportPlan plan = planImport(manifestOf({child, parent}, "Drums"),
                                       bookWithBanks({}), kProjectDir, {}, kTag);
    CHECK(landed(plan, 0).sample.provenance->parentSampleId == landed(plan, 1).sample.id);
}

static void testForeignParentIsClearedNotCarried() {
    PackageEntry child = entry("kick.wav", "cap-child", "h1");
    child.sample.provenance = model::Provenance{"cap-not-in-this-package", "fx"};

    const ImportPlan plan = planImport(manifestOf({child}, "Drums"),
                                       bookWithBanks({}), kProjectDir, {}, kTag);
    CHECK(landed(plan, 0).sample.provenance.has_value());
    CHECK(landed(plan, 0).sample.provenance->parentSampleId.empty());
    CHECK(landed(plan, 0).sample.provenance->fxChainSnapshot == "fx");
}

// --- bank-folder file names (collision class 2) ------------------------------

static void testAFreeBankLegalNameIsKept() {
    const ImportPlan plan = planImport(manifestOf({entry("kick.wav", "a", "h1")}, "Drums"),
                                       bookWithBanks({}), kProjectDir,
                                       {"unrelated.wav"}, kTag);
    CHECK(landed(plan, 0).destFileName == "kick.wav");
    CHECK(!landed(plan, 0).renamed);
    CHECK(plan.collisionRenameCount == 0);
    CHECK(plan.sanitizeRenameCount == 0);
    CHECK(landed(plan, 0).sample.relativePath == "reasampler_bank/kick.wav");
}

static void testATakenNameIsMintedFreshAndNeverOverwritten() {
    const ImportPlan plan = planImport(manifestOf({entry("kick.wav", "a", "h1")}, "Drums"),
                                       bookWithBanks({}), kProjectDir,
                                       {"kick.wav"}, kTag);
    CHECK(landed(plan, 0).destFileName != "kick.wav");
    CHECK(landed(plan, 0).renamed);
    // A genuine folder-name collision, not a spelling mint.
    CHECK(plan.collisionRenameCount == 1);
    CHECK(plan.sanitizeRenameCount == 0);
    CHECK(landed(plan, 0).sample.relativePath ==
          "reasampler_bank/" + landed(plan, 0).destFileName);
}

static void testTheFolderNameCheckFoldsAsciiCase() {
    // Windows and the default APFS would land "kick.wav" onto "KICK.WAV".
    const ImportPlan plan = planImport(manifestOf({entry("kick.wav", "a", "h1")}, "Drums"),
                                       bookWithBanks({}), kProjectDir,
                                       {"KICK.WAV"}, kTag);
    CHECK(landed(plan, 0).destFileName != "kick.wav");
    CHECK(landed(plan, 0).renamed);
    // The case-fold hit is still a collision, not a spelling mint.
    CHECK(plan.collisionRenameCount == 1);
    CHECK(plan.sanitizeRenameCount == 0);
}

static void testTwoEntriesNeverLandOnOneName() {
    // Two package names that differ only by case are one destination file.
    const ImportPlan plan =
        planImport(manifestOf({entry("kick.wav", "a", "h1"), entry("Kick.wav", "b", "h2")},
                              "Drums"),
                   bookWithBanks({}), kProjectDir, {}, kTag);
    CHECK(plan.landCount == 2);
    CHECK(landed(plan, 0).destFileName != landed(plan, 1).destFileName);
}

static void testAnUnsanitaryNameIsMintedRatherThanLandedVerbatim() {
    const ImportPlan plan =
        planImport(manifestOf({entry("Hit One.wav", "a", "h1")}, "Drums"),
                   bookWithBanks({}), kProjectDir, {}, kTag);
    CHECK(landed(plan, 0).destFileName.find(' ') == std::string::npos);
    CHECK(landed(plan, 0).renamed);
    // No collision here (the bank folder is empty) — this is a sanitize mint.
    CHECK(plan.sanitizeRenameCount == 1);
    CHECK(plan.collisionRenameCount == 0);
}

// --- content hash (collision class 3) ----------------------------------------

static void testAnAlreadyLandedHashCollapsesWithoutAWrite() {
    const ImportPlan plan =
        planImport(manifestOf({entry("kick.wav", "a", "same"),
                               entry("kick_copy.wav", "b", "same")},
                              "Drums"),
                   bookWithBanks({}), kProjectDir, {}, kTag);

    CHECK(plan.landCount == 1);
    CHECK(plan.collapseCount == 1);
    CHECK(landed(plan, 0).action == EntryAction::Land);
    CHECK(landed(plan, 1).action == EntryAction::Collapse);
    // No name is claimed for it — a dedup that wrote a file would manufacture an orphan.
    CHECK(landed(plan, 1).destFileName.empty());
    // Every manifest entry still yields exactly one planned entry: planImport is total.
    CHECK(plan.entries.size() == 2);
}

static void testAParentPointingAtACollapsedEntryResolvesToTheSurvivor() {
    PackageEntry first = entry("kick.wav", "cap-first", "same");
    PackageEntry dupe = entry("kick_copy.wav", "cap-dupe", "same");
    PackageEntry child = entry("kick_r2.wav", "cap-child", "other");
    child.sample.provenance = model::Provenance{"cap-dupe", ""};

    const ImportPlan plan = planImport(manifestOf({first, dupe, child}, "Drums"),
                                       bookWithBanks({}), kProjectDir, {}, kTag);
    CHECK(landed(plan, 2).sample.provenance->parentSampleId == landed(plan, 0).sample.id);
}

static void testAnEmptyHashNeverCollapses() {
    // Mirrors findByHash: an unhashable entry does not participate in dedup.
    const ImportPlan plan =
        planImport(manifestOf({entry("a.wav", "a", ""), entry("b.wav", "b", "")}, "Drums"),
                   bookWithBanks({}), kProjectDir, {}, kTag);
    CHECK(plan.landCount == 2);
    CHECK(plan.collapseCount == 0);
}

// --- slots -------------------------------------------------------------------

static void testSlotsRideAlongOverTheRemintedIds() {
    PackageManifest m = manifestOf({entry("kick.wav", "cap-a", "h1"),
                                    entry("snare.wav", "cap-b", "h2")},
                                   "Drums");
    // A gap the package carried: slot 0 empty, occupants at 1 and 3.
    m.slots = SlotMap::fromEntries({{"cap-a", 1}, {"cap-b", 3}});

    const ImportPlan plan = planImport(m, bookWithBanks({}), kProjectDir, {}, kTag);
    CHECK(plan.slots.slotOf(landed(plan, 0).sample.id) == 1);
    CHECK(plan.slots.slotOf(landed(plan, 1).sample.id) == 3);
    // The package's own ids are gone from the map — a foreign id never enters the index.
    CHECK(plan.slots.slotOf("cap-a") == -1);
}

static void testACollapsedEntryDoesNotDoubleOccupyASlot() {
    PackageManifest m = manifestOf({entry("kick.wav", "cap-a", "same"),
                                    entry("kick_copy.wav", "cap-b", "same")},
                                   "Drums");
    m.slots = SlotMap::fromEntries({{"cap-a", 0}, {"cap-b", 1}});

    const ImportPlan plan = planImport(m, bookWithBanks({}), kProjectDir, {}, kTag);
    CHECK(plan.slots.size() == 1);
    CHECK(plan.slots.slotOf(landed(plan, 0).sample.id) == 0);
}

// --- the ledger gate ---------------------------------------------------------

static void testDegradedLedgerStatusesRefuseAndTheUsableOnesDoNot() {
    CHECK(importLedgerRefusal(tracking::LedgerStatus::Unreadable) ==
          LedgerRefusal::Malformed);
    CHECK(importLedgerRefusal(tracking::LedgerStatus::FutureVersion) ==
          LedgerRefusal::FutureVersion);
    CHECK(importLedgerRefusal(tracking::LedgerStatus::Fresh) == LedgerRefusal::None);
    CHECK(importLedgerRefusal(tracking::LedgerStatus::Loaded) == LedgerRefusal::None);
}

static void testAnUndecodableUsageKeyBlocksPruneButNotImport() {
    // The tempting reuse of PruneReport::blockedByTracking would silently refuse an
    // import over a key that only ever governs what a DELETION may touch.
    tracking::OriginLedger ledger;
    wire::UsageFoldResult usage;
    usage.abortPrune = true;
    usage.offendingKeys = {"rsusage_{ABC}"};

    const tracking::TrackingState state{tracking::LedgerStatus::Loaded, ledger, usage};
    CHECK(tracking::pruneProtection(state).blocked);
    CHECK(importLedgerRefusal(tracking::LedgerStatus::Loaded) == LedgerRefusal::None);
}

// Pins the delegation itself: importLedgerRefusal must refuse EXACTLY the statuses
// ledgerDegraded() calls degraded, over every value the enum has today. A gate that
// re-derived its own notion of "degraded" could silently diverge from this the moment
// either side changes without the other.
static void testImportLedgerRefusalDelegatesToLedgerDegraded() {
    const tracking::LedgerStatus all[] = {
        tracking::LedgerStatus::Fresh,
        tracking::LedgerStatus::Loaded,
        tracking::LedgerStatus::Unreadable,
        tracking::LedgerStatus::FutureVersion,
    };
    for (tracking::LedgerStatus s : all)
        CHECK((importLedgerRefusal(s) != LedgerRefusal::None) == tracking::ledgerDegraded(s));
}

// --- the ledger-refusal message (pure, so both channels are assertable without a DAW) --

static void testLedgerRefusalMessageIsEmptyForNone() {
    CHECK(ledgerRefusalMessage(LedgerRefusal::None, "reasampler").empty());
}

static void testLedgerRefusalMessageNamesTheChannelCorrectNamespace() {
    // The two real namespaces (app_version.h): stable "reasampler", beta "reasampler_beta".
    const std::string stable = ledgerRefusalMessage(LedgerRefusal::Malformed, "reasampler");
    CHECK(stable.find("\"reasampler\"") != std::string::npos);
    CHECK(stable.find("reasampler_beta") == std::string::npos);

    const std::string beta = ledgerRefusalMessage(LedgerRefusal::Malformed, "reasampler_beta");
    CHECK(beta.find("\"reasampler_beta\"") != std::string::npos);
}

static void testLedgerRefusalMessageDistinguishesMalformedFromFutureVersion() {
    const std::string malformed = ledgerRefusalMessage(LedgerRefusal::Malformed, "reasampler");
    const std::string futureVersion =
        ledgerRefusalMessage(LedgerRefusal::FutureVersion, "reasampler");
    CHECK(malformed != futureVersion);
    CHECK(malformed.find("malformed") != std::string::npos);
    CHECK(futureVersion.find("NEWER version") != std::string::npos);
}

int main() {
    testFreeSeedIsKeptVerbatim();
    testFoldedCollisionTakesTheFirstSuffix();
    testProbeFillsAGap();
    testSeedIsNeverReparsed();
    testBlankRecordedNameFallsBackToTheDefault();
    testPoolExportLandsAsANamedBank();
    testRepeatedImportsWalkTheSuffixUpwards();

    testEveryIdIsRemintedUnderTheImportPrefix();
    testReimportingIntoTheSourceProjectRemintsRatherThanCollides();
    testParentIsRemappedWhenItTravelledInThePackage();
    testParentIsRemappedEvenWhenItFollowsTheChild();
    testForeignParentIsClearedNotCarried();

    testAFreeBankLegalNameIsKept();
    testATakenNameIsMintedFreshAndNeverOverwritten();
    testTheFolderNameCheckFoldsAsciiCase();
    testTwoEntriesNeverLandOnOneName();
    testAnUnsanitaryNameIsMintedRatherThanLandedVerbatim();

    testAnAlreadyLandedHashCollapsesWithoutAWrite();
    testAParentPointingAtACollapsedEntryResolvesToTheSurvivor();
    testAnEmptyHashNeverCollapses();

    testSlotsRideAlongOverTheRemintedIds();
    testACollapsedEntryDoesNotDoubleOccupyASlot();

    testDegradedLedgerStatusesRefuseAndTheUsableOnesDoNot();
    testAnUndecodableUsageKeyBlocksPruneButNotImport();
    testImportLedgerRefusalDelegatesToLedgerDegraded();

    testLedgerRefusalMessageIsEmptyForNone();
    testLedgerRefusalMessageNamesTheChannelCorrectNamespace();
    testLedgerRefusalMessageDistinguishesMalformedFromFutureVersion();

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