// See view.h. Compiled into the reaper_reasampler module; includes
// reaper_plugin_functions.h without REAPERAPI_IMPLEMENT (main.cpp owns that).
// Tree arithmetic lives in view_tree (pure); this file owns REAPER reads/writes
// and the snapshot-before-park ordering.

#include "shell/view/view.h"

#include <cstdio>
#include <map>
#include <optional>
#include <set>
#include <string>
#include <utility>
#include <vector>

#include "shell/capture/item_read.h"
#include "core/view/fx_offline.h"
#include "core/view/lane_keys.h"
#include "core/view/solo_cache.h"
#include "shell/capture/track_guid.h"
#include "shell/view/view_solo.h"
#include "core/view/view_tree.h"

#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_CountTracks
#define REAPERAPI_WANT_GetPlayStateEx
#define REAPERAPI_WANT_GetTrack
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
#define REAPERAPI_WANT_SetMediaTrackInfo_Value
#define REAPERAPI_WANT_GetSetMediaTrackInfo_String
#define REAPERAPI_WANT_ShowConsoleMsg
#define REAPERAPI_WANT_TrackFX_GetCount
#define REAPERAPI_WANT_TrackFX_GetFXGUID
#define REAPERAPI_WANT_TrackFX_GetOffline
#define REAPERAPI_WANT_TrackFX_SetOffline
#define REAPERAPI_WANT_guidToString
#define REAPERAPI_WANT_Undo_BeginBlock2
#define REAPERAPI_WANT_Undo_EndBlock2
#define REAPERAPI_WANT_TrackList_AdjustWindows
#define REAPERAPI_WANT_UpdateArrange
#define REAPERAPI_WANT_UpdateTimeline
// Lane minting (D2 Wave 3): item-side lane reads/writes to assign each item to
// its mode's managed lane.
#define REAPERAPI_WANT_CountTrackMediaItems
#define REAPERAPI_WANT_GetTrackMediaItem
#define REAPERAPI_WANT_GetMediaItemInfo_Value
#define REAPERAPI_WANT_SetMediaItemInfo_Value
#include "reaper_plugin_functions.h"

namespace reasampler {

using view::buildFolderTree;
using view::isOnManualLane;
using view::managedLaneKey;
using view::modeIdFromLaneName;
using view::TrackFolderEntry;

namespace {

// I_FREEMODE value for fixed lanes. SDK: 0=normal, 1=free item positioning, 2=fixed lanes.
constexpr int kFreeModeFixedLanes = 2;

// GetPlayStateEx bitmask. SDK: &1 playing, &2 paused, &4 recording — paused is
// deliberately excluded, nothing is moving there.
constexpr int kTransportMoving = 1 | 4;

// C_LANESCOLLAPSED=2: render a tool-split track like a normal single-lane
// track showing only the playing lane (SDK: 1=collapsed, 2=hidden-lanes-exist
// but displays as non-fixed-lane).
constexpr int kLanesDisplayAsNormal = 2;

// C_LANESETTINGS &32 = hide per-lane buttons; OR'd in, never clobbering the
// mask. Deliberately NOT setting &1 (auto-remove empty lanes): the lazy-mint
// decision never mints an empty lane, so &1 buys nothing and risks REAPER
// silently removing a managed lane out from under the ownership index.
constexpr int kLaneSettingsHideButtons = 32;

// Makes a tool-split track's display read as an ordinary track. Gated by every
// caller on the tool-driven transition INTO fixed lanes (freeMode != 2 before
// the flip) — a track already in fixed-lane mode (the user's own) never
// reaches this, so a user's comp-lane display prefs are never stomped.
void applyTransparentLaneDisplay(MediaTrack* tr) {
    SetMediaTrackInfo_Value(tr, "C_LANESCOLLAPSED",
                            static_cast<double>(kLanesDisplayAsNormal));
    const int settings = static_cast<int>(GetMediaTrackInfo_Value(tr, "C_LANESETTINGS"));
    SetMediaTrackInfo_Value(tr, "C_LANESETTINGS",
                            static_cast<double>(settings | kLaneSettingsHideButtons));
}

const char* flagParm(Flag f) {
    switch (f) {
        case Flag::ShowInTcp:   return "B_SHOWINTCP";
        case Flag::ShowInMixer: return "B_SHOWINMIXER";
        case Flag::MainSend:    return "B_MAINSEND";
        case Flag::FxEnable:    return "I_FXEN";
    }
    return "B_SHOWINTCP";  // unreachable; keeps the compiler quiet
}

// The master track is not enumerated by GetTrack (index space excludes it),
// so it can never enter the tree — the master-untouched invariant holds by
// construction. Also caches each MediaTrack* by GUID for later resolve().
std::vector<TrackFolderEntry> readFolderEntries(
        ReaProject* proj,
        std::vector<std::pair<std::string, MediaTrack*>>& handleByGuid) {
    std::vector<TrackFolderEntry> entries;
    int count = CountTracks(proj);
    entries.reserve(static_cast<std::size_t>(count));
    handleByGuid.reserve(static_cast<std::size_t>(count));
    for (int i = 0; i < count; ++i) {
        MediaTrack* tr = GetTrack(proj, i);
        if (!tr) continue;
        std::string guid = guidString(tr);
        if (guid.empty()) continue;
        int depth = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FOLDERDEPTH"));
        entries.push_back(TrackFolderEntry{guid, depth});
        handleByGuid.emplace_back(guid, tr);
    }
    return entries;
}

MediaTrack* resolve(const std::vector<std::pair<std::string, MediaTrack*>>& handleByGuid,
                    const std::string& guid) {
    for (const auto& kv : handleByGuid) {
        if (kv.first == guid) return kv.second;
    }
    return nullptr;  // stale/deleted GUID — pruned by being skipped
}

// The FX's own durable identity, braced exactly like the track GUID keys. Empty
// when REAPER reports none — an FX we cannot name is one we cannot restore, and
// fx_offline treats it that way rather than guessing at its slot. Lifetime is
// settled: the string copy is taken immediately and the GUID* is never held
// past this call (reaper_plugin_functions.h:7348 documents no null contract for
// TrackFX_GetFXGUID; treating null as "no identity" is the safe read).
//
// [verify — DAW] STABILITY across a chain mutation is not settled the same way:
// confirm the GUID for one FX instance survives a native drag-reorder, an SWS
// move (SNM_MoveOrRemoveTrackFX — SWS issue #802 reports the FXID lines do not
// follow the plugin after that call, i.e. wrong-plugin restores or mass drops
// through fx_offline on that path specifically), a save/reload round trip, and
// two live instances of one plugin type staying distinguishable. See
// src/shell/view/CLAUDE.md's Gotchas for the SWS-path risk this leaves open.
std::string fxGuidString(MediaTrack* tr, int fx) {
    GUID* g = TrackFX_GetFXGUID(tr, fx);
    if (!g) return {};
    char buf[64] = {0};  // guidToString needs a >=64-char destination (SDK contract)
    guidToString(g, buf);
    return std::string(buf);
}

// The chain as it stands now: identity by current slot. Snapshot, park and
// restore all address FX through this one plain 0..TrackFX_GetCount-1
// enumeration — never the 0x1000000/0x2000000 input-FX or container forms — so
// whatever it covers, all three cover identically.
std::vector<std::string> liveFxGuids(MediaTrack* tr) {
    const int fxCount = TrackFX_GetCount(tr);
    std::vector<std::string> guids;
    guids.reserve(static_cast<std::size_t>(fxCount));
    for (int fx = 0; fx < fxCount; ++fx) guids.push_back(fxGuidString(tr, fx));
    return guids;
}

// Captures prior driven-flag state before parking. Never reads B_MUTE/I_SOLO;
// ints preserve whatever REAPER reported (TrackSnapshot's defensive contract).
TrackSnapshot snapshotTrack(MediaTrack* tr) {
    TrackSnapshot snap;
    snap.showInTcp   = static_cast<int>(GetMediaTrackInfo_Value(tr, "B_SHOWINTCP"));
    snap.showInMixer = static_cast<int>(GetMediaTrackInfo_Value(tr, "B_SHOWINMIXER"));
    snap.mainSend    = static_cast<int>(GetMediaTrackInfo_Value(tr, "B_MAINSEND"));
    snap.fxEnable    = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FXEN"));

    const std::vector<std::string> guids = liveFxGuids(tr);
    snap.fxOffline.reserve(guids.size());
    for (std::size_t fx = 0; fx < guids.size(); ++fx) {
        snap.fxOffline.push_back(
            FxOfflineState{guids[fx], TrackFX_GetOffline(tr, static_cast<int>(fx)) ? 1 : 0});
    }
    return snap;  // fxKeying stays Identity — a live capture always knows the chain
}

void applyFlags(MediaTrack* tr, const std::vector<TrackFlagOp>& flags) {
    for (const TrackFlagOp& op : flags) {
        SetMediaTrackInfo_Value(tr, flagParm(op.flag), static_cast<double>(op.value));
    }
}

// The pure park plan leaves fxOffline empty by design; expand it here from the
// live FX count.
void parkFxOffline(MediaTrack* tr) {
    int fxCount = TrackFX_GetCount(tr);
    for (int fx = 0; fx < fxCount; ++fx) {
        TrackFX_SetOffline(tr, fx, true);
    }
}

// Restores per-FX offline from the snapshot verbatim — never a blanket "online".
// Which live FX each captured state belongs to is resolveFxRestore's call, and
// what it could not place comes back for the caller to report.
FxRestoreDrops restoreFxOffline(MediaTrack* tr, const std::vector<FxOfflineOp>& fxOffline) {
    const FxRestoreResolution res = resolveFxRestore(fxOffline, liveFxGuids(tr));
    for (const FxOfflineWrite& w : res.writes) TrackFX_SetOffline(tr, w.fxIndex, w.offline);
    return res.drops;
}

// Managed-lane application: the pure planner keys LanePlayOps by the lane's
// DURABLE name; REAPER's C_LANEPLAYS:N is keyed by current ordinal, which
// renumbers on reorder. So every write here re-resolves durable key -> current
// ordinal first. A lane whose name lacks the managed prefix never enters this
// map and so can never be driven.

// Lane `laneIdx`'s durable name (P_LANENAME:n) on `tr`, or empty if unnamed /
// unavailable (non-fixed-lane track).
std::string laneName(MediaTrack* tr, int laneIdx) {
    char parm[32];
    std::snprintf(parm, sizeof(parm), "P_LANENAME:%d", laneIdx);
    char buf[512] = {0};
    if (!GetSetMediaTrackInfo_String(tr, parm, buf, false)) return {};
    return std::string(buf);
}

// Managed lane durable key -> current ordinal on `tr`. Manual lanes are
// omitted, so a key absent from the map must not be driven.
std::map<std::string, int> managedLaneOrdinals(MediaTrack* tr) {
    std::map<std::string, int> byKey;
    const int numLanes = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES"));
    for (int lane = 0; lane < numLanes; ++lane) {
        std::optional<std::string> key = managedLaneKey(laneName(tr, lane));
        if (key) byKey.emplace(*key, lane);  // first ordinal wins if names collide
    }
    return byKey;
}

// Track-side C_LANEPLAYS:N alone hides+silences every item on lane N (SDK:
// item-side C_LANEPLAYS is read-only, so no per-item write exists or is
// needed). B_FIXEDLANE_HIDDEN is also read-only — hide/show follows from
// C_LANEPLAYS=0/1, never written directly.
void applyLanePlays(MediaTrack* tr, int laneIdx, int lanePlays) {
    char parm[32];
    std::snprintf(parm, sizeof(parm), "C_LANEPLAYS:%d", laneIdx);
    SetMediaTrackInfo_Value(tr, parm, static_cast<double>(lanePlays));
}

// Groups ops by track, reconciles each op's durable laneKey to the track's
// current ordinal (a stale/renamed/deleted key is pruned, never mis-driven),
// enables fixed-lane mode on any track carrying a managed lane, and drives
// C_LANEPLAYS. UpdateTimeline() is the caller's job when this returns true
// (SDK: required after an I_FREEMODE change).
bool applyLaneOps(const std::vector<std::pair<std::string, MediaTrack*>>& handleByGuid,
                  const std::vector<LanePlayOp>& lanes) {
    if (lanes.empty()) return false;

    std::map<std::string, std::vector<const LanePlayOp*>> byTrack;
    for (const LanePlayOp& op : lanes) byTrack[op.trackGuid].push_back(&op);

    bool touchedFreeMode = false;
    for (const auto& [guid, ops] : byTrack) {
        MediaTrack* tr = resolve(handleByGuid, guid);
        if (!tr) continue;  // stale GUID — prune

        // Every track reaching here already owns a managed lane (planToggle
        // only emits ops for managed lanes), so re-asserting fixed-lane mode
        // is always a tool-driven (re)split — never a user's untouched
        // manual-fixed-lane track — and gets the same transparent display.
        const int freeMode = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FREEMODE"));
        if (freeMode != kFreeModeFixedLanes) {
            SetMediaTrackInfo_Value(tr, "I_FREEMODE",
                                    static_cast<double>(kFreeModeFixedLanes));
            applyTransparentLaneDisplay(tr);
            touchedFreeMode = true;
        }

        const std::map<std::string, int> ordinals = managedLaneOrdinals(tr);
        for (const LanePlayOp* op : ops) {
            auto it = ordinals.find(op->laneKey);
            if (it == ordinals.end()) continue;  // key not live on this track — prune
            applyLanePlays(tr, it->second, op->lanePlays);
        }
    }
    return touchedFreeMode;
}

// Managed-lane minting: the DECISION (which tracks split, which lanes, which
// item goes where) is planLaneMinting; this shell only reads live per-item
// mode+lane state, calls it, and applies the resulting writes.

// Maps every item GUID on `tr` to its handle in one pass (avoids a per-item
// re-scan in the assign loop).
std::map<std::string, MediaItem*> itemHandlesByGuid(MediaTrack* tr) {
    std::map<std::string, MediaItem*> byGuid;
    const int itemCount = CountTrackMediaItems(tr);
    for (int i = 0; i < itemCount; ++i) {
        MediaItem* it = GetTrackMediaItem(tr, i);
        if (!it) continue;
        std::string ig = itemGuid(it);
        if (!ig.empty()) byGuid.emplace(std::move(ig), it);
    }
    return byGuid;
}

// An untagged item is Arrange by default (mirrors leafBelongsToMode). A
// show-both/multi-mode item resolves to its first mode id — unusual for lane
// content, and any one mode is sufficient for the decision.
std::string itemModeFromMembership(const ViewModeModel& model, const std::string& itemGuid) {
    const std::set<std::string> modes = model.membership().modesOf(itemGuid);
    if (modes.empty()) return kArrangeModeId;
    return *modes.begin();
}

// Builds the per-track LaneItem picture the pure decision consumes. Manual-
// lane reads are skipped on a non-fixed-lane track (isOnManualLane is false
// there regardless of name).
std::vector<LaneTrack> readLaneTracks(
        const ViewModeModel& model,
        const std::vector<std::pair<std::string, MediaTrack*>>& handleByGuid) {
    std::vector<LaneTrack> tracks;
    tracks.reserve(handleByGuid.size());
    for (const auto& [guid, tr] : handleByGuid) {
        LaneTrack lt;
        lt.trackGuid = guid;

        const bool fixedLane =
            static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FREEMODE")) == kFreeModeFixedLanes;

        const int itemCount = CountTrackMediaItems(tr);
        lt.items.reserve(static_cast<std::size_t>(itemCount));
        for (int i = 0; i < itemCount; ++i) {
            MediaItem* it = GetTrackMediaItem(tr, i);
            if (!it) continue;
            const std::string ig = itemGuid(it);
            if (ig.empty()) continue;
            LaneItem li;
            li.guid = ig;
            li.modeId = itemModeFromMembership(model, ig);
            const std::string ln = fixedLane ? itemLaneName(tr, it) : std::string{};
            li.onManualLane = isOnManualLane(fixedLane, ln);
            lt.items.push_back(std::move(li));
        }
        tracks.push_back(std::move(lt));
    }
    return tracks;
}

// Idempotent: writes I_FIXEDLANE only when it differs from the item's current
// lane. Non-destructive — only this reversible flag is written, never a move
// in time or across tracks.
bool assignItemToLane(MediaTrack* tr, MediaItem* it, int laneOrdinal) {
    const int current = static_cast<int>(GetMediaItemInfo_Value(it, "I_FIXEDLANE"));
    if (current == laneOrdinal) return false;  // already there — no-op
    SetMediaItemInfo_Value(it, "I_FIXEDLANE", static_cast<double>(laneOrdinal));
    return true;
}

// Applies the pure LaneMintPlan. Returns true if any project write actually
// changed state (⇒ caller keeps the Undo block and refreshes the timeline).
//
// The plan only ever names managed-prefixed lanes and only ever assigns
// managed-eligible items; I_NUMFIXEDLANES is only ever GROWN, never shrunk,
// so a user's existing manual lanes are never renamed or reassigned.
bool applyMintPlan(ViewModeModel& model, const LaneMintPlan& plan,
                   const std::vector<std::pair<std::string, MediaTrack*>>& handleByGuid) {
    bool changed = false;

    std::map<std::string, std::vector<const LaneMint*>> mintsByTrack;
    for (const LaneMint& m : plan.mints) mintsByTrack[m.trackGuid].push_back(&m);
    std::map<std::string, std::vector<const LaneAssign*>> assignsByTrack;
    for (const LaneAssign& a : plan.assigns) assignsByTrack[a.trackGuid].push_back(&a);

    for (const LaneMintPlan::TrackSplit& split : plan.splits) {
        MediaTrack* tr = resolve(handleByGuid, split.trackGuid);
        if (!tr) continue;  // stale GUID — prune

        // A track not already in fixed-lane mode is one the tool is splitting
        // now, so it owns the display; a track already at I_FREEMODE==2 (the
        // user's own, or a prior tool run) skips this and keeps its display prefs.
        const int freeMode = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FREEMODE"));
        if (freeMode != kFreeModeFixedLanes) {
            SetMediaTrackInfo_Value(tr, "I_FREEMODE", static_cast<double>(kFreeModeFixedLanes));
            applyTransparentLaneDisplay(tr);
            changed = true;
        }

        // Grow-only: a track may already carry the user's manual lanes, so the
        // lane count only ever increases; managed lanes occupy the tail ordinals.
        int laneCount = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES"));

        std::map<std::string, int> present = managedLaneOrdinals(tr);

        for (const LaneMint* m : mintsByTrack[split.trackGuid]) {
            model.lanes().setManaged(m->trackGuid, m->laneKey, m->modeId);  // ownership
            if (present.count(m->laneKey)) continue;  // already minted — idempotent

            const int laneIdx = laneCount++;
            SetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES", static_cast<double>(laneCount));
            char parm[32];
            std::snprintf(parm, sizeof(parm), "P_LANENAME:%d", laneIdx);
            std::vector<char> name(m->laneKey.begin(), m->laneKey.end());
            name.push_back('\0');
            GetSetMediaTrackInfo_String(tr, parm, name.data(), true);
            present.emplace(m->laneKey, laneIdx);  // now resolvable for the assign pass
            changed = true;
        }

        const std::map<std::string, int> ordinals = managedLaneOrdinals(tr);
        const std::map<std::string, MediaItem*> itemsByGuid = itemHandlesByGuid(tr);
        for (const LaneAssign* a : assignsByTrack[split.trackGuid]) {
            auto ord = ordinals.find(a->laneKey);
            if (ord == ordinals.end()) continue;  // key not live — prune, never mis-assign
            auto handle = itemsByGuid.find(a->itemGuid);
            if (handle == itemsByGuid.end()) continue;  // stale item GUID — prune
            if (assignItemToLane(tr, handle->second, ord->second)) changed = true;
        }
    }
    return changed;
}

} // namespace

bool transportBlocksModeSwitch(ReaProject* proj) {
    return (GetPlayStateEx(proj) & kTransportMoving) != 0;
}

bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj) {
    if (!model.modes().contains(targetModeId)) {
        return false;  // reject before touching the project — no partial apply
    }

    // The discriminator behind both halves of the contract in view.h. A gate placed
    // unconditionally here would break tagging and the project-load reapply during
    // playback; a solo round on every reapply would flicker the user's solo on every
    // membership edit.
    const std::string outgoingModeId = model.activeModeId();
    const bool realSwitch = targetModeId != outgoingModeId;

    if (realSwitch && transportBlocksModeSwitch(proj)) {
        return false;  // same fail-closed shape as the mode-exists guard above
    }

    std::vector<std::pair<std::string, MediaTrack*>> handleByGuid;
    std::vector<TrackFolderEntry> entries = readFolderEntries(proj, handleByGuid);
    FolderTree tree = buildFolderTree(entries);

    // Prune snapshots for tracks no longer in the live enumeration before
    // planning (membership is intentionally left alone — see model.reconcile).
    // Because reapply-on-load routes through applyMode, this also reconciles
    // on project open.
    std::set<std::string> liveGuids;
    for (const auto& kv : handleByGuid) liveGuids.insert(kv.first);
    model.reconcile(liveGuids);

    // Read before any write, and while activeModeId() still names the mode being left.
    const std::map<std::string, int> outgoingSolo =
        realSwitch ? view::soloedTracks(readTrackSolos(handleByGuid))
                   : std::map<std::string, int>{};

    TogglePlan plan = model.planToggle(tree, targetModeId);

    Undo_BeginBlock2(proj);

    // DISJOIN THE SOLO SURFACES. Both this clear and the replay after setActiveMode
    // ride the existing undo block — one mode toggle stays one Ctrl-Z.
    if (realSwitch) {
        model.soloCache().store(outgoingModeId, outgoingSolo);
        clearTrackSolos(handleByGuid, outgoingSolo);
    }

    // PARK: snapshot before mutating, store into the model, then apply.
    for (const TrackPlan& tp : plan.park) {
        if (tp.flags.empty()) continue;  // every op in a TrackPlan targets one track
        const std::string& guid = tp.flags.front().guid;
        MediaTrack* tr = resolve(handleByGuid, guid);
        if (!tr) continue;  // stale GUID — prune

        // Snapshot ONCE, at first park: a snapshot already present means the
        // track is still parked from a prior apply, so its live flags are the
        // parked values — recapturing would overwrite the true pre-park state
        // with zeros and a later restore would hide it for good. Restore
        // clears the snapshot, so the next genuine park recaptures fresh state.
        if (model.snapshot(guid) == nullptr)
            model.storeSnapshot(guid, snapshotTrack(tr));
        applyFlags(tr, tp.flags);
        parkFxOffline(tr);
    }

    // RESTORE: apply verbatim, then drop the consumed snapshot.
    FxRestoreDrops fxDrops;
    int fxDropTracks = 0;
    for (const TrackPlan& tp : plan.restore) {
        if (tp.flags.empty()) continue;
        const std::string& guid = tp.flags.front().guid;
        MediaTrack* tr = resolve(handleByGuid, guid);
        if (!tr) continue;  // stale GUID — prune

        applyFlags(tr, tp.flags);
        const FxRestoreDrops drops = restoreFxOffline(tr, tp.fxOffline);
        if (drops.total() > 0) {
            fxDrops.add(drops);
            ++fxDropTracks;
        }
        model.clearSnapshot(guid);
    }

    // Captured FX state that could not be applied is REPORTED. Silence here would
    // read to the user as "restore worked" while an FX sat at whatever state the
    // park left it in. Sent with the "!SHOW:" prefix (reaper_plugin_functions.h:6536)
    // so it never force-opens the console window: applyMode's reapply path also
    // runs unattended on project load (see the reconcile comment above), and this
    // one call site can't tell that case apart from an interactive toggle/tag-edit
    // reapply — both call in with target == active — so splitting loud-on-toggle
    // from quiet-on-load would need a flag threaded from every caller, several of
    // which are outside this change. Quiet-always is the safe default: the message
    // still lands in the console for whoever opens it, on every path.
    const std::string fxDropMsg = describeFxRestoreDrops(fxDrops, fxDropTracks);
    if (!fxDropMsg.empty()) ShowConsoleMsg(("!SHOW:" + fxDropMsg).c_str());

    // MANAGED LANES: drive C_LANEPLAYS so the active mode's lane plays+shows
    // and every other managed lane is silenced+hidden. Empty for a D1-only
    // project, leaving that behavior byte-identical.
    const bool laneModeChanged = applyLaneOps(handleByGuid, plan.lanes);

    // PARENT VISIBILITY (never parked): recomputed every toggle, never
    // snapshotted. Only the two visibility flags — never mainSend/FX on a parent.
    std::set<std::string> visible = model.visibleTracks(tree, targetModeId);
    for (const FolderNode& node : tree.nodes) {
        if (!node.isParent) continue;
        MediaTrack* tr = resolve(handleByGuid, node.guid);
        if (!tr) continue;  // stale GUID — prune
        double show = visible.count(node.guid) ? 1.0 : 0.0;
        SetMediaTrackInfo_Value(tr, "B_SHOWINTCP", show);
        SetMediaTrackInfo_Value(tr, "B_SHOWINMIXER", show);
    }

    // Target is guaranteed registered (checked at entry); fall back to the id
    // defensively if that ever changes.
    const Mode* targetMode = model.modes().query(targetModeId);
    const std::string undoLabel =
        "ReaSampler: activate " +
        (targetMode ? targetMode->displayName : targetModeId) + " view";

    model.setActiveMode(targetModeId);

    // Replay + consume, before the single relayout below picks the change up. Dropped
    // against `visible` (computed above for parent visibility), not the park plan: a
    // folder parent can be hidden without being parked, and a hidden track must not
    // receive a replayed solo it carries no visible control to undo.
    if (realSwitch) {
        if (const std::map<std::string, int>* cached = model.soloCache().query(targetModeId)) {
            restoreTrackSolos(handleByGuid, *cached, liveGuids, visible);
            model.soloCache().clear(targetModeId);
        }
    }

    // Force REAPER to rebuild the TCP/MCP now rather than on the next user
    // interaction: TrackList_AdjustWindows(false) does the full relayout owed
    // when tracks appear/disappear; UpdateArrange() repaints.
    TrackList_AdjustWindows(false);
    UpdateArrange();

    // UpdateTimeline() is owed only when a track was actually toggled into
    // fixed lanes this apply (SDK requirement for I_FREEMODE changes).
    if (laneModeChanged) UpdateTimeline();

    Undo_EndBlock2(proj, undoLabel.c_str(), -1);
    return true;
}

bool mintManagedLanes(ViewModeModel& model, ReaProject* proj) {
    std::vector<std::pair<std::string, MediaTrack*>> handleByGuid;
    std::vector<TrackFolderEntry> entries = readFolderEntries(proj, handleByGuid);
    // The tree is needed to detect a content-bearing folder derived-visible in
    // >1 mode, exactly as applyMode builds it.
    const FolderTree tree = buildFolderTree(entries);

    const std::vector<LaneTrack> tracks = readLaneTracks(model, handleByGuid);
    const LaneMintPlan plan = planLaneMinting(model, tree, tracks);
    if (plan.empty()) return false;  // nothing to mint — no Undo point for a no-op tick

    Undo_BeginBlock2(proj);
    const bool changed = applyMintPlan(model, plan, handleByGuid);

    if (!changed) {
        // Plan was non-empty but every write was already satisfied — discard
        // the empty undo point rather than flooding history every detect tick.
        Undo_EndBlock2(proj, "", 0);

        // The arrange still needs a redraw: this no-op path is reached when a
        // freshly-inserted item already landed on the active mode's playing
        // lane (REAPER places new items on the playing lane), so
        // assignItemToLane wrote nothing even though the item needs to appear
        // there now. UpdateArrange() alone (no I_FREEMODE change happened, so
        // UpdateTimeline isn't owed) is a repaint, not a mutation — stays
        // outside the undo block.
        UpdateArrange();
        return false;
    }

    // Reapply the active mode's lane visibility so freshly-minted lanes take
    // their play/show state immediately. applyMode is deliberately NOT reused
    // here — it would re-park/restore whole tracks and recompute parent
    // visibility, which a lane-only mint must not touch.
    const TogglePlan togglePlan = model.planToggle(FolderTree{}, model.activeModeId());
    applyLaneOps(handleByGuid, togglePlan.lanes);

    UpdateTimeline();  // a split happened this call — refresh is owed
    UpdateArrange();

    Undo_EndBlock2(proj, "ReaSampler: separate cross-mode content into lanes", -1);
    return true;
}

void reconcileManagedLanes(ViewModeModel& model, ReaProject* proj) {
    std::vector<std::pair<std::string, MediaTrack*>> handleByGuid;
    readFolderEntries(proj, handleByGuid);  // populates handleByGuid (tree unused here)

    // Pure read of REAPER state (no lane created, no I_FREEMODE/I_NUMFIXEDLANES/
    // I_FIXEDLANE written) plus an ownership-index write, recovering managed
    // classification from the durable name. An unprefixed lane is left alone.
    for (const auto& [guid, tr] : handleByGuid) {
        const int freeMode = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FREEMODE"));
        if (freeMode != kFreeModeFixedLanes) continue;  // no fixed lanes ⇒ nothing managed

        const int numLanes = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES"));
        for (int lane = 0; lane < numLanes; ++lane) {
            const std::string name = laneName(tr, lane);
            std::optional<std::string> key = managedLaneKey(name);
            if (!key) continue;  // manual/unnamed lane — leave off the index
            std::optional<std::string> mode = modeIdFromLaneName(name);
            if (!mode) continue;  // prefix-only/illegal name — skip defensively

            // A mode id encoded in the name may no longer be registered (e.g.
            // removed since the project was saved). Recording it managed would
            // make the toggle planner drive a lane keyed to a mode that can
            // never be active — permanently silenced, orphaning its items. So
            // skip: the lane stays off the index (manual-by-default) but keeps
            // its name, so a later re-registration of the mode heals cleanly.
            if (!model.modes().contains(*mode)) continue;
            model.lanes().setManaged(guid, *key, *mode);
        }
    }
}

} // namespace reasampler
