-- 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 }
    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 = rw.gold, warCredit = rw.warCredit, kills = 0,
    }), true)
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

-- ── 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()
        WB.Alerts.RecordAssault({
            objId           = id,
            place           = obj.alertPlace or (WE.PlaceFromObjective and WE.PlaceFromObjective(obj)) or obj.title,
            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

-- ── 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 counts as one kill on the Leader Board — regardless of zone, and on
-- top of any objective progress. Kills are batched into a single ledger record on a
-- short debounce so a busy session doesn't flood (and evict) the synced ledger.

local pendingKills   = 0
local killFlushArmed = false

local function FlushPvPKills()
    killFlushArmed = false
    local n = pendingKills
    pendingKills = 0
    if n <= 0 or 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 = "pvpkill", obj = "World PvP", gold = 0, warCredit = n, kills = n,
    }), true)
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
    pendingKills = pendingKills + 1
    -- 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 not killFlushArmed and C_Timer then
        killFlushArmed = true
        C_Timer.After(12, FlushPvPKills)
    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

    -- 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) 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.
                --   • obj.mapID pins it to that exact UiMap (so an old-world zone sharing the
                --     name never counts).
                --   • obj.zones (explicit names) ALWAYS count — works alongside a mapID to catch
                --     subzones that report their own map yet name the parent (e.g. Atal'Aman).
                --   • the texture-derived zone name counts ONLY when there's no mapID, so a
                --     mapID-only objective (Eversong) stays collision-proof.
                -- All names come from PlayerZoneNames — everywhere the player is standing.
                local matched = false
                if obj.mapID and mapID == obj.mapID then
                    matched = true
                end
                if not matched and zoneNames then
                    if not obj.mapID then
                        local objZone = WE.ZoneFromTexture and WE.ZoneFromTexture(obj.texture)
                        if objZone and zoneNames[objZone] then matched = true end
                    end
                    if not matched and obj.zones then
                        for _, z in ipairs(obj.zones) do
                            if zoneNames[z] then matched = true; break end
                        end
                    end
                end
                if matched 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
        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()
    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)
