// bake_land.cpp — see bake_land.h. main.cpp owns the API pointers; this TU gets them
// extern. REAPER symbols used here (EnumProjects, EnumProjExtState, GetProjExtState,
// SetProjExtState, ShowConsoleMsg, Undo_BeginBlock2/EndBlock2) are verified against
// vendor/reaper-sdk/sdk/reaper_plugin_functions.h. Landing one request is bake_landing's.

#include "shell/capture/bake_land.h"

#include <cstdint>
#include <ctime>
#include <optional>
#include <string>
#include <utility>
#include <vector>

#include "core/capture/capture_paths.h"  // projectDirOfRpp
#include "core/wire/bake_wire.h"
#include "core/wire/ext_state_read.h"    // readProjExtStateGrowing (the shared grow loop)
#include "ext_keys.h"
#include "shell/capture/bake_landing.h"
#include "shell/panel/panel_input.h"     // bankPanelRefresh
#include "shell/persist/session.h"

#include "reaper_plugin.h"  // UNDO_STATE_MISCCFG

#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_EnumProjExtState
#define REAPERAPI_WANT_GetProjExtState
#define REAPERAPI_WANT_SetProjExtState
#define REAPERAPI_WANT_ShowConsoleMsg
#define REAPERAPI_WANT_Undo_BeginBlock2
#define REAPERAPI_WANT_Undo_EndBlock2
#include "reaper_plugin_functions.h"

namespace reasampler::capture {

namespace {

using wire::BakeOutcome;
using wire::BakeRequest;
using wire::BakeStatus;

// Every "rsbake_*" key in one project, keys only — the value can outgrow
// EnumProjExtState's fixed buffer, so it is read separately by the growing reader.
std::vector<std::string> pendingBakeKeys(ReaProject* proj) {
    std::vector<std::string> keys;
    const std::string prefix = kProjExtBakeKeyPrefix;
    char keyBuf[256];
    for (int idx = 0;; ++idx) {
        keyBuf[0] = '\0';
        if (!EnumProjExtState(proj, kProjExtNamespace(), idx, keyBuf,
                              static_cast<int>(sizeof(keyBuf)), nullptr, 0))
            break;
        const std::string key(keyBuf);
        if (key.compare(0, prefix.size(), prefix) == 0) keys.push_back(key);
    }
    return keys;
}

// Whole value, or the reason there isn't one. Absent and Overflow both leave the scan with
// nothing to decode — one verdict — but they are different faults to go fix, so the reason
// survives to the per-key console line rather than collapsing here.
struct KeyRead {
    std::optional<std::string> value;
    bool oversized = false;  // the value never fit under the grow loop's ceiling
};

KeyRead readKey(ReaProject* proj, const std::string& key) {
    const auto read = wire::readProjExtStateGrowing([&](char* buf, int cap) {
        return GetProjExtState(proj, kProjExtNamespace(), key.c_str(), buf, cap);
    });
    KeyRead out;
    out.oversized = read.status == wire::GrowingExtStateRead::Status::Overflow;
    if (read.status == wire::GrowingExtStateRead::Status::Complete) out.value = read.value;
    return out;
}

struct OpenProject {
    ReaProject* proj = nullptr;
    std::string dir;  // the project's own directory; empty for a never-saved project
};

// Every open project tab. The request key lives in the INSTANCE's project, which is not
// necessarily the focused one, so the scan cannot assume the current tab holds it.
std::vector<OpenProject> openProjects() {
    std::vector<OpenProject> out;
    std::vector<char> buf(4096, '\0');
    for (int idx = 0;; ++idx) {
        buf[0] = '\0';
        ReaProject* proj = EnumProjects(idx, buf.data(), static_cast<int>(buf.size()));
        if (!proj) break;
        out.push_back(OpenProject{proj, projectDirOfRpp(std::string(buf.data()))});
    }
    return out;
}

// One scanned key: what the pass decided about it, and what to write back after the undo
// block closes. The outcome is kept UNENCODED until then, because the pass's persist runs
// after the whole scan and an Ok the project never took must not be sent as one.
struct ScannedKey {
    enum class Write { None, Clear, Answer };

    ReaProject* proj = nullptr;
    std::string key;
    Write write = Write::None;  // None = an Ignore verdict, nothing to write
    BakeOutcome outcome;        // Write::Answer only
    wire::BakeKeyOutcome report;
};

// Undo_BeginBlock2/EndBlock2 as stack RAII, the FxBypassGuard discipline: a landing reads a
// whole WAV and hashes it, so a bad_alloc between the two calls would otherwise leave an
// open undo block in the user's project. Closing with an empty description records no undo
// point, which is what every path that landed nothing wants anyway.
class UndoBlock {
public:
    UndoBlock() { Undo_BeginBlock2(nullptr); }
    ~UndoBlock() { Undo_EndBlock2(nullptr, description_.c_str(), flags_); }
    void recordPoint(std::string description, int flags) {
        description_ = std::move(description);
        flags_ = flags;
    }
    UndoBlock(const UndoBlock&) = delete;
    UndoBlock& operator=(const UndoBlock&) = delete;

private:
    std::string description_;
    int flags_ = 0;
};

// One entry's write-back, and the proof of whether it took. Guarded whole: it encodes into
// a fresh string and readKey grows a buffer toward 16 MB, and a throw escaping here would
// skip the ShowConsoleMsg for EVERY key — the silent console this action exists to make
// impossible. Which proof a throw yields turns on whether SetProjExtState had run: before
// it, the write is KNOWN not to have been made (`Rejected`); after it, the write may well
// have landed and only the check failed (`Unknown`).
wire::BakeWriteProof writeBackOne(ScannedKey& entry, bool persisted) {
    bool issued = false;
    try {
        std::string value;  // empty = clear the key
        if (entry.write == ScannedKey::Write::Answer) {
            // Where a landing earns the word: the pass's persist runs after the whole scan,
            // so until here no bank write had been issued for it at all. Without one the
            // entry lives in memory alone, which is a failed bake to anyone who reloads —
            // so it is answered as one rather than as an Ok.
            const wire::BakeLanding after =
                wire::bakeLandingAfterPersist(entry.report.landing, persisted);
            if (after == wire::BakeLanding::Unpersisted) {
                entry.outcome = refuseBake(
                    BakeStatus::Failed,
                    "the bake reached the bank in memory, but this pass's persist did not "
                    "report success, so this answer cannot promise a reload will find it",
                    entry.outcome.generation);
                // `detail` left empty on purpose: describeLanding's Unpersisted clause is
                // that sentence's one home on the console side, and the message above is
                // the instrument's own copy on the wire.
            } else {
                entry.report.detail = entry.outcome.message;
            }
            entry.report.landing = after;
            value = wire::encodeBakeOutcome(entry.outcome);
        }
        SetProjExtState(entry.proj, kProjExtNamespace(), entry.key.c_str(), value.c_str());
        issued = true;
        // Only re-reading THIS key can speak for it (wire::extStateWriteLanded owns why).
        // An oversized read proves nothing either way — folding it into that predicate's
        // absent case would report a clear off unread bytes.
        const KeyRead back = readKey(entry.proj, entry.key);
        if (back.oversized) return wire::BakeWriteProof::Unknown;
        return wire::extStateWriteLanded(value, back.value) ? wire::BakeWriteProof::Confirmed
                                                            : wire::BakeWriteProof::Rejected;
    } catch (...) {
        return issued ? wire::BakeWriteProof::Unknown : wire::BakeWriteProof::Rejected;
    }
}

}  // namespace

void RunResampleBake(ReaSamplerSession& session) {
    const void* loaded = session.loadedProject();
    // REAPER's CURRENT tab, which is all this is. The action was invoked with the asking
    // instance's own project as `proj` — getReaperParent(3), documented as the parent
    // project in reaper_vst3_interfaces.h, and already paired with an EnumProjects-derived
    // enumeration by the shipped rsusage_* path prune's protection depends on. What is
    // `[verify — DAW]` is only whether REAPER makes that `proj` CURRENT for the action's
    // duration, so nothing below may call this "the tab the bake was fired against". The
    // per-key verdict holds either way, and a request this pass cannot land is told why
    // rather than silently ignored.
    const void* active = EnumProjects(-1, nullptr, 0);
    // `loaded == active` alone is enough: every OpenProject::proj enumerated below is a
    // real, non-null tab, so an unloaded `loaded` (nullptr) can never equal one and
    // matchesLoadedProject (below) falls out false regardless of this flag's value.
    const wire::BakeScanContext scanContext{loaded == active};
    const std::int64_t nowSec = static_cast<std::int64_t>(std::time(nullptr));

    std::vector<ScannedKey> scanned;
    wire::BakeScanTally tally;  // every verdict below is counted, skips included
    std::string aborted;        // set only when the scan itself threw
    bool persisted = false;     // the pass's ONE persist ran and reported success
    bool bookChanged = false;   // some landing added or refreshed an entry
    // A local, not a tally field: an entry counted here can still be re-answered as a
    // failure by the write-back loop below, so this is a persist GATE and never a count
    // anything reports.
    bool anyLanded = false;
    // The scan allocates outside attemptLanding's own guards too (readKey grows a buffer
    // toward 16 MB, every container here allocates), and a throw that escaped would discard
    // the answers already buffered — the exact no-answer-with-a-silent-console this action
    // exists to make impossible. UndoBlock is inside the try, so unwinding still closes it.
    try {
        UndoBlock undo;
        for (const OpenProject& open : openProjects()) {
            ++tally.tabsScanned;
            const bool activeTab = static_cast<const void*>(open.proj) == active;
            for (const std::string& key : pendingBakeKeys(open.proj)) {
                ++tally.keysFound;
                if (activeTab) ++tally.activeTabKeys;
                const KeyRead read = readKey(open.proj, key);
                const std::optional<BakeRequest> request =
                    read.value ? wire::decodeBakeRequest(*read.value) : std::nullopt;
                const wire::BakeScanKey scanKey{
                    read.value.has_value(), request.has_value(),
                    request ? request->generation : 0,
                    static_cast<const void*>(open.proj) == loaded};
                const wire::BakeScanVerdict verdict =
                    wire::classifyBakeScan(scanContext, scanKey, nowSec);

                ScannedKey entry;
                entry.proj = open.proj;
                entry.key = key;
                entry.report.verdict = verdict;
                entry.report.oversized = read.oversized;

                // Leave it — the writing instance owns clearing its own key.
                if (verdict == wire::BakeScanVerdict::IgnoreUnreadable) {
                    ++tally.unreadable;
                    scanned.push_back(std::move(entry));
                    continue;
                }
                // `!request` is folded in here rather than dereferenced below: today
                // classifyBakeScan filters every undecoded key into this verdict, but a
                // future verdict that did not would otherwise reach *request unguarded.
                if (verdict == wire::BakeScanVerdict::IgnoreNotARequest || !request) {
                    ++tally.notARequest;
                    entry.report.verdict = wire::BakeScanVerdict::IgnoreNotARequest;
                    scanned.push_back(std::move(entry));
                    continue;
                }
                if (verdict == wire::BakeScanVerdict::ClearStale) {
                    ++tally.staleCleared;
                    entry.write = ScannedKey::Write::Clear;
                    scanned.push_back(std::move(entry));
                    continue;
                }

                if (verdict == wire::BakeScanVerdict::RefuseWrongProject) {
                    entry.outcome =
                        refuseBake(BakeStatus::WrongProject,
                                   "this bake's project tab is not the one the extension "
                                   "has loaded -- focus that tab and try again",
                                   request->generation);
                } else {
                    LandingAttempt attempt = attemptLanding(session, open.dir, *request);
                    if (attempt.outcome.status == BakeStatus::Ok) anyLanded = true;
                    if (attempt.changedBook) bookChanged = true;
                    // Never Banked here, on ANY path: the pass's persist has not run yet,
                    // and bakeLandingAfterPersist is the one place that word is earned. A
                    // dedup hit is no exception — the entry it points at may be one this
                    // same pass added and is about to fail to persist.
                    if (attempt.partial)
                        entry.report.landing = wire::BakeLanding::Partial;
                    else if (attempt.outcome.status != BakeStatus::Ok)
                        entry.report.landing = wire::BakeLanding::Refused;
                    else
                        entry.report.landing = wire::BakeLanding::Unpersisted;
                    entry.outcome = std::move(attempt.outcome);
                }
                ++tally.answered;  // QUEUED, not written — the write loop below judges that
                entry.write = ScannedKey::Write::Answer;
                scanned.push_back(std::move(entry));
            }
        }

        if (anyLanded) {
            // A dedup hit rides the persist too, because that is the only observation that
            // can promote its landing — but it added nothing, so it earns neither the
            // refresh every open instance pays for nor an undo point named after a bake
            // that banked nothing.
            if (bookChanged) session.bumpBankGeneration();
            persisted = session.saveToActiveProject();
            if (persisted && bookChanged)
                undo.recordPoint("ReaSampler: resample bake into bank", UNDO_STATE_MISCCFG);
        }
    } catch (...) {
        aborted =
            "ReaSampler resample: the landing pass stopped early on an internal failure. "
            "The answers it had already prepared are reported below; any key whose answer "
            "it had not prepared yet went unanswered and is not named at all.\n";
    }

    // OUTSIDE the undo block on purpose: the answer is transient handshake state, and an
    // undo point that captured it could restore the consumed request on the next Ctrl-Z —
    // which the following bake would then re-land against a temp file that is long gone.
    std::string console = aborted;
    try {
        for (ScannedKey& entry : scanned) {
            if (entry.write != ScannedKey::Write::None) {
                entry.report.proof = writeBackOne(entry, persisted);
                if (entry.write == ScannedKey::Write::Answer) {
                    if (entry.report.proof == wire::BakeWriteProof::Rejected)
                        ++tally.writeFailed;
                    else if (entry.report.proof == wire::BakeWriteProof::Unknown)
                        ++tally.writeUnproven;
                }
            }
            // Unconditional, including on success: the tally counts keys but cannot name
            // them, and the key name is the only thing that tells an instance which line is
            // its own.
            console += wire::describeBakeKey(entry.key, entry.report);
        }
        console += wire::describeBakeScan(tally);
    } catch (...) {
        // Whatever lines already accumulated must still reach the console — that guarantee
        // is this action's whole reason for existing. A second throw while appending the
        // note below is swallowed for exactly the same reason: the print is what matters,
        // and append gives the strong guarantee, so `console` survives either way.
        try {
            console +=
                "ReaSampler resample: the write-back pass stopped early on an internal "
                "failure. The lines above are everything it managed to report; any key not "
                "named above may or may not have been answered.\n";
        } catch (...) {
        }
    }
    if (!console.empty()) ShowConsoleMsg(console.c_str());
    if (bookChanged) bankPanelRefresh();
}

}  // namespace reasampler::capture
