-- WarBoard_KillTally.lua
-- Per-player World-PvP kill credit, kept OUT of the activity ledger.
--
-- Why: every enemy killing blow is worth +1 War Credit, and at population that's by far the
-- highest-volume event. Streaming a ledger record per kill (even batched) floods the synced ledger —
-- each add invalidates the derived caches, fans out over comm, sorts on trim, and eats the cap. So
-- kills live here instead: a compact per-player, per-campaign TALLY that syncs as ONE evolving value
-- per player (highest wins), never a growing stream. It still feeds the live faction bar, the leader
-- board, campaign finalization, and personal totals — just without bloating the ledger.
--
-- Correctness properties that matter here:
--   • MONOTONIC merge — a smaller value never overwrites a larger, so a stale/lost message can only
--     leave a peer briefly BEHIND, never undercount below the true value. It can't delete.
--   • DURABLE — my own tally + lifetime total persist in SavedVariables, so my kills are never lost
--     on logout/reload; I re-announce on login and peers converge.
--   • CHECKPOINT-SAFE — finalization bakes the kill WC into the locked campaign result, so pruning a
--     tally later (2h after the campaign ends, matching the ledger trim) can't change a past result.
--
-- Trust tradeoff (accepted by design): a latest-wins aggregate isn't tamper-proof the way a hashed
-- ledger record is. Kills feed the campaign economy, so this trades verification on that slice for
-- scale. Being monotonic, the only abuse is self-inflation by a cheater — never harming other players.

local addonName, WB = ...
WB.KillTally = WB.KillTally or {}
local KT = WB.KillTally

local BROADCAST_DEBOUNCE = 30        -- coalesce rapid kills into one sync every N seconds
local CONVERGE_INTERVAL  = 300       -- periodic re-broadcast so fresh logins catch up
local KEEP_AFTER_END     = 2 * 3600  -- drop a campaign's tally this long after it ends (matches ledger trim)

local db

local function EnsureDB()
    WarBoardCharDB = WarBoardCharDB or {}
    db = WarBoardCharDB
    db.killTally = db.killTally or {}          -- [campaignIndex] = { [guid] = {wc,kills,name,realm,faction} }
    db.myKillWC  = tonumber(db.myKillWC) or 0  -- my LIFETIME kill war-credit (feeds L.MyTotals)
end

local function CurIndex()
    return (WB.Campaign and WB.Campaign.Index and WB.Campaign.Index()) or 0
end

local function Bucket(idx)
    if not db then EnsureDB() end
    db.killTally[idx] = db.killTally[idx] or {}
    return db.killTally[idx]
end

local function BumpScore()
    if WB.Ledger and WB.Ledger.BumpScore then WB.Ledger.BumpScore() end  -- refresh leaderboard / faction-bar caches
end

-- ── Broadcast (debounced) ──────────────────────────────────────────────────────────
local pendingBroadcast = false
local function DoBroadcast()
    pendingBroadcast = false
    if not (db and WB.Comm and WB.Comm.BroadcastKillTally) then return end
    local me = UnitGUID("player")
    -- Announce my entry from EVERY bucket I appear in (at most the current campaign plus
    -- the one settling/awaiting prune) — NOT just the current index. Reading only
    -- CurIndex() had a rollover race: kills landed in campaign N, the 30s debounce fired
    -- after the boundary, the N+1 bucket was empty → those kills were never announced.
    for idx, bucket in pairs(db.killTally) do
        local mine = bucket[me]
        if mine then
            WB.Comm.BroadcastKillTally({
                campaign = idx, guid = me,
                name = UnitNameUnmodified("player"), realm = GetNormalizedRealmName() or "",
                faction = WB.faction or UnitFactionGroup("player") or "Horde",
                wc = mine.wc or 0, kills = mine.kills or 0,
            })
        end
    end
end
local function ArmBroadcast()
    if pendingBroadcast or not C_Timer then return end
    pendingBroadcast = true
    C_Timer.After(BROADCAST_DEBOUNCE, DoBroadcast)
end
function KT.Flush() DoBroadcast() end   -- push my latest tally now (called before a loading screen)

-- ── Local kill ─────────────────────────────────────────────────────────────────────
-- One enemy killing blow = +1 kill, +1 war credit. Called INSTEAD of writing a pvpkill ledger record.
function KT.NoteKill()
    if not db then EnsureDB() end
    local idx = CurIndex()
    local me  = UnitGUID("player")
    local b   = Bucket(idx)
    local e   = b[me]
    if not e then
        e = { wc = 0, kills = 0, name = UnitNameUnmodified("player"),
              realm = GetNormalizedRealmName() or "",
              faction = WB.faction or UnitFactionGroup("player") or "Horde" }
        b[me] = e
    end
    e.wc        = e.wc + 1
    e.kills     = e.kills + 1
    db.myKillWC = (db.myKillWC or 0) + 1
    if WB.Roster and WB.Roster.AddLifetimeWC then WB.Roster.AddLifetimeWC(1) end   -- keep the rank bar climbing
    BumpScore()
    ArmBroadcast()
end

-- A campaign bucket we'd still keep (mirrors Prune's window): the current campaign, a
-- future-dated one (clock skew tolerance), or one that ended less than KEEP_AFTER_END ago.
-- Gossiped entries outside this window are ignored so stale relays can't re-seed pruned
-- buckets or grow SavedVariables without bound.
local function KeepableIndex(idx)
    if type(idx) ~= "number" or idx ~= math.floor(idx) or idx < 0 then return false end
    if not (WB.Campaign and WB.Campaign.Window) then return idx == CurIndex() end
    if idx > CurIndex() + 1 then return false end   -- ≥2 campaigns ahead = bogus clock/peer
    local _, endT = WB.Campaign.Window(idx)
    return endT ~= nil and (time() - endT) < KEEP_AFTER_END
end

-- Monotonic merge of one remote entry: only a HIGHER value wins → can never undercount.
-- Returns true when something grew (caller bumps the score caches once per batch).
local function MergeEntry(idx, guid, name, realm, faction, wc, kills)
    if type(guid) ~= "string" or guid == "" then return false end
    if not KeepableIndex(idx) then return false end
    local b = Bucket(idx)
    local e = b[guid]
    wc, kills = tonumber(wc) or 0, tonumber(kills) or 0
    if not e then e = { wc = 0, kills = 0 }; b[guid] = e end
    local changed = false
    if wc    > (e.wc    or 0) then e.wc    = wc;    changed = true end
    if kills > (e.kills or 0) then e.kills = kills; changed = true end
    e.name    = name    or e.name
    e.realm   = realm   or e.realm
    e.faction = faction or e.faction
    return changed
end

-- ── Remote merge ─────────────────────────────────────────────────────────────────────
function KT.OnRemote(d)
    if type(d) ~= "table" or not d.guid or d.campaign == nil then return end
    if not db then EnsureDB() end
    if MergeEntry(tonumber(d.campaign), d.guid, d.name, d.realm, d.faction, d.wc, d.kills) then
        BumpScore()
    end
end

-- Bulk gossip merge (KTLB): a peer's whole known tally set. Monotonic per entry, so
-- receiving the same set from many peers is idempotent and can never undercount.
function KT.OnRemoteBulk(list)
    if type(list) ~= "table" then return end
    if not db then EnsureDB() end
    local changed = false
    for _, it in ipairs(list) do
        if type(it) == "table" then
            if MergeEntry(tonumber(it.c), it.g, it.n, it.r, it.f, it.w, it.k) then
                changed = true
            end
        end
    end
    if changed then BumpScore() end
end

-- Everything I know (all kept buckets), compact, for gossip. Capped: my own entries always
-- ride along; the rest are the highest-WC entries — the ones that matter on the board.
local GOSSIP_CAP = 60
function KT.GossipList()
    if not db then EnsureDB() end
    local me   = UnitGUID("player")
    local mine, rest = {}, {}
    for idx, bucket in pairs(db.killTally) do
        if KeepableIndex(idx) then
            for guid, e in pairs(bucket) do
                local item = { c = idx, g = guid, n = e.name, r = e.realm,
                               f = e.faction, w = e.wc or 0, k = e.kills or 0 }
                if guid == me then mine[#mine + 1] = item else rest[#rest + 1] = item end
            end
        end
    end
    table.sort(rest, function(a, b) return (a.w or 0) > (b.w or 0) end)
    local list = mine
    for i = 1, math.min(#rest, GOSSIP_CAP - #list) do list[#list + 1] = rest[i] end
    return list
end

-- Gossip my known tallies wide (piggybacks the converge ticker + login announce). This is
-- what makes kill counts EPIDEMIC like ledger records: any peer who ever heard a player's
-- tally re-shares it, so it reaches clients that were offline/unreachable when the killer
-- announced it — and it survives the killer logging off. (The old model — each client only
-- ever announcing its OWN tally, one hop — silently left such peers at 0 kills forever,
-- while the quest WC still arrived through the ledger's digest/dump healing.)
local function GossipBroadcast()
    if not (WB.Comm and WB.Comm.BroadcastKillTallyBulk) then return end
    local list = KT.GossipList()
    if #list > 0 then WB.Comm.BroadcastKillTallyBulk(list) end
end

-- Targeted catch-up: whisper everything I know to one peer (Comm calls this when a HELLO
-- arrives — the same moment the ledger reconciles — so a fresh login heals kill counts too).
function KT.SendTalliesTo(target)
    if not (target and WB.Comm and WB.Comm.SendKillTallyBulkTo) then return end
    local list = KT.GossipList()
    if #list > 0 then WB.Comm.SendKillTallyBulkTo(target, list) end
end

-- ── Views (read by L.Leaderboard / L.FactionWarCredit and by campaign finalization) ──
function KT.FactionWC(faction, idx)
    if not db then EnsureDB() end
    local b = idx and db.killTally[idx]
    if not b then return 0 end
    local sum = 0
    for _, e in pairs(b) do
        if e.faction == faction then sum = sum + (e.wc or 0) end
    end
    return sum
end

-- Fold this campaign's kill tally into a leaderboard aggregation table (same keying as L.Leaderboard:
-- by guid), so kill WC + kill count show per player alongside their ledger activity.
function KT.MergeLeaderboard(agg, idx)
    if not (db and type(agg) == "table") then return end
    local b = idx and db.killTally[idx]
    if not b then return end
    for guid, e in pairs(b) do
        local a = agg[guid]
        if not a then
            a = { name = e.name, realm = e.realm, guid = guid, faction = e.faction,
                  warCredit = 0, gold = 0, kills = 0, count = 0 }
            agg[guid] = a
        end
        a.warCredit = a.warCredit + (e.wc or 0)
        a.kills     = a.kills     + (e.kills or 0)
        a.faction   = a.faction or e.faction
        a.name      = a.name    or e.name
        a.realm     = a.realm   or e.realm
    end
end

function KT.MyLifetimeWC() return (db and tonumber(db.myKillWC)) or 0 end

-- ── Prune old campaign tallies (mirrors the ledger's 2h-after-end trim) ──────────────
function KT.Prune()
    if not (db and WB.Campaign and WB.Campaign.Window) then return end
    local now = time()
    for idx in pairs(db.killTally) do
        local _, endT = WB.Campaign.Window(idx)
        if endT and (now - endT) > KEEP_AFTER_END then db.killTally[idx] = nil end
    end
end

-- ── Events ───────────────────────────────────────────────────────────────────────────
local f = CreateFrame("Frame")
f:RegisterEvent("ADDON_LOADED")
f:RegisterEvent("PLAYER_LOGIN")
f:SetScript("OnEvent", function(_, event, a1)
    if event == "ADDON_LOADED" then
        if a1 == addonName then EnsureDB() end
    elseif event == "PLAYER_LOGIN" then
        EnsureDB()
        KT.Prune()
        if C_Timer then
            -- Announce my tally (compat single-entry, old clients merge it) + gossip
            -- everything I know (KTLB, new clients) so peers converge from EITHER source.
            C_Timer.After(15, function() DoBroadcast(); GossipBroadcast() end)
            C_Timer.NewTicker(CONVERGE_INTERVAL, function()
                KT.Prune(); DoBroadcast(); GossipBroadcast()
            end)
        end
    end
end)
