-- 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 ─────────────────────────────────────────────────────────────────────
-- Direct-vs-wide cadence: every broadcast whispers the acknowledged hunters DIRECTLY (raw fast
-- path + AceComm backstop) — that's who the position is for. The WIDE insurance spray (8 bridge
-- peers + guild + group + channel) is redundant-routing insurance only, and doing it on every 5s
-- ping was enough traffic to back up ChatThrottleLib (~11 copies × ~300B every 5s ≈ CTL's entire
-- 800 B/s budget with ONE tracked target). A backed-up CTL queue is what made live tracking stall
-- for a minute and then catch up in a burst. So the wide spray — and the piggybacked sightings
-- share — now rides only every WIDE_EVERY'th tick; the 5s cadence itself stays direct and cheap.
local BROADCAST_INTERVAL = 5     -- seconds between a target's position broadcasts (only while tracked)
local WIDE_EVERY         = 3     -- every Nth broadcast also does the wide insurance spray (~15s)
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 preferAddr  = {}   -- HUNTER side: [targetGuid] = exact comm sender to ack (proven-routable reply
                         -- address, e.g. the sender of a supply-runner alert — see DoAck)
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
        -- A brand-new hunter gets the wide insurance spray (first contact — their direct address
        -- is unproven from OUR side); retry answers go direct-only, they already route.
        BroadcastMyLocation(isNew and true or false)
        -- 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, function() BroadcastMyLocation(false) end) end
    end
end

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

-- A random player who has ACKNOWLEDGED (pressed TRACK) tracking of ME, skipping any guid in
-- `exclude` (used to keep an escaped-bounty reward away from the bounty's own posters). Returns
-- { guid, name, realm } or nil. This is the pool for the port-out bounty award.
function BT.RandomAcknowledger(exclude)
    exclude = exclude or {}
    local list = {}
    for guid, addr in pairs(SubsDB()) do
        if not exclude[guid] then list[#list + 1] = { guid = guid, addr = tostring(addr) } end
    end
    if #list == 0 then return nil end
    local pick = list[math.random(#list)]
    local name, realm = pick.addr:match("^([^%-]+)%-?(.*)$")
    return { guid = pick.guid, name = (name and name ~= "" and name) or pick.addr,
             realm = (realm and realm ~= "" and realm) or nil }
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

-- Backstop only: if the live position read ever fails TRANSIENTLY (a map transition, an HBD
-- lookup gap, a pcall hiccup), re-send the last confirmed fix for a while instead of silently
-- skipping pings — a failed read should never be what silences the stream. In normal outdoor
-- play the read succeeds and this never activates. (This was NOT the cause of the "tracking
-- stalls then catches up" reports — that was the CTL backlog from the every-ping wide spray,
-- plus no hunter-side re-ack; see the tunables note and WatchdogReack.)
local POS_HOLD = 180
local lastGoodPos   -- { x, y, iID, at }

function BroadcastMyLocation(wide)
    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()
    local approx = false
    if worldX then
        lastGoodPos = { x = worldX, y = worldY, iID = iID, at = GetTime() }
    elseif lastGoodPos and (GetTime() - lastGoodPos.at) < POS_HOLD then
        worldX, worldY, iID = lastGoodPos.x, lastGoodPos.y, lastGoodPos.iID   -- indoors: hold the fix
        approx = true
    else
        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%s%s"):format(
        #roster, tostring(GetZoneText()), approx and " (held fix)" or "", wide and " [wide]" or ""))
    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"  (still correct indoors)
            subZone  = GetSubZoneText(),    -- e.g. "Zoram Strand"
            approx   = approx or nil,       -- position is a held last-known fix (indoors)
            roster   = roster,              -- who to whisper, and who they can bounce to
            -- Self-report my carry state so a hunter tracking me as a supply runner has the
            -- authoritative "still carrying?" signal in the position itself (not a separate, laggy
            -- carrier alert). "none" = not carrying → they can end the cargo track cleanly.
            carrierType = (WB.Cargo and WB.Cargo.CurrentCarrierType and WB.Cargo.CurrentCarrierType()) or "none",
        }, wide and true or false)
    end
    -- Share my "warm bucket" of recently-seen enemies with my hunters (for the eye popup).
    -- Rides the WIDE cadence only — it's the bulkiest tracking payload and not latency-critical,
    -- so sending it on every 5s ping was a big slice of the CTL backlog that stalled tracking.
    if wide and 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 EVERY plausible address for this player, most-routable first:
    --   1) preferAddr — the EXACT comm sender we last heard from (a supply-runner alert; a prior
    --      message). It's the address the client actually delivered from, so a reply rides the
    --      client's own routing cache — the single most reliable target, especially cross-realm /
    --      cross-faction. This is why supply-runner tracking (which now captures the alert's sender)
    --      no longer strands on "Locating…" the way it did when it only had the payload name.
    --   2) the stored Name-Realm (bounty record or alert identity),
    --   3) the roster-resolved Name-Realm by GUID (Comm.NameForGuid).
    -- Any one can be the broken one — a stale/display-form realm, a roster form that differs — and a
    -- bad whisper fails SILENTLY (the offline-whisper filter eats the error), so sending every
    -- candidate is the only way one bad address can't strand the handshake. The target dedups acks
    -- by hunterGuid, so the extra whispers are harmless.
    local resolved = WB.Comm and WB.Comm.NameForGuid and WB.Comm.NameForGuid(targetGuid)
    local names, seen = {}, {}
    local function addName(n)
        if type(n) == "string" and n ~= "" and not seen[n] then seen[n] = true; names[#names + 1] = n end
    end
    addName(preferAddr[targetGuid])
    addName(targetName)
    addName(resolved)
    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 }
    -- A proven-routable reply address (the exact sender of the alert that started this track). Kept
    -- across retries; only overwritten when a caller supplies a fresh one, never cleared by a nil.
    if type(group.preferAddr) == "string" and group.preferAddr ~= "" then
        preferAddr[guid] = group.preferAddr
    end
    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. The runner
    -- SELF-REPORTS their carry state in each position (data.carrierType) — the authoritative
    -- signal — so when it says "none", the run is over: drop the cargo role + track (unless they're
    -- also a live bounty). We deliberately do NOT infer "ended" from a missing carrier alert: a
    -- position IS proof the runner is live, and the alert can lag or miss cross-faction, which used
    -- to unack a still-moving runner and FREEZE their map pin. An absent field (pre-update runner)
    -- leaves it to the explicit "ended" alert + the map-overlay staleness purge.
    local role = WB.Cargo and WB.Cargo.RoleFor and WB.Cargo.RoleFor(data.targetGuid)
    if role and data.carrierType == "none" then
        if WB.Cargo.ClearRole then WB.Cargo.ClearRole(data.targetGuid) end
        -- They just told us they're not carrying → forget them as a carrier NOW, so a later killing
        -- blow can't "steal" from a runner who already delivered (don't wait for CARRIER_TTL).
        if WB.Cargo.ForgetCarrier then WB.Cargo.ForgetCarrier(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) .. " self-reports no cargo → 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

-- ── Self-healing keepalive (hunter side) ───────────────────────────────────────────
-- A live track should ping every BROADCAST_INTERVAL. When the stream stalls mid-track (a run of
-- dropped whispers, a CTL backlog on either side, the target reloading), the old behavior was a
-- silent freeze until the user abandoned + re-tracked — which "worked" only because the fresh ack
-- makes the target answer with an immediate position. This is that same nudge, automated: after
-- STALE_REACK_AFTER seconds of silence, quietly re-ack (throttled per target). Past REACK_MAX_AGE
-- we stop nagging — they're clearly offline, and their own ticker resumes the stream when they
-- return (acknowledgers are persistent on the target's side).
local STALE_REACK_AFTER = 20    -- missed ~4 pings → nudge the target
local REACK_MAX_AGE     = 240   -- silent this long = offline; stop nudging (self-heals on their login)
local REACK_THROTTLE    = 30    -- min seconds between nudges per target

local function WatchdogReack()
    local now = time()
    for guid in pairs(hunting) do
        if not attempt[guid] then                       -- never poke a handshake already in flight
            local info = trackedInfo[guid]
            local age  = info and (now - (info.rxAt or 0)) or nil
            local presenceFresh = presenceAt[guid] and (now - presenceAt[guid]) < PRESENCE_WINDOW
            -- Nudge a stalled live track, or a reload-resumed track that's had no ping yet while
            -- the target is visibly online (RebuildHunting deliberately doesn't ack on login).
            local stalled = (age and age >= STALE_REACK_AFTER and age <= REACK_MAX_AGE)
                or (age == nil and presenceFresh)
            if stalled then
                local g = GetTime()
                if not (lastAutoRetryAt[guid] and (g - lastAutoRetryAt[guid]) < REACK_THROTTLE) then
                    lastAutoRetryAt[guid] = g
                    btdbg(("Watchdog: no position from %s for %ss -> re-ack"):format(
                        Short(guid), tostring(age or "?")))
                    DoAck(guid)
                end
            end
        end
    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
    preferAddr[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
    preferAddr[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(preferAddr)
    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
        -- TARGET: share position. Every tick whispers the hunters directly; every WIDE_EVERY'th
        -- also gets the wide insurance spray. NB: a bare NewTicker(x, BroadcastMyLocation) would
        -- pass the TICKER OBJECT as `wide` (truthy → every ping wide) — hence the closure.
        local tick = 0
        C_Timer.NewTicker(BROADCAST_INTERVAL, function()
            tick = tick + 1
            BroadcastMyLocation(tick % WIDE_EVERY == 0)
        end)
        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
            WatchdogReack()   -- nudge stalled live tracks back to life (auto "cancel + re-track")
            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)
