// capture_batch.cpp — the batch-capture family + re-capture-from-source. See the
// header.
//
// 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.

#include "shell/capture/capture_batch.h"

#include <cstddef>
#include <optional>
#include <string>
#include <utility>
#include <vector>

#include "shell/panel/panel_bank_ops.h"         // bankPanelSelectedSampleIds / SourceBankId
#include "shell/panel/panel_input.h"            // bankPanelRefresh
#include "core/capture/batch_capture.h"         // planCaptureUnits / BatchOutcome
#include "core/capture/capture_paths.h"         // projectDirOfRpp
#include "core/model/bank_book.h"               // BankBook / Bank
#include "core/model/provenance.h"              // recipe parse/build, fingerprint
#include "shell/persist/session.h"              // ReaSamplerSession
#include "shell/capture/capture_orchestrator.h" // captureAndIndexOne / renderOffline
#include "shell/capture/provenance_shell.h"     // fxChainIdentity* / trackByGuid
#include "shell/capture/render_bounds_gate.h"   // refusedRenderFolder
#include "shell/capture/scope_resolve.h"        // ResolvedSource
#include "shell/capture/track_guid.h"           // guidString

#include "reaper_plugin.h"  // UNDO_STATE_MISCCFG

#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_CountSelectedMediaItems
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_GetSelectedMediaItem
#define REAPERAPI_WANT_GetMediaItem_Track
#define REAPERAPI_WANT_GetMediaItemInfo_Value
#define REAPERAPI_WANT_CountMediaItems
#define REAPERAPI_WANT_GetMediaItem
#define REAPERAPI_WANT_SetMediaItemSelected
#define REAPERAPI_WANT_UpdateArrange
#define REAPERAPI_WANT_CountTracks
#define REAPERAPI_WANT_GetTrack
#define REAPERAPI_WANT_GetSetMediaTrackInfo_String
#define REAPERAPI_WANT_CountSelectedTracks
#define REAPERAPI_WANT_GetSelectedTrack
#define REAPERAPI_WANT_SetTrackSelected
#define REAPERAPI_WANT_SetOnlyTrackSelected
#define REAPERAPI_WANT_ShowConsoleMsg
#define REAPERAPI_WANT_Undo_BeginBlock2
#define REAPERAPI_WANT_Undo_EndBlock2
#include "reaper_plugin_functions.h"

namespace reasampler::capture {

// One action fires N captures — one sample per selected item (item scope) or per
// razor area (track scope, each area's own range). Each unit routes through
// captureAndIndexOne so every precision invariant holds; nothing lands in the
// arrange (load-bearing principle).
//
// Each unit is named after its own source track and carries its batch ordinal, so two
// units in one batch read apart even when they came off the same track; makeUniqueTag's
// per-session monotonic counter keeps same-second units across batches from colliding.

namespace {

// RAII snapshot/restore of the item selection. Batch item capture must transiently
// select exactly one item per render (RENDER_SETTINGS &32 renders whatever is
// selected); the original selection is restored on every exit path — including a
// mid-batch failure — as part of the non-destructive invariant.
class ItemSelectionGuard
{
public:
    ItemSelectionGuard()
    {
        const int n = CountSelectedMediaItems(nullptr);
        for (int i = 0; i < n; ++i)
            if (MediaItem* it = GetSelectedMediaItem(nullptr, i))
                selected_.push_back(it);
    }

    ~ItemSelectionGuard()
    {
        // Deselect everything first (not just currently-selected) so any transient
        // selection is cleared, then re-select exactly the snapshot.
        const int total = CountMediaItems(nullptr);
        for (int i = 0; i < total; ++i)
            if (MediaItem* it = GetMediaItem(nullptr, i))
                SetMediaItemSelected(it, false);
        for (MediaItem* it : selected_)
            SetMediaItemSelected(it, true);
        UpdateArrange();  // reflect the restored selection in the arrange view
    }

    ItemSelectionGuard(const ItemSelectionGuard&)            = delete;
    ItemSelectionGuard& operator=(const ItemSelectionGuard&) = delete;

private:
    std::vector<MediaItem*> selected_;
};

// Names where refused renders were retained, once, when the batch quarantined at
// least one (BoundsMismatch failures only -- a render that never produced a file has
// nothing to retain). Without this, a batch's per-unit failure detail (which DOES name
// the destination, same as a single capture's console line) never reaches the console
// at all -- the batch summary reports ordinals only.
std::string withQuarantineNote(std::string line, int quarantinedCount) {
    if (quarantinedCount <= 0) return line;
    std::vector<char> buf(4096, '\0');
    EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
    const std::string dir = projectDirOfRpp(std::string(buf.data()));
    if (dir.empty()) return line;  // unreachable: a quarantine implies a saved project
    line += " " + std::to_string(quarantinedCount) + " refused render" +
            (quarantinedCount == 1 ? " was" : "s were") + " retained for diagnosis, "
            "normally at " + refusedRenderFolder(dir) + " (delete when done) -- one "
            "whose move there failed instead stays in the bank folder, unindexed.";
    return line;
}

// Deselect-all then select-one so the offline render's &32 bit captures exactly
// this item. Called inside ItemSelectionGuard, which restores the original selection.
void selectOnlyItem(MediaItem* item)
{
    const int total = CountMediaItems(nullptr);
    for (int i = 0; i < total; ++i)
        if (MediaItem* it = GetMediaItem(nullptr, i))
            SetMediaItemSelected(it, it == item);
}

// Collects every track's razor areas as (owning track, range) pairs, track order
// then area order. Read-only — never clears the razor selection.
std::vector<std::pair<MediaTrack*, RazorRange>> collectRazorAreas()
{
    std::vector<std::pair<MediaTrack*, RazorRange>> areas;
    const int n = CountTracks(nullptr);
    for (int i = 0; i < n; ++i)
    {
        MediaTrack* tr = GetTrack(nullptr, i);
        if (!tr) continue;
        std::vector<char> buf(8192, '\0');
        if (!GetSetMediaTrackInfo_String(tr, "P_RAZOREDITS", buf.data(), false))
            continue;
        for (const RazorRange& r : parseRazorEdits(std::string(buf.data())))
            areas.push_back({tr, r});
    }
    return areas;
}

// Mirror of ItemSelectionGuard for track selection: batch razor capture transiently
// selects the area's owning track per render (&128 renders selected tracks), restoring
// the original selection on every exit path (non-destructive invariant).
class TrackSelectionGuard
{
public:
    TrackSelectionGuard()
    {
        const int n = CountSelectedTracks(nullptr);
        for (int i = 0; i < n; ++i)
            if (MediaTrack* tr = GetSelectedTrack(nullptr, i))
                selected_.push_back(tr);
    }

    ~TrackSelectionGuard()
    {
        // Deselect every track, then re-select the snapshot — the exact original set.
        const int total = CountTracks(nullptr);
        for (int i = 0; i < total; ++i)
            if (MediaTrack* tr = GetTrack(nullptr, i))
                SetTrackSelected(tr, false);
        for (MediaTrack* tr : selected_)
            SetTrackSelected(tr, true);
    }

    TrackSelectionGuard(const TrackSelectionGuard&)            = delete;
    TrackSelectionGuard& operator=(const TrackSelectionGuard&) = delete;

private:
    std::vector<MediaTrack*> selected_;
};

}  // namespace

// One sample per selected item. Snapshots the selection (RAII-restored), transiently
// selects each item in turn, renders its exact range under item-scope FX neutralize,
// and persists once at the end for the whole batch.
void RunBatchCaptureItems(ReaSamplerSession& session)
{
    // Read bounds + owning track now, while the full selection is live and before any
    // transient re-selection (batch only mutates selection flags, never adds/removes items).
    struct ItemUnit { MediaItem* item; MediaTrack* track; double start; double end; };
    std::vector<ItemUnit> itemUnits;
    {
        const int n = CountSelectedMediaItems(nullptr);
        for (int i = 0; i < n; ++i)
        {
            MediaItem* it = GetSelectedMediaItem(nullptr, i);
            if (!it) continue;
            MediaTrack* tr = GetMediaItem_Track(it);
            if (!tr) continue;
            const double pos = GetMediaItemInfo_Value(it, "D_POSITION");
            const double len = GetMediaItemInfo_Value(it, "D_LENGTH");
            itemUnits.push_back({it, tr, pos, pos + len});
        }
    }
    if (itemUnits.empty())
    {
        ShowConsoleMsg("ReaSampler batch capture: select at least one media item.\n");
        return;
    }

    // Plan exact ranges -> validated, ordinal-assigned units; zero-length items are
    // dropped here so no stray render runs.
    std::vector<BatchRange> ranges;
    ranges.reserve(itemUnits.size());
    for (const ItemUnit& u : itemUnits)
        ranges.push_back({u.start, u.end});
    const std::vector<CaptureUnit> plan = planCaptureUnits(ranges);

    BatchOutcome outcome;
    bool anyAdded = false;
    int quarantined = 0;  // BoundsMismatch failures, each of which retained a file
    {
        // selGuard restores the original item selection on every exit path.
        ItemSelectionGuard selGuard;

        // plan and itemUnits are parallel over kept units; skip dropped ranges in lockstep.
        std::size_t planIdx = 0;
        for (const ItemUnit& u : itemUnits)
        {
            if (!(u.end > u.start)) continue;  // dropped by planCaptureUnits — skip in lockstep
            const CaptureUnit& unit = plan[planIdx++];

            // select only this item so the item-scope render captures exactly it.
            selectOnlyItem(u.item);

            ResolvedSource src;
            src.startSeconds = unit.startSeconds;
            src.endSeconds   = unit.endSeconds;
            src.sourceTracks.push_back(u.track);
            // The unit's range IS this item's own extent (read above from
            // D_POSITION/D_LENGTH) and it is the only selected item, so the
            // selected-items render source prints exactly it — batch keeps the
            // one-sample-per-item-at-item-extent semantics, unchanged.
            src.itemExtentIsWindow = true;
            src.trackNames.push_back(trackName(u.track));
            if (std::string g = guidString(u.track); !g.empty())
                src.trackGuids.push_back(std::move(g));

            const CaptureName name =
                captureNameFor(src.trackNames, unit.ordinal, "item");
            CaptureResult res = captureAndIndexOne(
                session, CaptureScope::Item, src, name,
                unit.startSeconds, unit.endSeconds);

            const bool ok = (res.status == CaptureStatus::Ok);
            outcome.record(unit.ordinal, ok, ok ? std::string{} : res.message);
            if (ok) anyAdded = true;
            else if (res.status == CaptureStatus::BoundsMismatch) ++quarantined;
        }
    }  // selGuard restores the original selection here, on every path

    // One ext-state write for the whole batch, only if something landed. The generation
    // bump is monotonic, so one increment past the last-seen value triggers reload in
    // any listening instance.
    if (anyAdded) {
        session.bumpBankGeneration();
        session.saveToActiveProject();
    }

    ShowConsoleMsg((withQuarantineNote(outcome.summaryLine("item"), quarantined) +
                   "\n").c_str());
}

// One sample per razor area, track scope over that area's own range. Track scope
// renders selected tracks via master (&128), so each unit selects only its owning
// track under TrackSelectionGuard; the razor selection itself is read-only. Persists
// once at the end.
void RunBatchCaptureRazor(ReaSamplerSession& session)
{
    const std::vector<std::pair<MediaTrack*, RazorRange>> areas = collectRazorAreas();
    if (areas.empty())
    {
        ShowConsoleMsg("ReaSampler batch capture: make at least one razor area first.\n");
        return;
    }

    std::vector<BatchRange> ranges;
    ranges.reserve(areas.size());
    for (const auto& a : areas)
        ranges.push_back({a.second.startSeconds, a.second.endSeconds});
    const std::vector<CaptureUnit> plan = planCaptureUnits(ranges);

    BatchOutcome outcome;
    bool anyAdded = false;
    int quarantined = 0;  // BoundsMismatch failures, each of which retained a file
    {
        // selGuard restores the original track selection on every exit path.
        TrackSelectionGuard selGuard;

        std::size_t planIdx = 0;
        for (const auto& a : areas)
        {
            if (!(a.second.endSeconds > a.second.startSeconds)) continue;  // dropped — lockstep
            const CaptureUnit& unit = plan[planIdx++];
            MediaTrack* tr = a.first;

            // select only this track so track-scope render (&128) captures it via master.
            SetOnlyTrackSelected(tr);

            ResolvedSource src;
            src.startSeconds = unit.startSeconds;
            src.endSeconds   = unit.endSeconds;
            src.sourceTracks.push_back(tr);
            src.trackNames.push_back(trackName(tr));
            if (std::string g = guidString(tr); !g.empty())
                src.trackGuids.push_back(std::move(g));

            const CaptureName name =
                captureNameFor(src.trackNames, unit.ordinal, "razor");
            CaptureResult res = captureAndIndexOne(
                session, CaptureScope::Track, src, name,
                unit.startSeconds, unit.endSeconds);

            const bool ok = (res.status == CaptureStatus::Ok);
            outcome.record(unit.ordinal, ok, ok ? std::string{} : res.message);
            if (ok) anyAdded = true;
            else if (res.status == CaptureStatus::BoundsMismatch) ++quarantined;
        }
    }  // selGuard restores the original track selection here, on every path

    // One coalesced generation bump for the whole batch (see the item-batch note above).
    if (anyAdded) {
        session.bumpBankGeneration();
        session.saveToActiveProject();
    }

    ShowConsoleMsg((withQuarantineNote(outcome.summaryLine("razor area"), quarantined) +
                   "\n").c_str());
}

// Regenerates a provenanced sample's file from its recorded source's current state
// and updates the bank Sample in place. Bank-only — never calls InsertMedia; the
// user re-places manually if they want the new version on the timeline.
// Non-destructive to the source (FxBypassGuard snapshot/restore via renderOffline).
//
// Failure modes are explicit and reported, each a no-op: no provenance, unparseable
// fingerprint, a missing recorded source track, or a failed render. On success, if
// the source FX chain drifted since capture, the user is told — the re-capture still
// reflects the source as it is now (drift is detected, not frozen against).
void RunRecaptureFromSource(ReaSamplerSession& session)
{
    const std::vector<std::string> selected = bankPanelSelectedSampleIds();
    if (selected.empty())
    {
        ShowConsoleMsg("ReaSampler re-capture: select a sample in the bank panel first.\n");
        return;
    }
    if (selected.size() > 1)
    {
        ShowConsoleMsg("ReaSampler re-capture: select a single sample to re-capture.\n");
        return;
    }
    const std::string sampleId = selected.front();

    // Resolve the sample from the bank it lives in (the focused region's displayed bank).
    const std::string srcBankId = bankPanelSelectedSourceBankId();
    const Bank* bank = session.book().bank(srcBankId);
    const model::Sample* orig = bank ? bank->index.query(sampleId) : nullptr;
    if (!orig)
    {
        ShowConsoleMsg("ReaSampler re-capture: the selected sample is no longer in the bank.\n");
        return;
    }
    if (!orig->provenance)
    {
        ShowConsoleMsg("ReaSampler re-capture: this sample has no provenance "
                       "(it was not resampled from a bank sample).\n");
        return;
    }

    // Parse the recorded recipe; legacy/corrupt fingerprints fail gracefully, never
    // a partial re-capture.
    const std::string recordedParentId = orig->provenance->parentSampleId;
    const std::string recordedFingerprint = orig->provenance->fxChainSnapshot;
    const std::optional<model::CaptureRecipe> recipe =
        model::parseFingerprint(recordedFingerprint);
    if (!recipe)
    {
        ShowConsoleMsg("ReaSampler re-capture: this sample's provenance is unreadable "
                       "(recorded by an older/incompatible build); cannot re-capture.\n");
        return;
    }

    // Missing recorded track = hard failure; never silently re-capture a different source.
    std::vector<MediaTrack*> sourceTracks;
    for (const std::string& g : recipe->trackGuids)
    {
        MediaTrack* tr = trackByGuid(g);
        if (!tr)
        {
            ShowConsoleMsg("ReaSampler re-capture: a recorded source track no longer "
                           "exists in this project; cannot re-capture from source.\n");
            return;
        }
        sourceTracks.push_back(tr);
    }
    if (sourceTracks.empty())
    {
        // e.g. an item-scope capture with no track-scoped source — nothing to resolve.
        ShowConsoleMsg("ReaSampler re-capture: no resolvable recorded source for this "
                       "sample; cannot re-capture from source.\n");
        return;
    }

    const CaptureScope scope =
        recipe->scope == model::ProvenanceScope::Item ? CaptureScope::Item
                                                      : CaptureScope::Track;

    // Rebuild the request verbatim from the recorded recipe, re-run against the
    // source's current state: an unchanged source reproduces a byte-identical file
    // (bit-identical-repeats invariant).
    CaptureRequest req;
    req.sourceMode   = static_cast<SourceMode>(recipe->sourceMode);
    req.startSeconds = recipe->startSeconds;
    req.endSeconds   = recipe->endSeconds;
    req.wetDry       = 1.0;
    req.tailMode     = static_cast<TailMode>(recipe->tailMode);
    req.tailMs       = recipe->tailMs;
    req.sampleRate   = recipe->sampleRate;
    req.channelCount = recipe->channelCount;
    req.bitDepth     = WavBitDepth::Float32;
    // orig->displayName is the ORIGINAL capture's label (recapture preserves identity, it
    // does not re-mint it — see this function's header comment), so the regenerated file's
    // stem carries the original capture's stamp, not this render's — do not read it as a
    // render timestamp.
    req.baseName     = orig->displayName.empty() ? "recapture" : orig->displayName;
    req.trackGuids   = recipe->trackGuids;

    // Read the current FX-chain identity BEFORE the render bypasses it, for drift
    // comparison against the recorded identity (item scope via TakeFX_*, track scope
    // via TrackFX_*).
    std::string currentIdentity;
    if (scope == CaptureScope::Item) {
        const int n = CountSelectedMediaItems(nullptr);
        std::vector<MediaItem*> items;
        items.reserve(static_cast<std::size_t>(n < 0 ? 0 : n));
        for (int i = 0; i < n; ++i) {
            MediaItem* it = GetSelectedMediaItem(nullptr, i);
            if (it) items.push_back(it);
        }
        currentIdentity = fxChainIdentityForItems(items);
    } else {
        std::vector<std::string> perTrackNow;
        perTrackNow.reserve(sourceTracks.size());
        for (MediaTrack* tr : sourceTracks)
            perTrackNow.push_back(fxChainIdentityForTrack(tr));
        currentIdentity = model::combineChainIdentities(perTrackNow);
    }
    const bool drifted = (currentIdentity != recipe->fxChainIdentity);

    // Render (bank-only; renderOffline never touches the timeline).
    CaptureResult res = renderOffline(scope, sourceTracks, req);
    if (res.status != CaptureStatus::Ok)
    {
        ShowConsoleMsg(("ReaSampler re-capture failed: " + res.message + "\n").c_str());
        return;
    }

    // Update in place: keep identity (id) + provenance parent, adopt the regenerated
    // file's path/hash/length/rate/timestamp, and rebuild the fingerprint with the
    // current FX identity so the next re-capture measures drift from here, not the
    // original.
    model::CaptureRecipe refreshed = *recipe;
    refreshed.fxChainIdentity = currentIdentity;

    model::Sample updated = *orig;           // copy: preserves id, displayName, tier, key
    updated.relativePath  = res.sample.relativePath;
    updated.contentHash   = res.sample.contentHash;
    updated.sourceMode    = res.sample.sourceMode;
    updated.sourceRange   = res.sample.sourceRange;
    updated.channelCount  = res.sample.channelCount;
    updated.sampleRate    = res.sample.sampleRate;
    updated.lengthSeconds = res.sample.lengthSeconds;
    updated.captureTempo  = res.sample.captureTempo;
    updated.captureTimeSigNum   = res.sample.captureTimeSigNum;   // refresh meter stamp
    updated.captureTimeSigDenom = res.sample.captureTimeSigDenom; // to the re-capture's meter
    updated.trackGuids    = res.sample.trackGuids;
    updated.createdTimestamp = res.sample.createdTimestamp;
    // levels/clipped/lengthBeats carry from *orig — the offline backend doesn't
    // populate them; refresh from res.sample here if that ever changes.
    model::Provenance prov;
    prov.parentSampleId  = recordedParentId;
    prov.fxChainSnapshot = model::buildFingerprint(refreshed);
    updated.provenance   = prov;

    // One batched undo point around the in-place mutation; index-only ext-state,
    // nothing placed on the timeline.
    Undo_BeginBlock2(nullptr);
    // Before the index outcome is even known: the render above already wrote the file,
    // and makeUniqueTag guarantees a NEW path, so this is a second record carrying the
    // same sampleId as the original. The superseded file becomes an orphan for prune.
    session.recordCreated(updated, tracking::OriginKind::Recapture);
    const bool changed = session.book().updateSampleInPlace(sampleId, updated);
    if (changed)
    {
        // Regenerating the same id's audio is exactly why instances need the generation
        // bump — they'd otherwise keep playing stale audio until reload. Bumped inside
        // the undo block so undo rolls back the generation with the rest of the blob.
        session.bumpBankGeneration();
        const bool persisted = session.saveToActiveProject();  // book + ledger + generation + MarkProjectDirty
        Undo_EndBlock2(nullptr, persisted ? "ReaSampler: re-capture from source" : "",
                       persisted ? UNDO_STATE_MISCCFG : 0);
    }
    else
    {
        Undo_EndBlock2(nullptr, "", 0);  // nothing mutated -> discard the empty point
    }

    bankPanelRefresh();  // reflect the regenerated file in the docked grid

    if (drifted)
        ShowConsoleMsg("ReaSampler re-capture: the source FX chain changed since the "
                       "original capture -- the sample was regenerated from the source's "
                       "current state.\n");
}

}  // namespace reasampler::capture
