#pragma once
// bank_book — the pure core of the multi-bank phase (Phase B), deliberately free
// of any REAPER type so it compiles and unit-tests OUTSIDE the DAW. It is the
// third instance of the same "pure registry + JSON round-trip, unit-tested outside
// the DAW" pattern as bank_model and view_mode_model.
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes. Standard library only.
//
// -- What it is --------------------------------------------------------------
//
// An ordered registry of banks. Each bank = { stable id, display name, ordinal,
// BankIndex }. The book WRAPS N BankIndex instances — bank_model / BankIndex are
// UNTOUCHED (additive: no bankId on Sample). Movement of samples between banks is
// index-only (remove from source's BankIndex, add to destination's); files never
// relocate — banks are logical groupings over one shared file pool.
//
// -- The pool (privileged, not special-cased) --------------------------------
//
// Structurally the pool is bank-zero — one Bank among many, seeded on construction
// with a fixed id (kPoolBankId) and fixed display name (kPoolBankName), ordinal 0.
// Semantically it is privileged, and the privileges are enforced HERE in the pure
// rules layer (CONTEXT.md §Multi-bank guardrail — not deferred to a shell):
//   * always exists (seeded on construction; the book never reaches zero banks)
//   * un-deletable (deleteBank rejects the pool)
//   * un-renamable (renameBank rejects the pool)
//   * un-evacuable (evacuate rejects the pool — the pool is evacuation's
//     destination, not a source)
//
// -- Id minting is the CALLER'S job (design decision) ------------------------
//
// createBank takes a caller-supplied stable id, mirroring bank_model's "id
// assigned by the caller" and view_mode_model's mode ids. The pure core has no
// REAPER genGuid / RNG and deliberately introduces none: a fake in-model id source
// would not be a real GUID anyway, and keeping ids caller-supplied lets the B2
// shell mint a genuine REAPER GUID while the model stays pure and deterministically
// testable. The model still enforces the invariants: non-empty, unique, not the
// reserved pool id.

#include <optional>
#include <string>
#include <utility>
#include <vector>

#include "bank_model.h"

namespace reasampler {

// The pool's fixed identity. The id is reserved: createBank rejects it, and the
// pool is always bank-zero. The name is fixed: renameBank rejects the pool.
inline constexpr const char* kPoolBankId   = "pool";
inline constexpr const char* kPoolBankName = "Pool";

// SlotMap — the L7 gap-preserving display-position carrier for ONE bank (F2 settled:
// plain interchangeable slots, NOT M9 fixed/addressable slots). A slot is just a
// display position a sample id occupies; the map is sample id -> slot (>= 0). Gaps
// are first-class: a bank may have a sample at slot 1 with slot 0 empty (an empty
// first row above an occupied second row). At most one id per slot (a slot is never
// double-occupied) and at most one slot per id (an id sits in exactly one place).
//
// Position lives HERE, not on Sample (CLAUDE.md wrapping discipline): a copy of one
// sample into two banks may sit at different slots, so position is a per-bank display
// concern owned by the bank's membership. bank_model / Sample stay untouched.
//
// PURE: standard library only. Hard-tested to the bar of BankIndex's round-trip.
class SlotMap {
public:
    // The slot an id occupies, or -1 if the id is not mapped. O(N).
    int slotOf(const std::string& id) const;

    // The id occupying `slot`, or "" if the slot is empty. O(N).
    std::string idAt(int slot) const;

    // The highest occupied slot, or -1 when the map is empty. Defines the append
    // frontier and (with trailing-empty trim) the content extent.
    int maxSlot() const;

    // Ids in ASCENDING slot order (the deterministic display order). Empty slots
    // produce no entry — the caller iterates occupants; sparse layout is a draw
    // concern that reads slotOf/idAt, not this list.
    std::vector<std::string> orderedIds() const;

    // Places `id` at the next free slot after the last occupied one (append). If the
    // id is already mapped it is first removed (leaving its old slot empty), then
    // appended — an append never fills an earlier gap. No-op guard: empty id ignored.
    void append(const std::string& id);

    // Drops `id`'s mapping, LEAVING ITS SLOT EMPTY (no re-pack) so every other id
    // keeps its position. Returns true if the id was mapped.
    bool remove(const std::string& id);

    // Moves `id` to `targetSlot`, gap-preserving (F3 reorder semantics):
    //   * target slot EMPTY  -> `id` moves there; its old slot is left empty.
    //   * target slot OCCUPIED -> insert-before-and-shift: `id` takes targetSlot and
    //     every occupant at slot >= targetSlot (except `id` itself) shifts up by one,
    //     preserving their relative order and never colliding. Matches file-manager
    //     reorder. Interior gaps between shifted occupants are preserved as-is
    //     (shift is +1 on each occupant, so the gap structure above the target is kept).
    //   * negative targetSlot is clamped to 0.
    // Returns false (no mutation) if `id` is not mapped. Deterministic.
    bool reorder(const std::string& id, int targetSlot);

    // Rebuilds the map densely from `ids` in the given order (slot i = ids[i]),
    // dropping any prior state. The migration path: a pre-L7 bank with no persisted
    // slot data is seeded from its BankIndex insertion order, densely packed (no gaps),
    // so it is visually identical on first post-L7 load. Empty/duplicate ids skipped.
    void resetDense(const std::vector<std::string>& ids);

    // Drops any mapping whose id is NOT in `liveIds` (a stale marker whose sample left
    // the index) and appends any live id that has NO mapping yet (a sample the index
    // gained out-of-band). Slots of surviving ids are untouched (gaps preserved). Keeps
    // the map consistent with the bank's membership without a re-pack. Deterministic:
    // orphan appends follow `liveIds` order.
    void reconcile(const std::vector<std::string>& liveIds);

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

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

    // JSON fragment (an array of {id, slot} objects, ascending slot). Emitted as the
    // bank envelope's "slots" member by BankBook::serialize; parsed back by its parser.
    // Round-trips losslessly with the rest of the bank.
    std::string serialize() const;

    // Builds a map from explicit (id, slot) pairs parsed from persisted JSON. Enforces
    // the map invariants defensively against a hand-edited blob: a duplicate id keeps
    // its FIRST occurrence; a slot already taken by a kept id drops the later pair
    // (never double-occupies); an empty id or negative slot is dropped. The result is
    // sorted ascending by slot. reconcile() against live membership runs afterward, so
    // a lossy repair here degrades gracefully rather than corrupting lookup.
    static SlotMap fromEntries(const std::vector<std::pair<std::string, int>>& pairs);

private:
    struct Entry {
        std::string id;
        int         slot = 0;
        bool operator==(const Entry& o) const { return id == o.id && slot == o.slot; }
    };
    std::vector<Entry> entries_;  // kept sorted ascending by slot (invariant)

    void sortBySlot();
};

// One bank: a stable id, a display name, an ordinal (tab/display order), and its
// own BankIndex. The pool is the bank whose id == kPoolBankId.
struct Bank {
    std::string id;           // stable, persisted; the pool's is kPoolBankId
    std::string displayName;  // mutable for named banks; fixed "Pool" for the pool
    int         ordinal = 0;  // display order; pool is 0, named banks 1..N
    BankIndex   index;        // this bank's samples
    SlotMap     slots;        // L7 display positions of this bank's samples (gap-preserving)

    bool isPool() const { return id == kPoolBankId; }

    bool operator==(const Bank& o) const {
        return id == o.id && displayName == o.displayName &&
               ordinal == o.ordinal && index == o.index && slots == o.slots;
    }
};

// Outcome of a cross-bank sample move/copy. Mirrors AddResult's honesty: the op
// reports what happened rather than silently mutating on a bad request.
//  - Moved / Copied:  the sample was transferred to the destination as a new entry.
//  - Collapsed:       the destination already held the hash; it collapsed onto the
//                     existing entry (a no-op add on the destination side). For a
//                     MOVE the source entry is STILL removed; for a COPY the source
//                     entry is (as always) retained.
//  - RejectedUnknownBank:   a source or destination id named no bank.
//  - RejectedSampleAbsent:  the sample id was not in the source bank.
//  - RejectedSameBank:      source and destination were the same bank (no-op).
enum class TransferResult {
    Moved,
    Copied,
    Collapsed,
    RejectedUnknownBank,
    RejectedSampleAbsent,
    RejectedSameBank,
};

// Scope of a sample-remove (fork R-A, settled 2026-07-24). ThisBank is the default
// and the ONLY behavior surfaced in the UI/action layer; AllBanks is a latent seam —
// live and tested at the model level, promotable later behind this parameter without
// a rewrite, but never wired to an affordance in B5.
//  - ThisBank: drop the entry from the one named source bank only. A same-hash entry
//              in another bank survives (no cross-bank cascade — dedup is per-bank).
//  - AllBanks: drop the sample's entry from EVERY bank that holds the source id
//              ("purge from the library"). Latent; unsurfaced.
enum class RemoveScope {
    ThisBank,
    AllBanks,
};

// Outcome of BankBook::removeSample. Mirrors TransferResult's honesty: the op reports
// what happened rather than silently mutating on a bad request.
//  - Removed:              at least one index entry was dropped.
//  - RejectedUnknownBank:  the source bank id named no bank (ThisBank scope only).
//  - RejectedSampleAbsent: the sample id was in no bank in scope (nothing removed).
enum class RemoveResult {
    Removed,
    RejectedUnknownBank,
    RejectedSampleAbsent,
};

// An ordered registry of banks with the pool seeded as bank-zero, per-bank sample
// indices, an active-bank pointer, and lossless JSON round-trip. The heart of the
// multi-bank phase — mirror of bank_model / view_mode_model.
class BankBook {
public:
    BankBook();  // seeds the pool (id kPoolBankId, name kPoolBankName, ordinal 0);
                 // active bank = pool; zero named banks.

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

    // Creates a named bank with the caller-supplied stable id and display name,
    // assigning the next ordinal. Rejects (returns false, no mutation) an empty id,
    // a duplicate id, the reserved pool id, or a display name that duplicates an
    // existing bank's name (including the pool's "Pool"). Display-name uniqueness is
    // trimmed + case-insensitive (ASCII): "Drums", "drums", and " Drums " collide.
    bool createBank(const std::string& id, const std::string& displayName);

    // Renames a named bank. Rejects (false, no mutation) an unknown id, the pool, or a
    // target name already used by a DIFFERENT bank (trimmed + case-insensitive, as
    // createBank). Renaming a bank to its own current name is a no-op success.
    bool renameBank(const std::string& id, const std::string& displayName);

    // Deletes a NAMED bank, removing it (and its member index entries) from the
    // registry. Files are a shell/prune concern and are NOT touched here. Rejects
    // (false, no mutation) an unknown id or the pool. Remaining banks' ordinals are
    // compacted so the pool stays 0 and named banks stay contiguous 1..N. If the
    // deleted bank was active, the active bank falls back to the pool.
    bool deleteBank(const std::string& id);

    // Reorders a NAMED bank to `newOrdinal` (clamped into the named-bank range),
    // shifting the others to keep ordinals contiguous. The pool is pinned at 0 and
    // cannot be reordered. Rejects (false, no mutation) an unknown id or the pool.
    bool reorderBank(const std::string& id, int newOrdinal);

    // Moves EVERY member of a named bank into the pool (index-only, observing the
    // same destination-collapse-by-hash as a move), leaving the bank empty. Rejects
    // (false, no mutation) an unknown id or the pool (the pool is the destination,
    // never a source). Returns true on success even if the bank was already empty.
    bool evacuate(const std::string& id);

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

    // The active bank's id (the capture target). Defaults to the pool.
    const std::string& activeBankId() const { return activeBankId_; }

    // Sets the active bank. Rejects (returns false, no change) an id that names no
    // bank — an invalid set never corrupts state.
    bool setActiveBank(const std::string& id);

    // The active bank's BankIndex — the index the capture layer adds to. Always
    // valid (the active id always names a live bank; it falls back to the pool).
    BankIndex&       activeIndex();
    const BankIndex& activeIndex() const;

    // -- Sample movement (index-only; files never relocate) ------------------

    // Moves a sample by id from `fromBankId` to `toBankId`: removes it from the
    // source index and adds it to the destination (observing destination
    // collapse-by-hash). See TransferResult for the full outcome set.
    TransferResult moveSample(const std::string& sampleId,
                              const std::string& fromBankId,
                              const std::string& toBankId);

    // Copies a sample by id from `fromBankId` to `toBankId`: the source entry is
    // retained, the destination gains it (observing destination collapse-by-hash).
    // Same hash may then live in both banks — cross-bank dedup is NOT enforced.
    TransferResult copySample(const std::string& sampleId,
                              const std::string& fromBankId,
                              const std::string& toBankId);

    // -- Sample removal (index-only; the file is NEVER touched — orphaned until prune) --

    // Drops a sample's index entry (the sample-level sibling of move/copy/evacuate).
    // Index-only and non-destructive to the file: a last-reference remove leaves the
    // file on disk, orphaned until Phase R prune — remove NEVER deletes bytes.
    //
    // Scope (fork R-A): ThisBank (default, the only surfaced verb) drops the entry from
    // `fromBankId` alone; AllBanks (latent seam) drops the sample id from every bank
    // that holds it. See RemoveResult for the outcome set.
    //   * ThisBank:  RejectedUnknownBank if `fromBankId` names no bank; RejectedSampleAbsent
    //                if that bank does not hold the id; Removed on a drop.
    //   * AllBanks:  `fromBankId` is ignored (the id is purged book-wide);
    //                RejectedSampleAbsent if NO bank held the id; Removed otherwise.
    // No mutation occurs on any Rejected outcome (no-op guardrail for the undo layer).
    RemoveResult removeSample(const std::string& sampleId,
                              const std::string& fromBankId,
                              RemoveScope scope = RemoveScope::ThisBank);

    // -- Sample display order (L7; index membership untouched) ---------------

    // The bank's sample ids in DISPLAY (slot) order — the deterministic order the grid
    // iterates, sourced from the bank's SlotMap. Reconciles the map against live index
    // membership first (drops stale markers, appends unmapped samples densely), so a
    // freshly-migrated or out-of-band-mutated bank always yields a complete order. An
    // unknown bank id yields an empty vector. Const-logical but reconciles lazily, so
    // it is a non-const member.
    std::vector<std::string> orderedSampleIds(const std::string& bankId);

    // Ensures every bank's SlotMap is consistent with its index membership: seeds a
    // map that has NO overlap with its index from insertion order (the pre-L7 migration
    // default — dense, no gaps), and reconciles a partially-populated map (drop stale,
    // append unmapped). Idempotent. Called after deserialize and after any capture/
    // transfer that added samples out-of-band of the L7 reorder path.
    void reconcileSlots();

    // Reorders sample `id` within `bankId` to `targetSlot` (gap-preserving; see
    // SlotMap::reorder). INDEX-ONLY of positions — the sample's membership, file, and
    // metadata are untouched (capture != placement holds). Reconciles the bank's slots
    // first so the target space is complete. Returns false (no mutation) on an unknown
    // bank or an id the bank does not hold.
    bool reorderSample(const std::string& id, const std::string& bankId, int targetSlot);

    // Alt-replace (L7 F3): the dragged sample `newId` (already a member of `bankId`)
    // takes the slot of the occupant `oldId`, and `oldId` is REMOVED from `bankId`'s
    // index (index-only, same semantics as removeSample ThisBank — the file stays on
    // disk; owned-manifest/prune govern bytes; hashReferencedElsewhere handles the
    // last-reference case). Position of the slot is preserved; only its occupant changes.
    //
    // POOL GUARD (settled): the index-removal of `oldId` passes the SAME guard the
    // remove verb applies — removeSample(oldId, bankId, ThisBank) must return Removed.
    // For the pool this is permitted whenever the occupant exists (per-sample removal
    // is not a pool privilege violation — the pool's guards are un-delete/rename/evacuate,
    // never per-sample remove). If the removal would be rejected (occupant absent), the
    // whole replace is rejected: false, NO mutation (neither the index nor the slots
    // change), so the shell can fall back to the default insert-shift or a no-op.
    // Rejects (false, no mutation) an unknown bank, a `newId`/`oldId` the bank does not
    // hold, or `newId == oldId`. NEVER touches disk; introduces no new deletion authority.
    bool replaceSample(const std::string& newId, const std::string& oldId,
                       const std::string& bankId);

    // Refreshes a sample IN PLACE wherever it lives in the book (M10 re-capture):
    // finds the bank holding `sampleId` and replaces its entry with `updated`
    // (order-preserving, no dedup — see BankIndex::updateInPlace). Scans banks in
    // ordinal order and updates the FIRST holder (a sample id is unique within a
    // bank; the same id living in two banks via copy would update the earliest, which
    // is acceptable — re-capture operates on the panel's focused single selection).
    // Returns false (no mutation) if no bank holds the id or the replacement's path
    // is absolute. Index-only and non-destructive to the timeline.
    bool updateSampleInPlace(const std::string& sampleId, const Sample& updated);

    // Reference-count query backing the confirm-on-last-reference guardrail: does any
    // bank OTHER than `exceptBankId` still hold an entry whose contentHash == `hash`?
    //
    // Identity is the CONTENT HASH, not the file path: hash is the canonical dedup key
    // the whole model already reasons in (findByHash / collapse-by-hash), and two
    // entries that share content share one file — so "some other bank still references
    // this hash" is exactly "removing here does not orphan the file." An EMPTY hash is
    // never matched (it does not participate in dedup, mirroring findByHash), so an
    // empty-hash sample reads as referenced-nowhere-else — the safe, confirm-eliciting
    // direction (we cannot prove another bank shares an unhashed file).
    bool hashReferencedElsewhere(const std::string& hash,
                                 const std::string& exceptBankId) const;

    // Every project-relative file path referenced by ANY bank in the book, pool
    // included — the union across the whole book (Phase R, prune). This is the
    // safety-critical referenced-set the prune core subtracts: a file referenced by
    // any bank (INCLUDING via a copy into a second bank) appears here, so prune never
    // reclaims it. Paths are returned VERBATIM (Sample.relativePath, exact strings —
    // no normalization), first-seen order across banks in ordinal order then sample
    // insertion order, and DE-DUPLICATED (one file referenced by N banks appears
    // once). An empty relativePath is skipped (it references no file). Additive
    // read-only query; adds no mutation and no coupling to Phase R.
    std::vector<std::string> referencedPaths() const;

    // -- Query ---------------------------------------------------------------

    // The bank with `id`, or nullptr. Pointer invalidated by any mutating call.
    Bank*       bank(const std::string& id);
    const Bank* bank(const std::string& id) const;

    // The bank's BankIndex by id, or nullptr. Convenience over bank()->index.
    BankIndex*       index(const std::string& id);
    const BankIndex* index(const std::string& id) const;

    // The pool (always present). Never null.
    Bank&       pool();
    const Bank& pool() const;

    // All banks in ordinal order (pool first). The pool is always banks()[0].
    const std::vector<Bank>& banks() const { return banks_; }

    std::size_t size() const { return banks_.size(); }  // >= 1 (the pool)

    bool operator==(const BankBook& o) const {
        return banks_ == o.banks_ && activeBankId_ == o.activeBankId_;
    }

    // -- Persistence ---------------------------------------------------------

    // Serializes the whole book to a JSON string (lossless round-trip): the pool
    // folded in as bank-zero + named banks + per-bank indices + ordinals + active
    // id. deserialize(serialize(x)) == x.
    std::string serialize() const;

    // Parses a book JSON produced by serialize(). std::nullopt on malformed input.
    //
    // LEGACY MIGRATION: a bare legacy bank_index JSON (the pre-multi-bank shape, an
    // object with a "samples" array and no "banks" key) is promoted into the pool's
    // index, yielding a book of { pool } with zero named banks — one-way, lossless.
    // After migration the book blob is authoritative (the caller persists the book
    // shape going forward; the legacy key is retired by the B2 shell).
    static std::optional<BankBook> deserialize(const std::string& json);

    // Resolve a BankBook from the two persisted ext-state values a project may carry:
    // the authoritative `banks` blob and the retired-but-possibly-present legacy
    // `bank_index` blob. The persist shell (B2) hands both raw strings straight here so
    // the load-source decision stays REAPER-free and unit-tested. Precedence:
    //   1. non-empty `banksJson` present  -> deserialize it (authoritative). If it is
    //      MALFORMED, do NOT silently fall back to the legacy blob — a corrupt `banks`
    //      blob is an error, not an absence; return an empty book so a stale legacy key
    //      can never resurrect a superseded single-bank state over a broken book.
    //   2. else non-empty `legacyJson`    -> deserialize it (one-way pool migration).
    //   3. else (both absent/empty)        -> a fresh empty book (pool only).
    // Never returns nullopt: an unloadable input degrades to the empty book (matching
    // the shell's existing "malformed -> ignore, start empty" behaviour), so the caller
    // has one branchless install path.
    static BankBook loadFromPersisted(const std::string& banksJson,
                                      const std::string& legacyJson);

private:
    std::vector<Bank> banks_;         // ordinal order; banks_[0] is always the pool
    std::string       activeBankId_;  // always names a live bank; defaults to pool

    // True if a bank OTHER than `exceptId` already carries `name`'s uniqueness key
    // (trimmed + case-insensitive, ASCII). Backs the create/rename uniqueness check;
    // pass exceptId=id to let a bank keep (or re-case/-space) its own name.
    bool displayNameTaken(const std::string& name, const std::string& exceptId) const;

    // Re-sorts banks_ by ordinal (pool pinned first) and rewrites ordinals to a
    // contiguous 0..N-1 so the pool is 0 and named banks are 1..N. Called after any
    // structural change (create / delete / reorder).
    void normalizeOrdinals();

    // Replaces the book's banks with a parsed set, normalizes ordinals, and resolves
    // the active bank (falling back to the pool if the id names no bank). Used only
    // by deserialize; kept private so the public surface stays create/rename/etc.
    void adoptBanks(std::vector<Bank>&& banks, const std::string& activeBank);
};

// The next bank id to activate when cycling the active bank forward, in ordinal
// order (the ids arrive pool-first, named 1..N, matching banks()). Wraps: the id
// after the last returns the first (pool → named → … → pool). This is the pure
// decision behind the "cycle active bank" action — the shell reads the book's
// ordered bank ids + current active id, asks for the next, and activates it.
//   * empty list                 -> "" (nothing to cycle to)
//   * single id (pool-only)      -> that id (a one-bank book stays put)
//   * currentBankId not present  -> the first id (a sane home to jump to)
// Exposed as a free function (not a BankBook member) so it is unit-testable against
// a bare id vector without a full book. Mirror of view_mode_model's nextModeId.
std::string nextBankId(const std::vector<std::string>& orderedBankIds,
                       const std::string& currentBankId);

} // namespace reasampler
