#include "core/view/view_mode_model.h"

#include <algorithm>
#include <cassert>
#include <set>
#include <utility>

#include "core/json/json.h"
#include "core/view/lane_keys.h"  // laneNameForMode

namespace reasampler {

using view::laneNameForMode;

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

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);
    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;
}

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>{};
}

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) {
    // Assumes at most one managed lane per (track, mode) — planToggle asserts
    // this in debug builds; two lanes claiming the same mode would both be
    // told to play exclusively, which REAPER can't honor coherently.
    return managedMode == activeMode ? kLanePlaysExclusive : kLaneSilent;
}

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;

        // Adopt the track's single pre-existing mode (strand guard — see header).
        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;
        if (item.onManualLane) continue;
        ops.push_back(ItemRetagOp{item.guid, untag, untag ? std::string{} : targetMode});
    }
    return ops;
}

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

    // Per track GUID, the modes it's visible in (tree-aware) — captures the
    // folder-derived-visibility split trigger, not just own-item mode span.
    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;

        if (model.membership().isShowBoth(track.trackGuid)) continue;  // never force-split

        std::set<std::string> ownItemModes;
        for (const LaneItem& item : track.items) {
            if (item.guid.empty() || item.modeId.empty()) continue;
            if (item.onManualLane) continue;  // exempt
            ownItemModes.insert(item.modeId);
        }

        if (ownItemModes.empty()) continue;  // no own media, nothing to confine

        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;

        if (!multiMode) continue;  // single-mode: D1 whole-track parking still separates

        const std::set<std::string>& laneModes = ownItemModes;  // lazy-mint: own modes only

        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});
        }

        for (const LaneItem& item : track.items) {
            if (item.guid.empty() || item.modeId.empty()) continue;
            if (item.onManualLane) continue;
            plan.assigns.push_back(LaneAssign{
                item.guid, track.trackGuid, laneNameForMode(item.modeId)});
        }
    }

    return plan;
}

TrackPlan makeParkPlan(const std::string& guid, int fxCount) {
    TrackPlan p;
    p.flags = {
        {guid, Flag::ShowInTcp, 0},
        {guid, Flag::ShowInMixer, 0},
        {guid, Flag::MainSend, 0},
        {guid, Flag::FxEnable, 0},
    };
    // Park has no identity question to answer: it offlines every slot that is
    // live right now, so slot keying IS the addressing. In production fxCount
    // is always 0 here — planToggle calls this with fxCount=0 and the D2 shell
    // expands the real writes itself via TrackFX_GetCount (parkFxOffline in
    // shell/view/view.cpp); a nonzero fxCount only exercises this loop in tests.
    for (int i = 0; i < fxCount; ++i)
        p.fxOffline.push_back(FxOfflineOp{guid, FxKeying::Slot, {}, i, true});
    return p;
}

TrackPlan makeRestorePlan(const std::string& guid, const TrackSnapshot& snap) {
    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) {
        const FxOfflineState& fx = snap.fxOffline[i];
        p.fxOffline.push_back(FxOfflineOp{guid, snap.fxKeying, fx.fxGuid,
                                          static_cast<int>(i), fx.offline != 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
    }
    return all.front().id;  // stale/unknown current id -> jump to the first mode
}

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) {
    // See header: snapshots are pruned, membership is not (undo-delete rationale).
    soloCache_.reconcile(liveGuids);

    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;
    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: nodes visible by their own membership (leaf rule, or an
    // untagged/Arrange-default folder).
    for (const auto& node : tree.nodes) {
        if (leafBelongsToMode(node.guid, modeId))
            visible.insert(node.guid);
    }

    // Pass 2: propagate up parent chains so a parent with any visible
    // descendant is visible too (OR'd with pass 1). Cycle-guarded.
    std::map<std::string, std::string> parentOf;
    for (const auto& node : tree.nodes) parentOf[node.guid] = node.parentGuid;

    // Seed from the full pass-1 set (not just leaves) so a parent already visible by
    // its own membership still propagates visibility up its remaining ancestors; a
    // snapshot copy so mid-loop insertions into `visible` are never re-walked.
    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;

    // Enumerate the tree (not membership_.all()) so untagged leaves — absent
    // from the membership index but still Arrange members — park/restore too.
    for (const auto& node : tree.nodes) {
        if (node.isParent) continue;
        const std::string& guid = node.guid;
        if (membership_.isShowBoth(guid)) continue;

        const bool active = leafBelongsToMode(guid, targetMode);
        if (active) {
            if (const TrackSnapshot* snap = snapshot(guid))
                plan.restore.push_back(makeRestorePlan(guid, *snap));
        } else {
            // fxOffline is empty here; the D2 shell expands it via TrackFX_GetCount.
            plan.park.push_back(makeParkPlan(guid, /*fxCount=*/0));
        }
    }

    // One C_LANEPLAYS op per MANAGED lane; manual lanes are skipped entirely.
#ifndef NDEBUG
    std::set<std::pair<std::string, std::string>> seenTrackMode;  // (trackGuid, mode)
#endif
    for (const auto& [ref, ownership] : lanes_.all()) {
        if (!ownership.isManaged()) continue;
#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 {
    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_ &&
           soloCache_ == o.soloCache_;
}

namespace {

// Shared core/json emit helpers (Q-W1) — byte-identical escape/int rendering.
using json::writeEscaped;
using json::writeIntArray;
std::string intToStr(int v) { return json::numToStr(v); }
using ObjWriter = json::Writer;

// Version ladder for the stored blob, under the FOREVER-STABLE "view_state" key.
// Only the per-FX snapshot shape has ever moved:
//
//   v1  "snapshots":[{...,"fxOffline":[0,1,0]}]          — offline state by SLOT
//   v2  "snapshots":[{...,"fxOffline":[0,1,0],           — the v1 array, still written
//                         "fx":[{"guid":"{..}","offline":0},...]}]  — by FX IDENTITY
//
// v2 writes BOTH: "fx" is what this build reads, and the v1 array is what a build
// that predates identity keying reads — a downgrade keeps exactly the behavior it
// had rather than losing every captured FX state to an unknown key. Reading, "fx"
// wins outright; a blob carrying only "fxOffline" lifts to a Slot-keyed snapshot
// and restores by slot ONCE, which is the only thing its bytes can support (the
// restore then clears it, so the next park captures identities).
//
// "version" is WRITTEN but deliberately not validated on read: an unreadable
// view_state falls back to a default model, which loses every membership tag, so
// leniency is the safe direction here — the opposite call from origin_ledger,
// where a misread blob would put prune's deletion authority on bad data.
constexpr int kViewStateVersion = 2;

} // namespace

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

        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[], fx[] } — see the version ladder above.
        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));

                std::vector<int> bySlot;
                bySlot.reserve(snap.fxOffline.size());
                for (const FxOfflineState& fx : snap.fxOffline) bySlot.push_back(fx.offline);
                e.keyBegin("fxOffline");
                writeIntArray(out, bySlot);

                // A Slot-keyed snapshot has no identities to write — emitting an
                // "fx" array for it would invent the very keys it lacks.
                if (snap.fxKeying == FxKeying::Identity) {
                    e.keyBegin("fx");
                    out += '[';
                    bool firstFx = true;
                    for (const FxOfflineState& fx : snap.fxOffline) {
                        if (!firstFx) out += ',';
                        firstFx = false;
                        ObjWriter f(out);
                        f.keyStr("guid", fx.fxGuid);
                        f.keyRaw("offline", intToStr(fx.offline));
                    }
                    out += ']';
                }
            }
        }
        out += ']';

        // lanes: array of { trackGuid, laneKey, managed(bool), mode(str, managed only) }
        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 += ']';

        // soloCache: array of { mode, tracks: [ { guid, solo } ] }
        root.keyBegin("soloCache");
        out += '[';
        {
            bool firstMode = true;
            for (const auto& [modeId, byGuid] : soloCache_.all()) {
                if (!firstMode) out += ',';
                firstMode = false;
                ObjWriter m(out);
                m.keyStr("mode", modeId);
                m.keyBegin("tracks");
                out += '[';
                bool firstTrack = true;
                for (const auto& [guid, solo] : byGuid) {
                    if (!firstTrack) out += ',';
                    firstTrack = false;
                    ObjWriter e(out);
                    e.keyStr("guid", guid);
                    e.keyRaw("solo", intToStr(solo));
                }
                out += ']';
            }
        }
        out += ']';
    }  // root closes here (NRVO + deferred close, mirrors bank_model)
    return out;
}

namespace {

bool parseModes(json::Reader& r, ModeRegistry& reg) {
    if (!r.consume('[')) return false;
    r.skipWs();
    if (r.consume(']')) return true;   // empty array (unusual, but valid)
    do {
        if (!r.consume('{')) return false;
        Mode m;
        bool haveId = false;
        do {
            std::string k;
            if (!r.parseKey(k)) return false;
            if (k == "id") { if (!r.parseString(m.id)) return false; haveId = true; }
            else if (k == "displayName") { if (!r.parseString(m.displayName)) return false; }
            else if (k == "ordinal") { if (!r.parseInt(m.ordinal)) return false; }
            else if (!r.skipValue()) return false;
        } while (r.consume(','));
        if (!r.consume('}')) return false;
        if (!haveId || !reg.add(m)) return false;  // malformed / duplicate id
    } while (r.consume(','));
    return r.consume(']');
}

bool parseMembership(json::Reader& r, MembershipIndex& idx) {
    if (!r.consume('[')) return false;
    r.skipWs();
    if (r.consume(']')) return true;
    do {
        if (!r.consume('{')) return false;
        std::string guid;
        Membership mem;
        bool haveGuid = false;
        do {
            std::string k;
            if (!r.parseKey(k)) return false;
            if (k == "guid") { if (!r.parseString(guid)) return false; haveGuid = true; }
            else if (k == "modes") {
                if (!r.consume('[')) return false;
                r.skipWs();
                if (!r.consume(']')) {
                    do {
                        std::string id;
                        if (!r.parseString(id)) return false;
                        mem.modeIds.insert(id);
                    } while (r.consume(','));
                    if (!r.consume(']')) return false;
                }
            }
            else if (k == "showBoth") { if (!r.parseBool(mem.showBoth)) return false; }
            else if (!r.skipValue()) return false;
        } while (r.consume(','));
        if (!r.consume('}')) return false;
        if (!haveGuid || guid.empty()) return false;
        // Install verbatim (tag() would clobber a multi-mode set / show-both).
        // Stale mode ids / stale GUIDs are tolerated by design — only
        // activeMode is validated (below).
        if (!idx.restore(guid, mem)) return false;
    } while (r.consume(','));
    return r.consume(']');
}

// The identity-keyed "fx" array. Both keys are mandatory (strict like parseLanes);
// an EMPTY guid is accepted, because a live capture records one when REAPER
// reported no identity for that FX — the entry is honest about being unresolvable
// rather than being silently dropped at write time.
bool parseFxStates(json::Reader& r, std::vector<FxOfflineState>& out) {
    if (!r.consume('[')) return false;
    r.skipWs();
    if (r.consume(']')) return true;
    do {
        if (!r.consume('{')) return false;
        FxOfflineState fx;
        bool haveGuid = false, haveOffline = false;
        do {
            std::string k;
            if (!r.parseKey(k)) return false;
            if (k == "guid") { if (!r.parseString(fx.fxGuid)) return false; haveGuid = true; }
            else if (k == "offline") { if (!r.parseInt(fx.offline)) return false; haveOffline = true; }
            else if (!r.skipValue()) return false;
        } while (r.consume(','));
        if (!r.consume('}')) return false;
        if (!haveGuid || !haveOffline) return false;
        out.push_back(fx);
    } while (r.consume(','));
    return r.consume(']');
}

bool parseSnapshots(json::Reader& r, std::map<std::string, TrackSnapshot>& snaps) {
    if (!r.consume('[')) return false;
    r.skipWs();
    if (r.consume(']')) return true;
    do {
        if (!r.consume('{')) return false;
        std::string guid;
        TrackSnapshot snap;
        bool haveGuid = false;
        std::vector<int> bySlot;
        bool haveSlot = false;
        std::vector<FxOfflineState> byIdentity;
        bool haveIdentity = false;
        do {
            std::string k;
            if (!r.parseKey(k)) return false;
            if (k == "guid") { if (!r.parseString(guid)) return false; haveGuid = true; }
            else if (k == "showInTcp") { if (!r.parseInt(snap.showInTcp)) return false; }
            else if (k == "showInMixer") { if (!r.parseInt(snap.showInMixer)) return false; }
            else if (k == "mainSend") { if (!r.parseInt(snap.mainSend)) return false; }
            else if (k == "fxEnable") { if (!r.parseInt(snap.fxEnable)) return false; }
            else if (k == "fxOffline") { if (!r.parseIntArray(bySlot)) return false; haveSlot = true; }
            else if (k == "fx") {
                if (!parseFxStates(r, byIdentity)) return false;
                haveIdentity = true;
            }
            else if (!r.skipValue()) return false;
        } while (r.consume(','));
        if (!r.consume('}')) return false;
        if (!haveGuid || guid.empty()) return false;

        // A blob carrying both arrays at different lengths is not something this
        // writer (or any prior version) produces — reject rather than silently
        // trusting "fx" over a slot array that disagrees with it; an unreadable
        // view_state falls back to a default model per the version-ladder note
        // above, which is the same leniency-direction call already made there.
        if (haveIdentity && haveSlot && byIdentity.size() != bySlot.size()) return false;

        // "fx" wins outright — v2 writes the slot array beside it purely so an
        // older build can still read something (see the version ladder above).
        if (haveIdentity) {
            snap.fxOffline = std::move(byIdentity);
            snap.fxKeying = FxKeying::Identity;
        } else {
            for (int offline : bySlot) snap.fxOffline.push_back(FxOfflineState{{}, offline});
            snap.fxKeying = FxKeying::Slot;
        }
        snaps[guid] = std::move(snap);
    } while (r.consume(','));
    return r.consume(']');
}

bool parseLanes(json::Reader& r, LaneOwnershipIndex& idx) {
    if (!r.consume('[')) return false;
    r.skipWs();
    if (r.consume(']')) return true;
    do {
        if (!r.consume('{')) return false;
        std::string trackGuid, laneKey, mode;
        bool haveTrack = false, haveLane = false, managed = false, haveManaged = false;
        do {
            std::string k;
            if (!r.parseKey(k)) return false;
            if (k == "trackGuid") { if (!r.parseString(trackGuid)) return false; haveTrack = true; }
            else if (k == "laneKey") { if (!r.parseString(laneKey)) return false; haveLane = true; }
            else if (k == "managed") { if (!r.parseBool(managed)) return false; haveManaged = true; }
            else if (k == "mode") { if (!r.parseString(mode)) return false; }
            else if (!r.skipValue()) return false;
        } while (r.consume(','));
        if (!r.consume('}')) return false;
        // Both keys mandatory/non-empty; managed must carry a mode, manual must not
        // — keeps a round-tripped index byte-for-byte identical to the source.
        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 (r.consume(','));
    return r.consume(']');
}

// One mode's cached solo set. Strict like parseLanes: every key the writer emits is
// mandatory and non-empty. An empty tracks array is rejected — serialize never emits
// one (store drops an empty set), so accepting it would let a hand-edited blob parse
// into a model that re-serializes differently.
bool parseSoloTracks(json::Reader& r, std::map<std::string, int>& byGuid) {
    if (!r.consume('[')) return false;
    r.skipWs();
    if (r.consume(']')) return true;
    do {
        if (!r.consume('{')) return false;
        std::string guid;
        int solo = 0;
        bool haveGuid = false, haveSolo = false;
        do {
            std::string k;
            if (!r.parseKey(k)) return false;
            if (k == "guid") { if (!r.parseString(guid)) return false; haveGuid = true; }
            else if (k == "solo") { if (!r.parseInt(solo)) return false; haveSolo = true; }
            else if (!r.skipValue()) return false;
        } while (r.consume(','));
        if (!r.consume('}')) return false;
        if (!haveGuid || !haveSolo || guid.empty()) return false;
        byGuid[guid] = solo;
    } while (r.consume(','));
    return r.consume(']');
}

bool parseSoloCache(json::Reader& r, view::SoloCache& cache) {
    if (!r.consume('[')) return false;
    r.skipWs();
    if (r.consume(']')) return true;
    do {
        if (!r.consume('{')) return false;
        std::string modeId;
        std::map<std::string, int> byGuid;
        bool haveMode = false, haveTracks = false;
        do {
            std::string k;
            if (!r.parseKey(k)) return false;
            if (k == "mode") { if (!r.parseString(modeId)) return false; haveMode = true; }
            else if (k == "tracks") {
                if (!parseSoloTracks(r, byGuid)) return false;
                haveTracks = true;
            }
            else if (!r.skipValue()) return false;
        } while (r.consume(','));
        if (!r.consume('}')) return false;
        if (!haveMode || modeId.empty() || !haveTracks || byGuid.empty()) return false;
        if (!cache.store(modeId, byGuid)) return false;
    } while (r.consume(','));
    return r.consume(']');
}

bool parseModel(json::Reader& r, ViewModeModel& out) {
    if (!r.consume('{')) return false;
    r.skipWs();
    if (r.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;
    view::SoloCache soloCache;

    do {
        std::string key;
        if (!r.parseKey(key)) return false;
        if (key == "activeMode") {
            if (!r.parseString(activeMode)) return false;
            haveActive = true;
        } else if (key == "modes") {
            ModeRegistry fresh = ModeRegistry::makeEmpty();  // parse into empty, then own
            if (!parseModes(r, fresh)) return false;
            reg = fresh;
            haveModes = true;
        } else if (key == "membership") {
            if (!parseMembership(r, membership)) return false;
        } else if (key == "snapshots") {
            if (!parseSnapshots(r, snaps)) return false;
        } else if (key == "lanes") {
            if (!parseLanes(r, lanes)) return false;
        } else if (key == "soloCache") {
            if (!parseSoloCache(r, soloCache)) return false;
        } else {
            if (!r.skipValue()) return false;  // unknown keys / "version" placeholder
        }
    } while (r.consume(','));

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

    if (haveModes) out.modes() = reg;
    out.membership() = membership;
    out.lanes() = lanes;
    out.soloCache() = soloCache;
    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& blob) {
    ViewModeModel vm;
    json::Reader r(blob);
    if (!parseModel(r, vm)) return std::nullopt;
    return vm;
}

} // namespace reasampler
