local addonFolderName, addonTable = ...

local ADDON_NAME = "MyKeyStone"
local ADDON_DISPLAY_NAME_KEY = "大秘境钥石助手"
local ADDON_SHORT_NAME = "MK"
local SAVED_VARIABLES_NAME = "MyKeyStoneDB"

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 MyKeyStone
local MK = LibStub("AceAddon-3.0"):NewAddon(
    ADDON_NAME,
    "AceConsole-3.0",
    "AceEvent-3.0",
    "AceComm-3.0"
)

addonTable.MyKeyStone = MK
addonTable.MK = MK

local TRANSLATE = addonTable.TRANSLATE or function(msg) return msg end
local MythicSeason = addonTable.MythicSeason or {}
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 MK: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

function addonTable.PlayPanelButtonClickSound()
    if not PlaySound then
        return
    end

    local sound = _G.SOUNDKIT and (_G.SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON or _G.SOUNDKIT.U_CHAT_SCROLL_BUTTON)
    if sound then
        pcall(PlaySound, sound)
    end
end

function MK:PlayPanelButtonClickSound()
    addonTable.PlayPanelButtonClickSound()
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 CountNestedTableEntries(tableValue, nestedKey)
    local count = 0
    if type(tableValue) ~= "table" then
        return count
    end
    for _, entry in pairs(tableValue) do
        if type(entry) == "table" and type(entry[nestedKey]) == "table" then
            count = count + CountTableEntries(entry[nestedKey])
        end
    end
    return count
end

local function FormatMemoryUsageKilobytes(value)
    local numberValue = tonumber(value)
    if not numberValue then
        return FormatChatValue(nil)
    end
    if numberValue >= 1024 then
        return ColorChatText(string.format("%.2f MB", numberValue / 1024), CHAT_COLOR_VALUE)
    end
    return ColorChatText(string.format("%.0f KB", numberValue), CHAT_COLOR_VALUE)
end

local function GetAddonMemoryUsageKilobytes()
    if UpdateAddOnMemoryUsage then
        pcall(UpdateAddOnMemoryUsage)
    end
    if not GetAddOnMemoryUsage then
        return nil
    end

    local ok, memory = pcall(GetAddOnMemoryUsage, addonFolderName)
    if ok and tonumber(memory) then
        return memory
    end

    ok, memory = pcall(GetAddOnMemoryUsage, ADDON_NAME)
    if ok and tonumber(memory) then
        return memory
    end
    return nil
end

local function GetLuaHeapMemoryKilobytes()
    if not collectgarbage then
        return nil
    end
    local ok, memory = pcall(collectgarbage, "count")
    if ok and tonumber(memory) then
        return memory
    end
    return nil
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 function FormatDiagnosticField(label, formattedValue)
    return string.format(
        "%s%s",
        ColorChatText(tostring(label or "") .. "：", CHAT_COLOR_HEADER),
        tostring(formattedValue or FormatChatValue(nil))
    )
end

local function FormatDiagnosticIndex(index)
    return ColorChatText(string.format("#%02d", tonumber(index) or 0), CHAT_COLOR_COMMAND)
end

local function PrintDiagnosticSection(owner, title, countText, count)
    if not owner then
        return
    end

    local countColor = (tonumber(count) or 0) > 0 and CHAT_COLOR_SUCCESS or CHAT_COLOR_WARNING
    owner:Print(string.format(
        "%s %s",
        ColorChatText("[" .. tostring(title or "诊断") .. "]", CHAT_COLOR_HEADER),
        ColorChatText(tostring(countText or ""), countColor)
    ))
end

local COMM_PREFIX = ADDON_SHORT_NAME
local ADDON_ART_ROOT = "Interface\\AddOns\\" .. addonFolderName .. "\\Art\\Logo\\"
local ADDON_ICON_ROOT = "Interface\\AddOns\\" .. addonFolderName .. "\\Art\\Ui\\Icon\\"
local ADDON_PANEL_PORTRAIT_PATH = ADDON_ART_ROOT .. "MyKeyStone.png"
local ADDON_LOGO_PATH = ADDON_ART_ROOT .. "MyKeyStone-logo.png"
local ADDON_MINIMAP_ICON_PATH = ADDON_ART_ROOT .. "MyKeyStone.png"
addonTable.MK_ICON_PATH = addonTable.MK_ICON_PATH or ADDON_ICON_ROOT

local SPEC_ICON_FILE_BY_ID = {
    [62] = "Mage_Arcane_Spell_Holy_MagicalSentry.png",
    [63] = "Mage_Fire_Spell_Fire_FireBolt02.png",
    [64] = "Mage_Frost_Spell_Frost_FrostBolt02.png",
    [65] = "Paladin_Holy_Spell_Holy_HolyBolt.png",
    [66] = "Paladin_Protection_Ability_Paladin_ShieldoftheTemplar.png",
    [70] = "Paladin_Retribution_Spell_Holy_AuraOfLight.png",
    [71] = "Warrior_Arms_Ability_Warrior_SavageBlow.png",
    [72] = "Warrior_Fury_Ability_Warrior_InnerRage.png",
    [73] = "Warrior_Protection_Ability_Warrior_DefensiveStance.png",
    [102] = "Druid_Balance_Spell_Nature_StarFall.png",
    [103] = "Druid_Feral_Ability_Druid_CatForm.png",
    [104] = "Druid_Guardian_Ability_Racial_BearForm.png",
    [105] = "Druid_Restoration_SPELL_NATURE_HEALINGTOUCH.png",
    [250] = "DeathKnight_Blood_Spell_Deathknight_BloodPresence.png",
    [251] = "DeathKnight_Frost_Spell_Deathknight_FrostPresence.png",
    [252] = "DeathKnight_Unholy_Spell_Deathknight_UnholyPresence.png",
    [253] = "Hunter_BeastMastery_Ability_Hunter_BeastTaming.png",
    [254] = "Hunter_Marksmanship_Ability_Hunter_FocusedAim.png",
    [255] = "Hunter_Survival_Ability_Hunter_Camouflage.png",
    [256] = "Priest_Discipline_Spell_Holy_PowerWordShield.png",
    [257] = "Priest_Holy_Spell_Holy_GuardianSpirit.png",
    [258] = "Priest_Shadow_Spell_Shadow_ShadowWordPain.png",
    [259] = "Rogue_Assassination_Ability_Rogue_DeadlyBrew.png",
    [260] = "Rogue_Outlaw_INV_Sword_30.png",
    [261] = "Rogue_Subtlety_Ability_Stealth.png",
    [262] = "Shaman_Elemental_Spell_Nature_Lightning.png",
    [263] = "Shaman_Enhancement_Spell_Nature_LightningShield.png",
    [264] = "Shaman_Restoration_Spell_Nature_MagicImmunity.png",
    [265] = "Warlock_Affliction_Spell_Shadow_DeathCoil.png",
    [266] = "Warlock_Demonology_Spell_Shadow_Metamorphosis.png",
    [267] = "Warlock_Destruction_Spell_Shadow_RainOfFire.png",
    [268] = "Monk_Brewmaster_Spell_Monk_Brewmaster_Spec.png",
    [269] = "Monk_Windwalker_Spell_Monk_WindWalker_Spec.png",
    [270] = "Monk_Mistweaver_Spell_Monk_MistWeaver_Spec.png",
    [577] = "DemonHunter_Havoc_Ability_DemonHunter_SpecDPS.png",
    [581] = "DemonHunter_Vengeance_Ability_DemonHunter_SpecTank.png",
    [1467] = "Evoker_Devastation_ClassIcon_Evoker_Devastation.png",
    [1468] = "Evoker_Preservation_ClassIcon_Evoker_Preservation.png",
    [1473] = "Evoker_Augmentation_ClassIcon_Evoker_Augmentation.png",
    [1480] = "DemonHunter_Devourer_Classicon_DemonHunter_Void.png",
}
addonTable.SPEC_ICON_FILE_BY_ID = addonTable.SPEC_ICON_FILE_BY_ID or SPEC_ICON_FILE_BY_ID

function addonTable.GetSpecIconTexture(specID, classFile)
    local specIDNumber = tonumber(specID)
    local specIconFile = specIDNumber and addonTable.SPEC_ICON_FILE_BY_ID[specIDNumber] or nil
    if specIconFile then
        return addonTable.MK_ICON_PATH .. specIconFile
    end

    if type(classFile) == "string" and classFile ~= "" then
        return addonTable.MK_ICON_PATH .. string.lower(classFile) .. "_flatborder2.tga"
    end

    return nil
end
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 = "NonMK-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 KEYSTONE_INVENTORY_RECHECK_DELAYS = { 0.50, 2.00 }
local MYTHIC_PLUS_DATA_READY_RETRY_DELAYS = { 0.75, 2.00, 5.00 }
local DUNGEON_TELEPORT_CHAT_MESSAGE_MAX_BYTES = 255
local DUNGEON_TELEPORT_TACTICAL_DEDUPE_SECONDS = 60
local KEYSTONE_ANNOUNCEMENT_DEDUPE_SECONDS = 2
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 ROLE_OPTIONS_BY_CLASS = {
    DEATHKNIGHT = { TANK = true, DPS = true },
    DEMONHUNTER = { TANK = true, DPS = true },
    DRUID = { TANK = true, HEAL = true, DPS = true },
    EVOKER = { HEAL = true, DPS = true },
    HUNTER = { DPS = true },
    MAGE = { DPS = true },
    MONK = { TANK = true, HEAL = true, DPS = true },
    PALADIN = { TANK = true, HEAL = true, DPS = true },
    PRIEST = { HEAL = true, DPS = true },
    ROGUE = { DPS = true },
    SHAMAN = { HEAL = true, DPS = true },
    WARLOCK = { DPS = true },
    WARRIOR = { TANK = true, DPS = true },
}

local function IsRoleAvailableForClass(classToken, roleKey)
    if not ROLE_OPTIONS[roleKey] then
        return false
    end

    local availableRoles = classToken and ROLE_OPTIONS_BY_CLASS[classToken]
    if type(availableRoles) ~= "table" then
        return true
    end

    return availableRoles[roleKey] == true
end

local function CopyRoleAvailabilityForClass(classToken)
    local availableRoles = ROLE_OPTIONS_BY_CLASS[classToken]
    local copiedRoles = {}
    for _, roleKey in ipairs(ROLE_ORDER) do
        copiedRoles[roleKey] = type(availableRoles) ~= "table" or availableRoles[roleKey] == true
    end
    return copiedRoles
end

local function NormalizeRoleTableForClass(roles, classToken)
    local normalizedRoles = {}
    if type(roles) == "table" then
        for _, roleKey in ipairs(ROLE_ORDER) do
            if roles[roleKey] == true and IsRoleAvailableForClass(classToken, roleKey) then
                normalizedRoles[roleKey] = true
            end
        end
    end

    if next(normalizedRoles) then
        return normalizedRoles
    end

    if IsRoleAvailableForClass(classToken, "DPS") then
        normalizedRoles.DPS = true
        return normalizedRoles
    end

    for _, roleKey in ipairs(ROLE_ORDER) do
        if IsRoleAvailableForClass(classToken, roleKey) then
            normalizedRoles[roleKey] = true
            return normalizedRoles
        end
    end

    normalizedRoles.DPS = true
    return normalizedRoles
end

local function RoleTablesHaveSameFlags(left, right)
    left = left or {}
    right = right or {}
    for _, roleKey in ipairs(ROLE_ORDER) do
        if (left[roleKey] == true) ~= (right[roleKey] == true) then
            return false
        end
    end
    return true
end

local ARMOR_BY_CLASS = {
    PRIEST = "布甲",
    MAGE = "布甲",
    WARLOCK = "布甲",
    DRUID = "皮甲",
    ROGUE = "皮甲",
    MONK = "皮甲",
    DEMONHUNTER = "皮甲",
    HUNTER = "锁甲",
    SHAMAN = "锁甲",
    EVOKER = "锁甲",
    WARRIOR = "板甲",
    PALADIN = "板甲",
    DEATHKNIGHT = "板甲",
}

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

local DUNGEON_TEXTURE_FALLBACKS = type(MythicSeason.GetFallbackDungeonTextures) == "function"
    and MythicSeason.GetFallbackDungeonTextures()
    or {}
local TEST_DUNGEON_MAP_IDS = type(MythicSeason.GetTestDungeonMapIDs) == "function"
    and MythicSeason.GetTestDungeonMapIDs()
    or {}
local DUNGEON_NAME_FALLBACKS = type(MythicSeason.GetFallbackDungeonNames) == "function"
    and MythicSeason.GetFallbackDungeonNames()
    or {}

local function SafeGetChallengeMapUIInfo(mapID)
    if not (mapID and C_ChallengeMode and type(C_ChallengeMode.GetMapUIInfo) == "function") then
        return nil
    end

    local ok, mapName = pcall(C_ChallengeMode.GetMapUIInfo, mapID)
    if ok and type(mapName) == "string" and mapName ~= "" then
        return mapName
    end

    return nil
end

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 GetCurrentCharacterSpecializationData()
    if not (GetSpecialization and GetSpecializationInfo) then
        return nil, nil, nil
    end

    local specializationIndex = GetSpecialization()
    if not specializationIndex then
        return nil, nil, nil
    end

    local specID, specName, _, specIcon = GetSpecializationInfo(specializationIndex)
    return tonumber(specID), specName, specIcon
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 = SafeGetChallengeMapUIInfo(mapID)

    return mapID, level, mapName or TRANSLATE("未知地下城")
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
        dungeonName = SafeGetChallengeMapUIInfo(normalizedMapID)
    end
    if type(dungeonName) ~= "string" or dungeonName == "" then
        dungeonName = TRANSLATE("未知地下城")
    end

    local affix1, affix2, affix3, affix4 = GetCurrentKeystoneAffixIDs()
    local linkText = string.format(TRANSLATE("钥石：%s (%d)"), 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 function ClampColorComponent(value)
    return math.max(0, math.min(255, math.floor(((tonumber(value) or 0) * 255) + 0.5)))
end

local function GetColorMixinComponents(color, fallbackR, fallbackG, fallbackB, fallbackA)
    if type(color) == "table" then
        if color.GetRGBA then
            local ok, r, g, b, a = pcall(color.GetRGBA, color)
            if ok and r and g and b then
                return r, g, b, a or 1
            end
        end

        if color.GetRGB then
            local ok, r, g, b = pcall(color.GetRGB, color)
            if ok and r and g and b then
                return r, g, b, color.a or 1
            end
        end

        if tonumber(color.r) and tonumber(color.g) and tonumber(color.b) then
            return color.r, color.g, color.b, color.a or 1
        end
    end

    return fallbackR or 1, fallbackG or 1, fallbackB or 1, fallbackA or 1
end

local function GetBlizzardDungeonScoreColor(score)
    if C_ChallengeMode and C_ChallengeMode.GetDungeonScoreRarityColor then
        local ok, color = pcall(C_ChallengeMode.GetDungeonScoreRarityColor, tonumber(score) or 0)
        if ok and color then
            return color
        end
    end

    return HIGHLIGHT_FONT_COLOR
end

local function GetBlizzardSpecificDungeonScoreColor(score)
    if C_ChallengeMode and C_ChallengeMode.GetSpecificDungeonOverallScoreRarityColor then
        local ok, color = pcall(C_ChallengeMode.GetSpecificDungeonOverallScoreRarityColor, tonumber(score) or 0)
        if ok and color then
            return color
        end
    end

    return HIGHLIGHT_FONT_COLOR
end

local function GetColorMixinString(color, fallbackR, fallbackG, fallbackB)
    if type(color) == "table" then
        if color.GenerateHexColor then
            local ok, colorString = pcall(color.GenerateHexColor, color)
            if ok and type(colorString) == "string" and colorString ~= "" then
                return colorString
            end
        end

        if type(color.colorStr) == "string" and color.colorStr ~= "" then
            if #color.colorStr == 6 then
                return "ff" .. color.colorStr
            end
            return color.colorStr
        end
    end

    local r, g, b = GetColorMixinComponents(color, fallbackR or 1, fallbackG or 1, fallbackB or 1, 1)
    return string.format("ff%02x%02x%02x", ClampColorComponent(r), ClampColorComponent(g), ClampColorComponent(b))
end

function MK:GetDungeonScoreColor(score)
    return GetColorMixinComponents(GetBlizzardDungeonScoreColor(score), 1, 1, 1, 1)
end

function MK:GetDungeonScoreColorString(score)
    return GetColorMixinString(GetBlizzardDungeonScoreColor(score), 1, 1, 1)
end

function MK:GetSpecificDungeonScoreColor(score)
    return GetColorMixinComponents(GetBlizzardSpecificDungeonScoreColor(score), 1, 1, 1, 1)
end

function MK:GetSpecificDungeonScoreColorString(score)
    return GetColorMixinString(GetBlizzardSpecificDungeonScoreColor(score), 1, 1, 1)
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 = "mk",
    }
end

function MK: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 ResolveKeystoneDisplayLevelAndTrackFromLink = addonTable.ResolveKeystoneDisplayLevelAndTrackFromLink

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 = "MKCoreKeystoneTrackTooltip"
        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 ResolveKeystoneDisplayLevelAndTrack(keystoneLink, fallbackLevel)
    if type(ResolveKeystoneDisplayLevelAndTrackFromLink) == "function" then
        local displayLevel, displayTrack = ResolveKeystoneDisplayLevelAndTrackFromLink(keystoneLink, fallbackLevel)
        if displayLevel then
            return displayLevel, displayTrack or ResolveKeystoneUpgradeTrack(keystoneLink, displayLevel)
        end
    end

    return fallbackLevel, ResolveKeystoneUpgradeTrack(keystoneLink, fallbackLevel)
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.specID == right.specID
        and left.specName == right.specName
        and left.specIcon == right.specIcon
        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 == "mk" 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 ShouldQueueKeystoneInventoryRechecks(event)
    return event == "BAG_UPDATE_DELAYED"
        or event == "ITEM_CHANGED"
end

local function RequestMythicPlusSystemData()
    local requested = false
    if not C_MythicPlus then
        return requested
    end

    local requestMethods = {
        "RequestCurrentAffixes",
        "RequestMapInfo",
        "RequestRewards",
    }
    for _, methodName in ipairs(requestMethods) do
        local method = C_MythicPlus[methodName]
        if type(method) == "function" then
            pcall(method)
            requested = true
        end
    end

    return requested
end

local function IsMythicPlusMapDataReady()
    if not C_ChallengeMode or type(C_ChallengeMode.GetMapTable) ~= "function" then
        return true
    end

    local ok, mapTable = pcall(C_ChallengeMode.GetMapTable)
    if not ok or type(mapTable) ~= "table" or #mapTable == 0 then
        return false
    end

    if type(C_ChallengeMode.GetMapUIInfo) ~= "function" then
        return true
    end

    for _, mapID in ipairs(mapTable) do
        local infoOK, mapName = pcall(C_ChallengeMode.GetMapUIInfo, mapID)
        if infoOK and type(mapName) == "string" and mapName ~= "" then
            return true
        end
    end

    return false
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 function HasAnnounceableKeystoneChangeReason(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 == "hasKeystone=true"
        then
            return true
        end
    end

    return false
end

local function IsKeystoneAnnouncementEvent(event)
    if IsKeystoneInventoryEvent(event) or IsKeystoneFollowupEvent(event) then
        return true
    end
    return event == "MYTHIC_PLUS_COMPLETION_RECHECK"
end

local MK_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(MK_VERBOSE_DEBUG_PREFIXES) do
        if message:match(pattern) then
            return true
        end
    end
    return false
end

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

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

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

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

function MK: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 MK: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("[调试] %s"), message))
end

function MK:RequestMythicPlusSystemData(reason)
    local requested = RequestMythicPlusSystemData()
    self:DebugLog(
        "RequestMythicPlusSystemData reason=%s requested=%s ready=%s",
        tostring(reason or "unspecified"),
        tostring(requested),
        tostring(IsMythicPlusMapDataReady())
    )
    return requested
end

function MK:QueueMythicPlusDataReadyRefresh(reason)
    self:RequestMythicPlusSystemData(reason)

    if IsMythicPlusMapDataReady() or not C_Timer or not C_Timer.After then
        return
    end

    self.mythicPlusDataReadyTicket = (self.mythicPlusDataReadyTicket or 0) + 1
    local ticket = self.mythicPlusDataReadyTicket
    local retryReason = reason or "MYTHIC_PLUS_DATA_READY"

    for retryIndex, delay in ipairs(MYTHIC_PLUS_DATA_READY_RETRY_DELAYS) do
        C_Timer.After(delay, function()
            if self.mythicPlusDataReadyTicket ~= ticket then
                return
            end

            self:RequestMythicPlusSystemData(retryReason)
            local ready = IsMythicPlusMapDataReady()
            self:DebugLog(
                "Mythic+ data readiness retry index=%s delay=%s ready=%s reason=%s",
                tostring(retryIndex),
                tostring(delay),
                tostring(ready),
                tostring(retryReason)
            )

            self:QueueCharacterUpdate(0.01, "MYTHIC_PLUS_DATA_READY_RETRY")

            if ready then
                self.mythicPlusDataReadyTicket = ticket + 1
                if self.IsMainWindowShown
                    and self:IsMainWindowShown()
                    and (self.activeTab == "characters" or self.activeTab == "dungeons")
                then
                    self:RefreshMainWindow("MYTHIC_PLUS_DATA_READY", tostring(retryReason))
                end
            end
        end)
    end
end

function MK: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 MK: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:RequestMythicPlusSystemData(recheckReason)
            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 MK:QueueKeystoneInventoryRechecks(reason)
    if not C_Timer or not C_Timer.After then
        return
    end

    self.keystoneInventoryRecheckTicket = (self.keystoneInventoryRecheckTicket or 0) + 1
    local ticket = self.keystoneInventoryRecheckTicket
    local recheckReason = reason or "BAG_UPDATE_DELAYED"

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

            self:DebugLog("QueueKeystoneInventoryRechecks running reason=%s delay=%s", tostring(recheckReason), tostring(delay))
            self:QueueCharacterUpdate(0.01, recheckReason)
        end)
    end
end

function MK: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 MK: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 MK: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.SetupMeetingStoneLeaderChangePopupRouting then
        self:SetupMeetingStoneLeaderChangePopupRouting()
    end
    if self.SetupMeetingStoneNativePanelVisibilityRouting then
        self:SetupMeetingStoneNativePanelVisibilityRouting()
    end
    self:RegisterChatCommand("mk", "HandleSlashCommand")
    self:RegisterComm(COMM_PREFIX)
    if addonTable.AccountType and addonTable.AccountType.Initialize then
        addonTable.AccountType.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 MK: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.AccountType and addonTable.AccountType.Start then
        addonTable.AccountType.Start(self)
    end
    if self.RefreshDungeonTeleportPeerSpellcastWatcher then
        self:RefreshDungeonTeleportPeerSpellcastWatcher("OnEnable")
    end
end

function MK:NotifyWindowClosedForCombat()
    self:Print(TRANSLATE("因进入战斗，窗口已关闭。"))
end

function MK:HandleCombatStarted()
    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 MK:HandleCombatEnded()
    local refreshDungeonTabAfterCombat = self.dungeonTabRefreshAfterCombat == true
    self.dungeonTabRefreshAfterCombat = nil

    if not self.reopenMainWindowAfterCombat then
        if refreshDungeonTabAfterCombat
            and self.IsMainWindowShown
            and self:IsMainWindowShown()
            and self.activeTab == "dungeons"
            and self.RefreshMainWindow
        then
            self:RefreshMainWindow("PLAYER_REGEN_ENABLED", "dungeon_secure_refresh")
        end
        return
    end

    self.reopenMainWindowAfterCombat = nil

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

function MK: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 MK:HandleEnteringWorld(_, isInitialLogin, isReloadingUi)
    self:DebugLog(
        "PLAYER_ENTERING_WORLD initial=%s reload=%s",
        tostring(isInitialLogin),
        tostring(isReloadingUi)
    )
    if isInitialLogin or isReloadingUi then
        self:QueueMythicPlusDataReadyRefresh("PLAYER_ENTERING_WORLD")
    else
        self:RequestMythicPlusSystemData("PLAYER_ENTERING_WORLD")
    end

    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 MK:HandleCharacterUpdate(event)
    self:DebugLog("HandleCharacterUpdate event=%s", tostring(event))
    if event == "CHALLENGE_MODE_MAPS_UPDATE" then
        -- RequestMapInfo can emit this event; consume it here instead of requesting again.
        if IsMythicPlusMapDataReady() then
            self.mythicPlusDataReadyTicket = (self.mythicPlusDataReadyTicket or 0) + 1
            if self.IsMainWindowShown and self:IsMainWindowShown() and self.activeTab == "dungeons" then
                self:RefreshMainWindow("CHALLENGE_MODE_MAPS_UPDATE", "map_data_ready")
            end
        end
    end
    self:QueueCharacterUpdate(CHARACTER_UPDATE_DEBOUNCE_SECONDS, event)
    if ShouldQueueKeystoneInventoryRechecks(event) then
        self:QueueKeystoneInventoryRechecks(event)
    end
    if IsKeystoneFollowupEvent(event) then
        self:QueueKeystoneCompletionRechecks(tostring(event or "keystone_followup"):lower())
    end
end

function MK: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 MK:HandleGroupRolesAssigned(event)
    self:DebugLog("Group role refresh event=%s", tostring(event))

    local lfgRolesChanged = false
    if self.SyncCurrentCharacterLFGRoles then
        lfgRolesChanged = self:SyncCurrentCharacterLFGRoles(event or "LFG_ROLE_UPDATE", true) == true
    end

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

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

function MK: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 MK: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 MK: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 MK: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 MK: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 MK: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 MK: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 MK: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 MK:QueueGroupDataRefresh(reason, details, delay)
    if not (self.IsMainWindowShown and self:IsMainWindowShown() and self.RefreshMainWindow) then
        return
    end

    self.pendingGroupDataRefreshReason = reason or self.pendingGroupDataRefreshReason or "GroupData"
    self.pendingGroupDataRefreshDetails = details or self.pendingGroupDataRefreshDetails
    if self.groupDataRefreshTimer then
        return
    end

    local function runRefresh()
        self.groupDataRefreshTimer = nil
        if not (self.IsMainWindowShown and self:IsMainWindowShown() and self.RefreshMainWindow) then
            return
        end
        local refreshReason = self.pendingGroupDataRefreshReason or "GroupData"
        local refreshDetails = self.pendingGroupDataRefreshDetails
        self.pendingGroupDataRefreshReason = nil
        self.pendingGroupDataRefreshDetails = nil
        self:RefreshMainWindow(refreshReason, refreshDetails)
    end

    if C_Timer and C_Timer.NewTimer then
        self.groupDataRefreshTimer = C_Timer.NewTimer(delay or 0.15, runRefresh)
    else
        runRefresh()
    end
end

function MK: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)
    local groupedMember = GetGroupMemberInfoByIdentifier(pending.ownerKey)
        or GetGroupMemberInfoByIdentifier(pending.playerName)
        or GetGroupMemberInfoByIdentifier(pending.characterKey)
        or GetGroupMemberInfoByIdentifier(pending.characterData.name)

    if not (groupedMember and groupedMember.fullName) then
        self:DebugLog(
            "ApplyLibKeystoneFallback ignored player=%s owner=%s source=%s reason=not_in_group",
            tostring(pending.playerName),
            tostring(pending.ownerKey),
            fallbackSourceLabel
        )
        return
    end

    pending.ownerKey = groupedMember.fullName
    pending.characterKey = groupedMember.fullName
    pending.characterData.name = groupedMember.fullName
    pending.characterData.realm = groupedMember.realm or pending.characterData.realm
    pending.characterData.class = groupedMember.class or pending.characterData.class

    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)
        )

        self:QueueGroupDataRefresh(
            "ApplyLibKeystoneFallback",
            string.format("fallback:%s mapID=%s level=%s", tostring(ownerKey), tostring(pending.mapID), tostring(pending.keyLevel))
        )
        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)
    )

    self:QueueGroupDataRefresh(
        "ApplyLibKeystoneFallback",
        string.format("player=%s mapID=%s level=%s", tostring(ownerKey), tostring(pending.mapID), tostring(pending.keyLevel))
    )
end

function MK: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 MK: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 MK:CancelLibKeystoneFallbacksForIdentifier(identifier)
    if type(identifier) ~= "string" or identifier == "" or not self.pendingLibKeystoneFallbacks then
        return
    end

    local identifierAliases = GetPlayerIdentifierAliases(identifier)
    for characterKey, pending in pairs(self.pendingLibKeystoneFallbacks) do
        if IdentifierMatchesAliasTable(characterKey, identifierAliases)
            or IdentifierMatchesAliasTable(pending and pending.ownerKey, identifierAliases)
            or IdentifierMatchesAliasTable(pending and pending.playerName, identifierAliases)
            or IdentifierMatchesAliasTable(pending and pending.characterData and pending.characterData.name, identifierAliases)
        then
            if pending and pending.timer then
                pending.timer:Cancel()
            end
            self.pendingLibKeystoneFallbacks[characterKey] = nil
        end
    end
end

function MK: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 MK: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 MK: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)
            if self.BroadcastKeystoneAnnouncement then
                self:BroadcastKeystoneAnnouncement(event, changeReasons)
            end
        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 MK: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 displayKeyLevel, keyUpgradeTrack = ResolveKeystoneDisplayLevelAndTrack(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 specID, specName, specIcon = GetCurrentCharacterSpecializationData()
    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 rolesManuallyConfigured = existing.rolesManuallyConfigured == true or nil
    if self.GetCurrentLFGRoleSelection then
        local lfgRoles, hasLFGRoles = self:GetCurrentLFGRoleSelection()
        if hasLFGRoles then
            roles = {}
            for _, roleKey in ipairs(ROLE_ORDER) do
                if lfgRoles and lfgRoles[roleKey] then
                    roles[roleKey] = true
                end
            end
            rolesManuallyConfigured = true
        end
    end
    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 rolesManuallyConfigured and existing.role ~= nil then
        existing.role = nil
        markChanged("role=nil")
    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 specID and existing.specID ~= specID then
        existing.specID = specID
        markChanged("specID")
    end

    if type(specName) == "string" and specName ~= "" and existing.specName ~= specName then
        existing.specName = specName
        markChanged("specName")
    end

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

    if hasConfirmedKeystone then
        if displayKeyLevel ~= nil and existing.keyLevel ~= displayKeyLevel then
            existing.keyLevel = displayKeyLevel
            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 not AreRoleTablesEqual(existing.roles, roles) then
        existing.roles = roles
        markChanged("roles")
    end

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

    if existing.source ~= "mk" then
        existing.source = "mk"
        markChanged("source=mk")
    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 MK: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 MK: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 MK:BuildTestCharacter(name, realm, classToken, mapID, keyLevel, rating, roleList, specID, specName)
    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,
        specID = specID,
        specName = specName,
        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 = "晨星领航",
        warbandTag = "晨星战队",
        characters = {
            { name = "晨星浩劫", class = "DEMONHUNTER", mapID = 239, keyLevel = 14, rating = 2520, roles = { "DPS" } },
            { name = "晨星平衡", class = "DRUID", mapID = 556, keyLevel = 10, rating = 2010, roles = { "DPS", "HEAL" } },
        },
    },
    {
        owner = "周末影刃",
        warbandTag = "周末冲层",
        characters = {
            { name = "周末影刃", class = "ROGUE", mapID = 161, keyLevel = 19, rating = 2875, roles = { "DPS" } },
            { name = "周末酒仙", class = "MONK", mapID = 559, keyLevel = 12, rating = 2240, roles = { "TANK", "HEAL", "DPS" } },
        },
    },
    {
        owner = "夜航队长",
        warbandTag = "夜航小队",
        characters = {
            { name = "夜航猎手", class = "HUNTER", mapID = 239, keyLevel = 17, rating = 2680, roles = { "DPS" } },
            { name = "夜航萨满", class = "SHAMAN", mapID = 239, keyLevel = 19, rating = 2910, roles = { "HEAL", "DPS" } },
            { name = "夜航圣骑", class = "PALADIN", mapID = 239, keyLevel = 22, rating = 3235, roles = { "TANK", "HEAL" } },
            { name = "夜航唤魔", class = "EVOKER", mapID = 402, keyLevel = 8, rating = 1760, roles = { "DPS" } },
        },
    },
    {
        owner = "深夜坦克",
        warbandTag = "深夜补位",
        characters = {
            { name = "深夜血坦", class = "DEATHKNIGHT", mapID = 402, keyLevel = 14, rating = 2410, roles = { "TANK", "DPS" } },
            { name = "深夜牧师", class = "PRIEST", mapID = 402, keyLevel = 14, rating = 2395, roles = { "HEAL", "DPS" } },
            { name = "深夜术士", class = "WARLOCK", mapID = 402, keyLevel = 14, rating = 2360, roles = { "DPS" } },
        },
    },
}

local TEST_GROUP_MEMBER_TEMPLATES = {
    {
        name = "队伍坦克",
        class = "WARRIOR",
        mapID = 239,
        keyLevel = 18,
        rating = 2865,
        roleKey = "TANK",
        roles = { "TANK" },
        specID = 73,
        specName = "防护",
        warbandTag = "队伍测试",
    },
    {
        name = "队伍治疗",
        class = "PRIEST",
        mapID = 556,
        keyLevel = 16,
        rating = 2580,
        roleKey = "HEAL",
        roles = { "HEAL", "DPS" },
        specID = 257,
        specName = "神圣",
        warbandTag = "队伍测试",
    },
    {
        name = "队伍近战",
        class = "ROGUE",
        mapID = 161,
        keyLevel = 20,
        rating = 3120,
        roleKey = "DPS",
        roles = { "DPS" },
        specID = 260,
        specName = "狂徒",
        warbandTag = "队伍测试",
    },
    {
        name = "队伍远程",
        class = "SHAMAN",
        mapID = 402,
        keyLevel = 14,
        rating = 2315,
        roleKey = "DPS",
        roles = { "HEAL", "DPS" },
        specID = 262,
        specName = "元素",
        warbandTag = "队伍测试",
    },
    {
        name = "队伍离线",
        class = "MAGE",
        mapID = 557,
        keyLevel = 15,
        rating = 2260,
        roleKey = "DPS",
        roles = { "DPS" },
        specID = 64,
        specName = "冰霜",
        warbandTag = "队伍测试",
        connected = false,
    },
}

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 AddTestGroupMemberFromTemplate(self, targetTable, rosterMembers, realm, memberTemplate, reservedNames)
    local uniqueCharacterName = BuildUniqueTestCharacterName(memberTemplate.name, reservedNames or {})
    local characterKey = string.format("%s-%s", uniqueCharacterName, realm)
    local character = self:BuildTestCharacter(
        uniqueCharacterName,
        realm,
        memberTemplate.class,
        memberTemplate.mapID,
        memberTemplate.keyLevel,
        memberTemplate.rating,
        memberTemplate.roles,
        memberTemplate.specID,
        memberTemplate.specName
    )
    character.source = "test"

    targetTable[characterKey] = {
        owner = characterKey,
        source = "test",
        warbandTag = memberTemplate.warbandTag or "队伍测试",
        lastSeen = time(),
        characters = {
            [characterKey] = character,
        },
    }

    rosterMembers[#rosterMembers + 1] = {
        fullName = characterKey,
        name = uniqueCharacterName,
        realm = realm,
        class = memberTemplate.class,
        connected = memberTemplate.connected ~= false,
        roleKey = memberTemplate.roleKey or (memberTemplate.roles and memberTemplate.roles[1]) or "DPS",
        rosterIndex = #rosterMembers + 1,
        isTest = true,
    }
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 MK:EnableTestData(requestedAdditionalGroups)
    local realm = GetRealmName() or "Testrealm"
    local reservedNames = CollectReservedTestCharacterNames(self, self.testGroupCharacters)
    _G.MK_MEETINGSTONE_APPLICANT_TEST_ROWS_ENABLED = true
    self.mkMeetingStoneTestApplicant = nil
    self.mkMeetingStoneTestApplicants = nil

    self.testCharacters = {
        [string.format("钥石坦克-%s", realm)] = self:BuildTestCharacter("钥石坦克", realm, "WARRIOR", TEST_DUNGEON_MAP_IDS[1], 12, 2140, { "TANK" }),
        [string.format("钥石治疗-%s", realm)] = self:BuildTestCharacter("钥石治疗", realm, "PRIEST", TEST_DUNGEON_MAP_IDS[2], 15, 2465, { "HEAL" }),
        [string.format("钥石输出-%s", realm)] = self:BuildTestCharacter("钥石输出", realm, "MAGE", TEST_DUNGEON_MAP_IDS[3], 18, 2890, { "DPS" }),
    }

    self.testGroupCharacters = {}
    self.testGroupRosterMembers = {}
    self.testCarpoolGroupCount = 0

    for _, memberTemplate in ipairs(TEST_GROUP_MEMBER_TEMPLATES) do
        AddTestGroupMemberFromTemplate(self, self.testGroupCharacters, self.testGroupRosterMembers, realm, memberTemplate, reservedNames)
    end

    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)
            self.testCarpoolGroupCount = self.testCarpoolGroupCount + 1
        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
                self.testCarpoolGroupCount = self.testCarpoolGroupCount + 1
            end
        end
    end

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

function MK:DisableTestData()
    self.testCharacters = nil
    self.testGroupCharacters = nil
    self.testGroupRosterMembers = nil
    self.testCarpoolGroupCount = nil
    _G.MK_MEETINGSTONE_APPLICANT_TEST_ROWS_ENABLED = false
    self.mkMeetingStoneTestApplicant = nil
    self.mkMeetingStoneTestApplicants = nil

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

function MK:GetRoleOrder()
    return ROLE_ORDER
end

function MK: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 MK:IsCharacterHidden(character)
    return not self:IsCharacterShownInGroup(character)
end

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

    return TRANSLATE(armorType)
end

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

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

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

    local normalizedRoles = NormalizeRoleTableForClass(cachedRoles, character.class)
    if not RoleTablesHaveSameFlags(character.roles, normalizedRoles) or character.role ~= nil then
        character.roles = normalizedRoles
        character.role = nil
    end

    return character.roles
end

function MK:GetCharacterRoleAvailability(character)
    return CopyRoleAvailabilityForClass(character and character.class)
end

function MK:IsCharacterRoleAvailable(character, roleKey)
    return IsRoleAvailableForClass(character and character.class, roleKey)
end

function MK:GetCharacterDataByKey(characterKey)
    if type(characterKey) ~= "string" or characterKey == "" then
        return nil
    end

    local characters = self.db and self.db.global and self.db.global.characters
    if characters and characters[characterKey] then
        return characters[characterKey], "saved"
    end

    local testCharacters = self.testCharacters
    if testCharacters and testCharacters[characterKey] then
        return testCharacters[characterKey], "test"
    end

    return nil
end

function MK:UpdateCharacterSetting(characterKey, field, value)
    local character, characterSource = self:GetCharacterDataByKey(characterKey)
    if not character then
        return false
    end

    if character[field] == value then
        return false, character
    end

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

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

    if not shouldSkipImmediateRefresh then
        self:RefreshMainWindow()
    end

    return true, character
end

function MK:UpdateCharacterRole(characterKey, roleKey, value)
    local character, characterSource = self:GetCharacterDataByKey(characterKey)
    if not character or not ROLE_OPTIONS[roleKey] then
        return false
    end
    if value and not self:IsCharacterRoleAvailable(character, roleKey) then
        return false, self:NormalizeRoles(character)
    end

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

    roles[roleKey] = newValue

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

    character.roles = roles
    character.rolesManuallyConfigured = true
    if characterSource ~= "test" then
        self:QueueCharacterPatchBroadcast(CHARACTER_PATCH_DELAY_SECONDS)
        self:QueueBroadcastUpdate(FULL_BROADCAST_FALLBACK_DELAY_SECONDS)
    end

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

    if not shouldSkipImmediateRefresh then
        self:RefreshMainWindow()
    end

    return true, roles
end
function MK:GetLocalizedDungeonName(mapID, fallbackName)
    local localizedName = SafeGetChallengeMapUIInfo(mapID)
    if localizedName then
        return localizedName
    end

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

    local fallback = DUNGEON_NAME_FALLBACKS[tonumber(mapID or 0)]
    if fallback then
        return fallback
    end

    return TRANSLATE("未知地下城")
end

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

function MK: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
        pcall(EncounterJournal_LoadUI)
    elseif C_AddOns and C_AddOns.LoadAddOn then
        pcall(C_AddOns.LoadAddOn, "Blizzard_EncounterJournal")
    elseif LoadAddOn then
        pcall(LoadAddOn, "Blizzard_EncounterJournal")
    end

    return EJ_GetInstanceForMap and EJ_GetInstanceInfo
end

function MK:OpenGreatVaultFrame()
    if InCombatLockdown and InCombatLockdown() then
        self:Print(TRANSLATE("战斗中无法打开系统面板。"))
        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 MK:BuildDungeonJournalTextureMap()
    self.dungeonJournalTextureByName = {}

    if not self:EnsureEncounterJournalLoaded() then
        return
    end

    local tierCount = EJ_GetNumTiers and EJ_GetNumTiers() or 0

    for tierIndex = 1, tierCount do
        if EJ_SelectTier then
            pcall(EJ_SelectTier, tierIndex)
        end

        local instanceIndex = 1
        while true do
            if type(EJ_GetInstanceByIndex) ~= "function" then
                break
            end
            local ok, instanceID, instanceName, description, bgImage, buttonImage1, loreImage, buttonImage2 =
                pcall(EJ_GetInstanceByIndex, instanceIndex, false)
            if not ok then
                break
            end
            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 MK: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 MK:GetCharacterKeystoneText(character)
    if not character or not character.hasKeystone or not character.keyLevel then
        return TRANSLATE("无钥石")
    end

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

    if dungeonName and dungeonName ~= "" then
        return string.format(TRANSLATE("钥石：%s (%d)"), dungeonName, character.keyLevel)
    end

    return string.format(TRANSLATE("钥石：%s (%d)"), "-", character.keyLevel)
end

local function GetFullCharacterDisplayName(character)
    local name = (character and character.name) or "Unknown"
    if type(name) ~= "string" or name == "" then
        name = "Unknown"
    end
    if name:find("-", 1, true) then
        return name
    end

    local realm = character and character.realm
    if type(realm) == "string" and realm ~= "" then
        return string.format("%s-%s", name, realm)
    end

    return name
end

function MK:GetCharacterDisplayNameColored(character)
    local name = GetFullCharacterDisplayName(character)
    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 MK:IsInGroupForComm()
    return IsInRaid() or IsInGroup()
end

function MK: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 MK: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,
        specID = current.specID,
        specName = current.specName,
        specIcon = current.specIcon,
        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 = "mk",
        updatedAt = current.lastSeen,
    }
end

function MK: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,
                specID = data.specID,
                specName = data.specName,
                specIcon = data.specIcon,
                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 = "mk",
                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 MK:GetWarbandTag()
    return self.db and self.db.profile and self.db.profile.warbandTag or ""
end

function MK: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 false
end

---@param enabled boolean
function MK: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 MK:IsKeystoneAnnouncementEnabled()
    local profile = self.db and self.db.profile
    if not profile then
        return false
    end
    return profile.keystoneAnnouncement == true
end

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

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

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

function MK:BroadcastKeystoneAnnouncement(event, changeReasons)
    if not (self.IsKeystoneAnnouncementEnabled and self:IsKeystoneAnnouncementEnabled()) then
        return false
    end
    if not IsKeystoneAnnouncementEvent(event) or not HasAnnounceableKeystoneChangeReason(changeReasons) then
        return false
    end

    local channel = self:GetGroupChannel()
    if not channel then
        self:DebugLog("BroadcastKeystoneAnnouncement skipped event=%s reason=no_channel", tostring(event))
        return false
    end

    local current = self.GetCurrentCharacterCommData and self:GetCurrentCharacterCommData() or nil
    local keystoneLink = current and current.keystoneLink
    if not (current and current.hasKeystone == true and type(keystoneLink) == "string" and keystoneLink ~= "") then
        self:DebugLog("BroadcastKeystoneAnnouncement skipped event=%s reason=no_keystone_link", tostring(event))
        return false
    end

    local now = GetTime and GetTime() or 0
    if self.lastKeystoneAnnouncementLink == keystoneLink
        and self.lastKeystoneAnnouncementAt
        and (now - self.lastKeystoneAnnouncementAt) < KEYSTONE_ANNOUNCEMENT_DEDUPE_SECONDS
    then
        self:DebugLog("BroadcastKeystoneAnnouncement skipped event=%s reason=dedupe", tostring(event))
        return false
    end

    local messageTemplate = TRANSLATE("钥石已更新：%s")
    local ok, message = pcall(string.format, messageTemplate, keystoneLink)
    if not ok or type(message) ~= "string" then
        message = "钥石已更新：" .. tostring(keystoneLink)
    end
    message = addonTable.TruncateUtf8ToByteLimit(message, DUNGEON_TELEPORT_CHAT_MESSAGE_MAX_BYTES)
    if type(message) ~= "string" or message == "" then
        return false
    end

    local sendChatMessage = SendChatMessage or (C_ChatInfo and C_ChatInfo.SendChatMessage)
    if not sendChatMessage then
        self:DebugLog("BroadcastKeystoneAnnouncement skipped event=%s reason=no_sender", tostring(event))
        return false
    end

    local sent = pcall(sendChatMessage, message, channel)
    if sent then
        self.lastKeystoneAnnouncementLink = keystoneLink
        self.lastKeystoneAnnouncementAt = now
    end
    self:DebugLog(
        "BroadcastKeystoneAnnouncement event=%s channel=%s sent=%s",
        tostring(event),
        tostring(channel),
        tostring(sent == true)
    )
    return sent == true
end

function MK:CanSendGroupKeystoneInfo(characterEntry)
    local character = characterEntry and characterEntry.data or characterEntry
    local keystoneLink = character and character.keystoneLink
    if type(keystoneLink) ~= "string" or keystoneLink == "" then
        return false
    end
    if not (self.GetGroupChannel and self:GetGroupChannel()) then
        return false
    end

    local sendChatMessage = SendChatMessage or (C_ChatInfo and C_ChatInfo.SendChatMessage)
    return sendChatMessage ~= nil
end

function MK:SendGroupKeystoneInfo(characterEntry, isCurrentPlayerEntry)
    local character = characterEntry and characterEntry.data or characterEntry
    local keystoneLink = character and character.keystoneLink
    if type(keystoneLink) ~= "string" or keystoneLink == "" then
        self:DebugLog("SendGroupKeystoneInfo skipped reason=no_keystone_link")
        return false
    end

    local channel = self:GetGroupChannel()
    if not channel then
        self:DebugLog("SendGroupKeystoneInfo skipped reason=no_channel")
        return false
    end

    local message
    if isCurrentPlayerEntry then
        local ok
        ok, message = pcall(string.format, TRANSLATE("我的钥石信息：%s"), keystoneLink)
        if not ok or type(message) ~= "string" then
            message = "我的钥石信息：" .. tostring(keystoneLink)
        end
    else
        local displayName = GetFullCharacterDisplayName(character)
        local ok
        ok, message = pcall(string.format, TRANSLATE("%s的钥石信息：%s"), displayName, keystoneLink)
        if not ok or type(message) ~= "string" then
            message = tostring(displayName) .. "的钥石信息：" .. tostring(keystoneLink)
        end
    end

    message = addonTable.TruncateUtf8ToByteLimit(message, DUNGEON_TELEPORT_CHAT_MESSAGE_MAX_BYTES)
    if type(message) ~= "string" or message == "" then
        return false
    end

    local sendChatMessage = SendChatMessage or (C_ChatInfo and C_ChatInfo.SendChatMessage)
    if not sendChatMessage then
        self:DebugLog("SendGroupKeystoneInfo skipped reason=no_sender")
        return false
    end

    local sent = pcall(sendChatMessage, message, channel)
    self:DebugLog(
        "SendGroupKeystoneInfo channel=%s current=%s sent=%s",
        tostring(channel),
        tostring(isCurrentPlayerEntry == true),
        tostring(sent == true)
    )
    return sent == true
end

function MK:GetDefaultDungeonTeleportDialogMessage()
    return TRANSLATE("{player} 正在使用传送[{spell}]，即将前往[{dungeon}]！")
end

local function NormalizeDungeonTeleportDialogCustomMessage(value, defaultMessage)
    local normalizedValue = addonTable.TruncateUtf8ToByteLimit(value, DUNGEON_TELEPORT_CHAT_MESSAGE_MAX_BYTES)
    if normalizedValue == defaultMessage then
        normalizedValue = ""
    end
    return normalizedValue
end

function MK: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 type(characterSettings.dungeonTeleportDialogMessage) ~= "string" then
        characterSettings.dungeonTeleportDialogMessage = ""
    end

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

    return characterSettings
end

function MK: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 MK: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

local function NormalizeDungeonTeleportTacticalAnnouncementValue(value)
    local message = tostring(value or "")
    message = message:gsub("[%c\r\n]+", " ")
    message = message:match("^%s*(.-)%s*$") or ""
    message = addonTable.TruncateUtf8ToByteLimit(message, DUNGEON_TELEPORT_CHAT_MESSAGE_MAX_BYTES)
    message = message:match("^%s*(.-)%s*$") or ""
    return message
end

function MK: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 NormalizeDungeonTeleportTacticalAnnouncementValue(value)
    end

    return ""
end

function MK: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 = NormalizeDungeonTeleportTacticalAnnouncementValue(value)
    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 MK:ResetDungeonTeleportCustomSettings()
    local characterSettings = self:GetDungeonTeleportCharacterSettings()
    if not characterSettings then
        return
    end

    characterSettings.dungeonTeleportDialogMessage = ""
    characterSettings.dungeonTeleportTacticalAnnouncements = {}
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("战术通报：")
    if type(prefix) ~= "string" or prefix == "" or 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 MK: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

function MK: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 MK: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 MK: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 MK: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 MK: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 MK: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 MK:BroadcastCurrentCharacterPatch()
    local channel = self:GetGroupChannel()
    if not channel then
        return
    end

    local characterKey = GetCharacterKey()
    local current = self.GetCurrentCharacterCommData and self:GetCurrentCharacterCommData() or nil
    if not current then
        return
    end

    local payload = {
        messageType = "character_patch",
        sender = GetCharacterDisplayName(),
        warbandTag = self:GetWarbandTag(),
        sentAt = time(),
        characterKey = characterKey,
        hidden = current.showInGroup ~= true,
        character = current,
    }

    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 MK: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 MK: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 MK: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 MK: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("mk"),
            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 incomingGroupedMember = GetGroupMemberInfoByIdentifier(incomingSender)
        if not (incomingGroupedMember and incomingGroupedMember.fullName) then
            self:DebugLog(
                "OnCommReceived character_patch ignored sender=%s reason=not_in_group",
                tostring(incomingSender)
            )
            return
        end

        local incomingOwnerKey = incomingGroupedMember.fullName
        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 = "mk",
            characters = {},
        }
        local characters = ownerData.characters or {}
        local changed = removedFallbackOwner

        ownerData.warbandTag = payload.warbandTag or ownerData.warbandTag or ""
        ownerData.lastSeen = time()
        ownerData.source = "mk"
        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,
                specID = characterData.specID,
                specName = characterData.specName,
                specIcon = characterData.specIcon,
                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 = "mk",
                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
        local incomingGroupedMember = GetGroupMemberInfoByIdentifier(sender)
            or GetGroupMemberInfoByIdentifier(incomingSender)
        if not (incomingGroupedMember and incomingGroupedMember.fullName) then
            self:DebugLog(
                "OnCommReceived teleport_dialog ignored sender=%s payloadSender=%s reason=not_in_group",
                tostring(sender),
                tostring(incomingSender)
            )
            return
        end

        if self.HandleDungeonTeleportCommPayload then
            self:HandleDungeonTeleportCommPayload(payload, sender, distribution)
        else
            self:DebugLog("OnCommReceived teleport_dialog ignored reason=no_follow_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 incomingGroupedMember = GetGroupMemberInfoByIdentifier(incomingSender)
    if not (incomingGroupedMember and incomingGroupedMember.fullName) then
        self:DebugLog(
            "OnCommReceived characters ignored sender=%s reason=not_in_group",
            tostring(incomingSender)
        )
        return
    end

    local incomingOwnerKey = incomingGroupedMember.fullName
    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 = "mk",
                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 = "mk"
        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 = "mk",
        characters = incomingCharacters,
    }

    self:DebugLog(
        "OnCommReceived sender=%s source=%s distribution=%s changed=%s owner=%s reasons=%s",
        tostring(sender),
        GetCharacterDataSourceLabel("mk"),
        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 MK: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 offlineMembers = {}
    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
                local isOffline = UnitIsConnected and not UnitIsConnected(unit)
                for alias in pairs(GetPlayerIdentifierAliases(fullName)) do
                    activeMembers[alias] = true
                    if isOffline then
                        offlineMembers[alias] = true
                    end
                end
            end
        end
    end
	
    local changed = false
    self.offlineGroupMemberAliases = offlineMembers

    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:CancelLibKeystoneFallbacksForIdentifier(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

local function FormatKeystoneDiagnosticSummary(data)
    if type(data) ~= "table" or data.hasKeystone ~= true then
        return ColorChatText("未持有钥石", CHAT_COLOR_MUTED)
    end

    local keyLevel = tonumber(data.keyLevel)
    local levelText = keyLevel and string.format("+%d", keyLevel) or "等级未知"
    return string.format(
        "%s %s",
        ColorChatText(levelText, CHAT_COLOR_SUCCESS),
        FormatChatValue(data.dungeonName)
    )
end

function MK:DumpStoredCharacters()
    local characters = self.db and self.db.global and self.db.global.characters
    if not characters then
        self:Print(ColorChatText(TRANSLATE("没有找到角色数据。"), 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)

    PrintDiagnosticSection(self, "角色缓存诊断", string.format("共 %d 条角色", #entries), #entries)

    if #entries == 0 then
        self:Print(ColorChatText("提示：当前没有本地角色缓存；登录角色或启用 /mk test data 后再查看。", CHAT_COLOR_WARNING))
        return
    end

    for index, 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)
        self:Print(string.format(
            "%s %s",
            FormatDiagnosticIndex(index),
            FormatChatValue(data.name or entry.key, nil, CHAT_COLOR_VALUE)
        ))
        self:Print(string.format(
            "  %s | %s | %s | %s",
            FormatDiagnosticField("来源", FormatChatValue(GetCharacterDataSourceLabel(data.source))),
            FormatDiagnosticField("职责", roleText),
            FormatDiagnosticField("评分", FormatChatValue(data.rating)),
            FormatDiagnosticField("队伍显示", FormatChatBool(self:IsCharacterShownInGroup(data)))
        ))
        self:Print(string.format(
            "  %s | %s | %s | %s",
            FormatDiagnosticField("钥石", FormatKeystoneDiagnosticSummary(data)),
            FormatDiagnosticField("副本ID", FormatChatValue(data.mapID)),
            FormatDiagnosticField("钥石链接", FormatChatValue(data.keystoneLink)),
            FormatDiagnosticField("最近更新", FormatChatValue(data.lastKeySeenAt))
        ))
    end
end

function MK:DumpGroupCharacters()
    local entries = self:GetGroupCharacters()
    PrintDiagnosticSection(self, "队伍钥石诊断", string.format("共 %d 条队伍记录", #entries), #entries)

    if #entries == 0 then
        self:Print(ColorChatText("提示：当前没有队伍共享钥石数据；组队后等待同步，或启用 /mk test data 查看测试数据。", CHAT_COLOR_WARNING))
        return
    end

    for index, entry in ipairs(entries) do
        local data = entry.data
        self:Print(string.format(
            "%s %s",
            FormatDiagnosticIndex(index),
            FormatChatValue(data.name or entry.key, nil, CHAT_COLOR_VALUE)
        ))
        self:Print(string.format(
            "  %s | %s | %s | %s",
            FormatDiagnosticField("所属玩家", FormatChatValue(entry.owner or "?")),
            FormatDiagnosticField("来源", FormatChatValue(GetCharacterDataSourceLabel(entry.source or data.source))),
            FormatDiagnosticField("评分", FormatChatValue(data.rating)),
            FormatDiagnosticField("钥石", FormatKeystoneDiagnosticSummary(data))
        ))
        self:Print(string.format(
            "  %s | %s | %s",
            FormatDiagnosticField("副本", FormatChatValue(data.dungeonName)),
            FormatDiagnosticField("钥石链接", FormatChatValue(data.keystoneLink)),
            FormatDiagnosticField("最近更新", FormatChatValue(data.lastKeySeenAt))
        ))
    end
end

function MK:DumpDungeonTeleportMapping()
    local dungeonTeleport = addonTable.DungeonTeleport or {}
    local getSpellID = dungeonTeleport.GetSpellID
    local getSpellName = dungeonTeleport.GetSpellName
    local getStatus = dungeonTeleport.GetStatus
    local getMacroText = dungeonTeleport.GetMacroText

    if type(self.GetCurrentSeasonDungeonMapIDs) ~= "function" then
        self:Print(ColorChatText("英雄之路导出不可用：地下城模块尚未加载。", CHAT_COLOR_ERROR))
        return
    end

    local mapIDs = self:GetCurrentSeasonDungeonMapIDs()
    PrintDiagnosticSection(self, "英雄之路映射", string.format("当前赛季 %d 个副本", #mapIDs), #mapIDs)

    if #mapIDs == 0 then
        self:Print(ColorChatText("未获取到当前赛季大秘境副本列表。", CHAT_COLOR_WARNING))
        return
    end

    for index, mapID in ipairs(mapIDs) do
        local spellID = type(getSpellID) == "function" and getSpellID(mapID) or nil
        local spellName = type(getSpellName) == "function" and getSpellName(mapID) or nil
        local status = type(getStatus) == "function" and getStatus(mapID) or nil
        local macroReady = type(getMacroText) == "function" and getMacroText(mapID) ~= nil or false
        local mappingState = spellID and ColorChatText("已配置", CHAT_COLOR_SUCCESS) or ColorChatText("缺失映射", CHAT_COLOR_ERROR)
        local learnedState = spellID and FormatChatBool(status == "ready" or status == "cooldown") or ColorChatText("未知", CHAT_COLOR_MUTED)
        local cooldownState
        if not spellID then
            cooldownState = ColorChatText("未知", CHAT_COLOR_MUTED)
        else
            cooldownState = FormatChatBool(status == "cooldown")
        end

        self:Print(string.format(
            "%s %s",
            FormatDiagnosticIndex(index),
            FormatChatValue(self:GetLocalizedDungeonName(mapID), "未知地下城", CHAT_COLOR_VALUE)
        ))
        self:Print(string.format(
            "  %s | %s | %s | %s",
            FormatDiagnosticField("mapID", FormatChatValue(mapID)),
            FormatDiagnosticField("spellID", FormatChatValue(spellID)),
            FormatDiagnosticField("法术", FormatChatValue(spellName)),
            FormatDiagnosticField("映射", mappingState)
        ))
        self:Print(string.format(
            "  %s | %s | %s",
            FormatDiagnosticField("已学习", learnedState),
            FormatDiagnosticField("冷却中", cooldownState),
            FormatDiagnosticField("宏就绪", FormatChatBool(macroReady))
        ))
    end
end

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

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

    local button = CreateFrame("Button", "MyKeyStoneMinimapButton", 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_RIGHT")
        GameTooltip:SetText(TRANSLATE(ADDON_DISPLAY_NAME_KEY), 1, 1, 1)
        local leftTooltip = self.GetStartupClickTooltipLine and self:GetStartupClickTooltipLine("LeftButton") or TRANSLATE("左键：打开总览")
        local rightTooltip = self.GetStartupClickTooltipLine and self:GetStartupClickTooltipLine("RightButton") or TRANSLATE("右键：打开设置")
        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 MK:PrintMemoryDiagnostics()
    local widgets = self.widgets or {}
    local findGroupState = self.mkFindGroupState or {}
    local findGroupSettings = self.db and self.db.profile and self.db.profile.findGroup or {}
    local accountTypeStats = addonTable.AccountType and addonTable.AccountType.GetStats and addonTable.AccountType.GetStats() or nil

    self:Print(ColorChatText("[内存诊断]", CHAT_COLOR_HEADER))
    self:Print(string.format(
        "  %s | %s",
        FormatDiagnosticField("插件内存", FormatMemoryUsageKilobytes(GetAddonMemoryUsageKilobytes())),
        FormatDiagnosticField("Lua 总堆", FormatMemoryUsageKilobytes(GetLuaHeapMemoryKilobytes()))
    ))

    self:Print(string.format(
        "  %s | %s | %s | %s",
        FormatDiagnosticField("寻找结果", FormatChatValue(#(findGroupState.results or {}), "0")),
        FormatDiagnosticField("详情缓存", FormatChatValue(CountTableEntries(findGroupState.detailCache), "0")),
        FormatDiagnosticField("详情队列", FormatChatValue(#(findGroupState.detailQueue or {}), "0")),
        FormatDiagnosticField("屏蔽记录", FormatChatValue(CountTableEntries(findGroupSettings.reportBlockedResults), "0"))
    ))
    self:Print(string.format(
        "  %s | %s | %s | %s",
        FormatDiagnosticField("账号缓存", FormatChatValue(accountTypeStats and accountTypeStats.cacheCount, "0")),
        FormatDiagnosticField("申请评分缓存", FormatChatValue(CountTableEntries(self.meetingStoneApplicantRatingCache), "0")),
        FormatDiagnosticField("队伍评分缓存", FormatChatValue(CountTableEntries(self.groupUnitRatingCache), "0")),
        FormatDiagnosticField("新兵待复查", FormatChatValue(#(findGroupState.newbiePendingResults or {}), "0"))
    ))
    self:Print(string.format(
        "  %s | %s | %s",
        FormatDiagnosticField("队伍数据拥有者", FormatChatValue(CountTableEntries(self.groupCharacters), "0")),
        FormatDiagnosticField("队伍数据角色", FormatChatValue(CountNestedTableEntries(self.groupCharacters, "characters"), "0")),
        FormatDiagnosticField("队伍插件用户", FormatChatValue(CountTableEntries(self.groupAddonUsers), "0"))
    ))
    self:Print(string.format(
        "  %s | %s | %s | %s | %s",
        FormatDiagnosticField("角色卡片池", FormatChatValue(#(widgets.characterCards or {}), "0")),
        FormatDiagnosticField("队伍行池", FormatChatValue(#(widgets.groupRowFrames or {}), "0")),
        FormatDiagnosticField("集合石行池", FormatChatValue(#(widgets.meetingStoneApplicantRows or {}), "0")),
        FormatDiagnosticField("寻找队伍行池", FormatChatValue(#(widgets.findGroupRows or {}), "0")),
        FormatDiagnosticField("黑名单行池", FormatChatValue(#(widgets.blacklistRows or {}), "0"))
    ))
    self:Print(string.format(
        "  %s | %s | %s",
        FormatDiagnosticField("地下城卡片池", FormatChatValue(#(widgets.dungeonTiles or {}), "0")),
        FormatDiagnosticField("地下城纹理缓存", FormatChatValue(CountTableEntries(self.dungeonJournalTextureCache), "0")),
        FormatDiagnosticField("纹理名称映射", FormatChatValue(CountTableEntries(self.dungeonJournalTextureByName), "0"))
    ))
end

local function PrintTestDataSummary(owner, stateText, stateColor)
    if not owner then
        return
    end

    owner:Print(string.format(
        "%s %s | %s | %s | %s",
        ColorChatText("[测试数据] " .. tostring(stateText or "状态已更新"), stateColor or CHAT_COLOR_HEADER),
        FormatDiagnosticField("角色", FormatChatValue(CountTableEntries(owner.testCharacters) .. " 条")),
        FormatDiagnosticField("队伍成员", FormatChatValue(#(owner.testGroupRosterMembers or {}) .. " 条")),
        FormatDiagnosticField("车队分组", FormatChatValue((tonumber(owner.testCarpoolGroupCount) or 0) .. " 组")),
        FormatDiagnosticField("集合石申请", FormatChatBool(_G.MK_MEETINGSTONE_APPLICANT_TEST_ROWS_ENABLED == true))
    ))
    owner:Print(string.format(
        "  %s | %s",
        FormatDiagnosticField("覆盖范围", FormatChatValue("角色页、队伍页、车队页、集合石申请列表")),
        FormatDiagnosticField("清理命令", ColorChatText("/mk test data off", CHAT_COLOR_COMMAND))
    ))
end

local function EnableTestDataCommand(owner, requestedAdditionalGroups)
    if not owner then
        return
    end

    owner:EnableTestData(requestedAdditionalGroups)
    PrintTestDataSummary(owner, "已启用", CHAT_COLOR_SUCCESS)
end

local function DisableTestDataCommand(owner)
    if not owner then
        return
    end

    owner:DisableTestData()
    PrintTestDataSummary(owner, "已清理", CHAT_COLOR_WARNING)
end

local function ShowTeleportDialogTestCommand(owner, mapID)
    if not owner then
        return
    end

    if owner.ShowDungeonTeleportDialogTest then
        local previewMapID, previewDungeonName = owner:ShowDungeonTeleportDialogTest(tonumber(mapID))
        owner:Print(string.format(
            "%s %s | %s",
            ColorChatText("[传送弹窗] 预览已打开", CHAT_COLOR_SUCCESS),
            FormatDiagnosticField("副本", FormatChatValue(previewDungeonName)),
            FormatDiagnosticField("mapID", FormatChatValue(previewMapID))
        ))
    else
        owner:Print(ColorChatText("[传送弹窗] 预览不可用：地下城传送模块尚未加载。", CHAT_COLOR_ERROR))
    end
end

local function SetDebugModeCommand(owner, mode)
    if not owner then
        return false
    end

    mode = strtrim and strtrim(mode or "") or tostring(mode or "")
    if mode == "" or mode == "on" then
        owner:SetDebugLoggingEnabled(true)
        owner:SetDebugVerboseLoggingEnabled(false)
        owner:Print(ColorChatText(TRANSLATE("调试日志已启用。"), CHAT_COLOR_SUCCESS))
        return true
    end

    if mode == "verbose" then
        owner:SetDebugLoggingEnabled(true)
        owner:SetDebugVerboseLoggingEnabled(true)
        owner:Print(ColorChatText(TRANSLATE("完整调试日志已开启。"), CHAT_COLOR_SUCCESS))
        return true
    end

    if mode == "verbose off" then
        owner:SetDebugVerboseLoggingEnabled(false)
        owner:Print(ColorChatText(TRANSLATE("完整调试日志已关闭，保留精简调试。"), CHAT_COLOR_WARNING))
        return true
    end

    if mode == "off" then
        owner:SetDebugLoggingEnabled(false)
        owner:SetDebugVerboseLoggingEnabled(false)
        owner:Print(ColorChatText(TRANSLATE("调试日志已禁用。"), CHAT_COLOR_WARNING))
        return true
    end

    return false
end

local function SetDebugLocaleCommand(owner, mode)
    if not owner then
        return false
    end

    mode = strtrim and strtrim(mode or "") or tostring(mode or "")
    mode = mode:lower()
    if mode == "" then
        return false
    end

    local locale
    local successMessage
    if mode == "zh" then
        locale = "zhCN"
        successMessage = "语言覆盖已保存：下次 /reload 后将加载简体中文。"
    elseif mode == "zhtw" then
        locale = "zhTW"
        successMessage = "语言覆盖已保存：下次 /reload 后将加载繁体中文。"
    elseif mode == "en" then
        locale = "enUS"
        successMessage = "语言覆盖已保存：下次 /reload 后将加载英文。"
    elseif mode == "reset" then
        locale = nil
        successMessage = "语言覆盖已清除：下次 /reload 后将重新使用客户端语言。"
    else
        return false
    end

    if not owner:IsDebugLoggingEnabled() then
        owner:Print(ColorChatText(TRANSLATE("仅在启用调试日志时可使用语言覆盖。"), CHAT_COLOR_WARNING))
        return true
    end

    owner:SetDebugLocaleOverride(locale)
    owner:Print(ColorChatText(TRANSLATE(successMessage), CHAT_COLOR_SUCCESS))
    return true
end

local function PrintTestCommandHelp(owner)
    if not owner then
        return
    end

    owner:Print(ColorChatText("测试命令：", CHAT_COLOR_HEADER))
    PrintCommandHelpLine(owner, "/mk test - 显示精简测试命令")
    PrintCommandHelpLine(owner, "/mk test data [1|2|3] - 启用临时测试数据")
    PrintCommandHelpLine(owner, "/mk test data off - 清理临时测试数据")
    PrintCommandHelpLine(owner, "/mk test chars - 输出本地角色缓存")
    PrintCommandHelpLine(owner, "/mk test group - 输出队伍钥石数据")
    PrintCommandHelpLine(owner, "/mk test account [角色-服务器] - 诊断账号类型")
    PrintCommandHelpLine(owner, "/mk test account refresh - 清空账号类型缓存并重查")
    PrintCommandHelpLine(owner, "/mk test teleport - 导出英雄之路映射")
    PrintCommandHelpLine(owner, "/mk test dialog [mapID] - 预览传送弹窗")
    PrintCommandHelpLine(owner, "/mk test memory - 输出内存与缓存诊断")
    PrintCommandHelpLine(owner, "/mk test debug on|verbose|verbose off|off - 开关测试调试日志")
    PrintCommandHelpLine(owner, "/mk test locale zh|zhTW|en|reset - 切换调试语言覆盖")
end

addonTable.Debug = addonTable.Debug or {}

function addonTable.Debug.PrintTestCommandHelp(owner)
    PrintTestCommandHelp(owner)
end

function addonTable.Debug.EnableTestData(owner, requestedAdditionalGroups)
    EnableTestDataCommand(owner, requestedAdditionalGroups)
end

function addonTable.Debug.DisableTestData(owner)
    DisableTestDataCommand(owner)
end

function addonTable.Debug.DumpStoredCharacters(owner)
    if owner and owner.DumpStoredCharacters then
        owner:DumpStoredCharacters()
    end
end

function addonTable.Debug.DumpGroupCharacters(owner)
    if owner and owner.DumpGroupCharacters then
        owner:DumpGroupCharacters()
    end
end

function addonTable.Debug.DumpDungeonTeleportMapping(owner)
    if owner and owner.DumpDungeonTeleportMapping then
        owner:DumpDungeonTeleportMapping()
    end
end

function addonTable.Debug.ShowTeleportDialog(owner, mapID)
    ShowTeleportDialogTestCommand(owner, mapID)
end

function addonTable.Debug.DebugAccountTypeLookup(owner, playerName)
    if owner and owner.DebugAccountTypeLookup then
        owner:DebugAccountTypeLookup(playerName)
    end
end

function addonTable.Debug.RefreshAccountTypeCache(owner)
    if owner and owner.RefreshAccountTypeCache then
        owner:RefreshAccountTypeCache()
    end
end

function addonTable.Debug.PrintMemoryDiagnostics(owner)
    if owner and owner.PrintMemoryDiagnostics then
        owner:PrintMemoryDiagnostics()
    end
end

function addonTable.Debug.SetDebugLogMode(owner, mode)
    return SetDebugModeCommand(owner, mode)
end

function addonTable.Debug.SetDebugLocale(owner, mode)
    return SetDebugLocaleCommand(owner, mode)
end

local function HandleTestSlashCommand(owner, rawTestCommand)
    if not owner then
        return true
    end

    local raw = strtrim and strtrim(rawTestCommand or "") or tostring(rawTestCommand or "")
    local command = raw:lower()

    if command == "" then
        PrintTestCommandHelp(owner)
        return true
    end

    if command == "data" then
        EnableTestDataCommand(owner)
        return true
    end

    local testDataCount = command:match("^data%s+([123])$")
    if testDataCount then
        EnableTestDataCommand(owner, tonumber(testDataCount))
        return true
    end

    if command == "data off" then
        DisableTestDataCommand(owner)
        return true
    end

    if command == "chars" then
        owner:DumpStoredCharacters()
        return true
    end

    if command == "group" then
        owner:DumpGroupCharacters()
        return true
    end

    if command == "teleport" then
        owner:DumpDungeonTeleportMapping()
        return true
    end

    local dialogMapID = command == "dialog" and "" or command:match("^dialog%s+(%d+)$")
    if dialogMapID then
        ShowTeleportDialogTestCommand(owner, dialogMapID)
        return true
    end

    if command == "account refresh" then
        owner:RefreshAccountTypeCache()
        return true
    end

    if command == "account" then
        owner:DebugAccountTypeLookup()
        return true
    end

    local accountName = command:match("^account%s+(.+)$")
    if accountName then
        accountName = raw:sub(#raw - #accountName + 1)
        owner:DebugAccountTypeLookup(accountName)
        return true
    end

    if command == "memory" then
        owner:PrintMemoryDiagnostics()
        return true
    end

    local debugMode = command:match("^debug%s+(.+)$")
    if debugMode and SetDebugModeCommand(owner, debugMode) then
        return true
    end

    local localeMode = command:match("^locale%s+(.+)$")
    if localeMode and SetDebugLocaleCommand(owner, localeMode) then
        return true
    end

    owner:Print(ColorChatText("未知测试命令，请输入 /mk test 查看精简测试命令。", CHAT_COLOR_WARNING))
    return true
end

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

    if command == "test" or command:match("^test%s+") then
        HandleTestSlashCommand(self, rawCommand:sub(5))
        return
    end

    if command == "开启调试模式" then
        if addonTable.Debug and addonTable.Debug.SetDebugModeEnabled then
            addonTable.Debug.SetDebugModeEnabled(self, true)
        end
        return
    end

    if command == "关闭调试模式" then
        if addonTable.Debug and addonTable.Debug.SetDebugModeEnabled then
            addonTable.Debug.SetDebugModeEnabled(self, false)
        end
        return
    end

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

    if command == "help" then
        self:Print(ColorChatText("可用命令：", CHAT_COLOR_HEADER))
        PrintCommandHelpLine(self, "/mk - 打开主面板")
        PrintCommandHelpLine(self, "/mk config - 打开设置选项卡")
        PrintCommandHelpLine(self, "/mk 开启调试模式 - 显示调试选项卡")
        PrintCommandHelpLine(self, "/mk 关闭调试模式 - 隐藏调试选项卡")
        PrintCommandHelpLine(self, "/mk test - 显示精简测试命令")
        return
    end

    if command ~= "" then
        self:Print(ColorChatText("未知命令，请输入 /mk help 查看可用命令。", CHAT_COLOR_WARNING))
        return
    end

    self:ToggleMainWindow()
end
