// persist.cpp — REAPER-facing implementation of the BankIndex <-> project
// ext-state bridge (M4). See persist.h for the contract.
//
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API
// pointers; here they are extern (CLAUDE.md §contract).
//
// Storage: SetProjExtState / GetProjExtState, namespace "reasampler". Phase B: the
// whole BankBook (pool as bank-zero + named banks) is written under key "banks"
// (authoritative); the legacy single-bank key "bank_index" is RETIRED — cleared on
// save (SetProjExtState with "" deletes it) and read only once, to migrate a pre-
// multi-bank project's index into the pool. Ext state is stored INSIDE the .rpp, so
// the banks travel with the project automatically (CONTEXT.md §Persistence & paths).
// The only thing that does NOT travel for free is the physical bank folder; on
// Save-As to a new directory we relocate it so the indices' relative paths still
// resolve.
//
// PROJECT-LOAD / SAVE-AS DETECTION (chosen mechanism):
//   Driven by REAPER's "timer" register (main.cpp). Each poll() reads the active
//   project (EnumProjects(-1)), its .rpp path, and the GUID we store in its ext
//   state. Identity is layered GUID-PRIMARY, with the ReaProject* pointer as the
//   secondary disambiguator (classifyProjectTransition owns the exact order):
//     * different stored GUID -> a different project of record -> LOAD its index;
//       NEVER relocate. Catches pointer RECYCLING (REAPER reuses a closed project's
//       address, so a reopened/new project can present the previous pointer with a
//       different GUID), new/unsaved<->saved, and switching between distinct saved
//       projects.
//     * SAME GUID, DIFFERENT object -> a forked sibling that copied our GUID via
//       Save-As -> LOAD its index; NEVER relocate; re-GUID it so the siblings
//       diverge going forward.
//     * SAME GUID, SAME object, .rpp path changed -> genuine Save-As to a new
//       location -> relocate the bank folder from the old dir to the new one, then
//       re-GUID.
//   Why GUID-primary (W12 fix): this layers the two prior designs. M4 (GUID-only)
//   broke Save-As forks — Save-As copies the whole .rpp incl. our stored GUID, so a
//   fork and its parent share a GUID on disk; switching between them read as a
//   Save-As and clobbered a bank. W10 (pointer-primary, GUID voided) broke pointer
//   RECYCLING — a reopened/new project reusing the previous project's address read
//   as NoOp/SaveAsRelocate and the bank never reloaded. Checking the GUID first
//   catches recycling; the pointer then separates a fork (same GUID, different
//   object -> Load) from a Save-As (same GUID, same object, new path -> relocate).
//   classifyProjectTransition (pure, capture_paths) takes a `sameProjectObject`
//   bool (poll() computes `proj == lastProject_`) so the decision stays REAPER-free
//   and testable; poll() executes the verdict.
//
//   REAPER exposes no stable per-project GUID (GetSetProjectInfo_String has no
//   PROJECT_GUID desc; GetProjectStateChangeCount is a session-local counter, not
//   a cross-open identity), so we MINT one with genGuid/guidToString and store it
//   under kProjExtGuidKey. On Save-As REAPER copies the whole .rpp incl. our ext
//   state, so the new project initially shares the old GUID; poll() re-GUIDs it
//   (after relocating, or on the forked-sibling Load branch) so identities diverge.
//
//   Rationale for the timer: the brief mandates ext-state storage (rules out the
//   projectconfig .rpp-line hook for STORAGE), and the timer composes cleanly with
//   ext-state while covering identity-transition load + Save-As detection in one
//   place.
//
//   DIVISION OF LABOUR (R-B undo):
//     * Identity-transition poll (this file, classifyProjectTransition) owns
//       open / tab-switch / new / forked-sibling / Save-As-relocation — every case
//       where the project OF RECORD changes.
//     * The `projectconfig` hook (main.cpp registers project_config_extension_t;
//       BeginLoadProjectState with isUndo) owns UNDO/REDO — where the project
//       identity is unchanged but its ext state rolled back/forward on disk. The
//       identity poll sees NoOp there and would never re-read ext state, so the hook
//       requests a reload (requestReload) that poll() drains on the next tick, once
//       REAPER has restored the <EXTSTATE> block. See requestReload / the poll drain.
//   The hook fires on undo AND redo (isUndo true for both), and on normal open
//   (isUndo false) — but we set the reload flag ONLY for isUndo, so a normal open
//   flows solely through the identity-transition Load path and never double-loads.
//
// NON-DESTRUCTIVE: this module writes ONLY our own ext-state key and moves ONLY
// our own reasampler_bank/ folder. It never touches the user's media, items, or
// other ext-state namespaces.

#include "persist.h"

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

// Move-to-trash surface (fork R-C, trash-preferred). On Windows the Recycle Bin is
// reached via SHFileOperationW + FOF_ALLOWUNDO (verified against the Windows SDK
// shellapi.h: SHFILEOPSTRUCTW { hwnd, wFunc, pFrom(double-NUL list), pTo, fFlags, ... },
// FO_DELETE=0x3, FOF_ALLOWUNDO=0x40). No portable move-to-trash exists on the SWELL
// (macOS/Linux) side of this codebase, so those platforms fall back to unlink behind the
// R3 dry-run/confirm guardrail — see deleteOrphanFile below for the per-platform routing.
#ifdef _WIN32
#include <windows.h>
#include <shellapi.h>
#endif

#include "app_version.h"
#include "capture_paths.h"
#include "prune_reconcile.h"
#include "vst/bank_sync.h"  // parseBankGeneration / formatBankGeneration (SHARED with the instrument reader)

#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_GetProjExtState
#define REAPERAPI_WANT_MarkProjectDirty
#define REAPERAPI_WANT_SetProjExtState
#define REAPERAPI_WANT_ShowConsoleMsg
#define REAPERAPI_WANT_genGuid
#define REAPERAPI_WANT_guidToString
#include "reaper_plugin_functions.h"

namespace reasampler {

namespace {

namespace fs = std::filesystem;

// Read the active project pointer and its .rpp path in one shot. idx=-1 is the
// current project tab (SDK header line ~1262). The out-buffer receives the full
// .rpp path, EMPTY for a never-saved project (the reliable unsaved sentinel —
// same fact capture.cpp relies on). Returns nullptr proj only when there is no
// active project at all.
void* readActiveProject(std::string& rppPathOut) {
    std::vector<char> buf(4096, '\0');
    ReaProject* proj = EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
    rppPathOut.assign(buf.data());
    return proj;
}

// Parent directory of the .rpp, forward-slashed, no trailing slash. Empty in ->
// empty out. Mirrors capture.cpp's derivation so the bank sits alongside the
// .rpp (NOT GetProjectPathEx, which returns the recording path — see capture.cpp
// for the full rationale). The derivation itself is projectDirOfRpp in capture_paths
// (pure) — the SAME convention the VST3 instrument resolves audio paths by, so both
// artifacts share one implementation rather than duplicating the parent-of-.rpp step.
std::string projectDirOf(const std::string& rppPath) {
    return projectDirOfRpp(rppPath);
}

// GetProjExtState needs a caller-supplied buffer; the index JSON can be large
// (many samples). Query the required size first (a NULL/zero call is not part of
// the documented contract, so we grow a buffer until it fits). Returns "" when
// the key is absent (GetProjExtState returns <=0) — an absent key is a valid
// empty bank, not an error.
std::string getProjExtStateString(ReaProject* proj, const char* ns,
                                   const char* key) {
    // Start generous; grow if REAPER reports the value was truncated. The return
    // value is the length of the value (SDK: "returns length"); if it equals the
    // buffer capacity minus the NUL, the value may have been clipped, so retry.
    for (int cap = 1 << 16; cap <= (1 << 24); cap <<= 2) {
        std::vector<char> buf(static_cast<size_t>(cap), '\0');
        int rv = GetProjExtState(proj, ns, key, buf.data(), cap);
        if (rv <= 0) return {};              // absent / empty -> empty bank
        // If the written string fits strictly inside the buffer it is complete.
        std::string s(buf.data());
        if (static_cast<int>(s.size()) + 1 < cap) return s;
        // else: possibly truncated -> grow and retry.
    }
    // Pathologically large (>16 MB) — give up rather than loop forever. Warn on
    // the console so this reads as "too large to load", not silent data loss
    // (mirrors the malformed-JSON warning in loadFromProject).
    ShowConsoleMsg(("ReaSampler: stored value for key '" + std::string(key) +
                    "' exceeds the 16 MB read ceiling -- ignoring (bank not "
                    "loaded).\n").c_str());
    return {};
}

// The GUID we mint per project, formatted "{XXXXXXXX-....}" by guidToString.
// guidToString wants a >=64-char destination (SDK header line ~3846).
std::string genProjectGuidString() {
    GUID g{};
    genGuid(&g);
    char buf[64] = {0};
    guidToString(&g, buf);
    return std::string(buf);
}

// Copy the bank folder from oldDir to newDir, non-destructively (copy, do not
// move — see the handoff for the copy-vs-move rationale). Overwrites existing
// files at the destination so a re-save is idempotent. Best-effort: filesystem
// errors are swallowed and reported to the console rather than thrown across the
// REAPER boundary. Returns true if the copy ran (source existed).
bool relocateBankFolder(const std::string& oldBankDir,
                        const std::string& newBankDir) {
    std::error_code ec;
    if (!fs::exists(oldBankDir, ec) || !fs::is_directory(oldBankDir, ec)) {
        return false;  // nothing at the old location to relocate
    }
    if (oldBankDir == newBankDir) return false;  // defensive; plan guards this too

    fs::create_directories(newBankDir, ec);
    fs::copy(oldBankDir, newBankDir,
             fs::copy_options::recursive | fs::copy_options::overwrite_existing,
             ec);
    if (ec) {
        ShowConsoleMsg(("ReaSampler: bank relocation to '" + newBankDir +
                        "' failed: " + ec.message() + "\n").c_str());
        return false;
    }
    return true;
}

}  // namespace

bool ReaSamplerSession::saveToActiveProject() {
    std::string rppPath;
    void* proj = readActiveProject(rppPath);
    if (!proj) return false;           // no active project — nothing to persist
    if (rppPath.empty()) return false; // unsaved project — no .rpp to store into

    // Phase B: the whole book (pool as bank-zero + named banks) is authoritative and
    // rides in the `banks` key.
    const std::string banksJson = book_.serialize();
    SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
                    kProjExtBanksKey, banksJson.c_str());

    // Retire the legacy single-bank `bank_index` key: SetProjExtState with an empty
    // value DELETES the key (SDK header ~6288: val NULL or "" deletes the data). This
    // realizes retirement concretely — after any save, a formerly-legacy project
    // carries `banks` and NO `bank_index`, and going forward the legacy key is never
    // written. Cheap and idempotent when the key is already absent.
    SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
                    kProjExtIndexKey, "");

    // Additive: the Design-View model rides alongside the banks in its own key.
    // Independent write — does not disturb the `banks` blob above.
    const std::string viewJson = view_.serialize();
    SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
                    kProjExtViewKey, viewJson.c_str());

    // Additive: the docked panel's tail setting rides alongside in its own key, so the
    // tail choice travels inside the .rpp. Independent write — does not disturb the
    // bank_index or view_state above.
    const std::string tailJson = serializeTailSetting(tail_);
    SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
                    kProjExtTailKey, tailJson.c_str());

    // Additive: the owned-file manifest (Phase B B-cap) rides alongside in its own
    // `owned_files` key. Independent write — does not disturb the blobs above. Written
    // on EVERY save so a capture's manifest record survives Save / Save-As / reopen,
    // and so the manifest and the bank stay in lockstep on disk (both persisted by the
    // same saveToActiveProject the capture add-path calls). Uses the channel-derived
    // namespace (projExtNamespace) like its sibling keys — V4 isolation applies here too.
    const std::string ownedJson = owned_.serialize();
    SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
                    kProjExtOwnedKey, ownedJson.c_str());

    // Phase V (V1/V4): stamp the WRITING version — the build producing this save — under
    // the version key, on the SAME seam as the keys above so the stamp and MarkProjectDirty
    // stay paired (no drifting ad-hoc SetProjExtState). stampVersion() (NOT appVersion()) is
    // the NUMERIC TRIPLE ONLY on both channels — no "-beta" suffix — so the stamp parses as
    // Stamped on read-back and stays byte-identical to stable regardless of channel; the
    // channel is already carried by the isolated namespace (projExtNamespace) this writes to.
    SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
                    kProjExtVersionKey, stampVersion().c_str());

    // S9: stamp the current bank-generation counter under its own wire-shared key, on the SAME
    // seam so the counter and MarkProjectDirty stay paired. The value is whatever
    // bumpBankGeneration() advanced it to since the last save (0 if never bumped / pre-S9), so
    // every content mutation's own save carries the fresh generation the instrument reads. The
    // format is the SHARED pure encoder (vst::formatBankGeneration) so writer and reader agree
    // byte-for-byte — a decimal integer. Additive: does not disturb the blobs above.
    SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
                    kProjExtBankGenKey,
                    vst::formatBankGeneration(bankGeneration_).c_str());

    MarkProjectDirty(static_cast<ReaProject*>(proj));
    return true;
}

bool ReaSamplerSession::writeAssignmentRequest(const std::string& wire) {
    std::string rppPath;
    void* proj = readActiveProject(rppPath);
    if (!proj) return false;           // no active project — nothing to signal
    if (rppPath.empty()) return false; // unsaved project — no .rpp to store into

    // One-shot write of the ingest assignment request under its own key (S8). Independent
    // of the book/view/tail blobs — this is a transient signal to the instrument, not
    // session state that must ride every save. Uses the channel-derived namespace
    // (projExtNamespace) like every sibling key — V4 isolation applies here too, so a beta
    // instrument reads only a beta extension's assignment requests.
    SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
                    kProjExtAssignKey, wire.c_str());
    MarkProjectDirty(static_cast<ReaProject*>(proj));
    return true;
}

namespace {

// The dry-run file-list display cap: the orphan COUNT and reclaimed SIZE are always
// exact (tallied over the full orphan set), but the enumerated file list handed to the
// console is clipped to this many entries so a project with thousands of orphans does
// not flood the report. PruneReport::truncated flags the clip. R3's confirm surface can
// choose its own presentation; this is purely the Wave-2 dry-run readout ceiling.
constexpr std::size_t kPruneListDisplayCap = 64;

// A fresh enumerate + pure-core prune compute for the active project. Shared by the
// dry-run report (pruneDryRun), the full-set query (pruneOrphanSet), and the deletion
// (pruneReclaim) so all three agree on ONE resolution + enumeration + set-algebra path
// (no divergence between what is shown and what is deleted). REAPER-facing (resolves the
// active project, enumerates the folder) but writes nothing.
//
//   * bankDirAbs — the resolved CURRENT bank folder (absolute, forward-slashed). Empty
//                  when there is no active/saved project, no project dir, or no folder on
//                  disk yet -> the caller treats an empty dir as "nothing to reclaim".
//   * orphans    — the FULL orphan set (owned ∩ present) − referenced, in enumeration
//                  order, untruncated. The pure core decides; this only supplies inputs.
//   * sizeByRel  — per-orphan-relative on-disk byte size (0 when it could not be stat'd).
struct PruneScan {
    std::string bankDirAbs;
    std::vector<std::string> orphans;
    std::unordered_map<std::string, std::uint64_t> sizeByRel;
};

// 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 OwnedFileManifest& owned) {
    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 (M4): project dir of
    // the live .rpp + the fixed bank subfolder. Never a stored absolute path, so a
    // Save-As relocation is followed automatically. resolveBankFile is the shared M4
    // arithmetic; feeding it the bank subfolder as the "relative path" yields the folder.
    const std::string projectDir = projectDirOf(rppPath);
    const std::string bankDir = resolveBankFile(projectDir, 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 the folder into project-relative index-spelled paths, spelled the SAME
    // way the capture path spelled them (bankRelativeForName == deriveBankPaths's
    // convention) so the pure core's exact-string match lines up with referencedPaths()
    // and the manifest. Non-recursive: the bank folder is flat (capture writes files
    // directly here); skip any subdirectory. Size is stat'd here and cached by relative
    // path so the report's byte tally reuses the same on-disk read.
    // Manual iterator form (it.increment(ec)) keeps the loop non-throwing: a mid-iteration
    // failure (file removed, permission flip) breaks out with a best-effort partial list
    // rather than propagating std::filesystem_error across REAPER's C ABI.
    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 = 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 — read-only inputs from the book and manifest.
    // referencedPaths() unions across the whole book (pool included); owned().paths() is
    // the manifest set. This shell only enumerates, resolves, and stats.
    scan.bankDirAbs = bankDir;
    scan.orphans = pruneOrphans(present, book.referencedPaths(), owned.paths());
    return scan;
}

}  // namespace

PruneReport ReaSamplerSession::pruneDryRun() const {
    const PruneScan scan = scanPruneOrphans(book_, owned_);
    // buildPruneReport tallies count / byte-sum / display-truncation — no report logic
    // re-implemented here. An empty scan (no project / no folder) yields a zero report.
    return buildPruneReport(scan.orphans, scan.sizeByRel, kPruneListDisplayCap);
}

std::vector<std::string> ReaSamplerSession::pruneOrphanSet() const {
    return scanPruneOrphans(book_, owned_).orphans;  // FULL set, untruncated
}

namespace {

// Deletes ONE orphan file, trash-preferred (fork R-C, settled). Returns true iff the
// file was deleted BY THIS CALL (reclaimed here). Returns false for two distinct cases:
//   * `outAlreadyAbsent` set true  — the file was already gone before we touched it;
//     the caller folds this into the stale/staleness tally, NOT reclaimedCount.
//   * `outAlreadyAbsent` left false — a real delete failure (locked, conversion error);
//     the caller folds this into skippedCount.
// `absPath` is the resolved absolute path (forward-slashed). NON-THROWING: no exception
// may cross the C ABI.
//
// Per-platform routing:
//   * Windows — SHFileOperationW(FO_DELETE, pFrom=<double-NUL path>, FOF_ALLOWUNDO |
//     FOF_NOCONFIRMATION | FOF_SILENT | FOF_NOERRORUI). FOF_ALLOWUNDO routes to the
//     Recycle Bin (recoverable); the no-UI flags suppress REAPER-blocking dialogs (our
//     own confirm already happened). Verified against shellapi.h. `outUsedTrash` set true.
//   * Other (SWELL: macOS/Linux) — no portable move-to-trash surface is available in this
//     codebase, so fall back to std::filesystem::remove (hard unlink) behind the R3
//     confirm guardrail. `outUsedTrash` left as-is (false).
bool deleteOrphanFile(const std::string& absPath, bool& outUsedTrash,
                      bool& outAlreadyAbsent) {
#ifdef _WIN32
    // Convert forward-slashed UTF-8 to a back-slashed, double-NUL-terminated wide string.
    // SHFileOperation's pFrom is a list; a single path still needs the extra terminating
    // NUL. Backslashes are required (shell APIs reject 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 (outAlreadyAbsent stays false)
    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);
    // wbuf now holds the path + its NUL at [wlen-1]; the extra trailing L'\0' at [wlen]
    // makes it the double-NUL-terminated single-element list SHFileOperation wants.

    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
    }
    // SHFileOperation failed (e.g. file already gone yields a nonzero code on some
    // versions, or a lock). Distinguish "already absent" from a real failure so the
    // caller can tally them separately (absent -> staleness skip; failure -> locked skip).
    std::error_code ec;
    if (!fs::exists(absPath, ec)) {
        outAlreadyAbsent = true;  // vanished between scan and delete -> staleness, not reclaim
    }
    return false;
#else
    // No portable trash surface on SWELL platforms -> hard unlink behind the confirm.
    std::error_code ec;
    const bool removed = fs::remove(absPath, ec);
    if (removed) return true;          // deleted this call -> reclaimed
    if (ec) return false;              // a real failure (locked / permission) -> skip
    // remove returned false with no error == the file did not exist -> already gone.
    outAlreadyAbsent = true;           // vanished between scan and delete -> staleness, not reclaim
    return false;
#endif
}

}  // namespace

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

    // Re-enumerate + run the pure core FRESH (never a stale set): the deletion targets
    // exactly `confirmed ∩ freshOrphans` (pruneDeletePlan). A file that vanished or became
    // referenced between confirm and delete drops out of freshOrphans and is skipped; a
    // newly-appeared orphan not in `confirmed` is never swept without its own confirm.
    // Because freshOrphans is itself a pure-core output, the plan can contain NO referenced
    // and NO hand-dropped file — the R-C/R-D safety survives the recompute.
    const PruneScan scan = scanPruneOrphans(book_, owned_);
    if (scan.bankDirAbs.empty()) return result;  // no project / no folder -> nothing

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

    // Staleness skip count: entries the user confirmed that are no longer fresh orphans
    // (vanished or became referenced between confirm and delete). pruneDeletePlan already
    // de-dups confirmed internally, so compute the unique-confirmed size to avoid counting
    // de-duplicated entries as stale — that would be dishonest.
    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) {
        // Reconstruct the absolute path from the resolved bank dir + the entry's file name.
        // rel is index-spelled "<kBankSubfolder>/<name>"; the name is 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) {
            // File vanished between plan and delete — treat as staleness, same as the
            // confirm→plan gap above. Does NOT count as reclaimed (we didn't delete it).
            ++result.skippedCount;
        } else {
            ++result.skippedCount;  // locked / conversion failure -> recorded, not thrown
        }
    }
    return result;
}

namespace {

// Load the Design-View model from a project's view_state key, or return a fresh
// default. An absent/empty key (older project with no view state) yields a
// default-constructed model (Arrange + Design seeded, active = Arrange) — graceful,
// never a crash. Malformed JSON is warned and also falls back to default, mirroring
// the bank's malformed-index handling. The whole model round-trips: modes,
// membership, show-both, snapshots, and active mode all ride inside the one blob.
ViewModeModel loadViewModel(ReaProject* proj) {
    if (!proj) return ViewModeModel{};
    const std::string viewJson =
        getProjExtStateString(proj, projExtNamespace(), kProjExtViewKey);
    if (viewJson.empty()) return ViewModeModel{};  // no stored view state -> default
    std::optional<ViewModeModel> loaded = ViewModeModel::deserialize(viewJson);
    if (!loaded) {
        ShowConsoleMsg("ReaSampler: stored view state is malformed -- ignoring.\n");
        return ViewModeModel{};
    }
    return std::move(*loaded);
}

// Load the tail setting from a project's tail_setting key, or return the default. An
// absent/empty key (older / never-adjusted project) yields the default setting (None /
// 2 s manual) — graceful, never a crash. Malformed JSON is warned and also falls back
// to default, mirroring the bank's and view's malformed handling.
TailSetting loadTailSetting(ReaProject* proj) {
    if (!proj) return TailSetting{};
    const std::string tailJson =
        getProjExtStateString(proj, projExtNamespace(), kProjExtTailKey);
    if (tailJson.empty()) return TailSetting{};  // no stored setting -> default
    std::optional<TailSetting> loaded = deserializeTailSetting(tailJson);
    if (!loaded) {
        ShowConsoleMsg("ReaSampler: stored tail setting is malformed -- ignoring.\n");
        return TailSetting{};
    }
    return *loaded;
}

// Load the owned-file manifest from a project's owned_files key, or return an empty
// manifest. An absent/empty key (older / never-captured project) yields an empty
// manifest — graceful, never a crash. Malformed JSON is warned and also falls back to
// empty, mirroring the bank's / view's / tail's malformed handling. Phase R prune then
// sees an empty ownership record and (safely) attributes nothing until the next capture
// rebuilds it — losing the record degrades safety, never correctness.
OwnedFileManifest loadOwnedManifest(ReaProject* proj) {
    if (!proj) return OwnedFileManifest{};
    const std::string ownedJson =
        getProjExtStateString(proj, projExtNamespace(), kProjExtOwnedKey);
    if (ownedJson.empty()) return OwnedFileManifest{};  // no stored manifest -> empty
    std::optional<OwnedFileManifest> loaded = OwnedFileManifest::deserialize(ownedJson);
    if (!loaded) {
        ShowConsoleMsg("ReaSampler: stored owned-file manifest is malformed -- ignoring.\n");
        return OwnedFileManifest{};
    }
    return std::move(*loaded);
}

}  // namespace

void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDir) {
    // Raise the load signal for the D4 reapply-on-open glue. loadFromProject is the
    // single choke point for every load path (prime, project switch/open, forked-
    // sibling load), so setting it here — and NOT on the Save-As branch, which keeps
    // the in-memory model as-is — makes the signal fire exactly when a fresh view
    // model has been installed and its active mode's visibility needs reapplying.
    // main.cpp drains it via consumeLoadSignal() on the same tick.
    loadPending_ = true;

    // The view model is restored on EVERY load path (peer-symmetry with the bank
    // reset below): switching to a project with no view state must clear stale
    // in-memory state, not inherit the previous project's. D3 restores MODEL STATE
    // only — no visibility/processing is applied here (that is D4).
    view_ = loadViewModel(static_cast<ReaProject*>(proj));

    // The tail setting is restored on EVERY load path too (peer-symmetry): switching
    // to a project with no stored setting must fall back to the default, not inherit
    // the previous project's choice (this REPLACES the old session-carry behavior).
    tail_ = loadTailSetting(static_cast<ReaProject*>(proj));

    // The owned-file manifest is restored on EVERY load path too (peer-symmetry with the
    // bank/view/tail resets): switching to a project with no stored manifest must reset
    // to empty, not inherit the previous project's ownership record; an undo/redo reload
    // (R-B) must re-read the restored manifest so it matches the rolled-back bank state.
    owned_ = loadOwnedManifest(static_cast<ReaProject*>(proj));

    // Phase V (V1): recover the writing-version stamp on EVERY load path (peer-symmetry
    // with tail_/view_ above). An absent stamp classifies as PreVersioning, a malformed
    // one as Unknown — both silent, no console warning (a pre-versioning project is not
    // an error). getProjExtStateString returns "" for an absent key, which is exactly the
    // PreVersioning input classifyWritingVersion expects. proj == nullptr -> "" -> default.
    writingVersion_ = classifyWritingVersion(
        proj ? getProjExtStateString(static_cast<ReaProject*>(proj), projExtNamespace(),
                                     kProjExtVersionKey)
             : std::string{});

    // S9: recover the bank-generation counter on EVERY load path (peer-symmetry with
    // writingVersion_/tail_/view_ above), so it continues monotonic from the stored value
    // rather than resetting to 0 on reopen — a next bump then reads > the stored value. A
    // project switch reads THAT project's counter, not the previous one's; an absent/malformed
    // stamp (pre-S9 or corrupt) parses to 0 via the SHARED decoder. proj == nullptr -> 0.
    bankGeneration_ = vst::parseBankGeneration(
        proj ? getProjExtStateString(static_cast<ReaProject*>(proj), projExtNamespace(),
                                     kProjExtBankGenKey)
             : std::string{});

    if (!proj) {
        book_ = BankBook{};
        return;
    }

    // Read both possible sources: the authoritative `banks` blob and the retired-but-
    // possibly-still-present legacy `bank_index`. The precedence + migration decision
    // (`banks` wins; else the legacy index migrates into the pool; else an empty book)
    // is pure logic; it is inlined here rather than via BankBook::loadFromPersisted only
    // so a malformed `banks` blob can be warned on the console (single parse) — a corrupt
    // blob must read as "ignored", not silent loss, mirroring the prior malformed-index
    // warning. A malformed `banks` degrades to an empty book and does NOT fall back to
    // the stale legacy key (which would resurrect superseded single-bank state).
    const std::string banksJson =
        getProjExtStateString(static_cast<ReaProject*>(proj), projExtNamespace(),
                              kProjExtBanksKey);
    if (!banksJson.empty()) {
        std::optional<BankBook> loaded = BankBook::deserialize(banksJson);
        if (!loaded) {
            ShowConsoleMsg("ReaSampler: stored banks are malformed -- ignoring.\n");
            book_ = BankBook{};
        } else {
            book_ = std::move(*loaded);
        }
    } else {
        // No `banks` yet — fall back to the legacy `bank_index`, migrated into the pool
        // by BankBook's parse-time promotion. loadFromPersisted covers the legacy-or-
        // empty tail; passing "" for banksJson takes exactly that branch.
        const std::string legacyJson =
            getProjExtStateString(static_cast<ReaProject*>(proj), projExtNamespace(),
                                  kProjExtIndexKey);
        book_ = BankBook::loadFromPersisted(std::string{}, legacyJson);
    }

    // L7 slot migration: seed every bank's display-position SlotMap from its index
    // insertion order when the loaded blob carried none (a pre-L7 project -> dense,
    // gap-free, visually identical on first post-L7 load), and reconcile a partial map
    // (drop stale markers, append unmapped samples) for a blob written by an earlier L7
    // build. One-way: once the book is re-saved the reconciled slot data is authoritative.
    // Idempotent, so a fresh empty book is a cheap no-op.
    book_.reconcileSlots();

    // Project-relative resolution is a READ-time concern: every BankIndex in the book
    // stores only relative paths (invariant, enforced per-bank at add()), and consumers
    // (M5 panel, M6 insert) resolve each entry against the CURRENT project dir via
    // resolveBankFile(projectDir, relativePath). We do NOT rewrite stored paths to
    // absolute here — that would break the relative-only invariant and travel-with-.rpp.
    // projectDir is threaded through for those consumers; nothing to do at load time
    // beyond replacing the in-memory book.
    (void)projectDir;
}

namespace {

// Ensure a SAVED project carries a stored GUID, minting and writing one if it
// has none yet (a project saved before this feature shipped, or a brand-new
// first save). Returns the effective GUID: the existing one, the freshly minted
// one, or "" for an unsaved project (no .rpp to store ext state into — the same
// gate SetProjExtState/saveToActiveProject already respect on empty path).
// Called from BOTH prime and the Load branch so identity is established the same
// way on every entry to a project (peer-symmetry: no path skips the mint).
std::string ensureProjectGuid(void* proj, const std::string& rppPath,
                              const std::string& currentGuid) {
    if (!proj || rppPath.empty()) return {};   // unsaved -> cannot store a GUID
    if (!currentGuid.empty()) return currentGuid;
    const std::string minted = genProjectGuidString();
    SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
                    kProjExtGuidKey, minted.c_str());
    return minted;
}

}  // namespace

bool ReaSamplerSession::consumeLoadSignal() {
    const bool pending = loadPending_;
    loadPending_ = false;
    return pending;
}

void ReaSamplerSession::requestReload() {
    // Set-only; poll() drains it on the next tick (see the poll() drain block for why
    // the read is deferred past the projectconfig callback). Cheap and idempotent —
    // multiple undo/redo callbacks before the next tick collapse to one reload.
    reloadRequested_ = true;
}

void ReaSamplerSession::poll() {
    std::string rppPath;
    void* proj = readActiveProject(rppPath);
    const std::string currentGuid =
        proj ? getProjExtStateString(static_cast<ReaProject*>(proj),
                                     projExtNamespace(), kProjExtGuidKey)
             : std::string{};

    if (!primed_) {
        // First observation: adopt current identity and load its index, without
        // treating it as a "change" (avoids a spurious relocation on startup).
        primed_      = true;
        loadFromProject(proj, projectDirOf(rppPath));
        lastProject_ = proj;
        lastGuid_    = ensureProjectGuid(proj, rppPath, currentGuid);
        lastRppPath_ = rppPath;
        reloadRequested_ = false;  // priming already loaded — a co-tick request is moot
        return;
    }

    // Undo/redo reload (owner: the projectconfig hook, NOT the identity classifier
    // below). An undo/redo keeps the SAME project identity — same ReaProject*, GUID,
    // and .rpp path — so classifyProjectTransition would return NoOp and never re-read
    // ext state, leaving book_/view_ stale after the on-disk ext state rolled back.
    // The projectconfig BeginLoadProjectState callback (isUndo) raised reloadRequested_
    // one or more ticks ago; by NOW REAPER has finished restoring the project's
    // <EXTSTATE> block, so GetProjExtState returns the POST-undo value. Reload from the
    // current active project and identity-adopt it (no relocation — the path is
    // unchanged), then return. loadFromProject raises loadPending_, so the existing
    // consumeLoadSignal() glue re-baselines the panel detector and reapplies the active
    // mode; bankPanelRefresh's fingerprint pass then repaints the restored book. This is
    // the ONLY undo/redo reload path — the timer never polls ext-state CONTENT to detect
    // an undo (Daniel's directive: the hook drives it, not a poll heuristic).
    if (reloadRequested_) {
        reloadRequested_ = false;
        loadFromProject(proj, projectDirOf(rppPath));
        lastProject_ = proj;
        lastGuid_    = ensureProjectGuid(proj, rppPath, currentGuid);
        lastRppPath_ = rppPath;
        return;
    }

    // Pointer identity is the primary signal: a genuine Save-As keeps the SAME
    // ReaProject* (one object saved elsewhere); a tab-switch/open is a different
    // object. Passing the bool (not the pointer) keeps the classifier pure.
    const bool sameProjectObject = (proj == lastProject_);
    const ProjectTransition transition = classifyProjectTransition(
        sameProjectObject, lastGuid_, lastRppPath_, currentGuid, rppPath);

    switch (transition) {
        case ProjectTransition::NoOp:
            return;

        case ProjectTransition::Load: {
            // A different project of record is active (open / tab switch / new /
            // reopened / recycled pointer / forked sibling). Load ITS index; never
            // relocate.
            //
            // Forked-sibling divergence: gate on `!sameProjectObject` so this fires
            // ONLY for a step-2 Load (same GUID, different object) — a Save-As fork
            // that copied our GUID and never re-saved (its fresh GUID was runtime-
            // only on the sibling we came from). A recycled-pointer Load (step 1:
            // currentGuid != lastGuid_) must NOT re-GUID — it is already a distinct
            // identity. currentGuid == lastGuid_ can only hold here when step 1 did
            // NOT fire, i.e. this is the fork case; the explicit !sameProjectObject
            // makes that intent load-bearing rather than incidental. Do this BEFORE
            // loadFromProject reads the index (order is irrelevant — GUID and
            // bank_index are distinct keys — but self-contained is clearest).
            if (proj && !sameProjectObject && !currentGuid.empty() &&
                currentGuid == lastGuid_ && !rppPath.empty()) {
                const std::string fresh = genProjectGuidString();
                SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
                                kProjExtGuidKey, fresh.c_str());
                MarkProjectDirty(static_cast<ReaProject*>(proj));
                loadFromProject(proj, projectDirOf(rppPath));
                lastProject_ = proj;
                lastGuid_    = fresh;
                lastRppPath_ = rppPath;
                return;
            }

            // Normal load: establish identity the same way prime does.
            loadFromProject(proj, projectDirOf(rppPath));
            lastProject_ = proj;
            lastGuid_    = ensureProjectGuid(proj, rppPath, currentGuid);
            lastRppPath_ = rppPath;
            return;
        }

        case ProjectTransition::SaveAsRelocate: {
            // SAME project object + new .rpp path: a genuine Save-As (the pointer
            // proves it — a fork tab-switch is a DIFFERENT object and took the Load
            // branch above). Relocate the bank folder from the old dir to the new
            // one so the wavs sit under the new .rpp and the index's relative paths
            // still resolve. Keep the in-memory bank as-is (Save-As copied our ext
            // state, the relative paths are unchanged) — do NOT reload.
            const std::string oldDir = projectDirOf(lastRppPath_);
            const std::string newDir = projectDirOf(rppPath);
            const BankRelocation plan = deriveRelocationPlan(oldDir, newDir);
            if (plan.needed) {
                relocateBankFolder(plan.oldBankDir, plan.newBankDir);
            }

            // Save-As duplicated our ext state, so the new project B currently
            // shares A's GUID. Mint a FRESH GUID for B and write it, so A and B
            // no longer collide on identity when reopened later. Adopt the fresh
            // GUID as our last-seen identity. Mark dirty so the fresh GUID flushes
            // to the new .rpp on the next normal save / close-prompt.
            const std::string fresh = genProjectGuidString();
            if (proj) {
                SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
                                kProjExtGuidKey, fresh.c_str());
                MarkProjectDirty(static_cast<ReaProject*>(proj));
            }
            lastProject_ = proj;   // unchanged (same object) — set for symmetry
            lastGuid_    = fresh;
            lastRppPath_ = rppPath;
            return;
        }
    }
}

}  // namespace reasampler
