local addonName, addonTable = ...
---@type MK
local MK = addonTable.MK
local core = addonTable
local TRANSLATE = core.TRANSLATE or function(msg) return msg end
local MythicSeason = core.MythicSeason or {}

core.DungeonTab = core.DungeonTab or {}
local DungeonTab = core.DungeonTab

local DungeonTeleport = core.DungeonTeleport or {}
local AddDungeonTooltipScoreLine = DungeonTeleport.AddTooltipScoreLine or function() end
local AddDungeonTeleportTooltipLine = DungeonTeleport.AddTeleportTooltipLine or function() end
local GetDungeonTeleportSpellID = DungeonTeleport.GetSpellID or function() return nil end
local GetDungeonTeleportSpellName = DungeonTeleport.GetSpellName or function() return nil end
local GetDungeonTeleportStatus = DungeonTeleport.GetStatus or function() return nil end
local GetDungeonTeleportMacroText = DungeonTeleport.GetMacroText or function() return nil end
local UpdateDungeonTeleportBadge = DungeonTeleport.UpdateBadge or function() end
local GetDungeonTeleportDebugFields = DungeonTeleport.GetDebugFields or function() return nil, nil, nil, nil, nil end
local AttachDungeonTeleportDialogTrigger = DungeonTeleport.AttachDialogTrigger or function() end
local SetSecureButtonAttributes = core.SetSecureButtonAttributes or function(button, ...)
    if not (button and button.SetAttribute) then
        return false
    end

    local count = select("#", ...)
    for index = 1, count, 2 do
        local key = select(index, ...)
        if key ~= nil then
            local value = select(index + 1, ...)
            button:SetAttribute(key, value)
        end
    end
    return true
end
local ApplyDungeonTeleportButtonSecureState = DungeonTeleport.ApplyButtonSecureState or function(button, canUseTeleport, teleportMacroText, mapID)
    local canUse = canUseTeleport == true and type(teleportMacroText) == "string" and teleportMacroText ~= ""
    SetSecureButtonAttributes(
        button,
        "type", canUse and "macro" or nil,
        "type1", canUse and "macro" or nil,
        "macrotext", canUse and teleportMacroText or nil,
        "macrotext1", canUse and teleportMacroText or nil,
        "spell", nil,
        "spell1", nil
    )
    if button then
        button.mkTeleportReady = canUse
        button.mkTeleportEffectiveMapID = mapID
    end
    return canUse, false, mapID
end

local DUNGEON_TILE_KEY_STYLE = {
    colorIron = "ffffd100",
    colorMythic = "ff1eff00",
    trackIron = "iron",
    trackMythic = "mythic",
    cache = {},
}

local function TrySetAtlas(texture, atlas, useAtlasSize)
    if not texture or not atlas or not texture.SetAtlas then
        return false
    end

    local ok = pcall(texture.SetAtlas, texture, atlas, useAtlasSize)
    return ok == true
end

local function CopyFallbackSeasonDungeonMapIDs()
    if type(MythicSeason.GetCurrentSeasonMapIDs) == "function" then
        return MythicSeason.GetCurrentSeasonMapIDs()
    end
    return {}
end

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

    local seen = {}
    for _, mapID in ipairs(source) do
        mapID = tonumber(mapID)
        if mapID and mapID > 0 and not seen[mapID] then
            seen[mapID] = true
            mapIDs[#mapIDs + 1] = mapID
        end
    end

    return mapIDs
end

local function GetCachedSeasonDungeonMapIDs(owner)
    local database = owner and owner.db and owner.db.global
    if type(database) ~= "table" then
        return {}
    end

    return CopyValidSeasonDungeonMapIDs(database.lastKnownSeasonDungeonMapIDs)
end

local function SaveSeasonDungeonMapIDs(owner, mapIDs)
    local database = owner and owner.db and owner.db.global
    if type(database) ~= "table" then
        return
    end

    local validMapIDs = CopyValidSeasonDungeonMapIDs(mapIDs)
    if #validMapIDs == 0 then
        return
    end

    database.lastKnownSeasonDungeonMapIDs = validMapIDs
    database.lastKnownSeasonDungeonMapIDsUpdatedAt = type(time) == "function" and time() or nil
    database.lastKnownSeasonDungeonMapIDsSource = "C_ChallengeMode.GetMapTable"
end

local function GetFallbackSeasonDungeonMapIDs(owner, reason)
    local cachedMapIDs = GetCachedSeasonDungeonMapIDs(owner)
    if #cachedMapIDs > 0 then
        if owner and owner.DebugLog then
            owner:DebugLog("Current season dungeons source=saved_cache reason=%s count=%s", tostring(reason), tostring(#cachedMapIDs))
        end
        return cachedMapIDs
    end

    local fallbackMapIDs = CopyFallbackSeasonDungeonMapIDs()
    if owner and owner.DebugLog then
        owner:DebugLog("Current season dungeons source=hardcoded_fallback reason=%s count=%s", tostring(reason), tostring(#fallbackMapIDs))
    end
    return fallbackMapIDs
end

local function SnapshotBool(value)
    return value and "1" or "0"
end

local function SnapshotValue(value)
    if value == nil then
        return ""
    end

    return tostring(value)
end

function MK:GetDungeonViewCharacters()
    if core.CharacterStore and core.CharacterStore.GetDungeonViewCharacters then
        return core.CharacterStore:GetDungeonViewCharacters(self)
    end

    return {}
end

function MK:GetCurrentSeasonDungeonMapIDs()
    ---@type integer[]
    local mapIDs = {}

    if not C_ChallengeMode or type(C_ChallengeMode.GetMapTable) ~= "function" then
        return GetFallbackSeasonDungeonMapIDs(self, "missing_api")
    end

    local ok, currentSeasonMaps = pcall(C_ChallengeMode.GetMapTable)
    if not ok then
        if self.DebugLog then
            self:DebugLog("GetMapTable failed error=%s", tostring(currentSeasonMaps))
        end
        return GetFallbackSeasonDungeonMapIDs(self, "api_error")
    end
    if type(currentSeasonMaps) ~= "table" then
        return GetFallbackSeasonDungeonMapIDs(self, "invalid_api_result")
    end

    mapIDs = CopyValidSeasonDungeonMapIDs(currentSeasonMaps)

    if #mapIDs == 0 then
        return GetFallbackSeasonDungeonMapIDs(self, "empty_api_result")
    end

    SaveSeasonDungeonMapIDs(self, mapIDs)
    if self.DebugLog then
        self:DebugLog("Current season dungeons source=api count=%s", tostring(#mapIDs))
    end
    return mapIDs
end

function MK:GetPlayerDungeonBestLevel(mapID)
    if not mapID or not C_MythicPlus or not C_MythicPlus.GetSeasonBestForMap then
        return nil, nil, 0
    end

    local ok, inTimeInfo, overtimeInfo = pcall(C_MythicPlus.GetSeasonBestForMap, mapID)
    if not ok then
        if self.DebugLog then
            self:DebugLog("GetSeasonBestForMap failed mapID=%s error=%s", tostring(mapID), tostring(inTimeInfo))
        end

        return nil, nil, 0
    end

    local bestInfo
    local bestTimed
    if type(inTimeInfo) == "table" and type(overtimeInfo) == "table" then
        local inTimeScore = tonumber(inTimeInfo.dungeonScore) or 0
        local overtimeScore = tonumber(overtimeInfo.dungeonScore) or 0
        if inTimeScore > overtimeScore then
            bestInfo = inTimeInfo
            bestTimed = true
        else
            bestInfo = overtimeInfo
            bestTimed = false
        end
    elseif type(inTimeInfo) == "table" then
        bestInfo = inTimeInfo
        bestTimed = true
    elseif type(overtimeInfo) == "table" then
        bestInfo = overtimeInfo
        bestTimed = false
    end

    if type(bestInfo) ~= "table" then
        return nil, nil, 0
    end

    local bestLevel = tonumber(bestInfo.level) or 0
    local bestDungeonScore = tonumber(bestInfo.dungeonScore) or 0
    if bestLevel <= 0 then
        return nil, nil, bestDungeonScore
    end

    return bestLevel, bestTimed, bestDungeonScore
end

function MK:GetCurrentSeasonDungeonGroups()
    ---@type table<integer, BKDungeonGroup>
    local groupsByMapID = {}
    ---@type BKDungeonGroup[]
    local dungeonGroups = {}

    for sourceIndex, mapID in ipairs(self:GetCurrentSeasonDungeonMapIDs()) do
        local playerBestLevel, playerBestTimed, playerDungeonScore = self:GetPlayerDungeonBestLevel(mapID)
        local challengeMapName = self:GetLocalizedDungeonName(mapID)
        local dungeonGroup = {
            dungeonName = self:GetAbbreviatedDungeonName(mapID, challengeMapName),
            mapID = mapID,
            playerBestLevel = playerBestLevel,
            playerBestTimed = playerBestTimed,
            playerDungeonScore = playerDungeonScore or 0,
            sourceIndex = sourceIndex,
            entries = {},
        }
        groupsByMapID[mapID] = dungeonGroup
        dungeonGroups[#dungeonGroups + 1] = dungeonGroup
    end

    local dungeonEntries = self.GetDungeonKeystoneEntries
        and self:GetDungeonKeystoneEntries()
        or self:GetDungeonViewCharacters()
    for _, characterEntry in ipairs(dungeonEntries) do
        local character = characterEntry.data or {}
        local dungeonGroup = character.mapID and groupsByMapID[character.mapID]

        if dungeonGroup then
            dungeonGroup.entries[#dungeonGroup.entries + 1] = {
                key = characterEntry.key,
                owner = characterEntry.owner,
                data = character,
            }
        end
    end

    for _, dungeonGroup in ipairs(dungeonGroups) do
        table.sort(dungeonGroup.entries, function(left, right)
            local leftLevel = left.data.keyLevel or 0
            local rightLevel = right.data.keyLevel or 0

            if leftLevel == rightLevel then
                return (left.data.name or left.key) < (right.data.name or right.key)
            end

            return leftLevel > rightLevel
        end)
    end

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

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

    return dungeonGroups
end

function MK:BuildDungeonsTabSnapshot()
    local groupParts = {}

    for _, dungeonGroup in ipairs(self:GetCurrentSeasonDungeonGroups()) do
        local entryParts = {}

        for _, dungeonEntry in ipairs(dungeonGroup.entries or {}) do
            local character = dungeonEntry.data or {}
            entryParts[#entryParts + 1] = table.concat({
                SnapshotValue(dungeonEntry.key),
                SnapshotValue(dungeonEntry.owner),
                SnapshotValue(character.name),
                SnapshotValue(character.keyLevel),
                SnapshotValue(character.mapID),
                SnapshotValue(character.keystoneLink),
                SnapshotValue(character.keyUpgradeTrack),
            }, "~")
        end

        groupParts[#groupParts + 1] = table.concat({
            SnapshotValue(dungeonGroup.mapID),
            SnapshotValue(dungeonGroup.dungeonName),
            SnapshotValue(dungeonGroup.playerBestLevel),
            SnapshotBool(dungeonGroup.playerBestTimed),
            SnapshotValue(dungeonGroup.playerDungeonScore),
            table.concat(entryParts, "%%"),
        }, "~~")
    end

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

local function ColorizeDungeonTileKeyText(text, color)
    return string.format("|c%s%s|r", color or DUNGEON_TILE_KEY_STYLE.colorIron, text or "")
end

local MatchKeystoneUpgradeTrackText = core.MatchKeystoneUpgradeTrackText or function() return nil end
local ResolveKeystoneUpgradeTrackFromLink = core.ResolveKeystoneUpgradeTrackFromLink or function() return nil end
local ResolveKeystoneDisplayLevelAndTrackFromLink = core.ResolveKeystoneDisplayLevelAndTrackFromLink or function(_, fallbackLevel) return fallbackLevel, nil end

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

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

    local linkTrack = ResolveKeystoneUpgradeTrackFromLink(keystoneLink)
    if linkTrack then
        DUNGEON_TILE_KEY_STYLE.cache[keystoneLink] = linkTrack
        return linkTrack
    end

    local cachedTrack = DUNGEON_TILE_KEY_STYLE.cache[keystoneLink]
    if cachedTrack ~= nil then
        return cachedTrack ~= false and cachedTrack or nil
    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
                        DUNGEON_TILE_KEY_STYLE.cache[keystoneLink] = track
                        return track
                    end
                end
            end
        end
    end

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

        if ok then
            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
                    DUNGEON_TILE_KEY_STYLE.cache[keystoneLink] = track
                    tooltip:Hide()
                    return track
                end
            end
        end

        tooltip:Hide()
    end

    DUNGEON_TILE_KEY_STYLE.cache[keystoneLink] = false
    return nil
end

local function GetDungeonTileKeyUpgradeTrack(character, displayLevel)
    if not character then
        return nil
    end

    local linkTrack = ResolveKeystoneUpgradeTrackFromLink(character.keystoneLink, displayLevel or character.keyLevel)
    if linkTrack then
        return linkTrack
    end

    local tooltipTrack = ResolveKeystoneUpgradeTrackFromTooltip(character.keystoneLink)
    if tooltipTrack then
        return tooltipTrack
    end

    if character.keyUpgradeTrack == DUNGEON_TILE_KEY_STYLE.trackIron
        or character.keyUpgradeTrack == DUNGEON_TILE_KEY_STYLE.trackMythic then
        return character.keyUpgradeTrack
    end

    return nil
end

local function GetDungeonTileDisplayKeyInfo(character)
    if not character then
        return nil, nil
    end

    local fallbackLevel = tonumber(character.keyLevel)
    local displayLevel, displayTrack = ResolveKeystoneDisplayLevelAndTrackFromLink(character.keystoneLink, fallbackLevel)
    displayLevel = tonumber(displayLevel) or fallbackLevel
    if not displayLevel or displayLevel <= 0 then
        return nil, nil
    end

    return displayLevel, displayTrack or GetDungeonTileKeyUpgradeTrack(character, displayLevel)
end

local function GetDungeonTileKeyColor(character, upgradeTrack)
    upgradeTrack = upgradeTrack or GetDungeonTileKeyUpgradeTrack(character)
    if upgradeTrack == DUNGEON_TILE_KEY_STYLE.trackIron then
        return DUNGEON_TILE_KEY_STYLE.colorIron
    end
    if upgradeTrack == DUNGEON_TILE_KEY_STYLE.trackMythic then
        return DUNGEON_TILE_KEY_STYLE.colorMythic
    end

    return DUNGEON_TILE_KEY_STYLE.colorIron
end

local function GetDungeonTileKeyText(character, showUpgradeTrackLabel)
    local keyLevel, upgradeTrack = GetDungeonTileDisplayKeyInfo(character)
    if not keyLevel or keyLevel <= 0 then
        return nil
    end

    local text = tostring(keyLevel)
    if showUpgradeTrackLabel and upgradeTrack == DUNGEON_TILE_KEY_STYLE.trackIron then
        text = string.format("%s %s", text, TRANSLATE("坚韧"))
    elseif showUpgradeTrackLabel and upgradeTrack == DUNGEON_TILE_KEY_STYLE.trackMythic then
        text = string.format("%s %s", text, TRANSLATE("史诗"))
    end

    return ColorizeDungeonTileKeyText(text, GetDungeonTileKeyColor(character, upgradeTrack))
end

local DUNGEON_TILE_WIDTH = 215
local DUNGEON_TILE_HEIGHT = 173
local DUNGEON_TILE_COLUMNS = 4
local DUNGEON_TILE_GAP_X = 11
local DUNGEON_TILE_GAP_Y = 14
local DUNGEON_TILE_BORDER_LEFT = -4
local DUNGEON_TILE_BORDER_TOP = 5
local DUNGEON_TILE_BORDER_RIGHT = 5
local DUNGEON_TILE_BORDER_BOTTOM = -5
local DUNGEON_TILE_IMAGE_LEFT = 2
local DUNGEON_TILE_IMAGE_TOP = 2
local DUNGEON_TILE_IMAGE_WIDTH = 211
local DUNGEON_TILE_IMAGE_HEIGHT = 169
local DUNGEON_TILE_UI = {
    topMaskTexture = "Interface\\AddOns\\" .. addonName .. "\\Art\\Ui\\InfoTips.png",
    topMaskHeight = math.floor((DUNGEON_TILE_IMAGE_WIDTH * 88 / 659) + 0.5),
    keystoneIconTexture = "Interface\\Icons\\INV_Relics_Hourglass_02",
    keystoneIconSize = 14,
    keystoneIconInset = 2,
    keystoneIconBorderTexture = "Interface\\Buttons\\UI-Quickslot2",
    keystoneIconOuterBorderPadding = 9,
    keystoneIconGap = 4,
    keySummaryHeight = 18,
    keySummaryMaxWidth = DUNGEON_TILE_IMAGE_WIDTH - 24,
    keySummaryOffsetY = 3,
    bestBadgeTexture = "Interface\\AddOns\\" .. addonName .. "\\Art\\Ui\\Chakram.png",
    bestBadgeWidth = 64,
    bestBadgeHeight = 64,
    bestBadgeOffsetX = 0,
    bestBadgeOffsetY = 0,
    bestTextOffsetX = 0,
    bestTextOffsetY = 0,
    bestTextWidth = 64,
    bestTextHeight = 64,
    bestFontSize = 18,
    portalEffectTexture = "Interface\\AddOns\\" .. addonName .. "\\Art\\Ui\\DungeonPortalEffect.png",
    portalEffectWidth = 86,
    portalEffectHeight = 92,
    portalEffectOffsetX = 0,
    portalEffectOffsetY = 0,
    portalEffectFrameColumns = 7,
    portalEffectFrameRows = 4,
    portalEffectFrameCount = 28,
    portalEffectFrameSeconds = 0.04,
    teleportCastVisualTimeoutSeconds = 16,
    portalBackdropAlpha = 0.58,
    portalEffectFadeInSeconds = 0.18,
    portalEffectFadeOutSeconds = 0.14,
    portalBackdropFadeInSeconds = 0.16,
    portalBackdropFadeOutSeconds = 0.14,
    bestLevelFadeInSeconds = 0.16,
    bestLevelFadeOutSeconds = 0.12,
    nameBackgroundOffsetX = 1,
    nameBackgroundOffsetY = 2,
    nameBackgroundExtraWidth = 2,
    nameBackgroundFallbackWidth = DUNGEON_TILE_WIDTH - 18,
    nameBackgroundFallbackHeight = 40,
    teleportBadgeSize = 22,
    teleportBadgeRight = -12,
    teleportBadgeOffsetY = 0,
    teleportRefreshSeconds = 1,
    pressOffsetX = 2,
    pressOffsetY = -2,
}
local DUNGEON_TILE_NAME_TEXT_WIDTH = 200
local DUNGEON_TILE_NAME_TEXT_HEIGHT = 32

local function ApplyDungeonTileImageTexCoords(image, baseTexCoords)
    if not image then
        return
    end

    local left = 0
    local right = 1
    local top = 0
    local bottom = 1

    if baseTexCoords and #baseTexCoords == 4 then
        left = baseTexCoords[1]
        right = baseTexCoords[2]
        top = baseTexCoords[3]
        bottom = baseTexCoords[4]
    end

    image:SetTexCoord(left, right, top, bottom)
end

local function UpdateDungeonTileKeySummary(tile)
    if not tile or not tile.keySummary or not tile.keyIcon or not tile.entries then
        return
    end

    local text = tile.entries:GetText()
    if type(text) ~= "string" or text == "" then
        tile.keySummary:Hide()
        tile.keyIcon:Hide()
        if tile.keyIconOuterBorder then
            tile.keyIconOuterBorder:Hide()
        end
        tile.entries:Hide()
        return
    end

    local scale = tile.layoutScale or 1
    local iconSize = math.max(
        1,
        math.floor((DUNGEON_TILE_UI.keystoneIconSize - (DUNGEON_TILE_UI.keystoneIconInset or 0)) * scale + 0.5)
    )
    local iconOuterBorderSize = math.max(
        iconSize,
        math.floor((DUNGEON_TILE_UI.keystoneIconSize + DUNGEON_TILE_UI.keystoneIconOuterBorderPadding) * scale + 0.5)
    )
    local iconGap = math.max(0, math.floor(DUNGEON_TILE_UI.keystoneIconGap * scale + 0.5))
    local summaryHeight = math.max(iconSize, math.floor(DUNGEON_TILE_UI.keySummaryHeight * scale + 0.5))
    local maxSummaryWidth = math.max(1, math.floor(DUNGEON_TILE_UI.keySummaryMaxWidth * scale + 0.5))
    local maxTextWidth = math.max(1, maxSummaryWidth - ((iconOuterBorderSize + iconGap) * 2))

    tile.entries:SetWidth(maxTextWidth)

    local measuredTextWidth = tile.entries:GetStringWidth() or 0
    if measuredTextWidth <= 0 then
        measuredTextWidth = maxTextWidth
    end

    local textWidth = math.ceil(measuredTextWidth)
    textWidth = math.max(1, math.min(maxTextWidth, textWidth))

    tile.keySummary:ClearAllPoints()
    tile.keySummary:SetPoint("CENTER", tile.topMask or tile, "CENTER", 0, DUNGEON_TILE_UI.keySummaryOffsetY * scale)
    tile.keySummary:SetSize(maxSummaryWidth, summaryHeight)

    tile.keyIcon:ClearAllPoints()
    tile.keyIcon:SetPoint("RIGHT", tile.entries, "LEFT", -iconGap, 0)
    tile.keyIcon:SetSize(iconSize, iconSize)

    if tile.keyIconOuterBorder then
        tile.keyIconOuterBorder:ClearAllPoints()
        tile.keyIconOuterBorder:SetPoint("CENTER", tile.keyIcon, "CENTER", 0, 0)
        tile.keyIconOuterBorder:SetSize(iconOuterBorderSize, iconOuterBorderSize)
        tile.keyIconOuterBorder:Show()
    end

    tile.entries:ClearAllPoints()
    tile.entries:SetPoint("CENTER", tile.keySummary, "CENTER", 0, 0)
    tile.entries:SetSize(textWidth, summaryHeight)
    tile.entries:SetJustifyH("CENTER")
    tile.entries:SetJustifyV("MIDDLE")

    tile.keySummary:Show()
    tile.keyIcon:Show()
    tile.entries:Show()
end

local function CreateDungeonTileAlphaAnimationGroup(region, fromAlpha, toAlpha, duration, smoothing)
    if not region or not region.CreateAnimationGroup then
        return nil
    end

    local group = region:CreateAnimationGroup()
    local alpha = group:CreateAnimation("Alpha")
    alpha:SetFromAlpha(fromAlpha)
    alpha:SetToAlpha(toAlpha)
    alpha:SetDuration(duration or 0.15)
    if smoothing and alpha.SetSmoothing then
        alpha:SetSmoothing(smoothing)
    end
    return group
end

local function StopDungeonTileAnimationGroup(group)
    if group and group.Stop then
        group:Stop()
    end
end

local function GetDungeonTileMapID(tile)
    local mapID = tile and (tile.mapID or (tile.dungeonGroup and tile.dungeonGroup.mapID)) or nil
    return tonumber(mapID)
end

local function IsDungeonTilePortalEffectAvailable(tile)
    local mapID = GetDungeonTileMapID(tile)
    return GetDungeonTeleportStatus(mapID) == "ready"
end

local function IsDungeonTileTeleportCastVisualActive(tile)
    local mapID = GetDungeonTileMapID(tile)
    local activeMapID = tonumber(MK.activeDungeonTeleportCastVisualMapID)
    if not mapID or not activeMapID or mapID ~= activeMapID then
        return false
    end

    local expiresAt = tonumber(MK.activeDungeonTeleportCastVisualExpiresAt)
    if expiresAt and GetTime and GetTime() > expiresAt then
        MK.activeDungeonTeleportCastVisualMapID = nil
        MK.activeDungeonTeleportCastVisualExpiresAt = nil
        return false
    end

    return true
end

local function DungeonGroupHasPlayerRecord(dungeonGroup)
    if not dungeonGroup then
        return false
    end

    return (tonumber(dungeonGroup.playerDungeonScore) or 0) > 0
        or (tonumber(dungeonGroup.playerBestLevel) or 0) > 0
end

local function SetDungeonTileBestLevelAlpha(tile, visible, immediate)
    local frame = tile and tile.bestLevelFrame
    if not frame then
        return
    end

    StopDungeonTileAnimationGroup(tile.bestLevelFadeIn)
    StopDungeonTileAnimationGroup(tile.bestLevelFadeOut)

    if not tile.bestLevelAvailable then
        frame:SetAlpha(0)
        return
    end

    if immediate then
        frame:SetAlpha(visible and 1 or 0)
        return
    end

    if visible then
        frame:SetAlpha(0)
        if tile.bestLevelFadeIn then
            tile.bestLevelFadeIn:Play()
        else
            frame:SetAlpha(1)
        end
    else
        frame:SetAlpha(1)
        if tile.bestLevelFadeOut then
            tile.bestLevelFadeOut:Play()
        else
            frame:SetAlpha(0)
        end
    end
end

local function SetDungeonTilePortalBackdropAlpha(tile, visible, immediate)
    local backdrop = tile and tile.portalEffectBackdrop
    if not backdrop then
        return
    end

    StopDungeonTileAnimationGroup(tile.portalBackdropFadeIn)
    StopDungeonTileAnimationGroup(tile.portalBackdropFadeOut)

    local targetAlpha = DUNGEON_TILE_UI.portalBackdropAlpha or 0.58
    if immediate then
        backdrop:SetAlpha(visible and targetAlpha or 0)
        return
    end

    if visible then
        backdrop:SetAlpha(0)
        if tile.portalBackdropFadeIn then
            tile.portalBackdropFadeIn:Play()
        else
            backdrop:SetAlpha(targetAlpha)
        end
    else
        backdrop:SetAlpha(targetAlpha)
        if tile.portalBackdropFadeOut then
            tile.portalBackdropFadeOut:Play()
        else
            backdrop:SetAlpha(0)
        end
    end
end

local function SetDungeonTilePortalEffectSpriteFrame(tile, frameIndex)
    local texture = tile and tile.portalEffectTexture
    if not texture then
        return
    end

    local columns = DUNGEON_TILE_UI.portalEffectFrameColumns or 1
    local rows = DUNGEON_TILE_UI.portalEffectFrameRows or 1
    local frameCount = DUNGEON_TILE_UI.portalEffectFrameCount or (columns * rows)
    local index = math.max(1, math.min(frameCount, tonumber(frameIndex) or 1))
    local zeroBasedIndex = index - 1
    local column = zeroBasedIndex % columns
    local row = math.floor(zeroBasedIndex / columns)

    texture:SetTexCoord(
        column / columns,
        (column + 1) / columns,
        row / rows,
        (row + 1) / rows
    )
    tile.portalEffectSpriteFrame = index
end

local function ResetDungeonTilePortalEffectSprite(tile)
    if not tile then
        return
    end

    tile.portalEffectSpriteElapsed = 0
    SetDungeonTilePortalEffectSpriteFrame(tile, 1)
end

local function UpdateDungeonTilePortalEffectSprite(tile, elapsed)
    if not (tile and tile.portalEffectActive and tile.portalEffectTexture) then
        return
    end

    local frameSeconds = DUNGEON_TILE_UI.portalEffectFrameSeconds or 0.04
    if frameSeconds <= 0 then
        return
    end

    local frameCount = DUNGEON_TILE_UI.portalEffectFrameCount or 1
    if frameCount <= 1 then
        return
    end

    local spriteElapsed = (tile.portalEffectSpriteElapsed or 0) + (elapsed or 0)
    if spriteElapsed < frameSeconds then
        tile.portalEffectSpriteElapsed = spriteElapsed
        return
    end

    local steps = math.floor(spriteElapsed / frameSeconds)
    tile.portalEffectSpriteElapsed = spriteElapsed - (steps * frameSeconds)
    SetDungeonTilePortalEffectSpriteFrame(tile, (((tile.portalEffectSpriteFrame or 1) - 1 + steps) % frameCount) + 1)
end

local function SetDungeonTilePortalEffectAlpha(tile, visible, immediate)
    local frame = tile and tile.portalEffectFrame
    if not frame then
        return
    end

    StopDungeonTileAnimationGroup(tile.portalEffectFadeIn)
    StopDungeonTileAnimationGroup(tile.portalEffectFadeOut)

    if visible then
        ResetDungeonTilePortalEffectSprite(tile)
        if immediate then
            frame:SetAlpha(1)
            return
        end
        frame:SetAlpha(0)
        if tile.portalEffectFadeIn then
            tile.portalEffectFadeIn:Play()
        else
            frame:SetAlpha(1)
        end
    else
        ResetDungeonTilePortalEffectSprite(tile)
        if immediate then
            frame:SetAlpha(0)
            return
        end
        frame:SetAlpha(1)
        if tile.portalEffectFadeOut then
            tile.portalEffectFadeOut:Play()
        else
            frame:SetAlpha(0)
        end
    end
end

local function SetDungeonTilePortalHover(tile, active, immediate)
    if not tile then
        return
    end

    local castVisualActive = IsDungeonTileTeleportCastVisualActive(tile)
    local shouldShowPortal = castVisualActive or (active and IsDungeonTilePortalEffectAvailable(tile))
    tile.portalEffectCastVisualActive = castVisualActive
    if tile.portalEffectActive == shouldShowPortal and not immediate then
        return
    end

    tile.portalEffectActive = shouldShowPortal and true or false
    SetDungeonTilePortalBackdropAlpha(tile, tile.portalEffectActive, immediate)
    SetDungeonTileBestLevelAlpha(tile, not tile.portalEffectActive, immediate)
    SetDungeonTilePortalEffectAlpha(tile, tile.portalEffectActive, immediate)
end

local UpdateDungeonTileSelectedState

function MK:RefreshDungeonTeleportCastVisualTiles(reason)
    local dungeonTiles = self.widgets and self.widgets.dungeonTiles or nil
    if not dungeonTiles then
        return false
    end

    for _, tile in ipairs(dungeonTiles) do
        local isMouseOver = tile.IsMouseOver and tile:IsMouseOver()
        SetDungeonTilePortalHover(tile, isMouseOver, false)
        if UpdateDungeonTileSelectedState then
            UpdateDungeonTileSelectedState(tile, IsDungeonTileTeleportCastVisualActive(tile))
        end
    end

    if self.DebugLog then
        self:DebugLog(
            "RefreshDungeonTeleportCastVisualTiles mapID=%s reason=%s",
            tostring(self.activeDungeonTeleportCastVisualMapID),
            tostring(reason)
        )
    end
    return true
end

function MK:SetDungeonTeleportCastVisualState(mapID, active, reason)
    mapID = tonumber(mapID)
    if not mapID then
        return false
    end

    if active then
        local now = GetTime and GetTime() or 0
        self.activeDungeonTeleportCastVisualMapID = mapID
        self.activeDungeonTeleportCastVisualExpiresAt = now + (DUNGEON_TILE_UI.teleportCastVisualTimeoutSeconds or 16)
    elseif tonumber(self.activeDungeonTeleportCastVisualMapID) == mapID then
        self.activeDungeonTeleportCastVisualMapID = nil
        self.activeDungeonTeleportCastVisualExpiresAt = nil
    else
        return false
    end

    return self:RefreshDungeonTeleportCastVisualTiles(reason)
end

local function UpdateDungeonTileBestLevel(tile, level, timed)
    if not tile or not tile.bestLevelFrame or not tile.bestBadge or not tile.bestText then
        return
    end

    local bestLevel = tonumber(level) or 0
    if bestLevel <= 0 then
        tile.bestLevelAvailable = false
        StopDungeonTileAnimationGroup(tile.bestLevelFadeIn)
        StopDungeonTileAnimationGroup(tile.bestLevelFadeOut)
        tile.bestLevelFrame:SetAlpha(0)
        tile.bestBadge:Hide()
        tile.bestText:Hide()
        return
    end

    tile.bestLevelAvailable = true
    local scale = tile.layoutScale or 1
    local badgeWidth = math.max(1, math.floor(DUNGEON_TILE_UI.bestBadgeWidth * scale + 0.5))
    local badgeHeight = math.max(1, math.floor(DUNGEON_TILE_UI.bestBadgeHeight * scale + 0.5))
    local badgeOffsetX = (DUNGEON_TILE_UI.bestBadgeOffsetX or 0) * scale
    local badgeOffsetY = (DUNGEON_TILE_UI.bestBadgeOffsetY or 0) * scale
    local textOffsetX = (DUNGEON_TILE_UI.bestTextOffsetX or 0) * scale
    local textOffsetY = (DUNGEON_TILE_UI.bestTextOffsetY or 0) * scale

    tile.bestLevelFrame:ClearAllPoints()
    tile.bestLevelFrame:SetPoint("CENTER", tile, "CENTER", badgeOffsetX, badgeOffsetY)
    tile.bestLevelFrame:SetSize(badgeWidth, badgeHeight)

    tile.bestBadge:ClearAllPoints()
    tile.bestBadge:SetAllPoints(tile.bestLevelFrame)

    tile.bestText:ClearAllPoints()
    tile.bestText:SetPoint("CENTER", tile.bestLevelFrame, "CENTER", textOffsetX, textOffsetY)
    tile.bestText:SetSize(
        math.max(1, math.floor(DUNGEON_TILE_UI.bestTextWidth * scale + 0.5)),
        math.max(1, math.floor(DUNGEON_TILE_UI.bestTextHeight * scale + 0.5))
    )
    tile.bestText:SetJustifyH("CENTER")
    tile.bestText:SetJustifyV("MIDDLE")
    tile.bestText:SetText(tostring(bestLevel))
    if timed == false then
        tile.bestText:SetTextColor(1, 0.22, 0.18)
    else
        tile.bestText:SetTextColor(1, 1, 1)
    end

    if core.ApplyFont then
        core.ApplyFont(tile.bestText, math.max(1, math.floor(DUNGEON_TILE_UI.bestFontSize * scale + 0.5)), "OUTLINE")
        tile.bestText:SetJustifyH("CENTER")
        tile.bestText:SetJustifyV("MIDDLE")
    end

    tile.bestBadge:Show()
    tile.bestText:Show()
    SetDungeonTileBestLevelAlpha(tile, not tile.portalEffectActive, true)
end

UpdateDungeonTileSelectedState = function(tile, selected)
    if not tile then
        return
    end

    local wasSelected = tile.isSelected
    local isAvailable = tile.dungeonAvailable ~= false
    tile.isSelected = selected and true or false
    if tile.selectedBorder then
        if tile.isSelected then
            tile.selectedBorder:Show()
            if not wasSelected and tile.selectedBorderFadeIn then
                tile.selectedBorderFadeIn:Stop()
                tile.selectedBorder:SetAlpha(0)
                tile.selectedBorderFadeIn:Play()
            else
                tile.selectedBorder:SetAlpha(1)
            end
        else
            if tile.selectedBorderFadeIn then
                tile.selectedBorderFadeIn:Stop()
            end
            tile.selectedBorder:SetAlpha(0)
            tile.selectedBorder:Hide()
        end
    end
    if tile.highlightTexture then
        tile.highlightTexture:SetAlpha((not isAvailable or tile.isSelected) and 0 or 1)
        if tile.SetHighlightTexture then
            tile:SetHighlightTexture(tile.highlightTexture)
        end
    end
end

local function PositionDungeonTileTeleportBadge(tile, pressed)
    if not tile or not tile.teleportBadge then
        return
    end

    local scale = tile.layoutScale or 1
    local offsetX = DUNGEON_TILE_UI.teleportBadgeRight
    local offsetY = DUNGEON_TILE_UI.teleportBadgeOffsetY
    if pressed then
        offsetX = offsetX + (DUNGEON_TILE_UI.pressOffsetX or 0)
        offsetY = offsetY + (DUNGEON_TILE_UI.pressOffsetY or 0)
    end

    tile.teleportBadge:ClearAllPoints()
    tile.teleportBadge:SetPoint(
        "RIGHT",
        tile.nameBackground or tile,
        "RIGHT",
        offsetX * scale,
        offsetY * scale
    )
end

local function SetDungeonTileTeleportBadgePressed(tile, pressed)
    if not tile or not tile.teleportBadge then
        return
    end

    if pressed and not IsDungeonTilePortalEffectAvailable(tile) then
        pressed = false
    end

    tile.teleportBadgePressed = pressed and true or false
    PositionDungeonTileTeleportBadge(tile, tile.teleportBadgePressed)
    if tile.teleportBadge.SetVertexColor and tile.teleportBadge:IsShown() then
        if tile.teleportBadgePressed then
            tile.teleportBadge:SetVertexColor(0.78, 0.78, 0.78, 0.95)
        else
            UpdateDungeonTeleportBadge(tile, tile.mapID or (tile.dungeonGroup and tile.dungeonGroup.mapID) or nil)
        end
    end
end

function MK:GetDungeonTileEntriesText(dungeonGroup)
    local parts = {}
    local levelEntries = {}

    if not dungeonGroup or not dungeonGroup.entries or #dungeonGroup.entries == 0 then
        return TRANSLATE("暂无钥匙")
    end

    for _, dungeonEntry in ipairs(dungeonGroup.entries) do
        local character = dungeonEntry.data
        local displayLevel, displayTrack = GetDungeonTileDisplayKeyInfo(character)
        levelEntries[#levelEntries + 1] = {
            keyLevel = displayLevel or 0,
            keystoneLink = character and character.keystoneLink or nil,
            keyUpgradeTrack = displayTrack or (character and character.keyUpgradeTrack) or nil,
        }
    end

    table.sort(levelEntries, function(left, right)
        return left.keyLevel > right.keyLevel
    end)

    local visibleEntries = math.min(#levelEntries, 6)

    for index = 1, visibleEntries do
        local entry = levelEntries[index]
        if entry.keyLevel and entry.keyLevel > 0 then
            parts[#parts + 1] = GetDungeonTileKeyText(entry)
        else
            parts[#parts + 1] = ColorizeDungeonTileKeyText(TRANSLATE("钥石"), DUNGEON_TILE_KEY_STYLE.colorIron)
        end
    end

    if #levelEntries > visibleEntries then
        parts[#parts + 1] = string.format("|cffcccccc%d|r", #levelEntries - visibleEntries)
    end

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

function MK:CreateDungeonTile(parentFrame)
    local tile = CreateFrame("Button", nil, parentFrame, "SecureActionButtonTemplate")
    tile:SetSize(DUNGEON_TILE_WIDTH, DUNGEON_TILE_HEIGHT)
    tile:RegisterForClicks("AnyUp", "AnyDown")
    ApplyDungeonTeleportButtonSecureState(tile, false, nil, nil)
    tile:Hide()

    local image = tile:CreateTexture(nil, "BACKGROUND")
    image:SetPoint("TOPLEFT", tile, "TOPLEFT", DUNGEON_TILE_IMAGE_LEFT, -DUNGEON_TILE_IMAGE_TOP)
    image:SetSize(DUNGEON_TILE_IMAGE_WIDTH, DUNGEON_TILE_IMAGE_HEIGHT)
    image:SetTexture("Interface\\Buttons\\WHITE8X8")
    image:SetVertexColor(0.08, 0.08, 0.08, 0.95)

    local portalEffectBackdrop = tile:CreateTexture(nil, "ARTWORK", nil, 0)
    portalEffectBackdrop:SetPoint("TOPLEFT", image, "TOPLEFT", 0, 0)
    portalEffectBackdrop:SetPoint("BOTTOMRIGHT", image, "BOTTOMRIGHT", 0, 0)
    if portalEffectBackdrop.SetColorTexture then
        portalEffectBackdrop:SetColorTexture(0, 0, 0, 1)
    else
        portalEffectBackdrop:SetTexture("Interface\\Buttons\\WHITE8X8")
        portalEffectBackdrop:SetVertexColor(0, 0, 0, 1)
    end
    portalEffectBackdrop:SetAlpha(0)
    portalEffectBackdrop:Show()

    local portalBackdropFadeIn = CreateDungeonTileAlphaAnimationGroup(portalEffectBackdrop, 0, DUNGEON_TILE_UI.portalBackdropAlpha, DUNGEON_TILE_UI.portalBackdropFadeInSeconds, "OUT")
    if portalBackdropFadeIn then
        portalBackdropFadeIn:SetScript("OnFinished", function()
            portalEffectBackdrop:SetAlpha(DUNGEON_TILE_UI.portalBackdropAlpha)
        end)
    end
    local portalBackdropFadeOut = CreateDungeonTileAlphaAnimationGroup(portalEffectBackdrop, DUNGEON_TILE_UI.portalBackdropAlpha, 0, DUNGEON_TILE_UI.portalBackdropFadeOutSeconds, "IN")
    if portalBackdropFadeOut then
        portalBackdropFadeOut:SetScript("OnFinished", function()
            portalEffectBackdrop:SetAlpha(0)
        end)
    end

    local topMask = tile:CreateTexture(nil, "ARTWORK", nil, 1)
    topMask:SetTexture(DUNGEON_TILE_UI.topMaskTexture)
    topMask:SetPoint("TOPLEFT", image, "TOPLEFT", 0, 0)
    topMask:SetPoint("TOPRIGHT", image, "TOPRIGHT", 0, 0)
    topMask:SetHeight(DUNGEON_TILE_UI.topMaskHeight)
    topMask:SetAlpha(1)

    local nameBackground = tile:CreateTexture(nil, "ARTWORK", nil, 1)
    if not TrySetAtlas(nameBackground, "campcollection-bg-text", true) then
        nameBackground:SetTexture("Interface\\Buttons\\WHITE8X8")
        nameBackground:SetVertexColor(0, 0, 0, 0.72)
    end
    nameBackground.mkBaseWidth = (nameBackground.GetWidth and nameBackground:GetWidth()) or DUNGEON_TILE_UI.nameBackgroundFallbackWidth
    nameBackground.mkBaseHeight = (nameBackground.GetHeight and nameBackground:GetHeight()) or DUNGEON_TILE_UI.nameBackgroundFallbackHeight
    if nameBackground.mkBaseWidth <= 0 then
        nameBackground.mkBaseWidth = DUNGEON_TILE_UI.nameBackgroundFallbackWidth
    end
    if nameBackground.mkBaseHeight <= 0 then
        nameBackground.mkBaseHeight = DUNGEON_TILE_UI.nameBackgroundFallbackHeight
    end
    nameBackground:SetSize(nameBackground.mkBaseWidth + DUNGEON_TILE_UI.nameBackgroundExtraWidth, nameBackground.mkBaseHeight)
    nameBackground:SetPoint("BOTTOM", tile, "BOTTOM", DUNGEON_TILE_UI.nameBackgroundOffsetX, DUNGEON_TILE_UI.nameBackgroundOffsetY)

    local border = tile:CreateTexture(nil, "ARTWORK", nil, 2)
    if not TrySetAtlas(border, "campcollection-frame", false) then
        border:SetTexture(nil)
    end
    border:SetPoint("TOPLEFT", tile, "TOPLEFT", DUNGEON_TILE_BORDER_LEFT, DUNGEON_TILE_BORDER_TOP)
    border:SetPoint("BOTTOMRIGHT", tile, "BOTTOMRIGHT", DUNGEON_TILE_BORDER_RIGHT, DUNGEON_TILE_BORDER_BOTTOM)

    local highlight = tile:CreateTexture(nil, "HIGHLIGHT")
    if not TrySetAtlas(highlight, "campcollection-frame-hover", false) then
        highlight:SetTexture(nil)
    end
    highlight:SetPoint("TOPLEFT", tile, "TOPLEFT", DUNGEON_TILE_BORDER_LEFT, DUNGEON_TILE_BORDER_TOP)
    highlight:SetPoint("BOTTOMRIGHT", tile, "BOTTOMRIGHT", DUNGEON_TILE_BORDER_RIGHT, DUNGEON_TILE_BORDER_BOTTOM)
    highlight:SetBlendMode("ADD")
    tile:SetHighlightTexture(highlight)

    local selectedBorder = tile:CreateTexture(nil, "ARTWORK", nil, 3)
    if not TrySetAtlas(selectedBorder, "campcollection-frame-hover", false) then
        selectedBorder:SetTexture(nil)
    end
    selectedBorder:SetPoint("TOPLEFT", tile, "TOPLEFT", DUNGEON_TILE_BORDER_LEFT, DUNGEON_TILE_BORDER_TOP)
    selectedBorder:SetPoint("BOTTOMRIGHT", tile, "BOTTOMRIGHT", DUNGEON_TILE_BORDER_RIGHT, DUNGEON_TILE_BORDER_BOTTOM)
    selectedBorder:SetBlendMode("ADD")
    selectedBorder:SetVertexColor(1, 0.72, 0.05, 1)
    selectedBorder:SetAlpha(0)
    selectedBorder:Hide()

    local selectedBorderFadeIn = selectedBorder:CreateAnimationGroup()
    local selectedBorderFade = selectedBorderFadeIn:CreateAnimation("Alpha")
    selectedBorderFade:SetFromAlpha(0)
    selectedBorderFade:SetToAlpha(1)
    selectedBorderFade:SetDuration(0.18)
    selectedBorderFade:SetSmoothing("OUT")
    selectedBorderFadeIn:SetScript("OnFinished", function()
        selectedBorder:SetAlpha(1)
    end)

    local title = tile:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    title:SetPoint("CENTER", nameBackground, "CENTER", 0, 0)
    title:SetSize(DUNGEON_TILE_NAME_TEXT_WIDTH, DUNGEON_TILE_NAME_TEXT_HEIGHT)
    title:SetJustifyH("CENTER")
    title:SetJustifyV("MIDDLE")
    title:SetTextColor(1, 1, 1)
    title:SetWordWrap(true)
    if title.SetSpacing then
        title:SetSpacing(2)
    end

    local bestLevelFrame = CreateFrame("Frame", nil, tile)
    bestLevelFrame:SetFrameLevel(tile:GetFrameLevel() + 6)
    bestLevelFrame:SetPoint("CENTER", tile, "CENTER", DUNGEON_TILE_UI.bestBadgeOffsetX, DUNGEON_TILE_UI.bestBadgeOffsetY)
    bestLevelFrame:SetSize(DUNGEON_TILE_UI.bestBadgeWidth, DUNGEON_TILE_UI.bestBadgeHeight)
    bestLevelFrame:SetAlpha(0)
    bestLevelFrame:Show()

    local bestLevelFadeIn = CreateDungeonTileAlphaAnimationGroup(bestLevelFrame, 0, 1, DUNGEON_TILE_UI.bestLevelFadeInSeconds, "OUT")
    if bestLevelFadeIn then
        bestLevelFadeIn:SetScript("OnFinished", function()
            bestLevelFrame:SetAlpha(1)
        end)
    end
    local bestLevelFadeOut = CreateDungeonTileAlphaAnimationGroup(bestLevelFrame, 1, 0, DUNGEON_TILE_UI.bestLevelFadeOutSeconds, "IN")
    if bestLevelFadeOut then
        bestLevelFadeOut:SetScript("OnFinished", function()
            bestLevelFrame:SetAlpha(0)
        end)
    end

    local bestBadge = bestLevelFrame:CreateTexture(nil, "OVERLAY", nil, 0)
    bestBadge:SetTexture(DUNGEON_TILE_UI.bestBadgeTexture)
    bestBadge:SetAllPoints(bestLevelFrame)
    bestBadge:Hide()

    local bestText = bestLevelFrame:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
    bestText:SetPoint("CENTER", bestLevelFrame, "CENTER", DUNGEON_TILE_UI.bestTextOffsetX, DUNGEON_TILE_UI.bestTextOffsetY)
    bestText:SetSize(DUNGEON_TILE_UI.bestTextWidth, DUNGEON_TILE_UI.bestTextHeight)
    bestText:SetJustifyH("CENTER")
    bestText:SetJustifyV("MIDDLE")
    bestText:SetTextColor(1, 1, 1)
    if core.ApplyFont then
        core.ApplyFont(bestText, DUNGEON_TILE_UI.bestFontSize, "OUTLINE")
        bestText:SetJustifyH("CENTER")
        bestText:SetJustifyV("MIDDLE")
    end
    bestText:Hide()

    local portalEffectFrame = CreateFrame("Frame", nil, tile)
    portalEffectFrame:SetFrameLevel(tile:GetFrameLevel() + 7)
    portalEffectFrame:SetPoint("CENTER", tile, "CENTER", DUNGEON_TILE_UI.portalEffectOffsetX, DUNGEON_TILE_UI.portalEffectOffsetY)
    portalEffectFrame:SetSize(DUNGEON_TILE_UI.portalEffectWidth, DUNGEON_TILE_UI.portalEffectHeight)
    portalEffectFrame:SetAlpha(0)
    portalEffectFrame:Show()

    local portalEffectTexture = portalEffectFrame:CreateTexture(nil, "OVERLAY")
    portalEffectTexture:SetAllPoints(portalEffectFrame)
    portalEffectTexture:SetTexture(DUNGEON_TILE_UI.portalEffectTexture, false)
    portalEffectTexture:SetTexCoord(0, 1 / DUNGEON_TILE_UI.portalEffectFrameColumns, 0, 1 / DUNGEON_TILE_UI.portalEffectFrameRows)

    local portalEffectFadeIn = CreateDungeonTileAlphaAnimationGroup(portalEffectFrame, 0, 1, DUNGEON_TILE_UI.portalEffectFadeInSeconds, "OUT")
    if portalEffectFadeIn then
        portalEffectFadeIn:SetScript("OnFinished", function()
            portalEffectFrame:SetAlpha(1)
        end)
    end
    local portalEffectFadeOut = CreateDungeonTileAlphaAnimationGroup(portalEffectFrame, 1, 0, DUNGEON_TILE_UI.portalEffectFadeOutSeconds, "IN")
    if portalEffectFadeOut then
        portalEffectFadeOut:SetScript("OnFinished", function()
            portalEffectFrame:SetAlpha(0)
        end)
    end

    local teleportBadge = tile:CreateTexture(nil, "OVERLAY", nil, 2)
    teleportBadge:SetPoint("RIGHT", nameBackground, "RIGHT", DUNGEON_TILE_UI.teleportBadgeRight, DUNGEON_TILE_UI.teleportBadgeOffsetY)
    teleportBadge:SetSize(DUNGEON_TILE_UI.teleportBadgeSize, DUNGEON_TILE_UI.teleportBadgeSize)
    if teleportBadge.SetAtlas then
        teleportBadge:SetAtlas("MagePortalAlliance", false)
    else
        teleportBadge:SetTexture("Interface\\Buttons\\UI-GroupLoot-Pass-Up")
    end
    teleportBadge:Hide()

    local keySummary = CreateFrame("Frame", nil, tile)
    keySummary:SetPoint("CENTER", topMask, "CENTER", 0, DUNGEON_TILE_UI.keySummaryOffsetY)
    keySummary:SetSize(DUNGEON_TILE_UI.keySummaryMaxWidth, DUNGEON_TILE_UI.keySummaryHeight)
    keySummary:Hide()

    local keyIconOuterBorder = keySummary:CreateTexture(nil, "OVERLAY", nil, 2)
    keyIconOuterBorder:SetTexture(DUNGEON_TILE_UI.keystoneIconBorderTexture)
    keyIconOuterBorder:SetVertexColor(1, 0.82, 0, 1)
    keyIconOuterBorder:SetSize(
        DUNGEON_TILE_UI.keystoneIconSize + DUNGEON_TILE_UI.keystoneIconOuterBorderPadding,
        DUNGEON_TILE_UI.keystoneIconSize + DUNGEON_TILE_UI.keystoneIconOuterBorderPadding
    )
    keyIconOuterBorder:Hide()

    local keyIcon = keySummary:CreateTexture(nil, "OVERLAY", nil, 1)
    keyIcon:SetTexture(DUNGEON_TILE_UI.keystoneIconTexture)
    local initialKeyIconSize = math.max(1, DUNGEON_TILE_UI.keystoneIconSize - (DUNGEON_TILE_UI.keystoneIconInset or 0))
    keyIcon:SetSize(
        initialKeyIconSize,
        initialKeyIconSize
    )
    keyIcon:Hide()

    local entries = keySummary:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    entries:SetPoint("CENTER", keySummary, "CENTER", 0, 0)
    entries:SetSize(DUNGEON_TILE_UI.keySummaryMaxWidth - ((DUNGEON_TILE_UI.keystoneIconSize + DUNGEON_TILE_UI.keystoneIconGap) * 2), DUNGEON_TILE_UI.keySummaryHeight)
    entries:SetJustifyH("CENTER")
    entries:SetJustifyV("MIDDLE")
    entries:SetTextColor(1, 1, 1)
    keyIcon:SetPoint("RIGHT", entries, "LEFT", -DUNGEON_TILE_UI.keystoneIconGap, 0)
    keyIconOuterBorder:SetPoint("CENTER", keyIcon, "CENTER", 0, 0)
    if entries.SetWordWrap then
        entries:SetWordWrap(false)
    end
    if core.ApplyFont then
        core.ApplyFont(entries, 11, "OUTLINE")
    end
    entries:Hide()

    tile:SetScript("OnEnter", function(frame)
        local dungeonGroup = frame.dungeonGroup
        if not dungeonGroup then
            return
        end

        SetDungeonTilePortalHover(frame, true)
        GameTooltip:SetOwner(frame, "ANCHOR_RIGHT")
        GameTooltip:AddLine(dungeonGroup.dungeonName, 1, 0.82, 0)
        AddDungeonTooltipScoreLine(GameTooltip, dungeonGroup.mapID, { showEmpty = true })
        AddDungeonTeleportTooltipLine(GameTooltip, dungeonGroup.mapID, { includeDestinationTitle = false })

        if #dungeonGroup.entries == 0 then
            GameTooltip:AddLine(TRANSLATE("队伍中没有钥石"), 0.85, 0.85, 0.85)
        else
            for _, dungeonEntry in ipairs(dungeonGroup.entries) do
                local character = dungeonEntry.data
                local ownerName = self:GetCharacterDisplayNameColored(character)
                local keyText = GetDungeonTileKeyText(character, true)

                if keyText then
                    GameTooltip:AddLine(string.format("%s  %s", ownerName, keyText), 1, 1, 1)
                else
                    GameTooltip:AddLine(ownerName, 1, 1, 1)
                end
            end
        end

        GameTooltip:Show()
    end)
    tile:HookScript("OnMouseDown", function(frame, mouseButton)
        if mouseButton == "LeftButton" then
            SetDungeonTileTeleportBadgePressed(frame, true)
        end

        local dungeonGroup = frame.dungeonGroup
        local mapID = dungeonGroup and dungeonGroup.mapID or nil
        local spellID, spellName, status, secureType, secureSpell = GetDungeonTeleportDebugFields(frame, mapID)

        self:DebugLog(
            "DungeonTile OnMouseDown button=%s mapID=%s spellID=%s spellName=%s status=%s secureType=%s secureSpell=%s protected=%s combat=%s",
            tostring(mouseButton),
            tostring(mapID),
            tostring(spellID),
            tostring(spellName),
            tostring(status),
            tostring(secureType),
            tostring(secureSpell),
            tostring(frame.IsProtected and frame:IsProtected()),
            tostring(InCombatLockdown and InCombatLockdown())
        )
    end)
    tile:HookScript("OnMouseUp", function(frame, mouseButton)
        if mouseButton == "LeftButton" then
            SetDungeonTileTeleportBadgePressed(frame, false)
        end

        local dungeonGroup = frame.dungeonGroup
        local mapID = dungeonGroup and dungeonGroup.mapID or nil
        local spellID, spellName, status, secureType, secureSpell = GetDungeonTeleportDebugFields(frame, mapID)
        self:DebugLog(
            "DungeonTile OnMouseUp button=%s mapID=%s spellID=%s spellName=%s status=%s secureType=%s secureSpell=%s",
            tostring(mouseButton),
            tostring(mapID),
            tostring(spellID),
            tostring(spellName),
            tostring(status),
            tostring(secureType),
            tostring(secureSpell)
        )
    end)
    tile:HookScript("OnClick", function(frame, mouseButton)
        local dungeonGroup = frame.dungeonGroup
        local mapID = dungeonGroup and dungeonGroup.mapID or nil
        local spellID, spellName, status, secureType, secureSpell = GetDungeonTeleportDebugFields(frame, mapID)
        self:DebugLog(
            "DungeonTile OnClick button=%s mapID=%s spellID=%s spellName=%s status=%s secureType=%s secureSpell=%s",
            tostring(mouseButton),
            tostring(mapID),
            tostring(spellID),
            tostring(spellName),
            tostring(status),
            tostring(secureType),
            tostring(secureSpell)
        )
    end)
    tile:SetScript("OnLeave", function(frame)
        SetDungeonTilePortalHover(frame, false)
        SetDungeonTileTeleportBadgePressed(frame, false)
        GameTooltip:Hide()
    end)
    tile:SetScript("OnUpdate", function(frame, elapsed)
        UpdateDungeonTilePortalEffectSprite(frame, elapsed)

        frame.teleportBadgeElapsed = (frame.teleportBadgeElapsed or 0) + (elapsed or 0)
        if frame.teleportBadgeElapsed < DUNGEON_TILE_UI.teleportRefreshSeconds then
            return
        end

        frame.teleportBadgeElapsed = 0
        UpdateDungeonTeleportBadge(frame, frame.mapID or (frame.dungeonGroup and frame.dungeonGroup.mapID) or nil)
        if frame.portalEffectCastVisualActive
            or IsDungeonTileTeleportCastVisualActive(frame)
            or frame.IsMouseOver
        then
            SetDungeonTilePortalHover(frame, frame:IsMouseOver())
            UpdateDungeonTileSelectedState(frame, IsDungeonTileTeleportCastVisualActive(frame))
        end
    end)

    tile.image = image
    tile.portalEffectBackdrop = portalEffectBackdrop
    tile.portalBackdropFadeIn = portalBackdropFadeIn
    tile.portalBackdropFadeOut = portalBackdropFadeOut
    tile.topMask = topMask
    tile.nameBackground = nameBackground
    tile.border = border
    tile.highlightTexture = highlight
    tile.selectedBorder = selectedBorder
    tile.selectedBorderFadeIn = selectedBorderFadeIn
    tile.title = title
    tile.bestLevelFrame = bestLevelFrame
    tile.bestLevelFadeIn = bestLevelFadeIn
    tile.bestLevelFadeOut = bestLevelFadeOut
    tile.bestBadge = bestBadge
    tile.bestText = bestText
    tile.portalEffectFrame = portalEffectFrame
    tile.portalEffectTexture = portalEffectTexture
    tile.portalEffectFadeIn = portalEffectFadeIn
    tile.portalEffectFadeOut = portalEffectFadeOut
    tile.keySummary = keySummary
    tile.keyIconOuterBorder = keyIconOuterBorder
    tile.keyIcon = keyIcon
    tile.entries = entries
    tile.teleportBadge = teleportBadge
    tile.mkSecureAttributesApplied = function(frame)
        frame.mkTeleportReady = frame.mkTeleportDesiredReady == true
        frame.mapID = frame.mkTeleportEffectiveMapID or frame.mapID
        frame.mkTeleportSecurePending = nil
        UpdateDungeonTeleportBadge(frame, frame.mapID)
        if frame.IsMouseOver then
            SetDungeonTilePortalHover(frame, frame:IsMouseOver(), true)
        end
        UpdateDungeonTileSelectedState(frame, IsDungeonTileTeleportCastVisualActive(frame))
    end
    AttachDungeonTeleportDialogTrigger(self, tile, function(frame)
        return frame.mapID or (frame.dungeonGroup and frame.dungeonGroup.mapID) or nil
    end)

    return tile
end

local function ApplyDungeonTileLayout(tile, width, height, scale)
    if not tile then
        return
    end

    tile:SetSize(width, height)
    tile.layoutScale = scale

    if tile.image then
        tile.image:SetSize(
            math.max(1, math.floor(DUNGEON_TILE_IMAGE_WIDTH * scale + 0.5)),
            math.max(1, math.floor(DUNGEON_TILE_IMAGE_HEIGHT * scale + 0.5))
        )
    end
    if tile.portalEffectBackdrop and tile.image then
        tile.portalEffectBackdrop:ClearAllPoints()
        tile.portalEffectBackdrop:SetPoint("TOPLEFT", tile.image, "TOPLEFT", 0, 0)
        tile.portalEffectBackdrop:SetPoint("BOTTOMRIGHT", tile.image, "BOTTOMRIGHT", 0, 0)
    end
    if tile.topMask then
        tile.topMask:ClearAllPoints()
        tile.topMask:SetPoint("TOPLEFT", tile, "TOPLEFT", DUNGEON_TILE_IMAGE_LEFT * scale, -DUNGEON_TILE_IMAGE_TOP * scale)
        tile.topMask:SetPoint(
            "TOPRIGHT",
            tile,
            "TOPLEFT",
            (DUNGEON_TILE_IMAGE_LEFT + DUNGEON_TILE_IMAGE_WIDTH) * scale,
            -DUNGEON_TILE_IMAGE_TOP * scale
        )
        tile.topMask:SetHeight(math.max(1, math.floor(DUNGEON_TILE_UI.topMaskHeight * scale + 0.5)))
    end
    if tile.nameBackground then
        tile.nameBackground:ClearAllPoints()
        tile.nameBackground:SetPoint(
            "BOTTOM",
            tile,
            "BOTTOM",
            DUNGEON_TILE_UI.nameBackgroundOffsetX * scale,
            DUNGEON_TILE_UI.nameBackgroundOffsetY * scale
        )
        tile.nameBackground:SetSize(
            math.max(1, math.floor(((tile.nameBackground.mkBaseWidth or DUNGEON_TILE_UI.nameBackgroundFallbackWidth) + DUNGEON_TILE_UI.nameBackgroundExtraWidth) * scale + 0.5)),
            math.max(1, math.floor((tile.nameBackground.mkBaseHeight or DUNGEON_TILE_UI.nameBackgroundFallbackHeight) * scale + 0.5))
        )
    end
    if tile.border then
        tile.border:ClearAllPoints()
        tile.border:SetPoint("TOPLEFT", tile, "TOPLEFT", DUNGEON_TILE_BORDER_LEFT, DUNGEON_TILE_BORDER_TOP)
        tile.border:SetPoint("BOTTOMRIGHT", tile, "BOTTOMRIGHT", DUNGEON_TILE_BORDER_RIGHT, DUNGEON_TILE_BORDER_BOTTOM)
    end
    if tile.highlightTexture then
        tile.highlightTexture:ClearAllPoints()
        tile.highlightTexture:SetPoint("TOPLEFT", tile, "TOPLEFT", DUNGEON_TILE_BORDER_LEFT, DUNGEON_TILE_BORDER_TOP)
        tile.highlightTexture:SetPoint("BOTTOMRIGHT", tile, "BOTTOMRIGHT", DUNGEON_TILE_BORDER_RIGHT, DUNGEON_TILE_BORDER_BOTTOM)
    end
    if tile.selectedBorder then
        tile.selectedBorder:ClearAllPoints()
        tile.selectedBorder:SetPoint("TOPLEFT", tile, "TOPLEFT", DUNGEON_TILE_BORDER_LEFT, DUNGEON_TILE_BORDER_TOP)
        tile.selectedBorder:SetPoint("BOTTOMRIGHT", tile, "BOTTOMRIGHT", DUNGEON_TILE_BORDER_RIGHT, DUNGEON_TILE_BORDER_BOTTOM)
    end
    if tile.title then
        tile.title:ClearAllPoints()
        tile.title:SetPoint("CENTER", tile.nameBackground or tile, "CENTER", 0, 0)
        tile.title:SetSize(
            math.max(1, math.floor(DUNGEON_TILE_NAME_TEXT_WIDTH * scale + 0.5)),
            math.max(1, math.floor(DUNGEON_TILE_NAME_TEXT_HEIGHT * scale + 0.5))
        )
        tile.title:SetJustifyH("CENTER")
        tile.title:SetJustifyV("MIDDLE")
        tile.title:SetTextColor(1, 1, 1)
    end

    if tile.portalEffectFrame then
        tile.portalEffectFrame:ClearAllPoints()
        tile.portalEffectFrame:SetPoint(
            "CENTER",
            tile,
            "CENTER",
            (DUNGEON_TILE_UI.portalEffectOffsetX or 0) * scale,
            (DUNGEON_TILE_UI.portalEffectOffsetY or 0) * scale
        )
        tile.portalEffectFrame:SetSize(
            math.max(1, math.floor((DUNGEON_TILE_UI.portalEffectWidth or 1) * scale + 0.5)),
            math.max(1, math.floor((DUNGEON_TILE_UI.portalEffectHeight or 1) * scale + 0.5))
        )
    end

    if tile.teleportBadge then
        PositionDungeonTileTeleportBadge(tile, tile.teleportBadgePressed)
        tile.teleportBadge:SetSize(
            math.max(1, math.floor(DUNGEON_TILE_UI.teleportBadgeSize * scale + 0.5)),
            math.max(1, math.floor(DUNGEON_TILE_UI.teleportBadgeSize * scale + 0.5))
        )
    end

    UpdateDungeonTileBestLevel(tile, tile.playerBestLevel, tile.playerBestTimed)

    if tile.entries then
        if tile.entries.SetWordWrap then
            tile.entries:SetWordWrap(false)
        end
        if core.ApplyFont then
            core.ApplyFont(tile.entries, 11, "OUTLINE")
        end
    end

    UpdateDungeonTileKeySummary(tile)
end

function MK:EnsureDungeonTiles(requiredCount)
    local dungeonTiles = self.widgets.dungeonTiles
    local parentFrame = self.widgets.dungeonContentFrame or self.widgets.dungeonContainer
    if not dungeonTiles or not parentFrame then
        return
    end

    while #dungeonTiles < requiredCount do
        dungeonTiles[#dungeonTiles + 1] = self:CreateDungeonTile(parentFrame)
    end

    for _, tile in ipairs(dungeonTiles) do
        if tile:GetParent() ~= parentFrame then
            tile:SetParent(parentFrame)
        end
    end
end

function MK:RefreshDungeonTileSelection()
    local dungeonTiles = self.widgets and self.widgets.dungeonTiles or nil
    if not dungeonTiles then
        return
    end

    for _, tile in ipairs(dungeonTiles) do
        UpdateDungeonTileSelectedState(tile, IsDungeonTileTeleportCastVisualActive(tile))
    end
end

function MK:LayoutDungeonTiles(tileCount)
    local dungeonContentFrame = self.widgets and self.widgets.dungeonContentFrame
    local dungeonContainer = self.widgets.dungeonContainer
    local dungeonTiles = self.widgets.dungeonTiles or {}
    if not dungeonContentFrame or not dungeonContainer then
        return
    end

    local availableWidth = math.max(dungeonContentFrame:GetWidth() or 0, 1)
    local availableHeight = math.max(dungeonContentFrame:GetHeight() or 0, 1)
    local columns = math.min(math.max(tileCount, 1), DUNGEON_TILE_COLUMNS)
    local rows = math.max(1, math.ceil(tileCount / DUNGEON_TILE_COLUMNS))
    local tileWidth = DUNGEON_TILE_WIDTH
    local tileHeight = DUNGEON_TILE_HEIGHT
    local gapX = DUNGEON_TILE_GAP_X
    local gapY = DUNGEON_TILE_GAP_Y

    local freeWidth = availableWidth - (columns * tileWidth)
    if freeWidth > 0 then
        gapX = math.max(0, math.floor((freeWidth / (columns + 1)) + 0.5))
    end

    local freeHeight = availableHeight - (rows * tileHeight)
    if freeHeight > 0 then
        gapY = math.max(0, math.floor((freeHeight / (rows + 1)) + 0.5))
    end

    local gridWidth = (columns * tileWidth) + (math.max(0, columns - 1) * gapX)
    local totalHeight = rows * tileHeight + math.max(0, rows - 1) * gapY
    local startX = math.floor(((availableWidth - gridWidth) * 0.5) + 0.5)
    local startY = -math.floor(((availableHeight - totalHeight) * 0.5) + 0.5)
    startY = math.min(0, startY + (core.DUNGEON_TILE_GRID_OFFSET_Y or 0))

    dungeonContainer:ClearAllPoints()
    dungeonContainer:SetSize(gridWidth, totalHeight)
    dungeonContainer:SetPoint("TOPLEFT", dungeonContentFrame, "TOPLEFT", startX, startY)

    for index = 1, tileCount do
        local tile = dungeonTiles[index]
        local column = (index - 1) % DUNGEON_TILE_COLUMNS
        local row = math.floor((index - 1) / DUNGEON_TILE_COLUMNS)
        local xOffset = column * (tileWidth + gapX)
        local yOffset = -(row * (tileHeight + gapY))

        tile:ClearAllPoints()
        tile:SetPoint("TOPLEFT", dungeonContentFrame, "TOPLEFT", startX + xOffset, startY + yOffset)
        ApplyDungeonTileLayout(tile, tileWidth, tileHeight, 1)
    end
end

function MK:RefreshDungeonTile(tile, dungeonGroup)
    if InCombatLockdown and InCombatLockdown()
        and tile
        and tile.IsProtected
        and tile:IsProtected()
        and tile.mapID
        and dungeonGroup
        and dungeonGroup.mapID
        and tonumber(tile.mapID) ~= tonumber(dungeonGroup.mapID)
    then
        if self.DebugLog then
            self:DebugLog(
                "RefreshDungeonTile skipped protected remap in combat currentMapID=%s nextMapID=%s",
                tostring(tile.mapID),
                tostring(dungeonGroup.mapID)
            )
        end
        self.dungeonTabRefreshAfterCombat = true
        UpdateDungeonTeleportBadge(tile, tile.mapID)
        return
    end

    local imageTexture, imageTexCoords, _, imageZoom = self:GetDungeonJournalTexture(dungeonGroup.mapID)
    local teleportSpellID = GetDungeonTeleportSpellID(dungeonGroup.mapID)
    local teleportSpellName = GetDungeonTeleportSpellName(dungeonGroup.mapID)
    local teleportMacroText = GetDungeonTeleportMacroText(dungeonGroup.mapID)
    local teleportStatus = GetDungeonTeleportStatus(dungeonGroup.mapID)
    local hasPlayerRecord = DungeonGroupHasPlayerRecord(dungeonGroup)
    local canUseTeleport = teleportStatus == "ready" and teleportMacroText ~= nil
    tile.dungeonGroup = dungeonGroup
    tile.mapID = dungeonGroup.mapID
    tile.dungeonAvailable = hasPlayerRecord
    tile.playerBestLevel = dungeonGroup.playerBestLevel
    tile.playerBestTimed = dungeonGroup.playerBestTimed
    tile.title:SetText(dungeonGroup.dungeonName)
    tile.title:SetTextColor(hasPlayerRecord and 1 or 0.58, hasPlayerRecord and 1 or 0.58, hasPlayerRecord and 1 or 0.58)
    tile.entries:SetText(self:GetDungeonTileEntriesText(dungeonGroup))
    UpdateDungeonTileKeySummary(tile)
    UpdateDungeonTileBestLevel(tile, tile.playerBestLevel, tile.playerBestTimed)
    ApplyDungeonTeleportButtonSecureState(tile, canUseTeleport, teleportMacroText, dungeonGroup.mapID)
    UpdateDungeonTeleportBadge(tile, dungeonGroup.mapID)
    UpdateDungeonTileSelectedState(tile, IsDungeonTileTeleportCastVisualActive(tile))
    if tile.IsMouseOver then
        SetDungeonTilePortalHover(tile, tile:IsMouseOver(), true)
    else
        SetDungeonTilePortalHover(tile, false, true)
    end
    self:DebugLog(
        "RefreshDungeonTile mapID=%s dungeon=%s spellID=%s spellName=%s secureType=%s secureMacro=%s status=%s",
        tostring(dungeonGroup.mapID),
        tostring(dungeonGroup.dungeonName),
        tostring(teleportSpellID),
        tostring(teleportSpellName),
        tostring(tile:GetAttribute("type1")),
        tostring(tile:GetAttribute("macrotext1")),
        tostring(GetDungeonTeleportStatus(dungeonGroup.mapID))
    )

    if imageTexture then
        tile.image:SetTexture(imageTexture)
        ApplyDungeonTileImageTexCoords(tile.image, imageTexCoords, imageZoom)
        if tile.image.SetDesaturated then
            tile.image:SetDesaturated(not hasPlayerRecord)
        end
        if hasPlayerRecord then
            tile.image:SetVertexColor(1, 1, 1, 1)
        else
            tile.image:SetVertexColor(0.42, 0.42, 0.42, 0.9)
        end
    else
        tile.image:SetTexture("Interface\\Buttons\\WHITE8X8")
        ApplyDungeonTileImageTexCoords(tile.image, nil, 1)
        tile.image:SetVertexColor(0.08, 0.08, 0.08, 0.95)
    end

    tile:Show()
end

function MK:SetDungeonTileVisible(tile, visible)
    if not tile then
        return
    end

    if visible then
        tile:Show()
        return
    end

    if InCombatLockdown and InCombatLockdown()
        and tile.IsProtected
        and tile:IsProtected()
    then
        if self.DebugLog then
            self:DebugLog("SetDungeonTileVisible skipped protected hide in combat mapID=%s", tostring(tile.mapID))
        end
        self.dungeonTabRefreshAfterCombat = true
        UpdateDungeonTeleportBadge(tile, tile.mapID)
        return
    end

    tile.dungeonGroup = nil
    tile.mapID = nil
    tile.dungeonAvailable = nil
    tile.playerBestLevel = nil
    tile.playerBestTimed = nil
    tile.teleportBadgePressed = false
    SetDungeonTilePortalHover(tile, false, true)
    ApplyDungeonTeleportButtonSecureState(tile, false, nil, nil)
    tile.title:SetText("")
    tile.title:SetTextColor(1, 1, 1)
    tile.entries:SetText("")
    UpdateDungeonTileKeySummary(tile)
    UpdateDungeonTileBestLevel(tile, nil, nil)
    tile.image:SetTexture("Interface\\Buttons\\WHITE8X8")
    if tile.image.SetDesaturated then
        tile.image:SetDesaturated(false)
    end
    ApplyDungeonTileImageTexCoords(tile.image, nil, 1)
    tile.image:SetVertexColor(0.08, 0.08, 0.08, 0.95)
    UpdateDungeonTileSelectedState(tile, false)
    UpdateDungeonTeleportBadge(tile, nil)
    tile:Hide()
end

function DungeonTab.Build(owner, tabGroup)
    local container = CreateFrame("Frame", nil, UIParent)
    tabGroup:AddChild(container)

    local dungeonContentFrame = CreateFrame("Frame", nil, container)
    dungeonContentFrame:ClearAllPoints()
    dungeonContentFrame:SetPoint("TOPLEFT", container, "TOPLEFT", 0, 0)
    dungeonContentFrame:SetPoint("BOTTOMRIGHT", container, "BOTTOMRIGHT", 0, 0)
    dungeonContentFrame:SetFrameLevel(container:GetFrameLevel() + 1)

    local dungeonContainer = CreateFrame("Frame", nil, dungeonContentFrame)
    dungeonContainer:SetPoint("CENTER", dungeonContentFrame, "CENTER", 0, 0)
    dungeonContainer:SetSize(1, 1)
    dungeonContainer:Show()

    local dungeonEmptyContainer = CreateFrame("Frame", nil, dungeonContentFrame)
    dungeonEmptyContainer:SetPoint("TOPLEFT", dungeonContainer, "BOTTOMLEFT", 0, -12)
    dungeonEmptyContainer:SetPoint("TOPRIGHT", dungeonContentFrame, "TOPRIGHT", -8, -12)
    dungeonEmptyContainer:SetHeight(28)

    local dungeonEmpty = dungeonEmptyContainer:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    dungeonEmpty:SetPoint("TOPLEFT", dungeonEmptyContainer, "TOPLEFT", 0, 0)
    dungeonEmpty:SetPoint("TOPRIGHT", dungeonEmptyContainer, "TOPRIGHT", 0, 0)
    dungeonEmpty:SetJustifyH("LEFT")
    dungeonEmpty:SetJustifyV("TOP")
    dungeonEmpty:SetText(TRANSLATE("队伍中没有找到钥石。"))
    if core.ApplyFont then
        core.ApplyFont(dungeonEmpty, 12, "")
    end

    owner.widgets = {
        tabGroup = owner.widgets and owner.widgets.tabGroup or tabGroup,
        root = container,
        dungeonContentFrame = dungeonContentFrame,
        dungeonContainer = dungeonContainer,
        dungeonEmptyContainer = dungeonEmptyContainer,
        dungeonEmpty = dungeonEmpty,
        dungeonTiles = {},
    }
    owner.tabViews = owner.tabViews or {}
    owner.tabViews.dungeons = owner.widgets

    dungeonContentFrame:SetScript("OnSizeChanged", function()
        if owner.activeTab == "dungeons" and owner.widgets and owner.widgets.dungeonContentFrame == dungeonContentFrame then
            local dungeonGroups = owner:GetCurrentSeasonDungeonGroups()
            owner:EnsureDungeonTiles(#dungeonGroups)
            owner:LayoutDungeonTiles(#dungeonGroups)
        end
    end)

    if core.ApplyThemeToTabGroup then
        core.ApplyThemeToTabGroup(tabGroup)
    end
    if core.ApplyFontToFrame then
        core.ApplyFontToFrame(container)
    end

    C_Timer.After(0, function()
        if core.FinalizeFlatTabLayout then
            core.FinalizeFlatTabLayout(tabGroup)
        end
        if owner.activeTab == "dungeons" and owner.widgets and owner.widgets.dungeonContentFrame == dungeonContentFrame then
            owner:RefreshMainWindow("DungeonContentFrame", "deferred_layout")
        end
    end)

    owner:RefreshMainWindow("BuildSelectedTab", "dungeons")
end
