-- WarBoard_Cargo.lua
-- Supply Runner / stolen-cargo INTEL, opt-in tracking, and CARGO THEFT.
--
-- Kept separate from WarBoard_SupplyRun.lua (which owns the LOCAL player's DAILY run):
--   * RUNNER / CARRIER broadcast — while you're carrying (a daily supply run OR stolen cargo),
--     broadcast a lightweight alert so the ENEMY faction sees it; a silent refresh keeps it fresh
--     for late logins. NO precise location is shared by default.
--   * VIEWER tracking — enemies get the alert; clicking TRACK opts THAT viewer into live location,
--     reusing the bounty transport (which streams a target's position to any acknowledger). The
--     map shows a distinct icon (priority: stolen cargo > supply runner > bounty).
--   * THEFT — landing the killing blow on an active enemy carrier steals their cargo: you become a
--     stolen-cargo carrier, subject to the same no-mount rule, and can turn it in with NO cooldown.
--
-- Durability = LOCAL persistence (your carried cargo survives /reload) + live comm alerts.
-- Turn-ins BANK real synced rewards: each delivered shipment writes a "supply_run" ledger record
-- (the same fixed WB.SUPPLY_REWARD a daily run pays), validated by the Verifier and counted toward
-- gold/WC totals, the leaderboard, and the war bar.

local addonName, WB = ...
WB.Cargo = WB.Cargo or {}
local C = WB.Cargo

local REFRESH_INTERVAL = 60   -- silent re-broadcast cadence while carrying
-- A turned-in stolen shipment pays the SAME as completing a Supply Run (WB.SUPPLY_REWARD),
-- per shipment. Single source of truth lives in Core so the run/cargo layers and the Verifier
-- all agree; the local alias just keeps older references working.
C.REWARD = WB.SUPPLY_REWARD or { gold = 300, warCredit = 150 }

local function MyFaction() return WB.faction or UnitFactionGroup("player") or "Horde" end
local function Shard()
    return WB.ShardDetector and WB.ShardDetector.GetShardID and WB.ShardDetector:GetShardID() or nil
end
local function AlertId(guid) return "supply:" .. tostring(guid) end

-- ── Stolen-cargo carrier state (persistent — survives /reload) ────────────────────
-- A carrier can hold MULTIPLE stolen shipments; each is one record.
local function CargoDB()
    WarBoardCharDB = WarBoardCharDB or {}
    WarBoardCharDB.stolenCargo = WarBoardCharDB.stolenCargo or {}   -- array of { id, fromGuid, fromRunner, fromFaction, stolenAt }
    return WarBoardCharDB.stolenCargo
end
function C.CarriedCount() return #CargoDB() end
function C.IsCarrying()   return #CargoDB() > 0 end

-- Am I carrying ANYTHING (daily run or stolen cargo), and what's the highest-priority type?
local function AmICarrying()
    return (WB.SupplyRun and WB.SupplyRun:IsActive()) or C.IsCarrying()
end
local function CurrentCarrierType()
    if C.IsCarrying() then return "stolen_cargo" end                       -- stolen outranks the daily run
    if WB.SupplyRun and WB.SupplyRun:IsActive() then return "supply_run" end
    return nil
end
C.CurrentCarrierType = CurrentCarrierType   -- exposed so my position pings can self-report carry state

-- ── Broadcast (RUNNER / CARRIER side) ─────────────────────────────────────────────
local broadcastTicker
local function BuildPayload(carrierType)
    local sub = GetSubZoneText()
    local p = {
        kind          = "supply_runner",
        id            = AlertId(UnitGUID("player")),
        runnerGuid    = UnitGUID("player"),
        runner        = UnitNameUnmodified("player"),
        runnerRealm   = GetNormalizedRealmName() or "",
        runnerFaction = MyFaction(),
        place         = (sub and sub ~= "" and sub) or GetZoneText(),
        zone          = GetZoneText(),
        shardId       = Shard(),
        carrierType   = carrierType or "supply_run",
        cargoCount    = C.CarriedCount(),
        isBounty      = (WB.Bounties and WB.Bounties.GetBountyOnMe and WB.Bounties.GetBountyOnMe()) and true or false,
    }
    if carrierType == "stolen_cargo" then
        -- The victims this cargo was taken from, so a viewer tracking one of them can follow the
        -- cargo to the THIEF instead of losing it at the corpse (tracking continuity).
        local from = {}
        for _, rec in ipairs(CargoDB()) do
            if rec.fromGuid then from[#from + 1] = rec.fromGuid end
        end
        p.stolenFrom = from
    end
    return p
end
local function Broadcast(carrierType)
    if not (WB.Comm and WB.Comm.BroadcastAlert) then return end
    if WB.InInstance and WB.InInstance() then return end
    WB.Comm.BroadcastAlert(BuildPayload(carrierType))
end
local function EndBroadcast()
    if broadcastTicker then broadcastTicker:Cancel(); broadcastTicker = nil end
    if WB.Comm and WB.Comm.BroadcastAlert then WB.Comm.BroadcastAlert(BuildPayload("ended")) end
end

-- ── "Stolen Cargo!" objective (a tracker step ONLY — no War Effort card) ──────────
-- Shown while carrying stolen cargo; the instruction points at the same quartermaster the daily
-- run delivers to. Removed automatically once the cargo is turned in / lost.
local STOLEN_OBJ_ID = "wb_stolen_cargo"
function C.RefreshStolenObjective()
    if not (WB.Tracker and WB.Tracker.AddObjective) then return end
    local n = C.CarriedCount()
    if n > 0 then
        local where = (WB.SupplyRun and WB.SupplyRun.TurnInLocation and WB.SupplyRun.TurnInLocation())
                      or "your quartermaster"
        local label = (n > 1) and ("Deliver %d seized shipments to %s"):format(n, where)
                              or  ("Deliver the seized supplies to %s"):format(where)
        WB.Tracker:AddObjective(STOLEN_OBJ_ID, "Stolen Cargo!", label, 0, 0)
    elseif WB.Tracker.RemoveObjective then
        WB.Tracker:RemoveObjective(STOLEN_OBJ_ID)
    end
end

-- Re-evaluate what I'm carrying and (re)broadcast the matching alert, or end it. Called whenever
-- carrying state changes (run begin/end, cargo stolen/turned-in/lost) and on reload resume.
function C.OnCarryChanged()
    C.RefreshStolenObjective()   -- add/remove the "Stolen Cargo!" tracker step to match the cargo
    C.RefreshCarrierDebuff()     -- show/hide the "Supply Runner" cosmetic debuff to match carry state
    local ct = CurrentCarrierType()
    -- X-mark my faction's turn-in NPC on the map while I'm carrying ANYTHING (daily shipment OR
    -- stolen cargo — both deliver there); clear it once I'm empty-handed (delivered / lost / failed).
    local d  = WB.SupplyRun and WB.SupplyRun.EnsureDef and WB.SupplyRun.EnsureDef()
    local tm = d and d.turnInMarker
    if ct and tm and WB.MapPins and WB.MapPins.ShowXMark then
        WB.MapPins:ShowXMark("supplyturnin", tm.mapID, tm.x, tm.y)
    elseif WB.MapPins and WB.MapPins.HideXMark then
        WB.MapPins:HideXMark("supplyturnin")
    end
    if not ct then EndBroadcast(); return end
    Broadcast(ct)
    if not broadcastTicker then
        broadcastTicker = C_Timer.NewTicker(REFRESH_INTERVAL, function()
            local t = CurrentCarrierType()
            if t then Broadcast(t) else EndBroadcast() end
        end)
    end
end

-- ── "Supply Runner" cosmetic debuff (client-side) ────────────────────────────────
-- While I'm carrying ANYTHING (daily supply run OR stolen cargo — same debuff, no separate cargo-
-- thief one), show a PERMANENT "Supply Runner" fake debuff on my own frame (WB.FakeDebuff — no real
-- aura). Suppressed in instanced content (BG/arena/dungeon/raid/scenario) where War Board stands
-- down — cleared on entry, restored on exit if still carrying. Driven by OnCarryChanged (every
-- carry state change) and the PLAYER_ENTERING_WORLD refresh below (instance gate + re-apply).
function C.RefreshCarrierDebuff()
    local FD = WB.FakeDebuff
    if not FD then return end
    local show = (not (WB.InInstance and WB.InInstance())) and AmICarrying()
    if show then
        FD:Show("supply_runner", {
            name        = "Supply Runner",
            description = "You are carrying supplies for your faction's war effort. Mounting is disabled while this effect is active.",
            icon        = 236350,
            duration    = nil,   -- permanent — until I'm no longer carrying
        })
    else
        FD:Hide("supply_runner")
    end
end

-- ── VIEWER side: receive a remote carrier alert ──────────────────────────────────
-- guid -> { faction, carrierType, name, realm } of enemy carriers we currently know about
-- (from alerts). Drives both the map role and the "can I steal from this kill?" check.
C.knownCarriers = C.knownCarriers or {}
C.robbedNotified = C.robbedNotified or {}   -- [thiefGuid] = true once we've told the victim who robbed them

-- A known carrier expires this long after their LAST alert. A runner re-alerts every REFRESH_INTERVAL
-- (60s) while carrying, so an active carrier stays fresh; one who delivered / lost their cargo stops
-- alerting and ages out. This is what stops a STALE flag (e.g. an "ended" alert lost to comm backup)
-- from making a later killing blow "steal" cargo the victim no longer has.
local CARRIER_TTL = 150
function C.ForgetCarrier(guid) if guid then C.knownCarriers[guid] = nil end end
if C_Timer then
    C_Timer.NewTicker(30, function()
        local now = time()
        for guid, info in pairs(C.knownCarriers) do
            if now - (info.t or 0) > CARRIER_TTL then C.knownCarriers[guid] = nil end
        end
    end)
end

function C.HandleRemoteAlert(a, sender)
    if type(a) ~= "table" then return end
    -- SAME-faction carrier: friendly intel, not a theft target. A daily supply run raises a
    -- "join them and help" alert (the feed shows a JOIN chip instead of TRACK); their "ended"
    -- clears it. Everything below this branch — knownCarriers, steal-on-kill, victim notices,
    -- tracking continuity — is enemy machinery and must never see a friendly carrier.
    if a.runnerFaction and MyFaction() == a.runnerFaction then
        if a.carrierType == "ended" then
            if WB.Alerts and WB.Alerts.RemoveSupplyRunner then
                WB.Alerts.RemoveSupplyRunner(a.id or AlertId(a.runnerGuid))
            end
        elseif a.carrierType == "supply_run" then
            if WB.Alerts and WB.Alerts.AddSupplyRunner then WB.Alerts.AddSupplyRunner(a, true) end
        end
        return
    end
    if a.carrierType == "ended" then
        if a.runnerGuid then
            C.knownCarriers[a.runnerGuid] = nil
            C.robbedNotified[a.runnerGuid] = nil   -- they dropped it → re-notify if they rob me again later
            C.StopTrackingCarrier(a.runnerGuid)   -- drop their now-stale map icon at once (don't wait to time out)
        end
        if WB.Alerts and WB.Alerts.RemoveSupplyRunner then
            WB.Alerts.RemoveSupplyRunner(a.id or AlertId(a.runnerGuid))
        end
        return
    end
    if a.runnerGuid then
        local prev = C.knownCarriers[a.runnerGuid]
        C.knownCarriers[a.runnerGuid] = {
            faction = a.runnerFaction, carrierType = a.carrierType, name = a.runner, realm = a.runnerRealm,
            -- The EXACT comm sender this alert arrived from — the proven-routable address to whisper the
            -- tracking ack back to (the runner's own cross-faction community whisper). Reusing it is what
            -- makes supply-runner tracking as reliable as bounty tracking; without it we could only guess
            -- the address from the payload name, which often isn't the routable form. Keep a prior good
            -- address if this copy came in without a sender (e.g. an odd relay path).
            addr    = (type(sender) == "string" and sender ~= "" and sender) or (prev and prev.addr) or nil,
            t = time(),   -- freshness for the CARRIER_TTL sweep + the kill-steal check
        }
    end
    if WB.Alerts and WB.Alerts.AddSupplyRunner then WB.Alerts.AddSupplyRunner(a, true) end
    -- Auto-retry parity with bounty tracking: the runner re-broadcasts every REFRESH_INTERVAL while
    -- carrying. If we opted into tracking this carrier (C.roles set) but no position has landed yet —
    -- the handshake failed or is stalled — quietly re-run the ack on each refresh. The runner's own
    -- re-broadcast is our retry heartbeat, mirroring how a bounty target reappearing in the roster
    -- re-arms its track. StartHunting no-ops while a handshake is in flight or a position is already
    -- live, so this can never spam or loop, and it prints nothing.
    if a.runnerGuid and C.roles[a.runnerGuid] and WB.BountyTracker and WB.BountyTracker.StartHunting then
        local st = WB.BountyTracker.GetTrackState and WB.BountyTracker.GetTrackState(a.runnerGuid)
        if st ~= "tracking" then
            local info = C.knownCarriers[a.runnerGuid]
            WB.BountyTracker.StartHunting({
                targetGuid = a.runnerGuid, targetName = a.runner, targetRealm = a.runnerRealm,
                preferAddr = (info and info.addr) or sender,
            })
        end
    end
    -- If this stolen cargo was seized from ME, tell me who has it (once per thief, until they drop it).
    if a.carrierType == "stolen_cargo" and a.runnerGuid and type(a.stolenFrom) == "table" then
        local myGuid = UnitGUID("player")
        for _, victimGuid in ipairs(a.stolenFrom) do
            if myGuid and victimGuid == myGuid then
                if not C.robbedNotified[a.runnerGuid] then
                    C.robbedNotified[a.runnerGuid] = true
                    print((WB.TAG .. " Your supply cargo was seized by %s! Cut them down to take it back.")
                        :format(a.runner or "an enemy"))
                end
                break
            end
        end
    end
    -- Tracking continuity: a stolen-cargo alert names the victims it was taken from. If I was
    -- tracking one of them (or just was — recentlyTracked survives the victim's "ended", whichever
    -- arrives first), move my track to the THIEF so the cargo doesn't vanish at the corpse.
    if a.carrierType == "stolen_cargo" and a.runnerGuid and not C.roles[a.runnerGuid]
       and type(a.stolenFrom) == "table" then
        for _, victimGuid in ipairs(a.stolenFrom) do
            local wasTracking = C.roles[victimGuid]
                or (C.recentlyTracked[victimGuid] and (time() - C.recentlyTracked[victimGuid]) < 90)
            if wasTracking and victimGuid ~= a.runnerGuid then
                C.StopTrackingCarrier(victimGuid)
                C.recentlyTracked[victimGuid] = nil   -- consumed → don't re-transfer on the next refresh
                C.TrackAlert(a)                        -- follow the thief
                break
            end
        end
    end
end

-- ── VIEWER side: opt-in tracking (reuses the bounty location transport) ──────────
C.roles = C.roles or {}   -- guid -> "stolen_cargo" | "supply_runner" (for the map icon priority)
C.recentlyTracked = C.recentlyTracked or {}   -- [guid] = time() I last opted into tracking a carrier
function C.RoleFor(guid) return guid and C.roles[guid] or nil end
function C.ClearRole(guid) if guid then C.roles[guid] = nil end end

-- Stop following a carrier we were tracking (they died / delivered / lost the cargo). Clears the
-- map role AND the live track — which removes their world-map icon immediately instead of leaving
-- it to go stale ~130s later. Left alone only if they're ALSO a live bounty (the bounty keeps them).
function C.StopTrackingCarrier(guid)
    if not guid or not C.roles[guid] then return end
    C.roles[guid] = nil
    local stillBounty = WB.Bounties and WB.Bounties.IsAccepted and WB.Bounties.IsAccepted(guid)
    if not stillBounty and WB.BountyTracker and WB.BountyTracker.StopTracking then
        WB.BountyTracker.StopTracking(guid)
    end
end

-- Are we still hearing live carrier alerts for this guid? knownCarriers is populated from each
-- alert and cleared the moment their run ENDS (the "ended" alert), so a tracker can tell an
-- active carrier apart from a stale map flag whose owner has already failed / delivered / lost
-- the cargo — even while the bounty location transport is still streaming their position.
function C.IsKnownCarrier(guid) return (guid and C.knownCarriers[guid]) and true or false end

function C.TrackAlert(al)
    if type(al) ~= "table" then return end
    local guid = al.runnerGuid
    if not guid or guid == "" then
        print(WB.TAG .. " Can't track this carrier — no target id on the alert.")
        return
    end
    C.roles[guid] = (al.carrierType == "stolen_cargo") and "stolen_cargo" or "supply_runner"
    C.recentlyTracked[guid] = time()
    -- Warm the community roster so DoAck's NameForGuid(guid) can resolve a routable address as a
    -- fallback. Bounty tracking gets this for free from the always-running presence poll; a supply
    -- track is reactive (clicked the moment the alert pops), so the roster may be cold. Cheap on a
    -- user click (cached 10s, skipped in combat).
    if WB.Comm and WB.Comm.GetOnlinePeers then WB.Comm.GetOnlinePeers() end
    if WB.BountyTracker and WB.BountyTracker.StartHunting then
        -- Prefer the exact sender we captured from the runner's alert — the most reliable ack address.
        local info = C.knownCarriers[guid]
        WB.BountyTracker.StartHunting({ targetGuid = guid, targetName = al.runner, targetRealm = al.runnerRealm,
            preferAddr = info and info.addr })
        print((WB.TAG .. " Tracking %s — open the map to follow them."):format(al.runner or "the carrier"))
    end
    if WB.UI and WB.UI.RefreshAlerts then WB.UI.RefreshAlerts() end
end

function C.TrackState(al)
    local guid = type(al) == "table" and al.runnerGuid
    if guid and WB.BountyTracker and WB.BountyTracker.GetTrackState then
        return WB.BountyTracker.GetTrackState(guid)
    end
    return "idle"
end

-- JOIN a friendly runner's group (the same-faction alert's chip). Same flow as JARVIS's
-- Call-to-Arms answer: leave the current party first if needed (can't auto-leave an LFG/instance
-- group — just ask anyway), then request an invite by Name-Realm.
function C.JoinAlert(al)
    if type(al) ~= "table" then return end
    local name  = al.runner
    local realm = type(al.runnerRealm) == "string" and al.runnerRealm:gsub("%s+", "") or nil
    local full  = (realm and realm ~= "" and (name .. "-" .. realm)) or name
    if not full or full == "" or not (C_PartyInfo and C_PartyInfo.RequestInviteFromUnit) then
        print(WB.TAG .. " Can't request an invite \226\128\148 no runner name on this alert.")
        return
    end
    if IsInGroup(LE_PARTY_CATEGORY_INSTANCE) then
        C_PartyInfo.RequestInviteFromUnit(full)
    elseif IsInGroup() then
        C_PartyInfo.LeaveParty()
        C_Timer.After(0.5, function() C_PartyInfo.RequestInviteFromUnit(full) end)
    else
        C_PartyInfo.RequestInviteFromUnit(full)
    end
    print((WB.TAG .. " Requested to join |cff2fbf4f%s|r's supply run."):format(name or "the runner"))
end

-- ── THEFT: killing blow on an active enemy carrier steals their cargo ─────────────
-- Lightweight validation: the victim must be a KNOWN active carrier of the OPPOSITE faction.
local function StealFrom(guid, info)
    local db = CargoDB()
    db[#db + 1] = {
        id = "cargo:" .. tostring(guid) .. ":" .. tostring(time()),
        fromGuid = guid, fromRunner = info.name, fromFaction = info.faction, stolenAt = time(),
    }
    C.knownCarriers[guid] = nil   -- they're down; their own client clears their alert
    C.StopTrackingCarrier(guid)   -- I downed them → clear their (now-stale) map icon on my side too
    if WB.SupplyRun and WB.SupplyRun.EnsureCarryWatch then WB.SupplyRun.EnsureCarryWatch() end  -- no mounting now
    C.OnCarryChanged()            -- broadcast my stolen-cargo alert + raise the "Stolen Cargo!" objective
    if WB.Popup and WB.Popup.Show then
        WB.Popup:Show("Stolen Cargo!", "Recovered Supplies", STOLEN_OBJ_ID, 15)
    end
    print((WB.TAG .. " You seized the enemy's cargo from %s! Deliver it to your quartermaster — no daily limit.")
        :format(info.name or "an enemy"))
    if WB.UI and WB.UI.RefreshAlerts then WB.UI.RefreshAlerts() end
end

local function OnKill(attackerGUID, targetGUID)
    if not targetGUID then return end
    if WB.InInstance and WB.InInstance() then return end
    local me, pet = UnitGUID("player"), UnitGUID("pet")
    if attackerGUID ~= me and not (pet and attackerGUID == pet) then return end   -- must be my killing blow
    local info = C.knownCarriers[targetGUID]
    if not info then return end                                                    -- victim wasn't a known carrier
    if info.t and (time() - info.t) > CARRIER_TTL then                             -- stale flag → they already delivered/lost it
        C.knownCarriers[targetGUID] = nil
        return
    end
    if info.faction == MyFaction() then return end                                 -- must be opposite faction
    StealFrom(targetGUID, info)
end

-- ── CARRIER lifecycle: lose / turn in stolen cargo ───────────────────────────────
-- Lose ALL stolen cargo (death, logout, loading screen, taxi). If an enemy landed the killing
-- blow, THEY steal it (their own PARTY_KILL handles the gain) — from our side it's simply gone.
function C.LoseCargo(reason)
    if not C.IsCarrying() then return end
    wipe(CargoDB())
    C.OnCarryChanged()   -- ends my alert (unless I still have a daily run going)
    print(WB.TAG .. " You lost the stolen cargo" .. (reason and (" (" .. reason .. ")") or "") .. ".")
    if WB.UI and WB.UI.RefreshAlerts then WB.UI.RefreshAlerts() end
end

-- Turn stolen cargo in at your quartermaster. NO daily cooldown. Each shipment banks the fixed
-- Supply Run reward (WB.SUPPLY_REWARD) as its own synced "supply_run" ledger record — same as
-- completing a daily run — so the payout, leaderboard, and war bar all count it.
function C.TurnIn()
    local n = C.CarriedCount()
    if n == 0 then return false end
    local rw = WB.SUPPLY_REWARD or { gold = 300, warCredit = 150 }
    wipe(CargoDB())
    C.OnCarryChanged()
    if WB.WarEffort and WB.WarEffort.RecordSupplyRun then
        for _ = 1, n do WB.WarEffort.RecordSupplyRun("stolen_cargo", "Recovered Stolen Supplies") end
    end
    if PlaySound then PlaySound(619, "Master") end   -- "questcompleted" jingle, like objective completions
    print((WB.TAG .. " Delivered %d stolen shipment(s)! (+%d gold, +%d war credit each)")
        :format(n, rw.gold, rw.warCredit))
    if WB.UI and WB.UI.RefreshAlerts then WB.UI.RefreshAlerts() end
    return true
end

-- ── Events ────────────────────────────────────────────────────────────────────────
local ev = CreateFrame("Frame")
ev:RegisterEvent("PLAYER_LOGIN")
ev:RegisterEvent("PLAYER_ENTERING_WORLD")
ev:RegisterEvent("PLAYER_DEAD")
ev:RegisterEvent("PARTY_KILL")
ev:SetScript("OnEvent", function(_, event, a1, a2)
    if event == "PLAYER_LOGIN" then
        if C.IsCarrying() then
            if WB.SupplyRun and WB.SupplyRun.EnsureCarryWatch then WB.SupplyRun.EnsureCarryWatch() end
        end
    elseif event == "PLAYER_ENTERING_WORLD" then
        -- isLogin, isReload = a1, a2. A reload keeps the cargo (and re-announces); a fresh login or
        -- any zone loading screen loses it (matches the daily run's rule).
        if C.IsCarrying() then
            if a2 then C.OnCarryChanged() else C.LoseCargo("left the field") end
        end
        -- Instance gate + re-apply for the carrier debuff (covers supply RUNS, which persist across
        -- zones). A small delay lets IsInInstance() settle after the loading screen.
        if C_Timer then C_Timer.After(1, C.RefreshCarrierDebuff) else C.RefreshCarrierDebuff() end
    elseif event == "PLAYER_DEAD" then
        if C.IsCarrying() then C.LoseCargo("died") end   -- an enemy killer steals it via their PARTY_KILL
    elseif event == "PARTY_KILL" then
        OnKill(a1, a2)   -- a1 = attackerGUID, a2 = targetGUID
    end
end)
