-- WarBoard_Alerts.lua
-- The Alerts intel feed: a live, lightly-throttled log of war events.
--
-- Two shapes of alert:
--   * Assault alerts — a recurring situation ("X under attack") keyed by
--     objective + shard, so the SAME objective on different shards is tracked
--     separately. Continued combat refreshes its timestamp; it ages ACTIVE -> RECENT
--     and finally prunes. Chat warnings are throttled to one per 10 min per key.
--   * Event alerts — point-in-time notices (objective completed, bounty posted,
--     campaign reset, ...) keyed by a unique id so they never duplicate.
--
-- The local player raises assault alerts from their OWN objective-NPC kills and
-- broadcasts them so the defending faction is warned (cross-faction via the realm
-- channel). Completion/bounty/system alerts are raised locally on each client from
-- data it already has, so they need no separate broadcast.

local addonName, WB = ...

WB.Alerts = WB.Alerts or {}
local A = WB.Alerts

-- Categories (drive the feed's colour + tag).
A.CATEGORY = {
    WAR_EFFORT = "war_effort",
    BOUNTY     = "bounty",
    WORLD      = "world",
    SYSTEM     = "system",
}

-- ── Tunables ─────────────────────────────────────────────────────────────────────
local ACTIVE_WINDOW  = 180     -- <= 3 min since last activity  => ACTIVE, else RECENT
local ALERT_TTL      = 1800    -- alerts drop off after 30 min idle
local CHAT_THROTTLE  = 600     -- 10 min between chat warnings per assault key
local BCAST_THROTTLE = 60      -- 1 min between assault re-broadcasts per key
local MAX_ALERTS     = 120

-- ── State ────────────────────────────────────────────────────────────────────────
local db                 -- WarBoardCharDB.alerts  ([id] = alert)
local lastBcastAt = {}   -- assaultKey -> GetTime()  (session only; throttles comm)

local function EnsureDB()
    WarBoardCharDB = WarBoardCharDB or {}
    WarBoardCharDB.alerts     = WarBoardCharDB.alerts     or {}   -- [id] = alert record
    WarBoardCharDB.alertChat  = WarBoardCharDB.alertChat  or {}   -- [key] = time() of last chat warning
    db = WarBoardCharDB
end
local function Ready() if not db then EnsureDB() end return db ~= nil end

-- Sanitize an untrusted (wire) string before display — strips colour/link/texture/kstring
-- escapes so a crafted attacker/place/zone can't inject codes into the chat line or feed.
local function Safe(s, n) if s == nil then return nil end return (WB.SanitizeDisplay and WB.SanitizeDisplay(s, n or 48)) or s end
local function MyFaction() return WB.faction or UnitFactionGroup("player") or "Horde" end
local function EnemyOf(faction) return (faction == "Alliance") and "Horde" or "Alliance" end
local function CurrentShard()
    return WB.ShardDetector and WB.ShardDetector.GetShardID and WB.ShardDetector:GetShardID() or nil
end

local function RefreshUI()
    if WB.UI and WB.UI.RefreshAlerts then WB.UI.RefreshAlerts() end
end

-- Remove alerts past their TTL; cap the total (drop the stalest).
local function Prune()
    if not db then return end
    local now, n = time(), 0
    for id, al in pairs(db.alerts) do
        if (now - (al.lastSeen or 0)) > ALERT_TTL then db.alerts[id] = nil else n = n + 1 end
    end
    while n > MAX_ALERTS do
        local oldestId, oldestT
        for id, al in pairs(db.alerts) do
            if not oldestT or (al.lastSeen or 0) < oldestT then oldestId, oldestT = id, (al.lastSeen or 0) end
        end
        if not oldestId then break end
        db.alerts[oldestId] = nil
        n = n - 1
    end
    -- Once a key's chat throttle has fully elapsed it can't gate anything; drop it.
    for key, ts in pairs(db.alertChat) do
        if (now - ts) > CHAT_THROTTLE then db.alertChat[key] = nil end
    end
end

-- ── Raising alerts ────────────────────────────────────────────────────────────────

-- Assault: combat against an objective's NPCs — or, with opts.gather, an enemy GATHERING a
-- resource at an acquisition objective (e.g. lumber). Same machinery either way. opts:
--   objId, place, zone, attacker, attackerFaction, defenderFaction, shardId [, gather]
-- `fromRemote` = received over comm (don't re-broadcast).
function A.RecordAssault(opts, fromRemote)
    if not Ready() or type(opts) ~= "table" or not opts.objId then return end
    local shard = opts.shardId
    local key   = "assault:" .. tostring(opts.objId) .. ":" .. tostring(shard or "0")
    local now   = time()

    -- The originating client is the ATTACKER. It spreads the alert to the network so
    -- the DEFENDING faction is warned (throttled per key), but never logs/shows it
    -- locally — an assault is intel for the side being attacked, not the aggressor.
    if not fromRemote then
        local g = GetTime()
        if not lastBcastAt[key] or (g - lastBcastAt[key]) >= BCAST_THROTTLE then
            lastBcastAt[key] = g
            if WB.Comm and WB.Comm.BroadcastAlert then
                WB.Comm.BroadcastAlert({
                    kind = "assault", objId = opts.objId, shardId = shard,
                    place = opts.place, zone = opts.zone, attacker = opts.attacker,
                    attackerGuid = opts.attackerGuid or UnitGUID("player"),
                    attackerFaction = opts.attackerFaction, defenderFaction = opts.defenderFaction,
                    gather = opts.gather and true or nil,
                    t = time(),
                })
            end
        end
    end

    -- Only the DEFENDING faction logs + displays the assault.
    if MyFaction() ~= opts.defenderFaction then return end

    local al = db.alerts[key]
    if al then
        al.lastSeen = now
        al.attacker = opts.attacker or al.attacker
        -- A gather alert's place is LIVE (whichever oasis they're in now); assaults keep theirs.
        if opts.gather then al.gather = true; al.place = opts.place or al.place end
    else
        al = {
            id = key, category = A.CATEGORY.WAR_EFFORT, kind = "assault",
            objId = opts.objId, shardId = shard,
            place = opts.place, zone = opts.zone,
            attacker = opts.attacker,
            attackerFaction = opts.attackerFaction,
            defenderFaction = opts.defenderFaction,
            gather = opts.gather and true or nil,
            firstSeen = now, lastSeen = now,
        }
        db.alerts[key] = al
    end

    -- Chat warning (we're the defender here), throttled per key + persisted.
    local lastChat = db.alertChat[key] or 0
    if (now - lastChat) >= CHAT_THROTTLE then
        db.alertChat[key] = now
        -- Wire fields sanitized before they hit chat; shard is a number, faction is short.
        local atk      = Safe(al.attacker, 32) or "An enemy"
        local def      = Safe(al.defenderFaction, 16) or "your"
        local plc      = Safe(al.place, 48) or "an objective"
        local zone     = Safe(al.zone, 32)
        local zoneTxt  = (zone and zone ~= "") and (" in " .. zone) or ""
        local shardTxt = al.shardId and tostring(al.shardId) or "?"
        if al.gather then
            local fac = Safe(al.attackerFaction, 16) or "The enemy"
            print(("|cffff3030WarBoard Alert:|r %s is collecting lumber at %s%s! Stop them and secure the lumber! (SHARD: %s)")
                :format(fac, plc, zoneTxt, shardTxt))
        else
            print(("|cffff3030WarBoard Alert:|r Enemy assault reported \226\128\148 %s is attacking %s forces near %s%s! (SHARD: %s)")
                :format(atk, def, plc, zoneTxt, shardTxt))
        end
        if PlaySound then PlaySound(8959, "Master") end  -- RAID_WARNING ping
    end

    Prune()
    RefreshUI()
end

-- Point-in-time event. opts: category, kind, id, title, body. Deduped by id.
function A.AddEvent(opts)
    if not Ready() or type(opts) ~= "table" or not opts.id then return end
    if db.alerts[opts.id] then return end   -- already logged
    local now = time()
    db.alerts[opts.id] = {
        id = opts.id, category = opts.category or A.CATEGORY.SYSTEM, kind = opts.kind or "event",
        title = opts.title, body = opts.body,
        firstSeen = now, lastSeen = now,
    }
    Prune()
    RefreshUI()
end

-- Supply runner / stolen-cargo carrier intel. These are persistent, refreshable alerts like
-- assaults: continued location updates keep lastSeen fresh so the row stays ACTIVE/RECENT until
-- the runner drops off or the alert ages out. `fromRemote` is reserved for the future synced
-- implementation; this helper itself only updates local alert state.
function A.AddSupplyRunner(opts, fromRemote)
    if not Ready() or type(opts) ~= "table" then return end
    local runner = Safe(opts.runner or opts.runnerName, 32) or "Unknown runner"
    local shard  = opts.shardId or CurrentShard()
    local key    = opts.id or ("supply:" .. tostring(opts.runnerGuid or runner) .. ":" .. tostring(shard or "0"))
    local now    = time()
    local isNew
    -- SAME-faction runner → a friendly "join them" alert (JOIN chip) instead of enemy intel (TRACK).
    local friendly = opts.runnerFaction ~= nil
        and opts.runnerFaction == (WB.faction or UnitFactionGroup("player"))

    local al = db.alerts[key]
    if al then
        al.lastSeen = now
        al.runner = runner
        al.runnerGuid = opts.runnerGuid or al.runnerGuid
        al.runnerRealm = Safe(opts.runnerRealm, 48) or al.runnerRealm
        al.place = Safe(opts.place, 48) or al.place
        al.zone = Safe(opts.zone, 32) or al.zone
        al.shardId = shard or al.shardId
        al.carrierType = opts.carrierType or al.carrierType
        al.isBounty = opts.isBounty or al.isBounty
    else
        isNew = true
        db.alerts[key] = {
            id = key, category = A.CATEGORY.WORLD, kind = "supply_runner",
            runner = runner,
            runnerGuid = opts.runnerGuid,
            runnerRealm = Safe(opts.runnerRealm, 48),
            runnerFaction = Safe(opts.runnerFaction, 16),
            friendly = friendly or nil,
            place = Safe(opts.place, 48),
            zone = Safe(opts.zone, 32),
            shardId = shard,
            carrierType = opts.carrierType or "supply_run",
            isBounty = opts.isBounty and true or false,
            firstSeen = now, lastSeen = now,
            localOnly = opts.localOnly and true or false,
        }
    end

    if isNew or opts.forceChat then
        if friendly then
            local factionName = Safe(opts.runnerFaction, 16) or "our faction"
            print(("|cff2fbf4fWarBoard Alert:|r %s has started a supply run for the %s! Join them and help \226\128\148 open the Alerts tab and click |cffffd100JOIN|r.")
                :format(runner, factionName))
        else
            local place = Safe(opts.place, 48) or "the warfront"
            local zone  = Safe(opts.zone, 32)
            local loc   = zone and (place .. ", " .. zone) or place
            local verb  = (opts.carrierType == "stolen_cargo") and "is carrying stolen supplies" or "is starting a supply run"
            print(("|cffff3030WarBoard Alert:|r %s %s near %s. Open the Alerts tab and click |cffffd100TRACK|r to follow their location.")
                :format(runner, verb, loc))
        end
    end

    Prune()
    RefreshUI()
end

-- Drop a supply runner / stolen-cargo alert (the run ended, or a test cleanup). Keyed by the
-- same id AddSupplyRunner used.
function A.RemoveSupplyRunner(id)
    if not Ready() or not id then return end
    if db.alerts[id] then db.alerts[id] = nil; RefreshUI() end
end

-- Only raise live events; skip backfill (e.g. a bulk ledger/bounty sync on login
-- replaying records minutes/hours old) so the feed isn't flooded with history.
local FRESH_WINDOW = 300
local function IsFresh(t) return (not t) or (time() - t) <= FRESH_WINDOW end

-- Objective completions are intentionally NOT surfaced in the Alerts feed — it's for
-- system notices and enemy assaults, not a play-by-play of every completion (those live
-- in the Activity tab). Kept as a no-op so any older caller stays harmless.
function A.AddCompletion() end

-- Bounty posted (local or received).
function A.AddBountyPosted(b)
    if type(b) ~= "table" or not b.id then return end
    if not IsFresh(b.postedAt) then return end
    local target  = Safe(b.targetName, 32) or "?"        -- wire fields → sanitize before display
    local poster  = Safe(b.posterName, 32) or "Someone"
    local faction = Safe(b.targetFaction, 16) or "?"
    A.AddEvent({
        id = "bpost:" .. b.id, category = A.CATEGORY.BOUNTY, kind = "bounty_posted",
        title = "Bounty Posted: " .. target,
        body = ("%s placed a bounty on %s (%s) \226\128\148 %s gold reward.")
            :format(poster, target, faction, tostring(b.rewardGold or 0)),
    })
end

-- Your own bounty-accepts are intentionally NOT surfaced in the Alerts feed (no
-- self-spam). Kept as a no-op so any older caller stays harmless.
function A.AddBountyAccepted() end

-- System notice (campaign resets, maintenance, ...).
function A.AddSystem(id, title, body)
    A.AddEvent({ id = "sys:" .. tostring(id), category = A.CATEGORY.SYSTEM, kind = "system",
        title = title, body = body })
end

-- ── Reading / describing ───────────────────────────────────────────────────────────

function A.Status(al)
    if al.kind == "assault" or al.kind == "supply_runner" then
        return ((time() - (al.lastSeen or 0)) <= ACTIVE_WINDOW) and "ACTIVE" or "RECENT"
    end
    return "EVENT"
end

local function TimeText(t)
    local d = math.max(0, time() - (t or 0))
    if d < 60    then return "Just Now" end
    if d < 3600  then return math.floor(d / 60) .. "m ago" end
    if d < 86400 then return math.floor(d / 3600) .. "h ago" end
    return math.floor(d / 86400) .. "d ago"
end
A.TimeText = TimeText

local TAG_LABEL = {
    war_effort = "WAR EFFORT",
    bounty     = "BOUNTY",
    world      = "WORLD",
    system     = "SYSTEM",
}
local CATEGORY_COLOR = {
    war_effort = { 0.95, 0.62, 0.20 },   -- amber
    bounty     = { 0.90, 0.30, 0.28 },   -- red
    world      = { 0.30, 0.74, 0.74 },   -- teal
    system     = { 0.55, 0.62, 0.78 },   -- steel
}
local STATUS_COLOR = {
    ACTIVE = { 0.95, 0.25, 0.20 },
    RECENT = { 0.85, 0.66, 0.22 },
}
-- Dark, parchment-readable headline colours per category; assaults get their own red.
local TITLE_COLOR = {
    war_effort = { 0.52, 0.30, 0.06 },
    bounty     = { 0.58, 0.13, 0.10 },
    world      = { 0.10, 0.34, 0.34 },
    system     = { 0.22, 0.27, 0.42 },
}
local ASSAULT_TITLE = { 0.62, 0.13, 0.10 }
local CHIP_BORDER   = { 0.82, 0.64, 0.34 }   -- tan bevel around the chip plate
-- Inline colour codes for the body segments.
local C_ATTACKER = "ffcf2f24"  -- red
local C_LOCATION = "ffc8a13e"  -- gold
local C_SHARD    = "ff5b8dd9"  -- blue

local function Darken(c, f) f = f or 0.42; return { c[1] * f, c[2] * f, c[3] * f } end

-- Build everything the feed row needs to render this alert, from the local viewer's
-- perspective. Returns { tag, chipFill, chipBorder, accent, title, titleColor,
-- body, timeText }.
function A.Describe(al)
    local accent = CATEGORY_COLOR[al.category] or CATEGORY_COLOR.system
    if al.kind == "assault" then
        local status   = A.Status(al)
        local place    = Safe(al.place, 48) or "an objective"
        local zone     = Safe(al.zone, 32)
        local zoneTxt  = (zone and zone ~= "") and (", " .. zone) or ""
        local shardTxt = al.shardId and tostring(al.shardId) or "?"
        local defender = Safe(al.defenderFaction, 16) or "the enemy"
        local attacker = Safe(al.attacker, 32) or "An enemy"

        -- Gather variant (acquisition objectives, e.g. lumber): the enemy FACTION is collecting a
        -- resource at the alert's live place. Same chip/status treatment as an assault.
        if al.gather then
            local fac  = Safe(al.attackerFaction, 16) or "The enemy"
            local body = ("|c%s%s|r is collecting lumber at |c%s%s%s.|r Stop them and secure the lumber!   ||   |c%sSHARD: %s|r")
                :format(C_ATTACKER, fac, C_LOCATION, place, zoneTxt, C_SHARD, shardTxt)
            return {
                tag = status, chipFill = Darken(STATUS_COLOR[status] or STATUS_COLOR.RECENT),
                chipBorder = CHIP_BORDER, accent = accent,
                title = "ENEMY GATHERING LUMBER", titleColor = ASSAULT_TITLE,
                body = body,
                timeText = "Last Seen: " .. TimeText(al.lastSeen),
            }
        end

        -- Coloured body: attacker (red), prose (base dark), location (gold), shard
        -- (blue). "||" prints as one literal pipe separating location and shard.
        local body = ("|c%s%s|r is attacking %s forces at |c%s%s%s.|r   ||   |c%sSHARD: %s|r")
            :format(C_ATTACKER, attacker, defender, C_LOCATION, place, zoneTxt, C_SHARD, shardTxt)

        return {
            tag = status, chipFill = Darken(STATUS_COLOR[status] or STATUS_COLOR.RECENT),
            chipBorder = CHIP_BORDER, accent = accent,
            title = "ENEMY ASSAULT REPORTED", titleColor = ASSAULT_TITLE,
            body = body,
            timeText = "Last Seen: " .. TimeText(al.lastSeen),
        }
    end

    if al.kind == "supply_runner" then
        local status   = A.Status(al)
        local runner   = Safe(al.runner, 32) or "A player"
        local place    = Safe(al.place, 48) or "the warfront"
        local zone     = Safe(al.zone, 32)
        local zoneTxt  = (zone and zone ~= "") and (", " .. zone) or ""
        local shardTxt = al.shardId and tostring(al.shardId) or "?"

        -- Friendly (same-faction) supply run: a rally card with a JOIN chip, not enemy intel.
        if al.friendly then
            local factionName = Safe(al.runnerFaction, 16) or "our faction"
            local body = ("|cff2fbf4f%s|r has started a supply run for the %s near |c%s%s%s|r \226\128\148 join them and help!   ||   |c%sSHARD: %s|r")
                :format(runner, factionName, C_LOCATION, place, zoneTxt, C_SHARD, shardTxt)
            return {
                tag = "JOIN", chipFill = { 0.09, 0.30, 0.14 },
                chipBorder = CHIP_BORDER, accent = { 0.24, 0.75, 0.35 },
                title = "ALLIED SUPPLY RUN UNDERWAY", titleColor = { 0.10, 0.36, 0.16 },
                body = body,
                timeText = "Last Seen: " .. TimeText(al.lastSeen),
                clickable = true,
                clickHint = "Click to request an invite to their group.",
            }
        end

        local stolen   = al.carrierType == "stolen_cargo"
        local bounty   = al.isBounty and " They are also a bounty target." or ""
        local title    = stolen and "STOLEN SUPPLIES MOVING" or "SUPPLY RUNNER SPOTTED"
        local action   = stolen and "is carrying stolen supplies near" or "started a supply run near"
        local body = ("|c%s%s|r %s |c%s%s%s.|r%s   ||   |c%sSHARD: %s|r")
            :format(C_ATTACKER, runner, action, C_LOCATION, place, zoneTxt, bounty, C_SHARD, shardTxt)

        -- Chip label mirrors bounty tracking: TRACK → LOCATING (handshaking) → TRACKING (getting
        -- position) → RETRY (gave up). Driven by the shared WB.Cargo/BountyTracker state.
        local trackState = (WB.Cargo and WB.Cargo.TrackState and WB.Cargo.TrackState(al)) or "idle"
        local tag = (trackState == "tracking" and "TRACKING")
                 or (trackState == "trying"   and "LOCATING")
                 or (trackState == "failed"   and "RETRY")
                 or "TRACK"

        return {
            tag = tag, chipFill = Darken(STATUS_COLOR[status] or STATUS_COLOR.RECENT),
            chipBorder = CHIP_BORDER, accent = CATEGORY_COLOR.world,
            title = title, titleColor = TITLE_COLOR.world,
            body = body,
            timeText = "Last Seen: " .. TimeText(al.lastSeen),
            clickable = true,
            clickHint = (tag == "TRACK") and "Click to track this runner's location on the map." or nil,
        }
    end

    -- Event alert: category tag + stored title/body.
    return {
        tag = TAG_LABEL[al.category] or "ALERT", chipFill = Darken(accent),
        chipBorder = CHIP_BORDER, accent = accent,
        title = al.title or "Alert", titleColor = TITLE_COLOR[al.category] or TITLE_COLOR.system,
        body = al.body or "",
        timeText = TimeText(al.lastSeen),
    }
end

-- Sorted feed: ACTIVE assaults first, then everything by recency.
function A.All()
    if not Ready() then return {} end
    Prune()
    local out = {}
    for _, al in pairs(db.alerts) do out[#out + 1] = al end
    table.sort(out, function(a, b)
        local ar = ((a.kind == "assault" or a.kind == "supply_runner") and A.Status(a) == "ACTIVE") and 0 or 1
        local br = ((b.kind == "assault" or b.kind == "supply_runner") and A.Status(b) == "ACTIVE") and 0 or 1
        if ar ~= br then return ar < br end
        return (a.lastSeen or 0) > (b.lastSeen or 0)
    end)
    return out
end

function A.Count()
    if not Ready() then return 0 end
    local n = 0
    for _ in pairs(db.alerts) do n = n + 1 end
    return n
end

function A.Wipe()
    if not Ready() then return end
    wipe(db.alerts)
    wipe(db.alertChat)
    RefreshUI()
end

-- ── Init + periodic ageing tick ────────────────────────────────────────────────────
do
    local f = CreateFrame("Frame")
    f:RegisterEvent("ADDON_LOADED")
    f:SetScript("OnEvent", function(self, _, name)
        if name == addonName then EnsureDB(); self:UnregisterEvent("ADDON_LOADED") end
    end)
    -- Re-render every 30s so "Just Now" ages and ACTIVE -> RECENT transitions show.
    C_Timer.NewTicker(30, function()
        if not db then return end
        Prune()
        RefreshUI()
    end)
end
