-- WarEffort_Tracker.lua
-- Detects War Effort objective progress from kills and persists it per character.
--
--   Player-kill objectives (no npcIDs): an enemy-player killing blow by YOU or a
--       PARTY MEMBER, while you're in the objective's zone, counts.
--   NPC-kill objectives (npcIDs = {...}): a killing blow on one of those creature
--       IDs by you or a party member counts.
--   Your own qualifying killing blows are also tracked separately (myKills) for
--       the leaderboard later.
--
-- Uses the PARTY_KILL event — the same proven approach as JARVIS's KillTracker /
-- SurvivalProtocol (it fires with (attackerGUID, targetGUID) on killing blows by
-- you or a party member, and avoids the combat-log taint pitfalls).

local addonName, WB = ...

WB.WarEffort = WB.WarEffort or {}
local WE = WB.WarEffort

-- ── Saved progress (per character) ────────────────────────────────────────────
local db  -- bound to WarBoardCharDB on ADDON_LOADED

-- Repeat completions pay this fraction of an objective's original reward.
local REPEAT_FACTOR = 0.10
WE.REPEAT_FACTOR = REPEAT_FACTOR   -- shared with the Verifier (audit recomputes the same legal reward)

local function EnsureDB()
    WarBoardCharDB = WarBoardCharDB or {}
    WarBoardCharDB.progress    = WarBoardCharDB.progress    or {}  -- [id] = kills toward goal (you + party)
    WarBoardCharDB.myKills     = WarBoardCharDB.myKills     or {}  -- [id] = your own qualifying kills (leaderboard), uncapped
    WarBoardCharDB.completions = WarBoardCharDB.completions or {}  -- [id] = times completed; drives repeat reward + "completed" banner
    db = WarBoardCharDB
    -- Gold, war credit, and the Activity feed now live in WB.Ledger (the synced
    -- source of truth) and are DERIVED from records, not stored as raw totals.
end

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

-- War Mode is required to use the addon: with it OFF you earn nothing (no gold, no
-- war credit) and objectives don't progress/complete. Fail closed if the API is
-- missing. (The explanatory text/rule shown to the player is handled elsewhere.)
function WB.IsWarModeOn()
    if not (C_PvP and C_PvP.IsWarModeDesired) then return false end
    local ok, on = pcall(C_PvP.IsWarModeDesired)
    return (ok and on) and true or false
end

-- Push the War Board UI to re-read derived state (currency, activity, war effort).
-- The board is the ONLY consumer of these panels, and each rebuild is O(ledger size) — the
-- Leader Board aggregates every record per player and the Activity feed sorts the whole ledger.
-- Peer sync fires this constantly in the background, so while the board is CLOSED we skip the work
-- and just flag it dirty; WB.UI.Toggle() does one full rebuild when it's next opened. This is the
-- main cure for sync-time micro-stutter (the panels aren't visible anyway, so nothing is lost).
function WE.RefreshUI()
    if not WB.UI then return end
    local frame = WB.UI.frame
    if frame and not frame:IsShown() then WB.UI.refreshDirty = true; return end
    WB.UI.refreshDirty = nil
    if WB.UI.RefreshActivity    then WB.UI.RefreshActivity()    end
    if WB.UI.RefreshCurrency    then WB.UI.RefreshCurrency()    end
    if WB.UI.RefreshWarEffort   then WB.UI.RefreshWarEffort()   end
    if WB.UI.RefreshLeaderboard then WB.UI.RefreshLeaderboard() end
    if WB.UI.RefreshCampaign    then WB.UI.RefreshCampaign()    end
end

-- Coalesced refresh for bursty paths (a sync dump arrives as several batches back-to-back).
-- RefreshUI is a full multi-pass rebuild of every panel, so doing it per batch freezes the
-- frame; this collapses a burst into a single rebuild shortly after it settles. Local
-- player actions keep using RefreshUI directly so they stay instant.
local refreshPending = false
function WE.RefreshUISoon()
    if refreshPending then return end
    refreshPending = true
    C_Timer.After(0.2, function()
        refreshPending = false
        WE.RefreshUI()
    end)
end

-- ── Activity ledger bridge ───────────────────────────────────────────────────
-- Activity records live in WB.Ledger. The tracker builds records and submits
-- them; gold/war credit are rebuilt from the ledger, never trusted saved numbers.

-- Add a record locally and optionally broadcast it. Returns true if it was new.
function WE.SubmitRecord(rec, broadcast)
    if not WB.Ledger then return false end
    -- Deserters bank nothing: silently drop our OWN records that would grant gold or war
    -- credit (objective rewards, pvp kills, etc.). Costs (negative gold), zero-value board
    -- records, and bounty claim/clear (which must still clear the contract) pass through.
    if WB.Deserter and WB.Deserter.IsActive()
       and rec.atype ~= "bounty_claim" and rec.atype ~= "bounty_clear"
       and ((tonumber(rec.gold) or 0) > 0 or (tonumber(rec.warCredit) or 0) > 0) then
        return false
    end
    local isNew = WB.Ledger.Add(rec)
    if isNew then
        -- Grow our lifetime-WC floor by any war credit this record earns, so it shows on the rank
        -- bar / Service Record immediately even while the live ledger total is still below the floor
        -- after the historical roll-back (otherwise new WC stays "underwater" until the ledger
        -- climbs back past the floor). Deserter records were already dropped above, so this only
        -- counts genuinely-banked earnings.
        local earnedWC = tonumber(rec.warCredit) or 0
        if earnedWC > 0 and WB.Roster and WB.Roster.AddLifetimeWC then
            WB.Roster.AddLifetimeWC(earnedWC)
        end
        if broadcast and WB.Comm and WB.Comm.BroadcastRecord then
            WB.Comm.BroadcastRecord(rec)
        end
        WE.RefreshUI()
    end
    return isNew
end

-- A completion by the LOCAL player (carries our identity + GUID; broadcast out).
-- kills = enemies credited for this completion (the objective's goal count).
function WE.RecordCompletion(objId, objTitle, gold, wc, isRepeat, kills)
    if not WB.Ledger then return end
    local name, realm, guid = WB.Ledger.MyIdentity()
    WE.SubmitRecord(WB.Ledger.MakeRecord({
        name = name, realm = realm, guid = guid, faction = PlayerFaction(),
        atype = "objective", objId = objId, obj = objTitle,
        gold = gold, warCredit = wc, kills = kills, isRepeat = isRepeat,
    }), true)
end

-- A Supply Run delivery / stolen-cargo turn-in by the LOCAL player. ONE call = ONE delivered
-- shipment, paying the fixed WB.SUPPLY_REWARD; a stolen-cargo turn-in of N shipments calls this
-- N times. Banked + synced like any other record (feeds gold/WC totals, leaderboard, war bar,
-- Activity feed). objId/title are informational — the Verifier validates "supply_run" purely by
-- the fixed reward, so it never needs an objective-catalog lookup.
function WE.RecordSupplyRun(objId, title)
    if not WB.Ledger then return end
    local rw = WB.SUPPLY_REWARD or { gold = 300, warCredit = 150 }
    -- Active-warfront bonus: a run whose zone is today's warfront pays the same +50% its zone's
    -- War Effort cards get. The zone comes from the static RUNS table by objId (deterministic —
    -- the Verifier recomputes the same multiplier from the record's timestamp). Stolen-cargo
    -- turn-ins pass objId "stolen_cargo" (no zone) and stay at the base reward.
    local mult = 1
    local zone = WB.SupplyRun and WB.SupplyRun.ZoneForId and WB.SupplyRun.ZoneForId(objId)
    if zone and WB.Campaign and WB.Campaign.RewardMultiplier then
        mult = WB.Campaign.RewardMultiplier(zone) or 1
    end
    local gold = math.floor((rw.gold or 0) * mult + 0.5)
    local wc   = math.floor((rw.warCredit or 0) * mult + 0.5)
    local name, realm, guid = WB.Ledger.MyIdentity()
    WE.SubmitRecord(WB.Ledger.MakeRecord({
        name = name, realm = realm, guid = guid, faction = PlayerFaction(),
        atype = "supply_run", objId = objId, obj = title or "Supply Run",
        gold = gold, warCredit = wc, kills = 0,
    }), true)
    return gold, wc
end

-- Add + broadcast a ledger record that credits ANOTHER player (e.g. an escaped bounty awarded to a
-- random tracker). Unlike SubmitRecord this skips the local lifetime-WC bump and the Deserter
-- earning-gate — the reward is the CLAIMER's, not ours; their own client reflects it from the synced
-- record. (Used only for pre-built bounty_claim records that credit rec.guid ~= me.)
function WE.RecordForeignClaim(rec)
    if not (WB.Ledger and type(rec) == "table") then return end
    if WB.Ledger.Add(rec) then
        if WB.Comm and WB.Comm.BroadcastRecord then WB.Comm.BroadcastRecord(rec) end
        WE.RefreshUI()
    end
end

-- Bounty economy (both are ledger records, so they sync + feed gold/war-credit
-- totals/leaderboard and show on the Activity feed). obj = the enemy target's name.
function WE.RecordBountyCost(targetName, cost)
    if not WB.Ledger or not cost or cost == 0 then return end
    local name, realm, guid = WB.Ledger.MyIdentity()
    WE.SubmitRecord(WB.Ledger.MakeRecord({
        name = name, realm = realm, guid = guid, faction = PlayerFaction(),
        atype = "bounty_cost", obj = targetName or "?",
        gold = -math.abs(math.floor(tonumber(cost) or 0)), warCredit = 0,
    }), true)
end

function WE.RecordBountyReward(targetName, rewardGold, rewardWC)
    if not WB.Ledger then return end
    local name, realm, guid = WB.Ledger.MyIdentity()
    WE.SubmitRecord(WB.Ledger.MakeRecord({
        name = name, realm = realm, guid = guid, faction = PlayerFaction(),
        atype = "bounty_reward", obj = targetName or "?",
        gold = math.floor(tonumber(rewardGold) or 0), warCredit = math.floor(tonumber(rewardWC) or 0),
    }), true)
end

function WE.GetActivity() return (WB.Ledger and WB.Ledger.GetActivity()) or {} end

-- Current objective progress (read by the UI when it builds the cards).
function WE.GetProgress(id)    return (db and db.progress[id])    or 0 end
function WE.GetMyKills(id)     return (db and db.myKills[id])     or 0 end
function WE.GetCompletions(id) return (db and db.completions[id]) or 0 end
-- "Completed" once it has been finished at least once (drives the card banner).
function WE.IsComplete(id)     return ((db and db.completions[id]) or 0) >= 1 end

-- Display name for an objective: appends " (Repeat)" once it's been completed today (so the
-- map pin and the tracker show e.g. "Battle for the Scar (Repeat)" — clears at the daily reset).
function WE.DisplayTitle(obj)
    if type(obj) ~= "table" then return nil end
    local title = obj.title or "?"
    if obj.id and WE.IsComplete(obj.id) then
        return title .. " (Repeat)"
    end
    return title
end

function WE.DisplayTitleById(id)
    local obj = WE.ById and WE.ById[id]
    return obj and WE.DisplayTitle(obj) or nil
end

-- Active-warfront reward multiplier for an objective (1.5 when its zone is today's
-- warfront, else 1). Shared by the displayed reward and the actual grant.
function WE.WarfrontMultiplier(obj)
    if type(obj) ~= "table" then return 1 end
    if not (WB.Campaign and WB.Campaign.RewardMultiplier) then return 1 end
    local zone = WE.ZoneFromTexture and WE.ZoneFromTexture(obj.texture)
    return WB.Campaign.RewardMultiplier(zone) or 1
end

function WE.GetRewardForObjective(obj)
    if type(obj) ~= "table" then return 0, 0, false end
    local isRepeat = WE.IsComplete(obj.id)
    local factor   = isRepeat and REPEAT_FACTOR or 1
    local mult     = WE.WarfrontMultiplier(obj)
    local gold     = math.floor((obj.gold or 0)      * factor * mult + 0.5)
    local wc       = math.floor((obj.warCredit or 0) * factor * mult + 0.5)
    return gold, wc, isRepeat
end

-- Gold / war credit are rebuilt from the ledger, not trusted saved totals.
function WE.GetGold()
    if not WB.Ledger then return 0 end
    local g = WB.Ledger.MyTotals()
    return g or 0
end
function WE.GetWarCredit()
    if not WB.Ledger then return 0 end
    local _, w = WB.Ledger.MyTotals()
    return w or 0
end

-- ── Helpers ───────────────────────────────────────────────────────────────────

-- NPC id from a "Creature-0-srv-inst-zone-NPCID-spawn" GUID (nil if not a creature).
local function NpcIdFromGUID(guid)
    if type(guid) ~= "string" then return nil end
    local unitType, _, _, _, _, npcID = strsplit("-", guid)
    if unitType == "Creature" or unitType == "Vehicle" then
        return tonumber(npcID)
    end
    return nil
end

local function CurrentZoneName()
    local mapID = C_Map.GetBestMapForUnit("player")
    local info  = mapID and C_Map.GetMapInfo(mapID)
    return info and info.name or nil
end

-- Every name that describes where the player is standing, as a set. A subzone can map
-- to its OWN UiMap (e.g. Atal'Aman inside Zul'Aman), so the best-map name there is the
-- subzone, not the parent — but GetZoneText()/GetRealZoneText() still report the broad
-- zone. Collecting all of them lets a player-kill objective keyed to the parent zone
-- credit kills made anywhere inside it (including its subzones). Empty strings skipped.
local function PlayerZoneNames()
    local names = {}
    local mapID = C_Map.GetBestMapForUnit("player")
    local info  = mapID and C_Map.GetMapInfo(mapID)
    if info and info.name and info.name ~= "" then names[info.name] = true end
    local zt = GetZoneText();     if zt and zt ~= "" then names[zt] = true end
    local rz = GetRealZoneText(); if rz and rz ~= "" then names[rz] = true end
    local sz = GetSubZoneText();  if sz and sz ~= "" then names[sz] = true end
    return names
end

-- ── War Effort ordering by current zone ────────────────────────────────────────
-- "Priority zone" = the objective zone whose cards float to the top of the War Effort
-- list. It is STICKY: it changes only when you ENTER a zone that has objectives for YOUR
-- faction. A zone with none (a city, an instance, an unrelated zone) leaves it unchanged,
-- so the last objective zone's ordering persists until you reach a new one. Persisted
-- across sessions in WarBoardCharDB.priorityZone; the UI reads it via WE.GetPriorityZone.
local function ZoneHasMyObjectives(zone)
    if not zone then return false end
    local myFaction = (WB.faction or UnitFactionGroup("player") or "Horde"):lower()
    for _, obj in ipairs(WE.Objectives or {}) do
        local f = obj.faction and obj.faction:lower()
        if (not f or f == myFaction) and not obj.retired and WB.WarEffort.IsFlipZoneActive(obj) then
            if (WE.ZoneFromTexture and WE.ZoneFromTexture(obj.texture)) == zone then return true end
        end
    end
    return false
end

function WE.GetPriorityZone() return db and db.priorityZone or nil end

-- Re-evaluate the priority zone from the player's current zone. Returns true if it
-- changed (the caller then re-lays-out the War Effort cards).
function WE.UpdatePriorityZone()
    if not db then return false end
    local zone = CurrentZoneName()
    if zone and ZoneHasMyObjectives(zone) and db.priorityZone ~= zone then
        db.priorityZone = zone
        return true
    end
    return false
end

-- True when the player is PHYSICALLY in the given UiMap / zone right now (live — distinct from
-- the sticky priority above). Weekly-flip objectives use this so they float to the top only
-- while you're actually standing in their zone and drop back the moment you leave, instead of
-- staying pinned like a home battlefield. Matches by exact map ID first (Val 2599 / Naigtal
-- 2600), then by any name the player is standing in.
function WE.PlayerInZoneNow(mapID, zoneName)
    local here = C_Map.GetBestMapForUnit("player")
    if mapID and here == mapID then return true end
    if zoneName then
        local names = PlayerZoneNames()
        if names[zoneName] then return true end
    end
    return false
end

local function ForMyFaction(obj)
    if not obj.faction then return true end
    return obj.faction:lower() == (WB.faction or "Horde"):lower()
end

-- Does a player-kill made in (mapID, zoneNames) credit this player-kill objective? SINGLE source of
-- truth for "is this the objective's zone", shared by kill-crediting (OnPartyKill) AND the zone
-- tracker below — so an objective shows in the tracker in exactly the places a kill would count.
--   • obj.mapID pins it to that exact UiMap (an old-world zone sharing the name never counts).
--   • obj.zones (explicit names) ALWAYS count (subzones that report their own map — e.g. Atal'Aman).
--   • the texture-derived zone name counts ONLY when there's no mapID (keeps a mapID-only objective
--     like Eversong collision-proof).
function WE.KillZoneMatch(obj, mapID, zoneNames)
    if obj.mapID and mapID == obj.mapID then return true end
    if zoneNames then
        if not obj.mapID then
            local objZone = WE.ZoneFromTexture and WE.ZoneFromTexture(obj.texture)
            if objZone and zoneNames[objZone] then return true end
        end
        if obj.zones then
            for _, z in ipairs(obj.zones) do
                if zoneNames[z] then return true end
            end
        end
    end
    return false
end

-- A player-kill "front": an enemy-PLAYER objective (no npcIDs) that also isn't a non-kill objective
-- like the foot race (noKillTrack). These are ZONE-WIDE rather than tied to a point, so they show in
-- the tracker the whole time you're anywhere in their zone.
function WE.IsPlayerKillObjective(obj)
    return type(obj) == "table" and not obj.npcIDs and not obj.noKillTrack
end

-- Is the player physically inside this objective's kill-zone right now? Uses KillZoneMatch, so it
-- agrees exactly with where kills count.
function WE.PlayerInObjectiveZone(obj)
    local mapID     = C_Map.GetBestMapForUnit("player")
    local zoneNames = PlayerZoneNames()
    return WE.KillZoneMatch(obj, mapID, zoneNames)
end

-- ── Zone-wide player-kill objectives → the objective tracker ─────────────────────
-- A player-kill front is shown in Blizzard's objective tracker the whole time you're in its zone —
-- no proximity point, no popup, just the tracked quest — and clears when you leave. Mirrors the
-- point-based proximity tracker entries (WarBoard_Proximity.lua), but keyed to the whole zone and
-- re-evaluated on each zone change. Progress ticks flow in through the normal kill path (AddProgress
-- → RefreshObjectiveProgress → Tracker:OnProgress), so we only manage show/hide here. The shown-set
-- guard means AddObjective/RemoveObjective (each a tracker relayout) fire only on real transitions.
local shownZoneObjs = {}   -- [objId] = true while the zone watcher is showing it

-- Tracker progress label from a "Kill N %s players in <zone>." front → "<Enemy> players slain".
local function ZoneKillLabel()
    local enemy = (PlayerFaction() == "Alliance") and "Horde" or "Alliance"
    return enemy .. " players slain"
end

function WE.RefreshZoneObjectives()
    if not db then return end
    -- No earning/objectives in instanced content, and only in War Mode (the addon's base rule), so a
    -- front is trackable only out in the world with War Mode on.
    local trackable = (WB.IsWarModeOn and WB.IsWarModeOn()) and not (WB.InInstance and WB.InInstance())
    for _, obj in ipairs(WE.Objectives or {}) do
        if WE.IsPlayerKillObjective(obj) and ForMyFaction(obj) and not obj.retired
           and WB.WarEffort.IsFlipZoneActive(obj) then
            local show = trackable and WE.PlayerInObjectiveZone(obj)
            if show and not shownZoneObjs[obj.id] then
                shownZoneObjs[obj.id] = true
                if WB.Tracker and WB.Tracker.AddObjective then
                    WB.Tracker:AddObjective(obj.id, WE.DisplayTitle(obj) or obj.title,
                        ZoneKillLabel(), WE.GetProgress(obj.id), obj.goal or 0)
                end
            elseif not show and shownZoneObjs[obj.id] then
                shownZoneObjs[obj.id] = nil
                if WB.Tracker and WB.Tracker.RemoveObjective then
                    WB.Tracker:RemoveObjective(obj.id)
                end
            end
        end
    end
end

-- ── Award progress ─────────────────────────────────────────────────────────────

-- Bank a completion: first one pays the full reward, every repeat pays 10%.
local function GrantCompletion(obj)
    local id = obj.id
    local n  = (db.completions[id] or 0) + 1
    db.completions[id] = n

    -- Quest-completed jingle (SoundKit 619, "questcompleted") on every completion.
    if PlaySound then PlaySound(619, "Master") end

    local first  = (n == 1)
    local factor = first and 1 or REPEAT_FACTOR
    local mult   = WE.WarfrontMultiplier(obj)
    local gold   = math.floor((obj.gold or 0)      * factor * mult + 0.5)
    local wc     = math.floor((obj.warCredit or 0) * factor * mult + 0.5)
    local bonus  = (mult > 1) and "  |cff55ff55(Active Warfront +50%)|r" or ""

    if WB.Deserter and WB.Deserter.IsActive() then
        print((WB.TAG.." %s complete \226\128\148 |cffff3030no reward (deserter)|r."):format(obj.title or id))
    elseif first then
        print((WB.TAG.." %s complete! +%d gold, +%d war credit.%s"):format(obj.title or id, gold, wc, bonus))
    else
        print((WB.TAG.." %s completed again (x%d) \226\128\148 +%d gold, +%d war credit (repeat: 10%%).%s")
            :format(obj.title or id, n, gold, wc, bonus))
    end

    -- Bank it on the synchronized ledger + broadcast to peers. Currency totals are
    -- derived from the ledger, so there is no separate raw accumulation here. Kills
    -- are recorded 0 here: the Leader Board's "Kills" column counts only enemy-PLAYER
    -- killing blows (tracked per kill below), never objective/NPC tallies.
    WE.RecordCompletion(id, obj.title or id, gold, wc, not first, 0)

    -- Roll the live counter back to 0 now that the completion is banked + announced. The
    -- card's "Completed" banner is driven by db.completions (not this number), so it stays
    -- flagged complete while the count reads 0 / goal — the next kill starts a fresh 1 / goal
    -- toward the repeatable version, instead of the card sitting stuck at goal / goal.
    db.progress[id] = 0
end

local function AddProgress(obj, isOwnKill)
    local id = obj.id
    if not id or not db then return end

    -- Leaderboard: every qualifying kill BY YOU, uncapped.
    if isOwnKill then
        db.myKills[id] = (db.myKills[id] or 0) + 1
    end

    -- Objective progress: you + party. Objectives are infinitely repeatable, so a
    -- kill once the goal is already met starts a fresh cycle at 1.
    local goal = obj.goal or 0
    local cur  = db.progress[id] or 0
    if goal > 0 and cur >= goal then
        cur = 0
    end
    cur = cur + 1
    db.progress[id] = cur

    -- Crossing the goal banks a completion (full reward first time, 10% on repeats).
    local completed = false
    if goal > 0 and cur >= goal then
        GrantCompletion(obj)
        completed = true
    end

    -- Raise/refresh an assault alert for NPC objectives we personally strike, warning
    -- the defending faction (broadcast cross-faction via the realm channel). Keyed by
    -- objective + shard, so the same fight on different shards is tracked separately.
    if isOwnKill and obj.npcIDs and WB.Alerts and WB.Alerts.RecordAssault then
        -- Include our realm so cross-realm attackers are unambiguous in the defenders' feed.
        local myName  = UnitNameUnmodified("player")
        local myRealm = GetNormalizedRealmName()
        local place   = obj.alertPlace or (WE.PlaceFromObjective and WE.PlaceFromObjective(obj)) or obj.title
        -- Some Ashenvale NPC IDs/copies are associated with a broader objective roster even
        -- when fought at these supply-run outposts. The attacker's live subzone is authoritative
        -- here, preventing Tweedle from reporting Zoram'gar and Boulian from reporting the lumber camp.
        local subZone = GetSubZoneText and GetSubZoneText()
        if subZone == "Hellscream's Watch" or subZone == "Forest Song" then
            place = subZone
        end
        WB.Alerts.RecordAssault({
            objId           = id,
            place           = place,
            zone            = obj.alertZone or (WE.ZoneFromTexture and WE.ZoneFromTexture(obj.texture)) or nil,
            attacker        = (myRealm and myRealm ~= "") and (myName .. "-" .. myRealm) or myName,
            attackerFaction = PlayerFaction(),
            defenderFaction = (PlayerFaction() == "Alliance") and "Horde" or "Alliance",
            shardId         = WB.ShardDetector and WB.ShardDetector.GetShardID and WB.ShardDetector:GetShardID() or nil,
        })
    end

    -- A completion shows at once; the climbing intermediate ticks are coalesced so rapid/AoE
    -- farming doesn't relayout Blizzard's objective tracker on every single kill.
    if completed then
        if WB.UI and WB.UI.RefreshObjectiveProgress then WB.UI.RefreshObjectiveProgress(id) end
    elseif WB.UI and WB.UI.RefreshObjectiveProgressSoon then
        WB.UI.RefreshObjectiveProgressSoon(id)
    elseif WB.UI and WB.UI.RefreshObjectiveProgress then
        WB.UI.RefreshObjectiveProgress(id)
    end
end

-- Bank ACQUISITION progress (looting a tracked resource, e.g. Ironwood Lumber). `amount` is how
-- many were looted (a stack of xN counts N), default 1. Mirrors the kill AddProgress path minus the
-- kill-only bits (no myKills leaderboard tick, no assault alert): bump progress, and when it crosses
-- the goal bank the completion (reward via the same GrantCompletion → ledger path everything else
-- uses) then cycle for the repeatable version. Called by WarBoard_Acquire.lua. Returns cur, goal, completed.
function WE.NoteAcquire(objId, amount)
    if not db then return end
    local obj = WE.ById and WE.ById[objId]
    if not obj then return 0, 0, false end
    amount = math.max(1, math.floor(tonumber(amount) or 1))

    local goal = obj.goal or 0
    local cur  = db.progress[objId] or 0
    if goal > 0 and cur >= goal then cur = 0 end   -- a finished cycle rolls over to a fresh count
    cur = cur + amount
    db.progress[objId] = cur

    local completed = false
    if goal > 0 and cur >= goal then
        GrantCompletion(obj)   -- banks reward + resets db.progress to 0, flags the card complete
        completed = true
    end

    if WB.UI and WB.UI.RefreshObjectiveProgress then WB.UI.RefreshObjectiveProgress(objId) end
    return cur, goal, completed
end

-- Grant a completion for a NON-kill objective directly (e.g. the Breaking Badlands race, finished
-- through its NPC instead of kills). Banks the reward + records it through the same path a kill
-- completion uses — so the active-warfront bonus, the ledger record, and the card's completed banner
-- all apply — and fills the card's progress so it reads a clean "1/1".
function WE.GrantObjectiveCompletion(objId)
    if not db then return end
    local obj = WE.ById and WE.ById[objId]
    if not obj then return end
    db.progress[objId] = obj.goal or 1
    GrantCompletion(obj)
    if WB.UI and WB.UI.RefreshObjectiveProgress then WB.UI.RefreshObjectiveProgress(objId) end
end

-- ── World PvP kills → flat +1 war credit ───────────────────────────────────────
-- Every enemy-player killing blow by YOU (or your pet) is worth a flat +1 war credit and one kill on
-- the Leader Board — regardless of zone, and on top of any objective progress. Kill credit is NOT a
-- ledger record: it goes to WarBoard_KillTally, a compact per-player/per-campaign aggregate that
-- syncs as one evolving value instead of a stream of records (see that file for why). It still feeds
-- the faction bar, leaderboard, finalization, and personal totals.

-- Push my latest kill tally now (called before a loading screen so recent kills sync promptly).
local function FlushPvPKills()
    if WB.KillTally and WB.KillTally.Flush then WB.KillTally.Flush() end
end
WE.FlushPvPKills = FlushPvPKills

local function NotePvPKill()
    -- While marked a deserter you earn no war credit — the kill is acknowledged, not banked.
    if WB.Deserter and WB.Deserter.IsActive() then
        print(WB.TAG .. " |cff44ff44+1 Kill|r |cff999999(no War Credit \226\128\148 deserter)|r.")
        return
    end
    -- Per-kill confirmation in the player's OWN chat only (a plain print never broadcasts).
    print(WB.TAG .. " |cff44ff44+1 Kill|r and |cffffcc00+1 War Credit|r.")
    if WB.KillTally and WB.KillTally.NoteKill then WB.KillTally.NoteKill() end
end

-- ── PARTY_KILL → objective credit ──────────────────────────────────────────────

-- One creature death can reach us from both PARTY_KILL and the pet combat-log path
-- (below); this dedups so it's credited once. Creature GUIDs are unique per spawn, so a
-- short TTL never blocks a genuine second kill. Players respawn → never deduped here.
local recentNpcDeath = {}   -- [creatureGUID] = GetTime()
local function NpcDeathCounted(guid)
    local t = recentNpcDeath[guid]
    if t and (GetTime() - t) < 5 then return true end
    recentNpcDeath[guid] = GetTime()
    return false
end

-- A group-member's death reaches us only from the group-kill watcher below (Blizzard
-- fires no PARTY_KILL for a groupmate's death). This dedups a player kill so it can never
-- be credited twice for one death — a player can't be re-killed within seconds (corpse +
-- res timer), so a short TTL never blocks a genuine later kill of the same player.
local recentPlayerDeath = {}   -- [playerGUID] = GetTime()
local function PlayerDeathCounted(guid)
    local t = recentPlayerDeath[guid]
    if t and (GetTime() - t) < 5 then return true end
    recentPlayerDeath[guid] = GetTime()
    return false
end

-- forcedOwn = credit this as the player's own kill (used by the pet/guardian path, since
-- a pet's killing blow has the pet — not the player — as attacker).
local function OnPartyKill(attackerGUID, targetGUID, forcedOwn)
    if not db then return end
    if not attackerGUID or not targetGUID then return end
    -- Stand down in any instance (battleground, arena, dungeon, raid, scenario): no
    -- tracking or earning in instanced content. The game also marks combat GUIDs
    -- "secret" in instanced PvP/PvE — reading them would taint us — so bail on those.
    if WB.InInstance() then return end
    if WB.IsSecret(attackerGUID) or WB.IsSecret(targetGUID) then return end
    if not WB.IsWarModeOn() then return end   -- no War Mode → no objective progress/earning

    local playerGUID = UnitGUID("player")
    local petGUID    = UnitGUID("pet")
    local isOwnKill  = forcedOwn
                    or (playerGUID and attackerGUID == playerGUID)
                    or (petGUID    and attackerGUID == petGUID)

    local isPlayerKill = targetGUID:sub(1, 7) == "Player-"
    local npcID        = (not isPlayerKill) and NpcIdFromGUID(targetGUID) or nil
    local zoneNames    = isPlayerKill and PlayerZoneNames() or nil
    local mapID        = isPlayerKill and C_Map.GetBestMapForUnit("player") or nil

    -- Cargo theft follows the same authoritative killing-blow path as player-kill credit.
    -- In particular, ConfirmGroupKill reaches here even though hostile groupmates do not
    -- generate PARTY_KILL, allowing stolen supplies to be taken back in those tests/fights.
    if isPlayerKill and isOwnKill and WB.Cargo and WB.Cargo.OnKillingBlow then
        WB.Cargo.OnKillingBlow(targetGUID)
    end

    -- Same creature death from PARTY_KILL + the pet path → count it once.
    if npcID and NpcDeathCounted(targetGUID) then return end
    -- Same player death from PARTY_KILL + the group-kill watcher → count it once.
    if isPlayerKill and PlayerDeathCounted(targetGUID) then return end

    -- Flat +1 war credit for your own enemy-player killing blows, any zone.
    if isPlayerKill and isOwnKill then NotePvPKill() end

    for _, obj in ipairs(WE.Objectives or {}) do
        if ForMyFaction(obj) and WB.WarEffort.IsFlipZoneActive(obj) and not obj.noKillTrack then
            if obj.npcIDs then
                -- NPC-kill objective: did we slay one of the tracked creatures?
                if npcID then
                    for _, wantID in ipairs(obj.npcIDs) do
                        if npcID == wantID then
                            AddProgress(obj, isOwnKill)
                            break
                        end
                    end
                end
            elseif isPlayerKill then
                -- Player-kill objective: an enemy player slain in the objective's zone. The zone
                -- test lives in WE.KillZoneMatch (shared with the zone tracker, so an objective shows
                -- in exactly the places its kills count). Names come from PlayerZoneNames —
                -- everywhere the player is standing.
                if WE.KillZoneMatch(obj, mapID, zoneNames) then
                    AddProgress(obj, isOwnKill)
                end
            end
        end
    end
end

-- ── Pet / guardian kills (no combat log) ───────────────────────────────────────
-- PARTY_KILL only fires for the player's and party members' killing blows — NOT a pet's
-- or guardian's — so a hunter's Kill Command finisher on a quest NPC would never count.
-- COMBAT_LOG_EVENT_UNFILTERED is forbidden for this addon (ADDON_ACTION_FORBIDDEN on
-- every frame — the same wall JARVIS's KillTracker hit), so instead of reading the combat
-- log we WATCH the pet on a throttled OnUpdate: while our pet is attacking a tracked
-- creature we remember it, and when that creature turns up dead we credit the kill
-- (deduped against PARTY_KILL so the player's own killing blow is never double-counted).
-- Reliable for the normal flow (you target the mob and send the pet); a pure pet-AOE kill
-- on a mob you never target may be missed — an acceptable trade for not touching the log.
local petFighting = {}        -- [creatureGUID] = GetTime() our pet was last attacking it

-- UnitGUID, but only when the result is usable as a table key. The restricted/secret-value
-- system marks some combat GUIDs "secret" (notably "pettarget"), and using a secret value as
-- a table key throws ("cannot be indexed with secret keys") on both read and write. Treat a
-- secret or absent GUID as nil so this bookkeeping skips it instead of faulting every poll.
local function KeyGUID(unit)
    local guid = UnitGUID(unit)
    if guid == nil or WB.IsSecret(guid) then return nil end
    return guid
end

local function ConsiderUnitDeath(unit)
    if not UnitExists(unit) or UnitIsPlayer(unit) or not UnitIsDead(unit) then return end
    local guid = KeyGUID(unit)
    if guid and petFighting[guid] then
        petFighting[guid] = nil
        OnPartyKill(UnitGUID("player"), guid, true)   -- credit as our kill (pet path = own kill)
    end
end

-- ── Group-member kills (no PARTY_KILL fires) ───────────────────────────────────
-- Blizzard awards no honor — and fires no PARTY_KILL — for killing a member of your own
-- group, even an opposite-faction one you're hostile to in War Mode. But because sharding
-- often splits you from a target unless you party up, a genuine fight frequently REQUIRES
-- grouping, so we credit those kills ourselves. Two signals confirm the kill so it pays
-- exactly the player who landed the killing blow — no double-credit in a group fight:
--   1) WE were actually fighting the victim — they were our target/focus while we were in
--      combat (tracked here as "engaged").
--   2) The VICTIM's client reports the killing blow. On death it reads its own Death Recap
--      (C_DeathRecap — NOT the forbidden combat log) for the true finisher's GUID and, if
--      that's a groupmate, broadcasts it to the group (Comm "PKB"). The named killer credits
--      the kill only if they ALSO have the victim engaged — so a 5-on-1 pays just one person,
--      and a stray report can't fabricate credit.
-- Worth a flat +1 war credit (deduped via PlayerDeathCounted). The victim must be running
-- War Board for the report to arrive; if not, the kill simply doesn't pay (never falsely).
-- Non-group enemies are untouched here — their kill fires PARTY_KILL as normal.
local engagedMates = {}   -- [groupmateGUID] = GetTime() last seen as our attackable target in combat

local function ForEachGroupUnit(fn)
    if IsInRaid() then
        for i = 1, GetNumGroupMembers() do fn("raid" .. i) end
    elseif IsInGroup() then
        for i = 1, GetNumSubgroupMembers() do fn("party" .. i) end
    end
end

-- Is this GUID a current group member? (Victim side: only report a groupmate as our killer.)
local function GuidInGroup(guid)
    if not guid or not IsInGroup() then return false end
    local match = false
    ForEachGroupUnit(function(unit)
        if not match and UnitGUID(unit) == guid then match = true end
    end)
    return match
end

-- Remember a live group enemy we're fighting (so we can claim its kill if its own client
-- later reports that we landed the killing blow).
local function NoteEngagedMate(unit)
    if not UnitExists(unit) or not UnitIsPlayer(unit) then return end
    if UnitIsUnit(unit, "player") then return end
    if not (UnitInParty(unit) or UnitInRaid(unit)) then return end   -- group members only
    if not UnitCanAttack("player", unit) then return end             -- hostile (opposite faction, War Mode)
    if UnitIsDeadOrGhost(unit) then return end
    if not UnitAffectingCombat("player") then return end             -- we're actually in the fight
    local guid = KeyGUID(unit)
    if guid then engagedMates[guid] = GetTime() end
end

local function PollGroupKills()
    -- Just keep engagement fresh; the actual credit waits for the victim's killing-blow
    -- report (Comm → WE.ConfirmGroupKill).
    NoteEngagedMate("target")
    NoteEngagedMate("focus")
    NoteEngagedMate("mouseover")
end

-- Comm calls this when a victim's client reports WE landed the killing blow on a groupmate.
-- We credit it only if we were actually fighting them — turning the recap's verdict into a
-- single honest +1. Deduped (PlayerDeathCounted) so one death can never score twice.
function WE.ConfirmGroupKill(victimGUID)
    if not victimGUID or not engagedMates[victimGUID] then return end
    engagedMates[victimGUID] = nil
    OnPartyKill(UnitGUID("player"), victimGUID, true)
end

-- Who landed the killing blow on US, from our own Death Recap (events are newest-first, so
-- events[1] is the finisher). Returns a Player GUID, or nil if environmental / no player /
-- recap unavailable. Mirrors JARVIS's KillTracker — the combat log is forbidden for us.
local function GetLatestRecapID()
    if not C_DeathRecap then return nil end
    local recapID
    for i = 1, 1000 do
        local ev = C_DeathRecap.GetRecapEvents(i)
        if ev and #ev > 0 then recapID = i else break end
    end
    return recapID
end
local function GetKillerFromRecap()
    local recapID = GetLatestRecapID()
    if not recapID then return nil end
    local events = C_DeathRecap.GetRecapEvents(recapID)
    if not events or #events == 0 then return nil end
    if events[1].event == "ENVIRONMENTAL_DAMAGE" then return nil end   -- fell/drowned/etc. — no killer
    for i = 1, #events do
        local caster = events[i].caster or events[i].casterGUID or events[i].sourceGUID
        if type(caster) == "string" and not WB.IsSecret(caster) and caster:sub(1, 7) == "Player-" then
            return caster
        end
    end
    return nil
end

-- On our death, tell the group who finished us — but only if the killer is a groupmate (a
-- non-group enemy already got PARTY_KILL on their end). Their client confirms + credits.
local function ReportOwnDeath()
    if not IsInGroup() then return end
    local killer = GetKillerFromRecap()
    if killer and GuidInGroup(killer) and WB.Comm and WB.Comm.BroadcastKillReport then
        WB.Comm.BroadcastKillReport(killer)
    end
end

local POLL_INTERVAL = 0.2
local pollAccum = 0
local function PetPoll(_, elapsed)
    pollAccum = pollAccum + elapsed
    if pollAccum < POLL_INTERVAL then return end
    pollAccum = 0
    PollGroupKills()   -- group-member kills run regardless of whether we have a pet
    if not UnitExists("pet") then return end
    -- Remember the live creature our pet is attacking.
    if UnitExists("pettarget") and not UnitIsPlayer("pettarget")
       and UnitCanAttack("player", "pettarget") and not UnitIsDead("pettarget") then
        local guid = KeyGUID("pettarget")
        if guid then petFighting[guid] = GetTime() end
    end
    -- Credit any creature our pet was fighting that is now a corpse (target stays selected).
    ConsiderUnitDeath("target")
    ConsiderUnitDeath("pettarget")
    ConsiderUnitDeath("mouseover")
end
local petWatcher = CreateFrame("Frame")
petWatcher:SetScript("OnUpdate", PetPoll)

-- Prune creatures our pet tagged that never died (and stale death-dedup entries).
if C_Timer then
    C_Timer.NewTicker(30, function()
        local now = GetTime()
        for g, t in pairs(petFighting)       do if now - t > 8  then petFighting[g]       = nil end end
        for g, t in pairs(recentNpcDeath)    do if now - t > 10 then recentNpcDeath[g]    = nil end end
        for g, t in pairs(engagedMates)      do if now - t > 10 then engagedMates[g]      = nil end end
        for g, t in pairs(recentPlayerDeath) do if now - t > 10 then recentPlayerDeath[g] = nil end end
    end)
end

-- ── Daily reset ────────────────────────────────────────────────────────────────
-- Objectives are daily by default (daily ~= false). At each WoW daily reset their
-- progress and completion count clear, so the next finish pays the full reward
-- again. Lifetime leaderboard kills (myKills) and banked currency are kept.

local function SecondsUntilDailyReset()
    if C_DateAndTime and C_DateAndTime.GetSecondsUntilDailyReset then
        local ok, secs = pcall(C_DateAndTime.GetSecondsUntilDailyReset)
        if ok and secs and secs > 0 then return secs end
    end
    if GetQuestResetTime then
        local secs = GetQuestResetTime()
        if secs and secs > 0 then return secs end
    end
    return nil
end

local function ResetDailyObjectives()
    if not db then return end
    local any = false
    for _, obj in ipairs(WE.Objectives or {}) do
        if obj.id and obj.daily ~= false then
            if db.progress[obj.id]    then db.progress[obj.id]    = nil; any = true end
            if db.completions[obj.id] then db.completions[obj.id] = nil; any = true end
        end
    end
    if any then
        print(WB.TAG.." daily objectives reset.")
        if WB.UI and WB.UI.RefreshAllObjectives then WB.UI.RefreshAllObjectives() end
        -- Update any zone-tracker line in place so a rolled-back "(Repeat)" title / progress shows.
        if WB.Tracker and WB.Tracker.OnProgress then
            for id in pairs(shownZoneObjs) do WB.Tracker:OnProgress(id) end
        end
        if WB.Alerts and WB.Alerts.AddSystem then
            WB.Alerts.AddSystem("dailyreset:" .. date("%Y-%m-%d"), "Daily Campaign Reset",
                "War Effort daily objectives have reset. Rewards are available again.")
        end
    end
end
WE.ResetDailyObjectives = ResetDailyObjectives  -- exposed for /wb daily testing

local dailyTimerArmed = false
local function ArmDailyTimer()
    local secs = SecondsUntilDailyReset()
    if not secs or not C_Timer then return end
    C_Timer.After(secs + 5, function()
        ResetDailyObjectives()
        db.dailyResetAt = time() + (SecondsUntilDailyReset() or 86400)
        ArmDailyTimer()
    end)
end

-- Catch any reset that elapsed while logged out, then arm the live timer.
local function InitDailyReset()
    if not db then return end
    local now  = time()
    local secs = SecondsUntilDailyReset()
    if db.dailyResetAt and now >= db.dailyResetAt then
        ResetDailyObjectives()
    end
    if secs then db.dailyResetAt = now + secs end
    if not dailyTimerArmed then
        dailyTimerArmed = true
        ArmDailyTimer()
    end
end

-- ── Events ─────────────────────────────────────────────────────────────────────

local f = CreateFrame("Frame")
-- Entering a new zone may change the priority zone → re-order the War Effort cards.
local function OnZoneChanged()
    -- Refresh the sticky priority for regular objectives, then ALWAYS re-lay-out: weekly-flip
    -- cards pin on live position, so they must re-sort even when the sticky zone didn't change
    -- (e.g. leaving Val for a city — UpdatePriorityZone returns false, but the flip card still
    -- has to drop from the top).
    WE.UpdatePriorityZone()
    WE.RefreshZoneObjectives()   -- show/hide zone-wide player-kill fronts in the objective tracker
    if WB.UI and WB.UI.RelayoutObjectives then WB.UI.RelayoutObjectives() end
end

f:RegisterEvent("ADDON_LOADED")
f:RegisterEvent("PLAYER_LOGIN")
f:RegisterEvent("PARTY_KILL")
f:RegisterEvent("PLAYER_DEAD")             -- report who landed the killing blow on us (group kills)
f:RegisterEvent("PLAYER_LEAVING_WORLD")
f:RegisterEvent("ZONE_CHANGED_NEW_AREA")   -- crossed into a new zone
f:RegisterEvent("PLAYER_ENTERING_WORLD")   -- login / reload / loading screen
f:SetScript("OnEvent", function(self, event, arg1, arg2)
    if event == "ADDON_LOADED" then
        if arg1 ~= addonName then return end
        EnsureDB()
        self:UnregisterEvent("ADDON_LOADED")
    elseif event == "PLAYER_LOGIN" then
        EnsureDB()        -- safety net if ADDON_LOADED was missed
        InitDailyReset()  -- quest-reset time is reliable by login
    elseif event == "PARTY_KILL" then
        OnPartyKill(arg1, arg2)  -- arg1 = attackerGUID, arg2 = targetGUID
    elseif event == "PLAYER_DEAD" then
        ReportOwnDeath()         -- tell a groupmate killer they got us (no PARTY_KILL fires for it)
    elseif event == "ZONE_CHANGED_NEW_AREA" or event == "PLAYER_ENTERING_WORLD" then
        OnZoneChanged()
    elseif event == "PLAYER_LEAVING_WORLD" then
        FlushPvPKills()   -- bank any pending kills before a loading screen / logout
    end
end)
