// bake_landing.cpp — see bake_landing.h. Touches no REAPER API: a landing is filesystem +
// book + ledger only, which is what lets the scan pass own every ext-state read and write.

#include "shell/capture/bake_landing.h"

#include <ctime>
#include <exception>
#include <filesystem>
#include <fstream>
#include <optional>
#include <system_error>
#include <utility>
#include <vector>

#include "core/capture/capture_paths.h"   // deriveBankPaths
#include "core/capture/wav_codec.h"       // applyMonoCollapse / hashWavContent
#include "core/model/bank_book.h"
#include "core/model/bank_model.h"
#include "core/model/resample_name.h"     // the iteration-chain display name
#include "core/tracking/tracking_authority.h"  // resampleLanding (replace vs add-distinct)
#include "core/util/file_bytes.h"         // shared whole-file loader
#include "shell/persist/session.h"

namespace reasampler::capture {

namespace {

namespace fs = std::filesystem;

using model::AddResult;
using model::Sample;
using model::nextIterationName;
using wire::BakeOutcome;
using wire::BakeRequest;
using wire::BakeStatus;

bool writeFileBytes(const std::string& path, const std::vector<std::uint8_t>& bytes) {
    std::ofstream f(path, std::ios::binary | std::ios::trunc);
    if (!f) return false;
    f.write(reinterpret_cast<const char*>(bytes.data()),
            static_cast<std::streamsize>(bytes.size()));
    return f.good();
}

// The bank holding `sampleId`, plus the entry itself. nullptr when no bank holds it.
const Sample* findSourceSample(const BankBook& book, const std::string& sampleId,
                               std::string& bankIdOut) {
    for (const Bank& b : book.banks()) {
        if (const Sample* s = b.index.query(sampleId)) {
            bankIdOut = b.id;
            return s;
        }
    }
    return nullptr;
}

// Everything a landing DECIDES, before it touches disk or book: the staged WAV read and
// hash, the source lookup, replace-vs-add, and the dedup short-circuit. Split from the
// commit below so the two halves' throws can be told apart — a prepare that threw left the
// project untouched, and only a commit that threw may have written something.
struct PreparedLanding {
    std::optional<BakeOutcome> settled;  // a refusal or a dedup hit: nothing left to commit
    BakeOutcome outcome;                 // the shape a successful commit completes
    Sample landed;                       // the entry the commit indexes
    std::vector<std::uint8_t> bytes;     // the staged file, already read
    std::string bankId;
    std::string destDir;
    std::string destPath;
    bool replace = false;
};

PreparedLanding prepareLanding(ReaSamplerSession& session, const std::string& projectDir,
                               const BakeRequest& request) {
    PreparedLanding prep;
    if (projectDir.empty()) {
        prep.settled = refuseBake(BakeStatus::NoProject,
                                  "no saved project, so the bank has no location",
                                  request.generation);
        return prep;
    }

    prep.bytes = util::readFileBytes(request.stagedFilePath);
    if (prep.bytes.empty()) {
        prep.settled = refuseBake(BakeStatus::StagedMissing,
                                  "the staged render was unreadable", request.generation);
        return prep;
    }
    // Before the hash, so nothing measures a buffer it won't write.
    // `staged.collapsed` goes unread: a rebuild that fails to reparse reverts to the
    // staged bytes inside applyMonoCollapse itself, so there is nothing left here to react to.
    CollapsedWav staged = applyMonoCollapse(std::move(prep.bytes));
    prep.bytes = std::move(staged.bytes);
    const WavLayout layout = staged.layout;
    if (!layout.valid || layout.frameCount() == 0) {
        prep.settled = refuseBake(BakeStatus::StagedMissing,
                                  "the staged render is not a usable WAV", request.generation);
        return prep;
    }

    BankBook& book = session.book();
    const Sample* source = findSourceSample(book, request.sourceSampleId, prep.bankId);
    if (!source) {
        prep.settled = refuseBake(BakeStatus::NoSource,
                                  "the resampled capture is not in any bank",
                                  request.generation);
        return prep;
    }
    // Copied, not aliased: every mutation the commit makes invalidates the book's pointers.
    const Sample sourceCopy = *source;

    const tracking::Landing landing = tracking::resampleLanding(
        session.tiedUsageFor(request.sourceRelativePath, request.ownUsageKey));
    prep.replace = (landing == tracking::Landing::Replace);

    // WAV-aware hash: the bank's dedup key, and — on the add path only — the reason a
    // byte-identical bake yields no second entry. Replace never dedups, matching
    // updateSampleInPlace's own contract: an in-place refresh is not an insert.
    const std::string contentHash = hashWavContent(prep.bytes);

    prep.outcome.generation = request.generation;
    prep.outcome.rootNote = request.rootNote;
    prep.outcome.channelCount = static_cast<int>(layout.channelCount);
    prep.outcome.replaced = prep.replace;

    if (!prep.replace && !contentHash.empty()) {
        if (const model::BankModel* index = book.index(prep.bankId)) {
            if (const Sample* existing = index->findByHash(contentHash)) {
                // Dedup BEFORE the disk write: this bake is bytes the bank already holds,
                // so it re-points at that entry rather than depositing an unreferenced
                // twin for the prune to reclaim later. It still leaves `changedBook` false
                // and still rides the pass's persist — the entry it points at may be one
                // the same pass added moments ago.
                BakeOutcome out = prep.outcome;
                out.status = BakeStatus::Ok;
                out.sampleId = existing->id;
                out.relativePath = existing->relativePath;
                out.displayName = existing->displayName;
                out.message = "identical to an existing capture -- pointed at it";
                prep.settled = std::move(out);
                return prep;
            }
        }
    }

    const std::int64_t nowSec = static_cast<std::int64_t>(std::time(nullptr));
    // Seconds alone are not unique enough here: two bakes of the same source inside one
    // second would derive the same file name, and the second would overwrite a file the
    // first had just indexed. The content hash separates them, and leaves a re-bake of
    // identical bytes idempotent rather than duplicated.
    const std::string uniqueTag =
        std::to_string(nowSec) +
        (contentHash.empty() ? std::string{} : "-" + contentHash.substr(0, 8));
    const std::string stem =
        sourceCopy.displayName.empty() ? std::string("resample") : sourceCopy.displayName;
    const BankPaths paths = deriveBankPaths(projectDir, stem, uniqueTag);
    prep.destDir = paths.absoluteDir;
    prep.destPath = paths.absoluteDir + "/" + paths.fileName;

    // Replace keeps the entry's identity and its slot — the sound iterated, it did not
    // become a different capture. The superseded FILE is untouched: it stays on disk,
    // unreferenced, until a prune reclaims it, which is the iterate loop's recovery floor.
    prep.landed.id =
        prep.replace ? sourceCopy.id : ("bake-" + uniqueTag + "-" + paths.fileName);
    prep.landed.displayName =
        prep.replace ? sourceCopy.displayName : nextIterationName(sourceCopy.displayName);
    prep.landed.relativePath = paths.relativePath;  // project-relative (invariant)
    prep.landed.channelCount = static_cast<int>(layout.channelCount);
    prep.landed.sampleRate = static_cast<int>(layout.sampleRate);
    prep.landed.lengthSeconds =
        layout.sampleRate ? static_cast<double>(layout.frameCount()) /
                                static_cast<double>(layout.sampleRate)
                          : 0.0;
    // Rendered AT root — that is what makes it survive.
    prep.landed.rootNote = request.rootNote;
    prep.landed.tier = model::Tier::Scratch;
    prep.landed.contentHash = contentHash;
    prep.landed.createdTimestamp = nowSec;
    // The lineage seed recordCreated reads: the ledger's parent chain is what makes a
    // repeated bake readable as one iteration chain.
    prep.landed.provenance = model::Provenance{sourceCopy.id, std::string{}};
    return prep;
}

// The mutating half. From its first successful write a file exists and the book can change,
// so a throw out of THIS function is not invisible to the project — which is why its caller
// reports it differently from a prepare that threw. Its own refusals withdraw the file they
// wrote, best-effort.
BakeOutcome commitLanding(ReaSamplerSession& session, PreparedLanding& prep) {
    std::error_code ec;
    fs::create_directories(prep.destDir, ec);  // idempotent; the write reports failure
    if (!writeFileBytes(prep.destPath, prep.bytes)) {
        // A failed write can still have created the file before failing; withdraw it for
        // the same reason the refused index below does.
        fs::remove(prep.destPath, ec);
        return refuseBake(BakeStatus::Failed,
                          "could not write the bake into the bank folder",
                          prep.outcome.generation);
    }

    BankBook& book = session.book();
    // On the replace path prepareLanding set `landed.id` to the source's own id, so this is
    // the entry being refreshed in place.
    const bool indexed =
        prep.replace ? book.updateSampleInPlace(prep.landed.id, prep.landed)
                     : (book.index(prep.bankId) &&
                        book.index(prep.bankId)->add(prep.landed) == AddResult::Added);
    if (!indexed) {
        // Self-cleanup of a file this call wrote seconds ago and never indexed — the
        // carve-out shell/persist/CLAUDE.md states, not prune's authority over the bank's
        // known bytes. Leaving it would deposit an untracked orphan per refused bake.
        fs::remove(prep.destPath, ec);
        return refuseBake(BakeStatus::IndexRejected,
                          prep.replace ? "the bank refused the replacement"
                                       : "the bank refused the new capture",
                          prep.outcome.generation);
    }
    session.recordCreated(prep.landed, tracking::OriginKind::Capture);

    BakeOutcome out = prep.outcome;
    out.status = BakeStatus::Ok;
    out.sampleId = prep.landed.id;
    out.relativePath = prep.landed.relativePath;
    out.displayName = prep.landed.displayName;
    out.message = prep.replace ? "replaced the bank entry" : "added as a distinct capture";
    return out;
}

}  // namespace

BakeOutcome refuseBake(BakeStatus status, std::string message, std::int64_t generation) {
    BakeOutcome out;
    out.status = status;
    out.message = std::move(message);
    out.generation = generation;
    return out;
}

LandingAttempt attemptLanding(ReaSamplerSession& session, const std::string& projectDir,
                              const BakeRequest& request) {
    LandingAttempt attempt;
    PreparedLanding prep;
    try {
        prep = prepareLanding(session, projectDir, request);
    } catch (const std::exception& e) {
        attempt.outcome = refuseBake(
            BakeStatus::Failed,
            std::string("the landing failed before it wrote anything: ") + e.what(),
            request.generation);
        return attempt;
    } catch (...) {
        attempt.outcome = refuseBake(
            BakeStatus::Failed,
            "the landing failed before it wrote anything, for an unknown reason",
            request.generation);
        return attempt;
    }
    if (prep.settled) {
        attempt.outcome = std::move(*prep.settled);
        return attempt;  // a refusal, or a dedup hit that changed nothing
    }
    try {
        attempt.outcome = commitLanding(session, prep);
        attempt.changedBook = attempt.outcome.status == BakeStatus::Ok;
    } catch (const std::exception& e) {
        attempt.partial = true;
        attempt.outcome =
            refuseBake(BakeStatus::Failed,
                       std::string("the landing failed while writing: ") + e.what(),
                       request.generation);
    } catch (...) {
        attempt.partial = true;
        attempt.outcome = refuseBake(BakeStatus::Failed,
                                     "the landing failed while writing, for an unknown reason",
                                     request.generation);
    }
    return attempt;
}

}  // namespace reasampler::capture
