-- Portal Authority — core/alerts.lua
-- Announcement runtime, summon handling, combat alerts, and death-alert systems.
--
-- Depends on: PortalAuthority; PortalAuthority.CoreInternal helpers; PortalAuthority.SpellMap
-- Exposes:    announcement/summon/death-alert public methods; PortalAuthority.CoreInternal M+ notice helpers
-- DB keys:    announce*, summon*, deathAlert*, combatAlertsEnabled
-- Events:     none
-- Search key: none

local ADDON_NAME = ...

PortalAuthority = PortalAuthority or {}
PortalAuthority.CoreInternal = PortalAuthority.CoreInternal or {}
local CoreInternal = PortalAuthority.CoreInternal
local L = PortalAuthority.L

local PA_IsUiSurfaceGateEnabled = CoreInternal.PA_IsUiSurfaceGateEnabled
local PA_IsStaticBaselineGateEnabled = CoreInternal.PA_IsStaticBaselineGateEnabled
local PA_IsSettingsBaselineGateEnabled = CoreInternal.PA_IsSettingsBaselineGateEnabled
local PA_IsSettingsEagerBuildGateEnabled = CoreInternal.PA_IsSettingsEagerBuildGateEnabled
local PA_IsSettingsDirectBuildGateEnabled = CoreInternal.PA_IsSettingsDirectBuildGateEnabled
local PA_IsSettingsWindowHostEnabled = CoreInternal.PA_IsSettingsWindowHostEnabled
local PA_IsSettingsWindowDevGateEnabled = CoreInternal.PA_IsSettingsWindowDevGateEnabled
local PA_IsSettingsWindowPublicGateEnabled = CoreInternal.PA_IsSettingsWindowPublicGateEnabled
local PA_IsOnboardingD1DevGateEnabled = CoreInternal.PA_IsOnboardingD1DevGateEnabled
local PA_IsOnboardingD3DevGateEnabled = CoreInternal.PA_IsOnboardingD3DevGateEnabled
local PA_IsOnboardingD4DevGateEnabled = CoreInternal.PA_IsOnboardingD4DevGateEnabled
local PA_IsOnboardingD3PublicGateEnabled = CoreInternal.PA_IsOnboardingD3PublicGateEnabled
local PA_IsOnboardingD4PublicGateEnabled = CoreInternal.PA_IsOnboardingD4PublicGateEnabled
local PA_IsOnboardingQualifyingUpgradePublicGateEnabled = CoreInternal.PA_IsOnboardingQualifyingUpgradePublicGateEnabled
local PA_IsOnboardingD3Enabled = CoreInternal.PA_IsOnboardingD3Enabled
local PA_IsOnboardingD4Enabled = CoreInternal.PA_IsOnboardingD4Enabled
local trim = CoreInternal.trim
local clampNumber = CoreInternal.clampNumber
local PA_Clamp = CoreInternal.PA_Clamp
local PA_ToNumber = CoreInternal.PA_ToNumber
local PA_SafeBool = CoreInternal.PA_SafeBool
local PA_CompareSemver = CoreInternal.PA_CompareSemver
local PA_IsSemverStringValid = CoreInternal.PA_IsSemverStringValid
local PA_GetCurrentAddonVersionState = CoreInternal.PA_GetCurrentAddonVersionState
local VALID_OUTLINES = CoreInternal.VALID_OUTLINES
local VALID_SOUND_CHANNELS = CoreInternal.VALID_SOUND_CHANNELS
local BUILTIN_FONT_FALLBACK = CoreInternal.BUILTIN_FONT_FALLBACK
local PLAYER_LOGIN_MESSAGE = CoreInternal.PLAYER_LOGIN_MESSAGE
local DOCK_ENABLE_DEFAULTS_VERSION = CoreInternal.DOCK_ENABLE_DEFAULTS_VERSION
local PA_PrintPortalAuthorityMessage = CoreInternal.PA_PrintPortalAuthorityMessage
local PA_MaybeShowMPlusNotice = CoreInternal.PA_MaybeShowMPlusNotice
local ResetMPlusNoticeShown = CoreInternal.ResetMPlusNoticeShown
local PA_IsSecretValue = CoreInternal.PA_IsSecretValue
local PA_NormalizeSafeString = CoreInternal.PA_NormalizeSafeString
local PA_SafeStringsEqual = CoreInternal.PA_SafeStringsEqual
local PA_SafeUnitGUID = CoreInternal.PA_SafeUnitGUID

local SUMMON_SENDER_COOLDOWN_SECONDS = 15
local SUMMON_SOUND_GLOBAL_COOLDOWN_SECONDS = 5
local SUMMON_BURST_WINDOW_SECONDS = 10
local SUMMON_BURST_WINDOW_MAX = 3
local WARLOCK_SUMMON_ANNOUNCEMENT_SUPPRESSION_SECONDS = 2
local DEATH_ALERT_SELF_DUPLICATE_WINDOW_SECONDS = 0.5

local OUTBOUND_ANNOUNCEMENT_DEFAULT_KEYS = {
    portals = {
        defaultDbKey = "portalsText",
        localizedStockKey = "SETTINGS_ANNOUNCE_DEFAULT_PORTALS_TEXT",
    },
    teleports = {
        defaultDbKey = "teleportsText",
        localizedStockKey = "SETTINGS_ANNOUNCE_DEFAULT_TELEPORTS_TEXT",
    },
    mplus = {
        defaultDbKey = "mplusText",
        localizedStockKey = "SETTINGS_ANNOUNCE_DEFAULT_MPLUS_TEXT",
    },
    warlockSummoning = {
        defaultDbKey = "warlockSummoningText",
        localizedStockKey = "SETTINGS_ANNOUNCE_DEFAULT_SUMMONING_STONE_TEXT",
    },
}

local OUTBOUND_ANNOUNCEMENT_ALIAS_TO_CANONICAL = {
    ["[destinazione]"] = "[destination]",
    ["[destino]"] = "[destination]",
    ["[lieu]"] = "[destination]",
    ["[ziel]"] = "[destination]",
    ["[Назначение]"] = "[destination]",
    ["[목적지]"] = "[destination]",
    ["[目的地]"] = "[destination]",
}

local SUMMON_REQUEST_KEYWORD_TOKENS = {
    ["hexerport"] = true,
    ["invocadme"] = true,
    ["sum"] = true,
    ["summ"] = true,
    ["summon"] = true,
    ["summons"] = true,
    ["summoning"] = true,
    ["소환좀"] = true,
    ["求拉"] = true,
    ["拉我"] = true,
}

local SUMMON_STONE_REQUEST_KEYWORD_TOKENS = {
    ["closet"] = true,
    ["stone"] = true,
    ["소환문"] = true,
}

local RITUAL_OF_SUMMONING_SPELL_ID = 698
local RITUAL_OF_SUMMONING_ICON_FALLBACK = 7439232

local BUILTIN_SUMMON_ALERT_SOUNDS = {
    { label = "None", value = "" },
    { label = "Alarm Clock", value = 567478 },
    { label = "Raid Warning", value = 567463 },
    { label = "Ready Check", value = 567482 },
    { label = "Tell Message", value = 3081 },
}

PortalAuthority.SpellMap = {
    -- Portals
    [446534] = { category = "portals", dest = "Dornogal" },
    [10059] = { category = "portals", dest = "Stormwind" },
    [11416] = { category = "portals", dest = "Ironforge" },
    [11419] = { category = "portals", dest = "Darnassus" },
    [32266] = { category = "portals", dest = "Exodar" },
    [49360] = { category = "portals", dest = "Theramore" },
    [53142] = { category = "portals", dest = "Dalaran (Northrend)" },
    [88346] = { category = "portals", dest = "Tol Barad" },
    [132620] = { category = "portals", dest = "Vale of Eternal Blossoms" },
    [224871] = { category = "portals", dest = "Dalaran (Broken Isles)" },
    [344597] = { category = "portals", dest = "Oribos" },
    [395289] = { category = "portals", dest = "Valdrakken" },

    -- Teleports
    [3561] = { category = "teleports", dest = "Stormwind" },
    [3562] = { category = "teleports", dest = "Ironforge" },
    [3565] = { category = "teleports", dest = "Darnassus" },
    [32271] = { category = "teleports", dest = "Exodar" },
    [49359] = { category = "teleports", dest = "Theramore" },
    [53140] = { category = "teleports", dest = "Dalaran (Northrend)" },
    [88344] = { category = "teleports", dest = "Tol Barad" },
    [132621] = { category = "teleports", dest = "Vale of Eternal Blossoms" },
    [224869] = { category = "teleports", dest = "Dalaran (Broken Isles)" },
    [344587] = { category = "teleports", dest = "Oribos" },
    [395277] = { category = "teleports", dest = "Valdrakken" },
    [446540] = { category = "teleports", dest = "Dornogal" },

    -- Warlock utility
    [698] = { category = "warlockSummoning", dest = "Summoning Stone" },
}

local function PA_AddSpellIfExists(spellID, category, destinationLabel)
    if C_Spell and C_Spell.GetSpellName and C_Spell.GetSpellName(spellID) then
        PortalAuthority.SpellMap[spellID] = { category = category, dest = destinationLabel }
    end
end

local function PA_AddSpellMapping(spellID, category, destinationLabel, shortLabel, classFile, useItemID)
    if type(spellID) ~= "number" or spellID <= 0 then
        return
    end
    local entry = {
        category = category,
        dest = destinationLabel,
    }
    if type(shortLabel) == "string" and shortLabel ~= "" then
        entry.short = shortLabel
    end
    if type(classFile) == "string" and classFile ~= "" then
        entry.class = classFile
    end
    if type(useItemID) == "number" and useItemID > 0 then
        entry.useItemID = math.floor(useItemID)
    end
    PortalAuthority.SpellMap[spellID] = entry
end

PA_AddSpellIfExists(33691, "portals", "Shattrath")
PA_AddSpellIfExists(176246, "portals", "Stormshield")
PA_AddSpellIfExists(281400, "portals", "Boralus")

PA_AddSpellIfExists(33690, "teleports", "Shattrath")
PA_AddSpellIfExists(176248, "teleports", "Stormshield")
PA_AddSpellIfExists(281403, "teleports", "Boralus")

-- Additional city portal/teleport mappings used by Announcements and Dock v2 slot picker.
PA_AddSpellMapping(120146, "portals", "Ancient Dalaran")
PA_AddSpellMapping(120145, "teleports", "Ancient Dalaran")
PA_AddSpellMapping(11417, "portals", "Orgrimmar")
PA_AddSpellMapping(3567, "teleports", "Orgrimmar")
PA_AddSpellMapping(11418, "portals", "Undercity")
PA_AddSpellMapping(3563, "teleports", "Undercity")
PA_AddSpellMapping(11420, "portals", "Thunder Bluff")
PA_AddSpellMapping(3566, "teleports", "Thunder Bluff")
PA_AddSpellMapping(32267, "portals", "Silvermoon (TBC)")
PA_AddSpellMapping(32272, "teleports", "Silvermoon")
PA_AddSpellMapping(49361, "portals", "Stonard")
PA_AddSpellMapping(49358, "teleports", "Stonard")
PA_AddSpellMapping(281402, "portals", "Dazar'alor")
PA_AddSpellMapping(281404, "teleports", "Dazar'alor")
PA_AddSpellMapping(176244, "portals", "Warspear")
PA_AddSpellMapping(176242, "teleports", "Warspear")

-- Additional non-mage travel spells that should be selectable for Dock/Announcements.
PA_AddSpellMapping(556, "teleports", "Astral Recall", nil, "SHAMAN")                       -- Astral Recall
PA_AddSpellMapping(50977, "teleports", "Acherus: The Ebon Hold", nil, "DEATHKNIGHT")      -- Death Gate
PA_AddSpellMapping(193753, "teleports", "Emerald Dreamway", nil, "DRUID")                  -- Dreamwalk
PA_AddSpellMapping(18960, "teleports", "Moonglade", nil, "DRUID")                          -- Teleport: Moonglade
PA_AddSpellMapping(126892, "teleports", "Peak of Serenity", nil, "MONK")                   -- Zen Pilgrimage
PA_AddSpellMapping(8690, "teleports", "Hearthstone", nil, "ANY", 6948)                     -- Hearthstone item-use routing

-- Extended Mythic+ / Path spell mappings used by the Destination table and dock labels.
local PA_MPLUS_PATH_MAPPINGS = {
    { 1254572, "Magister's Terrace", "MGT" },
    { 1254559, "Maisara Caverns", "MAIS" },
    { 1254563, "Nexus-Point Xenas", "NPX" },
    { 1254400, "Windrunner Spire", "WS" },
    { 445440, "Cinderbrew Meadery", "CBM" },
    { 445417, "Ara-Kara", "ARAK" },
    { 445416, "City of Threads", "COT" },
    { 445441, "Darkflame Cleft", "DFC" },
    { 1237215, "Eco-Dome", "ED" },
    { 1239155, "Manaforge Omega", "MFO" },
    { 1216786, "Floodgate", "FLOOD" },
    { 445444, "Priory of the Sacred Flame", "PSF" },
    { 445414, "Dawnbreaker", "DAWN" },
    { 445443, "Rookery", "ROOK" },
    { 445269, "Stonevault", "SV" },
    { 1226482, "Undermine", "UNDR" },
    { 432257, "Aberrus", "ABER" },
    { 393273, "Algeth'ar Academy", "AA" },
    { 432258, "Amirdrassil", "AMIR" },
    { 393267, "Brackenhide Hollow", "BH" },
    { 393283, "Halls of Infusion", "HOI" },
    { 393276, "Neltharus", "NELT" },
    { 393256, "Ruby Life Pools", "RLP" },
    { 424197, "Dawn of the Infinite", "DOTI" },
    { 393279, "Azure Vault", "AV" },
    { 393262, "Nokhud Offensive", "NO" },
    { 393222, "Uldaman: Legacy of Tyr", "ULD" },
    { 432254, "Vault of the Incarnates", "VOTI" },
    { 373190, "Castle Nathria", "CN" },
    { 354468, "De Other Side", "DOS" },
    { 354465, "Halls of Atonement", "HOA" },
    { 354463, "Plaguefall", "PF" },
    { 373191, "Sanctum of Domination", "SOD" },
    { 354469, "Sanguine Depths", "SD" },
    { 373192, "Sepulcher of the First Ones", "SOFO" },
    { 354466, "Spires of Ascension", "SOA" },
    { 367416, "Tazavesh", "TAZA" },
    { 354462, "Necrotic Wake", "NW" },
    { 354467, "Theater of Pain", "TOP" },
    { 424187, "Atal'Dazar", "AD" },
    { 410071, "Freehold", "FH" },
    { 464256, "Siege of Boralus", "SOB" },
    { 467555, "The MOTHERLODE!!", "ML" },
    { 410074, "Underrot", "UR" },
    { 424167, "Waycrest Manor", "WM" },
    { 424153, "Blackrook Hold", "BRH" },
    { 393766, "Court of Stars", "COS" },
    { 424163, "Darkheart Thicket", "DHT" },
    { 393764, "Halls of Valor", "HOV" },
    { 373262, "Karazhan (Legion)", "KARA" },
    { 410078, "Neltharion's Lair", "NL" },
    { 1254551, "Seat of the Triumvirate", "SEAT" },
    { 159897, "Auchindoun", "AUCH" },
    { 159895, "Bloodmaul Slag Mines", "BSM" },
    { 159900, "Grimrail Depot", "GD" },
    { 159896, "Iron Docks", "ID" },
    { 159899, "Shadowmoon Burial Grounds", "SBG" },
    { 159898, "Skyreach", "SR" },
    { 159901, "Everbloom", "EB" },
    { 159902, "UBRS (WOD)", "UBRS" },
    { 131225, "Gate of the Setting Sun", "GSS" },
    { 131222, "Mogu'shan Palace", "MSP" },
    { 131231, "Scarlet Halls", "SH" },
    { 131229, "Scarlet Monastery (MOP)", "SM" },
    { 131232, "Scholo (MOP)", "SCHO" },
    { 131206, "Shadow-Pan Monastery", "SPM" },
    { 131228, "Siege of Niuzao", "SNT" },
    { 131205, "Stormstout Brewery", "SB" },
    { 131204, "Temple of the Jade Serpent", "TJS" },
    { 445424, "Grim Batol", "GB" },
    { 410080, "Vortex Pinnacle", "VP" },
    { 424142, "Throne of Tides", "TOT" },
    { 1254555, "Pit of Saron", "POS" },
}
for _, entry in ipairs(PA_MPLUS_PATH_MAPPINGS) do
    PA_AddSpellMapping(entry[1], "mplus", entry[2], entry[3])
end

-- These entries remain category "mplus" for announcement/template routing,
-- but are displayed as "Raid" in Dock v2 destination selection.
local PA_MPLUS_RAID_DISPLAY_IDS = {
    [1226482] = true, -- Undermine
    [1239155] = true, -- Manaforge Omega
    [373192] = true,  -- Sepulcher of the First Ones
    [373191] = true,  -- Sanctum of Domination
    [373190] = true,  -- Castle Nathria
}
for spellID in pairs(PA_MPLUS_RAID_DISPLAY_IDS) do
    if PortalAuthority.SpellMap[spellID] then
        PortalAuthority.SpellMap[spellID].displayCategory = "raid"
    end
end

function PortalAuthority:ApplyGlobalFont(object, size, flags)
    if not object or not object.SetFont then
        return
    end

    local fontPath, defaultFlags = self:GetGlobalFontPath()
    object:SetFont(fontPath, size or select(2, object:GetFont()) or 12, flags or defaultFlags)
end

function PortalAuthority:GetSpellData(spellID)
    return spellID and self.SpellMap[spellID] or nil
end

function PortalAuthority:GetCategoryForSpell(spellID)
    local data = self:GetSpellData(spellID)
    return data and data.category or nil
end

function PortalAuthority:IsWatchedSpell(spellID)
    return self:GetSpellData(spellID) ~= nil
end

function PortalAuthority:GetDestinationForSpell(spellID)
    if spellID == 8690 or spellID == 556 then
        local bindLocation = GetBindLocation and trim(GetBindLocation() or "") or ""
        if bindLocation ~= "" then
            return bindLocation
        end
    end

    local data = self:GetSpellData(spellID)
    return data and data.dest or nil
end

function PortalAuthority:GetTemplateForCategory(category)
    local db = PortalAuthorityDB or self.defaults

    if category == "portals" then
        return trim(db.portalsText) ~= "" and db.portalsText or self.defaults.portalsText
    elseif category == "teleports" then
        return trim(db.teleportsText) ~= "" and db.teleportsText or self.defaults.teleportsText
    elseif category == "mplus" then
        return trim(db.mplusText) ~= "" and db.mplusText or self.defaults.mplusText
    elseif category == "warlockSummoning" then
        return trim(db.warlockSummoningText) ~= "" and db.warlockSummoningText or self.defaults.warlockSummoningText
    end

    return nil
end

function PortalAuthority:IsCategoryEnabled(category)
    local db = PortalAuthorityDB or self.defaults

    if category == "portals" then
        return db.portalsEnabled
    elseif category == "teleports" then
        return db.teleportsEnabled
    elseif category == "mplus" then
        return db.mplusEnabled
    elseif category == "warlockSummoning" then
        return db.warlockSummoningEnabled
    end

    return false
end

local function PA_EscapeLuaPattern(text)
    return tostring(text or ""):gsub("(%W)", "%%%1")
end

function PortalAuthority:GetLocalizedAnnouncementStockTemplate(category)
    local config = OUTBOUND_ANNOUNCEMENT_DEFAULT_KEYS[category]
    if type(config) ~= "table" then
        return ""
    end

    local defaults = self.defaults or {}
    local canonicalStock = tostring(defaults[config.defaultDbKey] or "")
    local localizedStock = tostring(L[config.localizedStockKey] or canonicalStock)
    if trim(localizedStock) == "" then
        localizedStock = canonicalStock
    end
    return localizedStock, canonicalStock
end

function PortalAuthority:NormalizeOutboundAnnouncementTemplate(template)
    local normalized = tostring(template or "")
    for aliasToken, canonicalToken in pairs(OUTBOUND_ANNOUNCEMENT_ALIAS_TO_CANONICAL) do
        if aliasToken ~= canonicalToken and aliasToken ~= "" and canonicalToken ~= "" then
            normalized = normalized:gsub(PA_EscapeLuaPattern(aliasToken), canonicalToken)
        end
    end
    return normalized
end

function PortalAuthority:ResolveOutboundAnnouncementTemplate(category, template)
    local resolvedTemplate = tostring(template or "")
    local localizedStock, canonicalStock = self:GetLocalizedAnnouncementStockTemplate(category)

    if trim(resolvedTemplate) == "" or (canonicalStock ~= "" and resolvedTemplate == canonicalStock) then
        resolvedTemplate = localizedStock ~= "" and localizedStock or canonicalStock
    end

    return self:NormalizeOutboundAnnouncementTemplate(resolvedTemplate)
end

function PortalAuthority:BuildMessage(template, spellID, category)
    local spellName = C_Spell and C_Spell.GetSpellName and C_Spell.GetSpellName(spellID)
    if trim(spellName) == "" then
        spellName = "Unknown Spell"
    end

    local destName = self:GetDestinationForSpell(spellID)
    if trim(destName) == "" then
        destName = spellName
    end

    local finalTemplate = self:ResolveOutboundAnnouncementTemplate(category, template)
    if trim(finalTemplate) == "" then
        finalTemplate = self.defaults.portalsText
    end
    return finalTemplate
        :gsub("{spell}", spellName)
        :gsub("%[spell%]", spellName)
        :gsub("%[destination%]", destName)
        :gsub("{dest}", destName)
end

function PortalAuthority:GetChatType(category)
    if IsInRaid and IsInRaid() then
        return "RAID"
    end
    if IsInGroup and IsInGroup() then
        return "PARTY"
    end

    return nil
end

PortalAuthority.lastAnnouncedCastKey = nil

function PortalAuthority:BuildCastKey(castGUID, spellID)
    if castGUID and castGUID ~= "" then
        return castGUID
    end
    return string.format("%s:%d", tostring(spellID), math.floor(GetTime() * 10))
end

local function PA_IsChallengeActive()
    return C_ChallengeMode and C_ChallengeMode.IsChallengeModeActive and C_ChallengeMode.IsChallengeModeActive()
end

local PA_MPLUS_NOTICE_SHOWN = false

local function PA_MaybeShowMPlusNotice()
    if PA_MPLUS_NOTICE_SHOWN then return end
    PA_MPLUS_NOTICE_SHOWN = true
    local msg = "|cffE88BC7Portal Authority:|r " .. tostring(L["RUNTIME_ALERTS_MPLUS_NOTICE"] or "")
    if DEFAULT_CHAT_FRAME and DEFAULT_CHAT_FRAME.AddMessage then
        DEFAULT_CHAT_FRAME:AddMessage(msg)
    else
        print(msg)
    end
end

function PortalAuthority:TryAnnounce(spellID, castGUID)
    if not self:IsWatchedSpell(spellID) then return end

    local category = self:GetCategoryForSpell(spellID)
    if not category or not self:IsCategoryEnabled(category) then return end

    if PA_IsChallengeActive() then
        PA_MaybeShowMPlusNotice()
        return
    end

    local castKey = self:BuildCastKey(castGUID, spellID)
    if castKey == self.lastAnnouncedCastKey then return end

    local chatType = self:GetChatType(category)
    if not chatType then return end

    if PA_IsChallengeActive and PA_IsChallengeActive() and category == "warlockSummoning" then
        return
    end

    if category == "warlockSummoning" then
        chatType = "SAY"
    end

    local message = self:BuildMessage(self:GetTemplateForCategory(category), spellID, category)
    if trim(message) == "" then return end

    if C_ChatInfo and type(C_ChatInfo.SendChatMessage) == "function" then
        C_ChatInfo.SendChatMessage(message, chatType)
    elseif type(SendChatMessage) == "function" then
        SendChatMessage(message, chatType)
    end

    self.lastAnnouncedCastKey = castKey
end

PortalAuthority.lastSummonPopupBySender = PortalAuthority.lastSummonPopupBySender or {}
PortalAuthority.lastSummonRequestSoundAt = PortalAuthority.lastSummonRequestSoundAt or 0
PortalAuthority.summonRequestTimestamps = PortalAuthority.summonRequestTimestamps or {}
PortalAuthority.lastSummonStonePopupBySender = PortalAuthority.lastSummonStonePopupBySender or {}
PortalAuthority.lastSummonStoneRequestSoundAt = PortalAuthority.lastSummonStoneRequestSoundAt or 0
PortalAuthority.summonStoneRequestTimestamps = PortalAuthority.summonStoneRequestTimestamps or {}
PortalAuthority.lastWarlockSummoningAnnouncement = PortalAuthority.lastWarlockSummoningAnnouncement or nil
PortalAuthority.summonDebugEnabled = PortalAuthority.summonDebugEnabled or false

local function PA_SummonDebugValue(value)
    if value == nil then
        return "nil"
    end
    local ok, text = pcall(tostring, value)
    if ok and type(text) == "string" then
        return text
    end
    return "<unprintable>"
end

function PortalAuthority:SummonDebugTrace(kind, stage, event, sender, senderGUID, message)
    if not self.summonDebugEnabled then
        return
    end

    local normalizedMessage = self.NormalizeChatMessage and self:NormalizeChatMessage(message) or ""
    local normalizedSender = self.NormalizeSenderName and self:NormalizeSenderName(sender) or PA_SummonDebugValue(sender)
    local guidState = PA_NormalizeSafeString(senderGUID) and "guid" or "noguid"
    print(string.format(
        "|cffffd100Portal Authority SummonDebug:|r %s %s event=%s sender=%s %s msg=%s",
        tostring(kind or "?"),
        tostring(stage or "?"),
        tostring(event or "?"),
        tostring(normalizedSender or "?"),
        guidState,
        tostring(normalizedMessage or "")
    ))
end

function PortalAuthority:RememberWarlockSummoningAnnouncement(message, chatType)
    if trim(message) == "" or trim(chatType) == "" then
        self.lastWarlockSummoningAnnouncement = nil
        return
    end

    self.lastWarlockSummoningAnnouncement = {
        message = message,
        chatType = chatType,
        sentAt = GetTime(),
    }
end

function PortalAuthority:GetSummonChatTypeForEvent(event)
    if event == "CHAT_MSG_RAID" or event == "CHAT_MSG_RAID_LEADER" then
        return "RAID"
    end

    if event == "CHAT_MSG_PARTY" or event == "CHAT_MSG_PARTY_LEADER" then
        return "PARTY"
    end

    return nil
end

function PortalAuthority:IsSenderPlayer(sender, senderGUID)
    local normalizedSenderGUID = PA_NormalizeSafeString(senderGUID)
    if normalizedSenderGUID and PA_SafeStringsEqual(PA_SafeUnitGUID("player"), normalizedSenderGUID) then
        return true
    end

    local playerName = UnitName("player")
    if trim(playerName) == "" then
        return false
    end

    return self:NormalizeSenderName(sender):lower() == self:NormalizeSenderName(playerName):lower()
end

function PortalAuthority:ShouldSuppressOwnWarlockSummoningAnnouncement(event, message, sender, senderGUID)
    local outgoing = self.lastWarlockSummoningAnnouncement
    if not outgoing then
        return false
    end

    local eventChatType = self:GetSummonChatTypeForEvent(event)
    if not eventChatType or eventChatType ~= outgoing.chatType then
        return false
    end

    if message ~= outgoing.message then
        return false
    end

    if not self:IsSenderPlayer(sender, senderGUID) then
        return false
    end

    if (GetTime() - (outgoing.sentAt or 0)) > WARLOCK_SUMMON_ANNOUNCEMENT_SUPPRESSION_SECONDS then
        return false
    end

    return true
end

function PortalAuthority:NormalizeChatMessage(message)
    if type(message) ~= "string" then
        return ""
    end

    local ok, normalized = pcall(function()
        local text = message:lower()
        text = text:gsub("[%p%c]", " ")
        text = text:gsub("%s+", " ")
        text = text:gsub("^%s+", ""):gsub("%s+$", "")
        return text
    end)
    if ok and type(normalized) == "string" then
        return normalized
    end
    return ""
end

function PortalAuthority:IsSummonKeywordMessage(message)
    local normalized = self:NormalizeChatMessage(message)
    if normalized == "" then
        return false
    end

    for token in normalized:gmatch("%S+") do
        if token == "123" or token == "1" or SUMMON_REQUEST_KEYWORD_TOKENS[token] then
            return true
        end
    end

    return false
end

function PortalAuthority:IsSummonStoneKeywordMessage(message)
    local normalized = self:NormalizeChatMessage(message)
    if normalized == "" then
        return false
    end

    for token in normalized:gmatch("%S+") do
        if SUMMON_STONE_REQUEST_KEYWORD_TOKENS[token] then
            return true
        end
    end

    return false
end

function PortalAuthority:NormalizeSenderName(sender)
    if type(sender) ~= "string" then
        return L["COMMON_UNKNOWN"]
    end

    local ok, normalized = pcall(function()
        if Ambiguate then
            return Ambiguate(sender, "short")
        end
        return sender:match("^[^-]+") or sender
    end)
    if ok and type(normalized) == "string" and normalized ~= "" then
        return normalized
    end
    return L["COMMON_UNKNOWN"]
end

function PortalAuthority:GetClassHexColor(classFile)
    if trim(classFile) == "" then
        return nil
    end

    local classColor = nil
    if C_ClassColor and C_ClassColor.GetClassColor then
        classColor = C_ClassColor.GetClassColor(classFile)
    elseif RAID_CLASS_COLORS then
        classColor = RAID_CLASS_COLORS[classFile]
    end

    if not classColor then
        return nil
    end

    if classColor.GenerateHexColor then
        return classColor:GenerateHexColor()
    end

    if classColor.colorStr then
        return classColor.colorStr
    end

    if classColor.r and classColor.g and classColor.b then
        return string.format("ff%02x%02x%02x", math.floor(classColor.r * 255), math.floor(classColor.g * 255), math.floor(classColor.b * 255))
    end

    return nil
end

function PortalAuthority:GetGroupUnitClassBySender(sender, senderGUID)
    local normalizedSender = self:NormalizeSenderName(sender):lower()
    local normalizedSenderGUID = PA_NormalizeSafeString(senderGUID)

    local function matchesUnit(unit)
        if not UnitExists(unit) then
            return false
        end

        if normalizedSenderGUID and PA_SafeStringsEqual(PA_SafeUnitGUID(unit), normalizedSenderGUID) then
            return true
        end

        local unitName = UnitName(unit)
        if trim(unitName) == "" then
            return false
        end

        return self:NormalizeSenderName(unitName):lower() == normalizedSender
    end

    local function getClassFromUnit(unit)
        if not matchesUnit(unit) then
            return nil
        end

        local _, classFile = UnitClass(unit)
        return classFile
    end

    if IsInRaid and IsInRaid() then
        local raidCount = GetNumGroupMembers and GetNumGroupMembers() or 0
        for index = 1, raidCount do
            local classFile = getClassFromUnit("raid" .. index)
            if classFile then
                return classFile
            end

            if GetRaidRosterInfo then
                local name, _, _, _, _, classFilename = GetRaidRosterInfo(index)
                if trim(name) ~= "" and self:NormalizeSenderName(name):lower() == normalizedSender and trim(classFilename) ~= "" then
                    return classFilename
                end
            end
        end
    elseif IsInGroup and IsInGroup() then
        local partyCount = GetNumSubgroupMembers and GetNumSubgroupMembers() or 0
        for index = 1, partyCount do
            local classFile = getClassFromUnit("party" .. index)
            if classFile then
                return classFile
            end
        end

        local classFile = getClassFromUnit("player")
        if classFile then
            return classFile
        end
    end

    return nil
end

function PortalAuthority:GetSenderClassForSummon(event, sender, senderGUID)
    if event == "CHAT_MSG_WHISPER" then
        if not self:IsUnitInCurrentGroup(sender, senderGUID) then
            return nil
        end
    end

    return self:GetGroupUnitClassBySender(sender, senderGUID)
end

function PortalAuthority:GetClassColoredSenderName(event, sender, senderGUID)
    local playerName = self:NormalizeSenderName(sender)
    local classFile = self:GetSenderClassForSummon(event, sender, senderGUID)
    if not classFile then
        return playerName
    end

    local hexColor = self:GetClassHexColor(classFile)
    if trim(hexColor) == "" then
        return playerName
    end

    return string.format("|c%s%s|r", hexColor, playerName)
end

function PortalAuthority:ShowSummonPopup(event, sender, senderGUID)
    local playerName = self:GetClassColoredSenderName(event, sender, senderGUID)
    local icon = 136223
    local message = string.format(L["RUNTIME_ALERTS_SUMMON_FOR_FORMAT"] or "|T%s:0|t Summon for %s", tostring(icon), playerName)

    if RaidNotice_AddMessage and RaidWarningFrame and ChatTypeInfo and ChatTypeInfo.RAID_WARNING then
        RaidNotice_AddMessage(RaidWarningFrame, message, ChatTypeInfo.RAID_WARNING)
        return
    end

    UIErrorsFrame:AddMessage(message, 1, 0.82, 0, 1, 3)
end

function PortalAuthority:IsWarlockPlayer()
    local _, classFile = UnitClass("player")
    return classFile == "WARLOCK"
end

function PortalAuthority:IsPlayerInCombat()
    return UnitAffectingCombat and UnitAffectingCombat("player")
end

function PortalAuthority:ShouldThrottleSummonPopup(sender)
    local playerName = self:NormalizeSenderName(sender)
    local now = GetTime()
    local lastShown = self.lastSummonPopupBySender[playerName]

    if lastShown and (now - lastShown) < SUMMON_SENDER_COOLDOWN_SECONDS then
        return true
    end

    self.lastSummonPopupBySender[playerName] = now
    return false
end

function PortalAuthority:IsSummonBurstLimited()
    local now = GetTime()
    local windowStart = now - SUMMON_BURST_WINDOW_SECONDS

    local kept = {}
    for _, ts in ipairs(self.summonRequestTimestamps) do
        if ts >= windowStart then
            table.insert(kept, ts)
        end
    end
    self.summonRequestTimestamps = kept

    if #self.summonRequestTimestamps >= SUMMON_BURST_WINDOW_MAX then
        return true
    end

    table.insert(self.summonRequestTimestamps, now)
    return false
end

local function PA_ResolveConfiguredSound(selected)
    if selected == nil or selected == "" then
        return nil
    end

    if PortalAuthority and PortalAuthority.Media and PortalAuthority.Media.ResolveSoundFile then
        local resolved = PortalAuthority.Media.ResolveSoundFile(selected)
        if resolved ~= nil and resolved ~= "" then
            return resolved
        end
    end

    if type(selected) == "number" then
        return selected
    end

    local lsm = LibStub and LibStub("LibSharedMedia-3.0", true)
    if lsm and lsm.Fetch then
        local media = lsm:Fetch("sound", selected, true)
        if media and media ~= "" then
            return media
        end
    end

    return selected
end

function PortalAuthority:GetSummonRequestSoundFile()
    local selected = PortalAuthorityDB and PortalAuthorityDB.summonRequestSound
    return PA_ResolveConfiguredSound(selected)
end

function PortalAuthority:GetBuiltinSummonAlertSounds()
    return BUILTIN_SUMMON_ALERT_SOUNDS
end

function PortalAuthority:PlaySummonRequestSound()
    local soundFile = self:GetSummonRequestSoundFile()
    if not soundFile then
        return
    end

    local now = GetTime()
    if (now - (self.lastSummonRequestSoundAt or 0)) < SUMMON_SOUND_GLOBAL_COOLDOWN_SECONDS then
        return
    end

    local channel = "Master"

    self.lastSummonRequestSoundAt = now
    if type(soundFile) == "number" and PlaySound then
        PlaySound(soundFile, channel)
    elseif PlaySoundFile then
        PlaySoundFile(soundFile, channel)
    end
end

function PortalAuthority:GetSummonStoneRequestSoundFile()
    local selected = PortalAuthorityDB and PortalAuthorityDB.summonStoneRequestSound
    return PA_ResolveConfiguredSound(selected)
end

function PortalAuthority:PlaySummonStoneRequestSound()
    local soundFile = self:GetSummonStoneRequestSoundFile()
    if not soundFile then
        return
    end

    local now = GetTime()
    if (now - (self.lastSummonStoneRequestSoundAt or 0)) < SUMMON_SOUND_GLOBAL_COOLDOWN_SECONDS then
        return
    end

    local channel = "Master"

    self.lastSummonStoneRequestSoundAt = now
    if type(soundFile) == "number" and PlaySound then
        PlaySound(soundFile, channel)
    elseif PlaySoundFile then
        PlaySoundFile(soundFile, channel)
    end
end

function PortalAuthority:GetDeathAlertSoundFile(selected)
    return PA_ResolveConfiguredSound(selected)
end

PA_IsSecretValue = function(value)
    if hasanysecretvalues then
        local ok, secret = pcall(hasanysecretvalues, value)
        if ok and secret then
            return true
        end
    end
    return false
end

PA_NormalizeSafeString = function(value)
    if type(value) ~= "string" or PA_IsSecretValue(value) then
        return nil
    end
    return value
end

PA_SafeStringsEqual = function(left, right)
    local normalizedLeft = PA_NormalizeSafeString(left)
    local normalizedRight = PA_NormalizeSafeString(right)
    return normalizedLeft ~= nil and normalizedRight ~= nil and normalizedLeft == normalizedRight
end

PA_SafeUnitGUID = function(unit)
    if type(unit) ~= "string" or unit == "" or not UnitGUID then
        return nil
    end
    local ok, guid = pcall(UnitGUID, unit)
    if ok then
        return PA_NormalizeSafeString(guid)
    end
    return nil
end

local function PA_BitBand(a, b)
    if bit and bit.band then
        return bit.band(a, b)
    end
    if bit32 and bit32.band then
        return bit32.band(a, b)
    end
    return 0
end

local function PA_GetRoleIconTag(role)
    if role == "TANK" then
        return "|A:groupfinder-icon-role-large-tank:15:15|a"
    elseif role == "HEALER" then
        return "|A:groupfinder-icon-role-large-healer:15:15|a"
    end
    return "|A:groupfinder-icon-role-large-dps:15:15|a"
end

local function PA_GetDeathAlertPreviewSample(roleKey)
    if roleKey == "HEALER" then
        return "HEALER", "SHAMAN", "Shiftus"
    elseif roleKey == "DPS" then
        return "DAMAGER", "WARRIOR", "Streetw"
    end
    return "TANK", "PALADIN", "Streetxo"
end

function PortalAuthority:DeathAlerts_StopPreviewTicker()
    self.DeathAlerts = self.DeathAlerts or {}
    if self.DeathAlerts.previewTicker and self.DeathAlerts.previewTicker.Cancel then
        self.DeathAlerts.previewTicker:Cancel()
    end
    self.DeathAlerts.previewTicker = nil
    self.DeathAlerts.previewIndex = nil
    self.DeathAlerts.previewPool = nil
end

function PortalAuthority:DeathAlerts_BuildPreviewRolePool(db)
    local pool = {}
    if PA_SafeBool(db and db.deathAlertTankEnabled) then
        pool[#pool + 1] = "TANK"
    end
    if PA_SafeBool(db and db.deathAlertHealerEnabled) then
        pool[#pool + 1] = "HEALER"
    end
    if PA_SafeBool(db and db.deathAlertDpsEnabled) then
        pool[#pool + 1] = "DPS"
    end
    if #pool == 0 then
        pool[1] = "TANK"
    end
    return pool
end

function PortalAuthority:DeathAlerts_RenderPreviewRole(roleKey)
    local db = PortalAuthorityDB or self.defaults
    local roleToken, classFile, sampleName = PA_GetDeathAlertPreviewSample(roleKey)
    local template = trim(db and db.deathAlertMessageTemplate or "")
    if template == "" then
        template = "[role] Died: [name]"
    end
    local message = self:DeathAlerts_FormatMessage(template, roleToken, classFile, sampleName)
    if PA_SafeBool(db and db.deathAlertShowRoleIcon) then
        message = (PA_GetRoleIconTag and PA_GetRoleIconTag(roleToken) or "") .. " " .. message
    end
    self:DeathAlerts_Show(message, 0)
end

function PortalAuthority:DeathAlerts_ApplyUnlockedPreview()
    if PA_IsUiSurfaceGateEnabled() then
        return
    end
    local perfStart, perfState = self:PerfBegin("deathalerts_unlocked_preview")
    local function finish(...)
        if perfStart ~= nil then
            self:PerfEnd("deathalerts_unlocked_preview", perfStart, perfState)
        end
        return ...
    end

    if not PortalAuthorityDB then
        return finish()
    end

    self:DeathAlerts_EnsureFrame()
    self.DeathAlerts = self.DeathAlerts or {}

    if InCombatLockdown and InCombatLockdown() then
        self:DeathAlerts_StopPreviewTicker()
        if self.DeathAlerts.frame then
            self.DeathAlerts.frame:Hide()
        end
        return finish()
    end

    local pool = self:DeathAlerts_BuildPreviewRolePool(PortalAuthorityDB)
    self.DeathAlerts.previewPool = pool
    local index = tonumber(self.DeathAlerts.previewIndex) or 1
    if index < 1 or index > #pool then
        index = 1
    end
    self.DeathAlerts.previewIndex = index
    self:DeathAlerts_RenderPreviewRole(pool[index])

    if #pool <= 1 then
        self:DeathAlerts_StopPreviewTicker()
        self.DeathAlerts.previewPool = pool
        self.DeathAlerts.previewIndex = 1
        self:DeathAlerts_RenderPreviewRole(pool[1])
        return finish()
    end

    if self.DeathAlerts.previewTicker and self.DeathAlerts.previewTicker.Cancel then
        return finish()
    end

    if C_Timer and C_Timer.NewTicker then
        self.DeathAlerts.previewTicker = C_Timer.NewTicker(2.0, function()
            local perfStart, perfState = PortalAuthority:PerfBegin("death_alerts_preview_tick")
            PortalAuthority:CpuDiagCount("death_alerts_preview_tick")
            if not PortalAuthorityDB or PA_SafeBool(PortalAuthorityDB.deathAlertLocked) then
                PortalAuthority:DeathAlerts_StopPreviewTicker()
                PortalAuthority:PerfEnd("death_alerts_preview_tick", perfStart, perfState)
                return
            end
            if InCombatLockdown and InCombatLockdown() then
                if PortalAuthority.DeathAlerts and PortalAuthority.DeathAlerts.frame then
                    PortalAuthority.DeathAlerts.frame:Hide()
                end
                PortalAuthority:PerfEnd("death_alerts_preview_tick", perfStart, perfState)
                return
            end

            local activePool = PortalAuthority.DeathAlerts and PortalAuthority.DeathAlerts.previewPool or nil
            if type(activePool) ~= "table" or #activePool == 0 then
                activePool = { "TANK" }
                PortalAuthority.DeathAlerts.previewPool = activePool
            end

            local nextIndex = (tonumber(PortalAuthority.DeathAlerts.previewIndex) or 1) + 1
            if nextIndex > #activePool then
                nextIndex = 1
            end
            PortalAuthority.DeathAlerts.previewIndex = nextIndex
            PortalAuthority:DeathAlerts_RenderPreviewRole(activePool[nextIndex])
            PortalAuthority:PerfEnd("death_alerts_preview_tick", perfStart, perfState)
        end)
    end

    return finish()
end
function PortalAuthority:DeathAlerts_EnsureFrame()
    if PA_IsUiSurfaceGateEnabled() then
        return nil
    end
    if self.DeathAlerts and self.DeathAlerts.frame then
        return self.DeathAlerts.frame
    end

    local db = PortalAuthorityDB or self.defaults
    self.DeathAlerts = self.DeathAlerts or {}

    local frame = CreateFrame("Frame", nil, UIParent, "BackdropTemplate")
    frame:SetFrameStrata("FULLSCREEN_DIALOG")
    frame:SetClampedToScreen(false)
    frame:SetMovable(true)
    frame:EnableMouse(false)
    frame:SetSize(800, 90)
    frame:SetPoint("CENTER", UIParent, "CENTER", math.floor(tonumber(db.deathAlertX) or 0), math.floor(tonumber(db.deathAlertY) or 0))
    frame:Hide()

    local text = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    text:SetPoint("CENTER", frame, "CENTER", 0, 0)
    text:SetJustifyH("CENTER")
    text:SetJustifyV("MIDDLE")
    text:SetWordWrap(true)
    if text.SetNonSpaceWrap then
        text:SetNonSpaceWrap(true)
    end
    text:SetWidth(780)
    text:SetTextColor(1, 1, 1, 1)

    local handle = CreateFrame("Button", nil, frame, "BackdropTemplate")
    handle:SetPoint("BOTTOMLEFT", frame, "TOPLEFT", 0, 3)
    handle:SetPoint("BOTTOMRIGHT", frame, "TOPRIGHT", 0, 3)
    handle:SetHeight(16)
    handle:SetBackdrop({
        bgFile = "Interface/Buttons/WHITE8X8",
        edgeFile = "Interface/Tooltips/UI-Tooltip-Border",
        edgeSize = 10,
        insets = { left = 2, right = 2, top = 2, bottom = 2 },
    })
    handle:SetBackdropColor(0.0, 1.0, 0.0, 0.85)
    handle:SetBackdropBorderColor(0.0, 0.25, 0.0, 1.0)
    local handleText = handle:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    handleText:SetPoint("CENTER", handle, "CENTER", 0, 0)
    handleText:SetText(L["RUNTIME_DRAG_HANDLE_GRAB"])
    handleText:SetTextColor(0, 0, 0, 1)
    handle:EnableMouse(true)
    handle:RegisterForDrag("LeftButton")
    handle:SetScript("OnDragStart", function()
        if InCombatLockdown() then return end
        if not PortalAuthorityDB or PA_SafeBool(PortalAuthorityDB.deathAlertLocked) then return end
        frame:StartMoving()
        if not self.DeathAlerts.dragTicker and C_Timer and C_Timer.NewTicker then
            self.DeathAlerts.dragTicker = C_Timer.NewTicker(0.1, function()
                local perfStart, perfState = PortalAuthority:PerfBegin("death_alerts_drag_tick")
                PortalAuthority:CpuDiagCount("death_alerts_drag_tick")
                local cx, cy = frame:GetCenter()
                local ux, uy = UIParent:GetCenter()
                if cx and cy and ux and uy and PortalAuthority.DeathAlerts_OnPositionChanged then
                    PortalAuthority.DeathAlerts_OnPositionChanged(math.floor((cx - ux) + 0.5), math.floor((cy - uy) + 0.5))
                end
                PortalAuthority:PerfEnd("death_alerts_drag_tick", perfStart, perfState)
            end)
        end
    end)
    handle:SetScript("OnDragStop", function()
        frame:StopMovingOrSizing()
        if self.DeathAlerts.dragTicker then
            self.DeathAlerts.dragTicker:Cancel()
            self.DeathAlerts.dragTicker = nil
        end
        local cx, cy = frame:GetCenter()
        local ux, uy = UIParent:GetCenter()
        if cx and cy and ux and uy and PortalAuthorityDB then
            local x = math.floor((cx - ux) + 0.5)
            local y = math.floor((cy - uy) + 0.5)
            PortalAuthorityDB.deathAlertX = x
            PortalAuthorityDB.deathAlertY = y
            if PortalAuthority.DeathAlerts_OnPositionChanged then
                PortalAuthority.DeathAlerts_OnPositionChanged(x, y)
            end
        end
    end)

    self.DeathAlerts.frame = frame
    self.DeathAlerts.text = text
    self.DeathAlerts.handle = handle
    return frame
end

function PortalAuthority:DeathAlerts_FormatMessage(template, role, classFile, name)
    local text = trim(template)
    if text == "" then
        text = self.defaults.deathAlertMessageTemplate
    end

    local roleForIcon = role
    if roleForIcon == "SELF" then
        roleForIcon = UnitGroupRolesAssigned and UnitGroupRolesAssigned("player") or "DAMAGER"
        if roleForIcon ~= "TANK" and roleForIcon ~= "HEALER" and roleForIcon ~= "DAMAGER" then
            roleForIcon = "DAMAGER"
        end
    end

    local classIcon = ""
    if classFile and CLASS_ICON_TCOORDS and CLASS_ICON_TCOORDS[classFile] then
        local coords = CLASS_ICON_TCOORDS[classFile]
        classIcon = string.format(
            "|TInterface\\GLUES\\CHARACTERCREATE\\UI-CHARACTERCREATE-CLASSES:16:16:0:0:256:256:%d:%d:%d:%d|t",
            math.floor(coords[1] * 256 + 0.5),
            math.floor(coords[2] * 256 + 0.5),
            math.floor(coords[3] * 256 + 0.5),
            math.floor(coords[4] * 256 + 0.5)
        )
    end

    local playerName = name or L["COMMON_UNKNOWN"]
    local hex = self:GetClassHexColor(classFile) or "ffffffff"
    local coloredName = string.format("|c%s%s|r", hex, playerName)
    local roleText = L["COMMON_DPS"]
    if roleForIcon == "TANK" then
        roleText = L["COMMON_TANK"]
    elseif roleForIcon == "HEALER" then
        roleText = L["COMMON_HEALER"]
    end

    text = text:gsub("%[role%]", roleText)
    text = text:gsub("%[class%]", classIcon)
    text = text:gsub("%[name%]", coloredName)
    return text
end

function PortalAuthority:DeathAlerts_Show(message, durationSeconds)
    if PA_IsUiSurfaceGateEnabled() then
        return
    end
    if not self:IsCombatAlertsEnabled() then
        if self.DeathAlerts and self.DeathAlerts.frame then
            self.DeathAlerts.frame:Hide()
        end
        return
    end
    self:DeathAlerts_EnsureFrame()
    if not (self.DeathAlerts and self.DeathAlerts.frame and self.DeathAlerts.text) then
        return
    end

    local dur = durationSeconds
    if dur == nil then
        dur = 5
    end
    self.DeathAlerts.runtimeAlertVisible = dur > 0

    self.DeathAlerts.text:SetText(message or "")
    self.DeathAlerts.frame:Show()

    if self.DeathAlerts.hideTimer and self.DeathAlerts.hideTimer.Cancel then
        self.DeathAlerts.hideTimer:Cancel()
    end
    self.DeathAlerts.hideTimer = nil
    self.DeathAlerts.hideToken = (self.DeathAlerts.hideToken or 0) + 1
    local token = self.DeathAlerts.hideToken

    if dur and dur > 0 then
        if C_Timer and C_Timer.NewTimer then
            self.DeathAlerts.hideTimer = C_Timer.NewTimer(dur, function()
                if not self.DeathAlerts or self.DeathAlerts.hideToken ~= token then return end
                self.DeathAlerts.runtimeAlertVisible = false
                if self.DeathAlerts.frame then
                    self.DeathAlerts.frame:Hide()
                end
            end)
        elseif C_Timer and C_Timer.After then
            C_Timer.After(dur, function()
                if not self.DeathAlerts or self.DeathAlerts.hideToken ~= token then return end
                self.DeathAlerts.runtimeAlertVisible = false
                if self.DeathAlerts.frame then
                    self.DeathAlerts.frame:Hide()
                end
            end)
        end
    end
end

function PortalAuthority:DeathAlerts_TestOnScreen()
    if PA_IsUiSurfaceGateEnabled() then
        return
    end
    if not self:IsCombatAlertsEnabled() then
        return
    end
    local db = PortalAuthorityDB or self.defaults
    local msg = self:DeathAlerts_FormatMessage(db.deathAlertMessageTemplate, "TANK", "PALADIN", "Streetxo")
    if PortalAuthorityDB and PA_SafeBool(PortalAuthorityDB.deathAlertShowRoleIcon) then
        msg = (PA_GetRoleIconTag and PA_GetRoleIconTag("TANK") or "") .. " " .. msg
    end
    self:DeathAlerts_Show(msg)
end

function PortalAuthority:DeathAlerts_RefreshTestMode()
    if PA_IsUiSurfaceGateEnabled() then
        return false
    end
    if not self:IsCombatAlertsEnabled() then
        self.DeathAlerts = self.DeathAlerts or {}
        self.DeathAlerts._testMode = false
        if self.DeathAlerts.frame then
            self.DeathAlerts.frame:Hide()
        end
        return false
    end
    if not (self.DeathAlerts and self.DeathAlerts._testMode) then
        return false
    end
    local db = PortalAuthorityDB or self.defaults
    local msg = self:DeathAlerts_FormatMessage(db.deathAlertMessageTemplate, "TANK", "PALADIN", "Streetxo")
    if PortalAuthorityDB and PA_SafeBool(PortalAuthorityDB.deathAlertShowRoleIcon) then
        msg = (PA_GetRoleIconTag and PA_GetRoleIconTag("TANK") or "") .. " " .. msg
    end
    -- Keep test mode infinite (no auto-hide)
    self:DeathAlerts_Show(msg, 0)
    return true
end

function PortalAuthority:DeathAlerts_IsTestModeActive()
    return self.DeathAlerts and self.DeathAlerts._testMode == true
end

function PortalAuthority:DeathAlerts_ToggleTestMode()
    if PA_IsUiSurfaceGateEnabled() then
        self.DeathAlerts = self.DeathAlerts or {}
        self.DeathAlerts._testMode = false
        if self.DeathAlerts.hideTimer and self.DeathAlerts.hideTimer.Cancel then
            self.DeathAlerts.hideTimer:Cancel()
        end
        self.DeathAlerts.hideTimer = nil
        if self.DeathAlerts.frame then
            self.DeathAlerts.frame:Hide()
        end
        return false
    end
    if not self:IsCombatAlertsEnabled() then
        self.DeathAlerts = self.DeathAlerts or {}
        self.DeathAlerts._testMode = false
        if self.DeathAlerts.hideTimer and self.DeathAlerts.hideTimer.Cancel then
            self.DeathAlerts.hideTimer:Cancel()
        end
        self.DeathAlerts.hideTimer = nil
        if self.DeathAlerts.frame then
            self.DeathAlerts.frame:Hide()
        end
        return false
    end
    self.DeathAlerts = self.DeathAlerts or {}
    local active = self.DeathAlerts._testMode == true

    if active then
        self.DeathAlerts._testMode = false
        if self.DeathAlerts.hideTimer and self.DeathAlerts.hideTimer.Cancel then
            self.DeathAlerts.hideTimer:Cancel()
        end
        self.DeathAlerts.hideTimer = nil
        self.DeathAlerts.hideToken = (self.DeathAlerts.hideToken or 0) + 1
        if self.DeathAlerts.frame then
            self.DeathAlerts.frame:Hide()
        end
        return false
    end

    self.DeathAlerts._testMode = true
    local db = PortalAuthorityDB or self.defaults
    local msg = self:DeathAlerts_FormatMessage(db.deathAlertMessageTemplate, "TANK", "PALADIN", "Streetxo")
    if PortalAuthorityDB and PA_SafeBool(PortalAuthorityDB.deathAlertShowRoleIcon) then
        msg = (PA_GetRoleIconTag and PA_GetRoleIconTag("TANK") or "") .. " " .. msg
    end
    self:DeathAlerts_Show(msg, 0)
    return true
end

function PortalAuthority:ApplyCombatAlertsSettings()
    if PA_IsUiSurfaceGateEnabled() then
        self.DeathAlerts = self.DeathAlerts or {}
        self:DeathAlerts_StopPreviewTicker()
        if self.DeathAlerts.dragTicker and self.DeathAlerts.dragTicker.Cancel then
            self.DeathAlerts.dragTicker:Cancel()
        end
        self.DeathAlerts.dragTicker = nil
        self.DeathAlerts._testMode = false
        if self.DeathAlerts.frame then
            self.DeathAlerts.frame:Hide()
        end
        return
    end
    if not self:IsCombatAlertsEnabled() then
        self.DeathAlerts = self.DeathAlerts or {}
        self:DeathAlerts_StopPreviewTicker()
        if self.DeathAlerts.dragTicker and self.DeathAlerts.dragTicker.Cancel then
            self.DeathAlerts.dragTicker:Cancel()
        end
        self.DeathAlerts.dragTicker = nil
        self.DeathAlerts._testMode = false
        if self.DeathAlerts.handle then
            self.DeathAlerts.handle:Hide()
        end
        if self.DeathAlerts.frame then
            self.DeathAlerts.frame:Hide()
        end
        if not (self.IsOnboardingD4Active and self:IsOnboardingD4Active())
            and self._OnboardingEnsureMarkers and self._OnboardingUpdateMarkers
        then
            self:_OnboardingEnsureMarkers()
            self:_OnboardingUpdateMarkers(self._moveHintBlink == true)
        end
        self:UpdateMoveHintTickerState()
        return
    end
    local perfStart, perfState = self:PerfBegin("deathalerts_apply_settings")
    local function finish(...)
        if perfStart ~= nil then
            self:PerfEnd("deathalerts_apply_settings", perfStart, perfState)
        end
        return ...
    end

    self:DeathAlerts_EnsureFrame()
    if not (self.DeathAlerts and self.DeathAlerts.frame and self.DeathAlerts.text and PortalAuthorityDB) then
        return finish()
    end

    local db = PortalAuthorityDB
    local fontPath = trim(db.deathAlertFontPath)
    if fontPath == "" then
        fontPath = self:GetGlobalFontPath()
    end
    local fontSize = math.floor(PA_Clamp(db.deathAlertFontSize, self.defaults.deathAlertFontSize, 8, 72))
    self.DeathAlerts.text:SetFont(fontPath, fontSize, "OUTLINE")
    self.DeathAlerts.text:SetTextColor(1, 1, 1, 1)
    self.DeathAlerts.frame:ClearAllPoints()
    self.DeathAlerts.frame:SetPoint("CENTER", UIParent, "CENTER", math.floor(tonumber(db.deathAlertX) or 0), math.floor(tonumber(db.deathAlertY) or 0))
    local unlocked = not PA_SafeBool(db.deathAlertLocked)
    local inCombat = InCombatLockdown and InCombatLockdown() or false
    self.DeathAlerts.handle:SetShown(unlocked)

    if unlocked then
        if not self.DeathAlerts._previewForcedVisible then
            self:DeathAlerts_StopPreviewTicker()
        end
        self.DeathAlerts._previewForcedVisible = true
        if inCombat then
            self:DeathAlerts_StopPreviewTicker()
            self.DeathAlerts.runtimeAlertVisible = false
            if self.DeathAlerts.frame then
                self.DeathAlerts.frame:Hide()
            end
        else
            self:DeathAlerts_ApplyUnlockedPreview()
        end
    else
        self.DeathAlerts._previewForcedVisible = false
        self:DeathAlerts_StopPreviewTicker()
        if self.DeathAlerts_IsTestModeActive and self:DeathAlerts_IsTestModeActive() and self.DeathAlerts_RefreshTestMode then
            self:DeathAlerts_RefreshTestMode()
        elseif self.DeathAlerts.frame and not (self.DeathAlerts.runtimeAlertVisible and self.DeathAlerts.frame:IsShown()) then
            self.DeathAlerts.runtimeAlertVisible = false
            self.DeathAlerts.frame:Hide()
        end
    end

    -- Apply lock/unlock marker visibility immediately instead of waiting for ticker cadence.
    if not (self.IsOnboardingD4Active and self:IsOnboardingD4Active())
        and self._OnboardingEnsureMarkers and self._OnboardingUpdateMarkers
    then
        self:_OnboardingEnsureMarkers()
        self:_OnboardingUpdateMarkers(self._moveHintBlink == true)
    end
    self:UpdateMoveHintTickerState()

    return finish()
end

function PortalAuthority:DeathAlerts_HandleDeath(deadGUID, destNameOptional, destFlags)
    local normalizedDeadGUID = PA_NormalizeSafeString(deadGUID)
    if not normalizedDeadGUID or normalizedDeadGUID == "" then
        return
    end
    if not PortalAuthorityDB then
        return
    end
    if not self:IsCombatAlertsEnabled() then
        return
    end

    local playerGUID = PA_SafeUnitGUID("player")
    if PA_SafeStringsEqual(normalizedDeadGUID, playerGUID) then
        self:DeathAlerts_HandlePlayerDeath()
        return
    end

    local inRaid = IsInRaid and IsInRaid() or false
    local inGroup = IsInGroup and IsInGroup() or false
    if not inRaid and not inGroup then
        return
    end

    if not self:IsGUIDInCurrentGroup(normalizedDeadGUID) then
        return
    end

    -- UnitTokenFromGUID is enrichment-only. Group relevance is established by GUID membership first,
    -- and role fallback only applies after that relevance check passes.
    local unitToken = self:GetCurrentGroupUnitTokenByGUID(normalizedDeadGUID)
    if not unitToken and UnitTokenFromGUID then
        local ok, token = pcall(UnitTokenFromGUID, normalizedDeadGUID)
        if ok and token and not PA_IsSecretValue(token) then
            local tokenGUID = PA_SafeUnitGUID(token)
            if PA_SafeStringsEqual(tokenGUID, normalizedDeadGUID) then
                unitToken = token
            end
        end
    end

    if unitToken and not PA_SafeStringsEqual(PA_SafeUnitGUID(unitToken), normalizedDeadGUID) then
        unitToken = nil
    end

    if unitToken == "player" then
        self:DeathAlerts_HandlePlayerDeath()
        return
    end

    local role = "DAMAGER"
    if unitToken and UnitGroupRolesAssigned then
        local ok, r = pcall(UnitGroupRolesAssigned, unitToken)
        if ok and not PA_IsSecretValue(r) and type(r) == "string" and r ~= "" and r ~= "NONE" then
            role = r
        end
    end
    if role ~= "TANK" and role ~= "HEALER" and role ~= "DAMAGER" then
        role = "DAMAGER"
    end

    local name = nil
    if unitToken and UnitName then
        local ok, n = pcall(UnitName, unitToken)
        if ok and not PA_IsSecretValue(n) and type(n) == "string" and n ~= "" then
            name = Ambiguate and Ambiguate(n, "short") or n
        end
    end
    local normalizedDestName = PA_NormalizeSafeString(destNameOptional)
    if trim(name) == "" and normalizedDestName then
        name = Ambiguate and Ambiguate(normalizedDestName, "short") or normalizedDestName
    end
    if trim(name) == "" then
        name = L["COMMON_UNKNOWN"]
    end

    local classFile = nil
    if GetPlayerInfoByGUID then
        local ok, _, class = pcall(GetPlayerInfoByGUID, normalizedDeadGUID)
        if ok and not PA_IsSecretValue(class) then
            classFile = class
        end
    end
    if trim(classFile) == "" and unitToken and UnitClass then
        local ok, _, class = pcall(UnitClass, unitToken)
        if ok and not PA_IsSecretValue(class) then
            classFile = class
        end
    end

    local keyPrefix = "deathAlertDps"
    if role == "SELF" then
        keyPrefix = "deathAlertSelf"
    elseif role == "TANK" then
        keyPrefix = "deathAlertTank"
    elseif role == "HEALER" then
        keyPrefix = "deathAlertHealer"
    end

    self:DeathAlerts_TriggerResolvedAlert(keyPrefix, role, classFile, name)
end

function PortalAuthority:DeathAlerts_TriggerResolvedAlert(keyPrefix, role, classFile, name)
    local rowEnabled = PA_SafeBool(PortalAuthorityDB[keyPrefix .. "Enabled"])
    if not rowEnabled then
        return
    end

    if self.DeathAlerts and self.DeathAlerts._testMode then
        self.DeathAlerts._testMode = false
    end

    if keyPrefix == "deathAlertSelf" then
        self.DeathAlerts = self.DeathAlerts or {}
        local now = GetTime and GetTime() or 0
        local lastAt = tonumber(self.DeathAlerts.lastSelfResolvedAlertAt) or 0
        if lastAt > 0 and (now - lastAt) <= DEATH_ALERT_SELF_DUPLICATE_WINDOW_SECONDS then
            return
        end
        self.DeathAlerts.lastSelfResolvedAlertAt = now
    end

    local suppressAudio = false
    if PA_SafeBool(PortalAuthorityDB.deathAlertAntiSpamWipeEnabled) then
        local tracker = self.DeathAlerts and self.DeathAlerts.wipeTracker or {}
        self.DeathAlerts = self.DeathAlerts or {}
        self.DeathAlerts.wipeTracker = tracker
        local now = GetTime and GetTime() or 0
        local keep = {}
        for _, ts in ipairs(tracker) do
            if (now - ts) <= 5 then
                keep[#keep + 1] = ts
            end
        end
        keep[#keep + 1] = now
        self.DeathAlerts.wipeTracker = keep
        if #keep > 5 then
            self.DeathAlerts.wipeInProgressUntil = now + 15
            self.DeathAlerts.wipeTracker = {}
        end
        if self.DeathAlerts.wipeInProgressUntil and now < self.DeathAlerts.wipeInProgressUntil then
            suppressAudio = true
        end
    end

    local message
    if keyPrefix == "deathAlertSelf" and PA_SafeBool(PortalAuthorityDB[keyPrefix .. "OnScreen"]) then
        message = L["RUNTIME_ALERTS_SELF_DIED"]
    else
        message = self:DeathAlerts_FormatMessage(PortalAuthorityDB.deathAlertMessageTemplate, role, classFile, name)
    end
    if PA_SafeBool(PortalAuthorityDB.deathAlertShowRoleIcon) and role ~= "SELF" then
        message = (PA_GetRoleIconTag and PA_GetRoleIconTag(role) or "") .. " " .. message
    end
    if PA_SafeBool(PortalAuthorityDB[keyPrefix .. "OnScreen"]) then
        self:DeathAlerts_Show(message)
    end

    if not suppressAudio then
        local selectedSound = PortalAuthorityDB[keyPrefix .. "Sound"]
        local soundFile = self:GetDeathAlertSoundFile(selectedSound)
        if soundFile then
            local channel = PortalAuthorityDB.deathAlertSoundChannel
            if not VALID_SOUND_CHANNELS[channel] then
                channel = self.defaults.deathAlertSoundChannel
            end
            if type(soundFile) == "number" and PlaySound then
                PlaySound(soundFile, channel)
            elseif PlaySoundFile then
                PlaySoundFile(soundFile, channel)
            end
        end
    end
end

function PortalAuthority:DeathAlerts_HandlePlayerDeath()
    if not PortalAuthorityDB then
        return
    end
    if not self:IsCombatAlertsEnabled() then
        return
    end

    local classFile = nil
    if UnitClass then
        local ok, _, class = pcall(UnitClass, "player")
        if ok and not PA_IsSecretValue(class) and type(class) == "string" and class ~= "" then
            classFile = class
        end
    end

    local name = nil
    if UnitName then
        local ok, playerName = pcall(UnitName, "player")
        if ok and not PA_IsSecretValue(playerName) and type(playerName) == "string" and playerName ~= "" then
            name = Ambiguate and Ambiguate(playerName, "short") or playerName
        end
    end
    if trim(name) == "" then
        name = L["COMMON_PLAYER"]
    end

    self:DeathAlerts_TriggerResolvedAlert("deathAlertSelf", "SELF", classFile, name)
end

function PortalAuthority:InitializeDeathAlerts()
    if PA_IsUiSurfaceGateEnabled() then
        return
    end
    if self._deathAlertsHooked then return end
    self._deathAlertsHooked = true

    self.DeathAlerts = self.DeathAlerts or {}
    self.DeathAlerts.wipeTracker = self.DeathAlerts.wipeTracker or {}

    -- Do not create/register an event frame here; main addon frame handles events.
    self.DeathAlerts.eventFrame = nil

    self:ApplyCombatAlertsSettings()
end

function PortalAuthority:GetRitualOfSummoningIconTexture()
    if C_Spell and C_Spell.GetSpellTexture then
        local textureID = C_Spell.GetSpellTexture(RITUAL_OF_SUMMONING_SPELL_ID)
        if textureID then
            return textureID
        end
    end

    if GetSpellTexture then
        local textureID = GetSpellTexture(RITUAL_OF_SUMMONING_SPELL_ID)
        if textureID then
            return textureID
        end
    end

    return RITUAL_OF_SUMMONING_ICON_FALLBACK
end

function PortalAuthority:GetSummonStoneRequestPopupMessage()
    local textureID = self:GetRitualOfSummoningIconTexture()
    return string.format("|T%s:0|t Summoning Stone has been requested", tostring(textureID))
end

function PortalAuthority:ShowSummonStoneRequestPopup()
    local message = self:GetSummonStoneRequestPopupMessage()

    if RaidNotice_AddMessage and RaidWarningFrame and ChatTypeInfo and ChatTypeInfo.RAID_WARNING then
        RaidNotice_AddMessage(RaidWarningFrame, message, ChatTypeInfo.RAID_WARNING)
        return
    end

    UIErrorsFrame:AddMessage(message, 1, 0.82, 0, 1, 3)
end

function PortalAuthority:ShouldThrottleSummonStonePopup(sender)
    local playerName = self:NormalizeSenderName(sender)
    local now = GetTime()
    local lastShown = self.lastSummonStonePopupBySender[playerName]

    if lastShown and (now - lastShown) < SUMMON_SENDER_COOLDOWN_SECONDS then
        return true
    end

    self.lastSummonStonePopupBySender[playerName] = now
    return false
end

function PortalAuthority:IsSummonStoneBurstLimited()
    local now = GetTime()
    local windowStart = now - SUMMON_BURST_WINDOW_SECONDS

    local kept = {}
    for _, ts in ipairs(self.summonStoneRequestTimestamps) do
        if ts >= windowStart then
            table.insert(kept, ts)
        end
    end
    self.summonStoneRequestTimestamps = kept

    if #self.summonStoneRequestTimestamps >= SUMMON_BURST_WINDOW_MAX then
        return true
    end

    table.insert(self.summonStoneRequestTimestamps, now)
    return false
end

function PortalAuthority:GetSenderForEvent(event, sender, bnSenderID)
    if event == "CHAT_MSG_BN_WHISPER" then
        if bnSenderID and C_BattleNet and C_BattleNet.GetAccountInfoByID then
            local accountInfo = C_BattleNet.GetAccountInfoByID(bnSenderID)
            if accountInfo then
                local btag = accountInfo.battleTag
                if trim(btag) ~= "" then
                    return btag
                end
                if accountInfo.accountName and trim(accountInfo.accountName) ~= "" then
                    return accountInfo.accountName
                end
            end
        end
        return trim(sender) ~= "" and sender or "Battle.net"
    end

    return sender
end

function PortalAuthority:ResolveSummonChatPayload(message, sender, senderGUID, lineID)
    local resolvedMessage = message
    local resolvedSender = sender
    local resolvedSenderGUID = senderGUID
    local chatLineID = tonumber(lineID)

    if chatLineID and chatLineID > 0 and C_ChatInfo then
        if C_ChatInfo.GetChatLineText then
            local ok, fetchedMessage = pcall(C_ChatInfo.GetChatLineText, chatLineID)
            if ok and type(fetchedMessage) == "string" and fetchedMessage ~= "" then
                resolvedMessage = fetchedMessage
            end
        end

        if C_ChatInfo.GetChatLineSenderName then
            local ok, fetchedSender = pcall(C_ChatInfo.GetChatLineSenderName, chatLineID)
            if ok and type(fetchedSender) == "string" and fetchedSender ~= "" then
                resolvedSender = fetchedSender
            end
        end

        if C_ChatInfo.GetChatLineSenderGUID then
            local ok, fetchedGUID = pcall(C_ChatInfo.GetChatLineSenderGUID, chatLineID)
            if ok and type(fetchedGUID) == "string" and fetchedGUID ~= "" then
                resolvedSenderGUID = fetchedGUID
            end
        end
    end

    return resolvedMessage, resolvedSender, resolvedSenderGUID
end

function PortalAuthority:IsUnitInCurrentGroup(sender, senderGUID)
    local normalizedSenderGUID = PA_NormalizeSafeString(senderGUID)
    if normalizedSenderGUID then
        if UnitExists and UnitExists("player") and PA_SafeStringsEqual(PA_SafeUnitGUID("player"), normalizedSenderGUID) then
            return true
        end

        local raidCount = GetNumGroupMembers and GetNumGroupMembers() or 0
        for index = 1, raidCount do
            if PA_SafeStringsEqual(PA_SafeUnitGUID("raid" .. index), normalizedSenderGUID) then
                return true
            end
        end

        local partyCount = GetNumSubgroupMembers and GetNumSubgroupMembers() or 0
        for index = 1, partyCount do
            if PA_SafeStringsEqual(PA_SafeUnitGUID("party" .. index), normalizedSenderGUID) then
                return true
            end
        end
    end

    if type(sender) ~= "string" or sender == "" then
        return false
    end

    local normalizedSender = self:NormalizeSenderName(sender):lower()
    local function matchesUnitByName(unit)
        if not UnitExists(unit) then
            return false
        end

        local unitName = UnitName(unit)
        if type(unitName) ~= "string" or unitName == "" then
            return false
        end

        return self:NormalizeSenderName(unitName):lower() == normalizedSender
    end

    if IsInRaid and IsInRaid() then
        local raidCount = GetNumGroupMembers and GetNumGroupMembers() or 0
        for index = 1, raidCount do
            if matchesUnitByName("raid" .. index) then
                return true
            end
        end
    elseif IsInGroup and IsInGroup() then
        local partyCount = GetNumSubgroupMembers and GetNumSubgroupMembers() or 0
        for index = 1, partyCount do
            if matchesUnitByName("party" .. index) then
                return true
            end
        end

        if matchesUnitByName("player") then
            return true
        end
    end

    return false
end

function PortalAuthority:GetCurrentGroupUnitTokenByGUID(unitGUID)
    local normalizedUnitGUID = PA_NormalizeSafeString(unitGUID)
    if not normalizedUnitGUID then
        return nil
    end

    if PA_SafeStringsEqual(PA_SafeUnitGUID("player"), normalizedUnitGUID) then
        return "player"
    end

    if IsInRaid and IsInRaid() then
        local raidCount = GetNumGroupMembers and GetNumGroupMembers() or 0
        for index = 1, raidCount do
            local unit = "raid" .. index
            if PA_SafeStringsEqual(PA_SafeUnitGUID(unit), normalizedUnitGUID) then
                return unit
            end
        end
    elseif IsInGroup and IsInGroup() then
        local partyCount = GetNumSubgroupMembers and GetNumSubgroupMembers() or 0
        for index = 1, partyCount do
            local unit = "party" .. index
            if PA_SafeStringsEqual(PA_SafeUnitGUID(unit), normalizedUnitGUID) then
                return unit
            end
        end
    end

    return nil
end

function PortalAuthority:IsGUIDInCurrentGroup(unitGUID)
    local normalizedUnitGUID = PA_NormalizeSafeString(unitGUID)
    if not normalizedUnitGUID then
        return false
    end

    if PA_SafeStringsEqual(PA_SafeUnitGUID("player"), normalizedUnitGUID) then
        return true
    end

    local inRaid = IsInRaid and IsInRaid() or false
    local inGroup = IsInGroup and IsInGroup() or false
    if not inRaid and not inGroup then
        return false
    end

    if IsGUIDInGroup then
        local ok, inCurrentGroup = pcall(IsGUIDInGroup, normalizedUnitGUID)
        if ok and inCurrentGroup then
            return true
        end
    end

    return self:GetCurrentGroupUnitTokenByGUID(normalizedUnitGUID) ~= nil
end

function PortalAuthority:GetBNetWhisperCharacter(bnSenderID)
    if not bnSenderID or not C_BattleNet or not C_BattleNet.GetAccountInfoByID then
        return nil, nil
    end

    local accountInfo = C_BattleNet.GetAccountInfoByID(bnSenderID)
    if not accountInfo then
        return nil, nil
    end

    local gameAccountInfo = accountInfo.gameAccountInfo
    if not gameAccountInfo then
        return nil, nil
    end

    local characterName = trim(gameAccountInfo.characterName)
    local realmName = trim(gameAccountInfo.realmName)
    local playerGuid = trim(gameAccountInfo.playerGuid)

    if characterName == "" then
        characterName = nil
    elseif realmName ~= "" then
        characterName = string.format("%s-%s", characterName, realmName)
    end

    if playerGuid == "" then
        playerGuid = nil
    end

    return characterName, playerGuid
end

function PortalAuthority:IsSummonSenderInGroup(event, sender, bnSenderID, senderGUID)
    if event == "CHAT_MSG_RAID" or event == "CHAT_MSG_RAID_LEADER" or event == "CHAT_MSG_PARTY" or event == "CHAT_MSG_PARTY_LEADER" then
        return true
    end

    if event == "CHAT_MSG_INSTANCE_CHAT" or event == "CHAT_MSG_INSTANCE_CHAT_LEADER" then
        return self:IsUnitInCurrentGroup(sender, senderGUID)
    end

    if event == "CHAT_MSG_WHISPER" then
        return self:IsUnitInCurrentGroup(sender, senderGUID)
    end

    if event == "CHAT_MSG_BN_WHISPER" then
        local characterName, characterGUID = self:GetBNetWhisperCharacter(bnSenderID)

        if self:IsUnitInCurrentGroup(characterName, characterGUID) then
            return true
        end

        if senderGUID and self:IsUnitInCurrentGroup(nil, senderGUID) then
            return true
        end

        return false
    end

    return false
end

function PortalAuthority:HandleSummonChatEvent(event, message, sender, bnSenderID, senderGUID, lineID)
    self:SummonDebugTrace("summon", "entered", event, sender, senderGUID, message)
    if not PortalAuthorityDB or not PortalAuthorityDB.summonRequestsEnabled then
        self:SummonDebugTrace("summon", "disabled", event, sender, senderGUID, message)
        return
    end

    if PA_IsChallengeActive and PA_IsChallengeActive() then
        self:SummonDebugTrace("summon", "challenge_blocked", event, sender, senderGUID, message)
        return
    end

    if not self:IsWarlockPlayer() then
        self:SummonDebugTrace("summon", "not_warlock", event, sender, senderGUID, message)
        return
    end

    if self:IsPlayerInCombat() then
        self:SummonDebugTrace("summon", "in_combat", event, sender, senderGUID, message)
        return
    end

    message, sender, senderGUID = self:ResolveSummonChatPayload(message, sender, senderGUID, lineID)

    if self:IsSenderPlayer(sender, senderGUID) then
        self:SummonDebugTrace("summon", "self_sender", event, sender, senderGUID, message)
        return
    end

    if self:ShouldSuppressOwnWarlockSummoningAnnouncement(event, message, sender, senderGUID) then
        self:SummonDebugTrace("summon", "suppressed_own_announcement", event, sender, senderGUID, message)
        return
    end

    if not self:IsSummonKeywordMessage(message) then
        self:SummonDebugTrace("summon", "no_keyword", event, sender, senderGUID, message)
        return
    end

    if not self:IsSummonSenderInGroup(event, sender, bnSenderID, senderGUID) then
        self:SummonDebugTrace("summon", "sender_not_in_group", event, sender, senderGUID, message)
        return
    end

    if self:IsSummonBurstLimited() then
        self:SummonDebugTrace("summon", "burst_limited", event, sender, senderGUID, message)
        return
    end

    local triggerSender = self:GetSenderForEvent(event, sender, bnSenderID)
    if self:ShouldThrottleSummonPopup(triggerSender) then
        self:SummonDebugTrace("summon", "sender_throttled", event, sender, senderGUID, message)
        return
    end

    self:SummonDebugTrace("summon", "triggered", event, sender, senderGUID, message)
    self:ShowSummonPopup(event, triggerSender, senderGUID)
    self:PlaySummonRequestSound()
end

function PortalAuthority:HandleSummonStoneChatEvent(event, message, sender, bnSenderID, senderGUID, lineID)
    self:SummonDebugTrace("stone", "entered", event, sender, senderGUID, message)
    if not PortalAuthorityDB or not PortalAuthorityDB.summonStoneRequestsEnabled then
        self:SummonDebugTrace("stone", "disabled", event, sender, senderGUID, message)
        return
    end

    if PA_IsChallengeActive and PA_IsChallengeActive() then
        self:SummonDebugTrace("stone", "challenge_blocked", event, sender, senderGUID, message)
        return
    end

    if not self:IsWarlockPlayer() then
        self:SummonDebugTrace("stone", "not_warlock", event, sender, senderGUID, message)
        return
    end

    if self:IsPlayerInCombat() then
        self:SummonDebugTrace("stone", "in_combat", event, sender, senderGUID, message)
        return
    end

    message, sender, senderGUID = self:ResolveSummonChatPayload(message, sender, senderGUID, lineID)

    if self:IsSenderPlayer(sender, senderGUID) then
        self:SummonDebugTrace("stone", "self_sender", event, sender, senderGUID, message)
        return
    end

    if not self:IsSummonStoneKeywordMessage(message) then
        self:SummonDebugTrace("stone", "no_keyword", event, sender, senderGUID, message)
        return
    end

    if not self:IsSummonSenderInGroup(event, sender, bnSenderID, senderGUID) then
        self:SummonDebugTrace("stone", "sender_not_in_group", event, sender, senderGUID, message)
        return
    end

    if self:IsSummonStoneBurstLimited() then
        self:SummonDebugTrace("stone", "burst_limited", event, sender, senderGUID, message)
        return
    end

    local triggerSender = self:GetSenderForEvent(event, sender, bnSenderID)
    if self:ShouldThrottleSummonStonePopup(triggerSender) then
        self:SummonDebugTrace("stone", "sender_throttled", event, sender, senderGUID, message)
        return
    end

    self:SummonDebugTrace("stone", "triggered", event, sender, senderGUID, message)
    self:ShowSummonStoneRequestPopup()
    self:PlaySummonStoneRequestSound()
end

CoreInternal.PA_MaybeShowMPlusNotice = PA_MaybeShowMPlusNotice
CoreInternal.ResetMPlusNoticeShown = ResetMPlusNoticeShown
CoreInternal.PA_IsSecretValue = PA_IsSecretValue
CoreInternal.PA_NormalizeSafeString = PA_NormalizeSafeString
CoreInternal.PA_SafeStringsEqual = PA_SafeStringsEqual
CoreInternal.PA_SafeUnitGUID = PA_SafeUnitGUID
