#include "bank_book.h"

#include <algorithm>
#include <climits>
#include <unordered_set>

// bank_book implementation.
//
// JSON is hand-rolled and self-contained, matching the house style of bank_model
// and view_mode_model (brief: keep the pure core dependency-free — no third-party
// JSON lib). The book blob nests one bank object per bank, each carrying that
// bank's BankIndex serialized by bank_model's OWN writer (BankIndex::serialize),
// so per-bank sample serialization stays owned by bank_model and is not duplicated
// here. The book writer emits the bank envelope (id / displayName / ordinal) plus a
// raw "index" member whose value is the BankIndex blob verbatim; the parser splits
// the book envelope, then hands each nested index blob straight to
// BankIndex::deserialize. Ints use %d; strings are escaped by writeEscaped.

namespace reasampler {

// ===========================================================================
// SlotMap — the L7 gap-preserving display-position carrier (pure). See bank_book.h.
// The invariant: entries_ is kept sorted ascending by slot, one id per slot, one
// slot per id. Every mutator restores it; queries assume it.
// ===========================================================================

void SlotMap::sortBySlot() {
    std::stable_sort(entries_.begin(), entries_.end(),
                     [](const Entry& a, const Entry& b) { return a.slot < b.slot; });
}

int SlotMap::slotOf(const std::string& id) const {
    for (const auto& e : entries_)
        if (e.id == id) return e.slot;
    return -1;
}

std::string SlotMap::idAt(int slot) const {
    for (const auto& e : entries_)
        if (e.slot == slot) return e.id;
    return {};
}

int SlotMap::maxSlot() const {
    int m = -1;
    for (const auto& e : entries_)
        if (e.slot > m) m = e.slot;
    return m;
}

std::vector<std::string> SlotMap::orderedIds() const {
    // entries_ is sorted ascending by slot, so a straight walk is display order.
    std::vector<std::string> out;
    out.reserve(entries_.size());
    for (const auto& e : entries_) out.push_back(e.id);
    return out;
}

void SlotMap::append(const std::string& id) {
    if (id.empty()) return;
    remove(id);                       // an existing id is re-appended, not left in place
    entries_.push_back(Entry{id, maxSlot() + 1});  // next free slot after the last occupied
    sortBySlot();
}

bool SlotMap::remove(const std::string& id) {
    for (auto it = entries_.begin(); it != entries_.end(); ++it) {
        if (it->id == id) {
            entries_.erase(it);       // leaves the slot empty — no re-pack
            return true;
        }
    }
    return false;
}

bool SlotMap::reorder(const std::string& id, int targetSlot) {
    if (slotOf(id) < 0) return false;  // not mapped -> no mutation
    if (targetSlot < 0) targetSlot = 0;
    if (slotOf(id) == targetSlot) return false;  // already there — true no-op

    // Detach the moving id first so the occupancy test below sees the post-move world.
    remove(id);

    const bool occupied = !idAt(targetSlot).empty();
    if (occupied) {
        // Insert-before-and-shift: every occupant at slot >= targetSlot shifts up by one,
        // preserving relative order and interior gaps above the target. The moving id then
        // takes targetSlot cleanly.
        for (auto& e : entries_)
            if (e.slot >= targetSlot) ++e.slot;
    }
    entries_.push_back(Entry{id, targetSlot});
    sortBySlot();
    return true;
}

void SlotMap::resetDense(const std::vector<std::string>& ids) {
    entries_.clear();
    int slot = 0;
    for (const auto& id : ids) {
        if (id.empty()) continue;
        if (slotOf(id) >= 0) continue;  // skip a duplicate id (one slot per id)
        entries_.push_back(Entry{id, slot++});
    }
    // Already ascending by construction; no sort needed.
}

void SlotMap::reconcile(const std::vector<std::string>& liveIds) {
    // Drop markers whose sample left the index.
    entries_.erase(
        std::remove_if(entries_.begin(), entries_.end(),
                       [&](const Entry& e) {
                           return std::find(liveIds.begin(), liveIds.end(), e.id) ==
                                  liveIds.end();
                       }),
        entries_.end());
    // Append live ids that have no mapping yet (out-of-band index growth), in liveIds
    // order, each to the next free slot after the current frontier.
    for (const auto& id : liveIds)
        if (slotOf(id) < 0) append(id);
    sortBySlot();
}

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

SlotMap SlotMap::fromEntries(const std::vector<std::pair<std::string, int>>& pairs) {
    SlotMap m;
    for (const auto& [id, slot] : pairs) {
        if (id.empty() || slot < 0) continue;         // drop malformed pair
        if (m.slotOf(id) >= 0) continue;              // duplicate id: first wins
        if (!m.idAt(slot).empty()) continue;          // slot taken: never double-occupy
        m.entries_.push_back(Entry{id, slot});
    }
    m.sortBySlot();
    return m;
}

// SlotMap::serialize is defined in the JSON writer section below (it reuses the
// file-local ObjWriter / intToStr helpers).

// ---------------------------------------------------------------------------
// BankBook — construction + bank lookup
// ---------------------------------------------------------------------------

BankBook::BankBook() {
    Bank pool;
    pool.id = kPoolBankId;
    pool.displayName = kPoolBankName;
    pool.ordinal = 0;
    banks_.push_back(std::move(pool));
    activeBankId_ = kPoolBankId;
}

Bank* BankBook::bank(const std::string& id) {
    for (auto& b : banks_)
        if (b.id == id) return &b;
    return nullptr;
}

const Bank* BankBook::bank(const std::string& id) const {
    for (const auto& b : banks_)
        if (b.id == id) return &b;
    return nullptr;
}

BankIndex* BankBook::index(const std::string& id) {
    Bank* b = bank(id);
    return b ? &b->index : nullptr;
}

const BankIndex* BankBook::index(const std::string& id) const {
    const Bank* b = bank(id);
    return b ? &b->index : nullptr;
}

Bank& BankBook::pool() {
    // The pool is seeded on construction and is un-deletable, so it always exists.
    return *bank(kPoolBankId);
}

const Bank& BankBook::pool() const {
    return *bank(kPoolBankId);
}

// ---------------------------------------------------------------------------
// Ordinal normalization
// ---------------------------------------------------------------------------

void BankBook::normalizeOrdinals() {
    // Stable-sort by ordinal with the pool pinned first, then rewrite ordinals to a
    // contiguous 0..N-1. Stability preserves the caller's relative order among banks
    // that share (or, after a reorder shuffle, tie on) an ordinal.
    std::stable_sort(banks_.begin(), banks_.end(), [](const Bank& a, const Bank& b) {
        if (a.isPool() != b.isPool()) return a.isPool();  // pool always first
        return a.ordinal < b.ordinal;
    });
    for (std::size_t i = 0; i < banks_.size(); ++i)
        banks_[i].ordinal = static_cast<int>(i);
}

// ---------------------------------------------------------------------------
// Display-name uniqueness (trimmed + case-insensitive, ASCII)
// ---------------------------------------------------------------------------

namespace {

// Folds a display name to its uniqueness key: strip leading/trailing ASCII
// whitespace, lower-case ASCII letters. So "Drums", "drums", and " Drums " share one
// key and cannot coexist. ASCII-only by design — the pure core carries no locale
// facility and must not grow one; bank names are short user labels, not full Unicode
// case-folding candidates.
std::string nameKey(const std::string& s) {
    std::size_t b = 0, e = s.size();
    auto isWs = [](char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; };
    while (b < e && isWs(s[b])) ++b;
    while (e > b && isWs(s[e - 1])) --e;
    std::string out;
    out.reserve(e - b);
    for (std::size_t i = b; i < e; ++i) {
        char c = s[i];
        if (c >= 'A' && c <= 'Z') c = static_cast<char>(c - 'A' + 'a');
        out += c;
    }
    return out;
}

}  // namespace

// True if any bank OTHER than `exceptId` already carries `name`'s uniqueness key. The
// exception lets renameBank accept a bank keeping (or re-casing/-spacing) its own name.
bool BankBook::displayNameTaken(const std::string& name, const std::string& exceptId) const {
    const std::string key = nameKey(name);
    for (const auto& b : banks_)
        if (b.id != exceptId && nameKey(b.displayName) == key) return true;
    return false;
}

// ---------------------------------------------------------------------------
// Bank lifecycle
// ---------------------------------------------------------------------------

bool BankBook::createBank(const std::string& id, const std::string& displayName) {
    if (id.empty()) return false;              // ids key the registry
    if (id == kPoolBankId) return false;       // reserved pool id
    if (bank(id) != nullptr) return false;     // duplicate id
    // Display names are unique (trimmed + case-insensitive); the pool's "Pool" is a
    // reserved name and is caught here like any other collision.
    if (displayNameTaken(displayName, /*exceptId=*/id)) return false;

    Bank b;
    b.id = id;
    b.displayName = displayName;
    b.ordinal = static_cast<int>(banks_.size());  // append; normalize compacts it
    banks_.push_back(std::move(b));
    normalizeOrdinals();
    return true;
}

bool BankBook::renameBank(const std::string& id, const std::string& displayName) {
    if (id == kPoolBankId) return false;  // pool is un-renamable
    Bank* b = bank(id);
    if (b == nullptr) return false;
    // Reject a name already used by a DIFFERENT bank. Renaming a bank to its own
    // current name (or a case/space variant of it) is a no-op success, not a
    // rejection — exceptId=id excludes the bank itself from the collision scan.
    if (displayNameTaken(displayName, /*exceptId=*/id)) return false;
    b->displayName = displayName;
    return true;
}

bool BankBook::deleteBank(const std::string& id) {
    if (id == kPoolBankId) return false;  // pool is un-deletable
    auto it = std::find_if(banks_.begin(), banks_.end(),
                           [&](const Bank& b) { return b.id == id; });
    if (it == banks_.end()) return false;

    banks_.erase(it);
    // If the active bank was the one deleted, fall back to the pool (invariant: the
    // active id always names a live bank).
    if (activeBankId_ == id) activeBankId_ = kPoolBankId;
    normalizeOrdinals();
    return true;
}

bool BankBook::reorderBank(const std::string& id, int newOrdinal) {
    if (id == kPoolBankId) return false;  // pool is pinned at ordinal 0
    if (bank(id) == nullptr) return false;

    // Work on the named banks as an ordered list (banks_ is already ordinal-sorted
    // with the pool first, so named banks are banks_[1..]). Pull the target out and
    // re-insert it at the requested position, clamped into the named-bank range
    // [1..N], then rewrite ordinals contiguously. This is O(N) and obviously correct.
    std::vector<Bank> named;
    named.reserve(banks_.size());
    for (auto& b : banks_)
        if (!b.isPool()) named.push_back(std::move(b));

    auto it = std::find_if(named.begin(), named.end(),
                           [&](const Bank& b) { return b.id == id; });
    Bank moved = std::move(*it);
    named.erase(it);

    // Named ordinals are 1..N; convert to a 0-based insertion index into `named`.
    const int hi = static_cast<int>(named.size());  // insert-at range is [0..size]
    int insertAt = std::max(0, std::min(newOrdinal - 1, hi));
    named.insert(named.begin() + insertAt, std::move(moved));

    // Rebuild banks_: pool first, then the reordered named banks. Assign ordinals
    // directly by position here — NOT via normalizeOrdinals(), whose stable_sort keys
    // on the (now stale) ordinals and would undo the reinsertion order.
    std::vector<Bank> rebuilt;
    rebuilt.reserve(named.size() + 1);
    rebuilt.push_back(std::move(pool()));
    for (auto& b : named) rebuilt.push_back(std::move(b));
    banks_ = std::move(rebuilt);
    for (std::size_t i = 0; i < banks_.size(); ++i)
        banks_[i].ordinal = static_cast<int>(i);
    return true;
}

bool BankBook::evacuate(const std::string& id) {
    if (id == kPoolBankId) return false;  // pool is un-evacuable (it is the target)
    Bank* src = bank(id);
    if (src == nullptr) return false;

    // Move every member into the pool, index-only, observing destination collapse.
    // Snapshot the members first, then clear the source — BankIndex has no bulk move,
    // and adding into the pool must not alias the vector we are draining.
    BankIndex& poolIndex = pool().index;
    const std::vector<Sample> members = src->index.all();  // copy
    for (const auto& s : members)
        poolIndex.add(s);  // Added or Collapsed; either way the pool now holds the hash
    src->index = BankIndex{};  // leave the evacuated bank empty
    return true;
}

// ---------------------------------------------------------------------------
// Active bank
// ---------------------------------------------------------------------------

bool BankBook::setActiveBank(const std::string& id) {
    if (bank(id) == nullptr) return false;  // unknown id never corrupts state
    activeBankId_ = id;
    return true;
}

BankIndex& BankBook::activeIndex() {
    // activeBankId_ always names a live bank; it falls back to the pool on delete.
    return bank(activeBankId_)->index;
}

const BankIndex& BankBook::activeIndex() const {
    return bank(activeBankId_)->index;
}

// ---------------------------------------------------------------------------
// Sample movement (index-only)
// ---------------------------------------------------------------------------

namespace {

// Adds `s` to `dest` and maps the BankIndex outcome onto the transfer outcome for
// the "gained a NEW entry" case (`gained`) vs the collapse case. Rejected outcomes
// (absolute path / empty id) cannot occur here: the sample already passed add() on
// the source side, so its path and id are already valid.
TransferResult applyDestAdd(BankIndex& dest, const Sample& s, TransferResult gained) {
    return dest.add(s) == AddResult::Collapsed ? TransferResult::Collapsed : gained;
}

} // namespace

TransferResult BankBook::moveSample(const std::string& sampleId,
                                    const std::string& fromBankId,
                                    const std::string& toBankId) {
    Bank* from = bank(fromBankId);
    Bank* to   = bank(toBankId);
    if (from == nullptr || to == nullptr) return TransferResult::RejectedUnknownBank;
    if (fromBankId == toBankId) return TransferResult::RejectedSameBank;

    const Sample* s = from->index.query(sampleId);
    if (s == nullptr) return TransferResult::RejectedSampleAbsent;

    // Copy the sample out before removing it: query returns a pointer into the
    // source vector that remove() invalidates.
    const Sample moved = *s;
    from->index.remove(sampleId);  // source loses the entry unconditionally on a move
    return applyDestAdd(to->index, moved, TransferResult::Moved);
}

TransferResult BankBook::copySample(const std::string& sampleId,
                                    const std::string& fromBankId,
                                    const std::string& toBankId) {
    Bank* from = bank(fromBankId);
    Bank* to   = bank(toBankId);
    if (from == nullptr || to == nullptr) return TransferResult::RejectedUnknownBank;
    if (fromBankId == toBankId) return TransferResult::RejectedSameBank;

    const Sample* s = from->index.query(sampleId);
    if (s == nullptr) return TransferResult::RejectedSampleAbsent;

    const Sample copy = *s;  // source entry is left intact
    return applyDestAdd(to->index, copy, TransferResult::Copied);
}

// ---------------------------------------------------------------------------
// Sample removal (index-only) + the last-reference query
// ---------------------------------------------------------------------------

RemoveResult BankBook::removeSample(const std::string& sampleId,
                                    const std::string& fromBankId,
                                    RemoveScope scope) {
    if (scope == RemoveScope::AllBanks) {
        // Latent seam: purge the id from every bank that holds it. fromBankId is
        // ignored (the id is dropped book-wide). Removed iff at least one drop landed.
        bool any = false;
        for (auto& b : banks_)
            if (b.index.remove(sampleId)) {
                b.slots.remove(sampleId);  // keep SlotMap in sync: leave an empty gap
                any = true;
            }
        return any ? RemoveResult::Removed : RemoveResult::RejectedSampleAbsent;
    }

    // ThisBank (default, the only surfaced verb): drop from the one named source bank.
    Bank* from = bank(fromBankId);
    if (from == nullptr) return RemoveResult::RejectedUnknownBank;
    if (!from->index.remove(sampleId)) return RemoveResult::RejectedSampleAbsent;
    from->slots.remove(sampleId);  // keep SlotMap in sync: the removed sample's slot becomes a gap
    return RemoveResult::Removed;
}

bool BankBook::updateSampleInPlace(const std::string& sampleId, const Sample& updated) {
    for (auto& b : banks_)
        if (b.index.query(sampleId) != nullptr)
            return b.index.updateInPlace(sampleId, updated);
    return false;  // no bank holds the id
}

// ---------------------------------------------------------------------------
// Sample display order (L7) — SlotMap driven, index membership untouched
// ---------------------------------------------------------------------------

namespace {

// The bank's live sample ids in INDEX (insertion) order — the reconcile/migration seed.
std::vector<std::string> indexIds(const BankIndex& idx) {
    std::vector<std::string> ids;
    for (const auto& s : idx.all()) ids.push_back(s.id);
    return ids;
}

// Squares one bank's SlotMap with its index membership. A map with NO overlap with the
// index (the pre-L7 migration case, or a freshly-constructed bank) is seeded dense from
// insertion order; an existing map is reconciled (drop stale markers, append unmapped).
void reconcileBankSlots(Bank& b) {
    const std::vector<std::string> live = indexIds(b.index);
    if (b.slots.empty()) {
        b.slots.resetDense(live);  // migration / first-population default: dense, no gaps
        return;
    }
    b.slots.reconcile(live);  // partial map: keep positions, drop stale, append new
}

}  // namespace

void BankBook::reconcileSlots() {
    for (auto& b : banks_) reconcileBankSlots(b);
}

std::vector<std::string> BankBook::orderedSampleIds(const std::string& bankId) {
    Bank* b = bank(bankId);
    if (b == nullptr) return {};
    reconcileBankSlots(*b);            // ensure the map covers all live members
    return b->slots.orderedIds();
}

bool BankBook::reorderSample(const std::string& id, const std::string& bankId,
                             int targetSlot) {
    Bank* b = bank(bankId);
    if (b == nullptr) return false;
    if (b->index.query(id) == nullptr) return false;  // bank does not hold the sample
    reconcileBankSlots(*b);                            // complete the target space first
    return b->slots.reorder(id, targetSlot);           // gap-preserving; index untouched
}

bool BankBook::replaceSample(const std::string& newId, const std::string& oldId,
                             const std::string& bankId) {
    if (newId == oldId) return false;
    Bank* b = bank(bankId);
    if (b == nullptr) return false;
    // Both the dragged sample and the occupant must live in this bank.
    if (b->index.query(newId) == nullptr) return false;
    if (b->index.query(oldId) == nullptr) return false;

    reconcileBankSlots(*b);  // complete the map so oldId's slot is known

    // Capture the target slot BEFORE any mutation so the position survives the removal.
    const int targetSlot = b->slots.slotOf(oldId);
    if (targetSlot < 0) return false;  // occupant not positioned (shouldn't happen post-reconcile)

    // POOL GUARD (settled): the occupant's index-removal must pass the SAME guard the
    // remove verb applies. Commit the removal FIRST so a rejection is a true no-op (no
    // slot mutation happened yet). removeSample(ThisBank) permits per-sample removal from
    // any bank incl. the pool (per-sample remove is not a pool privilege), so it succeeds
    // whenever the occupant exists — which we verified — but routing through it means a
    // future pool-floor guard added to remove governs replace identically, one rule.
    const RemoveResult r = removeSample(oldId, bankId, RemoveScope::ThisBank);
    if (r != RemoveResult::Removed) return false;  // guard rejected -> nothing changed

    // Occupant gone from the index; now update the slot markers. Drop oldId's now-dangling
    // marker to free the target slot, then move newId onto it. reorder onto an EMPTY slot
    // places newId there exactly and empties newId's own (source) slot — the slot position
    // is preserved and only its occupant changed, exactly the replace contract.
    b->slots.remove(oldId);
    b->slots.reorder(newId, targetSlot);
    return true;
}

bool BankBook::hashReferencedElsewhere(const std::string& hash,
                                       const std::string& exceptBankId) const {
    if (hash.empty()) return false;  // empty hashes never dedup (mirror findByHash)
    for (const auto& b : banks_) {
        if (b.id == exceptBankId) continue;         // the removed-from bank is excluded
        if (b.index.findByHash(hash) != nullptr) return true;
    }
    return false;
}

std::vector<std::string> BankBook::referencedPaths() const {
    // Union across the whole book (pool first, then named banks in ordinal order —
    // banks_ is kept ordinal-sorted). De-duplicate by exact string so a file a copy
    // put in two banks appears once. Skip empty paths (they reference no file).
    std::vector<std::string> paths;
    std::unordered_set<std::string> seen;
    for (const auto& b : banks_) {
        for (const auto& s : b.index.all()) {
            if (s.relativePath.empty()) continue;
            if (seen.insert(s.relativePath).second)
                paths.push_back(s.relativePath);
        }
    }
    return paths;
}

// ===========================================================================
// 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;
}

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 SlotMap::serialize() const {
    // Array of {id, slot} objects in ascending slot order (entries_ is kept sorted).
    std::string out;
    out += '[';
    for (std::size_t i = 0; i < entries_.size(); ++i) {
        if (i) out += ',';
        ObjWriter e(out);
        e.keyStr("id", entries_[i].id);
        e.keyRaw("slot", intToStr(entries_[i].slot));
    }
    out += ']';
    return out;
}

std::string BankBook::serialize() const {
    std::string out;
    {
        ObjWriter root(out);
        root.keyRaw("version", intToStr(1));
        root.keyStr("activeBank", activeBankId_);

        // banks: array of { id, displayName, ordinal, index: <BankIndex blob> }.
        // The pool rides in as bank-zero, persisted identically to any named bank.
        root.keyBegin("banks");
        out += '[';
        for (std::size_t i = 0; i < banks_.size(); ++i) {
            if (i) out += ',';
            ObjWriter b(out);
            b.keyStr("id", banks_[i].id);
            b.keyStr("displayName", banks_[i].displayName);
            b.keyRaw("ordinal", intToStr(banks_[i].ordinal));
            // The nested index is bank_model's own JSON, emitted verbatim so the
            // per-sample shape stays owned by BankIndex::serialize (not duplicated).
            b.keyRaw("index", banks_[i].index.serialize());
            // L7 display positions (gap-preserving). Absent on a pre-L7 blob; the
            // parser defaults such a bank's slots from insertion order on load.
            b.keyRaw("slots", banks_[i].slots.serialize());
        }
        out += ']';
    }  // root closes here (see bank_model note on NRVO + deferred close)
    return out;
}

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

namespace {

class Parser {
public:
    explicit Parser(const std::string& s) : s_(s) {}

    // Parses a book blob into a bank set + active id. On success fills the out-params
    // and returns true. Distinguishes the legacy shape (a bare bank_index object: has
    // "samples", no "banks") from the book shape (has "banks"): a legacy blob yields a
    // single pool bank carrying the migrated index and an empty active id (⇒ pool). The
    // member deserialize() adopts the result (ordinal normalize + active resolve).
    bool parseBook(std::vector<Bank>& banks, std::string& activeBank);

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 parseInt(int& out);
    bool parseKey(std::string& key);
    bool skipValue();
    // Captures the raw source text of one JSON value (object / array / string /
    // scalar) verbatim, so a nested BankIndex blob can be handed to its own parser.
    bool captureValue(std::string& raw);

    bool parseBank(Bank& out);
    // Parses the "slots" array ([{id, slot}, ...]) into (id, slot) pairs. An empty
    // array is valid (an empty bank). Malformed structure fails the whole parse; the
    // pair-level defensive repair (dupes/conflicts) lives in SlotMap::fromEntries.
    bool parseSlots(std::vector<std::pair<std::string, 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;  // unpaired low surrogate
                }
                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::parseInt(int& out) {
    skipWs();
    std::size_t start = pos_;
    if (!eof() && (s_[pos_] == '-' || s_[pos_] == '+')) ++pos_;
    std::size_t digitsStart = pos_;
    while (!eof() && s_[pos_] >= '0' && s_[pos_] <= '9') ++pos_;
    if (pos_ == digitsStart) return false;  // no digits
    long v = 0;
    try {
        v = std::stol(s_.substr(start, pos_ - start));
    } catch (...) {
        return false;  // out of long range → malformed
    }
    if (v < INT_MIN || v > INT_MAX) return false;
    out = static_cast<int>(v);
    return true;
}

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

bool Parser::skipValue() {
    std::string raw;
    return captureValue(raw);
}

// Records the raw source span of one JSON value starting at the current position
// (after whitespace) so it can be re-parsed by a nested parser. Handles nested
// objects/arrays with string-aware brace matching (braces inside strings ignored).
bool Parser::captureValue(std::string& raw) {
    skipWs();
    if (eof()) return false;
    std::size_t start = pos_;
    char c = s_[pos_];
    if (c == '"') {
        std::string tmp;
        if (!parseString(tmp)) return false;
        raw.assign(s_, start, pos_ - start);
        return true;
    }
    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;  // advances past the string
                continue;
            }
            if (d == open) ++depth;
            else if (d == close) --depth;
            ++pos_;
        }
        if (depth != 0) return false;
        raw.assign(s_, start, pos_ - start);
        return true;
    }
    // bare scalar (number / true / false / null)
    while (!eof()) {
        char d = s_[pos_];
        if (d == ',' || d == '}' || d == ']' || d == ' ' || d == '\t' ||
            d == '\n' || d == '\r')
            break;
        ++pos_;
    }
    if (pos_ == start) return false;
    raw.assign(s_, start, pos_ - start);
    return true;
}

bool Parser::parseBank(Bank& b) {
    if (!consume('{')) return false;
    skipWs();
    if (consume('}')) return false;  // a bank object must at least carry an id

    bool haveId = false;
    bool haveIndex = false;
    do {
        std::string key;
        if (!parseKey(key)) return false;

        if (key == "id") {
            if (!parseString(b.id)) return false;
            haveId = true;
        } else if (key == "displayName") {
            if (!parseString(b.displayName)) return false;
        } else if (key == "ordinal") {
            if (!parseInt(b.ordinal)) return false;
        } else if (key == "index") {
            std::string raw;
            if (!captureValue(raw)) return false;
            auto idx = BankIndex::deserialize(raw);
            if (!idx) return false;  // a malformed nested index fails the whole parse
            b.index = std::move(*idx);
            haveIndex = true;
        } else if (key == "slots") {
            // L7 display positions. Absent on a pre-L7 blob (the else-branch skips
            // nothing because the key never appears); when present it drives the
            // bank's SlotMap. reconcileSlots() (post-adopt) squares it with membership.
            std::vector<std::pair<std::string, int>> pairs;
            if (!parseSlots(pairs)) return false;
            b.slots = SlotMap::fromEntries(pairs);
        } else {
            if (!skipValue()) return false;  // forward-compat unknown keys
        }
    } while (consume(','));

    if (!consume('}')) return false;
    if (!haveId || b.id.empty()) return false;   // id keys the registry
    if (!haveIndex) return false;                // every bank persists its index
    return true;
}

bool Parser::parseSlots(std::vector<std::pair<std::string, int>>& out) {
    out.clear();
    if (!consume('[')) return false;
    skipWs();
    if (consume(']')) return true;  // empty slot array — a bank with no positions yet
    do {
        if (!consume('{')) return false;
        std::string id;
        int slot = 0;
        bool haveId = false, haveSlot = false;
        do {
            std::string k;
            if (!parseKey(k)) return false;
            if (k == "id") { if (!parseString(id)) return false; haveId = true; }
            else if (k == "slot") { if (!parseInt(slot)) return false; haveSlot = true; }
            else { if (!skipValue()) return false; }  // forward-compat
        } while (consume(','));
        if (!consume('}')) return false;
        if (!haveId || !haveSlot) return false;  // a slot entry needs both
        out.emplace_back(std::move(id), slot);
    } while (consume(','));
    return consume(']');
}

bool Parser::parseBook(std::vector<Bank>& banks, std::string& activeBank) {
    banks.clear();
    activeBank.clear();
    if (!consume('{')) return false;
    skipWs();
    if (consume('}')) return false;  // an empty object is neither shape → malformed

    // Decide the shape by which structural key we saw. A "banks" key ⇒ book shape; a
    // "samples" key with no "banks" ⇒ legacy shape (promote into the pool).
    std::vector<Bank> parsedBanks;
    bool sawBanks = false;
    bool sawSamples = false;

    do {
        std::string key;
        if (!parseKey(key)) return false;

        if (key == "banks") {
            sawBanks = true;
            if (!consume('[')) return false;
            skipWs();
            if (!consume(']')) {
                do {
                    Bank b;
                    if (!parseBank(b)) return false;
                    parsedBanks.push_back(std::move(b));
                } while (consume(','));
                if (!consume(']')) return false;
            }
        } else if (key == "activeBank") {
            if (!parseString(activeBank)) return false;
        } else if (key == "samples") {
            // Legacy marker. The legacy index is re-parsed from the whole input below
            // (BankIndex::deserialize owns that shape); here we only skip the value to
            // keep the scan well-formed and note that we saw it.
            sawSamples = true;
            if (!skipValue()) return false;
        } else {
            if (!skipValue()) return false;  // version, or unknown
        }
    } while (consume(','));

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

    // --- Legacy migration: a bare bank_index (samples, no banks) → pool. ---
    if (!sawBanks) {
        if (!sawSamples) return false;  // neither shape's marker → malformed
        auto legacy = BankIndex::deserialize(s_);
        if (!legacy) return false;
        Bank pool;
        pool.id = kPoolBankId;
        pool.displayName = kPoolBankName;
        pool.ordinal = 0;
        pool.index = std::move(*legacy);
        banks.push_back(std::move(pool));  // { pool } with zero named banks
        activeBank.clear();                // ⇒ pool (default) after adoption
        return true;
    }

    // --- Book shape: the parsed banks ARE the book (pool folded in). ---
    // The pool must be present as bank-zero (serialize always emits it). Reject a
    // book blob that omits it rather than silently re-seeding — a book without its
    // pool is malformed, not a legacy blob.
    bool hasPool = std::any_of(parsedBanks.begin(), parsedBanks.end(),
                               [](const Bank& b) { return b.isPool(); });
    if (!hasPool) return false;

    // Reject duplicate bank ids (ids key the registry; a dup would corrupt lookup).
    for (std::size_t i = 0; i < parsedBanks.size(); ++i)
        for (std::size_t j = i + 1; j < parsedBanks.size(); ++j)
            if (parsedBanks[i].id == parsedBanks[j].id) return false;

    // Force the pool's fixed display name — it is not user-mutable, so we do not
    // trust a persisted override for it (keeps kPoolBankName authoritative).
    for (auto& b : parsedBanks)
        if (b.isPool()) b.displayName = kPoolBankName;

    // --- Coalesce duplicate folded display names (B4 re-review fold-in). --------
    // The in-model create/rename path enforces unique display names under nameKey,
    // but a hand-edited .rpp blob can smuggle in two banks whose names fold to the
    // same key ("Drums" and " drums "). Rejecting the whole book over one collision
    // would degrade the user's entire library to empty, so instead we AUTO-
    // DISAMBIGUATE the later duplicate deterministically: scan in parse order, and
    // the first time a folded key repeats, suffix that bank's display name (" 2",
    // " 3", …) until its folded key is unique among all names seen so far. The FIRST
    // bank to carry a key keeps its name verbatim; only subsequent collisions are
    // renamed. No bank or sample is lost, and ids are untouched. The pool is included
    // in the seen-set (its "Pool" key is reserved) so a named bank folding to "pool"
    // is disambiguated away from it, never the reverse.
    {
        std::vector<std::string> seenKeys;
        seenKeys.reserve(parsedBanks.size());
        for (auto& b : parsedBanks) {
            if (b.isPool()) {                 // pool's name is fixed; reserve its key
                seenKeys.push_back(nameKey(b.displayName));
                continue;
            }
            const auto taken = [&](const std::string& k) {
                return std::find(seenKeys.begin(), seenKeys.end(), k) != seenKeys.end();
            };
            std::string key = nameKey(b.displayName);
            if (taken(key)) {
                // Suffix with an ascending integer until the folded key is free. Guard
                // against a pathological blob whose base name already ends in a number
                // by folding the candidate each attempt (nameKey normalizes it).
                const std::string base = b.displayName;
                for (int n = 2;; ++n) {
                    const std::string candidate = base + " " + std::to_string(n);
                    const std::string candKey = nameKey(candidate);
                    if (!taken(candKey)) {
                        b.displayName = candidate;
                        key = candKey;
                        break;
                    }
                }
            }
            seenKeys.push_back(key);
        }
    }

    banks = std::move(parsedBanks);
    return true;
}

} // namespace

void BankBook::adoptBanks(std::vector<Bank>&& banks, const std::string& activeBank) {
    banks_ = std::move(banks);
    normalizeOrdinals();
    // Resolve the active bank defensively: fall back to the pool if the persisted id
    // names no bank, so a corrupt active id never leaves a dangling capture target.
    activeBankId_ = (bank(activeBank) != nullptr) ? activeBank : std::string(kPoolBankId);
}

std::optional<BankBook> BankBook::deserialize(const std::string& json) {
    std::vector<Bank> banks;
    std::string activeBank;
    Parser p(json);
    if (!p.parseBook(banks, activeBank)) return std::nullopt;

    BankBook book;
    book.adoptBanks(std::move(banks), activeBank);
    return book;
}

// ---------------------------------------------------------------------------
// Active-bank cycle ordering (pure, free function — mirror of nextModeId)
// ---------------------------------------------------------------------------

std::string nextBankId(const std::vector<std::string>& orderedBankIds,
                       const std::string& currentBankId) {
    if (orderedBankIds.empty()) return {};  // nothing to cycle to
    for (std::size_t i = 0; i < orderedBankIds.size(); ++i) {
        if (orderedBankIds[i] == currentBankId)
            return orderedBankIds[(i + 1) % orderedBankIds.size()];  // wrap past the last
    }
    // Active id not in the list (stale/unknown) — jump to the first id as a sane
    // home rather than returning "" (matches nextModeId's fallback).
    return orderedBankIds.front();
}

BankBook BankBook::loadFromPersisted(const std::string& banksJson,
                                     const std::string& legacyJson) {
    // Precedence 1: the authoritative `banks` blob. A present-but-malformed blob is
    // an error, not an absence — degrade to an empty book rather than falling through
    // to a stale legacy key (which would resurrect superseded single-bank state).
    if (!banksJson.empty()) {
        auto book = deserialize(banksJson);
        return book ? std::move(*book) : BankBook{};
    }
    // Precedence 2: no `banks` yet, but a legacy `bank_index` — one-way pool migration
    // (deserialize's parse-time legacy path promotes it into the pool). A malformed
    // legacy blob likewise degrades to empty.
    if (!legacyJson.empty()) {
        auto book = deserialize(legacyJson);
        return book ? std::move(*book) : BankBook{};
    }
    // Precedence 3: a brand-new / never-captured project — a fresh empty book.
    return BankBook{};
}

} // namespace reasampler
