local ADDON_NAME = ...
do
    if not _G.MythicMate__debugLoadedPrint then
        _G.MythicMate__debugLoadedPrint = true
    end
end

local MythicMateNS = _G.MythicMate
local Util = MythicMateNS and MythicMateNS.Util
local SettingsNS = MythicMateNS and MythicMateNS.Settings
local SharedData = MythicMateNS and MythicMateNS.Data
local HeaderFiltersNS = MythicMateNS and MythicMateNS.HeaderFilters
local FormatTime = Util and Util.FormatTime
local MythicMate_NowSeconds = Util and Util.NowSeconds
local MythicMate_GetBestCommChannel = Util and Util.GetBestCommChannel
local MythicMate_SafeFullPlayerName = Util and Util.SafeFullPlayerName
local MythicMate_EncodeSection = Util and Util.EncodeSection
local MythicMate_GetLocalPlayerFullName = Util and Util.GetLocalPlayerFullName
local MythicMate_GetDisplayInstanceName = Util and Util.GetDisplayInstanceName

if SettingsNS and type(SettingsNS.EnsureInitialized) == "function" then
    pcall(SettingsNS.EnsureInitialized)
end

local function MythicMate_TryInitAceDB()
    if SettingsNS and type(SettingsNS.TryInitAceDB) == "function" then
        return SettingsNS.TryInitAceDB()
    end
    return false
end

local RenderList -- forward declaration (used by options toggle)
local MythicMate_RefreshTooltipIfShown -- forward declaration (used by options toggle)
local MythicMate_TryApplyElvUISkin -- forward declaration (used by options toggle)

------------------------------------------------------------
-- Main frame (GUI)
------------------------------------------------------------
local frame = CreateFrame("Frame", "MythicMateFrame", UIParent, "BackdropTemplate")
-- Wider so the LFG toolbar (Filter · Key · Rio · List Group · keystone | Quick · Search · Refresh)
-- and the datagrid columns all fit without crowding.
frame:SetSize(900, 620)
frame:SetPoint("CENTER")
frame:EnableMouse(true)
-- Keep the window above most other UI/addons.
frame:SetFrameStrata("DIALOG")
frame:SetToplevel(true)
-- Remember where the user drags the main window (persists across reloads).
if type(_G.MythicMate_PersistFrame) == "function" then
    _G.MythicMate_PersistFrame(frame, "main")
else
    frame:SetMovable(true)
    frame:RegisterForDrag("LeftButton")
    frame:SetScript("OnDragStart", frame.StartMoving)
    frame:SetScript("OnDragStop", frame.StopMovingOrSizing)
end
frame:SetScript("OnMouseDown", function(self)
    -- Bring to front when clicked.
    if self.Raise then self:Raise() end
end)

-- Fancy backdrop
frame:SetBackdrop({
    bgFile   = "Interface\\Buttons\\WHITE8x8",
    -- Use the dialog border for softer, rounded corners
    edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
    tile = true, tileSize = 16, edgeSize = 24,
    insets = { left = 8, right = 8, top = 8, bottom = 8 },
})
frame:SetBackdropColor(0.05, 0.05, 0.1, 0.95)
frame:SetBackdropBorderColor(0.8, 0.7, 1.0, 1)

-- Top gradient bar
local header = frame:CreateTexture(nil, "ARTWORK")
header:SetPoint("TOPLEFT", 5, -5)
header:SetPoint("TOPRIGHT", -5, -5)
header:SetHeight(32)
header:SetTexture("Interface\\Buttons\\WHITE8x8")
header:SetGradient("VERTICAL",
    CreateColor(0.35, 0.1, 0.7, 1),
    CreateColor(0.1, 0.02, 0.3, 1)
)

-- Title text
local title = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
title:SetPoint("LEFT", header, "LEFT", 12, -1)
title:SetText("MythicMate") -- color applied via the accent below (no inline |cff so SetTextColor works)

-- Tint the main window's title bar gradient + title text with the active LFG accent color, so
-- the whole window matches the chosen accent (not just the LFG panel). Assigned as a global (no
-- new file-local — MythicMate.lua is at the 200-local limit) and re-applied on show + whenever
-- the accent picker changes it. header/title are captured as upvalues.
function _G.MythicMate_ApplyMainHeaderAccent()
    local r, g, b = 0.66, 0.40, 1.0
    if type(_G.MythicMate_GetLFGAccent) == "function" then
        local ar, ag, ab = _G.MythicMate_GetLFGAccent()
        if ar then r, g, b = ar, ag, ab end
    end
    if header and header.SetGradient then
        header:SetGradient("VERTICAL",
            CreateColor(r * 0.5, g * 0.5, b * 0.5, 1),
            CreateColor(r * 0.13, g * 0.13, b * 0.13, 1))
    end
    if title and title.SetTextColor then title:SetTextColor(r, g, b) end
    if frame and frame.SetBackdropBorderColor then
        frame:SetBackdropBorderColor(r * 0.55 + 0.25, g * 0.55 + 0.25, b * 0.55 + 0.25, 1)
    end
    -- Re-tint the tab row too (defined later in this file; guarded so the initial call is safe).
    if type(_G.MythicMate_RefreshTabAccents) == "function" then
        _G.MythicMate_RefreshTabAccents()
    end
    -- Re-tint the datagrid header strip as well (defined later; guarded for the initial call).
    if type(_G.MythicMate_RefreshMainHeaderSkin) == "function" then
        _G.MythicMate_RefreshMainHeaderSkin()
    end
end
_G.MythicMate_ApplyMainHeaderAccent()

-- Close button
local close = CreateFrame("Button", nil, frame, "UIPanelCloseButton")
-- Nudge slightly inward so the X is fully inside the frame border
close:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -4, -4)

-- Great Vault (weekly rewards) — always show UI (no toggle)
local function MythicMate_OpenGreatVaultUI()
    if C_AddOns and type(C_AddOns.LoadAddOn) == "function" then
        pcall(C_AddOns.LoadAddOn, "Blizzard_WeeklyRewards")
    elseif LoadAddOn then
        pcall(LoadAddOn, "Blizzard_WeeklyRewards")
    end
    local f = _G.WeeklyRewardsFrame
    if f and type(f.Show) == "function" then
        f:Show()
        return
    end
    print("|cffc868ffMythicMate:|r Great Vault UI could not be opened (try opening it once from the game world).")
end

-- Great Vault launcher: icon-only header control (no button frame/border).
local vaultBtn = CreateFrame("Button", "MythicMateVaultButton", frame, "BackdropTemplate")
vaultBtn:SetSize(28, 28)
vaultBtn:SetPoint("TOPRIGHT", close, "TOPLEFT", -5, 0)
vaultBtn:RegisterForClicks("LeftButtonUp")
if type(vaultBtn.SetBackdrop) == "function" then
    pcall(vaultBtn.SetBackdrop, vaultBtn, nil)
end
if type(vaultBtn.SetNormalTexture) == "function" then
    pcall(vaultBtn.SetNormalTexture, vaultBtn, nil)
end
if type(vaultBtn.SetPushedTexture) == "function" then
    pcall(vaultBtn.SetPushedTexture, vaultBtn, nil)
end

local vaultIcon = vaultBtn:CreateTexture(nil, "ARTWORK")
vaultIcon:SetSize(20, 20)
vaultIcon:SetPoint("CENTER", 0, 0)
-- Keep a clear, stable chest icon without texture masks (masks can render as squares on some icons/clients).
vaultIcon:SetTexture("Interface\\ICONS\\INV_Misc_TreasureChest04A")
if type(vaultIcon.SetMask) == "function" then
    pcall(vaultIcon.SetMask, vaultIcon, nil)
end
vaultIcon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
vaultIcon:SetVertexColor(1, 1, 1)

local vaultIconRing = vaultBtn:CreateTexture(nil, "OVERLAY")
vaultIconRing:SetPoint("CENTER", 0, 0)
vaultIconRing:SetSize(24, 24)
vaultIconRing:SetTexture("Interface\\Buttons\\UI-Quickslot2")
vaultIconRing:SetVertexColor(1, 1, 1, 0.42)

local vaultHi = vaultBtn:CreateTexture(nil, "HIGHLIGHT")
vaultHi:SetAllPoints()
vaultHi:SetColorTexture(1, 1, 1, 0.08)
if type(vaultBtn.SetHighlightTexture) == "function" then
    pcall(vaultBtn.SetHighlightTexture, vaultBtn, vaultHi)
end

local vaultIconOnlyMode = true

vaultBtn:SetScript("OnClick", function()
    frame:Hide()
    MythicMate_OpenGreatVaultUI()
end)
vaultBtn:SetScript("OnEnter", function(self)
    if not vaultIconOnlyMode and type(self.SetBackdropBorderColor) == "function" then
        self:SetBackdropBorderColor(1, 0.94, 1, 1)
    end
    vaultIcon:SetVertexColor(1.08, 1.08, 1.08)
    if GameTooltip then
        GameTooltip:SetOwner(self, "ANCHOR_LEFT")
        GameTooltip:SetText("Great Vault", 1, 1, 1)
        GameTooltip:AddLine("Open weekly rewards and close MythicMate.", nil, nil, nil, true)
        GameTooltip:Show()
    end
end)
vaultBtn:SetScript("OnLeave", function(self)
    -- icon-only mode: no border restore
    vaultIcon:SetVertexColor(1, 1, 1)
    if GameTooltip then GameTooltip:Hide() end
end)

-- Hide legacy header icon launcher; replaced by footer text button.
vaultBtn:Hide()
vaultBtn:EnableMouse(false)

local vaultFooterButton = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
vaultFooterButton:SetSize(110, 22)
vaultFooterButton:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 12, 38)
vaultFooterButton:SetText("Great Vault")
vaultFooterButton:SetScript("OnClick", function()
    frame:Hide()
    MythicMate_OpenGreatVaultUI()
end)

-- Scoped in a do...end block so these locals free their register slots afterwards and don't
-- count toward the main chunk's 200-local limit (see CLAUDE.md). The frames/closures persist
-- regardless of Lua scope; nothing below needs to reference these locals.
do
-- "My Alts" overview launcher (window built in MythicMate_Alts.lua).
local altsFooterButton = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
altsFooterButton:SetSize(90, 22)
altsFooterButton:SetPoint("LEFT", vaultFooterButton, "RIGHT", 6, 0)
altsFooterButton:SetText("My Alts")
altsFooterButton:SetScript("OnClick", function()
    local ns = _G.MythicMate
    if ns and ns.Alts and type(ns.Alts.Toggle) == "function" then
        pcall(ns.Alts.Toggle)
    end
end)
altsFooterButton:SetScript("OnEnter", function(self)
    if not GameTooltip then return end
    GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
    GameTooltip:SetText("My Characters", 1, 1, 1)
    GameTooltip:AddLine("Lockouts, keystone and Great Vault for all your characters.", 0.6, 0.85, 1, true)
    GameTooltip:Show()
end)
altsFooterButton:SetScript("OnLeave", function() if GameTooltip then GameTooltip:Hide() end end)
-- Expose so the tab handler can hide it on the Players tab, where the (wide) player-select
-- dropdown occupies the same footer space and would otherwise overlap it.
_G.MythicMate_AltsFooterButton = altsFooterButton

-- Marker shown on the Great Vault button when there are unclaimed rewards.
local vaultRewardMarker = vaultFooterButton:CreateFontString(nil, "OVERLAY", "GameFontNormal")
vaultRewardMarker:SetPoint("RIGHT", vaultFooterButton, "RIGHT", -6, 0)
vaultRewardMarker:SetText("!")
vaultRewardMarker:SetTextColor(1, 0.82, 0)
vaultRewardMarker:Hide()

local function MythicMate_UpdateVaultRewardMarker()
    local ns = _G.MythicMate
    local has = ns and ns.Vault and type(ns.Vault.HasAvailableRewards) == "function"
        and ns.Vault.HasAvailableRewards() or false
    if has then vaultRewardMarker:Show() else vaultRewardMarker:Hide() end
end

-- Rich tooltip: weekly reset countdown + Great Vault progress + "what to run next"
-- (all from MythicMate_Vault.lua). Falls back to the plain hint if the module is missing.
vaultFooterButton:SetScript("OnEnter", function(self)
    MythicMate_UpdateVaultRewardMarker()
    if not GameTooltip then return end
    GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
    GameTooltip:SetText("Great Vault", 1, 1, 1)
    local ns = _G.MythicMate
    if ns and ns.Vault and type(ns.Vault.GetSummaryLines) == "function" then
        local ok, lines = pcall(ns.Vault.GetSummaryLines)
        if ok and type(lines) == "table" and #lines > 0 then
            for _, l in ipairs(lines) do
                GameTooltip:AddLine(l.text, l.r or 1, l.g or 1, l.b or 1)
            end
        end
    end
    GameTooltip:AddLine(" ")
    GameTooltip:AddLine("Click to open weekly rewards.", 0.6, 0.85, 1)
    GameTooltip:Show()
end)
vaultFooterButton:SetScript("OnLeave", function()
    if GameTooltip then GameTooltip:Hide() end
end)

-- Keep the marker current when the vault frame shows or rewards update.
local vaultMarkerFrame = CreateFrame("Frame")
vaultMarkerFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
vaultMarkerFrame:RegisterEvent("WEEKLY_REWARDS_UPDATE")
vaultMarkerFrame:SetScript("OnEvent", function()
    if C_Timer and type(C_Timer.After) == "function" then
        C_Timer.After(6, MythicMate_UpdateVaultRewardMarker)
    else
        MythicMate_UpdateVaultRewardMarker()
    end
end)
end -- end do-block (vault/alts footer button locals)

-- Subtitle
local subtitle = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
subtitle:SetPoint("TOPLEFT", header, "BOTTOMLEFT", 10, -6)
subtitle:SetText("Track your raid and dungeon lockouts at a glance.")

------------------------------------------------------------
-- WoW Settings panel (Interface -> Addons -> MythicMate)
------------------------------------------------------------
local whatLockoutOptionsPanel = nil
local whatLockoutOptionsTooltipCheckbox = nil
local whatLockoutOptionsElvUISkinCheckbox = nil
local whatLockoutOptionsAbbrevCheckbox = nil
local whatLockoutOptionsWhisperReplyCheckbox = nil
local whatLockoutOptionsWhisperKeywordEdit = nil
local whatLockoutOptionsCreateTries = 0
local whatLockoutOptionsDebugOnce = false
local whatLockoutOptionsRoute = nil -- "Settings" | "InterfaceOptions" | nil
local whatLockoutOptionsSettingsCategory = nil

local function MythicMate_OpenOptionsPanel()
    if not whatLockoutOptionsPanel then return end
    local addonName = whatLockoutOptionsPanel.name or "MythicMate"

    -- Prefer using the same route we registered with.
    if whatLockoutOptionsRoute == "Settings" and Settings and type(Settings.OpenToCategory) == "function" then
        -- Most builds expect a name string here.
        local ok = pcall(Settings.OpenToCategory, addonName)

        if not ok then
            -- Fallback for builds that expect a category ID number.
            if whatLockoutOptionsSettingsCategory and type(whatLockoutOptionsSettingsCategory.GetID) == "function" then
                local idOk, id = pcall(function() return whatLockoutOptionsSettingsCategory:GetID() end)
                if idOk and id ~= nil then
                    local ok2 = pcall(Settings.OpenToCategory, id)
                    if not ok2 then
                        -- Some versions subtract 1 for canvas-layout IDs.
                        pcall(Settings.OpenToCategory, id - 1)
                    end
                end
            end
        end
    end

    -- Legacy Interface options frame.
    if InterfaceOptionsFrame then
        if InterfaceOptionsFrame.Show then
            pcall(InterfaceOptionsFrame.Show, InterfaceOptionsFrame)
        end
    end

    if InterfaceOptionsFrame_OpenToCategory and type(InterfaceOptionsFrame_OpenToCategory) == "function" then
        -- Legacy API expects the addon panel name string.
        pcall(InterfaceOptionsFrame_OpenToCategory, addonName)
    end

    -- Ensure panel frame itself becomes visible when possible.
    if whatLockoutOptionsPanel.Show then
        pcall(whatLockoutOptionsPanel.Show, whatLockoutOptionsPanel)
    end
end

local function MythicMate_TryCreateOptionsPanel()
    whatLockoutOptionsCreateTries = (whatLockoutOptionsCreateTries or 0) + 1
    if whatLockoutOptionsPanel then return end
    if not CreateFrame then return end

    local route = nil

    -- Mimic common WoW addons (ex: MRT): create a simple frame and
    -- register it via InterfaceOptions_AddCategory().
    whatLockoutOptionsPanel = CreateFrame("Frame", "MythicMateOptionsPanel", UIParent)
    _G["MythicMateOptionsPanel"] = whatLockoutOptionsPanel
    whatLockoutOptionsPanel.name = "MythicMate"
    whatLockoutOptionsPanel:SetSize(550, 360)

    local title = whatLockoutOptionsPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    title:SetPoint("TOPLEFT", 16, -16)
    title:SetText("MythicMate Settings")

    local desc = whatLockoutOptionsPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    desc:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -6)
    desc:SetWidth(440)
    desc:SetText("Configure tooltips, abbreviations, and optional whisper auto-reply for Mythic dungeon availability.")

    local y = -70
    whatLockoutOptionsTooltipCheckbox = CreateFrame("CheckButton", nil, whatLockoutOptionsPanel, "UICheckButtonTemplate")
    whatLockoutOptionsTooltipCheckbox:SetPoint("TOPLEFT", 16, y)
    whatLockoutOptionsTooltipCheckbox.text:SetText("Show hover tooltip")
    whatLockoutOptionsTooltipCheckbox.text:SetFontObject("GameFontNormalSmall")
    whatLockoutOptionsTooltipCheckbox.tooltipText = "Show lockouts when you hover party/raid members."

    local checked = (MythicMateDB.settings and MythicMateDB.settings.showPlayerHoverTooltip) and true or false
    whatLockoutOptionsTooltipCheckbox:SetChecked(checked)

    whatLockoutOptionsTooltipCheckbox:SetScript("OnClick", function(self)
        local newChecked = self:GetChecked() and true or false
        MythicMateDB.settings = MythicMateDB.settings or {}
        MythicMateDB.settings.showPlayerHoverTooltip = newChecked
        -- Also update tabs because we hide raids/dungeons tabs based on these toggles.
        MythicMate_UpdateTabsVisibility()
        if not newChecked and GameTooltip then
            GameTooltip:Hide()
        end
    end)

    local tooltipRaidsCheckbox = nil
    local tooltipDungeonsCheckbox = nil
    local tooltipMplusCheckbox = nil
    local tooltipRaidLFRCheckbox = nil
    local tooltipRaidNormalCheckbox = nil
    local tooltipRaidHeroicCheckbox = nil
    local tooltipRaidMythicCheckbox = nil

    y = y - 26
    tooltipRaidsCheckbox = CreateFrame("CheckButton", nil, whatLockoutOptionsPanel, "UICheckButtonTemplate")
    tooltipRaidsCheckbox:SetPoint("TOPLEFT", 16, y)
    tooltipRaidsCheckbox.text:SetText("Show hover tooltip: Raids")
    tooltipRaidsCheckbox.text:SetFontObject("GameFontNormalSmall")
    tooltipRaidsCheckbox.tooltipText = "Include raid lockouts in the PLAYER HOVER tooltip."
    tooltipRaidsCheckbox:SetChecked(not (MythicMateDB.settings and MythicMateDB.settings.showPlayerHoverTooltipRaids == false))
    tooltipRaidsCheckbox:SetScript("OnClick", function(self)
        MythicMateDB.settings = MythicMateDB.settings or {}
        MythicMateDB.settings.showPlayerHoverTooltipRaids = self:GetChecked() and true or false
        MythicMate_UpdateTabsVisibility()
        MythicMate_RefreshTooltipIfShown()
        if not self:GetChecked() and GameTooltip then
            GameTooltip:Hide()
        end
    end)
    -- Raid difficulty filters for the PLAYER HOVER tooltip.
    -- Default: Normal/Heroic/Mythic ON, Looking for Raid OFF.
    y = y - 26
    tooltipRaidLFRCheckbox = CreateFrame("CheckButton", nil, whatLockoutOptionsPanel, "UICheckButtonTemplate")
    tooltipRaidLFRCheckbox:SetPoint("TOPLEFT", 16, y)
    tooltipRaidLFRCheckbox.text:SetText("Looking for Raid")
    tooltipRaidLFRCheckbox.text:SetFontObject("GameFontNormalSmall")
    tooltipRaidLFRCheckbox.tooltipText = "Include LFR raid lockouts in the PLAYER HOVER tooltip."
    tooltipRaidLFRCheckbox:SetChecked((MythicMateDB.settings and MythicMateDB.settings.showPlayerHoverTooltipRaidLFR == true))
    tooltipRaidLFRCheckbox:SetScript("OnClick", function(self)
        MythicMateDB.settings = MythicMateDB.settings or {}
        MythicMateDB.settings.showPlayerHoverTooltipRaidLFR = self:GetChecked() and true or false
        MythicMate_RefreshTooltipIfShown()
    end)

    y = y - 26
    tooltipRaidNormalCheckbox = CreateFrame("CheckButton", nil, whatLockoutOptionsPanel, "UICheckButtonTemplate")
    tooltipRaidNormalCheckbox:SetPoint("TOPLEFT", 16, y)
    tooltipRaidNormalCheckbox.text:SetText("Normal")
    tooltipRaidNormalCheckbox.text:SetFontObject("GameFontNormalSmall")
    tooltipRaidNormalCheckbox.tooltipText = "Include Normal raid lockouts in the PLAYER HOVER tooltip."
    tooltipRaidNormalCheckbox:SetChecked(not (MythicMateDB.settings and MythicMateDB.settings.showPlayerHoverTooltipRaidNormal == false))
    tooltipRaidNormalCheckbox:SetScript("OnClick", function(self)
        MythicMateDB.settings = MythicMateDB.settings or {}
        MythicMateDB.settings.showPlayerHoverTooltipRaidNormal = self:GetChecked() and true or false
        MythicMate_RefreshTooltipIfShown()
    end)

    y = y - 26
    tooltipRaidHeroicCheckbox = CreateFrame("CheckButton", nil, whatLockoutOptionsPanel, "UICheckButtonTemplate")
    tooltipRaidHeroicCheckbox:SetPoint("TOPLEFT", 16, y)
    tooltipRaidHeroicCheckbox.text:SetText("Heroic")
    tooltipRaidHeroicCheckbox.text:SetFontObject("GameFontNormalSmall")
    tooltipRaidHeroicCheckbox.tooltipText = "Include Heroic raid lockouts in the PLAYER HOVER tooltip."
    tooltipRaidHeroicCheckbox:SetChecked(not (MythicMateDB.settings and MythicMateDB.settings.showPlayerHoverTooltipRaidHeroic == false))
    tooltipRaidHeroicCheckbox:SetScript("OnClick", function(self)
        MythicMateDB.settings = MythicMateDB.settings or {}
        MythicMateDB.settings.showPlayerHoverTooltipRaidHeroic = self:GetChecked() and true or false
        MythicMate_RefreshTooltipIfShown()
    end)

    y = y - 26
    tooltipRaidMythicCheckbox = CreateFrame("CheckButton", nil, whatLockoutOptionsPanel, "UICheckButtonTemplate")
    tooltipRaidMythicCheckbox:SetPoint("TOPLEFT", 16, y)
    tooltipRaidMythicCheckbox.text:SetText("Mythic")
    tooltipRaidMythicCheckbox.text:SetFontObject("GameFontNormalSmall")
    tooltipRaidMythicCheckbox.tooltipText = "Include Mythic raid lockouts in the PLAYER HOVER tooltip."
    tooltipRaidMythicCheckbox:SetChecked(not (MythicMateDB.settings and MythicMateDB.settings.showPlayerHoverTooltipRaidMythic == false))
    tooltipRaidMythicCheckbox:SetScript("OnClick", function(self)
        MythicMateDB.settings = MythicMateDB.settings or {}
        MythicMateDB.settings.showPlayerHoverTooltipRaidMythic = self:GetChecked() and true or false
        MythicMate_RefreshTooltipIfShown()
    end)

    y = y - 26

    tooltipDungeonsCheckbox = CreateFrame("CheckButton", nil, whatLockoutOptionsPanel, "UICheckButtonTemplate")
    tooltipDungeonsCheckbox:SetPoint("TOPLEFT", 16, y)
    tooltipDungeonsCheckbox.text:SetText("Show hover tooltip: Dungeons")
    tooltipDungeonsCheckbox.text:SetFontObject("GameFontNormalSmall")
    tooltipDungeonsCheckbox.tooltipText = "Include dungeon lockouts in the PLAYER HOVER tooltip."
    tooltipDungeonsCheckbox:SetChecked(not (MythicMateDB.settings and MythicMateDB.settings.showPlayerHoverTooltipDungeons == false))
    tooltipDungeonsCheckbox:SetScript("OnClick", function(self)
        MythicMateDB.settings = MythicMateDB.settings or {}
        MythicMateDB.settings.showPlayerHoverTooltipDungeons = self:GetChecked() and true or false
        MythicMate_UpdateTabsVisibility()
        MythicMate_RefreshTooltipIfShown()
        if not self:GetChecked() and GameTooltip then
            GameTooltip:Hide()
        end
    end)

    y = y - 26
    tooltipMplusCheckbox = CreateFrame("CheckButton", nil, whatLockoutOptionsPanel, "UICheckButtonTemplate")
    tooltipMplusCheckbox:SetPoint("TOPLEFT", 16, y)
    tooltipMplusCheckbox.text:SetText("Show hover tooltip: M+ / Group Keys")
    tooltipMplusCheckbox.text:SetFontObject("GameFontNormalSmall")
    tooltipMplusCheckbox.tooltipText = "Include Keystone / group keys in the PLAYER HOVER tooltip."
    tooltipMplusCheckbox:SetChecked(not (MythicMateDB.settings and MythicMateDB.settings.showPlayerHoverTooltipMplus == false))
    tooltipMplusCheckbox:SetScript("OnClick", function(self)
        MythicMateDB.settings = MythicMateDB.settings or {}
        MythicMateDB.settings.showPlayerHoverTooltipMplus = self:GetChecked() and true or false
        MythicMate_UpdateTabsVisibility()
        MythicMate_RefreshTooltipIfShown()
        if not self:GetChecked() and GameTooltip then
            GameTooltip:Hide()
        end
    end)

    y = y - 24
    local hideBaseWhenMplusCheckbox = CreateFrame("CheckButton", nil, whatLockoutOptionsPanel, "UICheckButtonTemplate")
    hideBaseWhenMplusCheckbox:SetPoint("TOPLEFT", 32, y) -- indented under the M+ row it depends on
    hideBaseWhenMplusCheckbox.text:SetText("When M+ is active, show only keystone (hide base Mythic)")
    hideBaseWhenMplusCheckbox.text:SetFontObject("GameFontNormalSmall")
    hideBaseWhenMplusCheckbox.tooltipText =
        "In the player hover tooltip, hide base Mythic (M0) dungeon lockouts while a Mythic+ season" ..
        " is active — showing only your keystone. Default on."
    hideBaseWhenMplusCheckbox:SetChecked(not (MythicMateDB.settings and MythicMateDB.settings.hideMythicBaseWhenMplus == false))
    hideBaseWhenMplusCheckbox:SetScript("OnClick", function(self)
        MythicMateDB.settings = MythicMateDB.settings or {}
        MythicMateDB.settings.hideMythicBaseWhenMplus = self:GetChecked() and true or false
        MythicMate_RefreshTooltipIfShown()
    end)

    y = y - 26
    local whatLockoutOptionsRaiderIOLockoutDialogCheckbox = CreateFrame("CheckButton", nil, whatLockoutOptionsPanel, "UICheckButtonTemplate")
    whatLockoutOptionsRaiderIOLockoutDialogCheckbox:SetPoint("TOPLEFT", 16, y)
    whatLockoutOptionsRaiderIOLockoutDialogCheckbox.text:SetText("Show lockout list in RaiderIO profile tooltip")
    whatLockoutOptionsRaiderIOLockoutDialogCheckbox.text:SetFontObject("GameFontNormalSmall")
    whatLockoutOptionsRaiderIOLockoutDialogCheckbox.tooltipText =
        "Shows your locked base Mythic dungeons as a small list inside the RaiderIO profile tooltip." ..
        " Does not modify any RaiderIO settings and does not affect the standard PLAYER HOVER tooltip."
    whatLockoutOptionsRaiderIOLockoutDialogCheckbox:SetChecked(
        not (MythicMateDB.settings and MythicMateDB.settings.showRaiderIOLockoutDialog == false)
    )
    whatLockoutOptionsRaiderIOLockoutDialogCheckbox:SetScript("OnClick", function(self)
        MythicMateDB.settings = MythicMateDB.settings or {}
        MythicMateDB.settings.showRaiderIOLockoutDialog = self:GetChecked() and true or false
        local f = _G.MythicMateRaiderIOLockoutDialogFrame
        if f and f.Hide then f:Hide() end
    end)

    y = y - 26
    local whatLockoutJoinNotifyCheckbox = CreateFrame("CheckButton", nil, whatLockoutOptionsPanel, "UICheckButtonTemplate")
    whatLockoutJoinNotifyCheckbox:SetPoint("TOPLEFT", 16, y)
    whatLockoutJoinNotifyCheckbox.text:SetText("Notify which dungeon you joined")
    whatLockoutJoinNotifyCheckbox.text:SetFontObject("GameFontNormalSmall")
    whatLockoutJoinNotifyCheckbox.tooltipText =
        "When your LFG application is accepted and you join a group, alert you with the dungeon you joined. On by default."
    whatLockoutJoinNotifyCheckbox:SetChecked(not (MythicMateDB.settings and MythicMateDB.settings.notifyJoinedDungeon == false))
    whatLockoutJoinNotifyCheckbox:SetScript("OnClick", function(self)
        MythicMateDB.settings = MythicMateDB.settings or {}
        MythicMateDB.settings.notifyJoinedDungeon = self:GetChecked() and true or false
    end)

    y = y - 24
    local whatLockoutJoinStyleCheckbox = CreateFrame("CheckButton", nil, whatLockoutOptionsPanel, "UICheckButtonTemplate")
    whatLockoutJoinStyleCheckbox:SetPoint("TOPLEFT", 32, y) -- indented sub-option
    whatLockoutJoinStyleCheckbox.text:SetText("Use fading text instead of a click popup")
    whatLockoutJoinStyleCheckbox.text:SetFontObject("GameFontNormalSmall")
    whatLockoutJoinStyleCheckbox.tooltipText =
        "Off (default): the joined-dungeon alert is a click-to-dismiss popup. On: a large fading on-screen text (no click needed). The chat console line always shows either way."
    whatLockoutJoinStyleCheckbox:SetChecked(MythicMateDB.settings and MythicMateDB.settings.joinedNotifyStyle == "text" or false)
    whatLockoutJoinStyleCheckbox:SetScript("OnClick", function(self)
        MythicMateDB.settings = MythicMateDB.settings or {}
        MythicMateDB.settings.joinedNotifyStyle = self:GetChecked() and "text" or "popup"
    end)

    y = y - 26
    local whatLockoutQuickJoinNotifyCheckbox = CreateFrame("CheckButton", nil, whatLockoutOptionsPanel, "UICheckButtonTemplate")
    whatLockoutQuickJoinNotifyCheckbox:SetPoint("TOPLEFT", 16, y)
    whatLockoutQuickJoinNotifyCheckbox.text:SetText("Quick Join alert (fading text when a friend queues)")
    whatLockoutQuickJoinNotifyCheckbox.text:SetFontObject("GameFontNormalSmall")
    whatLockoutQuickJoinNotifyCheckbox.tooltipText =
        "Show a fading on-screen message when a friend or guildmate goes into a Quick Join (Social Queue) premade. On by default."
    whatLockoutQuickJoinNotifyCheckbox:SetChecked(not (MythicMateDB.settings and MythicMateDB.settings.notifyQuickJoin == false))
    whatLockoutQuickJoinNotifyCheckbox:SetScript("OnClick", function(self)
        MythicMateDB.settings = MythicMateDB.settings or {}
        MythicMateDB.settings.notifyQuickJoin = self:GetChecked() and true or false
    end)

    y = y - 26
    local whatLockoutEyeOpenCheckbox = CreateFrame("CheckButton", nil, whatLockoutOptionsPanel, "UICheckButtonTemplate")
    whatLockoutEyeOpenCheckbox:SetPoint("TOPLEFT", 16, y)
    whatLockoutEyeOpenCheckbox.text:SetText("Open applicant window on the minimap eye click")
    whatLockoutEyeOpenCheckbox.text:SetFontObject("GameFontNormalSmall")
    whatLockoutEyeOpenCheckbox.tooltipText =
        "When you click Blizzard's minimap queue 'eye' and you have an active group listing, also open MythicMate's applicant sorter window. On by default."
    whatLockoutEyeOpenCheckbox:SetChecked(not (MythicMateDB.settings and MythicMateDB.settings.applicantsOpenOnEye == false))
    whatLockoutEyeOpenCheckbox:SetScript("OnClick", function(self)
        MythicMateDB.settings = MythicMateDB.settings or {}
        MythicMateDB.settings.applicantsOpenOnEye = self:GetChecked() and true or false
    end)

    y = y - 26
    whatLockoutOptionsElvUISkinCheckbox = CreateFrame("CheckButton", nil, whatLockoutOptionsPanel, "UICheckButtonTemplate")
    whatLockoutOptionsElvUISkinCheckbox:SetPoint("TOPLEFT", 16, y)
    whatLockoutOptionsElvUISkinCheckbox.text:SetText("Use ElvUI skin (When ElvUI is installed)")
    whatLockoutOptionsElvUISkinCheckbox.text:SetFontObject("GameFontNormalSmall")
    whatLockoutOptionsElvUISkinCheckbox.tooltipText = "When enabled, apply ElvUI skin defaults to MythicMate. Saved persistently in addon settings."
    whatLockoutOptionsElvUISkinCheckbox:SetChecked((MythicMateDB.settings and MythicMateDB.settings.useElvUISkinDefaults) and true or false)
    whatLockoutOptionsElvUISkinCheckbox:SetScript("OnClick", function(self)
        local newChecked = self:GetChecked() and true or false
        MythicMateDB.settings = MythicMateDB.settings or {}
        MythicMateDB.settings.useElvUISkinDefaults = newChecked
        -- Only applies if enabled and not already skinned.
        if newChecked then
            MythicMate_TryApplyElvUISkin()
        end
    end)

    y = y - 26
    whatLockoutOptionsAbbrevCheckbox = CreateFrame("CheckButton", nil, whatLockoutOptionsPanel, "UICheckButtonTemplate")
    whatLockoutOptionsAbbrevCheckbox:SetPoint("TOPLEFT", 16, y)
    whatLockoutOptionsAbbrevCheckbox.text:SetText("Use abbreviations in player hover tooltip only")
    whatLockoutOptionsAbbrevCheckbox.text:SetFontObject("GameFontNormalSmall")
    whatLockoutOptionsAbbrevCheckbox.tooltipText = "When enabled, the PLAYER HOVER tooltip shows raid and dungeon names as abbreviations. The addon window list always keeps full names."
    whatLockoutOptionsAbbrevCheckbox:SetChecked((MythicMateDB.settings and MythicMateDB.settings.useInstanceAbbreviations) and true or false)
    whatLockoutOptionsAbbrevCheckbox:SetScript("OnClick", function(self)
        local newChecked = self:GetChecked() and true or false
        MythicMateDB.settings = MythicMateDB.settings or {}
        MythicMateDB.settings.useInstanceAbbreviations = newChecked
        if frame and frame:IsShown() then
            RenderList()
        end
        MythicMate_RefreshTooltipIfShown()
    end)

    y = y - 32
    whatLockoutOptionsWhisperReplyCheckbox = CreateFrame("CheckButton", nil, whatLockoutOptionsPanel, "UICheckButtonTemplate")
    whatLockoutOptionsWhisperReplyCheckbox:SetPoint("TOPLEFT", 16, y)
    whatLockoutOptionsWhisperReplyCheckbox.text:SetText("Auto-reply: Mythic dungeon availability (whisper trigger)")
    whatLockoutOptionsWhisperReplyCheckbox.text:SetFontObject("GameFontNormalSmall")
    whatLockoutOptionsWhisperReplyCheckbox.tooltipText =
        "When enabled, if someone whispers you the trigger phrase (alone or followed by other text), MythicMate whispers back a list of Mythic dungeons you can still run this week (Available / not locked). You can test by whispering yourself."
    whatLockoutOptionsWhisperReplyCheckbox:SetChecked((MythicMateDB.settings and MythicMateDB.settings.whisperAutoReplyEnabled) and true or false)
    whatLockoutOptionsWhisperReplyCheckbox:SetScript("OnClick", function(self)
        MythicMateDB.settings = MythicMateDB.settings or {}
        MythicMateDB.settings.whisperAutoReplyEnabled = self:GetChecked() and true or false
    end)

    y = y - 26
    local whisperKwLabel = whatLockoutOptionsPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    whisperKwLabel:SetPoint("TOPLEFT", whatLockoutOptionsWhisperReplyCheckbox, "BOTTOMLEFT", 24, -4)
    whisperKwLabel:SetText("Trigger phrase (case-insensitive):")
    whisperKwLabel:SetWidth(400)
    whisperKwLabel:SetJustifyH("LEFT")

    whatLockoutOptionsWhisperKeywordEdit = CreateFrame("EditBox", nil, whatLockoutOptionsPanel, "InputBoxTemplate")
    whatLockoutOptionsWhisperKeywordEdit:SetSize(260, 20)
    whatLockoutOptionsWhisperKeywordEdit:SetPoint("TOPLEFT", whisperKwLabel, "BOTTOMLEFT", 0, -6)
    whatLockoutOptionsWhisperKeywordEdit:SetAutoFocus(false)
    whatLockoutOptionsWhisperKeywordEdit:SetMaxLetters(48)
    local function saveWhisperKeywordFromEdit()
        MythicMateDB.settings = MythicMateDB.settings or {}
        local t = whatLockoutOptionsWhisperKeywordEdit:GetText() or ""
        t = t:match("^%s*(.-)%s*$") or ""
        if t == "" then
            t = "whatlockout"
            whatLockoutOptionsWhisperKeywordEdit:SetText(t)
        end
        MythicMateDB.settings.whisperAutoReplyKeyword = t
    end
    whatLockoutOptionsWhisperKeywordEdit:SetText(
        (MythicMateDB.settings and type(MythicMateDB.settings.whisperAutoReplyKeyword) == "string" and MythicMateDB.settings.whisperAutoReplyKeyword ~= "" and MythicMateDB.settings.whisperAutoReplyKeyword)
        or "whatlockout"
    )
    whatLockoutOptionsWhisperKeywordEdit:SetScript("OnEnterPressed", function(self)
        saveWhisperKeywordFromEdit()
        self:ClearFocus()
    end)
    whatLockoutOptionsWhisperKeywordEdit:SetScript("OnEditFocusLost", function()
        saveWhisperKeywordFromEdit()
    end)

    whatLockoutOptionsPanel:SetScript("OnShow", function()
        local st = MythicMateDB and MythicMateDB.settings
        if not st then return end
        if whatLockoutOptionsWhisperReplyCheckbox then
            whatLockoutOptionsWhisperReplyCheckbox:SetChecked(st.whisperAutoReplyEnabled and true or false)
        end
        if tooltipRaidsCheckbox then
            tooltipRaidsCheckbox:SetChecked(not (st.showPlayerHoverTooltipRaids == false))
        end
        if tooltipRaidLFRCheckbox then
            tooltipRaidLFRCheckbox:SetChecked(st.showPlayerHoverTooltipRaidLFR == true)
        end
        if tooltipRaidNormalCheckbox then
            tooltipRaidNormalCheckbox:SetChecked(not (st.showPlayerHoverTooltipRaidNormal == false))
        end
        if tooltipRaidHeroicCheckbox then
            tooltipRaidHeroicCheckbox:SetChecked(not (st.showPlayerHoverTooltipRaidHeroic == false))
        end
        if tooltipRaidMythicCheckbox then
            tooltipRaidMythicCheckbox:SetChecked(not (st.showPlayerHoverTooltipRaidMythic == false))
        end
        if tooltipDungeonsCheckbox then
            tooltipDungeonsCheckbox:SetChecked(not (st.showPlayerHoverTooltipDungeons == false))
        end
        if tooltipMplusCheckbox then
            tooltipMplusCheckbox:SetChecked(not (st.showPlayerHoverTooltipMplus == false))
        end
        if whatLockoutOptionsRaiderIOLockoutDialogCheckbox then
            whatLockoutOptionsRaiderIOLockoutDialogCheckbox:SetChecked(not (st.showRaiderIOLockoutDialog == false))
        end
        if whatLockoutOptionsWhisperKeywordEdit then
            local kw = type(st.whisperAutoReplyKeyword) == "string" and st.whisperAutoReplyKeyword ~= "" and st.whisperAutoReplyKeyword or "whatlockout"
            whatLockoutOptionsWhisperKeywordEdit:SetText(kw)
        end
    end)

    -- Register options category.
    -- Newer clients prefer Settings API; older ones use InterfaceOptions_AddCategory.
    if SettingsPanel and Settings and type(Settings.RegisterCanvasLayoutCategory) == "function" and
       type(Settings.RegisterAddOnCategory) == "function" then
        local okCanvas, category = pcall(Settings.RegisterCanvasLayoutCategory, whatLockoutOptionsPanel, "MythicMate")
        if okCanvas and category then
            local okAddOn = pcall(Settings.RegisterAddOnCategory, category)
            if okAddOn then
                whatLockoutOptionsSettingsCategory = category
                route = "Settings"
            end
        end
    end

    if not route and type(InterfaceOptions_AddCategory) == "function" then
        local okLegacy = pcall(InterfaceOptions_AddCategory, whatLockoutOptionsPanel)
        if okLegacy then
            route = "InterfaceOptions"
        end
    end

    if not route then
        if not whatLockoutOptionsDebugOnce then
            whatLockoutOptionsDebugOnce = true
        end
        whatLockoutOptionsPanel = nil
        return
    end

    whatLockoutOptionsPanel:Show()

    if route then
        whatLockoutOptionsRoute = route
    end
end

-- NOTE:
-- We intentionally do NOT create the options panel at file-load time.
-- The addon can perform SavedVariables/AceDB initialization after load
-- (and can be impacted by library load order). Creating the UI too early
-- can make checkboxes read/write the wrong in-memory settings table.
-- Creation/retries happen from PLAYER_ENTERING_WORLD and PLAYER_LOGIN.
------------------------------------------------------------
-- Tabs: Raids / Dungeons
------------------------------------------------------------
local tabContainer = CreateFrame("Frame", nil, frame)
tabContainer:SetPoint("TOPLEFT", subtitle, "BOTTOMLEFT", -4, -10)
tabContainer:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -10, -40)
tabContainer:SetHeight(26)

local tabs = {}
local activeTab = MythicMateDB.settings and MythicMateDB.settings.activeTab or "DUNGEONS"

local function SetTabVisual(tab, active)
    -- Drive the tab colors from the active accent so the tab row matches the rest of the window.
    local r, g, b = 0.66, 0.40, 1.0
    if type(_G.MythicMate_GetLFGAccent) == "function" then
        local ar, ag, ab = _G.MythicMate_GetLFGAccent()
        if ar then r, g, b = ar, ag, ab end
    end
    if active then
        tab.bg:SetVertexColor(r * 0.9, g * 0.9, b * 0.9, 1)
        tab.label:SetTextColor(1, 1, 1)
        tab.label:SetFontObject("GameFontHighlightLarge")
    else
        -- Inactive: dark neutral with a faint accent tint so it still reads as the same theme.
        tab.bg:SetVertexColor(0.16 + r * 0.06, 0.16 + g * 0.06, 0.24 + b * 0.06, 0.8)
        tab.label:SetTextColor(0.7, 0.7, 0.9)
        tab.label:SetFontObject("GameFontNormal")
    end
end

local function CreateTab(id, text, anchor, offsetX)
    local btn = CreateFrame("Button", nil, tabContainer)
    btn:SetHeight(24)
    if not anchor then
        btn:SetPoint("LEFT", tabContainer, "LEFT", 0, 0)
    else
        btn:SetPoint("LEFT", anchor, "RIGHT", offsetX or 6, 0)
    end

    btn.bg = btn:CreateTexture(nil, "BACKGROUND")
    btn.bg:SetAllPoints()
    btn.bg:SetTexture("Interface\\Buttons\\WHITE8x8")
    btn.bg:SetVertexColor(0.2, 0.2, 0.3, 0.8)

    btn.label = btn:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    btn.label:SetPoint("CENTER")
    btn.label:SetText(text)
    -- +30 padding (was +40) so the full tab row — now including the Quick Join tab — fits the
    -- frame width without overflowing past the right edge.
    btn:SetWidth(btn.label:GetStringWidth() + 30)

    btn:SetScript("OnEnter", function(self)
        if id ~= activeTab then
            local r, g, b = 0.66, 0.40, 1.0
            if type(_G.MythicMate_GetLFGAccent) == "function" then
                local ar, ag, ab = _G.MythicMate_GetLFGAccent()
                if ar then r, g, b = ar, ag, ab end
            end
            self.bg:SetVertexColor(r * 0.55, g * 0.55, b * 0.55, 0.9)
        end
    end)
    btn:SetScript("OnLeave", function(self)
        SetTabVisual(self, id == activeTab)
    end)

    tabs[id] = btn
    return btn
end

-- Tabs: show Mythic Dungeons first by default
local dungeonTab = CreateTab("DUNGEONS", "Mythic Dungeons")
local mplusTab = CreateTab("MPLUS", "M+ Stats", dungeonTab, 6)
local raidTab = CreateTab("RAIDS", "Raid", mplusTab, 6)
local playersTab = CreateTab("PLAYERS", "Players", raidTab, 6)
local sharedTab = CreateTab("SHARED", "Shared Party", playersTab, 6)
local keystoneTab = CreateTab("KEYSTONE", "Group Keys", sharedTab, 6)
local lfgTab = CreateTab("LFG", "LFG", keystoneTab, 6)
local quickJoinTab = CreateTab("QUICKJOIN", "Quick Join", lfgTab, 6)

-- Re-apply the active accent to every tab (active/inactive) — called when the accent changes so
-- the tab row updates live without waiting for a tab switch. Global (no new file-local) and
-- defined here so tabs/SetTabVisual/activeTab are in scope as upvalues.
function _G.MythicMate_RefreshTabAccents()
    for id, btn in pairs(tabs) do
        SetTabVisual(btn, id == activeTab)
    end
end

function MythicMate_UpdateTabsVisibility()
    local st = MythicMateDB and MythicMateDB.settings
    -- Default: all relevant tabs visible.
    local showRaidsTab = not (st and st.showPlayerHoverTooltipRaids == false)
    local showDungeonsTab = not (st and st.showPlayerHoverTooltipDungeons == false)
    -- Players/Shared are only relevant when the dungeons hover provider is enabled.
    -- (User request: hide PLAYERS/SHARED whenever DUNGEONS is off, even if RAIDS is on.)
    local showSharedTab = showDungeonsTab
    local showMplusTab = not (st and st.showPlayerHoverTooltipMplus == false)

    -- Only these tabs are controlled by the "hover tooltip" toggles.
    -- The re-anchor below will keep the row visually compact.
    if tabs and tabs["RAIDS"] then tabs["RAIDS"]:SetShown(showRaidsTab) end
    if tabs and tabs["DUNGEONS"] then tabs["DUNGEONS"]:SetShown(showDungeonsTab) end
    if tabs and tabs["SHARED"] then tabs["SHARED"]:SetShown(showSharedTab) end

    -- Ensure dependent tabs are not accidentally left hidden by previous state.
    if tabs and tabs["MPLUS"] then tabs["MPLUS"]:SetShown(showMplusTab) end
    -- Players / group keys only make sense when at least one of raids/dungeons
    -- providers is enabled (shared party content).
    if tabs and tabs["PLAYERS"] then tabs["PLAYERS"]:SetShown(showSharedTab) end
    -- Group keys are part of M+.
    if tabs and tabs["KEYSTONE"] then tabs["KEYSTONE"]:SetShown(showMplusTab) end
    -- LFG (custom group finder) + Quick Join (Social Queue only) are always available.
    if tabs and tabs["LFG"] then tabs["LFG"]:SetShown(true) end
    if tabs and tabs["QUICKJOIN"] then tabs["QUICKJOIN"]:SetShown(true) end

    local function isShown(id)
        return tabs and tabs[id] and (tabs[id].IsShown and tabs[id]:IsShown()) or false
    end

    -- Re-anchor visible tabs to avoid gaps when tabs are hidden.
    local order = { "DUNGEONS", "MPLUS", "RAIDS", "PLAYERS", "SHARED", "KEYSTONE", "LFG", "QUICKJOIN" }
    local visible = {}
    for i = 1, #order do
        local id = order[i]
        if isShown(id) and tabs[id] then
            visible[#visible + 1] = id
        end
    end
    local prevBtn = nil
    for i = 1, #visible do
        local id = visible[i]
        local btn = tabs[id]
        if btn and btn.SetPoint then
            btn:ClearAllPoints()
            if prevBtn then
                btn:SetPoint("LEFT", prevBtn, "RIGHT", 6, 0)
            else
                btn:SetPoint("LEFT", tabContainer, "LEFT", 0, 0)
            end
            prevBtn = btn
        end
    end

    -- If the active tab was just hidden, pick the first shown fallback.
    if activeTab and not isShown(activeTab) then
        local fallbackOrder = { "DUNGEONS", "RAIDS", "MPLUS", "PLAYERS", "KEYSTONE", "SHARED" }
        for _, id in ipairs(fallbackOrder) do
            if isShown(id) then
                if type(SetActiveTab) == "function" then
                    SetActiveTab(id)
                else
                    activeTab = id
                end
                return
            end
        end
        -- Last resort: keep whatever is active.
    end
end

-- Ensure tab visibility matches settings at creation time.
pcall(MythicMate_UpdateTabsVisibility)

------------------------------------------------------------
-- Scroll frame & content area
------------------------------------------------------------
local listContainer = CreateFrame("Frame", nil, frame, "BackdropTemplate")
-- Top moved up (was -120) so the bordered panel ENCLOSES the column headers, like the LFG panel
-- (which contains its toolbar + headers + rows inside one border).
listContainer:SetPoint("TOPLEFT", frame, "TOPLEFT", 10, -96)
-- Leave more vertical room for footer (buttons + text)
listContainer:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -10, 80)
listContainer:SetBackdrop({
    bgFile   = "Interface\\Buttons\\WHITE8x8",
    -- Accent-tinted border (matches the LFG finder panel) so the grid container is themed too.
    edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
    tile = true, tileSize = 16, edgeSize = 12,
    insets = { left = 3, right = 3, top = 3, bottom = 3 },
})
listContainer:SetBackdropBorderColor(0.42, 0.34, 0.62, 0.9) -- default; re-tinted from the accent
-- Near-opaque dark panel (matches the LFG finder's panel) so the striped rows + accent bars read
-- cleanly on every tab instead of the logo watermark bleeding through the transparent rows.
listContainer:SetBackdropColor(0.035, 0.04, 0.075, 0.97)

-- Toolbar strip at the top of the list panel — same violet gradient the LFG finder uses, so every
-- tab reads as one themed datagrid (toolbar → column headers → rows). The "Hide locked" checkbox
-- (and, on the Players tab, the "Last sync" text anchored to it) sit inside this strip.
-- Kept in a do-block + stored on listContainer (NOT a new file-local) so we don't blow Lua's
-- 200-locals-per-chunk limit; the accent refresher reads it back as listContainer.wlToolbar.
do
    local tb = listContainer:CreateTexture(nil, "ARTWORK")
    tb:SetPoint("TOPLEFT", 4, -4)
    tb:SetPoint("TOPRIGHT", -4, -4)
    tb:SetHeight(26)
    tb:SetTexture("Interface\\Buttons\\WHITE8x8")
    if tb.SetGradient then
        tb:SetGradient("VERTICAL",
            CreateColor(0.16, 0.07, 0.30, 0.95), CreateColor(0.06, 0.03, 0.13, 0.95))
    end
    listContainer.wlToolbar = tb
end

-- Full-width header strip + underline (exactly like the LFG panel's headerBar/hdrLine): they span
-- the whole panel edge-to-edge, NOT just the scrollbar-inset header row, so the column-header bar
-- fills the full width. Stored on listContainer (do-block, no new file-local → 200-local budget).
do
    local strip = listContainer:CreateTexture(nil, "ARTWORK")
    strip:SetPoint("TOPLEFT", 4, -31)
    strip:SetPoint("TOPRIGHT", -4, -31)
    strip:SetHeight(22)
    strip:SetColorTexture(1, 1, 1, 0.05) -- accent-tinted in MythicMate_RefreshMainHeaderSkin
    listContainer.hdrStrip = strip

    local line = listContainer:CreateTexture(nil, "ARTWORK")
    line:SetPoint("TOPLEFT", 6, -54)
    line:SetPoint("TOPRIGHT", -6, -54)
    line:SetHeight(1)
    line:SetColorTexture(0.4, 0.4, 0.55, 0.6)
    listContainer.hdrLine = line
end

-- Soft artwork background behind the dungeon/raid list
-- Parented to listContainer so it always stays inside the frame
local bgLogo = listContainer:CreateTexture(nil, "BACKGROUND")
bgLogo:SetTexture("Interface\\AddOns\\MythicMate\\media\\logo")
-- Slightly smaller so it sits nicely behind the rows
bgLogo:SetSize(280, 280)
bgLogo:SetPoint("CENTER", listContainer, "CENTER", 0, -6)
-- Use additive blend so the dark parts of the logo don't show as a black box
bgLogo:SetBlendMode("ADD")
-- Very soft so it stays a faint watermark on the now-opaque panel and doesn't bleed through rows
bgLogo:SetAlpha(0.05)
-- Crop to the central emblem area of the artwork
bgLogo:SetTexCoord(0.15, 0.85, 0.15, 0.85)

-- Footer area: 2 neatly spaced rows
local footer = frame:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
footer:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 12, 16)
footer:SetText("By Alvarín-Silvermoon")

-- Share controls (label + dropdown + button) on the top-right of the footer
local shareButton = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
shareButton:SetSize(120, 22)
-- Move footer controls up a bit so "By ..." text isn't clipped/overlapped.
shareButton:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -12, 34)
shareButton:SetText("Post lockouts")

local shareDropdown = CreateFrame("Frame", "MythicMateShareDropdown", frame, "UIDropDownMenuTemplate")
-- Align the dropdown vertically with the button and give it a small gap
shareDropdown:SetPoint("RIGHT", shareButton, "LEFT", -4, -1)

local shareLabel = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
shareLabel:SetText("Share in:")
-- Align label text baseline with the dropdown text
shareLabel:SetPoint("RIGHT", shareDropdown, "LEFT", -4, 0)

-- Sync + Players view controls (only visible on Players tab)
local syncButton = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
syncButton:SetSize(80, 22)
syncButton:SetPoint("RIGHT", shareLabel, "LEFT", -12, 0)
syncButton:SetText("Sync")

local playersDropdown = CreateFrame("Frame", "MythicMatePlayersDropdown", frame, "UIDropDownMenuTemplate")
playersDropdown:SetPoint("RIGHT", syncButton, "LEFT", -10, -1)
playersDropdown:Hide()

local playersLabel = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
playersLabel:SetText("View:")
playersLabel:SetPoint("RIGHT", playersDropdown, "LEFT", -4, 0)
playersLabel:Hide()

local scrollFrame = CreateFrame("ScrollFrame", nil, listContainer, "UIPanelScrollFrameTemplate")
-- Rows start below the toolbar strip + column header row (X kept so the row columns stay aligned
-- with the headers). Matches the LFG panel's toolbar → headers → rows rhythm.
scrollFrame:SetPoint("TOPLEFT", 4, -58)
scrollFrame:SetPoint("BOTTOMRIGHT", -26, 8)

local content = CreateFrame("Frame", nil, scrollFrame)
content:SetSize(1, 1)  -- will be resized dynamically
scrollFrame:SetScrollChild(content)

------------------------------------------------------------
-- Column headers
------------------------------------------------------------
local headerRow = CreateFrame("Frame", nil, frame)
-- Align header columns with row columns. Header now sits INSIDE the list panel near its top (like
-- the LFG grid), just below a small toolbar margin, with rows starting below it.
headerRow:SetPoint("TOPLEFT", listContainer, "TOPLEFT", 12, -34)
-- Right edge matches the scroll viewport (-26 clears the scrollbar), so the header strip, the
-- underline and the last column all line up with the data rows below instead of running past them.
headerRow:SetPoint("TOPRIGHT", listContainer, "TOPRIGHT", -26, -34)
headerRow:SetHeight(20)

local function CreateColumnHeader(parent, text, width, point, offsetX)
    local fs = parent:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") -- LFG-exact header font
    fs:SetTextColor(0.72, 0.72, 0.85)
    fs:SetText(text)
    fs:SetJustifyH("LEFT")
    if not point then
        fs:SetPoint("LEFT", parent, "LEFT", offsetX or 0, 0)
    else
        fs:SetPoint("LEFT", point, "LEFT", offsetX or 0, 0)
    end
    fs:SetWidth(width)
    return fs

end

-- Match the column positions used by the row fields:
-- name at x=16, progress at name+12+260, etc.
local nameHeader  = CreateColumnHeader(headerRow, "Instance", 300, nil, 16)
local bossHeader  = CreateColumnHeader(headerRow, "Progress", 100, nil, 16 + 300 + 12)
local resetHeader = CreateColumnHeader(headerRow, "Reset In", 100, nil, 16 + 300 + 12 + 100 + 12)
-- Last column stretches to the header row's right edge so it lines up with the stretched row cell.
local diffHeader  = CreateColumnHeader(headerRow, "Type", 180,    nil, 16 + 300 + 12 + 100 + 12 + 100 + 12)
diffHeader:SetPoint("RIGHT", headerRow, "RIGHT", -4, 0) -- fill to the right edge (over the fixed width)

-- Players tab helper text ("Last sync ..."), shown at the top-right.
local playersInfo = headerRow:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
playersInfo:SetPoint("RIGHT", headerRow, "RIGHT", -8, 0) -- will be re-anchored after checkbox is created
playersInfo:SetText("")
playersInfo:Hide()

-- Filter checkbox: hide locked entries for the current tab
local filterCheckbox = CreateFrame("CheckButton", nil, listContainer, "UICheckButtonTemplate")
filterCheckbox:SetScale(0.85)
-- Sits inside the toolbar strip at the panel's top-right (like the LFG finder's toolbar controls).
-- Label goes to the LEFT of the box so it never spills past the panel's right edge.
filterCheckbox:ClearAllPoints()
filterCheckbox:SetPoint("TOPRIGHT", listContainer, "TOPRIGHT", -8, -2)
filterCheckbox.text:SetText("Hide locked")
filterCheckbox.text:SetFontObject("GameFontNormalSmall")
filterCheckbox.text:ClearAllPoints()
filterCheckbox.text:SetPoint("RIGHT", filterCheckbox, "LEFT", -2, 0)
filterCheckbox.tooltipText = "Hide locked instances from this list."

-- Anchor Players/keystone info to the left of the checkbox's LABEL (not the box), so the
-- "Hide locked" text can never overlap it inside the toolbar strip.
playersInfo:ClearAllPoints()
playersInfo:SetPoint("RIGHT", filterCheckbox.text, "LEFT", -12, 0)
playersInfo:SetJustifyH("RIGHT")
if playersInfo.SetWordWrap then playersInfo:SetWordWrap(false) end

------------------------------------------------------------
-- Optional skinning: ElvUI
------------------------------------------------------------
function MythicMate_GetElvUISkinsModule()
    local elv = _G.ElvUI
    if type(elv) ~= "table" then return nil end

    local E = nil
    if type(elv.GetModule) == "function" then
        E = elv
    elseif type(elv[1]) == "table" and type(elv[1].GetModule) == "function" then
        E = elv[1]
    else
        local unpackFn = table and table.unpack or unpack
        if type(unpackFn) == "function" then
            local okUnpack, first = pcall(unpackFn, elv)
            if okUnpack and type(first) == "table" and type(first.GetModule) == "function" then
                E = first
            end
        end
    end
    if not E then return nil end

    local okSkinModule, S = pcall(E.GetModule, E, "Skins")
    if not okSkinModule or not S then return nil end
    return S
end

local whatLockoutElvUISkinApplied = false
MythicMate_TryApplyElvUISkin = function()
    -- Ensure persistence backend is initialized before reading settings.
    -- (ElvUI can load before/around our other events.)
    pcall(MythicMate_TryInitAceDB)
    if whatLockoutElvUISkinApplied then return end
    if MythicMateDB.settings and MythicMateDB.settings.useElvUISkinDefaults == false then
        return
    end
    local S = MythicMate_GetElvUISkinsModule()
    if not S then return end

    -- Main frame + container
    if type(S.HandleFrame) == "function" then
        pcall(S.HandleFrame, S, frame)
        pcall(S.HandleFrame, S, listContainer)
    end

    -- Buttons / close
    if type(S.HandleButton) == "function" then
        pcall(S.HandleButton, S, shareButton)
        pcall(S.HandleButton, S, syncButton)
        pcall(S.HandleButton, S, vaultFooterButton)
    end
    if type(S.HandleCloseButton) == "function" then
        pcall(S.HandleCloseButton, S, close)
    end

    -- Checkbox
    if type(S.HandleCheckBox) == "function" then
        pcall(S.HandleCheckBox, S, filterCheckbox)
    end

    -- Keep Blizzard UIDropDownMenu layout for these controls.
    -- ElvUI HandleDropDownBox can offset/oversize UIDropDownMenuTemplate frames
    -- here, causing weird positioning (see Party dropdown overlap).
    if shareDropdown then
        shareDropdown:ClearAllPoints()
        shareDropdown:SetPoint("RIGHT", shareButton, "LEFT", -4, -1)
        if UIDropDownMenu_SetWidth then
            UIDropDownMenu_SetWidth(shareDropdown, 90)
        end
    end
    if playersDropdown then
        playersDropdown:ClearAllPoints()
        playersDropdown:SetPoint("RIGHT", syncButton, "LEFT", -10, -1)
        if UIDropDownMenu_SetWidth then
            UIDropDownMenu_SetWidth(playersDropdown, 140)
        end
    end

    whatLockoutElvUISkinApplied = true
end

------------------------------------------------------------
-- Data storage
------------------------------------------------------------
local ScanLockouts -- forward declaration (used by comms export)
local MythicMate_BuildMythicPlusStats -- forward declaration (used by tab switch)
local MythicMate_NormalizeName -- forward declaration (used by tooltip filters)
local MythicMate_GetCurrentRaidNameSet -- forward declaration (used by tooltip filter)
local data = {
    RAIDS = {},
    DUNGEONS = {},
    PLAYERS = {},
    SHARED = {},
    MPLUS = {},
    KEYSTONE = {},
}
-- Spurious zone save (instance id 6006) — string fallback per locale for Shared Party / sync.
-- Matching uses MythicMate_NormalizeName, MythicMate_NormalizeInstanceKeyLoose, and Util.FoldForBlacklistKey (accent + apostrophe fold).
local whatLockoutJunkInstanceNameBlacklist = (SharedData and SharedData.JunkInstanceNameBlacklist) or {}
local whatLockoutDungeonBlacklistNormSet = whatLockoutJunkInstanceNameBlacklist
local whatLockoutRaidBlacklistNormSet = whatLockoutJunkInstanceNameBlacklist

-- Per-tab filters
local filters
if SharedData and type(SharedData.BuildTabFilters) == "function" then
    filters = SharedData.BuildTabFilters(MythicMateDB and MythicMateDB.settings)
else
    local settings = (MythicMateDB and MythicMateDB.settings) or {}
    local hideLockedByTab = settings.hideLockedByTab or {}
    local typeFilterByTab = settings.typeFilterByTab or {}
    filters = {
        RAIDS = {
            hideLocked = (hideLockedByTab.RAIDS and true or false),
            typeFilter = (typeFilterByTab.RAIDS or "ALL"),
        },
        DUNGEONS = {
            hideLocked = (hideLockedByTab.DUNGEONS and true or false),
            typeFilter = (typeFilterByTab.DUNGEONS or "ALL"),
        },
        PLAYERS = {
            hideLocked = (hideLockedByTab.PLAYERS and true or false),
            typeFilter = (typeFilterByTab.PLAYERS or "ALL"),
        },
        SHARED = {
            hideLocked = (hideLockedByTab.SHARED and true or false),
            typeFilter = (typeFilterByTab.SHARED or "ALL"),
        },
    }
end

------------------------------------------------------------
-- Header filters (click headers to toggle)
------------------------------------------------------------
local function MythicMate_CycleTypeFilter(current)
    if HeaderFiltersNS and type(HeaderFiltersNS.CycleTypeFilter) == "function" then
        return HeaderFiltersNS.CycleTypeFilter(current)
    end
    return "Raid"
end

-- Put filter state directly into the header text (no separate badges),
-- so it looks like "header row filtering on header text".
local function MythicMate_UpdateHeaderFilterText()
    if HeaderFiltersNS and type(HeaderFiltersNS.UpdateHeaderFilterText) == "function" then
        HeaderFiltersNS.UpdateHeaderFilterText(activeTab, filters, bossHeader, resetHeader, diffHeader)
    end
end

------------------------------------------------------------
-- Shared datagrid look + sortable headers (same component the LFG finder uses). All six lockout
-- tabs share this one header row + row pool, so wiring it here themes + sorts every tab at once.
-- LEFT-click a header sorts by that column (toggles asc/desc); the Progress and Type headers keep
-- their original filter actions on RIGHT-click (hide-locked toggle / cycle type). Globals (not new
-- file-locals) since MythicMate.lua is at the 200-local limit.
------------------------------------------------------------
_G.MythicMate_MainSort = _G.MythicMate_MainSort or { key = nil, asc = true }
_G.MythicMate_MainHeaders = { name = nameHeader, progress = bossHeader, reset = resetHeader, diff = diffHeader }

-- Re-tint the grid chrome (header accent strip + the list container border) when the accent
-- changes (called from the accent applier). Captures headerRow + listContainer as upvalues.
function _G.MythicMate_RefreshMainHeaderSkin()
    local r, g, b = 0.66, 0.40, 1.0
    if _G.MythicMate_Grid then r, g, b = _G.MythicMate_Grid.AccentRGB() end
    if listContainer and listContainer.SetBackdropBorderColor then
        listContainer:SetBackdropBorderColor(r * 0.65 + 0.06, g * 0.65 + 0.06, b * 0.65 + 0.06, 0.9)
    end
    -- Full-width header strip + underline follow the accent, matching the LFG panel.
    if listContainer and listContainer.hdrStrip then
        listContainer.hdrStrip:SetColorTexture(r, g, b, 0.12)
    end
    if listContainer and listContainer.hdrLine then
        listContainer.hdrLine:SetColorTexture(r, g, b, 0.55)
    end
    -- Accent-tint the toolbar gradient the same way the LFG panel does, so the strip follows the
    -- active class/preset accent.
    local tb = listContainer and listContainer.wlToolbar
    if tb and tb.SetGradient then
        tb:SetGradient("VERTICAL",
            CreateColor(r * 0.34, g * 0.34, b * 0.34, 0.95), CreateColor(0.05, 0.03, 0.09, 0.95))
    end
end

-- Toggle/set the active sort column, then re-render. Captures the file-local RenderList upvalue.
function _G.MythicMate_ToggleMainSort(key)
    local s = _G.MythicMate_MainSort
    if s.key == key then s.asc = not s.asc else s.key = key; s.asc = true end
    if RenderList then RenderList() end
end

-- Column sort value. Type is kept consistent PER key so table.sort never compares a number with a
-- string (which errors): progress = number, name/reset/diff = string.
function _G.MythicMate_MainSortValue(entry, key)
    if not entry then return "" end
    if key == "progress" then
        local a, b = tostring(entry.progress or ""):match("(%d+)%s*/%s*(%d+)")
        if a then return tonumber(a) / math.max(1, tonumber(b) or 1) end
        return tonumber(entry.lockedCount) or (entry.locked and 1 or 0)
    elseif key == "reset" then
        return tostring(entry.resetTime or entry.reset or "")
    elseif key == "diff" then
        return tostring(entry.difficulty or "")
    end
    return tostring(entry.name or ""):lower()
end

-- Progress header keeps its hide-locked toggle (now on right-click).
function _G.MythicMate_ProgressFilterToggle()
    if not activeTab or activeTab == "MPLUS" or activeTab == "KEYSTONE" or not filters[activeTab] then return end
    filters[activeTab].hideLocked = not filters[activeTab].hideLocked
    MythicMateDB.settings = MythicMateDB.settings or {}
    MythicMateDB.settings.hideLockedByTab = MythicMateDB.settings.hideLockedByTab or {}
    MythicMateDB.settings.hideLockedByTab[activeTab] = filters[activeTab].hideLocked
    if filterCheckbox and filterCheckbox.SetChecked then filterCheckbox:SetChecked(filters[activeTab].hideLocked) end
    MythicMate_UpdateHeaderFilterText()
    RenderList()
end

-- Type header keeps its All -> Raid -> Dungeon -> Mythic cycle (now on right-click).
function _G.MythicMate_TypeFilterCycle()
    if not activeTab or activeTab == "MPLUS" or activeTab == "KEYSTONE" or not filters[activeTab] then return end
    local nextType = MythicMate_CycleTypeFilter(filters[activeTab].typeFilter or "ALL")
    filters[activeTab].typeFilter = nextType
    MythicMateDB.settings = MythicMateDB.settings or {}
    MythicMateDB.settings.typeFilterByTab = MythicMateDB.settings.typeFilterByTab or {}
    MythicMateDB.settings.typeFilterByTab[activeTab] = nextType
    MythicMate_UpdateHeaderFilterText()
    RenderList()
end

if _G.MythicMate_Grid then
    -- Header strip/underline are drawn full-width on listContainer (above), so we don't attach the
    -- scrollbar-inset headerRow skin here — only make the columns sortable.
    _G.MythicMate_Grid.MakeHeaderSortable(nameHeader,  "name",  function(k) MythicMate_ToggleMainSort(k) end)
    _G.MythicMate_Grid.MakeHeaderSortable(resetHeader, "reset", function(k) MythicMate_ToggleMainSort(k) end)
    _G.MythicMate_Grid.MakeHeaderSortable(bossHeader,  "progress", function(k, button)
        if button == "RightButton" then MythicMate_ProgressFilterToggle() else MythicMate_ToggleMainSort(k) end
    end)
    _G.MythicMate_Grid.MakeHeaderSortable(diffHeader,  "diff", function(k, button)
        if button == "RightButton" then MythicMate_TypeFilterCycle() else MythicMate_ToggleMainSort(k) end
    end)
else
    -- Grid component missing (shouldn't happen): fall back to the original click filters.
    bossHeader:EnableMouse(true); bossHeader:SetScript("OnMouseUp", MythicMate_ProgressFilterToggle)
    diffHeader:EnableMouse(true); diffHeader:SetScript("OnMouseUp", MythicMate_TypeFilterCycle)
end

MythicMate_UpdateHeaderFilterText()

------------------------------------------------------------
-- Addon comms: share lockouts between players using addon
------------------------------------------------------------
local COMM_PREFIX = "WhatLockout" -- legacy prefix on purpose: keeps party sync compatible with pre-rename versions
-- Dungeon lines: 7–8 mapID/savedInstanceID; 9–10 difficultyId + status (optional, locale-stable).
-- Raid lines: 7–8 raidKeyID/journalInstanceID; 9 difficultyId (10 unused). Extra columns ignored by old clients.
local COMM_VERSION = "1"
local MYTHIC_DUNGEON_ID = 23 -- Base Mythic 5-man (GetSavedInstanceInfo / GetInstanceInfo)
local MythicMateDebugComm = false
local MythicMateDebugTooltip = false
local MythicMateDebugPremade = false

-- Keep-in-sync: periodically request fresh exports from each party/raid member.
-- This is the closest we can get to "always in sync" without relying on clicks.
local KEEP_SYNC_ENABLED = true
local KEEP_SYNC_INTERVAL = 60       -- seconds (tick frequency)
local KEEP_SYNC_PER_MEMBER_CD = 20  -- seconds (throttle per target)
local keepSyncLastAt = {} -- [targetKey] = seconds

MythicMateDB.shared = MythicMateDB.shared or {
    players = {}, -- [fullName] = { updatedAt = time(), raids = {...}, dungeons = {...} }
}

local commPending = {} -- [sender][section] = { total = n, chunks = { [i]=payload } }
local selectedRemotePlayer = (MythicMateDB.settings and MythicMateDB.settings.selectedRemotePlayer) or nil

-- Raids: merge encounter kills, progress "X/Y", and synced bossSummary — any single source can be wrong on new tiers.
local function MythicMate_GetRaidBossDisplayCounts(e)
    if MythicMateNS and MythicMateNS.CommsHelpers and type(MythicMateNS.CommsHelpers.GetRaidBossDisplayCounts) == "function" then
        return MythicMateNS.CommsHelpers.GetRaidBossDisplayCounts(e)
    end
    return tonumber(e and e.bossDefeatedCount) or 0, tonumber(e and e.bossTotalCount) or 0
end

local function MythicMate_RaidDifficultyLetter(diff)
    if MythicMateNS and MythicMateNS.CommsHelpers and type(MythicMateNS.CommsHelpers.RaidDifficultyLetter) == "function" then
        return MythicMateNS.CommsHelpers.RaidDifficultyLetter(diff)
    end
    return nil
end

function MythicMate_PlayerHoverRaidDifficultyAllowed(e, st)
    if type(e) ~= "table" then return false end
    if tonumber(e.difficultyId or e.difficulty) == 17 then return st and st.showPlayerHoverTooltipRaidLFR == true or false end
    if tonumber(e.difficultyId or e.difficulty) == 14 then return not (st and st.showPlayerHoverTooltipRaidNormal == false) end
    if tonumber(e.difficultyId or e.difficulty) == 15 then return not (st and st.showPlayerHoverTooltipRaidHeroic == false) end
    if tonumber(e.difficultyId or e.difficulty) == 16 then return not (st and st.showPlayerHoverTooltipRaidMythic == false) end
    if tostring(e.difficultyName or e.difficulty or ""):lower():find("looking for", 1, true) and tostring(e.difficultyName or e.difficulty or ""):lower():find("raid", 1, true) then
        return st and st.showPlayerHoverTooltipRaidLFR == true or false
    end
    if tostring(e.difficultyName or e.difficulty or ""):lower():find("normal", 1, true) then return not (st and st.showPlayerHoverTooltipRaidNormal == false) end
    if tostring(e.difficultyName or e.difficulty or ""):lower():find("heroic", 1, true) then return not (st and st.showPlayerHoverTooltipRaidHeroic == false) end
    if tostring(e.difficultyName or e.difficulty or ""):lower():find("mythic", 1, true) then return not (st and st.showPlayerHoverTooltipRaidMythic == false) end
    return true
end

function MythicMate_TooltipUnitTokenBlocked(u)
    if not u then return true end
    if type(u) ~= "string" then return true end
    -- Patch 12.x: GameTooltip:GetUnit() can hand back a "secret" string while our execution
    -- is tainted. Any comparison on it (u == "") throws "attempt to compare secret string
    -- value", so the secret check MUST come before the empty-string comparison below.
    if type(issecretvalue) == "function" and issecretvalue(u) then return true end
    if u == "" then return true end
    return false
end

-- Extract a compact M+ summary from the computed `data.MPLUS` rows.
-- Used for comm sync (MPLUS export) and player-hover tooltip title.
function MythicMate_ExtractMplusSummaryFromRows(mplusRows)
    local rows = mplusRows or (data and data.MPLUS)
    if MythicMateNS and MythicMateNS.CommsHelpers and type(MythicMateNS.CommsHelpers.ExtractMplusSummaryFromRows) == "function" then
        return MythicMateNS.CommsHelpers.ExtractMplusSummaryFromRows(rows)
    end
    return nil
end

local function MythicMate_GetOwnedKeystoneSnapshot()
    local mapID, level, mapName = nil, nil, nil

    if C_MythicPlus then
        if type(C_MythicPlus.GetOwnedKeystoneChallengeMapID) == "function" then
            local okMap, v = pcall(C_MythicPlus.GetOwnedKeystoneChallengeMapID)
            if okMap then mapID = tonumber(v or 0) or 0 end
        end
        if type(C_MythicPlus.GetOwnedKeystoneLevel) == "function" then
            local okLvl, v = pcall(C_MythicPlus.GetOwnedKeystoneLevel)
            if okLvl then level = tonumber(v or 0) or 0 end
        end
        if (not mapID or mapID <= 0) and type(C_MythicPlus.GetOwnedKeystoneActivityAndGroupAndLevel) == "function" then
            local okAct, activityID, _, lvl = pcall(C_MythicPlus.GetOwnedKeystoneActivityAndGroupAndLevel)
            if okAct then
                level = level or (tonumber(lvl or 0) or 0)
                if activityID and C_LFGList and type(C_LFGList.GetActivityInfoTable) == "function" then
                    local okInfo, info = pcall(C_LFGList.GetActivityInfoTable, activityID)
                    if okInfo and type(info) == "table" then
                        mapName = info.fullName or info.shortName or info.name or mapName
                    end
                end
            end
        end
    end

    if (not level or level <= 0) then return nil end

    if mapID and mapID > 0 and C_ChallengeMode and type(C_ChallengeMode.GetMapUIInfo) == "function" then
        local okName, n = pcall(C_ChallengeMode.GetMapUIInfo, mapID)
        if okName and n and n ~= "" then mapName = n end
    end
    if not mapName or mapName == "" then
        mapName = "Unknown"
    end

    return {
        level = level,
        mapID = (mapID and mapID > 0) and mapID or nil,
        mapName = mapName,
    }
end

local function MythicMate_BuildExport(section)
    section = MythicMate_EncodeSection(section)
    if not section then return "" end

    if section == "KEYSTONE" then
        local ks = MythicMate_GetOwnedKeystoneSnapshot()
        local lvl = (ks and tonumber(ks.level or 0)) or 0
        local mapID = (ks and tonumber(ks.mapID or 0)) or 0
        local name = (ks and tostring(ks.mapName or "")) or ""
        return table.concat({ tostring(lvl), tostring(mapID > 0 and mapID or ""), name }, "\t")
    end

    if section == "MPLUS" then
        if type(MythicMate_BuildMythicPlusStats) == "function" then
            pcall(MythicMate_BuildMythicPlusStats)
        end

        local summary = MythicMate_ExtractMplusSummaryFromRows(data and data.MPLUS)
        local highestKey = (summary and tonumber(summary.highestKey or 0) or 0)

        local maps = (summary and summary.mapsAtMax) or {}
        if type(maps) ~= "table" then maps = {} end

        -- Keep payload small: encode up to 2 dungeon names at this highest key.
        local maxEncodeMaps = 2
        local mapList = {}
        for i = 1, math.min(#maps, maxEncodeMaps) do
            local m = maps[i]
            if m and m ~= "" then
                mapList[#mapList + 1] = tostring(m)
            end
        end
        local mapsJoined = table.concat(mapList, "|")

        -- Fields: highestKey \t mapsAtMax joined by "|"
        return table.concat({ tostring(highestKey or 0), mapsJoined or "" }, "\t")
    end

    -- Ensure current data is fresh before exporting (RAIDS + DUNGEONS).
    ScanLockouts()

    local list = data[section] or {}
    local lines = {}

    for _, entry in ipairs(list) do
        local name = entry.name or ""
        local progress = entry.progress or ""
        local reset = tonumber(entry.reset or 0) or 0
        local difficulty = entry.difficulty or ""
        local locked = entry.locked and 1 or 0

        -- Encode boss defeat counts for player tooltips:
        -- "defeated/total" derived from entry.encounters (if provided by the scan).
        local bossDefeatedCount, bossTotalCount = 0, 0
        if type(entry.encounters) == "table" then
            for _, enc in ipairs(entry.encounters) do
                if enc and enc.name then
                    bossTotalCount = bossTotalCount + 1
                    if enc.killed then
                        bossDefeatedCount = bossDefeatedCount + 1
                    end
                end
            end
        end

        -- Fallback: raids progress is typically "defeated/total" (e.g. "1/6").
        if bossTotalCount == 0 then
            local p1, p2 = tostring(progress or ""):match("^(%d+)%s*/%s*(%d+)$")
            bossDefeatedCount = tonumber(p1 or 0) or 0
            bossTotalCount = tonumber(p2 or 0) or 0
        end
        if section == "RAIDS" then
            bossDefeatedCount, bossTotalCount = MythicMate_GetRaidBossDisplayCounts(entry)
            if bossTotalCount > 0 then
                progress = tostring(bossDefeatedCount) .. "/" .. tostring(bossTotalCount)
            end
        end
        local bossSummary = tostring(bossDefeatedCount) .. "/" .. tostring(bossTotalCount)

        local mapID = tonumber(entry.mapID or 0) or 0
        local diffId = tonumber(entry.difficultyId or 0) or 0
        if section == "DUNGEONS" and diffId <= 0 then
            diffId = MYTHIC_DUNGEON_ID
        end
        local statusOut = (section == "DUNGEONS" and entry.status and tostring(entry.status) ~= "") and tostring(entry.status) or ""
        if section == "DUNGEONS" then
            local sid = tonumber(entry.savedInstanceID or 0) or 0
            lines[#lines + 1] = table.concat({
                name, progress, tostring(reset), difficulty, tostring(locked), bossSummary,
                tostring(mapID > 0 and mapID or ""),
                tostring(sid > 0 and sid or ""),
                tostring(diffId > 0 and diffId or ""),
                statusOut,
            }, "\t")
        elseif section == "RAIDS" then
            local rk = tonumber(entry.raidKeyID or 0) or 0
            local jid = tonumber(entry.journalInstanceID or 0) or 0
            lines[#lines + 1] = table.concat({
                name, progress, tostring(reset), difficulty, tostring(locked), bossSummary,
                tostring(rk > 0 and rk or ""),
                tostring(jid > 0 and jid or ""),
                tostring(diffId > 0 and diffId or ""),
                "",
            }, "\t")
        end
    end

    if MythicMateDebugComm then
        print("|cffc868ffMythicMate debugcomm:|r export section=" .. tostring(section) .. " count=" .. tostring(#lines))
    end

    return table.concat(lines, "\n")
end

local function MythicMate_SendComm(msgType, section, payload, targetChannel, target)
    if not C_ChatInfo or type(C_ChatInfo.SendAddonMessage) ~= "function" then return end

    local channel = targetChannel
    if not channel or channel == "" then
        channel = MythicMate_GetBestCommChannel()
    end
    if not channel then
        print("|cffc868ffMythicMate:|r You're not in a party/raid/guild to sync with.")
        return
    end

    section = section and tostring(section):upper() or ""
    payload = payload or ""

    local base = table.concat({ COMM_VERSION, msgType, section }, "|") .. "|"

    -- Addon message payload limit is small; chunk conservatively.
    local maxLen = 230
    if #base >= maxLen then return end

    if #payload <= (maxLen - #base) then
        local msg = base .. "1/1|" .. payload
        C_ChatInfo.SendAddonMessage(COMM_PREFIX, msg, channel, target)
        return
    end

    local chunkMax = maxLen - #base - #"999/999|"
    if chunkMax < 20 then return end

    local chunks = {}
    local i = 1
    while i <= #payload do
        chunks[#chunks + 1] = payload:sub(i, i + chunkMax - 1)
        i = i + chunkMax
    end

    local total = #chunks
    for idx, part in ipairs(chunks) do
        local msg = base .. idx .. "/" .. total .. "|" .. part
        C_ChatInfo.SendAddonMessage(COMM_PREFIX, msg, channel, target)
    end
end

local function MythicMate_RequestSync()
    local channel = MythicMate_GetBestCommChannel()
    if not channel then
        return
    end
    MythicMate_SendComm("REQ", "RAIDS", "", channel)
    MythicMate_SendComm("REQ", "DUNGEONS", "", channel)
    MythicMate_SendComm("REQ", "MPLUS", "", channel)
    MythicMate_SendComm("REQ", "KEYSTONE", "", channel)
end

-- Auto-sync (throttled) so users don't need to click anything.
local AUTO_SYNC_MIN_INTERVAL = 120 -- seconds
local AUTO_SYNC_TICK_INTERVAL = 180 -- seconds
local lastAutoSyncAt = 0
local autoSyncQueued = false
local whatLockoutLastGroupCount = 0

local function MythicMate_TryAutoSync()
    local channel = MythicMate_GetBestCommChannel()
    if not channel then return end

    local now = MythicMate_NowSeconds()
    if (now - (lastAutoSyncAt or 0)) < AUTO_SYNC_MIN_INTERVAL then
        return
    end

    lastAutoSyncAt = now
    MythicMate_RequestSync()
end

local function MythicMate_QueueAutoSync(delay)
    if autoSyncQueued then return end
    if not C_Timer or type(C_Timer.After) ~= "function" then return end
    autoSyncQueued = true
    C_Timer.After(delay or 2, function()
        autoSyncQueued = false
        MythicMate_TryAutoSync()
    end)
end

local function MythicMate_HandleAssembledData(senderFull, section, payload)
    if not senderFull or senderFull == "" then return end
    section = MythicMate_EncodeSection(section)
    if not section then return end

    -- Ensure saved variables exist (fresh installs / early load order).
    MythicMateDB = MythicMateDB or {}
    MythicMateDB.shared = MythicMateDB.shared or {}
    MythicMateDB.shared.players = MythicMateDB.shared.players or {}

    local t = MythicMateDB.shared.players[senderFull] or {
        updatedAt = 0,
        updatedAtRaids = 0,
        updatedAtDungeons = 0,
        updatedAtMplus = 0,
        updatedAtKeystone = 0,
        raids = {},
        dungeons = {},
        mplus = nil,
        keystone = nil,
    }
    local out = {}
    local mplusHighestKey, mplusMapsAtMax = nil, {}
    local keystoneLevel, keystoneMapID, keystoneMapName = 0, 0, ""

    if payload and payload ~= "" then
        if section == "MPLUS" then
            -- Fields: highestKey \t mapsAtMax joined by "|"
            local line1 = nil
            for line in payload:gmatch("[^\n]+") do
                line1 = line
                break
            end
            if line1 then
                local parts = { strsplit("\t", line1) }
                local hk = tonumber(parts[1] or "") or 0
                local mapsStr = parts[2] or ""
                local maps = {}
                if mapsStr and mapsStr ~= "" then
                    for m in tostring(mapsStr):gmatch("([^|]+)") do
                        if m and m ~= "" then
                            maps[#maps + 1] = m
                        end
                    end
                end
                mplusHighestKey = hk
                mplusMapsAtMax = maps
            end
        elseif section == "KEYSTONE" then
            local line1 = nil
            for line in payload:gmatch("[^\n]+") do
                line1 = line
                break
            end
            if line1 then
                local parts = { strsplit("\t", line1) }
                keystoneLevel = tonumber(parts[1] or "") or 0
                keystoneMapID = tonumber(parts[2] or "") or 0
                keystoneMapName = tostring(parts[3] or "")
            end
        else
            for line in payload:gmatch("[^\n]+") do
                local parts = { strsplit("\t", line) }
                local name = parts[1]
                local progress = parts[2]
                local reset = parts[3]
                local difficulty = parts[4]
                local locked = parts[5]
                local bossSummary = parts[6]
                local mapIDRaw = parts[7]
                local savedInstRaw = parts[8]
                local difficultyIdRaw = parts[9]
                local statusRaw = parts[10]

                if name and name ~= "" then
                    local bossDefeatedCount, bossTotalCount = 0, 0
                    if bossSummary and bossSummary ~= "" then
                        bossDefeatedCount, bossTotalCount = bossSummary:match("^(%d+)%/(%d+)$")
                        bossDefeatedCount = tonumber(bossDefeatedCount or 0) or 0
                        bossTotalCount = tonumber(bossTotalCount or 0) or 0
                    end
                    local mapID, savedInst, raidKeyID, journalInst = nil, nil, nil, nil
                    if section == "DUNGEONS" then
                        mapID = tonumber(mapIDRaw or "") or nil
                        if not mapID or mapID <= 0 then mapID = nil end
                        savedInst = tonumber(savedInstRaw or "") or nil
                        if not savedInst or savedInst <= 0 then savedInst = nil end
                    elseif section == "RAIDS" then
                        raidKeyID = tonumber(mapIDRaw or "") or nil
                        if not raidKeyID or raidKeyID <= 0 then raidKeyID = nil end
                        journalInst = tonumber(savedInstRaw or "") or nil
                        if not journalInst or journalInst <= 0 then journalInst = nil end
                    end

                    local diffIdIn = tonumber(difficultyIdRaw or "") or nil
                    if not diffIdIn or diffIdIn <= 0 then diffIdIn = nil end
                    local statusIn = tostring(statusRaw or "")
                    if statusIn == "" then statusIn = nil end

                    local row = {
                        name = name,
                        progress = progress or "",
                        reset = tonumber(reset or 0) or 0,
                        difficulty = difficulty or "",
                        locked = (locked == "1"),
                        bossDefeatedCount = bossDefeatedCount,
                        bossTotalCount = bossTotalCount,
                    }
                    if diffIdIn then
                        row.difficultyId = diffIdIn
                    end
                    if section == "DUNGEONS" and statusIn then
                        row.status = statusIn
                    end
                    if mapID then
                        row.mapID = mapID
                    end
                    if savedInst then
                        row.savedInstanceID = savedInst
                    end
                    if raidKeyID then
                        row.raidKeyID = raidKeyID
                    end
                    if journalInst then
                        row.journalInstanceID = journalInst
                    end
                    if section == "RAIDS" then
                        row.bossDefeatedCount, row.bossTotalCount = MythicMate_GetRaidBossDisplayCounts(row)
                    end
                    out[#out + 1] = row
                end
            end
        end
    end

    local nowEpoch = time and time() or 0
    if section == "RAIDS" then
        t.raids = out
        t.updatedAtRaids = nowEpoch
    elseif section == "DUNGEONS" then
        t.dungeons = out
        t.updatedAtDungeons = nowEpoch
    elseif section == "MPLUS" then
        local hk = tonumber(mplusHighestKey or 0) or 0
        t.mplus = {
            highestKey = (hk > 0) and hk or nil,
            mapsAtMax = (type(mplusMapsAtMax) == "table") and mplusMapsAtMax or {},
        }
        t.updatedAtMplus = nowEpoch
    elseif section == "KEYSTONE" then
        local lvl = tonumber(keystoneLevel or 0) or 0
        local mid = tonumber(keystoneMapID or 0) or 0
        local name = tostring(keystoneMapName or "")
        t.keystone = {
            level = (lvl > 0) and lvl or nil,
            mapID = (mid > 0) and mid or nil,
            mapName = name,
        }
        t.updatedAtKeystone = nowEpoch
    end
    t.updatedAt = nowEpoch

    MythicMateDB.shared.players[senderFull] = t

    -- Also store a short-name alias to improve tooltip matching (some tooltips
    -- don't include realm). Avoid overwriting if it's already mapped to another
    -- full name.
    local short = tostring(senderFull):match("^([^%-]+)")
    if short and short ~= "" then
        local existing = MythicMateDB.shared.players[short]
        if not existing or existing == t then
            MythicMateDB.shared.players[short] = t
        end
    end

    if MythicMateDebugComm then
        local cnt = 0
        local lockedCnt = 0
        if section == "RAIDS" then
            cnt = #t.raids
        elseif section == "DUNGEONS" then
            cnt = #t.dungeons
            if type(t.dungeons) == "table" then
                for _, e in ipairs(t.dungeons) do
                    if e and e.locked then lockedCnt = lockedCnt + 1 end
                end
            end
        elseif section == "MPLUS" then
            cnt = (t.mplus and t.mplus.highestKey) and 1 or 0
        elseif section == "KEYSTONE" then
            cnt = (t.keystone and t.keystone.level) and 1 or 0
        end
        print("|cffc868ffMythicMate debugcomm:|r from=" .. tostring(senderFull)
            .. " section=" .. tostring(section)
            .. " entries=" .. tostring(cnt)
            .. " locked=" .. tostring(lockedCnt))
    end
end

local function MythicMate_SaveSelfSnapshot()
    local fullName = MythicMate_GetLocalPlayerFullName()
    if not fullName or fullName == "" then return end

    MythicMateDB = MythicMateDB or {}
    MythicMateDB.shared = MythicMateDB.shared or {}
    MythicMateDB.shared.players = MythicMateDB.shared.players or {}
    MythicMateDB.ownCharacters = MythicMateDB.ownCharacters or {}
    MythicMateDB.ownCharacters[fullName] = true

    local raidsCopy, dungeonsCopy = {}, {}
    for _, e in ipairs(data.RAIDS or {}) do
        local bossDefeatedCount, bossTotalCount = 0, 0
        if type(e.encounters) == "table" then
            for _, enc in ipairs(e.encounters) do
                if enc and enc.name then
                    bossTotalCount = bossTotalCount + 1
                    if enc.killed then
                        bossDefeatedCount = bossDefeatedCount + 1
                    end
                end
            end
        end

        -- Fallback: raids progress is typically "defeated/total" (e.g. "1/6").
        if bossTotalCount == 0 then
            local p1, p2 = tostring(e.progress or ""):match("^(%d+)%s*/%s*(%d+)$")
            bossDefeatedCount = tonumber(p1 or 0) or 0
            bossTotalCount = tonumber(p2 or 0) or 0
        end
        bossDefeatedCount, bossTotalCount = MythicMate_GetRaidBossDisplayCounts(e)
        local progressStr = e.progress
        if bossTotalCount > 0 then
            progressStr = tostring(bossDefeatedCount) .. "/" .. tostring(bossTotalCount)
        end
        raidsCopy[#raidsCopy + 1] = {
            name       = e.name,
            progress   = progressStr,
            reset      = e.reset,
            difficulty = e.difficulty,
            locked     = e.locked,
            encounters = e.encounters,
            bossDefeatedCount = bossDefeatedCount,
            bossTotalCount = bossTotalCount,
            raidKeyID  = e.raidKeyID,
            journalInstanceID = e.journalInstanceID,
            difficultyId = e.difficultyId,
        }
    end
    for _, e in ipairs(data.DUNGEONS or {}) do
        local bossDefeatedCount, bossTotalCount = 0, 0
        if type(e.encounters) == "table" then
            for _, enc in ipairs(e.encounters) do
                if enc and enc.name then
                    bossTotalCount = bossTotalCount + 1
                    if enc.killed then
                        bossDefeatedCount = bossDefeatedCount + 1
                    end
                end
            end
        end
        dungeonsCopy[#dungeonsCopy + 1] = {
            name            = e.name,
            mapID           = e.mapID,
            progress        = e.progress,
            reset           = e.reset,
            difficulty      = e.difficulty,
            locked          = e.locked,
            encounters      = e.encounters,
            bossDefeatedCount = bossDefeatedCount,
            bossTotalCount    = bossTotalCount,
            savedInstanceID   = e.savedInstanceID,
            difficultyId    = e.difficultyId,
            status          = e.status,
        }
    end

    local t = MythicMateDB.shared.players[fullName] or {}
    t.raids = raidsCopy
    t.dungeons = dungeonsCopy
    local ks = MythicMate_GetOwnedKeystoneSnapshot()
    t.keystone = ks and {
        level = ks.level,
        mapID = ks.mapID,
        mapName = ks.mapName,
    } or nil
    -- Great Vault snapshot for the "My Alts" overview (C_WeeklyRewards is per-character, so
    -- we persist the current char's rows here for other characters to read offline).
    local ns = _G.MythicMate
    if ns and ns.Vault and type(ns.Vault.GetGreatVaultRows) == "function" then
        local okV, vrows = pcall(ns.Vault.GetGreatVaultRows)
        t.vault = (okV and type(vrows) == "table") and vrows or nil
    end
    local nowEpoch = time and time() or 0
    t.updatedAt = nowEpoch
    t.updatedAtRaids = nowEpoch
    t.updatedAtDungeons = nowEpoch
    t.updatedAtKeystone = nowEpoch
    MythicMateDB.shared.players[fullName] = t

    -- Keep short-name alias in sync as well.
    local short = tostring(fullName):match("^([^%-]+)")
    if short and short ~= "" then
        local existing = MythicMateDB.shared.players[short]
        if not existing or existing == t then
            MythicMateDB.shared.players[short] = t
        end
    end
end

local function MythicMate_OnCommMessage(prefix, message, channel, sender)
    if prefix ~= COMM_PREFIX then return end
    if type(message) ~= "string" or message == "" then return end

    local ver, msgType, section, chunkInfo, payload = message:match("^([^|]+)|([^|]+)|([^|]*)|([^|]+)|?(.*)$")
    if ver ~= COMM_VERSION or not msgType then return end

    local senderFull = MythicMate_SafeFullPlayerName(sender)
    section = tostring(section or ""):upper()

    if msgType == "REQ" then
        section = MythicMate_EncodeSection(section)
        if not section then return end
        local export = MythicMate_BuildExport(section)
        MythicMate_SendComm("DATA", section, export, "WHISPER", senderFull)
        return
    end

    if msgType ~= "DATA" then return end
    -- Validate the section for DATA too (REQ already does). Without this, a peer or
    -- corrupted traffic can allocate commPending[sender][<arbitrary>] chunk tables that
    -- are only freed on full reassembly, growing unbounded.
    section = MythicMate_EncodeSection(section)
    if not section then return end
    local a, b = tostring(chunkInfo or ""):match("^(%d+)%/(%d+)$")
    local idx = tonumber(a or "")
    local total = tonumber(b or "")
    if not idx or not total or idx < 1 or total < 1 or idx > total then return end

    commPending[senderFull] = commPending[senderFull] or {}
    commPending[senderFull][section] = commPending[senderFull][section] or { total = total, chunks = {} }
    local pending = commPending[senderFull][section]
    if pending.total ~= total then
        pending.total = total
        pending.chunks = {}
    end

    pending.chunks[idx] = payload or ""

    for i = 1, pending.total do
        if pending.chunks[i] == nil then
            return
        end
    end

    local full = table.concat(pending.chunks, "")
    commPending[senderFull][section] = nil
    MythicMate_HandleAssembledData(senderFull, section, full)
    if section == "KEYSTONE" then
        MythicMate_RefreshTooltipIfShown()
    end
end

------------------------------------------------------------
-- Tooltip integration: show lockouts on player hover
------------------------------------------------------------
local tooltipHooked = false
local hoverLastSyncRequestAt = {}
local HOVER_SYNC_COOLDOWN = 30 -- seconds
local HOVER_STALE_DATA_THRESHOLD = 1800 -- seconds (30 min)
local HOVER_STALE_SYNC_COOLDOWN = 90 -- seconds
local hoverSyncPendingUntil = {} -- [playerKey] = GetTime()+seconds
local HOVER_SYNC_PENDING_WINDOW = 12 -- seconds
-- Forward declaration (used by RequestSyncToTarget before actual definition).
local MythicMate_GetCurrentPartyMemberKeys

local function MythicMate_ShouldShowSyncingHint(fullName, snapshotUpdatedAt)
    if not fullName or fullName == "" then return false end
    if type(snapshotUpdatedAt) ~= "number" or snapshotUpdatedAt <= 0 or type(time) ~= "function" then
        return false
    end
    local age = (time() or 0) - snapshotUpdatedAt
    if age <= HOVER_STALE_DATA_THRESHOLD then
        return false
    end
    local nowSec = (type(GetTime) == "function") and GetTime() or 0
    local pending = hoverSyncPendingUntil[fullName] or 0
    return pending > nowSec
end

local function MythicMate_GetPayloadUpdatedAtForSection(payload, section)
    if type(payload) ~= "table" then return 0 end
    if section == "RAIDS" then
        return tonumber(payload.updatedAtRaids or payload.updatedAt or 0) or 0
    elseif section == "DUNGEONS" then
        return tonumber(payload.updatedAtDungeons or payload.updatedAt or 0) or 0
    elseif section == "MPLUS" then
        return tonumber(payload.updatedAtMplus or payload.updatedAt or 0) or 0
    elseif section == "KEYSTONE" then
        return tonumber(payload.updatedAtKeystone or payload.updatedAt or 0) or 0
    end
    return tonumber(payload.updatedAt or 0) or 0
end

local function MythicMate_GetTooltipPlayerKey(tt)
    -- Avoid Unit* APIs in tooltips to prevent "secret string" taint.
    if not tt then return nil end

    local function getAndClean(fs)
        -- HeaderText/TitleText are not guaranteed to be FontString frames in
        -- every tooltip layout; rely on GetText existing (taint-safe).
        if not fs or type(fs.GetText) ~= "function" then return nil end
        local ok, raw = pcall(fs.GetText, fs)
        if not ok then return nil end

        -- Tooltip text can be a "secret string" and may not be comparable/manipulable.
        -- Convert to a normal Lua string inside a protected call.
        local ok2, text = pcall(function()
            -- tostring() is safest for secret strings.
            return tostring(raw)
        end)
        if not ok2 or type(text) ~= "string" then return nil end
        local okLen, len = pcall(string.len, text)
        if not okLen or not len or len <= 0 then return nil end

        -- Strip color codes and trim.
        local ok3, cleaned = pcall(function()
            local t = text:gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", "")
            t = t:gsub("^%s+", ""):gsub("%s+$", "")
            return t
        end)
        if not ok3 or type(cleaned) ~= "string" then return nil end
        local okLen2, len2 = pcall(string.len, cleaned)
        if not okLen2 or not len2 or len2 <= 0 then return nil end
        return cleaned
    end

    -- Tooltip layouts differ by unit type/addons. Don't rely on TextLeft1 only.
    local getFrom = {}
    for i = 1, 15 do
        getFrom[#getFrom + 1] = tt["TextLeft" .. i]
    end

    -- Some tooltip layouts place the player name in a header/title field.
    getFrom[#getFrom + 1] = tt.HeaderText
    getFrom[#getFrom + 1] = tt.TitleText
    if tt.GetName then
        local n = tt:GetName()
        if n then
            for i = 1, 15 do
                local gfs = _G[n .. "TextLeft" .. i]
                if gfs then getFrom[#getFrom + 1] = gfs end
            end
            local header = _G[n .. "HeaderText"]
            local title = _G[n .. "TitleText"]
            if header then getFrom[#getFrom + 1] = header end
            if title then getFrom[#getFrom + 1] = title end
        end
    end

    for i = 1, #getFrom do
        local cleaned = getAndClean(getFrom[i])
        if cleaned then
            -- Often already "Name-Realm" on cross-realm tooltips.
            return cleaned
        end
    end

    return nil
end

-- Unit-token based key extraction for tooltip contexts.
-- Prefer this over reading tooltip text to avoid WoW "secret string" taint.
local function MythicMate_GetPlayerKeyFromUnitToken(unit)
    -- Guards nil/empty AND secret strings (secret check precedes any comparison).
    if MythicMate_TooltipUnitTokenBlocked(unit) then return nil end

    if type(UnitFullName) == "function" then
        local ok, n, r = pcall(UnitFullName, unit)
        if ok and type(n) == "string" and n ~= "" then
            if type(r) == "string" and r ~= "" then
                return n .. "-" .. r
            end
            return n
        end
    end

    if type(UnitName) == "function" then
        local ok, name = pcall(function()
            return select(1, UnitName(unit))
        end)
        if ok and type(name) == "string" and name ~= "" then
            return name
        end
    end

    return nil
end

local function MythicMate_RequestSyncToTarget(targetKey)
    if not targetKey or targetKey == "" then return end
    -- Tooltip names can contain titles/extra text. Prefer a full Name-Realm key.
    local raw = tostring(targetKey)
    local token = raw:match("^([^%s]+)") or raw
    local fullCandidate = raw:match("([%w_]+%-%S+)")
    local fullFromText = nil
    if fullCandidate then
        local left = tostring(fullCandidate):match("^([^%-]+)")
        if left and left:lower() == tostring(token):lower() then
            fullFromText = fullCandidate
        end
    end
    local chosen = fullFromText or token
    if not chosen or chosen == "" then return end

    -- If still short name, resolve against current party/raid roster keys.
    if not chosen:find("-", 1, true) and type(MythicMate_GetCurrentPartyMemberKeys) == "function" then
        local short = chosen:lower()
        local keys = MythicMate_GetCurrentPartyMemberKeys() or {}
        for _, k in ipairs(keys) do
            local s = tostring(k):match("^([^%-]+)")
            if s and s:lower() == short then
                chosen = tostring(k)
                break
            end
        end
    end

    -- If still short name, resolve against cached shared full keys.
    if not chosen:find("-", 1, true) then
        local players = MythicMateDB and MythicMateDB.shared and MythicMateDB.shared.players
        if type(players) == "table" then
            local short = chosen:lower()
            local bestKey, bestAt = nil, -1
            for k, v in pairs(players) do
                local s = tostring(k):match("^([^%-]+)")
                if s and s:lower() == short and tostring(k):find("-", 1, true) then
                    local at = (type(v) == "table" and tonumber(v.updatedAt or 0)) or 0
                    if at > bestAt then
                        bestAt = at
                        bestKey = tostring(k)
                    end
                end
            end
            if bestKey then
                chosen = bestKey
            end
        end
    end

    -- Last fallback: local realm if still no realm part.
    if not chosen:find("-", 1, true) and type(GetRealmName) == "function" then
        local realm = GetRealmName()
        if realm and realm ~= "" then
            chosen = chosen .. "-" .. realm:gsub("%s+", "")
        end
    end

    -- Prefer group channel requests (no invalid-whisper system messages).
    local groupChannel = MythicMate_GetBestCommChannel and MythicMate_GetBestCommChannel() or nil
    if groupChannel == "PARTY" or groupChannel == "RAID" then
        MythicMate_SendComm("REQ", "RAIDS", "", groupChannel)
        MythicMate_SendComm("REQ", "DUNGEONS", "", groupChannel)
        MythicMate_SendComm("REQ", "MPLUS", "", groupChannel)
        MythicMate_SendComm("REQ", "KEYSTONE", "", groupChannel)
        return
    end

    -- If no group channel is available, only then whisper a fully-qualified target.
    if not chosen:find("-", 1, true) then
        if MythicMateDebugTooltip then
            print("|cffc868ffMythicMate debugtooltip:|r skip hover sync, unresolved whisper target=" .. tostring(targetKey))
        end
        return
    end

    MythicMate_SendComm("REQ", "RAIDS", "", "WHISPER", chosen)
    MythicMate_SendComm("REQ", "DUNGEONS", "", "WHISPER", chosen)
    MythicMate_SendComm("REQ", "MPLUS", "", "WHISPER", chosen)
    MythicMate_SendComm("REQ", "KEYSTONE", "", "WHISPER", chosen)
end

local function MythicMate_CollectTooltipCandidates(keyText)
    local candidates = {}
    local seen = {}

    local function add(v)
        if not v or v == "" then return end
        v = tostring(v)
        if seen[v] then return end
        seen[v] = true
        candidates[#candidates + 1] = v
    end

    local key = tostring(keyText or "")
    local keyToken = key:match("^([^%s]+)") or key
    local keyShort = keyToken:match("^([^%-]+)") or keyToken

    add(key)
    add(keyToken)
    add(keyShort)

    local myRealm = GetRealmName and GetRealmName()
    if myRealm and myRealm ~= "" then
        myRealm = myRealm:gsub("%s+", "")
        if not keyShort:find("-", 1, true) then
            add(keyShort .. "-" .. myRealm)
        end
        if not keyToken:find("-", 1, true) then
            add(keyToken .. "-" .. myRealm)
        end
    end

    -- Match against current party/raid roster to handle decorated tooltip names.
    if type(MythicMate_GetCurrentPartyMemberKeys) == "function" then
        local lowerText = key:lower()
        for _, partyKey in ipairs(MythicMate_GetCurrentPartyMemberKeys() or {}) do
            local full = tostring(partyKey)
            local short = full:match("^([^%-]+)") or full
            if lowerText:find(full:lower(), 1, true) or lowerText:find(short:lower(), 1, true) then
                add(full)
                add(short)
            end
        end
    end

    return candidates
end

local function MythicMate_ResolveBestSharedPayload(players, candidates)
    if type(players) ~= "table" or type(candidates) ~= "table" then return nil, nil end

    local bestKey, bestPayload, bestScore = nil, nil, -1
    local seen = {}

    local function scorePayload(p)
        if type(p) ~= "table" then return -1 end
        local hasRaids = (type(p.raids) == "table" and #p.raids > 0) and 1 or 0
        local hasDungeons = (type(p.dungeons) == "table" and #p.dungeons > 0) and 1 or 0
        local hasMplus = (type(p.mplus) == "table" and tonumber(p.mplus.highestKey or 0) and tonumber(p.mplus.highestKey or 0) > 0) and 1 or 0
        local hasKeystone = (type(p.keystone) == "table" and tonumber(p.keystone.level or 0) and tonumber(p.keystone.level or 0) > 0) and 1 or 0
        local updated = tonumber(p.updatedAt or 0) or 0
        -- Prefer raids presence first, then dungeons, then freshness and keystone.
        return (hasRaids * 1000000) + (hasDungeons * 100000) + (hasMplus * 10000) + (hasKeystone * 5000) + updated
    end

    for _, cand in ipairs(candidates) do
        if cand and cand ~= "" and not seen[cand] then
            seen[cand] = true
            local p = players[cand]
            local s = scorePayload(p)
            if s > bestScore then
                bestScore = s
                bestKey = cand
                bestPayload = p
            end
        end
    end

    return bestPayload, bestKey
end

local function MythicMate_ResolveMergedSharedPayload(players, candidates)
    if type(players) ~= "table" or type(candidates) ~= "table" then return nil, nil end

    local merged = { raids = {}, dungeons = {}, mplus = nil, updatedAt = 0, updatedAtMplus = 0, keystone = nil, updatedAtKeystone = 0 }
    local sourceKeys = {}
    local seen = {}
    local shortNames = {}
    local preferredSyncTarget = nil

    for _, cand in ipairs(candidates) do
        if cand and cand ~= "" and not seen[cand] then
            seen[cand] = true
            sourceKeys[#sourceKeys + 1] = cand
            local candToken = tostring(cand):match("^([^%s]+)") or tostring(cand)
            -- Prefer a true Name-Realm key (no spaces, one dash).
            if (not preferredSyncTarget) and candToken:match("^[^%s%-]+%-%S+$") then
                preferredSyncTarget = candToken
            end
            local s = tostring(cand):match("^([^%-]+)")
            if s and s ~= "" then
                shortNames[s] = true
            end
        end
    end

    -- Also include cached keys that share the same short name, but only when
    -- we do not already have a fully-qualified Name-Realm target candidate.
    local hasFullCandidate = false
    for _, cand in ipairs(sourceKeys) do
        if tostring(cand):find("-", 1, true) then
            hasFullCandidate = true
            break
        end
    end
    if not hasFullCandidate then
        for key, _ in pairs(players) do
            local s = tostring(key):match("^([^%-]+)")
            if s and shortNames[s] and not seen[key] then
                seen[key] = true
                sourceKeys[#sourceKeys + 1] = key
            end
        end
    end

    local bestRaidsAt, bestDungeonsAt = -1, -1
    local bestMplusAt = -1
    local bestKeystoneAt = -1
    for _, key in ipairs(sourceKeys) do
        local p = players[key]
        if type(p) == "table" then
            local updated = tonumber(p.updatedAt or 0) or 0
            local updatedRaids = tonumber(p.updatedAtRaids or updated) or updated
            local updatedDungeons = tonumber(p.updatedAtDungeons or updated) or updated
            local updatedMplus = tonumber(p.updatedAtMplus or updated) or updated
            local updatedKeystone = tonumber(p.updatedAtKeystone or updated) or updated
            if updated > (merged.updatedAt or 0) then
                merged.updatedAt = updated
            end
            if type(p.raids) == "table" and #p.raids > 0 and updatedRaids >= bestRaidsAt then
                bestRaidsAt = updatedRaids
                merged.raids = p.raids
                merged.updatedAtRaids = updatedRaids
            end
            if type(p.dungeons) == "table" and #p.dungeons > 0 and updatedDungeons >= bestDungeonsAt then
                bestDungeonsAt = updatedDungeons
                merged.dungeons = p.dungeons
                merged.updatedAtDungeons = updatedDungeons
            end

            local hk = type(p.mplus) == "table" and tonumber(p.mplus.highestKey or 0) or 0
            if hk > 0 and updatedMplus >= bestMplusAt then
                bestMplusAt = updatedMplus
                merged.mplus = p.mplus
                merged.updatedAtMplus = updatedMplus
            end

            local ksLevel = type(p.keystone) == "table" and tonumber(p.keystone.level or 0) or 0
            if ksLevel > 0 and updatedKeystone >= bestKeystoneAt then
                bestKeystoneAt = updatedKeystone
                merged.keystone = p.keystone
                merged.updatedAtKeystone = updatedKeystone
            end
        end
    end

    local hasMplus = type(merged.mplus) == "table" and tonumber(merged.mplus.highestKey or 0) and tonumber(merged.mplus.highestKey or 0) > 0
    local hasKeystone = type(merged.keystone) == "table" and tonumber(merged.keystone.level or 0) and tonumber(merged.keystone.level or 0) > 0
    if (#merged.raids == 0) and (#merged.dungeons == 0) and not hasMplus and not hasKeystone then
        return nil, nil
    end

    merged._sourceKeys = sourceKeys
    local fallback = sourceKeys[1] and ((tostring(sourceKeys[1]):match("^([^%s]+)") or tostring(sourceKeys[1]))) or nil
    merged._syncTargetKey = preferredSyncTarget or fallback
    return merged, (merged._syncTargetKey or sourceKeys[1])
end

-- keep-in-sync ticker uses the function defined later in the file.

local function MythicMate_RequestSyncToAllPartyMembers()
    -- In raids, some clients return IsInGroup() ~= true; allow both group+raid.
    if not ((IsInRaid and type(IsInRaid) == "function" and IsInRaid()) or
            (IsInGroup and type(IsInGroup) == "function" and IsInGroup())) then
        return
    end
    if not (type(MythicMate_GetCurrentPartyMemberKeys) == "function") then return end

    local keys = MythicMate_GetCurrentPartyMemberKeys()
    if type(keys) ~= "table" then return end

    local selfKey = MythicMate_GetLocalPlayerFullName and MythicMate_GetLocalPlayerFullName()

    local now = (type(GetTime) == "function") and GetTime() or 0
    for _, k in ipairs(keys) do
        if k and k ~= "" and KEEP_SYNC_ENABLED and k ~= selfKey then
            local last = keepSyncLastAt[k] or 0
            if (now - last) >= KEEP_SYNC_PER_MEMBER_CD then
                keepSyncLastAt[k] = now
                MythicMate_RequestSyncToTarget(k)
            end
        end
    end
end

local function MythicMate_GetEffectiveSharedResetSeconds(entry, snapshotUpdatedAt)
    local base = tonumber(entry and entry.reset or 0) or 0
    if base <= 0 then return 0 end
    if type(snapshotUpdatedAt) == "number" and snapshotUpdatedAt > 0 and type(time) == "function" then
        local now = time() or 0
        local age = now - snapshotUpdatedAt
        if age > 0 then
            base = base - age
        end
    end
    if base < 0 then base = 0 end
    return base
end

local function MythicMate_IsEffectivelyLockedSharedEntry(entry, snapshotUpdatedAt)
    if not entry or not entry.locked then return false end
    return MythicMate_GetEffectiveSharedResetSeconds(entry, snapshotUpdatedAt) > 0
end

-- Shared: show instance names in the local client language when mapID / raidKeyID are known (tooltips + Shared Party).
local function MythicMate_LocalizeDungeonNameForMapID(mapID, fallback)
    if mapID and type(mapID) == "number" and mapID > 0 and C_ChallengeMode and type(C_ChallengeMode.GetMapUIInfo) == "function" then
        local ok, n = pcall(C_ChallengeMode.GetMapUIInfo, mapID)
        if ok and n and n ~= "" then return n end
    end
    return fallback
end

local function MythicMate_LocalizeRaidNameForKey(raidKeyID, fallback)
    if not raidKeyID or type(raidKeyID) ~= "number" or raidKeyID <= 0 then
        return fallback
    end
    if C_LFGList and type(C_LFGList.GetActivityInfoTable) == "function" then
        local ok, info = pcall(C_LFGList.GetActivityInfoTable, raidKeyID)
        if ok and type(info) == "table" then
            local n = info.fullName or info.shortName or info.name
            if type(n) == "string" and n ~= "" then return n end
        end
    end
    if type(EJ_GetInstanceInfo) == "function" then
        local ok, name = pcall(EJ_GetInstanceInfo, raidKeyID)
        if ok and type(name) == "string" and name ~= "" then return name end
    end
    return fallback
end

local function MythicMate_IsLikelyPvPActivityName(name, instanceType)
    if instanceType == "pvp" or instanceType == "arena" then
        return true
    end
    if not name or name == "" then return false end
    local l = tostring(name)
    l = l:gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", "")
    l = l:lower()
    local tokens = {
        "battleground", "arena", "pvp", "brawl", "skirmish", "battle for",
        "schlacht", "arene", "arène", "поле боя", "арена",
        "전장", "투기장", "战场", "戰場", "竞技场", "競技場",
    }
    for i = 1, #tokens do
        if l:find(tokens[i], 1, true) then
            return true
        end
    end
    return false
end

-- Whether a DUNGEON entry should be treated as a stray PvP activity and hidden.
-- A confirmed challenge-mode dungeon (numeric mapID) is never PvP, even if its name
-- contains a PvP-ish word — otherwise a real season dungeon like "Voidscar Arena" would
-- wrongly vanish from the hover tooltip. The name heuristic only applies to entries whose
-- provenance is unknown (no mapID).
-- (Global on purpose: keeps it out of the main chunk's 200-local budget — see CLAUDE.md.)
function MythicMate_DungeonEntryLooksPvP(e)
    if type(e) ~= "table" then return false end
    local mapID = tonumber(e.mapID)
    if mapID and mapID > 0 then return false end
    return MythicMate_IsLikelyPvPActivityName(e.name)
end

local function MythicMate_GetDungeonStatusCode(entry)
    if MythicMateNS and MythicMateNS.CommsHelpers and type(MythicMateNS.CommsHelpers.GetDungeonStatusCode) == "function" then
        return MythicMateNS.CommsHelpers.GetDungeonStatusCode(entry)
    end
    return "UNKNOWN"
end

-- Stable player-hover panel rendered below GameTooltip (avoids tooltip reflow/flicker).
function MythicMate_HidePlayerHoverPanel()
    local f = _G.MythicMate_PlayerHoverPanel
    if f and f.Hide then f:Hide() end
end

function MythicMate_ShowPlayerHoverPanel(tt, fullName, payload)
    if not tt or not fullName or type(payload) ~= "table" then
        MythicMate_HidePlayerHoverPanel()
        return
    end

    if not _G.MythicMate_PlayerHoverPanel then
        local f = CreateFrame("Frame", "MythicMate_PlayerHoverPanel", UIParent, "BackdropTemplate")
        f:SetFrameStrata("TOOLTIP")
        f:SetToplevel(true)
        -- Clamp the panel to the screen so it stays fully visible on the right/top/left
        -- edges too (the manual placement below only handles the bottom edge).
        if type(f.SetClampedToScreen) == "function" then
            pcall(f.SetClampedToScreen, f, true)
        end
        -- Never let this panel capture mouse; it must not break unit hover.
        if f.EnableMouse then f:EnableMouse(false) end
        if f.SetMouseClickEnabled then f:SetMouseClickEnabled(false) end
        if f.SetMouseMotionEnabled then f:SetMouseMotionEnabled(false) end
        f:SetBackdrop({
            bgFile = "Interface\\Buttons\\WHITE8x8",
            edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
            tile = true, tileSize = 16, edgeSize = 12,
            insets = { left = 3, right = 3, top = 3, bottom = 3 },
        })
        f:SetBackdropColor(0.05, 0.07, 0.12, 0.96)
        f:SetBackdropBorderColor(0.25, 0.85, 0.25, 1)

        f.title = f:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        f.title:SetPoint("TOPLEFT", f, "TOPLEFT", 10, -8)
        f.title:SetJustifyH("LEFT")

        f.text = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        f.text:SetPoint("TOPLEFT", f, "TOPLEFT", 10, -24)
        f.text:SetJustifyH("LEFT")
        f.text:SetJustifyV("TOP")
        f.text:SetSpacing(0)
        if f.text.SetWordWrap then
            f.text:SetWordWrap(true)
        end
        -- Do not self-hide from OnUpdate: GameTooltip often reports IsShown=false for a moment
        -- while the unit tooltip is repopulating; that made the panel vanish "randomly".

        _G.MythicMate_PlayerHoverPanel = f
    end

    local st = MythicMateDB and MythicMateDB.settings
    -- Accept both payload shapes (new: raids/dungeons, legacy: RAIDS/DUNGEONS).
    local raidsList = (type(payload.raids) == "table" and payload.raids)
        or (type(payload.RAIDS) == "table" and payload.RAIDS)
        or {}
    local dungeonsList = (type(payload.dungeons) == "table" and payload.dungeons)
        or (type(payload.DUNGEONS) == "table" and payload.DUNGEONS)
        or {}
    -- Respect user settings for player-hover sections.
    local showRaids = not (st and st.showPlayerHoverTooltipRaids == false)
    local showDungeons = not (st and st.showPlayerHoverTooltipDungeons == false)
    local showMplus = not (st and st.showPlayerHoverTooltipMplus == false)

    -- When the "show only keystone" setting is on (default), drop the base-Mythic (M0) dungeon
    -- list from the hover panel so only the keystone remains. This is the panel that actually
    -- renders the player-hover box (the GameTooltip appender is a separate fallback).
    if showMplus and not (st and st.hideMythicBaseWhenMplus == false) then
        showDungeons = false
    end

    local function listEntries(src)
        if type(src) ~= "table" then return {} end
        local out = {}
        local hasArray = false
        for i, v in ipairs(src) do
            hasArray = true
            out[#out + 1] = v
        end
        if not hasArray then
            for _, v in pairs(src) do
                out[#out + 1] = v
            end
        end
        return out
    end
    raidsList = listEntries(raidsList)
    dungeonsList = listEntries(dungeonsList)

    local lines = {}
    if showMplus then
        local ks = payload.keystone
        local lvl = ks and tonumber(ks.level or 0) or 0
        if lvl > 0 then
            local mapName = tostring(ks.mapName or "")
            if mapName == "" then mapName = "Unknown" end
            lines[#lines + 1] = "|cff66ff66Keystone:|r " .. mapName .. " (" .. tostring(lvl) .. ")"
        else
            lines[#lines + 1] = "|cff66ff66Keystone:|r none"
        end
        if showRaids or showDungeons then
            lines[#lines + 1] = " "
        end
    end

    if showRaids then
        local added = 0
        lines[#lines + 1] = "|cffffd15fRaids:|r"
        for _, e in ipairs(raidsList) do
            if type(e) == "table" and e.name and added < 6 and MythicMate_PlayerHoverRaidDifficultyAllowed(e, st) then
                local d, t = MythicMate_GetRaidBossDisplayCounts(e)
                local isLocked = MythicMate_IsEffectivelyLockedSharedEntry(e, MythicMate_GetPayloadUpdatedAtForSection(payload, "RAIDS")) and true or false
                local rowColor = isLocked and "|cffff6666" or "|cff66ff66"
                local diffLetter = MythicMate_RaidDifficultyLetter(e.difficultyId or e.difficulty)
                local diffSuffix = diffLetter and (" (" .. tostring(diffLetter) .. ")") or ""
                lines[#lines + 1] = rowColor .. "- " .. tostring(MythicMate_GetDisplayInstanceName(e.name)) .. diffSuffix .. " (" .. tostring(d or 0) .. "/" .. tostring(t or 0) .. ")|r"
                added = added + 1
            end
        end
        if added == 0 then lines[#lines + 1] = "- none" end
    end

    if showDungeons then
        if showRaids then
            lines[#lines + 1] = " "
        end
        local added = 0
        lines[#lines + 1] = "|cff9fd3ffDungeons:|r"
        for _, e in ipairs(dungeonsList) do
            if type(e) == "table" and e.name and added < 8 and not MythicMate_DungeonEntryLooksPvP(e) then
                local isLocked = MythicMate_IsEffectivelyLockedSharedEntry(e, MythicMate_GetPayloadUpdatedAtForSection(payload, "DUNGEONS")) and true or false
                local rowColor = isLocked and "|cffff6666" or "|cff66ff66"
                lines[#lines + 1] = rowColor .. "- " .. tostring(MythicMate_GetDisplayInstanceName(e.name)) .. "|r"
                added = added + 1
            end
        end
        if added == 0 then lines[#lines + 1] = "- none" end
    end

    if #lines == 0 then
        lines[#lines + 1] = "- no data"
    end

    local panel = _G.MythicMate_PlayerHoverPanel
    -- Match tooltip font sizing exactly.
    if _G.GameTooltipText and _G.GameTooltipText.GetFont then
        local fontPath, fontSize, fontFlags = _G.GameTooltipText:GetFont()
        if fontPath and fontSize then
            local compactSize = math.max(8, (tonumber(fontSize) or 12) - 1)
            panel.title:SetFont(fontPath, compactSize, fontFlags)
            panel.text:SetFont(fontPath, compactSize, fontFlags)
        end
    end

    -- Match tooltip backdrop and border opacity/colors (with safe fallbacks).
    local br, bg, bb, ba = 0.05, 0.07, 0.12, 0.92
    local rr, rg, rb, ra = 0.25, 0.85, 0.25, 1
    if tt and tt.GetBackdropColor then
        local c1, c2, c3, c4 = tt:GetBackdropColor()
        if c1 ~= nil then
            br, bg, bb, ba = c1, c2, c3, c4
        end
    end
    if tt and tt.GetBackdropBorderColor then
        local d1, d2, d3, d4 = tt:GetBackdropBorderColor()
        if d1 ~= nil then
            rr, rg, rb, ra = d1, d2, d3, d4
        end
    end

    panel.title:SetText("|cffc868ffMythicMate|r")

    panel:ClearAllPoints()
    local a = (tt and type(tt.GetAlpha) == "function") and tt:GetAlpha() or 1
    a = tonumber(a) or 1
    if a < 0 then a = 0 end
    if a > 1 then a = 1 end
    panel:SetAlpha(1)
    if panel.SetBackdropColor then
        panel:SetBackdropColor(br, bg, bb, (tonumber(ba) or 1) * a)
    end
    if panel.SetBackdropBorderColor then
        panel:SetBackdropBorderColor(rr, rg, rb, (tonumber(ra) or 1) * a)
    end
    if panel.title and panel.title.SetAlpha then panel.title:SetAlpha(a) end
    if panel.text and panel.text.SetAlpha then panel.text:SetAlpha(a) end
    -- Size the panel to OUR OWN content (widest line), not the underlying tooltip's width — a wide
    -- RaiderIO tooltip used to stretch our panel, which looks broken when we only show a short
    -- keystone line. Cap at settings.playerHoverMaxWidth (default 340); longer lines wrap.
    -- Latch per hover session (grow-only) to avoid per-tick width jitter from the watchdog.
    local stW = MythicMateDB and MythicMateDB.settings
    local capW = tonumber(stW and stW.playerHoverMaxWidth) or 340
    if capW < 160 then capW = 160 end
    panel.text:SetWidth(0) -- unconstrained so GetStringWidth reports the natural (widest-line) width
    panel.text:SetText(table.concat(lines, "\n"))
    local contentW = tonumber(panel.text.GetStringWidth and panel.text:GetStringWidth()) or 0
    local wantW = math.max(140, math.min(capW, math.ceil(contentW) + 22))
    if panel.MythicMateWidthKey ~= fullName then
        panel.MythicMateWidthKey = fullName
        panel.MythicMateLatchedWidth = wantW
    else
        panel.MythicMateLatchedWidth = math.max(tonumber(panel.MythicMateLatchedWidth) or wantW, wantW)
    end
    panel:SetWidth(panel.MythicMateLatchedWidth)
    panel.text:SetWidth(panel:GetWidth() - 20)
    -- Grow height by actual rendered text (incl. word-wrap), with safe fallback.
    local lineH = 13
    local fallbackByLines = math.max(1, #lines) * lineH
    local renderedTextH = 0
    if panel.text and panel.text.GetStringHeight then
        renderedTextH = tonumber(panel.text:GetStringHeight() or 0) or 0
    end
    if renderedTextH <= 0 then renderedTextH = fallbackByLines end
    local minHeight = 46
    local maxHeight = 760
    local wanted = math.ceil(30 + renderedTextH + 14)
    panel:SetHeight(math.max(minHeight, math.min(maxHeight, wanted)))

    -- Keep panel visible near screen bottom: place above tooltip when needed.
    local showAbove = false
    if tt and tt.GetBottom and UIParent and UIParent.GetBottom and panel.GetHeight then
        local ttBottom = tt:GetBottom()
        local uiBottom = UIParent:GetBottom() or 0
        if ttBottom and (ttBottom - panel:GetHeight() - 12) < uiBottom then
            showAbove = true
        end
    end
    if showAbove then
        panel:SetPoint("BOTTOMLEFT", tt, "TOPLEFT", 0, 10)
    else
        panel:SetPoint("TOPLEFT", tt, "BOTTOMLEFT", 0, -10)
    end
    panel:Show()
end

-- Tooltip append (in-tooltip, flicker-safe)
function MythicMate_AppendDungeonLinesToTooltip(tt, fullName, payload)
    if not tt or not fullName then return end
    if not payload then return end

    -- Keep the tooltip within the viewport.
    -- In instances, Blizzard can re-position the tooltip after repaints,
    -- and our added lines can otherwise cause "off-screen" text.
    if not tt.MythicMateClampedToScreen and type(tt.SetClampedToScreen) == "function" then
        tt.MythicMateClampedToScreen = true
        pcall(tt.SetClampedToScreen, tt, true)
    end

    local st = MythicMateDB and MythicMateDB.settings
    local showRaids = not (st and st.showPlayerHoverTooltipRaids == false)
    local showDungeons = not (st and st.showPlayerHoverTooltipDungeons == false)
    local showMplus = not (st and st.showPlayerHoverTooltipMplus == false)

    -- Raid difficulty filters (PLAYER HOVER tooltip raids section).
    -- Default: Looking for Raid OFF, Normal/Heroic/Mythic ON.
    local showRaidLFR = st and st.showPlayerHoverTooltipRaidLFR == true or false
    local showRaidNormal = not (st and st.showPlayerHoverTooltipRaidNormal == false)
    local showRaidHeroic = not (st and st.showPlayerHoverTooltipRaidHeroic == false)
    local showRaidMythic = not (st and st.showPlayerHoverTooltipRaidMythic == false)
    local anyRaidDifficultyEnabled = showRaidLFR or showRaidNormal or showRaidHeroic or showRaidMythic
    showRaids = showRaids and anyRaidDifficultyEnabled or false

    local hasKeystone = type(payload.keystone) == "table" and tonumber(payload.keystone.level or 0) and tonumber(payload.keystone.level or 0) > 0
    local showKeystone = showMplus
    local effectiveHasKeystone = showKeystone and hasKeystone or false

    -- Drop the base-Mythic (M0) dungeon lockouts from the hover tooltip so it shows only the
    -- player's keystone. Configurable; default ON. During an M+ season (essentially always while
    -- playing) the keystone is what matters, so this is unconditional when the setting is on —
    -- earlier season-API detection was unreliable and left the dungeons showing.
    if showMplus and not (st and st.hideMythicBaseWhenMplus == false) then
        showDungeons = false
    end
    local function formatKeystoneValue()
        if Util and type(Util.FormatKeystoneTooltipValue) == "function" then
            return Util.FormatKeystoneTooltipValue(payload.keystone, {
                localizeDungeonName = MythicMate_LocalizeDungeonNameForMapID,
                displayInstanceName = MythicMate_GetDisplayInstanceName,
            })
        end

        -- Fallback (should not happen with normal load order).
        if not hasKeystone then return "  none" end
        local ksLevel = tonumber(payload.keystone.level or 0) or 0
        local mapName = tostring(payload.keystone.mapName or "") or ""
        return "  " .. tostring(mapName) .. " (" .. tostring(ksLevel) .. ")"
    end

    local mplusSuffix = ""

    local raidListFiltered = nil
    if type(payload.raids) == "table" then
        raidListFiltered = {}
        -- Player tooltip: keep the old name-based current-tier filter only (no raidKeyID).
        -- ID-based matching stays in Shared Party / sync payloads to avoid tooltip quirks.
        local currentRaidSet = MythicMate_GetCurrentRaidNameSet()
        local hasCurrentSet = (type(currentRaidSet) == "table" and next(currentRaidSet) ~= nil)
        for _, e in ipairs(payload.raids) do
            if e and e.name and hasCurrentSet then
                local norm = MythicMate_NormalizeName(e.name)
                local isPvP = MythicMate_IsLikelyPvPActivityName(e.name)
                if norm and currentRaidSet[norm] and not isPvP then
                    -- Difficulty filtering: use the difficultyName string we store
                    -- from GetSavedInstanceInfo (e.g. "Looking for Raid", "Normal", ...).
                    local diffName = tostring(e.difficulty or e.difficultyName or "")
                    diffName = diffName:lower()
                    local allowed = true
                    if diffName:find("looking for") and diffName:find("raid") then
                        allowed = showRaidLFR
                    elseif diffName:find("normal") then
                        allowed = showRaidNormal
                    elseif diffName:find("heroic") then
                        allowed = showRaidHeroic
                    elseif diffName:find("mythic") then
                        allowed = showRaidMythic
                    end

                    if allowed then
                        raidListFiltered[#raidListFiltered + 1] = e
                    end
                end
            end
        end
    end
    local dungeonListFiltered = nil
    if type(payload.dungeons) == "table" then
        dungeonListFiltered = {}
        for _, e in ipairs(payload.dungeons) do
            if e and e.name and not MythicMate_DungeonEntryLooksPvP(e) then
                dungeonListFiltered[#dungeonListFiltered + 1] = e
            end
        end
    end
    local hasRaids = (type(raidListFiltered) == "table" and #raidListFiltered > 0)
    local hasDungeons = (type(dungeonListFiltered) == "table" and #dungeonListFiltered > 0)
    local effectiveHasRaids = showRaids and hasRaids or false
    local effectiveHasDungeons = showDungeons and hasDungeons or false
    if not effectiveHasRaids and not effectiveHasDungeons and not effectiveHasKeystone then
        -- If the user disabled raids/dungeons/M+ entirely, do not append anything.
        if not showRaids and not showDungeons and not showKeystone then
            return
        end
        if tt.MythicMateAppendedFor == fullName then return end
        tt.MythicMateAppendedFor = fullName
        if not tt.MythicMateHeaderAdded then
            tt:AddLine(" ")
            tt:AddLine("|cffc868ffMythicMate|r" .. mplusSuffix, 1, 0.82, 1)
            tt.MythicMateHeaderAdded = true
        end
        -- Keep a stable section layout so users can see if raid data is just
        -- not synced yet vs truly absent.
        local raidsAt = MythicMate_GetPayloadUpdatedAtForSection(payload, "RAIDS")
        local dungeonsAt = MythicMate_GetPayloadUpdatedAtForSection(payload, "DUNGEONS")
        if showRaids then
            local syncing = MythicMate_ShouldShowSyncingHint(fullName, raidsAt)
            tt:AddLine("Raids:", 1, 0.82, 0)
            tt:AddLine(syncing and "- syncing..." or "- none", 0.75, 0.75, 0.75)
        end
        if showDungeons then
            local syncing = MythicMate_ShouldShowSyncingHint(fullName, dungeonsAt)
            tt:AddLine("Dungeons:", 1, 0.82, 0)
            tt:AddLine(syncing and "- syncing..." or "- none", 0.75, 0.75, 0.75)
        end
        return
    end

    -- Only append once per tooltip "session".
    if tt.MythicMateAppendedFor == fullName then return end
    tt.MythicMateAppendedFor = fullName

    -- If we've already added the header for this tooltip instance, don't append again
    -- (prevents duplicate lines + flicker when tooltip name matching jitters).
    if tt.MythicMateHeaderAdded then return end

    -- Eén duidelijke header per tooltip, niet dubbel.
    if not tt.MythicMateHeaderAdded then
        tt:AddLine(" ")
        tt:AddLine("|cffc868ffMythicMate|r" .. mplusSuffix, 1, 0.82, 1)
        tt.MythicMateHeaderAdded = true
    end

    -- Keep tooltip compact to avoid spilling outside the frame.
    local startHeight = tt.GetHeight and tt:GetHeight() or 0
    local shown = 0
    -- Hard cap on how many LINES we add to the *player hover tooltip*.
    -- This prevents "off-screen" rendering in instances where Blizzard
    -- may re-position/reflow the tooltip after our repaints.
    local MAX_ADDED_LINES = 16

    -- Keystone section (player hover tooltip).
    if showKeystone then
        tt:AddLine("Keystone:", 1, 0.82, 0)
        shown = shown + 1
        if shown >= MAX_ADDED_LINES then return end
        if hasKeystone then
            tt:AddLine(formatKeystoneValue(), 0.4, 1, 0.4)
        else
            tt:AddLine("  none", 0.75, 0.75, 0.75)
        end
        shown = shown + 1
        if shown >= MAX_ADDED_LINES then return end

        -- Small visual spacing before the next section.
        local willShowOtherSections = showDungeons or (showRaids and hasRaids)
        if willShowOtherSections then
            tt:AddLine(" ")
            shown = shown + 1
            if shown >= MAX_ADDED_LINES then return end
        end
    end

    -- Raids section: only show when there are actual raid lockouts.
    local raidList = raidListFiltered
    local addedRaidsSection = false
    if showRaids and type(raidList) == "table" and #raidList > 0 then
        tt:AddLine("Raids:", 1, 0.82, 0)
        shown = shown + 1
        if shown >= MAX_ADDED_LINES then return end
        addedRaidsSection = true

        -- Keep tooltip compact to avoid off-screen tooltips.
        -- When both raids and dungeons are enabled, we reduce further.
        local concurrentRaidsAndDungeons = showDungeons and effectiveHasDungeons and showRaids and effectiveHasRaids or false
        local raidCap = concurrentRaidsAndDungeons and 5 or 8
        local raidMax = math.min(#raidList, raidCap)
        local count = 0
        for _, e in ipairs(raidList) do
            if e and e.name then
                count = count + 1
                if count > raidMax then break end

                local name = MythicMate_GetDisplayInstanceName(e.name or "Unknown")
                local diffLetter = MythicMate_RaidDifficultyLetter(e.difficultyId or e.difficulty)
                local diffSuffix = diffLetter and (" (" .. diffLetter .. ")") or ""
                local defeated, total = MythicMate_GetRaidBossDisplayCounts(e)

                local progSuffix = ""
                if total and total > 0 then
                    progSuffix = " (" .. tostring(defeated or 0) .. "/" .. tostring(total) .. ")"
                end

                local effectiveLocked = MythicMate_IsEffectivelyLockedSharedEntry(e, MythicMate_GetPayloadUpdatedAtForSection(payload, "RAIDS"))
                local raidCleared = (total and total > 0 and defeated >= total)
                local raidInProgress = effectiveLocked and total and total > 0 and defeated < total
                if not effectiveLocked then
                    tt:AddLine("- " .. name .. diffSuffix .. progSuffix .. " (Ready)", 0.3, 1, 0.3)
                elseif raidInProgress then
                    tt:AddLine("- " .. name .. diffSuffix .. progSuffix .. " (In progress)", 0.3, 1, 0.3)
                elseif raidCleared then
                    tt:AddLine("- " .. name .. diffSuffix .. progSuffix .. " (Cleared)", 1, 0.3, 0.3)
                else
                    tt:AddLine("- " .. name .. diffSuffix .. progSuffix .. " (Locked)", 1, 0.3, 0.3)
                end
                shown = shown + 1
                if shown >= MAX_ADDED_LINES then return end
            end
        end
    end

    -- Dungeons section.
    if showDungeons then
        local dungeonList = dungeonListFiltered
        -- Visual spacing after raids block.
        if addedRaidsSection then
            tt:AddLine(" ")
            shown = shown + 1
            if shown >= MAX_ADDED_LINES then return end
        end

        tt:AddLine("Dungeons:", 1, 0.82, 0)
        shown = shown + 1
        if shown >= MAX_ADDED_LINES then return end

        if type(dungeonList) == "table" and #dungeonList > 0 then
                -- Cap to prevent huge tooltips going outside frame and causing reflow flicker.
                local concurrentRaidsAndDungeons = showRaids and effectiveHasRaids and effectiveHasDungeons or false
                local dungeonMax = concurrentRaidsAndDungeons and 8 or 12
                local dungeonCount = #dungeonList
                local shownCount = 0
                local limit = math.min(dungeonMax, dungeonCount)
                for i = 1, limit do
                    local e = dungeonList[i]
                    if e then
                        local name = MythicMate_GetDisplayInstanceName(e.name or "Unknown")
                        local effectiveLocked = MythicMate_IsEffectivelyLockedSharedEntry(
                            e,
                            MythicMate_GetPayloadUpdatedAtForSection(payload, "DUNGEONS")
                        )

                        local defeated = tonumber(e.bossDefeatedCount or 0) or 0
                        local total = tonumber(e.bossTotalCount or 0) or 0
                        local progSuffix = ""
                        if total and total > 0 then
                            progSuffix = " (" .. tostring(defeated) .. "/" .. tostring(total) .. ")"
                        end

                        local inProgress = effectiveLocked and total and total > 0 and defeated < total
                        if not effectiveLocked then
                            tt:AddLine("- " .. name .. progSuffix .. " (Ready)", 0.3, 1, 0.3)
                        elseif inProgress then
                            tt:AddLine("- " .. name .. progSuffix .. " (In progress)", 0.3, 1, 0.3)
                        else
                            tt:AddLine("- " .. name .. progSuffix .. " (Locked)", 1, 0.3, 0.3)
                        end
                        shown = shown + 1
                        shownCount = shownCount + 1
                        if shown >= MAX_ADDED_LINES then return end
                    end
                end
                if dungeonCount > dungeonMax then
                    local more = dungeonCount - shownCount
                    if more > 0 then
                        tt:AddLine("- ... (" .. tostring(more) .. " more)", 0.6, 0.6, 0.6)
                        shown = shown + 1
                        if shown >= MAX_ADDED_LINES then return end
                    end
                end
        else
            local syncing = MythicMate_ShouldShowSyncingHint(fullName, MythicMate_GetPayloadUpdatedAtForSection(payload, "DUNGEONS"))
            tt:AddLine(syncing and "- syncing..." or "- none", 0.75, 0.75, 0.75)
            shown = shown + 1
            if shown >= MAX_ADDED_LINES then return end
        end
    end

end

local function MythicMate_RenderHoverPanelForUnit(tt, unit)
    -- Mid-refresh the tooltip can be off-screen or not IsShown for a tick; never tear down
    -- the panel here (GameTooltip OnHide + watchdog debounce handle real dismiss).
    if not tt or type(tt.IsShown) ~= "function" or not tt:IsShown() then
        return false
    end
    if MythicMateDB.settings and MythicMateDB.settings.showPlayerHoverTooltip == false then
        pcall(MythicMate_HidePlayerHoverPanel)
        return false
    end

    local key = nil
    if not MythicMate_TooltipUnitTokenBlocked(unit) then
        key = MythicMate_GetPlayerKeyFromUnitToken(unit)
    end
    if not key and type(tt.GetUnit) == "function" then
        local ok, _, u = pcall(tt.GetUnit, tt)
        if ok and type(u) == "string" and not MythicMate_TooltipUnitTokenBlocked(u) then
            key = MythicMate_GetPlayerKeyFromUnitToken(u)
        end
    end
    -- Prefer mouseover before scraping tooltip text: line 1 is often not the player name
    -- (titles, addons), which yields a bogus key and makes the panel disappear until /reload.
    if not key and UnitExists and UnitExists("mouseover") then
        key = MythicMate_GetPlayerKeyFromUnitToken("mouseover")
    end
    if not key then
        key = MythicMate_GetTooltipPlayerKey(tt)
    end
    if not key or key == "nil" then
        pcall(MythicMate_HidePlayerHoverPanel)
        return false
    end

    local me = MythicMate_GetLocalPlayerFullName and MythicMate_GetLocalPlayerFullName()
    if me then
        local keyToken = key:match("^([^%s]+)") or key
        local keyShort = keyToken:match("^([^%-]+)") or keyToken
        local meShort = me:match("^([^%-]+)") or me
        if key == me or keyToken == me or keyShort == meShort then
            local mp = MythicMate_ExtractMplusSummaryFromRows(data and data.MPLUS)
            if not mp or not mp.highestKey then
                if type(MythicMate_BuildMythicPlusStats) == "function" then
                    pcall(MythicMate_BuildMythicPlusStats)
                end
                mp = MythicMate_ExtractMplusSummaryFromRows(data and data.MPLUS)
            end
            local payload = {
                raids = data.RAIDS or {},
                dungeons = data.DUNGEONS or {},
                mplus = mp,
                keystone = MythicMate_GetOwnedKeystoneSnapshot and MythicMate_GetOwnedKeystoneSnapshot() or nil,
            }
            tt.MythicMateLastPlayerKey = key
            pcall(MythicMate_ShowPlayerHoverPanel, tt, key, payload)
            return true
        end
    end

    local players = MythicMateDB.shared and MythicMateDB.shared.players
    if type(players) ~= "table" then
        pcall(MythicMate_HidePlayerHoverPanel)
        return false
    end

    -- The watchdog re-enters this ~12x/sec for the same hovered unit. Rebuilding the
    -- candidate/merge tables every tick is wasteful, so cache the resolved payload per key
    -- for a short window. We still call ShowPlayerHoverPanel every tick (keeps the panel
    -- reliably visible); we only skip the expensive resolve while the key is unchanged and
    -- fresh (< 1s), so newly-synced data still appears within a second.
    local now = (type(GetTime) == "function") and GetTime() or 0
    local payload
    if tt.MythicMateSharedCacheKey == key
        and type(tt.MythicMateSharedCachePayload) == "table"
        and tt.MythicMateSharedCacheAt and (now - tt.MythicMateSharedCacheAt) < 1 then
        payload = tt.MythicMateSharedCachePayload
    else
        local candidates = MythicMate_CollectTooltipCandidates(key)
        payload = MythicMate_ResolveMergedSharedPayload(players, candidates)
        if type(payload) ~= "table" then
            payload = MythicMate_ResolveBestSharedPayload(players, candidates)
        end
        if type(payload) == "table" then
            tt.MythicMateSharedCacheKey = key
            tt.MythicMateSharedCachePayload = payload
            tt.MythicMateSharedCacheAt = now
        end
    end
    if type(payload) ~= "table" then
        pcall(MythicMate_HidePlayerHoverPanel)
        return false
    end

    tt.MythicMateLastPlayerKey = key
    pcall(MythicMate_ShowPlayerHoverPanel, tt, key, payload)
    return true
end

function MythicMate_PlayerHoverWatchdogTick()
    if MythicMateDB and MythicMateDB.settings and MythicMateDB.settings.showPlayerHoverTooltip == false then
        pcall(MythicMate_HidePlayerHoverPanel)
        return
    end
    local tt = _G.GameTooltip
    -- When the tooltip isn't shown we normally leave the panel alone (IsShown can flap for a tick
    -- mid-repopulate, and tearing down here once made the panel vanish until /reload). But if the
    -- panel LINGERS visible while no tooltip is up AND the mouse isn't over any unit, GameTooltip's
    -- OnHide didn't fire (happens sporadically on re-anchored/unit-frame tooltips) — clean it up
    -- after a short debounce. The mouseover guard prevents hiding while you're genuinely hovering.
    if not tt or type(tt.IsShown) ~= "function" or not tt:IsShown() then
        local panel = _G.MythicMate_PlayerHoverPanel
        local overUnit = (type(UnitExists) == "function") and UnitExists("mouseover")
        if panel and panel.IsShown and panel:IsShown() and not overUnit then
            _G.MythicMate_HoverStaleTicks = (_G.MythicMate_HoverStaleTicks or 0) + 1
            if _G.MythicMate_HoverStaleTicks >= 3 then -- ~0.24s of no tooltip + no mouseover
                _G.MythicMate_HoverStaleTicks = 0
                pcall(MythicMate_HidePlayerHoverPanel)
            end
        else
            _G.MythicMate_HoverStaleTicks = 0
        end
        return
    end
    _G.MythicMate_HoverStaleTicks = 0
    local unit = nil
    if type(tt.GetUnit) == "function" then
        local ok, _, u = pcall(tt.GetUnit, tt)
        if ok and type(u) == "string" and not MythicMate_TooltipUnitTokenBlocked(u) then
            unit = u
        end
    end
    pcall(MythicMate_RenderHoverPanelForUnit, tt, unit)
end

MythicMate_RefreshTooltipIfShown = function()
    -- Force tooltip to re-run our append when data changes (e.g. after dungeon).
    -- Important: resolve the player key from the tooltip's unit-token, not from
    -- tooltip text. Reading tooltip text can produce WoW "secret string" taint.
    local tt = GameTooltip
    if not tt or type(tt.IsShown) ~= "function" or not tt:IsShown() then return end

    if MythicMateDB.settings and MythicMateDB.settings.showPlayerHoverTooltip == false then
        pcall(MythicMate_HidePlayerHoverPanel)
        return
    end

    local unit = nil
    if type(tt.GetUnit) == "function" then
        local ok, _, u = pcall(tt.GetUnit, tt)
        if ok and type(u) == "string" and not MythicMate_TooltipUnitTokenBlocked(u) then
            unit = u
        end
    end
    -- After zoning / M+ complete, GetUnit is often empty briefly; RenderHoverPanelForUnit
    -- still resolves mouseover + tooltip text — do not bail out here.

    -- Debounce refresh to avoid tooltip churn (bypass with MythicMate_LastTooltipRefreshAt = nil).
    -- The hover panel is a custom frame (not a Blizzard tooltip reflow), so a short window is
    -- safe and lets the panel reflect fresh data (post-dungeon / new sync) much sooner.
    local now = (type(GetTime) == "function") and GetTime() or 0
    local last = MythicMate_LastTooltipRefreshAt
    if last ~= nil and (now - last) < 2 then
        return
    end
    MythicMate_LastTooltipRefreshAt = now

    pcall(MythicMate_RenderHoverPanelForUnit, tt, unit)
end

function MythicMate_RefreshPlayerHoverPanelNow()
    local tt = GameTooltip
    if not tt or type(tt.IsShown) ~= "function" or not tt:IsShown() then return end
    if MythicMateDB and MythicMateDB.settings and MythicMateDB.settings.showPlayerHoverTooltip == false then
        return
    end
    local unit = nil
    if type(tt.GetUnit) == "function" then
        local ok, _, u = pcall(tt.GetUnit, tt)
        if ok and type(u) == "string" and not MythicMate_TooltipUnitTokenBlocked(u) then
            unit = u
        end
    end
    pcall(MythicMate_RenderHoverPanelForUnit, tt, unit)
end

-- Append into the *currently shown* tooltip without flickering/blowing away
-- Blizzard tooltip internal state.
MythicMate_ForceAppendToTooltip = function(tt, unit)
    return MythicMate_RenderHoverPanelForUnit(tt, unit)
end

local function MythicMate_HookUnitTooltip()
    if tooltipHooked then return end
    if not GameTooltip then return end
    if not _G.MythicMate_PlayerHoverWatchdogFrame then
        _G.MythicMate_PlayerHoverWatchdogFrame = CreateFrame("Frame", "MythicMate_PlayerHoverWatchdogFrame", UIParent)
        _G.MythicMate_PlayerHoverWatchdogFrame.acc = 0
        _G.MythicMate_PlayerHoverWatchdogFrame:SetScript("OnUpdate", function(self, elapsed)
            self.acc = (tonumber(self.acc) or 0) + (tonumber(elapsed) or 0)
            if self.acc < 0.08 then return end
            self.acc = 0
            pcall(MythicMate_PlayerHoverWatchdogTick)
        end)
    end

    local inAppend = false

    local function tooltipHasMythicMateHeader(tt)
        if not tt or type(tt.NumLines) ~= "function" then return false end
        local ok, has = pcall(function()
            local n = tt:NumLines() or 0
            if n <= 0 then return false end
            local name = tt.GetName and tt:GetName() or nil
            if not name or name == "" then return false end
            for i = 1, n do
                local fs = _G[name .. "TextLeft" .. i]
                if fs and fs.GetText then
                    local text = fs:GetText()
                    if type(text) == "string" and text ~= "" then
                        if text:find("|cffc868ffMythicMate|r", 1, true) or text:find("MythicMate", 1, true) then
                            return true
                        end
                    end
                end
            end
            return false
        end)
        return ok and has or false
    end

    local function tooltipRemoveMythicMateSection(tt)
        if not tt or type(tt.NumLines) ~= "function" then return false end
        -- Never mutate hidden tooltips; forcing layout on hidden tooltips can
        -- create offset/boundary glitches on the next hover.
        if type(tt.IsShown) == "function" and not tt:IsShown() then
            return false
        end
        local ok = pcall(function()
            local n = tt:NumLines() or 0
            if n <= 0 then return end
            local name = tt.GetName and tt:GetName() or nil
            if not name or name == "" then return end

            local inSection = false
            local removedAny = false
            local maxScan = math.min(n, 40)
            for i = 1, maxScan do
                local fs = _G[name .. "TextLeft" .. i]
                if fs and fs.GetText and fs.SetText then
                    local text = fs:GetText()
                    if type(text) == "string" then
                        if not inSection then
                            if text:find("|cffc868ffMythicMate|r", 1, true) or text:find("MythicMate", 1, true) then
                                inSection = true
                                fs:SetText("")
                                removedAny = true
                            end
                        else
                            -- Clear only lines that look like OUR block.
                            -- Stop when we hit a line that clearly belongs to other addons/UI.
                            local lower = text:lower()
                            local looksLikeOurs =
                                text == " " or text == "" or
                                lower:find("keystone:", 1, true) or
                                lower:find("raids:", 1, true) or
                                lower:find("dungeons:", 1, true) or
                                text:sub(1, 2) == "- " or
                                text:sub(1, 3) == "  " or
                                lower:find("syncing", 1, true) or
                                lower:find("ready", 1, true) or
                                lower:find("locked", 1, true) or
                                lower:find("in progress", 1, true) or
                                lower:find("cleared", 1, true) or
                                lower:find("more)", 1, true)

                            local looksLikeOtherAddon =
                                lower:find("raider%.io", 1, true) or
                                lower:find("right click", 1, true)

                            if looksLikeOtherAddon or (not looksLikeOurs) then
                                break
                            end

                            fs:SetText("")
                            removedAny = true
                        end
                    end
                end
            end

            -- Do not call tt:Show() here; that can reflow/re-anchor incorrectly
            -- after challenge/dungeon transitions.
        end)
        return ok
    end

    local function tooltipClearMythicMateState(tt, removeSection)
        if not tt then return end
        if removeSection and tooltipHasMythicMateHeader(tt) then
            tooltipRemoveMythicMateSection(tt)
        end
        tt.MythicMateAppendedFor = nil
        tt.MythicMateHeaderAdded = nil
        tt.MythicMateBaseStartHeight = nil
    end

    local function tryAppendNow(tt, unitToken)
        if not tt or inAppend then return end
        if MythicMateDB.settings and MythicMateDB.settings.showPlayerHoverTooltip == false then
            pcall(MythicMate_HidePlayerHoverPanel)
            return
        end
        pcall(MythicMate_RenderHoverPanelForUnit, tt, unitToken)
    end

    local function scheduleForTooltip(tt, unitToken)
        if not tt then return end

        -- Respect user setting (default enabled).
        if MythicMateDB.settings and MythicMateDB.settings.showPlayerHoverTooltip == false then
            pcall(MythicMate_HidePlayerHoverPanel)
            return
        end

        -- Reset marker when tooltip clears/hides.
        if not tt.MythicMateClearHooked and type(tt.HookScript) == "function" then
            tt.MythicMateClearHooked = true
            -- WoW kan OnTooltipCleared spammen tijdens re-populates terwijl
            -- je nog hover't. Resetten hier kan opnieuw appenden veroorzaken
            -- en knipperen geven. Alleen resetten bij OnHide.
            tt:HookScript("OnTooltipCleared", function(t)
                local hasHeader = tooltipHasMythicMateHeader(t)
                if not hasHeader then
                    t.MythicMateAppendedFor = nil
                    t.MythicMateHeaderAdded = nil
                else
                    t.MythicMateHeaderAdded = true
                end
                t.MythicMateBaseStartHeight = nil
                t.MythicMatePostCallSeenAt = nil
            end)
            tt:HookScript("OnHide", function(t)
                t.MythicMateAppendedFor = nil
                t.MythicMateHeaderAdded = nil
                t.MythicMateBaseStartHeight = nil
                t.MythicMatePostCallSeenAt = nil
                pcall(MythicMate_HidePlayerHoverPanel)
            end)
        end

        tt.MythicMateToken = (tt.MythicMateToken or 0) + 1
        local token = tt.MythicMateToken

        local function tryAppend()
            if not tt or tt.MythicMateToken ~= token then return true end -- stop
            if inAppend then return false end -- retry later

            local ok, shown = pcall(MythicMate_RenderHoverPanelForUnit, tt, unitToken)
            if ok and shown then return true end
            return false
        end

        -- Try a few times as the tooltip fills in (no flicker; we append once).
        if C_Timer and type(C_Timer.After) == "function" then
            -- Retry window for async tooltip/unit updates in dungeons.
            local delays = { 0, 0.05, 0.12, 0.2, 0.32, 0.5, 0.75, 1.05, 1.4 }
            for _, d in ipairs(delays) do
                C_Timer.After(d, function()
                    local stop = tryAppend()
                    if stop then return end
                end)
            end
        else
            tryAppend()
        end
    end

    tooltipHooked = true

    -- Preferred: modern tooltip pipeline.
    -- We still keep the fallback hooks enabled so that older clients
    -- (or cases where PostCall isn't fired) still get our tooltip lines.
    if TooltipDataProcessor and type(TooltipDataProcessor.AddTooltipPostCall) == "function" and
       Enum and Enum.TooltipDataType and Enum.TooltipDataType.Unit then
        pcall(function()
            TooltipDataProcessor.AddTooltipPostCall(Enum.TooltipDataType.Unit, function(tt, data)
                -- PostCall is already after the tooltip is populated; append immediately.
                if tt and type(GetTime) == "function" then
                    tt.MythicMatePostCallSeenAt = GetTime()
                end
                local unitToken = data and (data.unit or data.unitToken or data.ownerUnit or data.targetUnit) or nil
                tryAppendNow(tt, unitToken)
            end)
        end)
    end

    -- Fallback: hook GameTooltip's unit script only if it exists on this client.
    if type(GameTooltip.HookScript) == "function" and
       type(GameTooltip.HasScript) == "function" and GameTooltip:HasScript("OnTooltipSetUnit") then
        GameTooltip:HookScript("OnTooltipSetUnit", function(tt, unit)
            -- Immediate attempt first (best responsiveness).
            tryAppendNow(tt, unit)
            scheduleForTooltip(tt, unit)
        end)
    end

    -- Keep one deterministic fallback hook path (OnTooltipSetUnit).
end

------------------------------------------------------------
-- Sharing helpers
------------------------------------------------------------
local shareChannels = {
    { key = "PARTY",  text = "Party",  cmd = "PARTY" },
    { key = "GUILD",  text = "Guild",  cmd = "GUILD" },
    { key = "WHISPER", text = "Whisper", cmd = "WHISPER" },
}

-- Sharing state lives on _G.MythicMate; never replace the global with a fresh {} here (would drop Util/helpers).
local MythicMate = _G.MythicMate
if type(MythicMate) ~= "table" then
    MythicMate = {}
    _G.MythicMate = MythicMate
end
MythicMate.State = MythicMate.State or {}

MythicMate.State.selectedShareChannel = (MythicMateDB.settings and MythicMateDB.settings.shareChannel) or shareChannels[1].key
MythicMate.State.selectedWhisperTarget = (MythicMateDB.settings and MythicMateDB.settings.whisperTarget) or nil

local function MythicMate_GetSelectedChatChannel()
    local selectedShareChannel = MythicMate.State.selectedShareChannel
    if selectedShareChannel == "PARTY" then
        if IsInRaid and IsInRaid() then return "RAID" end
        if IsInGroup and IsInGroup() then return "PARTY" end
        return nil
    end
    if selectedShareChannel == "GUILD" then
        if IsInGuild and IsInGuild() then return "GUILD" end
        return nil
    end
    if selectedShareChannel == "WHISPER" then
        return "WHISPER"
    end
    return selectedShareChannel
end

local function MythicMate_BuildSummaryLines()
    local lines = {}
    local nowTab = activeTab or "DUNGEONS"
    local list = data[nowTab] or {}
    local hideLockedHere = filters[nowTab] and filters[nowTab].hideLocked
    if nowTab == "SHARED" then
        -- Shared view has no "hide locked" filter control; always show both.
        hideLockedHere = false
    end

    local title
    if nowTab == "SHARED" then
        title = "MythicMate - Shared Party (Everyone Available)"
    else
        title = "MythicMate - " .. (nowTab == "DUNGEONS" and "Mythic Dungeons" or "Raid")
        if hideLockedHere then
            title = title .. " (Available only)"
        end
    end
    table.insert(lines, title)

    if #list == 0 then
        table.insert(lines, "No lockouts found.")
    else
        local any = false
        for _, entry in ipairs(list) do
            if not (hideLockedHere and entry.locked) then
                if nowTab == "SHARED" then
                    local name = entry.name or "Unknown"
                    local typ = entry.difficulty or ""
                    local lockedCount = entry.lockedCount or 0
                    local availableCount = entry.availableCount or 0
                    local memberCount = data.SHARED_MEMBER_COUNT or entry.memberCount or 0

                    -- Shared "together" rule: show/share only instances where
                    -- every synced party member is Available (not Locked).
                    if memberCount > 0 then
                        local everyoneCan = (lockedCount == 0 and availableCount == memberCount)
                        if not everyoneCan then
                            -- Skip instances not runnable by the full synced group.
                        else
                            -- Only show instances runnable by the full synced group.
                            local suffix = ""
                            if lockedCount > 0 and availableCount > 0 then
                                suffix = " (Locked by " .. lockedCount .. ", Available by " .. availableCount .. ")"
                            elseif lockedCount > 0 then
                                suffix = " (Locked by " .. lockedCount .. ")"
                            elseif availableCount > 0 then
                                suffix = " (Available by " .. availableCount .. ")"
                            end

                            if typ and typ ~= "" then
                                table.insert(lines, "- " .. name .. " [" .. typ .. "]" .. suffix)
                            else
                                table.insert(lines, "- " .. name .. suffix)
                            end
                            any = true
                        end
                    end

                    if memberCount == 0 then
                        -- If memberCount is unknown, fallback to a minimal display.
                        if availableCount > 0 then
                            local suffix = " (Available by " .. availableCount .. ")"
                            if typ and typ ~= "" then
                                table.insert(lines, "- " .. name .. " [" .. typ .. "]" .. suffix)
                            else
                                table.insert(lines, "- " .. name .. suffix)
                            end
                            any = true
                        end
                    end
                else
                    -- Dungeons: always keep chat output compact.
                    -- Skip expired rows; they just add noise.
                    if nowTab == "DUNGEONS" then
                        local statusCode = MythicMate_GetDungeonStatusCode(entry)
                        -- When "Available only" is enabled, include dungeons you can run:
                        -- "Available" and "Expired" (expired means your previous lockout is gone).
                        if hideLockedHere then
                            if (statusCode == "AVAILABLE" or statusCode == "EXPIRED") and not entry.locked then
                                local dn = entry.name or "Unknown"
                                table.insert(lines, "- " .. dn)
                                any = true
                            end
                        elseif statusCode ~= "EXPIRED" then
                            local dn = entry.name or "Unknown"
                            if entry.locked then
                                table.insert(lines, "- " .. dn .. " (Locked)")
                            else
                                table.insert(lines, "- " .. dn)
                            end
                            any = true
                        end
                    else
                        local parts = {}
                        table.insert(parts, entry.name or "Unknown")
                        if entry.progress and entry.progress ~= "" then
                            table.insert(parts, "(" .. entry.progress .. ")")
                        end
                        if entry.difficulty and entry.difficulty ~= "" then
                            table.insert(parts, "-" .. entry.difficulty)
                        end
                        if entry.locked then
                            table.insert(parts, "- Locked")
                        end
                        table.insert(lines, table.concat(parts, " "))
                        any = true
                    end
                end
            end
        end
        if not any then
            table.insert(lines, "No entries match your filters.")
        end
    end

    return lines
end

local function MythicMate_NormalizeWhisperTarget(target)
    if target == nil then return nil end
    local t = tostring(target)
    t = t:match("^%s*(.-)%s*$")
    if not t or t == "" then return nil end
    -- Strip surrounding quotes like:  "Name"  or  'Name'
    t = t:gsub("^['\"](.*)['\"]$", "%1")
    t = t:match("^%s*(.-)%s*$")
    if t == "" then return nil end
    return t
end

local function MythicMate_PostSummary()
    local lines = MythicMate_BuildSummaryLines()
    if #lines == 0 then return end

    local ch = MythicMate_GetSelectedChatChannel()
    if not ch then
        print("|cffc868ffMythicMate:|r You're not in the selected channel (party/raid/guild).")
        return
    end

    if ch == "WHISPER" then
        local target = MythicMate_NormalizeWhisperTarget(MythicMate.State.selectedWhisperTarget)
        if not target then
            print("|cffc868ffMythicMate:|r Please set a whisper target first: /w Name")
            return
        end
        for _, line in ipairs(lines) do
            SendChatMessage(line, "WHISPER", nil, target)
        end
    else
        for _, line in ipairs(lines) do
            SendChatMessage(line, ch)
        end
    end
end

-- Whisper auto-reply: trigger phrase -> list of Mythic dungeons still Available (not locked).
local whatLockoutWhisperAutoReplyLastAt = {}

local function MythicMate_GetWhisperAutoReplyKeywordSetting()
    local st = MythicMateDB and MythicMateDB.settings
    local kw = st and st.whisperAutoReplyKeyword
    kw = type(kw) == "string" and kw:match("^%s*(.-)%s*$") or ""
    if kw == "" then return "whatlockout" end
    return kw
end

local function MythicMate_WhisperTextMatchesAutoReplyTrigger(text, keyword)
    local t = tostring(text or ""):lower()
    t = t:gsub("^%s+", ""):gsub("%s+$", "")
    local k = tostring(keyword or ""):lower()
    k = k:gsub("^%s+", ""):gsub("%s+$", "")
    if k == "" then return false end
    if t == k then return true end
    if #t > #k and t:sub(1, #k + 1) == k .. " " then return true end
    return false
end

local function MythicMate_BuildAvailableMythicDungeonWhisperLines()
    local lines = {}
    -- Bracket prefix so this line never matches the default trigger "whatlockout" (avoids echo if you /w yourself).
    lines[#lines + 1] = "[MythicMate] Mythic dungeons you can still run:"
    local list = data.DUNGEONS or {}
    local any = false
    for _, entry in ipairs(list) do
        local statusCode = MythicMate_GetDungeonStatusCode(entry)
        if (statusCode == "AVAILABLE" or statusCode == "EXPIRED") and not entry.locked then
            lines[#lines + 1] = "- " .. (entry.name or "Unknown")
            any = true
        end
    end
    if not any then
        lines[#lines + 1] = "(none - all locked or data not ready)"
    end
    return lines
end

local function MythicMate_TryWhisperAutoReply(text, sender)
    pcall(MythicMate_TryInitAceDB)
    local st = MythicMateDB and MythicMateDB.settings
    if not st or not st.whisperAutoReplyEnabled then return end
    if not text or not sender or sender == "" then return end
    local kw = MythicMate_GetWhisperAutoReplyKeywordSetting()
    if not MythicMate_WhisperTextMatchesAutoReplyTrigger(text, kw) then return end
    local now = (type(GetTime) == "function" and GetTime()) or 0
    local throttleKey = tostring(sender):lower()
    if (whatLockoutWhisperAutoReplyLastAt[throttleKey] or 0) + 12 > now then return end
    whatLockoutWhisperAutoReplyLastAt[throttleKey] = now
    if RequestRaidInfo then pcall(RequestRaidInfo) end
    local target = sender
    if type(Ambiguate) == "function" then
        local okA, amb = pcall(Ambiguate, sender, "none")
        if okA and type(amb) == "string" and amb ~= "" then target = amb end
    end
    local function sendDungeonReply()
        if type(ScanLockouts) == "function" then ScanLockouts() end
        local lines = MythicMate_BuildAvailableMythicDungeonWhisperLines()
        for _, line in ipairs(lines) do
            local chunk = tostring(line)
            if #chunk > 255 then chunk = chunk:sub(1, 255) end
            pcall(SendChatMessage, chunk, "WHISPER", nil, target)
        end
    end
    if C_Timer and type(C_Timer.After) == "function" then
        C_Timer.After(0.25, sendDungeonReply)
    else
        sendDungeonReply()
    end
end

-- Quick lookup for mythic dungeon locks by name
local lockedMythicDungeonsByName = {}
-- Stable lookup for dungeon finder rows (numeric map/dungeon id).
local lockedMythicDungeonsByMapID = {}
-- Premade auto-uncheck is disabled: Blizzard's Group Finder UI is secure and
-- does not reliably allow addons to change these checkboxes. Keep the helper
-- but hard-return false so all related hooks become no-ops.
local function MythicMate_ShouldAutoUncheckLockedPremadeDungeons()
    return false
end
_G.MythicMate_ShouldAutoUncheckLockedPremadeDungeons = MythicMate_ShouldAutoUncheckLockedPremadeDungeons

-- Normalize instance/dungeon/raid names for stable lookups.
-- This function is referenced via a file-local upvalue (`local MythicMate_NormalizeName`)
-- throughout the monolith, so it must be defined here even if moved to other modules.
MythicMate_NormalizeName = function(name)
    local fn = _G.MythicMate and _G.MythicMate.NormalizeName and _G.MythicMate.NormalizeName or _G.MythicMate_NormalizeName
    if type(fn) == "function" then
        return fn(name)
    end

    if not name then return nil end
    name = name:gsub("%s*%([^)]+%)", "") -- strip " (Mythic)" etc.
    name = name:lower()
    name = name:gsub("^%s+", ""):gsub("%s+$", "")
    return name
end

local function MythicMate_IsButtonCheckedLike(button)
    if not button then return false end
    if button.CheckButton and type(button.CheckButton.GetChecked) == "function" and button.CheckButton:GetChecked() then
        return true
    end
    if button.Check and button.Check.IsShown and button.Check:IsShown() then
        return true
    end
    if type(button.GetChecked) == "function" and button:GetChecked() then
        return true
    end
    if type(button.IsSelected) == "function" and button:IsSelected() then
        return true
    end
    if button.checked then
        return true
    end
    return false
end

local function MythicMate_TryUncheckLFGDungeonButton(button, dungeonID, name)
    if not button then return false end

    local checkedBefore = MythicMate_IsButtonCheckedLike(button)

    -- Prefer the button's own click handler so Blizzard updates the real filter model.
    if checkedBefore and type(button.GetScript) == "function" then
        local onClick = button:GetScript("OnClick")
        if type(onClick) == "function" then
            pcall(onClick, button, "LeftButton", true)
        end
    end
    if checkedBefore and button.CheckButton then
        if type(button.CheckButton.GetScript) == "function" then
            local cbOnClick = button.CheckButton:GetScript("OnClick")
            if type(cbOnClick) == "function" then
                pcall(cbOnClick, button.CheckButton, "LeftButton", true)
            end
        end
        if type(button.CheckButton.Click) == "function" and MythicMate_IsButtonCheckedLike(button) then
            pcall(button.CheckButton.Click, button.CheckButton)
        end
    end

    -- Force-clear known check/selection states across template variants.
    if button.CheckButton and type(button.CheckButton.SetChecked) == "function" then
        pcall(button.CheckButton.SetChecked, button.CheckButton, false)
    end
    if button.Check and type(button.Check.Hide) == "function" then
        pcall(button.Check.Hide, button.Check)
    end
    if type(button.SetChecked) == "function" then
        pcall(button.SetChecked, button, false)
    end
    if type(button.SetIsSelected) == "function" then
        pcall(button.SetIsSelected, button, false)
    end
    if type(button.SetSelected) == "function" then
        pcall(button.SetSelected, button, false)
    end
    if type(button.SetSelectionIgnored) == "function" then
        pcall(button.SetSelectionIgnored, button, false)
    end
    button.checked = false
    button.selected = false

    if type(button.UpdateSelection) == "function" then
        pcall(button.UpdateSelection, button)
    end

    -- Last-resort click toggle if still checked.
    if MythicMate_IsButtonCheckedLike(button) and type(button.Click) == "function" then
        pcall(button.Click, button)
    end

    local unchecked = not MythicMate_IsButtonCheckedLike(button)
    if MythicMateDebugPremade then
        print("|cffc868ffMythicMate premade (DF):|r auto-uncheck dungeonID=" .. tostring(dungeonID)
            .. " name=\"" .. tostring(name) .. "\""
            .. " button=" .. tostring(button.GetName and button:GetName() or "<unnamed>")
            .. " checkedBefore=" .. tostring(checkedBefore)
            .. " unchecked=" .. tostring(unchecked))
    end
    return unchecked
end

local function MythicMate_ForceLockedDungeonRowUnchecked(button, dungeonID, name)
    if not button then return end
    if not MythicMate_ShouldAutoUncheckLockedPremadeDungeons() then return end

    local function clearSelectionStateTables(root)
        local changed = false
        if type(root) ~= "table" then return false end

        local function clearInTable(t)
            if type(t) ~= "table" then return end
            local keys = {
                "selectedDungeons", "dungeonFilters", "selected", "checked",
                "enabledDungeons", "dungeons", "selectedEntries", "selectionMap",
            }
            for _, key in ipairs(keys) do
                local sub = t[key]
                if type(sub) == "table" and type(dungeonID) == "number" then
                    if sub[dungeonID] ~= nil and sub[dungeonID] ~= false then
                        sub[dungeonID] = false
                        changed = true
                    end
                end
            end
            if type(dungeonID) == "number" and type(t[dungeonID]) == "boolean" and t[dungeonID] ~= false then
                t[dungeonID] = false
                changed = true
            end
        end

        clearInTable(root)
        return changed
    end

    local function applyUnchecked()
        -- Force data-model state to false on common owner tables first.
        local node = button
        for _ = 1, 8 do
            if not node then break end
            clearSelectionStateTables(node)
            if type(node.GetParent) == "function" then
                node = node:GetParent()
            else
                break
            end
        end
        if _G and _G.LFGListFrame then
            clearSelectionStateTables(_G.LFGListFrame)
            if _G.LFGListFrame.SearchPanel then
                clearSelectionStateTables(_G.LFGListFrame.SearchPanel)
            end
        end

        -- Force visual and template states to unchecked.
        if button.CheckButton and type(button.CheckButton.SetChecked) == "function" then
            pcall(button.CheckButton.SetChecked, button.CheckButton, false)
        end
        if button.Check and type(button.Check.Hide) == "function" then
            pcall(button.Check.Hide, button.Check)
        end
        if type(button.SetChecked) == "function" then
            pcall(button.SetChecked, button, false)
        end
        if type(button.SetIsSelected) == "function" then
            pcall(button.SetIsSelected, button, false)
        end
        if type(button.SetSelected) == "function" then
            pcall(button.SetSelected, button, false)
        end
        button.checked = false
        button.selected = false
        if button.CheckButton and type(button.CheckButton.SetEnabled) == "function" then
            pcall(button.CheckButton.SetEnabled, button.CheckButton, false)
        end
    end

    -- Apply immediately and shortly after, because Blizzard may re-apply state
    -- during menu refresh/build.
    applyUnchecked()
    if C_Timer and type(C_Timer.After) == "function" then
        C_Timer.After(0, applyUnchecked)
        C_Timer.After(0.05, applyUnchecked)
        C_Timer.After(0.20, applyUnchecked)
    end

    -- Keep it unchecked when user clicks it.
    if not button.MythicMateForceUncheckHooked and type(button.HookScript) == "function" then
        button.MythicMateForceUncheckHooked = true
        button:HookScript("OnClick", function(self)
            if not self or not self.data then return end
            if self.data.MythicMateLockedDungeon then
                if C_Timer and type(C_Timer.After) == "function" then
                    C_Timer.After(0, function() MythicMate_ForceLockedDungeonRowUnchecked(self, self.data.dungeonID, self.data.name) end)
                else
                    MythicMate_ForceLockedDungeonRowUnchecked(self, self.data.dungeonID, self.data.name)
                end
            end
        end)
    end

    -- Tag current row data for click hook.
    button.data = button.data or {}
    button.data.MythicMateLockedDungeon = true
    button.data.dungeonID = dungeonID
    button.data.name = name
end

------------------------------------------------------------
-- Dungeon Finder list (left panel) warning icon
------------------------------------------------------------
-- We haken op Blizzard's LFGDungeonListButton_SetDungeon zodat elke regel
-- in de Dungeons-lijst kan laten zien of jij al base-Mythic locked bent
-- voor die instance.
if type(LFGDungeonListButton_SetDungeon) == "function" then
    hooksecurefunc("LFGDungeonListButton_SetDungeon", function(button, dungeonID)
        if not button or not dungeonID then
            return
        end

        local dungeonInfo = GetLFGDungeonInfo and { GetLFGDungeonInfo(dungeonID) } or nil
        local name = dungeonInfo and dungeonInfo[1] or nil
        if not name then
            return
        end

        -- Ensure our base-Mythic lock cache is populated (can be empty on first open).
        if next(lockedMythicDungeonsByName) == nil then
            if type(ScanLockouts) == "function" then pcall(ScanLockouts) end
        end

        local norm = MythicMate_NormalizeName(name)
        local did = tonumber(dungeonID)
        local isLocked = (did and lockedMythicDungeonsByMapID[did]) or (norm and lockedMythicDungeonsByName[norm])
        local didMatchedMapID = nil
        if not isLocked and dungeonInfo then
            -- LFGDungeonListButton's dungeonID is not always the same numeric value as our mapID cache keys.
            -- Scan all numeric returns from GetLFGDungeonInfo and match against lockedMythicDungeonsByMapID.
            for i = 1, #dungeonInfo do
                local n = tonumber(dungeonInfo[i])
                if n and n > 0 and lockedMythicDungeonsByMapID[n] then
                    isLocked = true
                    didMatchedMapID = n
                    break
                end
            end
        end
        if not isLocked and type(data) == "table" and type(data.DUNGEONS) == "table" and did and did > 0 then
            for _, e in ipairs(data.DUNGEONS) do
                if e and e.mapID and tonumber(e.mapID) == did and e.locked == true then
                    isLocked = true
                    break
                end
            end
        end
        local autoEnabled = MythicMate_ShouldAutoUncheckLockedPremadeDungeons()

        -- Store latest row state so OnEnter can read current values.
        button.data = button.data or {}
        button.data.dungeonID = dungeonID
        button.data.name = name
        button.data.norm = norm
        button.data.MythicMateDungeonIsLocked = isLocked and true or false
        button.data.MythicMateMatchedMapID = didMatchedMapID and true or false

        if MythicMateDebugPremade then
            print("|cffc868ffMythicMate debug (DF):|r dungeonID=" .. tostring(dungeonID)
                .. " name=\"" .. tostring(name) .. "\" norm=\"" .. tostring(norm)
                .. "\" locked=" .. tostring(isLocked)
                .. " didMatchedMapID=" .. tostring(didMatchedMapID)
                .. " autoEnabled=" .. tostring(autoEnabled))
        end

        if not button.MythicMateDungeonLockTooltipHooked and type(button.HookScript) == "function" then
            button.MythicMateDungeonLockTooltipHooked = true
            button:HookScript("OnEnter", function(self)
                if not self or not self.data then return end
                local key = tostring(self.data.dungeonID or self.data.name or "")
                if key == "" then return end

                local lockedNow = self.data and self.data.MythicMateDungeonIsLocked and true or false
                local doTryColor = lockedNow or MythicMateDebugPremade
                if MythicMateDebugPremade then
                    print("|cffc868ffMythicMate debug DF OnEnter:|r dungeonKey=" .. tostring(key) ..
                        " lockedNow=" .. tostring(lockedNow) ..
                        " doTryColor=" .. tostring(doTryColor))
                end
            end)
            button:HookScript("OnLeave", function()
                if GameTooltip then
                    GameTooltip.MythicMateDungeonLockFor = nil
                end
            end)
        end

        -- Maak / toon rood warning-icoon op de dungeon-regel (rechts, buiten tekst)
        if isLocked then
            -- Main fix path: uncheck locked dungeons directly on Blizzard's dungeon row buttons.
            if autoEnabled then
                MythicMate_TryUncheckLFGDungeonButton(button, dungeonID, name)
                MythicMate_ForceLockedDungeonRowUnchecked(button, dungeonID, name)
            elseif MythicMateDebugPremade then
                print("|cffc868ffMythicMate premade (DF):|r skipped auto-uncheck (setting disabled) for dungeonID=" .. tostring(dungeonID))
            end

            if not button.MythicMateWarningIcon then
                local icon = button:CreateTexture(nil, "OVERLAY")
                icon:SetSize(14, 14)
                icon:SetPoint("RIGHT", button, "RIGHT", -10, 0)
                icon:SetTexture("Interface\\RaidFrame\\ReadyCheck-NotReady") -- rood kruis
                button.MythicMateWarningIcon = icon

                button:HookScript("OnEnter", function(self)
                    if self.MythicMateWarningIcon and self.MythicMateWarningIcon:IsShown() then
                        -- Marker so we only hide this tooltip when it was our own warning.
                        GameTooltip.MythicMateWarningIconFor = tostring(self.data and self.data.dungeonID or "") or ""
                        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
                        GameTooltip:SetText("|cffff2020You are already LOCKED to this Mythic dungeon.|r", 1, 0.82, 0)
                        GameTooltip:AddLine("This is a base Mythic lockout (not Mythic+).", 1, 1, 1)
                        GameTooltip:Show()
                    end
                end)
                button:HookScript("OnLeave", function()
                    if GameTooltip and GameTooltip.MythicMateWarningIconFor ~= nil and GameTooltip.MythicMateWarningIconFor ~= "" then
                        GameTooltip:Hide()
                    end
                end)
            end
            button.MythicMateWarningIcon:Show()
        elseif button.MythicMateWarningIcon then
            if button.data then
                button.data.MythicMateLockedDungeon = false
            end
            button.MythicMateWarningIcon:Hide()
        end
    end)
end

------------------------------------------------------------
-- RaiderIO lockout list dialog.
-- We avoid relying on LFG row hover hooks (UI build dependent) and instead
-- poll the RaiderIO tooltip frame when it is shown.
------------------------------------------------------------
_G.MythicMateRaiderIOColorPollActive = _G.MythicMateRaiderIOColorPollActive or false
_G.MythicMate_PollRaiderIOProfileTooltip = _G.MythicMate_PollRaiderIOProfileTooltip or function()
    if _G.MythicMateRaiderIOColorPollActive then return end
    _G.MythicMateRaiderIOColorPollActive = true

    local t = _G["RaiderIO_ProfileTooltip"]
    if not t and EnumerateFrames and type(EnumerateFrames) == "function" then
        -- WoW's EnumerateFrames should return iterator triple, but in some builds it may return nil.
        -- Guard to prevent "attempt to call a nil value".
        local ok, it, state, initial = pcall(function()
            return EnumerateFrames()
        end)
        if ok and type(it) == "function" then
            for frame in it, state, initial do
                if frame and frame.GetName and type(frame.GetName) == "function" then
                    local nm = frame:GetName()
                    nm = nm and nm:lower() or ""
                    if nm:find("raiderio") and nm:find("profiletooltip") then
                        t = frame
                        break
                    end
                end
            end
        end
    end
    local showDialog = not (MythicMateDB and MythicMateDB.settings and MythicMateDB.settings.showRaiderIOLockoutDialog == false)

    -- Ensure dungeon cache is available before the dialog updates.
    if showDialog and t and t.IsShown and t:IsShown() then
        if next(lockedMythicDungeonsByName) == nil and type(ScanLockouts) == "function" then
            pcall(ScanLockouts)
        end
    end

    if Util and Util.UpdateRaiderIOLockoutDialogForTooltip then
        Util.UpdateRaiderIOLockoutDialogForTooltip(t, {
            showDialog = showDialog,
            data = data,
            lockedMythicByName = lockedMythicDungeonsByName,
            normalizeNameFn = MythicMate_NormalizeName,
            useElvUISkinDefaults = (MythicMateDB and MythicMateDB.settings and MythicMateDB.settings.useElvUISkinDefaults) and true or false,
            debug = MythicMateDebugPremade,
        })
    end

    if C_Timer and type(C_Timer.After) == "function" then
        C_Timer.After(0.25, function()
            _G.MythicMateRaiderIOColorPollActive = false
            _G.MythicMate_PollRaiderIOProfileTooltip()
        end)
    else
        _G.MythicMateRaiderIOColorPollActive = false
    end
end

_G.MythicMate_PollRaiderIOProfileTooltip()

local whatLockoutPremadeUncheckGuard = false
local whatLockoutPremadeFilterHooked = false
local whatLockoutPremadeLastForcedClickAtByName = {}
local function MythicMate_GetCheckButtonText(button)
    if not button then return nil end
    local txt = nil
    if button.Text and button.Text.GetText then
        txt = button.Text:GetText()
    end
    if (not txt or txt == "") and button.text and button.text.GetText then
        txt = button.text:GetText()
    end
    if (not txt or txt == "") then
        local fs = _G[button:GetName() .. "NormalText"] or button:GetFontString()
        txt = fs and fs.GetText and fs:GetText() or nil
    end
    if txt and txt ~= "" then
        txt = txt:gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", "")
    end
    return txt
end

local function MythicMate_NormalizeDungeonKeyLoose(name)
    if not name then return nil end
    local n = tostring(name)
    n = n:gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", "")
    n = n:gsub("%s*%([^)]+%)", "")
    n = n:lower()
    n = n:gsub("[%s%p_]+", "")
    if n == "" then return nil end
    return n
end

local function MythicMate_IsDungeonNameLocked(name)
    if not name or name == "" then return false end
    local norm = MythicMate_NormalizeName(name)
    if norm and lockedMythicDungeonsByName[norm] then
        return true
    end

    local loose = MythicMate_NormalizeDungeonKeyLoose(name)
    if not loose then return false end
    for lockedNorm, _ in pairs(lockedMythicDungeonsByName) do
        local lockedLoose = MythicMate_NormalizeDungeonKeyLoose(lockedNorm)
        if lockedLoose and lockedLoose == loose then
            return true
        end
    end
    return false
end

-- Expose the lock check on the shared namespace so the split-out premade filter file
-- (MythicMate_PremadeFilter.lua) reuses the exact same lock detection.
-- NOTE: do NOT expose NormalizeName here -- MythicMate_NormalizeName is a dispatcher
-- that reads _G.MythicMate.NormalizeName, so assigning it there makes it call itself.
if _G.MythicMate then
    _G.MythicMate.IsDungeonNameLocked = MythicMate_IsDungeonNameLocked
end

local function MythicMate_FindLockedDungeonInText(text)
    if not text or text == "" then return nil end
    local looseText = MythicMate_NormalizeDungeonKeyLoose(text)
    if not looseText then return nil end

    for lockedNorm, _ in pairs(lockedMythicDungeonsByName) do
        local lockedLoose = MythicMate_NormalizeDungeonKeyLoose(lockedNorm)
        if lockedLoose and lockedLoose ~= "" then
            if looseText:find(lockedLoose, 1, true) or lockedLoose:find(looseText, 1, true) then
                return lockedNorm
            end
        end
    end
    return nil
end

local function MythicMate_TryUncheckLockedButton(button, resolvedName)
    if not button or not resolvedName or resolvedName == "" then return false end
    if not MythicMate_IsDungeonNameLocked(resolvedName) then return false end

    local isChecked = false
    if button.Check and button.Check.IsShown and button.Check:IsShown() then
        isChecked = true
    elseif type(button.GetChecked) == "function" and button:GetChecked() then
        isChecked = true
    elseif type(button.IsSelected) == "function" and button:IsSelected() then
        isChecked = true
    elseif button.checked then
        isChecked = true
    end

    if not isChecked then return false end

    -- Prefer the dropdown-provided callback so we toggle the real filter model.
    if type(button.func) == "function" then
        pcall(button.func, button, button.arg1, button.arg2, button.checked)
    end

    -- Fallback for clients where callback doesn't immediately flip the check state.
    local stillChecked = false
    if button.Check and button.Check.IsShown and button.Check:IsShown() then
        stillChecked = true
    elseif type(button.GetChecked) == "function" and button:GetChecked() then
        stillChecked = true
    elseif type(button.IsSelected) == "function" and button:IsSelected() then
        stillChecked = true
    elseif button.checked then
        stillChecked = true
    end

    if stillChecked and type(button.Click) == "function" then
        if MythicMateDebugPremade then
            print("|cffc868ffMythicMate premade:|r unchecking locked dungeon \"" .. tostring(resolvedName) .. "\"")
        end
        button:Click()
        return true
    end
    return true
end

local function MythicMate_AutoUncheckLockedDungeonButtons(dropdownListFrame)
    if whatLockoutPremadeUncheckGuard or not dropdownListFrame or not dropdownListFrame:IsShown() then
        return
    end
    if not MythicMate_ShouldAutoUncheckLockedPremadeDungeons() then
        return
    end

    whatLockoutPremadeUncheckGuard = true
    local maxButtons = UIDROPDOWNMENU_MAXBUTTONS or 32
    local listName = dropdownListFrame:GetName()
    local seenRows, lockedRows, uncheckedRows = 0, 0, 0
    for i = 1, maxButtons do
        local button = listName and _G[listName .. "Button" .. i]
        if button and button:IsShown() and button:IsEnabled() then
            seenRows = seenRows + 1
            local resolvedName = nil

            -- Prefer stable IDs when present (more reliable than display text).
            local candidateDungeonID = button.value
            if type(candidateDungeonID) ~= "number" then
                candidateDungeonID = button.arg1
            end
            if type(candidateDungeonID) == "number" and type(GetLFGDungeonInfo) == "function" then
                resolvedName = select(1, GetLFGDungeonInfo(candidateDungeonID))
            end

            -- Fallback to visible text.
            if not resolvedName or resolvedName == "" then
                resolvedName = MythicMate_GetCheckButtonText(button) or ""
            end

            local isLockedName = MythicMate_IsDungeonNameLocked(resolvedName)
            if isLockedName then
                lockedRows = lockedRows + 1
            end
            if MythicMateDebugPremade and seenRows <= 25 then
                local checkedState = false
                if button.Check and button.Check.IsShown and button.Check:IsShown() then
                    checkedState = true
                elseif type(button.GetChecked) == "function" and button:GetChecked() then
                    checkedState = true
                elseif type(button.IsSelected) == "function" and button:IsSelected() then
                    checkedState = true
                elseif button.checked then
                    checkedState = true
                end
                print("|cffc868ffMythicMate premade row:|r levelFrame=" .. tostring(listName)
                    .. " idx=" .. tostring(i)
                    .. " name=\"" .. tostring(resolvedName) .. "\""
                    .. " value=" .. tostring(button.value)
                    .. " arg1=" .. tostring(button.arg1)
                    .. " checked=" .. tostring(checkedState)
                    .. " lockedMatch=" .. tostring(isLockedName))
            end

            if MythicMate_TryUncheckLockedButton(button, resolvedName) then
                uncheckedRows = uncheckedRows + 1
            end
        end
    end
    if MythicMateDebugPremade then
        print("|cffc868ffMythicMate premade:|r dropdown scan frame=" .. tostring(listName)
            .. " seenRows=" .. tostring(seenRows)
            .. " lockedRows=" .. tostring(lockedRows)
            .. " uncheckedRows=" .. tostring(uncheckedRows))
    end
    whatLockoutPremadeUncheckGuard = false
end

local function MythicMate_AutoUncheckLockedDungeonVisibleCheckboxes()
    if not MythicMate_ShouldAutoUncheckLockedPremadeDungeons() then return end
    if not EnumerateFrames or not _G.LFGListFrame or not _G.LFGListFrame:IsShown() then return end

    local frameObj = nil
    local seen, matched, unchecked = 0, 0, 0
    while true do
        frameObj = EnumerateFrames(frameObj)
        if not frameObj then break end
        if frameObj.IsObjectType and frameObj:IsObjectType("CheckButton") and frameObj.IsShown and frameObj:IsShown() and frameObj.IsEnabled and frameObj:IsEnabled() then
            local buttonName = frameObj.GetName and frameObj:GetName() or ""
            local parent = frameObj.GetParent and frameObj:GetParent() or nil
            local parentName = parent and parent.GetName and parent:GetName() or ""
            -- Scope down to LFG/Dropdown related widgets to avoid touching unrelated checkboxes.
            local scoped = (buttonName and (buttonName:find("DropDownList", 1, true) or buttonName:find("LFGList", 1, true))) or
                           (parentName and (parentName:find("DropDownList", 1, true) or parentName:find("LFGList", 1, true)))
            if scoped then
                seen = seen + 1
                local resolvedName = MythicMate_GetCheckButtonText(frameObj)
                if resolvedName and resolvedName ~= "" then
                    if MythicMate_IsDungeonNameLocked(resolvedName) then
                        matched = matched + 1
                    end
                end
                if MythicMate_TryUncheckLockedButton(frameObj, resolvedName) then
                    unchecked = unchecked + 1
                end
            end
        end
    end
    if MythicMateDebugPremade then
        print("|cffc868ffMythicMate premade:|r scan visible checkbuttons seen=" .. tostring(seen)
            .. " matchedLocked=" .. tostring(matched) .. " unchecked=" .. tostring(unchecked))
    end
end

local function MythicMate_GetButtonDisplayText(btn)
    if not btn then return nil end
    local t = nil
    if btn.Text and btn.Text.GetText then t = btn.Text:GetText() end
    if (not t or t == "") and btn.text and btn.text.GetText then t = btn.text:GetText() end
    if (not t or t == "") and btn.GetText then t = btn:GetText() end
    if (not t or t == "") then
        local fs = _G[btn:GetName() .. "NormalText"] or (btn.GetFontString and btn:GetFontString()) or nil
        if fs and fs.GetText then t = fs:GetText() end
    end
    if t and t ~= "" then
        t = t:gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", "")
    end
    return t
end

local function MythicMate_GetAnyFrameText(frame)
    if not frame then return nil end
    -- Try common direct accessors first.
    local t = MythicMate_GetButtonDisplayText(frame)
    if t and t ~= "" then return t end

    -- Then inspect regions (FontStrings are often attached here in Blizzard_Menu).
    if type(frame.GetRegions) == "function" then
        local regions = { frame:GetRegions() }
        for i = 1, #regions do
            local r = regions[i]
            if r and r.GetObjectType and r:GetObjectType() == "FontString" and r.GetText then
                local rt = r:GetText()
                if rt and rt ~= "" then
                    rt = rt:gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", "")
                    return rt
                end
            end
        end
    end

    return nil
end

local function MythicMate_IsButtonSelectedLike(btn)
    if not btn then return false end
    if btn.CheckButton and type(btn.CheckButton.GetChecked) == "function" and btn.CheckButton:GetChecked() then return true end
    if btn.Check and btn.Check.IsShown and btn.Check:IsShown() then return true end
    if type(btn.GetChecked) == "function" and btn:GetChecked() then return true end
    if type(btn.IsSelected) == "function" and btn:IsSelected() then return true end
    if btn.checked or btn.selected then return true end
    return false
end

local function MythicMate_ForceUncheckVisibleLFGDungeonButtons()
    if not MythicMate_ShouldAutoUncheckLockedPremadeDungeons() then return end
    if not EnumerateFrames then return end

    local seen, lockedSeen, toggled = 0, 0, 0
    local frameObj = nil
    while true do
        frameObj = EnumerateFrames(frameObj)
        if not frameObj then break end

        local name = frameObj.GetName and frameObj:GetName() or ""
        if frameObj.IsShown and frameObj:IsShown() and name and name:find("LFGDungeonListButton", 1, true) then
            seen = seen + 1

            local dungeonID = frameObj.dungeonID or frameObj.value or frameObj.arg1
            local dungeonName = nil
            if type(dungeonID) == "number" and type(GetLFGDungeonInfo) == "function" then
                dungeonName = select(1, GetLFGDungeonInfo(dungeonID))
            end
            if not dungeonName or dungeonName == "" then
                dungeonName = MythicMate_GetAnyFrameText(frameObj)
            end

            local isLocked = MythicMate_IsDungeonNameLocked(dungeonName)
            if isLocked then
                lockedSeen = lockedSeen + 1
                if MythicMate_TryUncheckLFGDungeonButton(frameObj, dungeonID, dungeonName) then
                    toggled = toggled + 1
                end
            end
        end
    end

    if MythicMateDebugPremade then
        print("|cffc868ffMythicMate premade (LFGButtons):|r seen=" .. tostring(seen)
            .. " lockedSeen=" .. tostring(lockedSeen)
            .. " toggled=" .. tostring(toggled))
    end
end

local function MythicMate_TryAutoUncheckBlizzardMenuButtons()
    if not MythicMate_ShouldAutoUncheckLockedPremadeDungeons() then return end
    if not EnumerateFrames then return end

    local now = (type(GetTime) == "function") and GetTime() or 0
    local seen, matched, toggled = 0, 0, 0
    local frameObj = nil
    while true do
        frameObj = EnumerateFrames(frameObj)
        if not frameObj then break end
        if frameObj.IsShown and frameObj:IsShown() then
            local txt = MythicMate_GetAnyFrameText(frameObj)
            if txt and txt ~= "" then
                local lower = txt:lower()
                local isDungeonMenuHeader = (lower == "dungeons")
                local isLockedDungeonLabel = MythicMate_IsDungeonNameLocked(txt)
                if isDungeonMenuHeader or isLockedDungeonLabel then
                    seen = seen + 1
                    local key = tostring(lower)
                    local last = whatLockoutPremadeLastForcedClickAtByName[key] or 0
                    local canClick = (now - last) > 0.25

                    local canInteract = frameObj.IsEnabled and frameObj:IsEnabled()
                    local canClickFn = type(frameObj.Click) == "function"
                    local canInteractByMouse = frameObj.IsMouseEnabled and frameObj:IsMouseEnabled()

                    if isDungeonMenuHeader and canClick and (canClickFn or canInteractByMouse) then
                        -- Open the submenu so locked dungeon rows become visible.
                        if canClickFn then
                            pcall(frameObj.Click, frameObj)
                            whatLockoutPremadeLastForcedClickAtByName[key] = now
                        end
                    elseif isLockedDungeonLabel then
                        matched = matched + 1
                        local selectedLike = MythicMate_IsButtonSelectedLike(frameObj)
                        if MythicMateDebugPremade then
                            print("|cffc868ffMythicMate premade (menu):|r candidate name=\"" .. tostring(txt)
                                .. "\" selected=" .. tostring(selectedLike)
                                .. " canClick=" .. tostring(canClickFn)
                                .. " enabled=" .. tostring(canInteract))
                        end
                        if canClick and canClickFn then
                            pcall(frameObj.Click, frameObj)
                            whatLockoutPremadeLastForcedClickAtByName[key] = now
                            toggled = toggled + 1
                            if MythicMateDebugPremade then
                                print("|cffc868ffMythicMate premade (menu):|r toggled \"" .. tostring(txt) .. "\"")
                            end
                        end
                    end
                end
            end
        end
    end
    if MythicMateDebugPremade then
        print("|cffc868ffMythicMate premade (menu):|r seen=" .. tostring(seen)
            .. " matchedLocked=" .. tostring(matched)
            .. " toggled=" .. tostring(toggled))
    end
end

local function MythicMate_HookPremadeDungeonFilterDropdown()
    if whatLockoutPremadeFilterHooked then return end
    if type(hooksecurefunc) ~= "function" then return end
    whatLockoutPremadeFilterHooked = true

    local function runOnVisibleDropdowns()
        -- Keep lock cache fresh when filter menus are opened.
        ScanLockouts()
        if MythicMateDebugPremade then
            local lockedCount = 0
            for _ in pairs(lockedMythicDungeonsByName) do
                lockedCount = lockedCount + 1
            end
            print("|cffc868ffMythicMate premade:|r runOnVisibleDropdowns lockedCacheCount=" .. tostring(lockedCount))
            for k in pairs(lockedMythicDungeonsByName) do
                print("  - locked: " .. tostring(k))
            end
        end
        for level = 1, 3 do
            local listFrame = _G["DropDownList" .. level]
            if listFrame and listFrame.IsShown and listFrame:IsShown() then
                if MythicMateDebugPremade then
                    print("|cffc868ffMythicMate premade:|r visible dropdown level=" .. tostring(level)
                        .. " name=" .. tostring(listFrame:GetName()))
                end
                MythicMate_AutoUncheckLockedDungeonButtons(listFrame)
                -- Submenus are often opened by hovering a parent menu button.
                local maxButtons = UIDROPDOWNMENU_MAXBUTTONS or 32
                local listName = listFrame:GetName()
                for i = 1, maxButtons do
                    local button = listName and _G[listName .. "Button" .. i]
                    if button and not button.MythicMatePremadeHoverHooked and button.HookScript then
                        button.MythicMatePremadeHoverHooked = true
                        button:HookScript("OnEnter", function()
                            if C_Timer and type(C_Timer.After) == "function" then
                                C_Timer.After(0, runOnVisibleDropdowns)
                                C_Timer.After(0.05, runOnVisibleDropdowns)
                                C_Timer.After(0.20, runOnVisibleDropdowns)
                            else
                                runOnVisibleDropdowns()
                            end
                        end)
                    end
                end
            end
        end
        MythicMate_AutoUncheckLockedDungeonVisibleCheckboxes()
        MythicMate_TryAutoUncheckBlizzardMenuButtons()
        MythicMate_ForceUncheckVisibleLFGDungeonButtons()
    end

    local function runOnVisibleDropdownsDelayed()
        if C_Timer and type(C_Timer.After) == "function" then
            -- Immediate + short follow-ups for hover-opened submenus.
            C_Timer.After(0, runOnVisibleDropdowns)
            C_Timer.After(0.05, runOnVisibleDropdowns)
            C_Timer.After(0.20, runOnVisibleDropdowns)
        else
            runOnVisibleDropdowns()
        end
    end

    hooksecurefunc("ToggleDropDownMenu", function()
        if MythicMateDebugPremade then
            print("|cffc868ffMythicMate premade:|r ToggleDropDownMenu fired")
        end
        runOnVisibleDropdownsDelayed()
    end)
    hooksecurefunc("UIDropDownMenu_Refresh", function()
        if MythicMateDebugPremade then
            print("|cffc868ffMythicMate premade:|r UIDropDownMenu_Refresh fired")
        end
        runOnVisibleDropdownsDelayed()
    end)

    -- Also react to dropdown levels being shown directly.
    for level = 1, 3 do
        local listFrame = _G["DropDownList" .. level]
        if listFrame and listFrame.HookScript then
            listFrame:HookScript("OnShow", function()
                runOnVisibleDropdownsDelayed()
            end)
        end
    end

    -- Premade filter button opens this menu on some builds.
    local premadeFilterButton = _G.LFGListFrame and _G.LFGListFrame.SearchPanel and _G.LFGListFrame.SearchPanel.FilterButton
    if premadeFilterButton and premadeFilterButton.HookScript then
        premadeFilterButton:HookScript("OnClick", function()
            if MythicMateDebugPremade then
                print("|cffc868ffMythicMate premade:|r FilterButton OnClick fired")
            end
            runOnVisibleDropdownsDelayed()
            if C_Timer and type(C_Timer.After) == "function" then
                C_Timer.After(0.35, runOnVisibleDropdowns)
                C_Timer.After(0.70, runOnVisibleDropdowns)
                C_Timer.After(1.10, runOnVisibleDropdowns)
                C_Timer.After(1.40, runOnVisibleDropdowns)
            end
        end)
        if MythicMateDebugPremade then
            print("|cffc868ffMythicMate premade:|r FilterButton hook installed")
        end
    elseif MythicMateDebugPremade then
        print("|cffc868ffMythicMate premade:|r FilterButton NOT found at hook time")
    end
end

------------------------------------------------------------
-- Row creation
------------------------------------------------------------
local rowPool = {}

------------------------------------------------------------
-- Premade results fallback filter (locked dungeons)
------------------------------------------------------------
local whatLockoutPremadeResultFilterHooked = false
local whatLockoutPremadeSignupHooked = false
local whatLockoutPremadeCreateHooked = false

local function MythicMate_HookPremadeResultFilter()
    -- Auto-filtering premade results is disabled; keep a no-op hook to avoid
    -- future duplicate-hook attempts.
    if whatLockoutPremadeResultFilterHooked then return end
    whatLockoutPremadeResultFilterHooked = true
end


local function MythicMate_HookPremadeSignupWarning()
    if whatLockoutPremadeSignupHooked then return end
    if not LFGListApplicationDialog or not LFGListApplicationDialog.SignUpButton then
        if C_Timer and type(C_Timer.After) == "function" then
            C_Timer.After(3, MythicMate_HookPremadeSignupWarning)
        end
        return
    end
    whatLockoutPremadeSignupHooked = true

    LFGListApplicationDialog.SignUpButton:HookScript("OnClick", function()
        if not C_LFGList or type(C_LFGList.GetSearchResultInfo) ~= "function" or
           type(C_LFGList.GetActivityInfoTable) ~= "function" then
            return
        end
        local resultID = LFGListApplicationDialog.resultID
        if not resultID then return end

        local resultInfo = C_LFGList.GetSearchResultInfo(resultID)
        if type(resultInfo) ~= "table" or not resultInfo.activityID then return end

        local activityInfo = C_LFGList.GetActivityInfoTable(resultInfo.activityID, resultInfo.questID, resultInfo.isWarMode)
        if type(activityInfo) ~= "table" then return end

        local name = activityInfo.shortName or activityInfo.fullName or activityInfo.name
        local norm = MythicMate_NormalizeName(name)
        local isLocked = norm and lockedMythicDungeonsByName[norm]
        if not isLocked then return end

        local msg = "|cffc868ffMythicMate:|r You are already LOCKED to this Mythic dungeon: " .. tostring(name)
        print(msg)
        if UIErrorsFrame and UIErrorsFrame.AddMessage then
            UIErrorsFrame:AddMessage(msg, 1, 0.3, 0.3, 1)
        end
    end)
end

local function MythicMate_HookPremadeCreateWarning()
    if whatLockoutPremadeCreateHooked then return end

    -- We hook the visible "List Group" button on the EntryCreation panel,
    -- similar to how we hook the SignUpButton for existing groups.
    local entry = _G.LFGListFrame and _G.LFGListFrame.EntryCreation
    local listButton = entry and entry.ListGroupButton
    if not listButton or not listButton.HookScript then
        if MythicMateDebugPremade then
            print("|cffc868ffMythicMate premade:|r ListGroupButton NOT ready yet, retrying hook...")
        end
        if C_Timer and type(C_Timer.After) == "function" then
            C_Timer.After(3, MythicMate_HookPremadeCreateWarning)
        end
        return
    end

    whatLockoutPremadeCreateHooked = true

    if MythicMateDebugPremade then
        print("|cffc868ffMythicMate premade:|r ListGroupButton hook installed")
    end

    listButton:HookScript("OnClick", function()
        if MythicMateDebugPremade then
            print("|cffc868ffMythicMate premade:|r ListGroupButton OnClick fired")
        end

        if not C_LFGList or type(C_LFGList.GetActivityInfoTable) ~= "function" then
            return
        end

        local entryFrame = _G.LFGListFrame and _G.LFGListFrame.EntryCreation
        if not entryFrame then return end

        -- Try multiple ways to get the selected activity ID.
        local activityID = entryFrame.selectedActivity
        local selectedGroupID = entryFrame.selectedGroup
        if MythicMateDebugPremade then
            print("|cffc868ffMythicMate premade:|r entryFrame.selectedActivity =", tostring(activityID))
            print("|cffc868ffMythicMate premade:|r entryFrame.selectedGroup =", tostring(selectedGroupID))
        end
        if (not activityID) and type(LFGListEntryCreation_GetSelectedActivity) == "function" then
            local ok, result = pcall(LFGListEntryCreation_GetSelectedActivity, entryFrame)
            if ok then activityID = result end
            if MythicMateDebugPremade then
                print("|cffc868ffMythicMate premade:|r LFGListEntryCreation_GetSelectedActivity() ->", tostring(activityID))
            end
        end

        if not activityID then return end

        local activityInfo = C_LFGList.GetActivityInfoTable(activityID)
        if type(activityInfo) ~= "table" then
            if MythicMateDebugPremade then
                print("|cffc868ffMythicMate premade:|r GetActivityInfoTable returned nil for activityID", tostring(activityID))
            end
            return
        end

        local activityName = activityInfo.shortName or activityInfo.fullName or activityInfo.name
        local groupName = nil
        if C_LFGList and type(C_LFGList.GetActivityGroupInfo) == "function" and selectedGroupID then
            local ok, n = pcall(C_LFGList.GetActivityGroupInfo, selectedGroupID)
            if ok and type(n) == "string" and n ~= "" then
                groupName = n
            end
        end

        local instanceName = nil
        if type(GetInstanceInfo) == "function" and type(IsInInstance) == "function" then
            local inInstance = IsInInstance()
            if inInstance then
                local n = GetInstanceInfo()
                if n and n ~= "" then
                    instanceName = n
                end
            end
        end

        local listingTitle = nil
        if entryFrame.Name and entryFrame.Name.GetText then
            listingTitle = entryFrame.Name:GetText()
        end
        if (not listingTitle or listingTitle == "") and type(LFGListEntryCreation_GetSanitizedName) == "function" then
            local ok, title = pcall(LFGListEntryCreation_GetSanitizedName, entryFrame)
            if ok then listingTitle = title end
        end

        local lockedFromActivity = MythicMate_IsDungeonNameLocked(activityName)
        local lockedFromGroup = MythicMate_IsDungeonNameLocked(groupName)
        local lockedFromTitle = MythicMate_IsDungeonNameLocked(listingTitle)
        local lockedFromInstance = MythicMate_IsDungeonNameLocked(instanceName)
        local matchedFromTitle = MythicMate_FindLockedDungeonInText(listingTitle)
        local isLocked = lockedFromActivity or lockedFromGroup or lockedFromTitle or lockedFromInstance or (matchedFromTitle ~= nil)

        if MythicMateDebugPremade then
            local actNorm = MythicMate_NormalizeName(activityName)
            local groupNorm = MythicMate_NormalizeName(groupName)
            local titleNorm = MythicMate_NormalizeName(listingTitle)
            local instanceNorm = MythicMate_NormalizeName(instanceName)
            print("|cffc868ffMythicMate premade:|r activity name =", tostring(activityName))
            print("|cffc868ffMythicMate premade:|r group name =", tostring(groupName))
            print("|cffc868ffMythicMate premade:|r listing title =", tostring(listingTitle))
            print("|cffc868ffMythicMate premade:|r instance name =", tostring(instanceName))
            print("|cffc868ffMythicMate premade:|r activityNorm =", tostring(actNorm), " activityLocked =", tostring(lockedFromActivity))
            print("|cffc868ffMythicMate premade:|r groupNorm =", tostring(groupNorm), " groupLocked =", tostring(lockedFromGroup))
            print("|cffc868ffMythicMate premade:|r titleNorm =", tostring(titleNorm), " titleLocked =", tostring(lockedFromTitle), " titleMatchLockedDungeon =", tostring(matchedFromTitle))
            print("|cffc868ffMythicMate premade:|r instanceNorm =", tostring(instanceNorm), " instanceLocked =", tostring(lockedFromInstance))
        end

        if not isLocked then return end

        local shownName = activityName
        if shownName == nil or shownName == "" or shownName == "Mythic" then
            shownName = matchedFromTitle or groupName or instanceName or listingTitle or activityName
        end
        local msg = "|cffc868ffMythicMate:|r You are already LOCKED to this Mythic dungeon (your own group): " .. tostring(shownName)
        print(msg)
        if UIErrorsFrame and UIErrorsFrame.AddMessage then
            UIErrorsFrame:AddMessage(msg, 1, 0.3, 0.3, 1)
        end
    end)
end

local function AcquireRow(index)
    if rowPool[index] then
        return rowPool[index]
    end

    local row = CreateFrame("Frame", nil, content)
    row:SetHeight(26) -- LFG-exact row height

    if index == 1 then
        row:SetPoint("TOPLEFT", content, "TOPLEFT", 8, -4)
        row:SetPoint("TOPRIGHT", content, "TOPRIGHT", -8, -4)
    else
        -- Contiguous with the previous row (no gap) so the striped bands are continuous, like LFG.
        row:SetPoint("TOPLEFT", rowPool[index - 1], "BOTTOMLEFT", 0, 0)
        row:SetPoint("TOPRIGHT", rowPool[index - 1], "BOTTOMRIGHT", 0, 0)
    end

    -- Soft background + subtle border for a datagrid look
    row.bg = row:CreateTexture(nil, "BACKGROUND")
    row.bg:SetAllPoints()
    row.bg:SetTexture("Interface\\Buttons\\WHITE8x8")
    row.bg:SetVertexColor(0.05, 0.05, 0.10, 0.95)

    row.highlight = row:CreateTexture(nil, "HIGHLIGHT")
    row.highlight:SetAllPoints()
    row.highlight:SetTexture("Interface\\Buttons\\WHITE8x8")
    row.highlight:SetVertexColor(0.5, 0.4, 1.0, 0.18)

    -- Allow mouse interaction so OnEnter/OnLeave fire for tooltips
    row:EnableMouse(true)

    row.name = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") -- LFG-exact row font
    -- Smaller left margin so rows are less indented
    row.name:SetPoint("LEFT", 16, 0)
    row.name:SetWidth(300)
    row.name:SetJustifyH("LEFT")
    if row.name.SetWordWrap then row.name:SetWordWrap(false) end
    -- Make the text itself hoverable for the tooltip as well
    row.name:EnableMouse(true)
    row.name:SetScript("OnEnter", function(fs)
        local parent = fs:GetParent()
        if parent and parent:GetScript("OnEnter") then
            parent:GetScript("OnEnter")(parent)
        end
    end)
    row.name:SetScript("OnLeave", function()
        GameTooltip:Hide()
    end)

    row.progress = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    -- Position relative to the name so it always stays inside the row
    row.progress:SetPoint("LEFT", row.name, "RIGHT", 12, 0)
    row.progress:SetWidth(100)
    row.progress:SetJustifyH("LEFT")

    row.reset = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    row.reset:SetPoint("LEFT", row.progress, "RIGHT", 12, 0)
    row.reset:SetWidth(100)
    row.reset:SetJustifyH("LEFT")

    -- Last column stretches to the row's right edge so the grid fills the full panel width
    -- (no dead space on the right) and long values like a dungeon name get real room.
    row.diff = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    row.diff:SetPoint("LEFT", row.reset, "RIGHT", 12, 0)
    row.diff:SetPoint("RIGHT", row, "RIGHT", -8, 0)
    row.diff:SetJustifyH("LEFT")
    if row.diff.SetWordWrap then row.diff:SetWordWrap(false) end

    -- Tooltip over de hele rij met duidelijke samenvatting
    row:SetScript("OnEnter", function(self)
        if not self.data then return end
        GameTooltip:SetOwner(self, "ANCHOR_LEFT")
        GameTooltip:ClearLines()
        GameTooltip:AddLine(self.data.name or "Unknown", 1, 0.82, 0)
        if self.data.difficulty then
            GameTooltip:AddLine(self.data.difficulty, 0.8, 0.8, 1)
        end

        if self.data.mode == "SHARED" then
            local function addListLines(header, list, r, g, b, max)
                max = max or 6
                if type(list) ~= "table" or #list == 0 then
                    GameTooltip:AddLine(header .. " (none)", r, g, b)
                    return
                end

                GameTooltip:AddLine(header, r, g, b)
                local count = 0
                for _, v in ipairs(list) do
                    if v and v ~= "" then
                        count = count + 1
                        if count > max then break end
                        GameTooltip:AddLine("- " .. tostring(v), r, g, b)
                    end
                end
                if #list > max then
                    GameTooltip:AddLine("- ...", r, g, b)
                end
            end

            addListLines("Locked by", self.data.lockedBy, 1, 0.3, 0.3, 6)
            addListLines("Available by", self.data.availableBy, 0.3, 1, 0.3, 6)
            GameTooltip:Show()
            return
        end

        if self.data.mode == "MPLUS" then
            if self.data.progress and self.data.progress ~= "" then
                GameTooltip:AddLine("Key: " .. tostring(self.data.progress), 0.4, 1, 0.4)
            end
            if self.data.reset and self.data.reset ~= "" then
                GameTooltip:AddLine("Count: " .. tostring(self.data.reset), 0.7, 0.75, 1.0)
            end

            local maps = self.data.mapsAtLevel or self.data.mapsAtMax
            if type(maps) == "table" and #maps > 0 then
                local max = 8
                local ready, locked = {}, {}

                for _, name in ipairs(maps) do
                    if name and name ~= "" then
                        if MythicMate_IsDungeonNameLocked and MythicMate_IsDungeonNameLocked(name) then
                            locked[#locked + 1] = name
                        else
                            ready[#ready + 1] = name
                        end
                    end
                end

                local function concatCap(list)
                    local out = {}
                    local lim = math.min(max, #list)
                    for i = 1, lim do out[#out + 1] = list[i] end
                    local suffix = (#list > max) and ", ..." or ""
                    return table.concat(out, ", ", 1, #out) .. suffix
                end

                local total = #maps
                GameTooltip:AddLine("Dungeons (best at this key): " .. tostring(total), 0.8, 0.8, 1)
                if #ready > 0 then
                    GameTooltip:AddLine("|cff50ff50Ready|r: " .. concatCap(ready), 0.4, 1, 0.4)
                end
                if #locked > 0 then
                    GameTooltip:AddLine("|cffff5050Locked|r: " .. concatCap(locked), 1, 0.3, 0.3)
                end
                GameTooltip:AddLine("Count is based on your current season-best per dungeon.", 0.6, 0.6, 0.6)
            end
            GameTooltip:Show()
            return
        end

        if self.data.progress then
            GameTooltip:AddLine("Progress: " .. self.data.progress, 0.9, 0.9, 0.9)
        end
        local resetText = FormatTime(self.data.reset)
        if resetText and resetText ~= "" then
            GameTooltip:AddLine("Reset in: " .. resetText, 0.9, 0.9, 0.9)
        end
        if self.data.locked then
            GameTooltip:AddLine("Status: |cffff5050Locked for this character|r", 1, 0.3, 0.3)
        else
            GameTooltip:AddLine("Status: |cff50ff50Available|r", 0.3, 1, 0.3)
        end

        -- If this is a player-view raid row, show defeated/open boss counts
        -- as a compact section (instead of the full per-boss list).
        -- Fallback to parsing `Progress` (usually "X/Y") so this still works
        -- even if old cached data didn't carry boss counts.
        if (not self.data.encounters) and self.data.raidBossTotalCount and self.data.raidBossTotalCount > 0 then
            local defeated = self.data.raidBossDefeatedCount or 0
            GameTooltip:AddLine("Bosses defeated: " .. tostring(defeated) .. "/" .. tostring(self.data.raidBossTotalCount), 0.8, 0.8, 1)
        elseif (not self.data.encounters) and type(self.data.progress) == "string" then
            local p1, p2 = self.data.progress:match("^(%d+)%s*/%s*(%d+)$")
            local defeated = tonumber(p1 or 0) or 0
            local total = tonumber(p2 or 0) or 0
            if total > 0 then
                GameTooltip:AddLine("Bosses defeated: " .. tostring(defeated) .. "/" .. tostring(total), 0.8, 0.8, 1)
            end
        end

        -- Show defeated/undefeated bosses similar to the Raid Info tooltip (for raids
        -- and any dungeons where the API provides encounter data)
        if self.data.encounters and #self.data.encounters > 0 then
            GameTooltip:AddLine(" ")
            GameTooltip:AddLine("Bosses:", 1, 0.82, 0)
            for _, enc in ipairs(self.data.encounters) do
                local name = enc.name or "Unknown"
                if enc.killed then
                    -- Red for defeated
                    GameTooltip:AddLine("|cffff5050- " .. name .. " (Defeated)|r", 1, 0.3, 0.3)
                else
                    -- Green for still open
                    GameTooltip:AddLine("|cff50ff50- " .. name .. " (Open)|r", 0.3, 1, 0.3)
                end
            end
        elseif self.data.locked then
            -- Fallback hint if we have no per-boss info (e.g., many 5-man dungeons)
            GameTooltip:AddLine(" ")
            GameTooltip:AddLine("Boss details are not available for this instance.", 0.8, 0.8, 0.8, true)
        end
        GameTooltip:Show()
    end)
    row:SetScript("OnLeave", function()
        GameTooltip:Hide()
    end)

    rowPool[index] = row
    return row
end

local function ClearRows()
    for _, row in ipairs(rowPool) do
        row:Hide()
    end
end

-- Scanning lockouts (Raid Info + Mythic dungeon overview)
------------------------------------------------------------

-- Warning: entering a locked base-Mythic dungeon
------------------------------------------------------------
local lastLockedDungeonWarningKey = nil
local lastLockedDungeonWarningAt = 0

local function MythicMate_CheckLockedMythicDungeonEntry()
    if not IsInInstance or not GetInstanceInfo then return end

    local inInstance, instanceType = IsInInstance()
    if not inInstance or instanceType ~= "party" then return end

    local name, _, difficultyID = GetInstanceInfo()
    if difficultyID ~= MYTHIC_DUNGEON_ID then return end
    if not name or name == "" then return end

    -- Ensure our lockout cache is up to date.
    ScanLockouts()

    local norm = MythicMate_NormalizeName(name)
    if not norm then return end
    local isLocked = lockedMythicDungeonsByName[norm] and true or false
    if not isLocked then
        -- Only warn when already locked (no "creating lockout" warning).
        return
    end

    -- Debounce so zoning / loading screens don't spam.
    local now = (type(GetTime) == "function") and GetTime() or 0
    local key = norm .. ":" .. tostring(difficultyID) .. ":LOCKED"
    if lastLockedDungeonWarningKey == key and (now - (lastLockedDungeonWarningAt or 0)) < 30 then
        return
    end
    lastLockedDungeonWarningKey = key
    lastLockedDungeonWarningAt = now
end

-- Mythic dungeon overview list.
-- Blizzard rotates the Mythic+ season pool, so don't hardcode names.
-- Only show dungeons that are currently in the rotation (current season pool).
local whatLockoutSeasonDungeonPoolAvailable = true
local whatLockoutNeedsSeasonPoolRetry = false

-- Season M+ pool as { mapID, name } so scan/export/shared merge can use a locale-stable id.
local function MythicMate_GetMythicDungeonSeasonEntries()
    local out = {}
    local seenMapID = {}

    -- LFG M+ activity names (normalized) to ensure fallback maps match real keystone dungeons.
    local lfgMplusNormSet = {}
    if C_LFGList and type(C_LFGList.GetAvailableActivities) == "function" and
       type(C_LFGList.GetActivityInfoTable) == "function" then
        local DUNGEON_CATEGORY_ID = 2
        local filters = 0
        local okActs, activities = pcall(C_LFGList.GetAvailableActivities, DUNGEON_CATEGORY_ID, 0, filters)
        if okActs and type(activities) == "table" then
            for _, activityID in ipairs(activities) do
                local info = C_LFGList.GetActivityInfoTable(activityID)
                if type(info) == "table" and info.isMythicPlusActivity then
                    local n = info.fullName or info.shortName or info.name
                    local norm = n and MythicMate_NormalizeName(n) or nil
                    if norm and norm ~= "" then
                        lfgMplusNormSet[norm] = true
                    end
                end
            end
        end
    end

    local function addEntry(mapID, name, opts)
        if type(mapID) ~= "number" then return end
        if seenMapID[mapID] then return end
        local uiName, _, timeLimit = nil, nil, nil
        if C_ChallengeMode and type(C_ChallengeMode.GetMapUIInfo) == "function" then
            uiName, _, timeLimit = C_ChallengeMode.GetMapUIInfo(mapID)
        end
        local useName = name or uiName
        if not useName or useName == "" then return end
        if Util and Util.InstanceNameIsHiddenSeasonOrShared and Util.InstanceNameIsHiddenSeasonOrShared(useName, { mapID = mapID, challengeMapID = mapID }) then return end
        local allowMissingTime = opts and opts.allowMissingTimeLimit
        -- Only accept timed challenge maps unless explicitly allowed (season lists can be missing limits).
        if not allowMissingTime then
            if not timeLimit or timeLimit <= 0 then return end
        end
        local norm = MythicMate_NormalizeName(useName)
        local loose = MythicMate_NormalizeInstanceKeyLoose and MythicMate_NormalizeInstanceKeyLoose(useName) or nil
        if not norm and not loose then return end
        if Util and Util.InstanceNameHitsStringBlacklist and whatLockoutDungeonBlacklistNormSet then
            if Util.InstanceNameHitsStringBlacklist(useName, whatLockoutDungeonBlacklistNormSet, MythicMate_NormalizeName, MythicMate_NormalizeInstanceKeyLoose) then
                return
            end
        elseif (norm and whatLockoutDungeonBlacklistNormSet and whatLockoutDungeonBlacklistNormSet[norm])
            or (loose and whatLockoutDungeonBlacklistNormSet and whatLockoutDungeonBlacklistNormSet[loose]) then
            return
        end
        local requireLfgMatch = opts and opts.requireLfgMatch
        -- If a match is required, we must have LFG names; otherwise skip.
        if requireLfgMatch then
            local hasLfg = next(lfgMplusNormSet) ~= nil
            if hasLfg then
                if not ((norm and lfgMplusNormSet[norm]) or (loose and lfgMplusNormSet[loose])) then return end
            end
            -- If no LFG data yet, allow the entry (still blacklist/timeLimit filtered).
        end
        seenMapID[mapID] = true
        out[#out + 1] = { mapID = mapID, name = useName }
    end

    -- Preferred: derive the season map list from GetCurrentSeasonValues().
    -- The returned table's field names have varied across builds, so we scan for
    -- the "best" candidate: a numeric array of mapIDs that resolves to UI names.
    if C_MythicPlus and type(C_MythicPlus.GetCurrentSeasonValues) == "function" and
       C_ChallengeMode and type(C_ChallengeMode.GetMapUIInfo) == "function" then
        local ok, values = pcall(C_MythicPlus.GetCurrentSeasonValues)
        if ok and type(values) == "table" then
            local bestList = nil
            local bestScore = 0

            local function scoreCandidate(list)
                if type(list) ~= "table" then return 0 end
                local n = #list
                if n < 4 or n > 20 then return 0 end
                local valid = 0
                for i = 1, n do
                    local mapID = list[i]
                    if type(mapID) == "number" then
                        local name = C_ChallengeMode.GetMapUIInfo(mapID)
                        if name and name ~= "" then
                            valid = valid + 1
                        end
                    end
                end
                -- Prefer lists where most entries resolve to names.
                return valid
            end

            -- First check the most likely field names quickly.
            local likely = {
                values.seasonMaps, values.currentSeasonMaps, values.maps, values.mapIDs,
                values.seasonMapIDs, values.currentSeasonMapIDs, values.trackedMaps,
            }
            for _, cand in ipairs(likely) do
                local s = scoreCandidate(cand)
                if s > bestScore then
                    bestScore = s
                    bestList = cand
                end
            end

            -- If still nothing, scan all fields for a plausible numeric mapID array.
            if bestScore == 0 then
                for _, cand in pairs(values) do
                    local s = scoreCandidate(cand)
                    if s > bestScore then
                        bestScore = s
                        bestList = cand
                    end
                end
            end

            if type(bestList) == "table" and bestScore > 0 then
                for _, mapID in ipairs(bestList) do
                    if type(mapID) == "number" then
                        local name = C_ChallengeMode.GetMapUIInfo(mapID)
                        addEntry(mapID, name, { allowMissingTimeLimit = true })
                    end
                end
            end
        end
    end

    -- Fallback: some clients expose an explicit current-season map list.
    if #out == 0 and C_MythicPlus and type(C_MythicPlus.GetCurrentSeasonMaps) == "function" and
       C_ChallengeMode and type(C_ChallengeMode.GetMapUIInfo) == "function" then
        local ok, maps = pcall(C_MythicPlus.GetCurrentSeasonMaps)
        if ok and type(maps) == "table" then
            for _, mapID in ipairs(maps) do
                if type(mapID) == "number" then
                    local name = C_ChallengeMode.GetMapUIInfo(mapID)
                    addEntry(mapID, name, { allowMissingTimeLimit = true })
                end
            end
        end
    end

    -- Always union in the challenge-mode map table (the authoritative current-season M+ map
    -- list). This catches dungeons that GetCurrentSeasonValues omits or is slow to report —
    -- e.g. a newly added season dungeon like Voidscar Arena. Deduped by mapID (seenMapID) and
    -- still filtered by blacklist / hidden-id / LFG-activity match, so it can't introduce
    -- stray scenarios. Previously this only ran when nothing else was found (#out == 0), which
    -- meant a partial season-values list would permanently hide any dungeon it left out.
    if C_ChallengeMode and type(C_ChallengeMode.GetMapTable) == "function" then
        local maps = C_ChallengeMode.GetMapTable()
        if type(maps) == "table" then
            for _, mapID in ipairs(maps) do
                if type(mapID) == "number" then
                    addEntry(mapID, nil, { requireLfgMatch = true })
                end
            end
        end
    end

    -- If the season pool isn't available yet (common right after login), request
    -- Mythic+ map info and retry shortly via a timer/event.
    if #out == 0 and C_MythicPlus and type(C_MythicPlus.RequestMapInfo) == "function" then
        pcall(C_MythicPlus.RequestMapInfo)
        whatLockoutNeedsSeasonPoolRetry = true
    end

    return out
end

local function MythicMate_GetMythicDungeonDisplayList()
    local entries = MythicMate_GetMythicDungeonSeasonEntries()
    local out = {}
    for i = 1, #entries do
        out[i] = entries[i].name
    end
    return out
end

-- Returns { { name = localizedString, raidKeyID = number }, ... }.
-- raidKeyID is an LFG activity id or EJ instance id for stable merge/dedupe.
local function MythicMate_GetCurrentRaidDisplayList()
    local out, seenNorm, seenKey = {}, {}, {}

    local function addName(name, raidKeyID)
        if not name or name == "" then return end
        local kid = tonumber(raidKeyID) or nil
        if kid and kid <= 0 then kid = nil end
        if Util and Util.InstanceNameIsHiddenSeasonOrShared and Util.InstanceNameIsHiddenSeasonOrShared(name, { journalInstanceID = kid, raidKeyID = kid }) then return end
        local raw = tostring(name)
        raw = raw:gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", "")
        raw = raw:gsub("%s*%([^)]+%)", "")
        if MythicMate_IsLikelyPvPActivityName(raw) then
            return
        end
        local norm = MythicMate_NormalizeName(name)
        if not norm or norm == "" then return end
        if Util and Util.InstanceNameHitsStringBlacklist and whatLockoutRaidBlacklistNormSet then
            if Util.InstanceNameHitsStringBlacklist(name, whatLockoutRaidBlacklistNormSet, MythicMate_NormalizeName, MythicMate_NormalizeInstanceKeyLoose) then return end
        elseif whatLockoutRaidBlacklistNormSet and whatLockoutRaidBlacklistNormSet[norm] then return end
        if kid and seenKey[kid] then return end
        if seenNorm[norm] then return end
        seenNorm[norm] = true
        if kid then seenKey[kid] = true end
        out[#out + 1] = { name = name, raidKeyID = kid }
    end

    local ejNormSet, ejLooseSet = {}, {}

    if type(EJ_SelectTier) == "function" and type(EJ_GetInstanceByIndex) == "function" then
        local savedTier = nil
        if type(EJ_GetCurrentTier) == "function" then
            local okSaved, st = pcall(EJ_GetCurrentTier)
            savedTier = (okSaved and type(st) == "number" and st > 0) and st or nil
        end

        local useTier = nil
        if type(EJ_GetNumTiers) == "function" then
            local okN, n = pcall(EJ_GetNumTiers)
            if okN and type(n) == "number" and n > 0 then
                useTier = n
            end
        end
        if not useTier and savedTier then
            useTier = savedTier
        end

        if useTier then
            pcall(EJ_SelectTier, useTier)
            pcall(function()
                for ejIdx = 1, 48 do
                    local instanceID, name, _, _, _, _, _, _, _, shouldDisplayDifficulty, mapID = EJ_GetInstanceByIndex(ejIdx, true)
                    if not instanceID then break end
                    if type(name) == "string" and name ~= "" then
                        local mp = tonumber(mapID) or 0
                        if mp > 0 and shouldDisplayDifficulty == true then
                            addName(name, instanceID)
                            local norm = MythicMate_NormalizeName(name)
                            local loose = MythicMate_NormalizeInstanceKeyLoose(name)
                            if norm then ejNormSet[norm] = true end
                            if loose then ejLooseSet[loose] = true end
                        end
                    end
                end
            end)
            if savedTier then
                pcall(EJ_SelectTier, savedTier)
            end
        end
    end

    local function matchesEj(name)
        if next(ejNormSet) == nil and next(ejLooseSet) == nil then
            return true
        end
        local norm = MythicMate_NormalizeName(name)
        if norm and ejNormSet[norm] then return true end
        local loose = MythicMate_NormalizeInstanceKeyLoose(name)
        if loose and ejLooseSet[loose] then return true end
        return false
    end

    if C_LFGList and type(C_LFGList.GetAvailableActivities) == "function" and
       type(C_LFGList.GetActivityInfoTable) == "function" then
        local RAID_CATEGORY_ID = 3
        local filters = 0
        local okActsAll, activitiesAll = pcall(C_LFGList.GetAvailableActivities, RAID_CATEGORY_ID, 0, filters)
        local activities = (okActsAll and type(activitiesAll) == "table") and activitiesAll or {}

        if Enum and Enum.LFGListFilter and Enum.LFGListFilter.Recommended then
            local recFilters = bit.bor(filters, Enum.LFGListFilter.Recommended)
            local okActsRec, activitiesRec = pcall(C_LFGList.GetAvailableActivities, RAID_CATEGORY_ID, 0, recFilters)
            if okActsRec and type(activitiesRec) == "table" and #activitiesRec > 0 then
                activities = activitiesRec
            end
        end

        for _, activityID in ipairs(activities) do
            local info = C_LFGList.GetActivityInfoTable(activityID)
            if type(info) == "table" and info.isCurrentRaidActivity then
                local chosenName = info.fullName or info.shortName or info.name
                if chosenName and matchesEj(chosenName) then
                    addName(chosenName, activityID)
                end
            end
        end
    end

    return out
end

local function MythicMate_NormalizeInstanceKeyLoose(name)
    if not name then return nil end
    local n = tostring(name)
    n = n:gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", "")
    n = n:gsub("%s*%([^)]+%)", "")
    n = n:lower()
    n = n:gsub("[%s%p_]+", "")
    if n == "" then return nil end
    return n
end

MythicMate_GetCurrentRaidNameSet = function()
    local set = {}
    for _, row in ipairs(data.RAIDS or {}) do
        if row and row.name then
            local norm = MythicMate_NormalizeName(row.name)
            if norm and norm ~= "" then
                set[norm] = true
            end
        end
    end
    return set
end

ScanLockouts = function()
    data.RAIDS = {}
    data.DUNGEONS = {}
    lockedMythicDungeonsByName = {}
    lockedMythicDungeonsByMapID = {}

    local seasonEntries = MythicMate_GetMythicDungeonSeasonEntries()
    local savedBaseMythicByMapID = {}
    -- Map of saved base-Mythic dungeons by normalized name so we can
    -- show the full Mythic dungeon overview and mark which ones are locked.
    local savedBaseMythicByName = {}
    -- Raid saves: one entry per (raid name + difficulty). Merging only by name hid Heroic when Mythic reset was longer.
    local savedRaidEntries = {}

    local num = GetNumSavedInstances()
    for i = 1, num do
        local gsi = { GetSavedInstanceInfo(i) }
        local name, id, reset, difficultyId, locked, extended, instanceIdMostSig =
            gsi[1], gsi[2], gsi[3], gsi[4], gsi[5], gsi[6], gsi[7]
        local isRaid, maxPlayers, difficultyName, numEncounters, progress =
            gsi[8], gsi[9], gsi[10], gsi[11], gsi[12]
        -- Retail: #13 extendDisabled, #14 instanceId (warcraft.wiki.gg/API_GetSavedInstanceInfo). Do not use #14 as a lock flag.
        local isCompleted = gsi[16]
        local journalInstanceID = (type(gsi[14]) == "number" and gsi[14] > 0) and tonumber(gsi[14]) or nil

        local mp = tonumber(maxPlayers) or 0
        local ne = tonumber(numEncounters) or 0
        local did = tonumber(difficultyId) or 0
        local progN = tonumber(progress) or 0
        -- Newer/special raids sometimes report isRaid=false. Include large groups and
        -- small-group raids with many bosses (some current tiers use maxPlayers like a dungeon).
        local raidLike = isRaid or (mp >= 10) or (mp >= 8 and ne >= 3) or (mp >= 5 and ne >= 6)
        -- WoW often sets isRaid=true for 5-man dungeons (TW/Mythic/Heroic). Those never have
        -- raid-sized rosters; cap <=5 players with fewer than 8 bosses => dungeon, not raid.
        if mp > 0 and mp <= 5 and ne < 8 then
            raidLike = false
        end

        -- Collect encounter (boss) information for tooltips.
        -- Some builds return numEncounters=0/nil for raids; in that case we probe
        -- a reasonable range until bossName stops resolving.
        local encounters = nil
        if numEncounters and numEncounters > 0 then
            encounters = {}
            local seenBoss = {} -- [bossName]=encounterRow
            for e = 1, numEncounters do
                local bossName, _, bossKilled = GetSavedInstanceEncounterInfo(i, e)
                if bossName then
                    local killed = bossKilled and true or false
                    local existing = seenBoss[bossName]
                    if not existing then
                        local enc = { name = bossName, killed = killed }
                        seenBoss[bossName] = enc
                        table.insert(encounters, enc)
                    else
                        -- If any encounter entry says "killed", treat the boss as killed.
                        if killed then existing.killed = true end
                    end
                end
            end
        elseif GetSavedInstanceEncounterInfo and raidLike then
            encounters = {}
            local seenBoss = {} -- [bossName]=encounterRow
            local maxProbe = 40
            for e = 1, maxProbe do
                local bossName, _, bossKilled = GetSavedInstanceEncounterInfo(i, e)
                if not bossName then
                    break
                end
                local killed = bossKilled and true or false
                local existing = seenBoss[bossName]
                if not existing then
                    local enc = { name = bossName, killed = killed }
                    seenBoss[bossName] = enc
                    table.insert(encounters, enc)
                else
                    if killed then existing.killed = true end
                end
            end
            if #encounters == 0 then
                encounters = nil
            end
        end

        -- Treat an instance as saved exactly like the Raid Info panel.
        -- Rare builds omit `locked` but still report boss kills for an active ID.
        -- Do NOT use numEncounters alone: that is just boss count, not "you are saved".
        local anyEncounterKilled = false
        if type(encounters) == "table" then
            for _, enc in ipairs(encounters) do
                if enc and enc.killed then
                    anyEncounterKilled = true
                    break
                end
            end
        end
        local bound = (locked == true)
        local looksLikeActiveRaidSave = raidLike and (progN > 0 or anyEncounterKilled)
        if bound or looksLikeActiveRaidSave then
            if Util and Util.SavedInstanceGSIContainsHiddenId and Util.SavedInstanceGSIContainsHiddenId(gsi) then
                -- Junk row: hide id appears somewhere in the API tuple (not always return 14).
            else
            local diffName = type(difficultyName) == "string" and difficultyName or ""
            if raidLike and did ~= MYTHIC_DUNGEON_ID then
                local raidName = name or "Unknown"
                if Util and Util.InstanceNameIsHiddenSeasonOrShared and Util.InstanceNameIsHiddenSeasonOrShared(raidName, {
                    journalInstanceID = journalInstanceID,
                    savedInstanceID = tonumber(id) or nil,
                }) then
                    -- Ignore misclassified zone/scenario saves (e.g. The Veiled Stair).
                else
                local normRaid = MythicMate_NormalizeName(raidName)
                if normRaid and normRaid ~= "" then
                    local computedBossTotal, computedBossDefeated = 0, 0
                    if type(encounters) == "table" and #encounters > 0 then
                        computedBossTotal = #encounters
                        for _, enc in ipairs(encounters) do
                            if enc and enc.killed then
                                computedBossDefeated = computedBossDefeated + 1
                            end
                        end
                    end

                    local raidProgress
                    if computedBossTotal > 0 then
                        local defeatedFromApi = tonumber(progress or 0) or 0
                        if defeatedFromApi < 0 then defeatedFromApi = 0 end
                        if defeatedFromApi > computedBossTotal then defeatedFromApi = computedBossTotal end
                        -- API `progress` and per-boss flags disagree on some raids; take the higher kill count.
                        local defeated = math.max(defeatedFromApi, computedBossDefeated)
                        if defeated > computedBossTotal then
                            defeated = computedBossTotal
                        end
                        raidProgress = tostring(defeated) .. "/" .. tostring(computedBossTotal)
                    else
                        raidProgress = (progress or 0) .. "/" .. (numEncounters or 0)
                    end

                    local thisReset = tonumber(reset) or 0
                    local diffId = did
                    local merged = false
                    for _, e in ipairs(savedRaidEntries) do
                        if e.normRaid == normRaid and e.difficultyId == diffId then
                            local er = tonumber(e.reset) or 0
                            if thisReset > er then
                                e.name = raidName
                                e.progress = raidProgress
                                e.reset = reset
                                e.difficulty = diffName ~= "" and diffName or "Raid"
                                e.encounters = encounters
                                if not e.instanceID and id then
                                    e.instanceID = tonumber(id) or nil
                                end
                                if journalInstanceID then
                                    e.journalInstanceID = journalInstanceID
                                end
                            end
                            merged = true
                            break
                        end
                    end
                    if not merged then
                        savedRaidEntries[#savedRaidEntries + 1] = {
                            normRaid     = normRaid,
                            difficultyId = diffId,
                            name         = raidName,
                            progress     = raidProgress,
                            reset        = reset,
                            difficulty   = diffName ~= "" and diffName or "Raid",
                            locked       = true,
                            encounters   = encounters,
                            instanceID   = tonumber(id) or nil,
                            journalInstanceID = journalInstanceID,
                        }
                    end
                end
                end
            elseif (not raidLike) and did == MYTHIC_DUNGEON_ID then
                -- Base Mythic 5-man dungeons (NOT Mythic+)
                local dungeonName = name or "Unknown"
                if Util and Util.InstanceNameIsHiddenSeasonOrShared and Util.InstanceNameIsHiddenSeasonOrShared(dungeonName, {
                    journalInstanceID = journalInstanceID,
                    savedInstanceID = tonumber(id) or nil,
                }) then
                    -- Junk challenge/scenario names must not match season rows or sync payloads.
                else
                local norm = MythicMate_NormalizeName(dungeonName)
                local expired = (reset == 0)
                if norm and norm ~= "" then
                    local mapID = nil
                    for _, se in ipairs(seasonEntries) do
                        if MythicMate_NormalizeName(se.name) == norm then
                            mapID = se.mapID
                            break
                        end
                    end
                    if not mapID and C_ChallengeMode and type(C_ChallengeMode.GetMapTable) == "function" and
                       type(C_ChallengeMode.GetMapUIInfo) == "function" then
                        local okMaps, maps = pcall(C_ChallengeMode.GetMapTable)
                        if okMaps and type(maps) == "table" then
                            for _, mid in ipairs(maps) do
                                if type(mid) == "number" then
                                    local uiName = C_ChallengeMode.GetMapUIInfo(mid)
                                    if uiName and MythicMate_NormalizeName(uiName) == norm then
                                        mapID = mid
                                        break
                                    end
                                end
                            end
                        end
                    end
                    local entry = {
                        name            = dungeonName,
                        progress        = expired and "Expired" or (isCompleted and "Done" or "In progress"),
                        status          = expired and "EXPIRED" or (isCompleted and "LOCKED_DONE" or "LOCKED_IN_PROGRESS"),
                        reset           = reset,
                        difficulty      = difficultyName or "Mythic",
                        difficultyId    = MYTHIC_DUNGEON_ID,
                        locked          = (not expired) and (reset and reset > 0),
                        encounters      = encounters,
                        savedInstanceID = tonumber(id) or nil,
                    }
                    if mapID then
                        savedBaseMythicByMapID[mapID] = entry
                    else
                        savedBaseMythicByName[norm] = entry
                    end
                    -- Use the actual `locked` flag; some builds can have reset==0 while
                    -- still reporting a currently locked base-Mythic save.
                    if locked == true then
                        lockedMythicDungeonsByName[norm] = true
                        if mapID then
                            lockedMythicDungeonsByMapID[mapID] = true
                        end
                    end
                end
                end
            end
            end
        end
    end

    -- Build dungeon overview: one row per Mythic dungeon (season pool when
    -- possible), so you can clearly see which ones are still Available.
    whatLockoutSeasonDungeonPoolAvailable = (#seasonEntries > 0)
    for _, se in ipairs(seasonEntries) do
        if Util and Util.InstanceNameIsHiddenSeasonOrShared and Util.InstanceNameIsHiddenSeasonOrShared(se.name, { mapID = se.mapID, challengeMapID = se.mapID }) then
            -- Pandaria scenario / junk map names must not seed allowlists or UI rows.
        else
        local saved = savedBaseMythicByMapID[se.mapID]
        if not saved then
            local norm = MythicMate_NormalizeName(se.name)
            saved = norm and savedBaseMythicByName[norm] or nil
        end

        if saved then
            table.insert(data.DUNGEONS, {
                mapID           = se.mapID,
                name            = se.name,
                progress        = saved.progress,
                status          = saved.status,
                reset           = saved.reset or 0,
                difficulty      = saved.difficulty or "Mythic",
                difficultyId    = saved.difficultyId or MYTHIC_DUNGEON_ID,
                locked          = saved.locked,
                encounters      = saved.encounters,
                savedInstanceID = saved.savedInstanceID,
            })
        else
            -- Not saved at all: clearly mark as Available
            table.insert(data.DUNGEONS, {
                mapID      = se.mapID,
                name       = se.name,
                progress   = "Available",
                status     = "AVAILABLE",
                reset      = 0,
                difficulty = "Mythic",
                difficultyId = MYTHIC_DUNGEON_ID,
                locked     = false,
            })
        end
        end
    end

    -- Build raid overview: include current raids even when not locked.
    local raidDisplayList = MythicMate_GetCurrentRaidDisplayList()
    local function savedRaidMatchesDisplay(savedEntry, displayName)
        local norm = MythicMate_NormalizeName(displayName)
        if not norm or norm == "" then return false end
        if savedEntry.normRaid == norm then return true end
        local looseDisplay = MythicMate_NormalizeInstanceKeyLoose(displayName)
        local looseSaved = MythicMate_NormalizeInstanceKeyLoose(savedEntry.name)
        if looseDisplay and looseSaved then
            if looseSaved == looseDisplay then return true end
            if looseSaved:find(looseDisplay, 1, true) or looseDisplay:find(looseSaved, 1, true) then
                return true
            end
        end
        return false
    end

    local consumed = {}
    for _, displayEntry in ipairs(raidDisplayList) do
        local displayName = displayEntry and displayEntry.name
        local displayRaidKey = displayEntry and tonumber(displayEntry.raidKeyID) or nil
        if displayRaidKey and displayRaidKey <= 0 then displayRaidKey = nil end
        if displayName then
            local matchIdx = {}
            for idx, s in ipairs(savedRaidEntries) do
                if not consumed[idx] and savedRaidMatchesDisplay(s, displayName) then
                    matchIdx[#matchIdx + 1] = idx
                end
            end

            if #matchIdx > 0 then
                for _, idx in ipairs(matchIdx) do
                    consumed[idx] = true
                    local s = savedRaidEntries[idx]
                    -- Prefer LFG id when this row came from the current raid list so
                    -- cross-locale party merge keys match "Available" rows (saved instance
                    -- ids often differ from LFG activity/group ids).
                    local rk = displayRaidKey or s.instanceID
                    table.insert(data.RAIDS, {
                        name       = displayName,
                        progress   = s.progress,
                        reset      = s.reset or 0,
                        difficulty = s.difficulty or "Raid",
                        difficultyId = s.difficultyId,
                        locked     = true,
                        encounters = s.encounters,
                        raidKeyID  = rk,
                        journalInstanceID = s.journalInstanceID,
                    })
                end
            else
                table.insert(data.RAIDS, {
                    name       = displayName,
                    progress   = "Available",
                    reset      = 0,
                    difficulty = "Raid",
                    locked     = false,
                    raidKeyID  = displayRaidKey,
                    journalInstanceID = (displayRaidKey and displayRaidKey > 0) and displayRaidKey or nil,
                })
            end
        end
    end

    -- Saves not tied to the LFG "current raid" list (older tiers, etc.)
    for idx, s in ipairs(savedRaidEntries) do
        if not consumed[idx] and not (Util and Util.InstanceNameIsHiddenSeasonOrShared and Util.InstanceNameIsHiddenSeasonOrShared(s.name, { journalInstanceID = s.journalInstanceID, raidKeyID = s.instanceID })) then
            table.insert(data.RAIDS, {
                name       = s.name,
                progress   = s.progress,
                reset      = s.reset or 0,
                difficulty = s.difficulty or "Raid",
                difficultyId = s.difficultyId,
                locked     = true,
                encounters = s.encounters,
                raidKeyID  = s.instanceID,
                journalInstanceID = s.journalInstanceID,
            })
        end
    end

    table.sort(data.RAIDS, function(a, b)
        local na, nb = a.name or "", b.name or ""
        if na ~= nb then return na < nb end
        return (a.difficulty or "") < (b.difficulty or "")
    end)

    -- Keep the local player snapshot up to date for Players tab + tooltips.
    MythicMate_SaveSelfSnapshot()
end

------------------------------------------------------------
-- Mythic+ stats tab (highest key achieved + distribution)
------------------------------------------------------------
local function MythicMate_GetMythicPlusSeasonMapIDs()
    -- Prefer current-season map lists (less noise than all maps).
    if C_MythicPlus and type(C_MythicPlus.GetCurrentSeasonMaps) == "function" and
       C_ChallengeMode and type(C_ChallengeMode.GetMapUIInfo) == "function" then
        local ok, maps = pcall(C_MythicPlus.GetCurrentSeasonMaps)
        if ok and type(maps) == "table" then
            local out, seen = {}, {}
            for _, mapID in ipairs(maps) do
                if type(mapID) == "number" and not seen[mapID] then
                    local name = C_ChallengeMode.GetMapUIInfo(mapID)
                    if name and name ~= "" then
                        seen[mapID] = true
                        out[#out + 1] = mapID
                    end
                end
            end
            if #out > 0 then return out end
        end
    end

    -- Fallback: derive from GetCurrentSeasonValues() by scoring candidate arrays.
    if C_MythicPlus and type(C_MythicPlus.GetCurrentSeasonValues) == "function" and
       C_ChallengeMode and type(C_ChallengeMode.GetMapUIInfo) == "function" then
        local ok, values = pcall(C_MythicPlus.GetCurrentSeasonValues)
        if ok and type(values) == "table" then
            local bestList, bestScore = nil, 0

            local function scoreCandidate(list)
                if type(list) ~= "table" then return 0 end
                local n = #list
                if n < 4 or n > 40 then return 0 end
                local valid = 0
                for i = 1, n do
                    local mapID = list[i]
                    if type(mapID) == "number" then
                        local name = C_ChallengeMode.GetMapUIInfo(mapID)
                        if name and name ~= "" then
                            valid = valid + 1
                        end
                    end
                end
                return valid
            end

            local likely = {
                values.seasonMaps, values.currentSeasonMaps, values.maps, values.mapIDs,
                values.seasonMapIDs, values.currentSeasonMapIDs, values.trackedMaps,
            }

            for _, cand in ipairs(likely) do
                local s = scoreCandidate(cand)
                if s > bestScore then
                    bestScore = s
                    bestList = cand
                end
            end

            if not bestList then
                for _, cand in pairs(values) do
                    local s = scoreCandidate(cand)
                    if s > bestScore then
                        bestScore = s
                        bestList = cand
                    end
                end
            end

            if type(bestList) == "table" and bestScore > 0 then
                local out, seen = {}, {}
                for _, mapID in ipairs(bestList) do
                    if type(mapID) == "number" and not seen[mapID] then
                        local name = C_ChallengeMode.GetMapUIInfo(mapID)
                        if name and name ~= "" then
                            seen[mapID] = true
                            out[#out + 1] = mapID
                        end
                    end
                end
                if #out > 0 then return out end
            end
        end
    end

    -- Last fallback: any challenge-mode map.
    if C_ChallengeMode and type(C_ChallengeMode.GetMapTable) == "function" and
       C_ChallengeMode and type(C_ChallengeMode.GetMapUIInfo) == "function" then
        local ok, maps = pcall(C_ChallengeMode.GetMapTable)
        if ok and type(maps) == "table" then
            local out, seen = {}, {}
            for _, mapID in ipairs(maps) do
                if type(mapID) == "number" and not seen[mapID] then
                    local name = C_ChallengeMode.GetMapUIInfo(mapID)
                    if name and name ~= "" then
                        seen[mapID] = true
                        out[#out + 1] = mapID
                    end
                end
            end
            return out
        end
    end

    return {}
end

MythicMate_BuildMythicPlusStats = function()
    data.MPLUS = {}

    if not (C_MythicPlus and type(C_MythicPlus.GetSeasonBestForMap) == "function") then
        data.MPLUS[1] = { mode = "MPLUS", order = 1, name = "Mythic+ Stats", progress = "N/A", reset = "Missing API", difficulty = "" }
        return
    end
    if not (C_ChallengeMode and type(C_ChallengeMode.GetMapUIInfo) == "function") then
        data.MPLUS[1] = { mode = "MPLUS", order = 1, name = "Mythic+ Stats", progress = "N/A", reset = "Missing map name API", difficulty = "" }
        return
    end

    local mapIDs = MythicMate_GetMythicPlusSeasonMapIDs()
    if type(mapIDs) ~= "table" or #mapIDs == 0 then
        data.MPLUS[1] = { mode = "MPLUS", order = 1, name = "Mythic+ Stats", progress = "N/A", reset = "No season maps", difficulty = "" }
        return
    end

    -- Per-dungeon breakdown: every season dungeon, its current best key level + dungeon score,
    -- and — crucially — the ones you HAVEN'T done yet (score 0). Overall M+ rating is the sum of
    -- your per-dungeon scores, so the biggest rating gains come from your lowest-scored / not-yet
    -- run dungeons. We surface those first so it's obvious which key to push for the most score.
    local perMap = {}
    local totalScore = 0
    local maxLevel = 0
    local doneCount = 0

    for _, mapID in ipairs(mapIDs) do
        local name = C_ChallengeMode.GetMapUIInfo(mapID) or ("Map " .. tostring(mapID))
        local level, score = 0, 0

        -- Best run (in-time and over-time). Check each explicitly: intime can be nil while overtime
        -- is set, and ipairs over a table with a nil first element would stop immediately and miss
        -- the over-time run.
        --   level        = the absolute highest key you finished (timed OR depleted) — feeds the
        --                  "Highest Key" summary.
        --   dispLevel/timed = the best-SCORING run (the one Raider.IO shows), plus whether that run
        --                  was completed in time. A timed +9 can outscore a depleted +10, so the
        --                  row must show the run that actually counts, not just the highest number.
        local dispLevel, dispScore, timed = 0, -1, false
        local okB, intime, overtime = pcall(C_MythicPlus.GetSeasonBestForMap, mapID)
        if okB then
            local function take(info, isTimed)
                if type(info) == "table" then
                    local lvl = tonumber(info.level) or 0
                    local sc = tonumber(info.dungeonScore) or 0
                    if lvl > level then level = lvl end
                    if sc > score then score = sc end
                    -- Pick the highest-scoring run for the display; on a score tie prefer the timed
                    -- run (matches how Raider.IO stars the best run).
                    local better = sc > dispScore or (sc == dispScore and isTimed and not timed)
                    if better then dispScore, dispLevel, timed = sc, lvl, isTimed end
                end
            end
            take(intime, true)
            take(overtime, false)
        end

        -- Prefer the affix-aggregated per-dungeon score (bestOverAllScore): this is the value that
        -- actually feeds your overall rating, so it's the right number to compare across dungeons.
        if type(C_MythicPlus.GetSeasonBestAffixScoreInfoForMap) == "function" then
            local okA, _affix, bestOverall = pcall(C_MythicPlus.GetSeasonBestAffixScoreInfoForMap, mapID)
            if okA and type(bestOverall) == "number" and bestOverall > score then
                score = bestOverall
            end
        end

        local notDone = (level <= 0 and score <= 0)
        if not notDone then doneCount = doneCount + 1 end
        if level > maxLevel then maxLevel = level end
        totalScore = totalScore + score
        -- Fall back to the highest key if no per-run score was available to pick a best run.
        if dispLevel <= 0 then dispLevel = level end
        perMap[#perMap + 1] = {
            mapID = mapID, name = name, level = level, dispLevel = dispLevel,
            score = score, notDone = notDone, timed = timed,
        }
    end

    -- Opportunity order: not-done first (max gain, from 0), then lowest score first.
    table.sort(perMap, function(a, b)
        if a.notDone ~= b.notDone then return a.notDone end
        if a.score ~= b.score then return a.score < b.score end
        return tostring(a.name) < tostring(b.name)
    end)

    -- Dungeons sitting at your highest key (kept for the party-sync summary + tooltip).
    local mapsAtMax = {}
    if maxLevel > 0 then
        for _, m in ipairs(perMap) do
            if m.level == maxLevel then mapsAtMax[#mapsAtMax + 1] = m.name end
        end
        table.sort(mapsAtMax)
    end

    -- Summary row: overall score, highest key, and how many dungeons are still open.
    local openCount = #perMap - doneCount
    data.MPLUS[#data.MPLUS + 1] = {
        mode = "MPLUS",
        order = 1,
        name = "Overall M+ Score",
        progress = (maxLevel > 0) and ("+" .. tostring(maxLevel)) or "None",
        reset = ("%d score  •  %d/%d dungeons done%s"):format(
            math.floor(totalScore + 0.5), doneCount, #perMap,
            (openCount > 0) and ("  •  " .. openCount .. " to go") or ""),
        difficulty = "Current season",
        isHighest = true,
        mapsAtMax = mapsAtMax,
        mapsAtLevel = mapsAtMax,
    }

    -- One row per dungeon, best (biggest score gain) at the top. The single best opportunity is
    -- flagged in the name so "which key gives the most score" is answered at a glance.
    local flaggedTop = false
    for i, m in ipairs(perMap) do
        local nm = m.name
        if not flaggedTop then
            nm = nm .. "  |cffffd200<< push this|r"
            flaggedTop = true
        end
        data.MPLUS[#data.MPLUS + 1] = {
            mode = "MPLUS",
            order = 10 + i,
            name = nm,
            progress = m.notDone and "—" or ("+" .. tostring(m.dispLevel)),
            reset = m.notDone and "Not done" or (tostring(math.floor(m.score + 0.5)) .. " score"),
            difficulty = "Per dungeon",
            notDone = m.notDone,
            -- Amber-flag the lowest-scored dungeons you HAVE done (still the best places to gain).
            opportunity = (not m.notDone) and (i <= math.max(1, math.floor(#perMap / 3))),
            mapScore = m.score,
            mapLevel = m.dispLevel,
            -- Best key timed (true) or only depleted/over-time (false). Only meaningful when done
            -- (render gates on notDone); m.timed is already false for not-done maps.
            timed = m.timed,
        }
    end
end

------------------------------------------------------------
-- Rendering
------------------------------------------------------------
RenderList = function()
    ClearRows()

    local list = data[activeTab] or {}
    local hideLockedHere = filters[activeTab] and filters[activeTab].hideLocked
    local typeFilterHere = filters[activeTab] and filters[activeTab].typeFilter or "ALL"
    if activeTab == "MPLUS" then
        typeFilterHere = "ALL"
    end
    local count = #list

    -- Hard guarantee: the M+ Stats tab should never render "empty".
    -- Some clients momentarily return no data after zoning/reload; keep the UI stable.
    if activeTab == "MPLUS" and count == 0 then
        data.MPLUS = data.MPLUS or {}
        data.MPLUS[1] = data.MPLUS[1] or {
            mode = "MPLUS",
            order = 1,
            name = "Highest Key",
            progress = "None",
            reset = "No season completions found",
            difficulty = "Current season",
            isHighest = true,
        }
        list = data.MPLUS
        count = #list
    end

    if count == 0 then
        content:SetHeight(40)
        local msg = content.msg or content:CreateFontString(nil, "OVERLAY", "GameFontDisable")
        msg:SetPoint("TOPLEFT", content, "TOPLEFT", 8, -10)
        if activeTab == "MPLUS" then
            msg:SetText("No Mythic+ stats found yet.")
        elseif activeTab == "DUNGEONS" and not whatLockoutSeasonDungeonPoolAvailable then
            msg:SetText("Couldn't detect the current Mythic+ season pool yet. Showing the Challenge Mode dungeon list instead.")
        else
            msg:SetText("No " .. (activeTab == "RAIDS" and "raid" or "Mythic dungeon") .. " lockouts found.")
        end
        content.msg = msg
        msg:Show()
        return
    else
        if content.msg then content.msg:Hide() end
    end

    local mainSort = _G.MythicMate_MainSort
    if activeTab == "MPLUS" then
        -- Keep deterministic order for stats rows (not user-sortable).
        table.sort(list, function(a, b)
            local ao, bo = a.order or 0, b.order or 0
            if ao ~= bo then return ao < bo end
            return (a.name or "") < (b.name or "")
        end)
    elseif mainSort and mainSort.key and type(MythicMate_MainSortValue) == "function" then
        -- User picked a sort column (LFG-style). Tie-break on name for a stable order.
        local key, asc = mainSort.key, mainSort.asc
        table.sort(list, function(a, b)
            local av = MythicMate_MainSortValue(a, key)
            local bv = MythicMate_MainSortValue(b, key)
            if av ~= bv then
                if asc then return av < bv else return av > bv end
            end
            return tostring(a.name or "") < tostring(b.name or "")
        end)
    else
        -- Default: locked entries grouped first, then by name.
        table.sort(list, function(a, b)
            local al, bl = a.locked and 1 or 0, b.locked and 1 or 0
            if al ~= bl then
                return al > bl -- locked first
            end
            return (a.name or "") < (b.name or "")
        end)
    end
    -- Reflect the active sort column with an arrow on its header.
    if _G.MythicMate_Grid and _G.MythicMate_MainHeaders then
        _G.MythicMate_Grid.UpdateSortArrows(_G.MythicMate_MainHeaders,
            (activeTab ~= "MPLUS" and mainSort) and mainSort.key or nil,
            mainSort and mainSort.asc)
    end

    local totalHeight = 0
    local shown = 0
    for _, entry in ipairs(list) do
        local skipByType = typeFilterHere and typeFilterHere ~= "ALL" and entry.difficulty ~= typeFilterHere
        if not ((hideLockedHere and entry.locked) or skipByType) then
            shown = shown + 1

            local row = AcquireRow(shown)
            row:Show()
            row.data = entry
            -- Shared LFG-style row look: stripe + left accent bar + accent hover (re-applied each
            -- render so it tracks the active accent and the alternating stripe by row index).
            if _G.MythicMate_Grid then _G.MythicMate_Grid.SkinRow(row, shown) end

            local displayName = entry.name or "Unknown"
            local raidGuiD, raidGuiT = 0, 0
            local raidGuiInProg, raidGuiCleared = false, false
            local raidGuiRow = (activeTab == "RAIDS") or (activeTab == "PLAYERS" and entry.difficulty == "Raid")
            if raidGuiRow then
                local raidEntry = entry
                if activeTab == "PLAYERS" then
                    raidEntry = {
                        progress = entry.progress,
                        bossDefeatedCount = entry.raidBossDefeatedCount,
                        bossTotalCount = entry.raidBossTotalCount,
                    }
                end
                raidGuiD, raidGuiT = MythicMate_GetRaidBossDisplayCounts(raidEntry)
                raidGuiCleared = entry.locked and raidGuiT > 0 and raidGuiD >= raidGuiT
                raidGuiInProg = entry.locked and raidGuiT > 0 and raidGuiD < raidGuiT
                if entry.locked then
                    if raidGuiInProg then
                        displayName = displayName .. " |cff55ee77[In progress]|r"
                    elseif raidGuiCleared then
                        displayName = displayName .. " |cffc868ff[Cleared]|r"
                    else
                        displayName = displayName .. " |cffc868ff[Locked]|r"
                    end
                end
            elseif entry.locked then
                displayName = displayName .. " |cffc868ff[Locked]|r"
            end
            row.name:SetText(displayName)
            -- Default name color; pooled rows can carry a dimmed color from a prior MPLUS render.
            row.name:SetTextColor(1, 1, 1)

            if activeTab == "SHARED" then
                local lockedCount = entry.lockedCount or 0
                local availCount = entry.availableCount or 0

                row.progress:SetText(tostring(lockedCount) .. " locked")
                if lockedCount > 0 then
                    row.progress:SetTextColor(1, 0.3, 0.3)
                else
                    row.progress:SetTextColor(0.6, 0.6, 0.6)
                end

                row.reset:SetText(tostring(availCount) .. " available")
                if availCount > 0 then
                    row.reset:SetTextColor(0.4, 1, 0.4)
                else
                    row.reset:SetTextColor(0.6, 0.6, 0.6)
                end
            elseif activeTab == "MPLUS" then
                -- Mythic+ stats tab uses string fields (no FormatTime).
                local p = entry.progress or ""
                local r = entry.reset or ""

                -- Per-dungeon done rows show whether the best key was timed. Depleted (over-time)
                -- runs get a grey [Depleted] tag and the whole row is dimmed; timed runs get a
                -- green [Timed] tag and render at full brightness.
                local isDoneDungeon = (not entry.isHighest) and (not entry.notDone)
                local depleted = isDoneDungeon and (entry.timed == false)
                if isDoneDungeon then
                    local tag = entry.timed and " |cff40ff40[Timed]|r" or " |cff888888[Depleted]|r"
                    row.name:SetText(displayName .. tag)
                    row.name:SetTextColor(depleted and 0.55 or 1, depleted and 0.55 or 1, depleted and 0.55 or 1)
                else
                    row.name:SetTextColor(1, 1, 1)
                end

                row.progress:SetText(p)
                if entry.isHighest then
                    row.progress:SetTextColor(0.4, 1, 0.4)
                elseif entry.notDone then
                    row.progress:SetTextColor(1, 0.42, 0.42) -- not done yet: red
                elseif depleted then
                    row.progress:SetTextColor(0.5, 0.5, 0.5) -- depleted: dimmed
                else
                    row.progress:SetTextColor(0.7, 0.75, 1.0)
                end

                row.reset:SetText(r)
                if entry.isHighest then
                    row.reset:SetTextColor(0.7, 0.9, 1.0)
                elseif entry.notDone then
                    row.reset:SetTextColor(1, 0.55, 0.55) -- not done yet: biggest gain
                elseif depleted then
                    row.reset:SetTextColor(0.5, 0.5, 0.5) -- depleted: dimmed
                elseif entry.opportunity then
                    row.reset:SetTextColor(1, 0.85, 0.4) -- low score: good place to push
                else
                    row.reset:SetTextColor(0.6, 0.6, 0.6)
                end
            elseif activeTab == "KEYSTONE" then
                row.progress:SetText(entry.progress or "")
                row.progress:SetTextColor(0.4, 1, 0.4)
                row.reset:SetText(entry.reset or "")
                row.reset:SetTextColor(0.75, 0.85, 1.0)
            elseif raidGuiRow then
                local progress = entry.progress or ""
                local statusCode = MythicMate_GetDungeonStatusCode(entry)
                if raidGuiT > 0 then
                    progress = tostring(raidGuiD) .. "/" .. tostring(raidGuiT)
                end
                row.progress:SetText(progress)
                if not entry.locked or statusCode == "AVAILABLE" then
                    row.progress:SetTextColor(0.4, 1, 0.4)
                elseif raidGuiInProg then
                    row.progress:SetTextColor(0.35, 1, 0.45)
                elseif raidGuiCleared then
                    row.progress:SetTextColor(1, 0.5, 0.35)
                else
                    row.progress:SetTextColor(1, 0.9, 0.4)
                end

                local resetText = FormatTime(entry.reset)
                row.reset:SetText(resetText)
                if (tonumber(entry.reset) or 0) <= 0 then
                    row.reset:SetTextColor(0.4, 1, 0.4)
                else
                    row.reset:SetTextColor(0.8, 0.8, 1.0)
                end
            else
                -- Dungeons: groen = Available, grijs = Expired, geel = anders
                local progress = entry.progress or ""
                local statusCode = MythicMate_GetDungeonStatusCode(entry)
                row.progress:SetText(progress)
                if statusCode == "AVAILABLE" then
                    row.progress:SetTextColor(0.4, 1, 0.4)
                elseif statusCode == "EXPIRED" then
                    row.progress:SetTextColor(0.6, 0.6, 0.6)
                else
                    row.progress:SetTextColor(1, 0.9, 0.4)
                end

                local resetText = FormatTime(entry.reset)
                row.reset:SetText(resetText)
                if (tonumber(entry.reset) or 0) <= 0 then
                    row.reset:SetTextColor(0.4, 1, 0.4)
                else
                    row.reset:SetTextColor(0.8, 0.8, 1.0)
                end
            end

            row.diff:SetText(entry.difficulty or "")

            -- Red warning icon in front of the instance name when locked
            if entry.locked then
                if not row.lockIcon then
                    local icon = row:CreateTexture(nil, "OVERLAY")
                    icon:SetSize(14, 14)
                    -- A bit in from the left edge so it doesn't sit on any border/shadow
                    icon:SetPoint("RIGHT", row.name, "LEFT", -4, 0)
                    icon:SetTexture("Interface\\RaidFrame\\ReadyCheck-NotReady") -- red cross
                    row.lockIcon = icon
                end
                row.lockIcon:Show()
            elseif row.lockIcon then
                row.lockIcon:Hide()
            end

            -- LFG-style per-row color: the striped background comes from the shared grid skin
            -- (Grid.SkinRow above); status is shown on the LEFT accent bar instead of tinting the
            -- whole row dark (green = raid still in progress, red = locked, else the accent color).
            row.bg:SetAlpha(1)
            if row._wlAccentBar then
                if raidGuiRow and raidGuiInProg then
                    row._wlAccentBar:SetColorTexture(0.30, 0.90, 0.55, 0.95)
                elseif entry.locked then
                    row._wlAccentBar:SetColorTexture(0.95, 0.35, 0.30, 0.95)
                elseif (entry.availableCount and entry.availableCount > 0)
                    or (type(entry.progress) == "string" and entry.progress:find("[Aa]vailable")) then
                    row._wlAccentBar:SetColorTexture(0.30, 0.90, 0.45, 0.95)
                end
                -- else: keep the accent color already set by Grid.SkinRow.
            end

            -- Contiguous rows (no gap) so the stripes read as continuous bands, like the LFG grid.
            totalHeight = totalHeight + row:GetHeight()
        end
    end

    if shown == 0 then
        content:SetHeight(40)
        local msg = content.msg or content:CreateFontString(nil, "OVERLAY", "GameFontDisable")
        msg:SetPoint("TOPLEFT", content, "TOPLEFT", 8, -10)
        msg:SetText("All entries are hidden by filters.")
        content.msg = msg
        msg:Show()
    else
        content:SetHeight(math.max(totalHeight + 10, listContainer:GetHeight()))
        if content.msg then content.msg:Hide() end
    end
end

------------------------------------------------------------
-- Tab switching
------------------------------------------------------------
local function MythicMate_GetUnitPlayerKey(unit)
    if not unit or unit == "" then return nil end

    if type(UnitFullName) == "function" then
        local n, r = UnitFullName(unit)
        if n and n ~= "" then
            if r and r ~= "" then return n .. "-" .. r end
            return n
        end
    end

    if type(UnitName) == "function" then
        local name = select(1, UnitName(unit))
        if name and name ~= "" then return name end
    end

    return nil
end

MythicMate_GetCurrentPartyMemberKeys = function()
    local out = {}
    local seen = {}

    local selfKey = MythicMate_GetLocalPlayerFullName and MythicMate_GetLocalPlayerFullName()
    if selfKey then
        seen[selfKey] = true
        out[#out + 1] = selfKey
    end

    local num = type(GetNumGroupMembers) == "function" and GetNumGroupMembers() or 0
    if type(IsInRaid) == "function" and IsInRaid() then
        for i = 1, num do
            local key = MythicMate_GetUnitPlayerKey("raid" .. i)
            if key and not seen[key] then
                seen[key] = true
                out[#out + 1] = key
            end
        end
    elseif type(IsInGroup) == "function" and IsInGroup() then
        for i = 1, num do
            local key = MythicMate_GetUnitPlayerKey("party" .. i)
            if key and not seen[key] then
                seen[key] = true
                out[#out + 1] = key
            end
        end
    end

    table.sort(out)
    return out
end

-- Shared Party merge lives in MythicMate_Util.lua (avoids WoW Lua 60-upvalue limit).
local function MythicMate_PartyMemberBestCacheKey(players, partyKey)
    if Util and Util.PartyMemberBestCacheKey then
        return Util.PartyMemberBestCacheKey(players, partyKey)
    end
    return nil
end

local function MythicMate_BuildSharedPartyData()
    if Util and Util.BuildSharedPartyData then
        Util.BuildSharedPartyData({
            data = data,
            db = MythicMateDB,
            normalizeName = MythicMate_NormalizeName,
            normalizeLoose = MythicMate_NormalizeInstanceKeyLoose,
            dungeonBlacklist = whatLockoutDungeonBlacklistNormSet,
            raidBlacklist = whatLockoutRaidBlacklistNormSet,
            localizeDungeon = MythicMate_LocalizeDungeonNameForMapID,
            localizeRaid = MythicMate_LocalizeRaidNameForKey,
            getPartyKeys = MythicMate_GetCurrentPartyMemberKeys,
            -- Same visibility rules as Raid / Mythic tabs: Shared Party used to seed from all rows, so junk
            -- (e.g. Heroic zone) could appear on Shared while the Raid list hid it via type / hide-locked.
            seedRaidHideLocked = filters.RAIDS.hideLocked,
            seedRaidTypeFilter = filters.RAIDS.typeFilter or "ALL",
            seedDungeonHideLocked = filters.DUNGEONS.hideLocked,
            seedDungeonTypeFilter = filters.DUNGEONS.typeFilter or "ALL",
        })
    end
end

function MythicMate_BuildGroupKeystoneSummaryText()
    local players = MythicMateDB and MythicMateDB.shared and MythicMateDB.shared.players
    local ownText = nil

    local function keystoneFromRow(row)
        local ks = row and row.keystone or nil
        if type(ks) ~= "table" then return nil end
        local lvl = tonumber(ks.level or 0) or 0
        if lvl <= 0 then return nil end
        local mapID = tonumber(ks.mapID or 0) or 0
        local mapName = tostring(ks.mapName or "")
        if mapName == "" and mapID > 0 and C_ChallengeMode and type(C_ChallengeMode.GetMapUIInfo) == "function" then
            local okName, n = pcall(C_ChallengeMode.GetMapUIInfo, mapID)
            if okName and n and n ~= "" then
                mapName = n
            end
        end
        if mapName == "" then mapName = "Unknown" end
        return {
            level = lvl,
            mapID = (mapID > 0) and mapID or nil,
            mapName = mapName,
        }
    end

    local function findOwnSharedKeystone()
        if type(players) ~= "table" then return nil end
        local me = MythicMate_GetLocalPlayerFullName and MythicMate_GetLocalPlayerFullName() or nil
        local meShort = me and tostring(me):match("^([^%-]+)") or nil
        local best = nil

        local function consider(row, updatedAt)
            local cand = keystoneFromRow(row)
            if not cand then return end
            local ts = tonumber(updatedAt or 0) or 0
            if (not best) or ts > (best.updatedAt or 0) then
                cand.updatedAt = ts
                best = cand
            end
        end

        if me and me ~= "" then
            local direct = players[me]
            consider(direct, direct and direct.updatedAtKeystone)
        end
        if meShort and meShort ~= "" then
            local short = players[meShort]
            consider(short, short and short.updatedAtKeystone)
        end

        if not best and meShort and meShort ~= "" then
            for key, row in pairs(players) do
                local keyShort = tostring(key or ""):match("^([^%-]+)")
                if keyShort == meShort then
                    consider(row, row and row.updatedAtKeystone)
                end
            end
        end

        return best
    end

    do
        local own = MythicMate_GetOwnedKeystoneSnapshot and MythicMate_GetOwnedKeystoneSnapshot() or nil
        if not own then
            own = findOwnSharedKeystone()
        end
        if not own and MythicMateDB and MythicMateDB.settings then
            own = keystoneFromRow({ keystone = MythicMateDB.settings.lastOwnedKeystone })
        end

        local ownLevel = own and (tonumber(own.level or 0) or 0) or 0
        local ownMap = own and tostring(own.mapName or "") or ""

        if ownLevel > 0 and MythicMateDB and MythicMateDB.settings then
            local ownMapID = own and tonumber(own.mapID or 0) or 0
            MythicMateDB.settings.lastOwnedKeystone = {
                level = ownLevel,
                mapID = (ownMapID > 0) and ownMapID or nil,
                mapName = ownMap,
            }
        end

        if ownLevel > 0 then
            if ownMap == "" then ownMap = "Unknown" end
            -- Colour the level by keystone rarity (falls back to green), with a gold "Keystone:"
            -- label — matching the coloured keystone shown on the LFG tab.
            local lvlHex = "|cff66ff66"
            if type(C_ChallengeMode) == "table" and type(C_ChallengeMode.GetKeystoneLevelRarityColor) == "function" then
                local ok, col = pcall(C_ChallengeMode.GetKeystoneLevelRarityColor, ownLevel)
                if ok and col and col.GenerateHexColor then lvlHex = "|c" .. col:GenerateHexColor() end
            end
            ownText = "|cffffd200Keystone:|r " .. tostring(ownMap) .. " " .. lvlHex .. "+" .. tostring(ownLevel) .. "|r"
        else
            ownText = "|cff808080Keystone: none|r"
        end
    end

    return ownText
end

function MythicMate_SetPlayersInfoText(extraText)
    if not playersInfo then return end
    local base = MythicMate_BuildGroupKeystoneSummaryText()
    local extra = tostring(extraText or "")
    if extra ~= "" then
        playersInfo:SetText(base .. "  |  " .. extra)
    else
        playersInfo:SetText(base)
    end
end

function MythicMate_BuildGroupKeystoneTabData()
    data.KEYSTONE = {}

    local players = MythicMateDB and MythicMateDB.shared and MythicMateDB.shared.players
    if type(players) ~= "table" then
        data.KEYSTONE[1] = { name = "(no shared data)", progress = "", reset = "", difficulty = "" }
        return
    end

    local partyKeys = MythicMate_GetCurrentPartyMemberKeys()
    if type(partyKeys) ~= "table" or #partyKeys == 0 then
        data.KEYSTONE[1] = { name = "(join a party/raid)", progress = "", reset = "", difficulty = "" }
        return
    end

    local rows, seen = {}, {}
    for _, partyKey in ipairs(partyKeys) do
        local cacheKey = MythicMate_PartyMemberBestCacheKey(players, partyKey)
        local p = cacheKey and players[cacheKey] or nil
        local ks = p and p.keystone or nil
        local lvl = ks and tonumber(ks.level or 0) or 0
        if cacheKey and lvl > 0 and not seen[cacheKey] then
            seen[cacheKey] = true
            local who = tostring(cacheKey):match("^([^%-]+)") or tostring(cacheKey)
            local mapName = tostring((ks and ks.mapName) or "")
            if mapName == "" and ks and ks.mapID and C_ChallengeMode and type(C_ChallengeMode.GetMapUIInfo) == "function" then
                local okName, n = pcall(C_ChallengeMode.GetMapUIInfo, ks.mapID)
                if okName and n and n ~= "" then mapName = n end
            end
            local seenAt = MythicMate_GetPayloadUpdatedAtForSection(p, "KEYSTONE")
            local seenText = "unknown"
            if seenAt and seenAt > 0 and date then
                seenText = date("%H:%M", seenAt)
            end
            rows[#rows + 1] = {
                who = who,
                level = lvl,
                mapName = (mapName ~= "" and mapName) or "Unknown",
                seenText = seenText,
            }
        end
    end

    if #rows == 0 then
        data.KEYSTONE[1] = { name = "(nobody shared a key yet)", progress = "", reset = "", difficulty = "" }
        return
    end

    table.sort(rows, function(a, b)
        if a.level ~= b.level then return a.level > b.level end
        return a.who < b.who
    end)

    for _, r in ipairs(rows) do
        data.KEYSTONE[#data.KEYSTONE + 1] = {
            mode = "KEYSTONE",
            name = r.who,
            progress = "+" .. tostring(r.level),
            reset = r.seenText,
            difficulty = r.mapName,
            locked = false,
        }
    end
end

-- fromUser = true only when called from a genuine hardware event (a tab-button click or
-- slash+Enter). It is threaded to the LFG finder so it may run the hardware-gated
-- C_LFGList.Search; programmatic calls (e.g. the login tab-restore) leave it false so the
-- finder just shows + refreshes instead of triggering a search that WoW would block.
local function SetActiveTab(id, fromUser)
    activeTab = id
    if MythicMateDB and MythicMateDB.settings then
        MythicMateDB.settings.activeTab = id
    end
    for tabId, btn in pairs(tabs) do
        -- Keep hidden tabs visually consistent with the currently active tab.
        SetTabVisual(btn, tabId == id)
    end

    -- Re-apply visibility rules, in case settings changed while switching tabs.
    if MythicMate_UpdateTabsVisibility then
        pcall(MythicMate_UpdateTabsVisibility)
    end

    -- "My Alts" footer button: hide it on the Players tab (the player-select dropdown sits in
    -- the same spot and would overlap); keep it on every other tab, including LFG and Mythic
    -- Dungeons. Handled before the LFG early-return below so it's correct on that tab too.
    if _G.MythicMate_AltsFooterButton then
        if id == "PLAYERS" then
            _G.MythicMate_AltsFooterButton:Hide()
        else
            _G.MythicMate_AltsFooterButton:Show()
        end
    end

    -- LFG tab is drawn by the split-out group finder into its own frame over the list
    -- area; skip the normal lockout-list rendering for it.
    do
        local ns = _G.MythicMate
        if id == "LFG" or id == "QUICKJOIN" then
            if scrollFrame then scrollFrame:Hide() end
            if headerRow and headerRow.Hide then headerRow:Hide() end -- hide the lockout column bar
            -- Lockout-sharing controls are irrelevant on the LFG/Quick Join tabs; hide them.
            for _, c in ipairs({ syncButton, shareButton, shareDropdown, shareLabel, playersDropdown }) do
                if c and c.Hide then c:Hide() end
            end
            -- Both tabs render into the same group-finder panel; LFG = full finder, Quick Join =
            -- Social Queue (friends/guild) only.
            if ns and ns.LFG then
                if id == "QUICKJOIN" and type(ns.LFG.ShowQuickJoin) == "function" then
                    pcall(ns.LFG.ShowQuickJoin)
                elseif type(ns.LFG.Show) == "function" then
                    -- Only search when this tab switch came from a real click (fromUser).
                    pcall(ns.LFG.Show, fromUser)
                end
            end
            return
        else
            if ns and ns.LFG and type(ns.LFG.Hide) == "function" then pcall(ns.LFG.Hide) end
            if scrollFrame then scrollFrame:Show() end
            if headerRow and headerRow.Show then headerRow:Show() end
            if shareButton and shareButton.Show then shareButton:Show() end
        end
    end

    -- Update column headers per tab (Players uses Category instead of Type).
    if nameHeader then
        if id == "KEYSTONE" then
            nameHeader:SetText("Owner")
        else
            nameHeader:SetText("Instance")
        end
    end
    if diffHeader then
        if id == "PLAYERS" then
            diffHeader:SetText("Category")
        elseif id == "MPLUS" then
            diffHeader:SetText("Info")
        elseif id == "KEYSTONE" then
            diffHeader:SetText("Dungeon")
        else
            diffHeader:SetText("Type")
        end
    end
    if bossHeader and resetHeader and id == "SHARED" then
        bossHeader:SetText("Locked by")
        resetHeader:SetText("Available by")
    elseif bossHeader and resetHeader and id == "MPLUS" then
        bossHeader:SetText("Key level")
        resetHeader:SetText("Count")
    elseif bossHeader and resetHeader and id == "KEYSTONE" then
        bossHeader:SetText("Key")
        resetHeader:SetText("Seen")
    end

    if playersInfo then
        playersInfo:Show()
    end

    if playersDropdown and playersLabel then
        if id == "PLAYERS" then
            playersDropdown:Show()
            playersLabel:Show()
        else
            playersDropdown:Hide()
            playersLabel:Hide()
        end
    end

    -- M+ stats is informational; hide sync/share controls to keep the tab clean.
    if syncButton then
        if id == "MPLUS" then syncButton:Hide() else syncButton:Show() end
    end
    if shareButton then
        if id == "MPLUS" or id == "KEYSTONE" then shareButton:Hide() else shareButton:Show() end
    end
    if shareDropdown then
        if id == "MPLUS" or id == "KEYSTONE" then shareDropdown:Hide() else shareDropdown:Show() end
    end
    if shareLabel then
        if id == "MPLUS" or id == "KEYSTONE" then shareLabel:Hide() else shareLabel:Show() end
    end

    if filterCheckbox then
        if id == "RAIDS" or id == "DUNGEONS" or id == "PLAYERS" or id == "SHARED" then
            filterCheckbox:Show()
            if filters[id] then
                filterCheckbox:SetChecked(filters[id].hideLocked)
            end
        else
            filterCheckbox:Hide()
        end
    end

    if MythicMate_UpdateHeaderFilterText then
        MythicMate_UpdateHeaderFilterText()
    end

    if id ~= "PLAYERS" then
        if id == "MPLUS" then
            local ok = pcall(function()
                if type(MythicMate_BuildMythicPlusStats) == "function" then
                    MythicMate_BuildMythicPlusStats()
                end
            end)
            -- Robust fallback: if API/build fails, show a minimal row instead
            -- of an empty MPLUS table (which renders as "no stats found").
            if (not ok) or type(data.MPLUS) ~= "table" or #data.MPLUS == 0 then
                data.MPLUS = data.MPLUS or {}
                data.MPLUS[1] = {
                    mode = "MPLUS",
                    order = 1,
                    name = "Highest Key",
                    progress = "None",
                    reset = "No season completions found",
                    difficulty = "Current season",
                    isHighest = true,
                }
            end
            MythicMate_SetPlayersInfoText()
        elseif id == "KEYSTONE" then
            ScanLockouts()
            MythicMate_BuildGroupKeystoneTabData()
            MythicMate_SetPlayersInfoText()
        else
            ScanLockouts()
            if id == "SHARED" then
                MythicMate_BuildSharedPartyData()
                MythicMate_SetPlayersInfoText()
            else
                MythicMate_SetPlayersInfoText()
            end
        end
    else
        -- Rebuild view from cached shared data
        data.PLAYERS = {}
        local players = MythicMateDB.shared and MythicMateDB.shared.players
        if type(players) == "table" then
            -- Default the PLAYERS tab selection to the logged-in character
            -- when the current selection is not one of your own characters.
            do
                local me = MythicMate_GetLocalPlayerFullName and MythicMate_GetLocalPlayerFullName() or nil
                local meIsOwn = (me and type(MythicMateDB.ownCharacters) == "table" and MythicMateDB.ownCharacters[me]) and true or false
                -- Only auto-pick when there is no selection yet.
                -- This prevents overriding a manual choice of a party member.
                if meIsOwn and players[me] and not selectedRemotePlayer then
                    selectedRemotePlayer = me
                    MythicMateDB.settings = MythicMateDB.settings or {}
                    MythicMateDB.settings.selectedRemotePlayer = me
                end
            end

            local partyKeys = MythicMate_GetCurrentPartyMemberKeys()
            if partyKeys and #partyKeys > 0 then
                -- Choose current selection only if it has cached data.
                local isOwnSelection = false
                if selectedRemotePlayer and type(MythicMateDB.ownCharacters) == "table" then
                    isOwnSelection = MythicMateDB.ownCharacters[selectedRemotePlayer] and true or false
                end
                if not isOwnSelection and selectedRemotePlayer and type(players) == "table" then
                    local me = MythicMate_GetLocalPlayerFullName and MythicMate_GetLocalPlayerFullName() or nil
                    if me and players[me] and players[selectedRemotePlayer] and players[selectedRemotePlayer] == players[me] then
                        isOwnSelection = true
                    end
                end
                local hasSelection = selectedRemotePlayer and (isOwnSelection and players[selectedRemotePlayer] or MythicMate_PartyMemberBestCacheKey(players, selectedRemotePlayer))
                if not hasSelection then
                    selectedRemotePlayer = nil
                    MythicMateDB.settings = MythicMateDB.settings or {}
                    MythicMateDB.settings.selectedRemotePlayer = nil
                    for _, k in ipairs(partyKeys) do
                        local best = MythicMate_PartyMemberBestCacheKey(players, k)
                        if best then
                            -- Keep the roster key as selection; later we resolve it to
                            -- the cached player key when building the view.
                            selectedRemotePlayer = k
                            MythicMateDB.settings.selectedRemotePlayer = k
                            break
                        end
                    end
                end
            end

            -- If no party members are available (or selection was cleared),
            -- fall back to showing one of your own characters (if cached).
            local fallbackCacheKey = nil
            if selectedRemotePlayer then
                fallbackCacheKey = (players[selectedRemotePlayer] and selectedRemotePlayer) or MythicMate_PartyMemberBestCacheKey(players, selectedRemotePlayer)
            end
            if not selectedRemotePlayer or not fallbackCacheKey then
                local own = {}
                if type(MythicMateDB.ownCharacters) == "table" then
                    for k, _ in pairs(MythicMateDB.ownCharacters) do
                        if k and players[k] then
                            own[#own + 1] = k
                        end
                    end
                end
                if #own > 0 then
                    table.sort(own)
                    -- Prefer the currently logged-in character if possible.
                    local me = MythicMate_GetLocalPlayerFullName and MythicMate_GetLocalPlayerFullName() or nil
                    local pick = me and (players[me] and me) or own[1]
                    selectedRemotePlayer = pick
                    MythicMateDB.settings = MythicMateDB.settings or {}
                    MythicMateDB.settings.selectedRemotePlayer = pick
                end
            end

            -- Resolve selection to the cached player key (handles short aliases).
            local resolvedSelKey = selectedRemotePlayer
            if resolvedSelKey and players and not players[resolvedSelKey] then
                resolvedSelKey = MythicMate_PartyMemberBestCacheKey(players, resolvedSelKey)
            end
            local p = resolvedSelKey and players[resolvedSelKey]
            if p then
                local function addSection(sectionName, list)
                    local sectionUpdatedAt = MythicMate_GetPayloadUpdatedAtForSection(p, sectionName)
                    for _, e in ipairs(list or {}) do
                        local instName = e.name or "Unknown"
                        if e.difficulty and e.difficulty ~= "" then
                            instName = instName .. " (" .. e.difficulty .. ")"
                        end
                        local bossDefeatedCount, bossTotalCount = MythicMate_GetRaidBossDisplayCounts(e)
                        local effectiveLocked = MythicMate_IsEffectivelyLockedSharedEntry(e, sectionUpdatedAt)
                        data.PLAYERS[#data.PLAYERS + 1] = {
                            name = instName,
                            progress = e.progress or "",
                            reset = e.reset or 0,
                            difficulty = (sectionName == "RAIDS") and "Raid" or "Dungeon",
                            locked = effectiveLocked and true or false,
                            -- Only meaningful for raid entries; used in the tooltip.
                            raidBossDefeatedCount = (sectionName == "RAIDS") and bossDefeatedCount or nil,
                            raidBossTotalCount = (sectionName == "RAIDS") and bossTotalCount or nil,
                        }
                    end
                end
                addSection("RAIDS", p.raids)
                addSection("DUNGEONS", p.dungeons)
                if #data.PLAYERS == 0 then
                    data.PLAYERS[1] = { name = "(no entries)", progress = "", reset = 0, difficulty = "", locked = false }
                end

                if playersInfo then
                    if p.updatedAt and type(p.updatedAt) == "number" and p.updatedAt > 0 and date then
                        MythicMate_SetPlayersInfoText("Last sync: " .. date("%H:%M", p.updatedAt))
                    else
                        MythicMate_SetPlayersInfoText("Last sync: unknown")
                    end
                end
            else
                if playersInfo then
                    MythicMate_SetPlayersInfoText("This person doesn't have MythicMate (no synced data).")
                end
                data.PLAYERS[1] = { name = "(not synced)", progress = "", reset = 0, difficulty = "", locked = false }
            end
        else
            MythicMate_SetPlayersInfoText("No shared data yet")
        end
    end

    if playersDropdown then
        if selectedRemotePlayer and activeTab == "PLAYERS" then
            UIDropDownMenu_SetText(playersDropdown, selectedRemotePlayer)
        elseif activeTab == "PLAYERS" then
            UIDropDownMenu_SetText(playersDropdown, "No players")
        end
    end

    local okRender, renderErr = pcall(RenderList)
    if not okRender then
        -- If rendering fails, the UI can look "empty" even though data exists.
        -- Print the error once to help debugging without spamming chat.
        MythicMateDB = MythicMateDB or {}
        if not MythicMateDB._renderListErrOnce then
            MythicMateDB._renderListErrOnce = true
            if print then
                print("|cffc868ffMythicMate:|r RenderList error: " .. tostring(renderErr))
            end
        end
    end
end

if shareDropdown then
    UIDropDownMenu_SetWidth(shareDropdown, 90)
    local shownShareText = "Party"
    for _, ch in ipairs(shareChannels) do
        if ch.key == MythicMate.State.selectedShareChannel then
            shownShareText = ch.text
            break
        end
    end
    UIDropDownMenu_SetText(shareDropdown, shownShareText)

    UIDropDownMenu_Initialize(shareDropdown, function(self, level)
        level = level or 1
        local info

        for _, ch in ipairs(shareChannels) do
            info = UIDropDownMenu_CreateInfo()
            info.text = ch.text
            info.func = function()
                MythicMate.State.selectedShareChannel = ch.key
                MythicMateDB.settings = MythicMateDB.settings or {}
                MythicMateDB.settings.shareChannel = ch.key
                UIDropDownMenu_SetText(shareDropdown, ch.text)
                if ch.key ~= "WHISPER" then
                    MythicMate.State.selectedWhisperTarget = nil
                    MythicMateDB.settings.whisperTarget = nil
                end
            end
            info.checked = (MythicMate.State.selectedShareChannel == ch.key)
            UIDropDownMenu_AddButton(info, level)
        end
    end)
end

if syncButton then
    syncButton:SetScript("OnClick", function()
        -- Always refresh our own data first so Players tab + tooltip have
        -- up-to-date information for this character.
        if RequestRaidInfo then RequestRaidInfo() end
        ScanLockouts()

        MythicMate_RequestSync()
        local channel = MythicMate_GetBestCommChannel()
        if channel then
            print("|cffc868ffMythicMate:|r Sync request sent to " .. channel .. ".")
        else
            print("|cffc868ffMythicMate:|r You're not in a party/raid/guild to sync with.")
        end
    end)
end

if playersDropdown then
    UIDropDownMenu_SetWidth(playersDropdown, 140)
    UIDropDownMenu_SetText(playersDropdown, "No players")

    UIDropDownMenu_Initialize(playersDropdown, function(self, level)
        level = level or 1
        local info

        -- Show:
        -- 1) Always: your own characters (cached locally).
        -- 2) When in party/raid: current party members who synced data in cache.
        local players = MythicMateDB.shared and MythicMateDB.shared.players
        if type(players) ~= "table" then players = nil end

        local ownNames, partyNames = {}, {}

        -- Default dropdown selection to logged-in character when there is
        -- no current selection yet. This avoids overriding a manual choice.
        do
            local me = MythicMate_GetLocalPlayerFullName and MythicMate_GetLocalPlayerFullName() or nil
            local meIsOwn = (me and type(MythicMateDB.ownCharacters) == "table" and MythicMateDB.ownCharacters[me]) and true or false

            if meIsOwn and players and players[me] and not selectedRemotePlayer then
                selectedRemotePlayer = me
                MythicMateDB.settings = MythicMateDB.settings or {}
                MythicMateDB.settings.selectedRemotePlayer = me
                if activeTab == "PLAYERS" then
                    UIDropDownMenu_SetText(playersDropdown, me)
                end
            end
        end

        if type(players) == "table" and type(MythicMateDB.ownCharacters) == "table" then
            for k, _ in pairs(MythicMateDB.ownCharacters) do
                if k and players[k] then
                    ownNames[#ownNames + 1] = k
                end
            end
        end

        if type(players) == "table" then
            local partyKeys = MythicMate_GetCurrentPartyMemberKeys and MythicMate_GetCurrentPartyMemberKeys() or {}
            local seen = {}
            for _, k in ipairs(partyKeys) do
                local best = MythicMate_PartyMemberBestCacheKey(players, k)
                if best and not seen[k] then
                    seen[k] = true
                    -- Keep the roster key as the selectable id.
                    partyNames[#partyNames + 1] = k
                end
            end
        end

        table.sort(ownNames)
        table.sort(partyNames)

        if #ownNames == 0 and #partyNames == 0 then
            info = UIDropDownMenu_CreateInfo()
            info.text = "No synced players"
            info.notCheckable = true
            UIDropDownMenu_AddButton(info, level)
            return
        end

        if #ownNames > 0 then
            info = UIDropDownMenu_CreateInfo()
            info.text = "Your characters"
            info.notCheckable = true
            UIDropDownMenu_AddButton(info, level)

            for _, name in ipairs(ownNames) do
                info = UIDropDownMenu_CreateInfo()
                info.text = name
                info.func = function()
                    selectedRemotePlayer = name
                    MythicMateDB.settings = MythicMateDB.settings or {}
                    MythicMateDB.settings.selectedRemotePlayer = name
                    UIDropDownMenu_SetText(playersDropdown, name)
                    if activeTab == "PLAYERS" then
                        SetActiveTab("PLAYERS")
                    end
                end
                info.checked = (selectedRemotePlayer == name)
                UIDropDownMenu_AddButton(info, level)
            end
        end

        if #partyNames > 0 then
            info = UIDropDownMenu_CreateInfo()
            info.text = "Party members"
            info.notCheckable = true
            UIDropDownMenu_AddButton(info, level)

            for _, name in ipairs(partyNames) do
                info = UIDropDownMenu_CreateInfo()
                info.text = name
                info.func = function()
                    selectedRemotePlayer = name
                    MythicMateDB.settings = MythicMateDB.settings or {}
                    MythicMateDB.settings.selectedRemotePlayer = name
                    UIDropDownMenu_SetText(playersDropdown, name)
                    if activeTab == "PLAYERS" then
                        SetActiveTab("PLAYERS")
                    end
                end
                info.checked = (selectedRemotePlayer == name)
                UIDropDownMenu_AddButton(info, level)
            end
        end
    end)
end

if shareButton then
    shareButton:SetScript("OnClick", function()
        local target = MythicMate_NormalizeWhisperTarget(MythicMate.State.selectedWhisperTarget)
        if MythicMate.State.selectedShareChannel == "WHISPER" and not target then
            StaticPopupDialogs["MYTHICMATE_WHISPER_TARGET"] = StaticPopupDialogs["MYTHICMATE_WHISPER_TARGET"] or {
                text = "Whisper lockouts to:",
                button1 = OKAY,
                button2 = CANCEL,
                hasEditBox = true,
                maxLetters = 50,
                timeout = 0,
                whileDead = 1,
                hideOnEscape = 1,
                preferredIndex = 3,
                OnAccept = function(self)
                    local eb = self and (self.EditBox or self.editBox)
                    local text = eb and eb.GetText and eb:GetText() or nil
                    local norm = MythicMate_NormalizeWhisperTarget(text)
                    if norm then
                        MythicMate.State.selectedWhisperTarget = norm
                        MythicMateDB.settings = MythicMateDB.settings or {}
                        MythicMateDB.settings.whisperTarget = norm
                        MythicMate_PostSummary()
                    end
                end,
            }
            StaticPopup_Show("MYTHICMATE_WHISPER_TARGET")
        else
            MythicMate_PostSummary()
        end
    end)
end

------------------------------------------------------------
-- (Removed) Dungeon Finder / Premade highlighting
-- We only track true Mythic lockouts, not Mythic+ keystones.
------------------------------------------------------------

if filterCheckbox then
    filterCheckbox:SetScript("OnClick", function(self)
        local checked = self:GetChecked() and true or false
        if filters[activeTab] then
            filters[activeTab].hideLocked = checked
            MythicMateDB.settings = MythicMateDB.settings or {}
            MythicMateDB.settings.hideLockedByTab = MythicMateDB.settings.hideLockedByTab or {}
            MythicMateDB.settings.hideLockedByTab[activeTab] = checked
        end
        if MythicMate_UpdateHeaderFilterText then
            MythicMate_UpdateHeaderFilterText()
        end
        RenderList()
    end)
end

raidTab:SetScript("OnClick", function()
    SetActiveTab("RAIDS")
end)

dungeonTab:SetScript("OnClick", function()
    SetActiveTab("DUNGEONS")
end)

mplusTab:SetScript("OnClick", function()
    SetActiveTab("MPLUS")
end)

playersTab:SetScript("OnClick", function()
    SetActiveTab("PLAYERS")
end)

sharedTab:SetScript("OnClick", function()
    SetActiveTab("SHARED")
end)

keystoneTab:SetScript("OnClick", function()
    SetActiveTab("KEYSTONE")
end)

lfgTab:SetScript("OnClick", function()
    SetActiveTab("LFG", true) -- real click = hardware event → LFG finder may search
end)

quickJoinTab:SetScript("OnClick", function()
    SetActiveTab("QUICKJOIN")
end)

function MythicMate_RefreshOwnedKeystoneOnOpen()
    local function refreshOnce()
        local ks = MythicMate_GetOwnedKeystoneSnapshot and MythicMate_GetOwnedKeystoneSnapshot() or nil
        if ks and MythicMateDB then
            MythicMateDB.settings = MythicMateDB.settings or {}
            MythicMateDB.settings.lastOwnedKeystone = {
                level = tonumber(ks.level or 0) or 0,
                mapID = tonumber(ks.mapID or 0) or nil,
                mapName = tostring(ks.mapName or ""),
            }
        end

        -- Keep local shared cache in sync so tabs/tooltips see the fresh key immediately.
        if type(MythicMate_SaveSelfSnapshot) == "function" then
            pcall(MythicMate_SaveSelfSnapshot)
        end

        if activeTab == "KEYSTONE" then
            MythicMate_BuildGroupKeystoneTabData()
        end
        MythicMate_SetPlayersInfoText()
        RenderList()

        return ks ~= nil
    end

    if refreshOnce() then return end

    -- On some clients the keystone API updates a moment after opening UI.
    if C_Timer and type(C_Timer.After) == "function" then
        C_Timer.After(1.0, function()
            if frame and frame:IsShown() then
                refreshOnce()
            end
        end)
    end
end

------------------------------------------------------------
-- Events & slash command
------------------------------------------------------------
frame:HookScript("OnShow", function()
    MythicMate_RefreshOwnedKeystoneOnOpen()
    -- Re-tint the title bar in case the accent (esp. CLASS color) resolved after this frame was built.
    if type(MythicMate_ApplyMainHeaderAccent) == "function" then
        pcall(MythicMate_ApplyMainHeaderAccent)
    end
end)

frame:RegisterEvent("PLAYER_ENTERING_WORLD")
frame:RegisterEvent("PLAYER_LOGIN")
frame:RegisterEvent("UPDATE_INSTANCE_INFO")
frame:RegisterEvent("CHALLENGE_MODE_MAPS_UPDATE")
frame:RegisterEvent("MYTHIC_PLUS_CURRENT_AFFIX_UPDATE")
frame:RegisterEvent("CHALLENGE_MODE_COMPLETED")
frame:RegisterEvent("ADDON_LOADED")
frame:RegisterEvent("CHAT_MSG_ADDON")
frame:RegisterEvent("GROUP_ROSTER_UPDATE")
frame:RegisterEvent("PLAYER_GUILD_UPDATE")
frame:RegisterEvent("BOSS_KILL")
frame:RegisterEvent("ENCOUNTER_END")
frame:RegisterEvent("ZONE_CHANGED_NEW_AREA")
frame:RegisterEvent("PLAYER_DIFFICULTY_CHANGED")
frame:RegisterEvent("CHAT_MSG_WHISPER")

-- Forward decl: used in event handler before definition.
local MythicMate_RegisterSlashCommands

-- Season pool data can arrive a moment after login; retry a few times.
local whatLockoutSeasonRetryAttempts = 0
local whatLockoutSeasonRetryScheduled = false
local function MythicMate_ScheduleSeasonPoolRetry()
    if not whatLockoutNeedsSeasonPoolRetry then return end
    if whatLockoutSeasonRetryScheduled then return end
    if not C_Timer or type(C_Timer.After) ~= "function" then return end
    if whatLockoutSeasonRetryAttempts >= 6 then
        whatLockoutNeedsSeasonPoolRetry = false
        return
    end

    whatLockoutSeasonRetryScheduled = true
    whatLockoutSeasonRetryAttempts = whatLockoutSeasonRetryAttempts + 1
    local delay = math.min(1 * (2 ^ (whatLockoutSeasonRetryAttempts - 1)), 10)
    C_Timer.After(delay, function()
        whatLockoutSeasonRetryScheduled = false
        if not whatLockoutNeedsSeasonPoolRetry then return end
        ScanLockouts()
        if frame and frame:IsShown() then
            RenderList()
        end
        if not whatLockoutSeasonDungeonPoolAvailable then
            MythicMate_ScheduleSeasonPoolRetry()
        else
            whatLockoutNeedsSeasonPoolRetry = false
        end
    end)
end

-- Fast keystone refresh path after dungeon/key completion.
function MythicMate_QuickRefreshKeystoneData(delaySeconds)
    local function run()
        if RequestRaidInfo then pcall(RequestRaidInfo) end
        pcall(ScanLockouts)
        pcall(MythicMate_BuildGroupKeystoneTabData)
        pcall(MythicMate_SetPlayersInfoText)
        if frame and frame:IsShown() and activeTab == "KEYSTONE" then
            pcall(RenderList)
        end
        pcall(MythicMate_RefreshTooltipIfShown)
    end

    local d = tonumber(delaySeconds or 0) or 0
    if d > 0 and C_Timer and type(C_Timer.After) == "function" then
        C_Timer.After(d, run)
    else
        run()
    end
end

frame:SetScript("OnEvent", function(self, event, ...)
    if event == "PLAYER_ENTERING_WORLD" then
        -- Ensure persistence backend (AceDB) is initialized before we
        -- read settings for skinning/UI/options checkboxes.
        pcall(MythicMate_TryInitAceDB)
        MythicMate_TryApplyElvUISkin()
        MythicMate_RegisterSlashCommands()
        if C_ChatInfo and type(C_ChatInfo.RegisterAddonMessagePrefix) == "function" then
            pcall(C_ChatInfo.RegisterAddonMessagePrefix, COMM_PREFIX)
        end
        MythicMate_HookPremadeResultFilter()
        MythicMate_HookPremadeSignupWarning()
        MythicMate_HookPremadeCreateWarning()
        MythicMate_HookPremadeDungeonFilterDropdown()
        MythicMate_HookUnitTooltip()
        RequestRaidInfo()
        if type(IsInGroup) == "function" and IsInGroup() and type(GetNumGroupMembers) == "function" then
            whatLockoutLastGroupCount = tonumber(GetNumGroupMembers() or 0) or 0
        else
            whatLockoutLastGroupCount = 0
        end
        -- Initialize with the user's last active tab (keeps header filter text/state visible).
        local initialTab = (MythicMateDB.settings and MythicMateDB.settings.activeTab) or "DUNGEONS"
        SetActiveTab(initialTab)
        MythicMate_ScheduleSeasonPoolRetry()

        -- Register Options panel in Interface -> Addons.
        -- We retry a few times because some builds need the Options UI
        -- frames to be fully constructed after login.
        pcall(MythicMate_TryCreateOptionsPanel)
        if whatLockoutOptionsCreateTries < 6 and C_Timer and type(C_Timer.After) == "function" then
            C_Timer.After(2, function()
                pcall(MythicMate_TryCreateOptionsPanel)
            end)
        end

        -- Start background auto-sync ticker (throttled inside TryAutoSync).
        if C_Timer and type(C_Timer.NewTicker) == "function" then
            if not MythicMateDB._autoSyncTickerStarted then
                MythicMateDB._autoSyncTickerStarted = true
                C_Timer.NewTicker(AUTO_SYNC_TICK_INTERVAL, function()
                    MythicMate_TryAutoSync()
                end)
            end
        end

        -- Initial auto-sync shortly after login.
        MythicMate_QueueAutoSync(4)

        -- "Always keep in sync": whisper REQ to each current party/raid member.
        -- This makes their addon export their latest saved-instance data to you.
        if C_Timer and type(C_Timer.NewTicker) == "function" then
            if not MythicMateDB._keepSyncTickerStarted then
                MythicMateDB._keepSyncTickerStarted = true
                C_Timer.NewTicker(KEEP_SYNC_INTERVAL, function()
                    MythicMate_RequestSyncToAllPartyMembers()
                end)
            end
        end

        -- After zoning in, check for locked base-Mythic dungeon entry.
        if C_Timer and type(C_Timer.After) == "function" then
            C_Timer.After(3, function()
                MythicMate_CheckLockedMythicDungeonEntry()
            end)
        else
            MythicMate_CheckLockedMythicDungeonEntry()
        end

        -- Clear any sticky tooltip state; /reload also clears these, and we want
        -- the same clean behavior after loading into the world.
        if GameTooltip then
            GameTooltip.MythicMateAppendedFor = nil
            GameTooltip.MythicMateHeaderAdded = nil
            GameTooltip.MythicMateLastPlayerKey = nil
            GameTooltip.MythicMateToken = nil
            GameTooltip.MythicMatePostCallSeenAt = nil
            GameTooltip.MythicMateTooltipLayoutKind = nil
            if type(GameTooltip.SetClampedToScreen) == "function" then
                pcall(GameTooltip.SetClampedToScreen, GameTooltip, true)
            end

            -- Rebuild right away (matches the "reload fixes it" behavior).
            MythicMate_LastTooltipRefreshAt = nil
            pcall(MythicMate_RefreshTooltipIfShown)
            pcall(MythicMate_RefreshPlayerHoverPanelNow)
        end

    elseif event == "PLAYER_LOGIN" then
        -- Final retry after login; by now OptionsUI is usually ready.
        pcall(MythicMate_TryInitAceDB)
        pcall(MythicMate_TryCreateOptionsPanel)
        if C_Timer and type(C_Timer.After) == "function" then
            C_Timer.After(1, function() pcall(MythicMate_TryCreateOptionsPanel) end)
            C_Timer.After(3, function() pcall(MythicMate_TryCreateOptionsPanel) end)
        end
    elseif event == "UPDATE_INSTANCE_INFO" then
        ScanLockouts()
        if activeTab == "KEYSTONE" then
            MythicMate_BuildGroupKeystoneTabData()
            MythicMate_SetPlayersInfoText()
        end
        RenderList()
        MythicMate_ScheduleSeasonPoolRetry()
        MythicMate_CheckLockedMythicDungeonEntry()
    elseif event == "CHALLENGE_MODE_MAPS_UPDATE" or event == "MYTHIC_PLUS_CURRENT_AFFIX_UPDATE" then
        -- Refresh when Mythic+ / challenge mode data updates
        if activeTab == "MPLUS" then
            local ok = pcall(function()
                if type(MythicMate_BuildMythicPlusStats) == "function" then
                    MythicMate_BuildMythicPlusStats()
                end
            end)
            if (not ok) or type(data.MPLUS) ~= "table" or #data.MPLUS == 0 then
                data.MPLUS = data.MPLUS or {}
                data.MPLUS[1] = {
                    mode = "MPLUS",
                    order = 1,
                    name = "Highest Key",
                    progress = "None",
                    reset = "No season completions found",
                    difficulty = "Current season",
                    isHighest = true,
                }
            end
            if frame and frame:IsShown() then
                RenderList()
            end
        else
            ScanLockouts()
            if frame and frame:IsShown() then
                RenderList()
            end
            MythicMate_ScheduleSeasonPoolRetry()
            MythicMate_CheckLockedMythicDungeonEntry()
        end
    elseif event == "CHALLENGE_MODE_COMPLETED" then
        -- After a Mythic+ completion, refresh season-best stats.
        -- Also refresh own/group keystone data quickly.
        if GameTooltip then
            GameTooltip.MythicMateAppendedFor = nil
            GameTooltip.MythicMateHeaderAdded = nil
            GameTooltip.MythicMateLastPlayerKey = nil
            GameTooltip.MythicMateToken = nil
            GameTooltip.MythicMatePostCallSeenAt = nil
            GameTooltip.MythicMateTooltipLayoutKind = nil
            if type(GameTooltip.SetClampedToScreen) == "function" then
                pcall(GameTooltip.SetClampedToScreen, GameTooltip, true)
            end
        end
        MythicMate_LastTooltipRefreshAt = nil
        -- Force the client to pull fresh season-best data; this fires CHALLENGE_MODE_MAPS_UPDATE,
        -- which rebuilds the M+ stats tab. The season-best APIs lag the completion event, so also
        -- re-request on a short delay to reliably catch the updated score/timed state.
        if C_MythicPlus and type(C_MythicPlus.RequestMapInfo) == "function" then
            pcall(C_MythicPlus.RequestMapInfo)
            if C_Timer and type(C_Timer.After) == "function" then
                C_Timer.After(2.0, function() pcall(C_MythicPlus.RequestMapInfo) end)
            end
        end
        if C_Timer and type(C_Timer.After) == "function" then
            C_Timer.After(0.35, function() pcall(MythicMate_RefreshPlayerHoverPanelNow) end)
            C_Timer.After(1.2, function() pcall(MythicMate_RefreshPlayerHoverPanelNow) end)
            C_Timer.After(3.0, function() pcall(MythicMate_RefreshPlayerHoverPanelNow) end)
        end
        MythicMate_QuickRefreshKeystoneData(0.2)
        MythicMate_QuickRefreshKeystoneData(1.6)
        if IsInGroup and IsInGroup() then
            keepSyncLastAt = {}
            MythicMate_RequestSyncToAllPartyMembers()
            MythicMate_QueueAutoSync(1)
        end
        if activeTab == "MPLUS" then
            local ok = pcall(function()
                if type(MythicMate_BuildMythicPlusStats) == "function" then
                    MythicMate_BuildMythicPlusStats()
                end
            end)
            if (not ok) or type(data.MPLUS) ~= "table" or #data.MPLUS == 0 then
                data.MPLUS = data.MPLUS or {}
                data.MPLUS[1] = {
                    mode = "MPLUS",
                    order = 1,
                    name = "Highest Key",
                    progress = "None",
                    reset = "No season completions found",
                    difficulty = "Current season",
                    isHighest = true,
                }
            end
            if frame and frame:IsShown() then
                RenderList()
            end

            -- The underlying Blizzard APIs can lag slightly behind the completion event.
            -- A short retry makes the M+ stats update reliably after multiple keys.
            if C_Timer and type(C_Timer.After) == "function" and MythicMateDB then
                MythicMateDB._mplusRefreshSeq = (MythicMateDB._mplusRefreshSeq or 0) + 1
                local seq = MythicMateDB._mplusRefreshSeq
                C_Timer.After(1.0, function()
                    if MythicMateDB and MythicMateDB._mplusRefreshSeq == seq and activeTab == "MPLUS" then
                        local ok2 = pcall(function()
                            if type(MythicMate_BuildMythicPlusStats) == "function" then
                                MythicMate_BuildMythicPlusStats()
                            end
                        end)
                        if (not ok2) or type(data.MPLUS) ~= "table" or #data.MPLUS == 0 then
                            data.MPLUS = data.MPLUS or {}
                            data.MPLUS[1] = {
                                mode = "MPLUS",
                                order = 1,
                                name = "Highest Key",
                                progress = "None",
                                reset = "No season completions found",
                                difficulty = "Current season",
                                isHighest = true,
                            }
                        end
                        if frame and frame:IsShown() then
                            RenderList()
                        end
                    end
                end)
            end
        end
    elseif event == "ADDON_LOADED" then
        local addonName = ...
        if addonName == "ElvUI" then
            MythicMate_TryApplyElvUISkin()
        end

        -- Ensure Options UI is available before adding our category.
        if addonName == "Blizzard_InterfaceOptions" or addonName == "Blizzard_OptionsUI" then
            pcall(MythicMate_TryCreateOptionsPanel)
            if C_Timer and type(C_Timer.After) == "function" then
                C_Timer.After(1, function()
                    pcall(MythicMate_TryCreateOptionsPanel)
                end)
            end
        elseif addonName == ADDON_NAME then
            -- Ensure our own addon load also triggers options panel creation.
            pcall(MythicMate_TryCreateOptionsPanel)
        end
    elseif event == "CHAT_MSG_WHISPER" then
        local text, sender = ...
        MythicMate_TryWhisperAutoReply(text, sender)
    elseif event == "CHAT_MSG_ADDON" then
        local prefix, msg, channel, sender = ...
        local ok, err = pcall(MythicMate_OnCommMessage, prefix, msg, channel, sender)
        if not ok then
            local extra = ""
            if type(debug) == "table" and type(debug.traceback) == "function" then
                local okTb, tb = pcall(function()
                    return debug.traceback(tostring(err), 2)
                end)
                if okTb and type(tb) == "string" and tb ~= "" then
                    extra = " | " .. tb:gsub("[\r\n]+", " ")
                end
            end
            print("|cffc868ffMythicMate:|r comm receive error from=" .. tostring(sender) .. " err=" .. tostring(err) .. extra)
        end
        -- If the tooltip is currently open, refresh it so new shared data
        -- (from synced party members) shows immediately.
        MythicMate_RefreshTooltipIfShown()
        if frame and frame:IsShown() then
            if activeTab == "PLAYERS" then
                SetActiveTab("PLAYERS")
            elseif activeTab == "SHARED" then
                MythicMate_BuildSharedPartyData()
                MythicMate_SetPlayersInfoText()
                RenderList()
            elseif activeTab == "KEYSTONE" then
                MythicMate_BuildGroupKeystoneTabData()
                MythicMate_SetPlayersInfoText()
                RenderList()
            end
        end
    elseif event == "GROUP_ROSTER_UPDATE" or event == "PLAYER_GUILD_UPDATE" then
        -- Joining/leaving groups/guild: auto-sync after a short delay.
        -- Don't keep dropdown selection when party/raid changes,
        -- unless the selection is one of your own cached characters.
        local isOwnSelection = false
        if selectedRemotePlayer and type(MythicMateDB.ownCharacters) == "table" then
            isOwnSelection = MythicMateDB.ownCharacters[selectedRemotePlayer] and true or false
        end
        if not isOwnSelection and selectedRemotePlayer and MythicMateDB.shared and MythicMateDB.shared.players then
            local me = MythicMate_GetLocalPlayerFullName and MythicMate_GetLocalPlayerFullName() or nil
            local players = MythicMateDB.shared.players
            if me and players[me] and players[selectedRemotePlayer] and players[selectedRemotePlayer] == players[me] then
                isOwnSelection = true
            end
        end

        if not isOwnSelection then
            -- Keep party selection if the chosen member is still in group.
            -- Otherwise clear it when leaving the party/raid.
            local stillInParty = false
            if selectedRemotePlayer and MythicMateDB.shared and MythicMateDB.shared.players then
                local players = MythicMateDB.shared.players
                local selCacheKey = MythicMate_PartyMemberBestCacheKey(players, selectedRemotePlayer) or selectedRemotePlayer
                local partyKeys = MythicMate_GetCurrentPartyMemberKeys and MythicMate_GetCurrentPartyMemberKeys() or {}
                if type(partyKeys) == "table" then
                    for _, k in ipairs(partyKeys) do
                        if k == selectedRemotePlayer then
                            stillInParty = true
                            break
                        end
                        local kCacheKey = MythicMate_PartyMemberBestCacheKey(players, k) or k
                        if kCacheKey == selCacheKey then
                            stillInParty = true
                            break
                        end
                    end
                end
            end

            if not stillInParty then
                selectedRemotePlayer = nil
                MythicMateDB.settings = MythicMateDB.settings or {}
                MythicMateDB.settings.selectedRemotePlayer = nil
            end
        end
        MythicMate_QueueAutoSync(3)
        -- Clear per-member throttle so new party members get requested promptly.
        keepSyncLastAt = {}
        if event == "GROUP_ROSTER_UPDATE" then
            local currentCount = 0
            if type(IsInGroup) == "function" and IsInGroup() and type(GetNumGroupMembers) == "function" then
                currentCount = tonumber(GetNumGroupMembers() or 0) or 0
            end
            local someoneLeft = currentCount < (whatLockoutLastGroupCount or 0)
            whatLockoutLastGroupCount = currentCount
            if someoneLeft then
                -- Force immediate resync when a member leaves, then a short follow-up.
                lastAutoSyncAt = 0
                MythicMate_RequestSyncToAllPartyMembers()
                MythicMate_QueueAutoSync(2)
            end
        end
        if activeTab == "PLAYERS" and frame and frame:IsShown() then
            SetActiveTab("PLAYERS")
        end
    elseif event == "ZONE_CHANGED_NEW_AREA" or event == "PLAYER_DIFFICULTY_CHANGED" then
        -- Zoning / difficulty changes: re-check shortly after the client updates instance info.
        -- Also clear tooltip markers; zoning is a common time for tooltip rebuild behavior.
        if GameTooltip then
            GameTooltip.MythicMateAppendedFor = nil
            GameTooltip.MythicMateHeaderAdded = nil
            GameTooltip.MythicMateLastPlayerKey = nil
            GameTooltip.MythicMateToken = nil
            GameTooltip.MythicMatePostCallSeenAt = nil
            GameTooltip.MythicMateTooltipLayoutKind = nil
            if type(GameTooltip.SetClampedToScreen) == "function" then
                pcall(GameTooltip.SetClampedToScreen, GameTooltip, true)
            end

            -- Allow an immediate MythicMate_RefreshTooltipIfShown pass after zoning.
            MythicMate_LastTooltipRefreshAt = nil
        end
        if C_Timer and type(C_Timer.After) == "function" then
            C_Timer.After(0.45, function() pcall(MythicMate_RefreshPlayerHoverPanelNow) end)
            C_Timer.After(2, function()
                MythicMate_CheckLockedMythicDungeonEntry()
                MythicMate_LastTooltipRefreshAt = nil
                pcall(MythicMate_RefreshTooltipIfShown)
                pcall(MythicMate_RefreshPlayerHoverPanelNow)
            end)
        else
            MythicMate_CheckLockedMythicDungeonEntry()
            MythicMate_LastTooltipRefreshAt = nil
            pcall(MythicMate_RefreshTooltipIfShown)
            pcall(MythicMate_RefreshPlayerHoverPanelNow)
        end
    elseif event == "BOSS_KILL" or event == "ENCOUNTER_END" then
        -- After boss/encounter events, the saved-instance data often updates a moment later.
        -- Queue a resync so party members refresh automatically after a run progresses/completes.
        MythicMate_QuickRefreshKeystoneData(0.3)
        if IsInGroup and IsInGroup() then
            keepSyncLastAt = {}
            MythicMate_RequestSyncToAllPartyMembers()
            if C_Timer and type(C_Timer.After) == "function" then
                C_Timer.After(1.5, function()
                    if RequestRaidInfo then RequestRaidInfo() end
                    MythicMate_QueueAutoSync(1)
                    -- If the tooltip is currently open, refresh it once after data updates.
                    pcall(MythicMate_RefreshTooltipIfShown)
                end)
            else
                if RequestRaidInfo then RequestRaidInfo() end
                MythicMate_QueueAutoSync(1)
                pcall(MythicMate_RefreshTooltipIfShown)
            end
        end
    end
end)

-- Slash commands (register after UI is ready)
local slashRegistered = false
function MythicMate_PrintVersionInfo()
    local function getMeta(field)
        if C_AddOns and type(C_AddOns.GetAddOnMetadata) == "function" then
            return C_AddOns.GetAddOnMetadata(ADDON_NAME, field)
        end
        if type(GetAddOnMetadata) == "function" then
            return GetAddOnMetadata(ADDON_NAME, field)
        end
        return nil
    end

    local version = getMeta("Version")
    local commit = getMeta("X-Commit")
    local build = getMeta("X-Build")
    local revision = getMeta("X-Revision")

    version = (version and version ~= "" and version) or "unknown"
    local parts = { "version=" .. tostring(version) }
    if commit and commit ~= "" then parts[#parts + 1] = "commit=" .. tostring(commit) end
    if build and build ~= "" then parts[#parts + 1] = "build=" .. tostring(build) end
    if revision and revision ~= "" then parts[#parts + 1] = "rev=" .. tostring(revision) end
    parts[#parts + 1] = "comm=" .. tostring(COMM_VERSION or "?")
    print("|cffc868ffMythicMate:|r " .. table.concat(parts, " | "))
end

-- Faded on-screen warning shown while Lua script errors are enabled (/err 1), so it is
-- obvious the setting is on. Uses a lazily-created global frame (no chunk-local cost).
function MythicMate_UpdateScriptErrorsIndicator()
    local on = (type(GetCVar) == "function") and (GetCVar("scriptErrors") == "1") or false
    local f = _G.MythicMate_ScriptErrorsIndicatorFrame
    if not f then
        if not on then return end
        f = CreateFrame("Frame", "MythicMate_ScriptErrorsIndicatorFrame", UIParent)
        f:SetFrameStrata("HIGH")
        f:SetSize(340, 16)
        f:SetPoint("TOP", UIParent, "TOP", 0, -2)
        if f.EnableMouse then f:EnableMouse(false) end
        f.text = f:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        f.text:SetAllPoints(f)
        f.text:SetText("scriptErrors ON  —  /err 0 to disable")
        f.text:SetTextColor(1, 0.5, 0.5)
        f:SetAlpha(0.35) -- faded
        _G.MythicMate_ScriptErrorsIndicatorFrame = f
    end
    if on then f:Show() else f:Hide() end
end

MythicMate_RegisterSlashCommands = function()
    if slashRegistered then return end
    if not SlashCmdList then return end

    slashRegistered = true
    SLASH_MYTHICMATE1 = "/mythicmate"
    SLASH_MYTHICMATE2 = "/mm"
    -- Legacy aliases from the WhatLockout days — muscle memory keeps working.
    SLASH_MYTHICMATE3 = "/wl"
    SLASH_MYTHICMATE4 = "/lockouts"
    SLASH_MYTHICMATE5 = "/whatlockout"

    -- Convenience: /rl -> reload the UI.
    SLASH_MYTHICMATERELOAD1 = "/rl"
    SlashCmdList["MYTHICMATERELOAD"] = function()
        if type(ReloadUI) == "function" then ReloadUI() end
    end

    -- Convenience: /err 1 -> enable Lua script errors, /err 0 -> disable.
    SLASH_MYTHICMATEERR1 = "/err"
    SlashCmdList["MYTHICMATEERR"] = function(msg)
        local a = (msg or ""):lower():gsub("%s+", "")
        local val = (a == "1" or a == "on" or a == "true") and "1" or "0"
        if type(SetCVar) == "function" then
            pcall(SetCVar, "scriptErrors", val)
        elseif type(ConsoleExec) == "function" then
            pcall(ConsoleExec, "scriptErrors " .. val)
        end
        print("|cffc868ffMythicMate:|r scriptErrors = " .. val)
        MythicMate_UpdateScriptErrorsIndicator()
    end
    -- Reflect the persisted scriptErrors state on load.
    MythicMate_UpdateScriptErrorsIndicator()

    SlashCmdList["MYTHICMATE"] = function(msg)
        local lower = (msg or ""):lower()
        -- WoW can pass leading/trailing spaces in some builds; trim for reliable matching.
        lower = lower:gsub("^%s+", ""):gsub("%s+$", "")

        if lower == "options" or lower == "settings" then
            pcall(MythicMate_TryCreateOptionsPanel)
            local hasPanel = whatLockoutOptionsPanel ~= nil
            -- If the panel exists, actively open it so it’s visible even if it
            -- isn’t obvious in the AddOns list.
            if hasPanel then
                pcall(MythicMate_OpenOptionsPanel)
            end
            return
        end

        if lower == "logs" or lower == "log" then
            -- Hidden view: only visible when this slash-command is used.
            MythicMateNS.ToggleErrorLogsView(not MythicMateNS.State._logsEnabled)
            return
        end

        if lower == "logs off" or lower == "logoff" then
            MythicMateNS.ToggleErrorLogsView(false)
            return
        end

        if lower == "version" or lower == "ver" or lower == "build" then
            MythicMate_PrintVersionInfo()
            return
        end

        if lower == "clearlogs" or lower == "clear logs" or lower == "clearluaerrors" then
            MythicMateNS.State._luaErrorLogs = {}
            if MythicMateNS.State._logsEnabled then
                MythicMateNS.RefreshErrorLogsView()
            end
            return
        end

    if lower == "selftest" or lower == "me" then
        -- Loopback: export your current lockouts into the shared cache under your own name.
        local fullName = MythicMate_GetLocalPlayerFullName and MythicMate_GetLocalPlayerFullName()
        if not fullName or fullName == "" then
            print("|cffc868ffMythicMate:|r Couldn't determine your character name for selftest.")
            return
        end

        local raidsPayload = MythicMate_BuildExport("RAIDS")
        local dungeonsPayload = MythicMate_BuildExport("DUNGEONS")
        local keystonePayload = MythicMate_BuildExport("KEYSTONE")
        MythicMate_HandleAssembledData(fullName, "RAIDS", raidsPayload)
        MythicMate_HandleAssembledData(fullName, "DUNGEONS", dungeonsPayload)
        MythicMate_HandleAssembledData(fullName, "KEYSTONE", keystonePayload)

        selectedRemotePlayer = fullName
        MythicMateDB.settings = MythicMateDB.settings or {}
        MythicMateDB.settings.selectedRemotePlayer = fullName
        if playersDropdown then
            UIDropDownMenu_SetText(playersDropdown, fullName)
        end
        SetActiveTab("PLAYERS")
        frame:Show()
        if frame.Raise then frame:Raise() end
        print("|cffc868ffMythicMate:|r Self-test data generated for: " .. fullName)
        return
    end

    if lower:match("^sim%s+") or lower == "sim" then
        local who = (msg or ""):match("^[Ss][Ii][Mm]%s+(.+)$")
        if not who or who == "" then
            who = "TestPlayer-Realm"
        end

        MythicMateDB.shared = MythicMateDB.shared or { players = {} }
        MythicMateDB.shared.players[who] = {
            updatedAt = time and time() or 0,
            raids = {
                { name = "Liberation of Undermine", progress = "3/8", reset = 3600 * 20, difficulty = "Mythic", locked = true },
            },
            dungeons = {
                { name = "Ara-Kara, City of Echoes", progress = "Done", reset = 3600 * 10, difficulty = "Mythic", locked = true },
                { name = "The Dawnbreaker", progress = "Available", reset = 0, difficulty = "Mythic", locked = false },
            },
        }

        selectedRemotePlayer = who
        MythicMateDB.settings = MythicMateDB.settings or {}
        MythicMateDB.settings.selectedRemotePlayer = who
        if playersDropdown then
            UIDropDownMenu_SetText(playersDropdown, who)
        end
        SetActiveTab("PLAYERS")
        frame:Show()
        if frame.Raise then frame:Raise() end
        print("|cffc868ffMythicMate:|r Added simulated player: " .. who)
        return
    end

    if lower:find("debugpool") then
        local function tf(obj, name) print(" - " .. name .. ": " .. tostring(type(obj) == "function")) end
        print("|cffc868ffMythicMate debug:|r Mythic+ pool APIs")
        tf(C_MythicPlus and C_MythicPlus.GetCurrentSeason, "C_MythicPlus.GetCurrentSeason")
        tf(C_MythicPlus and C_MythicPlus.GetCurrentUIDisplaySeason, "C_MythicPlus.GetCurrentUIDisplaySeason")
        tf(C_MythicPlus and C_MythicPlus.GetCurrentSeasonMaps, "C_MythicPlus.GetCurrentSeasonMaps")
        tf(C_MythicPlus and C_MythicPlus.RequestMapInfo, "C_MythicPlus.RequestMapInfo")
        tf(C_ChallengeMode and C_ChallengeMode.GetMapUIInfo, "C_ChallengeMode.GetMapUIInfo")
        tf(C_ChallengeMode and C_ChallengeMode.GetMapInfo, "C_ChallengeMode.GetMapInfo")
        tf(C_ChallengeMode and C_ChallengeMode.GetMapTable, "C_ChallengeMode.GetMapTable")

        if C_MythicPlus and (type(C_MythicPlus.GetCurrentUIDisplaySeason) == "function" or type(C_MythicPlus.GetCurrentSeason) == "function") then
            local ok, seasonID = pcall(function()
                if type(C_MythicPlus.GetCurrentUIDisplaySeason) == "function" then
                    return C_MythicPlus.GetCurrentUIDisplaySeason()
                end
                return C_MythicPlus.GetCurrentSeason()
            end)
            print(" - Current seasonID ok: " .. tostring(ok) .. " value: " .. tostring(seasonID))
        end

        local list = MythicMate_GetMythicDungeonDisplayList()
        print(" - DisplayList count: " .. tostring(#list))
        for i = 1, math.min(#list, 8) do
            print("   " .. i .. ". " .. tostring(list[i]))
        end
        return
    end

    if lower:find("debugcomm") then
        MythicMateDebugComm = not MythicMateDebugComm
        print("|cffc868ffMythicMate:|r debugcomm=" .. tostring(MythicMateDebugComm))
        return
    end

    if lower:find("debugtooltip") then
        MythicMateDebugTooltip = not MythicMateDebugTooltip
        print("|cffc868ffMythicMate:|r debugtooltip=" .. tostring(MythicMateDebugTooltip))
        return
    end

    if lower:find("keepsync") then
        if lower:find("off") then
            KEEP_SYNC_ENABLED = false
        elseif lower:find("on") then
            KEEP_SYNC_ENABLED = true
        else
            KEEP_SYNC_ENABLED = not KEEP_SYNC_ENABLED
        end
        print("|cffc868ffMythicMate:|r keep sync=" .. tostring(KEEP_SYNC_ENABLED))
        return
    end

    if lower == "key" or lower == "listkey" or lower == "quicklist" then
        -- One-click list/delist of your own keystone. Slash+Enter is a hardware event, so the
        -- protected CreateListing call inside QuickToggle is allowed.
        local ns = _G.MythicMate
        if ns and ns.ListGroup and type(ns.ListGroup.QuickToggle) == "function" then
            ns.ListGroup.QuickToggle()
        end
        return
    end

    if lower:find("search") or lower:find("filter") or lower == "lfg" or lower == "pf" then
        -- Open het hoofdpaneel en spring direct naar de LFG-tab.
        if frame and frame.Show then
            frame:Show()
            if frame.Raise then frame:Raise() end
        end
        -- Slash+Enter is a hardware event → allow the finder's initial search.
        if type(SetActiveTab) == "function" then pcall(SetActiveTab, "LFG", true) end
        return
    end

    if lower == "alts" or lower == "chars" or lower == "characters" then
        local ns = _G.MythicMate
        if ns and ns.Alts and type(ns.Alts.Toggle) == "function" then
            pcall(ns.Alts.Toggle)
        end
        return
    end

    if lower == "applicants" or lower == "apps" then
        -- Leader-side applicant sorter window (also /wlapps).
        local ns = _G.MythicMate
        if ns and ns.Applicants and type(ns.Applicants.Toggle) == "function" then
            pcall(ns.Applicants.Toggle)
        end
        return
    end

    if lower == "lfgnote" or lower:find("^lfgnote%s") then
        -- Set/clear the default note prefilled into LFG applications.
        local note = (msg or ""):match("^%s*[Ll][Ff][Gg][Nn][Oo][Tt][Ee]%s+(.+)$")
        MythicMateDB.settings = MythicMateDB.settings or {}
        if note and note ~= "" then
            MythicMateDB.settings.lfgAutoNote = note
            print("|cffc868ffMythicMate:|r LFG application note set to: |cffffffff" .. note .. "|r")
        else
            MythicMateDB.settings.lfgAutoNote = nil
            print("|cffc868ffMythicMate:|r LFG application note cleared.")
        end
        return
    end

    if lower == "lfgrefresh" or lower:find("^lfgrefresh%s") then
        -- Set the LFG auto-search interval (seconds). 0/off disables it. Blizzard blocks
        -- timer-only searches, so we re-search while you hover the list; this tunes how
        -- often. Clamped to >= 8s to respect Blizzard's own search throttle.
        local arg = (msg or ""):match("^%s*[Ll][Ff][Gg][Rr][Ee][Ff][Rr][Ee][Ss][Hh]%s+(.+)$")
        MythicMateDB.settings = MythicMateDB.settings or {}
        if arg and (arg:lower() == "off" or arg == "0") then
            MythicMateDB.settings.lfgAutoSearchEvery = 0
            print("|cffc868ffMythicMate:|r LFG auto-refresh |cffff6060off|r (use the Search button).")
        elseif arg and tonumber(arg) then
            local v = math.max(8, math.floor(tonumber(arg)))
            MythicMateDB.settings.lfgAutoSearchEvery = v
            print("|cffc868ffMythicMate:|r LFG auto-refresh every |cffffffff" .. v .. "s|r while hovering the list.")
        else
            local cur = MythicMateDB.settings.lfgAutoSearchEvery
            local shown = (cur == nil) and "10 (default)" or (tonumber(cur) and tonumber(cur) <= 0 and "off" or tostring(cur))
            print("|cffc868ffMythicMate:|r LFG auto-refresh = |cffffffff" .. shown .. "|r. Usage: /wl lfgrefresh <seconds|off>")
        end
        return
    end

    if lower:find("debugpremade") then
        if lower:find("off") then
            MythicMateDebugPremade = false
        elseif lower:find("on") then
            MythicMateDebugPremade = true
        else
            MythicMateDebugPremade = not MythicMateDebugPremade
        end
        print("|cffc868ffMythicMate:|r debug premade=" .. tostring(MythicMateDebugPremade))
        return
    end

    if lower:find("debug") then
        print("|cffc868ffMythicMate:|r Locked Mythic dungeons I see (normalized names):")
        local any = false
        for k in pairs(lockedMythicDungeonsByName) do
            print(" - " .. tostring(k))
            any = true
        end
        if not any then
            print(" (none)")
        end
        return
    end

    if lower:find("debugpool") then
        local function tf(obj, name) print(" - " .. name .. ": " .. tostring(type(obj) == "function")) end
        print("|cffc868ffMythicMate debug:|r Mythic+ pool APIs")
        tf(C_MythicPlus and C_MythicPlus.GetCurrentSeasonValues, "C_MythicPlus.GetCurrentSeasonValues")
        tf(C_MythicPlus and C_MythicPlus.GetCurrentSeasonMaps, "C_MythicPlus.GetCurrentSeasonMaps")
        tf(C_MythicPlus and C_MythicPlus.RequestMapInfo, "C_MythicPlus.RequestMapInfo")
        tf(C_ChallengeMode and C_ChallengeMode.GetMapUIInfo, "C_ChallengeMode.GetMapUIInfo")

        if C_MythicPlus and type(C_MythicPlus.GetCurrentSeasonValues) == "function" then
            local ok, values = pcall(C_MythicPlus.GetCurrentSeasonValues)
            print(" - GetCurrentSeasonValues ok: " .. tostring(ok) .. " type: " .. tostring(type(values)))
            if ok and type(values) == "table" then
                local keys = {}
                for k in pairs(values) do table.insert(keys, tostring(k)) end
                table.sort(keys)
                print(" - SeasonValues keys: " .. table.concat(keys, ", "))
                local maps = values.seasonMaps or values.currentSeasonMaps or values.maps or values.mapIDs
                print(" - Season map list type: " .. tostring(type(maps)) .. " count: " .. tostring(type(maps) == "table" and #maps or 0))
            end
        end

        local list = MythicMate_GetMythicDungeonDisplayList()
        print(" - DisplayList count: " .. tostring(#list))
        for i = 1, math.min(#list, 8) do
            print("   " .. i .. ". " .. tostring(list[i]))
        end
        return
    end

        if frame:IsShown() then
            frame:Hide()
        else
            RequestRaidInfo()
            -- Start on the last active tab so header filtering doesn't "reset" visually.
            -- Slash+Enter is a hardware event, so reopening on the LFG tab may search.
            local initialTab = (MythicMateDB.settings and MythicMateDB.settings.activeTab) or "DUNGEONS"
            SetActiveTab(initialTab, true)
            frame:Show()
            if frame.Raise then frame:Raise() end
            MythicMate_ScheduleSeasonPoolRetry()
        end
    end
end

-- Try to register immediately; otherwise PLAYER_ENTERING_WORLD will do it.
MythicMate_RegisterSlashCommands()

-- Start hidden
frame:Hide()

-- Minimap button via LibDBIcon (same behavior as RaiderIO)
------------------------------------------------------------
local function MythicMate_SetupMinimap()
    -- Keep LibDBIcon locals out of the file-level main chunk (Lua local-var limit).
    local LDB  = LibStub and LibStub("LibDataBroker-1.1", true)
    local LDBI = LibStub and LibStub("LibDBIcon-1.0", true)
    if not (LDB and LDBI) then return end

    -- Use a dedicated minimap icon texture
    local addonIcon = "Interface\\AddOns\\MythicMate\\media\\minimap"

    local dataObject = LDB:NewDataObject("MythicMate", {
        type = "launcher",
        text = "MythicMate",
        icon = addonIcon,
        OnClick = function(_, button)
            if button == "RightButton" then
                -- reserved for future menu / settings
                return
            end
            if frame:IsShown() then
                frame:Hide()
            else
                RequestRaidInfo()
                ScanLockouts()
                RenderList()
                frame:Show()
                if frame.Raise then frame:Raise() end
            end
        end,
        OnTooltipShow = function(tt)
            tt:AddLine("|cffc868ffMythicMate|r")
            -- Great Vault progress + weekly reset countdown (from MythicMate_Vault.lua).
            local ns = _G.MythicMate
            if ns and ns.Vault and type(ns.Vault.GetSummaryLines) == "function" then
                local ok, lines = pcall(ns.Vault.GetSummaryLines)
                if ok and type(lines) == "table" and #lines > 0 then
                    for _, l in ipairs(lines) do
                        tt:AddLine(l.text, l.r or 1, l.g or 1, l.b or 1)
                    end
                    tt:AddLine(" ")
                end
            end
            tt:AddLine("Left-click: show/hide window.", 1, 1, 1)
        end,
    })

    LDBI:Register("MythicMate", dataObject, MythicMateDB.minimapIcon)
end

MythicMate_SetupMinimap()

