local addonName, TOAST = ...

-- A single conservative queue for every outgoing TOAST message. Guild and
-- whisper traffic share Blizzard's per-prefix allowance, so no feature may
-- send directly around this queue.

local MIN_SEND_INTERVAL = 1.05
local MAX_QUEUE_LENGTH = 160

local PRIORITY = {
    CONTROL = 500,
    SELF_STATE = 400,
    SNAPSHOT = 300,
    STATE_RESPONSE = 200,
    BACKGROUND = 100,
}

local queue = {}
local serial = 0
local lastSentAt = -math.huge

-- Correlating an asynchronous "player not found" system message back to a TOAST
-- addon whisper.
--
-- recentWhisperTargets is the source of truth, keyed on the canonical full
-- target. recentWhisperShort is only an index from a bare character name to the
-- SET of full keys carrying that name, so Bob-RealmA and Bob-RealmB never
-- overwrite each other and a bare-name message can be recognized as ambiguous.
--
-- An entry stays in place after it has been handled, marked offlineHandled: one
-- snapshot can have several already-accepted sends in flight, so the first error
-- is followed by one error per remaining in-flight send. Those are a tombstone
-- hit: always suppressed, never cancelled or traced again.
local recentWhisperTargets = {}
local recentWhisperShort = {}
local WHISPER_ERROR_WINDOW = 12

-- Explicit ASCII whitespace, never %s: Lua's %s is locale-dependent and matches
-- byte 0xA0, the SECOND byte of Cyrillic "Р" (U+0420 = D0 A0). Stripping with
-- %s tears a byte out of the middle of a UTF-8 name and leaves a key that can
-- never match. Same rule as crew_state.lua's REALM_STRIP_PATTERN.
local ASCII_SPACE = " \t\r\n\f\v"
local TARGET_STRIP = "[" .. ASCII_SPACE .. "%-'%.%(%)]"
local TRIM_LEADING = "^[" .. ASCII_SPACE .. "\"'`]+"
local TRIM_TRAILING = "[" .. ASCII_SPACE .. "\"'`]+$"

-- The one canonical whisper-target normalization: remembering, comparing and
-- cancelling all fold through this and nothing else.
--   "Bob-Tarren Mill" -> "bobtarrenmill", "bob", true
--   "Bob"             -> "bob",           "bob", false
local function NormalizeTarget(value)
    if type(value) ~= "string" then return nil end
    -- string.lower is byte-wise and corrupts UTF-8 lead bytes, see
    -- TOAST.Utf8Lower in core.lua.
    local Fold = TOAST.Utf8Lower or string.lower
    local namePart, realmPart = value:match("^([^%-]*)%-(.*)$")
    local full = Fold((value:gsub(TARGET_STRIP, "")))
    if full == "" then return nil end
    local short = Fold(((namePart or value):gsub(TARGET_STRIP, "")))
    return full, (short ~= "" and short or nil), realmPart ~= nil
end

-- Drops exactly one full target and only its own membership of the shared short
-- index. Another Name-Realm with the same character name keeps its correlation.
local function ForgetTarget(full)
    local entry = recentWhisperTargets[full]
    if not entry then return end
    recentWhisperTargets[full] = nil
    local bucket = entry.short and recentWhisperShort[entry.short]
    if bucket then
        bucket[full] = nil
        if next(bucket) == nil then recentWhisperShort[entry.short] = nil end
    end
end

-- Event-driven cleanup only: called from a real send and from a relevant system
-- message. Between those moments an expired entry may still be physically
-- present, which is harmless because every reader compares against expiresAt --
-- it is logically gone. This deliberately adds no timer, ticker or OnUpdate.
local function PruneCorrelations(now)
    for full, entry in pairs(recentWhisperTargets) do
        if entry.expiresAt < now then ForgetTarget(full) end
    end
end

local function RememberWhisperTarget(target)
    local full, short = NormalizeTarget(target)
    if not full then return end
    local now = GetTime()
    PruneCorrelations(now)
    -- Two spellings can fold to the same full key with different short keys, so
    -- drop the old record first rather than stranding it in the short index.
    ForgetTarget(full)
    recentWhisperTargets[full] = {
        expiresAt = now + WHISPER_ERROR_WINDOW,
        target = target,
        short = short,
    }
    if short then
        local bucket = recentWhisperShort[short]
        if not bucket then
            bucket = {}
            recentWhisperShort[short] = bucket
        end
        bucket[full] = true
    end
end

local function EscapePattern(value)
    return value:gsub("([^%w])", "%%%1")
end

-- Built once at load from the global strings that actually exist on this
-- client. Retail tends to suffix a one-string format global with _S, but that
-- is not assumed: either name is accepted, and only when the value really is a
-- string carrying exactly one %s and no other format specifier.
local ERROR_PATTERNS = {}
do
    local function AddPattern(text)
        if type(text) ~= "string" then return false end
        local _, specifiers = text:gsub("%%%a", "")
        local _, stringArgs = text:gsub("%%s", "")
        if specifiers ~= 1 or stringArgs ~= 1 then return false end
        local before, after = text:match("^(.-)%%s(.-)$")
        if not before then return false end
        ERROR_PATTERNS[#ERROR_PATTERNS + 1] =
            "^" .. EscapePattern(before) .. "(.+)" .. EscapePattern(after) .. "$"
        return true
    end

    local found = AddPattern(ERR_CHAT_PLAYER_NOT_FOUND_S)
    found = AddPattern(ERR_CHAT_PLAYER_NOT_FOUND) or found
    -- Last resort, only when neither global exists: the exact enUS wording.
    -- It is fully anchored English text, so on a localized client it simply
    -- never matches. This degrades to "nothing suppressed", never to a broader
    -- match on an unrelated or localized system message.
    if not found then AddPattern("No player named '%s' is currently playing.") end
end

local function PlayerNotFoundTarget(message)
    if type(message) ~= "string" then return nil end
    for _, pattern in ipairs(ERROR_PATTERNS) do
        local captured = message:match(pattern)
        if captured then
            -- Defensive: strip chat colour codes and any quoting a locale puts
            -- around the name before it reaches the canonical normalization.
            captured = captured:gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", "")
            captured = captured:gsub(TRIM_LEADING, ""):gsub(TRIM_TRAILING, "")
            if captured ~= "" then return captured end
        end
    end
    return nil
end

-- Tombstone this exact target: every further error for it inside the window is
-- suppressed silently, because the already-accepted sends still in flight each
-- produce their own. The window is re-armed from now so those siblings are
-- covered, and the entry still expires on the same bounded window -- after that
-- a manual whisper to the same player shows its own error again.
--
-- The synchronous verdict can arrive without any prior accepted send, so a
-- tombstone entry is created when there is none to mark.
local function MarkTargetHandled(target)
    local full = NormalizeTarget(target)
    if not full then return end
    local entry = recentWhisperTargets[full]
    if not entry then
        RememberWhisperTarget(target)
        entry = recentWhisperTargets[full]
        if not entry then return end
    end
    entry.offlineHandled = true
    entry.expiresAt = math.max(entry.expiresAt, GetTime() + WHISPER_ERROR_WINDOW)
end

-- The single terminal implementation of "this whisper target is gone", shared by
-- the synchronous SendAddonMessage verdict and the asynchronous system message.
--
-- The queue side and the protocol side are both terminated. The lifecycle hook
-- runs even when nothing was left to cancel: an empty queue is no proof that the
-- transfer ended, because already-accepted sends are still in flight.
local function TerminateWhisperTarget(target, alwaysTrace)
    -- Tombstone first: nothing below may re-enter this path for the same target.
    MarkTargetHandled(target)
    local cancelled = TOAST.CancelWhisperTarget and TOAST.CancelWhisperTarget(target) or 0
    if TOAST.OnWhisperTargetOffline then TOAST.OnWhisperTargetOffline(target) end
    if (alwaysTrace or cancelled > 0) and TOAST.LogCommsTrace then
        TOAST.LogCommsTrace("LOCAL", "WHISPER", { label = "TARGET OFFLINE" }, target,
            string.format("cancelled %d queued", cancelled))
    end
end

local function HandleTargetOffline(full)
    local entry = recentWhisperTargets[full]
    if not entry then return end
    TerminateWhisperTarget(entry.target, true)
end

-- A directed addon whisper can race a player logging out after C_Club marked
-- them online, and the client reports that asynchronously through
-- CHAT_MSG_SYSTEM. Hide only a failure that correlates to a recent TOAST
-- whisper; manual player whispers, other addons and unrelated system messages
-- are left untouched.
--
-- Known limitation: CHAT_MSG_SYSTEM carries no causal identifier, so when the
-- player manually whispers the exact same character within this window there is
-- no way to tell which send failed. The deliberate product choice is that a
-- valid recent exact TOAST correlation wins, which means a manual error to that
-- one target can be hidden inside this small overlap.
if ChatFrame_AddMessageEventFilter then
    ChatFrame_AddMessageEventFilter("CHAT_MSG_SYSTEM", function(_, _, message)
        -- Nothing whispered recently: no pattern work at all.
        if next(recentWhisperTargets) == nil then return end
        PruneCorrelations(GetTime())
        if next(recentWhisperTargets) == nil then return end

        local full, short, hasRealm = NormalizeTarget(PlayerNotFoundTarget(message))
        if not full then return end

        local entry = recentWhisperTargets[full]
        if entry then
            -- Tombstone: a further in-flight send for an already terminated
            -- target. Suppress, but do not cancel or trace a second time.
            if not entry.offlineHandled then HandleTargetOffline(full) end
            return true
        end

        -- Bare-name fallback: only for a message without a realm, and only when
        -- exactly one remembered target could have produced it. Everything is
        -- unexpired here, because the prune above already ran.
        if hasRealm or not short then return end
        local bucket = recentWhisperShort[short]
        if not bucket then return end
        local count, matched = 0, nil
        for candidate in pairs(bucket) do
            count = count + 1
            matched = candidate
        end
        if count == 1 then
            local candidate = recentWhisperTargets[matched]
            if candidate and not candidate.offlineHandled then HandleTargetOffline(matched) end
            return true
        end
        if count > 1 and TOAST.LogCommsTrace then
            -- Not reliably correlatable: suppress nothing, cancel nothing, and
            -- let the normal WoW error stand.
            TOAST.LogCommsTrace("LOCAL", "WHISPER", { label = "AMBIGUOUS TARGET" }, short,
                string.format("%d candidates", count))
        end
    end)
end

local function ResultCode(...)
    return select(-1, ...)
end

local function IsSuccess(result)
    local enum = Enum and Enum.SendAddonMessageResult
    return result == 0 or (enum and result == enum.Success)
end

local function IsThrottle(result)
    local enum = Enum and Enum.SendAddonMessageResult
    return result == 3 or result == 8
        or (enum and (result == enum.AddonMessageThrottle or result == enum.ChannelThrottle))
end

local function IsLockdown(result)
    local enum = Enum and Enum.SendAddonMessageResult
    return result == 11 or (enum and result == enum.AddOnMessageLockdown)
end

local function IsTargetOffline(result)
    local enum = Enum and Enum.SendAddonMessageResult
    return result == 12 or (enum and result == enum.TargetOffline)
end

local function RemoveAt(index)
    local entry = queue[index]
    table.remove(queue, index)
    return entry
end

local function BestReadyIndex(now)
    local bestIndex, best
    for index, entry in ipairs(queue) do
        if entry.notBefore <= now then
            if not best
                or entry.priority > best.priority
                or (entry.priority == best.priority and entry.serial < best.serial) then
                bestIndex, best = index, entry
            end
        end
    end
    return bestIndex, best
end

local function Finish(entry, status, result)
    if type(entry.callback) == "function" then
        entry.callback(status, result, entry)
    end
end

local function TraceOutgoing(entry, result)
    if TOAST.LogCommsTrace then
        TOAST.LogCommsTrace("OUT", entry.channel, entry.message, entry.target or "TIDE", result)
    end
end

local function SendQueuedMessage(entry)
    -- Target-not-found is emitted synchronously as UI_ERROR_MESSAGE by the
    -- client. Temporarily unhook only Blizzard's error frame for this single
    -- TOAST whisper; do not mute the user's normal errors or any other addon.
    local errorsFrame = entry.channel == "WHISPER" and UIErrorsFrame or nil
    local restoreErrors = errorsFrame and errorsFrame.IsEventRegistered
        and errorsFrame:IsEventRegistered("UI_ERROR_MESSAGE")
    if restoreErrors then errorsFrame:UnregisterEvent("UI_ERROR_MESSAGE") end

    local result = ResultCode(C_ChatInfo.SendAddonMessage(
        entry.prefix, entry.message, entry.channel, entry.target
    ))

    if restoreErrors then errorsFrame:RegisterEvent("UI_ERROR_MESSAGE") end
    return result
end

local function Pump()
    if #queue == 0 then return end
    if InCombatLockdown and InCombatLockdown() then return end

    local now = GetTime()
    if now - lastSentAt < MIN_SEND_INTERVAL then return end

    local index, entry = BestReadyIndex(now)
    if not entry then return end

    local result = SendQueuedMessage(entry)

    if IsSuccess(result) then
        RemoveAt(index)
        lastSentAt = now
        -- Only an ACCEPTED whisper can produce the asynchronous player-not-found
        -- system message, so only that one arms the correlation. A deferred or
        -- throttled attempt never reached the server and must not be able to
        -- swallow the error of a manual whisper.
        if entry.channel == "WHISPER" then RememberWhisperTarget(entry.target) end
        TraceOutgoing(entry, "sent")
        Finish(entry, "sent", result)
        return
    end

    if IsThrottle(result) or IsLockdown(result) then
        entry.attempts = entry.attempts + 1
        entry.notBefore = now + math.min(30, 1.5 * entry.attempts)
        TraceOutgoing(entry, IsThrottle(result) and "throttled" or "combat deferred")
        return
    end

    RemoveAt(index)
    if IsTargetOffline(result) then
        TraceOutgoing(entry, "target offline")
        -- The synchronous verdict is just as terminal as the asynchronous one,
        -- so it runs through the same single implementation instead of walking
        -- chunk by chunk into the same answer. Siblings and protocol state
        -- first, so the entry that completes the transfer callback is last.
        if entry.channel == "WHISPER" then
            TerminateWhisperTarget(entry.target, false)
        end
        Finish(entry, "target_offline", result)
    else
        TraceOutgoing(entry, "failed")
        Finish(entry, "failed", result)
    end
end

local frame = CreateFrame("Frame")
frame:SetScript("OnUpdate", function()
    Pump()
end)

function TOAST.QueueAddonMessage(priority, message, channel, target, callback)
    if type(message) ~= "string" or message == "" or #message > 210 then
        return false, "invalid_message"
    end
    if type(channel) ~= "string" or channel == "" then
        return false, "invalid_channel"
    end
    if channel == "WHISPER" and (type(target) ~= "string" or target == "") then
        return false, "missing_target"
    end
    if #queue >= MAX_QUEUE_LENGTH then
        return false, "queue_full"
    end

    serial = serial + 1
    queue[#queue + 1] = {
        prefix = "TOAST",
        message = message,
        channel = channel,
        target = target,
        callback = callback,
        priority = tonumber(priority) or PRIORITY.BACKGROUND,
        serial = serial,
        attempts = 0,
        notBefore = GetTime(),
    }
    return true
end

-- Terminal cancellation of every queued message for exactly one whisper target.
--
-- Whisper items only, matched on the canonical normalization: GUILD traffic and
-- every other whisper target are untouched. The walk is backwards so removal
-- cannot skip an entry, and items are removed BEFORE any callback runs -- a
-- callback that queues new work appends behind the loop, so nothing is finished
-- twice and nothing new is swept up by this cancellation.
function TOAST.CancelWhisperTarget(target)
    local full = NormalizeTarget(target)
    if not full then return 0 end

    local cancelled
    for index = #queue, 1, -1 do
        local entry = queue[index]
        if entry.channel == "WHISPER" and NormalizeTarget(entry.target) == full then
            table.remove(queue, index)
            cancelled = cancelled or {}
            cancelled[#cancelled + 1] = entry
        end
    end
    if not cancelled then return 0 end

    for _, entry in ipairs(cancelled) do
        Finish(entry, "target_offline", nil)
    end
    return #cancelled
end

function TOAST.GetAddonQueueStatus()
    return {
        pending = #queue,
        lastSentAt = lastSentAt,
    }
end

-- Transfers must be either queued in full or not started at all.  A partial
-- begin/chunk/end sequence only burns the receiver's timeout window.
function TOAST.CanQueueAddonMessages(count)
    count = tonumber(count)
    return count ~= nil and count >= 0 and #queue + math.floor(count) <= MAX_QUEUE_LENGTH
end

-- The canonical whisper-target fold, exported so the protocol side compares
-- targets exactly the same way. Returns full, short, hasRealm.
TOAST.NormalizeWhisperTarget = NormalizeTarget

TOAST.AddonMessagePriority = PRIORITY
