#include "view_mode_model.h"

#include <algorithm>
#include <cassert>
#include <cerrno>
#include <climits>
#include <cstdio>
#include <cstdlib>
#include <set>
#include <utility>

#include "lane_keys.h"  // laneNameForMode — the ONE durable managed-lane-key convention

// view_mode_model implementation.
//
// JSON is hand-rolled and self-contained, mirroring bank_model's approach (brief:
// keep the pure core dependency-free — no third-party JSON lib). A compact writer
// plus a recursive-descent parser covers the field set: the mode registry, the
// GUID-keyed membership map, per-track snapshots (with a variable-length per-FX
// offline vector), and the active mode. Ints are emitted plainly; strings are
// escaped identically to bank_model so control chars and unicode survive.

namespace reasampler {

// ---------------------------------------------------------------------------
// equality
// ---------------------------------------------------------------------------

bool Mode::operator==(const Mode& o) const {
    return id == o.id && displayName == o.displayName && ordinal == o.ordinal;
}

// ---------------------------------------------------------------------------
// ModeRegistry
// ---------------------------------------------------------------------------

ModeRegistry::ModeRegistry() {
    modes_.push_back(Mode{kArrangeModeId, "Arrange", 0});
    modes_.push_back(Mode{kDesignModeId, "Design", 1});
}

bool ModeRegistry::add(const Mode& mode) {
    if (mode.id.empty()) return false;
    if (query(mode.id) != nullptr) return false;  // ids are unique
    modes_.push_back(mode);
    // Keep ordinal order stable; std::stable_sort so equal ordinals keep insertion
    // order (the tie-break documented in the header).
    std::stable_sort(modes_.begin(), modes_.end(),
                     [](const Mode& a, const Mode& b) { return a.ordinal < b.ordinal; });
    return true;
}

const Mode* ModeRegistry::query(const std::string& id) const {
    for (const auto& m : modes_)
        if (m.id == id) return &m;
    return nullptr;
}

// ---------------------------------------------------------------------------
// MembershipIndex
// ---------------------------------------------------------------------------

bool MembershipIndex::tag(const std::string& guid, const std::string& modeId) {
    if (guid.empty() || modeId.empty()) return false;
    Membership& m = entries_[guid];
    m.modeIds.clear();          // a leaf lives in exactly one mode (show-both aside)
    m.modeIds.insert(modeId);
    return true;
}

bool MembershipIndex::untag(const std::string& guid) {
    return entries_.erase(guid) > 0;
}

bool MembershipIndex::setShowBoth(const std::string& guid, bool showBoth) {
    if (guid.empty()) return false;
    entries_[guid].showBoth = showBoth;  // creates an Arrange-default entry if new
    return true;
}

bool MembershipIndex::restore(const std::string& guid, const Membership& membership) {
    if (guid.empty()) return false;
    entries_[guid] = membership;
    return true;
}

const Membership* MembershipIndex::query(const std::string& guid) const {
    auto it = entries_.find(guid);
    return it == entries_.end() ? nullptr : &it->second;
}

std::set<std::string> MembershipIndex::modesOf(const std::string& guid) const {
    const Membership* m = query(guid);
    return m ? m->modeIds : std::set<std::string>{};
}

// ---------------------------------------------------------------------------
// LaneOwnershipIndex
// ---------------------------------------------------------------------------

bool LaneOwnershipIndex::setManaged(const std::string& trackGuid, const std::string& laneKey,
                                    const std::string& modeId) {
    if (trackGuid.empty() || laneKey.empty() || modeId.empty()) return false;
    entries_[LaneRef{trackGuid, laneKey}] = LaneOwnership{modeId};
    return true;
}

bool LaneOwnershipIndex::setManual(const std::string& trackGuid, const std::string& laneKey) {
    if (trackGuid.empty() || laneKey.empty()) return false;
    entries_[LaneRef{trackGuid, laneKey}] = LaneOwnership{std::nullopt};
    return true;
}

bool LaneOwnershipIndex::remove(const std::string& trackGuid, const std::string& laneKey) {
    return entries_.erase(LaneRef{trackGuid, laneKey}) > 0;
}

const LaneOwnership* LaneOwnershipIndex::query(const std::string& trackGuid,
                                               const std::string& laneKey) const {
    auto it = entries_.find(LaneRef{trackGuid, laneKey});
    return it == entries_.end() ? nullptr : &it->second;
}

int laneModeState(const std::string& managedMode, const std::string& activeMode) {
    // The active mode's lane plays exclusively; every other managed lane is silenced
    // and hidden (C_LANEPLAYS = 0). Exclusive membership: only one stance's lane at a
    // time. Show-both, which keeps a lane audible across modes, is a per-lane opt-out
    // the shell layers on; the default per-mode decision here is exclusive.
    //
    // EXCLUSIVITY ASSUMPTION (one managed lane per mode per track): the model assumes a
    // given (track, mode) owns AT MOST ONE managed lane. C_LANEPLAYS=1 means "this lane
    // plays EXCLUSIVELY" — two lanes on the same track both claiming mode M would both
    // be told to play exclusively on M's toggle, which REAPER cannot honor coherently
    // (the last write wins in the DAW). The Wave-3 lane-minting path is responsible for
    // upholding one-lane-per-(track,mode); planToggle asserts it in debug builds.
    return managedMode == activeMode ? kLanePlaysExclusive : kLaneSilent;
}

// ---------------------------------------------------------------------------
// auto-tag decision
// ---------------------------------------------------------------------------

std::vector<AutoTag> autoTagNewContent(const std::vector<std::string>& newTrackGuids,
                                       const std::vector<NewItem>& newItems,
                                       const std::string& activeMode) {
    std::vector<AutoTag> tags;
    if (activeMode.empty()) return tags;  // nothing to tag into

    for (const auto& guid : newTrackGuids) {
        if (guid.empty()) continue;
        tags.push_back(AutoTag{guid, activeMode});
    }
    for (const auto& item : newItems) {
        if (item.guid.empty()) continue;
        if (item.onManualLane) continue;   // manual-lane content is off-limits to auto-tag

        // ADOPTION (strand guard): a new item on a track whose PRE-EXISTING content
        // resolves to exactly one mode adopts THAT mode, so a drop onto a track already
        // showing content never pushes it multi-mode and never triggers a lane split that
        // would silence the pre-existing, previously-visible items. A track with no prior
        // content (empty trackModes) or one already carrying a deliberate multi-mode split
        // (>1) falls back to the active-mode rule.
        const std::string& target =
            item.trackModes.size() == 1 ? *item.trackModes.begin() : activeMode;
        tags.push_back(AutoTag{item.guid, target});
    }
    return tags;
}

std::vector<ItemRetagOp> planItemRetag(const std::vector<RetagItem>& selected,
                                       const std::string& targetMode) {
    std::vector<ItemRetagOp> ops;
    const bool untag = targetMode.empty();  // empty target ⇒ untag (→ Arrange default)
    for (const RetagItem& item : selected) {
        if (item.guid.empty()) continue;    // defensive; a real item always has a GUID
        if (item.onManualLane) continue;    // manual-lane item is EXEMPT — never retagged
        ops.push_back(ItemRetagOp{item.guid, untag, untag ? std::string{} : targetMode});
    }
    return ops;
}

// ---------------------------------------------------------------------------
// lane minting decision
// ---------------------------------------------------------------------------

LaneMintPlan planLaneMinting(const ViewModeModel& model, const FolderTree& tree,
                             const std::vector<LaneTrack>& tracks) {
    LaneMintPlan plan;

    // Precompute, per track GUID, the count of modes it is VISIBLE in and the set of
    // those mode ids — tree-aware, so a content-bearing folder's DERIVED visibility
    // (visibleTracks marks a parent visible in every mode a descendant is visible in)
    // is captured, not only the track's own item mode-span. This is the visibility
    // trigger source (b): a folder derived-visible in >= 2 modes must lane-separate its
    // own media even when that media is single-mode. Computed once for all tracks.
    std::map<std::string, std::set<std::string>> visibleModesOf;
    for (const Mode& mode : model.modes().all()) {
        const std::set<std::string> vis = model.visibleTracks(tree, mode.id);
        for (const std::string& guid : vis)
            visibleModesOf[guid].insert(mode.id);
    }

    for (const LaneTrack& track : tracks) {
        if (track.trackGuid.empty()) continue;

        // SHOW-BOTH escape hatch: never force-split. A show-both track is visible in
        // every mode ON PURPOSE and its content is meant to play across all of them, so
        // neither the visibility trigger nor the own-item-span trigger confines it. Skip
        // it entirely (no split/mint/assign) so its items stay cross-mode-visible.
        if (model.membership().isShowBoth(track.trackGuid)) continue;

        // Collect the DISTINCT modes the track's managed-eligible OWN items belong to, in
        // deterministic (sorted) order so the mint list and lane count are stable across
        // runs (a set orders by mode id). Items on a manual lane are EXEMPT — never
        // counted toward the multi-mode test and never reassigned (the managed-only
        // invariant, upheld at the source of the decision).
        std::set<std::string> ownItemModes;
        for (const LaneItem& item : track.items) {
            if (item.guid.empty() || item.modeId.empty()) continue;
            if (item.onManualLane) continue;  // exempt — user's hand-managed lane
            ownItemModes.insert(item.modeId);
        }

        // A track with NO managed-eligible own media never splits: there is nothing to
        // confine (lane separation projects OWN items across modes). A folder derived-
        // visible in many modes but carrying no own content stays whole-track visibility-
        // only (D1 parent handling) — this guards the "carries its own media" clause.
        if (ownItemModes.empty()) continue;

        // The two visibility sources, OR'd:
        //   (a) own items span >= 2 modes (W3-A trigger), and
        //   (b) the track is derived-visible in >= 2 modes (the folder-media case).
        // A track qualifies for a split if EITHER makes it multi-mode.
        const auto visIt = visibleModesOf.find(track.trackGuid);
        const std::size_t visibleModeCount =
            visIt == visibleModesOf.end() ? 0 : visIt->second.size();
        const bool multiMode = ownItemModes.size() >= 2 || visibleModeCount >= 2;

        // Single-mode (visible in exactly one mode, own items single-mode): whole-track
        // parking (D1) still separates the stances. NO split, NO mint, NO assignment —
        // this is the load-bearing "don't lane-split single-mode tracks" rule.
        if (!multiMode) continue;

        // Lazy-mint: lanes to mint = ONLY the modes the track's OWN items actually occupy —
        // never an empty reserved lane for a mode the track is merely derived-visible in.
        // A folder whose own item is Design-only but which is derived-visible in Arrange too
        // mints a Design lane ONLY (holding the item); it mints NO Arrange lane. Confinement
        // still holds: with only a Design lane present, toggling to Arrange drives that lane's
        // C_LANEPLAYS to 0 (it hides+silences) and no lane plays, so the track reads as an
        // empty normal track — the Design item does not leak. The Arrange lane is minted on
        // demand the moment an Arrange item first lands (a later mint tick sees ownItemModes
        // gain Arrange). The visibility trigger above still decides WHETHER to split; it no
        // longer inflates WHICH lanes are minted.
        const std::set<std::string>& laneModes = ownItemModes;

        // Transition to lane-split: one managed lane per own-content mode (durable key =
        // laneNameForMode(mode)), owned by that mode.
        plan.splits.push_back(LaneMintPlan::TrackSplit{
            track.trackGuid, static_cast<int>(laneModes.size())});
        for (const std::string& mode : laneModes) {
            plan.mints.push_back(
                LaneMint{track.trackGuid, laneNameForMode(mode), mode});
        }

        // Assign EVERY managed-eligible OWN item onto its tagged mode's lane — including
        // the pre-existing single-mode items, so a folder carrying one own Design item
        // while derived-visible in Arrange still lanes that item to the Design lane (it
        // then hides+silences whenever Arrange is active — the exact failing-case fix).
        for (const LaneItem& item : track.items) {
            if (item.guid.empty() || item.modeId.empty()) continue;
            if (item.onManualLane) continue;  // exempt — never reassigned
            plan.assigns.push_back(LaneAssign{
                item.guid, track.trackGuid, laneNameForMode(item.modeId)});
        }
    }

    return plan;
}

// ---------------------------------------------------------------------------
// planner helpers
// ---------------------------------------------------------------------------

TrackPlan makeParkPlan(const std::string& guid, int fxCount) {
    // Parking contract: hide both panels, out of the mix, FX bypassed, every FX
    // offline. All fixed zeros — park never consults a snapshot.
    TrackPlan p;
    p.flags = {
        {guid, Flag::ShowInTcp, 0},
        {guid, Flag::ShowInMixer, 0},
        {guid, Flag::MainSend, 0},
        {guid, Flag::FxEnable, 0},
    };
    for (int i = 0; i < fxCount; ++i)
        p.fxOffline.push_back({guid, i, true});
    return p;
}

TrackPlan makeRestorePlan(const std::string& guid, const TrackSnapshot& snap) {
    // Restore contract: every driven flag returns to its SNAPSHOTTED value — never
    // a hardcoded "on"/default. A flag captured at 0 restores to 0.
    TrackPlan p;
    p.flags = {
        {guid, Flag::ShowInTcp, snap.showInTcp},
        {guid, Flag::ShowInMixer, snap.showInMixer},
        {guid, Flag::MainSend, snap.mainSend},
        {guid, Flag::FxEnable, snap.fxEnable},
    };
    for (std::size_t i = 0; i < snap.fxOffline.size(); ++i)
        p.fxOffline.push_back({guid, static_cast<int>(i), snap.fxOffline[i] != 0});
    return p;
}

std::string nextModeId(const ModeRegistry& modes, const std::string& currentModeId) {
    const std::vector<Mode>& all = modes.all();
    if (all.empty()) return {};  // nothing to cycle to
    for (std::size_t i = 0; i < all.size(); ++i) {
        if (all[i].id == currentModeId)
            return all[(i + 1) % all.size()].id;  // wrap past the last
    }
    // Active mode not in the registry (stale/unknown) — jump to the first mode as a
    // sane home rather than returning "".
    return all.front().id;
}

// ---------------------------------------------------------------------------
// ViewModeModel
// ---------------------------------------------------------------------------

ViewModeModel::ViewModeModel() : activeModeId_(kArrangeModeId) {}

bool ViewModeModel::setActiveMode(const std::string& modeId) {
    if (!modes_.contains(modeId)) return false;
    activeModeId_ = modeId;
    return true;
}

void ViewModeModel::storeSnapshot(const std::string& guid, const TrackSnapshot& snap) {
    snapshots_[guid] = snap;
}

void ViewModeModel::clearSnapshot(const std::string& guid) {
    snapshots_.erase(guid);
}

const TrackSnapshot* ViewModeModel::snapshot(const std::string& guid) const {
    auto it = snapshots_.find(guid);
    return it == snapshots_.end() ? nullptr : &it->second;
}

std::size_t ViewModeModel::reconcile(const std::set<std::string>& liveGuids) {
    // Prune snapshots for GUIDs the project no longer contains (see header for the
    // deliberate snapshot-yes / membership-no asymmetry and the undo-delete rationale).
    std::size_t removed = 0;
    for (auto it = snapshots_.begin(); it != snapshots_.end();) {
        if (liveGuids.count(it->first) == 0) {
            it = snapshots_.erase(it);
            ++removed;
        } else {
            ++it;
        }
    }
    return removed;
}

bool ViewModeModel::leafBelongsToMode(const std::string& guid, const std::string& modeId) const {
    const Membership* m = membership_.query(guid);
    if (!m) return modeId == kArrangeModeId;      // untagged ⇒ Arrange default
    if (m->showBoth) return true;                 // show-both ⇒ every mode
    if (m->modeIds.empty()) return modeId == kArrangeModeId;  // show-both-cleared, no mode
    return m->modeIds.count(modeId) > 0;
}

std::set<std::string> ViewModeModel::visibleTracks(const FolderTree& tree,
                                               const std::string& modeId) const {
    std::set<std::string> visible;

    // Pass 1: every node — leaf OR parent — that belongs to the mode by its OWN
    // membership is visible. For a leaf this is the tagged/show-both/untagged-Arrange
    // rule; for a parent it means an untagged folder (which carries its own FX/media
    // and defaults to Arrange) shows in Arrange even when none of its children do.
    // Parents ALSO become visible in pass 2 by derivation from a visible descendant;
    // the two rules are OR'd, so an untagged folder of all-Design leaves shows in both
    // Arrange (own default) and Design (derived).
    for (const auto& node : tree.nodes) {
        if (leafBelongsToMode(node.guid, modeId))
            visible.insert(node.guid);
    }

    // Pass 2: a parent is also visible if any descendant is visible. Walk each
    // currently-visible node up its parent chain and mark ancestors. Seeding from the
    // full pass-1 set means a parent made visible by its own membership propagates its
    // visibility up the remaining ancestors too. Parent chains are read from the
    // supplied tree only (no REAPER access). A cycle-guard bounds the walk in case a
    // malformed tree links a node to itself.
    std::map<std::string, std::string> parentOf;
    for (const auto& node : tree.nodes) parentOf[node.guid] = node.parentGuid;

    // Snapshot the pass-1 visible set so we don't re-walk parents we add mid-loop.
    const std::vector<std::string> seeds(visible.begin(), visible.end());
    for (const auto& node : seeds) {
        auto it = parentOf.find(node);
        std::size_t guard = 0;
        while (it != parentOf.end() && !it->second.empty() && guard++ < parentOf.size()) {
            const std::string& parent = it->second;
            if (!visible.insert(parent).second) break;  // already marked ⇒ chain done
            it = parentOf.find(parent);
        }
    }

    return visible;
}

TogglePlan ViewModeModel::planToggle(const FolderTree& tree, const std::string& targetMode) const {
    TogglePlan plan;

    // The mode system manages EVERY leaf, not just tagged ones. An untagged leaf is
    // an Arrange member (leafBelongsToMode resolves that), so it must park when the
    // target mode is not Arrange and restore when it is — the same full park/restore
    // a tagged leaf gets. Enumerating the FolderTree (not membership_.all()) is what
    // brings untagged leaves — which are absent from the membership index — under
    // management. Parents are visibility-only (handled by visibleTracks + the shell's
    // parent-visibility pass) and show-both leaves are the always-visible escape;
    // neither is ever parked.
    for (const auto& node : tree.nodes) {
        if (node.isParent) continue;             // parents are derived, never parked
        const std::string& guid = node.guid;
        if (membership_.isShowBoth(guid)) continue;  // show-both leaves are never parked

        const bool active = leafBelongsToMode(guid, targetMode);
        if (active) {
            // Returning to visibility: restore from snapshot if we have one. No
            // snapshot ⇒ the track was never parked, nothing to restore.
            if (const TrackSnapshot* snap = snapshot(guid))
                plan.restore.push_back(makeRestorePlan(guid, *snap));
        } else {
            // Inactive leaf (tagged into another mode, or untagged in a non-Arrange
            // mode) ⇒ park. fxOffline is intentionally empty here: the D2 shell
            // expands per-FX offline writes using TrackFX_GetCount. The pure model
            // has no access to REAPER FX counts at plan time; makeParkPlan(guid, 0)
            // emits only the scalar flags as a result.
            plan.park.push_back(makeParkPlan(guid, /*fxCount=*/0));
        }
    }

    // D2 item-level projection: emit a C_LANEPLAYS op for every MANAGED lane. The
    // active mode's lane plays exclusively; every other managed lane is silenced+hidden
    // (laneModeState). MANUAL lanes are skipped entirely — the load-bearing invariant:
    // a toggle never drives a lane the tool did not mint (the fixed-lane analog of
    // "never touch mute/solo"). Lane ownership is not a tree property, so this walks the
    // ownership index directly, not the FolderTree; a project with no fixed lanes leaves
    // plan.lanes empty and the plan is byte-identical to a D1 plan.
#ifndef NDEBUG
    // Debug-time guard for the one-managed-lane-per-mode-per-track exclusivity
    // assumption (see laneModeState). Two managed lanes on the same track claiming the
    // same mode would both be told to play exclusively on that mode's toggle, which
    // REAPER cannot honor. Cheap set membership over the (usually tiny) managed-lane
    // set; compiled out of release builds.
    std::set<std::pair<std::string, std::string>> seenTrackMode;  // (trackGuid, mode)
#endif
    for (const auto& [ref, ownership] : lanes_.all()) {
        if (!ownership.isManaged()) continue;  // manual lanes are off-limits
#ifndef NDEBUG
        assert(seenTrackMode.insert({ref.trackGuid, *ownership.managedMode}).second &&
               "two managed lanes on one track claim the same mode (exclusivity broken)");
#endif
        const int lanePlays = laneModeState(*ownership.managedMode, targetMode);
        plan.lanes.push_back(LanePlayOp{ref.trackGuid, ref.laneKey, lanePlays});
    }

    return plan;
}

std::set<LaneRef> ViewModeModel::lanesTouchedByToggle() const {
    // Managed-only: exactly the lanes a toggle is permitted to drive. A manual lane —
    // absent OR recorded manual in the ownership index — is never returned, so the shell
    // can never write C_LANEPLAYS to a lane the user hand-manages.
    std::set<LaneRef> touched;
    for (const auto& [ref, ownership] : lanes_.all()) {
        if (ownership.isManaged()) touched.insert(ref);
    }
    return touched;
}

bool ViewModeModel::operator==(const ViewModeModel& o) const {
    return modes_ == o.modes_ && membership_ == o.membership_ && lanes_ == o.lanes_ &&
           activeModeId_ == o.activeModeId_ && snapshots_ == o.snapshots_;
}

// ===========================================================================
// JSON — writer
// ===========================================================================

namespace {

void writeEscaped(std::string& out, const std::string& s) {
    out += '"';
    for (char c : s) {
        switch (c) {
        case '"':  out += "\\\""; break;
        case '\\': out += "\\\\"; break;
        case '\b': out += "\\b";  break;
        case '\f': out += "\\f";  break;
        case '\n': out += "\\n";  break;
        case '\r': out += "\\r";  break;
        case '\t': out += "\\t";  break;
        default:
            if (static_cast<unsigned char>(c) < 0x20) {
                char buf[8];
                std::snprintf(buf, sizeof(buf), "\\u%04x", static_cast<unsigned char>(c));
                out += buf;
            } else {
                out += c;
            }
        }
    }
    out += '"';
}

std::string intToStr(int v) {
    char buf[16];
    std::snprintf(buf, sizeof(buf), "%d", v);
    return buf;
}

void writeIntArray(std::string& out, const std::vector<int>& v) {
    out += '[';
    for (std::size_t i = 0; i < v.size(); ++i) {
        if (i) out += ',';
        out += intToStr(v[i]);
    }
    out += ']';
}

class ObjWriter {
public:
    explicit ObjWriter(std::string& out) : out_(out) { out_ += '{'; }
    ~ObjWriter() { out_ += '}'; }

    void keyRaw(const char* key, const std::string& rawValue) {
        sep();
        writeEscaped(out_, key);
        out_ += ':';
        out_ += rawValue;
    }
    void keyStr(const char* key, const std::string& value) {
        sep();
        writeEscaped(out_, key);
        out_ += ':';
        writeEscaped(out_, value);
    }
    void keyBegin(const char* key) {
        sep();
        writeEscaped(out_, key);
        out_ += ':';
    }

private:
    void sep() { if (first_) first_ = false; else out_ += ','; }
    std::string& out_;
    bool first_ = true;
};

} // namespace

std::string ViewModeModel::serialize() const {
    std::string out;
    {
        ObjWriter root(out);
        root.keyRaw("version", intToStr(1));
        root.keyStr("activeMode", activeModeId_);

        // modes
        root.keyBegin("modes");
        out += '[';
        {
            const auto& all = modes_.all();
            for (std::size_t i = 0; i < all.size(); ++i) {
                if (i) out += ',';
                ObjWriter m(out);
                m.keyStr("id", all[i].id);
                m.keyStr("displayName", all[i].displayName);
                m.keyRaw("ordinal", intToStr(all[i].ordinal));
            }
        }
        out += ']';

        // membership: array of { guid, modes[], showBoth }
        root.keyBegin("membership");
        out += '[';
        {
            bool first = true;
            for (const auto& [guid, mem] : membership_.all()) {
                if (!first) out += ',';
                first = false;
                ObjWriter e(out);
                e.keyStr("guid", guid);
                e.keyBegin("modes");
                out += '[';
                {
                    bool mf = true;
                    for (const auto& id : mem.modeIds) {
                        if (!mf) out += ',';
                        mf = false;
                        writeEscaped(out, id);
                    }
                }
                out += ']';
                e.keyRaw("showBoth", mem.showBoth ? "true" : "false");
            }
        }
        out += ']';

        // snapshots: array of { guid, showInTcp, showInMixer, mainSend, fxEnable, fxOffline[] }
        root.keyBegin("snapshots");
        out += '[';
        {
            bool first = true;
            for (const auto& [guid, snap] : snapshots_) {
                if (!first) out += ',';
                first = false;
                ObjWriter e(out);
                e.keyStr("guid", guid);
                e.keyRaw("showInTcp", intToStr(snap.showInTcp));
                e.keyRaw("showInMixer", intToStr(snap.showInMixer));
                e.keyRaw("mainSend", intToStr(snap.mainSend));
                e.keyRaw("fxEnable", intToStr(snap.fxEnable));
                e.keyBegin("fxOffline");
                writeIntArray(out, snap.fxOffline);
            }
        }
        out += ']';

        // lanes: array of { trackGuid, laneKey, managed(bool), mode(str, managed only) }.
        // A manual lane omits "mode"; managed carries the owning mode id. Emitting an
        // explicit "managed" bool keeps a manual lane distinguishable from a managed lane
        // whose mode string is (illegally) empty — the parser rejects the latter.
        root.keyBegin("lanes");
        out += '[';
        {
            bool first = true;
            for (const auto& [ref, ownership] : lanes_.all()) {
                if (!first) out += ',';
                first = false;
                ObjWriter e(out);
                e.keyStr("trackGuid", ref.trackGuid);
                e.keyStr("laneKey", ref.laneKey);
                e.keyRaw("managed", ownership.isManaged() ? "true" : "false");
                if (ownership.isManaged()) e.keyStr("mode", *ownership.managedMode);
            }
        }
        out += ']';
    }  // root closes here (see bank_model note on NRVO + deferred close)
    return out;
}

// ===========================================================================
// JSON — parser (recursive descent; false on any malformed input, never UB)
// ===========================================================================

namespace {

class Parser {
public:
    explicit Parser(const std::string& s) : s_(s) {}
    bool parseModel(ViewModeModel& out);

private:
    const std::string& s_;
    std::size_t pos_ = 0;

    bool eof() const { return pos_ >= s_.size(); }

    void skipWs() {
        while (!eof()) {
            char c = s_[pos_];
            if (c == ' ' || c == '\t' || c == '\n' || c == '\r') ++pos_;
            else break;
        }
    }
    bool consume(char c) {
        skipWs();
        if (eof() || s_[pos_] != c) return false;
        ++pos_;
        return true;
    }
    bool parseString(std::string& out);
    bool parseRawScalar(std::string& out);
    bool parseInt(int& out);
    bool parseBool(bool& out);
    bool parseKey(std::string& key);
    bool skipValue();

    bool parseModes(ModeRegistry& reg);
    bool parseMembership(MembershipIndex& idx);
    bool parseSnapshots(std::map<std::string, TrackSnapshot>& snaps);
    bool parseLanes(LaneOwnershipIndex& idx);
    bool parseIntArray(std::vector<int>& out);
};

bool Parser::parseString(std::string& out) {
    skipWs();
    if (eof() || s_[pos_] != '"') return false;
    ++pos_;
    out.clear();
    while (!eof()) {
        char c = s_[pos_++];
        if (c == '"') return true;
        if (c == '\\') {
            if (eof()) return false;
            char e = s_[pos_++];
            switch (e) {
            case '"':  out += '"';  break;
            case '\\': out += '\\'; break;
            case '/':  out += '/';  break;
            case 'b':  out += '\b'; break;
            case 'f':  out += '\f'; break;
            case 'n':  out += '\n'; break;
            case 'r':  out += '\r'; break;
            case 't':  out += '\t'; break;
            case 'u': {
                auto readHex4 = [&](unsigned int& cp) -> bool {
                    if (pos_ + 4 > s_.size()) return false;
                    cp = 0;
                    for (int i = 0; i < 4; ++i) {
                        char h = s_[pos_++];
                        cp <<= 4;
                        if (h >= '0' && h <= '9') cp |= static_cast<unsigned>(h - '0');
                        else if (h >= 'a' && h <= 'f') cp |= static_cast<unsigned>(h - 'a' + 10);
                        else if (h >= 'A' && h <= 'F') cp |= static_cast<unsigned>(h - 'A' + 10);
                        else return false;
                    }
                    return true;
                };
                unsigned int hi = 0;
                if (!readHex4(hi)) return false;
                unsigned int codePoint = hi;
                if (hi >= 0xD800 && hi <= 0xDBFF) {
                    if (pos_ + 6 > s_.size()) return false;
                    if (s_[pos_] != '\\' || s_[pos_ + 1] != 'u') return false;
                    pos_ += 2;
                    unsigned int lo = 0;
                    if (!readHex4(lo)) return false;
                    if (lo < 0xDC00 || lo > 0xDFFF) return false;
                    codePoint = 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00);
                } else if (hi >= 0xDC00 && hi <= 0xDFFF) {
                    return false;
                }
                if (codePoint <= 0x7F) {
                    out += static_cast<char>(codePoint);
                } else if (codePoint <= 0x7FF) {
                    out += static_cast<char>(0xC0 | (codePoint >> 6));
                    out += static_cast<char>(0x80 | (codePoint & 0x3F));
                } else if (codePoint <= 0xFFFF) {
                    out += static_cast<char>(0xE0 | (codePoint >> 12));
                    out += static_cast<char>(0x80 | ((codePoint >> 6) & 0x3F));
                    out += static_cast<char>(0x80 | (codePoint & 0x3F));
                } else {
                    out += static_cast<char>(0xF0 | (codePoint >> 18));
                    out += static_cast<char>(0x80 | ((codePoint >> 12) & 0x3F));
                    out += static_cast<char>(0x80 | ((codePoint >> 6) & 0x3F));
                    out += static_cast<char>(0x80 | (codePoint & 0x3F));
                }
                break;
            }
            default: return false;
            }
        } else {
            out += c;
        }
    }
    return false;  // unterminated
}

bool Parser::parseRawScalar(std::string& out) {
    skipWs();
    std::size_t start = pos_;
    while (!eof()) {
        char c = s_[pos_];
        if (c == ',' || c == '}' || c == ']' || c == ' ' || c == '\t' ||
            c == '\n' || c == '\r')
            break;
        ++pos_;
    }
    if (pos_ == start) return false;
    out.assign(s_, start, pos_ - start);
    return true;
}

bool Parser::parseInt(int& out) {
    std::string tok;
    if (!parseRawScalar(tok)) return false;
    const char* b = tok.c_str();
    char* end = nullptr;
    errno = 0;
    long long v = std::strtoll(b, &end, 10);
    if (end != b + tok.size()) return false;
    if (errno == ERANGE) return false;
    if (v < INT_MIN || v > INT_MAX) return false;
    out = static_cast<int>(v);
    return true;
}

bool Parser::parseBool(bool& out) {
    std::string tok;
    if (!parseRawScalar(tok)) return false;
    if (tok == "true") { out = true; return true; }
    if (tok == "false") { out = false; return true; }
    return false;
}

bool Parser::parseKey(std::string& key) {
    if (!parseString(key)) return false;
    return consume(':');
}

bool Parser::skipValue() {
    skipWs();
    if (eof()) return false;
    char c = s_[pos_];
    if (c == '"') { std::string tmp; return parseString(tmp); }
    if (c == '{' || c == '[') {
        char open = c, close = (c == '{') ? '}' : ']';
        ++pos_;
        int depth = 1;
        while (!eof() && depth > 0) {
            char d = s_[pos_];
            if (d == '"') { std::string tmp; if (!parseString(tmp)) return false; continue; }
            if (d == open) ++depth;
            else if (d == close) --depth;
            ++pos_;
        }
        return depth == 0;
    }
    std::string tmp;
    return parseRawScalar(tmp);
}

bool Parser::parseIntArray(std::vector<int>& out) {
    if (!consume('[')) return false;
    skipWs();
    if (consume(']')) return true;
    do {
        int v = 0;
        if (!parseInt(v)) return false;
        out.push_back(v);
    } while (consume(','));
    return consume(']');
}

// The registry starts seeded (Arrange + Design). Deserialization must reproduce the
// serialized set exactly, so we replace the seeded contents with the parsed ones —
// add() dedups by id, so a serialized Arrange/Design would otherwise be rejected as
// duplicates and the ordinals/names would not round-trip. We therefore parse into a
// fresh vector and swap. `reg` is passed empty (see parseModel).
bool Parser::parseModes(ModeRegistry& reg) {
    if (!consume('[')) return false;
    skipWs();
    if (consume(']')) return true;   // empty array (unusual, but valid)
    do {
        if (!consume('{')) return false;
        Mode m;
        bool haveId = false;
        do {
            std::string k;
            if (!parseKey(k)) return false;
            if (k == "id") { if (!parseString(m.id)) return false; haveId = true; }
            else if (k == "displayName") { if (!parseString(m.displayName)) return false; }
            else if (k == "ordinal") { if (!parseInt(m.ordinal)) return false; }
            else if (!skipValue()) return false;
        } while (consume(','));
        if (!consume('}')) return false;
        if (!haveId || !reg.add(m)) return false;  // malformed / duplicate id
    } while (consume(','));
    return consume(']');
}

bool Parser::parseMembership(MembershipIndex& idx) {
    if (!consume('[')) return false;
    skipWs();
    if (consume(']')) return true;
    do {
        if (!consume('{')) return false;
        std::string guid;
        Membership mem;
        bool haveGuid = false;
        do {
            std::string k;
            if (!parseKey(k)) return false;
            if (k == "guid") { if (!parseString(guid)) return false; haveGuid = true; }
            else if (k == "modes") {
                if (!consume('[')) return false;
                skipWs();
                if (!consume(']')) {
                    do {
                        std::string id;
                        if (!parseString(id)) return false;
                        mem.modeIds.insert(id);
                    } while (consume(','));
                    if (!consume(']')) return false;
                }
            }
            else if (k == "showBoth") { if (!parseBool(mem.showBoth)) return false; }
            else if (!skipValue()) return false;
        } while (consume(','));
        if (!consume('}')) return false;
        if (!haveGuid || guid.empty()) return false;
        // Install the entry verbatim (tag() would clear a multi-mode set and drop
        // show-both). A serialized entry is trusted to already satisfy the model's
        // invariants.
        //
        // Deliberate tolerance: we do NOT validate that membership modeIds reference
        // registered modes, and we do not validate snapshot GUIDs against the index.
        // Stale-GUID and stale-mode tolerance is a stated invariant of this model —
        // a deserialized entry is treated as trusted data, not as live cross-checked
        // state. Rejecting stale entries here would violate that invariant. The one
        // exception is activeMode (validated below in parseModel): a persisted active
        // mode that no longer exists has an immediate behavioral consequence, so it
        // is caught and the parse is rejected.
        if (!idx.restore(guid, mem)) return false;
    } while (consume(','));
    return consume(']');
}

bool Parser::parseSnapshots(std::map<std::string, TrackSnapshot>& snaps) {
    if (!consume('[')) return false;
    skipWs();
    if (consume(']')) return true;
    do {
        if (!consume('{')) return false;
        std::string guid;
        TrackSnapshot snap;
        bool haveGuid = false;
        do {
            std::string k;
            if (!parseKey(k)) return false;
            if (k == "guid") { if (!parseString(guid)) return false; haveGuid = true; }
            else if (k == "showInTcp") { if (!parseInt(snap.showInTcp)) return false; }
            else if (k == "showInMixer") { if (!parseInt(snap.showInMixer)) return false; }
            else if (k == "mainSend") { if (!parseInt(snap.mainSend)) return false; }
            else if (k == "fxEnable") { if (!parseInt(snap.fxEnable)) return false; }
            else if (k == "fxOffline") { if (!parseIntArray(snap.fxOffline)) return false; }
            else if (!skipValue()) return false;
        } while (consume(','));
        if (!consume('}')) return false;
        if (!haveGuid || guid.empty()) return false;
        snaps[guid] = snap;
    } while (consume(','));
    return consume(']');
}

bool Parser::parseLanes(LaneOwnershipIndex& idx) {
    if (!consume('[')) return false;
    skipWs();
    if (consume(']')) return true;
    do {
        if (!consume('{')) return false;
        std::string trackGuid, laneKey, mode;
        bool haveTrack = false, haveLane = false, managed = false, haveManaged = false;
        do {
            std::string k;
            if (!parseKey(k)) return false;
            if (k == "trackGuid") { if (!parseString(trackGuid)) return false; haveTrack = true; }
            else if (k == "laneKey") { if (!parseString(laneKey)) return false; haveLane = true; }
            else if (k == "managed") { if (!parseBool(managed)) return false; haveManaged = true; }
            else if (k == "mode") { if (!parseString(mode)) return false; }
            else if (!skipValue()) return false;
        } while (consume(','));
        if (!consume('}')) return false;
        // Both keys mandatory and non-empty (they form the lane's identity). A managed
        // lane must carry a non-empty mode; a manual lane must not claim one. Enforcing
        // this on parse keeps a round-tripped index byte-for-byte identical to the
        // serialized one and rejects a malformed managed-without-mode entry.
        if (!haveTrack || !haveLane || !haveManaged) return false;
        if (trackGuid.empty() || laneKey.empty()) return false;
        if (managed) {
            if (mode.empty()) return false;
            if (!idx.setManaged(trackGuid, laneKey, mode)) return false;
        } else {
            if (!mode.empty()) return false;  // manual lane must not carry a mode
            if (!idx.setManual(trackGuid, laneKey)) return false;
        }
    } while (consume(','));
    return consume(']');
}

bool Parser::parseModel(ViewModeModel& out) {
    if (!consume('{')) return false;
    skipWs();
    if (consume('}')) return true;  // lenient empty root ⇒ default-seeded model

    ModeRegistry reg;  // seeded default; REPLACED if a modes array is present
    bool haveModes = false;
    std::string activeMode;
    bool haveActive = false;
    MembershipIndex membership;
    LaneOwnershipIndex lanes;
    std::map<std::string, TrackSnapshot> snaps;

    do {
        std::string key;
        if (!parseKey(key)) return false;
        if (key == "activeMode") {
            if (!parseString(activeMode)) return false;
            haveActive = true;
        } else if (key == "modes") {
            ModeRegistry fresh = ModeRegistry::makeEmpty();  // parse into empty, then own
            if (!parseModes(fresh)) return false;
            reg = fresh;
            haveModes = true;
        } else if (key == "membership") {
            if (!parseMembership(membership)) return false;
        } else if (key == "snapshots") {
            if (!parseSnapshots(snaps)) return false;
        } else if (key == "lanes") {
            if (!parseLanes(lanes)) return false;
        } else {
            // Unknown keys and the "version" field are skipped here.
            // "version" is serialized as a forward-compat placeholder — there is no
            // active version gate yet; all persisted data is parsed the same way
            // regardless of the value. A future gate would add a version branch here.
            if (!skipValue()) return false;
        }
    } while (consume(','));

    if (!consume('}')) return false;
    skipWs();
    if (!eof()) return false;  // trailing garbage

    if (haveModes) out.modes() = reg;
    out.membership() = membership;
    out.lanes() = lanes;
    for (const auto& [guid, snap] : snaps) out.storeSnapshot(guid, snap);
    if (haveActive) {
        if (!out.setActiveMode(activeMode)) return false;  // active mode must exist
    }
    return true;
}

} // namespace

std::optional<ViewModeModel> ViewModeModel::deserialize(const std::string& json) {
    ViewModeModel vm;
    Parser p(json);
    if (!p.parseModel(vm)) return std::nullopt;
    return vm;
}

} // namespace reasampler
