-- 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).
function WE.RefreshUI()
    if not WB.UI then return end
    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

-- ── 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
        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

-- 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

-- 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

-- Clear all progress + the ledger (for /wb reset testing).
function WE.ResetProgress()
    if not db then return end
    wipe(db.progress)
    wipe(db.myKills)
    wipe(db.completions)
    if WB.Ledger then WB.Ledger.Wipe() end
    if WB.UI and WB.UI.RefreshAllObjectives then WB.UI.RefreshAllObjectives() end
    WE.RefreshUI()
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 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

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)
end

-- Testing aid (/wb completeact): complete a random objective for your faction AS
-- YOU — runs the real completion path, so it banks a genuine self-record, updates
-- gold / war credit / progress / activity, and gossips it to peers for sync tests.
function WE.CompleteRandomObjective()
    if not db then return nil end
    local myFaction = PlayerFaction():lower()
    local pool = {}
    for _, o in ipairs((WB.WarEffort and WB.WarEffort.Objectives) or {}) do
        local f = o.faction and o.faction:lower()
        if o.id and (not f or f == myFaction) then pool[#pool + 1] = o end
    end
    if #pool == 0 then return nil end
    local obj = pool[math.random(#pool)]
    db.progress[obj.id] = obj.goal or 0          -- show the card as filled to goal
    GrantCompletion(obj)
    if WB.UI and WB.UI.RefreshObjectiveProgress then WB.UI.RefreshObjectiveProgress(obj.id) end
    return obj
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).
    if goal > 0 and cur >= goal then
        GrantCompletion(obj)
    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
        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        = UnitName("player"),
            attackerFaction = PlayerFaction(),
            defenderFaction = (PlayerFaction() == "Alliance") and "Horde" or "Alliance",
            shardId         = WB.ShardDetector and WB.ShardDetector.GetShardID and WB.ShardDetector:GetShardID() or nil,
        })
    end

    if 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) 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

local function ConsiderUnitDeath(unit)
    if not UnitExists(unit) or UnitIsPlayer(unit) or not UnitIsDead(unit) then return end
    local guid = UnitGUID(unit)
    if guid and petFighting[guid] then
        petFighting[guid] = nil
        OnPartyKill(UnitGUID("pet") or UnitGUID("player"), guid, true)   -- credit as our 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 = UnitGUID(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 = UnitGUID("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()
    if WE.UpdatePriorityZone() and 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)
