// ext_state_io.cpp — the ext-state <-> JSON serialization half of the persist
// seam (see session.h for the TU map, ext_state_io.h for the key contract):
// the session's save/load/assignment-request bridge, plus the shared
// persist_detail helpers the sibling TUs call.
//
// 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". 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, read only once to migrate a pre-multi-bank project into
// the pool. Ext state is stored inside the .rpp, so the banks travel with the
// project automatically; the physical bank folder does not, so a Save-As to a
// new directory relocates it (poll(), session.cpp).
//
// Non-destructive: this module writes only our own ext-state keys and moves
// only our own reasampler_bank/ folder.

#include "shell/persist/ext_state_io.h"

#include <filesystem>
#include <optional>
#include <string>
#include <system_error>
#include <utility>
#include <vector>

#include "shell/persist/persist_internal.h"
#include "shell/persist/session.h"

#include "core/capture/capture_paths.h"      // projectDirOfRpp (pure path arithmetic)
#include "core/instrument/map/bank_sync.h"   // parseBankGeneration / formatBankGeneration (SHARED with the instrument reader)
#include "core/wire/ext_state_read.h"        // readProjExtStateGrowing (T2-04: the ONE grow-loop policy)
#include "core/version/app_version.h"

#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::persist_detail {

namespace fs = std::filesystem;

// idx=-1 is the current project tab. rppPathOut is empty for a never-saved
// project (the reliable unsaved sentinel); returns nullptr only with 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;
}

// NOT GetProjectPathEx, which returns the recording path, not the .rpp's own
// directory. Delegates to the pure projectDirOfRpp so both artifacts share one
// implementation.
std::string projectDirOf(const std::string& rppPath) {
    return capture::projectDirOfRpp(rppPath);
}

std::string getProjExtStateString(void* proj, const char* ns, const char* key) {
    using wire::GrowingExtStateRead;
    const GrowingExtStateRead read = wire::readProjExtStateGrowing(
        [&](char* buf, int cap) {
            return GetProjExtState(static_cast<ReaProject*>(proj), ns, key, buf, cap);
        });
    switch (read.status) {
        case GrowingExtStateRead::Status::Complete:
            return read.value;
        case GrowingExtStateRead::Status::Absent:
            return {};  // absent / empty -> empty bank
        case GrowingExtStateRead::Status::Overflow:
            break;
    }
    ShowConsoleMsg(("ReaSampler: stored value for key '" + std::string(key) +
                    "' exceeds the 16 MB read ceiling -- ignoring (bank not "
                    "loaded).\n").c_str());
    return {};
}

// guidToString wants a >=64-char destination.
std::string genProjectGuidString() {
    GUID g{};
    genGuid(&g);
    char buf[64] = {0};
    guidToString(&g, buf);
    return std::string(buf);
}

// Returns the existing GUID, a freshly minted one, or "" for an unsaved
// project. Called from both prime and the Load branch so 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;
}

// Copy, not move (non-destructive); overwrites existing files at the
// destination so a re-save is idempotent. Best-effort: filesystem errors are
// swallowed and reported to the console. Returns true if the copy ran.
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 reasampler::persist_detail

namespace reasampler {

using persist_detail::getProjExtStateString;
using persist_detail::readActiveProject;

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

    const std::string banksJson = book_.serialize();
    SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
                    kProjExtBanksKey, banksJson.c_str());

    // Retire the legacy single-bank key: SetProjExtState with an empty value
    // deletes it. Idempotent when already absent.
    SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
                    kProjExtIndexKey, "");

    // Each of the following rides in its own key, independent of `banks`.
    const std::string viewJson = view_.serialize();
    SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
                    kProjExtViewKey, viewJson.c_str());

    const std::string tailJson = capture::serializeTailSetting(tail_);
    SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
                    kProjExtTailKey, tailJson.c_str());

    // Never write over a blob this build could not read (see this directory's
    // CLAUDE.md for why the suppression, not a rewrite, is the safe direction).
    if (!tracking::ledgerDegraded(trackingStatus_)) {
        const std::string ledgerJson = tracking_.serialize();
        SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
                        kProjExtOwnedKey, ledgerJson.c_str());
    }

    // stampVersion() (not appVersion()) is the numeric triple only, no "-beta"
    // suffix, so the stamp is byte-identical to stable regardless of channel
    // — the channel is already carried by the isolated namespace.
    SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
                    kProjExtVersionKey, version::stampVersion().c_str());

    // Whatever bumpBankGeneration() advanced the counter to since the last
    // save (0 if never bumped). Shared encoder so writer/reader agree byte-for-byte.
    SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
                    kProjExtBankGenKey,
                    instrument::map::formatBankGeneration(bankGeneration_).c_str());

    MarkProjectDirty(static_cast<ReaProject*>(proj));

    // The writes were ISSUED into a saved active project — all this call can observe, and
    // deliberately all it claims. Do not "prove" them with a read-back; session.h states
    // what a false has to keep meaning to its callers, and why.
    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 under its own key: a transient signal to the instrument,
    // not session state that rides every save.
    SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
                    kProjExtAssignKey, wire.c_str());
    MarkProjectDirty(static_cast<ReaProject*>(proj));
    return true;
}

namespace {

// Absent/empty key -> default-constructed model, graceful, never a crash.
// Malformed JSON is warned and also falls back to default.
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);
}

// Absent/empty key -> default (None / 2 s manual). Malformed JSON warns and
// falls back to default.
capture::TailSetting loadTailSetting(ReaProject* proj) {
    if (!proj) return capture::TailSetting{};
    const std::string tailJson =
        getProjExtStateString(proj, projExtNamespace(), kProjExtTailKey);
    if (tailJson.empty()) return capture::TailSetting{};  // no stored setting -> default
    std::optional<capture::TailSetting> loaded =
        capture::deserializeTailSetting(tailJson);
    if (!loaded) {
        ShowConsoleMsg("ReaSampler: stored tail setting is malformed -- ignoring.\n");
        return capture::TailSetting{};
    }
    return *loaded;
}

// Absent/empty key -> Fresh (a new project, or a bank predating the ledger). Anything
// this build cannot read is a degraded status, never an empty ledger.
tracking::LedgerLoad loadOriginLedger(ReaProject* proj) {
    if (!proj) return tracking::LedgerLoad{};
    tracking::LedgerLoad load = tracking::loadLedger(
        getProjExtStateString(proj, projExtNamespace(), kProjExtOwnedKey));
    if (load.status == tracking::LedgerStatus::Unreadable) {
        ShowConsoleMsg("ReaSampler: the stored file-tracking ledger is malformed. Prune "
                       "is halted for this project and the stored value is left intact "
                       "for recovery.\n");
    } else if (load.status == tracking::LedgerStatus::FutureVersion) {
        ShowConsoleMsg("ReaSampler: the stored file-tracking ledger was written by a "
                       "NEWER version of ReaSampler. Prune is halted for this project "
                       "and the stored value will not be overwritten -- reopen the "
                       "project with that version.\n");
    }
    return load;
}

}  // namespace

void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDir) {
    // loadFromProject is the single choke point for every load path (prime,
    // project switch/open, forked-sibling load) — NOT the Save-As branch,
    // which keeps the in-memory model as-is. main.cpp drains this via
    // consumeLoadSignal() on the same tick.
    loadPending_ = true;

    // view_/tail_/tracking_ are all restored on EVERY load path: switching to a
    // project with no stored state must reset to default, never inherit the
    // previous project's. An undo/redo reload must re-read the restored
    // values so they match the rolled-back state.
    view_ = loadViewModel(static_cast<ReaProject*>(proj));
    tail_ = loadTailSetting(static_cast<ReaProject*>(proj));
    tracking::LedgerLoad ledger = loadOriginLedger(static_cast<ReaProject*>(proj));
    trackingStatus_ = ledger.status;
    tracking_ = std::move(ledger.ledger);

    // An absent stamp classifies as PreVersioning, a malformed one as Unknown
    // — both silent. proj == nullptr -> "" -> default.
    writingVersion_ = version::classifyWritingVersion(
        proj ? getProjExtStateString(proj, projExtNamespace(), kProjExtVersionKey)
             : std::string{});

    // Continues monotonic from the stored value rather than resetting to 0 on
    // reopen; absent/malformed parses to 0 via the shared decoder.
    bankGeneration_ = instrument::map::parseBankGeneration(
        proj ? getProjExtStateString(proj, projExtNamespace(), kProjExtBankGenKey)
             : std::string{});

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

    // `banks` is authoritative when present; a malformed blob degrades to an
    // empty book rather than falling back to the stale legacy key (which
    // would resurrect superseded single-bank state).
    const std::string banksJson =
        getProjExtStateString(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 — migrate the legacy `bank_index` into the pool.
        const std::string legacyJson =
            getProjExtStateString(proj, projExtNamespace(), kProjExtIndexKey);
        book_ = BankBook::loadFromPersisted(std::string{}, legacyJson);
    }

    // Seed each bank's display-position SlotMap from insertion order when the
    // loaded blob carried none, and reconcile a partial map. Idempotent.
    book_.reconcileSlots();

    // Paths stay relative (read-time resolution is the consumers' job);
    // nothing to do here beyond replacing the in-memory book.
    (void)projectDir;
}

}  // namespace reasampler
