-- WarBoard_Ledger.lua
-- The synchronized activity ledger: the single source of truth for War Board
-- activity, currency, and war-effort totals. Gold and War Credit are NEVER read
-- from a trusted saved number — they are rebuilt by summing the deltas in the
-- ledger's activity records. Records are deduped by a stable unique id so the
-- same activity (whether produced locally or received from a peer) is counted
-- once. A deterministic hash over the records gives a cheap integrity digest
-- peers can compare to detect divergence (basic tamper detection, not anti-cheat).
--
-- Activity record schema:
--   id        unique, stable id (assigned once by the originator)   (string)
--   name      originator short name                                 (string)
--   realm     originator normalized realm                           (string)
--   guid      originator player GUID, if known                      (string|nil)
--   faction   "Horde" / "Alliance"                                  (string)
--   atype     "objective" | "bounty_reward" | "bounty_cost" | ...   (string)
--   objId     objective id / bounty id this record refers to        (string)
--   obj       display title (cached for robust rendering)           (string)
--   gold      gold delta (may be negative for expenditures)         (number)
--   warCredit war-credit delta                                      (number)
--   t         epoch timestamp (ordering + verification)             (number)
--   ver       source addon version                                  (string)
--   isRepeat  was this a repeat completion                          (bool)

local addonName, WB = ...

WB.Ledger = WB.Ledger or {}
local L = WB.Ledger

L.VERSION = (C_AddOns and C_AddOns.GetAddOnMetadata and C_AddOns.GetAddOnMetadata(addonName, "Version")) or "?"

-- Hard cap on ledger size; oldest records are trimmed beyond this. Sized to hold a full
-- campaign's worth of pooled community records (kills, objectives, bounties) so the Leader
-- Board — which sums kills/WC from surviving records — never loses current-campaign data to
-- trimming. (The leaderboard resets per campaign; the cap just has to outlast one campaign.)
local MAX_LEDGER = 10000
-- Trimming sorts the whole ledger to find the oldest, so we only do it once we're this far
-- over the cap (then trim back to it). That amortises the sort across many adds instead of
-- running it on every add while pegged at the cap — important now that the ledger is large.
local TRIM_SLACK = 200

local db          -- WarBoardCharDB
local byId        -- in-memory id -> record (dedup index; rebuilt on load)

-- Sync digest is recomputed on every HELLO we receive, so it's cached and only rebuilt
-- when the ledger actually changes (any add/trim/wipe bumps the version) or the sync
-- window slides (cutoff change). Without this a populated community channel re-hashes the
-- whole windowed ledger dozens of times a minute. (Record status P/A/Q/U isn't part of the
-- hash, so status changes need no bump.)
local syncVer = 0
-- scoreVer drives the DERIVED-TOTAL caches (MyTotals / Leaderboard / FactionWarCredit / GetActivity).
-- Unlike the digest, those depend on record STATUS (st: P/A/Q/U decides what scores), so this bumps
-- on any content change AND whenever the Verifier re-judges statuses (via L.BumpScore).
local scoreVer = 0
local function BumpSync() syncVer = syncVer + 1; scoreVer = scoreVer + 1 end
function L.BumpScore() scoreVer = scoreVer + 1 end   -- Verifier calls this after changing record statuses

-- ── Deterministic hash (djb2 / sdbm pair → base36), adapted from JARVIS ────────
local BASE36     = "0123456789abcdefghijklmnopqrstuvwxyz"
local HASH_MOD_A = 4294967291
local HASH_MOD_B = 4294967279

local function ToBase36(n)
    n = math.floor(tonumber(n) or 0)
    if n <= 0 then return "0" end
    local out = ""
    while n > 0 do
        local r = n % 36
        out = BASE36:sub(r + 1, r + 1) .. out
        n = (n - r) / 36
    end
    return out
end

local function HashString(str)
    str = tostring(str or "")
    local h1, h2 = 5381, 1315423911
    for i = 1, #str do
        local b = str:byte(i)
        h1 = ((h1 * 33) + b) % HASH_MOD_A
        h2 = ((h2 * 131) + b) % HASH_MOD_B
    end
    return ToBase36(h1) .. ToBase36(h2)
end

local function Canon(v)
    if v == nil then return "" end
    return tostring(v)
end

L.HashString = HashString  -- shared with the comm layer

-- ── Identity ──────────────────────────────────────────────────────────────────
function L.MyIdentity()
    local name  = UnitNameUnmodified("player") or "?"
    local realm = GetNormalizedRealmName() or ""
    local guid  = UnitGUID("player")
    return name, realm, guid
end

local function MyKey()
    local n, r = L.MyIdentity()
    return (n .. "-" .. (r or "")):lower()
end

-- ── DB ────────────────────────────────────────────────────────────────────────
local function RebuildIndex()
    byId = {}
    for _, rec in ipairs(db.ledger) do
        if rec.id then byId[rec.id] = rec end
    end
end

function L.EnsureDB()
    WarBoardCharDB = WarBoardCharDB or {}
    WarBoardCharDB.ledger       = WarBoardCharDB.ledger       or {}
    WarBoardCharDB.ledgerSeq    = WarBoardCharDB.ledgerSeq    or 0
    WarBoardCharDB.retiredIds   = WarBoardCharDB.retiredIds   or {}  -- set of trimmed record ids (never re-admitted)
    WarBoardCharDB.retiredOrder = WarBoardCharDB.retiredOrder or {}  -- FIFO order for evicting the retired set
    WarBoardCharDB.settledGold  = WarBoardCharDB.settledGold  or 0   -- gold from records trimmed past the cap, banked so totals don't drift
    WarBoardCharDB.settledWC    = WarBoardCharDB.settledWC    or 0   -- war credit from those trimmed records
    db = WarBoardCharDB
    RebuildIndex()

    -- One-time migration of the old flat activity feed into the ledger.
    if not db._ledgerMigrated and type(db.activity) == "table" and #db.activity > 0 and #db.ledger == 0 then
        for _, e in ipairs(db.activity) do
            L.Add(L.MakeRecord({
                name = e.name, faction = e.faction, atype = "objective",
                objId = e.objId, obj = e.obj, gold = e.gold, warCredit = e.warCredit,
                isRepeat = e.isRepeat, t = e.t,
            }))
        end
    end
    db._ledgerMigrated = true
end

local function Ready()
    if not db then L.EnsureDB() end
    return db ~= nil
end

-- ── Record construction ───────────────────────────────────────────────────────
-- Build a record from loose fields. The caller supplies identity (local records
-- pass their own name/realm/guid; received records arrive fully populated).
function L.MakeRecord(f)
    local t = tonumber(f.t) or time()
    return {
        id        = f.id,                                  -- assigned on Add if nil
        name      = f.name,
        realm     = f.realm,
        guid      = f.guid,
        faction   = f.faction or "Horde",
        atype     = f.atype or "objective",
        objId     = f.objId,
        obj       = f.obj,
        gold      = tonumber(f.gold) or 0,
        warCredit = tonumber(f.warCredit) or 0,
        kills     = tonumber(f.kills) or 0,   -- enemies credited (objective goal on completion)
        t         = t,
        -- Campaign id derived from the timestamp (deterministic; convenience/debug only,
        -- NOT trusted from the wire and NOT part of RecordHash — see L.RecordHash).
        cid       = (WB.Campaign and WB.Campaign.IndexForTime and WB.Campaign.IndexForTime(t)) or nil,
        ver       = f.ver or L.VERSION,
        -- Rules version this record was created under (objective/atype/rank catalog).
        -- Drives the Verifier's forward-compat gate. NOT part of RecordHash (it's the
        -- originator's claim, used to decide abstain-vs-convict — both outcomes safe).
        rv        = tonumber(f.rv) or WB.RULESVER or 0,
        isRepeat  = (f.isRepeat == true) or (f.isRepeat == 1) or false,
    }
end

-- A stable, unique id for a freshly-originated record. Not derived from content
-- (so identical-looking completions stay distinct); created once, then carried
-- unchanged through every relay so receivers dedup on it.
function L.NewId()
    if not Ready() then return tostring(time()) end
    db.ledgerSeq = (db.ledgerSeq or 0) + 1
    local n, r = L.MyIdentity()
    return n .. "-" .. (r or "") .. ":" .. ToBase36(time()) .. ":" .. ToBase36(db.ledgerSeq)
end

-- Persistent "retired" set: ids of records trimmed past the cap. We remember
-- them (bounded, FIFO-evicted) so a trimmed record can't be silently re-admitted
-- — and thus re-counted / re-relayed — if it reaches us again later.
local RETIRED_CAP = 5000
local function Retire(id)
    if not id or db.retiredIds[id] then return end
    db.retiredIds[id] = true
    db.retiredOrder[#db.retiredOrder + 1] = id
    while #db.retiredOrder > RETIRED_CAP do
        local oldest = table.remove(db.retiredOrder, 1)
        if oldest then db.retiredIds[oldest] = nil end
    end
end

-- Add a record. Returns true only if it was new (deduped against live + retired ids).
-- A trimmed record leaves the ledger for good, so its gold/war-credit contribution would
-- silently vanish from MyTotals (which rebuilds the banked totals by summing surviving
-- records). To keep those totals trim-stable we fold a departing record's delta into a
-- persistent settled baseline first — but only for OUR OWN scoring records, since MyTotals
-- only counts those. (Q/U records never scored, so there's nothing to bank.)
local function BankIfMine(rec)
    if not rec or rec.st == "Q" or rec.st == "U" then return end
    local myName, myRealm, myGuid = L.MyIdentity()
    local mine = (myGuid and rec.guid and rec.guid == myGuid)
              or (not rec.guid and rec.name
                  and (rec.name .. "-" .. (rec.realm or "")):lower()
                      == (myName .. "-" .. (myRealm or "")):lower())
    if not mine then return end
    db.settledGold = (db.settledGold or 0) + (tonumber(rec.gold) or 0)
    db.settledWC   = (db.settledWC   or 0) + (tonumber(rec.warCredit) or 0)
end

function L.Add(rec)
    if not Ready() or type(rec) ~= "table" then return false end
    if not rec.id or rec.id == "" then rec.id = L.NewId() end
    if byId[rec.id] or db.retiredIds[rec.id] then return false end

    rec.t         = tonumber(rec.t) or time()
    rec.gold      = tonumber(rec.gold) or 0
    rec.warCredit = tonumber(rec.warCredit) or 0
    if not rec.st then rec.st = "P" end   -- provisional by default (the Verifier upgrades to "A"/"Q")

    db.ledger[#db.ledger + 1] = rec
    byId[rec.id] = rec

    if #db.ledger > MAX_LEDGER + TRIM_SLACK then
        table.sort(db.ledger, function(a, b) return (a.t or 0) > (b.t or 0) end)
        for i = #db.ledger, MAX_LEDGER + 1, -1 do
            local old = db.ledger[i]
            if old and old.id then
                BankIfMine(old)   -- preserve our currency before the record is gone
                byId[old.id] = nil
                Retire(old.id)
            end
            db.ledger[i] = nil
        end
    end
    BumpSync()   -- ledger changed → sync digest cache is stale
    return true
end

function L.Has(id)  return Ready() and id and byId[id] ~= nil or false end
function L.Count()  return Ready() and #db.ledger or 0 end
function L.All()    if not Ready() then return {} end return db.ledger end

function L.Wipe()
    if not Ready() then return end
    wipe(db.ledger)
    wipe(db.retiredIds)
    wipe(db.retiredOrder)
    db.settledGold = 0
    db.settledWC   = 0
    byId = {}
    BumpSync()
end

-- ── Views derived from the ledger ─────────────────────────────────────────────

-- Activity feed (newest first) — consumed directly by the Activity UI. World-PvP
-- kill records ("pvpkill") are excluded: they still feed war credit / leaderboard
-- kills (which scan the raw ledger), but would otherwise flood the feed.
-- Start of the "recent" window: the previous campaign onward. This is the ONE cutoff used by
-- both the Activity feed AND the sync (digest + dump). Records older than it are kept locally
-- (so lifetime totals / rank still sum them) but are excluded from the feed, the HELLO digest,
-- and the dump — so a catch-up only ever transfers fresh data, never a long historical tail.
-- The PREVIOUS campaign is kept so a just-ended one stays synced through its reconciliation
-- window. Falls back to 0 (everything) before the campaign clock has loaded.
function L.RecentCutoff()
    if WB.Campaign and WB.Campaign.Index and WB.Campaign.Window then
        local from = WB.Campaign.Window(WB.Campaign.Index() - 1)
        if from then return from end
    end
    return 0
end

local activityCache = { ver = -1, cutoff = nil, list = nil }
function L.GetActivity()
    if not Ready() then return {} end
    local cutoff = L.RecentCutoff()   -- hide anything older than the previous campaign
    if activityCache.list and activityCache.ver == scoreVer and activityCache.cutoff == cutoff then
        return activityCache.list
    end
    table.sort(db.ledger, function(a, b) return (a.t or 0) > (b.t or 0) end)
    local out = {}
    for _, rec in ipairs(db.ledger) do
        -- pvpkill + bounty_post + bounty_clear are board/internal events, not currency
        -- activity. (bounty_cost shows the placement; bounty_claim/bounty_reward the payout.)
        local a = rec.atype
        if a ~= "pvpkill" and a ~= "bounty_post" and a ~= "bounty_clear" and rec.st ~= "Q"
           and (rec.t or 0) >= cutoff then
            out[#out + 1] = rec
        end
    end
    activityCache.ver, activityCache.cutoff, activityCache.list = scoreVer, cutoff, out
    return out
end

-- A record counts toward live/competitive views (currency, leaderboard, war bar) only
-- when it's provisional or accepted — never quarantined ("Q") or abstained/unverified
-- ("U"). "U" records come from a newer rules version we can't audit: they still SHOW in
-- the Activity feed, but score nothing until a client that has the rules blesses them
-- (locally → "A"). This is what stops a fabricated objId/atype/rank from inflating the
-- leaderboard: if it can't reach "A", it's worth zero everywhere.
local function CountsForScore(rec)
    local st = rec.st
    return st ~= "Q" and st ~= "U"
end
L.CountsForScore = CountsForScore

local function RecordIsMine(rec, myGuid, myKey)
    if myGuid and rec.guid and rec.guid == myGuid then return true end
    if not rec.guid and rec.name then
        local key = (rec.name .. "-" .. (rec.realm or "")):lower()
        if key == myKey then return true end
    end
    return false
end

-- My gold / war credit, rebuilt from my records (never a trusted saved total). Cached on scoreVer:
-- this is the hottest scan — called several times per refresh (currency, rank, lifetime-WC).
local myTotalsCache = { ver = -1, gold = 0, wc = 0 }
function L.MyTotals()
    if not Ready() then return 0, 0 end
    if myTotalsCache.ver == scoreVer then return myTotalsCache.gold, myTotalsCache.wc end
    local _, _, myGuid = L.MyIdentity()
    local myKey = MyKey()
    -- Start from the settled baseline (currency from records already trimmed past the cap),
    -- then add everything still in the ledger. This keeps the totals stable as old records
    -- age out, instead of shrinking toward 0 each time the ledger trims.
    local gold, wc = (db.settledGold or 0), (db.settledWC or 0)
    for _, rec in ipairs(db.ledger) do
        if CountsForScore(rec) and RecordIsMine(rec, myGuid, myKey) then   -- Q/U records pay nothing
            gold = gold + (rec.gold or 0)
            wc   = wc   + (rec.warCredit or 0)
        end
    end
    -- Gold is a spendable currency, never a debt. If a historical roll-back left the sum negative
    -- (lost earning records while their bounty-cost records remained), bank the deficit into the
    -- settled baseline so the total becomes a REAL 0 and stays there. A plain display-floor would
    -- leave the negative sitting underneath, silently swallowing newly-earned gold until it was paid
    -- back off (e.g. a 200g quest only nudging the total from a hidden −150 to +50). One-time and
    -- idempotent: once healed the total is 0, so there's nothing left to bank. (War credit grows via
    -- its lifetime floor instead; gold has no synced floor since it's spendable.)
    if gold < 0 then
        db.settledGold = (db.settledGold or 0) - gold   -- gold is negative → this adds |gold|
        gold = 0
    end
    myTotalsCache.ver, myTotalsCache.gold, myTotalsCache.wc = scoreVer, gold, wc
    return gold, wc
end

-- The campaign view window [from, to) from the campaign clock: the previous
-- (settling) campaign while finalizing, else the live current one (to = nil, open).
-- The Leader Board and War Contribution bar are scoped to it (they reset each
-- campaign); lifetime totals (MyTotals → rank progress) are deliberately NOT scoped.
-- Falls back to all-time when the campaign clock isn't loaded yet.
local function ViewWindow()
    if WB.Campaign and WB.Campaign.ViewWindow then
        local from, to = WB.Campaign.ViewWindow()
        return from or 0, to
    end
    return 0, nil
end

local function InWindow(t, from, to)
    t = t or 0
    if t < (from or 0) then return false end
    if to and t >= to then return false end
    return true
end

-- War credit a faction contributed within an explicit [fromT, toT) window. Used to
-- score a finished campaign's winner — eligibility is purely by activity timestamp,
-- so late-synced records still count as long as they predate the campaign end.
--   acceptedOnly = true  → only deep-audited records (st == "A"); the FINAL result.
--   acceptedOnly = false → provisional view (everything not quarantined); the LIVE bar.
function L.FactionWarCreditBetween(faction, fromT, toT, acceptedOnly)
    if not Ready() then return 0 end
    local wc = 0
    for _, rec in ipairs(db.ledger) do
        if rec.faction == faction and InWindow(rec.t, fromT, toT) then
            if acceptedOnly then
                if rec.st == "A" then wc = wc + (rec.warCredit or 0) end
            elseif CountsForScore(rec) then
                wc = wc + (rec.warCredit or 0)
            end
        end
    end
    return wc
end

-- War credit contributed by a faction in the current view window (the War Contribution bar).
-- Both factions are asked for back-to-back each refresh, so compute + cache the pair together.
local fwcCache = { ver = -1, from = nil, to = nil, Horde = 0, Alliance = 0 }
function L.FactionWarCredit(faction)
    if not Ready() then return 0 end
    local from, to = ViewWindow()
    if fwcCache.ver ~= scoreVer or fwcCache.from ~= from or fwcCache.to ~= to then
        fwcCache.ver, fwcCache.from, fwcCache.to = scoreVer, from, to
        fwcCache.Horde    = L.FactionWarCreditBetween("Horde",    from, to)
        fwcCache.Alliance = L.FactionWarCreditBetween("Alliance", from, to)
    end
    return fwcCache[faction] or 0
end

-- Per-player aggregates for the Leader Board (scoped to the current view window —
-- resets at each campaign end, shows the settling campaign while finalizing). Cached on
-- scoreVer + window; the caller may re-sort the returned list by its active metric (harmless).
local lbCache = { ver = -1, from = nil, to = nil, list = nil }
function L.Leaderboard()
    if not Ready() then return {} end
    local from, to = ViewWindow()
    if lbCache.list and lbCache.ver == scoreVer and lbCache.from == from and lbCache.to == to then
        return lbCache.list
    end
    local agg = {}
    for _, rec in ipairs(db.ledger) do
        if CountsForScore(rec) and InWindow(rec.t, from, to) then   -- Q/U records don't score
            local key = rec.guid or ((rec.name or "?") .. "-" .. (rec.realm or ""))
            local a = agg[key]
            if not a then
                a = { name = rec.name, realm = rec.realm, guid = rec.guid, faction = rec.faction,
                      warCredit = 0, gold = 0, kills = 0, count = 0 }
                agg[key] = a
            end
            a.warCredit = a.warCredit + (rec.warCredit or 0)
            a.gold      = a.gold      + (rec.gold or 0)
            a.kills     = a.kills     + (tonumber(rec.kills) or 0)
            a.count     = a.count + 1
        end
    end
    local list = {}
    for _, a in pairs(agg) do list[#list + 1] = a end
    table.sort(list, function(x, y) return (x.warCredit or 0) > (y.warCredit or 0) end)
    lbCache.ver, lbCache.from, lbCache.to, lbCache.list = scoreVer, from, to, list
    return list
end

-- ── Integrity ─────────────────────────────────────────────────────────────────
-- Per-record content hash (excludes display-only fields like obj title).
function L.RecordHash(rec)
    return HashString(table.concat({
        Canon(rec.id), Canon(rec.name), Canon(rec.realm), Canon(rec.guid),
        Canon(rec.faction), Canon(rec.atype), Canon(rec.objId),
        Canon(rec.gold), Canon(rec.warCredit), Canon(rec.kills), Canon(rec.t),
    }, "\31"))
end

-- Order-independent digest of the WHOLE ledger (local integrity / debug). Live sync uses the
-- windowed L.SyncHash below, not this.
function L.Hash()
    if not Ready() then return "0" end
    local hs = {}
    for _, rec in ipairs(db.ledger) do hs[#hs + 1] = L.RecordHash(rec) end
    table.sort(hs)
    return HashString(table.concat(hs, "|"))
end

-- Accepted-record count + content hash for a campaign window [fromT, toT). Used to STAMP a
-- finalized campaign result: peers compare recordCount to tell a complete tally from a stale /
-- partial one (more accepted records = more complete = wins), and ledgerHash flags divergence.
-- Accepted-only (st == "A") so it matches the audited final result (FactionWarCreditBetween
-- with acceptedOnly=true). Eligibility is purely by timestamp, so records with t >= toT (the
-- campaign end) are correctly excluded — they belong to the next campaign.
function L.CampaignDigest(fromT, toT)
    if not Ready() then return 0, "0" end
    local hs, n = {}, 0
    for _, rec in ipairs(db.ledger) do
        if rec.st == "A" and (rec.t or 0) >= (fromT or 0) and (not toT or (rec.t or 0) < toT) then
            n = n + 1
            hs[n] = L.RecordHash(rec)
        end
    end
    table.sort(hs)
    return n, HashString(table.concat(hs, "|"))
end

-- ── Sync window (the recent slice peers actually exchange) ─────────────────────
-- HELLO and the dump operate over L.RecentCutoff onward instead of the whole ledger, so a
-- fresh/reset client catches up on current+previous-campaign data fast and never drags a long
-- tail of old records across the wire. Convergence still works: two clients hash only the
-- recent slice, so they agree on fresh data even if their archived (older) records differ —
-- and old records simply stop being dumped, so they don't keep resurrecting on a reset client.
function L.SyncRecords()
    if not Ready() then return {} end
    local cutoff = L.RecentCutoff()
    if cutoff <= 0 then return db.ledger end
    local out = {}
    for _, rec in ipairs(db.ledger) do
        if (rec.t or 0) >= cutoff then out[#out + 1] = rec end
    end
    return out
end

-- Cached digest of the windowed slice. Rebuilt only when the ledger changes (syncVer) or
-- the window cutoff slides — so the per-HELLO cost is O(1) in the common case. Computes the
-- count and hash together in a single pass.
local syncCache = { ver = -1, cutoff = nil, hash = "0", count = 0 }
local function EnsureSyncCache()
    local cutoff = L.RecentCutoff()
    if syncCache.ver == syncVer and syncCache.cutoff == cutoff then return end
    local hs, n = {}, 0
    for _, rec in ipairs(db.ledger) do
        if cutoff <= 0 or (rec.t or 0) >= cutoff then
            n = n + 1
            hs[n] = L.RecordHash(rec)
        end
    end
    table.sort(hs)
    syncCache.hash   = HashString(table.concat(hs, "|"))
    syncCache.count  = n
    syncCache.cutoff = cutoff
    syncCache.ver    = syncVer
end

function L.SyncCount()
    if not Ready() then return 0 end
    EnsureSyncCache()
    return syncCache.count
end

function L.SyncHash()
    if not Ready() then return "0" end
    EnsureSyncCache()
    return syncCache.hash
end

-- ── Async (time-sliced) sync digest ─────────────────────────────────────────────
-- EnsureSyncCache hashes the ENTIRE windowed ledger in one frame; at the 10k cap that's a
-- long frame spike whenever the cache is stale — and any add stales it, so busy comm
-- traffic turns every HELLO into a micro-freeze. This builder computes the SAME digest
-- (byte-identical algorithm → wire-compatible with peers on any version) sliced across
-- frames, then fires the queued callbacks with (hash, count). HELLO handling isn't
-- latency-sensitive (sync is eventually-consistent + per-peer cooldown), so the few-frame
-- delay is free. If the ledger changes mid-build the result is a hair stale — the cache is
-- left stale in that case, so the next caller simply rebuilds; convergence self-heals.
local STEP_HASH = 700    -- records hashed per slice (phase 1)
local STEP_FOLD = 2000   -- per-record hash strings folded into the digest per slice (phase 3)
local asyncCbs, asyncBusy = {}, false

function L.SyncDigestAsync(cb)
    if not Ready() then if cb then cb("0", 0) end return end
    local cutoff = L.RecentCutoff()
    if syncCache.ver == syncVer and syncCache.cutoff == cutoff then
        if cb then cb(syncCache.hash, syncCache.count) end   -- cache fresh → answer now
        return
    end
    if not C_Timer then   -- no timer API (shouldn't happen on retail): fall back to sync
        EnsureSyncCache()
        if cb then cb(syncCache.hash, syncCache.count) end
        return
    end
    if cb then asyncCbs[#asyncCbs + 1] = cb end
    if asyncBusy then return end   -- a build is in flight; callbacks queue onto it
    asyncBusy = true

    -- Snapshot record REFS so a mid-build add/trim (which re-sorts db.ledger in place)
    -- can't corrupt the walk. Record contents are immutable after add (st isn't hashed).
    local startVer, snap = syncVer, {}
    for i = 1, #db.ledger do snap[i] = db.ledger[i] end
    local total = #snap

    local hs, n, i = {}, 0, 1

    local function complete(hash)
        syncCache.hash, syncCache.count = hash, n
        syncCache.cutoff, syncCache.ver = cutoff, startVer   -- stale already if ver moved on
        asyncBusy = false
        local cbs = asyncCbs
        asyncCbs = {}
        for _, f in ipairs(cbs) do pcall(f, hash, n) end
    end

    -- Phase 3: resumable byte-fold reproducing HashString(table.concat(hs, "|")) exactly
    -- (same constants, same "|" separators), carrying h1/h2 across slices.
    local function foldStep(idx, h1, h2)
        local last = math.min(idx + STEP_FOLD - 1, n)
        for k = idx, last do
            if k > 1 then                                   -- the "|" separator (byte 124)
                h1 = ((h1 * 33) + 124) % HASH_MOD_A
                h2 = ((h2 * 131) + 124) % HASH_MOD_B
            end
            local s = hs[k]
            for b = 1, #s do
                local byte = s:byte(b)
                h1 = ((h1 * 33) + byte) % HASH_MOD_A
                h2 = ((h2 * 131) + byte) % HASH_MOD_B
            end
        end
        if last < n then
            C_Timer.After(0, function() foldStep(last + 1, h1, h2) end)
        else
            complete(ToBase36(h1) .. ToBase36(h2))
        end
    end

    -- Phase 1: per-record hashes, a slice per frame; then sort (phase 2, C-side, cheap)
    -- and start the fold.
    local function hashStep()
        local last = math.min(i + STEP_HASH - 1, total)
        for k = i, last do
            local rec = snap[k]
            if cutoff <= 0 or (rec.t or 0) >= cutoff then
                n = n + 1
                hs[n] = L.RecordHash(rec)
            end
        end
        i = last + 1
        if i <= total then
            C_Timer.After(0, hashStep)
        elseif n == 0 then
            complete(HashString(""))                        -- matches concat of an empty list
        else
            table.sort(hs)
            foldStep(1, 5381, 1315423911)                   -- HashString's seeds
        end
    end
    hashStep()
end

-- ── Init ──────────────────────────────────────────────────────────────────────
do
    local f = CreateFrame("Frame")
    f:RegisterEvent("ADDON_LOADED")
    f:SetScript("OnEvent", function(self, _, name)
        if name ~= addonName then return end
        L.EnsureDB()
        self:UnregisterEvent("ADDON_LOADED")
    end)
end
