local addonFolderName, addonTable = ...

local ADDON_NAME = "MythicKeystoneAssistant"
local ADDON_DISPLAY_NAME_KEY = "ADDON.NAME"
local ADDON_SHORT_NAME = "MKA"
local SAVED_VARIABLES_NAME = "MythicKeystoneAssistantDB"

addonTable.ADDON_NAME = ADDON_NAME
addonTable.ADDON_DISPLAY_NAME_KEY = ADDON_DISPLAY_NAME_KEY
addonTable.ADDON_SHORT_NAME = ADDON_SHORT_NAME
addonTable.ADDON_FOLDER_NAME = addonFolderName

---@type MythicKeystoneAssistant
local MKA = LibStub("AceAddon-3.0"):NewAddon(
    ADDON_NAME,
    "AceConsole-3.0",
    "AceEvent-3.0",
    "AceComm-3.0"
)

addonTable.MythicKeystoneAssistant = MKA
addonTable.MKA = MKA

local TRANSLATE = addonTable.TRANSLATE or function(msg) return msg end
local AceSerializer = LibStub("AceSerializer-3.0")
local LibKeystone = LibStub("LibKeystone", true)
local LibOpenKeystone = LibStub("LibOpenKeystone-1.0", true)

local function GetLocalizedAddonDisplayName()
    local displayName = TRANSLATE(ADDON_DISPLAY_NAME_KEY)
    if type(displayName) ~= "string" or displayName == "" then
        return ADDON_NAME
    end
    return displayName
end

local function FormatAddonPrintMessage(...)
    local count = select("#", ...)
    if count <= 0 then
        return ""
    end

    local parts = {}
    for index = 1, count do
        parts[index] = tostring(select(index, ...))
    end
    return table.concat(parts, " ")
end

function MKA:Print(...)
    local message = FormatAddonPrintMessage(...)
    if message == "" then
        return
    end

    local displayName = GetLocalizedAddonDisplayName()
    local chatFrame = DEFAULT_CHAT_FRAME or _G.ChatFrame1
    if chatFrame and chatFrame.AddMessage then
        chatFrame:AddMessage(string.format("|cff33ff99%s:|r %s", displayName, message))
    elseif print then
        print(string.format("%s: %s", displayName, message))
    end
end

local CHAT_COLOR_COMMAND = "ff58d7ff"
local CHAT_COLOR_HEADER = "ffffd100"
local CHAT_COLOR_VALUE = "ffffd100"
local CHAT_COLOR_SUCCESS = "ff00ff66"
local CHAT_COLOR_WARNING = "ffffaa00"
local CHAT_COLOR_ERROR = "ffff5555"
local CHAT_COLOR_MUTED = "ff999999"

local function ColorChatText(value, color)
    value = tostring(value or "")
    if value == "" then
        return value
    end
    return string.format("|c%s%s|r", color or CHAT_COLOR_VALUE, value)
end

local function FormatChatValue(value, emptyText, color)
    if value == nil or value == "" or value == "-" then
        return ColorChatText(emptyText or "未获取", CHAT_COLOR_MUTED)
    end
    return ColorChatText(value, color or CHAT_COLOR_VALUE)
end

local function FormatChatBool(value)
    return ColorChatText(value and "是" or "否", value and CHAT_COLOR_SUCCESS or CHAT_COLOR_ERROR)
end

local function CountTableEntries(tableValue)
    local count = 0
    if type(tableValue) ~= "table" then
        return count
    end
    for _ in pairs(tableValue) do
        count = count + 1
    end
    return count
end

local function FormatRoleListText(activeRoles)
    if type(activeRoles) ~= "table" or #activeRoles == 0 then
        return FormatChatValue(nil)
    end
    local roleNames = {}
    for _, roleKey in ipairs(activeRoles) do
        if roleKey == "TANK" then
            roleNames[#roleNames + 1] = "坦克"
        elseif roleKey == "HEAL" then
            roleNames[#roleNames + 1] = "治疗"
        elseif roleKey == "DPS" then
            roleNames[#roleNames + 1] = "输出"
        else
            roleNames[#roleNames + 1] = tostring(roleKey)
        end
    end
    return ColorChatText(table.concat(roleNames, "、"), CHAT_COLOR_VALUE)
end

local function FormatCommandHelpLine(line)
    line = tostring(line or "")
    local command, description = line:match("^(.-)%s+%-%s+(.+)$")
    if command and description then
        return string.format(
            "%s - %s",
            ColorChatText(strtrim and strtrim(command) or command, CHAT_COLOR_COMMAND),
            description
        )
    end
    return line
end

local function PrintCommandHelpLine(owner, translationKey)
    if not owner then
        return
    end
    owner:Print(FormatCommandHelpLine(TRANSLATE(translationKey)))
end

local COMM_PREFIX = ADDON_SHORT_NAME
local ADDON_ART_ROOT = "Interface\\AddOns\\" .. addonFolderName .. "\\Art\\Logo\\"
local ADDON_PANEL_PORTRAIT_PATH = ADDON_ART_ROOT .. "MythicKeystoneAssistant.png"
local ADDON_LOGO_PATH = ADDON_ART_ROOT .. "MythicKeystoneAssistant-logo.png"
local ADDON_MINIMAP_ICON_PATH = ADDON_ART_ROOT .. "MythicKeystoneAssistant.png"
local BROADCAST_COOLDOWN_SECONDS = 0.20
local CHARACTER_PATCH_DELAY_SECONDS = 0.05
local CHARACTER_UPDATE_DEBOUNCE_SECONDS = 0.35
local INITIAL_WORLD_UPDATE_DELAY_SECONDS = 0.75
local INITIAL_BROADCAST_DELAY_SECONDS = 1.00
local INITIAL_GROUP_SYNC_DELAY_SECONDS = 1.25
local GROUP_ROSTER_SETTLE_SECONDS = 0.25
local GROUP_ROSTER_BROADCAST_DELAY_SECONDS = 0.15
local GROUP_SYNC_REQUEST_DELAY_SECONDS = 0.30
local CHANGED_BROADCAST_DELAY_SECONDS = 0.05
local SYNC_REQUEST_RESPONSE_DELAY_SECONDS = 0.05
local LIB_KEYSTONE_REQUEST_DELAY_SECONDS = 0.10
local LIB_KEYSTONE_UNIT_CONNECTION_DELAY_SECONDS = 1.00
local LIB_KEYSTONE_COMPLETION_REQUEST_DELAY_SECONDS = 5.50
local LIB_KEYSTONE_FALLBACK_APPLY_DELAY_SECONDS = 0.75
local LIB_KEYSTONE_FALLBACK_WARBAND_TAG = "NonMKA-Warband"
local LIB_OPEN_KEYSTONE_REQUEST_DELAY_SECONDS = 0.20
local FULL_BROADCAST_FALLBACK_DELAY_SECONDS = 1.25
local MYTHIC_PLUS_AUTO_OPEN_DELAY_SECONDS = 0.20
local KEYSTONE_COMPLETION_RECHECK_DELAYS = { 0.50, 2.00, 5.00 }
local DUNGEON_TELEPORT_CHAT_MESSAGE_MAX_BYTES = 255
local DUNGEON_TELEPORT_TACTICAL_DEDUPE_SECONDS = 60
local MINIMAP_BUTTON_SIZE = 31
local MINIMAP_BUTTON_BACKGROUND_SIZE = 22
local MINIMAP_BUTTON_ICON_SIZE = 17
local MINIMAP_BUTTON_BORDER_SIZE = 50
local MINIMAP_BUTTON_EDGE_OFFSET = 5
local DEFAULT_MINIMAP_BUTTON_ANGLE = 225

addonTable.ADDON_LOGO_PATH = ADDON_LOGO_PATH
addonTable.ADDON_MINIMAP_ICON_PATH = ADDON_MINIMAP_ICON_PATH
addonTable.ADDON_PANEL_PORTRAIT_PATH = ADDON_PANEL_PORTRAIT_PATH
addonTable.DUNGEON_TELEPORT_CHAT_MESSAGE_MAX_BYTES = DUNGEON_TELEPORT_CHAT_MESSAGE_MAX_BYTES

function addonTable.TruncateUtf8ToByteLimit(value, maxBytes)
    value = tostring(value or "")
    maxBytes = tonumber(maxBytes)
    if not maxBytes then
        return value
    end

    maxBytes = math.floor(maxBytes)
    if maxBytes <= 0 then
        return ""
    end
    if #value <= maxBytes then
        return value
    end

    -- Lua string length is byte-based; walk UTF-8 characters to avoid cutting Chinese text mid-character.
    local lastValidEnd = 0
    local index = 1
    local valueLength = #value
    while index <= valueLength do
        local byte = value:byte(index)
        local charBytes = 1
        if byte >= 240 then
            charBytes = 4
        elseif byte >= 224 then
            charBytes = 3
        elseif byte >= 192 then
            charBytes = 2
        end

        if index + charBytes - 1 > maxBytes then
            break
        end
        if index + charBytes - 1 > valueLength then
            charBytes = 1
        end

        lastValidEnd = index + charBytes - 1
        index = index + charBytes
    end

    return value:sub(1, lastValidEnd)
end

local ROLE_OPTIONS = {
    TANK = "Tank",
    HEAL = "Heal",
    DPS = "DPS",
}

local ROLE_ORDER = {
    "TANK",
    "HEAL",
    "DPS",
}

local ARMOR_BY_CLASS = {
    PRIEST = "ARMOR.CLOTH",
    MAGE = "ARMOR.CLOTH",
    WARLOCK = "ARMOR.CLOTH",
    DRUID = "ARMOR.LEATHER",
    ROGUE = "ARMOR.LEATHER",
    MONK = "ARMOR.LEATHER",
    DEMONHUNTER = "ARMOR.LEATHER",
    HUNTER = "ARMOR.MAIL",
    SHAMAN = "ARMOR.MAIL",
    EVOKER = "ARMOR.MAIL",
    WARRIOR = "ARMOR.PLATE",
    PALADIN = "ARMOR.PLATE",
    DEATHKNIGHT = "ARMOR.PLATE",
}

local EJ_LORE_IMAGE_TEX_COORDS = { 0.052734375, 0.703125, 0.091796875, 0.556640625 }

local DUNGEON_TEXTURE_FALLBACKS = {
    [161] = {
        texture = 1042064, -- Skyreach / Die Himmelsnadel
        texCoords = { 0.0, 0.96, 0.0, 0.96 },
        source = "fallback",
        zoom = 1.0,
    },
}

local TEST_DUNGEON_MAP_IDS = {
    239, -- Seat
    556, -- Pit
    161, -- Skyreach
    557, -- Windrunner Spire
    558, -- Magisters' Terrace
    559, -- Nexus-Point Xenas
    560, -- Maisara Caverns
    402, -- Algeth'ar Academy
}

local function GetCharacterKey()
    local name = UnitName("player") or "Unknown"
    local realm = GetRealmName() or "Unknown"

    return string.format("%s-%s", name, realm)
end

local function GetCharacterDisplayName()
    local name = UnitName("player") or "Unknown"
    local realm = GetRealmName() or "Unknown"

    return string.format("%s-%s", name, realm)
end

local function GetCurrentCharacterLevel()
    local level = UnitLevel and UnitLevel("player")
    level = tonumber(level)
    if level and level > 0 then
        return level
    end
    return nil
end

local function BuildFullName(name, realm)
    if type(name) ~= "string" or name == "" then
        return nil
    end

    local resolvedRealm = realm
    if type(resolvedRealm) ~= "string" or resolvedRealm == "" then
        resolvedRealm = GetRealmName() or "Unknown"
    end

    return string.format("%s-%s", name, resolvedRealm)
end

local function GetUnitDisplayName(unit, allowLocalRealmFallback)
    if not unit or not UnitExists(unit) then
        return nil, nil
    end

    local fullName
    local name, realm = UnitFullName and UnitFullName(unit)
    if name and name ~= "" then
        if type(realm) == "string" and realm ~= "" then
            fullName = BuildFullName(name, realm)
        else
            fullName = name
        end
    end

    local unitName = GetUnitName(unit, true)
    if type(unitName) == "string" and unitName ~= "" and unitName:find("-", 1, true) then
        fullName = unitName
    elseif not fullName then
        fullName = unitName
    end

    if type(fullName) ~= "string" or fullName == "" then
        return nil, nil
    end

    local resolvedRealm = fullName:match("-(.+)$")
    if not resolvedRealm or resolvedRealm == "" then
        resolvedRealm = realm
    end

    if (not resolvedRealm or resolvedRealm == "") and allowLocalRealmFallback ~= false then
        resolvedRealm = GetRealmName() or "Unknown"
        fullName = BuildFullName(fullName:match("^[^-]+") or fullName, resolvedRealm)
    end

    if not resolvedRealm or resolvedRealm == "" then
        return nil, nil
    end

    return fullName, resolvedRealm
end

local function GetKeystoneInfo()
    if not C_MythicPlus then
        return nil, nil, nil
    end

    local mapID = C_MythicPlus.GetOwnedKeystoneChallengeMapID and C_MythicPlus.GetOwnedKeystoneChallengeMapID()
    local level = C_MythicPlus.GetOwnedKeystoneLevel and C_MythicPlus.GetOwnedKeystoneLevel()

    if not mapID or not level or level <= 0 then
        return nil, nil, nil
    end

    local mapName
    if C_ChallengeMode and C_ChallengeMode.GetMapUIInfo then
        mapName = C_ChallengeMode.GetMapUIInfo(mapID)
    end

    return mapID, level, mapName or TRANSLATE("DUNGEON.UNKNOWN")
end

local function GetCurrentKeystoneAffixIDs()
    local affixIDs = { 0, 0, 0, 0 }

    if C_MythicPlus and C_MythicPlus.GetCurrentAffixes then
        local ok, affixes = pcall(C_MythicPlus.GetCurrentAffixes)
        if ok and type(affixes) == "table" then
            for index = 1, 4 do
                local affix = affixes[index]
                if type(affix) == "table" then
                    affixIDs[index] = tonumber(affix.id or affix.affixID) or 0
                else
                    affixIDs[index] = tonumber(affix) or 0
                end
            end
        end
    end

    return affixIDs[1], affixIDs[2], affixIDs[3], affixIDs[4]
end

local function BuildFallbackKeystoneLink(mapID, keyLevel, dungeonName)
    local normalizedMapID = tonumber(mapID)
    local normalizedKeyLevel = tonumber(keyLevel)
    if not normalizedMapID or normalizedMapID <= 0 or not normalizedKeyLevel or normalizedKeyLevel <= 0 then
        return nil
    end

    if type(dungeonName) ~= "string" or dungeonName == "" then
        if C_ChallengeMode and C_ChallengeMode.GetMapUIInfo then
            dungeonName = C_ChallengeMode.GetMapUIInfo(normalizedMapID)
        end
    end
    if type(dungeonName) ~= "string" or dungeonName == "" then
        dungeonName = TRANSLATE("DUNGEON.UNKNOWN")
    end

    local affix1, affix2, affix3, affix4 = GetCurrentKeystoneAffixIDs()
    local linkText = string.format(TRANSLATE("KEYSTONE.FORMAT"), dungeonName, normalizedKeyLevel)

    return string.format(
        "|cnIQ4:|Hkeystone:180653:%d:%d:%d:%d:%d:%d:0|h[%s]|h|r",
        normalizedMapID,
        normalizedKeyLevel,
        affix1 or 0,
        affix2 or 0,
        affix3 or 0,
        affix4 or 0,
        linkText
    )
end

local function NormalizeMythicPlusRating(value)
    local rating = tonumber(value)
    if not rating then
        return nil
    end

    return math.max(0, math.floor(rating + 0.5))
end

local function HasPositiveMythicPlusRating(characterData)
    local rating = NormalizeMythicPlusRating(characterData and characterData.rating)
    return rating and rating > 0
end

local RAIDER_IO_SCORE_RANGES = {
    4075,
    3125,
    3000,
    1825,
    1600,
    200,
}

local function ClampColorComponent(value)
    return math.max(0, math.min(255, math.floor(((tonumber(value) or 0) * 255) + 0.5)))
end

function MKA:GetRaiderIOScoreColor(score)
    score = math.max(0, math.min(tonumber(score) or 0, RAIDER_IO_SCORE_RANGES[1]))

    local r, g, b
    if score >= RAIDER_IO_SCORE_RANGES[2] then
        local t = (RAIDER_IO_SCORE_RANGES[1] - score) / (RAIDER_IO_SCORE_RANGES[1] - RAIDER_IO_SCORE_RANGES[2])
        r, g, b = 1 - (0.36 * t), 0.5 - (0.29 * t), 0.93 * t
    elseif score >= RAIDER_IO_SCORE_RANGES[3] then
        local t = (RAIDER_IO_SCORE_RANGES[2] - score) / (RAIDER_IO_SCORE_RANGES[2] - RAIDER_IO_SCORE_RANGES[3])
        r, g, b = 0.64 - (0.39 * t), 0.21 + (0.21 * t), 0.93 - (0.05 * t)
    elseif score >= RAIDER_IO_SCORE_RANGES[4] then
        local t = (RAIDER_IO_SCORE_RANGES[3] - score) / (RAIDER_IO_SCORE_RANGES[3] - RAIDER_IO_SCORE_RANGES[4])
        r, g, b = 0.25 - (0.13 * t), 0.42 + (0.58 * t), 0.88 - (0.88 * t)
    elseif score >= RAIDER_IO_SCORE_RANGES[5] then
        local t = (RAIDER_IO_SCORE_RANGES[4] - score) / (RAIDER_IO_SCORE_RANGES[4] - RAIDER_IO_SCORE_RANGES[5])
        r, g, b = 0.12 + (0.26 * t), 1, 0.28 * t
    elseif score >= RAIDER_IO_SCORE_RANGES[6] then
        local t = (RAIDER_IO_SCORE_RANGES[5] - score) / (RAIDER_IO_SCORE_RANGES[5] - RAIDER_IO_SCORE_RANGES[6])
        r, g, b = 0.38 + (0.62 * t), 1, 0.28 + (0.72 * t)
    else
        r, g, b = 1, 1, 1
    end

    return r, g, b, 1
end

function MKA:GetRaiderIOScoreColorString(score)
    local r, g, b = self:GetRaiderIOScoreColor(score)
    return string.format("ff%02x%02x%02x", ClampColorComponent(r), ClampColorComponent(g), ClampColorComponent(b))
end

local function GetCurrentPlayerMythicPlusRating()
    if C_ChallengeMode and C_ChallengeMode.GetOverallDungeonScore then
        local ok, rating = pcall(C_ChallengeMode.GetOverallDungeonScore)
        if ok then
            return NormalizeMythicPlusRating(rating)
        end
    end

    if C_PlayerInfo and C_PlayerInfo.GetPlayerMythicPlusRatingSummary then
        local ok, ratingSummary = pcall(C_PlayerInfo.GetPlayerMythicPlusRatingSummary, "player")
        if ok and type(ratingSummary) == "table" then
            return NormalizeMythicPlusRating(ratingSummary.currentSeasonScore)
        end
    end

    return nil
end

local function BuildPlayerMythicPlusBestRuns(unitName)
    if not C_PlayerInfo or not C_PlayerInfo.GetPlayerMythicPlusRatingSummary then
        return nil
    end

    local ok, summary = pcall(C_PlayerInfo.GetPlayerMythicPlusRatingSummary, unitName or "player")
    if not ok or type(summary) ~= "table" then
        return nil
    end

    local runs = {}
    if type(summary.runs) == "table" then
        for _, run in ipairs(summary.runs) do
            if type(run) == "table" and type(run.challengeModeID) == "number" then
                runs[#runs + 1] = {
                    mapID = run.challengeModeID,
                    level = tonumber(run.bestRunLevel) or 0,
                    score = tonumber(run.mapScore) or 0,
                    durationMS = tonumber(run.bestRunDurationMS) or 0,
                    timed = run.finishedSuccess == true,
                }
            end
        end
    end

    table.sort(runs, function(left, right)
        local leftScore = tonumber(left.score) or 0
        local rightScore = tonumber(right.score) or 0
        if leftScore ~= rightScore then
            return leftScore > rightScore
        end

        local leftLevel = tonumber(left.level) or 0
        local rightLevel = tonumber(right.level) or 0
        if leftLevel ~= rightLevel then
            return leftLevel > rightLevel
        end

        return (tonumber(left.mapID) or 0) < (tonumber(right.mapID) or 0)
    end)

    return {
        score = NormalizeMythicPlusRating(summary.currentSeasonScore),
        runs = runs,
        updated = time(),
        source = "mka",
    }
end

function MKA:GetPlayerMythicPlusBestRuns(unitName)
    return BuildPlayerMythicPlusBestRuns(unitName)
end

local function GetOwnedKeystoneLink()
    if not C_Container then
        return nil
    end

    local function isKeystoneItemLink(itemLink)
        if type(itemLink) ~= "string" or itemLink == "" then
            return false
        end

        if itemLink:find("|Hkeystone:", 1, true) then
            return true
        end

        local itemID
        if C_Item and C_Item.GetItemInfoInstant then
            itemID = C_Item.GetItemInfoInstant(itemLink)
        elseif GetItemInfoInstant then
            itemID = GetItemInfoInstant(itemLink)
        end

        if itemID then
            if C_Item and C_Item.IsItemKeystoneByID then
                local ok, isKeystone = pcall(C_Item.IsItemKeystoneByID, itemID)
                if ok and isKeystone then
                    return true
                end
            end

            if itemID == 180653 or itemID == 186159 then
                return true
            end
        end

        return itemLink:find("item:180653", 1, true) ~= nil or itemLink:find("item:186159", 1, true) ~= nil
    end

    for bagID = BACKPACK_CONTAINER, (NUM_TOTAL_EQUIPPED_BAG_SLOTS or NUM_BAG_SLOTS or 4) do
        local numSlots = C_Container.GetContainerNumSlots and C_Container.GetContainerNumSlots(bagID) or 0
        for slotIndex = 1, numSlots do
            local itemLink = C_Container.GetContainerItemLink and C_Container.GetContainerItemLink(bagID, slotIndex)
            if isKeystoneItemLink(itemLink) then
                return itemLink
            end
        end
    end

    return nil
end

local KEYSTONE_UPGRADE_TRACK_IRON = addonTable.KEYSTONE_UPGRADE_TRACK_IRON
local KEYSTONE_UPGRADE_TRACK_MYTHIC = addonTable.KEYSTONE_UPGRADE_TRACK_MYTHIC
local MatchKeystoneUpgradeTrackText = addonTable.MatchKeystoneUpgradeTrackText
local ResolveKeystoneUpgradeTrackFromLink = addonTable.ResolveKeystoneUpgradeTrackFromLink

local function ResolveKeystoneUpgradeTrack(keystoneLink, keyLevel)
    if type(keystoneLink) ~= "string" or keystoneLink == "" then
        return nil
    end

    local directTrack = MatchKeystoneUpgradeTrackText(keystoneLink)
    if directTrack then
        return directTrack
    end

    local linkTrack = ResolveKeystoneUpgradeTrackFromLink(keystoneLink, keyLevel)
    if linkTrack then
        return linkTrack
    end

    if C_TooltipInfo and C_TooltipInfo.GetHyperlink then
        local ok, tooltipData = pcall(C_TooltipInfo.GetHyperlink, keystoneLink)
        if ok and type(tooltipData) == "table" and type(tooltipData.lines) == "table" then
            for _, line in ipairs(tooltipData.lines) do
                if type(line) == "table" then
                    local track = MatchKeystoneUpgradeTrackText(line.leftText)
                        or MatchKeystoneUpgradeTrackText(line.rightText)
                        or MatchKeystoneUpgradeTrackText(line.text)
                    if not track and type(line.args) == "table" then
                        for _, arg in ipairs(line.args) do
                            if type(arg) == "table" then
                                track = MatchKeystoneUpgradeTrackText(arg.stringVal)
                                if track then
                                    break
                                end
                            end
                        end
                    end
                    if track then
                        return track
                    end
                end
            end
        end
    end

    if CreateFrame and UIParent then
        local tooltipName = "MKACoreKeystoneTrackTooltip"
        local tooltip = _G[tooltipName] or CreateFrame("GameTooltip", tooltipName, UIParent, "GameTooltipTemplate")
        tooltip:SetOwner(UIParent, "ANCHOR_NONE")
        tooltip:ClearLines()
        tooltip:SetHyperlink(keystoneLink)

        local lineCount = tooltip.NumLines and tooltip:NumLines() or 0
        for lineIndex = 1, lineCount do
            local leftLine = _G[tooltipName .. "TextLeft" .. lineIndex]
            local rightLine = _G[tooltipName .. "TextRight" .. lineIndex]
            local track = MatchKeystoneUpgradeTrackText(leftLine and leftLine:GetText())
                or MatchKeystoneUpgradeTrackText(rightLine and rightLine:GetText())
            if track then
                tooltip:Hide()
                return track
            end
        end

        tooltip:Hide()
    end

    return nil
end

local function GetLastWeeklyResetTimestamp()
    if C_DateAndTime and C_DateAndTime.GetWeeklyResetStartTime then
        local resetTime = C_DateAndTime.GetWeeklyResetStartTime()
        if type(resetTime) == "number" and resetTime > 0 then
            return resetTime
        end
    end

    if C_DateAndTime and C_DateAndTime.GetSecondsUntilWeeklyReset then
        local secondsUntilReset = C_DateAndTime.GetSecondsUntilWeeklyReset()
        if type(secondsUntilReset) == "number" and secondsUntilReset > 0 then
            return time() + secondsUntilReset - (7 * 24 * 60 * 60)
        end
    end

    local now = time()
    local current = date("*t", now)
    local daysSinceWednesday = (current.wday - 4) % 7
    local resetDate = {
        year = current.year,
        month = current.month,
        day = current.day - daysSinceWednesday,
        hour = 5,
        min = 0,
        sec = 0,
    }
    local resetTime = time(resetDate)

    if now < resetTime then
        resetTime = resetTime - (7 * 24 * 60 * 60)
    end

    return resetTime
end

local function NormalizeDungeonNameForMatch(name)
    if type(name) ~= "string" then
        return nil
    end

    local normalized = name:lower()
    normalized = normalized:gsub("[%s%p]+", "")

    if normalized == "" then
        return nil
    end

    return normalized
end

local function AreRoleTablesEqual(left, right)
    if left == right then
        return true
    end

    left = left or {}
    right = right or {}

    for roleKey, value in pairs(left) do
        if right[roleKey] ~= value then
            return false
        end
    end

    for roleKey, value in pairs(right) do
        if left[roleKey] ~= value then
            return false
        end
    end

    return true
end

local function HasAnyRole(roles)
    if type(roles) ~= "table" then
        return false
    end

    for _, value in pairs(roles) do
        if value == true then
            return true
        end
    end

    return false
end

local function BuildBestRunsSignature(bestRuns)
    if type(bestRuns) ~= "table" or type(bestRuns.runs) ~= "table" then
        return ""
    end

    local parts = {
        tostring(bestRuns.score or 0),
    }
    for _, run in ipairs(bestRuns.runs) do
        parts[#parts + 1] = table.concat({
            tostring(run.mapID or ""),
            tostring(run.level or 0),
            tostring(run.score or 0),
            tostring(run.durationMS or 0),
            run.timed and "1" or "0",
        }, ":")
    end

    return table.concat(parts, "|")
end

local function AreCharacterCommDataEqual(left, right)
    if left == right then
        return true
    end

    if type(left) ~= "table" or type(right) ~= "table" then
        return false
    end

    return left.name == right.name
        and left.class == right.class
        and left.realm == right.realm
        and left.level == right.level
        and left.keyLevel == right.keyLevel
        and left.mapID == right.mapID
        and left.dungeonName == right.dungeonName
        and left.keystoneLink == right.keystoneLink
        and left.keyUpgradeTrack == right.keyUpgradeTrack
        and left.hasKeystone == right.hasKeystone
        and left.lastKeySeenAt == right.lastKeySeenAt
        and left.rating == right.rating
        and left.showInGroup == right.showInGroup
        and AreRoleTablesEqual(left.roles, right.roles)
        and BuildBestRunsSignature(left.bestRuns) == BuildBestRunsSignature(right.bestRuns)
end

local function IsFallbackCharacterSource(source)
    return source == "libkeystone" or source == "libopenkeystone"
end

local function PreserveExistingFallbackCharacterData(existing, incoming)
    if type(existing) ~= "table" or type(incoming) ~= "table" then
        return incoming
    end

    local merged = incoming
    local function ensureCopy()
        if merged == incoming then
            merged = {}
            for key, value in pairs(incoming) do
                merged[key] = value
            end
        end
        return merged
    end

    if HasPositiveMythicPlusRating(existing) and not HasPositiveMythicPlusRating(incoming) then
        ensureCopy().rating = existing.rating
    end
    if type(existing.bestRuns) == "table" and type(incoming.bestRuns) ~= "table" then
        ensureCopy().bestRuns = existing.bestRuns
    end
    if HasAnyRole(existing.roles) and not HasAnyRole(incoming.roles) then
        ensureCopy().roles = existing.roles
    end
    if (incoming.class == nil or incoming.class == "") and existing.class then
        ensureCopy().class = existing.class
    end

    return merged
end

local function GetCharacterDataSourceLabel(source)
    if source == "mka" then
        return ADDON_SHORT_NAME
    end

    if source == "libkeystone" then
        return "LibKeystone"
    end

    if source == "libopenkeystone" then
        return "LibOpenKeystone"
    end

    return tostring(source or "unknown")
end

local function BuildGroupRosterSignature()
    if not (IsInRaid() or IsInGroup()) then
        return ""
    end

    local members = {}
    local groupPrefix = IsInRaid() and "raid" or "party"
    local memberCount = GetNumGroupMembers()

    if not IsInRaid() and IsInGroup() then
        memberCount = math.max(memberCount - 1, 0)
    end

    for index = 1, memberCount do
        local unit = groupPrefix .. index
        if UnitExists(unit) then
            local fullName = GetUnitName(unit, true)
            if fullName and fullName ~= GetCharacterDisplayName() then
                members[#members + 1] = fullName
            end
        end
    end

    table.sort(members)
    return table.concat(members, "|")
end

local function NormalizePlayerIdentifier(value)
    if type(value) ~= "string" then
        return nil
    end

    local normalized = strtrim(value)
    if normalized == "" then
        return nil
    end

    return normalized:lower()
end

local function SplitPlayerIdentifier(value)
    if type(value) ~= "string" or value == "" then
        return nil, nil
    end

    local name, realm = strsplit("-", value)
    if type(name) ~= "string" or name == "" then
        return nil, nil
    end

    if type(realm) ~= "string" or realm == "" then
        realm = nil
    end

    return name, realm
end

local function PlayerIdentifiersCanMatchByShortName(left, right)
    local _, leftRealm = SplitPlayerIdentifier(left)
    local _, rightRealm = SplitPlayerIdentifier(right)

    if leftRealm and rightRealm and NormalizePlayerIdentifier(leftRealm) ~= NormalizePlayerIdentifier(rightRealm) then
        return false
    end

    return true
end

local function GetPlayerIdentifierAliases(value)
    local aliases = {}
    local normalized = NormalizePlayerIdentifier(value)
    if not normalized then
        return aliases
    end

    aliases[normalized] = true

    local plainName = value
    if Ambiguate then
        plainName = Ambiguate(value, "none")
    elseif value:find("-", 1, true) then
        plainName = value:match("^[^-]+")
    end

    local normalizedPlainName = NormalizePlayerIdentifier(plainName)
    if normalizedPlainName then
        aliases[normalizedPlainName] = true
    end

    local shortName = value:match("^[^-]+")
    local normalizedShortName = NormalizePlayerIdentifier(shortName)
    if normalizedShortName then
        aliases[normalizedShortName] = true
    end

    return aliases
end

local function FindMatchingIdentifierKey(entries, identifier)
    if type(entries) ~= "table" or type(identifier) ~= "string" then
        return nil
    end

    if entries[identifier] ~= nil then
        return identifier
    end

    local aliases = GetPlayerIdentifierAliases(identifier)
    for existingKey in pairs(entries) do
        if PlayerIdentifiersCanMatchByShortName(existingKey, identifier) then
            for alias in pairs(GetPlayerIdentifierAliases(existingKey)) do
                if aliases[alias] then
                    return existingKey
                end
            end
        end
    end

    return nil
end

local function FindMatchingOwnerKey(groupCharacters, ownerKey)
    return FindMatchingIdentifierKey(groupCharacters, ownerKey)
end

local function RemoveConflictingFallbackOwners(groupCharacters, ownerKey)
    if type(groupCharacters) ~= "table" or type(ownerKey) ~= "string" then
        return false
    end

    local ownerName = NormalizePlayerIdentifier((SplitPlayerIdentifier(ownerKey)))
    if not ownerName then
        return false
    end

    local changed = false
    for existingKey, ownerData in pairs(groupCharacters) do
        local existingName = NormalizePlayerIdentifier((SplitPlayerIdentifier(existingKey)))
        if existingKey ~= ownerKey
            and ownerName == existingName
            and type(ownerData) == "table"
            and IsFallbackCharacterSource(ownerData.source)
        then
            groupCharacters[existingKey] = nil
            changed = true
        end
    end

    return changed
end

local function GetGroupMemberInfoByIdentifier(identifier)
    local aliases = GetPlayerIdentifierAliases(identifier)
    if next(aliases) == nil or not (IsInRaid() or IsInGroup()) then
        return nil
    end

    local groupPrefix = IsInRaid() and "raid" or "party"
    local memberCount = GetNumGroupMembers()

    if not IsInRaid() and IsInGroup() then
        memberCount = math.max(memberCount - 1, 0)
    end

    for index = 1, memberCount do
        local unit = groupPrefix .. index
        if UnitExists(unit) then
            local fullName, realm = GetUnitDisplayName(unit)
            for alias in pairs(GetPlayerIdentifierAliases(fullName)) do
                if aliases[alias] then
	                    return {
	                        unit = unit,
	                        fullName = fullName,
	                        realm = realm,
	                        class = select(2, UnitClass(unit)),
	                        connected = not UnitIsConnected or UnitIsConnected(unit),
	                    }
	                end
	            end
	        end
	    end

	    return nil
	end

local function IdentifierMatchesAliasTable(identifier, aliasTable)
    if type(identifier) ~= "string" or type(aliasTable) ~= "table" then
        return false
    end

    for alias in pairs(GetPlayerIdentifierAliases(identifier)) do
        if aliasTable[alias] then
            return true
        end
    end

    return false
end

local function ResolveGroupOwnerIdentifier(identifier)
    local groupedMember = GetGroupMemberInfoByIdentifier(identifier)
    return (groupedMember and groupedMember.fullName) or identifier
end

local function GetLibKeystoneGroupMemberInfo(playerName)
    local groupedMember = GetGroupMemberInfoByIdentifier(playerName)
    if groupedMember then
        return groupedMember
    end

    if type(playerName) ~= "string" or playerName == "" or IsInRaid() then
        return nil
    end

    local name, realm = SplitPlayerIdentifier(playerName)
    if type(name) ~= "string" or name == "" then
        return nil
    end

    if type(realm) ~= "string" or realm == "" then
        if UnitInParty(playerName) then
            local fullName, resolvedRealm = GetUnitDisplayName(playerName, false)
            if fullName and resolvedRealm then
	                return {
	                    unit = playerName,
	                    fullName = fullName,
	                    realm = resolvedRealm,
	                    class = select(2, UnitClass(playerName)),
	                    connected = not UnitIsConnected or UnitIsConnected(playerName),
	                    resolvedByPartyCallback = true,
	                    resolvedByUnitInParty = true,
	                }
            end
        end

        return nil
    end

	    return {
	        unit = playerName,
	        fullName = BuildFullName(name, realm),
	        realm = realm,
	        class = select(2, UnitClass(playerName)),
	        connected = not UnitIsConnected or UnitIsConnected(playerName),
	        resolvedByPartyCallback = true,
	        resolvedByUnitInParty = UnitInParty(playerName) and true or false,
	    }
end

local function IsKeystoneInventoryEvent(event)
    return event == "BAG_UPDATE"
        or event == "BAG_UPDATE_DELAYED"
        or event == "ITEM_CHANGED"
        or event == "CHALLENGE_MODE_KEYSTONE_RECEPTABLE_OPEN"
        or event == "CHALLENGE_MODE_KEYSTONE_SLOTTED"
end

local function IsKeystoneFollowupEvent(event)
    return event == "CHALLENGE_MODE_COMPLETED"
        or event == "LOOT_CLOSED"
        or event == "MYTHIC_PLUS_COMPLETED"
end

local function HasKeystoneChangeReason(changeReasons)
    if type(changeReasons) ~= "table" then
        return false
    end

    for _, reason in ipairs(changeReasons) do
        reason = tostring(reason or "")
        if reason:find("^keyLevel")
            or reason:find("^mapID")
            or reason:find("^dungeonName")
            or reason:find("^keystoneLink")
            or reason:find("^keyUpgradeTrack")
            or reason:find("^hasKeystone")
            or reason:find("^lastKeySeenAt")
        then
            return true
        end
    end

    return false
end

local MKA_VERBOSE_DEBUG_PREFIXES = {
    "^PLAYER_ENTERING_WORLD",
    "^ProcessCharacterUpdate",
    "^HandleCharacterUpdate",
    "^QueueBroadcastUpdate",
    "^QueueGroupSyncRequest",
    "^RefreshMainWindow",
    "^GROUP_ROSTER_UPDATE",
    "^HandleLibKeystoneData",
    "^HandleLibOpenKeystoneData",
    "^Requesting LibKeystone",
    "^Requesting LibOpenKeystone",
    "^QueueLibKeystone",
    "^QueueLibOpenKeystone",
    "^ApplyLibKeystoneFallback",
    "^GetCharacters filtered",
    "^GetGroupCharacters filtered",
    "^CleanupGroupCharacters",
}

local function IsVerboseDebugMessage(message)
    message = tostring(message or "")
    for _, pattern in ipairs(MKA_VERBOSE_DEBUG_PREFIXES) do
        if message:match(pattern) then
            return true
        end
    end
    return false
end

function MKA:IsDebugLoggingEnabled()
    return self.db and self.db.profile and self.db.profile.debugLogging == true
end

function MKA:SetDebugLoggingEnabled(enabled)
    self.db.profile.debugLogging = enabled and true or false
end

function MKA:IsDebugVerboseLoggingEnabled()
    return self.db and self.db.profile and self.db.profile.debugVerboseLogging == true
end

function MKA:SetDebugVerboseLoggingEnabled(enabled)
    self.db.profile.debugVerboseLogging = enabled and true or false
end

function MKA:SetDebugLocaleOverride(locale)
    if not self.db or not self.db.global then
        return
    end

    if not addonTable.IsSupportedLocaleOverride or not addonTable.IsSupportedLocaleOverride(locale) then
        locale = nil
    end

    self.db.global.debugLocaleOverride = locale
end

function MKA:DebugLog(message, ...)
    if not self:IsDebugLoggingEnabled() then
        return
    end

    if IsVerboseDebugMessage(message) and not self:IsDebugVerboseLoggingEnabled() then
        return
    end

    if select("#", ...) > 0 then
        message = string.format(message, ...)
    end

    self:Print(string.format(TRANSLATE("DEBUG.MESSAGE_PREFIX_FORMAT"), message))
end

function MKA:QueueCharacterUpdate(delay, event)
    self.pendingCharacterUpdateEvent = event or self.pendingCharacterUpdateEvent

    if self.characterUpdateTimer then
        self.characterUpdateTimer:Cancel()
    end

    self.characterUpdateTimer = C_Timer.NewTimer(delay or 0, function()
        self.characterUpdateTimer = nil

        local pendingEvent = self.pendingCharacterUpdateEvent or event
        self.pendingCharacterUpdateEvent = nil
        self:ProcessCharacterUpdate(pendingEvent or "DEFERRED_UPDATE")
    end)
end

function MKA:QueueKeystoneCompletionRechecks(reason)
    if not C_Timer or not C_Timer.After then
        return
    end

    self.keystoneCompletionRecheckTicket = (self.keystoneCompletionRecheckTicket or 0) + 1
    local ticket = self.keystoneCompletionRecheckTicket
    local recheckReason = reason or "MYTHIC_PLUS_COMPLETION_RECHECK"

    for _, delay in ipairs(KEYSTONE_COMPLETION_RECHECK_DELAYS) do
        C_Timer.After(delay, function()
            if self.keystoneCompletionRecheckTicket ~= ticket then
                return
            end

            self:DebugLog("QueueKeystoneCompletionRechecks running reason=%s delay=%s", tostring(recheckReason), tostring(delay))
            self:QueueCharacterUpdate(0.01, "MYTHIC_PLUS_COMPLETION_RECHECK")
            self:QueueLibKeystoneRequest(LIB_KEYSTONE_REQUEST_DELAY_SECONDS, recheckReason)
            self:QueueLibOpenKeystoneRequest(LIB_KEYSTONE_REQUEST_DELAY_SECONDS + LIB_OPEN_KEYSTONE_REQUEST_DELAY_SECONDS, recheckReason)
        end)
    end
end

function MKA:HandleMythicPlusCompletionAutoOpen()
    local autoOpenTab = self.GetAutoOpenOnCompletedRunTab and self:GetAutoOpenOnCompletedRunTab() or nil
    if not autoOpenTab then
        autoOpenTab = self:IsAutoOpenOnCompletedRunEnabled() and self:GetStartupTab() or "off"
    end

    if autoOpenTab == "off" then
        self:DebugLog("Skipping Mythic+ auto-open because the setting is disabled")
        return
    end

    self:DebugLog("Opening main window after completed Mythic+ run")
    self:QueueCharacterUpdate(0.05, "MYTHIC_PLUS_COMPLETED")
    self:QueueKeystoneCompletionRechecks("mythic_plus_completed")

    C_Timer.After(MYTHIC_PLUS_AUTO_OPEN_DELAY_SECONDS, function()
        if not self.ShowMainWindow then
            return
        end

        self:ShowMainWindow(autoOpenTab, true)
    end)
end

function MKA:EnsureMythicPlusCompletionWatcher()
    if self.mythicPlusCompletionWatcher then
        return
    end

    local watcher = CreateFrame("Frame")
    watcher.owner = self
    watcher:RegisterEvent("CHALLENGE_MODE_COMPLETED")
    watcher:SetScript("OnEvent", function(frame, event, ...)
        local owner = frame.owner
        if not owner then
            return
        end

        if event == "CHALLENGE_MODE_COMPLETED" then
            owner:DebugLog("CHALLENGE_MODE_COMPLETED received, waiting for LOOT_CLOSED")
            owner.pendingMythicPlusAutoOpen = true
            owner:QueueCharacterUpdate(0.05, event)
            owner:QueueKeystoneCompletionRechecks("challenge_mode_completed")
            owner:QueueLibKeystoneRequest(LIB_KEYSTONE_COMPLETION_REQUEST_DELAY_SECONDS, "challenge_mode_completed")
            owner:QueueLibOpenKeystoneRequest(LIB_KEYSTONE_COMPLETION_REQUEST_DELAY_SECONDS + LIB_OPEN_KEYSTONE_REQUEST_DELAY_SECONDS, "challenge_mode_completed")
            frame:RegisterEvent("LOOT_CLOSED")
            frame:RegisterEvent("PLAYER_ENTERING_WORLD")
            frame:UnregisterEvent("CHALLENGE_MODE_COMPLETED")
        elseif event == "LOOT_CLOSED" then
            frame:UnregisterEvent("LOOT_CLOSED")
            frame:UnregisterEvent("PLAYER_ENTERING_WORLD")
            frame:RegisterEvent("CHALLENGE_MODE_COMPLETED")

            if owner.pendingMythicPlusAutoOpen then
                owner.pendingMythicPlusAutoOpen = false
                owner:QueueKeystoneCompletionRechecks("loot_closed")
                owner:HandleMythicPlusCompletionAutoOpen()
            end
        elseif event == "PLAYER_ENTERING_WORLD" then
            local isInitialLogin, isReloadingUi = ...
            if not isInitialLogin and not isReloadingUi then
                owner:DebugLog("Player left instance before LOOT_CLOSED, resetting Mythic+ completion watcher")
                owner.pendingMythicPlusAutoOpen = false
                frame:UnregisterEvent("LOOT_CLOSED")
                frame:UnregisterEvent("PLAYER_ENTERING_WORLD")
                frame:RegisterEvent("CHALLENGE_MODE_COMPLETED")
            end
        end
    end)

    self.mythicPlusCompletionWatcher = watcher
end

function MKA:OnInitialize()
    self.db = LibStub("AceDB-3.0"):New(SAVED_VARIABLES_NAME, self.defaults, true)
    self.groupCharacters = {}
    self.groupAddonUsers = {}

    self:SetupOptions()
    self:SetupMinimapButton()
    if self.SetupMicroMenuButton then
        self:SetupMicroMenuButton()
    end
    if self.SetupMeetingStoneQueueStatusRouting then
        self:SetupMeetingStoneQueueStatusRouting()
    end
    if self.SetupMeetingStoneNativePanelVisibilityRouting then
        self:SetupMeetingStoneNativePanelVisibilityRouting()
    end
    self:RegisterChatCommand("mka", "HandleSlashCommand")
    self:RegisterChatCommand("dm", "HandleSlashCommand")
    self:RegisterComm(COMM_PREFIX)
    if addonTable.MeetingStoneData and addonTable.MeetingStoneData.Initialize then
        addonTable.MeetingStoneData:Initialize(self)
    end

    if LibKeystone then
        LibKeystone.Register(self, function(keyLevel, keyMapID, playerRating, playerName, channel)
            self:HandleLibKeystoneData(keyLevel, keyMapID, playerRating, playerName, channel)
        end)
    end

    self:EnsureLibOpenKeystoneFallback()
end

function MKA:OnEnable()
    self:RegisterEvent("ADDON_LOADED", "HandleAddonLoaded")
    self:RegisterEvent("PLAYER_ENTERING_WORLD", "HandleEnteringWorld")
    self:RegisterEvent("BAG_UPDATE_DELAYED", "HandleCharacterUpdate")
    self:RegisterEvent("BAG_UPDATE", "HandleCharacterUpdate")
    self:RegisterEvent("ITEM_CHANGED", "HandleCharacterUpdate")
    self:RegisterEvent("CHALLENGE_MODE_MAPS_UPDATE", "HandleCharacterUpdate")
    pcall(self.RegisterEvent, self, "CHALLENGE_MODE_KEYSTONE_RECEPTABLE_OPEN", "HandleCharacterUpdate")
    pcall(self.RegisterEvent, self, "CHALLENGE_MODE_KEYSTONE_SLOTTED", "HandleCharacterUpdate")
    pcall(self.RegisterEvent, self, "CHALLENGE_MODE_START", "HandleCharacterUpdate")
    pcall(self.RegisterEvent, self, "CHALLENGE_MODE_RESET", "HandleCharacterUpdate")
    self:RegisterEvent("GROUP_ROSTER_UPDATE", "HandleGroupRosterUpdate")
    self:RegisterEvent("PARTY_LEADER_CHANGED", "HandleGroupRosterUpdate")
    pcall(self.RegisterEvent, self, "PLAYER_ROLES_ASSIGNED", "HandleGroupRolesAssigned")
    pcall(self.RegisterEvent, self, "LFG_ROLE_UPDATE", "HandleGroupRolesAssigned")
    self:RegisterEvent("PLAYER_REGEN_DISABLED", "HandleCombatStarted")
    self:RegisterEvent("PLAYER_REGEN_ENABLED", "HandleCombatEnded")
    self:RegisterEvent("UNIT_CONNECTION", "HandleUnitConnection")
    self:RegisterEvent("LFG_LIST_ACTIVE_ENTRY_UPDATE", "HandleMeetingStoneActiveEntryUpdate")
    self:RegisterEvent("LFG_LIST_ENTRY_CREATION_FAILED", "HandleMeetingStoneEntryCreationFailed")
    self:RegisterEvent("LFG_LIST_APPLICANT_LIST_UPDATED", "HandleMeetingStoneApplicantListUpdated")
    self:RegisterEvent("LFG_LIST_APPLICANT_UPDATED", "HandleMeetingStoneApplicantUpdated")
    self:RegisterEvent("LFG_LIST_ENTRY_EXPIRED_TOO_MANY_PLAYERS", "HandleMeetingStoneEntryExpired")
    self:RegisterEvent("LFG_LIST_ENTRY_EXPIRED_TIMEOUT", "HandleMeetingStoneEntryExpired")
    self:EnsureMythicPlusCompletionWatcher()
    if addonTable.MeetingStoneData and addonTable.MeetingStoneData.Start then
        addonTable.MeetingStoneData:Start()
    end
end

function MKA:NotifyWindowClosedForCombat()
    self:Print(TRANSLATE("COMBAT.WINDOW_CLOSED"))
end

function MKA:HandleCombatStarted()
    if addonTable.MeetingStoneData and addonTable.MeetingStoneData.SetCombatPaused then
        addonTable.MeetingStoneData:SetCombatPaused(true)
    end

    if self.IsMainWindowShown and self:IsMainWindowShown() then
        self.reopenMainWindowAfterCombat = true
        if self.HideMainWindow then
            self:HideMainWindow()
        elseif self.mainWindow and self.mainWindow.Hide then
            self.mainWindow:Hide()
        end
        self:NotifyWindowClosedForCombat()
    end
end

function MKA:HandleCombatEnded()
    if addonTable.MeetingStoneData and addonTable.MeetingStoneData.SetCombatPaused then
        addonTable.MeetingStoneData:SetCombatPaused(false)
    end

    if not self.reopenMainWindowAfterCombat then
        return
    end

    self.reopenMainWindowAfterCombat = nil

    if self.ShowMainWindow then
        self:ShowMainWindow()
    end
end

function MKA:HandleAddonLoaded(_, loadedAddonName)
    if self.libOpenKeystoneRegistered then
        return
    end

    if loadedAddonName == "EnhanceQoL" or LibStub("LibOpenKeystone-1.0", true) then
        if self:EnsureLibOpenKeystoneFallback() then
            self:QueueLibOpenKeystoneRequest(LIB_OPEN_KEYSTONE_REQUEST_DELAY_SECONDS, "addon_loaded")
        end
    end
end

function MKA:HandleEnteringWorld(_, isInitialLogin, isReloadingUi)
    self:DebugLog(
        "PLAYER_ENTERING_WORLD initial=%s reload=%s",
        tostring(isInitialLogin),
        tostring(isReloadingUi)
    )
    self:ProcessCharacterUpdate("PLAYER_ENTERING_WORLD")
    self:QueueCharacterUpdate(INITIAL_WORLD_UPDATE_DELAY_SECONDS, "PLAYER_ENTERING_WORLD")

    if isInitialLogin or isReloadingUi then
        self:QueueBroadcastUpdate(INITIAL_BROADCAST_DELAY_SECONDS)
        self:QueueGroupSyncRequest(INITIAL_GROUP_SYNC_DELAY_SECONDS)
        self:QueueLibOpenKeystoneRequest(INITIAL_GROUP_SYNC_DELAY_SECONDS + LIB_OPEN_KEYSTONE_REQUEST_DELAY_SECONDS, "entering_world")

        if self:IsDebugLoggingEnabled() then
            C_Timer.After(0.2, function()
                if self.ShowMainWindow and not self:IsMainWindowShown() then
                    self:ShowMainWindow()
                end
            end)
        end
    end
end

function MKA:HandleCharacterUpdate(event)
    self:DebugLog("HandleCharacterUpdate event=%s", tostring(event))
    self:QueueCharacterUpdate(CHARACTER_UPDATE_DEBOUNCE_SECONDS, event)
    if IsKeystoneFollowupEvent(event) then
        self:QueueKeystoneCompletionRechecks(tostring(event or "keystone_followup"):lower())
    end
end

function MKA:HandleGroupRosterUpdate(event)
    local leaderChanged = event == "PARTY_LEADER_CHANGED"
    local pendingSignature = BuildGroupRosterSignature()
    self.pendingGroupRosterSignature = pendingSignature

    self:DebugLog(
        "GROUP_ROSTER_UPDATE queued pending=%s",
        pendingSignature == "" and "<empty>" or pendingSignature
    )

    if self.groupRosterUpdateTimer then
        self.groupRosterUpdateTimer:Cancel()
    end

    self.groupRosterUpdateTimer = C_Timer.NewTimer(GROUP_ROSTER_SETTLE_SECONDS, function()
        self.groupRosterUpdateTimer = nil

        local previousRosterSignature = self.groupRosterSignature or ""
        local currentRosterSignature = self.pendingGroupRosterSignature or BuildGroupRosterSignature()
        self.pendingGroupRosterSignature = nil
        self.groupRosterSignature = currentRosterSignature

        local rosterChanged = previousRosterSignature ~= currentRosterSignature
        local groupChanged = self:CleanupGroupCharacters()
        local characterChanged, characterChangeReasons = self:UpdateCurrentCharacter("GROUP_ROSTER_UPDATE")

        self:DebugLog(
            "GROUP_ROSTER_UPDATE settled rosterChanged=%s groupChanged=%s characterChanged=%s reasons=%s previous=%s current=%s",
            tostring(rosterChanged),
            tostring(groupChanged),
            tostring(characterChanged),
            (characterChangeReasons and #characterChangeReasons > 0) and table.concat(characterChangeReasons, ", ") or "-",
            previousRosterSignature == "" and "<empty>" or previousRosterSignature,
            currentRosterSignature == "" and "<empty>" or currentRosterSignature
        )

        if rosterChanged or characterChanged then
            self:QueueBroadcastUpdate(GROUP_ROSTER_BROADCAST_DELAY_SECONDS)
            self:QueueGroupSyncRequest(GROUP_SYNC_REQUEST_DELAY_SECONDS)
            self:QueueLibOpenKeystoneRequest(GROUP_SYNC_REQUEST_DELAY_SECONDS + LIB_OPEN_KEYSTONE_REQUEST_DELAY_SECONDS, "group_roster_update")
        end

        if (leaderChanged or rosterChanged or groupChanged or characterChanged) and self.IsMainWindowShown and self:IsMainWindowShown() then
            self:RefreshMainWindow(
                event or "GROUP_ROSTER_UPDATE",
                string.format(
                    "leaderChanged=%s rosterChanged=%s groupChanged=%s characterChanged=%s",
                    tostring(leaderChanged),
                    tostring(rosterChanged),
                    tostring(groupChanged),
                    tostring(characterChanged)
                )
            )
        end
    end)
end

function MKA:HandleGroupRolesAssigned(event)
    self:DebugLog("Group role refresh event=%s", tostring(event))

    if self.lastRenderedSnapshotByTab then
        self.lastRenderedSnapshotByTab.group = nil
    end

    if self.IsMainWindowShown and self:IsMainWindowShown() and self.activeTab == "group" then
        self:RefreshMainWindow(event or "PLAYER_ROLES_ASSIGNED")
    end
end

function MKA:QueueBroadcastUpdate(delay)
    self:DebugLog("QueueBroadcastUpdate delay=%s", tostring(delay or 0))
    self.broadcastDirty = true

    if self.groupBroadcastTimer then
        self.groupBroadcastTimer:Cancel()
    end

    local now = GetTime and GetTime() or 0
    local lastBroadcastAt = self.lastBroadcastAt or 0
    local remainingCooldown = math.max(BROADCAST_COOLDOWN_SECONDS - (now - lastBroadcastAt), 0)
    local timerDelay = math.max(delay or 0, remainingCooldown)

    self.groupBroadcastTimer = C_Timer.NewTimer(timerDelay, function()
        self.groupBroadcastTimer = nil
        if not self.broadcastDirty then
            return
        end

        self.broadcastDirty = false
        self:BroadcastCurrentCharacter()
    end)
end

function MKA:QueueCharacterPatchBroadcast(delay)
    self.pendingCharacterPatch = true

    if self.characterPatchTimer then
        self.characterPatchTimer:Cancel()
    end

    self.characterPatchTimer = C_Timer.NewTimer(delay or CHARACTER_PATCH_DELAY_SECONDS, function()
        self.characterPatchTimer = nil
        if not self.pendingCharacterPatch then
            return
        end

        self.pendingCharacterPatch = false
        self:BroadcastCurrentCharacterPatch()
    end)
end

function MKA:QueueGroupSyncRequest(delay)
    self:DebugLog("QueueGroupSyncRequest delay=%s", tostring(delay or 0))
    if self.ResetGroupRatingLookupState then
        self:ResetGroupRatingLookupState()
    end

    if self.groupSyncRequestTimer then
        self.groupSyncRequestTimer:Cancel()
    end

    self.groupSyncRequestTimer = C_Timer.NewTimer(delay or 0, function()
        self.groupSyncRequestTimer = nil
        self:RequestGroupSync()
    end)
end

function MKA:QueueLibKeystoneRequest(delay, reason)
    if not LibKeystone or IsInRaid() or not IsInGroup() then
        return
    end
    if self.ResetGroupRatingLookupState then
        self:ResetGroupRatingLookupState()
    end

    local requestedDelay = delay or 0
    self.pendingLibKeystoneRequestReason = reason or self.pendingLibKeystoneRequestReason or "unspecified"

    self:DebugLog(
        "QueueLibKeystoneRequest delay=%s reason=%s",
        tostring(requestedDelay),
        tostring(self.pendingLibKeystoneRequestReason)
    )

    if self.libKeystoneRequestTimer then
        self.libKeystoneRequestTimer:Cancel()
    end

    self.libKeystoneRequestTimer = C_Timer.NewTimer(requestedDelay, function()
        self.libKeystoneRequestTimer = nil

        if not LibKeystone or IsInRaid() or not IsInGroup() then
            self.pendingLibKeystoneRequestReason = nil
            return
        end

        local finalReason = self.pendingLibKeystoneRequestReason or reason or "unspecified"
        self.pendingLibKeystoneRequestReason = nil
        self:DebugLog("Requesting LibKeystone PARTY refresh reason=%s", tostring(finalReason))
        LibKeystone.Request("PARTY")
    end)
end

function MKA:EnsureLibOpenKeystoneFallback()
    LibOpenKeystone = LibOpenKeystone or LibStub("LibOpenKeystone-1.0", true)
    if not LibOpenKeystone then
        return false
    end

    if LibOpenKeystone.SetEnabled then
        LibOpenKeystone.SetEnabled(true)
    end

    if self.libOpenKeystoneRegistered then
        return true
    end

    if not LibOpenKeystone.RegisterCallback then
        return false
    end

    LibOpenKeystone.RegisterCallback(self, "KeystoneUpdate", function(owner, unitName, keystoneInfo)
        owner:HandleLibOpenKeystoneData(unitName, keystoneInfo)
    end)

    self.libOpenKeystoneRegistered = true
    return true
end

function MKA:QueueLibOpenKeystoneRequest(delay, reason)
    if IsInRaid() or not IsInGroup() or not self:EnsureLibOpenKeystoneFallback() then
        return
    end

    local requestedDelay = delay or 0
    self.pendingLibOpenKeystoneRequestReason = reason or self.pendingLibOpenKeystoneRequestReason or "unspecified"

    self:DebugLog(
        "QueueLibOpenKeystoneRequest delay=%s reason=%s",
        tostring(requestedDelay),
        tostring(self.pendingLibOpenKeystoneRequestReason)
    )

    if self.libOpenKeystoneRequestTimer then
        self.libOpenKeystoneRequestTimer:Cancel()
    end

    self.libOpenKeystoneRequestTimer = C_Timer.NewTimer(requestedDelay, function()
        self.libOpenKeystoneRequestTimer = nil

        if IsInRaid() or not IsInGroup() or not self:EnsureLibOpenKeystoneFallback() then
            self.pendingLibOpenKeystoneRequestReason = nil
            return
        end

        local finalReason = self.pendingLibOpenKeystoneRequestReason or reason or "unspecified"
        self.pendingLibOpenKeystoneRequestReason = nil
        self:DebugLog("Requesting LibOpenKeystone PARTY refresh reason=%s", tostring(finalReason))

        if LibOpenKeystone and LibOpenKeystone.RequestKeystoneDataFromParty then
            LibOpenKeystone.RequestKeystoneDataFromParty()
        end
    end)
end

function MKA:HandleUnitConnection(_, unitTarget, isConnected)
    if IsInGroup() or IsInRaid() then
        local normalizedUnit = type(unitTarget) == "string" and unitTarget:lower() or ""
        if normalizedUnit ~= "" and (normalizedUnit:match("^party%d+$") or normalizedUnit:match("^raid%d+$")) and UnitExists(unitTarget) then
            local fullName = GetUnitName(unitTarget, true)
            if fullName then
                self.offlineGroupMemberAliases = self.offlineGroupMemberAliases or {}
                for alias in pairs(GetPlayerIdentifierAliases(fullName)) do
                    if isConnected then
                        self.offlineGroupMemberAliases[alias] = nil
                    else
                        self.offlineGroupMemberAliases[alias] = true
                    end
                end

                if not isConnected and self.IsMainWindowShown and self:IsMainWindowShown() then
                    self:RefreshMainWindow("UNIT_CONNECTION", string.format("offline:%s", tostring(fullName)))
                end
            end
        end
    end

    if not isConnected or IsInRaid() or not IsInGroup() then
        return
    end

    local normalizedUnit = type(unitTarget) == "string" and unitTarget:lower() or ""
    if normalizedUnit ~= "" and not normalizedUnit:match("^party%d+$") then
        return
    end

    if LibKeystone then
        self:QueueLibKeystoneRequest(
            LIB_KEYSTONE_UNIT_CONNECTION_DELAY_SECONDS,
            string.format("unit_connection:%s", tostring(unitTarget))
        )
    end

    self:QueueLibOpenKeystoneRequest(
        LIB_KEYSTONE_UNIT_CONNECTION_DELAY_SECONDS + LIB_OPEN_KEYSTONE_REQUEST_DELAY_SECONDS,
        string.format("unit_connection:%s", tostring(unitTarget))
    )
end

function MKA:RememberGroupAddonUser(identifier)
    if type(identifier) ~= "string" or identifier == "" then
        return
    end

    self.groupAddonUsers = self.groupAddonUsers or {}
    local groupedMember = GetGroupMemberInfoByIdentifier(identifier)
    local resolvedIdentifier = (groupedMember and groupedMember.fullName) or identifier
    local existingKey = FindMatchingIdentifierKey(self.groupAddonUsers, resolvedIdentifier) or resolvedIdentifier
    self.groupAddonUsers[existingKey] = true
end

function MKA:ApplyLibKeystoneFallback(pending)
    if type(pending) ~= "table" or type(pending.characterKey) ~= "string" or type(pending.characterData) ~= "table" then
        return
    end

    local fallbackSource = pending.source or "libkeystone"
    local fallbackWarbandTag = pending.warbandTag or LIB_KEYSTONE_FALLBACK_WARBAND_TAG
    local fallbackSourceLabel = GetCharacterDataSourceLabel(fallbackSource)

    self.groupCharacters = self.groupCharacters or {}
    local ownerKey = FindMatchingOwnerKey(self.groupCharacters, pending.ownerKey) or pending.ownerKey
    local existingOwnerData = self.groupCharacters[ownerKey]
    local existingCharacter = existingOwnerData and existingOwnerData.characters and existingOwnerData.characters[pending.characterKey] or nil
    local characterData = PreserveExistingFallbackCharacterData(existingCharacter, pending.characterData)

    if existingOwnerData and existingOwnerData.source ~= fallbackSource then
        if type(existingCharacter) == "table"
            and not IsFallbackCharacterSource(existingCharacter.source)
            and existingCharacter.source ~= fallbackSource
        then
            self:DebugLog(
                "ApplyLibKeystoneFallback ignored player=%s owner=%s source=%s reason=existing_character_present",
                tostring(pending.playerName),
                tostring(ownerKey),
                fallbackSourceLabel
            )
            return
        end

        local characters = existingOwnerData.characters or {}
        if type(existingCharacter) == "table" and AreCharacterCommDataEqual(existingCharacter, characterData) then
            existingOwnerData.lastSeen = pending.seenAt or time()
            if IsFallbackCharacterSource(existingCharacter.source) and existingCharacter.source ~= fallbackSource then
                existingCharacter.source = fallbackSource
                existingOwnerData.source = fallbackSource
            end
            return
        end

        characters[pending.characterKey] = characterData
        existingOwnerData.characters = characters
        existingOwnerData.warbandTag = existingOwnerData.warbandTag or fallbackWarbandTag
        existingOwnerData.lastSeen = pending.seenAt or time()
        if existingOwnerData.source == nil or IsFallbackCharacterSource(existingOwnerData.source) then
            existingOwnerData.source = fallbackSource
        end

        self:DebugLog(
            "ApplyLibKeystoneFallback stored player=%s owner=%s source=%s mapID=%s level=%s",
            tostring(pending.playerName),
            tostring(ownerKey),
            fallbackSourceLabel,
            tostring(pending.mapID),
            tostring(pending.keyLevel)
        )

        if self.IsMainWindowShown and self:IsMainWindowShown() then
            self:RefreshMainWindow(
                "ApplyLibKeystoneFallback",
                string.format("fallback:%s mapID=%s level=%s", tostring(ownerKey), tostring(pending.mapID), tostring(pending.keyLevel))
            )
        end
        return
    end

    if existingOwnerData and existingOwnerData.source == fallbackSource and AreCharacterCommDataEqual(existingCharacter, characterData) then
        existingOwnerData.lastSeen = pending.seenAt or time()
        return
    end

    self.groupCharacters[ownerKey] = {
        owner = ownerKey,
        warbandTag = fallbackWarbandTag,
        lastSeen = pending.seenAt or time(),
        source = fallbackSource,
        characters = {
            [pending.characterKey] = characterData,
        },
    }

    self:DebugLog(
        "ApplyLibKeystoneFallback stored owner player=%s owner=%s source=%s mapID=%s level=%s",
        tostring(pending.playerName),
        tostring(ownerKey),
        fallbackSourceLabel,
        tostring(pending.mapID),
        tostring(pending.keyLevel)
    )

    if self.IsMainWindowShown and self:IsMainWindowShown() then
        self:RefreshMainWindow(
            "ApplyLibKeystoneFallback",
            string.format("player=%s mapID=%s level=%s", tostring(ownerKey), tostring(pending.mapID), tostring(pending.keyLevel))
        )
    end
end

function MKA:QueueLibKeystoneFallback(pending)
    if type(pending) ~= "table" or type(pending.characterKey) ~= "string" then
        return
    end

    self.pendingLibKeystoneFallbacks = self.pendingLibKeystoneFallbacks or {}

    local existingPending = self.pendingLibKeystoneFallbacks[pending.characterKey]
    if existingPending and existingPending.timer then
        existingPending.timer:Cancel()
    end

    pending.timer = C_Timer.NewTimer(LIB_KEYSTONE_FALLBACK_APPLY_DELAY_SECONDS, function()
        local latestPending = self.pendingLibKeystoneFallbacks and self.pendingLibKeystoneFallbacks[pending.characterKey]
        if latestPending ~= pending then
            return
        end

        self.pendingLibKeystoneFallbacks[pending.characterKey] = nil
        self:ApplyLibKeystoneFallback(pending)
    end)

    self.pendingLibKeystoneFallbacks[pending.characterKey] = pending

    self:DebugLog(
        "QueueLibKeystoneFallback player=%s owner=%s character=%s source=%s delay=%s",
        tostring(pending.playerName),
        tostring(pending.ownerKey),
        tostring(pending.characterKey),
        GetCharacterDataSourceLabel(pending.source or "libkeystone"),
        tostring(LIB_KEYSTONE_FALLBACK_APPLY_DELAY_SECONDS)
    )
end

function MKA:CancelLibKeystoneFallback(characterKey)
    if type(characterKey) ~= "string" or not self.pendingLibKeystoneFallbacks then
        return
    end

    local pending = self.pendingLibKeystoneFallbacks[characterKey]
    if pending and pending.timer then
        pending.timer:Cancel()
    end

    self.pendingLibKeystoneFallbacks[characterKey] = nil
end

function MKA:HandleLibKeystoneData(keyLevel, keyMapID, playerRating, playerName, channel)
    self:DebugLog(
        "HandleLibKeystoneData received player=%s source=%s channel=%s mapID=%s level=%s inGroup=%s",
        tostring(playerName),
        GetCharacterDataSourceLabel("libkeystone"),
        tostring(channel),
        tostring(keyMapID),
        tostring(keyLevel),
        tostring(self:IsInGroupForComm())
    )

    if not LibKeystone or not self:IsInGroupForComm() or channel ~= "PARTY" then
        return
    end

    for alias in pairs(GetPlayerIdentifierAliases(GetCharacterDisplayName())) do
        if GetPlayerIdentifierAliases(playerName)[alias] then
            self:DebugLog(
                "HandleLibKeystoneData ignored player=%s reason=self channel=%s",
                tostring(playerName),
                tostring(channel)
            )
            return
        end
    end

    local groupedMember = GetLibKeystoneGroupMemberInfo(playerName)
    if not groupedMember or not groupedMember.fullName then
        self:DebugLog(
            "HandleLibKeystoneData ignored player=%s reason=not_in_group channel=%s",
            tostring(playerName),
            tostring(channel)
        )
        return
    end

    if groupedMember.resolvedByPartyCallback then
        self:DebugLog(
            "HandleLibKeystoneData accepted player=%s reason=party_callback fullName=%s unitInParty=%s channel=%s",
            tostring(playerName),
            tostring(groupedMember.fullName),
            tostring(groupedMember.resolvedByUnitInParty),
            tostring(channel)
        )
    end

    self.groupCharacters = self.groupCharacters or {}
    local ownerKey = FindMatchingOwnerKey(self.groupCharacters, groupedMember.fullName) or groupedMember.fullName
    local existingOwnerData = self.groupCharacters[ownerKey]

    local normalizedKeyLevel = tonumber(keyLevel) or 0
    local normalizedMapID = tonumber(keyMapID) or 0
    local hasKeystone = normalizedKeyLevel > 0 and normalizedMapID > 0
    local characterKey = groupedMember.fullName

    if not hasKeystone then
        if groupedMember.connected == false and existingOwnerData then
            existingOwnerData.lastSeen = time()
            self:DebugLog(
                "HandleLibKeystoneData kept offline player=%s owner=%s source=%s",
                tostring(playerName),
                tostring(ownerKey),
                GetCharacterDataSourceLabel("libkeystone")
            )
            return
        end

        self:CancelLibKeystoneFallback(characterKey)

        if existingOwnerData and existingOwnerData.source == "libkeystone" then
            self.groupCharacters[ownerKey] = nil
            if self.IsMainWindowShown and self:IsMainWindowShown() then
                self:RefreshMainWindow("HandleLibKeystoneData", string.format("removed:%s", tostring(ownerKey)))
            end
        elseif existingOwnerData and existingOwnerData.characters then
            local existingCharacter = existingOwnerData.characters[characterKey]
            if type(existingCharacter) == "table" and existingCharacter.source == "libkeystone" then
                existingOwnerData.characters[characterKey] = nil
                existingOwnerData.lastSeen = time()
                if self.IsMainWindowShown and self:IsMainWindowShown() then
                    self:RefreshMainWindow("HandleLibKeystoneData", string.format("removed:%s", tostring(characterKey)))
                end
            end
        end
        return
    end

    local now = time()
    local dungeonName = self:GetLocalizedDungeonName(normalizedMapID)
    local keystoneLink = BuildFallbackKeystoneLink(normalizedMapID, normalizedKeyLevel, dungeonName)
    local characterData = {
        name = groupedMember.fullName,
        class = groupedMember.class,
        realm = groupedMember.realm,
        keyLevel = normalizedKeyLevel,
        mapID = normalizedMapID,
        dungeonName = dungeonName,
        keystoneLink = keystoneLink,
        keyUpgradeTrack = ResolveKeystoneUpgradeTrack(keystoneLink, normalizedKeyLevel),
        hasKeystone = true,
        lastKeySeenAt = now,
        rating = NormalizeMythicPlusRating(playerRating),
        showInGroup = true,
        hide = false,
        roles = nil,
        source = "libkeystone",
    }

    self:QueueLibKeystoneFallback({
        ownerKey = ownerKey,
        characterKey = characterKey,
        characterData = characterData,
        playerName = playerName,
        mapID = normalizedMapID,
        keyLevel = normalizedKeyLevel,
        seenAt = now,
        source = "libkeystone",
        warbandTag = LIB_KEYSTONE_FALLBACK_WARBAND_TAG,
    })
end

function MKA:HandleLibOpenKeystoneData(playerName, keystoneInfo)
    self:DebugLog(
        "HandleLibOpenKeystoneData received player=%s source=%s mapID=%s level=%s inGroup=%s",
        tostring(playerName),
        GetCharacterDataSourceLabel("libopenkeystone"),
        tostring(keystoneInfo and keystoneInfo.challengeMapID),
        tostring(keystoneInfo and keystoneInfo.level),
        tostring(self:IsInGroupForComm())
    )

    if not LibOpenKeystone or not self:IsInGroupForComm() then
        return
    end

    for alias in pairs(GetPlayerIdentifierAliases(GetCharacterDisplayName())) do
        if GetPlayerIdentifierAliases(playerName)[alias] then
            self:DebugLog(
                "HandleLibOpenKeystoneData ignored player=%s reason=self",
                tostring(playerName)
            )
            return
        end
    end

    local groupedMember = GetGroupMemberInfoByIdentifier(playerName)
    if not groupedMember or not groupedMember.fullName then
        self:DebugLog(
            "HandleLibOpenKeystoneData ignored player=%s reason=not_in_group",
            tostring(playerName)
        )
        return
    end

    self.groupCharacters = self.groupCharacters or {}
    local ownerKey = FindMatchingOwnerKey(self.groupCharacters, groupedMember.fullName) or groupedMember.fullName
    local existingOwnerData = self.groupCharacters[ownerKey]

    local normalizedKeyLevel = tonumber(keystoneInfo and keystoneInfo.level) or 0
    local normalizedMapID = tonumber(keystoneInfo and keystoneInfo.challengeMapID) or 0
    local hasKeystone = normalizedKeyLevel > 0 and normalizedMapID > 0
    local characterKey = groupedMember.fullName

    if not hasKeystone then
        if groupedMember.connected == false and existingOwnerData then
            existingOwnerData.lastSeen = time()
            self:DebugLog(
                "HandleLibOpenKeystoneData kept offline player=%s owner=%s source=%s",
                tostring(playerName),
                tostring(ownerKey),
                GetCharacterDataSourceLabel("libopenkeystone")
            )
            return
        end

        self:CancelLibKeystoneFallback(characterKey)

        if existingOwnerData and existingOwnerData.source == "libopenkeystone" then
            self.groupCharacters[ownerKey] = nil
            if self.IsMainWindowShown and self:IsMainWindowShown() then
                self:RefreshMainWindow("HandleLibOpenKeystoneData", string.format("removed:%s", tostring(ownerKey)))
            end
        elseif existingOwnerData and existingOwnerData.characters then
            local existingCharacter = existingOwnerData.characters[characterKey]
            if type(existingCharacter) == "table" and existingCharacter.source == "libopenkeystone" then
                existingOwnerData.characters[characterKey] = nil
                existingOwnerData.lastSeen = time()
                if self.IsMainWindowShown and self:IsMainWindowShown() then
                    self:RefreshMainWindow("HandleLibOpenKeystoneData", string.format("removed:%s", tostring(characterKey)))
                end
            end
        end
        return
    end

    local now = time()
    local dungeonName = self:GetLocalizedDungeonName(normalizedMapID)
    local keystoneLink = BuildFallbackKeystoneLink(normalizedMapID, normalizedKeyLevel, dungeonName)
    local characterData = {
        name = groupedMember.fullName,
        class = groupedMember.class,
        realm = groupedMember.realm,
        keyLevel = normalizedKeyLevel,
        mapID = normalizedMapID,
        dungeonName = dungeonName,
        keystoneLink = keystoneLink,
        keyUpgradeTrack = ResolveKeystoneUpgradeTrack(keystoneLink, normalizedKeyLevel),
        hasKeystone = true,
        lastKeySeenAt = now,
        rating = nil,
        showInGroup = true,
        hide = false,
        roles = nil,
        source = "libopenkeystone",
    }

    self:QueueLibKeystoneFallback({
        ownerKey = ownerKey,
        characterKey = characterKey,
        characterData = characterData,
        playerName = playerName,
        mapID = normalizedMapID,
        keyLevel = normalizedKeyLevel,
        seenAt = now,
        source = "libopenkeystone",
        warbandTag = LIB_KEYSTONE_FALLBACK_WARBAND_TAG,
    })
end

function MKA:ProcessCharacterUpdate(event)
    local changed, changeReasons = self:UpdateCurrentCharacter(event)
    local keystoneChanged = HasKeystoneChangeReason(changeReasons)

    self:DebugLog(
        "ProcessCharacterUpdate event=%s changed=%s keystoneChanged=%s reasons=%s",
        tostring(event),
        tostring(changed),
        tostring(keystoneChanged),
        (changeReasons and #changeReasons > 0) and table.concat(changeReasons, ", ") or "-"
    )

    if changed then
        self:QueueBroadcastUpdate(CHANGED_BROADCAST_DELAY_SECONDS)
        if keystoneChanged then
            self:QueueCharacterPatchBroadcast(CHARACTER_PATCH_DELAY_SECONDS)
        end
    end

    if changed and self.IsMainWindowShown and self:IsMainWindowShown() then
        self:RefreshMainWindow("ProcessCharacterUpdate", string.format("event=%s reasons=%s", tostring(event), table.concat(changeReasons or {}, ", ")))
    end
end

function MKA:UpdateCurrentCharacter(event)
    local characterKey = GetCharacterKey()
    local characters = self.db.global.characters
    local existing = characters[characterKey] or {}
    local now = time()
    local lastReset = GetLastWeeklyResetTimestamp()
    local mapID, level, dungeonName = GetKeystoneInfo()
    local keystoneLink = GetOwnedKeystoneLink() or BuildFallbackKeystoneLink(mapID, level, dungeonName)
    local keyUpgradeTrack = ResolveKeystoneUpgradeTrack(keystoneLink, level)
    local rating = GetCurrentPlayerMythicPlusRating()
    local bestRuns = BuildPlayerMythicPlusBestRuns("player")
    local name = GetCharacterDisplayName()
    local class = select(2, UnitClass("player"))
    local realm = GetRealmName()
    local characterLevel = GetCurrentCharacterLevel()
    local hasKeystoneData = level ~= nil or mapID ~= nil or dungeonName ~= nil
    local hasKeystoneLink = keystoneLink ~= nil
    local hasConfirmedKeystone = hasKeystoneData or hasKeystoneLink
    local roles = self:NormalizeRoles(existing)
    local changed = false
    local changeReasons = {}
    local allowKeystoneRemoval = event == "PLAYER_LOGOUT" or IsKeystoneInventoryEvent(event)

    local function markChanged(reason)
        changed = true
        changeReasons[#changeReasons + 1] = reason
    end

    if existing.name ~= name then
        existing.name = name
        markChanged("name")
    end

    if existing.class ~= class then
        existing.class = class
        markChanged("class")
    end

    if existing.realm ~= realm then
        existing.realm = realm
        markChanged("realm")
    end

    if existing.level ~= characterLevel then
        existing.level = characterLevel
        markChanged("level")
    end

    if hasConfirmedKeystone then
        if level ~= nil and existing.keyLevel ~= level then
            existing.keyLevel = level
            markChanged("keyLevel")
        end

        if mapID ~= nil and existing.mapID ~= mapID then
            existing.mapID = mapID
            markChanged("mapID")
        end

        if dungeonName ~= nil and existing.dungeonName ~= dungeonName then
            existing.dungeonName = dungeonName
            markChanged("dungeonName")
        end

        if hasKeystoneLink and existing.keystoneLink ~= keystoneLink then
            existing.keystoneLink = keystoneLink
            markChanged("keystoneLink")
        end

        if hasKeystoneLink and existing.keyUpgradeTrack ~= keyUpgradeTrack then
            existing.keyUpgradeTrack = keyUpgradeTrack
            markChanged("keyUpgradeTrack")
        end

        if existing.hasKeystone ~= true then
            existing.hasKeystone = true
            markChanged("hasKeystone=true")
        end
    elseif allowKeystoneRemoval then
        if existing.keyLevel ~= nil then
            existing.keyLevel = nil
            markChanged("keyLevel=nil")
        end

        if existing.mapID ~= nil then
            existing.mapID = nil
            markChanged("mapID=nil")
        end

        if existing.dungeonName ~= nil then
            existing.dungeonName = nil
            markChanged("dungeonName=nil")
        end

        if existing.keystoneLink ~= nil then
            existing.keystoneLink = nil
            markChanged("keystoneLink=nil")
        end

        if existing.keyUpgradeTrack ~= nil then
            existing.keyUpgradeTrack = nil
            markChanged("keyUpgradeTrack=nil")
        end

        if existing.hasKeystone ~= false then
            existing.hasKeystone = false
            markChanged("hasKeystone=false")
        end
    end

    if hasConfirmedKeystone and (not existing.lastKeySeenAt or existing.lastKeySeenAt < lastReset) then
        existing.lastKeySeenAt = now
        markChanged("lastKeySeenAt")
    end

    if rating ~= nil and existing.rating ~= rating then
        existing.rating = rating
        markChanged("rating")
    end

    if type(bestRuns) == "table" and BuildBestRunsSignature(existing.bestRuns) ~= BuildBestRunsSignature(bestRuns) then
        existing.bestRuns = bestRuns
        markChanged("bestRuns")
    end

    if existing.canTradeAll ~= nil then
        existing.canTradeAll = nil
        markChanged("canTradeAll=nil")
    end

    if existing.showInGroup == nil then
        if existing.hide ~= nil then
            existing.showInGroup = not existing.hide
            markChanged("showInGroup=migrated")
        else
            existing.showInGroup = true
            markChanged("showInGroup=default")
        end
    end

    local mirroredHide = not existing.showInGroup
    if existing.hide ~= mirroredHide then
        existing.hide = mirroredHide
        markChanged("hide=mirrored")
    end

    if existing.roles ~= roles then
        existing.roles = roles
        markChanged("roles")
    end

    if existing.source ~= "mka" then
        existing.source = "mka"
        markChanged("source=mka")
    end

    if changed or not existing.lastSeen then
        existing.lastSeen = now
    elseif event == "PLAYER_ENTERING_WORLD" then
        existing.lastSeen = now
    end

    characters[characterKey] = existing

    return changed, changeReasons
end

local function GetMaxCharacterLevel()
    if GetMaxPlayerLevel then
        local ok, maxLevel = pcall(GetMaxPlayerLevel)
        maxLevel = tonumber(maxLevel)
        if ok and maxLevel and maxLevel > 0 then
            return maxLevel
        end
    end

    local globalMaxLevel = tonumber(_G.MAX_PLAYER_LEVEL)
    if globalMaxLevel and globalMaxLevel > 0 then
        return globalMaxLevel
    end

    if type(_G.MAX_PLAYER_LEVEL_TABLE) == "table" then
        local maxLevel = 0
        for _, level in pairs(_G.MAX_PLAYER_LEVEL_TABLE) do
            level = tonumber(level)
            if level and level > maxLevel then
                maxLevel = level
            end
        end
        if maxLevel > 0 then
            return maxLevel
        end
    end

    return nil
end

local function IsMaxLevelOrLegacyKeystoneCharacter(character)
    if not character then
        return false
    end

    local level = tonumber(character.level)
    local maxLevel = GetMaxCharacterLevel()
    if level and maxLevel then
        return level >= maxLevel
    end

    return level == nil and (
        (tonumber(character.keyLevel) and tonumber(character.keyLevel) > 0)
        or character.keystoneLink
        or character.hasKeystone
        or (tonumber(character.rating) or 0) > 0
    )
end

function MKA:GetCharacters(includeShownWithoutKeystone)
    local entries = {}
    local lastReset = GetLastWeeklyResetTimestamp()
    local currentCharacterKey = GetCharacterKey()

    for characterKey, data in pairs(self.db.global.characters) do
        local hasStoredKeystone = data and (
            (data.keyLevel and data.keyLevel > 0) or
            data.keystoneLink or
            data.hasKeystone
        )
        local keyConfirmedAfterReset = data and data.lastKeySeenAt and data.lastKeySeenAt >= lastReset

        local shouldIncludeShownWithoutKeystone = includeShownWithoutKeystone
            and self:IsCharacterShownInGroup(data)
            and IsMaxLevelOrLegacyKeystoneCharacter(data)

        if (hasStoredKeystone and keyConfirmedAfterReset) or shouldIncludeShownWithoutKeystone then
            entries[#entries + 1] = {
                key = characterKey,
                data = data,
            }
        elseif characterKey == currentCharacterKey then
            self:DebugLog(
                "GetCharacters filtered current=%s source=%s hasStoredKeystone=%s keyConfirmedAfterReset=%s lastKeySeenAt=%s lastReset=%s keyLevel=%s mapID=%s hasKeystone=%s link=%s showInGroup=%s",
                tostring(characterKey),
                GetCharacterDataSourceLabel(data and data.source),
                tostring(hasStoredKeystone),
                tostring(keyConfirmedAfterReset),
                tostring(data and data.lastKeySeenAt),
                tostring(lastReset),
                tostring(data and data.keyLevel),
                tostring(data and data.mapID),
                tostring(data and data.hasKeystone),
                tostring(data and data.keystoneLink),
                tostring(data and self:IsCharacterShownInGroup(data))
            )
        end
    end

    for characterKey, data in pairs(self.testCharacters or {}) do
        entries[#entries + 1] = {
            key = characterKey,
            data = data,
        }
    end

    table.sort(entries, function(left, right)
        return (left.data.name or left.key) < (right.data.name or right.key)
    end)

    return entries
end

function MKA:GetGroupCharacters()
    local entries = {}
    local lastReset = GetLastWeeklyResetTimestamp()

    for ownerKey, ownerData in pairs(self.groupCharacters or {}) do
        local characters = ownerData and ownerData.characters or {}
        local warbandTag = ownerData and ownerData.warbandTag or ""
        for characterKey, data in pairs(characters) do
            local hasStoredKeystone = data and (
                (data.keyLevel and data.keyLevel > 0) or
                data.keystoneLink or
                data.hasKeystone
            )
            local keyConfirmedAfterReset = data and data.lastKeySeenAt and data.lastKeySeenAt >= lastReset

            if (hasStoredKeystone and keyConfirmedAfterReset) or self:IsCharacterShownInGroup(data) then
                entries[#entries + 1] = {
                    key = characterKey,
                    owner = ownerKey,
                    source = (data and data.source) or (ownerData and ownerData.source),
                    warbandTag = warbandTag,
                    data = data,
                }
            elseif IsFallbackCharacterSource((data and data.source) or (ownerData and ownerData.source)) then
                self:DebugLog(
                    "GetGroupCharacters filtered fallback owner=%s character=%s source=%s hasStoredKeystone=%s keyConfirmedAfterReset=%s lastKeySeenAt=%s lastReset=%s keyLevel=%s mapID=%s hasKeystone=%s",
                    tostring(ownerKey),
                    tostring(characterKey),
                    GetCharacterDataSourceLabel((data and data.source) or (ownerData and ownerData.source)),
                    tostring(hasStoredKeystone),
                    tostring(keyConfirmedAfterReset),
                    tostring(data and data.lastKeySeenAt),
                    tostring(lastReset),
                    tostring(data and data.keyLevel),
                    tostring(data and data.mapID),
                    tostring(data and data.hasKeystone)
                )
            end
        end
    end

    for ownerKey, ownerData in pairs(self.testGroupCharacters or {}) do
        local characters = ownerData and ownerData.characters or {}
        local warbandTag = ownerData and ownerData.warbandTag or ""
        for characterKey, data in pairs(characters) do
            entries[#entries + 1] = {
                key = characterKey,
                owner = ownerKey,
                source = (data and data.source) or (ownerData and ownerData.source),
                warbandTag = warbandTag,
                data = data,
            }
        end
    end

    table.sort(entries, function(left, right)
        local leftWarband = left.warbandTag or ""
        local rightWarband = right.warbandTag or ""
        if leftWarband ~= rightWarband then
            return leftWarband < rightWarband
        end

        local leftName = left.data.name or left.key
        local rightName = right.data.name or right.key
        if leftName == rightName then
            return (left.owner or "") < (right.owner or "")
        end

        return leftName < rightName
    end)

    return entries
end

function MKA:BuildTestCharacter(name, realm, classToken, mapID, keyLevel, rating, roleList)
    local roles = {}
    for _, roleKey in ipairs(roleList or { "DPS" }) do
        roles[roleKey] = true
    end

    return {
        name = string.format("%s-%s", name, realm),
        class = classToken,
        realm = realm,
        keyLevel = keyLevel,
        mapID = mapID,
        dungeonName = self:GetLocalizedDungeonName(mapID),
        keystoneLink = nil,
        hasKeystone = true,
        lastKeySeenAt = time(),
        lastSeen = time(),
        rating = NormalizeMythicPlusRating(rating),
        showInGroup = true,
        hide = false,
        roles = roles,
    }
end

local TEST_GROUP_TEMPLATES = {
    {
        owner = "Naowhjunior",
        warbandTag = "Alpha Warband",
        characters = {
            { name = "Naowhjunior", class = "DEMONHUNTER", mapID = 239, keyLevel = 14, rating = 2520, roles = { "DPS" } },
            { name = "Boomyfw", class = "DRUID", mapID = 556, keyLevel = 10, rating = 2010, roles = { "DPS", "HEAL" } },
        },
    },
    {
        owner = "Binary",
        warbandTag = "Weekend Warband",
        characters = {
            { name = "Binary", class = "ROGUE", mapID = 161, keyLevel = 19, rating = 2875, roles = { "DPS" } },
            { name = "Mahatma", class = "MONK", mapID = 559, keyLevel = 12, rating = 2240, roles = { "TANK", "HEAL", "DPS" } },
        },
    },
    {
        owner = "MultiKey",
        warbandTag = "Late Warband",
        characters = {
            { name = "Altone", class = "HUNTER", mapID = 239, keyLevel = 17, rating = 2680, roles = { "DPS" } },
            { name = "Alttwo", class = "SHAMAN", mapID = 239, keyLevel = 19, rating = 2910, roles = { "HEAL", "DPS" } },
            { name = "Altthree", class = "PALADIN", mapID = 239, keyLevel = 22, rating = 3235, roles = { "TANK", "HEAL" } },
            { name = "Altfour", class = "EVOKER", mapID = 402, keyLevel = 8, rating = 1760, roles = { "DPS" } },
        },
    },
    {
        owner = "Fourthstack",
        warbandTag = "Night Warband",
        characters = {
            { name = "Fourthtank", class = "DEATHKNIGHT", mapID = 402, keyLevel = 14, rating = 2410, roles = { "TANK", "DPS" } },
            { name = "Fourthheal", class = "PRIEST", mapID = 402, keyLevel = 14, rating = 2395, roles = { "HEAL", "DPS" } },
            { name = "Fourthdps", class = "WARLOCK", mapID = 402, keyLevel = 14, rating = 2360, roles = { "DPS" } },
        },
    },
}

local function CountUniqueWarbandTagsFromGroupContext(self)
    local uniqueWarbandTags = {}

    local function addWarbandTag(warbandTag)
        if type(warbandTag) == "string" and warbandTag ~= "" then
            uniqueWarbandTags[warbandTag] = true
        end
    end

    if self.IsInGroupForComm and self:IsInGroupForComm() then
        addWarbandTag(self.GetWarbandTag and self:GetWarbandTag() or nil)
    end

    for _, ownerData in pairs(self.groupCharacters or {}) do
        addWarbandTag(ownerData and ownerData.warbandTag)
    end

    local uniqueWarbandTagCount = 0
    for _ in pairs(uniqueWarbandTags) do
        uniqueWarbandTagCount = uniqueWarbandTagCount + 1
    end

    return uniqueWarbandTagCount, uniqueWarbandTags
end

local function GetNormalizedCharacterName(value)
    if type(value) ~= "string" then
        return nil
    end

    local plainName = value
    if Ambiguate then
        plainName = Ambiguate(value, "none")
    elseif value:find("-", 1, true) then
        plainName = value:match("^[^-]+")
    end

    plainName = plainName and strtrim(plainName) or nil
    if not plainName or plainName == "" then
        return nil
    end

    return plainName:lower()
end

local function CollectReservedTestCharacterNames(self, targetTable)
    local reservedNames = {}

    local function reserveName(name)
        local normalizedName = GetNormalizedCharacterName(name)
        if normalizedName then
            reservedNames[normalizedName] = true
        end
    end

    for _, characterEntry in ipairs(self:GetSharedGroupViewCharacters() or {}) do
        local character = characterEntry and characterEntry.data or {}
        reserveName(character.name or characterEntry.key)
    end

    for _, ownerData in pairs(targetTable or {}) do
        for characterKey, characterData in pairs(ownerData.characters or {}) do
            reserveName((characterData and characterData.name) or characterKey)
        end
    end

    return reservedNames
end

local function BuildUniqueTestCharacterName(baseName, reservedNames)
    local normalizedBaseName = GetNormalizedCharacterName(baseName)
    if not normalizedBaseName then
        return baseName
    end

    if not reservedNames[normalizedBaseName] then
        reservedNames[normalizedBaseName] = true
        return baseName
    end

    local suffixIndex = 2
    while true do
        local candidateName = string.format("%sBK%d", baseName, suffixIndex)
        local normalizedCandidateName = GetNormalizedCharacterName(candidateName)
        if normalizedCandidateName and not reservedNames[normalizedCandidateName] then
            reservedNames[normalizedCandidateName] = true
            return candidateName
        end
        suffixIndex = suffixIndex + 1
    end
end

local function AddTestGroupFromTemplate(self, targetTable, realm, groupTemplate, reservedNames)
    local ownerKey = string.format("%s-%s", groupTemplate.owner, realm)
    local characters = {}

    for _, characterTemplate in ipairs(groupTemplate.characters) do
        local uniqueCharacterName = BuildUniqueTestCharacterName(characterTemplate.name, reservedNames or {})
        local characterKey = string.format("%s-%s", uniqueCharacterName, realm)
        characters[characterKey] = self:BuildTestCharacter(
            uniqueCharacterName,
            realm,
            characterTemplate.class,
            characterTemplate.mapID,
            characterTemplate.keyLevel,
            characterTemplate.rating,
            characterTemplate.roles
        )
    end

    targetTable[ownerKey] = {
        owner = ownerKey,
        warbandTag = groupTemplate.warbandTag,
        lastSeen = time(),
        characters = characters,
    }
end

function MKA:EnableTestData(requestedAdditionalGroups)
    local realm = GetRealmName() or "Testrealm"
    local reservedNames = CollectReservedTestCharacterNames(self, self.testGroupCharacters)
    _G.MKA_MEETINGSTONE_APPLICANT_TEST_ROWS_ENABLED = true
    self.mkaMeetingStoneTestApplicant = nil
    self.mkaMeetingStoneTestApplicants = nil

    self.testCharacters = {
        [string.format("Testtank-%s", realm)] = self:BuildTestCharacter("Testtank", realm, "WARRIOR", TEST_DUNGEON_MAP_IDS[1], 12, true, { "TANK" }),
        [string.format("Testheal-%s", realm)] = self:BuildTestCharacter("Testheal", realm, "PRIEST", TEST_DUNGEON_MAP_IDS[2], 15, false, { "HEAL" }),
        [string.format("Testdps-%s", realm)] = self:BuildTestCharacter("Testdps", realm, "MAGE", TEST_DUNGEON_MAP_IDS[3], 18, false, { "DPS" }),
    }

    self.testGroupCharacters = {}

    if type(requestedAdditionalGroups) == "number" then
        local additionalGroupCount = math.max(0, math.min(math.floor(requestedAdditionalGroups), 3))
        for index = 1, additionalGroupCount do
            AddTestGroupFromTemplate(self, self.testGroupCharacters, realm, TEST_GROUP_TEMPLATES[index], reservedNames)
        end
    else
        local uniqueWarbandTagCount, uniqueWarbandTags = CountUniqueWarbandTagsFromGroupContext(self)
        local missingWarbandTags = math.max(0, 4 - uniqueWarbandTagCount)
        local addedGroups = 0

        for _, groupTemplate in ipairs(TEST_GROUP_TEMPLATES) do
            if addedGroups >= missingWarbandTags then
                break
            end

            if not uniqueWarbandTags[groupTemplate.warbandTag] then
                AddTestGroupFromTemplate(self, self.testGroupCharacters, realm, groupTemplate, reservedNames)
                uniqueWarbandTags[groupTemplate.warbandTag] = true
                addedGroups = addedGroups + 1
            end
        end
    end

    if self.IsMainWindowShown and self:IsMainWindowShown() then
        self:RefreshMainWindow()
    end
end

function MKA:DisableTestData()
    self.testCharacters = nil
    self.testGroupCharacters = nil
    _G.MKA_MEETINGSTONE_APPLICANT_TEST_ROWS_ENABLED = false
    self.mkaMeetingStoneTestApplicant = nil
    self.mkaMeetingStoneTestApplicants = nil

    if self.IsMainWindowShown and self:IsMainWindowShown() then
        self:RefreshMainWindow()
    end
end

function MKA:GetRoleOrder()
    return ROLE_ORDER
end

function MKA:IsCharacterShownInGroup(character)
    if not character then
        return true
    end

    if character.showInGroup ~= nil then
        return character.showInGroup == true
    end

    if character.hide ~= nil then
        return character.hide ~= true
    end

    return true
end

function MKA:IsCharacterHidden(character)
    return not self:IsCharacterShownInGroup(character)
end

function MKA:GetArmorType(character)
    local armorType = self:GetArmorTypeKey(character)
    if not armorType then
        return "-"
    end

    return TRANSLATE(armorType)
end

function MKA:GetArmorTypeKey(character)
    local classToken = character and character.class
    return ARMOR_BY_CLASS[classToken]
end

function MKA:NormalizeRoles(character)
    if not character then
        return {
            DPS = true,
        }
    end

    if character.roles then
        return character.roles
    end

    local roles = {}
    if type(character.role) == "string" and ROLE_OPTIONS[character.role] then
        roles[character.role] = true
    end

    if not next(roles) then
        roles.DPS = true
    end

    character.roles = roles
    character.role = nil

    return roles
end

function MKA:UpdateCharacterSetting(characterKey, field, value)
    local character = self.db.global.characters[characterKey]
    if not character then
        return
    end

    if character[field] == value then
        return
    end

    character[field] = value
    if field == "showInGroup" then
        character.hide = not value
    end
    self:QueueCharacterPatchBroadcast(CHARACTER_PATCH_DELAY_SECONDS)
    self:QueueBroadcastUpdate(FULL_BROADCAST_FALLBACK_DELAY_SECONDS)

    local shouldSkipImmediateRefresh = self.IsMainWindowShown
        and self:IsMainWindowShown()
        and self.activeTab == "characters"

    if not shouldSkipImmediateRefresh then
        self:RefreshMainWindow()
    end
end

function MKA:UpdateCharacterRole(characterKey, roleKey, value)
    local character = self.db.global.characters[characterKey]
    if not character or not ROLE_OPTIONS[roleKey] then
        return
    end

    local roles = self:NormalizeRoles(character)
    local newValue = value and true or nil
    if roles[roleKey] == newValue then
        return
    end

    roles[roleKey] = newValue

    if not next(roles) then
        roles[roleKey] = true
    end

    character.roles = roles
    character.rolesManuallyConfigured = true
    self:QueueCharacterPatchBroadcast(CHARACTER_PATCH_DELAY_SECONDS)
    self:QueueBroadcastUpdate(FULL_BROADCAST_FALLBACK_DELAY_SECONDS)

    local shouldSkipImmediateRefresh = self.IsMainWindowShown
        and self:IsMainWindowShown()
        and self.activeTab == "characters"

    if not shouldSkipImmediateRefresh then
        self:RefreshMainWindow()
    end
end
function MKA:GetLocalizedDungeonName(mapID, fallbackName)
    if mapID and C_ChallengeMode and C_ChallengeMode.GetMapUIInfo then
        local localizedName = C_ChallengeMode.GetMapUIInfo(mapID)
        if localizedName and localizedName ~= "" then
            return localizedName
        end
    end

    if fallbackName and fallbackName ~= "" then
        return fallbackName
    end

    return TRANSLATE("DUNGEON.UNKNOWN")
end

function MKA:GetAbbreviatedDungeonName(mapID, fallbackName)
    return self:GetLocalizedDungeonName(mapID, fallbackName)
end

function MKA:EnsureEncounterJournalLoaded()
    if self.encounterJournalAttempted then
        return EJ_GetInstanceForMap and EJ_GetInstanceInfo
    end

    self.encounterJournalAttempted = true

    if EJ_GetInstanceForMap and EJ_GetInstanceInfo then
        return true
    end

    if EncounterJournal_LoadUI then
        EncounterJournal_LoadUI()
    elseif C_AddOns and C_AddOns.LoadAddOn then
        C_AddOns.LoadAddOn("Blizzard_EncounterJournal")
    elseif LoadAddOn then
        LoadAddOn("Blizzard_EncounterJournal")
    end

    return EJ_GetInstanceForMap and EJ_GetInstanceInfo
end

function MKA:OpenGreatVaultFrame()
    if InCombatLockdown and InCombatLockdown() then
        self:Print(TRANSLATE("COMBAT.SYSTEM_PANEL_BLOCKED"))
        return false
    end

    if WeeklyRewards_ShowUI then
        local opened = pcall(WeeklyRewards_ShowUI)
        if opened then
            return true
        end
    end

    local weeklyRewards = _G.WeeklyRewardsFrame
    if weeklyRewards and weeklyRewards.IsVisible and weeklyRewards:IsVisible() then
        return true
    end

    if WeeklyRewards_LoadUI then
        pcall(WeeklyRewards_LoadUI)
    elseif C_AddOns and C_AddOns.LoadAddOn then
        local isLoaded = C_AddOns.IsAddOnLoaded and C_AddOns.IsAddOnLoaded("Blizzard_WeeklyRewards")
        if not isLoaded then
            pcall(C_AddOns.LoadAddOn, "Blizzard_WeeklyRewards")
        end
    elseif LoadAddOn then
        pcall(LoadAddOn, "Blizzard_WeeklyRewards")
    end

    weeklyRewards = _G.WeeklyRewardsFrame
    if not weeklyRewards then
        return false
    end

    if ShowUIPanel then
        local shown = pcall(ShowUIPanel, weeklyRewards, true)
        if shown then
            return true
        end
    end

    weeklyRewards:Show()
    return true
end

function MKA:BuildDungeonJournalTextureMap()
    self.dungeonJournalTextureByName = {}

    if not self:EnsureEncounterJournalLoaded() then
        return
    end

    local tierCount = EJ_GetNumTiers and EJ_GetNumTiers() or 0

    for tierIndex = 1, tierCount do
        EJ_SelectTier(tierIndex)

        local instanceIndex = 1
        while true do
            local _, instanceName, _, bgImage, buttonImage1, loreImage, buttonImage2 = EJ_GetInstanceByIndex(instanceIndex, false)
            if not instanceName then
                break
            end

            local normalizedName = NormalizeDungeonNameForMatch(instanceName)
            local visual

            if loreImage then
                visual = {
                    texture = loreImage,
                    texCoords = EJ_LORE_IMAGE_TEX_COORDS,
                    source = "loreImage",
                    zoom = 1.0,
                }
            elseif bgImage then
                visual = {
                    texture = bgImage,
                    texCoords = { 0, 1, 0, 1 },
                    source = "bgImage",
                    zoom = 1.0,
                }
            elseif buttonImage2 then
                visual = {
                    texture = buttonImage2,
                    texCoords = { 0, 1, 0, 1 },
                    source = "buttonImage2",
                    zoom = 1.0,
                }
            elseif buttonImage1 then
                visual = {
                    texture = buttonImage1,
                    texCoords = { 0, 1, 0, 1 },
                    source = "buttonImage",
                    zoom = 1.0,
                }
            end

            if visual and normalizedName and not self.dungeonJournalTextureByName[normalizedName] then
                self.dungeonJournalTextureByName[normalizedName] = visual
            end

            instanceIndex = instanceIndex + 1
        end
    end
end

function MKA:GetDungeonJournalTexture(mapID)
    if not mapID then
        return nil, nil
    end

    self.dungeonJournalTextureCache = self.dungeonJournalTextureCache or {}

    local cachedVisual = self.dungeonJournalTextureCache[mapID]
    if cachedVisual ~= nil then
        if cachedVisual == false then
            return nil, nil, nil, nil
        end

        return cachedVisual.texture, cachedVisual.texCoords, cachedVisual.source, cachedVisual.zoom
    end

    if not self.dungeonJournalTextureByName then
        self:BuildDungeonJournalTextureMap()
    end

    local dungeonName = self:GetLocalizedDungeonName(mapID)
    local normalizedName = NormalizeDungeonNameForMatch(dungeonName)
    local visual = normalizedName and self.dungeonJournalTextureByName and self.dungeonJournalTextureByName[normalizedName] or nil

    if not visual then
        visual = DUNGEON_TEXTURE_FALLBACKS[mapID]
    end

    visual = visual or false
    self.dungeonJournalTextureCache[mapID] = visual

    if visual == false then
        return nil, nil, nil, nil
    end

    self:DebugLog(
        "Dungeon texture mapID=%s source=%s texture=%s zoom=%s",
        tostring(mapID),
        tostring(visual.source or "fallback"),
        tostring(visual.texture),
        tostring(visual.zoom or 1)
    )

    return visual.texture, visual.texCoords, visual.source, visual.zoom
end


function MKA:GetCharacterKeystoneText(character)
    if not character or not character.hasKeystone or not character.keyLevel then
        return TRANSLATE("KEYSTONE.NONE")
    end

    local dungeonName = self:GetLocalizedDungeonName(character.mapID, character.dungeonName)

    if dungeonName and dungeonName ~= "" then
        return string.format(TRANSLATE("KEYSTONE.FORMAT"), dungeonName, character.keyLevel)
    end

    return string.format(TRANSLATE("KEYSTONE.FORMAT"), "-", character.keyLevel)
end

function MKA:GetCharacterDisplayNameColored(character)
    local name = (character and character.name) or "Unknown"
    local classToken = character and character.class
---@diagnostic disable-next-line: undefined-global
    local classColors = CUSTOM_CLASS_COLORS or RAID_CLASS_COLORS
    local color = classToken and classColors and classColors[classToken]

    if color and color.colorStr then
        return string.format("|c%s%s|r", color.colorStr, name)
    end

    return name
end

function MKA:IsInGroupForComm()
    return IsInRaid() or IsInGroup()
end

function MKA:GetGroupChannel()
    if IsInRaid() then
        return "RAID"
    end

    if IsInGroup(LE_PARTY_CATEGORY_INSTANCE) then
        return "INSTANCE_CHAT"
    end

    if IsInGroup() then
        return "PARTY"
    end

    return nil
end

local function CharacterHasShareableKeystone(character)
    return character and (
        (character.keyLevel and character.keyLevel > 0) or
        character.keystoneLink or
        character.hasKeystone
    )
end

function MKA:GetCurrentCharacterCommData()
    local current = self.db.global.characters[GetCharacterKey()]
    if not current then
        return nil
    end

    return {
        name = current.name,
        class = current.class,
        realm = current.realm,
        level = current.level,
        keyLevel = current.keyLevel,
        mapID = current.mapID,
        dungeonName = current.dungeonName,
        keystoneLink = current.keystoneLink,
        keyUpgradeTrack = current.keyUpgradeTrack,
        hasKeystone = current.hasKeystone,
        lastKeySeenAt = current.lastKeySeenAt,
        rating = current.rating,
        bestRuns = current.bestRuns,
        showInGroup = self:IsCharacterShownInGroup(current),
        hide = not self:IsCharacterShownInGroup(current),
        roles = current.roles,
        source = "mka",
        updatedAt = current.lastSeen,
    }
end

function MKA:GetSharableCharacters()
    local sharable = {}

    for _, entry in ipairs(self:GetCharacters(true)) do
        local data = entry.data
        if self:IsCharacterShownInGroup(data) then
            sharable[entry.key] = {
                name = data.name,
                class = data.class,
                realm = data.realm,
                level = data.level,
                keyLevel = data.keyLevel,
                mapID = data.mapID,
                dungeonName = data.dungeonName,
                keystoneLink = data.keystoneLink,
                keyUpgradeTrack = data.keyUpgradeTrack,
                hasKeystone = data.hasKeystone,
                lastKeySeenAt = data.lastKeySeenAt,
                rating = data.rating,
                bestRuns = data.bestRuns,
                showInGroup = true,
                hide = false,
                roles = data.roles,
                source = "mka",
                updatedAt = data.lastSeen,
            }
        end
    end

    local currentKey = GetCharacterKey()
    local current = self.db.global.characters[currentKey]
    if self:IsCharacterShownInGroup(current) and CharacterHasShareableKeystone(current) then
        sharable[currentKey] = self:GetCurrentCharacterCommData()
    end

    local sharableCount = 0
    for _ in pairs(sharable) do
        sharableCount = sharableCount + 1
    end

    self:DebugLog(
        "GetSharableCharacters current=%s included=%s showInGroup=%s hasShareableKeystone=%s lastKeySeenAt=%s keyLevel=%s mapID=%s link=%s count=%s",
        tostring(currentKey),
        tostring(sharable[currentKey] ~= nil),
        tostring(current and self:IsCharacterShownInGroup(current)),
        tostring(CharacterHasShareableKeystone(current)),
        tostring(current and current.lastKeySeenAt),
        tostring(current and current.keyLevel),
        tostring(current and current.mapID),
        tostring(current and current.keystoneLink),
        tostring(sharableCount)
    )

    return sharable
end

function MKA:GetWarbandTag()
    return self.db and self.db.profile and self.db.profile.warbandTag or ""
end

function MKA:IsDungeonTeleportAnnouncementEnabled()
    local profile = self.db and self.db.profile
    if not profile then
        return false
    end
    if profile.dungeonTeleportAnnouncement ~= nil then
        return profile.dungeonTeleportAnnouncement == true
    end
    return profile.dungeonTeleportDialog == true
end

---@param enabled boolean
function MKA:SetDungeonTeleportAnnouncementEnabled(enabled)
    if not self.db or not self.db.profile then
        return
    end

    self.db.profile.dungeonTeleportAnnouncement = enabled and true or false

    if self.IsMainWindowShown and self:IsMainWindowShown() then
        self:RefreshMainWindow("SetDungeonTeleportAnnouncementEnabled")
    end
end

function MKA:IsDungeonTeleportPromptEnabled()
    local profile = self.db and self.db.profile
    if not profile then
        return false
    end
    if profile.dungeonTeleportPrompt ~= nil then
        return profile.dungeonTeleportPrompt == true
    end
    return profile.dungeonTeleportDialog == true
end

function MKA:IsDungeonTeleportDialogEnabled()
    return self:IsDungeonTeleportPromptEnabled()
end

function MKA:GetDefaultDungeonTeleportDialogMessage()
    return TRANSLATE("TELEPORT.ANNOUNCEMENT_DEFAULT")
end

local function NormalizeDungeonTeleportDialogCustomMessage(value, defaultMessage)
    local normalizedValue = tostring(value or "")
    if normalizedValue == defaultMessage then
        normalizedValue = ""
    end
    return normalizedValue
end

local function CopyDungeonTeleportTacticalAnnouncements(source)
    if type(source) ~= "table" then
        return {}
    end

    local copy = {}
    for mapID, value in pairs(source) do
        if type(value) == "string" then
            local normalizedValue = addonTable.TruncateUtf8ToByteLimit(value, DUNGEON_TELEPORT_CHAT_MESSAGE_MAX_BYTES)
            if normalizedValue ~= "" then
                copy[tostring(mapID)] = normalizedValue
            end
        end
    end
    return copy
end

function MKA:GetDungeonTeleportCharacterSettings()
    if not self.db then
        return nil
    end

    if type(self.db.char) ~= "table" then
        self.db.char = {}
    end

    local characterSettings = self.db.char
    if characterSettings.dungeonTeleportCustomSettingsMigrated ~= true then
        local profile = self.db.profile
        local legacyMessage = profile and profile.dungeonTeleportDialogMessage
        if type(characterSettings.dungeonTeleportDialogMessage) ~= "string"
            or characterSettings.dungeonTeleportDialogMessage == ""
        then
            characterSettings.dungeonTeleportDialogMessage = NormalizeDungeonTeleportDialogCustomMessage(
                legacyMessage,
                self:GetDefaultDungeonTeleportDialogMessage()
            )
        end

        if type(characterSettings.dungeonTeleportTacticalAnnouncements) ~= "table"
            or next(characterSettings.dungeonTeleportTacticalAnnouncements) == nil
        then
            characterSettings.dungeonTeleportTacticalAnnouncements = CopyDungeonTeleportTacticalAnnouncements(
                profile and profile.dungeonTeleportTacticalAnnouncements
            )
        end

        characterSettings.dungeonTeleportCustomSettingsMigrated = true
    end

    if type(characterSettings.dungeonTeleportTacticalAnnouncements) ~= "table" then
        characterSettings.dungeonTeleportTacticalAnnouncements = {}
    end

    return characterSettings
end

function MKA:GetDungeonTeleportDialogMessage()
    local characterSettings = self:GetDungeonTeleportCharacterSettings()
    local value = characterSettings and characterSettings.dungeonTeleportDialogMessage
    if type(value) == "string" and value ~= "" then
        return value
    end
    return self:GetDefaultDungeonTeleportDialogMessage()
end

---@param value string?
function MKA:SetDungeonTeleportDialogMessage(value)
    local characterSettings = self:GetDungeonTeleportCharacterSettings()
    if not characterSettings then
        return
    end

    local normalizedValue = NormalizeDungeonTeleportDialogCustomMessage(value, self:GetDefaultDungeonTeleportDialogMessage())

    if characterSettings.dungeonTeleportDialogMessage == normalizedValue then
        return
    end

    characterSettings.dungeonTeleportDialogMessage = normalizedValue

    if self.IsMainWindowShown and self:IsMainWindowShown() then
        self:RefreshMainWindow("SetDungeonTeleportDialogMessage")
    end
end

local function GetDungeonTeleportTacticalAnnouncements(characterSettings)
    if type(characterSettings) ~= "table" then
        return nil
    end

    if type(characterSettings.dungeonTeleportTacticalAnnouncements) ~= "table" then
        characterSettings.dungeonTeleportTacticalAnnouncements = {}
    end

    return characterSettings.dungeonTeleportTacticalAnnouncements
end

function MKA:GetDungeonTeleportTacticalAnnouncement(mapID)
    if type(mapID) ~= "number" then
        return ""
    end

    local characterSettings = self:GetDungeonTeleportCharacterSettings()
    local messages = characterSettings and characterSettings.dungeonTeleportTacticalAnnouncements
    if type(messages) ~= "table" then
        return ""
    end

    local value = messages[tostring(mapID)] or messages[mapID]
    if type(value) == "string" then
        return addonTable.TruncateUtf8ToByteLimit(value, DUNGEON_TELEPORT_CHAT_MESSAGE_MAX_BYTES)
    end

    return ""
end

function MKA:SetDungeonTeleportTacticalAnnouncement(mapID, value)
    if type(mapID) ~= "number" then
        return
    end

    local messages = GetDungeonTeleportTacticalAnnouncements(self:GetDungeonTeleportCharacterSettings())
    if not messages then
        return
    end

    local key = tostring(mapID)
    local normalizedValue = addonTable.TruncateUtf8ToByteLimit(value, DUNGEON_TELEPORT_CHAT_MESSAGE_MAX_BYTES)
    local currentValue = messages[key] or messages[mapID] or ""
    if currentValue == normalizedValue then
        return
    end

    messages[mapID] = nil
    if normalizedValue == "" then
        messages[key] = nil
    else
        messages[key] = normalizedValue
    end

    if self.IsMainWindowShown and self:IsMainWindowShown() then
        self:RefreshMainWindow("SetDungeonTeleportTacticalAnnouncement", mapID)
    end
end

function MKA:ResetDungeonTeleportCustomSettings()
    local characterSettings = self:GetDungeonTeleportCharacterSettings()
    if not characterSettings then
        return
    end

    characterSettings.dungeonTeleportDialogMessage = ""
    characterSettings.dungeonTeleportTacticalAnnouncements = {}
    characterSettings.dungeonTeleportCustomSettingsMigrated = true
end

local function FormatDungeonTeleportTacticalAnnouncementMessage(value)
    local message = addonTable.TruncateUtf8ToByteLimit(value, DUNGEON_TELEPORT_CHAT_MESSAGE_MAX_BYTES)
    if type(message) ~= "string" or message:match("%S") == nil then
        return ""
    end

    local prefix = TRANSLATE("TELEPORT.TACTICAL_PREFIX")
    if type(prefix) ~= "string" or prefix == "" or prefix == "TELEPORT.TACTICAL_PREFIX" then
        prefix = "战术通报："
    end

    if message:sub(1, #prefix) == prefix
        or message:match("^%s*战术通报[:：]")
        or message:match("^%s*戰術通報[:：]")
    then
        return message
    end

    return addonTable.TruncateUtf8ToByteLimit(prefix .. message, DUNGEON_TELEPORT_CHAT_MESSAGE_MAX_BYTES)
end

function MKA:BroadcastDungeonTeleportTacticalAnnouncement(mapID, reason)
    local tacticalMessage = FormatDungeonTeleportTacticalAnnouncementMessage(self:GetDungeonTeleportTacticalAnnouncement(mapID))
    if type(tacticalMessage) ~= "string" or tacticalMessage:match("%S") == nil then
        self:DebugLog(
            "BroadcastDungeonTeleportTacticalAnnouncement skipped mapID=%s reason=%s no_message=true",
            tostring(mapID),
            tostring(reason)
        )
        return false
    end

    local channel = self:GetGroupChannel()
    if not channel or type(mapID) ~= "number" then
        self:DebugLog(
            "BroadcastDungeonTeleportTacticalAnnouncement skipped mapID=%s channel=%s reason=%s",
            tostring(mapID),
            tostring(channel),
            type(mapID) ~= "number" and "invalid_map" or "no_channel"
        )
        return false
    end

    local now = GetTime and GetTime() or 0
    local requestKey = tostring(mapID)
    self.recentDungeonTeleportTacticalAnnouncements = self.recentDungeonTeleportTacticalAnnouncements or {}
    local previousAt = self.recentDungeonTeleportTacticalAnnouncements[requestKey]
    if previousAt and (now - previousAt) < DUNGEON_TELEPORT_TACTICAL_DEDUPE_SECONDS then
        self:DebugLog(
            "BroadcastDungeonTeleportTacticalAnnouncement skipped mapID=%s reason=dedupe source=%s delta=%.2f",
            tostring(mapID),
            tostring(reason),
            now - previousAt
        )
        return false
    end

    local dungeonTeleport = addonTable.DungeonTeleport
    if not (dungeonTeleport and dungeonTeleport.SendAnnouncement) then
        self:DebugLog("BroadcastDungeonTeleportTacticalAnnouncement skipped mapID=%s reason=no_sender", tostring(mapID))
        return false
    end

    local sent = dungeonTeleport.SendAnnouncement(self, mapID, self:GetLocalizedDungeonName(mapID), tacticalMessage)
    if sent then
        self.recentDungeonTeleportTacticalAnnouncements[requestKey] = now
    end

    self:DebugLog(
        "BroadcastDungeonTeleportTacticalAnnouncement mapID=%s channel=%s reason=%s sent=%s",
        tostring(mapID),
        tostring(channel),
        tostring(reason),
        tostring(sent == true)
    )
    return sent == true
end

---@param enabled boolean
function MKA:SetDungeonTeleportDialogEnabled(enabled)
    self:SetDungeonTeleportPromptEnabled(enabled)
end

---@param enabled boolean
function MKA:SetDungeonTeleportPromptEnabled(enabled)
    if not self.db or not self.db.profile then
        return
    end

    self.db.profile.dungeonTeleportPrompt = enabled and true or false

    if self.IsMainWindowShown and self:IsMainWindowShown() then
        self:RefreshMainWindow("SetDungeonTeleportPromptEnabled")
    end
end

function MKA:IsMicroMenuButtonEnabled()
    if not self.db or not self.db.profile then
        return true
    end
    return self.db.profile.microMenuButton ~= false
end

---@param enabled boolean
function MKA:SetMicroMenuButtonEnabled(enabled)
    if not self.db or not self.db.profile then
        return
    end

    self.db.profile.microMenuButton = enabled and true or false

    if self.RefreshMicroMenuButtonAnchor then
        self:RefreshMicroMenuButtonAnchor()
    end
    if self.RefreshMicroMenuButtonState then
        self:RefreshMicroMenuButtonState()
    end
    if self.RefreshQueueStatusButtonOffset then
        self:RefreshQueueStatusButtonOffset()
    end

    if self.IsMainWindowShown and self:IsMainWindowShown() then
        self:RefreshMainWindow()
    end
end

function MKA:IsAutoOpenOnCompletedRunEnabled()
    if self.GetAutoOpenOnCompletedRunTab then
        return self:GetAutoOpenOnCompletedRunTab() ~= "off"
    end

    local value = self.db and self.db.profile and self.db.profile.autoOpenOnCompletedRun
    return value == true or (type(value) == "string" and value ~= "" and value ~= "off")
end

---@param enabled boolean
function MKA:SetAutoOpenOnCompletedRunEnabled(enabled)
    if not self.db or not self.db.profile then
        return
    end

    local startupTab = self:GetStartupTab()
    local targetTab = startupTab ~= "options" and startupTab or "group"

    if self.SetAutoOpenOnCompletedRunTab then
        self:SetAutoOpenOnCompletedRunTab(enabled and targetTab or "off")
        return
    end

    self.db.profile.autoOpenOnCompletedRun = enabled and targetTab or "off"

    if self.IsMainWindowShown and self:IsMainWindowShown() then
        self:RefreshMainWindow()
    end
end

local function GetMinimapSettings(owner)
    if not owner or not owner.db or not owner.db.profile then
        return nil
    end

    owner.db.profile.minimap = owner.db.profile.minimap or {
        hide = false,
        angle = DEFAULT_MINIMAP_BUTTON_ANGLE,
    }

    local settings = owner.db.profile.minimap
    if settings.angle == nil then
        settings.angle = tonumber(settings.minimapPos) or DEFAULT_MINIMAP_BUTTON_ANGLE
    end

    return settings
end

local function GetMinimapButtonAngle(owner)
    local settings = GetMinimapSettings(owner)
    local angle = settings and tonumber(settings.angle)
    if not angle then
        angle = DEFAULT_MINIMAP_BUTTON_ANGLE
    end

    return angle % 360
end

local function SaveMinimapButtonAngle(owner, angle)
    local settings = GetMinimapSettings(owner)
    if not settings then
        return
    end

    settings.angle = (angle or DEFAULT_MINIMAP_BUTTON_ANGLE) % 360
end

local function GetMinimapButtonRadius()
    local width = Minimap and Minimap.GetWidth and Minimap:GetWidth() or 140
    local height = Minimap and Minimap.GetHeight and Minimap:GetHeight() or width
    return (math.min(width, height) / 2) + MINIMAP_BUTTON_EDGE_OFFSET
end

local function GetMinimapDragAngle(owner)
    if not Minimap or not Minimap.GetCenter then
        return GetMinimapButtonAngle(owner)
    end

    local centerX, centerY = Minimap:GetCenter()
    local cursorX, cursorY = GetCursorPosition()
    local scale = Minimap:GetEffectiveScale() or 1
    cursorX = cursorX / scale
    cursorY = cursorY / scale

    local dx = cursorX - centerX
    local dy = cursorY - centerY
    if dx == 0 and dy == 0 then
        return GetMinimapButtonAngle(owner)
    end
    if dx == 0 then
        return dy > 0 and 90 or 270
    end

    return math.deg(math.atan2(dy, dx)) % 360
end

local function UpdateMinimapButtonPosition(button)
    if not button or not Minimap then
        return
    end

    local angle = math.rad(GetMinimapButtonAngle(button.owner))
    local radius = GetMinimapButtonRadius()
    button:ClearAllPoints()
    button:SetPoint("CENTER", Minimap, "CENTER", math.cos(angle) * radius, math.sin(angle) * radius)
end

local function UpdateMinimapButtonPositionFromCursor(button)
    if not button then
        return
    end

    SaveMinimapButtonAngle(button.owner, GetMinimapDragAngle(button.owner))
    UpdateMinimapButtonPosition(button)
end

local function SetMinimapButtonPressed(button, pressed)
    if not button then
        return
    end

    local background = button.Background
    local shadow = button.IconShadow
    local icon = button.Icon
    if not background or not shadow or not icon then
        return
    end

    local offsetX = pressed and 1 or 0
    local offsetY = pressed and -1 or 0

    background:ClearAllPoints()
    background:SetPoint("TOPLEFT", button, "TOPLEFT", 5 + offsetX, -5 + offsetY)

    shadow:ClearAllPoints()
    shadow:SetPoint("CENTER", button, "CENTER", 1 + offsetX, -1 + offsetY)

    icon:ClearAllPoints()
    icon:SetPoint("CENTER", button, "CENTER", offsetX, offsetY)

    if pressed then
        button:SetAlpha(0.88)
    else
        button:SetAlpha(1)
    end
end

---@param visible boolean
function MKA:SetMinimapButtonVisible(visible)
    local settings = GetMinimapSettings(self)
    if not settings then
        return
    end

    settings.hide = not visible

    if not self.minimapButton then
        self:SetupMinimapButton()
    end

    if not self.minimapButton then
        return
    end

    if visible and Minimap then
        UpdateMinimapButtonPosition(self.minimapButton)
        self.minimapButton:Show()
    else
        self.minimapButton:Hide()
    end
end

function MKA:BroadcastCurrentCharacter()
    local channel = self:GetGroupChannel()
    if not channel then
        return
    end

    self:UpdateCurrentCharacter("BROADCAST_CURRENT_CHARACTER")

    local payload = {
        sender = GetCharacterDisplayName(),
        warbandTag = self:GetWarbandTag(),
        sentAt = time(),
        characters = self:GetSharableCharacters(),
    }

    if self.GetSharableMeetingStoneListingDraft then
        payload.meetingStoneListing = self:GetSharableMeetingStoneListingDraft()
    end

    if not next(payload.characters) and not payload.meetingStoneListing then
        return
    end

    local serialized = AceSerializer:Serialize(payload)
    self.lastBroadcastAt = GetTime and GetTime() or 0
    self:SendCommMessage(COMM_PREFIX, serialized, channel, nil, "BULK")
end

function MKA:BroadcastCurrentCharacterPatch()
    local channel = self:GetGroupChannel()
    if not channel then
        return
    end

    local characterKey = GetCharacterKey()
    local current = self.db.global.characters[characterKey]
    if not current then
        return
    end

    local payload = {
        messageType = "character_patch",
        sender = GetCharacterDisplayName(),
        warbandTag = self:GetWarbandTag(),
        sentAt = time(),
        characterKey = characterKey,
        hidden = not self:IsCharacterShownInGroup(current),
        character = {
            name = current.name,
            class = current.class,
            realm = current.realm,
            keyLevel = current.keyLevel,
            mapID = current.mapID,
            dungeonName = current.dungeonName,
            keystoneLink = current.keystoneLink,
            hasKeystone = current.hasKeystone,
            lastKeySeenAt = current.lastKeySeenAt,
            rating = current.rating,
            bestRuns = current.bestRuns,
            showInGroup = self:IsCharacterShownInGroup(current),
            hide = not self:IsCharacterShownInGroup(current),
            roles = current.roles,
            source = "mka",
            updatedAt = current.lastSeen,
        },
    }

    local serialized = AceSerializer:Serialize(payload)
    self:SendCommMessage(COMM_PREFIX, serialized, channel, nil, "ALERT")
end

local function CreateMeetingStoneListingRemovalPayload()
    return {
        active = false,
        sentAt = time and time() or nil,
    }
end

function MKA:BroadcastMeetingStoneListingDraft(listingOverride)
    local channel = self:GetGroupChannel()
    if not channel then
        return
    end

    local listing = type(listingOverride) == "table" and listingOverride or nil
    if not listing then
        if not self.GetSharableMeetingStoneListingDraft then
            return
        end
        listing = self:GetSharableMeetingStoneListingDraft()
    end
    if type(listing) ~= "table" then
        return
    end

    local payload = {
        messageType = "meetingstone_listing",
        sender = GetCharacterDisplayName(),
        meetingStoneListing = listing,
    }

    local serialized = AceSerializer:Serialize(payload)
    self:SendCommMessage(COMM_PREFIX, serialized, channel, nil, "ALERT")
end

function MKA:BroadcastMeetingStoneListingRemoval()
    self.meetingStoneListingRemovalBroadcastTicket = (self.meetingStoneListingRemovalBroadcastTicket or 0) + 1
    local ticket = self.meetingStoneListingRemovalBroadcastTicket

    self:BroadcastMeetingStoneListingDraft(CreateMeetingStoneListingRemovalPayload())

    if not (C_Timer and C_Timer.After) then
        return
    end

    C_Timer.After(0.5, function()
        if self.meetingStoneListingRemovalBroadcastTicket == ticket and self.meetingStoneListingRemovalPendingAt then
            self:BroadcastMeetingStoneListingDraft(CreateMeetingStoneListingRemovalPayload())
        end
    end)
    C_Timer.After(1.5, function()
        if self.meetingStoneListingRemovalBroadcastTicket == ticket and self.meetingStoneListingRemovalPendingAt then
            self:BroadcastMeetingStoneListingDraft(CreateMeetingStoneListingRemovalPayload())
        end
    end)
end

function MKA:BroadcastDungeonTeleportDialog(mapID, options)
    options = type(options) == "table" and options or {}
    local announcementEnabled = options.announcement ~= false and self:IsDungeonTeleportAnnouncementEnabled()
    local promptEnabled = options.prompt ~= false and self:IsDungeonTeleportPromptEnabled()
    local teleportState = type(options.state) == "string" and options.state or "start"
    local tacticalMessage = self:GetDungeonTeleportTacticalAnnouncement(mapID)
    local tacticalAnnouncementEnabled = options.tactical ~= false
        and type(tacticalMessage) == "string"
        and tacticalMessage:match("%S") ~= nil
    if not announcementEnabled and not promptEnabled and not tacticalAnnouncementEnabled then
        self:DebugLog("BroadcastDungeonTeleportDialog skipped mapID=%s reason=settings_disabled", tostring(mapID))
        return
    end

    local channel = self:GetGroupChannel()
    if not channel or type(mapID) ~= "number" then
        self:DebugLog(
            "BroadcastDungeonTeleportDialog skipped mapID=%s channel=%s reason=%s",
            tostring(mapID),
            tostring(channel),
            type(mapID) ~= "number" and "invalid_map" or "no_channel"
        )
        return
    end

    local payload = {
        messageType = "teleport_dialog",
        sender = GetCharacterDisplayName(),
        senderClass = select(2, UnitClass("player")),
        mapID = mapID,
        dungeonName = self:GetLocalizedDungeonName(mapID),
        requestedAt = time(),
        teleportState = teleportState,
        castGUID = options.castGUID,
        timeoutSeconds = options.timeoutSeconds,
        tacticalAnnouncementHandled = tacticalAnnouncementEnabled,
    }

    local dungeonTeleport = addonTable.DungeonTeleport
    if dungeonTeleport and dungeonTeleport.SendAnnouncement then
        if announcementEnabled then
            dungeonTeleport.SendAnnouncement(self, mapID, payload.dungeonName, self:GetDungeonTeleportDialogMessage())
        end
        if tacticalAnnouncementEnabled then
            self:BroadcastDungeonTeleportTacticalAnnouncement(mapID, "active_teleport")
        end
    end

    if not promptEnabled then
        self:DebugLog(
            "BroadcastDungeonTeleportDialog chat_only mapID=%s dungeon=%s channel=%s announcement=%s tactical=%s",
            tostring(mapID),
            tostring(payload.dungeonName),
            tostring(channel),
            tostring(announcementEnabled),
            tostring(tacticalAnnouncementEnabled)
        )
        return
    end

    self:DebugLog(
        "BroadcastDungeonTeleportDialog sending mapID=%s dungeon=%s channel=%s announcement=%s tactical=%s prompt=%s state=%s castGUID=%s",
        tostring(mapID),
        tostring(payload.dungeonName),
        tostring(channel),
        tostring(announcementEnabled),
        tostring(tacticalAnnouncementEnabled),
        tostring(promptEnabled),
        tostring(teleportState),
        tostring(options.castGUID)
    )

    local serialized = AceSerializer:Serialize(payload)
    self:SendCommMessage(COMM_PREFIX, serialized, channel, nil, "ALERT")
end

function MKA:BroadcastDungeonTeleportDialogCancel(mapID, options)
    options = type(options) == "table" and options or {}
    local channel = self:GetGroupChannel()
    if not channel or type(mapID) ~= "number" then
        self:DebugLog(
            "BroadcastDungeonTeleportDialogCancel skipped mapID=%s channel=%s reason=%s castGUID=%s",
            tostring(mapID),
            tostring(channel),
            type(mapID) ~= "number" and "invalid_map" or "no_channel",
            tostring(options.castGUID)
        )
        return
    end

    local payload = {
        messageType = "teleport_dialog",
        teleportState = "cancel",
        sender = GetCharacterDisplayName(),
        senderClass = select(2, UnitClass("player")),
        mapID = mapID,
        dungeonName = self:GetLocalizedDungeonName(mapID),
        requestedAt = time(),
        castGUID = options.castGUID,
        cancelReason = options.reason,
    }

    self:DebugLog(
        "BroadcastDungeonTeleportDialogCancel sending mapID=%s dungeon=%s channel=%s reason=%s castGUID=%s",
        tostring(mapID),
        tostring(payload.dungeonName),
        tostring(channel),
        tostring(options.reason),
        tostring(options.castGUID)
    )

    local serialized = AceSerializer:Serialize(payload)
    self:SendCommMessage(COMM_PREFIX, serialized, channel, nil, "ALERT")
end

function MKA:RequestGroupSync()
    local channel = self:GetGroupChannel()
    if not channel then
        return
    end

    local request = AceSerializer:Serialize({
        messageType = "sync_request",
        sender = GetCharacterDisplayName(),
    })

    self:SendCommMessage(COMM_PREFIX, request, channel)

    self:QueueLibKeystoneRequest(LIB_KEYSTONE_REQUEST_DELAY_SECONDS, "group_sync")
    self:QueueLibOpenKeystoneRequest(LIB_OPEN_KEYSTONE_REQUEST_DELAY_SECONDS, "group_sync")
end

function MKA:OnCommReceived(prefix, message, distribution, sender)
    if prefix ~= COMM_PREFIX or not sender or sender == "" then
        return
    end

    local playerName = UnitName("player")
    if sender == playerName or sender == GetCharacterDisplayName() then
        return
    end

    local success, payload = AceSerializer:Deserialize(message)
    if not success or type(payload) ~= "table" then
        return
    end

    local incomingSender = payload.sender or sender

    if payload.messageType == "meetingstone_listing" then
        if self.ReceiveMeetingStoneListingDraft then
            self:ReceiveMeetingStoneListingDraft(incomingSender, payload.meetingStoneListing)
        end
        return
    end

    if payload.messageType == "sync_request" then
        self:DebugLog(
            "OnCommReceived sync_request sender=%s source=%s distribution=%s",
            tostring(sender),
            GetCharacterDataSourceLabel("mka"),
            tostring(distribution)
        )
        self:QueueBroadcastUpdate(SYNC_REQUEST_RESPONSE_DELAY_SECONDS)
        if self.BroadcastMeetingStoneListingDraft then
            self:BroadcastMeetingStoneListingDraft()
        end
        return
    end

    if payload.messageType == "character_patch" then
        local incomingOwnerKey = ResolveGroupOwnerIdentifier(incomingSender)
        local characterKey = payload.characterKey
        local characterData = payload.character
        if type(characterKey) ~= "string" or type(characterData) ~= "table" then
            return
        end

        self:RememberGroupAddonUser(incomingOwnerKey)

        self.groupCharacters = self.groupCharacters or {}
        local ownerKey = FindMatchingOwnerKey(self.groupCharacters, incomingOwnerKey) or incomingOwnerKey
        local removedFallbackOwner = RemoveConflictingFallbackOwners(self.groupCharacters, ownerKey)
        local ownerData = self.groupCharacters[ownerKey] or {
            owner = ownerKey,
            warbandTag = "",
            lastSeen = time(),
            source = "mka",
            characters = {},
        }
        local characters = ownerData.characters or {}
        local changed = removedFallbackOwner

        ownerData.warbandTag = payload.warbandTag or ownerData.warbandTag or ""
        ownerData.lastSeen = time()
        ownerData.source = "mka"
        ownerData.characters = characters

        if payload.hidden then
            self:CancelLibKeystoneFallback(characterKey)

            if characters[characterKey] ~= nil then
                characters[characterKey] = nil
                changed = true
            end
        else
            self:CancelLibKeystoneFallback(characterKey)

            local showInGroup
            if characterData.showInGroup ~= nil then
                showInGroup = characterData.showInGroup == true
            elseif characterData.hide ~= nil then
                showInGroup = characterData.hide ~= true
            else
                showInGroup = true
            end

            local keystoneLink = characterData.keystoneLink
                or BuildFallbackKeystoneLink(characterData.mapID, characterData.keyLevel, characterData.dungeonName)
            local normalizedCharacter = {
                name = characterData.name or characterKey,
                class = characterData.class,
                realm = characterData.realm,
                level = characterData.level,
                keyLevel = characterData.keyLevel,
                mapID = characterData.mapID,
                dungeonName = characterData.dungeonName,
                keystoneLink = keystoneLink,
                keyUpgradeTrack = characterData.keyUpgradeTrack or ResolveKeystoneUpgradeTrack(keystoneLink, characterData.keyLevel),
                hasKeystone = characterData.hasKeystone,
                lastKeySeenAt = characterData.lastKeySeenAt,
                rating = NormalizeMythicPlusRating(characterData.rating or characterData.dungeonScore or characterData.playerRating),
                bestRuns = characterData.bestRuns,
                showInGroup = showInGroup,
                hide = not showInGroup,
                roles = characterData.roles,
                source = "mka",
                updatedAt = characterData.updatedAt or payload.sentAt or time(),
            }

            local existingCharacter = characters[characterKey]
            if not AreCharacterCommDataEqual(existingCharacter, normalizedCharacter)
                or (type(existingCharacter) == "table" and IsFallbackCharacterSource(existingCharacter.source))
            then
                characters[characterKey] = normalizedCharacter
                changed = true
            end
        end

        self.groupCharacters[ownerKey] = ownerData

        self:DebugLog(
            "OnCommReceived character_patch sender=%s source=%s changed=%s owner=%s character=%s",
            tostring(sender),
            GetCharacterDataSourceLabel(ownerData.source),
            tostring(changed),
            tostring(ownerKey),
            tostring(characterKey)
        )

        if changed and self.IsMainWindowShown and self:IsMainWindowShown() then
            self:RefreshMainWindow("OnCommReceived", string.format("sender=%s reasons=character_patch:%s", tostring(sender), tostring(characterKey)))
        end
        return
    end

    if payload.messageType == "teleport_dialog" then
        self:DebugLog(
            "OnCommReceived teleport_dialog sender=%s distribution=%s payloadSender=%s mapID=%s dungeon=%s state=%s castGUID=%s",
            tostring(sender),
            tostring(distribution),
            tostring(payload.sender),
            tostring(payload.mapID),
            tostring(payload.dungeonName),
            tostring(payload.teleportState),
            tostring(payload.castGUID)
        )

        local mapID = tonumber(payload.mapID)
        if not mapID then
            self:DebugLog("OnCommReceived teleport_dialog ignored reason=invalid_map payloadMapID=%s", tostring(payload.mapID))
            return
        end

        local teleportState = type(payload.teleportState) == "string" and payload.teleportState or "start"
        if teleportState == "cancel" then
            if self.HandleIncomingDungeonTeleportDialogCancel then
                self:HandleIncomingDungeonTeleportDialogCancel(
                    payload.sender or sender,
                    mapID,
                    payload.castGUID,
                    payload.cancelReason
                )
            else
                self:DebugLog("OnCommReceived teleport_dialog cancel ignored reason=no_cancel_handler")
            end
            return
        end

        if not self:IsDungeonTeleportPromptEnabled() then
            self:DebugLog("OnCommReceived teleport_dialog ignored reason=prompt_disabled")
            return
        end

        if self.IsDungeonTeleportPromptSuppressed and self:IsDungeonTeleportPromptSuppressed(mapID) then
            self:DebugLog("OnCommReceived teleport_dialog ignored reason=prompt_suppressed mapID=%s", tostring(mapID))
            return
        end

        if self.HandleIncomingDungeonTeleportDialog then
            self:DebugLog(
                "OnCommReceived teleport_dialog forwarding sender=%s mapID=%s dungeon=%s",
                tostring(payload.sender or sender),
                tostring(mapID),
                tostring(payload.dungeonName)
            )
            self:HandleIncomingDungeonTeleportDialog(
                payload.sender or sender,
                mapID,
                payload.dungeonName,
                payload.dialogMessage,
                payload.senderClass,
                payload.tacticalAnnouncementHandled == true,
                payload.castGUID,
                payload.timeoutSeconds
            )
        else
            self:DebugLog("OnCommReceived teleport_dialog ignored reason=no_handler")
        end
        return
    end

    if type(payload.characters) ~= "table" then
        if type(payload.meetingStoneListing) == "table" and self.ReceiveMeetingStoneListingDraft then
            self:ReceiveMeetingStoneListingDraft(incomingSender, payload.meetingStoneListing)
        end
        return
    end

    local incomingOwnerKey = ResolveGroupOwnerIdentifier(incomingSender)
    self:RememberGroupAddonUser(incomingOwnerKey)

    local meetingStoneListingChanged = false
    if type(payload.meetingStoneListing) == "table" and self.ReceiveMeetingStoneListingDraft then
        meetingStoneListingChanged = self:ReceiveMeetingStoneListingDraft(incomingSender, payload.meetingStoneListing) == true
    end

    self.groupCharacters = self.groupCharacters or {}
    local ownerKey = FindMatchingOwnerKey(self.groupCharacters, incomingOwnerKey) or incomingOwnerKey
    local removedFallbackOwner = RemoveConflictingFallbackOwners(self.groupCharacters, ownerKey)
    local existingOwnerData = self.groupCharacters[ownerKey]
    local existingCharacters = existingOwnerData and existingOwnerData.characters or {}
    local incomingCharacters = {}
    local changed = removedFallbackOwner
    local changeReasons = {}
    local incomingWarbandTag = payload.warbandTag or ""

    local function markChanged(reason)
        changed = true
        changeReasons[#changeReasons + 1] = reason
    end

    if removedFallbackOwner then
        markChanged("removed_fallback_owner")
    end

    local incomingCount = 0
    local existingCount = 0

    for _ in pairs(existingCharacters) do
        existingCount = existingCount + 1
    end

    for characterKey, characterData in pairs(payload.characters) do
        if type(characterData) == "table" then
            self:CancelLibKeystoneFallback(characterKey)

            local showInGroup
            if characterData.showInGroup ~= nil then
                showInGroup = characterData.showInGroup == true
            elseif characterData.hide ~= nil then
                showInGroup = characterData.hide ~= true
            else
                showInGroup = true
            end

            local keystoneLink = characterData.keystoneLink
                or BuildFallbackKeystoneLink(characterData.mapID, characterData.keyLevel, characterData.dungeonName)
            incomingCharacters[characterKey] = {
                name = characterData.name or characterKey,
                class = characterData.class,
                realm = characterData.realm,
                level = characterData.level,
                keyLevel = characterData.keyLevel,
                mapID = characterData.mapID,
                dungeonName = characterData.dungeonName,
                keystoneLink = keystoneLink,
                keyUpgradeTrack = characterData.keyUpgradeTrack or ResolveKeystoneUpgradeTrack(keystoneLink, characterData.keyLevel),
                hasKeystone = characterData.hasKeystone,
                lastKeySeenAt = characterData.lastKeySeenAt,
                rating = NormalizeMythicPlusRating(characterData.rating or characterData.dungeonScore or characterData.playerRating),
                bestRuns = characterData.bestRuns,
                showInGroup = showInGroup,
                hide = not showInGroup,
                roles = characterData.roles,
                source = "mka",
                updatedAt = characterData.updatedAt or payload.sentAt or time(),
            }

            local existingCharacter = existingCharacters[characterKey]
            if not AreCharacterCommDataEqual(existingCharacter, incomingCharacters[characterKey])
                or (type(existingCharacter) == "table" and IsFallbackCharacterSource(existingCharacter.source))
            then
                markChanged("character:" .. tostring(characterKey))
            end
        end
    end

    for characterKey, characterData in pairs(existingCharacters) do
        if incomingCharacters[characterKey] == nil and type(characterData) == "table" and IsFallbackCharacterSource(characterData.source) then
            incomingCharacters[characterKey] = characterData
        end
    end

    for _ in pairs(incomingCharacters) do
        incomingCount = incomingCount + 1
    end

    if existingCount ~= incomingCount then
        markChanged(string.format("count:%d->%d", existingCount, incomingCount))
    end

    if existingOwnerData and (existingOwnerData.warbandTag or "") ~= incomingWarbandTag then
        markChanged("warbandTag")
    end

    if not changed and existingOwnerData and not meetingStoneListingChanged then
        existingOwnerData.source = "mka"
        existingOwnerData.lastSeen = time()
        self:DebugLog(
            "OnCommReceived sender=%s source=%s distribution=%s changed=false owner=%s",
            tostring(sender),
            GetCharacterDataSourceLabel(existingOwnerData.source),
            tostring(distribution),
            tostring(ownerKey)
        )
        return
    end

    self.groupCharacters[ownerKey] = {
        owner = ownerKey,
        warbandTag = incomingWarbandTag,
        lastSeen = time(),
        source = "mka",
        characters = incomingCharacters,
    }

    self:DebugLog(
        "OnCommReceived sender=%s source=%s distribution=%s changed=%s owner=%s reasons=%s",
        tostring(sender),
        GetCharacterDataSourceLabel("mka"),
        tostring(distribution),
        tostring(changed),
        tostring(ownerKey),
        (#changeReasons > 0) and table.concat(changeReasons, ", ") or "-"
    )

    if (changed or meetingStoneListingChanged) and self.IsMainWindowShown and self:IsMainWindowShown() then
        self:RefreshMainWindow("OnCommReceived", string.format("sender=%s reasons=%s", tostring(sender), table.concat(changeReasons, ", ")))
    end
end

function MKA:CleanupGroupCharacters()
    if not self.groupCharacters then
        return false
    end

    if not self:IsInGroupForComm() then
        local hadEntries = next(self.groupCharacters) ~= nil
        self.groupAddonUsers = {}
        self.offlineGroupMemberAliases = {}
        if self.pendingLibKeystoneFallbacks then
            for _, pending in pairs(self.pendingLibKeystoneFallbacks) do
                if pending and pending.timer then
                    pending.timer:Cancel()
                end
            end
            self.pendingLibKeystoneFallbacks = {}
        end
        self.groupCharacters = {}
        return hadEntries
    end

    local activeMembers = {}
    local groupPrefix = IsInRaid() and "raid" or "party"
    local memberCount = GetNumGroupMembers()

    if not IsInRaid() and IsInGroup() then
        memberCount = math.max(memberCount - 1, 0)
    end

    for index = 1, memberCount do
        local unit = groupPrefix .. index
        if UnitExists(unit) then
            local fullName = GetUnitName(unit, true)
            if fullName and fullName ~= GetCharacterDisplayName() then
                for alias in pairs(GetPlayerIdentifierAliases(fullName)) do
                    activeMembers[alias] = true
                end
            end
        end
    end
	
    local changed = false
    local offlineMembers = self.offlineGroupMemberAliases or {}

    for ownerKey in pairs(self.groupCharacters) do
        local matched = false
        for alias in pairs(GetPlayerIdentifierAliases(ownerKey)) do
            if activeMembers[alias] then
                matched = true
                break
            end
        end

        if not matched and IdentifierMatchesAliasTable(ownerKey, offlineMembers) then
            matched = true
            self:DebugLog("CleanupGroupCharacters keeping offline owner=%s", tostring(ownerKey))
        end

        if not matched then
            self:DebugLog("CleanupGroupCharacters removing owner=%s", tostring(ownerKey))
            self.groupCharacters[ownerKey] = nil
            changed = true
        end
    end

    if self.groupAddonUsers then
        for ownerKey in pairs(self.groupAddonUsers) do
            local matched = false
            for alias in pairs(GetPlayerIdentifierAliases(ownerKey)) do
                if activeMembers[alias] then
                    matched = true
                    break
                end
	            end
	
	            if not matched and IdentifierMatchesAliasTable(ownerKey, offlineMembers) then
	                matched = true
	            end

	            if not matched then
	                self.groupAddonUsers[ownerKey] = nil
	            end
        end
    end

    return changed
end

function MKA:DumpStoredCharacters()
    local characters = self.db and self.db.global and self.db.global.characters
    if not characters then
        self:Print(ColorChatText(TRANSLATE("DATA.NO_CHARACTERS"), CHAT_COLOR_WARNING))
        return
    end

    local entries = {}
    for characterKey, data in pairs(characters) do
        entries[#entries + 1] = {
            key = characterKey,
            data = data or {},
        }
    end

    table.sort(entries, function(left, right)
        return left.key < right.key
    end)

    self:Print(string.format(
        "%s %s",
        ColorChatText("本地角色缓存：", CHAT_COLOR_HEADER),
        ColorChatText(string.format("%d 条", #entries), #entries > 0 and CHAT_COLOR_SUCCESS or CHAT_COLOR_WARNING)
    ))

    if #entries == 0 then
        return
    end

    for _, entry in ipairs(entries) do
        local data = entry.data
        local roles = self:NormalizeRoles(data)
        local activeRoles = {}

        for _, roleKey in ipairs(self:GetRoleOrder()) do
            if roles[roleKey] then
                activeRoles[#activeRoles + 1] = roleKey
            end
        end

        local roleText = FormatRoleListText(activeRoles)
        local line = string.format(
            "角色=%s | 数据来源=%s | 钥石等级=%s | 评分=%s | 有钥石=%s | 副本ID=%s | 副本=%s | 钥石链接=%s | 最近钥石时间=%s | 队伍列表显示=%s | 职责=%s",
            FormatChatValue(data.name or entry.key, nil, CHAT_COLOR_VALUE),
            FormatChatValue(GetCharacterDataSourceLabel(data.source)),
            FormatChatValue(data.keyLevel),
            FormatChatValue(data.rating),
            FormatChatBool(data.hasKeystone == true),
            FormatChatValue(data.mapID),
            FormatChatValue(data.dungeonName),
            FormatChatValue(data.keystoneLink),
            FormatChatValue(data.lastKeySeenAt),
            FormatChatBool(self:IsCharacterShownInGroup(data)),
            roleText
        )

        self:Print(line)
    end
end

function MKA:DumpGroupCharacters()
    local entries = self:GetGroupCharacters()
    self:Print(string.format(
        "%s %s",
        ColorChatText("当前队伍钥石数据：", CHAT_COLOR_HEADER),
        ColorChatText(string.format("%d 条", #entries), #entries > 0 and CHAT_COLOR_SUCCESS or CHAT_COLOR_WARNING)
    ))

    for _, entry in ipairs(entries) do
        local data = entry.data
        self:Print(string.format(
            "角色=%s | 所属玩家=%s | 数据来源=%s | 钥石等级=%s | 评分=%s | 有钥石=%s | 副本=%s | 钥石链接=%s | 最近钥石时间=%s",
            FormatChatValue(data.name or entry.key, nil, CHAT_COLOR_VALUE),
            FormatChatValue(entry.owner or "?"),
            FormatChatValue(GetCharacterDataSourceLabel(entry.source or data.source)),
            FormatChatValue(data.keyLevel),
            FormatChatValue(data.rating),
            FormatChatBool(data.hasKeystone == true),
            FormatChatValue(data.dungeonName),
            FormatChatValue(data.keystoneLink),
            FormatChatValue(data.lastKeySeenAt)
        ))
    end
end

function MKA:SetupMinimapButton()
    if self.minimapButton or not Minimap then
        return
    end

    local settings = GetMinimapSettings(self)
    if not settings then
        return
    end

    local button = CreateFrame("Button", "MythicKeystoneAssistantMinimapButton", Minimap)
    button.owner = self
    button:SetSize(MINIMAP_BUTTON_SIZE, MINIMAP_BUTTON_SIZE)
    button:SetFrameStrata("MEDIUM")
    button:RegisterForDrag("LeftButton")
    button:RegisterForClicks("LeftButtonUp", "RightButtonUp")
    button:EnableMouse(true)

    local background = button:CreateTexture(nil, "ARTWORK", nil, 1)
    background:SetTexture("Interface\\Minimap\\UI-Minimap-Background")
    background:SetSize(MINIMAP_BUTTON_BACKGROUND_SIZE, MINIMAP_BUTTON_BACKGROUND_SIZE)
    background:SetPoint("TOPLEFT", button, "TOPLEFT", 5, -5)
    button.Background = background

    local iconShadow = button:CreateTexture(nil, "ARTWORK", nil, 2)
    iconShadow:SetTexture(ADDON_MINIMAP_ICON_PATH)
    iconShadow:SetSize(MINIMAP_BUTTON_ICON_SIZE, MINIMAP_BUTTON_ICON_SIZE)
    iconShadow:SetPoint("CENTER", button, "CENTER", 1, -1)
    iconShadow:SetTexCoord(0.08, 0.92, 0.08, 0.92)
    iconShadow:SetVertexColor(0, 0, 0, 0.4)
    button.IconShadow = iconShadow

    local icon = button:CreateTexture(nil, "ARTWORK", nil, 3)
    icon:SetTexture(ADDON_MINIMAP_ICON_PATH)
    icon:SetSize(MINIMAP_BUTTON_ICON_SIZE, MINIMAP_BUTTON_ICON_SIZE)
    icon:SetPoint("CENTER")
    icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
    button.Icon = icon

    local border = button:CreateTexture(nil, "ARTWORK", nil, 4)
    border:SetTexture("Interface\\Minimap\\MiniMap-TrackingBorder")
    border:SetSize(MINIMAP_BUTTON_BORDER_SIZE, MINIMAP_BUTTON_BORDER_SIZE)
    border:SetPoint("TOPLEFT")
    button.Border = border

    local highlight = button:CreateTexture(nil, "HIGHLIGHT")
    highlight:SetTexture("Interface\\Minimap\\UI-Minimap-ZoomButton-Highlight")
    highlight:SetBlendMode("ADD")
    highlight:SetAllPoints(button)
    button.Highlight = highlight

    button:SetScript("OnClick", function(minimapButton, mouseButton)
        if minimapButton.ignoreNextClick then
            minimapButton.ignoreNextClick = false
            return
        end

        if mouseButton == "RightButton" then
            if self.ToggleMainWindowAtRightClickStartupTab then
                self:ToggleMainWindowAtRightClickStartupTab()
            elseif self.OpenMainWindowAtRightClickStartupTab then
                self:OpenMainWindowAtRightClickStartupTab()
            else
                self:OpenOptionsTab()
            end
        elseif mouseButton == "LeftButton" then
            if self.ToggleMainWindowAtStartupTab then
                self:ToggleMainWindowAtStartupTab()
            else
                self:OpenMainWindowAtStartupTab()
            end
        end
    end)

    button:SetScript("OnMouseDown", function(minimapButton, mouseButton)
        if mouseButton == "LeftButton" or mouseButton == "RightButton" then
            SetMinimapButtonPressed(minimapButton, true)
        end
    end)

    button:SetScript("OnMouseUp", function(minimapButton)
        SetMinimapButtonPressed(minimapButton, false)
    end)

    button:SetScript("OnEnter", function(minimapButton)
        GameTooltip:SetOwner(minimapButton, "ANCHOR_LEFT")
        GameTooltip:SetText(TRANSLATE(ADDON_DISPLAY_NAME_KEY), 1, 1, 1)
        local leftTooltip = self.GetStartupClickTooltipLine and self:GetStartupClickTooltipLine("LeftButton") or TRANSLATE("TOOLTIP.OPEN_OVERVIEW")
        local rightTooltip = self.GetStartupClickTooltipLine and self:GetStartupClickTooltipLine("RightButton") or TRANSLATE("TOOLTIP.OPEN_OPTIONS")
        GameTooltip:AddLine(leftTooltip, 1, 0.82, 0, true)
        GameTooltip:AddLine(rightTooltip, 1, 0.82, 0, true)
        GameTooltip:Show()
    end)

    button:SetScript("OnLeave", function(minimapButton)
        SetMinimapButtonPressed(minimapButton, false)
        GameTooltip:Hide()
    end)

    button:SetScript("OnDragStart", function(minimapButton)
        minimapButton.ignoreNextClick = true
        SetMinimapButtonPressed(minimapButton, true)
        minimapButton:SetScript("OnUpdate", UpdateMinimapButtonPositionFromCursor)
    end)

    button:SetScript("OnDragStop", function(minimapButton)
        UpdateMinimapButtonPositionFromCursor(minimapButton)
        minimapButton:SetScript("OnUpdate", nil)
        SetMinimapButtonPressed(minimapButton, false)
        C_Timer.After(0.1, function()
            minimapButton.ignoreNextClick = false
        end)
    end)

    self.minimapButton = button
    UpdateMinimapButtonPosition(button)

    if settings.hide then
        button:Hide()
    else
        button:Show()
    end
end

function MKA:HandleSlashCommand(input)
    local rawCommand = input and strtrim(input) or ""
    local command = rawCommand:lower()

    if command == "config" then
        self:OpenOptionsTab()
        return
    end

    if command == "dump" then
        self:DumpStoredCharacters()
        return
    end

    if command == "groupdump" then
        self:DumpGroupCharacters()
        return
    end

    if command == "accountdebug" then
        self:DebugAccountTypeLookup()
        return
    end

    if command == "accountrefresh" or command:match("^accountrefresh%s+") then
        self:RefreshAccountTypeCache()
        return
    end

    local accountDebugName = command:match("^accountdebug%s+(.+)$")
    if accountDebugName then
        accountDebugName = rawCommand:sub(#rawCommand - #accountDebugName + 1)
        self:DebugAccountTypeLookup(accountDebugName)
        return
    end

    local teleportDialogMapID = command:match("^teleportdialog%s*(%d*)$")
    if teleportDialogMapID then
        if self.ShowDungeonTeleportDialogTest then
            local previewMapID, previewDungeonName = self:ShowDungeonTeleportDialogTest(tonumber(teleportDialogMapID))
            self:Print(string.format(
                "%s 副本=%s，mapID=%s",
                ColorChatText("传送弹窗预览已打开：", CHAT_COLOR_SUCCESS),
                FormatChatValue(previewDungeonName),
                FormatChatValue(previewMapID)
            ))
        else
            self:Print(ColorChatText("传送弹窗预览不可用：地下城传送模块尚未加载。", CHAT_COLOR_ERROR))
        end
        return
    end

    if command == "locale zh" or command == "locale cn" or command == "locale zhcn" or command == "locale simplified" then
        if not self:IsDebugLoggingEnabled() then
            self:Print(ColorChatText(TRANSLATE("LOCALE.OVERRIDE.REQUIRES_DEBUG"), CHAT_COLOR_WARNING))
            return
        end

        self:SetDebugLocaleOverride("zhCN")
        self:Print(ColorChatText(TRANSLATE("LOCALE.OVERRIDE.SAVED_ZHCN"), CHAT_COLOR_SUCCESS))
        return
    end

    if command == "locale tw" or command == "locale zhtw" or command == "locale zh-tw" or command == "locale traditional" then
        if not self:IsDebugLoggingEnabled() then
            self:Print(ColorChatText(TRANSLATE("LOCALE.OVERRIDE.REQUIRES_DEBUG"), CHAT_COLOR_WARNING))
            return
        end

        self:SetDebugLocaleOverride("zhTW")
        self:Print(ColorChatText(TRANSLATE("LOCALE.OVERRIDE.SAVED_ZHTW"), CHAT_COLOR_SUCCESS))
        return
    end

    if command == "locale reset" or command == "locale auto" then
        if not self:IsDebugLoggingEnabled() then
            self:Print(ColorChatText(TRANSLATE("LOCALE.OVERRIDE.REQUIRES_DEBUG"), CHAT_COLOR_WARNING))
            return
        end

        self:SetDebugLocaleOverride(nil)
        self:Print(ColorChatText(TRANSLATE("LOCALE.OVERRIDE.CLEARED"), CHAT_COLOR_SUCCESS))
        return
    end

    if command == "debug" or command == "debug on" then
        self:SetDebugLoggingEnabled(true)
        self:SetDebugVerboseLoggingEnabled(false)
        self:Print(ColorChatText(TRANSLATE("DEBUG.LOGGING_ENABLED"), CHAT_COLOR_SUCCESS))
        return
    end

    if command == "debug verbose" then
        self:SetDebugLoggingEnabled(true)
        self:SetDebugVerboseLoggingEnabled(true)
        self:Print(ColorChatText(TRANSLATE("DEBUG.VERBOSE_LOGGING_ENABLED"), CHAT_COLOR_SUCCESS))
        return
    end

    if command == "debug verbose off" then
        self:SetDebugVerboseLoggingEnabled(false)
        self:Print(ColorChatText(TRANSLATE("DEBUG.VERBOSE_LOGGING_DISABLED"), CHAT_COLOR_WARNING))
        return
    end

    if command == "debug off" then
        self:SetDebugLoggingEnabled(false)
        self:SetDebugVerboseLoggingEnabled(false)
        self:Print(ColorChatText(TRANSLATE("DEBUG.LOGGING_DISABLED"), CHAT_COLOR_WARNING))
        return
    end

    if command == "testdata" or command == "testdata on" then
        self:EnableTestData()
        self:Print(string.format(
            "%s 角色=%s，车队分组=%s，集合石申请测试=%s。",
            ColorChatText("临时测试数据已启用：", CHAT_COLOR_SUCCESS),
            FormatChatValue(CountTableEntries(self.testCharacters) .. " 条"),
            FormatChatValue(CountTableEntries(self.testGroupCharacters) .. " 组"),
            FormatChatBool(_G.MKA_MEETINGSTONE_APPLICANT_TEST_ROWS_ENABLED == true)
        ))
        return
    end

    local testdataCount = command:match("^testdata%s+([123])$")
    if testdataCount then
        self:EnableTestData(tonumber(testdataCount))
        self:Print(string.format(
            "%s 角色=%s，车队分组=%s，集合石申请测试=%s。",
            ColorChatText("临时测试数据已启用：", CHAT_COLOR_SUCCESS),
            FormatChatValue(CountTableEntries(self.testCharacters) .. " 条"),
            FormatChatValue(CountTableEntries(self.testGroupCharacters) .. " 组"),
            FormatChatBool(_G.MKA_MEETINGSTONE_APPLICANT_TEST_ROWS_ENABLED == true)
        ))
        return
    end

    if command == "testdata off" or command == "cleartestdata" then
        self:DisableTestData()
        self:Print(ColorChatText("临时测试数据已移除：角色、车队分组和集合石申请测试均已清空。", CHAT_COLOR_WARNING))
        return
    end

    if command == "help" then
        self:Print(ColorChatText("可用命令：", CHAT_COLOR_HEADER))
        PrintCommandHelpLine(self, "COMMAND.HELP.OPEN_PANEL")
        PrintCommandHelpLine(self, "COMMAND.HELP.CONFIG")
        PrintCommandHelpLine(self, "COMMAND.HELP.TESTDATA_ON")
        PrintCommandHelpLine(self, "COMMAND.HELP.TESTDATA_ONE")
        PrintCommandHelpLine(self, "COMMAND.HELP.TESTDATA_TWO")
        PrintCommandHelpLine(self, "COMMAND.HELP.TESTDATA_THREE")
        PrintCommandHelpLine(self, "COMMAND.HELP.TESTDATA_OFF")
        PrintCommandHelpLine(self, "COMMAND.HELP.DUMP")
        PrintCommandHelpLine(self, "COMMAND.HELP.GROUPDUMP")
        PrintCommandHelpLine(self, "COMMAND.HELP.ACCOUNTDEBUG")
        PrintCommandHelpLine(self, "COMMAND.HELP.ACCOUNTREFRESH")
        PrintCommandHelpLine(self, "COMMAND.HELP.TELEPORT_DIALOG")
        PrintCommandHelpLine(self, "COMMAND.HELP.DEBUG_ON")
        PrintCommandHelpLine(self, "COMMAND.HELP.DEBUG_VERBOSE")
        PrintCommandHelpLine(self, "COMMAND.HELP.DEBUG_VERBOSE_OFF")
        PrintCommandHelpLine(self, "COMMAND.HELP.DEBUG_OFF")
        if self:IsDebugLoggingEnabled() then
            PrintCommandHelpLine(self, "COMMAND.HELP.LOCALE_ZHCN")
            PrintCommandHelpLine(self, "COMMAND.HELP.LOCALE_ZHTW")
            PrintCommandHelpLine(self, "COMMAND.HELP.LOCALE_RESET")
        end
        return
    end

    self:ToggleMainWindow()
end

local function GetAddonMetadata(fieldName, fallback)
    local getter = C_AddOns and C_AddOns.GetAddOnMetadata or GetAddOnMetadata
    if getter then
        local ok, value = pcall(getter, addonFolderName, fieldName)
        if ok and type(value) == "string" and value ~= "" then
            return value
        end
    end

    return fallback
end

local function CreateSystemSettingsInfoBlock(parent, title, value, width, valueHeight)
    valueHeight = valueHeight or 28

    local block = CreateFrame("Frame", nil, parent)
    block:SetSize(width or 560, valueHeight + 24)

    block.Title = block:CreateFontString(nil, "ARTWORK", "GameFontNormalSmall")
    block.Title:SetPoint("TOPLEFT")
    block.Title:SetText(title)
    block.Title:SetTextColor(1, 0.82, 0)

    block.Value = block:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall")
    block.Value:SetPoint("TOPLEFT", block.Title, "BOTTOMLEFT", 0, -5)
    block.Value:SetSize(width or 560, valueHeight)
    block.Value:SetJustifyH("LEFT")
    block.Value:SetJustifyV("TOP")
    if block.Value.SetWordWrap then
        block.Value:SetWordWrap(true)
    end
    if block.Value.SetNonSpaceWrap then
        block.Value:SetNonSpaceWrap(true)
    end
    block.Value:SetText(value)

    return block
end

local function RegisterSystemSettingsCategory(owner, panel)
    if Settings and Settings.RegisterCanvasLayoutCategory and Settings.RegisterAddOnCategory then
        local ok, category = pcall(Settings.RegisterCanvasLayoutCategory, panel, GetLocalizedAddonDisplayName())
        if ok and category then
            category.ID = ADDON_NAME
            local registerOk = pcall(Settings.RegisterAddOnCategory, category)
            if registerOk then
                owner.systemSettingsCategory = category
                return
            end
        end
    end

    if InterfaceOptions_AddCategory then
        pcall(InterfaceOptions_AddCategory, panel)
    end
end

local function CreateSystemSettingsPanel(owner)
    local panel = CreateFrame("Frame", "MythicKeystoneAssistantSettingsPanel")
    local displayName = GetLocalizedAddonDisplayName()
    local addonVersion = GetAddonMetadata("Version", "1.0.1")
    local addonAuthor = GetAddonMetadata("Author", "草东 · 白银之手 · WoWChina")
    local settingsInfoWidth = 560

    panel.name = displayName

    panel.Icon = panel:CreateTexture(nil, "ARTWORK")
    panel.Icon:SetPoint("TOPLEFT", 24, -24)
    panel.Icon:SetSize(46, 46)
    panel.Icon:SetTexture(ADDON_LOGO_PATH)

    panel.Title = panel:CreateFontString(nil, "ARTWORK", "GameFontNormalLarge")
    panel.Title:SetPoint("TOPLEFT", panel.Icon, "TOPRIGHT", 14, -2)
    panel.Title:SetText(displayName)
    panel.Title:SetTextColor(1, 0.82, 0)

    panel.TitleVersion = panel:CreateFontString(nil, "ARTWORK", "GameFontDisableSmall")
    panel.TitleVersion:SetPoint("TOPLEFT", panel.Title, "BOTTOMLEFT", 0, -6)
    panel.TitleVersion:SetText(string.format(TRANSLATE("SETTINGS.VERSION_FORMAT"), addonVersion))
    panel.TitleVersion:SetTextColor(0.72, 0.72, 0.72)

    panel.IntroBlock = CreateSystemSettingsInfoBlock(panel, TRANSLATE("SETTINGS.INTRO_TITLE"), TRANSLATE("SETTINGS.INTRO_DESC"), settingsInfoWidth, 62)
    panel.IntroBlock:SetPoint("TOPLEFT", panel.Icon, "BOTTOMLEFT", 0, -28)

    panel.ShortcutBlock = CreateSystemSettingsInfoBlock(panel, TRANSLATE("SETTINGS.SHORTCUT_TITLE"), TRANSLATE("SETTINGS.SHORTCUT_VALUE"), settingsInfoWidth)
    panel.ShortcutBlock:SetPoint("TOPLEFT", panel.IntroBlock, "BOTTOMLEFT", 0, -20)
    panel.ShortcutBlock.Value:SetTextColor(0.52, 0.76, 1)

    panel.VersionBlock = CreateSystemSettingsInfoBlock(panel, TRANSLATE("SETTINGS.VERSION_TITLE"), string.format(TRANSLATE("SETTINGS.VERSION_FORMAT"), addonVersion), settingsInfoWidth)
    panel.VersionBlock:SetPoint("TOPLEFT", panel.ShortcutBlock, "BOTTOMLEFT", 0, -18)
    panel.VersionBlock.Value:SetTextColor(0.86, 0.86, 0.86)

    panel.AuthorBlock = CreateSystemSettingsInfoBlock(panel, TRANSLATE("SETTINGS.AUTHOR_TITLE"), addonAuthor, settingsInfoWidth)
    panel.AuthorBlock:SetPoint("TOPLEFT", panel.VersionBlock, "BOTTOMLEFT", 0, -18)
    panel.AuthorBlock.Value:SetTextColor(0.78, 0.72, 0.62)

    panel.HomepageBlock = CreateSystemSettingsInfoBlock(panel, TRANSLATE("SETTINGS.AUTHOR_HOMEPAGE_TITLE"), TRANSLATE("SETTINGS.AUTHOR_HOMEPAGE_VALUE"), settingsInfoWidth)
    panel.HomepageBlock:SetPoint("TOPLEFT", panel.AuthorBlock, "BOTTOMLEFT", 0, -18)
    panel.HomepageBlock.Value:SetTextColor(0.52, 0.76, 1)

    panel.OpenButton = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate")
    panel.OpenButton:SetSize(148, 26)
    panel.OpenButton:SetPoint("TOPLEFT", panel.HomepageBlock, "BOTTOMLEFT", 0, -20)
    panel.OpenButton:SetText(TRANSLATE("SETTINGS.OPEN_PANEL"))
    panel.OpenButton:SetScript("OnClick", function()
        if owner.OpenOptionsTab then
            owner:OpenOptionsTab()
        end
    end)

    return panel
end

function MKA:SetupOptions()
    self.optionsFrame = nil
    self.profilesFrame = nil

    if self.systemSettingsPanel then
        return
    end

    self.systemSettingsPanel = CreateSystemSettingsPanel(self)
    RegisterSystemSettingsCategory(self, self.systemSettingsPanel)
end


