#pragma once
// view_mode_model — the pure core of the Design View feature, deliberately free of any
// REAPER type so it compiles and unit-tests OUTSIDE the DAW. It is the mirror of
// bank_model: it owns the mode registry, the GUID-keyed membership index, the
// folder-tree-aware visibility derivation, the parking/restore planner, and the
// JSON round-trip of all of it.
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes. Standard library only. The folder structure is an INPUT
// supplied by the D2 shell (which reads REAPER's I_FOLDERDEPTH); this model never
// fetches or stores REAPER's live tree — folder structure is REAPER's truth and
// changes underneath us, so it is passed in per query, not held.
//
// -- Representation decisions (design latitude exercised; invariants below) -----
//
// * A mode is (stable string id, display name, ordinal). Arrange (id "arrange",
//   ordinal 0) and Design (id "design", ordinal 1) are seeded. Arrange is the
//   fallback home for every untagged leaf; structurally it is just another mode.
//
// * Membership is GUID -> { mode ids } (a set, not a bool) plus a per-track
//   show-both flag. Normally a leaf is in exactly one mode; multiple only via the
//   parent-derivation rule (computed, not stored) or the show-both escape hatch.
//   An untagged GUID is NOT in the index and belongs to Arrange by default.
//
// * The planner drives exactly four scalar flags (showInTcp, showInMixer,
//   mainSend, fxEnable) plus a per-FX offline list. Park values are fixed zeros
//   (defined by the parking contract), so PARK ops need no snapshot. RESTORE ops
//   come entirely FROM a TrackSnapshot captured before parking — never a hardcoded
//   default. This is where the restore-contract invariant lives and is tested.
//
// * The snapshot stores the full prior per-FX offline vector so a save-while-parked
//   project round-trips and restores each FX to its exact prior offline state. The
//   pure model does NOT need REAPER FX counts to plan a park (park offlines all N,
//   which the shell expands from TrackFX_GetCount); it only needs them to restore,
//   and it gets them from the snapshot it captured.

#include <cstdint>
#include <map>
#include <optional>
#include <set>
#include <string>
#include <vector>

namespace reasampler {

// Stable seed-mode ids. Arrange is the default home for untagged leaves.
inline constexpr const char* kArrangeModeId = "arrange";
inline constexpr const char* kDesignModeId  = "design";

// A display "stance" the user adopts. Modes are ordered by `ordinal` for tab order.
struct Mode {
    std::string id;           // stable, persisted; never reused for a different mode
    std::string displayName;
    int         ordinal = 0;  // tab order

    bool operator==(const Mode& o) const;
};

// Ordered registry of modes. Arrange + Design are seeded on construction. Add more
// to prove the model is N-mode, not boolean. Ids are unique; adding a duplicate id
// is rejected.
class ModeRegistry {
public:
    ModeRegistry();  // seeds Arrange (ordinal 0) + Design (ordinal 1)

    // Adds a mode. Rejects (returns false, no mutation) an empty or duplicate id.
    bool add(const Mode& mode);

    // Returns the mode with `id`, or nullptr. Invalidated by any mutating call.
    const Mode* query(const std::string& id) const;

    bool contains(const std::string& id) const { return query(id) != nullptr; }

    // All modes in ordinal order (ties broken by insertion order).
    const std::vector<Mode>& all() const { return modes_; }

    std::size_t size() const { return modes_.size(); }

    bool operator==(const ModeRegistry& o) const { return modes_ == o.modes_; }

    // An empty registry (no seed modes). Deserialization parses the persisted mode
    // set into this and then owns it; the default ctor's seed would otherwise make
    // the serialized Arrange/Design collide on add() and fail to round-trip.
    static ModeRegistry makeEmpty() { return ModeRegistry(EmptyTag{}); }

private:
    struct EmptyTag {};
    explicit ModeRegistry(EmptyTag) {}  // no seed

    std::vector<Mode> modes_;  // kept sorted by ordinal, then insertion
};

// The membership record for one tagged leaf track, keyed externally by GUID.
struct Membership {
    std::set<std::string> modeIds;   // the mode(s) this leaf opted into
    bool                  showBoth = false;  // pinned visible + running in every mode

    bool operator==(const Membership& o) const {
        return modeIds == o.modeIds && showBoth == o.showBoth;
    }
};

// -- Lane ownership (Phase D2 / two-canvas item-level projection) -------------
//
// D2 extends the track-level projection to the ITEM level via REAPER fixed lanes
// (I_FREEMODE=2). On a track shared by two stances, each mode owns a fixed lane; a
// toggle shows/plays only the active mode's lane. This is the item-visibility analog
// of D1's track parking, and it carries the same load-bearing guarantee:
//
//   THE TOOL DRIVES ONLY WHAT IT MINTED. A fixed-lane track is also REAPER's native
//   comping surface — a user may keep their OWN manual lanes (comp takes, alternate
//   reads). Mode operations touch ONLY managed lanes; manual lanes are never shown,
//   hidden, silenced, or re-laned, and their C_LANEPLAYS stays exactly as set. This
//   is the fixed-lane analog of "never touch B_MUTE/I_SOLO" and "never touch master".
//
// LANE IDENTITY IS AN OPAQUE, STABLE KEY SUPPLIED BY THE SHELL (boundary). The pure
// index keys a lane by (track GUID + a lane key string). The lane key is an OPAQUE
// identifier the shell provides; this model does NOT assume lane ordinals are stable
// and bakes in NO I_FIXEDLANE renumber/reorder assumptions. Whether the shell derives
// the key from a raw I_FIXEDLANE ordinal or a more durable identity — and how it keeps
// the index from going stale across lane reorder/renumber/deletion — is a Wave-2 SHELL
// design point (CONTEXT.md §Lane-identity fragility). The pure model's only contract:
// the same lane key denotes the same lane across calls.

// One lane's ownership: managed by a specific mode, or manual (user-minted, outside
// the mode system). `managedMode` present ⇒ managed by that mode id; absent ⇒ manual.
struct LaneOwnership {
    std::optional<std::string> managedMode;  // set ⇒ managed by this mode; unset ⇒ manual

    bool isManaged() const { return managedMode.has_value(); }
    bool isManual()  const { return !managedMode.has_value(); }

    bool operator==(const LaneOwnership& o) const { return managedMode == o.managedMode; }
};

// A lane's composite key: (track GUID, opaque lane key). Ordered so it can key a map.
struct LaneRef {
    std::string trackGuid;
    std::string laneKey;   // opaque, shell-supplied; NOT assumed to be a stable ordinal

    bool operator<(const LaneRef& o) const {
        if (trackGuid != o.trackGuid) return trackGuid < o.trackGuid;
        return laneKey < o.laneKey;
    }
    bool operator==(const LaneRef& o) const {
        return trackGuid == o.trackGuid && laneKey == o.laneKey;
    }
};

// (track GUID, lane key) -> ownership. Managed lanes name their owning mode; manual
// lanes are user-minted and off-limits to every mode operation. GUID-keyed and
// portable, it rides in the "reasampler" view_state alongside the membership index.
// A lane ABSENT from the index has no recorded ownership — the model treats an absent
// lane as manual by default (the tool never minted it), so the managed-only guarantee
// holds even before the index is populated.
class LaneOwnershipIndex {
public:
    // Records lane (trackGuid, laneKey) as MANAGED by `modeId`, replacing any prior
    // ownership. Returns false if any argument is empty.
    bool setManaged(const std::string& trackGuid, const std::string& laneKey,
                    const std::string& modeId);

    // Records lane (trackGuid, laneKey) as MANUAL (user-minted), replacing any prior
    // ownership. Returns false if trackGuid or laneKey is empty.
    bool setManual(const std::string& trackGuid, const std::string& laneKey);

    // Removes the lane from the index entirely (⇒ treated as manual-by-default again).
    // Returns true if it was present.
    bool remove(const std::string& trackGuid, const std::string& laneKey);

    // The ownership for a lane, or nullptr if the lane has no recorded entry (⇒ manual
    // by default). Invalidated by any mutating call.
    const LaneOwnership* query(const std::string& trackGuid, const std::string& laneKey) const;

    // True if the lane is recorded MANAGED (by any mode). A lane absent from the index
    // is NOT managed (manual by default) — this is the load-bearing predicate the
    // toggle planner and the "which lanes may this toggle touch" query gate on.
    bool isManaged(const std::string& trackGuid, const std::string& laneKey) const {
        const LaneOwnership* o = query(trackGuid, laneKey);
        return o && o->isManaged();
    }

    const std::map<LaneRef, LaneOwnership>& all() const { return entries_; }

    std::size_t size() const { return entries_.size(); }
    bool empty() const { return entries_.empty(); }

    bool operator==(const LaneOwnershipIndex& o) const { return entries_ == o.entries_; }

private:
    std::map<LaneRef, LaneOwnership> entries_;  // (guid, laneKey) -> ownership
};

// The play/show state a managed lane takes for a given active mode, matching REAPER's
// item/track-side C_LANEPLAYS values (SDK: 0=lane silent+hidden, 1=lane plays
// exclusively). A managed lane owned by the ACTIVE mode plays (1); every other managed
// lane is silenced+hidden (0) — consistent with exclusive membership and D1's "a mode
// flip is a real change, not cosmetic." Exposed as a free function for direct testing.
//   managedMode == activeMode ⇒ 1 (plays exclusively)
//   otherwise                 ⇒ 0 (does not play; hidden + silent)
// The caller must only pass MANAGED lanes here; manual lanes never reach this decision.
inline constexpr int kLanePlaysExclusive = 1;  // C_LANEPLAYS: plays exclusively
inline constexpr int kLaneSilent         = 0;  // C_LANEPLAYS: does not play (hidden+silent)
int laneModeState(const std::string& managedMode, const std::string& activeMode);

// GUID-keyed membership index. Untagged GUIDs are absent and belong to Arrange.
// Keyed by track GUID string, never index (reorder-safe).
class MembershipIndex {
public:
    // Tags `guid` into `modeId`, replacing any prior mode set (a leaf lives in one
    // mode; use showBoth for the cross-mode case). No-op-safe on repeated calls.
    // Returns false if guid or modeId is empty.
    bool tag(const std::string& guid, const std::string& modeId);

    // Removes `guid` from the index entirely (returns it to the Arrange default).
    // Returns true if it was present.
    bool untag(const std::string& guid);

    // Sets the show-both flag for `guid`. Tags the guid into no new mode; if the
    // guid is untagged it is created with an empty mode set (Arrange default) so
    // show-both alone is representable. Returns false if guid is empty.
    bool setShowBoth(const std::string& guid, bool showBoth);

    // Installs a complete membership record verbatim (multi-mode set + show-both),
    // replacing any existing entry for `guid`. Used by deserialization to rebuild a
    // trusted, already-valid entry without tag()'s single-mode clobbering. Returns
    // false if guid is empty.
    bool restore(const std::string& guid, const Membership& membership);

    // Returns the membership for `guid`, or nullptr if untagged. Invalidated by any
    // mutating call.
    const Membership* query(const std::string& guid) const;

    bool isShowBoth(const std::string& guid) const {
        const Membership* m = query(guid);
        return m && m->showBoth;
    }

    // The mode ids `guid` belongs to. Empty for an untagged guid (⇒ Arrange).
    std::set<std::string> modesOf(const std::string& guid) const;

    const std::map<std::string, Membership>& all() const { return entries_; }

    std::size_t size() const { return entries_.size(); }
    bool empty() const { return entries_.empty(); }

    bool operator==(const MembershipIndex& o) const { return entries_ == o.entries_; }

private:
    std::map<std::string, Membership> entries_;  // guid -> membership
};

// -- Folder tree (INPUT, not stored) ----------------------------------------
//
// The shell builds this from I_FOLDERDEPTH each time and passes it to a visibility
// query. A node is a leaf or a parent; a parent is visible in a mode if it belongs
// to that mode by its own membership OR any of its descendant leaves does, and is
// never parked. The master track is
// modeled implicitly (always visible, never touched) and is NOT a node here.
struct FolderNode {
    std::string guid;
    std::string parentGuid;  // empty ⇒ top-level (child of master / project root)
    bool        isParent = false;  // true if this node has descendant tracks (a folder)
};

// A flat parent↔child description of the current track tree. Order is arrange-view
// order; parentGuid links each node to its immediate parent folder.
struct FolderTree {
    std::vector<FolderNode> nodes;
};

// -- Snapshot + planner ------------------------------------------------------

// The prior value of every tool-driven flag on one track, captured BEFORE parking.
// Restore uses these values verbatim — the restore contract's source of truth.
// Flags mirror REAPER's numeric representation (0/1 for the bools) so the shell
// applies them without translation; ints, not bools, so a snapshot faithfully
// round-trips whatever REAPER reported (defensive against non-0/1 values).
struct TrackSnapshot {
    int showInTcp   = 0;   // B_SHOWINTCP prior value
    int showInMixer = 0;   // B_SHOWINMIXER prior value
    int mainSend    = 0;   // B_MAINSEND  prior value
    int fxEnable    = 0;   // I_FXEN      prior value

    // Prior per-FX offline state, index = fx slot. Lets restore return each FX to
    // exactly its captured offline value rather than a blanket "online".
    std::vector<int> fxOffline;

    bool operator==(const TrackSnapshot& o) const {
        return showInTcp == o.showInTcp && showInMixer == o.showInMixer &&
               mainSend == o.mainSend && fxEnable == o.fxEnable &&
               fxOffline == o.fxOffline;
    }
};

// Which scalar flag a TrackFlagOp drives. FX-offline is carried separately (it is
// per-slot, variable length), see TrackParkPlan::fxOffline.
enum class Flag {
    ShowInTcp,    // B_SHOWINTCP
    ShowInMixer,  // B_SHOWINMIXER
    MainSend,     // B_MAINSEND
    FxEnable,     // I_FXEN
};

// One scalar-flag write the shell must apply: SetMediaTrackInfo_Value(guid, flag, value).
struct TrackFlagOp {
    std::string guid;
    Flag        flag = Flag::ShowInTcp;
    int         value = 0;

    bool operator==(const TrackFlagOp& o) const {
        return guid == o.guid && flag == o.flag && value == o.value;
    }
};

// One per-FX offline write: TrackFX_SetOffline(guid, fxIndex, offline).
struct FxOfflineOp {
    std::string guid;
    int         fxIndex = 0;
    bool        offline = false;

    bool operator==(const FxOfflineOp& o) const {
        return guid == o.guid && fxIndex == o.fxIndex && offline == o.offline;
    }
};

// One managed-lane play/show write the shell must apply. The shell translates this
// into the REAPER lane setters (track-side C_LANEPLAYS:N and, per item, I_FIXEDLANE /
// C_LANEPLAYS; B_FIXEDLANE_HIDDEN follows from the play state). `lanePlays` is a
// C_LANEPLAYS value: kLanePlaysExclusive when the active mode owns the lane,
// kLaneSilent otherwise. The pure model emits these for MANAGED lanes ONLY — never a
// manual lane (the fixed-lane analog of "never touch mute/solo"), enforced in
// planToggle and mirrored by lanesTouchedByToggle.
struct LanePlayOp {
    std::string trackGuid;
    std::string laneKey;   // opaque, shell-supplied
    int         lanePlays = kLaneSilent;

    bool operator==(const LanePlayOp& o) const {
        return trackGuid == o.trackGuid && laneKey == o.laneKey && lanePlays == o.lanePlays;
    }
};

// The complete set of operations to park one inactive leaf, or restore one leaf.
// Park uses fixed zeros (parking contract); restore uses a snapshot's values.
// fxOffline is emitted per known FX slot: on park, from the snapshot's slot count
// (all -> offline); on restore, each slot back to its captured value.
struct TrackPlan {
    std::vector<TrackFlagOp>  flags;
    std::vector<FxOfflineOp>  fxOffline;
};

// The plan for a whole toggle to a target mode: which tracks to park, and which to
// restore from their snapshots. Parents and show-both leaves never appear here —
// they are derived-visible and never parked (visibility is answered separately by
// visibleTracks). Untagged LEAVES DO appear: an untagged leaf is an Arrange member,
// so it parks in every non-Arrange mode and restores in Arrange — the mode system
// manages all leaves, not only tagged ones.
struct TogglePlan {
    std::vector<TrackPlan> park;     // inactive leaves -> parked (fixed zeros)
    std::vector<TrackPlan> restore;  // active leaves returning -> snapshot values

    // D2 item-level projection: per managed lane, the C_LANEPLAYS state for the target
    // mode (active mode's lane plays; every other managed lane silenced+hidden). MANAGED
    // lanes ONLY — a manual lane never appears here. Empty when no managed lanes exist,
    // so a D1-only project (no fixed lanes) produces an identical plan to before.
    std::vector<LanePlayOp> lanes;
};

// -- The view mode model -----------------------------------------------------
//
// Owns the mode registry, the membership index, the active mode, and the durable
// per-track snapshots (kept for tracks currently parked so a save-while-parked
// project restores correctly). Visibility and the toggle plan are computed against
// a supplied FolderTree — the tree is never stored.
class ViewModeModel {
public:
    ViewModeModel();  // Arrange + Design seeded; active mode = Arrange

    ModeRegistry&           modes()       { return modes_; }
    const ModeRegistry&     modes() const { return modes_; }
    MembershipIndex&        membership()       { return membership_; }
    const MembershipIndex&  membership() const { return membership_; }
    LaneOwnershipIndex&        lanes()       { return lanes_; }
    const LaneOwnershipIndex&  lanes() const { return lanes_; }

    const std::string& activeModeId() const { return activeModeId_; }
    // Sets the active mode. Returns false (no change) if the id is not registered.
    bool setActiveMode(const std::string& modeId);

    // Records / clears the pre-park snapshot for a track. The shell calls store
    // before it parks a track; the model persists it so restore survives a save.
    void storeSnapshot(const std::string& guid, const TrackSnapshot& snap);
    void clearSnapshot(const std::string& guid);
    const TrackSnapshot* snapshot(const std::string& guid) const;
    const std::map<std::string, TrackSnapshot>& snapshots() const { return snapshots_; }

    // Prunes orphaned per-track state: drops every snapshot whose GUID is NOT in
    // `liveGuids` (the set of GUIDs the shell currently enumerates from the project).
    // Returns the number of snapshots removed. The shell calls this before planning a
    // toggle; because reapply-on-load also routes through the shell's applyMode, this
    // reconciles on project open too.
    //
    // Why snapshots and NOT membership: a parked track's snapshot is dead weight once
    // the track is deleted — it can never be restored, and if REAPER reuses that GUID
    // for a different track a stale snapshot would drive an INCORRECT restore. So it
    // must be pruned. Membership is deliberately KEPT: REAPER's undo of a track delete
    // restores the SAME GUID, so dropping the Design tag on delete would silently lose
    // it on undo-delete. Keeping membership means an undone delete brings the track
    // back correctly tagged and it re-snapshots + re-parks cleanly on the next toggle.
    // A genuinely-deleted-and-never-restored track leaves only a tiny dormant
    // membership entry — acceptable, and far better than losing tags on undo. Folder
    // RESTRUCTURE (moving tracks without deleting) is already self-healing: the tree is
    // rebuilt from I_FOLDERDEPTH every toggle, so a restructure leaves every GUID live
    // and reconcile is a no-op over it. This handles DELETION specifically.
    std::size_t reconcile(const std::set<std::string>& liveGuids);

    // Does `guid` belong to `modeId`? A leaf belongs if it is tagged into modeId,
    // is show-both (belongs everywhere), or is untagged and modeId is Arrange (the
    // default). Parent derivation is NOT applied here — this is the LEAF rule; use
    // visibleTracks for the tree-aware answer.
    bool leafBelongsToMode(const std::string& guid, const std::string& modeId) const;

    // The set of track GUIDs visible in `modeId`, tree-aware: active leaves,
    // show-both leaves, and every parent that EITHER belongs to the mode by its own
    // membership OR has at least one descendant visible in the mode. Untagged nodes
    // (leaf or folder) count as Arrange, so an untagged folder carrying its own
    // FX/media shows in Arrange even when none of its children do, and additionally
    // shows in a child's mode by derivation. Stale GUIDs in the tree are tolerated.
    // The master is not represented (always visible; the shell never touches it).
    std::set<std::string> visibleTracks(const FolderTree& tree,
                                        const std::string& modeId) const;

    // Plans a toggle to `targetMode` by enumerating EVERY leaf in the supplied tree.
    // A leaf inactive in the target mode — tagged into another mode, or untagged and
    // the target isn't Arrange — is parked with fixed zeros; a leaf that becomes
    // active AND has a stored snapshot is restored from it. Parents (visibility-only)
    // and show-both leaves (always visible) are never parked; the master is not in
    // the tree. Untagged leaves ARE managed: they are Arrange members, so they park
    // in non-Arrange modes and restore in Arrange. Tree membership is the enumeration
    // source, so stale membership GUIDs absent from the tree are naturally ignored.
    //
    // Note: park plans emitted here have an empty fxOffline vector. The D2 shell
    // expands per-FX offline writes using TrackFX_GetCount — the pure model has no
    // access to REAPER FX counts at plan time.
    TogglePlan planToggle(const FolderTree& tree, const std::string& targetMode) const;

    // The managed-only "which lanes may this toggle touch" query: the set of lane refs
    // a toggle is permitted to drive — MANAGED lanes ONLY, from the ownership index.
    // Manual lanes are NEVER in the result, regardless of target mode. This is the pure,
    // testable decision behind the load-bearing invariant; the shell reads live lane
    // state and applies C_LANEPLAYS only to lanes this query returns. Independent of the
    // folder tree (lane ownership is not a tree property) — the target mode does not
    // filter the SET (every managed lane is touchable), only the play VALUE each takes
    // (see planToggle / laneModeState).
    std::set<LaneRef> lanesTouchedByToggle() const;

    bool operator==(const ViewModeModel& o) const;

    std::string serialize() const;

    // Parses a JSON string produced by serialize(). std::nullopt on malformed
    // input. On success deserialize(serialize(x)) == x.
    static std::optional<ViewModeModel> deserialize(const std::string& json);

private:
    ModeRegistry    modes_;
    MembershipIndex membership_;
    LaneOwnershipIndex lanes_;                            // (guid, laneKey) -> ownership
    std::string     activeModeId_;                       // always a registered id
    std::map<std::string, TrackSnapshot> snapshots_;     // guid -> pre-park snapshot
};

// Builds the fixed-zero park plan for one leaf. Offlines `fxCount` slots. Exposed
// for the shell and for direct testing of the parking contract.
TrackPlan makeParkPlan(const std::string& guid, int fxCount);

// Builds the restore plan for one leaf from its snapshot — every flag set to its
// captured value, never a default. Exposed for the shell and for testing the
// restore-contract invariant directly.
TrackPlan makeRestorePlan(const std::string& guid, const TrackSnapshot& snap);

// -- Auto-tag decision (Phase D2) --------------------------------------------
//
// New content — both new tracks and new items — is tagged to whatever mode is active
// when it is created; pre-existing content defaults to Arrange. The DECISION is pure:
// the Wave-2 shell detects new GUIDs by diffing project state on the panel timer and
// asks this function what to tag. Pre-existing content (a GUID the shell does not
// report as new) never reaches here and stays at its index state (Arrange by default).
//
// Manual-lane exemption: an item that landed in a MANUAL lane is off-limits to auto-tag
// — auto-tag governs normal timeline content, not hand-managed lanes. The shell marks
// such an item `onManualLane = true` (it knows the item's lane and consults the
// ownership index); the decision then emits NO tag for it. New tracks and new items on
// managed/no lane follow the active-mode rule.
//
// -- Pre-existing-content adoption (strand fix) -------------------------------
//
// A new item dropped onto a track that ALREADY carries currently-visible content must
// not silently push that track into a different mode. If the pre-existing content
// resolves to ONE mode and the new item were blindly tagged to the (different) ACTIVE
// mode, the track would become multi-mode, planLaneMinting would split it, and the
// toggle would silence whichever lane the active mode does not own — stranding the
// pre-existing, previously-visible items on a C_LANEPLAYS=0 lane with no user intent.
//
// The rule: a new item ADOPTS the single mode of the pre-existing content already on its
// track. Only when the track carries no pre-existing managed-eligible content (an empty
// or brand-new track), or when that content already spans multiple modes (an existing
// deliberate split, which the new item joins under the active mode), does the new item
// fall back to the active-mode rule. Deliberate two-take splits are unaffected: those go
// through the explicit item mode-move actions (planItemRetag), never auto-tag.
// The shell reports each new item's track pre-existing-content modes in `trackModes`.

// One new item the shell detected this poll. Its lane disposition decides exemption; its
// track's pre-existing content modes decide adoption (see above).
struct NewItem {
    std::string guid;
    bool        onManualLane = false;  // true ⇒ EXEMPT from auto-tag (hand-managed lane)
    // The distinct modes the PRE-EXISTING (not-new-this-tick) managed-eligible content on
    // this item's track resolves to. Empty ⇒ the item's track carried no prior content, so
    // the item takes the active mode. Exactly one ⇒ ADOPT that mode (the strand guard).
    // More than one ⇒ the track is already a deliberate split; the item takes the active
    // mode. The shell fills this by resolving each pre-existing item's mode from membership.
    std::set<std::string> trackModes;
};

// One membership write the auto-tag decision produced: tag `guid` into `modeId`. The
// shell applies it to the MembershipIndex (a new track/item joins the active mode).
struct AutoTag {
    std::string guid;
    std::string modeId;

    bool operator==(const AutoTag& o) const { return guid == o.guid && modeId == o.modeId; }
};

// The pure auto-tag decision: given the new track GUIDs and new items detected this
// poll plus the active mode, produce the membership writes. Every new track is tagged
// to `activeMode`. Every new item is tagged UNLESS it landed on a manual lane (exempt);
// its target mode is the single mode of its track's pre-existing content (adoption — the
// strand guard) when that content resolves to exactly one mode, otherwise `activeMode`.
// An empty `activeMode` yields no tags (nothing to tag into). Empty GUIDs are skipped.
// The result is a plan the shell applies; this function mutates nothing.
std::vector<AutoTag> autoTagNewContent(const std::vector<std::string>& newTrackGuids,
                                       const std::vector<NewItem>& newItems,
                                       const std::string& activeMode);

// -- Item-level mode-move decision (Phase D2 / Wave 3-B) ---------------------
//
// The bindable item actions (Move selected items -> Design / -> Arrange / Untag)
// retag the CURRENT item selection's membership, then re-drive the minting/apply
// path so each moved item lands on its target mode's managed lane. The DECISION —
// which selected items to retag, and to what — is pure and unit-tested here; the
// shell only reads the item selection (GUID + manual-lane disposition) and applies
// the resulting membership writes + re-lane pass.
//
// MANAGED-LANES-ONLY INVARIANT (upheld at the source, exactly as auto-tag does): an
// item the shell reports as already on a MANUAL lane is EXEMPT — it is never retagged,
// never untagged, never re-laned. The tool drives only what it minted, even under an
// explicit user action. The shell reports `onManualLane` per item and this decision
// emits NO op for such items; the shell then skips them entirely.

// One selected item the shell reports for the retag decision: its GUID and whether it
// currently sits on a MANUAL lane (⇒ EXEMPT: no membership change, no re-lane).
struct RetagItem {
    std::string guid;
    bool        onManualLane = false;  // true ⇒ EXEMPT from the item mode-move actions
};

// One membership op the item mode-move decision produced for one selected item. `untag`
// true ⇒ remove the item from the index (return it to the Arrange default); otherwise
// tag it into `modeId`. The shell applies each verbatim to the MembershipIndex.
struct ItemRetagOp {
    std::string guid;
    bool        untag = false;   // true ⇒ untag; false ⇒ tag into modeId
    std::string modeId;          // the target mode when !untag (empty when untag)

    bool operator==(const ItemRetagOp& o) const {
        return guid == o.guid && untag == o.untag && modeId == o.modeId;
    }
};

// The pure item mode-move decision: given the selected items and a target mode, produce
// the membership ops. An EMPTY `targetMode` means UNTAG (the "Untag selected items" and
// "Move -> Arrange" actions collapse to the same act — Arrange is the absence of a tag,
// mirroring the track-level doUntag). A non-empty `targetMode` tags each eligible item
// into it. Manual-lane items are skipped (no op emitted); items with an empty GUID are
// skipped (defensive). The function mutates nothing — it returns a plan the shell applies.
std::vector<ItemRetagOp> planItemRetag(const std::vector<RetagItem>& selected,
                                       const std::string& targetMode);

// -- Lane minting decision (Phase D2 / Wave 3) -------------------------------
//
// D1 parks a whole track when it holds content of only ONE mode. The moment a track
// is VISIBLE IN MORE THAN ONE MODE while carrying its OWN media, whole-track parking
// can no longer keep the stances separate (the track shows in every mode it is visible
// in, so its items leak across all of them), so the projection drops to the ITEM level:
// the track becomes a fixed-lane track, each involved mode gets its own MANAGED lane,
// and each item is assigned to its mode's lane. A toggle then shows+plays only the
// active mode's lane.
//
// "Visible in more than one mode" has TWO sources, and both trigger a split:
//   (1) the track's OWN managed-eligible items span >= 2 modes (a leaf carrying both
//       an Arrange take and a Design take), OR
//   (2) the track is a content-bearing FOLDER whose descendant leaves span modes, so
//       it is DERIVED-VISIBLE in >= 2 modes (ViewModeModel::visibleTracks) even though
//       its own single item is single-mode. This second source is why the decision is
//       folder-tree / visibility aware — mirroring visibleTracks — rather than looking
//       only at the track's own item mode-span. Without it, one MIDI item or capture
//       dropped straight onto such a folder sits on the default lane and leaks into
//       every mode the folder derives visibility in.
//
// SHOW-BOTH is the deliberate escape hatch: a show-both track is visible in every mode
// ON PURPOSE and its content is meant to play in all of them. It is NEVER force-split —
// neither the visibility trigger nor the own-item-span trigger confines its items to
// per-mode lanes. (Confining show-both content would contradict "stay audible across
// modes.") The decision skips show-both tracks entirely.
//
// This is the pure DECISION behind that transition — REAPER-free and unit-tested.
// The shell reads each track's items and their live mode+lane disposition, builds the
// FolderTree (via the existing view_tree helper, exactly as the D1 shell does), calls
// this with the model + tree, and applies the resulting REAPER writes (I_FREEMODE /
// I_NUMFIXEDLANES / P_LANENAME / I_FIXEDLANE) plus the ownership-index writes. The
// DECISION never lives in the shell.
//
// THE MANAGED-LANES-ONLY INVARIANT is upheld here at the source: an item the shell
// reports as already on a MANUAL lane is EXEMPT — it is never counted toward the
// multi-mode test, never reassigned, and its lane is never minted-over. The plan only
// ever names lanes with the managed prefix (laneNameForMode) and only ever moves
// managed-eligible items. A track the user already lane-splits for their own comping
// is handled by minting ADDITIONAL managed lanes alongside the user's manual lanes;
// the manual lanes and the items on them are untouched (they are reported exempt).

// One item the shell reports for the minting decision: its GUID, the mode its
// membership resolves to (untagged ⇒ Arrange, resolved by the shell via
// leafBelongsToMode / the active-mode default), and whether it currently sits on a
// MANUAL lane (⇒ exempt: never counted, never reassigned).
struct LaneItem {
    std::string guid;
    std::string modeId;                // the mode this item's content belongs to
    bool        onManualLane = false;  // true ⇒ EXEMPT (user's hand-managed lane)
};

// One track the shell reports: its GUID plus the items on it. The shell builds this by
// enumerating the track's media items and resolving each item's mode from membership.
struct LaneTrack {
    std::string trackGuid;
    std::vector<LaneItem> items;
};

// One item→lane assignment the shell must apply (I_FIXEDLANE = the lane the durable
// key `laneKey` currently occupies; the shell resolves key→ordinal exactly as the
// C_LANEPLAYS apply path does). Only managed-eligible items appear here.
struct LaneAssign {
    std::string itemGuid;
    std::string trackGuid;
    std::string laneKey;   // durable managed-lane key (laneNameForMode(modeId))

    bool operator==(const LaneAssign& o) const {
        return itemGuid == o.itemGuid && trackGuid == o.trackGuid && laneKey == o.laneKey;
    }
};

// One managed lane the shell must mint on a track: its durable key (== the name to
// stamp via P_LANENAME) and the mode that owns it (recorded in the ownership index).
struct LaneMint {
    std::string trackGuid;
    std::string laneKey;   // == laneNameForMode(modeId); the P_LANENAME to stamp
    std::string modeId;    // the owning mode (ownership-index managed-for-mode write)

    bool operator==(const LaneMint& o) const {
        return trackGuid == o.trackGuid && laneKey == o.laneKey && modeId == o.modeId;
    }
};

// The complete lane-minting plan for the tracks the shell reported. Empty (all three
// vectors) when NO track needs splitting — a single-mode-only project produces an empty
// plan and the shell does nothing (D1 behavior unchanged). The shell wraps the whole
// application in ONE Undo block because it is a visible structural mutation.
struct LaneMintPlan {
    // Tracks to switch into fixed-lane mode, each with the number of managed lanes to
    // ensure (I_FREEMODE=2, I_NUMFIXEDLANES >= laneCount). Only tracks that need a
    // split appear; a track already carrying the tool's managed lanes for exactly the
    // involved modes still appears (idempotent — the shell's ensure is a no-op then).
    struct TrackSplit {
        std::string trackGuid;
        int         laneCount = 0;  // number of managed lanes this track needs
    };
    std::vector<TrackSplit>  splits;
    std::vector<LaneMint>    mints;    // managed lanes to mint (name + ownership write)
    std::vector<LaneAssign>  assigns;  // item→managed-lane assignments

    bool empty() const {
        return splits.empty() && mints.empty() && assigns.empty();
    }
};

// The pure lane-minting decision, folder-tree / visibility aware. `model` supplies the
// membership + show-both state; `tree` supplies the folder structure so a content-bearing
// folder's DERIVED visibility is accounted for (mirrors ViewModeModel::visibleTracks).
// For each reported track:
//   * SHOW-BOTH tracks are skipped outright — never force-split (the escape hatch: their
//     content is meant to stay audible in every mode). No split, mint, or assignment.
//   * Ignore items on manual lanes entirely (exempt — the managed-only invariant).
//   * A track splits iff it CARRIES OWN managed-eligible media AND is VISIBLE IN >= 2
//     MODES. Visibility spans two sources, either of which qualifies:
//       (a) the track's own managed-eligible items span >= 2 modes (leaf carrying an
//           Arrange take and a Design take), OR
//       (b) the track is derived-visible in >= 2 modes per visibleTracks (a content-
//           bearing folder whose descendant leaves span modes) — the missed case.
//   * A track visible in exactly ONE mode (single-mode leaf, single-mode folder) stays
//     whole-track-parked (D1) — NO split. This is the single-mode-track rule.
//   * On a split: one TrackSplit (laneCount == number of lanes to mint), one LaneMint per
//     mode the track's OWN items occupy, and one LaneAssign per managed-eligible OWN item
//     onto ITS tagged mode's lane — INCLUDING pre-existing items, so a folder carrying one
//     own Design item while derived-visible in Arrange too still lanes that item to the
//     Design lane (it then hides+silences whenever Arrange is active).
//   * LAZY-MINT: lanes are minted ONLY for modes the track's own items actually occupy —
//     never an empty reserved lane for a mode the track is merely derived-visible in. So a
//     folder whose own item is Design-only but which is derived-visible in Arrange mints a
//     Design lane ONLY (holding the item), NOT an empty Arrange lane. Confinement still
//     holds: with only a Design lane present, toggling to Arrange drives that lane's
//     C_LANEPLAYS to 0 (hide+silence) and no lane plays, so the track reads as an empty
//     normal track and the Design item does not leak. The Arrange lane is minted on demand
//     when an Arrange item first lands. The derived-visibility trigger still decides WHETHER
//     to split; it no longer inflates WHICH lanes are minted.
//
// Items with an empty GUID or empty modeId are skipped (defensive; a real item always
// resolves to a mode). The function mutates nothing — it returns a plan the shell
// applies. Idempotency: re-reporting an already-split track yields the same mints and
// assignments; the shell's ensure/assign writes are no-ops when the state already
// matches, so re-running the detection path does not thrash the project or the undo
// history (the shell only opens an Undo block when the plan is non-empty AND some
// write actually changes state — see the shell).
LaneMintPlan planLaneMinting(const ViewModeModel& model, const FolderTree& tree,
                             const std::vector<LaneTrack>& tracks);

// The next mode id in the registry's ordinal order, cycling past `currentModeId`
// and wrapping to the first mode after the last (Arrange -> Design -> Arrange with
// the two seed modes; the same cycle scales to N modes with no call-site change).
// This is the pure decision behind the "toggle active mode" action: the shell reads
// the model's active mode, asks for the next one, and applies it.
//   * empty registry           -> "" (nothing to cycle to)
//   * currentModeId not present -> the first mode's id (a sane home to jump to)
// Exposed as a free function (not a model member) so it is unit-testable against a
// bare ModeRegistry without a full ViewModeModel.
std::string nextModeId(const ModeRegistry& modes, const std::string& currentModeId);

} // namespace reasampler
