local _, ns = ...
local MythicTools = ns.MythicTools

local WHITE_TEXTURE = "Interface\\Buttons\\WHITE8x8"
local CLASS_CIRCLE_TEXTURE = "Interface\\TargetingFrame\\UI-Classes-Circles"
local QUESTION_MARK_ICON = "Interface\\ICONS\\INV_Misc_QuestionMark"
local ADDON_ICON = "Interface\\AddOns\\SkyMythicHistory\\Media\\smh.tga"
local SORT_ARROW_ICON = "Interface\\AddOns\\SkyMythicHistory\\Media\\Textures\\ArrowDown.tga"
local RATING_STAR_ICON = "Interface\\AddOns\\SkyMythicHistory\\Media\\rating_star.tga"
local DEFAULT_DUNGEON_ICON = "Interface\\Icons\\achievement_challengemode_gold"
local DEFAULT_DUNGEON_BG = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark"
local DUNGEON_BACKGROUND_BASE = "Interface\\AddOns\\SkyMythicHistory\\Media\\DungeonBackgrounds\\"
local DEFAULT_CUSTOM_DUNGEON_BG = DUNGEON_BACKGROUND_BASE .. "placeholder.tga"
local PLAYER_NOTE_ICON = "Interface\\AddOns\\SkyMythicHistory\\Media\\note.tga"
local MAX_LOOT_ICONS = 5
local RUN_ROWS = 6
local PLAYER_ROWS = 6
local RECENT_RUN_ROWS = 4
local COMPLETION_ROWS = 5
local PLAYER_DETAIL_SPEC_CHIPS = 6
local RUN_ROW_HEIGHT = 72
local PLAYER_ROW_HEIGHT = 60
local COMPLETION_ROW_HEIGHT = 52
local PLAYER_TABLE_ROW_HEIGHT = 18
local PLAYER_DETAIL_HERO_HEIGHT = 304
local PLAYER_DETAIL_NOTE_HEIGHT = 116
local PLAYER_DETAIL_HISTORY_HEIGHT = 326
local PLAYER_DETAIL_CONTENT_HEIGHT = PLAYER_DETAIL_HERO_HEIGHT + PLAYER_DETAIL_NOTE_HEIGHT + PLAYER_DETAIL_HISTORY_HEIGHT + 48
local COMPLETION_POPUP_HEIGHT = 492
local COMPACT_STATS_PLAYER_WIDTH = 170
local COMPACT_STATS_ROW_INSET_X = 12
local COMPACT_STATS_SCORE_X = 232
local COMPACT_STATS_ITEMS_X = 326
local COMPACT_STATS_DAMAGE_X = 520
local COMPACT_STATS_DPS_X = 600
local COMPACT_STATS_HEALING_X = 672
local COMPACT_STATS_HPS_X = 750
local COMPACT_STATS_INTERRUPTS_X = 824
local COMPACT_STATS_DEATHS_X = 922
local COMPACT_STATS_RATING_X = 1148
local COMPACT_STATS_LOOT_ICON_SIZE = 26
local COMPACT_STATS_LOOT_SPACING = 4
local DUNGEON_BACKGROUND_ASPECT = 2
local DUNGEON_HEADER_BACKGROUND_ASPECT = 1050 / 110
local MAX_STATS_DUNGEON_ROWS = 8
local MAX_STATS_CLASS_ROWS = 13
local MAX_STATS_TEAMMATE_ROWS = 10
local CHARACTER_ROWS = 6
local CHARACTER_ROW_HEIGHT = 82
local STATS_BAR_H = 8
local STATS_DUNGEON_ROW_H = 30
local STATS_CLASS_ROW_H = 28
local STATS_TEAMMATE_ROW_H = 34
local STATS_SECTION_TITLE_H = 40
local STATS_PAD = 12
local STATS_OVERVIEW_H = 80
local STATS_DUNGEON_SECTION_H = STATS_SECTION_TITLE_H + MAX_STATS_DUNGEON_ROWS * STATS_DUNGEON_ROW_H + STATS_PAD
local STATS_CLASS_SECTION_H = STATS_SECTION_TITLE_H + MAX_STATS_CLASS_ROWS * STATS_CLASS_ROW_H + STATS_PAD
local STATS_TWOCOL_H = (STATS_CLASS_SECTION_H > STATS_DUNGEON_SECTION_H) and STATS_CLASS_SECTION_H or STATS_DUNGEON_SECTION_H
local STATS_TEAMMATE_SECTION_H = STATS_SECTION_TITLE_H + MAX_STATS_TEAMMATE_ROWS * STATS_TEAMMATE_ROW_H + STATS_PAD
local STATS_CONTENT_H = STATS_PAD + STATS_OVERVIEW_H + STATS_PAD + STATS_DUNGEON_SECTION_H + STATS_PAD + STATS_TWOCOL_H + STATS_PAD + STATS_TEAMMATE_SECTION_H + STATS_PAD
local CHARACTER_DETAIL_CONTENT_HEIGHT = 304 + 12 + 326 + 12

local floor = math.floor
local min = math.min
local max = math.max
local ScrollingTable = LibStub and LibStub("ScrollingTable", true)
local ROLE_ICON_TEXTURE = "Interface\\LFGFrame\\UI-LFG-ICON-ROLES"
MythicTools.MMTTransparentClassIcon = MythicTools.MMTTransparentClassIcon or "Interface\\AddOns\\SkyMythicHistory\\Media\\mmt_transparent.tga"
MythicTools.MMTTransparentClassCoords = MythicTools.MMTTransparentClassCoords or {
    WARRIOR = {0.000, 0.125, 0.000, 0.125},
    MAGE = {0.125, 0.250, 0.000, 0.125},
    ROGUE = {0.250, 0.375, 0.000, 0.125},
    DRUID = {0.375, 0.500, 0.000, 0.125},
    EVOKER = {0.500, 0.625, 0.000, 0.125},
    HUNTER = {0.000, 0.125, 0.125, 0.250},
    SHAMAN = {0.125, 0.250, 0.125, 0.250},
    PRIEST = {0.250, 0.375, 0.125, 0.250},
    WARLOCK = {0.375, 0.500, 0.125, 0.250},
    PALADIN = {0.000, 0.125, 0.250, 0.375},
    DEATHKNIGHT = {0.125, 0.250, 0.250, 0.375},
    MONK = {0.250, 0.375, 0.250, 0.375},
    DEMONHUNTER = {0.375, 0.500, 0.250, 0.375},
}

local COLORS = {
    frameBG = {0.07, 0.07, 0.07, 0.94},
    frameBorder = {0, 0, 0, 1},
    headerBG = {0.09, 0.09, 0.09, 1},
    panelBG = {0.08, 0.08, 0.08, 0.92},
    sectionBG = {0.09, 0.09, 0.09, 1},
    rowBG = {0.095, 0.095, 0.095, 1},
    rowSelected = {0.22, 0.235, 0.225, 1},
    surface = {0, 0, 0, 1},
    accent = {0.7, 0.7, 0.7, 1},
    accentSoft = {0.7, 0.7, 0.7, 0.5},
    success = {0.35, 0.85, 0.35, 1},
    bestMetric = {1.0, 0.82, 0.28, 1},
    danger = {0.85, 0.25, 0.25, 1},
    warning = {0.85, 0.75, 0.20, 1},
    text = {1, 1, 1, 1},
    muted = {0.7, 0.7, 0.7, 1},
    subdued = {0.5, 0.5, 0.5, 1},
    bright = {1, 1, 1, 1},
    shadow = {0, 0, 0, 0},
}

local CONTROL_COLORS = {}

local function RefreshControlColors()
    CONTROL_COLORS.accentBorder40 = {COLORS.accent[1], COLORS.accent[2], COLORS.accent[3], 0.40}
    CONTROL_COLORS.controlBorder = {0, 0, 0, 1}
    CONTROL_COLORS.accentFillActive = {COLORS.accent[1], COLORS.accent[2], COLORS.accent[3], 0.30}
    CONTROL_COLORS.controlBG = {COLORS.rowBG[1], COLORS.rowBG[2], COLORS.rowBG[3], 1}
    CONTROL_COLORS.controlHoverBG = {COLORS.accent[1], COLORS.accent[2], COLORS.accent[3], 0.20}
    CONTROL_COLORS.controlText = {COLORS.text[1], COLORS.text[2], COLORS.text[3], 1}
    CONTROL_COLORS.controlLabelAccent = {COLORS.accent[1], COLORS.accent[2], COLORS.accent[3], 1}
end

-- Dynamic class color accent (like Cell's accent system)
do
    local _class = select(2, UnitClass("player"))
    if _class and RAID_CLASS_COLORS and RAID_CLASS_COLORS[_class] then
        local cc = RAID_CLASS_COLORS[_class]
        COLORS.accent = {cc.r, cc.g, cc.b, 1}
        COLORS.accentSoft = {cc.r, cc.g, cc.b, 0.5}
    end
end

RefreshControlColors()

local STATUS_OPTIONS = {
    {value = "all", label = "All"},
    {value = "timed", label = "Timed"},
    {value = "overtime", label = "Overtime"},
    {value = "abandoned", label = "Abandoned"},
}

local POPUP_TRIGGER_OPTIONS = {
    {value = "LOOT_OPENED", labelKey = "POPUP_TRIGGER_LOOT_OPENED"},
    {value = "COMPLETED", labelKey = "POPUP_TRIGGER_COMPLETED"},
}

local POPUP_MODE_OPTIONS = {
    {value = "full", labelKey = "POPUP_MODE_FULL"},
    {value = "compact", labelKey = "POPUP_MODE_COMPACT"},
    {value = "off", labelKey = "POPUP_MODE_OFF"},
}

local SEASON_OPTIONS = MythicTools.GetSeasonOptions and MythicTools:GetSeasonOptions() or {{value = "midnight-season1", label = "Midnight Season 1"}}
local SEASON_FILTER_OPTIONS = {{value = "all", label = "All seasons"}}
for _, seasonInfo in ipairs(SEASON_OPTIONS) do
    SEASON_FILTER_OPTIONS[#SEASON_FILTER_OPTIONS + 1] = seasonInfo
end
local DUNGEON_BACKGROUND_BY_SLUG = {}
for slug, fileName in pairs(MythicTools.DUNGEON_BACKGROUND_FILES_BY_SLUG or {}) do
    DUNGEON_BACKGROUND_BY_SLUG[slug] = DUNGEON_BACKGROUND_BASE .. fileName
end

local DUNGEON_BACKGROUND_BY_CHALLENGE_ID = {}
for challengeID, slug in pairs(MythicTools.DUNGEON_BACKGROUND_SLUG_BY_CHALLENGE_ID or {}) do
    DUNGEON_BACKGROUND_BY_CHALLENGE_ID[challengeID] = DUNGEON_BACKGROUND_BY_SLUG[slug]
end

local function GetDefaultSeason()
    return MythicTools.GetDefaultSeason and MythicTools:GetDefaultSeason() or (SEASON_OPTIONS[1] and SEASON_OPTIONS[1].value) or "midnight-season1"
end

local function SetTextColor(fontString, color)
    fontString:SetTextColor(color[1], color[2], color[3], color[4] or 1)
end

local function ColorizeInlineText(text, color)
    if type(text) ~= "string" or text == "" or type(color) ~= "table" then
        return text or ""
    end

    local r = min(255, max(0, floor((color[1] or 1) * 255 + 0.5)))
    local g = min(255, max(0, floor((color[2] or 1) * 255 + 0.5)))
    local b = min(255, max(0, floor((color[3] or 1) * 255 + 0.5)))
    return ("|cff%02x%02x%02x%s|r"):format(r, g, b, text)
end

local function CreateFont(parent, size, color, justifyH, layer, outline)
    local fontString = parent:CreateFontString(nil, layer or "OVERLAY")
    fontString:SetFont(STANDARD_TEXT_FONT, size, outline or "")
    fontString:SetJustifyH(justifyH or "LEFT")
    fontString:SetJustifyV("MIDDLE")
    fontString:SetShadowColor(0, 0, 0)
    fontString:SetShadowOffset(1, -1)
    SetTextColor(fontString, color or COLORS.text)
    return fontString
end

local function GetStatusLabel(status)
    for _, info in ipairs(STATUS_OPTIONS) do
        if info.value == status then
            return info.label
        end
    end
    return STATUS_OPTIONS[1].label
end

local function GetPopupTriggerLabel(trigger)
    for _, info in ipairs(POPUP_TRIGGER_OPTIONS) do
        if info.value == trigger then
            return MythicTools:L(info.labelKey)
        end
    end
    return MythicTools:L(POPUP_TRIGGER_OPTIONS[1].labelKey)
end

local function GetPopupTriggerNext(trigger)
    for index, info in ipairs(POPUP_TRIGGER_OPTIONS) do
        if info.value == trigger then
            local nextInfo = POPUP_TRIGGER_OPTIONS[index + 1] or POPUP_TRIGGER_OPTIONS[1]
            return nextInfo.value
        end
    end
    return POPUP_TRIGGER_OPTIONS[1].value
end

local function GetPopupModeLabel(mode)
    for _, info in ipairs(POPUP_MODE_OPTIONS) do
        if info.value == mode then
            return MythicTools:L(info.labelKey)
        end
    end
    return MythicTools:L(POPUP_MODE_OPTIONS[1].labelKey)
end

local function GetPopupModeNext(mode)
    for index, info in ipairs(POPUP_MODE_OPTIONS) do
        if info.value == mode then
            local nextInfo = POPUP_MODE_OPTIONS[index + 1] or POPUP_MODE_OPTIONS[1]
            return nextInfo.value
        end
    end
    return POPUP_MODE_OPTIONS[1].value
end

local function GetSeasonLabel(season)
    season = MythicTools.NormalizeSeason and MythicTools:NormalizeSeason(season) or season
    if MythicTools.GetSeasonDisplayLabel then
        return MythicTools:GetSeasonDisplayLabel(season)
    end
    for _, info in ipairs(SEASON_OPTIONS) do
        if info.value == season then
            return info.label
        end
    end
    return SEASON_OPTIONS[1].label
end

local function GetSeasonNext(season)
    season = MythicTools.NormalizeSeason and MythicTools:NormalizeSeason(season) or season
    for index, info in ipairs(SEASON_OPTIONS) do
        if info.value == season then
            local nextInfo = SEASON_OPTIONS[index + 1] or SEASON_OPTIONS[1]
            return nextInfo.value
        end
    end
    return SEASON_OPTIONS[1].value
end

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

    local normalized = MythicTools:Lower(name)
    normalized = normalized:gsub("[áàâãä]", "a")
        :gsub("[éèêë]", "e")
        :gsub("[íìîï]", "i")
        :gsub("[óòôõö]", "o")
        :gsub("[úùûü]", "u")
        :gsub("ç", "c")
        -- Apostrophes must vanish (not become a space), so "Algeth'ar
        -- Academy" normalizes to "algethar academy" and matches its alias.
        :gsub("'", "")
        :gsub("’", "")
        :gsub("`", "")
        :gsub("[^%w]+", " ")
        :gsub("^%s+", "")
        :gsub("%s+$", "")
        :gsub("%s+", " ")
    return normalized
end

local function GetDungeonSlugByName(name)
    local key = NormalizeDungeonLookupName(name)
    if key == "" then
        return nil
    end

    return MythicTools.GetDungeonSlugByAlias and MythicTools:GetDungeonSlugByAlias(key) or nil
end

local function EnsureDungeonBackgroundCache(run)
    if type(run) ~= "table" then
        return nil
    end

    local mapChallengeModeID = MythicTools:GetRunMapChallengeModeID(run)
    if mapChallengeModeID and DUNGEON_BACKGROUND_BY_CHALLENGE_ID[mapChallengeModeID] then
        return DUNGEON_BACKGROUND_BY_CHALLENGE_ID[mapChallengeModeID]
    end

    local slug = GetDungeonSlugByName(run.dungeonName)
    if not slug and mapChallengeModeID and C_ChallengeMode and C_ChallengeMode.GetMapUIInfo then
        local localizedName = C_ChallengeMode.GetMapUIInfo(mapChallengeModeID)
        slug = GetDungeonSlugByName(localizedName)
    end

    local background = slug and DUNGEON_BACKGROUND_BY_SLUG[slug] or nil
    if background and mapChallengeModeID then
        DUNGEON_BACKGROUND_BY_CHALLENGE_ID[mapChallengeModeID] = background
        if MythicTools.CacheDungeonBackgroundSlugForChallengeID then
            MythicTools:CacheDungeonBackgroundSlugForChallengeID(mapChallengeModeID, slug)
        end
    end

    return background
end

local function GetDungeonOptionIdentity(name)
    local slug = GetDungeonSlugByName(name)
    if slug and slug ~= "" then
        return "slug:" .. slug
    end

    return "name:" .. NormalizeDungeonLookupName(name)
end

local function AddDungeonOptionRow(rows, rowsByValue, rowsByIdentity, value, label)
    if type(value) ~= "string" or value == "" or type(label) ~= "string" or label == "" then
        return
    end

    local identity = GetDungeonOptionIdentity(label)
    if rowsByValue[value] or rowsByIdentity[identity] then
        return
    end

    local row = {
        value = value,
        label = label,
    }
    rowsByValue[value] = row
    rowsByIdentity[identity] = row
    rows[#rows + 1] = row
end

local function BuildDungeonOptionRowsFromFallbacks(season, rows, rowsByValue, rowsByIdentity)
    for _, dungeonName in ipairs(MythicTools.GetSeasonDungeonFallbacks and MythicTools:GetSeasonDungeonFallbacks(season) or {}) do
        local value = "name:" .. MythicTools:Lower(dungeonName)
        AddDungeonOptionRow(rows, rowsByValue, rowsByIdentity, value, dungeonName)
    end
end

local function BuildDungeonOptionRowsFromRuns(season, rows, rowsByValue, rowsByIdentity)
    for _, run in ipairs(MythicTools.db and MythicTools.db.runs or {}) do
        if MythicTools:GetRunSeason(run) == season then
            local value = MythicTools:GetRunDungeonKey(run)
            local label = run.dungeonName or "Unknown"
            if value ~= "unknown" then
                AddDungeonOptionRow(rows, rowsByValue, rowsByIdentity, value, label)
            end
        end
    end
end

local function GetDungeonOptionsForSeason(season)
    season = MythicTools.NormalizeSeason and MythicTools:NormalizeSeason(season) or season or GetDefaultSeason()

    local rows = {}
    local rowsByValue = {}
    local rowsByIdentity = {}
    BuildDungeonOptionRowsFromFallbacks(season, rows, rowsByValue, rowsByIdentity)
    BuildDungeonOptionRowsFromRuns(season, rows, rowsByValue, rowsByIdentity)

    table.sort(rows, function(left, right)
        if left.label ~= right.label then
            return left.label < right.label
        end
        return left.value < right.value
    end)

    local options = {
        {value = "all", label = "All dungeons"},
    }
    for _, info in ipairs(rows) do
        options[#options + 1] = info
    end
    return options
end

local function NormalizeDungeonFilterValueForSeason(season, value)
    if type(value) ~= "string" then
        value = tostring(value or "")
    end

    local options = GetDungeonOptionsForSeason(season)
    if value == nil or value == "" then
        return "all"
    end

    for _, info in ipairs(options) do
        if info.value == value then
            return value
        end
    end

    for _, info in ipairs(options) do
        if info.label == value then
            return info.value
        end
    end

    return "all"
end

local function GetOwnedCharacterFilterOptions()
    local options = {
        {value = "all", label = "All runs"},
    }

    local characters = MythicTools.GetOwnedCharacters and MythicTools:GetOwnedCharacters() or {}
    local visibleCharacters = {}
    local shortNameCounts = {}
    for _, fullName in ipairs(characters) do
        local playerEntry = MythicTools.db and MythicTools.db.playersIndex and MythicTools.db.playersIndex[fullName] or nil
        if playerEntry and (playerEntry.totalRuns or 0) > 0 then
            visibleCharacters[#visibleCharacters + 1] = fullName
            local shortName = MythicTools:GetShortName(fullName)
            shortNameCounts[shortName] = (shortNameCounts[shortName] or 0) + 1
        end
    end

    if #visibleCharacters > 0 then
        options[#options + 1] = {value = "mine", label = "Any account character"}
    end

    for _, fullName in ipairs(visibleCharacters) do
        local shortName = MythicTools:GetShortName(fullName)
        options[#options + 1] = {
            value = fullName,
            label = (shortNameCounts[shortName] or 0) > 1 and fullName or shortName,
        }
    end

    return options
end

function MythicTools:GetStatsCharacterFilterOptions(season)
    local options = {
        {value = "all", label = "All characters"},
    }
    local characters = {}
    local seen = {}
    local shortNameCounts = {}

    for _, run in ipairs(MythicTools.db and MythicTools.db.runs or {}) do
        if type(run) == "table" and (not season or season == "" or season == "all" or MythicTools:GetRunSeason(run) == season) then
            local ownerName = MythicTools:NormalizePlayerName(run.ownerCharacter) or MythicTools:TrimText(run.ownerCharacter)
            if ownerName and ownerName ~= "" and MythicTools:IsOwnedCharacter(ownerName) and not seen[ownerName] then
                seen[ownerName] = true
                characters[#characters + 1] = ownerName
                local shortName = MythicTools:GetShortName(ownerName)
                shortNameCounts[shortName] = (shortNameCounts[shortName] or 0) + 1
            end
        end
    end

    table.sort(characters, function(left, right)
        local leftShort = MythicTools:GetShortName(left)
        local rightShort = MythicTools:GetShortName(right)
        if leftShort ~= rightShort then
            return leftShort < rightShort
        end
        return left < right
    end)

    for _, fullName in ipairs(characters) do
        local shortName = MythicTools:GetShortName(fullName)
        options[#options + 1] = {
            value = fullName,
            label = (shortNameCounts[shortName] or 0) > 1 and fullName or shortName,
        }
    end

    return options
end

function MythicTools:ResolveDropdownValueFromOptions(options, value, fallback)
    fallback = fallback or "all"
    if value == nil or value == "" then
        return fallback
    end

    for _, info in ipairs(options or {}) do
        if info.value == value then
            return value
        end
    end

    for _, info in ipairs(options or {}) do
        if info.label == value then
            return info.value
        end
    end

    return fallback
end

local function NormalizeDateFilter(text)
    text = MythicTools:TrimText(text)
    if text == "" then
        return ""
    end

    local day, month, year = text:match("^(%d%d?)/(%d%d?)/(%d%d%d%d)$")
    if day and month and year then
        return ("%02d/%02d/%04d"):format(tonumber(day), tonumber(month), tonumber(year))
    end

    year, month, day = text:match("^(%d%d%d%d)%-(%d%d?)%-(%d%d?)$")
    if year and month and day then
        return ("%02d/%02d/%04d"):format(tonumber(day), tonumber(month), tonumber(year))
    end

    return MythicTools:Lower(text)
end

local function GetRunStatusColor(run)
    local result = MythicTools:GetRunResult(run)
    if result == "timed" then
        return COLORS.success
    end
    if result == "abandoned" then
        return COLORS.danger
    end
    return COLORS.warning
end

local function GetRunStatusText(run)
    local result = MythicTools:GetRunResult(run)
    if result == "timed" then
        local upgrades = MythicTools:GetRunKeystoneUpgradeLevels(run)
        return upgrades > 0 and ("+%d"):format(upgrades) or "Timed"
    end
    if result == "abandoned" then
        return "Abandoned"
    end
    return "Overtime"
end

local function GetRunOwnerInfo(run)
    if type(run) ~= "table" then
        return nil, nil
    end

    local owner = MythicTools:NormalizePlayerName(run.ownerCharacter) or MythicTools:TrimText(run.ownerCharacter)
    if not owner or owner == "" then
        owner = run.roster and run.roster[1] or nil
    end

    return owner, owner and run.playerStats and run.playerStats[owner] or nil
end

local function GetDungeonIcon(run)
    return run and (run.textureFileID or run.backgroundTextureFileID) or DEFAULT_DUNGEON_ICON
end

local function GetDungeonBackground(run)
    local background = EnsureDungeonBackgroundCache(run)
    if background then
        return background
    end

    return DEFAULT_CUSTOM_DUNGEON_BG
end

local function NormalizeDisplayItemLink(itemLink)
    if type(itemLink) ~= "string" or itemLink == "" then
        return nil
    end

    return itemLink:match("(|c%x+|Hitem:.-|h%[.-%]|h|r)")
        or itemLink:match("(|Hitem:.-|h%[.-%]|h)")
        or itemLink
end

local function GetItemIDFromLinkish(itemLink)
    if type(itemLink) ~= "string" or itemLink == "" then
        return nil
    end

    local itemID = itemLink:match("Hitem:(%d+)")
        or itemLink:match("|Hitem:(%d+)")
        or itemLink:match("^item:(%d+)")
        or itemLink:match("item:(%d+)")

    return itemID and tonumber(itemID) or nil
end

local function ExtractItemNameFromLink(itemLink)
    if type(itemLink) ~= "string" then
        return nil
    end

    local itemName = itemLink:match("%[(.-)%]")
    if itemName and itemName ~= "" then
        return itemName
    end

    return nil
end

-- Items whose server data was not loaded yet when the UI drew them.
-- GET_ITEM_INFO_RECEIVED fires for every item load in the game, so the
-- handler only refreshes when one of these specific items arrives.
local function MarkItemInfoPending(itemLink)
    local itemID = GetItemIDFromLinkish(itemLink)
    if not itemID then
        return
    end

    MythicTools._pendingItemInfo = MythicTools._pendingItemInfo or {}
    MythicTools._pendingItemInfo[itemID] = true
end

local function GetItemNameFallback(itemLink)
    itemLink = NormalizeDisplayItemLink(itemLink)
    if not itemLink then
        return ""
    end

    local itemName = GetItemInfo(itemLink)
    if itemName and itemName ~= "" then
        return itemName
    end

    MarkItemInfoPending(itemLink)

    itemName = ExtractItemNameFromLink(itemLink)
    if itemName and itemName ~= "" then
        return itemName
    end

    local itemID = GetItemIDFromLinkish(itemLink)
    if itemID and C_Item and C_Item.GetItemNameByID then
        itemName = C_Item.GetItemNameByID(itemID)
        if itemName and itemName ~= "" then
            return itemName
        end
    end

    return "Unknown item"
end

local function GetItemQualityColorByLink(itemLink)
    itemLink = NormalizeDisplayItemLink(itemLink)
    if not itemLink or not C_Item or not C_Item.GetItemQualityColor then
        return nil
    end

    local itemName, itemLinkResolved, itemQuality = GetItemInfo(itemLink)
    if itemName and itemQuality then
        local r, g, b = C_Item.GetItemQualityColor(itemQuality)
        if r and g and b then
            return {r, g, b, 1}
        end
    else
        MarkItemInfoPending(itemLink)
    end

    local itemID = GetItemIDFromLinkish(itemLink)
    if itemID and C_Item and C_Item.GetItemQualityByID then
        local quality = C_Item.GetItemQualityByID(itemID)
        if quality then
            local r, g, b = C_Item.GetItemQualityColor(quality)
            if r and g and b then
                return {r, g, b, 1}
            end
        end
    end

    return nil
end

local function GetItemLevelForLink(itemLink)
    itemLink = NormalizeDisplayItemLink(itemLink)
    if not itemLink then
        return nil
    end

    local itemLevel = GetDetailedItemLevelInfo and GetDetailedItemLevelInfo(itemLink) or nil
    itemLevel = tonumber(itemLevel) or itemLevel
    if itemLevel and itemLevel > 0 then
        return itemLevel
    end

    if C_Item and C_Item.GetDetailedItemLevelInfo then
        local detailedItemLevel = C_Item.GetDetailedItemLevelInfo(itemLink)
        itemLevel = tonumber(detailedItemLevel) or nil
        if itemLevel and itemLevel > 0 then
            return itemLevel
        end
    end

    return nil
end

local function FormatItemLevelText(itemLevel)
    itemLevel = tonumber(itemLevel)
    if not itemLevel or itemLevel <= 0 then
        return ""
    end

    if math.abs(itemLevel - math.floor(itemLevel + 0.5)) < 0.05 then
        return tostring(math.floor(itemLevel + 0.5))
    end

    return ("%.1f"):format(itemLevel)
end

local function GetClassIconMarkup(classFilename, specIconID, specID)
    if specID or (specIconID and not classFilename) then
        local icon = MythicTools.ResolveDisplayIcon and MythicTools:ResolveDisplayIcon(specIconID, specID) or specIconID
        return ("|T%s:16:16:0:0|t"):format(tostring(icon or QUESTION_MARK_ICON))
    end

    local coords = classFilename and CLASS_ICON_TCOORDS and CLASS_ICON_TCOORDS[classFilename]
    if coords then
        return ("|T%s:16:16:0:0:256:256:%d:%d:%d:%d|t"):format(
            CLASS_CIRCLE_TEXTURE,
            math.floor(coords[1] * 256),
            math.floor(coords[2] * 256),
            math.floor(coords[3] * 256),
            math.floor(coords[4] * 256)
        )
    end

    return ("|T%s:16:16:0:0|t"):format(QUESTION_MARK_ICON)
end

local function GetDungeonIconMarkup(run, size)
    local icon = GetDungeonIcon(run) or DEFAULT_DUNGEON_ICON
    size = tonumber(size) or 12
    return ("|T%s:%d:%d:0:0|t"):format(tostring(icon), size, size)
end

function MythicTools:ApplySurface(frame, bgColor, borderColor, accentColor)
    local surface = frame._mtSurface
    if not surface then
        local function SetupPixelTexture(texture)
            if texture and texture.SetSnapToPixelGrid then
                texture:SetSnapToPixelGrid(true)
            end
            if texture and texture.SetTexelSnappingBias then
                texture:SetTexelSnappingBias(0)
            end
        end

        local bg = frame:CreateTexture(nil, "BACKGROUND")
        bg:SetAllPoints()
        bg:SetTexture(WHITE_TEXTURE)
        SetupPixelTexture(bg)

        local top = frame:CreateTexture(nil, "ARTWORK")
        top:SetTexture(WHITE_TEXTURE)
        top:SetPoint("TOPLEFT")
        top:SetPoint("TOPRIGHT")
        top:SetHeight(1)
        SetupPixelTexture(top)

        local bottom = frame:CreateTexture(nil, "ARTWORK")
        bottom:SetTexture(WHITE_TEXTURE)
        bottom:SetPoint("BOTTOMLEFT")
        bottom:SetPoint("BOTTOMRIGHT")
        bottom:SetHeight(1)
        SetupPixelTexture(bottom)

        local left = frame:CreateTexture(nil, "ARTWORK")
        left:SetTexture(WHITE_TEXTURE)
        left:SetPoint("TOPLEFT")
        left:SetPoint("BOTTOMLEFT")
        left:SetWidth(1)
        SetupPixelTexture(left)

        local right = frame:CreateTexture(nil, "ARTWORK")
        right:SetTexture(WHITE_TEXTURE)
        right:SetPoint("TOPRIGHT")
        right:SetPoint("BOTTOMRIGHT")
        right:SetWidth(1)
        SetupPixelTexture(right)

        surface = {
            bg = bg,
            top = top,
            bottom = bottom,
            left = left,
            right = right,
        }
        frame._mtSurface = surface
    end

    surface.bg:SetColorTexture(bgColor[1], bgColor[2], bgColor[3], bgColor[4] or 1)
    surface.top:SetColorTexture(borderColor[1], borderColor[2], borderColor[3], borderColor[4] or 1)
    surface.bottom:SetColorTexture(borderColor[1], borderColor[2], borderColor[3], borderColor[4] or 1)
    surface.left:SetColorTexture(borderColor[1], borderColor[2], borderColor[3], borderColor[4] or 1)
    surface.right:SetColorTexture(borderColor[1], borderColor[2], borderColor[3], borderColor[4] or 1)

    -- Keep 1 physical-pixel borders at any UI scale to avoid random missing edges.
    local scale = frame.GetEffectiveScale and frame:GetEffectiveScale() or 1
    local pixel = (scale and scale > 0) and (1 / scale) or 1
    surface.top:SetHeight(pixel)
    surface.bottom:SetHeight(pixel)
    surface.left:SetWidth(pixel)
    surface.right:SetWidth(pixel)
end

local function StyleEditBox(editBox)
    editBox:SetAutoFocus(false)
    editBox:SetTextInsets(10, 10, 0, 0)
    editBox:SetFont(STANDARD_TEXT_FONT, 13, "")
    editBox:SetJustifyH("LEFT")
    editBox:SetTextColor(CONTROL_COLORS.controlText[1], CONTROL_COLORS.controlText[2], CONTROL_COLORS.controlText[3], CONTROL_COLORS.controlText[4] or 1)
    if editBox.Left then editBox.Left:SetAlpha(0) end
    if editBox.Middle then editBox.Middle:SetAlpha(0) end
    if editBox.Right then editBox.Right:SetAlpha(0) end
end

local function ClipChildren(frame)
    if frame and frame.SetClipsChildren then
        frame:SetClipsChildren(true)
    end
end

local function SetTextureCoverTexCoord(texture, sourceAspect)
    if not texture then
        return
    end

    local width = texture:GetWidth() or 0
    local height = texture:GetHeight() or 0
    local targetAspect = height > 0 and (width / height) or 0
    sourceAspect = tonumber(sourceAspect) or DUNGEON_BACKGROUND_ASPECT
    if targetAspect <= 0 or sourceAspect <= 0 then
        texture:SetTexCoord(0, 1, 0, 1)
        return
    end

    if targetAspect > sourceAspect then
        local visibleHeight = max(0.05, min(1, sourceAspect / targetAspect))
        local top = (1 - visibleHeight) / 2
        texture:SetTexCoord(0, 1, top, top + visibleHeight)
    else
        local visibleWidth = max(0.05, min(1, targetAspect / sourceAspect))
        local left = (1 - visibleWidth) / 2
        texture:SetTexCoord(left, left + visibleWidth, 0, 1)
    end
end

local function CreateLabeledEditBox(parent, labelText, width, onChanged)
    local holder = CreateFrame("Frame", nil, parent)
    holder:SetSize(width, 50)

    holder.Label = CreateFont(holder, 11, CONTROL_COLORS.controlLabelAccent)
    holder.Label:SetPoint("TOPLEFT", 2, 0)
    holder.Label:SetText(labelText)

    holder.Bg = CreateFrame("Frame", nil, holder)
    holder.Bg:SetPoint("TOPLEFT", 0, -18)
    holder.Bg:SetSize(width, 28)
    MythicTools:ApplySurface(holder.Bg, CONTROL_COLORS.controlBG, CONTROL_COLORS.controlBorder, COLORS.accent)

    holder.Input = CreateFrame("EditBox", nil, holder.Bg)
    holder.Input:SetPoint("TOPLEFT", 0, 0)
    holder.Input:SetPoint("BOTTOMRIGHT", 0, 0)
    StyleEditBox(holder.Input)
    holder.Input:SetScript("OnEscapePressed", function(editBox) editBox:ClearFocus() end)
    holder.Input:SetScript("OnEnterPressed", function(editBox)
        editBox:ClearFocus()
        if onChanged then
            onChanged(editBox:GetText())
        end
    end)
    holder.Input:SetScript("OnTextChanged", function(editBox, userInput)
        if userInput and onChanged then
            onChanged(editBox:GetText())
        end
    end)

    function holder:SetValue(value)
        if not self.Input:HasFocus() then
            self.Input:SetText(value or "")
        end
    end

    return holder
end

local function CreateLabeledSlider(parent, labelText, width, minValue, maxValue, valueStep, onChanged)
    local holder = CreateFrame("Frame", nil, parent)
    holder:SetSize(width, 50)

    holder.Label = CreateFont(holder, 11, CONTROL_COLORS.controlLabelAccent)
    holder.Label:SetPoint("TOPLEFT", 2, 0)
    holder.Label:SetText(labelText)

    holder.Slider = CreateFrame("Slider", nil, holder)
    holder.Slider:SetOrientation("HORIZONTAL")
    holder.Slider:SetPoint("TOPLEFT", 0, -26)
    holder.Slider:SetSize(width, 10)
    holder.Slider:SetMinMaxValues(minValue, maxValue)
    holder.Slider:SetValueStep(valueStep or 1)
    if holder.Slider.SetObeyStepOnDrag then
        holder.Slider:SetObeyStepOnDrag(true)
    end

    holder.Track = holder.Slider:CreateTexture(nil, "BACKGROUND")
    holder.Track:SetTexture(WHITE_TEXTURE)
    holder.Track:SetPoint("LEFT", 0, 0)
    holder.Track:SetPoint("RIGHT", 0, 0)
    holder.Track:SetHeight(4)
    holder.Track:SetVertexColor(COLORS.rowBG[1], COLORS.rowBG[2], COLORS.rowBG[3], 1)

    holder.Fill = holder.Slider:CreateTexture(nil, "ARTWORK")
    holder.Fill:SetTexture("Interface\\AddOns\\SkyMythicHistory\\Media\\Textures\\statusbar.tga")
    holder.Fill:SetPoint("LEFT", holder.Track, "LEFT", 0, 0)
    holder.Fill:SetHeight(4)
    holder.Fill:SetVertexColor(COLORS.accent[1], COLORS.accent[2], COLORS.accent[3], 0.75)

    local thumb = holder.Slider:CreateTexture(nil, "OVERLAY")
    thumb:SetTexture(WHITE_TEXTURE)
    thumb:SetSize(12, 18)
    thumb:SetVertexColor(COLORS.text[1], COLORS.text[2], COLORS.text[3], 1)
    holder.Slider:SetThumbTexture(thumb)

    holder.Slider:SetScript("OnValueChanged", function(slider, value)
        local minVal, maxVal = slider:GetMinMaxValues()
        local pct = maxVal > minVal and ((value - minVal) / (maxVal - minVal)) or 0
        holder.Fill:SetWidth(max(2, width * pct))
        if not holder._setting and onChanged then
            onChanged(value)
        end
    end)

    function holder:SetValue(value)
        local minVal, maxVal = self.Slider:GetMinMaxValues()
        value = max(minVal, min(maxVal, tonumber(value) or minVal))
        self._setting = true
        self.Slider:SetValue(value)
        self._setting = false
    end

    holder:SetValue(minValue)
    return holder
end

local function CreateActionButton(parent, width, height, label)
    local button = CreateFrame("Button", nil, parent)
    button:SetSize(width, height)
    MythicTools:ApplySurface(button, CONTROL_COLORS.controlBG, CONTROL_COLORS.controlBorder, COLORS.accent)

    button.Text = CreateFont(button, 12, CONTROL_COLORS.controlText, "CENTER", "OVERLAY", "")
    button.Text:SetPoint("CENTER")
    button.Text:SetText(label)

    button._restyle = function(self)
        if not self:IsEnabled() then
            MythicTools:ApplySurface(self, COLORS.rowBG, CONTROL_COLORS.controlBorder, COLORS.accent)
            SetTextColor(self.Text, COLORS.subdued)
            return
        end

        if self._selected then
            MythicTools:ApplySurface(self, CONTROL_COLORS.accentFillActive, CONTROL_COLORS.controlBorder, COLORS.accent)
            SetTextColor(self.Text, COLORS.bright)
        else
            MythicTools:ApplySurface(self, CONTROL_COLORS.controlBG, CONTROL_COLORS.controlBorder, COLORS.accent)
            SetTextColor(self.Text, CONTROL_COLORS.controlText)
        end
    end

    button:SetScript("OnEnter", function(self)
        if self:IsEnabled() and not self._selected then
            MythicTools:ApplySurface(self, CONTROL_COLORS.controlHoverBG, CONTROL_COLORS.controlBorder, COLORS.accent)
            SetTextColor(self.Text, COLORS.bright)
        end
    end)
    button:SetScript("OnLeave", function(self)
        if self._restyle then
            self:_restyle()
        end
    end)
    button:SetScript("OnEnable", function(self)
        if self._restyle then
            self:_restyle()
        end
    end)
    button:SetScript("OnDisable", function(self)
        if self._restyle then
            self:_restyle()
        end
    end)

    button:_restyle()
    return button
end

local function CreateCycleControl(parent, labelText, width, getLabel, onClick)
    local holder = CreateFrame("Frame", nil, parent)
    holder:SetSize(width, 50)

    holder.Label = CreateFont(holder, 11, CONTROL_COLORS.controlLabelAccent)
    holder.Label:SetPoint("TOPLEFT", 2, 0)
    holder.Label:SetText(labelText)

    holder.Button = CreateActionButton(holder, width, 28, "")
    holder.Button:SetPoint("TOPLEFT", 0, -18)
    holder.Button:SetScript("OnClick", function()
        if onClick then
            onClick()
        end
    end)

    function holder:SetValue(value)
        holder.Button.Text:SetText(getLabel and getLabel(value) or tostring(value or ""))
    end

    return holder
end

function MythicTools:ApplyCloseButtonIcon(button, size)
    if not button then
        return
    end

    local icon = button._mtCloseIcon
    if not icon then
        icon = button:CreateTexture(nil, "OVERLAY")
        button._mtCloseIcon = icon
    end

    size = tonumber(size) or 10
    icon:SetTexture("Interface\\AddOns\\SkyMythicHistory\\Media\\Textures\\ExitCross.tga")
    icon:SetSize(size, size)
    icon:SetPoint("CENTER")
    icon:SetVertexColor(1, 1, 1, 0.95)
    icon:SetBlendMode("BLEND")
    icon:Show()

    if button.Text and button.Text.SetText then
        button.Text:SetText("")
    end
end

function MythicTools:ApplyControlButtonIcon(button, iconPath, size)
    if not (button and iconPath and iconPath ~= "") then
        return
    end

    local icon = button._mtControlIcon
    if not icon then
        icon = button:CreateTexture(nil, "OVERLAY")
        button._mtControlIcon = icon
    end

    size = tonumber(size) or 10
    icon:SetTexture(iconPath)
    icon:SetSize(size, size)
    icon:SetPoint("CENTER")
    icon:SetVertexColor(1, 1, 1, 0.95)
    icon:SetTexCoord(0, 1, 0, 1)
    icon:SetBlendMode("BLEND")
    icon:Show()
end

function MythicTools:ApplyActionButtonIcon(button, iconPath, size)
    if not (button and iconPath and iconPath ~= "") then
        return
    end

    local icon = button._mtActionIcon
    if not icon then
        icon = button:CreateTexture(nil, "OVERLAY")
        button._mtActionIcon = icon
    end

    size = tonumber(size) or 12
    icon:SetTexture(iconPath)
    icon:SetSize(size, size)
    icon:SetPoint("LEFT", 8, 0)
    icon:SetVertexColor(1, 1, 1, 0.95)
    icon:SetTexCoord(0, 1, 0, 1)
    icon:SetBlendMode("BLEND")
    icon:Show()

    if button.Text then
        button.Text:ClearAllPoints()
        button.Text:SetPoint("LEFT", icon, "RIGHT", 6, 0)
        button.Text:SetPoint("RIGHT", -8, 0)
        button.Text:SetJustifyH("LEFT")
    end
end

local function CreateAceFlowRow(parent, topOffset, height)
    local row = CreateFrame("Frame", nil, parent)
    row:SetPoint("TOPLEFT", parent, "TOPLEFT", 14, topOffset)
    row:SetPoint("TOPRIGHT", parent, "TOPRIGHT", -14, topOffset)
    row:SetHeight(height or 50)
    row._nextX = 0
    row._maxHeight = 0

    function row:AddChild(child)
        if not child then
            return
        end

        local frame = child.frame or child
        if not frame or not frame.SetPoint then
            return
        end

        frame:SetParent(self)
        frame:ClearAllPoints()
        frame:SetPoint("TOPLEFT", self, "TOPLEFT", self._nextX, 0)
        frame:Show()

        local width = frame.GetWidth and frame:GetWidth() or 0
        local frameHeight = frame.GetHeight and frame:GetHeight() or 0
        self._nextX = self._nextX + width
        if frameHeight > self._maxHeight then
            self._maxHeight = frameHeight
            self:SetHeight(max(self._maxHeight, self:GetHeight()))
        end
    end

    return row
end

local function CreateAceSpacer(width)
    local spacer = CreateFrame("Frame", nil, UIParent)
    spacer:SetSize(width or 0, 1)
    spacer.frame = spacer
    return spacer
end

local function StyleAceEditWidget(widget)
    if not widget then
        return
    end
    if widget.Label then
        SetTextColor(widget.Label, CONTROL_COLORS.controlLabelAccent)
    end
    if widget.Bg then
        MythicTools:ApplySurface(widget.Bg, CONTROL_COLORS.controlBG, CONTROL_COLORS.controlBorder, COLORS.accent)
    end
end

local function StyleAceDropdownWidget(widget)
    if not widget then
        return
    end
    if widget.Label then
        SetTextColor(widget.Label, CONTROL_COLORS.controlLabelAccent)
    end
    if widget.Button and widget.Button._restyle then
        widget.Button:_restyle()
    end
end

local function BuildMenuCheckMark(parent)
    local border = CreateFrame("Frame", nil, parent)
    border:SetSize(12, 12)
    MythicTools:ApplySurface(border, {0, 0, 0, 0}, CONTROL_COLORS.accentBorder40, COLORS.accent)
    local fill = border:CreateTexture(nil, "ARTWORK")
    fill:SetTexture(WHITE_TEXTURE)
    fill:SetPoint("TOPLEFT", 3, -3)
    fill:SetPoint("BOTTOMRIGHT", -3, 3)
    fill:SetVertexColor(COLORS.accent[1], COLORS.accent[2], COLORS.accent[3], 1)
    border.Fill = fill
    return border
end

local function EnsureCustomMenuFrame()
    if MythicTools.customMenuFrame then
        return MythicTools.customMenuFrame
    end

    local dismiss = CreateFrame("Button", "MythicToolsCustomMenuDismiss", UIParent)
    dismiss:SetAllPoints(UIParent)
    dismiss:SetFrameStrata("FULLSCREEN_DIALOG")
    dismiss:EnableMouse(true)
    dismiss:Hide()

    local frame = CreateFrame("Frame", "MythicToolsCustomMenu", UIParent)
    frame:SetFrameStrata("FULLSCREEN_DIALOG")
    frame:SetFrameLevel(80)
    frame:SetClampedToScreen(true)
    frame:EnableMouse(true)
    frame:Hide()
    MythicTools:ApplySurface(frame, COLORS.frameBG, CONTROL_COLORS.accentBorder40, COLORS.accent)
    ClipChildren(frame)

    frame.rowHeight = 20
    frame.padding = 4
    frame.maxVisible = 12
    frame.offset = 0
    frame.rows = {}
    frame.items = {}
    frame._mtSettingScroll = nil

    local scrollBar = CreateFrame("Slider", nil, frame)
    scrollBar:SetOrientation("VERTICAL")
    scrollBar:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -2, -4)
    scrollBar:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -2, 4)
    scrollBar:SetWidth(8)
    scrollBar:SetMinMaxValues(0, 0)
    scrollBar:SetValueStep(1)
    scrollBar:SetObeyStepOnDrag(true)
    scrollBar:Hide()
    MythicTools:ApplySurface(scrollBar, CONTROL_COLORS.controlBG, CONTROL_COLORS.accentBorder40, COLORS.accent)

    local scrollThumb = scrollBar:CreateTexture(nil, "ARTWORK")
    scrollThumb:SetTexture("Interface\\AddOns\\SkyMythicHistory\\Media\\Textures\\statusbar.tga")
    scrollThumb:SetWidth(6)
    scrollThumb:SetVertexColor(COLORS.accent[1], COLORS.accent[2], COLORS.accent[3], 0.85)
    scrollBar:SetThumbTexture(scrollThumb)
    frame.ScrollBar = scrollBar

    function frame:SetOffset(nextOffset)
        local maxOffset = max(0, #self.items - self.maxVisible)
        self.offset = max(0, min(maxOffset, floor((tonumber(nextOffset) or 0) + 0.5)))
        self:RefreshRows()
    end

    local function EnsureRow(index)
        local row = frame.rows[index]
        if row then
            return row
        end

        row = CreateFrame("Button", nil, frame)
        row:SetHeight(frame.rowHeight)
        row:RegisterForClicks("LeftButtonUp")
        row.Text = CreateFont(row, 11, COLORS.text)
        row.Text:SetPoint("LEFT", 22, 0)
        row.Text:SetPoint("RIGHT", -10, 0)
        row.Text:SetJustifyH("LEFT")

        row.Check = BuildMenuCheckMark(row)
        row.Check:SetPoint("LEFT", 6, 0)

        row:SetScript("OnEnter", function(self)
            if self._isTitle or self._isDisabled then
                return
            end
            MythicTools:ApplySurface(self, CONTROL_COLORS.controlHoverBG, CONTROL_COLORS.accentBorder40, COLORS.accent)
        end)
        row:SetScript("OnLeave", function(self)
            if self._selected then
                MythicTools:ApplySurface(self, CONTROL_COLORS.accentFillActive, CONTROL_COLORS.accentBorder40, COLORS.accent)
            else
                MythicTools:ApplySurface(self, CONTROL_COLORS.controlBG, {0, 0, 0, 0}, COLORS.accent)
            end
        end)
        row:SetScript("OnClick", function(self)
            if self._isTitle or self._isDisabled then
                return
            end
            local item = self._item
            MythicTools:HideCustomMenu()
            if item and item.func then
                item.func()
            end
        end)

        frame.rows[index] = row
        return row
    end

    function frame:RefreshRows()
        local total = #self.items
        local visible = min(total, self.maxVisible)
        local needsScroll = total > visible
        local rightInset = self.padding + (needsScroll and 10 or 0)
        local contentHeight = (visible * self.rowHeight) + (self.padding * 2)
        self:SetHeight(max(24, contentHeight))

        for i = 1, visible do
            local row = EnsureRow(i)
            local itemIndex = i + self.offset
            local item = self.items[itemIndex]
            row:SetPoint("TOPLEFT", self, "TOPLEFT", self.padding, -self.padding - ((i - 1) * self.rowHeight))
            row:SetPoint("TOPRIGHT", self, "TOPRIGHT", -rightInset, -self.padding - ((i - 1) * self.rowHeight))
            row._item = item
            row._selected = item and item.checked and true or false
            row._isTitle = item and item.isTitle and true or false
            row._isDisabled = item and (item.disabled or false) or false
            row.Text:SetText(item and item.text or "")

            if row._isTitle then
                SetTextColor(row.Text, CONTROL_COLORS.controlLabelAccent)
            elseif row._isDisabled then
                SetTextColor(row.Text, COLORS.subdued)
            else
                SetTextColor(row.Text, COLORS.text)
            end

            local checkVisible = item and not item.notCheckable and not row._isTitle
            row.Check:SetShown(checkVisible)
            if checkVisible then
                row.Check.Fill:SetShown(item.checked and true or false)
            end

            if row._selected then
                MythicTools:ApplySurface(row, CONTROL_COLORS.accentFillActive, CONTROL_COLORS.accentBorder40, COLORS.accent)
            else
                MythicTools:ApplySurface(row, CONTROL_COLORS.controlBG, {0, 0, 0, 0}, COLORS.accent)
            end
            row:Show()
        end

        for i = visible + 1, #self.rows do
            self.rows[i]:Hide()
        end

        self:EnableMouseWheel(needsScroll)
        if self.ScrollBar then
            self.ScrollBar:SetShown(needsScroll)
            if needsScroll then
                local maxOffset = max(0, total - visible)
                self._mtSettingScroll = true
                self.ScrollBar:SetMinMaxValues(0, maxOffset)
                self.ScrollBar:SetValue(self.offset)
                self._mtSettingScroll = nil
            end
        end
    end

    frame:SetScript("OnMouseWheel", function(self, delta)
        if #self.items <= self.maxVisible then
            return
        end
        self:SetOffset(self.offset - (delta > 0 and 1 or -1))
    end)
    scrollBar:SetScript("OnValueChanged", function(_, value)
        if frame._mtSettingScroll then
            return
        end
        frame:SetOffset(value)
    end)
    frame:SetScript("OnHide", function()
        if MythicTools.customMenuDismiss then
            MythicTools.customMenuDismiss:Hide()
        end
    end)

    dismiss:SetScript("OnClick", function()
        MythicTools:HideCustomMenu()
    end)

    if UISpecialFrames then
        local exists = false
        for _, frameName in ipairs(UISpecialFrames) do
            if frameName == "MythicToolsCustomMenu" then
                exists = true
                break
            end
        end
        if not exists then
            UISpecialFrames[#UISpecialFrames + 1] = "MythicToolsCustomMenu"
        end
    end

    MythicTools.customMenuDismiss = dismiss
    MythicTools.customMenuFrame = frame
    return frame
end

function MythicTools:HideCustomMenu()
    if self.customMenuFrame then
        self.customMenuFrame:Hide()
    end
    if self.customMenuDismiss then
        self.customMenuDismiss:Hide()
    end
end

function MythicTools:ShowCustomMenu(items, opts)
    local menu = EnsureCustomMenuFrame()
    opts = opts or {}
    menu.items = items or {}
    menu.offset = 0
    menu:SetWidth(max(120, opts.width or 180))
    menu:RefreshRows()

    menu:ClearAllPoints()
    if opts.anchor == "cursor" then
        local x, y = GetCursorPosition()
        local scale = UIParent:GetEffectiveScale() or 1
        x = x / scale
        y = y / scale
        menu:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", x + (opts.x or 0), y + (opts.y or 0))
    elseif opts.anchorFrame then
        menu:SetPoint(opts.point or "TOPLEFT", opts.anchorFrame, opts.relativePoint or "BOTTOMLEFT", opts.x or 0, opts.y or 0)
    else
        menu:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
    end

    if self.customMenuDismiss then
        self.customMenuDismiss:SetFrameLevel(menu:GetFrameLevel() - 1)
        self.customMenuDismiss:Show()
    end
    menu:Show()
    menu:Raise()
end

local function EnsureCustomModal()
    if MythicTools.customModalFrame then
        return MythicTools.customModalFrame
    end

    local overlay = CreateFrame("Button", "MythicToolsCustomModalOverlay", UIParent)
    overlay:SetAllPoints(UIParent)
    overlay:SetFrameStrata("TOOLTIP")
    overlay:SetFrameLevel(950)
    overlay:EnableMouse(true)
    overlay:Hide()

    local frame = CreateFrame("Frame", "MythicToolsCustomModal", UIParent)
    frame:SetSize(470, 220)
    frame:SetPoint("CENTER")
    frame:SetFrameStrata("TOOLTIP")
    frame:SetFrameLevel(951)
    frame:SetMovable(true)
    frame:EnableMouse(true)
    frame:RegisterForDrag("LeftButton")
    frame:SetScript("OnDragStart", frame.StartMoving)
    frame:SetScript("OnDragStop", frame.StopMovingOrSizing)
    frame:Hide()
    MythicTools:ApplySurface(frame, COLORS.frameBG, CONTROL_COLORS.accentBorder40, COLORS.accent)
    ClipChildren(frame)

    frame.Title = CreateFont(frame, 14, CONTROL_COLORS.controlLabelAccent)
    frame.Title:SetPoint("TOPLEFT", 14, -12)
    frame.Title:SetPoint("TOPRIGHT", -14, -12)
    frame.Title:SetJustifyH("LEFT")

    frame.Body = CreateFont(frame, 12, COLORS.text)
    frame.Body:SetPoint("TOPLEFT", 14, -44)
    frame.Body:SetPoint("TOPRIGHT", -14, -44)
    frame.Body:SetJustifyH("LEFT")
    frame.Body:SetJustifyV("TOP")

    frame.InputHolder = CreateFrame("Frame", nil, frame)
    frame.InputHolder:SetPoint("TOPLEFT", 14, -84)
    frame.InputHolder:SetPoint("TOPRIGHT", -14, -84)
    frame.InputHolder:SetHeight(82)
    MythicTools:ApplySurface(frame.InputHolder, CONTROL_COLORS.controlBG, CONTROL_COLORS.controlBorder, COLORS.accent)
    ClipChildren(frame.InputHolder)

    frame.Input = CreateFrame("EditBox", nil, frame.InputHolder)
    frame.Input:SetAutoFocus(false)
    frame.Input:SetMultiLine(true)
    frame.Input:SetFont(STANDARD_TEXT_FONT, 12, "")
    frame.Input:SetTextColor(COLORS.text[1], COLORS.text[2], COLORS.text[3], 1)
    frame.Input:SetJustifyH("LEFT")
    frame.Input:SetJustifyV("TOP")
    frame.Input:SetPoint("TOPLEFT", 8, -8)
    frame.Input:SetPoint("BOTTOMRIGHT", -8, 8)
    frame.Input:SetScript("OnEscapePressed", function()
        MythicTools:HideCustomModal()
    end)

    frame.CancelButton = CreateActionButton(frame, 96, 28, CANCEL or "Cancel")
    frame.CancelButton:SetPoint("BOTTOMRIGHT", -14, 14)
    frame.ConfirmButton = CreateActionButton(frame, 96, 28, OKAY or "OK")
    frame.ConfirmButton:SetPoint("RIGHT", frame.CancelButton, "LEFT", -10, 0)

    frame.CloseButton = CreateActionButton(frame, 28, 22, "X")
    frame.CloseButton:SetPoint("TOPRIGHT", -10, -8)
    MythicTools:ApplyCloseButtonIcon(frame.CloseButton, 10)
    frame.CloseButton:SetScript("OnClick", function()
        MythicTools:HideCustomModal()
    end)

    overlay:SetScript("OnClick", function()
        MythicTools:HideCustomModal()
    end)

    frame:SetScript("OnHide", function()
        overlay:Hide()
    end)

    if UISpecialFrames then
        local exists = false
        for _, frameName in ipairs(UISpecialFrames) do
            if frameName == "MythicToolsCustomModal" then
                exists = true
                break
            end
        end
        if not exists then
            UISpecialFrames[#UISpecialFrames + 1] = "MythicToolsCustomModal"
        end
    end

    MythicTools.customModalOverlay = overlay
    MythicTools.customModalFrame = frame
    return frame
end

function MythicTools:HideCustomModal()
    if self.customModalFrame then
        self.customModalFrame:Hide()
    end
    if self.customModalOverlay then
        self.customModalOverlay:Hide()
    end
end

function MythicTools:ShowCustomModal(config)
    local frame = EnsureCustomModal()
    config = config or {}

    local bodyText = config.text or ""
    local hasBodyText = type(bodyText) == "string" and self:TrimText(bodyText) ~= ""

    frame:SetWidth(max(320, tonumber(config.width) or 470))
    frame.Title:SetText(config.title or "")
    frame.Body:SetText(bodyText)
    frame.Body:SetShown(hasBodyText)

    frame.Input:SetText(config.inputText or "")
    frame.Input:SetCursorPosition(0)
    frame.Input:SetMaxLetters(config.maxLetters or 0)
    frame.Input:SetEnabled(config.inputMode ~= "none")
    frame.InputHolder:SetShown(config.inputMode ~= "none")
    frame.Input:SetTextColor(COLORS.text[1], COLORS.text[2], COLORS.text[3], 1)

    local inputTopOffset = tonumber(config.inputTopOffset) or (hasBodyText and -84 or -44)
    frame.InputHolder:ClearAllPoints()
    frame.InputHolder:SetPoint("TOPLEFT", 14, inputTopOffset)
    frame.InputHolder:SetPoint("TOPRIGHT", -14, inputTopOffset)

    local inputHeight = config.inputMode == "none" and 0 or (config.inputHeight or 82)
    if config.inputMode == "none" then
        frame:SetHeight(config.height or (hasBodyText and 150 or 120))
    else
        frame.InputHolder:SetHeight(inputHeight)
        local modalMinHeight = (-inputTopOffset) + inputHeight + 54
        frame:SetHeight(config.height or modalMinHeight)
    end

    if config.inputMode == "readonly" then
        frame.Input:SetEnabled(true)
        frame.Input:SetFocus()
        frame.Input:HighlightText()
    elseif config.inputMode == "edit" then
        frame.Input:SetEnabled(true)
        frame.Input:SetFocus()
        frame.Input:HighlightText()
    else
        frame.Input:ClearFocus()
    end

    frame.ConfirmButton.Text:SetText(config.confirmText or (OKAY or "OK"))
    frame.CancelButton.Text:SetText(config.cancelText or (CANCEL or "Cancel"))

    local showCancel = config.showCancel ~= false
    frame.CancelButton:SetShown(showCancel)
    frame.ConfirmButton:ClearAllPoints()
    if showCancel then
        frame.ConfirmButton:SetPoint("RIGHT", frame.CancelButton, "LEFT", -10, 0)
    else
        frame.ConfirmButton:SetPoint("BOTTOMRIGHT", -14, 14)
    end
    frame.CancelButton:SetScript("OnClick", function()
        MythicTools:HideCustomModal()
        if config.onCancel then
            config.onCancel()
        end
    end)

    frame.ConfirmButton:SetScript("OnClick", function()
        local text = frame.Input:GetText() or ""
        MythicTools:HideCustomModal()
        if config.onAccept then
            config.onAccept(text)
        end
    end)

    frame.Input:SetScript("OnEnterPressed", function(self)
        if config.inputMode == "edit" then
            frame.ConfirmButton:Click()
        else
            self:HighlightText()
        end
    end)

    frame.Input:SetScript("OnKeyUp", function(_, key)
        if not (config.closeOnCopy and config.inputMode == "readonly") then
            return
        end

        local keyUpper = type(key) == "string" and key:upper() or ""
        local ctrlDown = IsControlKeyDown and IsControlKeyDown()
        local metaDown = IsMetaKeyDown and IsMetaKeyDown()
        if keyUpper == "C" and (ctrlDown or metaDown) then
            MythicTools:HideCustomModal()
            if config.onCopy then
                config.onCopy()
            end
        end
    end)

    frame.Input:SetScript("OnEscapePressed", function()
        if showCancel then
            frame.CancelButton:Click()
        else
            MythicTools:HideCustomModal()
        end
    end)

    if self.customModalOverlay then
        self.customModalOverlay:SetFrameLevel(frame:GetFrameLevel() - 1)
        self.customModalOverlay:Show()
    end

    frame:Show()
    frame:Raise()
end

local function ApplyThinScrollBar(scrollFrame)
    if not scrollFrame then return end
    local scrollBar
    for _, child in ipairs({scrollFrame:GetChildren()}) do
        if child.GetThumbTexture or child:GetObjectType() == "Slider" then
            scrollBar = child
            break
        end
    end
    if not scrollBar then return end

    scrollBar:SetWidth(6)
    scrollBar:ClearAllPoints()
    scrollBar:SetPoint("TOPRIGHT", scrollFrame, "TOPRIGHT", 0, -1)
    scrollBar:SetPoint("BOTTOMRIGHT", scrollFrame, "BOTTOMRIGHT", 0, 1)

    for _, btn in ipairs({scrollBar:GetChildren()}) do
        btn:SetAlpha(0)
        btn:EnableMouse(false)
    end

    local numRegions = scrollBar.GetNumRegions and scrollBar:GetNumRegions() or 0
    for i = 1, numRegions do
        local region = select(i, scrollBar:GetRegions())
        if region and region.SetTexture then
            region:SetTexture(nil)
        end
    end

    local thumb = scrollBar.GetThumbTexture and scrollBar:GetThumbTexture()
    if thumb then
        thumb:SetTexture("Interface\\AddOns\\SkyMythicHistory\\Media\\Textures\\statusbar.tga")
        thumb:SetWidth(4)
        thumb:SetVertexColor(COLORS.accent[1], COLORS.accent[2], COLORS.accent[3], 0.65)
    end
end

local function ApplyThinScrollBarToST(tableWidget)
    if not tableWidget or not tableWidget.scrollframe then return end
    local scrollframe = tableWidget.scrollframe
    local f = tableWidget.frame

    -- Hide lib-st's custom scroll trough frames (they are Frame children of scrollframe)
    for _, child in ipairs({scrollframe:GetChildren()}) do
        if child:GetObjectType() == "Frame" then
            child:Hide()
        end
    end

    -- Expand the scrollframe into the space freed by removing the wide trough
    scrollframe:ClearAllPoints()
    scrollframe:SetPoint("TOPLEFT", f, "TOPLEFT", 0, -4)
    scrollframe:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -10, 3)

    ApplyThinScrollBar(scrollframe)
end

local function CreateAceEditWidget(labelText, width, value, onChanged)
    local widget = {}
    local frame = CreateFrame("Frame", nil, UIParent)
    frame:SetSize(width or 200, 50)

    local label = CreateFont(frame, 11, CONTROL_COLORS.controlLabelAccent)
    label:SetPoint("TOPLEFT", 2, 0)
    label:SetPoint("TOPRIGHT", -2, 0)
    label:SetJustifyH("LEFT")
    label:SetText(labelText or "")
    widget.Label = label

    local bg = CreateFrame("Frame", nil, frame)
    bg:SetPoint("TOPLEFT", 0, -18)
    bg:SetPoint("TOPRIGHT", 0, -18)
    bg:SetHeight(28)
    MythicTools:ApplySurface(bg, CONTROL_COLORS.controlBG, CONTROL_COLORS.controlBorder, COLORS.accent)
    widget.Bg = bg

    local editBox = CreateFrame("EditBox", nil, bg)
    editBox:SetPoint("TOPLEFT", 0, 0)
    editBox:SetPoint("BOTTOMRIGHT", 0, 0)
    StyleEditBox(editBox)
    widget.editbox = editBox

    widget.frame = frame
    widget._callbacks = {}
    widget._mtSettingValue = nil

    function widget:SetLabel(text)
        self.Label:SetText(text or "")
    end

    function widget:SetWidth(newWidth)
        self.frame:SetWidth(newWidth or self.frame:GetWidth())
    end

    function widget:SetText(text)
        self.editbox:SetText(text or "")
        self.editbox:SetCursorPosition(0)
    end

    function widget:GetText()
        return self.editbox:GetText()
    end

    function widget:SetDisabled(disabled)
        self.disabled = disabled and true or false
        self.editbox:SetEnabled(not self.disabled)
        SetTextColor(self.Label, self.disabled and COLORS.subdued or CONTROL_COLORS.controlLabelAccent)
        SetTextColor(self.editbox, self.disabled and COLORS.subdued or CONTROL_COLORS.controlText)
    end

    function widget:DisableButton(_)
    end

    function widget:SetCallback(eventName, callback)
        self._callbacks[eventName] = callback
    end

    editBox:SetScript("OnEscapePressed", function(self)
        self:ClearFocus()
    end)
    editBox:SetScript("OnEnterPressed", function(self)
        local text = self:GetText() or ""
        if widget._callbacks.OnEnterPressed then
            widget._callbacks.OnEnterPressed(widget, "OnEnterPressed", text)
        end
        self:ClearFocus()
    end)
    editBox:SetScript("OnTextChanged", function(self)
        local text = self:GetText() or ""
        if widget._callbacks.OnTextChanged then
            widget._callbacks.OnTextChanged(widget, "OnTextChanged", text)
        end
    end)

    widget:SetText(value or "")
    widget:SetCallback("OnEnterPressed", function(_, _, text)
        if widget._mtSettingValue then
            return
        end
        if onChanged then
            onChanged(text or "")
        end
    end)
    widget:SetCallback("OnTextChanged", function(_, _, text)
        if widget._mtSettingValue then
            return
        end
        if onChanged then
            onChanged(text or "")
        end
    end)

    return widget
end

local function CreateAceDropdownWidget(labelText, width, options, value, onChanged)
    local widget = {}
    local frame = CreateFrame("Frame", nil, UIParent)
    frame:SetSize(width or 200, 50)

    local label = CreateFont(frame, 11, CONTROL_COLORS.controlLabelAccent)
    label:SetPoint("TOPLEFT", 2, 0)
    label:SetPoint("TOPRIGHT", -2, 0)
    label:SetJustifyH("LEFT")
    label:SetText(labelText or "")
    widget.Label = label

    local button = CreateActionButton(frame, width or 200, 28, "")
    button:SetPoint("TOPLEFT", 0, -18)
    button.Text:ClearAllPoints()
    button.Text:SetPoint("LEFT", 10, 0)
    button.Text:SetPoint("RIGHT", -26, 0)
    button.Text:SetJustifyH("LEFT")
    widget.Button = button
    widget.button = button

    local arrow = button:CreateTexture(nil, "OVERLAY")
    arrow:SetSize(10, 10)
    arrow:SetPoint("RIGHT", -8, 0)
    arrow:SetTexture("Interface\\AddOns\\SkyMythicHistory\\Media\\Textures\\ArrowDown.tga")
    arrow:SetTexCoord(0, 1, 0, 1)
    arrow:SetVertexColor(CONTROL_COLORS.controlLabelAccent[1], CONTROL_COLORS.controlLabelAccent[2], CONTROL_COLORS.controlLabelAccent[3], 1)
    widget.Arrow = arrow

    widget.frame = frame
    widget.list = {}
    widget.order = {}
    widget.value = nil
    widget._callbacks = {}
    widget._mtSettingValue = nil

    function widget:SetLabel(text)
        self.Label:SetText(text or "")
    end

    function widget:SetWidth(newWidth)
        newWidth = newWidth or self.frame:GetWidth()
        self.frame:SetWidth(newWidth)
        self.Button:SetWidth(newWidth)
    end

    function widget:SetList(list, order)
        self.list = list or {}
        self.order = order or {}
        if self.value ~= nil and self.list[self.value] == nil then
            self.value = self.order[1]
        end
        self:SetValue(self.value)
    end

    function widget:SetValue(nextValue)
        if nextValue ~= nil and nextValue ~= "" and self.list[nextValue] == nil then
            nextValue = self.order[1]
        end
        self.value = nextValue
        self.Button.Text:SetText(self.list[self.value] or "")
    end

    function widget:GetValue()
        return self.value
    end

    function widget:SetDisabled(disabled)
        self.disabled = disabled and true or false
        self.Button:SetEnabled(not self.disabled)
        SetTextColor(self.Label, self.disabled and COLORS.subdued or CONTROL_COLORS.controlLabelAccent)
        SetTextColor(self.Button.Text, self.disabled and COLORS.subdued or CONTROL_COLORS.controlText)
        if self.Arrow and self.Arrow.SetVertexColor then
            local color = self.disabled and COLORS.subdued or CONTROL_COLORS.controlLabelAccent
            self.Arrow:SetVertexColor(color[1], color[2], color[3], color[4] or 1)
        end
    end

    function widget:SetCallback(eventName, callback)
        self._callbacks[eventName] = callback
    end

    button:SetScript("OnClick", function()
        if widget.disabled then
            return
        end

        local menuItems = {}
        for _, key in ipairs(widget.order or {}) do
            menuItems[#menuItems + 1] = {
                text = widget.list[key],
                checked = key == widget.value,
                notCheckable = false,
                func = function()
                    widget:SetValue(key)
                    if widget._callbacks.OnValueChanged then
                        widget._callbacks.OnValueChanged(widget, "OnValueChanged", key)
                    end
                end,
            }
        end

        MythicTools:ShowCustomMenu(menuItems, {
            anchorFrame = frame,
            point = "TOPLEFT",
            relativePoint = "BOTTOMLEFT",
            x = 0,
            y = -2,
            width = max(140, frame:GetWidth()),
        })
    end)
    button:SetScript("OnMouseDown", function()
        if widget.disabled then
            return
        end
        if widget.Arrow then
            widget.Arrow:SetTexture("Interface\\AddOns\\SkyMythicHistory\\Media\\Textures\\ArrowDownPushed.tga")
        end
    end)
    button:SetScript("OnMouseUp", function()
        if widget.Arrow then
            widget.Arrow:SetTexture("Interface\\AddOns\\SkyMythicHistory\\Media\\Textures\\ArrowDown.tga")
        end
    end)

    local list = {}
    local order = {}
    for _, info in ipairs(options or {}) do
        list[info.value] = info.label
        order[#order + 1] = info.value
    end
    widget:SetList(list, order)
    widget:SetValue(value)
    widget:SetCallback("OnValueChanged", function(_, _, selectedValue)
        if widget._mtSettingValue then
            return
        end
        if onChanged then
            onChanged(selectedValue)
        end
    end)

    return widget
end

local function UpdateAceDropdownOptions(widget, options, value)
    if not widget then
        return
    end

    local list = {}
    local order = {}
    for _, info in ipairs(options or {}) do
        list[info.value] = info.label
        order[#order + 1] = info.value
    end

    local resolvedValue = value
    if resolvedValue ~= nil and resolvedValue ~= "" and list[resolvedValue] == nil then
        for _, info in ipairs(options or {}) do
            if info.label == resolvedValue then
                resolvedValue = info.value
                break
            end
        end
    end
    if resolvedValue == nil or resolvedValue == "" or list[resolvedValue] == nil then
        resolvedValue = order[1]
    end

    widget._mtSettingValue = true
    widget:SetList(list, order)
    widget:SetValue(resolvedValue)
    widget._mtSettingValue = nil
end

local function SetAceEditText(widget, value)
    if widget then
        widget._mtSettingValue = true
        widget:SetText(value or "")
        widget._mtSettingValue = nil
    end
end

local function SetAceDropdownValue(widget, value)
    if widget then
        widget._mtSettingValue = true
        widget:SetValue(value)
        widget._mtSettingValue = nil
    end
end

function MythicTools:RefreshOwnedCharacterFilterOptions()
    if not (self.ui and self.ui.runsOwnedFilter) then
        return
    end

    local options = GetOwnedCharacterFilterOptions()
    local selectedValue = (self.db and self.db.ui and self.db.ui.filters and self.db.ui.filters.ownedCharacters) or "all"
    local hasSelectedValue = false

    for _, info in ipairs(options) do
        if info.value == selectedValue then
            hasSelectedValue = true
            break
        end
    end

    if not hasSelectedValue then
        selectedValue = "all"
        self.db.ui.filters.ownedCharacters = selectedValue
    end

    UpdateAceDropdownOptions(self.ui.runsOwnedFilter, options, selectedValue)
end

function MythicTools:RefreshStatsCharacterFilterOptions()
    if not (self.ui and self.ui.statsCharacterFilter) then
        return
    end

    self.db.ui.statsFilters = self.db.ui.statsFilters or {}
    local statsFilters = self.db.ui.statsFilters
    local season = statsFilters.season or "all"
    local options = self:GetStatsCharacterFilterOptions(season)
    local selectedValue = self:ResolveDropdownValueFromOptions(options, statsFilters.character or "all", "all")

    statsFilters.character = selectedValue
    UpdateAceDropdownOptions(self.ui.statsCharacterFilter, options, selectedValue)
end

function MythicTools:ResetRunsFilters()
    self.db.ui.filters = {
        search = "",
        player = "",
        dungeon = "all",
        season = GetDefaultSeason(),
        date = "",
        status = "all",
        ownedCharacters = "all",
    }
end

function MythicTools:ResetPlayerFilters()
    self.db.ui.playerSearch = ""
    self.db.ui.playerFilters = {
        search = "",
        season = GetDefaultSeason(),
        dungeon = "all",
        augMode = "all",
    }
end

function MythicTools:ResetNavigationFilters()
    self:ResetRunsFilters()
    self:ResetPlayerFilters()
end

function MythicTools:SyncFilterWidgets()
    if not self.ui then
        return
    end

    local runFilters = self.db.ui.filters or {}
    runFilters.dungeon = NormalizeDungeonFilterValueForSeason(runFilters.season, runFilters.dungeon)
    SetAceEditText(self.ui.runsSearch, runFilters.search)
    SetAceEditText(self.ui.runsPlayerFilter, runFilters.player)
    SetAceDropdownValue(self.ui.runsSeasonFilter, runFilters.season)
    UpdateAceDropdownOptions(self.ui.runsDungeonFilter, GetDungeonOptionsForSeason(runFilters.season), runFilters.dungeon)
    SetAceEditText(self.ui.runsDateFilter, runFilters.date)
    SetAceDropdownValue(self.ui.runsStatusFilter, runFilters.status)
    self:RefreshOwnedCharacterFilterOptions()

    local playerFilters = self.db.ui.playerFilters or {}
    playerFilters.dungeon = NormalizeDungeonFilterValueForSeason(playerFilters.season or GetDefaultSeason(), playerFilters.dungeon)
    SetAceEditText(self.ui.playerSearch, playerFilters.search or self.db.ui.playerSearch)
    SetAceDropdownValue(self.ui.playersSeasonFilter, playerFilters.season or GetDefaultSeason())
    UpdateAceDropdownOptions(self.ui.playersDungeonFilter, GetDungeonOptionsForSeason(playerFilters.season or GetDefaultSeason()), playerFilters.dungeon or "all")
    SetAceDropdownValue(self.ui.playersAugFilter, playerFilters.augMode or "all")

    local statsFilters = self.db.ui.statsFilters or {}
    SetAceDropdownValue(self.ui.statsSeasonFilter, statsFilters.season or "all")
    self:RefreshStatsCharacterFilterOptions()

    local characterFilters = self.db.ui.characterFilters or {}
    SetAceDropdownValue(self.ui.charactersSeasonFilter, characterFilters.season or "all")
end

function MythicTools:ResolveDisplayIcon(icon, specID, classFilename)
    if specID then
        local _, _, specIconID = self:GetSpecInfo(specID)
        if specIconID then
            return specIconID
        end
    end

    if classFilename then
        return QUESTION_MARK_ICON
    end

    if type(icon) == "number" then
        if icon <= 0 then
            return QUESTION_MARK_ICON
        end
        if C_Spell and C_Spell.GetSpellTexture then
            local spellIcon = C_Spell.GetSpellTexture(icon)
            if spellIcon then
                return spellIcon
            end
        end
        return icon
    end

    if type(icon) == "string" then
        local value = self:TrimText(icon)
        if value == "" then
            return QUESTION_MARK_ICON
        end
        local numericIcon = tonumber(value)
        if numericIcon then
            return self:ResolveDisplayIcon(numericIcon, specID, classFilename)
        end
        if value:match("^[Ii]nterface\\") then
            return value
        end
    end

    return QUESTION_MARK_ICON
end

local function CreateListButton(parent, height)
    local button = CreateFrame("Button", nil, parent)
    button:SetHeight(height)
    button:RegisterForClicks("LeftButtonUp", "RightButtonUp")
    MythicTools:ApplySurface(button, COLORS.rowBG, COLORS.surface, nil)

    button.Accent = button:CreateTexture(nil, "ARTWORK")
    button.Accent:SetTexture(WHITE_TEXTURE)
    button.Accent:SetPoint("TOPLEFT")
    button.Accent:SetPoint("BOTTOMLEFT")
    button.Accent:SetWidth(3)
    button.Accent:SetVertexColor(COLORS.accent[1], COLORS.accent[2], COLORS.accent[3], 0.25)

    button.IconFrame = CreateFrame("Frame", nil, button)
    button.IconFrame:SetSize(42, 42)
    button.IconFrame:SetPoint("LEFT", 12, 0)
    MythicTools:ApplySurface(button.IconFrame, COLORS.frameBG, COLORS.surface, nil)

    button.Icon = button.IconFrame:CreateTexture(nil, "ARTWORK")
    button.Icon:SetPoint("TOPLEFT", 1, -1)
    button.Icon:SetPoint("BOTTOMRIGHT", -1, 1)
    button.Icon:SetTexture(DEFAULT_DUNGEON_ICON)
    button.Icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)

    button.Title = CreateFont(button, 13, COLORS.text)
    button.Title:SetPoint("LEFT", button.IconFrame, "RIGHT", 12, 10)
    button.Title:SetPoint("RIGHT", button, "RIGHT", -190, 10)

    button.Subtitle = CreateFont(button, 11, COLORS.muted)
    button.Subtitle:SetPoint("TOPLEFT", button.Title, "BOTTOMLEFT", 0, -6)
    button.Subtitle:SetPoint("TOPRIGHT", -104, -28)
    button.Subtitle:SetJustifyH("LEFT")

    button.OwnerRole = CreateFrame("Frame", nil, button)
    button.OwnerRole:SetSize(16, 16)
    button.OwnerRole:SetPoint("LEFT", button.IconFrame, "RIGHT", 12, -11)
    button.OwnerRole.Icon = button.OwnerRole:CreateTexture(nil, "ARTWORK")
    button.OwnerRole.Icon:SetPoint("TOPLEFT", 0, 0)
    button.OwnerRole.Icon:SetPoint("BOTTOMRIGHT", 0, 0)
    button.OwnerRole.Label = CreateFont(button.OwnerRole, 8, COLORS.text, "CENTER")
    button.OwnerRole.Label:SetAllPoints()
    button.OwnerRole:Hide()

    button.OwnerSpec = CreateFrame("Frame", nil, button)
    button.OwnerSpec:SetSize(16, 16)
    button.OwnerSpec:SetPoint("LEFT", button.OwnerRole, "RIGHT", 5, 0)
    MythicTools:ApplySurface(button.OwnerSpec, COLORS.frameBG, COLORS.surface, nil)
    button.OwnerSpec.Icon = button.OwnerSpec:CreateTexture(nil, "ARTWORK")
    button.OwnerSpec.Icon:SetPoint("TOPLEFT", 1, -1)
    button.OwnerSpec.Icon:SetPoint("BOTTOMRIGHT", -1, 1)
    button.OwnerSpec.Icon:SetTexture(QUESTION_MARK_ICON)
    button.OwnerSpec:Hide()

    button.OwnerName = CreateFont(button, 10, COLORS.muted)
    button.OwnerName:SetPoint("LEFT", button.OwnerSpec, "RIGHT", 5, 0)
    button.OwnerName:SetPoint("RIGHT", button, "RIGHT", -190, 0)
    button.OwnerName:SetJustifyH("LEFT")
    button.OwnerName:Hide()

    button.RightTop = CreateFont(button, 11, COLORS.accent, "RIGHT")
    button.RightTop:SetPoint("RIGHT", button, "RIGHT", -12, 10)
    button.RightTop:SetWidth(170)

    button.RightBottom = CreateFont(button, 11, COLORS.subdued, "RIGHT")
    button.RightBottom:SetPoint("RIGHT", button, "RIGHT", -12, -11)
    button.RightBottom:SetWidth(170)

    button:SetHighlightTexture(WHITE_TEXTURE)
    local highlight = button:GetHighlightTexture()
    highlight:SetAllPoints()
    highlight:SetVertexColor(1, 1, 1, 0.04)

    return button
end

function MythicTools:ConfirmDeleteRun(runId)
    if not runId then
        return
    end

    local run = self:GetRunById(runId)
    if not run then
        return
    end

    local label = ("%s +%d"):format(run.dungeonName or "Unknown", tonumber(run.level) or 0)
    self:ShowCustomModal({
        title = self:L("SETTINGS_HISTORY"),
        text = self:L("SETTINGS_DELETE_RUN_CONFIRM", label),
        inputMode = "none",
        confirmText = YES or "Yes",
        cancelText = NO or "No",
        onAccept = function()
            if MythicTools:RemoveRunById(runId) then
                MythicTools:HideCompletionPopup()
                MythicTools:SetRunViewMode("list")
                MythicTools:RefreshAllViews()
            end
        end,
    })
end

local function CreateMetricCard(parent, labelText)
    local card = CreateFrame("Frame", nil, parent)
    MythicTools:ApplySurface(card, COLORS.rowBG, COLORS.surface, nil)

    card.Label = CreateFont(card, 10, COLORS.subdued)
    card.Label:SetPoint("TOPLEFT", 12, -10)
    card.Label:SetPoint("TOPRIGHT", -12, -10)
    card.Label:SetJustifyH("LEFT")
    card.Label:SetText(labelText)

    card.Value = CreateFont(card, 18, COLORS.text)
    card.Value:SetPoint("TOPLEFT", card.Label, "BOTTOMLEFT", 0, -8)
    card.Value:SetPoint("BOTTOMLEFT", 12, 10)
    card.Value:SetWidth(96)
    card.Value:SetJustifyH("LEFT")

    card.Meta = CreateFont(card, 10, COLORS.subdued, "RIGHT")
    card.Meta:SetPoint("LEFT", card.Value, "RIGHT", 10, 0)
    card.Meta:SetPoint("RIGHT", card, "RIGHT", -12, 0)
    card.Meta:SetJustifyH("RIGHT")

    return card
end

local function CreateBreakdownRow(parent)
    local row = CreateFrame("Frame", nil, parent)
    row:SetHeight(34)
    MythicTools:ApplySurface(row, COLORS.rowBG, COLORS.surface, nil)

    row.Title = CreateFont(row, 11, COLORS.text)
    row.Title:SetPoint("TOPLEFT", 10, -6)
    row.Title:SetPoint("TOPRIGHT", -10, -6)
    row.Title:SetJustifyH("LEFT")

    row.Meta = CreateFont(row, 10, COLORS.muted)
    row.Meta:SetPoint("TOPLEFT", row.Title, "BOTTOMLEFT", 0, -4)
    row.Meta:SetPoint("TOPRIGHT", -10, -18)
    row.Meta:SetJustifyH("LEFT")

    return row
end

local function CreateSpecChip(parent)
    local chip = CreateFrame("Frame", nil, parent)
    chip:SetSize(88, 26)
    MythicTools:ApplySurface(chip, COLORS.rowBG, COLORS.surface, nil)

    chip.Icon = chip:CreateTexture(nil, "ARTWORK")
    chip.Icon:SetSize(18, 18)
    chip.Icon:SetPoint("LEFT", 6, 0)
    chip.Icon:SetTexture(QUESTION_MARK_ICON)
    chip.Icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)

    chip.Count = CreateFont(chip, 11, COLORS.text)
    chip.Count:SetPoint("LEFT", chip.Icon, "RIGHT", 6, 0)
    chip.Count:SetJustifyH("LEFT")

    chip.Role = CreateFont(chip, 9, COLORS.subdued, "RIGHT")
    chip.Role:SetPoint("RIGHT", -6, 0)
    chip.Role:SetJustifyH("RIGHT")

    return chip
end

local function CreateRoleBadge(parent)
    local badge = CreateFrame("Frame", nil, parent)
    badge:SetSize(24, 24)

    badge.Icon = badge:CreateTexture(nil, "ARTWORK")
    badge.Icon:SetPoint("TOPLEFT", 0, 0)
    badge.Icon:SetPoint("BOTTOMRIGHT", 0, 0)
    badge.Icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)

    badge.Label = CreateFont(badge, 9, COLORS.text, "CENTER")
    badge.Label:SetAllPoints()

    badge:Hide()
    return badge
end

function MythicTools:CreateHeroRatingBadge(parent)
    local badge = CreateFrame("Frame", nil, parent)
    badge:SetSize(170, 28)
    badge.Stars = {}
    badge.starSize = 16
    badge.starSpacing = 3

    badge.StarsContainer = CreateFrame("Frame", nil, badge)
    badge.StarsContainer:SetPoint("RIGHT", 0, 0)
    badge.StarsContainer:SetSize((badge.starSize * 5) + (badge.starSpacing * 4), badge.starSize)

    for value = 1, 5 do
        local star = CreateFrame("Frame", nil, badge.StarsContainer)
        star:SetSize(badge.starSize, badge.starSize)
        if value == 1 then
            star:SetPoint("LEFT", 0, 0)
        else
            star:SetPoint("LEFT", badge.Stars[value - 1], "RIGHT", badge.starSpacing, 0)
        end

        star.Bg = star:CreateTexture(nil, "ARTWORK")
        star.Bg:SetAllPoints()
        star.Bg:SetTexture(RATING_STAR_ICON)
        star.Bg:SetVertexColor(0.65, 0.68, 0.72, 1)
        star.Bg:SetAlpha(0.22)

        star.Fill = star:CreateTexture(nil, "OVERLAY")
        star.Fill:SetPoint("TOPLEFT", 0, 0)
        star.Fill:SetPoint("BOTTOMLEFT", 0, 0)
        star.Fill:SetWidth(badge.starSize)
        star.Fill:SetTexture(RATING_STAR_ICON)
        star.Fill:SetVertexColor(1, 0.82, 0.18, 1)
        star.Fill:SetTexCoord(0, 1, 0, 1)

        badge.Stars[value] = star
    end

    badge.Value = CreateFont(badge, 17, COLORS.bestMetric, "RIGHT")
    badge.Value:SetPoint("RIGHT", badge.StarsContainer, "LEFT", -8, 0)
    badge.Value:SetWidth(46)
    badge.Value:SetText("")

    return badge
end

local function CreateMultilineNoteEditor(parent, width, height)
    local holder = CreateFrame("Frame", nil, parent)
    holder:SetSize(width, height)
    MythicTools:ApplySurface(holder, COLORS.rowBG, COLORS.surface, nil)
    ClipChildren(holder)

    local editBox = CreateFrame("EditBox", nil, holder)
    editBox:SetAutoFocus(false)
    editBox:SetMultiLine(true)
    editBox:SetFontObject(ChatFontNormal)
    editBox:SetTextColor(COLORS.text[1], COLORS.text[2], COLORS.text[3], 1)
    editBox:SetMaxLetters(160)
    editBox:SetJustifyH("LEFT")
    editBox:SetJustifyV("TOP")
    editBox:SetPoint("TOPLEFT", 10, -8)
    editBox:SetPoint("BOTTOMRIGHT", -10, 8)
    editBox:SetScript("OnEscapePressed", function(self)
        self:ClearFocus()
    end)
    editBox:SetScript("OnTextChanged", function(self)
        local text = self:GetText() or ""
        if text:find("\n") then
            local firstLine, secondLine = text:match("^([^\n]*)\n([^\n]*)")
            local limited = (firstLine or "") .. "\n" .. (secondLine or "")
            if limited ~= text then
                self:SetText(limited)
                self:SetCursorPosition(strlen(limited))
            end
        end
    end)

    holder:SetScript("OnSizeChanged", function(self, newWidth, newHeight)
        editBox:SetWidth(max(20, (newWidth or width or 20) - 20))
        editBox:SetHeight(max(20, (newHeight or height or 20) - 16))
    end)

    holder.EditBox = editBox
    return holder
end

local function CreatePlayerFilterButton(parent, anchorWidget)
    local button = CreateFrame("Button", nil, parent)
    button:SetFrameLevel((parent:GetFrameLevel() or 1) + 6)
    button:SetSize(anchorWidget:GetWidth() > 0 and anchorWidget:GetWidth() or 220, 14)
    button:SetPoint("TOPLEFT", anchorWidget, "TOPLEFT", -2, 0)
    button:RegisterForClicks("AnyUp")
    button:SetHighlightTexture(WHITE_TEXTURE)

    local highlight = button:GetHighlightTexture()
    highlight:SetAllPoints()
    highlight:SetVertexColor(1, 1, 1, 0.08)

    button:SetScript("OnClick", function(self, mouseButton)
        if self.playerName and self.playerName ~= "" then
            if mouseButton == "RightButton" then
                MythicTools:ShowPlayerContextMenu(self.playerName)
            else
                MythicTools:OpenPlayerHistory(self.playerName)
            end
        end
    end)

    button:SetScript("OnEnter", function(self)
        if not self.playerName or self.playerName == "" then
            return
        end

        MythicTools:ShowPlayerSummaryTooltip(self, self.playerName, "ANCHOR_TOPLEFT")
    end)

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

    return button
end

local function CreatePortraitWidget(parent, size, options)
    local noBorder = type(options) == "table" and options.noBorder
    local portrait = CreateFrame("Frame", nil, parent)
    portrait:SetSize(size, size)
    if not noBorder then
        MythicTools:ApplySurface(portrait, COLORS.frameBG, COLORS.surface, nil)
    end

    portrait.Texture = portrait:CreateTexture(nil, "ARTWORK")
    if noBorder then
        portrait.Texture:SetAllPoints()
    else
        portrait.Texture:SetPoint("TOPLEFT", 1, -1)
        portrait.Texture:SetPoint("BOTTOMRIGHT", -1, 1)
    end
    portrait.Texture:SetTexture(QUESTION_MARK_ICON)
    portrait.Texture:SetTexCoord(0, 1, 0, 1)

    portrait.SpecIcon = portrait:CreateTexture(nil, "OVERLAY")
    portrait.SpecIcon:SetSize(14, 14)
    portrait.SpecIcon:SetPoint("BOTTOMRIGHT", 4, -4)
    portrait.SpecIcon:Hide()

    return portrait
end

local function CreateLootIconButton(parent, size)
    local button = CreateFrame("Button", nil, parent)
    button:SetSize(size, size)
    button:EnableMouse(true)
    button:RegisterForClicks("AnyUp")
    button:SetFrameLevel((parent:GetFrameLevel() or 1) + 8)
    MythicTools:ApplySurface(button, COLORS.frameBG, COLORS.surface, nil)
    if button._mtSurface then
        button._mtSurface.top:SetDrawLayer("OVERLAY", 6)
        button._mtSurface.bottom:SetDrawLayer("OVERLAY", 6)
        button._mtSurface.left:SetDrawLayer("OVERLAY", 6)
        button._mtSurface.right:SetDrawLayer("OVERLAY", 6)
    end

    button.Icon = button:CreateTexture(nil, "ARTWORK")
    button.Icon:SetPoint("TOPLEFT", 1, -1)
    button.Icon:SetPoint("BOTTOMRIGHT", -1, 1)
    button.Icon:SetTexture(QUESTION_MARK_ICON)
    button.Icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)

    button.Gloss = button:CreateTexture(nil, "OVERLAY")
    button.Gloss:SetTexture(WHITE_TEXTURE)
    button.Gloss:SetPoint("TOPLEFT", 1, -1)
    button.Gloss:SetPoint("TOPRIGHT", -1, -1)
    button.Gloss:SetHeight(6)
    button.Gloss:SetVertexColor(1, 1, 1, 0.10)

    button.ItemLevel = CreateFont(button, 9, COLORS.bright, "CENTER", "OVERLAY", "OUTLINE")
    button.ItemLevel:SetPoint("BOTTOM", 0, 1)
    button.ItemLevel:SetText("")

    button:SetScript("OnEnter", function(self)
        if not self.itemLink then
            return
        end

        GameTooltip:SetOwner(self, "ANCHOR_TOPLEFT")
        GameTooltip:SetHyperlink(self.itemLink)
        GameTooltip:Show()

        self:SetScript("OnUpdate", function()
            if IsShiftKeyDown() then
                GameTooltip_ShowCompareItem(GameTooltip)
            else
                GameTooltip_HideShoppingTooltips(GameTooltip)
            end
        end)
    end)

    button:SetScript("OnLeave", function(self)
        GameTooltip:Hide()
        GameTooltip_HideShoppingTooltips(GameTooltip)
        self:SetScript("OnUpdate", nil)
    end)

    button:Hide()
    return button
end

local function CreateLootTextButton(parent, width)
    local button = CreateFrame("Button", nil, parent)
    button:SetHeight(parent:GetHeight())
    button:SetWidth(width)
    button:SetFrameLevel((parent:GetFrameLevel() or 1) + 8)
    button:EnableMouse(true)
    button:RegisterForClicks("AnyUp")

    button.Text = CreateFont(button, 10, COLORS.muted)
    button.Text:SetPoint("TOPLEFT", 0, 0)
    button.Text:SetPoint("BOTTOMRIGHT", 0, 0)
    button.Text:SetJustifyH("LEFT")
    button.Text:SetJustifyV("MIDDLE")
    button.Text:SetWordWrap(false)
    button.Text:Show()

    button:SetScript("OnEnter", function(self)
        if not self.itemLink then
            return
        end

        GameTooltip:SetOwner(self, "ANCHOR_TOPLEFT")
        GameTooltip:SetHyperlink(self.itemLink)
        GameTooltip:Show()

        self:SetScript("OnUpdate", function()
            if IsShiftKeyDown() then
                GameTooltip_ShowCompareItem(GameTooltip)
            else
                GameTooltip_HideShoppingTooltips(GameTooltip)
            end
        end)
    end)

    button:SetScript("OnLeave", function(self)
        GameTooltip:Hide()
        GameTooltip_HideShoppingTooltips(GameTooltip)
        self:SetScript("OnUpdate", nil)
    end)

    button:Hide()
    return button
end

local function CreateLootLine(parent, iconSize, textWidth)
    local line = CreateFrame("Frame", nil, parent)
    line:SetHeight(iconSize)
    line:SetWidth(iconSize + 6 + textWidth)
    line:SetClipsChildren(true)

    line.IconButton = CreateLootIconButton(line, iconSize)
    line.IconButton:SetPoint("LEFT", 0, 0)

    line.TextButton = CreateLootTextButton(line, textWidth)
    line.TextButton:SetPoint("LEFT", line.IconButton, "RIGHT", 6, 0)

    return line
end

local function CreateCompactLootStrip(parent)
    local container = CreateFrame("Frame", nil, parent)
    local width = (MAX_LOOT_ICONS * COMPACT_STATS_LOOT_ICON_SIZE) + ((MAX_LOOT_ICONS - 1) * COMPACT_STATS_LOOT_SPACING) + 34
    container:SetSize(width, COMPACT_STATS_LOOT_ICON_SIZE)

    local buttons = {}
    for index = 1, MAX_LOOT_ICONS do
        local button = CreateLootIconButton(container, COMPACT_STATS_LOOT_ICON_SIZE)
        if index == 1 then
            button:SetPoint("LEFT", 0, 0)
        else
            button:SetPoint("LEFT", buttons[index - 1], "RIGHT", COMPACT_STATS_LOOT_SPACING, 0)
        end
        buttons[index] = button
    end

    local overflow = CreateFont(container, 10, COLORS.subdued)
    overflow:SetPoint("LEFT", buttons[#buttons], "RIGHT", 6, 0)
    overflow:SetJustifyH("LEFT")
    overflow.shortFormat = true

    local empty = CreateFont(container, 10, COLORS.subdued)
    empty:SetPoint("LEFT", 0, 0)
    empty:SetJustifyH("LEFT")
    empty:SetText(MythicTools:L("UI_NO_LOOT"))

    return container, buttons, overflow, empty
end

local function CreatePlayerRatingControl(parent)
    local control = CreateFrame("Frame", nil, parent)
    control:SetSize(80, 20)
    control.Stars = {}

    for value = 1, 5 do
        local button = CreateFrame("Button", nil, control)
        button:SetSize(14, 14)
        if value == 1 then
            button:SetPoint("LEFT", 0, 0)
        else
            button:SetPoint("LEFT", control.Stars[value - 1], "RIGHT", 2, 0)
        end
        button.ratingValue = value

        button.Icon = button:CreateTexture(nil, "ARTWORK")
        button.Icon:SetAllPoints()
        button.Icon:SetTexture(RATING_STAR_ICON)

        button:SetScript("OnClick", function(starButton)
            local row = starButton:GetParent() and starButton:GetParent().ownerRow
            if not row then
                return
            end

            local currentRating = math.floor((tonumber(row.playerStat and row.playerStat.rating) or 0) + 0.5)
            local nextRating = currentRating == starButton.ratingValue and 0 or starButton.ratingValue
            local applied = false
            if row.runId and row.playerName then
                applied = MythicTools:SetRunPlayerRating(row.runId, row.playerName, nextRating) and true or false
            end

            if row.playerStat and (applied or not row.runId or row.runId == -1) then
                row.playerStat.rating = nextRating > 0 and nextRating or nil
            end

            MythicTools:RefreshCompletionPopup()
        end)

        button:SetScript("OnEnter", function(starButton)
            local row = starButton:GetParent() and starButton:GetParent().ownerRow
            if not row then
                return
            end
            MythicTools:SetRatingControlValue(starButton:GetParent(), starButton.ratingValue)
        end)
        button:SetScript("OnLeave", function(starButton)
            local row = starButton:GetParent() and starButton:GetParent().ownerRow
            local rating = row and row.playerStat and row.playerStat.rating or nil
            MythicTools:SetRatingControlValue(starButton:GetParent(), rating)
        end)

        control.Stars[value] = button
    end

    return control
end

function MythicTools:SetRatingControlValue(control, rating)
    if not control or type(control.Stars) ~= "table" then
        return
    end

    rating = math.floor((tonumber(rating) or 0) + 0.5)
    for value, button in ipairs(control.Stars) do
        if button and button.Icon then
            if rating >= value then
                button.Icon:SetVertexColor(1, 0.82, 0.18, 1)
                button.Icon:SetAlpha(1)
            else
                button.Icon:SetVertexColor(0.65, 0.68, 0.72, 1)
                button.Icon:SetAlpha(0.26)
            end
        end
    end
end

local function ShowCompletionNoteTooltip(button)
    local row = button and button.ownerRow or nil
    local playerName = row and row.playerName or nil
    if not playerName then
        return
    end

    local shortName = row and row.playerStat and row.playerStat.shortName or MythicTools:GetShortName(playerName)
    local classColor = row and row.playerStat and row.playerStat.classFilename and RAID_CLASS_COLORS and RAID_CLASS_COLORS[row.playerStat.classFilename]
    local noteText = MythicTools:GetPlayerNote(playerName)

    GameTooltip:SetOwner(button, "ANCHOR_LEFT")
    GameTooltip:ClearLines()
    if classColor then
        GameTooltip:AddLine(shortName or playerName, classColor.r, classColor.g, classColor.b)
    else
        GameTooltip:AddLine(shortName or playerName, 1, 1, 1)
    end

    if noteText and noteText ~= "" then
        GameTooltip:AddLine(MythicTools:L("PLAYER_HISTORY_NOTE", noteText), 1, 1, 1, true)
    else
        GameTooltip:AddLine("No note saved for this player.", 0.7, 0.7, 0.7, true)
    end

    GameTooltip:AddLine(" ")
    GameTooltip:AddLine(MythicTools:L("PLAYER_CONTEXT_EDIT_NOTE"), COLORS.accent[1], COLORS.accent[2], COLORS.accent[3], true)
    GameTooltip._smhHistoryTooltipAdded = true
    GameTooltip:Show()
end

local function CreateCompletionNoteButton(parent)
    local button = CreateFrame("Button", nil, parent)
    button:SetSize(20, 20)
    button:RegisterForClicks("LeftButtonUp")

    button.Icon = button:CreateTexture(nil, "ARTWORK")
    button.Icon:SetSize(14, 14)
    button.Icon:SetPoint("CENTER")
    button.Icon:SetTexture(PLAYER_NOTE_ICON)
    button.Icon:SetBlendMode("ADD")
    button.Icon:SetTexCoord(0, 1, 0, 1)
    button.Icon:SetVertexColor(0.8, 0.82, 0.86, 1)
    button.Icon:SetAlpha(0.45)

    button:SetScript("OnEnter", ShowCompletionNoteTooltip)
    button:SetScript("OnLeave", function()
        GameTooltip:Hide()
    end)
    button:SetScript("OnClick", function(self)
        local row = self and self.ownerRow or nil
        if row and row.playerName then
            MythicTools:ShowPlayerNoteEditor(row.playerName)
        end
    end)

    return button
end

function MythicTools:UpdateCompletionNoteButton(button)
    if not (button and button.Icon) then
        return
    end

    local playerName = button.playerName
    local noteText = playerName and self:GetPlayerNote(playerName) or ""
    if noteText and noteText ~= "" then
        button.Icon:SetVertexColor(1, 0.82, 0.18, 1)
        button.Icon:SetAlpha(1)
    else
        button.Icon:SetVertexColor(0.8, 0.82, 0.86, 1)
        button.Icon:SetAlpha(0.45)
    end
end

local CreateCompletionMetricHoverButton

local function CreateCompactStatRow(parent, portraitSize, wideStats)
    local row = CreateFrame("Frame", nil, parent)
    row:SetHeight(COMPLETION_ROW_HEIGHT)
    MythicTools:ApplySurface(row, COLORS.rowBG, COLORS.surface, nil)

    row.Portrait = CreatePortraitWidget(row, portraitSize)
    row.Portrait:SetPoint("LEFT", 10, 0)

    row.PlayerText = CreateFrame("Frame", nil, row)
    row.PlayerText:SetSize(COMPACT_STATS_PLAYER_WIDTH, portraitSize)
    row.PlayerText:SetPoint("LEFT", row.Portrait, "RIGHT", 10, 0)

    row.Name = CreateFont(row.PlayerText, 12, COLORS.text)
    row.Name:SetPoint("CENTER", row.PlayerText, "CENTER", 0, 0)
    row.Name:SetWidth(COMPACT_STATS_PLAYER_WIDTH)
    row.Name:SetJustifyH("LEFT")

    row.ItemLevel = CreateFont(row.PlayerText, 10, COLORS.subdued)
    row.ItemLevel:SetPoint("TOPLEFT", row.Name, "BOTTOMLEFT", 0, -2)
    row.ItemLevel:SetWidth(COMPACT_STATS_PLAYER_WIDTH)
    row.ItemLevel:SetJustifyH("LEFT")
    row.ItemLevel:Hide()

    row.PlayerButton = CreatePlayerFilterButton(row, row.PlayerText)
    row.PlayerButton:SetSize(COMPACT_STATS_PLAYER_WIDTH - 10, portraitSize)
    row.PlayerButton:ClearAllPoints()
    row.PlayerButton:SetPoint("LEFT", row.PlayerText, "LEFT", 0, 0)

    row.Score = CreateFont(row, 11, COLORS.text)
    row.Score:SetPoint("LEFT", COMPACT_STATS_SCORE_X - COMPACT_STATS_ROW_INSET_X, 0)
    row.Score:SetWidth(88)

    row.Items, row.LootButtons, row.LootOverflow, row.LootEmpty = CreateCompactLootStrip(row)
    row.Items:SetPoint("LEFT", COMPACT_STATS_ITEMS_X - COMPACT_STATS_ROW_INSET_X, 0)
    row.LootLabel = nil
    row.LootTextButtons = nil

    row.Damage = CreateFont(row, 12, COLORS.text)
    row.Damage:SetPoint("LEFT", COMPACT_STATS_DAMAGE_X - COMPACT_STATS_ROW_INSET_X, 0)
    row.Damage:SetWidth(68)
    row.DamageHover = CreateCompletionMetricHoverButton(row, COMPACT_STATS_DAMAGE_X, 68, "damage")

    row.DPS = CreateFont(row, 12, COLORS.text)
    row.DPS:SetPoint("LEFT", COMPACT_STATS_DPS_X - COMPACT_STATS_ROW_INSET_X, 0)
    row.DPS:SetWidth(58)
    row.DPSHover = CreateCompletionMetricHoverButton(row, COMPACT_STATS_DPS_X, 58, "dps")

    row.Healing = CreateFont(row, 12, COLORS.text)
    row.Healing:SetPoint("LEFT", COMPACT_STATS_HEALING_X - COMPACT_STATS_ROW_INSET_X, 0)
    row.Healing:SetWidth(68)
    row.HealingHover = CreateCompletionMetricHoverButton(row, COMPACT_STATS_HEALING_X, 68, "healing")

    row.HPS = CreateFont(row, 12, COLORS.text)
    row.HPS:SetPoint("LEFT", COMPACT_STATS_HPS_X - COMPACT_STATS_ROW_INSET_X, 0)
    row.HPS:SetWidth(58)
    row.HPSHover = CreateCompletionMetricHoverButton(row, COMPACT_STATS_HPS_X, 58, "hps")

    row.Interrupts = CreateFont(row, 12, COLORS.text)
    row.Interrupts:SetPoint("LEFT", COMPACT_STATS_INTERRUPTS_X - COMPACT_STATS_ROW_INSET_X, 0)
    row.Interrupts:SetWidth(70)
    row.InterruptsHover = CreateCompletionMetricHoverButton(row, COMPACT_STATS_INTERRUPTS_X, 70, "interrupts")

    local deathsX = COMPACT_STATS_DEATHS_X
    if wideStats then
        row.Dispels = CreateFont(row, 12, COLORS.text)
        row.Dispels:SetPoint("LEFT", 914 - COMPACT_STATS_ROW_INSET_X, 0)
        row.Dispels:SetWidth(50)
        row.DispelsHover = CreateCompletionMetricHoverButton(row, 914, 50, "dispels")

        row.AvoidableDamage = CreateFont(row, 12, COLORS.text)
        row.AvoidableDamage:SetPoint("LEFT", 982 - COMPACT_STATS_ROW_INSET_X, 0)
        row.AvoidableDamage:SetWidth(96)
        row.AvoidableHover = CreateCompletionMetricHoverButton(row, 982, 96, "avoidable")

        deathsX = 1092
    end

    row.Deaths = CreateFont(row, 12, COLORS.text)
    row.Deaths:SetPoint("LEFT", deathsX - COMPACT_STATS_ROW_INSET_X, 0)
    row.Deaths:SetWidth(44)

    if wideStats then
        row.Rating = CreatePlayerRatingControl(row)
        row.Rating.ownerRow = row
        row.Rating:SetPoint("LEFT", COMPACT_STATS_RATING_X - COMPACT_STATS_ROW_INSET_X - 10, 0)

        row.NoteButton = CreateCompletionNoteButton(row)
        row.NoteButton.ownerRow = row
        row.NoteButton:SetPoint("LEFT", row.Rating, "RIGHT", 0, 0)
    end

    return row
end

local function CreateStatRow(parent)
    return CreateCompactStatRow(parent, 28)
end

local function CreateCompletionStatRow(parent)
    return CreateCompactStatRow(parent, 34, true)
end

local function CreateRecentRunButton(parent)
    return CreateListButton(parent, 54)
end

local function StyleTabButton(button, selected)
    button._selected = selected and true or false
    if selected then
        MythicTools:ApplySurface(button, CONTROL_COLORS.accentFillActive, COLORS.accent, COLORS.accent)
        SetTextColor(button.Text, COLORS.bright)
    else
        MythicTools:ApplySurface(button, CONTROL_COLORS.controlBG, CONTROL_COLORS.controlBorder, COLORS.accent)
        SetTextColor(button.Text, CONTROL_COLORS.controlText)
    end
end

local function StyleListButton(button, selected, accentColor)
    button._selected = selected and true or false
    accentColor = accentColor or COLORS.accentSoft

    if selected then
        MythicTools:ApplySurface(button, COLORS.rowSelected, accentColor, COLORS.accent)
        button.Accent:ClearAllPoints()
        button.Accent:SetPoint("TOPLEFT")
        button.Accent:SetPoint("BOTTOMLEFT")
        button.Accent:SetWidth(3)
        button.Accent:SetVertexColor(accentColor[1], accentColor[2], accentColor[3], 1)
        SetTextColor(button.Title, COLORS.bright)
    else
        MythicTools:ApplySurface(button, COLORS.rowBG, COLORS.surface, nil)
        button.Accent:ClearAllPoints()
        button.Accent:SetPoint("TOPLEFT")
        button.Accent:SetPoint("BOTTOMLEFT")
        button.Accent:SetWidth(3)
        button.Accent:SetVertexColor(accentColor[1], accentColor[2], accentColor[3], 0.75)
        SetTextColor(button.Title, COLORS.text)
    end
end

local function CreateSidebarMetric(parent, labelText)
    local card = CreateFrame("Frame", nil, parent)
    card:SetSize(176, 54)
    MythicTools:ApplySurface(card, COLORS.rowBG, COLORS.surface, nil)

    card.Value = CreateFont(card, 18, COLORS.text)
    card.Value:SetPoint("TOPLEFT", 12, -10)
    card.Value:SetText("0")

    card.Label = CreateFont(card, 10, COLORS.subdued)
    card.Label:SetPoint("TOPLEFT", card.Value, "BOTTOMLEFT", 0, -3)
    card.Label:SetText(labelText)

    return card
end

local function CreateSettingToggle(parent, labelText, descriptionText, onClick)
    local row = CreateFrame("Frame", nil, parent)
    row:SetHeight(42)

    row.Check = CreateFrame("Button", nil, row)
    row.Check:SetPoint("TOPLEFT", 0, -2)
    row.Check:SetSize(18, 18)
    row.Check.checked = false
    row.Check:SetScript("OnClick", function(checkButton)
        checkButton:SetChecked(not checkButton:GetChecked())
        if onClick then
            onClick(checkButton:GetChecked())
        end
    end)
    row.Check:SetScript("OnEnter", function(checkButton)
        MythicTools:ApplySurface(checkButton, CONTROL_COLORS.controlHoverBG, CONTROL_COLORS.accentBorder40, COLORS.accent)
    end)
    row.Check:SetScript("OnLeave", function(checkButton)
        if checkButton:GetChecked() then
            MythicTools:ApplySurface(checkButton, CONTROL_COLORS.controlBG, COLORS.accent, COLORS.accent)
        else
            MythicTools:ApplySurface(checkButton, CONTROL_COLORS.controlBG, CONTROL_COLORS.accentBorder40, COLORS.accent)
        end
    end)
    row.Check.Fill = row.Check:CreateTexture(nil, "ARTWORK")
    row.Check.Fill:SetTexture(WHITE_TEXTURE)
    row.Check.Fill:SetPoint("TOPLEFT", 5, -5)
    row.Check.Fill:SetPoint("BOTTOMRIGHT", -5, 5)
    row.Check.Fill:SetVertexColor(COLORS.accent[1], COLORS.accent[2], COLORS.accent[3], 1)
    row.Check.Fill:Hide()
    MythicTools:ApplySurface(row.Check, CONTROL_COLORS.controlBG, CONTROL_COLORS.accentBorder40, COLORS.accent)

    function row.Check:SetChecked(checked)
        self.checked = checked and true or false
        self.Fill:SetShown(self.checked)
        if self.checked then
            MythicTools:ApplySurface(self, CONTROL_COLORS.controlBG, COLORS.accent, COLORS.accent)
        else
            MythicTools:ApplySurface(self, CONTROL_COLORS.controlBG, CONTROL_COLORS.accentBorder40, COLORS.accent)
        end
    end

    function row.Check:GetChecked()
        return self.checked and true or false
    end

    row.Label = CreateFont(row, 12, COLORS.text)
    row.Label:SetPoint("TOPLEFT", row.Check, "TOPRIGHT", 4, -1)
    row.Label:SetText(labelText)

    row.Description = CreateFont(row, 10, COLORS.subdued)
    row.Description:SetPoint("TOPLEFT", row.Label, "BOTTOMLEFT", 0, -2)
    row.Description:SetPoint("RIGHT", row, "RIGHT", -4, 0)
    row.Description:SetJustifyH("LEFT")
    row.Description:SetText(descriptionText or "")

    return row
end

local function RunMatchesDungeonFilterValue(run, filterValue)
    if type(filterValue) ~= "string" then
        filterValue = tostring(filterValue or "")
    end

    if filterValue == nil or filterValue == "" or filterValue == "all" then
        return true
    end

    local runDungeonKey = MythicTools:GetRunDungeonKey(run)
    if runDungeonKey == filterValue then
        return true
    end

    -- Compatibility with older filter values stored as localized dungeon names.
    local dungeonName = run and run.dungeonName or ""
    if dungeonName ~= "" and dungeonName == filterValue then
        return true
    end

    if filterValue:sub(1, 5) == "name:" then
        if filterValue == ("name:" .. MythicTools:Lower(dungeonName)) then
            return true
        end

        local filterName = filterValue:sub(6)
        return GetDungeonOptionIdentity(filterName) == GetDungeonOptionIdentity(dungeonName)
    end

    return false
end

function MythicTools:GetRunSearchTextLower(run)
    if type(run) ~= "table" then
        return ""
    end

    if run._searchTextLower then
        return run._searchTextLower
    end

    local parts = {
        run.dungeonName or "",
        tostring(run.level or 0),
        self:FormatDate(run.endTime),
        self:GetRunResult(run),
    }
    for _, fullName in ipairs(run.roster or {}) do
        parts[#parts + 1] = fullName
        parts[#parts + 1] = self:GetShortName(fullName)
    end

    run._searchTextLower = self:Lower(table.concat(parts, " "))
    return run._searchTextLower
end

function MythicTools:GetFilteredRuns()
    local filters = self.db.ui.filters
    local normalizedDate = NormalizeDateFilter(filters.date or "")
    local cacheKey = table.concat({
        tostring(self._historyDataRevision or 0),
        filters.season or "",
        filters.status or "",
        filters.ownedCharacters or "",
        filters.player or "",
        filters.dungeon or "",
        normalizedDate,
        filters.search or "",
    }, "\031")
    if self._filteredRunsCache and self._filteredRunsCache.key == cacheKey then
        return self._filteredRunsCache.value
    end

    local filtered = {}
    local searchLower = filters.search ~= "" and self:Lower(filters.search) or ""

    for _, run in ipairs(self.db.runs) do
        local include = true
        local runResult = self:GetRunResult(run)
        if include and filters.season ~= "" and filters.season ~= "all" then
            include = self:GetRunSeason(run) == filters.season
        end
        if include and filters.status ~= "all" then
            include = runResult == filters.status
        end
        if include and filters.ownedCharacters == "mine" then
            include = self:RunHasOwnedCharacter(run)
        elseif include and filters.ownedCharacters ~= "" and filters.ownedCharacters ~= "all" then
            include = self:RunHasCharacter(run, filters.ownedCharacters)
        end
        if include and filters.player ~= "" then
            local foundPlayer = false
            for _, fullName in ipairs(run.roster or {}) do
                if self:ContainsText(fullName, filters.player) or self:ContainsText(self:GetShortName(fullName), filters.player) then
                    foundPlayer = true
                    break
                end
            end
            include = foundPlayer
        end
        if include and filters.dungeon ~= "" and filters.dungeon ~= "all" then
            include = RunMatchesDungeonFilterValue(run, filters.dungeon)
        end
        if include and normalizedDate ~= "" then
            include = self:ContainsText(self:FormatDate(run.endTime), normalizedDate)
        end
        if include and searchLower ~= "" then
            include = self:GetRunSearchTextLower(run):find(searchLower, 1, true) ~= nil
        end
        if include then
            filtered[#filtered + 1] = run
        end
    end

    self._filteredRunsCache = {
        key = cacheKey,
        value = filtered,
    }
    return filtered
end

function MythicTools:RunMatchesPlayerFilters(run, filters)
    filters = filters or {}
    if filters.season and filters.season ~= "" and filters.season ~= "all" and self:GetRunSeason(run) ~= filters.season then
        return false
    end

    if filters.dungeon and filters.dungeon ~= "" and filters.dungeon ~= "all" and not RunMatchesDungeonFilterValue(run, filters.dungeon) then
        return false
    end

    if filters.augMode == "no_aug" and self:RunHasAugmentationEvoker(run) then
        return false
    end

    return true
end

function MythicTools:GetFilteredRunsForPlayer(fullName, filters)
    local playerEntry = self.db.playersIndex[fullName]
    if not playerEntry then
        return {}
    end

    local runs = {}
    for _, runId in ipairs(playerEntry.runIds or {}) do
        local run = self:GetRunById(runId)
        if run and self:RunMatchesPlayerFilters(run, filters) then
            runs[#runs + 1] = run
        end
    end

    return runs
end

function MythicTools:IsAugmentationEvokerStat(stat)
    if type(stat) ~= "table" then
        return false
    end

    if tonumber(stat.specID) == 1473 then
        return true
    end

    local specName = self:Lower(stat.specName or "")
    return stat.classFilename == "EVOKER"
        and (specName:find("augmentation", 1, true) ~= nil or specName:find("aprimor", 1, true) ~= nil)
end

function MythicTools:RunHasAugmentationEvoker(run, exceptFullName)
    for fullName, stat in pairs(run and run.playerStats or {}) do
        if fullName ~= exceptFullName and self:IsAugmentationEvokerStat(stat) then
            return true
        end
    end

    return false
end

local function AccumulatePlayerFilterAggregate(owner, target, run, stat)
    target.totalRuns = (target.totalRuns or 0) + 1

    if run.result == "timed" then
        target.timedRuns = (target.timedRuns or 0) + 1
    elseif run.result == "abandoned" then
        target.abandonedRuns = (target.abandonedRuns or 0) + 1
    else
        target.overtimeRuns = (target.overtimeRuns or 0) + 1
    end

    local level = tonumber(run.level) or 0
    local isAbandoned = run.result == "abandoned"
    if level > 0 then
        target.bestLevel = max(target.bestLevel or 0, level)
        if run.result == "timed" then
            target.bestTimedLevel = max(target.bestTimedLevel or 0, level)
        end
        if not isAbandoned then
            target.totalLevel = (target.totalLevel or 0) + level
            target.countedLevels = (target.countedLevels or 0) + 1
        end
    end

    local dps = stat and stat.damage ~= nil and owner:GetPlayerDPS(run, stat) or nil
    if dps ~= nil and not isAbandoned then
        local previousMaxDps = tonumber(target.maxDps) or 0
        local hadAugmentation = dps > 0 and owner:RunHasAugmentationEvoker(run, target.fullName)
        if dps > previousMaxDps then
            target.maxDps = dps
            target.maxDpsAugmented = hadAugmentation
        elseif dps == previousMaxDps and hadAugmentation then
            target.maxDpsAugmented = true
        end
        target.totalDps = (target.totalDps or 0) + dps
        target.countedDps = (target.countedDps or 0) + 1
    end

    local hps = stat and stat.healing ~= nil and owner:GetPlayerHPS(run, stat) or nil
    if hps ~= nil and not isAbandoned then
        target.maxHps = max(target.maxHps or 0, hps)
        target.totalHps = (target.totalHps or 0) + hps
        target.countedHps = (target.countedHps or 0) + 1
    end
end

local function FinalizePlayerFilterAggregate(target)
    if (target.totalRuns or 0) > 0 then
        target.successRate = ((target.timedRuns or 0) / target.totalRuns) * 100
    else
        target.successRate = 0
    end

    target.averageLevel = (target.countedLevels or 0) > 0 and ((target.totalLevel or 0) / target.countedLevels) or 0
    target.averageDps = (target.countedDps or 0) > 0 and ((target.totalDps or 0) / target.countedDps) or 0
    target.averageHps = (target.countedHps or 0) > 0 and ((target.totalHps or 0) / target.countedHps) or 0
end

local function CreatePlayerFilterAggregate(playerEntry)
    return {
        fullName = playerEntry.fullName,
        shortName = playerEntry.shortName,
        classFilename = playerEntry.classFilename,
        specIconID = playerEntry.specIconID,
        specID = playerEntry.specID,
        specName = playerEntry.specName,
        role = playerEntry.role,
        sourceEntry = playerEntry,
        totalRuns = 0,
        timedRuns = 0,
        overtimeRuns = 0,
        abandonedRuns = 0,
        bestLevel = 0,
        bestTimedLevel = 0,
        totalLevel = 0,
        countedLevels = 0,
        maxDps = 0,
        maxDpsAugmented = false,
        totalDps = 0,
        countedDps = 0,
        maxHps = 0,
        totalHps = 0,
        countedHps = 0,
    }
end

function MythicTools:BuildFilteredPlayerAggregate(playerEntry, filters)
    local aggregate = CreatePlayerFilterAggregate(playerEntry)

    for _, run in ipairs(self:GetFilteredRunsForPlayer(playerEntry.fullName, filters)) do
        local stat = run.playerStats and run.playerStats[playerEntry.fullName] or nil
        AccumulatePlayerFilterAggregate(self, aggregate, run, stat)
    end

    FinalizePlayerFilterAggregate(aggregate)
    return aggregate
end

function MythicTools:GetFilteredPlayers()
    local filters = self.db.ui.playerFilters or {}
    local needle = filters.search or self.db.ui.playerSearch or ""
    local sortState = self.db.ui.playerSort or {column = "runs", direction = "desc"}
    local cacheKey = table.concat({
        tostring(self._historyDataRevision or 0),
        filters.season or "",
        filters.dungeon or "",
        filters.augMode or "",
        needle or "",
        sortState.column or "runs",
        sortState.direction or "desc",
    }, "\031")
    if self._filteredPlayersCache and self._filteredPlayersCache.key == cacheKey then
        return self._filteredPlayersCache.value
    end

    local playersByName = {}
    local players = {}

    for _, playerEntry in pairs(self.db.playersIndex or {}) do
        local isOwned = playerEntry.fullName == self.playerName or self:IsOwnedCharacter(playerEntry.fullName)
        local matchesNeedle = needle == ""
            or self:ContainsText(playerEntry.fullName, needle)
            or self:ContainsText(playerEntry.shortName, needle)
        local include = (not isOwned and matchesNeedle) or (isOwned and needle ~= "" and matchesNeedle)
        if include then
            playersByName[playerEntry.fullName] = CreatePlayerFilterAggregate(playerEntry)
        end
    end

    for _, run in ipairs(self.db.runs or {}) do
        if self:RunMatchesPlayerFilters(run, filters) then
            local roster = run.roster
            if type(roster) == "table" and #roster > 0 then
                for _, fullName in ipairs(roster) do
                    local aggregate = playersByName[fullName]
                    if aggregate then
                        local stat = run.playerStats and run.playerStats[fullName] or nil
                        AccumulatePlayerFilterAggregate(self, aggregate, run, stat)
                    end
                end
            elseif type(run.playerStats) == "table" then
                for fullName, stat in pairs(run.playerStats) do
                    local aggregate = playersByName[fullName]
                    if aggregate then
                        AccumulatePlayerFilterAggregate(self, aggregate, run, stat)
                    end
                end
            end
        end
    end

    for _, aggregate in pairs(playersByName) do
        if (aggregate.totalRuns or 0) > 0 then
            FinalizePlayerFilterAggregate(aggregate)
            players[#players + 1] = aggregate
        end
    end

    local direction = sortState.direction == "asc" and 1 or -1
    table.sort(players, function(left, right)
        local leftValue
        local rightValue
        if sortState.column == "player" then
            leftValue = left.shortName or left.fullName or ""
            rightValue = right.shortName or right.fullName or ""
            if leftValue ~= rightValue then
                if direction == 1 then
                    return leftValue < rightValue
                end
                return leftValue > rightValue
            end
        else
            local keyMap = {
                runs = "totalRuns",
                success = "successRate",
                timed = "timedRuns",
                overtime = "overtimeRuns",
                abandoned = "abandonedRuns",
                best = "bestTimedLevel",
                average = "averageLevel",
                maxdps = "maxDps",
                avgdps = "averageDps",
                maxhps = "maxHps",
                avghps = "averageHps",
            }
            local key = keyMap[sortState.column] or "totalRuns"
            leftValue = tonumber(left[key]) or 0
            rightValue = tonumber(right[key]) or 0
            if leftValue ~= rightValue then
                if direction == 1 then
                    return leftValue < rightValue
                end
                return leftValue > rightValue
            end
        end

        if (left.successRate or 0) ~= (right.successRate or 0) then
            return (left.successRate or 0) > (right.successRate or 0)
        end

        return (left.shortName or left.fullName or "") < (right.shortName or right.fullName or "")
    end)

    self._filteredPlayersCache = {
        key = cacheKey,
        value = players,
    }
    return players
end

local function GetCompletionStatValue(owner, run, stat, column)
    if column == "dps" then
        return tonumber(owner:GetPlayerDPS(run, stat)) or 0
    elseif column == "hps" then
        return tonumber(owner:GetPlayerHPS(run, stat)) or 0
    elseif column == "healing" then
        return tonumber(stat and stat.healing) or 0
    elseif column == "interrupts" then
        return tonumber(stat and stat.interrupts) or 0
    elseif column == "dispels" then
        return tonumber(stat and stat.dispels) or 0
    elseif column == "avoidableDamage" then
        return tonumber(stat and stat.avoidableDamage) or 0
    elseif column == "deaths" then
        return tonumber(stat and stat.deaths) or 0
    elseif column == "rating" then
        return tonumber(stat and stat.rating) or 0
    end

    return tonumber(stat and stat.damage) or 0
end

local function GetCompletionSortColumn(owner)
    local sortState = owner.db and owner.db.ui and owner.db.ui.completionPopupSort or nil
    local column = sortState and sortState.column or "damage"
    local validColumns = {
        damage = true,
        dps = true,
        healing = true,
        hps = true,
        interrupts = true,
        dispels = true,
        avoidableDamage = true,
        deaths = true,
        rating = true,
    }
    if not validColumns[column] then
        return "damage"
    end

    return column
end

local function GetCompletionStatLeaders(owner, run, stats)
    local leaders = {
        dps = 0,
        hps = 0,
        interrupts = 0,
        dispels = 0,
        avoidableDamage = nil,
    }

    for _, stat in ipairs(stats or {}) do
        leaders.dps = max(leaders.dps, GetCompletionStatValue(owner, run, stat, "dps"))
        leaders.hps = max(leaders.hps, GetCompletionStatValue(owner, run, stat, "hps"))
        leaders.interrupts = max(leaders.interrupts, GetCompletionStatValue(owner, run, stat, "interrupts"))
        leaders.dispels = max(leaders.dispels, GetCompletionStatValue(owner, run, stat, "dispels"))
        if stat and stat.avoidableDamage ~= nil then
            local avoidableDamage = GetCompletionStatValue(owner, run, stat, "avoidableDamage")
            leaders.avoidableDamage = leaders.avoidableDamage == nil and avoidableDamage or min(leaders.avoidableDamage, avoidableDamage)
        end
    end

    return leaders
end

local function IsCompletionStatLeader(value, bestValue)
    value = tonumber(value) or 0
    bestValue = tonumber(bestValue) or 0
    return value > 0 and value == bestValue
end

function MythicTools:GetSortedStatsForRun(run)
    local stats = {}
    for _, fullName in ipairs(run.roster or {}) do
        local entry = run.playerStats and run.playerStats[fullName]
        if entry then
            stats[#stats + 1] = entry
        end
    end

    local sortColumn = GetCompletionSortColumn(self)
    table.sort(stats, function(left, right)
        local leftSortValue = GetCompletionStatValue(self, run, left, sortColumn)
        local rightSortValue = GetCompletionStatValue(self, run, right, sortColumn)
        if leftSortValue ~= rightSortValue then
            if sortColumn == "avoidableDamage" then
                return leftSortValue < rightSortValue
            end
            return leftSortValue > rightSortValue
        end

        local leftDamage, rightDamage = left.damage or -1, right.damage or -1
        if leftDamage ~= rightDamage then return leftDamage > rightDamage end
        local leftHealing, rightHealing = left.healing or -1, right.healing or -1
        if leftHealing ~= rightHealing then return leftHealing > rightHealing end
        local leftInterrupts, rightInterrupts = left.interrupts or -1, right.interrupts or -1
        if leftInterrupts ~= rightInterrupts then return leftInterrupts > rightInterrupts end
        return (left.name or "") < (right.name or "")
    end)

    return stats
end

function MythicTools:GetRunLootCount(run)
    local count = 0
    for _, stat in pairs(run and run.playerStats or {}) do
        count = count + #(stat.loot or {})
    end
    return count
end


local function FormatKeyLevel(level)
    level = tonumber(level) or 0
    if level <= 0 then
        return "--"
    end
    return ("+%d"):format(level)
end

local function GetPlayerNameParts(fullName)
    if type(fullName) ~= "string" or fullName == "" then
        return "", ""
    end

    local characterName, realmName = fullName:match("^([^%-]+)%-(.+)$")
    if characterName and realmName then
        return characterName, realmName
    end

    return fullName, ""
end

local function GetRaiderIORegion()
    local regionID = GetCurrentRegion and GetCurrentRegion() or nil
    local regions = {
        [1] = "us",
        [2] = "kr",
        [3] = "eu",
        [4] = "tw",
        [5] = "cn",
    }

    return regions[regionID] or "us"
end

local function GetRaiderIOSlug(text)
    if type(text) ~= "string" or text == "" then
        return nil
    end

    return text:gsub("%s+", ""):gsub("%-+", ""):lower()
end

local function BuildRaiderIOCharacterURL(fullName)
    local characterName, realmName = GetPlayerNameParts(fullName)
    local realmSlug = GetRaiderIOSlug(realmName)
    if characterName == "" or not realmSlug then
        return nil
    end

    return ("https://raider.io/characters/%s/%s/%s"):format(GetRaiderIORegion(), realmSlug, characterName)
end

local function GetRunUpgradeText(run)
    if not run or MythicTools:GetRunResult(run) == "abandoned" then
        return ""
    end

    local upgradeLevels = MythicTools:GetRunKeystoneUpgradeLevels(run)
    if upgradeLevels <= 0 then
        return ""
    end

    return ("+%d"):format(upgradeLevels)
end

local function GetCompletionRunStatusText(run)
    local result = MythicTools:GetRunResult(run)
    if result == "timed" then
        return "Timed"
    end
    if result == "abandoned" then
        return "Abandoned"
    end
    return "Overtimed"
end

local function GetCompletionRunTitleText(run)
    local title = ("%s +%d"):format(run and run.dungeonName or "Unknown", run and run.level or 0)
    if not run then
        return title
    end

    local statusText = GetCompletionRunStatusText(run)
    local upgradeText = GetRunUpgradeText(run)
    if MythicTools:GetRunResult(run) == "timed" and upgradeText ~= "" then
        statusText = ("%s %s"):format(statusText, upgradeText)
    end

    return ("%s • %s"):format(title, ColorizeInlineText(statusText, GetRunStatusColor(run)))
end

local function FormatScoreNumber(score)
    score = tonumber(score)
    if not score then
        return ""
    end

    if math.abs(score - math.floor(score + 0.5)) < 0.05 then
        return tostring(math.floor(score + 0.5))
    end

    return ("%.1f"):format(score)
end

local function FormatScoreWithDelta(currentScore, previousScore)
    local current = tonumber(currentScore) or 0
    if current <= 0 then
        return ""
    end

    local text = FormatScoreNumber(current)
    local previous = tonumber(previousScore)
    if not previous or previous <= 0 then
        return text
    end

    local delta = current - previous
    if math.abs(delta) < 0.05 then
        return text
    end

    return ("%s(%s%s)"):format(text, delta > 0 and "+" or "", FormatScoreNumber(delta))
end

local function GetRunScoreText(run)
    local scoreText = FormatScoreWithDelta(run and run.newOverallDungeonScore, run and run.oldOverallDungeonScore)
    if scoreText ~= "" then
        return ("Score %s"):format(scoreText)
    end

    return ""
end

local function GetPlayerScoreDisplayText(stat)
    local score = tonumber(stat and stat.score) or 0
    if score > 0 then
        return FormatScoreWithDelta(score, stat and stat.previousScore)
    end

    local previousScore = tonumber(stat and stat.previousScore) or 0
    if previousScore > 0 then
        return FormatScoreNumber(previousScore)
    end

    return ""
end

local function GetPlayerScoreColor(stat)
    local score = tonumber(stat and stat.score) or tonumber(stat and stat.previousScore) or 0
    if score <= 0 or not C_ChallengeMode or not C_ChallengeMode.GetDungeonScoreRarityColor then
        return COLORS.text
    end

    local color = C_ChallengeMode.GetDungeonScoreRarityColor(score)
    if color then
        return {color.r or 1, color.g or 1, color.b or 1, color.a or 1}
    end

    return COLORS.text
end

local function FormatRunDurationSeconds(seconds)
    seconds = tonumber(seconds)
    if not seconds then
        return "--"
    end

    return MythicTools:FormatDurationMS(max(0, seconds) * 1000)
end

local function GetRunTimeRemainingText(run)
    local timeLimitSeconds = tonumber(run and run.timeLimitSeconds)
    if not timeLimitSeconds or timeLimitSeconds <= 0 then
        return "Time Remaining --"
    end

    local elapsedSeconds = (tonumber(run and run.timeMS) or 0) / 1000
    return ("Time Remaining %s"):format(FormatRunDurationSeconds(timeLimitSeconds - elapsedSeconds))
end

local function GetRunOutOfCombatDisplayText(run)
    return ("Out of combat %s"):format(FormatRunDurationSeconds(tonumber(run and run.outOfCombatSeconds) or 0))
end

local function GetRunStatsSourceText(run)
    local source = run and run.statsSource or nil

    if source == "damage_meter" or source == "details" then
        return ""
    end
    if run and run.statsUnavailable then
        return "Stats unavailable"
    end

    return "Stats pending"
end

local COMPLETION_TOOLTIP_MAX_SPELLS = 7

local function GetSpellNameForTooltip(spellID)
    if C_Spell and C_Spell.GetSpellName then
        local spellName = C_Spell.GetSpellName(spellID)
        if spellName and spellName ~= "" then
            return spellName
        end
    end

    if GetSpellInfo then
        local spellName = GetSpellInfo(spellID)
        if type(spellName) == "string" and spellName ~= "" then
            return spellName
        end
    end

    return ("Spell #%s"):format(tostring(spellID or "?"))
end

local function GetSpellIconForTooltip(spellID)
    if C_Spell and C_Spell.GetSpellTexture then
        local icon = C_Spell.GetSpellTexture(spellID)
        if icon then
            return icon
        end
    end

    if GetSpellInfo then
        local _, _, icon = GetSpellInfo(spellID)
        if icon then
            return icon
        end
    end

    return nil
end

local function GetCompletionMetricTooltipData(metricKey, stat)
    if metricKey == "damage" or metricKey == "dps" then
        return stat and stat.damageDoneBySpells or nil, "Damage Skills"
    elseif metricKey == "healing" or metricKey == "hps" then
        return stat and stat.healDoneBySpells or nil, "Healing Skills"
    elseif metricKey == "interrupts" then
        return stat and stat.interruptsBySpells or nil, "Interrupt Skills"
    elseif metricKey == "dispels" then
        return stat and stat.dispelsBySpells or nil, "Dispel Skills"
    elseif metricKey == "avoidable" then
        return stat and stat.avoidableDamageBySpells or nil, "Avoidable Damage Skills"
    end

    return nil, nil
end

local function ShowCompletionMetricTooltip(button)
    local stat = button and button.playerStat or nil
    local metricKey = button and button.metricKey or nil
    if not stat or not metricKey then
        return
    end

    local spells, title = GetCompletionMetricTooltipData(metricKey, stat)
    if type(spells) ~= "table" or #spells == 0 then
        return
    end

    local classColor = stat.classFilename and RAID_CLASS_COLORS and RAID_CLASS_COLORS[stat.classFilename]
    local headerName = stat.shortName or stat.name or "Unknown"

    GameTooltip:SetOwner(button, "ANCHOR_RIGHT")
    GameTooltip:ClearLines()
    if classColor then
        GameTooltip:AddLine(headerName, classColor.r, classColor.g, classColor.b)
    else
        GameTooltip:AddLine(headerName, 1, 1, 1)
    end
    GameTooltip:AddLine(title or "Skills", 1, 0.82, 0)
    GameTooltip:AddLine(" ")

    local count = 0
    for _, spellData in ipairs(spells) do
        count = count + 1
        if count > COMPLETION_TOOLTIP_MAX_SPELLS then
            break
        end

        local spellID = tonumber(spellData.spellID) or tonumber(spellData.spellId) or tonumber(spellData.id) or 0
        local totalAmount = tonumber(spellData.totalAmount)
            or tonumber(spellData.total)
            or tonumber(spellData.amount)
            or tonumber(spellData.count)
            or tonumber(spellData.value)
            or 0
        local spellName = GetSpellNameForTooltip(spellID)
        local spellIcon = GetSpellIconForTooltip(spellID)
        local iconMarkup = spellIcon and ("|T%s:14:14:0:0:64:64:5:59:5:59|t "):format(tostring(spellIcon)) or ""
        GameTooltip:AddDoubleLine(iconMarkup .. spellName, MythicTools:FormatAmount(totalAmount), 1, 1, 1, 1, 1, 1)
    end

    GameTooltip:Show()
end

CreateCompletionMetricHoverButton = function(row, x, width, metricKey)
    local hoverButton = CreateFrame("Button", nil, row)
    hoverButton:SetFrameLevel((row:GetFrameLevel() or 1) + 5)
    hoverButton:SetPoint("TOPLEFT", x - COMPACT_STATS_ROW_INSET_X, 0)
    hoverButton:SetSize(width, COMPLETION_ROW_HEIGHT)
    hoverButton.metricKey = metricKey
    hoverButton:SetScript("OnEnter", ShowCompletionMetricTooltip)
    hoverButton:SetScript("OnLeave", function()
        GameTooltip:Hide()
    end)
    return hoverButton
end

local function PlayerTableCellUpdate(rowFrame, cellFrame, data, cols, row, realrow, column, fShow, table)
    if not fShow then
        if cellFrame.NoteIcon then
            cellFrame.NoteIcon:Hide()
        end
        cellFrame:Hide()
        return
    end

    local rowData = table:GetRow(realrow)
    local cellData = table:GetCell(rowData, column)
    local isNoteColumn = cols and cols[column] and cols[column].noteColumn
    local displayValue = ""
    local color = COLORS.text

    if type(cellData) == "table" then
        displayValue = cellData.display or cellData.value or ""
        color = cellData.color or COLORS.text
    else
        displayValue = cellData or ""
    end

    if isNoteColumn then
        cellFrame.text:SetText("")
        if not cellFrame.NoteIcon then
            cellFrame.NoteIcon = cellFrame:CreateTexture(nil, "OVERLAY", nil, 7)
            cellFrame.NoteIcon:SetTexture(PLAYER_NOTE_ICON)
            cellFrame.NoteIcon:SetBlendMode("ADD")
            cellFrame.NoteIcon:SetTexCoord(0, 1, 0, 1)
            cellFrame.NoteIcon:SetSize(16, 16)
            cellFrame.NoteIcon:SetPoint("CENTER", cellFrame, "CENTER", 0, 0)
        end
        cellFrame.NoteIcon:SetShown(type(cellData) == "table" and cellData.noteText ~= nil and cellData.noteText ~= "")
        if rowData and rowData.playerName and type(cellData) == "table" and cellData.noteText ~= nil and cellData.noteText ~= "" then
            cellFrame:SetScript("OnEnter", function(self)
                MythicTools:ShowPlayerSummaryTooltip(self, rowData.playerName, "ANCHOR_RIGHT")
            end)
            cellFrame:SetScript("OnLeave", function()
                GameTooltip:Hide()
            end)
            cellFrame:SetScript("OnMouseUp", function(_, mouseButton)
                if mouseButton == "LeftButton" then
                    MythicTools:ShowPlayerNoteEditor(rowData.playerName)
                end
            end)
        else
            cellFrame:SetScript("OnEnter", nil)
            cellFrame:SetScript("OnLeave", nil)
            cellFrame:SetScript("OnMouseUp", nil)
        end
    else
        if cellFrame.NoteIcon then
            cellFrame.NoteIcon:Hide()
        end
        cellFrame:SetScript("OnEnter", nil)
        cellFrame:SetScript("OnLeave", nil)
        cellFrame:SetScript("OnMouseUp", nil)
        cellFrame.text:SetText(displayValue)
    end
    cellFrame.text:SetTextColor(color[1], color[2], color[3], color[4] or 1)
    cellFrame:Show()
end

local function DetailRunsTableCellUpdate(rowFrame, cellFrame, data, cols, row, realrow, column, fShow, table)
    if not fShow then
        cellFrame:Hide()
        return
    end

    local rowData = table:GetRow(realrow)
    local cellData = table:GetCell(rowData, column)
    local displayValue = ""
    local color = COLORS.text

    if type(cellData) == "table" then
        displayValue = cellData.display or cellData.value or ""
        color = cellData.color or COLORS.text
    else
        displayValue = cellData or ""
    end

    cellFrame:SetScript("OnEnter", nil)
    cellFrame:SetScript("OnLeave", nil)
    cellFrame.text:SetText(displayValue)
    cellFrame.text:SetTextColor(color[1], color[2], color[3], color[4] or 1)
    cellFrame:Show()
end

function MythicTools:BuildPlayerAnalyticsTableData(players)
    local rows = {}

    for _, playerEntry in ipairs(players or {}) do
        local iconMarkup = GetClassIconMarkup(playerEntry.classFilename, playerEntry.specIconID, playerEntry.specID)
        local noteText = self:GetPlayerNote(playerEntry.fullName)
        local hasNote = noteText ~= nil and noteText ~= ""
        local maxDpsText = self:FormatAmount(playerEntry.maxDps or 0)
        if playerEntry.maxDpsAugmented and (tonumber(playerEntry.maxDps) or 0) > 0 then
            maxDpsText = maxDpsText .. "*"
        end
        rows[#rows + 1] = {
            playerName = playerEntry.fullName,
            cols = {
                {value = playerEntry.shortName or playerEntry.fullName or "", display = iconMarkup},
                {value = playerEntry.shortName or playerEntry.fullName or "", display = playerEntry.shortName or playerEntry.fullName or "", color = COLORS.text},
                {value = playerEntry.totalRuns or 0, display = tostring(playerEntry.totalRuns or 0)},
                {value = playerEntry.successRate or 0, display = string.format("%.1f%%", playerEntry.successRate or 0)},
                {value = playerEntry.timedRuns or 0, display = tostring(playerEntry.timedRuns or 0), color = COLORS.success},
                {value = playerEntry.overtimeRuns or 0, display = tostring(playerEntry.overtimeRuns or 0), color = COLORS.accentSoft},
                {value = playerEntry.abandonedRuns or 0, display = tostring(playerEntry.abandonedRuns or 0), color = COLORS.danger},
                {value = playerEntry.bestTimedLevel or 0, display = tostring(playerEntry.bestTimedLevel or 0)},
                {value = playerEntry.averageLevel or 0, display = string.format("%.1f", playerEntry.averageLevel or 0)},
                {value = playerEntry.maxDps or 0, display = maxDpsText},
                {value = playerEntry.averageDps or 0, display = self:FormatAmount(playerEntry.averageDps or 0)},
                {value = playerEntry.maxHps or 0, display = self:FormatAmount(playerEntry.maxHps or 0)},
                {value = playerEntry.averageHps or 0, display = self:FormatAmount(playerEntry.averageHps or 0)},
                {value = hasNote and 1 or 0, display = "", noteText = hasNote and noteText or nil},
            }
        }
    end

    return rows
end

function MythicTools:CreatePlayersAnalyticsTable(parent)
    if not ScrollingTable then
        return nil
    end

    local columns = {
        {name = "", width = 30, sort = nil, DoCellUpdate = PlayerTableCellUpdate},
        {name = "Player", width = 160, DoCellUpdate = PlayerTableCellUpdate},
        {name = "Runs", width = 52, defaultsort = ScrollingTable.SORT_DSC, DoCellUpdate = PlayerTableCellUpdate},
        {name = "Success", width = 74, DoCellUpdate = PlayerTableCellUpdate},
        {name = "Timed", width = 52, DoCellUpdate = PlayerTableCellUpdate},
        {name = "Overtime", width = 66, DoCellUpdate = PlayerTableCellUpdate},
        {name = "Abandoned", width = 78, DoCellUpdate = PlayerTableCellUpdate},
        {name = "Best", width = 48, DoCellUpdate = PlayerTableCellUpdate},
        {name = "Average", width = 58, DoCellUpdate = PlayerTableCellUpdate},
        {name = "Max DPS", width = 82, DoCellUpdate = PlayerTableCellUpdate},
        {name = "Avg DPS", width = 82, DoCellUpdate = PlayerTableCellUpdate},
        {name = "Max HPS", width = 82, DoCellUpdate = PlayerTableCellUpdate},
        {name = "Avg HPS", width = 82, DoCellUpdate = PlayerTableCellUpdate},
        {name = "", width = 26, sort = nil, DoCellUpdate = PlayerTableCellUpdate, noteColumn = true, align = "CENTER"},
    }

    local windowHeight = (MythicTools.db and MythicTools.db.ui and MythicTools.db.ui.height) or 760
    local numRows = max(10, floor((windowHeight - 234) / PLAYER_TABLE_ROW_HEIGHT))
    local tableWidget = ScrollingTable:CreateST(columns, numRows, PLAYER_TABLE_ROW_HEIGHT, nil, parent)
    tableWidget.frame:SetPoint("TOPLEFT", parent, "TOPLEFT", 12, -46)
    tableWidget.frame:SetPoint("TOPRIGHT", parent, "TOPRIGHT", -12, -46)
    tableWidget.frame:SetBackdropColor(COLORS.panelBG[1], COLORS.panelBG[2], COLORS.panelBG[3], 0)
    tableWidget.frame:SetBackdropBorderColor(0, 0, 0, 0)
    tableWidget:EnableSelection(true)

    local sortColumns = {
        [1] = "player",
        [2] = "player",
        [3] = "runs",
        [4] = "success",
        [5] = "timed",
        [6] = "overtime",
        [7] = "abandoned",
        [8] = "best",
        [9] = "average",
        [10] = "maxdps",
        [11] = "avgdps",
        [12] = "maxhps",
        [13] = "avghps",
    }

    tableWidget:RegisterEvents({
        OnEnter = function(rowFrame, cellFrame, data, cols, row, realrow, column, scrollingTable)
            if not (cols and cols[column] and cols[column].noteColumn) then
                return false
            end

            local rowData = scrollingTable and scrollingTable:GetRow(realrow)
            local cellData = rowData and scrollingTable:GetCell(rowData, column)
            local noteText = type(cellData) == "table" and cellData.noteText or nil
            if rowData and rowData.playerName and noteText and noteText ~= "" then
                MythicTools:ShowPlayerSummaryTooltip(cellFrame, rowData.playerName, "ANCHOR_RIGHT")
            end
            return false
        end,
        OnLeave = function()
            GameTooltip:Hide()
            return false
        end,
        OnClick = function(_, _, data, _, row, realrow, column, scrollingTable, mouseButton)
            if row and realrow then
                local rowData = data and data[realrow]
                if rowData and rowData.playerName then
                    if mouseButton == "RightButton" then
                        MythicTools:ShowPlayerContextMenu(rowData.playerName)
                    else
                        MythicTools:OpenPlayerHistory(rowData.playerName)
                    end
                end
                return true
            end

            if mouseButton == "RightButton" then
                return false
            end

            C_Timer.After(0, function()
                local sortColumn = sortColumns[column]
                if not sortColumn then
                    return
                end

                local sortDirection = "desc"
                if scrollingTable and scrollingTable.cols and scrollingTable.cols[column] and scrollingTable.cols[column].sort == ScrollingTable.SORT_ASC then
                    sortDirection = "asc"
                end
                MythicTools.db.ui.playerSort = {
                    column = sortColumn,
                    direction = sortDirection,
                }
            end)

            return false
        end,
    }, true)

    return tableWidget
end

function MythicTools:BuildDetailRunsTableData(fullName, runs)
    local rows = {}
    fullName = self:NormalizePlayerName(fullName) or self:TrimText(fullName)
    if not fullName or fullName == "" then
        return rows
    end

    for _, run in ipairs(runs or {}) do
        local stat = run and run.playerStats and run.playerStats[fullName] or nil
        local dpsValue = stat and stat.damage ~= nil and self:GetPlayerDPS(run, stat) or nil
        local hpsValue = stat and stat.healing ~= nil and self:GetPlayerHPS(run, stat) or nil
        local resultText = GetRunStatusText(run)
        local resultColor = GetRunStatusColor(run)
        local dungeonName = run.dungeonName or "Unknown"
        local dungeonDisplay = ("%s  %s"):format(GetDungeonIconMarkup(run, 12), dungeonName)
        rows[#rows + 1] = {
            runId = run.runId,
            cols = {
                {value = dungeonName, display = dungeonDisplay},
                {value = tonumber(run.level) or 0, display = FormatKeyLevel(run.level)},
                {value = run.result or "", display = resultText, color = resultColor},
                {value = tonumber(run.timeMS) or 0, display = self:FormatDurationMS(run.timeMS)},
                {value = tonumber(stat and stat.deaths) or 0, display = tostring(tonumber(stat and stat.deaths) or 0)},
                {value = dpsValue or -1, display = dpsValue and self:FormatAmount(dpsValue) or "--"},
                {value = hpsValue or -1, display = hpsValue and self:FormatAmount(hpsValue) or "--"},
                {value = tonumber(run.endTime) or 0, display = self:FormatDateTime(run.endTime)},
            }
        }
    end

    return rows
end

function MythicTools:CreateDetailRunsTable(parent, numRows)
    if not (ScrollingTable and parent) then
        return nil
    end

    local columns = {
        {name = "Dungeon", width = 220, DoCellUpdate = DetailRunsTableCellUpdate},
        {name = "Level", width = 56, DoCellUpdate = DetailRunsTableCellUpdate},
        {name = "Result", width = 84, DoCellUpdate = DetailRunsTableCellUpdate},
        {name = "Time", width = 76, DoCellUpdate = DetailRunsTableCellUpdate},
        {name = "Deaths", width = 62, DoCellUpdate = DetailRunsTableCellUpdate},
        {name = "DPS", width = 104, DoCellUpdate = DetailRunsTableCellUpdate},
        {name = "HPS", width = 104, DoCellUpdate = DetailRunsTableCellUpdate},
        {name = "Date", width = 170, defaultsort = ScrollingTable.SORT_DSC, DoCellUpdate = DetailRunsTableCellUpdate},
    }

    local tableWidget = ScrollingTable:CreateST(columns, numRows or 14, PLAYER_TABLE_ROW_HEIGHT, nil, parent)
    tableWidget.frame:SetPoint("TOPLEFT", parent, "TOPLEFT", 12, -50)
    tableWidget.frame:SetPoint("TOPRIGHT", parent, "TOPRIGHT", -12, -50)
    tableWidget.frame:SetBackdropColor(COLORS.panelBG[1], COLORS.panelBG[2], COLORS.panelBG[3], 0)
    tableWidget.frame:SetBackdropBorderColor(0, 0, 0, 0)
    tableWidget:EnableSelection(true)

    tableWidget:RegisterEvents({
        OnClick = function(_, _, data, _, row, realrow)
            if not (row and realrow and data and data[realrow]) then
                return false
            end

            local rowData = data[realrow]
            if rowData and rowData.runId then
                MythicTools:ShowCompletionPopup(rowData.runId)
            end
            return true
        end,
    }, true)

    ApplyThinScrollBarToST(tableWidget)
    return tableWidget
end

function MythicTools:ApplyPlayersTableSortState()
    if not (self.ui and self.ui.playersTable and self.ui.playersTable.cols and ScrollingTable) then
        return
    end

    local sortState = self.db.ui.playerSort or {column = "runs", direction = "desc"}
    local columnIndexByKey = {
        player = 2,
        runs = 3,
        success = 4,
        timed = 5,
        overtime = 6,
        abandoned = 7,
        best = 8,
        average = 9,
        maxdps = 10,
        avgdps = 11,
        maxhps = 12,
        avghps = 13,
    }
    local selectedIndex = columnIndexByKey[sortState.column] or 3

    for index, column in ipairs(self.ui.playersTable.cols) do
        if index == selectedIndex then
            column.sort = sortState.direction == "asc" and ScrollingTable.SORT_ASC or ScrollingTable.SORT_DSC
        else
            column.sort = nil
        end
    end
end

function MythicTools:GetSelectedPlayerEntry()
    local selectedPlayer = self.db and self.db.ui and self.db.ui.selectedPlayer
    if not selectedPlayer then
        return nil
    end

    return self.db.playersIndex and self.db.playersIndex[selectedPlayer] or nil
end

function MythicTools:GetPlayerRecentRuns(fullName, limit, filters)
    local runs = filters and self:GetFilteredRunsForPlayer(fullName, filters) or self:GetRunsForPlayer(fullName)
    table.sort(runs, function(left, right)
        return (left.endTime or 0) > (right.endTime or 0)
    end)

    if limit and #runs > limit then
        local trimmed = {}
        for index = 1, limit do
            trimmed[index] = runs[index]
        end
        return trimmed
    end

    return runs
end

function MythicTools:GetSortedBreakdownEntries(breakdown)
    local rows = {}
    for _, entry in pairs(breakdown or {}) do
        rows[#rows + 1] = entry
    end

    table.sort(rows, function(left, right)
        if (left.totalRuns or 0) ~= (right.totalRuns or 0) then
            return (left.totalRuns or 0) > (right.totalRuns or 0)
        end
        if (left.bestTimedLevel or 0) ~= (right.bestTimedLevel or 0) then
            return (left.bestTimedLevel or 0) > (right.bestTimedLevel or 0)
        end
        return (left.label or "") < (right.label or "")
    end)

    return rows
end

function MythicTools:SetMetricCardValue(card, value, meta, color)
    if not card then
        return
    end

    card.Value:SetText(value or "--")
    if color then
        SetTextColor(card.Value, color)
    else
        SetTextColor(card.Value, COLORS.text)
    end

    if meta and meta ~= "" then
        card.Meta:SetText(meta)
        card.Meta:Show()
    else
        card.Meta:SetText("")
        card.Meta:Hide()
    end
end

function MythicTools:GetDungeonFilterLabel(value)
    local season = (self.db.ui.filters and self.db.ui.filters.season) or SEASON_OPTIONS[1].value
    local options = GetDungeonOptionsForSeason(season)
    local normalizedValue = NormalizeDungeonFilterValueForSeason(season, value)
    for _, info in ipairs(options) do
        if info.value == normalizedValue then
            return info.label
        end
    end
    return options[1] and options[1].label or "All dungeons"
end

function MythicTools:GetNextDungeonFilterValue(currentValue)
    local season = (self.db.ui.filters and self.db.ui.filters.season) or SEASON_OPTIONS[1].value
    local options = GetDungeonOptionsForSeason(season)
    local normalizedValue = NormalizeDungeonFilterValueForSeason(season, currentValue)
    for index, info in ipairs(options) do
        if info.value == normalizedValue then
            local nextInfo = options[index + 1] or options[1]
            return nextInfo.value
        end
    end
    return options[1] and options[1].value or "all"
end

function MythicTools:SetRunViewMode(mode)
    self.db.ui.runViewMode = mode == "detail" and "detail" or "list"
end

function MythicTools:OpenRunDetails(runId)
    if not runId then
        return
    end

    self.db.ui.selectedRunId = runId
    self:SetRunViewMode("detail")
    if self.ui then
        self:RefreshRunView()
    end
end
function MythicTools:StoreMainFramePoint()
    if not (self.ui and self.ui.frame) then
        return
    end

    local point, _, relativePoint, xOfs, yOfs = self.ui.frame:GetPoint(1)
    self.db.ui.point = {point or "CENTER", relativePoint or point or "CENTER", xOfs or 0, yOfs or 0}
end

function MythicTools:SetPortraitWidget(widget, fullName, classFilename, specIconID, specID, options)
    if not widget then
        return
    end

    local texture = widget.Texture
    if texture then
        local preferClassIcon = type(options) == "table" and options.preferClassIcon
        local classIconStyle = type(options) == "table" and options.classIconStyle
        local classCoords = classFilename and CLASS_ICON_TCOORDS and CLASS_ICON_TCOORDS[classFilename]

        if preferClassIcon and classFilename then
            local customCoords = classIconStyle == "mmt_transparent" and self.MMTTransparentClassCoords and self.MMTTransparentClassCoords[classFilename] or nil
            if customCoords then
                texture:SetTexture(self.MMTTransparentClassIcon)
                texture:SetTexCoord(customCoords[1], customCoords[2], customCoords[3], customCoords[4])
            elseif classCoords then
                texture:SetTexture(CLASS_CIRCLE_TEXTURE)
                texture:SetTexCoord(classCoords[1], classCoords[2], classCoords[3], classCoords[4])
            else
                texture:SetTexture(QUESTION_MARK_ICON)
                texture:SetTexCoord(0, 1, 0, 1)
            end
        elseif specID or (specIconID and not classFilename) then
            texture:SetTexture(self:ResolveDisplayIcon(specIconID, specID))
            texture:SetTexCoord(0.08, 0.92, 0.08, 0.92)
        else
            if classCoords then
                texture:SetTexture(CLASS_CIRCLE_TEXTURE)
                texture:SetTexCoord(classCoords[1], classCoords[2], classCoords[3], classCoords[4])
            else
                texture:SetTexture(QUESTION_MARK_ICON)
                texture:SetTexCoord(0, 1, 0, 1)
            end
        end
    end

    if widget.SpecIcon then
        widget.SpecIcon:Hide()
    end
end

function MythicTools:SetHeroRatingBadgeValue(badge, rating, ratingCount)
    if not badge then
        return
    end

    local count = tonumber(ratingCount) or 0
    local hasRating = count > 0
    local numericRating = tonumber(rating) or 0
    numericRating = max(0, min(5, numericRating))

    badge.Value:SetText(hasRating and ("%.1f"):format(numericRating) or "")
    SetTextColor(badge.Value, COLORS.bestMetric)
    badge.Value:SetShown(hasRating)

    for index, star in ipairs(badge.Stars or {}) do
        local fillAmount = hasRating and max(0, min(1, numericRating - (index - 1))) or 0
        if star and star.Bg and star.Fill then
            star.Bg:SetAlpha(hasRating and 0.22 or 0.14)
            if fillAmount > 0 then
                star.Fill:Show()
                local starSize = badge.starSize or 14
                star.Fill:SetWidth(starSize * fillAmount)
                star.Fill:SetTexCoord(0, fillAmount, 0, 1)
            else
                star.Fill:Hide()
            end
        end
    end
end

function MythicTools:SetRoleBadge(badge, role)
    if not badge then
        return
    end

    local normalizedRole = self:NormalizeRole(role)
    if not normalizedRole then
        badge:Hide()
        return
    end

    local atlasByRole = {
        TANK = "roleicon-tiny-tank",
        HEALER = "roleicon-tiny-healer",
        DAMAGER = "roleicon-tiny-dps",
    }
    local sunUIIconByRole = {
        TANK = "Interface\\AddOns\\SkyMythicHistory\\Media\\RoleIcons\\Tank.tga",
        HEALER = "Interface\\AddOns\\SkyMythicHistory\\Media\\RoleIcons\\Healer.tga",
        DAMAGER = "Interface\\AddOns\\SkyMythicHistory\\Media\\RoleIcons\\DPS.tga",
    }

    badge.Icon:SetTexture(nil)
    badge.Icon:SetTexCoord(0, 1, 0, 1)
    badge.Label:Hide()

    local customRoleIcon = sunUIIconByRole[normalizedRole]
    if customRoleIcon then
        badge.Icon:SetAtlas(nil)
        badge.Icon:SetTexture(customRoleIcon)
        badge.Icon:SetTexCoord(0, 1, 0, 1)
        badge:Show()
        return
    end

    local atlas = atlasByRole[normalizedRole]
    if atlas and badge.Icon.SetAtlas and pcall(badge.Icon.SetAtlas, badge.Icon, atlas, true) then
        badge:Show()
        return
    end

    if GetTexCoordsForRoleSmall then
        local left, right, top, bottom = GetTexCoordsForRoleSmall(normalizedRole)
        badge.Icon:SetAtlas(nil)
        badge.Icon:SetTexture(ROLE_ICON_TEXTURE)
        badge.Icon:SetTexCoord(left, right, top, bottom)
        badge:Show()
        return
    end

    badge.Icon:SetAtlas(nil)
    badge.Icon:SetTexture(nil)
    badge.Label:SetText(normalizedRole == "HEALER" and "H" or (normalizedRole == "TANK" and "T" or "D"))
    badge.Label:Show()
    badge:Show()
end

function MythicTools:HandleItemInfoReceived(itemID, success)
    if success == false then
        return
    end

    -- Only react to items the UI actually failed to resolve earlier;
    -- this event fires for every item info load in the entire game.
    local pending = self._pendingItemInfo
    if type(pending) ~= "table" or next(pending) == nil then
        return
    end

    if itemID then
        if not pending[itemID] then
            return
        end
        pending[itemID] = nil
    end

    local mainShown = self.ui and self.ui.frame and self.ui.frame:IsShown()
    local popupShown = self.completionPopup and self.completionPopup:IsShown()
    if mainShown or popupShown then
        self:QueueRefreshAllViews("item-info-received", 0.1)
    end
end

function MythicTools:SetRunArt(iconWidget, backgroundWidget, run, backgroundLayout)
    local icon = GetDungeonIcon(run)
    if iconWidget then
        iconWidget:SetTexture(icon or DEFAULT_DUNGEON_ICON)
        iconWidget:SetTexCoord(0.08, 0.92, 0.08, 0.92)
    end

    if backgroundWidget then
        local bg = GetDungeonBackground(run)
        backgroundWidget:SetTexture(bg or DEFAULT_DUNGEON_BG)
        if backgroundLayout == "cover" then
            SetTextureCoverTexCoord(backgroundWidget, DUNGEON_HEADER_BACKGROUND_ASPECT)
        else
            backgroundWidget:SetTexCoord(0, 1, 0, 1)
        end
        if backgroundWidget.SetHorizTile then
            backgroundWidget:SetHorizTile(false)
        end
        if backgroundWidget.SetVertTile then
            backgroundWidget:SetVertTile(false)
        end
    end
end

function MythicTools:ResetLootButtons(buttons, lootLabel, overflowLabel, emptyLabel, textButtons)
    if type(buttons) == "table" then
        for _, button in ipairs(buttons) do
            button.itemLink = nil
            button.itemLevel = nil
            button:SetScript("OnUpdate", nil)
            button.Icon:SetTexture(QUESTION_MARK_ICON)
            button.Icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
            if button.ItemLevel then
                button.ItemLevel:SetText("")
            end
            self:ApplySurface(button, COLORS.frameBG, COLORS.surface, nil)
            button:Hide()
        end
    end

    if type(textButtons) == "table" then
        for _, button in ipairs(textButtons) do
            button.itemLink = nil
            button.Text:SetText("")
            button.Text:Show()
            button:Hide()
        end
    end

    if lootLabel and (type(textButtons) ~= "table" or #textButtons == 0) then
        lootLabel:SetText("")
        lootLabel:Hide()
    end

    if overflowLabel then
        overflowLabel:SetText("")
        overflowLabel:Hide()
    end

    if emptyLabel then
        emptyLabel:Show()
    end
end

function MythicTools:SetLootButtons(buttons, lootLabel, overflowLabel, emptyLabel, lootLinks, textButtons)
    self:ResetLootButtons(buttons, lootLabel, overflowLabel, emptyLabel, textButtons)

    if self.db and self.db.settings and self.db.settings.showLootHistory == false then
        if emptyLabel then
            emptyLabel:SetText(self:L("UI_LOOT_HIDDEN"))
            emptyLabel:Show()
        end
        return
    end

    if type(lootLinks) ~= "table" or #lootLinks == 0 then
        return
    end

    if emptyLabel then
        emptyLabel:SetText(self:L("UI_NO_LOOT"))
        emptyLabel:Hide()
    end

    local visibleCount = min(#(buttons or {}), #lootLinks)
    for index = 1, visibleCount do
        local button = buttons[index]
        local textButton = textButtons and textButtons[index]
        local itemLink = NormalizeDisplayItemLink(lootLinks[index])
        if button and itemLink then
            local displayName = GetItemNameFallback(itemLink)
            if displayName == "" then
                displayName = "Unknown item"
            end

            button.itemLink = itemLink
            if textButton then
                textButton.itemLink = itemLink
            end

            local itemID = GetItemIDFromLinkish(itemLink)
            local icon = C_Item and C_Item.GetItemInfoInstant and select(5, C_Item.GetItemInfoInstant(itemLink)) or nil
            if (not icon or icon == 0) and itemID and C_Item and C_Item.GetItemIconByID then
                icon = C_Item.GetItemIconByID(itemID)
            end
            local itemLevel = GetItemLevelForLink(itemLink)
            button.Icon:SetTexture(icon or QUESTION_MARK_ICON)
            button.Icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
            button.itemLevel = itemLevel
            if button.ItemLevel then
                button.ItemLevel:SetText(FormatItemLevelText(itemLevel))
            end
            button:Show()

            local qualityColor = GetItemQualityColorByLink(itemLink) or COLORS.muted
            MythicTools:ApplySurface(button, COLORS.frameBG, qualityColor, nil)

            if textButton then
                textButton.Text:SetText(displayName)
                textButton.Text:Show()
                SetTextColor(textButton.Text, qualityColor)
                textButton:Show()
            elseif index == 1 and lootLabel then
                lootLabel:SetText(displayName)
                SetTextColor(lootLabel, qualityColor)
                lootLabel:Show()
            end

            if Item and Item.CreateFromItemLink then
                local item = Item:CreateFromItemLink(itemLink)
                item:ContinueOnItemLoad(function()
                    if button.itemLink ~= itemLink then
                        return
                    end

                    local quality = item.GetItemQuality and item:GetItemQuality() or nil
                    local loadedIcon = item.GetItemIcon and item:GetItemIcon() or icon
                    button.Icon:SetTexture(loadedIcon or QUESTION_MARK_ICON)
                    if button.ItemLevel then
                        local loadedItemLevel = (item.GetCurrentItemLevel and item:GetCurrentItemLevel()) or GetItemLevelForLink(itemLink)
                        button.itemLevel = tonumber(loadedItemLevel) or button.itemLevel
                        button.ItemLevel:SetText(FormatItemLevelText(button.itemLevel))
                    end

                    local targetText = textButton and textButton.Text or lootLabel
                    if targetText then
                        targetText:SetText(item.GetItemName and item:GetItemName() or displayName)
                        targetText:Show()
                    end

                    local border = COLORS.surface
                    if quality and C_Item.GetItemQualityColor then
                        local r, g, b = C_Item.GetItemQualityColor(quality)
                        border = {r, g, b, 1}
                        if targetText then
                            SetTextColor(targetText, border)
                        end
                    else
                        if targetText then
                            SetTextColor(targetText, COLORS.muted)
                        end
                    end
                    MythicTools:ApplySurface(button, COLORS.frameBG, border, nil)

                end)
            end
        end
    end

    if overflowLabel and #lootLinks > visibleCount then
        if overflowLabel.shortFormat then
            overflowLabel:SetText(("+%d"):format(#lootLinks - visibleCount))
        else
            overflowLabel:SetText(("+%d more item%s"):format(#lootLinks - visibleCount, (#lootLinks - visibleCount) == 1 and "" or "s"))
        end
        overflowLabel:Show()
    end
end

function MythicTools:GetCompletionPopupDelay()
    return self:Clamp(self.db.settings.completionPopupDelay or 0, 0, 10)
end

function MythicTools:GetCompletionPopupScale()
    return self:Clamp(self.db.settings.completionPopupScale or 1, 0.7, 1.4)
end

function MythicTools:GetCompletionToastDuration()
    return self:Clamp((self.db and self.db.settings and self.db.settings.completionToastDuration) or 10, 3, 60)
end

function MythicTools:GetCompletionToastAutoHide()
    return self.db and self.db.settings and self.db.settings.completionToastAutoHide ~= false
end

function MythicTools:ApplyCompletionPopupScale()
    if self.completionPopup then
        self.completionPopup:SetScale(self:GetCompletionPopupScale())
    end
end

function MythicTools:GetCompletionPopupMode()
    if not (self.db and self.db.settings) then
        return "full"
    end

    local mode = self.db.settings.completionPopupMode
    if mode == "full" or mode == "compact" or mode == "off" then
        return mode
    end

    if self.db.settings.showCompletionPopup == false then
        return "off"
    end

    return "full"
end

function MythicTools:BuildCompletionToast()
    if self.completionToast then
        return self.completionToast
    end

    local frame = CreateFrame("Button", "MythicToolsCompletionToast", UIParent, "BackdropTemplate")
    frame:SetSize(480, 82)
    local point = self.db and self.db.ui and self.db.ui.completionToastPoint or {"TOP", "TOP", 0, -210}
    frame:SetPoint(point[1] or "TOP", UIParent, point[2] or point[1] or "TOP", point[3] or 0, point[4] or -210)
    frame:SetFrameStrata("FULLSCREEN_DIALOG")
    frame:SetFrameLevel(401)
    frame:SetClampedToScreen(true)
    frame:SetMovable(true)
    frame:EnableMouse(true)
    frame:RegisterForClicks("LeftButtonUp", "RightButtonUp")
    frame:RegisterForDrag("LeftButton")
    frame:SetScript("OnMouseUp", function(_, mouseButton)
        if mouseButton == "RightButton" then
            MythicTools:HideCompletionToast()
        end
    end)
    frame:SetScript("OnDragStart", function()
        frame:StartMoving()
    end)
    frame:SetScript("OnDragStop", function()
        frame:StopMovingOrSizing()
        MythicTools:StoreCompletionToastPoint()
    end)
    frame:SetScript("OnEnter", nil)
    frame:SetScript("OnLeave", nil)
    frame:Hide()
    self:ApplySurface(frame, COLORS.frameBG, COLORS.frameBorder, COLORS.accent)

    local iconFrame = CreateFrame("Frame", nil, frame)
    iconFrame:SetSize(62, 62)
    iconFrame:SetPoint("LEFT", 12, 0)
    self:ApplySurface(iconFrame, COLORS.panelBG, COLORS.surface, nil)

    frame.Icon = iconFrame:CreateTexture(nil, "ARTWORK")
    frame.Icon:SetPoint("TOPLEFT", 1, -1)
    frame.Icon:SetPoint("BOTTOMRIGHT", -1, 1)
    frame.Icon:SetTexture(DEFAULT_DUNGEON_ICON)
    frame.Icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)

    frame.Title = CreateFont(frame, 15, COLORS.text)
    frame.Title:SetPoint("TOPLEFT", iconFrame, "TOPRIGHT", 12, -10)
    frame.Title:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -74, -10)
    frame.Title:SetJustifyH("LEFT")
    frame.Title:SetText(self:L("UI_MYTHIC_RUN_COMPLETED"))

    frame.Meta = CreateFont(frame, 12, COLORS.muted)
    frame.Meta:SetPoint("TOPLEFT", frame.Title, "BOTTOMLEFT", 0, -8)
    frame.Meta:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -18, -34)
    frame.Meta:SetJustifyH("LEFT")
    frame.Meta:SetText("")

    frame.Hint = CreateFont(frame, 11, COLORS.subdued)
    frame.Hint:SetPoint("BOTTOMLEFT", frame.Title, "BOTTOMLEFT", 0, -34)
    frame.Hint:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -18, 10)
    frame.Hint:SetJustifyH("LEFT")
    frame.Hint:SetText("")
    frame.Hint:Hide()

    local function OpenFullSummary()
        local previewRun = frame.previewRun
        local runId = frame.runId
        MythicTools:HideCompletionToast()
        if previewRun then
            MythicTools:ShowCompletionPopup(previewRun)
        elseif runId then
            MythicTools:ShowCompletionPopup(runId)
        end
    end

    frame.IconButton = CreateFrame("Button", nil, frame)
    frame.IconButton:SetPoint("TOPLEFT", iconFrame, "TOPLEFT", 0, 0)
    frame.IconButton:SetPoint("BOTTOMRIGHT", iconFrame, "BOTTOMRIGHT", 0, 0)
    frame.IconButton:RegisterForClicks("LeftButtonUp")
    frame.IconButton:SetScript("OnClick", OpenFullSummary)

    frame.TitleButton = CreateFrame("Button", nil, frame)
    frame.TitleButton:SetHeight(18)
    frame.TitleButton:SetPoint("TOPLEFT", frame.Title, "TOPLEFT", -1, 2)
    frame.TitleButton:RegisterForClicks("LeftButtonUp")
    frame.TitleButton:SetScript("OnClick", OpenFullSummary)

    local closeButton = CreateFrame("Button", nil, frame, "BackdropTemplate")
    closeButton:SetSize(20, 20)
    closeButton:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -8, -8)
    closeButton:SetBackdrop({bgFile = WHITE_TEXTURE, edgeFile = WHITE_TEXTURE, edgeSize = 1, insets = {left=1, right=1, top=1, bottom=1}})
    closeButton:SetBackdropColor(0.6, 0.1, 0.1, 0.6)
    closeButton:SetBackdropBorderColor(CONTROL_COLORS.controlBorder[1], CONTROL_COLORS.controlBorder[2], CONTROL_COLORS.controlBorder[3], CONTROL_COLORS.controlBorder[4])
    closeButton:SetScript("OnEnter", function() closeButton:SetBackdropColor(0.6, 0.1, 0.1, 1) end)
    closeButton:SetScript("OnLeave", function() closeButton:SetBackdropColor(0.6, 0.1, 0.1, 0.6) end)
    closeButton:SetScript("OnClick", function()
        MythicTools:HideCompletionToast()
    end)
    MythicTools:ApplyCloseButtonIcon(closeButton, 10)

    local maximizeButton = CreateFrame("Button", nil, frame, "BackdropTemplate")
    maximizeButton:SetSize(20, 20)
    maximizeButton:SetPoint("TOPRIGHT", closeButton, "TOPLEFT", -6, 0)
    maximizeButton:SetBackdrop({bgFile = WHITE_TEXTURE, edgeFile = WHITE_TEXTURE, edgeSize = 1, insets = {left=1, right=1, top=1, bottom=1}})
    maximizeButton:SetBackdropColor(0.16, 0.16, 0.16, 0.82)
    maximizeButton:SetBackdropBorderColor(CONTROL_COLORS.controlBorder[1], CONTROL_COLORS.controlBorder[2], CONTROL_COLORS.controlBorder[3], CONTROL_COLORS.controlBorder[4])
    maximizeButton:SetScript("OnEnter", function() maximizeButton:SetBackdropColor(0.24, 0.24, 0.24, 1) end)
    maximizeButton:SetScript("OnLeave", function() maximizeButton:SetBackdropColor(0.16, 0.16, 0.16, 0.82) end)
    maximizeButton:SetScript("OnClick", function()
        OpenFullSummary()
    end)
    MythicTools:ApplyControlButtonIcon(maximizeButton, "Interface\\AddOns\\SkyMythicHistory\\Media\\Textures\\Plus.tga", 10)

    self.completionToast = frame
    return frame
end

function MythicTools:HideCompletionToast()
    if self.completionToast then
        self.completionToast.runId = nil
        self.completionToast.previewRun = nil
        self.completionToast._showToken = nil
        self.completionToast:Hide()
    end
end

function MythicTools:ShowCompletionToast(runOrRunId)
    local previewRun = type(runOrRunId) == "table" and runOrRunId or nil
    local runId = previewRun and previewRun.runId or runOrRunId
    local run = previewRun or (runId and self:GetRunById(runId)) or nil
    if not run then
        return false
    end

    self:HideCompletionToast()
    if self.completionPopup and self.completionPopup:IsShown() then
        self.completionPopup.runId = nil
        self.completionPopup.previewRun = nil
        self.completionPopup:Hide()
    end
    local frame = self:BuildCompletionToast()
    frame.previewRun = previewRun
    frame.runId = previewRun and nil or runId
    self:SetRunArt(frame.Icon, nil, run)

    local statusText = GetRunStatusText(run)
    local statusColor = GetRunStatusColor(run)
    frame.Title:SetText(("%s %s  •  %s"):format(
        run.dungeonName or "Unknown",
        FormatKeyLevel(run.level),
        ColorizeInlineText(statusText, statusColor)
    ))
    frame.Meta:SetText(("%s  •  %s  •  %d deaths"):format(
        self:FormatDateTime(run.endTime),
        self:FormatDurationMS(run.timeMS),
        tonumber(run.totalDeaths) or 0
    ))
    if frame.TitleButton then
        local titleWidth = frame.Title.GetStringWidth and frame.Title:GetStringWidth() or 0
        titleWidth = min(max(titleWidth + 6, 20), frame:GetWidth() - 166)
        frame.TitleButton:SetWidth(titleWidth)
    end
    if frame.Hint then
        frame.Hint:SetText("")
        frame.Hint:Hide()
    end
    frame:Show()
    frame:Raise()

    if self:GetCompletionToastAutoHide() then
        local token = (frame._showToken or 0) + 1
        frame._showToken = token
        C_Timer.After(self:GetCompletionToastDuration(), function()
            if frame and frame._showToken == token then
                MythicTools:HideCompletionToast()
            end
        end)
    end
    return true
end

function MythicTools:ShowCompletionResultOverlay(runOrRunId, modeOverride)
    local mode = modeOverride
    if mode ~= "compact" and mode ~= "full" and mode ~= "off" then
        mode = self:GetCompletionPopupMode()
    end

    if mode == "off" then
        self:HideCompletionToast()
        return false
    end

    if mode == "compact" then
        return self:ShowCompletionToast(runOrRunId)
    end

    self:HideCompletionToast()
    self:ShowCompletionPopup(runOrRunId)
    return true
end

function MythicTools:QueueCompletionPopup(runId, reason)
    if not runId or not (self.db and self.db.settings) then
        return
    end

    if self:GetCompletionPopupMode() == "off" then
        if self.PushRuntimeDebug then
            self:PushRuntimeDebug(("skip completion popup run %s reason=%s popup disabled"):format(tostring(runId), tostring(reason)))
        end
        return
    end

    local trigger = self.db.settings.completionPopupTrigger or "LOOT_OPENED"
    local delay = self:GetCompletionPopupDelay()
    if self.PushRuntimeDebug then
        self:PushRuntimeDebug(("queue completion popup run %s trigger=%s reason=%s delay=%s"):format(
            tostring(runId),
            tostring(trigger),
            tostring(reason),
            tostring(delay)
        ))
    end

    local effectiveReason = reason
    local lootTracking = self.runtime and self.runtime.lootTracking or nil
    if trigger == "LOOT_OPENED"
        and reason == "COMPLETED"
        and type(lootTracking) == "table"
        and lootTracking.runId == runId
        and lootTracking.lootOpened then
        effectiveReason = "LOOT_OPENED"
    end

    if trigger == effectiveReason then
        self.runtime.pendingCompletionPopup = nil
        local function OpenPopupNow()
            if MythicTools.db and MythicTools:GetCompletionPopupMode() ~= "off" then
                local ok, err = pcall(MythicTools.ShowCompletionResultOverlay, MythicTools, runId)
                if not ok and MythicTools.StoreDebugReport then
                    MythicTools:StoreDebugReport("popup_show_error", {
                        trigger = effectiveReason,
                        runId = runId,
                        error = tostring(err),
                    })
                end
            end
        end

        if delay <= 0 then
            OpenPopupNow()
        else
            self:Schedule(delay, OpenPopupNow)
        end
    else
        self.runtime.pendingCompletionPopup = {runId = runId}
        if self.PushRuntimeDebug then
            self:PushRuntimeDebug(("store pending completion popup run %s waiting for %s"):format(tostring(runId), tostring(trigger)))
        end
    end
end

function MythicTools:ResolvePendingCompletionPopup(reason)
    if not (self.runtime and self.runtime.pendingCompletionPopup) then
        return
    end

    local trigger = self.db.settings.completionPopupTrigger or "LOOT_OPENED"
    if trigger ~= reason then
        return
    end

    local pending = self.runtime.pendingCompletionPopup
    self.runtime.pendingCompletionPopup = nil
    local delay = self:GetCompletionPopupDelay()
    if self.PushRuntimeDebug then
        self:PushRuntimeDebug(("resolve pending completion popup run %s reason=%s delay=%s"):format(
            tostring(pending.runId),
            tostring(reason),
            tostring(delay)
        ))
    end

    local function OpenPopupNow()
        if MythicTools.db and MythicTools:GetCompletionPopupMode() ~= "off" then
            local ok, err = pcall(MythicTools.ShowCompletionResultOverlay, MythicTools, pending.runId)
            if not ok and MythicTools.StoreDebugReport then
                MythicTools:StoreDebugReport("popup_show_error", {
                    trigger = reason,
                    runId = pending.runId,
                    error = tostring(err),
                })
            end
        end
    end

    if delay <= 0 then
        OpenPopupNow()
    else
        self:Schedule(delay, OpenPopupNow)
    end
end

function MythicTools:BuildTestRun()
    local now = time()
    local roster = {
        "Arkanis-Azralon",
        "Lumera-Stormrage",
        "Kaorth-Illidan",
        "Brielle-Sargeras",
        "Torvak-Ragnaros",
    }

    local playerStats = {
        [roster[1]] = {name = roster[1], shortName = "Arkanis", classFilename = "MAGE", damage = 20450000, healing = 310000, interrupts = 4, dispels = 0, avoidableDamage = 210000, deaths = 0, loot = {"|cff0070dd|Hitem:19019::::::::80:::::|h[Thunderfury Test]|h|r"}},
        [roster[2]] = {name = roster[2], shortName = "Lumera", classFilename = "PRIEST", damage = 5810000, healing = 15880000, interrupts = 1, dispels = 5, avoidableDamage = 78000, deaths = 1, loot = {"|cff1eff00|Hitem:17182::::::::80:::::|h[Sulfuras Cloak Test]|h|r"}},
        [roster[3]] = {name = roster[3], shortName = "Kaorth", classFilename = "WARRIOR", damage = 16800000, healing = 0, interrupts = 8, dispels = 0, avoidableDamage = 0, deaths = 0, loot = {}},
        [roster[4]] = {name = roster[4], shortName = "Brielle", classFilename = "DRUID", damage = 12150000, healing = 4320000, interrupts = 2, dispels = 2, avoidableDamage = 510000, deaths = 2, loot = {"|cffa335ee|Hitem:18803::::::::80:::::|h[Finkle's Lava Dredger Test]|h|r", "|cff0070dd|Hitem:19362::::::::80:::::|h[Doom's Edge Test]|h|r"}},
        [roster[5]] = {name = roster[5], shortName = "Torvak", classFilename = "PALADIN", damage = 10900000, healing = 2140000, interrupts = 3, dispels = 1, avoidableDamage = 125000, deaths = 0, loot = {"|cffa335ee|Hitem:19364::::::::80:::::|h[Ashkandi Test]|h|r"}},
    }

    return {
        runId = -1,
        preview = true,
        startTime = now - 1600,
        endTime = now,
        mapChallengeModeID = 1,
        mapID = 1,
        dungeonName = "Preview Dungeon",
        level = 12,
        timeMS = 1423000,
        onTime = true,
        keystoneUpgradeLevels = 2,
        practiceRun = false,
        timeLimitSeconds = 1560,
        totalDeaths = 3,
        deathPenaltySeconds = 15,
        roster = roster,
        playerStats = playerStats,
        statsUnavailable = false,
        statsNote = "Layout preview: tooltip, loot, and portraits.",
        textureFileID = DEFAULT_DUNGEON_ICON,
        backgroundTextureFileID = DEFAULT_DUNGEON_BG,
    }
end

function MythicTools:ShowTestCompletionPopup(modeOverride)
    local mode = modeOverride
    if mode ~= "compact" and mode ~= "full" and mode ~= "off" then
        mode = self:GetCompletionPopupMode()
    end
    if mode == "off" then
        mode = "full"
    end
    self:ShowCompletionResultOverlay(self:BuildTestRun(), mode)
end

function MythicTools:ShowCSVExportPopup()
    local selectedFormat = self.db and self.db.ui and self.db.ui.exportFormat or "raw_csv"
    if self.NormalizeExportFormat then
        selectedFormat = self:NormalizeExportFormat(selectedFormat)
    elseif selectedFormat ~= "filtered_csv" then
        selectedFormat = "raw_csv"
    end
    if self.db and self.db.ui then
        self.db.ui.exportFormat = selectedFormat
    end
    local exportTitle, exportText, exportInfo = self:BuildExportPayload(selectedFormat)
    local frame = self.csvExportFrame
    local function ShowExportChunk(targetFrame, chunkIndex)
        local info = targetFrame.ExportInfo
        if not (info and info.chunks) then
            return
        end

        chunkIndex = self:Clamp(tonumber(chunkIndex) or 1, 1, #info.chunks)
        targetFrame.ExportChunkIndex = chunkIndex
        targetFrame.EditBox:SetText(info.chunks[chunkIndex] or "")
        targetFrame.EditBox:SetCursorPosition(0)
        if targetFrame.ChunkLabel then
            targetFrame.ChunkLabel:SetText(("Part %d/%d, %d chars total"):format(chunkIndex, #info.chunks, tonumber(info.rawLength) or 0))
        end
    end

    local function ApplyExportPayload(targetFrame, titleText, payloadText, info)
        targetFrame.Title:SetText(titleText or "Export")
        targetFrame.ExportInfo = info
        if info and info.chunks then
            if targetFrame.PrevChunkButton then targetFrame.PrevChunkButton:Show() end
            if targetFrame.NextChunkButton then targetFrame.NextChunkButton:Show() end
            if targetFrame.ChunkLabel then targetFrame.ChunkLabel:Show() end
            ShowExportChunk(targetFrame, 1)
        else
            if targetFrame.PrevChunkButton then targetFrame.PrevChunkButton:Hide() end
            if targetFrame.NextChunkButton then targetFrame.NextChunkButton:Hide() end
            if targetFrame.ChunkLabel then targetFrame.ChunkLabel:Hide() end
            targetFrame.EditBox:SetText(payloadText or "")
            targetFrame.EditBox:SetCursorPosition(0)
        end
        targetFrame.EditBox:SetFocus()
        targetFrame.EditBox:HighlightText()
    end

    if not frame then
        frame = CreateFrame("Frame", "MythicToolsCSVExportFrame", UIParent, "BackdropTemplate")
        frame:SetSize(760, 520)
        frame:SetPoint("CENTER")
        frame:SetFrameStrata("FULLSCREEN_DIALOG")
        frame:SetFrameLevel(500)
        frame:SetClampedToScreen(true)
        frame:EnableMouse(true)
        frame:SetMovable(true)
        frame:RegisterForDrag("LeftButton")
        frame:SetScript("OnDragStart", function(self) self:StartMoving() end)
        frame:SetScript("OnDragStop", function(self) self:StopMovingOrSizing() end)
        frame:Hide()
        self:ApplySurface(frame, COLORS.frameBG, COLORS.frameBorder, COLORS.accent)
        table.insert(UISpecialFrames, frame:GetName())

        local title = CreateFont(frame, 16, COLORS.text)
        title:SetPoint("TOPLEFT", 16, -14)
        title:SetText(exportTitle or "Export")

        local hint = CreateFont(frame, 11, COLORS.muted, "RIGHT")
        hint:SetPoint("TOPRIGHT", -54, -17)
        hint:SetText(self:L("UI_COPY_PART_HINT"))

        local closeButton = CreateActionButton(frame, 28, 24, "X")
        closeButton:SetPoint("TOPRIGHT", -14, -12)
        MythicTools:ApplyCloseButtonIcon(closeButton, 10)
        closeButton:SetScript("OnClick", function()
            frame:Hide()
        end)

        local formatRow = CreateAceFlowRow(frame, -42, 56)
        frame.FormatDropdown = CreateAceDropdownWidget(self:L("UI_FORMAT"), 240, self:GetExportFormatOptions(), selectedFormat, function(value)
            MythicTools.db.ui.exportFormat = MythicTools.NormalizeExportFormat and MythicTools:NormalizeExportFormat(value) or (value or "raw_csv")
            local newTitle, newText, newInfo = MythicTools:BuildExportPayload(MythicTools.db.ui.exportFormat)
            ApplyExportPayload(frame, newTitle, newText, newInfo)
        end)
        StyleAceDropdownWidget(frame.FormatDropdown)
        if formatRow and frame.FormatDropdown then
            formatRow:AddChild(frame.FormatDropdown)
        end

        local wclNote = CreateFont(frame, 10, COLORS.muted, "LEFT")
        wclNote:SetPoint("TOPLEFT", 272, -50)
        wclNote:SetPoint("TOPRIGHT", -16, -50)
        wclNote:SetJustifyV("TOP")
        wclNote:SetText(self:L("EXPORT_WCL_NOTE"))
        wclNote:SetWordWrap(true)
        frame.WclNote = wclNote

        local box = CreateFrame("Frame", nil, frame, "BackdropTemplate")
        box:SetPoint("TOPLEFT", 16, -92)
        box:SetPoint("BOTTOMRIGHT", -16, 52)
        box:SetBackdrop({bgFile = WHITE_TEXTURE, edgeFile = WHITE_TEXTURE, edgeSize = 1, insets = {left=1, right=1, top=1, bottom=1}})
        box:SetBackdropColor(COLORS.rowBG[1], COLORS.rowBG[2], COLORS.rowBG[3], 1)
        box:SetBackdropBorderColor(CONTROL_COLORS.controlBorder[1], CONTROL_COLORS.controlBorder[2], CONTROL_COLORS.controlBorder[3], CONTROL_COLORS.controlBorder[4])
        ClipChildren(box)

        local scroll = CreateFrame("ScrollFrame", nil, box, "UIPanelScrollFrameTemplate")
        scroll:SetPoint("TOPLEFT", 8, -8)
        scroll:SetPoint("BOTTOMRIGHT", -28, 8)
        ApplyThinScrollBar(scroll)

        local editBox = CreateFrame("EditBox", nil, scroll)
        editBox:SetMultiLine(true)
        editBox:SetAutoFocus(false)
        editBox:SetMaxLetters(0)
        editBox:SetFontObject(ChatFontNormal)
        editBox:SetTextColor(COLORS.text[1], COLORS.text[2], COLORS.text[3], 1)
        editBox:SetJustifyH("LEFT")
        editBox:SetJustifyV("TOP")
        editBox:SetWidth(700)
        editBox:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)
        editBox:SetScript("OnEditFocusGained", function(self) self:HighlightText() end)
        scroll:SetScrollChild(editBox)

        local selectButton = CreateActionButton(frame, 120, 28, self:L("UI_SELECT_ALL"))
        selectButton:SetPoint("BOTTOMLEFT", 16, 14)
        self:ApplyActionButtonIcon(selectButton, "Interface\\AddOns\\SkyMythicHistory\\Media\\Icons\\copy.tga", 12)
        selectButton:SetScript("OnClick", function()
            frame.EditBox:SetFocus()
            frame.EditBox:HighlightText()
        end)

        local doneButton = CreateActionButton(frame, 86, 28, self:L("UI_DONE"))
        doneButton:SetPoint("BOTTOMRIGHT", -16, 14)
        self:ApplyActionButtonIcon(doneButton, "Interface\\AddOns\\SkyMythicHistory\\Media\\Icons\\close.tga", 12)
        doneButton:SetScript("OnClick", function()
            frame:Hide()
        end)

        local prevChunkButton = CreateActionButton(frame, 68, 28, self:L("UI_PREV"))
        prevChunkButton:SetPoint("LEFT", selectButton, "RIGHT", 10, 0)
        self:ApplyActionButtonIcon(prevChunkButton, "Interface\\AddOns\\SkyMythicHistory\\Media\\Icons\\left.tga", 12)
        prevChunkButton:SetScript("OnClick", function()
            ShowExportChunk(frame, (frame.ExportChunkIndex or 1) - 1)
            frame.EditBox:SetFocus()
            frame.EditBox:HighlightText()
        end)

        local nextChunkButton = CreateActionButton(frame, 68, 28, self:L("UI_NEXT"))
        nextChunkButton:SetPoint("LEFT", prevChunkButton, "RIGHT", 8, 0)
        self:ApplyActionButtonIcon(nextChunkButton, "Interface\\AddOns\\SkyMythicHistory\\Media\\Icons\\right.tga", 12)
        nextChunkButton:SetScript("OnClick", function()
            ShowExportChunk(frame, (frame.ExportChunkIndex or 1) + 1)
            frame.EditBox:SetFocus()
            frame.EditBox:HighlightText()
        end)

        local chunkLabel = CreateFont(frame, 11, COLORS.muted)
        chunkLabel:SetPoint("LEFT", nextChunkButton, "RIGHT", 10, 0)
        chunkLabel:SetPoint("RIGHT", doneButton, "LEFT", -10, 0)
        chunkLabel:SetJustifyH("LEFT")
        chunkLabel:SetText("")

        frame.EditBox = editBox
        frame.Title = title
        frame.PrevChunkButton = prevChunkButton
        frame.NextChunkButton = nextChunkButton
        frame.ChunkLabel = chunkLabel
        self.csvExportFrame = frame
    end

    if frame.FormatDropdown then
        UpdateAceDropdownOptions(frame.FormatDropdown, self:GetExportFormatOptions(), selectedFormat)
    end
    ApplyExportPayload(frame, exportTitle, exportText, exportInfo)
    frame:Show()
    frame:Raise()
end

function MythicTools:ShowImportPopup()
    local frame = self.importFrame
    if not frame then
        frame = CreateFrame("Frame", "MythicToolsImportFrame", UIParent, "BackdropTemplate")
        frame:SetSize(760, 520)
        frame:SetPoint("CENTER")
        frame:SetFrameStrata("FULLSCREEN_DIALOG")
        frame:SetFrameLevel(510)
        frame:SetClampedToScreen(true)
        frame:EnableMouse(true)
        frame:SetMovable(true)
        frame:RegisterForDrag("LeftButton")
        frame:SetScript("OnDragStart", function(self) self:StartMoving() end)
        frame:SetScript("OnDragStop", function(self) self:StopMovingOrSizing() end)
        frame:Hide()
        self:ApplySurface(frame, COLORS.frameBG, COLORS.frameBorder, COLORS.accent)
        table.insert(UISpecialFrames, frame:GetName())

        local title = CreateFont(frame, 16, COLORS.text)
        title:SetPoint("TOPLEFT", 16, -14)
        title:SetText(self:L("UI_IMPORT_BACKUP"))

        local hint = CreateFont(frame, 11, COLORS.muted, "RIGHT")
        hint:SetPoint("TOPRIGHT", -54, -17)
        hint:SetText(self:L("UI_IMPORT_HINT"))

        local closeButton = CreateActionButton(frame, 28, 24, "X")
        closeButton:SetPoint("TOPRIGHT", -14, -12)
        MythicTools:ApplyCloseButtonIcon(closeButton, 10)
        closeButton:SetScript("OnClick", function()
            frame:Hide()
        end)

        local box = CreateFrame("Frame", nil, frame, "BackdropTemplate")
        box:SetPoint("TOPLEFT", 16, -54)
        box:SetPoint("BOTTOMRIGHT", -16, 76)
        box:SetBackdrop({bgFile = WHITE_TEXTURE, edgeFile = WHITE_TEXTURE, edgeSize = 1, insets = {left=1, right=1, top=1, bottom=1}})
        box:SetBackdropColor(COLORS.rowBG[1], COLORS.rowBG[2], COLORS.rowBG[3], 1)
        box:SetBackdropBorderColor(CONTROL_COLORS.controlBorder[1], CONTROL_COLORS.controlBorder[2], CONTROL_COLORS.controlBorder[3], CONTROL_COLORS.controlBorder[4])
        ClipChildren(box)

        local scroll = CreateFrame("ScrollFrame", nil, box, "UIPanelScrollFrameTemplate")
        scroll:SetPoint("TOPLEFT", 8, -8)
        scroll:SetPoint("BOTTOMRIGHT", -28, 8)
        ApplyThinScrollBar(scroll)

        local editBox = CreateFrame("EditBox", nil, scroll)
        editBox:SetMultiLine(true)
        editBox:SetAutoFocus(false)
        editBox:SetMaxLetters(0)
        editBox:SetFontObject(ChatFontNormal)
        editBox:SetTextColor(COLORS.text[1], COLORS.text[2], COLORS.text[3], 1)
        editBox:SetJustifyH("LEFT")
        editBox:SetJustifyV("TOP")
        editBox:SetWidth(700)
        editBox:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)
        scroll:SetScrollChild(editBox)

        local status = CreateFont(frame, 11, COLORS.subdued)
        status:SetPoint("BOTTOMLEFT", 16, 52)
        status:SetPoint("RIGHT", frame, "RIGHT", -16, 0)
        status:SetText(self:L("UI_IMPORT_STATUS"))

        local mergeButton = CreateActionButton(frame, 126, 28, self:L("UI_MERGE_IMPORT"))
        mergeButton:SetPoint("BOTTOMLEFT", 16, 14)
        self:ApplyActionButtonIcon(mergeButton, "Interface\\AddOns\\SkyMythicHistory\\Media\\Icons\\import.tga", 12)
        mergeButton:SetScript("OnClick", function()
            local ok, message = MythicTools:ImportBackupPayload(frame.EditBox:GetText(), "merge")
            frame.Status:SetText(message or (ok and "Import complete." or "Import failed."))
            SetTextColor(frame.Status, ok and COLORS.success or COLORS.danger)
            if ok and message and message:find("Paste") then
                frame.EditBox:SetFocus()
                frame.EditBox:HighlightText()
            end
        end)

        local replaceButton = CreateActionButton(frame, 132, 28, self:L("UI_REPLACE_DATA"))
        replaceButton:SetPoint("LEFT", mergeButton, "RIGHT", 10, 0)
        self:ApplyActionButtonIcon(replaceButton, "Interface\\AddOns\\SkyMythicHistory\\Media\\Icons\\import.tga", 12)
        replaceButton:SetScript("OnClick", function()
            local ok, message = MythicTools:ImportBackupPayload(frame.EditBox:GetText(), "replace")
            frame.Status:SetText(message or (ok and "Import complete." or "Import failed."))
            SetTextColor(frame.Status, ok and COLORS.success or COLORS.danger)
            if ok and message and message:find("Paste") then
                frame.EditBox:SetFocus()
                frame.EditBox:HighlightText()
            end
        end)

        local doneButton = CreateActionButton(frame, 86, 28, self:L("UI_DONE"))
        doneButton:SetPoint("BOTTOMRIGHT", -16, 14)
        self:ApplyActionButtonIcon(doneButton, "Interface\\AddOns\\SkyMythicHistory\\Media\\Icons\\close.tga", 12)
        doneButton:SetScript("OnClick", function()
            frame:Hide()
        end)

        frame.EditBox = editBox
        frame.Status = status
        self.importFrame = frame
    end

    frame.EditBox:SetText("")
    frame.Status:SetText(self:L("UI_IMPORT_STATUS"))
    SetTextColor(frame.Status, COLORS.subdued)
    frame:Show()
    frame:Raise()
    frame.EditBox:SetFocus()
end

function MythicTools:ShowTab(tabName, preserveFilters)
    self:BuildUI()
    if not preserveFilters then
        self:ResetNavigationFilters()
        self:SyncFilterWidgets()
    end
    self.db.ui.activeTab = tabName
    for name, page in pairs(self.ui.pages) do
        page:SetShown(name == tabName)
    end
    for name, button in pairs(self.ui.tabButtons) do
        StyleTabButton(button, name == tabName)
    end
    self:RefreshUI()
end

function MythicTools:ToggleMainFrame(forceShow)
    self:BuildUI()
    if forceShow or not self.ui.frame:IsShown() then
        self:ResetNavigationFilters()
        self:SyncFilterWidgets()
        self.ui.frame:Show()
        self:ShowTab(self.db.ui.activeTab or "Runs", true)
    else
        self:HideCompletionPopup()
        self.ui.frame:Hide()
    end
end

function MythicTools:ShowPlayersIndex()
    self.db.ui.playersView = "index"
    self.db.ui.selectedPlayer = nil
    self:BuildUI()
    self:ToggleMainFrame(true)
    self:ShowTab("Players")
end

function MythicTools:ShowRaiderIOCopyPopup(playerName)
    local url = BuildRaiderIOCharacterURL(playerName)
    if not url then
        self:Print("Raider.IO link unavailable for this player.")
        return
    end

    self:ShowCustomModal({
        title = "Copy Raider.IO link",
        text = "Press Ctrl+C to copy.",
        inputMode = "readonly",
        inputText = url,
        width = 470,
        inputTopOffset = -76,
        inputHeight = 60,
        height = 168,
        showCancel = false,
        confirmText = OKAY or "OK",
        closeOnCopy = true,
    })
end

function MythicTools:ShowPlayerNoteEditor(playerName)
    playerName = self:NormalizePlayerName(playerName) or self:TrimText(playerName)
    if not playerName or playerName == "" then
        return
    end

    self:ShowCustomModal({
        title = "Player note",
        inputMode = "edit",
        inputText = self:GetPlayerNote(playerName) or "",
        maxLetters = 160,
        width = 460,
        inputHeight = 74,
        height = 178,
        confirmText = SAVE or "Save",
        cancelText = CANCEL or "Cancel",
        onAccept = function(text)
            MythicTools:SetPlayerNote(playerName, text or "")
            if MythicTools.completionPopup and MythicTools.completionPopup:IsShown() and MythicTools.RefreshCompletionPopup then
                MythicTools:RefreshCompletionPopup()
            end
            local playerEntry = MythicTools:GetSelectedPlayerEntry()
            if playerEntry and playerEntry.fullName == playerName then
                MythicTools:RefreshPlayerDetailView(playerEntry)
            end
        end,
    })
end

function MythicTools:FocusSelectedPlayerNoteEditor()
    local playerName = self.db and self.db.ui and self.db.ui.selectedPlayer
    if playerName then
        self:ShowPlayerNoteEditor(playerName)
    end
end

function MythicTools:OpenPlayerHistory(playerName, focusNote)
    playerName = self:NormalizePlayerName(playerName) or self:TrimText(playerName)
    if not playerName or playerName == "" then
        return
    end

    self.db.ui.selectedPlayer = playerName
    self.db.ui.playersView = "detail"
    self:BuildUI()
    self.ui.frame:Show()
    self:ShowTab("Players", true)
    if self.ui and self.ui.playerDetailScrollFrame then
        self.ui.playerDetailScrollFrame:SetVerticalScroll(0)
    end
    if self.ui and self.ui.playerDetailScrollBar then
        self.ui.playerDetailScrollBar:SetValue(0)
    end

    if focusNote then
        self.runtime = self.runtime or {}
        self.runtime.pendingNoteFocusPlayer = playerName
        C_Timer.After(0, function()
            if MythicTools.db and MythicTools.db.ui and MythicTools.db.ui.selectedPlayer == playerName then
                MythicTools:FocusSelectedPlayerNoteEditor()
            end
        end)
    end
end

function MythicTools:EditPlayerNote(playerName)
    self:OpenPlayerHistory(playerName, true)
end

function MythicTools:SaveSelectedPlayerNote()
    local playerName = self.db and self.db.ui and self.db.ui.selectedPlayer
    if not playerName then
        return
    end

    self:ShowPlayerNoteEditor(playerName)
end

function MythicTools:RefreshUI()
    if not self.ui then
        return
    end

    local activeTab = self.db and self.db.ui and self.db.ui.activeTab or "Runs"
    if activeTab == "Runs" then
        self:RefreshOwnedCharacterFilterOptions()
    elseif activeTab == "Stats" then
        self:RefreshStatsCharacterFilterOptions()
    end
    if activeTab == "Players" then
        self:RefreshPlayersView()
    elseif activeTab == "Characters" then
        self:RefreshCharactersView()
    elseif activeTab == "Stats" then
        self:RefreshStatsView()
    elseif activeTab == "Settings" then
        self:RefreshSettingsView()
    else
        self:RefreshRunView()
    end
end

function MythicTools:QueueRefreshRunView(delay)
    if self._refreshRunViewQueued then
        return
    end

    self._refreshRunViewQueued = true
    self:Schedule(delay or 0.15, function()
        self._refreshRunViewQueued = false
        if self.ui then
            self:RefreshRunView()
        end
    end)
end

function MythicTools:QueueRefreshPlayersView(delay)
    if self._refreshPlayersViewQueued then
        return
    end

    self._refreshPlayersViewQueued = true
    self:Schedule(delay or 0.15, function()
        self._refreshPlayersViewQueued = false
        if self.ui then
            self:RefreshPlayersView()
        end
    end)
end

function MythicTools:RefreshRunView()
    if not self.ui then
        return
    end

    local runs = self:GetFilteredRuns()
    self.ui.filteredRuns = runs
    self.ui.runCountLabel:SetText(("%d runs"):format(#runs))

    local hasVisibleSelection = false
    for _, run in ipairs(runs) do
        if run.runId == self.db.ui.selectedRunId then
            hasVisibleSelection = true
            break
        end
    end
    if not hasVisibleSelection then
        self.db.ui.selectedRunId = runs[1] and runs[1].runId or nil
    end

    local showDetail = false
    if self.ui.runsFilterBar then
        self.ui.runsFilterBar:SetShown(not showDetail)
    end
    if self.ui.runsListView then
        self.ui.runsListView:SetShown(not showDetail)
    end
    if self.ui.runsDetailView then
        self.ui.runsDetailView:SetShown(showDetail)
    end

    FauxScrollFrame_Update(self.ui.runsScroll, #runs, RUN_ROWS, RUN_ROW_HEIGHT)
    local offset = FauxScrollFrame_GetOffset(self.ui.runsScroll)

    for rowIndex = 1, RUN_ROWS do
        local row = self.ui.runRows[rowIndex]
        local run = runs[offset + rowIndex]
        if run then
            row:Show()
            row.runId = run.runId
            self:SetRunArt(row.Icon, nil, run)
            row.Title:SetText(("%s +%d  •  %s"):format(run.dungeonName or "Unknown", run.level or 0, ColorizeInlineText(GetRunStatusText(run), GetRunStatusColor(run))))
            row.Subtitle:SetText("")
            row.Subtitle:Hide()

            local ownerName, ownerStat = GetRunOwnerInfo(run)
            if ownerName then
                self:SetRoleBadge(row.OwnerRole, ownerStat and ownerStat.role)
                row.OwnerSpec.Icon:SetTexture(self:ResolveDisplayIcon(ownerStat and ownerStat.specIconID, ownerStat and ownerStat.specID, ownerStat and ownerStat.classFilename))
                row.OwnerSpec.Icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
                row.OwnerName:SetText(self:GetShortName(ownerName))
                local classColor = ownerStat and ownerStat.classFilename and RAID_CLASS_COLORS and RAID_CLASS_COLORS[ownerStat.classFilename]
                if classColor then
                    row.OwnerName:SetTextColor(classColor.r, classColor.g, classColor.b, 1)
                else
                    SetTextColor(row.OwnerName, COLORS.muted)
                end
                row.OwnerSpec:Show()
                row.OwnerName:Show()
            else
                row.OwnerRole:Hide()
                row.OwnerSpec:Hide()
                row.OwnerName:SetText("")
                row.OwnerName:Hide()
            end

            row.RightTop:SetText(self:FormatDateTime(run.endTime))
            row.RightBottom:SetText(self:FormatDurationMS(run.timeMS))
            SetTextColor(row.RightTop, COLORS.muted)
            StyleListButton(row, run.runId == self.db.ui.selectedRunId, GetRunStatusColor(run))
        else
            row:Hide()
            row.runId = nil
            if row.OwnerRole then row.OwnerRole:Hide() end
            if row.OwnerSpec then row.OwnerSpec:Hide() end
            if row.OwnerName then row.OwnerName:Hide() end
        end
    end

    if self.ui.runsEmptyState then
        self.ui.runsEmptyState:SetShown(#runs == 0)
    end
end
function MythicTools:RefreshPlayersView()
    if not self.ui then
        return
    end

    local players = self:GetFilteredPlayers()
    self.ui.filteredPlayers = players
    if self.ui.playerCountLabel then
        self.ui.playerCountLabel:SetText(("%d players"):format(#players))
    end

    if self.ui.playersEmptyState then
        self.ui.playersEmptyState:SetShown(#players == 0)
    end

    if self.ui.playersTable then
        local rows = self:BuildPlayerAnalyticsTableData(players)
        self:ApplyPlayersTableSortState()
        self.ui.playersTable:SetData(rows)
        self.ui.playersTable:SortData()
        if self.ui.playersTable.frame then
            self.ui.playersTable.frame:SetShown(#players > 0)
        end
    end

    local playerEntry = self:GetSelectedPlayerEntry()
    local showDetail = self.db.ui.playersView == "detail" and playerEntry ~= nil

    if self.ui.playersIndexView then
        self.ui.playersIndexView:SetShown(not showDetail)
    end
    if self.ui.playersDetailView then
        self.ui.playersDetailView:SetShown(showDetail)
    end

    if showDetail then
        self:RefreshPlayerDetailView(playerEntry)
    elseif self.db.ui.playersView == "detail" then
        self.db.ui.playersView = "index"
        self.db.ui.selectedPlayer = nil
    end
end

function MythicTools:RefreshPlayerDetailView(playerEntry)
    if not (self.ui and self.ui.playersDetailView and playerEntry) then
        return
    end

    local detailFilters = self.db and self.db.ui and self.db.ui.playerFilters or nil
    local analyticsEntry = self:BuildFilteredPlayerAggregate(playerEntry, detailFilters)
    self:SetPortraitWidget(
        self.ui.playerDetailPortrait,
        playerEntry.fullName,
        playerEntry.classFilename,
        playerEntry.specIconID,
        playerEntry.specID,
        {preferClassIcon = true, classIconStyle = "mmt_transparent"}
    )
    self:SetHeroRatingBadgeValue(self.ui.playerDetailRatingBadge, playerEntry.averageRating, playerEntry.ratingCount)
    local characterName, realmName = GetPlayerNameParts(playerEntry.fullName)
    self.ui.playerDetailName:SetText(characterName ~= "" and characterName or (playerEntry.shortName or playerEntry.fullName or "Unknown player"))
    if self.ui.playerDetailRealm then
        self.ui.playerDetailRealm:SetText(realmName ~= "" and realmName or "Unknown realm")
    end
    if self.ui.playerDetailMeta then
        self.ui.playerDetailMeta:SetText("")
        self.ui.playerDetailMeta:Hide()
    end

    local raiderIOUrl = BuildRaiderIOCharacterURL(playerEntry.fullName)
    if self.ui.playerDetailRaiderIOButton then
        self.ui.playerDetailRaiderIOButton.playerName = playerEntry.fullName
        if raiderIOUrl then
            self.ui.playerDetailRaiderIOButton:Enable()
        else
            self.ui.playerDetailRaiderIOButton:Disable()
        end
        self.ui.playerDetailRaiderIOButton.Text:SetText(raiderIOUrl and "Copy Raider.IO" or "No Raider.IO")
        SetTextColor(self.ui.playerDetailRaiderIOButton.Text, raiderIOUrl and COLORS.text or COLORS.subdued)
    end

    if self.ui.playerDetailMetricCards then
        self:SetMetricCardValue(self.ui.playerDetailMetricCards.runs, tostring(analyticsEntry.totalRuns or 0), "Total runs")
        self:SetMetricCardValue(self.ui.playerDetailMetricCards.timed, tostring(analyticsEntry.timedRuns or 0), ("%.1f%% success"):format(analyticsEntry.successRate or 0), COLORS.success)
        self:SetMetricCardValue(self.ui.playerDetailMetricCards.bestKey, FormatKeyLevel(analyticsEntry.bestTimedLevel), "Best timed")
        self:SetMetricCardValue(self.ui.playerDetailMetricCards.avgKey, string.format("%.1f", analyticsEntry.averageLevel or 0), "Average key")
        self:SetMetricCardValue(self.ui.playerDetailMetricCards.maxDps, self:FormatAmount(analyticsEntry.maxDps or 0), "Maximum DPS", COLORS.accentSoft)
        self:SetMetricCardValue(self.ui.playerDetailMetricCards.avgDps, self:FormatAmount(analyticsEntry.averageDps or 0), "Average DPS")
        self:SetMetricCardValue(self.ui.playerDetailMetricCards.maxHps, self:FormatAmount(analyticsEntry.maxHps or 0), "Maximum HPS", COLORS.accentSoft)
        self:SetMetricCardValue(self.ui.playerDetailMetricCards.avgHps, self:FormatAmount(analyticsEntry.averageHps or 0), "Average HPS")
    end

    local allRecentRuns = self:GetPlayerRecentRuns(playerEntry.fullName, nil, detailFilters)
    if self.ui.playerDetailHeroNote then
        self.ui.playerDetailHeroNote:SetText("")
        self.ui.playerDetailHeroNote:Hide()
    end

    local specEntries = self:GetSortedBreakdownEntries(playerEntry.specBreakdown)
    local roleShortLabel = {
        DAMAGER = "DPS",
        HEALER = "Heal",
        TANK = "Tank",
    }
    local visibleChipCount = 0
    for index, chip in ipairs(self.ui.playerDetailSpecChips or {}) do
        local entry = specEntries[index]
        if entry then
            visibleChipCount = visibleChipCount + 1
            chip:Show()
            chip:ClearAllPoints()
            chip:SetPoint("RIGHT", self.ui.playerDetailSpecChipsContainer, "RIGHT", -((visibleChipCount - 1) * 92), 0)
            chip.Icon:SetTexture(self:ResolveDisplayIcon(entry.specIconID, entry.specID, playerEntry.classFilename))
            chip.Icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
            chip.Count:SetText(("x%d"):format(entry.totalRuns or 0))
            chip.Role:SetText(roleShortLabel[entry.role] or "")
            chip.tooltipText = ("%s  |  %d runs"):format(entry.specName or entry.label or "Unknown spec", entry.totalRuns or 0)
            chip:SetScript("OnEnter", function(self)
                GameTooltip:SetOwner(self, "ANCHOR_TOP")
                GameTooltip:AddLine(self.tooltipText or "Unknown spec", COLORS.text[1], COLORS.text[2], COLORS.text[3])
                GameTooltip:Show()
            end)
            chip:SetScript("OnLeave", function()
                GameTooltip:Hide()
            end)
        else
            chip:Hide()
        end
    end
    if self.ui.playerDetailSpecChipsContainer then
        local reserveWidth = visibleChipCount > 0 and (88 + ((visibleChipCount - 1) * 92)) or 0
        self.ui.playerDetailSpecChipsContainer:SetWidth(reserveWidth)
        self.ui.playerDetailSpecChipsContainer:SetShown(visibleChipCount > 0)
    end
    local noteText = self:GetPlayerNote(playerEntry.fullName)
    if self.ui.playerDetailNoteText then
        self.ui.playerDetailNoteText:SetText(noteText ~= "" and noteText or "No note yet.")
        SetTextColor(self.ui.playerDetailNoteText, noteText ~= "" and COLORS.text or COLORS.subdued)
    end
    if self.ui.playerDetailNoteEdit then
        self.ui.playerDetailNoteEdit.playerName = playerEntry.fullName
        self.ui.playerDetailNoteEdit.Text:SetText(noteText ~= "" and "Edit note" or "Add note")
    end
    if self.ui.playerDetailNoteStatus then
        self.ui.playerDetailNoteStatus:SetText("")
        self.ui.playerDetailNoteStatus:Hide()
    end

    if self.ui.playerDetailHistoryCount then
        self.ui.playerDetailHistoryCount:SetText(("%d run%s total"):format(#allRecentRuns, #allRecentRuns == 1 and "" or "s"))
    end
    if self.ui.playerDetailEmptyHistory then
        self.ui.playerDetailEmptyHistory:SetShown(#allRecentRuns == 0)
    end
    if self.ui.playerDetailRunsTable then
        self.ui.playerDetailRunsTable:SetData(self:BuildDetailRunsTableData(playerEntry.fullName, allRecentRuns))
        self.ui.playerDetailRunsTable:SortData()
        if self.ui.playerDetailRunsTable.frame then
            self.ui.playerDetailRunsTable.frame:SetShown(#allRecentRuns > 0)
        end
    end

    if self.runtime and self.runtime.pendingNoteFocusPlayer == playerEntry.fullName then
        self.runtime.pendingNoteFocusPlayer = nil
        C_Timer.After(0, function()
            MythicTools:FocusSelectedPlayerNoteEditor()
        end)
    end
end

function MythicTools:GetDiagnosticsText()
    local interfaceVersion = "unknown"
    if GetBuildInfo then
        interfaceVersion = select(4, GetBuildInfo()) or "unknown"
    end
    local damageMeterAvailable = C_DamageMeter and (not C_DamageMeter.IsDamageMeterAvailable or C_DamageMeter.IsDamageMeterAvailable())
    local latestReport = self:GetLatestDebugReport()
    local latestReportText = latestReport and ("%s at %s"):format(tostring(latestReport.kind or "event"), self:FormatDateTime(latestReport.timestamp)) or "none"
    local lastIncomplete = "none"

    for _, run in ipairs(self.db and self.db.runs or {}) do
        if run.statsUnavailable or (run.statsNote and run.statsNote ~= "") then
            if lastIncomplete == "none" or (run.endTime or 0) > (lastIncomplete.endTime or 0) then
                lastIncomplete = run
            end
        end
    end
    if type(lastIncomplete) == "table" then
        lastIncomplete = ("%s +%d at %s"):format(lastIncomplete.dungeonName or "Unknown", tonumber(lastIncomplete.level) or 0, self:FormatDateTime(lastIncomplete.endTime))
    end

    return ("Diagnostics\nVersion: %s\nInterface: %s\nDamage Meter: %s\nLatest debug: %s\nLast incomplete stats: %s"):format(
        tostring(self.version or "unknown"),
        tostring(interfaceVersion),
        damageMeterAvailable and "available" or "unavailable",
        latestReportText,
        lastIncomplete
    )
end

function MythicTools:RefreshSettingsView()
    if not (self.ui
        and self.ui.settingsAlerts
        and self.ui.settingsLocale
        and self.ui.settingsMinimap
        and self.ui.settingsPopupEnabled
        and self.ui.settingsLootHistory
        and self.ui.settingsTooltipEnabled
        and self.ui.settingsTooltipHeader
        and self.ui.settingsTooltipRuns
        and self.ui.settingsTooltipRating
        and self.ui.settingsTooltipNotes
        and self.ui.settingsTooltipBadge
        and self.ui.settingsTooltipUnitMenu
        and self.ui.settingsPopupMode
        and self.ui.settingsPopupCompactAutoHide
        and self.ui.settingsPopupCompactDuration
        and self.ui.settingsPopupTrigger
        and self.ui.settingsMaxRuns
        and self.ui.settingsPopupDelay
        and self.ui.settingsPopupScale) then
        return
    end

    self.ui.settingsAlerts.Check:SetChecked(self.db.settings.historyAlertChat ~= false)
    if self.ui.settingsFloatingAlerts then
        self.ui.settingsFloatingAlerts.Check:SetChecked(self.db.settings.historyAlertFloating and true or false)
    end
    if self.ui.settingsSoundAlerts then
        self.ui.settingsSoundAlerts.Check:SetChecked(self.db.settings.historyAlertSound and true or false)
    end
    local popupMode = self:GetCompletionPopupMode()
    self.db.settings.completionPopupMode = popupMode
    self.db.settings.showCompletionPopup = popupMode ~= "off"
    self.ui.settingsLocale:SetLabel(self:L("SETTINGS_LANGUAGE"))
    UpdateAceDropdownOptions(self.ui.settingsLocale, self:GetLocaleOptions(), self:GetCurrentLocalePreference())
    self.ui.settingsMinimap.Check:SetChecked(self.db.settings.showMinimapButton)
    self.ui.settingsPopupEnabled.Check:SetChecked(popupMode ~= "off")
    self.ui.settingsPopupMode:SetValue(popupMode)
    self.ui.settingsPopupCompactAutoHide.Check:SetChecked(self:GetCompletionToastAutoHide())
    self.ui.settingsLootHistory.Check:SetChecked(self.db.settings.showLootHistory ~= false)
    self.ui.settingsTooltipEnabled.Check:SetChecked(self.db.settings.showPlayerHistoryTooltips ~= false)
    self.ui.settingsTooltipHeader.Check:SetChecked(self.db.settings.tooltipShowHeader ~= false)
    self.ui.settingsTooltipRuns.Check:SetChecked(self.db.settings.tooltipShowRuns ~= false)
    self.ui.settingsTooltipRating.Check:SetChecked(self.db.settings.tooltipShowRating ~= false)
    self.ui.settingsTooltipNotes.Check:SetChecked(self.db.settings.tooltipShowNotes ~= false)
    self.ui.settingsTooltipBadge.Check:SetChecked(self.db.settings.showLFGApplicantHistoryBadge ~= false)
    self.ui.settingsTooltipUnitMenu.Check:SetChecked(self.db.settings.showUnitContextMenuEntry ~= false)
    if self.ui.settingsAutoSave then
        self.ui.settingsAutoSave.Check:SetChecked(self.db.settings.autoSaveAfterRun and true or false)
    end
    self.ui.settingsPopupTrigger:SetValue(self.db.settings.completionPopupTrigger or "LOOT_OPENED")
    if self.ui.settingsAlertSound then
        UpdateAceDropdownOptions(
            self.ui.settingsAlertSound,
            self:GetAlertSoundOptions(),
            self.db.settings.historyAlertSoundKey or ((self.GetDefaultAlertSoundKey and self:GetDefaultAlertSoundKey()) or "Interface\\AddOns\\SkyMythicHistory\\Media\\Sounds\\OhNo.ogg")
        )
    end
    if self.ui.settingsAlertFont then
        UpdateAceDropdownOptions(self.ui.settingsAlertFont, self:GetAlertFontOptions(), self.db.settings.historyAlertFont or "default")
    end
    if self.ui.settingsAlertOutline then
        UpdateAceDropdownOptions(self.ui.settingsAlertOutline, self:GetAlertOutlineOptions(), self.db.settings.historyAlertOutline or "OUTLINE")
    end

    if not self.ui.settingsMaxRuns.Input:HasFocus() then
        self.ui.settingsMaxRuns.Input:SetText(tostring(self.db.settings.maxRuns or 500))
    end
    if not self.ui.settingsPopupDelay.Input:HasFocus() then
        self.ui.settingsPopupDelay.Input:SetText(tostring(self.db.settings.completionPopupDelay or 0))
    end
    if not self.ui.settingsPopupScale.Input:HasFocus() then
        self.ui.settingsPopupScale.Input:SetText(string.format("%.1f", self:GetCompletionPopupScale()))
    end
    if not self.ui.settingsPopupCompactDuration.Input:HasFocus() then
        self.ui.settingsPopupCompactDuration.Input:SetText(tostring(self:GetCompletionToastDuration()))
    end
    if self.ui.settingsAlertFontSize and not self.ui.settingsAlertFontSize.Input:HasFocus() then
        self.ui.settingsAlertFontSize.Input:SetText(tostring(self.db.settings.historyAlertFontSize or 22))
    end
    if self.ui.settingsFloatingAlertTemplate and not self.ui.settingsFloatingAlertTemplate.Input:HasFocus() then
        self.ui.settingsFloatingAlertTemplate.Input:SetText(self:GetHistoryAlertTemplate("floating"))
    end
    if self.ui.settingsChatAlertTemplate and not self.ui.settingsChatAlertTemplate.Input:HasFocus() then
        self.ui.settingsChatAlertTemplate.Input:SetText(self:GetHistoryAlertTemplate("chat"))
    end
    if self.ui.settingsAlertXSlider or self.ui.settingsAlertYSlider or self.ui.settingsAlertX or self.ui.settingsAlertY then
        local alertX, alertY = self:GetHistoryAlertCoordinates()
        if self.ui.settingsAlertXSlider then
            self.ui.settingsAlertXSlider:SetValue(alertX)
        end
        if self.ui.settingsAlertYSlider then
            self.ui.settingsAlertYSlider:SetValue(alertY)
        end
        if self.ui.settingsAlertX and not self.ui.settingsAlertX.Input:HasFocus() then
            self.ui.settingsAlertX.Input:SetText(tostring(floor(alertX + 0.5)))
        end
        if self.ui.settingsAlertY and not self.ui.settingsAlertY.Input:HasFocus() then
            self.ui.settingsAlertY.Input:SetText(tostring(floor(alertY + 0.5)))
        end
    end
    if self.ui.settingsAlertMoveLock then
        local locked = self.db.settings.historyAlertLocked ~= false
        self.ui.settingsAlertMoveLock.Text:SetText(locked and "Unlock" or "Lock")
        self.ui.settingsAlertMoveLock._selected = not locked
        if self.ui.settingsAlertMoveLock._restyle then
            self.ui.settingsAlertMoveLock:_restyle()
        end
    end

    self:ApplyCompletionPopupScale()
    if self.RefreshHistoryFloatingAlertStyle then
        self:RefreshHistoryFloatingAlertStyle()
    end
    if self.RefreshHistoryAlertMoveState then
        self:RefreshHistoryAlertMoveState()
    end

    local activeRunText = self:L("NO_ACTIVE_RUN")
    if self.runtime and self.runtime.activeRun then
        activeRunText = self:L("ACTIVE_RUN", self.runtime.activeRun.dungeonName or "Unknown", self.runtime.activeRun.keyLevel or 0)
    end

    local popupState = self:L("SETTINGS_POPUP_STATE",
        popupMode == "off" and self:L("SETTINGS_DISABLED") or GetPopupModeLabel(popupMode),
        GetPopupTriggerLabel(self.db.settings.completionPopupTrigger or "LOOT_OPENED"),
        tostring(self.db.settings.completionPopupDelay or 0),
        self:GetCompletionPopupScale()
    )
    self.ui.settingsSummary:SetText(self:L("SETTINGS_SUMMARY_TEXT",
        #self.db.runs,
        self:GetPlayerCount(),
        activeRunText,
        popupState
    ) .. "\n\n" .. self:GetDiagnosticsText())
end
local function FormatTotalTime(ms)
    if not ms or ms <= 0 then return "--" end
    local s = floor(ms / 1000)
    local d = floor(s / 86400)
    local h = floor((s % 86400) / 3600)
    local m = floor((s % 3600) / 60)
    if d > 0 then return ("%dd %dh"):format(d, h)
    elseif h > 0 then return ("%dh %dm"):format(h, m)
    else return ("%dm"):format(m) end
end

local function CreateStatsTitleBar(parent, labelText)
    local title = CreateFont(parent, 13, COLORS.text)
    title:SetPoint("TOPLEFT", 16, -12)
    title:SetText(labelText)
    local line = parent:CreateTexture(nil, "ARTWORK")
    line:SetTexture(WHITE_TEXTURE)
    line:SetPoint("BOTTOMLEFT", title, "BOTTOMLEFT", 0, -4)
    line:SetPoint("BOTTOMRIGHT", title, "BOTTOMRIGHT", 0, -4)
    line:SetHeight(1)
    line:SetVertexColor(COLORS.accent[1], COLORS.accent[2], COLORS.accent[3], 0.4)
    return title
end

local function CreateStatsCard(parent, y, w, h)
    local card = CreateFrame("Frame", nil, parent)
    card:SetPoint("TOPLEFT", STATS_PAD, y)
    card:SetSize(w, h)
    MythicTools:ApplySurface(card, COLORS.sectionBG, COLORS.surface, COLORS.accentSoft)
    ClipChildren(card)
    return card
end

local function UpdateDungeonRowBar(row, d, maxCount)
    local barW = row.barW or 200
    local fill = maxCount > 0 and (barW * d.count / maxCount) or 0
    local tw = d.count > 0 and (fill * d.timed / d.count) or 0
    local ow = d.count > 0 and (fill * d.overtime / d.count) or 0
    local aw = d.count > 0 and (fill * d.abandoned / d.count) or 0
    row.BarTimed:SetWidth(max(0, tw))
    row.BarOT:ClearAllPoints()
    row.BarOT:SetPoint("TOPLEFT", row.BarTimed, "TOPRIGHT", 0, 0)
    row.BarOT:SetWidth(max(0, ow))
    row.BarAb:ClearAllPoints()
    row.BarAb:SetPoint("TOPLEFT", row.BarOT, "TOPRIGHT", 0, 0)
    row.BarAb:SetWidth(max(0, aw))
end

local function CreateStatsDungeonRow(parent, index, rowW)
    local row = CreateFrame("Frame", nil, parent)
    local rowY = -((index - 1) * STATS_DUNGEON_ROW_H + STATS_SECTION_TITLE_H)
    row:SetPoint("TOPLEFT", 0, rowY)
    row:SetSize(rowW, STATS_DUNGEON_ROW_H)
    row:Hide()
    if index % 2 == 0 then
        local bg = row:CreateTexture(nil, "BACKGROUND")
        bg:SetAllPoints()
        bg:SetTexture(WHITE_TEXTURE)
        bg:SetVertexColor(0.07, 0.07, 0.07, 1)
    end
    row.Name = CreateFont(row, 11, COLORS.text)
    row.Name:SetPoint("LEFT", 16, 0)
    row.Name:SetWidth(196)
    row.Count = CreateFont(row, 11, COLORS.accent, "RIGHT")
    row.Count:SetPoint("RIGHT", -16, 0)
    row.Count:SetWidth(40)
    row.Status = CreateFont(row, 9, COLORS.subdued, "RIGHT")
    row.Status:SetPoint("RIGHT", row.Count, "LEFT", -6, 0)
    row.Status:SetWidth(140)
    -- barW: rowW minus fixed left/right elements
    -- LEFT(16) + nameW(196) + gap(10) + [bar] + gap(10) + statusW(140) + gap(6) + countW(40) + RIGHT(16)
    local barW = max(10, rowW - 434)
    row.barW = barW
    local barHolder = CreateFrame("Frame", nil, row)
    barHolder:SetSize(barW, STATS_BAR_H)
    barHolder:SetPoint("LEFT", row.Name, "RIGHT", 10, 0)
    local barBg = barHolder:CreateTexture(nil, "BACKGROUND")
    barBg:SetAllPoints()
    barBg:SetTexture(WHITE_TEXTURE)
    barBg:SetVertexColor(0.07, 0.07, 0.07, 1)
    row.BarTimed = barHolder:CreateTexture(nil, "ARTWORK")
    row.BarTimed:SetTexture(WHITE_TEXTURE)
    row.BarTimed:SetVertexColor(COLORS.success[1], COLORS.success[2], COLORS.success[3], 0.85)
    row.BarTimed:SetPoint("TOPLEFT")
    row.BarTimed:SetHeight(STATS_BAR_H)
    row.BarTimed:SetWidth(0)
    row.BarOT = barHolder:CreateTexture(nil, "ARTWORK")
    row.BarOT:SetTexture(WHITE_TEXTURE)
    row.BarOT:SetVertexColor(COLORS.warning[1], COLORS.warning[2], COLORS.warning[3], 0.85)
    row.BarOT:SetPoint("TOPLEFT", row.BarTimed, "TOPRIGHT", 0, 0)
    row.BarOT:SetHeight(STATS_BAR_H)
    row.BarOT:SetWidth(0)
    row.BarAb = barHolder:CreateTexture(nil, "ARTWORK")
    row.BarAb:SetTexture(WHITE_TEXTURE)
    row.BarAb:SetVertexColor(COLORS.danger[1], COLORS.danger[2], COLORS.danger[3], 0.85)
    row.BarAb:SetPoint("TOPLEFT", row.BarOT, "TOPRIGHT", 0, 0)
    row.BarAb:SetHeight(STATS_BAR_H)
    row.BarAb:SetWidth(0)
    row.BarHolder = barHolder
    return row
end

local function CreateCharacterOverviewRow(parent)
    local row = CreateFrame("Button", nil, parent)
    row:SetHeight(CHARACTER_ROW_HEIGHT - 4)
    row:RegisterForClicks("LeftButtonUp")
    MythicTools:ApplySurface(row, COLORS.rowBG, COLORS.surface, nil)

    row.Portrait = CreatePortraitWidget(row, 58, {noBorder = true})
    row.Portrait:SetPoint("LEFT", 14, 0)

    row.Name = CreateFont(row, 14, COLORS.text)
    row.Name:SetPoint("LEFT", row.Portrait, "RIGHT", 12, 14)
    row.Name:SetPoint("RIGHT", row, "RIGHT", -220, 0)

    row.Meta = CreateFont(row, 11, COLORS.subdued)
    row.Meta:SetPoint("TOPLEFT", row.Name, "BOTTOMLEFT", 0, -4)
    row.Meta:SetPoint("RIGHT", row, "RIGHT", -220, 0)

    row.Detail = CreateFont(row, 10, COLORS.muted)
    row.Detail:SetPoint("TOPLEFT", row.Meta, "BOTTOMLEFT", 0, -4)
    row.Detail:SetPoint("RIGHT", row, "RIGHT", -220, 0)

    row.Best = CreateFont(row, 18, COLORS.accent, "RIGHT")
    row.Best:SetPoint("RIGHT", row, "RIGHT", -18, 10)
    row.Best:SetWidth(90)

    row.Timed = CreateFont(row, 11, COLORS.subdued, "RIGHT")
    row.Timed:SetPoint("TOPRIGHT", row.Best, "BOTTOMRIGHT", 0, -4)
    row.Timed:SetWidth(160)

    row:SetHighlightTexture(WHITE_TEXTURE)
    row:GetHighlightTexture():SetVertexColor(1, 1, 1, 0.04)
    return row
end

function MythicTools:StoreCompletionPopupPoint()
    if not (self.completionPopup and self.db and self.db.ui) then
        return
    end

    local point, _, relativePoint, xOfs, yOfs = self.completionPopup:GetPoint(1)
    self.db.ui.completionPopupPoint = {point or "CENTER", relativePoint or point or "CENTER", xOfs or 0, yOfs or 0}
end

function MythicTools:StoreCompletionToastPoint()
    if not (self.completionToast and self.db and self.db.ui) then
        return
    end

    local point, _, relativePoint, xOfs, yOfs = self.completionToast:GetPoint(1)
    self.db.ui.completionToastPoint = {point or "TOP", relativePoint or point or "TOP", xOfs or 0, yOfs or -210}
end

local function RefreshCompletionSortHeaders(frame)
    if not frame or type(frame.SortHeaders) ~= "table" then
        return
    end

    local sortState = MythicTools.db and MythicTools.db.ui and MythicTools.db.ui.completionPopupSort or nil
    local selectedColumn = sortState and sortState.column or "damage"
    for _, header in ipairs(frame.SortHeaders) do
        local selected = header.columnKey == selectedColumn
        header.Text:SetText(header.label)
        if header.Arrow then
            header.Arrow:SetShown(selected)
            if selected then
                header.Arrow:ClearAllPoints()
                local textWidth = header.Text.GetStringWidth and header.Text:GetStringWidth() or ((#(header.label or "")) * 6)
                local arrowX = min((header:GetWidth() or 0) - 9, textWidth + 4)
                header.Arrow:SetPoint("LEFT", header, "LEFT", max(0, arrowX), 0)
            end
        end
        SetTextColor(header.Text, selected and COLORS.text or COLORS.subdued)
    end
end

local function CreateCompletionSortHeader(parent, label, columnKey, x, width)
    local button = CreateFrame("Button", nil, parent)
    button:SetSize(width, 18)
    button:SetPoint("TOPLEFT", x, -9)
    button:RegisterForClicks("LeftButtonUp")
    button:SetFrameLevel((parent:GetFrameLevel() or 1) + 6)
    button:SetHighlightTexture(WHITE_TEXTURE)
    button.columnKey = columnKey
    button.label = label

    local highlight = button:GetHighlightTexture()
    highlight:SetAllPoints()
    highlight:SetVertexColor(1, 1, 1, 0.08)

    button.Text = CreateFont(button, 11, COLORS.subdued)
    button.Text:SetAllPoints()
    button.Text:SetJustifyH("LEFT")
    button.Text:SetText(label)

    button.Arrow = button:CreateTexture(nil, "OVERLAY")
    button.Arrow:SetSize(10, 10)
    button.Arrow:SetTexture(SORT_ARROW_ICON)
    button.Arrow:SetTexCoord(0, 1, 0, 1)
    button.Arrow:SetBlendMode("BLEND")
    button.Arrow:Hide()

    button:SetScript("OnClick", function(self)
        MythicTools.db.ui.completionPopupSort = {
            column = self.columnKey,
            direction = "desc",
        }
        RefreshCompletionSortHeaders(MythicTools.completionPopup)
        MythicTools:RefreshCompletionPopup()
    end)

    return button
end

local function SetCompletionPlayerText(row, playerName, itemLevel)
    local itemLevelText = FormatItemLevelText(itemLevel)
    row.Name:ClearAllPoints()
    row.ItemLevel:ClearAllPoints()

    if itemLevelText ~= "" then
        row.Name:SetPoint("TOPLEFT", row.PlayerText, "TOPLEFT", 0, -6)
        row.ItemLevel:SetPoint("TOPLEFT", row.Name, "BOTTOMLEFT", 0, -2)
        row.ItemLevel:SetText(("ilvl %s"):format(itemLevelText))
        SetTextColor(row.ItemLevel, COLORS.subdued)
        row.ItemLevel:Show()
    else
        row.Name:SetPoint("CENTER", row.PlayerText, "CENTER", 0, 0)
        row.ItemLevel:SetText("")
        row.ItemLevel:Hide()
    end

    row.Name:SetText(playerName or "")
end

function MythicTools:BuildCompletionPopup()
    if self.completionPopup then
        return self.completionPopup
    end

    local frame = CreateFrame("Frame", "MythicToolsCompletionPopup", UIParent)
    frame:SetSize(1290, COMPLETION_POPUP_HEIGHT)
    frame:SetFrameStrata("FULLSCREEN_DIALOG")
    frame:SetFrameLevel(400)
    frame:SetClampedToScreen(true)
    frame:EnableMouse(true)
    frame:SetToplevel(true)
    frame:Hide()
    self:ApplySurface(frame, COLORS.frameBG, COLORS.frameBorder, COLORS.accent)

    local point = self.db.ui.completionPopupPoint or {"CENTER", "CENTER", 0, 40}
    frame:SetPoint(point[1] or "CENTER", UIParent, point[2] or point[1] or "CENTER", point[3] or 0, point[4] or 40)
    table.insert(UISpecialFrames, frame:GetName())

    local header = CreateFrame("Frame", nil, frame, "BackdropTemplate")
    header:SetPoint("TOPLEFT", 1, -1)
    header:SetPoint("TOPRIGHT", -1, -1)
    header:SetHeight(116)
    header:SetBackdrop({bgFile = WHITE_TEXTURE, edgeFile = WHITE_TEXTURE, edgeSize = 1, insets = {left=1, right=1, top=1, bottom=1}})
    header:SetBackdropColor(COLORS.headerBG[1], COLORS.headerBG[2], COLORS.headerBG[3], 1)
    header:SetBackdropBorderColor(CONTROL_COLORS.accentBorder40[1], CONTROL_COLORS.accentBorder40[2], CONTROL_COLORS.accentBorder40[3], CONTROL_COLORS.accentBorder40[4])

    header.Background = header:CreateTexture(nil, "BACKGROUND", nil, 1)
    header.Background:SetAllPoints()
    header.Background:SetAlpha(0.18)
    self.completionPopupHeroBackground = header.Background

    -- Linha accent na base do header do popup
    local popupHeaderLine = header:CreateTexture(nil, "OVERLAY")
    popupHeaderLine:SetTexture(WHITE_TEXTURE)
    popupHeaderLine:SetPoint("BOTTOMLEFT", 1, 0)
    popupHeaderLine:SetPoint("BOTTOMRIGHT", -1, 0)
    popupHeaderLine:SetHeight(1)
    popupHeaderLine:SetVertexColor(COLORS.accent[1], COLORS.accent[2], COLORS.accent[3], 0.8)

    local iconFrame = CreateFrame("Frame", nil, header)
    iconFrame:SetSize(76, 76)
    iconFrame:SetPoint("TOPLEFT", 18, -18)
    self:ApplySurface(iconFrame, COLORS.frameBG, COLORS.surface, nil)

    iconFrame.Icon = iconFrame:CreateTexture(nil, "ARTWORK")
    iconFrame.Icon:SetPoint("TOPLEFT", 1, -1)
    iconFrame.Icon:SetPoint("BOTTOMRIGHT", -1, 1)
    self.completionPopupHeroIcon = iconFrame.Icon

    frame.Title = CreateFont(header, 22, COLORS.text)
    frame.Title:SetPoint("TOPLEFT", iconFrame, "TOPRIGHT", 16, -2)
    frame.Title:SetPoint("RIGHT", header, "RIGHT", -52, 0)

    frame.Meta = CreateFont(header, 12, COLORS.muted)
    frame.Meta:SetPoint("TOPLEFT", frame.Title, "BOTTOMLEFT", 0, -8)
    frame.Meta:SetPoint("RIGHT", header, "RIGHT", -18, 0)
    frame.Meta:SetJustifyH("LEFT")

    frame.Submeta = CreateFont(header, 11, COLORS.text)
    frame.Submeta:SetPoint("TOPLEFT", frame.Meta, "BOTTOMLEFT", 0, -4)
    frame.Submeta:SetPoint("RIGHT", header, "RIGHT", -18, 0)
    frame.Submeta:SetJustifyH("LEFT")

    frame.Note = CreateFont(header, 10, COLORS.subdued)
    frame.Note:SetPoint("TOPLEFT", frame.Submeta, "BOTTOMLEFT", 0, -5)
    frame.Note:SetPoint("RIGHT", header, "RIGHT", -18, 0)
    frame.Note:SetJustifyH("LEFT")

    frame.Status = CreateFont(header, 14, COLORS.accent, "RIGHT")

    -- Botão fechar estilo Cell no popup
    local closeButton = CreateFrame("Button", nil, header, "BackdropTemplate")
    closeButton:SetSize(20, 20)
    closeButton:SetPoint("TOPRIGHT", -8, -8)
    closeButton:SetBackdrop({bgFile = WHITE_TEXTURE, edgeFile = WHITE_TEXTURE, edgeSize = 1, insets = {left=1, right=1, top=1, bottom=1}})
    closeButton:SetBackdropColor(0.6, 0.1, 0.1, 0.6)
    closeButton:SetBackdropBorderColor(CONTROL_COLORS.controlBorder[1], CONTROL_COLORS.controlBorder[2], CONTROL_COLORS.controlBorder[3], CONTROL_COLORS.controlBorder[4])
    closeButton:SetScript("OnEnter", function() closeButton:SetBackdropColor(0.6, 0.1, 0.1, 1) end)
    closeButton:SetScript("OnLeave", function() closeButton:SetBackdropColor(0.6, 0.1, 0.1, 0.6) end)
    closeButton:SetScript("OnClick", function()
        MythicTools:HideCompletionPopup()
    end)
    MythicTools:ApplyCloseButtonIcon(closeButton, 10)

    local minimizeButton = CreateFrame("Button", nil, header, "BackdropTemplate")
    minimizeButton:SetSize(20, 20)
    minimizeButton:SetPoint("TOPRIGHT", closeButton, "TOPLEFT", -6, 0)
    minimizeButton:SetBackdrop({bgFile = WHITE_TEXTURE, edgeFile = WHITE_TEXTURE, edgeSize = 1, insets = {left=1, right=1, top=1, bottom=1}})
    minimizeButton:SetBackdropColor(0.16, 0.16, 0.16, 0.82)
    minimizeButton:SetBackdropBorderColor(CONTROL_COLORS.controlBorder[1], CONTROL_COLORS.controlBorder[2], CONTROL_COLORS.controlBorder[3], CONTROL_COLORS.controlBorder[4])
    minimizeButton:SetScript("OnEnter", function() minimizeButton:SetBackdropColor(0.24, 0.24, 0.24, 1) end)
    minimizeButton:SetScript("OnLeave", function() minimizeButton:SetBackdropColor(0.16, 0.16, 0.16, 0.82) end)
    minimizeButton:SetScript("OnClick", function()
        local payload = frame.previewRun or frame.runId
        frame.runId = nil
        frame.previewRun = nil
        frame:Hide()
        if payload then
            MythicTools:ShowCompletionToast(payload)
        end
    end)
    MythicTools:ApplyControlButtonIcon(minimizeButton, "Interface\\AddOns\\SkyMythicHistory\\Media\\Textures\\Minus.tga", 10)

    frame.Status:SetPoint("TOPRIGHT", minimizeButton, "TOPLEFT", -16, 0)

    frame:SetMovable(true)
    header:EnableMouse(true)
    header:RegisterForDrag("LeftButton")
    header:SetScript("OnDragStart", function()
        frame:StartMoving()
    end)
    header:SetScript("OnDragStop", function()
        frame:StopMovingOrSizing()
        self:StoreCompletionPopupPoint()
    end)

    local statsPanel = CreateFrame("Frame", nil, frame)
    statsPanel:SetPoint("TOPLEFT", header, "BOTTOMLEFT", 14, -14)
    statsPanel:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -14, 14)
    self:ApplySurface(statsPanel, COLORS.panelBG, COLORS.surface, nil)
    ClipChildren(statsPanel)

    local columnName = CreateFont(statsPanel, 11, COLORS.subdued)
    columnName:SetPoint("TOPLEFT", 16, -12)
    columnName:SetText(self:L("UI_PLAYER"))

    local columnScore = CreateFont(statsPanel, 11, COLORS.subdued)
    columnScore:SetPoint("TOPLEFT", COMPACT_STATS_SCORE_X, -12)
    columnScore:SetText(self:L("UI_SCORE"))

    local columnItems = CreateFont(statsPanel, 11, COLORS.subdued)
    columnItems:SetPoint("TOPLEFT", COMPACT_STATS_ITEMS_X, -12)
    columnItems:SetText(self:L("UI_LOOT"))

    frame.SortHeaders = {
        CreateCompletionSortHeader(statsPanel, "Damage", "damage", COMPACT_STATS_DAMAGE_X, 68),
        CreateCompletionSortHeader(statsPanel, "DPS", "dps", COMPACT_STATS_DPS_X, 58),
        CreateCompletionSortHeader(statsPanel, "Healing", "healing", COMPACT_STATS_HEALING_X, 68),
        CreateCompletionSortHeader(statsPanel, "HPS", "hps", COMPACT_STATS_HPS_X, 58),
        CreateCompletionSortHeader(statsPanel, "Interrupts", "interrupts", COMPACT_STATS_INTERRUPTS_X, 88),
        CreateCompletionSortHeader(statsPanel, "Dispels", "dispels", 914, 64),
        CreateCompletionSortHeader(statsPanel, "Avoidable", "avoidableDamage", 982, 96),
        CreateCompletionSortHeader(statsPanel, "Deaths", "deaths", 1092, 54),
        CreateCompletionSortHeader(statsPanel, "Rating", "rating", COMPACT_STATS_RATING_X, 72),
    }
    RefreshCompletionSortHeaders(frame)

    local rowsBody = CreateFrame("Frame", nil, statsPanel)
    rowsBody:SetPoint("TOPLEFT", 12, -34)
    rowsBody:SetPoint("TOPRIGHT", -14, -34)
    rowsBody:SetHeight(COMPLETION_ROWS * COMPLETION_ROW_HEIGHT)
    ClipChildren(rowsBody)
    frame.rowsBody = rowsBody

    frame.rowsScroll = CreateFrame("ScrollFrame", nil, statsPanel, "FauxScrollFrameTemplate")
    frame.rowsScroll:SetPoint("TOPLEFT", 0, -34)
    frame.rowsScroll:SetPoint("BOTTOMRIGHT", -10, 12)
    frame.rowsScroll:SetScript("OnVerticalScroll", function(scrollFrame, offset)
        FauxScrollFrame_OnVerticalScroll(scrollFrame, offset, COMPLETION_ROW_HEIGHT, function()
            MythicTools:RefreshCompletionPopup()
        end)
    end)
    ApplyThinScrollBar(frame.rowsScroll)

    frame.rows = {}
    for index = 1, COMPLETION_ROWS do
        local row = CreateCompletionStatRow(rowsBody)
        row:SetPoint("TOPLEFT", 0, -((index - 1) * COMPLETION_ROW_HEIGHT))
        row:SetPoint("TOPRIGHT", 0, -((index - 1) * COMPLETION_ROW_HEIGHT))
        frame.rows[index] = row
    end

    local deleteButton = CreateActionButton(statsPanel, 92, 24, self:L("UI_DELETE"))
    deleteButton:SetPoint("BOTTOMRIGHT", statsPanel, "BOTTOMRIGHT", -12, 8)
    self:ApplyActionButtonIcon(deleteButton, "Interface\\AddOns\\SkyMythicHistory\\Media\\Icons\\delete.tga", 12)
    deleteButton:SetScript("OnClick", function()
        local runId = frame and frame.runId
        if runId then
            MythicTools:ConfirmDeleteRun(runId)
        end
    end)
    frame.DeleteButton = deleteButton

    self.completionPopup = frame
    self:ApplyCompletionPopupScale()
    return frame
end

function MythicTools:HideCompletionPopup()
    self:HideCompletionToast()
    if self.completionPopup then
        self.completionPopup.runId = nil
        self.completionPopup.previewRun = nil
        self.completionPopup:Hide()
    end
    if self.db and self.db.ui then
        self.db.ui.selectedRunId = nil
    end
    if self.ui then
        self:RefreshRunView()
    end
end

function MythicTools:ShowCompletionPopup(runOrRunId)
    local previewRun = type(runOrRunId) == "table" and runOrRunId or nil
    local runId = previewRun and previewRun.runId or runOrRunId
    if not previewRun and not runId then
        return
    end

    if self.PushRuntimeDebug then
        self:PushRuntimeDebug(("show completion popup run %s preview=%s"):format(
            tostring(runId or (previewRun and previewRun.runId) or "?"),
            tostring(previewRun ~= nil)
        ))
    end

    self:HideCompletionToast()
    local frame = self:BuildCompletionPopup()
    frame.previewRun = previewRun
    frame.runId = previewRun and nil or runId
    self:ApplyCompletionPopupScale()
    self:RefreshCompletionPopup()
    frame:Show()
    frame:Raise()
end
function MythicTools:RefreshCompletionPopup()
    local frame = self.completionPopup
    if not frame then
        return
    end

    local run = frame.previewRun or (frame.runId and self:GetRunById(frame.runId)) or nil
    if not run then
        -- An empty popup frame being refreshed by a global view refresh is
        -- normal (no run assigned yet); only a set runId that fails to
        -- resolve is a real problem worth reporting.
        if frame.runId then
            if self.PushRuntimeDebug then
                self:PushRuntimeDebug(("hide completion popup missing run %s"):format(tostring(frame.runId)))
            end
            if self.StoreDebugReport then
                self:StoreDebugReport("popup_missing_run", {
                    trigger = "RefreshCompletionPopup",
                    runId = frame.runId,
                })
            end
        end
        frame.runId = nil
        frame.previewRun = nil
        frame:Hide()
        return
    end

    if frame.DeleteButton then
        frame.DeleteButton:SetShown(not frame.previewRun and run.runId ~= nil)
    end

    self:SetRunArt(self.completionPopupHeroIcon, self.completionPopupHeroBackground, run)
    frame.Title:SetText(GetCompletionRunTitleText(run))
    frame.Meta:SetText(("%s  •  Time %s  •  Deaths %d"):format(
        self:FormatDateTime(run.endTime),
        self:FormatDurationMS(run.timeMS),
        run.totalDeaths or 0
    ))
    local scoreText = GetRunScoreText(run)
    local submeta = ("%s  •  %s"):format(
        GetRunTimeRemainingText(run),
        GetRunOutOfCombatDisplayText(run)
    )
    if scoreText ~= "" then
        submeta = submeta .. ("  •  %s"):format(scoreText)
    end
    local statsSourceText = GetRunStatsSourceText(run)
    if statsSourceText ~= "" then
        submeta = submeta .. ("  •  %s"):format(statsSourceText)
    end
    frame.Submeta:SetText(submeta)
    frame.Status:SetText("")
    SetTextColor(frame.Status, GetRunStatusColor(run))

    local noteParts = {}
    if run.preview then
        noteParts[#noteParts + 1] = "Preview"
    end
    if run.statsNote and run.statsNote ~= "" then
        local cleanedStatsNote = run.statsNote
        cleanedStatsNote = cleanedStatsNote:gsub("Waitingh? for damage meter sessions%.?", "")
        cleanedStatsNote = cleanedStatsNote:gsub("Waitingh? for damage meter totals%.?", "")
        cleanedStatsNote = cleanedStatsNote:gsub("Waiting for Details overall segment%.?", "")
        cleanedStatsNote = cleanedStatsNote:gsub("%s%s+", " ")
        cleanedStatsNote = self:TrimText(cleanedStatsNote)
        if cleanedStatsNote ~= "" then
            noteParts[#noteParts + 1] = cleanedStatsNote
        end
    end
    if self.runtime and self.runtime.lootTracking and run.runId and self.runtime.lootTracking.runId == run.runId then
        local tracking = self.runtime.lootTracking
        if tracking.lootOpened then
            noteParts[#noteParts + 1] = tracking.lootClosed and "Chest loot tracked" or "Tracking chest loot..."
        else
            noteParts[#noteParts + 1] = "Waiting for chest loot..."
        end
    end
    frame.Note:SetText(table.concat(noteParts, "  |  "))
    SetTextColor(frame.Note, run.statsUnavailable and COLORS.danger or COLORS.subdued)

    RefreshCompletionSortHeaders(frame)
    local sortedStats = self:GetSortedStatsForRun(run)
    local leaders = GetCompletionStatLeaders(self, run, sortedStats)
    if frame.rowsScroll then
        FauxScrollFrame_Update(frame.rowsScroll, #sortedStats, COMPLETION_ROWS, COMPLETION_ROW_HEIGHT)
    end
    local offset = frame.rowsScroll and FauxScrollFrame_GetOffset(frame.rowsScroll) or 0
    for index, row in ipairs(frame.rows) do
        local stat = sortedStats[offset + index]
        if stat then
            row:Show()
            row.runId = run.runId
            row.playerName = stat.name
            row.playerStat = stat
            if row.PlayerButton then
                row.PlayerButton.playerName = stat.name
            end
            if row.NoteButton then
                row.NoteButton.playerName = stat.name
                row.NoteButton.playerStat = stat
                row.NoteButton:Show()
                self:UpdateCompletionNoteButton(row.NoteButton)
            end
            self:SetPortraitWidget(row.Portrait, stat.name, stat.classFilename, stat.specIconID, stat.specID)

            local classColor = stat.classFilename and RAID_CLASS_COLORS and RAID_CLASS_COLORS[stat.classFilename]
            if classColor then
                row.Name:SetTextColor(classColor.r, classColor.g, classColor.b, 1)
            else
                SetTextColor(row.Name, COLORS.text)
            end

            SetCompletionPlayerText(row, stat.shortName or self:GetShortName(stat.name or ""), stat.itemLevel)
            row.Score:SetText(GetPlayerScoreDisplayText(stat))
            self:SetLootButtons(row.LootButtons, row.LootLabel, row.LootOverflow, row.LootEmpty, stat.loot, row.LootTextButtons)
            local dpsValue = self:GetPlayerDPS(run, stat)
            local hpsValue = self:GetPlayerHPS(run, stat)
            local interruptsValue = tonumber(stat.interrupts) or 0
            local dispelsValue = tonumber(stat.dispels) or 0
            local avoidableDamageValue = stat.avoidableDamage ~= nil and (tonumber(stat.avoidableDamage) or 0) or nil
            row.Damage:SetText(self:FormatAmount(stat.damage))
            row.DPS:SetText(self:FormatAmount(dpsValue))
            row.Healing:SetText(self:FormatAmount(stat.healing))
            row.HPS:SetText(self:FormatAmount(hpsValue))
            row.Interrupts:SetText(tostring(interruptsValue))
            row.Dispels:SetText(tostring(dispelsValue))
            row.AvoidableDamage:SetText(self:FormatAmount(avoidableDamageValue))
            row.Deaths:SetText(tostring(stat.deaths or 0))
            if row.Rating then
                row.Rating:Show()
                self:SetRatingControlValue(row.Rating, stat.rating)
            end
            SetTextColor(row.Score, GetPlayerScoreColor(stat))
            SetTextColor(row.DPS, IsCompletionStatLeader(dpsValue, leaders.dps) and COLORS.bestMetric or COLORS.text)
            SetTextColor(row.HPS, IsCompletionStatLeader(hpsValue, leaders.hps) and COLORS.bestMetric or COLORS.text)
            SetTextColor(row.Interrupts, IsCompletionStatLeader(interruptsValue, leaders.interrupts) and COLORS.bestMetric or COLORS.text)
            SetTextColor(row.Dispels, IsCompletionStatLeader(dispelsValue, leaders.dispels) and COLORS.bestMetric or COLORS.text)
            SetTextColor(row.AvoidableDamage, (avoidableDamageValue ~= nil and leaders.avoidableDamage ~= nil and avoidableDamageValue == leaders.avoidableDamage) and COLORS.bestMetric or COLORS.text)
            SetTextColor(row.Deaths, (stat.deaths or 0) > 0 and COLORS.danger or COLORS.text)
            if row.DamageHover then
                row.DamageHover.playerStat = stat
                row.DamageHover:Show()
            end
            if row.DPSHover then
                row.DPSHover.playerStat = stat
                row.DPSHover:Show()
            end
            if row.HealingHover then
                row.HealingHover.playerStat = stat
                row.HealingHover:Show()
            end
            if row.HPSHover then
                row.HPSHover.playerStat = stat
                row.HPSHover:Show()
            end
            if row.InterruptsHover then
                row.InterruptsHover.playerStat = stat
                row.InterruptsHover:Show()
            end
            if row.DispelsHover then
                row.DispelsHover.playerStat = stat
                row.DispelsHover:Show()
            end
            if row.AvoidableHover then
                row.AvoidableHover.playerStat = stat
                row.AvoidableHover:Show()
            end
        else
            row.runId = nil
            row.playerName = nil
            row.playerStat = nil
            if row.PlayerButton then
                row.PlayerButton.playerName = nil
            end
            if row.NoteButton then
                row.NoteButton.playerName = nil
                row.NoteButton.playerStat = nil
                row.NoteButton:Hide()
            end
            SetCompletionPlayerText(row, "", nil)
            row.Score:SetText("")
            row.Damage:SetText("")
            row.DPS:SetText("")
            row.Healing:SetText("")
            row.HPS:SetText("")
            row.Interrupts:SetText("")
            row.Dispels:SetText("")
            row.AvoidableDamage:SetText("")
            row.Deaths:SetText("")
            if row.Rating then
                self:SetRatingControlValue(row.Rating, nil)
                row.Rating:Hide()
            end
            self:ResetLootButtons(row.LootButtons, row.LootLabel, row.LootOverflow, row.LootEmpty, row.LootTextButtons)
            if row.DamageHover then
                row.DamageHover.playerStat = nil
                row.DamageHover:Hide()
            end
            if row.DPSHover then
                row.DPSHover.playerStat = nil
                row.DPSHover:Hide()
            end
            if row.HealingHover then
                row.HealingHover.playerStat = nil
                row.HealingHover:Hide()
            end
            if row.HPSHover then
                row.HPSHover.playerStat = nil
                row.HPSHover:Hide()
            end
            if row.InterruptsHover then
                row.InterruptsHover.playerStat = nil
                row.InterruptsHover:Hide()
            end
            if row.DispelsHover then
                row.DispelsHover.playerStat = nil
                row.DispelsHover:Hide()
            end
            if row.AvoidableHover then
                row.AvoidableHover.playerStat = nil
                row.AvoidableHover:Hide()
            end
            row:Hide()
        end
    end
end

function MythicTools:BuildUI()
    if self.ui then
        return self.ui
    end

    local ui = {}
    self.ui = ui

    local frame = CreateFrame("Frame", "MythicToolsMainFrame", UIParent)
    frame:SetSize(self.db.ui.width, self.db.ui.height)
    frame:SetFrameStrata("DIALOG")
    frame:SetClampedToScreen(true)
    frame:Hide()
    self:ApplySurface(frame, COLORS.frameBG, COLORS.frameBorder, COLORS.accent)

    frame.Background = frame:CreateTexture(nil, "BACKGROUND")
    frame.Background:SetAllPoints()
    frame.Background:SetTexture(DEFAULT_DUNGEON_BG)
    frame.Background:SetAlpha(0.06)

    local point = self.db.ui.point or {"CENTER", "CENTER", 0, 0}
    frame:SetPoint(point[1] or "CENTER", UIParent, point[2] or point[1] or "CENTER", point[3] or 0, point[4] or 0)
    table.insert(UISpecialFrames, frame:GetName())
    ui.frame = frame

    -- Header: estilo Cell — barra escura compacta com título na cor de classe
    local header = CreateFrame("Frame", nil, frame, "BackdropTemplate")
    header:SetPoint("TOPLEFT", 1, -1)
    header:SetPoint("TOPRIGHT", -1, -1)
    header:SetHeight(36)
    header:SetBackdrop({bgFile = WHITE_TEXTURE, edgeFile = WHITE_TEXTURE, edgeSize = 1, insets = {left=1, right=1, top=1, bottom=1}})
    header:SetBackdropColor(COLORS.headerBG[1], COLORS.headerBG[2], COLORS.headerBG[3], 1)
    header:SetBackdropBorderColor(CONTROL_COLORS.accentBorder40[1], CONTROL_COLORS.accentBorder40[2], CONTROL_COLORS.accentBorder40[3], CONTROL_COLORS.accentBorder40[4])

    -- Linha de destaque na cor de classe na base do header
    local headerAccentLine = header:CreateTexture(nil, "OVERLAY")
    headerAccentLine:SetTexture(WHITE_TEXTURE)
    headerAccentLine:SetPoint("BOTTOMLEFT", 1, 0)
    headerAccentLine:SetPoint("BOTTOMRIGHT", -1, 0)
    headerAccentLine:SetHeight(1)
    headerAccentLine:SetVertexColor(COLORS.accent[1], COLORS.accent[2], COLORS.accent[3], 0.8)

    local titleIcon = header:CreateTexture(nil, "OVERLAY", nil, 7)
    titleIcon:SetTexture(ADDON_ICON)
    titleIcon:SetBlendMode("ADD")
    titleIcon:SetTexCoord(0, 1, 0, 1)
    titleIcon:SetSize(16, 16)
    titleIcon:SetPoint("LEFT", 14, 0)

    local title = CreateFont(header, 13, COLORS.accent)
    title:SetPoint("LEFT", titleIcon, "RIGHT", 6, 0)
    title:SetText(self:L("ADDON_TITLE"))

    -- Botão fechar estilo Cell (× vermelho)
    local closeButton = CreateFrame("Button", nil, header, "BackdropTemplate")
    closeButton:SetSize(20, 20)
    closeButton:SetPoint("RIGHT", -6, 0)
    closeButton:SetBackdrop({bgFile = WHITE_TEXTURE, edgeFile = WHITE_TEXTURE, edgeSize = 1, insets = {left=1, right=1, top=1, bottom=1}})
    closeButton:SetBackdropColor(0.6, 0.1, 0.1, 0.6)
    closeButton:SetBackdropBorderColor(CONTROL_COLORS.controlBorder[1], CONTROL_COLORS.controlBorder[2], CONTROL_COLORS.controlBorder[3], CONTROL_COLORS.controlBorder[4])
    closeButton:SetScript("OnEnter", function() closeButton:SetBackdropColor(0.6, 0.1, 0.1, 1) end)
    closeButton:SetScript("OnLeave", function() closeButton:SetBackdropColor(0.6, 0.1, 0.1, 0.6) end)
    closeButton:SetScript("OnClick", function()
        MythicTools:HideCompletionPopup()
        frame:Hide()
    end)
    MythicTools:ApplyCloseButtonIcon(closeButton, 10)

    frame:SetMovable(true)
    header:EnableMouse(true)
    header:RegisterForDrag("LeftButton")
    header:SetScript("OnDragStart", function()
        frame:StartMoving()
    end)
    header:SetScript("OnDragStop", function()
        frame:StopMovingOrSizing()
        self:StoreMainFramePoint()
    end)

    -- Sidebar estilo Cell
    local sidebar = CreateFrame("Frame", nil, frame)
    sidebar:SetPoint("TOPLEFT", header, "BOTTOMLEFT", 0, -8)
    sidebar:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 1, 1)
    sidebar:SetWidth(204)
    self:ApplySurface(sidebar, COLORS.panelBG, COLORS.surface, COLORS.accentSoft)

    -- Separador de navegação estilo Cell
    local sidebarTitle = CreateFont(sidebar, 11, COLORS.accent)
    sidebarTitle:SetPoint("TOPLEFT", 14, -14)
    sidebarTitle:SetText(self:L("NAVIGATION"))
    local sidebarTitleLine = sidebar:CreateTexture(nil, "ARTWORK")
    sidebarTitleLine:SetTexture(WHITE_TEXTURE)
    sidebarTitleLine:SetPoint("TOPLEFT", sidebarTitle, "BOTTOMLEFT", 0, -3)
    sidebarTitleLine:SetWidth(176)
    sidebarTitleLine:SetHeight(1)
    sidebarTitleLine:SetVertexColor(COLORS.accent[1], COLORS.accent[2], COLORS.accent[3], 0.5)

    ui.tabButtons = {}
    local tabs = {
        {name = "Runs", labelKey = "TAB_RUNS"},
        {name = "Players", labelKey = "TAB_PLAYERS"},
        {name = "Characters", labelKey = "TAB_CHARACTERS"},
        {name = "Stats", labelKey = "TAB_STATS"},
        {name = "Settings", labelKey = "TAB_SETTINGS"},
    }
    for index, tabInfo in ipairs(tabs) do
        local tabName = tabInfo.name
        local button = CreateActionButton(sidebar, 176, 30, self:L(tabInfo.labelKey))
        button:SetPoint("TOPLEFT", 14, -34 - ((index - 1) * 38))
        button:SetScript("OnClick", function()
            if tabName == "Players" then
                self.db.ui.playersView = "index"
                self.db.ui.selectedPlayer = nil
            elseif tabName == "Characters" then
                self.db.ui.charactersView = "index"
                self.db.ui.selectedCharacter = nil
            end
            self:ShowTab(tabName)
        end)
        button:SetScript("OnEnter", function(self)
            if self:IsEnabled() and not self._selected then
                MythicTools:ApplySurface(self, CONTROL_COLORS.controlHoverBG, COLORS.accent, COLORS.accent)
                SetTextColor(self.Text, COLORS.bright)
            end
        end)
        button:SetScript("OnLeave", function(self)
            StyleTabButton(self, self._selected)
        end)
        button:SetScript("OnEnable", function(self)
            StyleTabButton(self, self._selected)
        end)
        button:SetScript("OnDisable", function(self)
            StyleTabButton(self, self._selected)
        end)
        ui.tabButtons[tabName] = button
    end

    local footer = CreateFont(sidebar, 10, COLORS.subdued)
    footer:SetPoint("BOTTOMLEFT", 14, 12)
    footer:SetText(("v%s"):format(self.version or "0.3.0"))

    local content = CreateFrame("Frame", nil, frame)
    content:SetPoint("TOPLEFT", sidebar, "TOPRIGHT", 8, 0)
    content:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -1, 1)
    self:ApplySurface(content, COLORS.panelBG, COLORS.surface, nil)
    ClipChildren(content)

    ui.pages = {}
    ui.pages.Runs = self:BuildRunsPage(content)
    ui.pages.Players = self:BuildPlayersPage(content)
    ui.pages.Characters = self:BuildCharactersPage(content)
    ui.pages.Stats = self:BuildStatsPage(content)
    ui.pages.Settings = self:BuildSettingsPage(content)

    for pageName, page in pairs(ui.pages) do
        page:SetShown(pageName == (self.db.ui.activeTab or "Runs"))
    end

    frame:SetScript("OnShow", function()
        self:RefreshUI()
    end)

    self:SyncFilterWidgets()

    if self.UpdateMinimapButton then
        self:UpdateMinimapButton()
    end
    if self.InitializeUnitPopupMenu then
        self:InitializeUnitPopupMenu()
    end

    return ui
end

-- ---------------------------------------------------------------------------
-- Shared UI foundation exposed for split page modules (UISettings.lua, etc.).
-- These references keep their upvalues, so consumer files only need to import
-- the names they directly use. Loaded modules must appear AFTER UI.lua in the .toc.
-- ---------------------------------------------------------------------------
ns.UI = ns.UI or {}
ns.UI.AccumulatePlayerFilterAggregate = AccumulatePlayerFilterAggregate
ns.UI.AddDungeonOptionRow = AddDungeonOptionRow
ns.UI.ADDON_ICON = ADDON_ICON
ns.UI.ApplyThinScrollBar = ApplyThinScrollBar
ns.UI.ApplyThinScrollBarToST = ApplyThinScrollBarToST
ns.UI.BuildDungeonOptionRowsFromFallbacks = BuildDungeonOptionRowsFromFallbacks
ns.UI.BuildDungeonOptionRowsFromRuns = BuildDungeonOptionRowsFromRuns
ns.UI.BuildMenuCheckMark = BuildMenuCheckMark
ns.UI.BuildRaiderIOCharacterURL = BuildRaiderIOCharacterURL
ns.UI.CHARACTER_DETAIL_CONTENT_HEIGHT = CHARACTER_DETAIL_CONTENT_HEIGHT
ns.UI.CHARACTER_ROW_HEIGHT = CHARACTER_ROW_HEIGHT
ns.UI.CHARACTER_ROWS = CHARACTER_ROWS
ns.UI.CLASS_CIRCLE_TEXTURE = CLASS_CIRCLE_TEXTURE
ns.UI.ClipChildren = ClipChildren
ns.UI.ColorizeInlineText = ColorizeInlineText
ns.UI.COLORS = COLORS
ns.UI.COMPACT_STATS_DAMAGE_X = COMPACT_STATS_DAMAGE_X
ns.UI.COMPACT_STATS_DEATHS_X = COMPACT_STATS_DEATHS_X
ns.UI.COMPACT_STATS_DPS_X = COMPACT_STATS_DPS_X
ns.UI.COMPACT_STATS_HEALING_X = COMPACT_STATS_HEALING_X
ns.UI.COMPACT_STATS_HPS_X = COMPACT_STATS_HPS_X
ns.UI.COMPACT_STATS_INTERRUPTS_X = COMPACT_STATS_INTERRUPTS_X
ns.UI.COMPACT_STATS_ITEMS_X = COMPACT_STATS_ITEMS_X
ns.UI.COMPACT_STATS_LOOT_ICON_SIZE = COMPACT_STATS_LOOT_ICON_SIZE
ns.UI.COMPACT_STATS_LOOT_SPACING = COMPACT_STATS_LOOT_SPACING
ns.UI.COMPACT_STATS_PLAYER_WIDTH = COMPACT_STATS_PLAYER_WIDTH
ns.UI.COMPACT_STATS_RATING_X = COMPACT_STATS_RATING_X
ns.UI.COMPACT_STATS_ROW_INSET_X = COMPACT_STATS_ROW_INSET_X
ns.UI.COMPACT_STATS_SCORE_X = COMPACT_STATS_SCORE_X
ns.UI.COMPLETION_POPUP_HEIGHT = COMPLETION_POPUP_HEIGHT
ns.UI.COMPLETION_ROW_HEIGHT = COMPLETION_ROW_HEIGHT
ns.UI.COMPLETION_ROWS = COMPLETION_ROWS
ns.UI.COMPLETION_TOOLTIP_MAX_SPELLS = COMPLETION_TOOLTIP_MAX_SPELLS
ns.UI.CONTROL_COLORS = CONTROL_COLORS
ns.UI.CreateAceDropdownWidget = CreateAceDropdownWidget
ns.UI.CreateAceEditWidget = CreateAceEditWidget
ns.UI.CreateAceFlowRow = CreateAceFlowRow
ns.UI.CreateAceSpacer = CreateAceSpacer
ns.UI.CreateActionButton = CreateActionButton
ns.UI.CreateBreakdownRow = CreateBreakdownRow
ns.UI.CreateCharacterOverviewRow = CreateCharacterOverviewRow
ns.UI.CreateCompactLootStrip = CreateCompactLootStrip
ns.UI.CreateCompactStatRow = CreateCompactStatRow
ns.UI.CreateCompletionMetricHoverButton = CreateCompletionMetricHoverButton
ns.UI.CreateCompletionNoteButton = CreateCompletionNoteButton
ns.UI.CreateCompletionSortHeader = CreateCompletionSortHeader
ns.UI.CreateCompletionStatRow = CreateCompletionStatRow
ns.UI.CreateCycleControl = CreateCycleControl
ns.UI.CreateFont = CreateFont
ns.UI.CreateLabeledEditBox = CreateLabeledEditBox
ns.UI.CreateLabeledSlider = CreateLabeledSlider
ns.UI.CreateListButton = CreateListButton
ns.UI.CreateLootIconButton = CreateLootIconButton
ns.UI.CreateLootLine = CreateLootLine
ns.UI.CreateLootTextButton = CreateLootTextButton
ns.UI.CreateMetricCard = CreateMetricCard
ns.UI.CreateMultilineNoteEditor = CreateMultilineNoteEditor
ns.UI.CreatePlayerFilterAggregate = CreatePlayerFilterAggregate
ns.UI.CreatePlayerFilterButton = CreatePlayerFilterButton
ns.UI.CreatePlayerRatingControl = CreatePlayerRatingControl
ns.UI.CreatePortraitWidget = CreatePortraitWidget
ns.UI.CreateRecentRunButton = CreateRecentRunButton
ns.UI.CreateRoleBadge = CreateRoleBadge
ns.UI.CreateSettingToggle = CreateSettingToggle
ns.UI.CreateSidebarMetric = CreateSidebarMetric
ns.UI.CreateSpecChip = CreateSpecChip
ns.UI.CreateStatRow = CreateStatRow
ns.UI.CreateStatsCard = CreateStatsCard
ns.UI.CreateStatsDungeonRow = CreateStatsDungeonRow
ns.UI.CreateStatsTitleBar = CreateStatsTitleBar
ns.UI.DEFAULT_CUSTOM_DUNGEON_BG = DEFAULT_CUSTOM_DUNGEON_BG
ns.UI.DEFAULT_DUNGEON_BG = DEFAULT_DUNGEON_BG
ns.UI.DEFAULT_DUNGEON_ICON = DEFAULT_DUNGEON_ICON
ns.UI.DetailRunsTableCellUpdate = DetailRunsTableCellUpdate
ns.UI.DUNGEON_BACKGROUND_ASPECT = DUNGEON_BACKGROUND_ASPECT
ns.UI.DUNGEON_BACKGROUND_BASE = DUNGEON_BACKGROUND_BASE
ns.UI.DUNGEON_BACKGROUND_BY_CHALLENGE_ID = DUNGEON_BACKGROUND_BY_CHALLENGE_ID
ns.UI.DUNGEON_BACKGROUND_BY_SLUG = DUNGEON_BACKGROUND_BY_SLUG
ns.UI.DUNGEON_HEADER_BACKGROUND_ASPECT = DUNGEON_HEADER_BACKGROUND_ASPECT
ns.UI.EnsureCustomMenuFrame = EnsureCustomMenuFrame
ns.UI.EnsureCustomModal = EnsureCustomModal
ns.UI.EnsureDungeonBackgroundCache = EnsureDungeonBackgroundCache
ns.UI.ExtractItemNameFromLink = ExtractItemNameFromLink
ns.UI.FinalizePlayerFilterAggregate = FinalizePlayerFilterAggregate
ns.UI.floor = floor
ns.UI.FormatItemLevelText = FormatItemLevelText
ns.UI.FormatKeyLevel = FormatKeyLevel
ns.UI.FormatRunDurationSeconds = FormatRunDurationSeconds
ns.UI.FormatScoreNumber = FormatScoreNumber
ns.UI.FormatScoreWithDelta = FormatScoreWithDelta
ns.UI.FormatTotalTime = FormatTotalTime
ns.UI.GetClassIconMarkup = GetClassIconMarkup
ns.UI.GetCompletionMetricTooltipData = GetCompletionMetricTooltipData
ns.UI.GetCompletionRunStatusText = GetCompletionRunStatusText
ns.UI.GetCompletionRunTitleText = GetCompletionRunTitleText
ns.UI.GetCompletionSortColumn = GetCompletionSortColumn
ns.UI.GetCompletionStatLeaders = GetCompletionStatLeaders
ns.UI.GetCompletionStatValue = GetCompletionStatValue
ns.UI.GetDefaultSeason = GetDefaultSeason
ns.UI.GetDungeonBackground = GetDungeonBackground
ns.UI.GetDungeonIcon = GetDungeonIcon
ns.UI.GetDungeonIconMarkup = GetDungeonIconMarkup
ns.UI.GetDungeonOptionIdentity = GetDungeonOptionIdentity
ns.UI.GetDungeonOptionsForSeason = GetDungeonOptionsForSeason
ns.UI.GetDungeonSlugByName = GetDungeonSlugByName
ns.UI.GetItemIDFromLinkish = GetItemIDFromLinkish
ns.UI.GetItemLevelForLink = GetItemLevelForLink
ns.UI.GetItemNameFallback = GetItemNameFallback
ns.UI.GetItemQualityColorByLink = GetItemQualityColorByLink
ns.UI.GetOwnedCharacterFilterOptions = GetOwnedCharacterFilterOptions
ns.UI.GetPlayerNameParts = GetPlayerNameParts
ns.UI.GetPlayerScoreColor = GetPlayerScoreColor
ns.UI.GetPlayerScoreDisplayText = GetPlayerScoreDisplayText
ns.UI.GetPopupModeLabel = GetPopupModeLabel
ns.UI.GetPopupModeNext = GetPopupModeNext
ns.UI.GetPopupTriggerLabel = GetPopupTriggerLabel
ns.UI.GetPopupTriggerNext = GetPopupTriggerNext
ns.UI.GetRaiderIORegion = GetRaiderIORegion
ns.UI.GetRaiderIOSlug = GetRaiderIOSlug
ns.UI.GetRunOutOfCombatDisplayText = GetRunOutOfCombatDisplayText
ns.UI.GetRunOwnerInfo = GetRunOwnerInfo
ns.UI.GetRunScoreText = GetRunScoreText
ns.UI.GetRunStatsSourceText = GetRunStatsSourceText
ns.UI.GetRunStatusColor = GetRunStatusColor
ns.UI.GetRunStatusText = GetRunStatusText
ns.UI.GetRunTimeRemainingText = GetRunTimeRemainingText
ns.UI.GetRunUpgradeText = GetRunUpgradeText
ns.UI.GetSeasonLabel = GetSeasonLabel
ns.UI.GetSeasonNext = GetSeasonNext
ns.UI.GetSpellIconForTooltip = GetSpellIconForTooltip
ns.UI.GetSpellNameForTooltip = GetSpellNameForTooltip
ns.UI.GetStatusLabel = GetStatusLabel
ns.UI.IsCompletionStatLeader = IsCompletionStatLeader
ns.UI.max = max
ns.UI.MAX_LOOT_ICONS = MAX_LOOT_ICONS
ns.UI.MAX_STATS_CLASS_ROWS = MAX_STATS_CLASS_ROWS
ns.UI.MAX_STATS_DUNGEON_ROWS = MAX_STATS_DUNGEON_ROWS
ns.UI.MAX_STATS_TEAMMATE_ROWS = MAX_STATS_TEAMMATE_ROWS
ns.UI.min = min
ns.UI.NormalizeDateFilter = NormalizeDateFilter
ns.UI.NormalizeDisplayItemLink = NormalizeDisplayItemLink
ns.UI.NormalizeDungeonFilterValueForSeason = NormalizeDungeonFilterValueForSeason
ns.UI.NormalizeDungeonLookupName = NormalizeDungeonLookupName
ns.UI.PLAYER_DETAIL_CONTENT_HEIGHT = PLAYER_DETAIL_CONTENT_HEIGHT
ns.UI.PLAYER_DETAIL_HERO_HEIGHT = PLAYER_DETAIL_HERO_HEIGHT
ns.UI.PLAYER_DETAIL_HISTORY_HEIGHT = PLAYER_DETAIL_HISTORY_HEIGHT
ns.UI.PLAYER_DETAIL_NOTE_HEIGHT = PLAYER_DETAIL_NOTE_HEIGHT
ns.UI.PLAYER_DETAIL_SPEC_CHIPS = PLAYER_DETAIL_SPEC_CHIPS
ns.UI.PLAYER_NOTE_ICON = PLAYER_NOTE_ICON
ns.UI.PLAYER_ROW_HEIGHT = PLAYER_ROW_HEIGHT
ns.UI.PLAYER_ROWS = PLAYER_ROWS
ns.UI.PlayerTableCellUpdate = PlayerTableCellUpdate
ns.UI.PLAYER_TABLE_ROW_HEIGHT = PLAYER_TABLE_ROW_HEIGHT
ns.UI.POPUP_MODE_OPTIONS = POPUP_MODE_OPTIONS
ns.UI.POPUP_TRIGGER_OPTIONS = POPUP_TRIGGER_OPTIONS
ns.UI.QUESTION_MARK_ICON = QUESTION_MARK_ICON
ns.UI.RATING_STAR_ICON = RATING_STAR_ICON
ns.UI.RECENT_RUN_ROWS = RECENT_RUN_ROWS
ns.UI.RefreshCompletionSortHeaders = RefreshCompletionSortHeaders
ns.UI.RefreshControlColors = RefreshControlColors
ns.UI.ROLE_ICON_TEXTURE = ROLE_ICON_TEXTURE
ns.UI.RunMatchesDungeonFilterValue = RunMatchesDungeonFilterValue
ns.UI.RUN_ROW_HEIGHT = RUN_ROW_HEIGHT
ns.UI.RUN_ROWS = RUN_ROWS
ns.UI.ScrollingTable = ScrollingTable
ns.UI.SEASON_FILTER_OPTIONS = SEASON_FILTER_OPTIONS
ns.UI.SEASON_OPTIONS = SEASON_OPTIONS
ns.UI.SetAceDropdownValue = SetAceDropdownValue
ns.UI.SetAceEditText = SetAceEditText
ns.UI.SetCompletionPlayerText = SetCompletionPlayerText
ns.UI.SetTextColor = SetTextColor
ns.UI.SetTextureCoverTexCoord = SetTextureCoverTexCoord
ns.UI.ShowCompletionMetricTooltip = ShowCompletionMetricTooltip
ns.UI.ShowCompletionNoteTooltip = ShowCompletionNoteTooltip
ns.UI.SORT_ARROW_ICON = SORT_ARROW_ICON
ns.UI.STATS_BAR_H = STATS_BAR_H
ns.UI.STATS_CLASS_ROW_H = STATS_CLASS_ROW_H
ns.UI.STATS_CLASS_SECTION_H = STATS_CLASS_SECTION_H
ns.UI.STATS_CONTENT_H = STATS_CONTENT_H
ns.UI.STATS_DUNGEON_ROW_H = STATS_DUNGEON_ROW_H
ns.UI.STATS_DUNGEON_SECTION_H = STATS_DUNGEON_SECTION_H
ns.UI.STATS_OVERVIEW_H = STATS_OVERVIEW_H
ns.UI.STATS_PAD = STATS_PAD
ns.UI.STATS_SECTION_TITLE_H = STATS_SECTION_TITLE_H
ns.UI.STATS_TEAMMATE_ROW_H = STATS_TEAMMATE_ROW_H
ns.UI.STATS_TEAMMATE_SECTION_H = STATS_TEAMMATE_SECTION_H
ns.UI.STATS_TWOCOL_H = STATS_TWOCOL_H
ns.UI.STATUS_OPTIONS = STATUS_OPTIONS
ns.UI.StyleAceDropdownWidget = StyleAceDropdownWidget
ns.UI.StyleAceEditWidget = StyleAceEditWidget
ns.UI.StyleEditBox = StyleEditBox
ns.UI.StyleListButton = StyleListButton
ns.UI.StyleTabButton = StyleTabButton
ns.UI.UpdateAceDropdownOptions = UpdateAceDropdownOptions
ns.UI.UpdateDungeonRowBar = UpdateDungeonRowBar
ns.UI.WHITE_TEXTURE = WHITE_TEXTURE
