// prune_fs.cpp — the prune scan + THE SINGLE FILE-DELETION AUTHORITY in ReaSampler.
//
// deleteOrphanFile below (SHFileOperationW on Windows, std::filesystem::remove
// on SWELL platforms) is the ONLY code in the system that deletes USER files —
// the sole deletion authority over the bank folder's bytes. THE carve-out, and
// its one home: a shell removing a file it wrote itself moments earlier and
// that no index ever referenced is self-cleanup, not authority over user data.
// It covers a transient scratch file outside the bank (the drop path's temp
// .vstpreset) AND a bank-folder write whose index entry was then refused
// (bake_land) — the discriminator is "did this call create it, and did anything
// ever reference it", not where it sits.
//
// deleteOrphanFile is deliberately file-local (anonymous namespace): nothing
// outside this TU can reach it, and this concentration must never spread. The
// safety-critical "which files are orphans" decision stays in the pure core
// (prune_reconcile); this TU only enumerates, resolves, stats, and — after the
// confirm — executes.
//
// Compiled into the reaper_reasampler module. REAPER-facing only through the
// persist_detail helpers and usage_scan; this TU itself calls no REAPER API directly.

#include <cstdint>
#include <filesystem>
#include <string>
#include <system_error>
#include <unordered_map>
#include <unordered_set>
#include <vector>

// Move-to-trash surface, trash-preferred. Windows reaches the Recycle Bin via
// SHFileOperationW + FOF_ALLOWUNDO (verified against shellapi.h: SHFILEOPSTRUCTW
// { hwnd, wFunc, pFrom(double-NUL list), pTo, fFlags, ... }, FO_DELETE=0x3,
// FOF_ALLOWUNDO=0x40). No portable move-to-trash exists on SWELL (macOS/Linux),
// so those platforms fall back to unlink — see deleteOrphanFile below.
#ifdef _WIN32
#include <windows.h>
#include <shellapi.h>
#endif

#include "shell/persist/persist_internal.h"
#include "shell/persist/session.h"
#include "shell/persist/usage_scan.h"  // scanInstanceUsage — one of the authority's two inputs

#include "core/capture/capture_paths.h"          // resolveBankFile / bankRelativeForName / kBankSubfolder
#include "core/reclaim/prune_reconcile.h"        // the pure orphan decision + report tallies
#include "core/tracking/tracking_authority.h"    // the one protection answer

namespace reasampler {

namespace {

namespace fs = std::filesystem;

using persist_detail::projectDirOf;
using persist_detail::readActiveProject;

// The dry-run file-list display cap: count and size are always exact
// (tallied over the full orphan set), but the enumerated list handed to the
// console is clipped so a project with thousands of orphans does not flood
// the report. PruneReport::truncated flags the clip.
constexpr std::size_t kPruneListDisplayCap = 64;

// A fresh enumerate + pure-core prune compute for the active project. Shared
// by the dry-run report, the full-set query, and the deletion so all three
// agree on one resolution + enumeration + set-algebra path — no divergence
// between what is shown and what is deleted. REAPER-facing but writes nothing.
//
//   * bankDirAbs — the resolved current bank folder. Empty when there is no
//                  active/saved project, no project dir, or no folder yet.
//   * orphans    — the full orphan set, untruncated. The pure core decides.
//   * sizeByRel  — per-orphan on-disk byte size (0 when it could not be stat'd).
//   * blocked      — true iff the tracking authority could not answer: `orphans`
//                  is left EMPTY, the prune must halt rather than proceed with
//                  degraded protection.
struct PruneScan {
    std::string bankDirAbs;
    std::vector<std::string> orphans;
    std::unordered_map<std::string, std::uint64_t> sizeByRel;
    bool blocked = false;
    bool ledgerUnreadable = false;
    bool ledgerFutureVersion = false;
    std::vector<std::string> unreadableUsageKeys;
};

// Non-throwing: readActiveProject + resolveBankFile are pure/string; every filesystem
// call below uses an error_code form so no std::filesystem_error crosses REAPER's C ABI.
PruneScan scanPruneOrphans(const BankBook& book, const tracking::OriginLedger& ledger,
                           tracking::LedgerStatus ledgerStatus) {
    PruneScan scan;

    std::string rppPath;
    void* proj = readActiveProject(rppPath);
    if (!proj || rppPath.empty()) return scan;  // no active/saved project -> empty scan

    // Resolve the current bank folder the same way the index does — never a
    // stored absolute path, so a Save-As relocation is followed automatically.
    const std::string projectDir = projectDirOf(rppPath);
    const std::string bankDir =
        capture::resolveBankFile(projectDir, capture::kBankSubfolder);
    if (bankDir.empty()) return scan;  // unresolvable (no project dir) -> empty scan

    std::error_code ec;
    if (!fs::exists(bankDir, ec) || !fs::is_directory(bankDir, ec)) {
        return scan;  // no bank folder captured yet -> nothing to reclaim
    }

    // Enumerate into project-relative paths spelled the SAME way the capture
    // path spells them, so the pure core's exact-string match lines up with
    // referencedPaths() and the ledger. Non-recursive: the bank folder is
    // flat. Manual iterator form (it.increment(ec)) keeps the loop
    // non-throwing on a mid-iteration failure.
    std::vector<std::string> present;
    fs::directory_iterator it(bankDir, ec);
    for (; !ec && it != fs::directory_iterator{}; it.increment(ec)) {
        const auto& entry = *it;
        std::error_code reg_ec;
        if (!entry.is_regular_file(reg_ec)) continue;  // skip subdirs / specials
        const std::string name = entry.path().filename().string();
        const std::string rel = capture::bankRelativeForName(name);
        if (rel.empty()) continue;
        present.push_back(rel);
        std::error_code sz_ec;
        const std::uintmax_t sz = entry.file_size(sz_ec);
        scan.sizeByRel[rel] = sz_ec ? 0 : static_cast<std::uint64_t>(sz);
    }

    // The decision lives in the pure core; the tracking authority supplies both of
    // its tracking-derived inputs so the prune and the resample can never disagree
    // about what is protected. referencedPaths() unions across the whole book; the
    // authority's heldPaths adds every live instance's captures on top — a capture
    // any live instance holds can never be an orphan, even if its bank entry was
    // deleted while the instance kept its ref. Read-only throughout: this shell
    // only enumerates, resolves, and stats.
    scan.bankDirAbs = bankDir;
    const wire::UsageFoldResult usage = scanInstanceUsage(proj);
    const tracking::TrackingState state{ledgerStatus, ledger, usage};
    const tracking::ProtectionAnswer protection = tracking::pruneProtection(state);
    if (protection.blocked) {
        // FAIL-SAFE ABORT: the protected set is unknowable. Compute NO orphans —
        // every downstream consumer then deletes nothing. The blockers let the
        // action tell the user what to recover.
        scan.blocked = true;
        scan.ledgerUnreadable = protection.ledgerUnreadable;
        scan.ledgerFutureVersion = protection.ledgerFutureVersion;
        scan.unreadableUsageKeys = protection.unreadableUsageKeys;
        return scan;
    }
    scan.orphans = reclaim::pruneOrphans(
        present,
        reclaim::mergeReferenced(book.referencedPaths(), protection.heldPaths),
        protection.ownedPaths);
    return scan;
}

// Deletes ONE orphan file, trash-preferred. Returns true iff deleted by this
// call. Returns false with `outAlreadyAbsent` set when the file was already
// gone (caller folds into staleness, not reclaimedCount); false with it unset
// on a real delete failure (locked, conversion error — folds into
// skippedCount). `absPath` is the resolved absolute path. Non-throwing: no
// exception may cross the C ABI.
//
// Windows routes through SHFileOperationW + FOF_ALLOWUNDO (Recycle Bin,
// recoverable); the no-UI flags suppress REAPER-blocking dialogs since our own
// confirm already happened. Other platforms (SWELL: macOS/Linux) have no
// portable move-to-trash surface, so they fall back to std::filesystem::remove
// (hard unlink) behind the confirm guardrail.
bool deleteOrphanFile(const std::string& absPath, bool& outUsedTrash,
                      bool& outAlreadyAbsent) {
#ifdef _WIN32
    // Back-slashed, double-NUL-terminated wide string: SHFileOperation's
    // pFrom is a list (needs the extra terminating NUL) and rejects forward
    // slashes in some cases.
    std::string win = absPath;
    for (char& c : win) if (c == '/') c = '\\';

    const int wlen = MultiByteToWideChar(CP_UTF8, 0, win.c_str(), -1, nullptr, 0);
    if (wlen <= 0) return false;  // conversion failed -> real skip
    std::vector<wchar_t> wbuf(static_cast<std::size_t>(wlen) + 1, L'\0');  // +1 for list NUL
    MultiByteToWideChar(CP_UTF8, 0, win.c_str(), -1, wbuf.data(), wlen);

    SHFILEOPSTRUCTW op{};
    op.hwnd = nullptr;
    op.wFunc = FO_DELETE;
    op.pFrom = wbuf.data();
    op.pTo = nullptr;
    op.fFlags = static_cast<FILEOP_FLAGS>(FOF_ALLOWUNDO | FOF_NOCONFIRMATION |
                                          FOF_SILENT | FOF_NOERRORUI);
    const int rv = SHFileOperationW(&op);
    if (rv == 0 && !op.fAnyOperationsAborted) {
        outUsedTrash = true;
        return true;  // deleted this call -> reclaimed
    }
    // Distinguish "already absent" (nonzero return on some Windows/shell versions
    // for a vanished file) from a real failure so the caller can tally separately.
    std::error_code ec;
    if (!fs::exists(absPath, ec)) {
        outAlreadyAbsent = true;
    }
    return false;
#else
    // No portable trash surface on SWELL platforms -> hard unlink.
    std::error_code ec;
    const bool removed = fs::remove(absPath, ec);
    if (removed) return true;
    if (ec) return false;              // real failure (locked/permission) -> skip
    outAlreadyAbsent = true;           // no error, no removal -> already gone
    return false;
#endif
}

}  // namespace

tracking::Answer ReaSamplerSession::tiedUsageFor(const std::string& capturePath,
                                                 const std::string& ownUsageKey) const {
    // The SECOND consumer of the same gather the prune scan above runs — deliberately
    // next to it, so "both answers come out of one TrackingState" is structural rather
    // than a rule two files have to remember.
    std::string rppPath;
    void* proj = readActiveProject(rppPath);
    if (!proj) return tracking::Answer::Indeterminate;
    const wire::UsageFoldResult usage = scanInstanceUsage(proj);
    const tracking::TrackingState state{trackingStatus_, tracking_, usage};
    return tracking::tiedUsageExists(state, capturePath, ownUsageKey);
}

reclaim::PruneReport ReaSamplerSession::pruneDryRun() const {
    const PruneScan scan = scanPruneOrphans(book_, tracking_, trackingStatus_);
    reclaim::PruneReport report =
        reclaim::buildPruneReport(scan.orphans, scan.sizeByRel, kPruneListDisplayCap);
    // Surface the block so the action halts with an explicit message instead of
    // reporting "no orphaned files" — the count IS zero, but the user must know
    // the prune refused to run.
    report.blockedByTracking = scan.blocked;
    report.ledgerUnreadable = scan.ledgerUnreadable;
    report.ledgerFutureVersion = scan.ledgerFutureVersion;
    report.unreadableUsageKeys = scan.unreadableUsageKeys;
    return report;
}

std::vector<std::string> ReaSamplerSession::pruneOrphanSet() const {
    // Full set, untruncated; empty on a block, so a caller that skipped the report
    // still confirms nothing.
    return scanPruneOrphans(book_, tracking_, trackingStatus_).orphans;
}

reclaim::PruneDeletionResult ReaSamplerSession::pruneReclaim(
    const std::vector<std::string>& confirmed) const {
    reclaim::PruneDeletionResult result;

    // Re-enumerate + run the pure core FRESH (never a stale set): deletion
    // targets exactly `confirmed ∩ freshOrphans`, so a file that vanished or
    // became referenced between confirm and delete is skipped, and a newly-
    // appeared orphan not in `confirmed` is never swept. If this fresh scan
    // hits unreadable tracking state it aborts with an EMPTY orphan set, so
    // the plan below intersects to empty and nothing is deleted — the
    // fail-safe holds even in the confirm-to-delete window.
    const PruneScan scan = scanPruneOrphans(book_, tracking_, trackingStatus_);
    if (scan.bankDirAbs.empty()) return result;  // no project / no folder -> nothing

    const std::vector<std::string> plan =
        reclaim::pruneDeletePlan(confirmed, scan.orphans);

    // Staleness skip count: confirmed entries no longer fresh orphans.
    // pruneDeletePlan de-dups confirmed internally, so compare against the
    // unique-confirmed size to avoid counting de-duped entries as stale.
    const std::size_t uniqueConfirmedCount =
        std::unordered_set<std::string>(confirmed.begin(), confirmed.end()).size();
    result.skippedCount += uniqueConfirmedCount - plan.size();

    for (const std::string& rel : plan) {
        // rel is index-spelled "<kBankSubfolder>/<name>"; reconstruct the
        // absolute path from the resolved bank dir + the tail after '/'.
        const std::string::size_type slash = rel.find_last_of('/');
        const std::string name = (slash == std::string::npos) ? rel : rel.substr(slash + 1);
        if (name.empty()) { ++result.skippedCount; continue; }
        const std::string absPath = scan.bankDirAbs + "/" + name;

        const auto szIt = scan.sizeByRel.find(rel);
        const std::uint64_t bytes = (szIt != scan.sizeByRel.end()) ? szIt->second : 0;

        bool alreadyAbsent = false;
        if (deleteOrphanFile(absPath, result.usedTrash, alreadyAbsent)) {
            ++result.reclaimedCount;
            result.reclaimedBytes += bytes;
        } else if (alreadyAbsent) {
            ++result.skippedCount;  // vanished between plan and delete -> staleness
        } else {
            ++result.skippedCount;  // locked / conversion failure -> recorded, not thrown
        }
    }
    return result;
}

}  // namespace reasampler
