#include "core/package/import_plan.h"

#include <unordered_map>
#include <unordered_set>
#include <utility>

#include "core/capture/capture_paths.h"
#include "core/package/package_format.h"
#include "core/util/ascii_ws.h"

namespace reasampler::package {

namespace {

using capture::bankRelativeForName;
using capture::deriveBankPaths;
using capture::sanitizeStem;
using util::isAsciiWs;

// Shares BankBook::nameKey's whitespace set (core/util/ascii_ws.h) so a name nameKey
// would fold to empty is never treated as recorded here.
bool blankName(const std::string& s) {
    for (char c : s)
        if (!isAsciiWs(c)) return false;
    return true;
}

// "kick.wav" -> "kick"; a name with no dot is its own stem. deriveBankPaths re-adds
// the extension, so handing it the full name would file "kick.wav" as "kick.wav.wav".
std::string stemOf(const std::string& fileName) {
    const std::size_t dot = fileName.rfind('.');
    if (dot == std::string::npos || dot == 0) return fileName;
    return fileName.substr(0, dot);
}

// True when `fileName` is already spelled the way this tool spells a bank file, so a
// package landing in a fresh project keeps the names it travelled with. Anything else
// is minted through deriveBankPaths, which is also the sanitizer.
bool spelledLikeABankFile(const std::string& fileName) {
    const std::string stem = stemOf(fileName);
    return stem != fileName && sanitizeStem(stem) == stem && fileName == stem + ".wav";
}

// The bank-folder names an import must not land on: what is there already, plus what
// this import has minted so far. Case-folded, because the two filesystems this tool
// ships on would treat "Kick.wav" and "kick.wav" as one file.
//
// `bankFolderFileNames` comes from `listFolderFileNames` (shell/package/package_io),
// which skips non-regular files — so a DIRECTORY sharing a bank file's name is
// invisible here. The plan then never mints around it, and the later exclusive-create
// land fails on that one entry (WriteFailed, rolled back). Safe direction ("never
// overwrite" still holds) but worth knowing before chasing a WriteFailed report that
// traces back to a same-named folder in the bank directory; test_import_landing's
// rollback suite deliberately exploits this to exercise the rollback path.
class NameSet {
public:
    explicit NameSet(const std::vector<std::string>& present) {
        keys_.reserve(present.size());
        for (const std::string& n : present) keys_.insert(entryNameKey(n));
    }
    bool taken(const std::string& name) const { return keys_.count(entryNameKey(name)) != 0; }
    void claim(const std::string& name) { keys_.insert(entryNameKey(name)); }

private:
    std::unordered_set<std::string> keys_;
};

// The name this entry lands under. Terminates: each attempt carries a distinct
// counter, and the taken set is finite.
std::string mintFileName(const std::string& projectDir, const std::string& packageName,
                         const std::string& uniqueTag, const NameSet& taken) {
    if (spelledLikeABankFile(packageName) && !taken.taken(packageName)) return packageName;

    const std::string stem = stemOf(packageName);
    std::string tag = uniqueTag;
    for (int n = 2;; ++n) {
        const std::string candidate = deriveBankPaths(projectDir, stem, tag).fileName;
        if (!taken.taken(candidate)) return candidate;
        tag = uniqueTag + "-" + std::to_string(n);
    }
}

}  // namespace

LedgerRefusal importLedgerRefusal(tracking::LedgerStatus status) {
    // Delegates the refuse/proceed decision entirely to ledgerDegraded() rather than
    // re-deriving it from the two named statuses, so a future degraded status added
    // there is refused here too rather than silently falling through to None.
    if (!tracking::ledgerDegraded(status)) return LedgerRefusal::None;
    // Below this point status is known degraded; only the message variant is picked.
    // Unreadable gets its own "corrupt, may be cleared" wording; every other degraded
    // status (today only FutureVersion) gets the "written by a newer build" wording.
    return status == tracking::LedgerStatus::Unreadable ? LedgerRefusal::Malformed
                                                          : LedgerRefusal::FutureVersion;
}

// Mirrors prune's abort block in structure and tone (shell/actions/prune_action.cpp),
// because a user who has hit that one should recognise this one. Every recovery line
// names THIS build's namespace: a beta user handed the stable spelling clears the wrong
// key and is still blocked.
std::string ledgerRefusalMessage(LedgerRefusal refusal, const std::string& extStateNamespace) {
    if (refusal == LedgerRefusal::None) return {};

    std::string msg =
        "ReaSampler import: ABORTED -- the file-tracking ledger could not be read. "
        "Nothing was imported.\n";
    if (refusal == LedgerRefusal::Malformed) {
        msg += "The stored file-tracking ledger is malformed. It has been left intact "
               "rather than overwritten, so it can be repaired or cleared:\n"
               "  reaper.SetProjExtState(0, \"" + extStateNamespace + "\", \"owned_files\", \"\")\n"
               "Clearing it makes every existing bank file un-reclaimable (they stop "
               "being attributable to ReaSampler); no file is lost. Reopen the project "
               "afterwards -- the block is held for the rest of this session.\n";
    } else {
        msg += "The stored file-tracking ledger was written by a NEWER version of "
               "ReaSampler than this one, so its records cannot be read safely. It has "
               "been left intact and will NOT be overwritten. Reopen the project with "
               "that newer version -- do NOT clear this key from here, that would "
               "discard tracking records this build cannot see. The block is held for "
               "the rest of this session.\n";
    }
    msg += "An import can land hundreds of files in one gesture. With no readable "
           "ledger, none of them could be given a birth record, and every one would be "
           "permanently unreclaimable.\n";
    return msg;
}

std::string bankFolderDir(const std::string& projectDir) {
    // Only the directory half of the result is wanted; the stem is a placeholder.
    return deriveBankPaths(projectDir, "bank", std::string{}).absoluteDir;
}

ImportPlan planImport(const PackageManifest& manifest,
                      const BankBook& destination,
                      const std::string& projectDir,
                      const std::vector<std::string>& bankFolderFileNames,
                      const std::string& uniqueTag) {
    ImportPlan plan;

    plan.seedBankName = blankName(manifest.bankDisplayName)
                            ? std::string(kDefaultImportBankName)
                            : manifest.bankDisplayName;
    plan.bankDisplayName = destination.uniqueDisplayName(plan.seedBankName);
    plan.bankNameAdjusted = plan.bankDisplayName != plan.seedBankName;

    NameSet taken(bankFolderFileNames);
    // The destination bank is created empty by this same import, so "already in the
    // destination bank by content" is exactly "already landed by this plan" — the
    // hash set below IS that bank's findByHash. Cross-bank dedup is deliberately not
    // enforced (core/model/CLAUDE.md), so other banks' hashes are not consulted.
    std::unordered_map<std::string, std::string> landedIdForHash;
    // Every package id, including a collapsed one's, so a parent link that pointed at
    // a duplicate still resolves to the entry that survived it.
    std::unordered_map<std::string, std::string> idRemap;

    plan.entries.reserve(manifest.entries.size());
    for (std::size_t i = 0; i < manifest.entries.size(); ++i) {
        const PackageEntry& src = manifest.entries[i];

        PlannedEntry e;
        e.manifestIndex = i;

        const std::string& hash = src.sample.contentHash;
        if (!hash.empty()) {
            const auto hit = landedIdForHash.find(hash);
            if (hit != landedIdForHash.end()) {
                e.action = EntryAction::Collapse;
                idRemap[src.sample.id] = hit->second;
                ++plan.collapseCount;
                plan.entries.push_back(std::move(e));
                continue;
            }
        }

        e.destFileName = mintFileName(projectDir, src.fileName, uniqueTag, taken);
        e.renamed = e.destFileName != src.fileName;
        taken.claim(e.destFileName);

        e.sample = src.sample;
        e.sample.id = std::string(kImportIdPrefix) + uniqueTag + "-" + e.destFileName;
        e.sample.relativePath = bankRelativeForName(e.destFileName);

        if (!hash.empty()) landedIdForHash.emplace(hash, e.sample.id);
        idRemap[src.sample.id] = e.sample.id;

        ++plan.landCount;
        // A rename happens for one of two reasons: the package's own name was already
        // taken (spelledLikeABankFile true but the mint's fast path lost the race to
        // `taken`), or the name never qualified for that fast path at all (sanitize).
        if (e.renamed) {
            if (spelledLikeABankFile(src.fileName)) ++plan.collisionRenameCount;
            else ++plan.sanitizeRenameCount;
        }
        plan.entries.push_back(std::move(e));
    }

    // Second pass: the remap must be complete before a parent is resolved, since a
    // sample may precede its own parent in manifest order.
    for (PlannedEntry& e : plan.entries) {
        if (e.action != EntryAction::Land || !e.sample.provenance) continue;
        const auto hit = idRemap.find(e.sample.provenance->parentSampleId);
        e.sample.provenance->parentSampleId =
            hit == idRemap.end() ? std::string{} : hit->second;
    }

    // The package's display order, over the ids that actually landed. SlotMap's own
    // repair rules settle the rest: two package ids collapsed onto one landed id give
    // one slot (first wins), and a landed sample the package never positioned is
    // appended by BankBook::reconcileSlots afterwards.
    std::vector<std::pair<std::string, int>> slotPairs;
    for (const std::string& oldId : manifest.slots.orderedIds()) {
        const auto hit = idRemap.find(oldId);
        if (hit == idRemap.end()) continue;
        slotPairs.emplace_back(hit->second, manifest.slots.slotOf(oldId));
    }
    plan.slots = model::SlotMap::fromEntries(slotPairs);

    return plan;
}

}  // namespace reasampler::package
