-- WarBoard_Deserter.lua
-- Anti-exploit for World PvP. A player who escapes a fight with a "port-out" teleport is marked a
-- DESERTER — ALWAYS, bounty or not: while marked they bank no gold or war credit, each repeat
-- offence stacks another hour (capped 10h), and it's announced publicly. If a hunter had a bounty
-- on them when they ported, the summed reward is handed to one random ACTIVE TRACKER (never a poster
-- of that bounty), and who received it is announced too.
--
-- Two detection vectors, both self-detected on the offender's OWN client (no server), then announced
-- to peers so hunters see the status; honest clients self-enforce the no-earning penalty (in
-- WarEffort_Tracker's WE.SubmitRecord, which drops earning records while WB.Deserter.IsActive()):
--   1) Quest-abandon teleport (adapted from JARVIS's Survival Protocol): hook C_QuestLog.AbandonQuest
--      — if in combat when a quest is abandoned, open a 5s window; if a loading screen begins inside
--      it (PLAYER_LEAVING_WORLD) and the player re-enters ALIVE (PLAYER_ENTERING_WORLD, not a
--      login/reload, not dead), it was a teleport escape.
--   2) Exit Lorewalking (the bench port-out): watch UNIT_SPELLCAST_SUCCEEDED for spell 460937. That
--      spell is legitimate on its own, so it only counts when cast in PvP combat (see the gate below).

local addonName, WB = ...
WB.Deserter = WB.Deserter or {}
local D = WB.Deserter

local PENALTY_STEP = 3600        -- +1 hour per offence
local PENALTY_MAX  = 10 * 3600   -- never marked more than 10 hours out

local EXIT_LOREWALKING = 460937  -- "Exit Lorewalking": the Lorewalking-bench port-out escape spell
local PVP_GRACE        = 5       -- seconds after PvP combat a port-out still counts as an escape
local SAMPLE_PERIOD    = 0.5     -- how often the "in PvP combat" window is refreshed while fighting

local db

local function EnsureDB()
    WarBoardCharDB = WarBoardCharDB or {}
    db = WarBoardCharDB   -- db.deserterUntil persists my own penalty across reloads
end

-- ── Status queries ───────────────────────────────────────────────────────────────
function D.MyUntil()  return (db and tonumber(db.deserterUntil)) or 0 end
function D.IsActive() return D.MyUntil() > time() end   -- am I currently a deserter?

-- ── Expiry notice (local "you can earn again") ─────────────────────────────────────
local expiryTimer
local function ScheduleExpiryNotice()
    if expiryTimer then expiryTimer:Cancel(); expiryTimer = nil end
    local left = D.MyUntil() - time()
    if left <= 0 or not C_Timer then return end
    expiryTimer = C_Timer.NewTimer(left + 1, function()
        if D.IsActive() then return end   -- got extended again in the meantime
        print(WB.TAG .. " your |cff44ff44deserter status has ended|r \226\128\148 you can earn gold and war credit again.")
        if WB.WarEffort and WB.WarEffort.RefreshUI then WB.WarEffort.RefreshUI() end
    end)
end

-- ── Announce my own status to peers ───────────────────────────────────────────────
local function BroadcastSelf(escaped, hours, awardedTo)
    if not (WB.Comm and WB.Comm.BroadcastDeserter) then return end
    -- COMMUNITY GATE: broadcasting a deserter status — and especially the public port-out SHAME —
    -- is a War Board community feature. A player who isn't in the community is still marked a
    -- deserter LOCALLY (the no-earning penalty self-enforces in WE.SubmitRecord), but their client
    -- never announces it to the network: no public shame, no status sync. Fully client-side.
    if WB.Community and WB.Community.IsMember and not WB.Community.IsMember() then return end
    WB.Comm.BroadcastDeserter({
        guid    = UnitGUID("player"),
        name    = UnitNameUnmodified("player"),
        realm   = GetNormalizedRealmName() or "",
        faction = WB.faction or UnitFactionGroup("player") or "Horde",
        u       = D.MyUntil(),
        escaped = escaped or nil,                  -- fresh port-out escape → public announcement
        hours   = escaped and hours or nil,
        awardedTo = escaped and awardedTo or nil,  -- winner's display name, if a bounty was handed over
    })
end

-- ── Mark myself a deserter (on detection) ──────────────────────────────────────────
local function MarkSelf()
    if not db then EnsureDB() end
    local now  = time()
    local base = math.max(D.MyUntil(), now)                 -- stack onto any remaining time
    db.deserterUntil = math.min(base + PENALTY_STEP, now + PENALTY_MAX)
    local hours = math.max(1, math.floor((db.deserterUntil - now) / 3600 + 0.5))

    -- If a hunter had a bounty on me when I ported, hand the summed reward to a random ACTIVE
    -- TRACKER (never one of the bounty's own posters). Resolved entirely on my client — it's the
    -- one that knows who's tracking me — which pays the winner + clears the bounty via a synced record.
    local awardedTo
    local bountied = WB.Bounties and WB.Bounties.GetBountyOnMe and WB.Bounties.GetBountyOnMe()
    if bountied and WB.BountyTracker and WB.BountyTracker.RandomAcknowledger and WB.Bounties.AwardEscapedBounty then
        local exclude = (WB.Bounties.PostersOnMe and WB.Bounties.PostersOnMe()) or {}
        local winner  = WB.BountyTracker.RandomAcknowledger(exclude)
        if winner and WB.Bounties.AwardEscapedBounty(winner) ~= nil then
            awardedTo = winner.realm and (winner.name .. "-" .. winner.realm) or winner.name
        end
    end

    print(("%s |cffff3030You used a PORT-OUT EXPLOIT to escape|r \226\128\148 marked a DESERTER for ~%d hour(s) (stacks per offense); no gold or war credit while marked."):format(WB.TAG, hours))
    if awardedTo then
        print(("%s Your bounty was handed to |cff44ff44%s|r for fleeing."):format(WB.TAG, awardedTo))
    end
    if RaidNotice_AddMessage and RaidWarningFrame and ChatTypeInfo then
        RaidNotice_AddMessage(RaidWarningFrame, "War Board: Port-out escape detected \226\128\148 marked DESERTER.", ChatTypeInfo["RAID_WARNING"])
    end
    ScheduleExpiryNotice()
    BroadcastSelf(true, hours, awardedTo)   -- public announcement + status
    if WB.WarEffort and WB.WarEffort.RefreshUI then WB.WarEffort.RefreshUI() end
end

-- ── Detection (JARVIS-derived) ─────────────────────────────────────────────────────
local suspiciousAbandonTime    = nil
local suspiciousLoadingPending = false

-- hooksecurefunc fires after AbandonQuest() runs, whatever the source (macro/addon/UI),
-- and only on real abandons, never completions.
if C_QuestLog and C_QuestLog.AbandonQuest then
    hooksecurefunc(C_QuestLog, "AbandonQuest", function()
        if UnitAffectingCombat("player") then
            suspiciousAbandonTime = GetTime()
        end
    end)
end

-- ── Second vector: the "Exit Lorewalking" port-out spell ────────────────────────────
-- Exit Lorewalking is a LEGITIMATE spell (leaving a Lorewalking bench), so unlike the quest-abandon
-- trick it can't be flagged on sight — we only treat it as an escape when it's cast in PvP combat.
-- The port-out itself ENDS combat, so checking "am I in combat" at cast time would miss the very
-- escape we're after; instead we keep a short grace window that's refreshed while I'm fighting a
-- hostile PLAYER, and fire if the cast lands inside it. Enemy is read from target/nameplate units —
-- COMBAT_LOG_EVENT_UNFILTERED is off-limits in this project.
local pvpUntil = 0   -- GetTime() through which I count as "in PvP combat"

local function HostilePlayerInFight()
    if UnitExists("target") and UnitIsPlayer("target")
       and UnitCanAttack("player", "target") and UnitAffectingCombat("target") then
        return true
    end
    if C_NamePlate and C_NamePlate.GetNamePlates then
        for _, np in ipairs(C_NamePlate.GetNamePlates()) do
            local u = np.namePlateUnitToken
            if u and UnitIsPlayer(u) and UnitCanAttack("player", u) and UnitAffectingCombat(u) then
                return true
            end
        end
    end
    return false
end

local function SampleCombat()
    if UnitAffectingCombat("player") and UnitIsPVP("player") and HostilePlayerInFight() then
        pvpUntil = GetTime() + PVP_GRACE
    end
end

-- The sampler runs ONLY while I'm in combat (started/stopped on the REGEN events below), so it costs
-- nothing out of combat; pvpUntil keeps its last stamp for the grace window after combat drops.
local combatSampler
local function StartSampler()
    if combatSampler or not C_Timer then return end
    combatSampler = C_Timer.NewTicker(SAMPLE_PERIOD, SampleCombat)
end
local function StopSampler()
    if combatSampler then combatSampler:Cancel(); combatSampler = nil end
end

local lastSpellMark = 0   -- de-dupes a single escape into one MarkSelf

local f = CreateFrame("Frame")
f:RegisterEvent("ADDON_LOADED")
f:RegisterEvent("PLAYER_LOGIN")
f:RegisterEvent("PLAYER_LEAVING_WORLD")
f:RegisterEvent("PLAYER_ENTERING_WORLD")
f:RegisterUnitEvent("UNIT_SPELLCAST_SUCCEEDED", "player")  -- vector 2: Exit Lorewalking (my own casts)
f:RegisterEvent("PLAYER_REGEN_DISABLED")                   -- combat start → sample PvP state
f:RegisterEvent("PLAYER_REGEN_ENABLED")                    -- combat end  → stop sampling
f:SetScript("OnEvent", function(self, event, a1, a2, a3, a4, a5)
    if event == "ADDON_LOADED" then
        if a1 == addonName then EnsureDB() end

    elseif event == "PLAYER_LOGIN" then
        EnsureDB()
        ScheduleExpiryNotice()
        -- Re-announce a still-active penalty so peers + the bounty board reflect it.
        if C_Timer then C_Timer.After(10, function() if D.IsActive() then BroadcastSelf() end end) end

    elseif event == "PLAYER_LEAVING_WORLD" then
        -- A loading screen is starting. Check the timing window NOW, before it begins, so
        -- the screen's duration never affects the result.
        if suspiciousAbandonTime and (GetTime() - suspiciousAbandonTime) <= 5 then
            suspiciousLoadingPending = true
        end
        suspiciousAbandonTime = nil

    elseif event == "PLAYER_ENTERING_WORLD" then
        local isInitialLogin, isReloadingUi = a1, a2
        if isInitialLogin or isReloadingUi then suspiciousLoadingPending = false; return end
        if not suspiciousLoadingPending then return end
        suspiciousLoadingPending = false
        if UnitIsDeadOrGhost("player") then return end          -- died legitimately, ignore
        MarkSelf()   -- flag on ANY detected port-out escape; the bounty reward inside is conditional

    elseif event == "PLAYER_REGEN_DISABLED" then
        StartSampler(); SampleCombat()   -- entered combat: begin (and take an immediate) PvP sample

    elseif event == "PLAYER_REGEN_ENABLED" then
        StopSampler()                    -- left combat: pvpUntil keeps its last stamp for the grace window

    elseif event == "UNIT_SPELLCAST_SUCCEEDED" then
        -- a1 = unit ("player", unit-filtered); a3 = spellID on Retail (classic put it at a5).
        local spellID = tonumber(a5) or tonumber(a3)
        if spellID ~= EXIT_LOREWALKING then return end
        SampleCombat()                                     -- also capture the exact cast instant
        if GetTime() > pvpUntil then return end            -- not a PvP escape (legit bench exit) → ignore
        if (GetTime() - lastSpellMark) < 3 then return end -- one escape shouldn't double-mark
        lastSpellMark = GetTime()
        MarkSelf()                                         -- same consequence engine as the quest-abandon vector
    end
end)
