-- WarBoard_Bounties.lua
-- Posted bounties: a player places a bounty on an enemy; it's stored locally,
-- broadcast to peers, and shown in everyone's Available Bounties list. Deduped by
-- id and capped, like the activity ledger. (Cost deduction + reward-on-claim are a
-- later pass; posting/listing/accepting is here.)
--
-- One poster may only have a single live bounty on a given target (blocked at post
-- time, and guarded again on receive). Several *different* posters on the same
-- target are merged into one card whose rewards stack.

local addonName, WB = ...

WB.Bounties = WB.Bounties or {}
local B = WB.Bounties

local MAX_BOUNTIES = 200
local BOUNTY_TTL   = 24 * 3600   -- bounties drop off the list after a day
local MAX_ACTIVE   = 3           -- you can only carry 3 active bounties at once
local CLAIM_COOLDOWN = 24 * 3600 -- a claimed target can't be re-bountied for 24h

local db

local function EnsureDB()
    WarBoardCharDB = WarBoardCharDB or {}
    WarBoardCharDB.bounties         = WarBoardCharDB.bounties         or {}  -- [id] = bounty
    WarBoardCharDB.acceptedBounties = WarBoardCharDB.acceptedBounties or {}  -- [targetKey] = true (taken up by me)
    WarBoardCharDB.trackedBounties  = WarBoardCharDB.trackedBounties  or {}  -- [targetKey] = true (TRACK TARGET pressed)
    WarBoardCharDB.bountyCooldowns  = WarBoardCharDB.bountyCooldowns  or {}  -- [targetGuid] = time() of claim
    WarBoardCharDB.bountySeq        = WarBoardCharDB.bountySeq        or 0
    db = WarBoardCharDB
end
local function Ready() if not db then EnsureDB() end return db ~= nil end

-- Did I post a bounty on this target? (I can't accept/claim my own contracts.)
function B.IPostedOn(targetGuid)
    if not Ready() or not targetGuid then return false end
    local myGuid = UnitGUID("player")
    if not myGuid then return false end
    for _, b in pairs(db.bounties) do
        if b.targetGuid == targetGuid and b.posterGuid == myGuid then return true end
    end
    return false
end

-- A claimed target is on a 24h cooldown before it can be bountied again.
function B.RecordClaimCooldown(targetGuid)
    if not Ready() or not targetGuid then return end
    db.bountyCooldowns[targetGuid] = time()
end
function B.CooldownRemaining(targetGuid)
    if not Ready() or not targetGuid then return 0 end
    local t = db.bountyCooldowns[targetGuid]
    if not t then return 0 end
    local left = CLAIM_COOLDOWN - (time() - t)
    if left <= 0 then db.bountyCooldowns[targetGuid] = nil; return 0 end
    return left
end

local function MyFaction() return WB.faction or UnitFactionGroup("player") or "Horde" end

-- Stable identity of the *target* (so two posters on the same person merge into one).
local function TargetKey(b)
    if type(b) ~= "table" then return nil end
    if b.targetGuid and b.targetGuid ~= "" then return b.targetGuid end
    return (b.targetName or "?") .. "-" .. (b.targetRealm or "")
end
B.TargetKey = TargetKey

-- Stable identity of the *poster* (so one poster can't stack a target).
local function PosterKey(b)
    if type(b) ~= "table" then return nil end
    if b.posterGuid and b.posterGuid ~= "" then return b.posterGuid end
    return (b.posterName or "?") .. "-" .. (b.posterRealm or "")
end

local function MyPosterKey()
    local guid = UnitGUID("player")
    if guid and guid ~= "" then return guid end
    return (UnitNameUnmodified("player") or "?") .. "-" .. (GetNormalizedRealmName() or "")
end

-- Bounties live until the current campaign ends (instead of a flat 24h TTL):
-- anything posted before the campaign's start is expired. Falls back to a 24h
-- window if the campaign clock isn't loaded yet.
local function CampaignCutoff()
    if WB.Campaign and WB.Campaign.StartTime then
        local s = WB.Campaign.StartTime()
        if s and s > 0 then return s end
    end
    return time() - BOUNTY_TTL
end

-- Drop bounties from earlier campaigns so they neither show nor block a fresh post.
local function PruneExpired()
    if not db then return end
    local cutoff = CampaignCutoff()
    for id, b in pairs(db.bounties) do
        if (b.postedAt or 0) < cutoff then db.bounties[id] = nil end
    end
end
B.PruneExpired = PruneExpired

function B.NewId()
    if not Ready() then return tostring(time()) end
    db.bountySeq = (db.bountySeq or 0) + 1
    local n = UnitNameUnmodified("player") or "?"
    local r = GetNormalizedRealmName() or ""
    return n .. "-" .. r .. ":bnty:" .. db.bountySeq
end

-- Do I already have a live bounty on this target? Returns true, existingBounty.
function B.HasMyBountyOn(target)
    if not Ready() then return false end
    PruneExpired()
    local tkey, mine = TargetKey(target), MyPosterKey()
    for _, b in pairs(db.bounties) do
        if TargetKey(b) == tkey and PosterKey(b) == mine then return true, b end
    end
    return false
end

-- Add a bounty. Deduped by id AND by poster+target (so one poster can never have two
-- bounties on the same person). Returns true only if newly stored.
function B.Add(bounty)
    if not Ready() or type(bounty) ~= "table" or not bounty.id then return false end
    if db.bounties[bounty.id] then return false end
    B.ClampUnknownRankReward(bounty)   -- cap a newer-rank bounty's reward to our top tier

    -- Reject bounties on a target that was just claimed (24h cooldown, receiver-side).
    if bounty.targetGuid and B.CooldownRemaining(bounty.targetGuid) > 0 then return false end

    -- Safety net for a sync race: we block same-poster duplicates at post time, but if
    -- one still arrives, drop it rather than stack the card. (When cost-deduction is
    -- wired, a duplicate of *ours* would be refunded here — cost isn't charged yet.)
    local tkey, pkey = TargetKey(bounty), PosterKey(bounty)
    for _, b in pairs(db.bounties) do
        if TargetKey(b) == tkey and PosterKey(b) == pkey then
            return false
        end
    end

    db.bounties[bounty.id] = bounty

    -- Cap: drop the oldest when over the limit.
    local count = 0
    for _ in pairs(db.bounties) do count = count + 1 end
    if count > MAX_BOUNTIES then
        local oldestId, oldestT
        for id, b in pairs(db.bounties) do
            if not oldestT or (b.postedAt or 0) < oldestT then oldestId, oldestT = id, (b.postedAt or 0) end
        end
        if oldestId then db.bounties[oldestId] = nil end
    end

    if WB.UI and WB.UI.RefreshBounties then WB.UI.RefreshBounties() end
    B.RefreshBountyDebuff()   -- a bounty on ME → show the cosmetic debuff
    return true
end

-- The (live) bounty currently placed on ME, if any — drives location sharing.
function B.GetBountyOnMe()
    if not Ready() then return nil end
    local myGuid = UnitGUID("player")
    local myName, myRealm = UnitNameUnmodified("player"), GetNormalizedRealmName() or ""
    local cutoff = CampaignCutoff()
    for _, b in pairs(db.bounties) do
        if (b.postedAt or 0) >= cutoff then
            if (myGuid and b.targetGuid == myGuid)
               or (b.targetName == myName and (b.targetRealm or "") == myRealm) then
                return b
            end
        end
    end
    return nil
end

-- ── "Bounty Marked" cosmetic debuff (client-side) ────────────────────────────────────────
-- While a live bounty is on ME, show a PERMANENT fake "Bounty Marked" debuff on my own debuff
-- frame (WB.FakeDebuff — no real aura, purely visual). Rules:
--   • Instanced content (BG / arena / dungeon / raid / scenario): War Board stands down, so the
--     debuff is CLEARED. When I come back out, this re-evaluates and restores it if I'm still
--     bountied (driven by the PLAYER_ENTERING_WORLD refresh below).
--   • Cleared for good once the bounty is claimed/removed (RemoveByTarget / RebuildFromLedger drop
--     it from db.bounties, so GetBountyOnMe() returns nil and this hides it).
-- Idempotent + cheap; safe to call from every bounty mutation and on zone changes.
function B.RefreshBountyDebuff()
    local FD = WB.FakeDebuff
    if not FD then return end
    local show = (not (WB.InInstance and WB.InInstance())) and (B.GetBountyOnMe() ~= nil)
    if show then
        FD:Show("bounty_marked", {
            name        = "Bounty Marked",
            description = "Enemy players may be hunting you for a bounty.",
            icon        = 2125377,
            duration    = nil,   -- permanent — lasts until the bounty is claimed / removed
            borderAtlas = "UI-Debuff-Border-Default-NoIcon",
        })
    else
        FD:Hide("bounty_marked")
    end
end

-- Remove every bounty on a target (a claim/clear), and drop my accept for it.
function B.RemoveByTarget(targetGuid)
    if not Ready() or not targetGuid then return end
    local removed = false
    for id, b in pairs(db.bounties) do
        if b.targetGuid == targetGuid then db.bounties[id] = nil; removed = true end
    end
    if db.acceptedBounties[targetGuid] then db.acceptedBounties[targetGuid] = nil end
    if db.trackedBounties[targetGuid]  then db.trackedBounties[targetGuid]  = nil end
    if removed then
        if WB.UI and WB.UI.RefreshBounties       then WB.UI.RefreshBounties()       end
        if WB.UI and WB.UI.RefreshActiveBounties then WB.UI.RefreshActiveBounties() end
        B.RefreshBountyDebuff()   -- my bounty may have just been cleared → drop the debuff
    end
end

-- Merge live bounties passing `filter` into one entry per target. Each group carries
-- the (newest) target details, summed rewards, and a `posters` list for the hover.
local function BuildGroups(filter)
    local groups, order = {}, {}
    for _, b in pairs(db.bounties) do
        if filter(b) then
            local key = TargetKey(b)
            local g = groups[key]
            if not g then
                g = {
                    key = key,
                    targetName = b.targetName, targetRealm = b.targetRealm, targetGuid = b.targetGuid,
                    targetLevel = b.targetLevel, targetRace = b.targetRace, raceFile = b.raceFile,
                    sex = b.sex, targetClass = b.targetClass, classToken = b.classToken, targetGuild = b.targetGuild,
                    targetFaction = b.targetFaction, rank = b.rank,
                    rewardGold = 0, rewardWarCredit = 0,
                    postedAt = b.postedAt or 0,
                    posters = {}, ids = {},
                }
                groups[key] = g
                order[#order + 1] = g
            end
            g.rewardGold      = g.rewardGold      + (b.rewardGold or 0)
            g.rewardWarCredit = g.rewardWarCredit + (b.rewardWarCredit or 0)
            g.posters[#g.posters + 1] = {
                name = b.posterName, realm = b.posterRealm, faction = b.posterFaction,
                guid = b.posterGuid, postedAt = b.postedAt, message = b.message,
            }
            g.ids[#g.ids + 1] = b.id
            -- Keep the newest post's target details (level/class/guild can drift).
            if (b.postedAt or 0) >= g.postedAt then
                g.postedAt    = b.postedAt or g.postedAt
                g.targetLevel = b.targetLevel or g.targetLevel
                g.targetClass = b.targetClass or g.targetClass
                g.classToken  = b.classToken  or g.classToken
                g.targetGuild = b.targetGuild or g.targetGuild
                g.targetRace  = b.targetRace  or g.targetRace
                g.raceFile    = b.raceFile    or g.raceFile
                g.sex         = b.sex         or g.sex
                g.rank        = b.rank        or g.rank
            end
        end
    end
    for _, g in ipairs(order) do
        table.sort(g.posters, function(a, c) return (a.postedAt or 0) > (c.postedAt or 0) end)
    end
    table.sort(order, function(a, c) return (a.postedAt or 0) > (c.postedAt or 0) end)
    return order
end

-- Bounties I should see: ENEMY-faction targets, merged by target (newest first).
function B.GetAvailable()
    if not Ready() then return {} end
    PruneExpired()
    local enemyOf = MyFaction()
    return BuildGroups(function(b)
        return b.targetFaction and b.targetFaction ~= enemyOf
    end)
end

-- Targets I've personally taken up, merged (for the Active Bounties panel).
function B.GetActive()
    if not Ready() then return {} end
    PruneExpired()
    return BuildGroups(function(b)
        return db.acceptedBounties[TargetKey(b)] and true or false
    end)
end

-- Accepts a target key, a group, or a raw bounty.
function B.IsAccepted(keyOrTable)
    if not Ready() then return false end
    local key = (type(keyOrTable) == "table") and (keyOrTable.key or TargetKey(keyOrTable)) or keyOrTable
    return (key and db.acceptedBounties[key]) and true or false
end

-- Count accepted bounties that STILL have a live bounty; drop any that don't. A stale key builds up
-- when a target's identity drifts between accept and abandon (a roster-captured targetGuid resolving
-- to the character's live GUID mid-track, so Abandon deletes the new key and the old one lingers) or
-- when a bounty is cleared/expired out from under an acceptance. That ghost used to fill an "active
-- bounty" slot — blocking new accepts even though the board showed fewer than the cap. Reconciling
-- here keeps the count honest. Safe to run anytime: db.bounties is persisted + only updated
-- incrementally (never wiped), so it's never transiently empty.
local function CountAccepted()
    local live = {}
    for _, b in pairs(db.bounties) do local k = TargetKey(b); if k then live[k] = true end end
    local n = 0
    for key in pairs(db.acceptedBounties) do
        if live[key] then
            n = n + 1
        else
            db.acceptedBounties[key] = nil
            db.trackedBounties[key]  = nil
        end
    end
    return n
end
function B.ActiveCount() return Ready() and CountAccepted() or 0 end
function B.MaxActive()   return MAX_ACTIVE end

-- Take a bounty onto the active board. Returns true, or false + "limit" when full.
-- Tracking is NOT started here — that's the TRACK TARGET button (B.SetTracking).
function B.Accept(group)
    if not Ready() then return false end
    local key = (type(group) == "table") and (group.key or TargetKey(group)) or group
    if not key then return false end
    if type(group) == "table" and group.targetGuid and B.IPostedOn(group.targetGuid) then
        return false, "ownbounty"   -- you can't take your own contract
    end
    if not db.acceptedBounties[key] and CountAccepted() >= MAX_ACTIVE then
        return false, "limit"
    end
    db.acceptedBounties[key] = true
    if WB.UI and WB.UI.RefreshActiveBounties then WB.UI.RefreshActiveBounties() end
    if WB.UI and WB.UI.RefreshBounties      then WB.UI.RefreshBounties()      end
    return true
end

-- Tracking (the TRACK TARGET button): persisted so it resumes after a reload.
function B.IsTracking(keyOrTable)
    if not Ready() then return false end
    local key = (type(keyOrTable) == "table") and (keyOrTable.key or TargetKey(keyOrTable)) or keyOrTable
    return (key and db.trackedBounties[key]) and true or false
end
function B.SetTracking(keyOrTable, on)
    if not Ready() then return end
    local key = (type(keyOrTable) == "table") and (keyOrTable.key or TargetKey(keyOrTable)) or keyOrTable
    if not key then return end
    db.trackedBounties[key] = on and true or nil
end
-- Active groups I have tracking turned on for (drives the re-acknowledge loop).
function B.GetTracked()
    if not Ready() then return {} end
    local out = {}
    for _, g in ipairs(B.GetActive()) do
        if db.trackedBounties[g.key or TargetKey(g)] then out[#out + 1] = g end
    end
    return out
end

-- Abandon: drop a bounty off MY active board + stop tracking it (local only — the
-- bounty itself stays postable/available; this isn't a claim).
function B.Abandon(group)
    if not Ready() then return end
    local key = (type(group) == "table") and (group.key or TargetKey(group)) or group
    if not key then return end
    db.acceptedBounties[key] = nil
    db.trackedBounties[key]  = nil
    if WB.BountyTracker and type(group) == "table" then
        if WB.BountyTracker.StopTracking then WB.BountyTracker.StopTracking(group.targetGuid) end
        -- Proactively tell the target to stop sharing with me (a stray ping would do it
        -- too, but this is immediate).
        if WB.BountyTracker.SendUnack and group.targetGuid then
            local full = (group.targetRealm and group.targetRealm ~= "")
                and (group.targetName .. "-" .. group.targetRealm) or group.targetName
            WB.BountyTracker.SendUnack(group.targetGuid, full)
        end
    end
    if WB.UI and WB.UI.RefreshActiveBounties then WB.UI.RefreshActiveBounties() end
    if WB.UI and WB.UI.RefreshBounties      then WB.UI.RefreshBounties()      end
end

-- Total reward (summed across all posters) + name for the bounty on a target.
function B.GetRewardForTarget(targetGuid)
    if not Ready() or not targetGuid then return 0, 0, nil, nil end
    local rg, rwc, tname, trealm = 0, 0, nil, nil
    for _, b in pairs(db.bounties) do
        if b.targetGuid == targetGuid then
            rg   = rg  + (b.rewardGold or 0)
            rwc  = rwc + (b.rewardWarCredit or 0)
            tname  = tname  or b.targetName
            trealm = trealm or b.targetRealm
        end
    end
    local full = tname and (((trealm and trealm ~= "") and (tname .. "-" .. trealm)) or tname) or nil
    return rg, rwc, tname, full
end

-- Place a bounty on `target` (an entry from Pick a Player). cost/reward come from
-- the rank tier (computed by the UI). Spends `cost` gold (a ledger deduction shown on
-- the Activity feed), stores it locally + broadcasts to peers. Returns the bounty on
-- success, or nil + reason ("duplicate" / "notready" / "broke").
function B.Post(target, message, cost, rewardGold, rewardWC)
    if not Ready() or type(target) ~= "table" then return nil, "notready" end
    if B.HasMyBountyOn(target) then return nil, "duplicate" end
    if target.guid and B.CooldownRemaining(target.guid) > 0 then return nil, "cooldown" end
    -- Can't price a rank this build doesn't define (added by a newer version) — refuse
    -- the post until the player updates, rather than charge/award a wrong amount.
    if not B.IsKnownRank(target.rank or 0) then return nil, "unknownrank" end
    cost = tonumber(cost) or 0
    -- Right after login the balance can read HIGH: cost records a lost save dropped may still be
    -- re-syncing from peers, and spending against that inflated number is the root of every
    -- "minted gold" case (spend → crash → balance looks restored → spend again). Spending waits
    -- until the ledger has settled — a peer's digest matching ours, or the short timeout.
    if cost > 0 and WB.Comm and WB.Comm.LedgerSettled and not WB.Comm.LedgerSettled() then
        return nil, "settling"
    end
    if cost > 0 and WB.WarEffort and WB.WarEffort.GetGold and WB.WarEffort.GetGold() < cost then
        return nil, "broke"
    end
    local bounty = {
        id          = B.NewId(),
        targetName  = target.name,
        targetRealm = target.realm,
        targetGuid  = target.guid,
        targetLevel = target.level,
        targetRace  = target.race,
        raceFile    = target.raceFile,
        sex         = target.sex,
        targetClass = target.class,
        classToken  = target.classToken,   -- locale-independent; UI localizes from this
        targetGuild = target.guild,
        targetFaction = target.faction,
        rank        = target.rank or 0,
        posterName    = UnitNameUnmodified("player"),
        posterRealm   = GetNormalizedRealmName() or "",
        posterFaction = MyFaction(),
        posterGuid    = UnitGUID("player"),
        message     = (message and message ~= "" and message) or nil,
        cost        = cost,
        rewardGold  = rewardGold,
        rewardWarCredit = rewardWC,
        postedAt    = time(),
    }
    B.Add(bounty)
    B.EmitPostRecord(bounty)   -- durable, synced via the ledger (offline catch-up)
    -- If I'd already accepted a contract on this target, posting my own makes me
    -- ineligible — drop my accept + tracking so I can't claim it.
    if bounty.targetGuid and db.acceptedBounties[bounty.targetGuid] then
        db.acceptedBounties[bounty.targetGuid] = nil
        db.trackedBounties[bounty.targetGuid]  = nil
        if WB.BountyTracker and WB.BountyTracker.StopTracking then WB.BountyTracker.StopTracking(bounty.targetGuid) end
    end
    if WB.Comm and WB.Comm.BroadcastBounty then WB.Comm.BroadcastBounty(bounty) end
    if WB.Alerts and WB.Alerts.AddBountyPosted then WB.Alerts.AddBountyPosted(bounty) end
    if B.NotifyPosted then B.NotifyPosted(bounty) end   -- sound + confirmation for the poster too
    -- Spend the placement cost (deduction shown on the Activity feed).
    if cost > 0 and WB.WarEffort and WB.WarEffort.RecordBountyCost then
        WB.WarEffort.RecordBountyCost(bounty.targetName, cost)
    end
    return bounty
end

-- ── New-bounty alert (sound + chat) ────────────────────────────────────────────────
-- When a fresh bounty is posted, ping the HUNTING faction (the poster's side) that can pick it
-- up: a one-line chat notice and an Ashran artifact sting themed to the bounty's TARGET faction.
-- Fired both for the POSTER (locally from B.Post, as a confirmation that they get to hear too)
-- and for hunters who RECEIVE it (Comm.HandleBounty). The TARGET's own faction is never alerted.
local BOUNTY_FRESH = 300   -- match the Alerts feed: ignore catch-up/backfill on login
local BOUNTY_SOUND = { Alliance = 46851, Horde = 46850 }   -- SoundKit fx-ashran-artifact-<target faction>
function B.NotifyPosted(b)
    if type(b) ~= "table" or not b.id then return end
    if (time() - (tonumber(b.postedAt) or 0)) > BOUNTY_FRESH then return end   -- stale/backfill → no ping
    -- Only the hunting faction (opposite the target) is alerted; the target's faction is not.
    if not b.targetFaction or b.targetFaction == MyFaction() then return end
    local target = (WB.SanitizeDisplay and WB.SanitizeDisplay(b.targetName, 32)) or b.targetName or "an enemy"
    local tc = (b.targetFaction == "Alliance") and "|cff3399ff" or "|cffff4040"
    if b.posterGuid and b.posterGuid == UnitGUID("player") then
        print(("%s You placed a bounty on %s%s|r!"):format(WB.TAG, tc, target))            -- our own post
    else
        local poster = (WB.SanitizeDisplay and WB.SanitizeDisplay(b.posterName, 32)) or b.posterName or "Someone"
        print(("%s |cffffd100%s|r has placed a bounty on %s%s|r!"):format(WB.TAG, poster, tc, target))
    end
    local snd = BOUNTY_SOUND[b.targetFaction]
    if snd then PlaySound(snd, "Master") end
end

-- ── Durable bounty events (synced via the ledger for offline catch-up) ─────────────
-- Posting and claiming are also recorded as ledger records (bounty_post / bounty_claim),
-- which propagate through the SAME gossip + HELLO/SREQ sync as activity records. A
-- player who was offline when a bounty was posted or claimed catches up on login: the
-- rebuild below reconstructs the board (and claim cooldowns) from those records. The
-- live BNTY/BCLR messages stay as fast notifications, not the only source of truth.

-- Reward/cost tiers by target rank (0-11). Shared with the UI's Post panel and the
-- Verifier's deep bounty audit (cost/reward must match the target's rank tier).
-- Gold reward = HALF the post cost (a gold sink — the poster funds it, the hunter collects
-- half back); war credit is the real competitive payout.
B.TIERS = {
    [0] = { cost = 500,  gold = 250, wc = 200 },
    [1] = { cost = 750,  gold = 375, wc = 300 },
    [2] = { cost = 1000, gold = 500, wc = 400 },
    [3] = { cost = 1250, gold = 625, wc = 500 },
    [4] = { cost = 1500, gold = 750, wc = 600 },
    [5] = { cost = 1750, gold = 875, wc = 700 },
    [6] = { cost = 2000, gold = 1000, wc = 800 },
    [7] = { cost = 2250, gold = 1125, wc = 900 },
    [8] = { cost = 2500, gold = 1250, wc = 1000 },
    [9] = { cost = 2750, gold = 1375, wc = 1100 },
    [10] = { cost = 3000, gold = 1500, wc = 1200 },
    [11] = { cost = 3250, gold = 1625, wc = 1300 },
}
-- Highest rank tier this build defines. Ranks added by a newer version are UNKNOWN to
-- this one. When you add a rank, append it to B.TIERS, raise B.MAX_RANK, and bump
-- WB.RULESVER so older clients abstain (rather than convict) on the new rank.
B.MAX_RANK = 11

-- Does this build define the given rank's tier? (nil / "No Rank" = rank 0 = known.)
function B.IsKnownRank(rank) return B.TIERS[tonumber(rank) or 0] ~= nil end

-- Tier for a rank. Known rank → its tier. Unknown rank (from a newer version) → the
-- highest tier we know, so an older client still pays a sensible DEFAULT reward instead
-- of nothing. Posting on an unknown rank is refused (B.Post) until the player updates.
function B.RankTier(rank)
    return B.TIERS[tonumber(rank) or 0] or B.TIERS[B.MAX_RANK] or B.TIERS[0]
end

-- An older client can't price a newer rank, so it caps an unknown-rank bounty's reward
-- at the top tier it knows — never paying out MORE than it can justify. We only clamp
-- DOWN: the poster (who must know the rank to post) set the true reward; a legit higher
-- reward simply pays this safe default here until we update. No-op for known ranks.
function B.ClampUnknownRankReward(b)
    if type(b) ~= "table" or B.IsKnownRank(b.rank) then return b end
    local def = B.RankTier(b.rank)   -- the top known tier
    if def then
        if (tonumber(b.rewardGold) or 0)      > def.gold then b.rewardGold      = def.gold end
        if (tonumber(b.rewardWarCredit) or 0) > def.wc   then b.rewardWarCredit = def.wc   end
    end
    return b
end

-- Reconstruct a bounty object from a bounty_post ledger record.
function B.BountyFromRecord(rec)
    if type(rec) ~= "table" or not rec.bountyId then return nil end
    return B.ClampUnknownRankReward({
        id = rec.bountyId,
        targetName = rec.targetName, targetRealm = rec.targetRealm, targetGuid = rec.targetGuid,
        targetLevel = rec.targetLevel, targetRace = rec.targetRace, raceFile = rec.raceFile,
        sex = rec.sex, targetClass = rec.targetClass, classToken = rec.classToken, targetGuild = rec.targetGuild,
        targetFaction = rec.targetFaction, rank = rec.rank,
        posterName = rec.name, posterRealm = rec.realm, posterGuid = rec.guid, posterFaction = rec.faction,
        message = rec.message, cost = rec.cost,
        rewardGold = rec.rewardGold, rewardWarCredit = rec.rewardWarCredit,
        postedAt = rec.t,
    })
end

-- Emit a durable bounty_post record (alongside the live BNTY message). gold/wc = 0:
-- the placement COST is a separate bounty_cost record; this is board metadata only.
function B.EmitPostRecord(b)
    if type(b) ~= "table" or not (WB.Ledger and WB.WarEffort and WB.WarEffort.SubmitRecord) then return end
    WB.WarEffort.SubmitRecord({
        id = WB.Ledger.NewId(), atype = "bounty_post",
        name = b.posterName, realm = b.posterRealm, guid = b.posterGuid, faction = b.posterFaction,
        bountyId = b.id,
        targetName = b.targetName, targetRealm = b.targetRealm, targetGuid = b.targetGuid,
        targetFaction = b.targetFaction, rank = b.rank,
        targetLevel = b.targetLevel, targetRace = b.targetRace, raceFile = b.raceFile,
        sex = b.sex, targetClass = b.targetClass, classToken = b.classToken, targetGuild = b.targetGuild,
        cost = b.cost, rewardGold = b.rewardGold, rewardWarCredit = b.rewardWarCredit,
        message = b.message, gold = 0, warCredit = 0, t = b.postedAt or time(),
    }, true)
end

-- Emit a durable bounty_claim record: pays the claimer (gold/wc) AND carries the clear
-- (targetGuid + clearedBountyIds), so every client — including offline ones on next
-- login — drops the consumed bounties and starts the 24h cooldown.
function B.EmitClaimRecord(targetGuid, targetName, targetRealm, clearedBountyIds, rewardGold, rewardWC)
    if not (WB.Ledger and WB.WarEffort and WB.WarEffort.SubmitRecord) then return end
    local name, realm, guid = WB.Ledger.MyIdentity()
    WB.WarEffort.SubmitRecord({
        id = WB.Ledger.NewId(), atype = "bounty_claim",
        name = name, realm = realm, guid = guid, faction = MyFaction(),
        targetGuid = targetGuid, targetName = targetName, targetRealm = targetRealm,
        clearedBountyIds = clearedBountyIds or {},
        rewardGold = rewardGold, rewardWarCredit = rewardWC,
        gold = math.floor(tonumber(rewardGold) or 0),       -- claimer payout (the currency)
        warCredit = math.floor(tonumber(rewardWC) or 0),
        obj = targetName, t = time(),
    }, true)
end

-- Claim every bounty on a target: emit the durable claim record + return the reward.
-- (Local board cleanup — cooldown + removal — is the caller's OnClear.)
function B.Claim(targetGuid)
    if not Ready() or not targetGuid then return 0, 0, nil end
    local rg, rwc, tname, trealm, ids = 0, 0, nil, nil, {}
    for id, b in pairs(db.bounties) do
        if b.targetGuid == targetGuid then
            rg = rg + (b.rewardGold or 0); rwc = rwc + (b.rewardWarCredit or 0)
            tname = tname or b.targetName; trealm = trealm or b.targetRealm
            ids[#ids + 1] = id
        end
    end
    B.EmitClaimRecord(targetGuid, tname, trealm, ids, rg, rwc)
    return rg, rwc, tname
end

-- Guids of everyone who has posted a live bounty on ME (so a port-out reward never goes to a
-- poster — the Verifier rejects a claim by a poster of the bounty it consumes).
function B.PostersOnMe()
    if not Ready() then return {} end
    local myGuid, set = UnitGUID("player"), {}
    for _, b in pairs(db.bounties) do
        if b.targetGuid == myGuid and b.posterGuid then set[b.posterGuid] = true end
    end
    return set
end

-- I port-out-escaped WHILE bountied: award the summed bounty on me to `winner` (a random tracker).
-- Emits a claim record crediting the WINNER (not me), so it pays them + clears the bounty for
-- everyone via sync, then drops it off my board + starts the re-bounty cooldown. Returns rg, rwc
-- (or nil if there was no live bounty on me).
function B.AwardEscapedBounty(winner)
    if not Ready() or type(winner) ~= "table" or not winner.guid then return nil end
    local myGuid = UnitGUID("player")
    local myName, myRealm = UnitNameUnmodified("player"), GetNormalizedRealmName() or ""
    local rg, rwc, ids = 0, 0, {}
    for id, b in pairs(db.bounties) do
        if b.targetGuid == myGuid then
            rg = rg + (b.rewardGold or 0); rwc = rwc + (b.rewardWarCredit or 0)
            ids[#ids + 1] = id
        end
    end
    if #ids == 0 then return nil end
    local winnerFaction = (MyFaction() == "Alliance") and "Horde" or "Alliance"   -- bounties are cross-faction
    local rec = WB.Ledger.MakeRecord({
        id = WB.Ledger.NewId(), atype = "bounty_claim",
        name = winner.name, realm = winner.realm or "", guid = winner.guid, faction = winnerFaction,
        targetGuid = myGuid, targetName = myName, targetRealm = myRealm,
        clearedBountyIds = ids, rewardGold = rg, rewardWarCredit = rwc,
        gold = math.floor(rg), warCredit = math.floor(rwc), obj = myName, t = time(),
    })
    if WB.WarEffort and WB.WarEffort.RecordForeignClaim then WB.WarEffort.RecordForeignClaim(rec) end
    B.RemoveByTarget(myGuid)   -- off my board + re-bounty cooldown
    if WB.Comm and WB.Comm.BroadcastBountyClear then WB.Comm.BroadcastBountyClear(myGuid, nil, myName) end
    return rg, rwc
end

-- Deterministic rebuild of the board from ledger events. Additive for posts,
-- subtractive for claims/clears (never fights the live BNTY flow), idempotent. This is
-- what catches up posts/clears (and cooldowns) missed while offline.
function B.RebuildFromLedger()
    if not Ready() or not (WB.Ledger and WB.Ledger.All) then return end
    local cutoff = CampaignCutoff()

    -- 1) Gather clears: per-bountyId + per-target clear times; refresh claim cooldowns.
    local clearedIds, clearedTargetAt = {}, {}
    for _, rec in ipairs(WB.Ledger.All()) do
        if (rec.atype == "bounty_claim" or rec.atype == "bounty_clear") and rec.st ~= "Q" then
            local at = tonumber(rec.t) or time()
            if rec.targetGuid then
                if at > (clearedTargetAt[rec.targetGuid] or 0) then clearedTargetAt[rec.targetGuid] = at end
                local cur = db.bountyCooldowns[rec.targetGuid]
                if not cur or at > cur then db.bountyCooldowns[rec.targetGuid] = at end
            end
            if type(rec.clearedBountyIds) == "table" then
                for _, bid in ipairs(rec.clearedBountyIds) do
                    if at > (clearedIds[bid] or 0) then clearedIds[bid] = at end
                end
            end
        end
    end
    local function IsCleared(bountyId, targetGuid, postedAt)
        if clearedIds[bountyId] then return true end
        local ta = clearedTargetAt[targetGuid]
        return ta ~= nil and (postedAt or 0) <= ta
    end

    -- 2) Add this-campaign bounty_post records that haven't been cleared.
    for _, rec in ipairs(WB.Ledger.All()) do
        if rec.atype == "bounty_post" and rec.st ~= "Q" and (rec.t or 0) >= cutoff then
            local bid = rec.bountyId
            if bid and not db.bounties[bid] and not IsCleared(bid, rec.targetGuid, rec.t) then
                local b = B.BountyFromRecord(rec)
                if b then db.bounties[bid] = b end
            end
        end
    end

    -- 3) Remove any live bounty (incl. BNTY-added) consumed by a clear.
    for id, b in pairs(db.bounties) do
        if IsCleared(id, b.targetGuid, b.postedAt) then
            db.bounties[id] = nil
            local tk = TargetKey(b)
            if tk then db.acceptedBounties[tk] = nil; db.trackedBounties[tk] = nil end
        end
    end

    PruneExpired()
    if WB.UI and WB.UI.RefreshBounties       then WB.UI.RefreshBounties()       end
    if WB.UI and WB.UI.RefreshActiveBounties then WB.UI.RefreshActiveBounties() end
    B.RefreshBountyDebuff()   -- reconciled bounty state (offline catch-up) → sync the debuff
end

-- Coalesced RebuildFromLedger: a sync burst delivers many bounty events back-to-back, and each
-- rebuild is a full O(ledger) replay. Collapse a burst into one rebuild shortly after it settles.
local rebuildPending = false
function B.RebuildFromLedgerSoon()
    if rebuildPending then return end
    rebuildPending = true
    -- The reconcile is a full O(ledger) replay and its triggers (synced bounty events) arrive DURING
    -- fights — so once debounced, it also waits out combat. Safe to delay: the live BNTY/BCLR
    -- handlers already update the board incrementally; this pass only reconciles stragglers.
    local function fire()
        if UnitAffectingCombat("player") then C_Timer.After(2, fire); return end
        rebuildPending = false
        B.RebuildFromLedger()
    end
    C_Timer.After(0.3, fire)
end

function B.Wipe()
    if not Ready() then return end
    wipe(db.bounties)
    wipe(db.acceptedBounties)
    wipe(db.trackedBounties)
    wipe(db.bountyCooldowns)
    if WB.BountyTracker and WB.BountyTracker.ClearAll then WB.BountyTracker.ClearAll() end
    if WB.UI and WB.UI.RefreshBounties       then WB.UI.RefreshBounties()       end
    if WB.UI and WB.UI.RefreshActiveBounties then WB.UI.RefreshActiveBounties() end
    B.RefreshBountyDebuff()
end

do
    local f = CreateFrame("Frame")
    f:RegisterEvent("ADDON_LOADED")
    f:RegisterEvent("PLAYER_LOGIN")
    f:RegisterEvent("PLAYER_ENTERING_WORLD")   -- login + every zone/instance transition
    f:SetScript("OnEvent", function(self, event, name)
        if event == "ADDON_LOADED" then
            if name == addonName then EnsureDB(); self:UnregisterEvent("ADDON_LOADED") end
        elseif event == "PLAYER_LOGIN" then
            -- Rebuild the board from synced ledger events after sync has had time to
            -- settle — catches up bounties/clears/cooldowns missed while offline.
            if C_Timer then
                C_Timer.After(12, B.RebuildFromLedger)
                C_Timer.After(35, B.RebuildFromLedger)
            end
        elseif event == "PLAYER_ENTERING_WORLD" then
            -- Instance gate + re-apply: entering a BG/arena/dungeon/etc. clears the bounty debuff
            -- (War Board is off there); coming back out restores it if I'm still bountied. A tiny
            -- delay lets IsInInstance() settle after the loading screen.
            if C_Timer then C_Timer.After(1, B.RefreshBountyDebuff) else B.RefreshBountyDebuff() end
        end
    end)
end
