// view.cpp — REAPER-facing Design View shell (Phase D2). See view.h.
//
// 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).
//
// The tree arithmetic (I_FOLDERDEPTH -> FolderTree) lives in the pure view_tree
// module so it is unit-tested outside the DAW; this file owns only the REAPER
// reads/writes and the snapshot-before-park ordering.

#include "view.h"

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

#include "item_read.h"
#include "lane_keys.h"
#include "track_guid.h"
#include "view_tree.h"

#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_CountTracks
#define REAPERAPI_WANT_GetTrack
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
#define REAPERAPI_WANT_SetMediaTrackInfo_Value
#define REAPERAPI_WANT_GetSetMediaTrackInfo_String
#define REAPERAPI_WANT_TrackFX_GetCount
#define REAPERAPI_WANT_TrackFX_GetOffline
#define REAPERAPI_WANT_TrackFX_SetOffline
#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): enumerate a track's items and read/write item-side lane
// state 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 {

namespace {

// Track fixed-lane mode value (I_FREEMODE=2). See SDK: 0=normal, 1=free item
// positioning, 2=fixed lanes.
constexpr int kFreeModeFixedLanes = 2;

// C_LANESCOLLAPSED display value (char*). SDK: 1=lanes collapsed,
// 2=track displays as non-fixed-lanes but hidden lanes exist. Value 2 is the lever that
// makes a tool-split track read like a NORMAL single-lane track showing only the playing
// lane — the inactive/silenced managed lanes are present but not drawn as separate rows.
constexpr int kLanesDisplayAsNormal = 2;

// C_LANESETTINGS bit (char* bitmask). SDK: &32=hide lane buttons. We OR this in (never
// clobber the whole mask) to strip the per-lane button chrome from a tool-split track, so
// it reads as an ordinary track. We deliberately do NOT set &1 (auto-remove empty lanes at
// bottom): a managed lane whose item is later deleted would be silently removed out from
// under the ownership index. The lazy-mint decision already avoids ever minting an empty
// lane, so &1 buys nothing and risks a reconcile hazard.
constexpr int kLaneSettingsHideButtons = 32;

// Drives a TOOL-SPLIT track's display transparent: C_LANESCOLLAPSED=2 (render like a normal
// single-lane track showing only the playing lane) + OR C_LANESETTINGS &32 (hide lane
// buttons). Both are char* params driven through the double API, same convention as
// C_LANEPLAYS:N. C_LANESETTINGS is read-modify-write so any pre-existing bit is preserved.
//
// MANAGED-VS-MANUAL BOUNDARY (load-bearing): these are TRACK-LEVEL settings that affect the
// whole track including a user's own manual comp lanes. Every caller gates this on the
// tool-driven transition INTO fixed lanes (freeMode != 2 before the flip), so a track the
// user already had in fixed-lane mode never reaches it and the user's comp-lane display
// prefs are never stomped. Idempotent: a re-run finds the track already at I_FREEMODE==2,
// the transition branch is skipped, and these writes do not fire again.
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));
}

// The parmname for each planner Flag. All four are documented bool*/int* track
// info params driven through the double-valued Get/SetMediaTrackInfo_Value API.
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
}

// Reads the arrange-ordered track list and their I_FOLDERDEPTH, keyed by GUID.
// The master track is NOT enumerated by GetTrack (index space is the non-master
// tracks), so it can never enter the tree — the master-untouched invariant holds
// by construction. Also caches the MediaTrack* per GUID so later apply steps
// resolve a GUID back to its handle without a second linear scan.
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
}

// Captures a track's prior driven-flag state BEFORE it is parked. Reads only the
// four owned flags + per-FX offline; never B_MUTE/I_SOLO, never the master (not
// reachable here). ints preserve whatever REAPER reported (defensive per D1's
// TrackSnapshot 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"));

    int fxCount = TrackFX_GetCount(tr);
    snap.fxOffline.reserve(static_cast<std::size_t>(fxCount));
    for (int fx = 0; fx < fxCount; ++fx) {
        snap.fxOffline.push_back(TrackFX_GetOffline(tr, fx) ? 1 : 0);
    }
    return snap;
}

// Applies the planner's scalar-flag writes. B_* are bool* params, I_FXEN is int*,
// all driven through the double API — marshal the plan's int value to double.
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));
    }
}

// Parks a track's FX offline: the pure park plan leaves fxOffline empty by design;
// the shell expands it from the live FX count and offlines every slot.
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 — each slot back to its
// captured value, never a blanket "online". Bounds-checked against the live FX
// count in case the plugin chain changed while parked (prune-safe).
//
// HAZARD (deferred, PLAN "reconcile on delete/restructure"): the remap is by
// slot INDEX, not plugin identity. If the FX chain changed while the track was
// parked, snapshot slot k is restored onto whatever plugin now occupies slot k —
// the bounds-check guards against out-of-range, not against a reshuffled chain.
// Acceptable for D2; full identity-based reconciliation is future hardening.
void restoreFxOffline(MediaTrack* tr, const std::vector<FxOfflineOp>& fxOffline) {
    int fxCount = TrackFX_GetCount(tr);
    for (const FxOfflineOp& op : fxOffline) {
        if (op.fxIndex < 0 || op.fxIndex >= fxCount) continue;
        TrackFX_SetOffline(tr, op.fxIndex, op.offline);
    }
}

// -- Managed-lane application (D2 Wave 2) ------------------------------------
//
// The pure planner emits LanePlayOps keyed by (trackGuid, laneKey) where laneKey is
// the lane's DURABLE name (lane_keys convention: "reasampler:<mode>"). REAPER's
// C_LANEPLAYS:N is keyed by the lane's CURRENT ORDINAL, which renumbers on reorder.
// So before applying, we build the ordinal<->key reconcile for a track by reading each
// lane's P_LANENAME:n; the write then targets the correct current ordinal for a given
// durable key even after a reorder (design point #2). A lane whose name lacks the
// managed prefix is manual and never appears in this map, so it can never be driven.

// Reads lane index `laneIdx`'s durable name off track `tr` (P_LANENAME:n). Empty if
// the lane is unnamed or the param is 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);
}

// Maps each MANAGED lane's durable key -> its current ordinal on `tr`, by walking the
// track's I_NUMFIXEDLANES lanes and reading each name. Manual (unprefixed/unnamed)
// lanes are omitted, so a key absent from the map is a lane the tool must not drive.
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;
}

// Drives one managed lane on `tr` to `lanePlays` (C_LANEPLAYS value) via the
// TRACK-SIDE C_LANEPLAYS:N write. Track-side C_LANEPLAYS:N alone produces the
// hide+silence effect for all items on lane N — no per-item write is needed or
// possible (item-side C_LANEPLAYS is marked read-only in the SDK).
// B_FIXEDLANE_HIDDEN is READ-ONLY (SDK) — hide/show follows from C_LANEPLAYS=0/1,
// never written directly. Non-destructive: only reversible play/show flags; no item
// is moved or deleted.
//
// DAW-VERIFY: confirm that track-side C_LANEPLAYS:N alone hides+silences all items
// on lane N without a per-item write. (SDK marks item-side C_LANEPLAYS as read-only;
// the track-side write is the documented mechanism.)
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));
}

// Applies the plan's managed-lane ops. Groups ops by track, resolves each op's durable
// laneKey to the track's current ordinal (skipping any key not present on the live
// track — a stale/renamed/deleted managed lane is pruned, never mis-driven), enables
// fixed-lane mode on any track that carries a managed lane, and drives C_LANEPLAYS.
// UpdateTimeline() is called ONCE at the end (SDK: required after I_FREEMODE changes).
// Returns true if any track's I_FREEMODE was (re)set to fixed lanes (⇒ needs timeline
// refresh). MANAGED lanes only — plan.lanes never contains a manual lane (pure planner
// gates on the ownership index), and a manual lane's name never resolves to a key here,
// so the invariant is enforced twice.
bool applyLaneOps(const std::vector<std::pair<std::string, MediaTrack*>>& handleByGuid,
                  const std::vector<LanePlayOp>& lanes) {
    if (lanes.empty()) return false;

    // Group op indices by track guid so we read each track's lane map once.
    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

        // Ensure fixed-lane mode is on before driving lane play state. A track carrying
        // a managed lane must be in I_FREEMODE=2; set it only if not already, and flag
        // that a timeline refresh is owed. Every track reaching this loop is already in the
        // managed-lane ownership index (planToggle only emits ops for managed lanes), so a
        // track here is one the TOOL split — a re-assert of fixed-lane mode is a tool-driven
        // (re)split and must carry the same transparent display, mirroring applyMintPlan's
        // transition branch. It is never a user's untouched manual-fixed-lane track.
        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);  // tool-managed track ⇒ read like a normal track
            touchedFreeMode = true;
        }

        // Reconcile durable keys -> current ordinals on THIS track, then drive each op.
        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 (D2 Wave 3) ----------------------------------------
//
// Mints one managed fixed lane per mode on any track that now holds content of MORE
// THAN ONE mode, and assigns each item to its mode's managed lane. The DECISION —
// which tracks split, which lanes to mint, which item goes where — is the pure
// planLaneMinting; this shell only reads live per-item mode+lane state, calls the
// decision, and applies the resulting REAPER + ownership-index writes.

// Item GUID + fixed-lane name reads come from the shared item_read seam (item_read.h):
// itemGuid(it) and itemLaneName(tr, it). view.cpp no longer carries its own copies.

// Maps every item GUID on `tr` to its MediaItem* handle, in one pass. The assign pass
// resolves plan item GUIDs back to handles through this map rather than re-scanning the
// track per item (avoids the quadratic that a per-item find would incur).
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;
}

// Resolves the mode one item's content belongs to, from the model's membership index.
// An item tagged into exactly one mode returns that mode; an untagged item is an
// Arrange member by default (mirrors leafBelongsToMode's untagged rule). A show-both or
// multi-mode item resolves to its first mode id — such items are unusual for lane
// content, and the pure decision only needs A mode per item; the managed-lane it lands
// on is that mode's lane. Never returns empty for a real item.
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;  // untagged ⇒ Arrange default
    return *modes.begin();
}

// Builds the per-track LaneItem picture the pure decision consumes. For each track and
// each item: resolve the item's mode from membership, and — only on a track already in
// fixed-lane mode — read whether it sits on a MANUAL lane (exempt). On a non-fixed-lane
// track no item is on a manual lane (isOnManualLane returns false for the empty name),
// so the manual read is skipped entirely there.
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);
            // Manual-lane exemption: only meaningful on a fixed-lane track. The shared
            // pure predicate decides; on a normal track it returns false regardless of
            // name, so we pass an empty name and skip the P_LANENAME read.
            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;
}

// Assigns item `it` to the managed lane whose durable key resolves to a current ordinal
// on `tr` (via managedLaneOrdinals). Idempotent: writes I_FIXEDLANE only when it differs
// from the item's current lane, so a re-run does not thrash the item or the undo state.
// Returns true iff a write actually changed the item's lane. Non-destructive: only the
// reversible I_FIXEDLANE flag is written — the item is never moved in time or across
// tracks. (I_FIXEDLANE is settable per SDK: "fine to call with setNewValue".)
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 to the live project. For each track that must split:
// enables fixed lanes, ensures the lane count, stamps each managed lane's durable name,
// records ownership in the model, then assigns each item to its mode's lane by resolving
// the durable key to the lane's current ordinal. Returns true if ANY project write
// changed state (⇒ the caller keeps the Undo block and refreshes the timeline).
//
// MANAGED-LANES-ONLY: the plan only ever names lanes with the managed prefix and only
// ever assigns managed-eligible items (manual-lane items were reported exempt and are
// absent from the plan). We only ever GROW I_NUMFIXEDLANES to fit the managed lanes and
// stamp names on the lanes we mint — a user's existing manual lanes keep their ordinals
// below/around ours and are never renamed or reassigned.
bool applyMintPlan(ViewModeModel& model, const LaneMintPlan& plan,
                   const std::vector<std::pair<std::string, MediaTrack*>>& handleByGuid) {
    bool changed = false;

    // Group mints + assigns by track so each track is set up once.
    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

        // Enable fixed-lane mode if not already (SDK: UpdateTimeline() owed after). The
        // pre-write freeMode read is ALSO the managed-vs-manual boundary signal: a track that
        // was NOT in fixed-lane mode here is one the TOOL is splitting now, so the tool owns
        // its lane display and drives it transparent. A track already at I_FREEMODE==2 (user
        // had fixed lanes, or a prior tool run) skips this branch — its C_LANESCOLLAPSED /
        // C_LANESETTINGS are left exactly as the user set them.
        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);  // tool-split track ⇒ read like a normal track
            changed = true;
        }

        // Ensure enough lanes for the managed set WITHOUT shrinking: a track may already
        // carry the user's manual lanes, so only GROW the count, never reduce it (which
        // would delete a user lane). The managed lanes we mint occupy the tail ordinals.
        // laneCount tracks the live I_NUMFIXEDLANES as we grow it: read ONCE here, then
        // each mint appends at laneCount and bumps it. No per-mint I_NUMFIXEDLANES re-read
        // is needed — nextOrdinal and laneCount are the same running value.
        int laneCount = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES"));

        // Which managed keys are already present on this track (durable-name reconcile).
        std::map<std::string, int> present = managedLaneOrdinals(tr);

        // Mint each managed lane that is not already present, appending at the tail so an
        // existing manual lane is never overwritten. Record ownership in the model.
        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

            // Append at the current tail ordinal, grow the tracked count, stamp its name.
            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;
        }

        // Assign each item to its mode's managed lane, resolving the durable key to the
        // lane's current ordinal on THIS track. A key not present (shouldn't happen — we
        // just minted them all) is skipped rather than mis-assigned. Item handles are
        // resolved through a one-pass GUID map (avoids re-scanning the track per item).
        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 applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj) {
    // Reject an unregistered target before touching the project (no partial apply).
    if (!model.modes().contains(targetModeId)) {
        return false;
    }

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

    // Reconcile orphaned model state BEFORE planning: prune snapshots whose track was
    // deleted from the project (its GUID no longer appears in the live enumeration).
    // handleByGuid holds every currently-enumerated track GUID, so its keys are the
    // authoritative live set. Membership is intentionally NOT pruned (undo-delete
    // restores the same GUID — see ViewModeModel::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);

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

    Undo_BeginBlock2(proj);

    // PARK: snapshot BEFORE mutating, store into the model (so restore survives a
    // save-while-parked), then apply the park writes + expand the FX-offline loop.
    for (const TrackPlan& tp : plan.park) {
        // Every op in a TrackPlan targets the same track; take the guid from the
        // first flag op (the pure park plan always emits the four flag ops).
        if (tp.flags.empty()) continue;
        const std::string& guid = tp.flags.front().guid;
        MediaTrack* tr = resolve(handleByGuid, guid);
        if (!tr) continue;  // stale GUID — prune

        // Snapshot ONCE, at the first park. If a snapshot already exists the track is
        // still parked from a prior apply, and its live flags are the PARKED (hidden)
        // values — recapturing here would overwrite the true pre-park state with zeros,
        // so a later restore would restore the track to hidden and it would vanish for
        // good. Re-applying the park flags to an already-parked track is idempotent and
        // fine; only the snapshot must not be recaptured. 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 the snapshot-sourced flag + per-FX offline writes verbatim,
    // then drop the now-consumed snapshot so a re-park recaptures fresh state.
    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);
        restoreFxOffline(tr, tp.fxOffline);
        model.clearSnapshot(guid);
    }

    // MANAGED LANES (D2 item-level projection): drive C_LANEPLAYS so the active mode's
    // managed lane plays+shows and every inactive-mode managed lane is silenced+hidden.
    // plan.lanes carries MANAGED lanes only (the pure planner gates on the ownership
    // index); applyLaneOps additionally resolves each op's durable key against the live
    // track's lane names, so a manual lane — which never carries the managed prefix —
    // can never be driven. Empty for a D1-only project (no fixed lanes), leaving D1
    // behavior byte-identical. UpdateTimeline() is owed only if a track's I_FREEMODE
    // was (re)set to fixed lanes (SDK requirement); deferred to the refresh block below.
    const bool laneModeChanged = applyLaneOps(handleByGuid, plan.lanes);

    // PARENT VISIBILITY (never parked): visibleTracks() marks a parent visible when
    // a descendant leaf is visible in the target mode OR the parent belongs to the
    // mode by its own membership (untagged folder → Arrange default). Recomputed
    // every toggle rather than snapshotted. Drive only the two visibility flags;
    // never touch B_MAINSEND/I_FXEN/FX-offline 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);
    }

    // Build the undo label from the ACTUAL target mode's display name, so activating
    // Arrange doesn't leave an "activate Design view" undo point (and vice versa).
    // The target is guaranteed registered (checked at entry), so query() is non-null;
    // 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);

    // Force REAPER to rebuild the TCP + MCP so visibility/park changes appear now,
    // not on the user's next TCP interaction. TrackList_AdjustWindows(false) does the
    // major (full) relayout required when tracks appear/disappear from the panels;
    // UpdateArrange() repaints the arrange view. Both are documented for exactly this
    // "you changed track-info flags, now refresh the panels" case.
    TrackList_AdjustWindows(false);
    UpdateArrange();

    // A fixed-lane mode change (I_FREEMODE -> 2) requires UpdateTimeline() to take
    // visible effect (SDK). Call it only when we actually toggled a track into fixed
    // lanes this apply; the C_LANEPLAYS writes themselves are picked up by the arrange
    // refresh above.
    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 minting decision is now folder-tree / visibility aware: it needs the tree to
    // detect a content-bearing folder derived-visible in >1 mode (which must lane-separate
    // its own media even when that media is single-mode). Build it exactly as applyMode does.
    const FolderTree tree = buildFolderTree(entries);

    // Build the live per-track item picture and run the PURE decision. A track visible in
    // exactly one mode produces no split; a track visible in >1 mode while carrying its own
    // media (own items span modes, OR a folder derived-visible across modes) produces mints
    // + assignments. Manual-lane items are reported exempt inside readLaneTracks; show-both
    // tracks are skipped inside the decision.
    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

    // Wrap the structural mutation in ONE Undo block (unlike the invisible membership
    // tag). Only opened when the plan is non-empty; applyMintPlan reports whether any
    // write actually changed state so we can label the undo meaningfully.
    Undo_BeginBlock2(proj);
    const bool changed = applyMintPlan(model, plan, handleByGuid);

    if (!changed) {
        // The plan was non-empty but every REAPER write was already satisfied. Close the
        // block with no description so REAPER discards the empty undo point rather than
        // flooding history with a no-change entry every detection tick.
        Undo_EndBlock2(proj, "", 0);

        // BUT the arrange still needs a redraw. On the detect-tick caller (bankPanelRefresh)
        // mintManagedLanes runs only when this tick just tagged new content, and a NON-EMPTY
        // plan means that content sits on a managed-split track. The idempotent no-op path is
        // reached when a freshly-inserted item ALREADY landed on the active mode's playing
        // lane (REAPER places a new item on the playing lane; the active mode's lane IS the
        // playing lane, so assignItemToLane sees I_FIXEDLANE unchanged and writes nothing).
        // The item is correctly placed and confined, but the arrange was never told to
        // repaint it onto the lane — so it stayed invisible until a manual mode toggle forced
        // applyMode's refresh. Force the redraw here so the item appears immediately without a
        // toggle. UpdateArrange() only repaints (no I_FREEMODE transition happened on this
        // path, so UpdateTimeline is not owed); it is NOT a project mutation, so it stays
        // outside the undo block and adds no history entry. On the action caller (doMoveItems)
        // this is a harmless repaint immediately before its own reapplyActiveMode() refresh.
        UpdateArrange();
        return false;
    }

    // Reapply the active mode's lane visibility so the freshly-minted lanes take their
    // correct play/show state immediately: the active mode's lane plays+shows, every
    // other managed lane hides+silences. Reusing planToggle's lane ops keeps the drive
    // logic in one place; applyLaneOps also (re)asserts I_FREEMODE and drives C_LANEPLAYS.
    // NOTE: applyMode is NOT reused here — it would re-park/restore whole tracks and
    // recompute parent visibility, which the minting tick must not do (it only just
    // changed item lanes). Driving lane play state directly is the minimal correct step.
    const TogglePlan togglePlan = model.planToggle(FolderTree{}, model.activeModeId());
    applyLaneOps(handleByGuid, togglePlan.lanes);

    // I_FREEMODE was (re)set to fixed lanes on at least one track (the plan minted a
    // split), so a timeline refresh is owed (SDK). Repaint the arrange too so the new
    // lane layout appears immediately.
    UpdateTimeline();
    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)

    // Walk every track's lanes; for each lane whose durable name carries the managed
    // prefix, record it MANAGED-for-its-mode in the ownership index. This is a pure READ
    // of REAPER state (no lane is created, no I_FREEMODE/I_NUMFIXEDLANES/I_FIXEDLANE is
    // written) plus an index write — self-healing classification from the source of
    // truth (the durable name) without re-minting or mass-tagging. A lane lacking the
    // prefix is left alone (manual by default), so a user's own lanes stay off the index.
    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

            // UNREGISTERED-MODE GUARD: the durable name encodes a mode id, but that mode
            // may no longer be a registered Mode (e.g. a mode removed from the registry
            // after the project was saved with lanes minted for it). Recording it MANAGED
            // would make the toggle planner drive a lane keyed to a mode that can never be
            // the active mode — the lane would stay silenced+hidden forever, orphaning its
            // items with no way for the user to reach them. So we do NOT record it: the
            // lane is left off the ownership index and thus treated as manual-by-default
            // (never driven). Its durable name is preserved on the track, so if the mode is
            // ever re-registered a later reconcile recovers the ownership cleanly.
            if (!model.modes().contains(*mode)) continue;
            model.lanes().setManaged(guid, *key, *mode);
        }
    }
}

} // namespace reasampler
