-- 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 "?"

local MAX_LEDGER = 500   -- cap; oldest records trimmed beyond this

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

-- ── 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  = UnitName("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
    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).
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 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
                byId[old.id] = nil
                Retire(old.id)
            end
            db.ledger[i] = nil
        end
    end
    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)
    byId = {}
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

function L.GetActivity()
    if not Ready() then return {} end
    table.sort(db.ledger, function(a, b) return (a.t or 0) > (b.t or 0) end)
    local cutoff = L.RecentCutoff()   -- hide anything older than the previous campaign
    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
    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).
function L.MyTotals()
    if not Ready() then return 0, 0 end
    local _, _, myGuid = L.MyIdentity()
    local myKey = MyKey()
    local gold, wc = 0, 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
    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).
function L.FactionWarCredit(faction)
    if not Ready() then return 0 end
    local from, to = ViewWindow()
    return L.FactionWarCreditBetween(faction, from, to)
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).
function L.Leaderboard()
    if not Ready() then return {} end
    local from, to = ViewWindow()
    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)
    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

-- ── 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

function L.SyncCount()
    if not Ready() then return 0 end
    local cutoff = L.RecentCutoff()
    if cutoff <= 0 then return #db.ledger end
    local n = 0
    for _, rec in ipairs(db.ledger) do
        if (rec.t or 0) >= cutoff then n = n + 1 end
    end
    return n
end

function L.SyncHash()
    if not Ready() then return "0" end
    local cutoff = L.RecentCutoff()
    local hs = {}
    for _, rec in ipairs(db.ledger) do
        if (rec.t or 0) >= cutoff then hs[#hs + 1] = L.RecordHash(rec) end
    end
    table.sort(hs)
    return HashString(table.concat(hs, "|"))
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
