// export_plan.cpp — see export_plan.h for the contract.

#include "core/package/export_plan.h"

#include <cstddef>

#include "core/package/package_format.h"

namespace reasampler::package {

namespace {

// The bare file name a bank-relative path ends in. Separators are matched in both
// spellings: a persisted index may hold either on Windows.
std::string baseNameOf(const std::string& path) {
    const std::size_t sep = path.find_last_of("/\\");
    return sep == std::string::npos ? path : path.substr(sep + 1);
}

// Mirrors package_format.cpp's trailing-dot/space rule so a truncated stem never
// reintroduces the collision isValidEntryName exists to prevent.
std::string stripTrailingDotsAndSpaces(std::string s) {
    while (!s.empty() && (s.back() == '.' || s.back() == ' ')) s.pop_back();
    return s;
}

// Truncates to at most `max` bytes without splitting a UTF-8 sequence (the rule
// itself is package_format.cpp's isWellFormedUtf8). A sequence landing exactly on
// the cut is dropped whole, one character short of `max`, rather than checked for
// cleanliness — over-truncating by one character is cheap insurance against a
// subtly wrong boundary check.
std::string truncateUtf8(std::string s, std::size_t max) {
    if (s.size() <= max) return s;
    s.resize(max);
    while (!s.empty() && (static_cast<unsigned char>(s.back()) & 0xC0) == 0x80) s.pop_back();
    if (!s.empty() && static_cast<unsigned char>(s.back()) >= 0xC0) s.pop_back();
    return s;
}

// `name` with `suffix` inserted before its extension, trimmed so the result still
// fits the entry-name cap. `suffix.size() + ext.size()` can exceed the cap on its
// own (a long extension, a two-digit disambiguation suffix) — clamped rather than
// subtracted unchecked, which would underflow the size_t `room` below and turn
// truncateUtf8 into a silent no-op. An extension that alone leaves no room even
// after the whole stem is dropped is dropped too; uniqueEntryName's own floor
// covers what even that cannot fix.
std::string insertSuffix(const std::string& name, const std::string& suffix) {
    const std::size_t dot = name.find_last_of('.');
    const bool hasExt = dot != std::string::npos && dot > 0;
    std::string stem = hasExt ? name.substr(0, dot) : name;
    std::string ext = hasExt ? name.substr(dot) : std::string();
    if (suffix.size() >= kMaxEntryNameBytes) return std::string();
    if (ext.size() > kMaxEntryNameBytes - suffix.size()) ext.clear();
    const std::size_t room = kMaxEntryNameBytes - suffix.size() - ext.size();
    stem = truncateUtf8(std::move(stem), room);
    return stem + suffix + ext;
}

bool nameTaken(const std::string& candidate, const std::vector<std::string>& taken) {
    for (const std::string& t : taken)
        if (sameEntryName(candidate, t)) return true;
    return false;
}

// A transport name distinct from every name already claimed, under the format's own
// case-folding equivalence (package_format.h's sameEntryName).
std::string uniqueEntryName(const std::string& base, const std::vector<std::string>& taken) {
    if (!nameTaken(base, taken)) return base;
    // Each iteration either returns a name both valid and distinct from `taken`, or
    // advances to the next suffix; taken.size() + 2 attempts is enough by pigeonhole
    // now that insertSuffix cannot underflow. The floor below is the residual case
    // validity alone can still fail — an extension so long insertSuffix must drop it
    // on every attempt tried here.
    for (std::size_t n = 2; n <= taken.size() + 2; ++n) {
        const std::string candidate = insertSuffix(base, "_" + std::to_string(n));
        if (!nameTaken(candidate, taken) && isValidEntryName(candidate)) return candidate;
    }
    // Floors like sanitizeEntryName's own "entry" floor: always valid, regardless of
    // how base's own extension behaved.
    return sanitizeEntryName("entry_" + std::to_string(taken.size() + 2));
}

// What BankModel::add and the manifest's nested-path rule together accept — the pair
// package_manifest::serializeManifest checks per entry. The codec's refusal is the
// backstop; classifying here is what lets the export name the offending entry.
bool recordRepresentable(const model::Sample& s) {
    return !s.id.empty() && isValidNestedSamplePath(s.relativePath);
}

ExcludedEntry excludedFrom(const model::Sample& s, ExclusionReason reason) {
    ExcludedEntry e;
    e.sampleId = s.id;
    e.displayName = s.displayName;
    e.relativePath = s.relativePath;
    e.reason = reason;
    return e;
}

}  // namespace

std::string sanitizeEntryName(const std::string& rawFileName) {
    std::string n = rawFileName;
    for (char& c : n) {
        const unsigned char u = static_cast<unsigned char>(c);
        if (u < 0x20 || u == 0x7F || u == '/' || u == '\\' || u == ':' || u == '*' ||
            u == '?' || u == '|' || u == '<' || u == '>' || u == '"')
            c = '_';
    }
    // One byte of headroom so the prefix repair below still fits the cap.
    n = stripTrailingDotsAndSpaces(truncateUtf8(std::move(n), kMaxEntryNameBytes - 1));
    if (isValidEntryName(n)) return n;

    // One prefix answers every remaining reserved form at once: "." / "..", a DOS
    // device name, and a name the strips emptied.
    std::string prefixed = stripTrailingDotsAndSpaces("_" + n);
    if (isValidEntryName(prefixed)) return prefixed;

    // Ill-formed UTF-8 is what is left, and isValidEntryName is the only authority on
    // it here, so fold the whole non-ASCII range rather than re-deriving the scanner.
    for (char& c : prefixed)
        if (static_cast<unsigned char>(c) >= 0x80) c = '_';
    prefixed = stripTrailingDotsAndSpaces(prefixed);
    return isValidEntryName(prefixed) ? prefixed : std::string("entry");
}

ExportPlan planExport(const ExportInputs& in) {
    ExportPlan plan;
    plan.manifest.bankDisplayName = in.bankDisplayName;

    bool anyAbsent = false;
    bool anyUnrepresentable = false;
    std::vector<std::string> takenNames;
    std::vector<std::string> shippedIds;

    for (const ExportCandidate& c : in.candidates) {
        if (!recordRepresentable(c.sample)) {
            plan.excluded.push_back(
                excludedFrom(c.sample, ExclusionReason::RecordUnrepresentable));
            anyUnrepresentable = true;
            continue;
        }
        if (c.fileState != SourceFileState::Present) {
            plan.excluded.push_back(excludedFrom(
                c.sample, c.fileState == SourceFileState::Unreadable
                              ? ExclusionReason::FileUnreadable
                              : ExclusionReason::FileMissing));
            anyAbsent = true;
            continue;
        }

        PackageEntry e;
        e.sample = c.sample;
        e.fileName = uniqueEntryName(sanitizeEntryName(baseNameOf(c.sample.relativePath)),
                                     takenNames);
        // The transport record names its payload by the package name and nothing
        // else, so the manifest carries no path at all — the bank subfolder is a
        // fixed constant the importer re-spells through capture_paths.
        e.sample.relativePath = e.fileName;

        takenNames.push_back(e.fileName);
        shippedIds.push_back(c.sample.id);
        plan.sourceRelativePaths.push_back(c.sample.relativePath);
        plan.manifest.entries.push_back(std::move(e));
    }

    // Display positions follow membership: an excluded entry's slot marker would name
    // a sample the package does not carry.
    plan.manifest.slots = in.slots;
    plan.manifest.slots.reconcile(shippedIds);

    plan.verdict = anyUnrepresentable ? ExportVerdict::Refused
                   : anyAbsent        ? ExportVerdict::Incomplete
                                      : ExportVerdict::Ready;
    return plan;
}

}  // namespace reasampler::package
