-- WarBoard_BountyTracker.lua
-- Live location tracking for bounties.
--
-- Two roles:
--   * TARGET  — someone has a bounty on you. When a hunter ACKNOWLEDGES (presses TRACK
--               while you're online) you record them and broadcast your position every
--               15s to your acknowledgers — no heartbeat needed, the acknowledge is
--               persistent. Stops while dead, and clears when the bounty is gone.
--   * HUNTER  — you accepted a bounty. Pressing TRACK acknowledges it once (so the
--               target starts sharing) and renders it on the world map. ABANDON (or a
--               ping you no longer want) un-acknowledges, so the target stops sending.
--               Landing the killing blow claims the bounty for everyone.
--
-- Transport is cross-faction addon WHISPERS (which work between community members):
-- the target whispers its position to acknowledgers, who then bounce it on via guild
-- + whispers to the rest of the roster. The roster gates display, so a target's
-- position only ever reaches (and is shown by) the hunters it acknowledged.

local addonName, WB = ...

WB.BountyTracker = WB.BountyTracker or {}
local BT = WB.BountyTracker

local HBD = LibStub and LibStub("HereBeDragons-2.0", true)

-- ── Tunables ─────────────────────────────────────────────────────────────────────
-- TODO (scale): every broadcast currently does BOTH the direct whispers to trackers AND the
-- wide backstop spray (8 peers + guild + channel). Fine at small scale. If the community grows
-- large, decouple them: keep the 15s cadence for the direct-to-tracker whispers, but throttle
-- the wide insurance spray to ~45s (e.g. every 3rd broadcast) to cut redundant network chatter.
local BROADCAST_INTERVAL = 15    -- seconds between a target's position broadcasts
local ACK_COOLDOWN       = 25    -- min seconds between un-acknowledges (anti-spam)
local ONLINE_WINDOW      = 100   -- heard from the target within this many seconds = online
local PRESENCE_WINDOW    = 45    -- seen in the community online list within this many = online
local ATTEMPT_TIMEOUT    = 5     -- seconds to wait for the target to answer each TRACK try
local MAX_TRIES          = 5     -- give up (show "Failed to obtain.") after this many tries
local CLAIM_ASSIST_DELAY = 1.6   -- assists wait this long for a killing-blow claim first

-- ── State ────────────────────────────────────────────────────────────────────────
-- TARGET side: acknowledgers PERSIST (no heartbeat), so tracking survives a reload.
-- Stored in WarBoardCharDB.btSubscribers = [hunterGuid] = "Name-Realm". A hunter is
-- dropped only when they un-acknowledge (abandon, or a ping they no longer want).
local hunting     = {}   -- HUNTER side: [targetGuid] = targetName ("Name-Realm")
local huntedIdent = {}   -- HUNTER side: [targetGuid] = { name = short, realm = normalized } for the ack
local lastRoster  = {}   -- HUNTER side: [targetGuid] = last roster (so a claim can reach them)
local trackedInfo = {}   -- HUNTER side: [targetGuid] = { zone, subZone, shard, rxAt }
local lastUnackAt = {}   -- [targetGuid] = GetTime() of last unack (anti-spam)
local attempt     = {}   -- [targetGuid] = { tries, startedAt, timer }  while handshaking
local failed      = {}   -- [targetGuid] = true  (last TRACK attempt got no response)
local claimedTargets = {} -- [targetGuid] = true  (a kill has been claimed this cycle)
local presenceAt     = {} -- [targetGuid] = time() last seen in the community online list
local lastKnownName   = {} -- [targetGuid] = "Name-Realm"  (kept across failures so we can re-ack)
local lastAutoRetryAt = {} -- [targetGuid] = GetTime() of last auto re-track (throttle)

local function SubsDB()
    WarBoardCharDB = WarBoardCharDB or {}
    WarBoardCharDB.btSubscribers = WarBoardCharDB.btSubscribers or {}
    return WarBoardCharDB.btSubscribers
end

-- ── Position (HereBeDragons world coords, like JARVIS's ally dots) ──────────────────
local function GetMyMapPosition()
    if not HBD then return nil end
    local ok, mapID = pcall(C_Map.GetBestMapForUnit, "player")
    if not ok or not mapID then return nil end
    local ok2, pos = pcall(C_Map.GetPlayerMapPosition, mapID, "player")
    if not ok2 or not pos then return nil end
    local ok3, x, y = pcall(pos.GetXY, pos)
    if not ok3 or not x then return nil end
    local worldX, worldY, instanceID = HBD:GetWorldCoordinatesFromZone(x, y, mapID)
    if not worldX then return nil end
    return worldX, worldY, instanceID
end

local function MyGuid()  return UnitGUID("player") end
local function MyShard()
    return WB.ShardDetector and WB.ShardDetector.GetShardID and WB.ShardDetector:GetShardID() or nil
end
local function FullName(name, realm)
    name = name or "?"
    -- Whisper addresses must use the NORMALIZED realm ("Area52"), but a bounty's stored realm can
    -- come from GetPlayerInfoByGUID, whose realm return is the DISPLAY name ("Area 52"). A spaced
    -- realm makes the ack whisper silently unroutable (and the failure text is hidden by the
    -- offline-whisper chat filter) — the classic "stuck on Locating…" cause.
    realm = (type(realm) == "string") and realm:gsub("%s+", "") or realm
    if realm and realm ~= "" then return name .. "-" .. realm end
    return name
end
local function MyFullName() return FullName(UnitNameUnmodified("player"), GetNormalizedRealmName()) end

-- Debug logging shares the comm's /wb commdebug toggle.
local function btdbg(msg)
    if WB.Comm and WB.Comm.IsDebug and WB.Comm.IsDebug() then
        print("|cff66ccffWBTrack|r " .. msg)
    end
end
local function Short(guid) return guid and tostring(guid):sub(-6) or "nil" end

-- ── TARGET: collect acknowledgers, broadcast position ──────────────────────────────
local ActiveSubscribers, BroadcastMyLocation   -- forward declarations

local lastAckBcast = 0
function BT.OnAck(hunterGuid, hunterName, replyAddr)
    if not hunterGuid then return end
    local subs  = SubsDB()
    local isNew = not subs[hunterGuid]
    -- Prefer the exact comm sender as the whisper-back address. It's the routable string
    -- the client actually received from, so a CROSS-REALM reply rides the client's own
    -- routing cache — a reconstructed "Name-Realm" often won't route cross-realm.
    subs[hunterGuid] = replyAddr or hunterName or subs[hunterGuid] or "?"
    btdbg(("OnAck: hunter %s (%s) isNew=%s addr=%s"):format(
        tostring(hunterName), Short(hunterGuid), tostring(isNew), tostring(replyAddr or hunterName)))
    -- Answer the FIRST ack instantly, and answer retries too (throttled), so a position
    -- dropped in transit gets another chance inside the hunter's handshake window.
    local g = GetTime()
    if isNew or (g - lastAckBcast) >= 5 then
        lastAckBcast = g
        BroadcastMyLocation()
        -- A brand-new hunter has nothing yet: if this single reply is lost in transit they sit on
        -- "Locating…" until a retry or the 15s ticker. Send one quick follow-up so an early drop
        -- doesn't stall acquisition — cheap (one extra whisper per new subscriber).
        if isNew then C_Timer.After(2, BroadcastMyLocation) end
    end
end

-- Drop an acknowledger (they un-acknowledged / abandoned).
function BT.OnUnack(hunterGuid)
    if hunterGuid then SubsDB()[hunterGuid] = nil end
end

-- Roster of acknowledgers: { { guid, name }, ... }.
function ActiveSubscribers()
    local roster = {}
    for guid, name in pairs(SubsDB()) do
        roster[#roster + 1] = { guid = guid, name = name }
    end
    return roster
end

function BroadcastMyLocation()
    if WB.InInstance() then return end   -- no position sharing in instanced content
    if UnitIsDeadOrGhost("player") then btdbg("BroadcastMyLocation: skip (dead)"); return end
    -- Like JARVIS's position ping: if anyone has acknowledged (a hunter only acks
    -- because they hold a real bounty on me), just share my position. We do NOT gate
    -- on having the bounty record locally — we may have missed its one-time broadcast.
    local roster = ActiveSubscribers()
    if #roster == 0 then return end                          -- nobody acknowledged → nobody wants it
    local worldX, worldY, iID = GetMyMapPosition()
    if not worldX then btdbg("BroadcastMyLocation: skip (no map position — HBD/instance?)"); return end
    local b = WB.Bounties and WB.Bounties.GetBountyOnMe and WB.Bounties.GetBountyOnMe()
    local rank = (b and b.rank) or 0   -- rank for the icon if we happen to have the record
    btdbg(("BroadcastMyLocation: sending pos to %d acknowledger(s), zone=%s"):format(#roster, tostring(GetZoneText())))
    if WB.Comm and WB.Comm.BroadcastBountyLocation then
        WB.Comm.BroadcastBountyLocation({
            targetGuid  = MyGuid(),
            targetName  = UnitNameUnmodified("player"),
            targetRealm = GetNormalizedRealmName() or "",
            faction     = WB.faction or UnitFactionGroup("player") or "Horde",
            rank        = rank,
            worldX = worldX, worldY = worldY, iID = iID,
            shard    = MyShard(), t = time(),
            warMode  = (WB.IsWarModeOn and WB.IsWarModeOn()) and true or false,
            zoneName = GetZoneText(),       -- e.g. "Ashenvale"
            subZone  = GetSubZoneText(),    -- e.g. "Zoram Strand"
            roster   = roster,              -- who to whisper, and who they can bounce to
        })
    end
    -- Share my "warm bucket" of recently-seen enemies with my hunters (for the eye popup).
    if WB.Comm and WB.Comm.BroadcastBountySightings and WB.Sightings and WB.Sightings.GetRecent then
        local sightings = WB.Sightings.GetRecent()
        if #sightings > 0 then
            WB.Comm.BroadcastBountySightings({
                targetGuid = MyGuid(), shard = MyShard(),
                list = sightings, t = time(), roster = roster,
            })
        end
    end
end

-- ── HUNTER: acknowledge targets, render them, claim on kill ─────────────────────────
-- Whisper the target directly (cross-faction works between community members).
local function DoAck(targetGuid)
    local targetName = hunting[targetGuid]
    if not targetName then btdbg("DoAck: skip (not tracking " .. Short(targetGuid) .. ")"); return end
    -- Whisper the ack to BOTH plausible addresses: the stored bounty name AND the target's
    -- current roster name resolved by GUID (Comm.NameForGuid). Either can be the broken one —
    -- the stored realm can be stale / display-form, the roster form can differ — and a bad
    -- whisper fails SILENTLY (the offline-whisper filter eats the error), so sending both is
    -- the only way one bad address can't strand the handshake on "Locating…". One extra
    -- whisper per try; the target dedups by hunterGuid.
    local resolved = WB.Comm and WB.Comm.NameForGuid and WB.Comm.NameForGuid(targetGuid)
    local names = { targetName }
    if resolved and resolved ~= targetName then names[#names + 1] = resolved end
    btdbg(("DoAck: ack target %s (%s) via [%s]"):format(
        tostring(targetName), Short(targetGuid), table.concat(names, ", ")))
    if WB.Comm and WB.Comm.BroadcastBountyAck then
        -- Carry the target's name+realm so their client can recognize the ack by name even when
        -- our stored targetGuid ≠ their current character GUID (roster-captured guid / an alt) —
        -- HandleBAck matches GUID *or* name+realm, mirroring GetBountyOnMe. Without this a stale
        -- guid makes the target silently ignore the ack and never share its position.
        local ident = huntedIdent[targetGuid]
        WB.Comm.BroadcastBountyAck({
            targetGuid = targetGuid, hunterGuid = MyGuid(), hunterName = MyFullName(),
            targetName = ident and ident.name, targetRealm = ident and ident.realm,
        }, names)
    end
end

-- Tell the target to stop sending to me (abandon, or a ping I no longer want).
function BT.SendUnack(targetGuid, targetName)
    if not targetGuid or not targetName then return end
    local now = GetTime()
    if lastUnackAt[targetGuid] and (now - lastUnackAt[targetGuid]) < ACK_COOLDOWN then return end
    lastUnackAt[targetGuid] = now
    if WB.Comm and WB.Comm.BroadcastBountyUnack then
        WB.Comm.BroadcastBountyUnack({ targetGuid = targetGuid, hunterGuid = MyGuid() }, targetName)
    end
end

local function CancelAttempt(guid)
    local a = attempt[guid]
    if a and a.timer then a.timer:Cancel() end
    attempt[guid] = nil
end

-- The TRACK handshake gave up: revert to untracked + flag the failure for the card.
local function FailAttempt(guid)
    local name = hunting[guid]
    CancelAttempt(guid)
    hunting[guid]     = nil
    trackedInfo[guid] = nil
    lastRoster[guid]  = nil
    failed[guid]      = true
    if WB.Bounties and WB.Bounties.SetTracking then WB.Bounties.SetTracking(guid, false) end
    -- In case the target DID subscribe us but the position never arrived, release it.
    if name and WB.Comm and WB.Comm.BroadcastBountyUnack then
        WB.Comm.BroadcastBountyUnack({ targetGuid = guid, hunterGuid = MyGuid() }, name)
    end
    if WB.UI and WB.UI.RefreshActiveBounties then WB.UI.RefreshActiveBounties() end
end

local function CheckAttempt(guid)
    local a = attempt[guid]
    if not a then return end
    local info = trackedInfo[guid]
    if info and (info.rxAt or 0) >= a.startedAt then   -- a fresh position arrived → success
        btdbg("CheckAttempt: SUCCESS for " .. Short(guid))
        CancelAttempt(guid)
        return
    end
    a.tries = a.tries + 1
    if a.tries > MAX_TRIES then
        btdbg("CheckAttempt: give up on " .. Short(guid) .. " (no position after " .. MAX_TRIES .. " tries)")
        FailAttempt(guid)
    else
        btdbg(("CheckAttempt: no position yet for %s, retry %d/%d"):format(Short(guid), a.tries, MAX_TRIES))
        DoAck(guid)
        a.timer = C_Timer.NewTimer(ATTEMPT_TIMEOUT, function() CheckAttempt(guid) end)
    end
end

local function StartAttempt(guid)
    attempt[guid] = { tries = 1, startedAt = time() }
    DoAck(guid)
    attempt[guid].timer = C_Timer.NewTimer(ATTEMPT_TIMEOUT, function() CheckAttempt(guid) end)
end

-- TRACK button. Begins a handshake: ack, then retry up to MAX_TRIES; if the target
-- never answers it gives up ("Failed to obtain."). No-ops while one is in flight or
-- already actively tracking, which doubles as the anti-spam guard.
function BT.StartHunting(group)
    if type(group) ~= "table" or not group.targetGuid then return end
    local guid = group.targetGuid
    btdbg(("TRACK pressed: target %s (%s)"):format(tostring(group.targetName), Short(guid)))
    if attempt[guid] then btdbg("  (already handshaking — ignored)"); return end  -- already handshaking
    local info = trackedInfo[guid]
    if hunting[guid] and info and (time() - (info.rxAt or 0)) < ONLINE_WINDOW then
        return                                                          -- already actively tracking
    end
    failed[guid]     = nil
    lastUnackAt[guid] = nil
    claimedTargets[guid] = nil
    hunting[guid]    = FullName(group.targetName, group.targetRealm)
    huntedIdent[guid] = { name = group.targetName, realm = group.targetRealm }
    lastKnownName[guid] = hunting[guid]
    StartAttempt(guid)
end

-- Card state: "idle" | "trying" | "tracking" | "failed".
--   trying  = a TRACK handshake is in flight (no position yet)
--   tracking = hunted (got a position, or resumed after reload awaiting pings)
function BT.GetTrackState(guid)
    if not guid then return "idle" end
    if hunting[guid] then
        if trackedInfo[guid] then return "tracking" end
        if attempt[guid]     then return "trying"   end
        return "tracking"
    end
    if failed[guid] then
        -- If the community list shows them online we keep retrying to locate them, so surface
        -- "trying" (Locating…) rather than a dead "failed"/"Failed to obtain."
        local onlinePres = presenceAt[guid] and (time() - presenceAt[guid]) < PRESENCE_WINDOW
        return onlinePres and "trying" or "failed"
    end
    return "idle"
end

-- Rebuild the hunted set from saved tracking after a reload. No ack is sent — the
-- target keeps us as a persistent acknowledger and keeps sending; we just need to
-- recognise its pings. (Re-acknowledging is the manual TRACK button.)
local function RebuildHunting()
    wipe(hunting)
    if not (WB.Bounties and WB.Bounties.GetTracked) then return end
    for _, g in ipairs(WB.Bounties.GetTracked()) do
        if g.targetGuid then
            hunting[g.targetGuid] = FullName(g.targetName, g.targetRealm)
            huntedIdent[g.targetGuid] = { name = g.targetName, realm = g.targetRealm }
            lastKnownName[g.targetGuid] = hunting[g.targetGuid]
        end
    end
end

-- A target's position arrived (the comm layer already verified I'm an acknowledger
-- and deduped it). Cache the roster so a claim can reach the other hunters, and draw.
function BT.OnLocation(data)
    if type(data) ~= "table" or not data.targetGuid then return end
    -- A ping for something I no longer track (e.g. abandoned): tell them to stop, drop it.
    if not hunting[data.targetGuid] then
        btdbg("OnLocation: got position for " .. Short(data.targetGuid) .. " but I'm NOT tracking it → unack")
        BT.SendUnack(data.targetGuid, FullName(data.targetName, data.targetRealm))
        return
    end
    -- Supply Runner / stolen-cargo flags are opt-in TRACKs, but the runner's position keeps
    -- streaming over the (persistent) bounty transport even after their run ends. When a run
    -- fails / delivers / loses the cargo, their "ended" alert clears them from WB.Cargo — so if
    -- this target still carries a cargo ROLE yet is no longer a known carrier, they've stopped
    -- being a supply runner. Drop the role; and unless they're ALSO a live bounty, stop tracking
    -- + unack so the map flag disappears and they stop sending to us.
    local role = WB.Cargo and WB.Cargo.RoleFor and WB.Cargo.RoleFor(data.targetGuid)
    if role and not (WB.Cargo.IsKnownCarrier and WB.Cargo.IsKnownCarrier(data.targetGuid)) then
        if WB.Cargo.ClearRole then WB.Cargo.ClearRole(data.targetGuid) end
        local stillBounty = WB.Bounties and WB.Bounties.IsAccepted and WB.Bounties.IsAccepted(data.targetGuid)
        if not stillBounty then
            btdbg("OnLocation: " .. Short(data.targetGuid) .. " is no longer a supply runner → stop tracking")
            BT.SendUnack(data.targetGuid, FullName(data.targetName, data.targetRealm))
            BT.StopTracking(data.targetGuid)
            return
        end
    end
    btdbg(("OnLocation: position for %s (%s) accepted, zone=%s"):format(
        tostring(data.targetName), Short(data.targetGuid), tostring(data.zoneName)))
    if data.roster then lastRoster[data.targetGuid] = data.roster end
    lastKnownName[data.targetGuid] = FullName(data.targetName, data.targetRealm)
    trackedInfo[data.targetGuid] = {
        zone = data.zoneName, subZone = data.subZone, shard = data.shard,
        warMode = data.warMode, rxAt = time(),
    }
    failed[data.targetGuid] = nil
    claimedTargets[data.targetGuid] = nil   -- a fresh tracking cycle can be claimed again
    CancelAttempt(data.targetGuid)   -- the handshake succeeded
    if WB.Bounties and WB.Bounties.SetTracking then WB.Bounties.SetTracking(data.targetGuid, true) end
    if WB.MapOverlay and WB.MapOverlay.UpdateTarget then
        WB.MapOverlay:UpdateTarget(data)
    end
    if WB.UI and WB.UI.RefreshActiveBounties then WB.UI.RefreshActiveBounties() end
end

-- A failed target reappeared in the community online list → quietly re-run the TRACK
-- handshake (throttled). We only auto-retry targets whose USER-initiated track FAILED
-- (failed[guid] set), so an abandoned bounty is never silently re-tracked.
local function AutoRetry(guid)
    local name = lastKnownName[guid]
    if not name then return end
    if attempt[guid] then return end                          -- already handshaking
    local g = GetTime()
    if lastAutoRetryAt[guid] and (g - lastAutoRetryAt[guid]) < 30 then return end
    lastAutoRetryAt[guid] = g
    failed[guid]  = nil
    hunting[guid] = name
    btdbg("AutoRetry: re-tracking " .. Short(guid) .. " (seen online)")
    StartAttempt(guid)
end

-- A bounty target was seen in the community online list (called from GetOnlinePeers).
-- Flips its card Online even before a position arrives.
function BT.NotePresence(guid)
    if not guid then return end
    if not (WB.Bounties and WB.Bounties.IsAccepted and WB.Bounties.IsAccepted(guid)) then return end
    local wasOnline = presenceAt[guid] and (time() - presenceAt[guid]) < PRESENCE_WINDOW
    presenceAt[guid] = time()
    if not wasOnline and WB.UI and WB.UI.RefreshActiveBounties then
        WB.UI.RefreshActiveBounties()   -- just came online → reflect it immediately
    end
    -- If a prior TRACK gave up but they're clearly online now, quietly try again.
    if failed[guid] then AutoRetry(guid) end
end

-- Live tracking info for a target's Active Bounties card. Online if we've heard a
-- recent position OR seen the target in the community online list.
function BT.GetTargetInfo(targetGuid)
    if not targetGuid then return nil end
    local now        = time()
    local info       = trackedInfo[targetGuid]
    local onlineLoc  = info and (now - (info.rxAt or 0)) < ONLINE_WINDOW
    local onlinePres = presenceAt[targetGuid] and (now - presenceAt[targetGuid]) < PRESENCE_WINDOW
    local online     = (onlineLoc or onlinePres) and true or false
    if not info and not online then return nil end
    return {
        zone = info and info.zone, subZone = info and info.subZone, shard = info and info.shard,
        warMode = info and info.warMode, online = online,
    }
end

-- Stop tracking a single target (the ABANDON button / a claim).
function BT.StopTracking(targetGuid)
    if not targetGuid then return end
    CancelAttempt(targetGuid)
    hunting[targetGuid]     = nil
    huntedIdent[targetGuid] = nil
    lastRoster[targetGuid]  = nil
    trackedInfo[targetGuid] = nil
    failed[targetGuid]      = nil
    presenceAt[targetGuid]  = nil
    lastKnownName[targetGuid]   = nil
    lastAutoRetryAt[targetGuid] = nil
    if WB.MapOverlay and WB.MapOverlay.RemoveTarget then WB.MapOverlay:RemoveTarget(targetGuid) end
end

-- A bounty was claimed/cleared: drop it everywhere it shows.
function BT.OnClear(targetGuid)
    if not targetGuid then return end
    CancelAttempt(targetGuid)
    claimedTargets[targetGuid] = true   -- a claim landed → any pending assist claim backs off
    hunting[targetGuid] = nil
    huntedIdent[targetGuid] = nil
    lastRoster[targetGuid] = nil
    trackedInfo[targetGuid] = nil
    lastUnackAt[targetGuid] = nil
    failed[targetGuid] = nil
    presenceAt[targetGuid] = nil
    lastKnownName[targetGuid]   = nil
    lastAutoRetryAt[targetGuid] = nil
    if targetGuid == MyGuid() then wipe(SubsDB()) end        -- I was the target
    -- A clear means the target was claimed → start its 24h re-bounty cooldown.
    if WB.Bounties and WB.Bounties.RecordClaimCooldown then WB.Bounties.RecordClaimCooldown(targetGuid) end
    if WB.Bounties and WB.Bounties.RemoveByTarget then WB.Bounties.RemoveByTarget(targetGuid) end
    if WB.MapOverlay and WB.MapOverlay.RemoveTarget then WB.MapOverlay:RemoveTarget(targetGuid) end
end

function BT.ClearAll()
    for guid in pairs(attempt) do CancelAttempt(guid) end
    wipe(SubsDB())
    wipe(hunting)
    wipe(huntedIdent)
    wipe(lastRoster)
    wipe(trackedInfo)
    wipe(lastUnackAt)
    wipe(failed)
    wipe(claimedTargets)
    wipe(presenceAt)
    wipe(lastKnownName)
    wipe(lastAutoRetryAt)
    if WB.MapOverlay and WB.MapOverlay.RemoveTarget then WB.MapOverlay:RemoveTarget(nil) end
end

-- ── Claim (killing blow + party-assist rule) ───────────────────────────────────────
-- One contract payout per kill. Priority:
--   1) addon user who landed the killing blow,
--   2) an addon user in the killer's party/raid who accepted the bounty (covers a
--      non-addon party member getting the killing blow),
--   3) among multiple assisting hunters, the killing blow wins.
-- Resolved without a server: PARTY_KILL fires on every party member's client. The
-- killing-blow hunter claims instantly; assists wait CLAIM_ASSIST_DELAY and back off
-- if a claim (BCLR) has already landed. A claim is one payout to the claimer.

local function HashGuid(g)
    local h = 0
    if type(g) == "string" then for i = 1, #g do h = (h + g:byte(i)) % 9973 end end
    return h
end

local function AcceptedTarget(targetGuid)
    return WB.Bounties and WB.Bounties.IsAccepted and WB.Bounties.IsAccepted(targetGuid)
end

local function ClaimBounty(targetGuid)
    if claimedTargets[targetGuid] then return end
    claimedTargets[targetGuid] = true
    -- Durable claim: a synced ledger record (bounty_claim) that pays me AND clears the
    -- bounty for everyone — including players offline right now, who catch up on login.
    local rg, rwc, tname = 0, 0, nil
    if WB.Bounties and WB.Bounties.Claim then
        rg, rwc, tname = WB.Bounties.Claim(targetGuid)
    end
    if WB.Comm and WB.Comm.BroadcastBountyClear then     -- fast live notification (backstop)
        WB.Comm.BroadcastBountyClear(targetGuid, lastRoster[targetGuid], hunting[targetGuid] or tname)
    end
    BT.OnClear(targetGuid)
    print((WB.TAG.." bounty on %s claimed!  +%d gold, +%d war credit.")
        :format(tname or "target", rg or 0, rwc or 0))
end

local function OnPartyKill(attackerGUID, targetGUID)
    if not targetGUID then return end
    if WB.InInstance() then return end   -- no bounty claims in instanced content
    if WB.IsSecret(targetGUID) or WB.IsSecret(attackerGUID) then return end
    if claimedTargets[targetGUID] then return end
    if WB.IsWarModeOn and not WB.IsWarModeOn() then return end  -- no War Mode → no bounty reward
    if WB.Bounties and WB.Bounties.IPostedOn and WB.Bounties.IPostedOn(targetGUID) then return end  -- can't claim my own contract
    if not AcceptedTarget(targetGUID) then return end          -- I must have accepted this bounty
    local me, pet = MyGuid(), UnitGUID("pet")
    local myKill  = (attackerGUID == me) or (pet and attackerGUID == pet)
    if myKill then
        ClaimBounty(targetGUID)                                -- priority 1: I landed the blow
    else
        -- Priority 2: a party member (possibly a non-addon player) got the blow. Let a
        -- killing-blow addon hunter claim first; if none does, I claim.
        local delay = CLAIM_ASSIST_DELAY + (HashGuid(me) % 8) * 0.1
        C_Timer.After(delay, function()
            if not claimedTargets[targetGUID] and AcceptedTarget(targetGUID) then
                ClaimBounty(targetGUID)
            end
        end)
    end
end

-- ── Status (testing aid: /wb track) ─────────────────────────────────────────────────
function BT.Status()
    local b = WB.Bounties and WB.Bounties.GetBountyOnMe and WB.Bounties.GetBountyOnMe()
    print("|cffff8800WarBoard tracker|r:")
    print("  bounty on me: " .. (b and "YES" or "no") .. (b and (" (rank " .. tostring(b.rank or 0) .. ")") or ""))
    print("  acknowledgers (hunters tracking me): " .. tostring(#ActiveSubscribers()))
    local n = 0; for _ in pairs(hunting) do n = n + 1 end
    print("  targets I'm hunting: " .. n)
end

-- Drop a fake enemy bounty icon at your current position (testing aid: /wb tracktest).
function BT.TestSpawn()
    local worldX, worldY, iID = GetMyMapPosition()
    if not worldX then print(WB.TAG.." no world position (are you on an outdoor map?)."); return end
    if WB.MapOverlay and WB.MapOverlay.UpdateTarget then
        WB.MapOverlay:UpdateTarget({
            targetGuid = "TEST-" .. time(),
            targetName = "TestTarget",
            faction    = (WB.faction == "Alliance") and "Horde" or "Alliance",
            rank       = 0,
            worldX = worldX, worldY = worldY, iID = iID,
            shard  = MyShard(),
        })
        print(WB.TAG.." spawned a test bounty icon at your position \226\128\148 open the world map.")
    end
end

-- ── Init ─────────────────────────────────────────────────────────────────────────
local f = CreateFrame("Frame")
f:RegisterEvent("PLAYER_LOGIN")
f:RegisterEvent("PARTY_KILL")
f:SetScript("OnEvent", function(_, event, a1, a2)
    if event == "PLAYER_LOGIN" then
        C_Timer.NewTicker(BROADCAST_INTERVAL, BroadcastMyLocation)  -- TARGET: share position
        C_Timer.After(3, RebuildHunting)                           -- HUNTER: recognise tracked targets
        -- HUNTER: poll the community online list so a target's card flips Online as
        -- soon as it appears there (GetOnlinePeers flags bounties via NotePresence),
        -- and re-render so it flips back Offline when it drops off the list.
        C_Timer.NewTicker(15, function()
            -- Nothing accepted and nothing being hunted → no card can flip Online/Offline, so
            -- skip the roster walk + re-render entirely (it's pure overhead for most players).
            local active = WB.Bounties and WB.Bounties.GetActive and WB.Bounties.GetActive()
            if (not active or #active == 0) and not next(hunting) then return end
            if WB.Comm and WB.Comm.GetOnlinePeers then WB.Comm.GetOnlinePeers() end
            if WB.UI and WB.UI.RefreshActiveBounties then WB.UI.RefreshActiveBounties() end
        end)
    elseif event == "PARTY_KILL" then
        OnPartyKill(a1, a2)          -- a1 = attackerGUID, a2 = targetGUID
    end
end)
