#include "bank_model.h"

#include <cctype>
#include <cerrno>
#include <cstdio>
#include <cstdlib>
#include <cstring>

// bank_model implementation.
//
// JSON is hand-rolled and self-contained (brief: keep the pure core
// dependency-free — no third-party JSON lib, no WDL coupling). The field set is
// a flat struct of primitives, strings, one enum, a small string array, and a
// few optionals, so a compact writer + recursive-descent parser is the simplest
// thing that works. Doubles are emitted with 17 significant digits (%.17g), the
// shortest form that round-trips every IEEE-754 double exactly, so the
// deserialize(serialize(x)) == x invariant holds bit-for-bit.

namespace reasampler {

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

bool SourceRange::operator==(const SourceRange& o) const {
    return startSeconds == o.startSeconds && endSeconds == o.endSeconds &&
           startPpq == o.startPpq && endPpq == o.endPpq;
}

bool Provenance::operator==(const Provenance& o) const {
    return parentSampleId == o.parentSampleId && fxChainSnapshot == o.fxChainSnapshot;
}

bool Levels::operator==(const Levels& o) const {
    return peakDb == o.peakDb && rmsDb == o.rmsDb && lufs == o.lufs;
}

bool LoopPoints::operator==(const LoopPoints& o) const {
    return start == o.start && end == o.end;
}

bool Sample::operator==(const Sample& o) const {
    return id == o.id && displayName == o.displayName && relativePath == o.relativePath &&
           sourceMode == o.sourceMode && sourceRange == o.sourceRange &&
           trackGuids == o.trackGuids && wetDry == o.wetDry &&
           channelCount == o.channelCount && sampleRate == o.sampleRate &&
           lengthSeconds == o.lengthSeconds && lengthBeats == o.lengthBeats &&
           captureTempo == o.captureTempo &&
           captureTimeSigNum == o.captureTimeSigNum &&
           captureTimeSigDenom == o.captureTimeSigDenom && key == o.key &&
           rootNote == o.rootNote && loop == o.loop && levels == o.levels &&
           clipped == o.clipped && tier == o.tier && contentHash == o.contentHash &&
           provenance == o.provenance && createdTimestamp == o.createdTimestamp;
}

// ---------------------------------------------------------------------------
// path invariant
// ---------------------------------------------------------------------------

// DECISION: reject absolute paths rather than normalize them. The pure model has
// no knowledge of the project root, so it cannot correctly relativize an absolute
// path — any "normalization" would be a guess that could point at the wrong file.
// Rejecting at the boundary is honest and deterministic; the capture backend (M3)
// is responsible for handing us an already-relative path. Covers POSIX ("/x"),
// Windows drive ("C:\x", "C:/x", "C:foo" drive-relative), and UNC ("\\host\share")
// forms. Any leading <alpha>: is rejected regardless of the character that follows —
// drive-relative paths ("C:foo.wav") resolve against the drive's current directory,
// not the project root, so they violate the relative-paths-only invariant just as
// much as "C:\foo.wav" does.
static bool isAbsolutePath(const std::string& p) {
    if (p.empty()) return false;
    if (p[0] == '/' || p[0] == '\\') return true;          // POSIX root or UNC
    if (p.size() >= 2 && std::isalpha(static_cast<unsigned char>(p[0])) && p[1] == ':')
        return true;                                        // Windows drive (C:\, C:/, C:foo, C:)
    return false;
}

// ---------------------------------------------------------------------------
// BankIndex
// ---------------------------------------------------------------------------

AddResult BankIndex::add(const Sample& sample) {
    if (sample.id.empty()) return AddResult::RejectedEmptyId;
    if (isAbsolutePath(sample.relativePath)) return AddResult::RejectedAbsolutePath;

    if (findByHash(sample.contentHash) != nullptr)
        return AddResult::Collapsed;

    samples_.push_back(sample);
    return AddResult::Added;
}

bool BankIndex::remove(const std::string& id) {
    for (auto it = samples_.begin(); it != samples_.end(); ++it) {
        if (it->id == id) {
            samples_.erase(it);
            return true;
        }
    }
    return false;
}

bool BankIndex::updateInPlace(const std::string& id, const Sample& updated) {
    if (isAbsolutePath(updated.relativePath)) return false;  // invariant still holds
    for (auto& s : samples_) {
        if (s.id == id) {
            s = updated;  // replace in place — position (insertion order) preserved
            return true;
        }
    }
    return false;
}

const Sample* BankIndex::query(const std::string& id) const {
    for (const auto& s : samples_)
        if (s.id == id) return &s;
    return nullptr;
}

const Sample* BankIndex::findByHash(const std::string& contentHash) const {
    if (contentHash.empty()) return nullptr;  // empty hashes never dedup
    for (const auto& s : samples_)
        if (s.contentHash == contentHash) return &s;
    return nullptr;
}

bool BankIndex::moveTier(const std::string& id, Tier tier) {
    for (auto& s : samples_) {
        if (s.id == id) {
            s.tier = tier;
            return true;
        }
    }
    return false;
}

std::vector<Sample> BankIndex::byTier(Tier tier) const {
    std::vector<Sample> out;
    for (const auto& s : samples_)
        if (s.tier == tier) out.push_back(s);
    return out;
}

// ---------------------------------------------------------------------------
// 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 numToStr(double v) {
    char buf[32];
    std::snprintf(buf, sizeof(buf), "%.17g", v);
    return buf;
}

std::string numToStr(std::int64_t v) {
    char buf[32];
    std::snprintf(buf, sizeof(buf), "%lld", static_cast<long long>(v));
    return buf;
}

std::string numToStr(int v) { return numToStr(static_cast<std::int64_t>(v)); }

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);
    }
    // Begin a nested value; caller writes the value immediately after.
    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;
};

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

void writeSample(std::string& out, const Sample& s) {
    ObjWriter w(out);
    w.keyStr("id", s.id);
    w.keyStr("displayName", s.displayName);
    w.keyStr("relativePath", s.relativePath);
    w.keyRaw("sourceMode", numToStr(static_cast<int>(s.sourceMode)));

    w.keyBegin("sourceRange");
    {
        ObjWriter r(out);
        r.keyRaw("startSeconds", numToStr(s.sourceRange.startSeconds));
        r.keyRaw("endSeconds", numToStr(s.sourceRange.endSeconds));
        r.keyRaw("startPpq", numToStr(s.sourceRange.startPpq));
        r.keyRaw("endPpq", numToStr(s.sourceRange.endPpq));
    }

    w.keyBegin("trackGuids");
    writeStringArray(out, s.trackGuids);

    w.keyRaw("wetDry", numToStr(s.wetDry));
    w.keyRaw("channelCount", numToStr(s.channelCount));
    w.keyRaw("sampleRate", numToStr(s.sampleRate));
    w.keyRaw("lengthSeconds", numToStr(s.lengthSeconds));
    w.keyRaw("lengthBeats", numToStr(s.lengthBeats));
    w.keyRaw("captureTempo", numToStr(s.captureTempo));
    w.keyRaw("captureTimeSigNum", numToStr(s.captureTimeSigNum));
    w.keyRaw("captureTimeSigDenom", numToStr(s.captureTimeSigDenom));

    // Optionals are emitted as null when absent so present/absent round-trips.
    w.keyBegin("key");
    if (s.key) writeEscaped(out, *s.key); else out += "null";

    // Phase S seam fields (D-B). Emitted as null when absent (same shape as `key`
    // and `provenance`) so pre-Phase-S JSON — which lacks these keys entirely —
    // parses to empty optionals and re-serializes without invention.
    w.keyBegin("rootNote");
    if (s.rootNote) out += numToStr(*s.rootNote); else out += "null";

    w.keyBegin("loop");
    if (s.loop) {
        ObjWriter lp(out);
        lp.keyRaw("start", numToStr(s.loop->start));
        lp.keyRaw("end", numToStr(s.loop->end));
    } else {
        out += "null";
    }

    w.keyBegin("levels");
    {
        ObjWriter l(out);
        l.keyRaw("peakDb", numToStr(s.levels.peakDb));
        l.keyRaw("rmsDb", numToStr(s.levels.rmsDb));
        l.keyRaw("lufs", numToStr(s.levels.lufs));
    }

    w.keyRaw("clipped", s.clipped ? "true" : "false");
    w.keyRaw("tier", numToStr(static_cast<int>(s.tier)));
    w.keyStr("contentHash", s.contentHash);

    w.keyBegin("provenance");
    if (s.provenance) {
        ObjWriter p(out);
        p.keyStr("parentSampleId", s.provenance->parentSampleId);
        p.keyStr("fxChainSnapshot", s.provenance->fxChainSnapshot);
    } else {
        out += "null";
    }

    w.keyRaw("createdTimestamp", numToStr(s.createdTimestamp));
}

} // namespace

std::string BankIndex::serialize() const {
    std::string out;
    {
        ObjWriter root(out);
        root.keyRaw("version", numToStr(1));
        root.keyBegin("samples");
        out += '[';
        for (std::size_t i = 0; i < samples_.size(); ++i) {
            if (i) out += ',';
            writeSample(out, samples_[i]);
        }
        out += ']';
    }  // root closes the object here — not deferred to function return (NRVO would
       // otherwise let the caller observe `out` before the closing brace is appended)
    return out;
}

// ---------------------------------------------------------------------------
// JSON parser (recursive descent). Returns false on any malformed input; never
// reads out of bounds. Only supports the subset our writer emits.
// ---------------------------------------------------------------------------

namespace {

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

    bool parseIndex(BankIndex& out);

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

    bool eof() const { return pos_ >= s_.size(); }
    char peek() const { return s_[pos_]; }

    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);  // number / true / false / null token
    bool parseDouble(double& out);
    bool parseInt64(std::int64_t& out);
    bool parseInt(int& out);
    bool parseBool(bool& out);
    bool expectNullOr(bool& wasNull);       // peeks for `null`; consumes if present

    bool parseSample(Sample& out);
    bool parseKey(std::string& key);        // an object member key + ':'
    bool skipValue();                        // for forward-compat unknown keys
};

// Parses a JSON string literal (with the escapes our writer emits, plus \uXXXX
// for control chars). Positioned at the opening quote after whitespace.
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': {
                // Decode a \uXXXX escape to its code point.
                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) {
                    // High surrogate — must be followed by \uDC00–\uDFFF.
                    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;  // unpaired high surrogate
                    codePoint = 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00);
                } else if (hi >= 0xDC00 && hi <= 0xDFFF) {
                    return false;  // unpaired low surrogate — malformed
                }

                // Encode codePoint as UTF-8.
                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 string
}

// Reads a bare token (number, true, false, null) up to the next structural char.
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::parseDouble(double& out) {
    std::string tok;
    if (!parseRawScalar(tok)) return false;
    const char* b = tok.c_str();
    char* end = nullptr;
    errno = 0;
    double v = std::strtod(b, &end);
    if (end != b + tok.size()) return false;
    if (errno == ERANGE) return false;  // overflow / underflow → malformed
    out = v;
    return true;
}

bool Parser::parseInt64(std::int64_t& 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;  // overflow → malformed
    out = static_cast<std::int64_t>(v);
    return true;
}

bool Parser::parseInt(int& out) {
    std::int64_t v = 0;
    if (!parseInt64(v)) 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;
}

// If the next value is the `null` token, consumes it and sets wasNull=true.
// Otherwise leaves the position untouched and sets wasNull=false. Returns false
// only on eof.
bool Parser::expectNullOr(bool& wasNull) {
    skipWs();
    if (eof()) return false;
    if (s_.compare(pos_, 4, "null") == 0) {
        pos_ += 4;
        wasNull = true;
    } else {
        wasNull = false;
    }
    return true;
}

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

// Skips one JSON value (object / array / string / scalar) for forward-compat
// with keys we don't recognize. Assumes position is at the start of the value.
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::parseSample(Sample& s) {
    if (!consume('{')) return false;
    skipWs();
    if (consume('}')) return true;  // empty object (shouldn't happen, but valid)

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

        if (key == "id") {
            if (!parseString(s.id)) return false;
        } else if (key == "displayName") {
            if (!parseString(s.displayName)) return false;
        } else if (key == "relativePath") {
            if (!parseString(s.relativePath)) return false;
        } else if (key == "sourceMode") {
            int v = 0;
            if (!parseInt(v)) return false;
            // Valid range: MasterMix(0) .. Realtime(5).
            if (v < static_cast<int>(SourceMode::MasterMix) ||
                v > static_cast<int>(SourceMode::Realtime))
                return false;
            s.sourceMode = static_cast<SourceMode>(v);
        } else if (key == "sourceRange") {
            if (!consume('{')) return false;
            do {
                std::string rk;
                if (!parseKey(rk)) return false;
                double dv = 0.0;
                if (!parseDouble(dv)) return false;
                if (rk == "startSeconds") s.sourceRange.startSeconds = dv;
                else if (rk == "endSeconds") s.sourceRange.endSeconds = dv;
                else if (rk == "startPpq") s.sourceRange.startPpq = dv;
                else if (rk == "endPpq") s.sourceRange.endPpq = dv;
            } while (consume(','));
            if (!consume('}')) return false;
        } else if (key == "trackGuids") {
            if (!consume('[')) return false;
            skipWs();
            if (!consume(']')) {
                do {
                    std::string g;
                    if (!parseString(g)) return false;
                    s.trackGuids.push_back(g);
                } while (consume(','));
                if (!consume(']')) return false;
            }
        } else if (key == "wetDry") {
            if (!parseDouble(s.wetDry)) return false;
        } else if (key == "channelCount") {
            if (!parseInt(s.channelCount)) return false;
        } else if (key == "sampleRate") {
            if (!parseInt(s.sampleRate)) return false;
        } else if (key == "lengthSeconds") {
            if (!parseDouble(s.lengthSeconds)) return false;
        } else if (key == "lengthBeats") {
            if (!parseDouble(s.lengthBeats)) return false;
        } else if (key == "captureTempo") {
            if (!parseDouble(s.captureTempo)) return false;
        } else if (key == "captureTimeSigNum") {
            if (!parseInt(s.captureTimeSigNum)) return false;
        } else if (key == "captureTimeSigDenom") {
            if (!parseInt(s.captureTimeSigDenom)) return false;
        } else if (key == "key") {
            bool wasNull = false;
            if (!expectNullOr(wasNull)) return false;
            if (wasNull) {
                s.key.reset();
            } else {
                std::string k;
                if (!parseString(k)) return false;
                s.key = k;
            }
        } else if (key == "rootNote") {
            bool wasNull = false;
            if (!expectNullOr(wasNull)) return false;
            if (wasNull) {
                s.rootNote.reset();
            } else {
                int v = 0;
                if (!parseInt(v)) return false;
                // Valid MIDI note range: 0..127 inclusive (boundaries valid).
                if (v < 0 || v > 127) return false;
                s.rootNote = v;
            }
        } else if (key == "loop") {
            bool wasNull = false;
            if (!expectNullOr(wasNull)) return false;
            if (wasNull) {
                s.loop.reset();
            } else {
                if (!consume('{')) return false;
                LoopPoints lp;
                do {
                    std::string lk;
                    if (!parseKey(lk)) return false;
                    std::int64_t lv = 0;
                    if (!parseInt64(lv)) return false;
                    if (lk == "start") lp.start = lv;
                    else if (lk == "end") lp.end = lv;
                } while (consume(','));
                if (!consume('}')) return false;
                // Invariant: 0 <= start <= end. start == end is a valid zero-length
                // marker; a negative index or start > end is malformed, not silently
                // clamped (mirrors the enum-range rejection above).
                if (lp.start < 0 || lp.end < lp.start) return false;
                s.loop = lp;
            }
        } else if (key == "levels") {
            if (!consume('{')) return false;
            do {
                std::string lk;
                if (!parseKey(lk)) return false;
                double dv = 0.0;
                if (!parseDouble(dv)) return false;
                if (lk == "peakDb") s.levels.peakDb = dv;
                else if (lk == "rmsDb") s.levels.rmsDb = dv;
                else if (lk == "lufs") s.levels.lufs = dv;
            } while (consume(','));
            if (!consume('}')) return false;
        } else if (key == "clipped") {
            if (!parseBool(s.clipped)) return false;
        } else if (key == "tier") {
            int v = 0;
            if (!parseInt(v)) return false;
            // Valid range: Scratch(0) .. Archive(1).
            if (v < static_cast<int>(Tier::Scratch) ||
                v > static_cast<int>(Tier::Archive))
                return false;
            s.tier = static_cast<Tier>(v);
        } else if (key == "contentHash") {
            if (!parseString(s.contentHash)) return false;
        } else if (key == "provenance") {
            bool wasNull = false;
            if (!expectNullOr(wasNull)) return false;
            if (wasNull) {
                s.provenance.reset();
            } else {
                if (!consume('{')) return false;
                Provenance p;
                do {
                    std::string pk;
                    if (!parseKey(pk)) return false;
                    std::string pv;
                    if (!parseString(pv)) return false;
                    if (pk == "parentSampleId") p.parentSampleId = pv;
                    else if (pk == "fxChainSnapshot") p.fxChainSnapshot = pv;
                } while (consume(','));
                if (!consume('}')) return false;
                s.provenance = p;
            }
        } else if (key == "createdTimestamp") {
            if (!parseInt64(s.createdTimestamp)) return false;
        } else {
            if (!skipValue()) return false;  // forward-compat: ignore unknown
        }
    } while (consume(','));

    return consume('}');
}

bool Parser::parseIndex(BankIndex& out) {
    if (!consume('{')) return false;
    skipWs();
    if (consume('}')) return true;  // empty object — vacuously an empty index

    std::vector<Sample> parsed;
    do {
        std::string key;
        if (!parseKey(key)) return false;

        if (key == "samples") {
            if (!consume('[')) return false;
            skipWs();
            if (!consume(']')) {
                do {
                    Sample s;
                    if (!parseSample(s)) return false;
                    parsed.push_back(std::move(s));
                } while (consume(','));
                if (!consume(']')) return false;
            }
        } else {
            if (!skipValue()) return false;  // version, or unknown keys
        }
    } while (consume(','));

    if (!consume('}')) return false;

    // Trailing garbage after the root object is malformed.
    skipWs();
    if (!eof()) return false;

    // Rebuild via add() so the same invariants (relative-path, dedup) that guard
    // live inserts also guard deserialized data. Rejected/collapsed entries are
    // dropped silently — a well-formed serialized index never triggers them.
    for (auto& s : parsed) out.add(s);
    return true;
}

} // namespace

std::optional<BankIndex> BankIndex::deserialize(const std::string& json) {
    BankIndex idx;
    Parser p(json);
    if (!p.parseIndex(idx)) return std::nullopt;
    return idx;
}

} // namespace reasampler
