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

local WhatLockoutNS = _G.WhatLockout
local Util = WhatLockoutNS and WhatLockoutNS.Util
local FormatTime = Util and Util.FormatTime
local WhatLockout_NowSeconds = Util and Util.NowSeconds
local WhatLockout_GetBestCommChannel = Util and Util.GetBestCommChannel
local WhatLockout_SafeFullPlayerName = Util and Util.SafeFullPlayerName
local WhatLockout_EncodeSection = Util and Util.EncodeSection
local WhatLockout_GetLocalPlayerFullName = Util and Util.GetLocalPlayerFullName
local WhatLockout_GetDisplayInstanceName = Util and Util.GetDisplayInstanceName

do
    local function initWithAceDB()
        local LibStub = _G.LibStub
        if not LibStub or type(LibStub) ~= "function" then return false end

        local AceDB = LibStub("AceDB-3.0", true)
        if not AceDB then return false end

        local defaults = {
            profile = {
                showPlayerHoverTooltip = true,
                useElvUISkinDefaults = true,
                activeTab = "DUNGEONS",
                hideLockedByTab = {},
                typeFilterByTab = {},
                useInstanceAbbreviations = false,
                shareChannel = "PARTY",
                whisperTarget = nil,
                selectedRemotePlayer = nil,
                whisperAutoReplyEnabled = false,
                whisperAutoReplyKeyword = "whatlockout",
            },
        }

        -- Migrate legacy SavedVariables into AceDB (if they exist).
        local legacySettings = (WhatLockoutDB and WhatLockoutDB.settings) and WhatLockoutDB.settings or nil
        local legacyMinimapIcon = WhatLockoutDB and WhatLockoutDB.minimapIcon or nil
        local legacyOwnCharacters = WhatLockoutDB and WhatLockoutDB.ownCharacters or nil
        local legacyShared = WhatLockoutDB and WhatLockoutDB.shared or nil

        -- This replaces the global SavedVariables table with an AceDB DB object.
        WhatLockoutDB = AceDB:New("WhatLockoutDB", defaults, true)
        WhatLockoutDB.settings = WhatLockoutDB.profile
        if WhatLockoutDB.settings.showRaiderIOLockoutDialog == nil then
            WhatLockoutDB.settings.showRaiderIOLockoutDialog = true
        end

        -- Non-profile fields used by the addon.
        WhatLockoutDB.minimapIcon = legacyMinimapIcon or WhatLockoutDB.minimapIcon or { hide = false, minimapPos = 220 }
        WhatLockoutDB.ownCharacters = legacyOwnCharacters or WhatLockoutDB.ownCharacters or {}
        if legacyShared then WhatLockoutDB.shared = legacyShared end

        if legacySettings and type(legacySettings) == "table" then
            for k, v in pairs(legacySettings) do
                WhatLockoutDB.settings[k] = v
            end
        end
        return true
    end

    if not initWithAceDB() then
        -- Fallback: legacy SavedVariables initialization (in case AceDB isn't available).
        WhatLockoutDB = WhatLockoutDB or {}
        WhatLockoutDB.minimapIcon = WhatLockoutDB.minimapIcon or { hide = false, minimapPos = 220 }
        WhatLockoutDB.settings = WhatLockoutDB.settings or {}
        WhatLockoutDB.ownCharacters = WhatLockoutDB.ownCharacters or {}
        if WhatLockoutDB.settings.showPlayerHoverTooltip == nil then
            WhatLockoutDB.settings.showPlayerHoverTooltip = true
        end
        if WhatLockoutDB.settings.showRaiderIOLockoutDialog == nil then
            WhatLockoutDB.settings.showRaiderIOLockoutDialog = true
        end
        if WhatLockoutDB.settings.useElvUISkinDefaults == nil then
            WhatLockoutDB.settings.useElvUISkinDefaults = true
        end
        if not WhatLockoutDB.settings.activeTab or type(WhatLockoutDB.settings.activeTab) ~= "string" then
            WhatLockoutDB.settings.activeTab = "DUNGEONS"
        end
        if type(WhatLockoutDB.settings.hideLockedByTab) ~= "table" then
            WhatLockoutDB.settings.hideLockedByTab = {}
        end
        if type(WhatLockoutDB.settings.typeFilterByTab) ~= "table" then
            WhatLockoutDB.settings.typeFilterByTab = {}
        end
        if WhatLockoutDB.settings.useInstanceAbbreviations == nil then
            WhatLockoutDB.settings.useInstanceAbbreviations = false
        end
        if type(WhatLockoutDB.settings.shareChannel) ~= "string" or WhatLockoutDB.settings.shareChannel == "" then
            WhatLockoutDB.settings.shareChannel = "PARTY"
        end
        if WhatLockoutDB.settings.whisperTarget ~= nil and type(WhatLockoutDB.settings.whisperTarget) ~= "string" then
            WhatLockoutDB.settings.whisperTarget = nil
        end
        if WhatLockoutDB.settings.whisperAutoReplyEnabled == nil then
            WhatLockoutDB.settings.whisperAutoReplyEnabled = false
        end
        if type(WhatLockoutDB.settings.whisperAutoReplyKeyword) ~= "string" or WhatLockoutDB.settings.whisperAutoReplyKeyword == "" then
            WhatLockoutDB.settings.whisperAutoReplyKeyword = "whatlockout"
        end
    end
end

-- If AceDB wasn't available at file-load time, we try again at PLAYER_LOGIN.
-- This helps when LibStub/Ace libraries load slightly later.
local whatLockoutAceDBInitialized = (WhatLockoutDB and WhatLockoutDB.profile) and true or false
local function WhatLockout_TryInitAceDB()
    if whatLockoutAceDBInitialized then return true end

    local LibStub = _G.LibStub
    if not LibStub or type(LibStub) ~= "function" then return false end

    local AceDB = LibStub("AceDB-3.0", true)
    if not AceDB then return false end

    local legacySettings = (WhatLockoutDB and WhatLockoutDB.settings) and WhatLockoutDB.settings or nil
    local legacyMinimapIcon = WhatLockoutDB and WhatLockoutDB.minimapIcon or nil
    local legacyOwnCharacters = WhatLockoutDB and WhatLockoutDB.ownCharacters or nil
    local legacyShared = WhatLockoutDB and WhatLockoutDB.shared or nil

    local defaults = {
        profile = {
            showPlayerHoverTooltip = true,
            showRaiderIOLockoutDialog = true,
            useElvUISkinDefaults = true,
            activeTab = "DUNGEONS",
            hideLockedByTab = {},
            typeFilterByTab = {},
            useInstanceAbbreviations = false,
            shareChannel = "PARTY",
            whisperTarget = nil,
            selectedRemotePlayer = nil,
            whisperAutoReplyEnabled = false,
            whisperAutoReplyKeyword = "whatlockout",
        },
    }

    WhatLockoutDB = AceDB:New("WhatLockoutDB", defaults, true)
    WhatLockoutDB.settings = WhatLockoutDB.profile

    if legacyMinimapIcon then WhatLockoutDB.minimapIcon = legacyMinimapIcon end
    if legacyOwnCharacters then WhatLockoutDB.ownCharacters = legacyOwnCharacters end
    if legacyShared then WhatLockoutDB.shared = legacyShared end

    if legacySettings and type(legacySettings) == "table" then
        for k, v in pairs(legacySettings) do
            WhatLockoutDB.settings[k] = v
        end
    end

    whatLockoutAceDBInitialized = true
    return true
end

local RenderList -- forward declaration (used by options toggle)
local WhatLockout_RefreshTooltipIfShown -- forward declaration (used by options toggle)
local WhatLockout_TryApplyElvUISkin -- forward declaration (used by options toggle)

------------------------------------------------------------
-- Main frame (GUI)
------------------------------------------------------------
local frame = CreateFrame("Frame", "WhatLockoutFrame", UIParent, "BackdropTemplate")
-- Slightly wider/taller so the datagrid, headers, and checkbox have enough room
frame:SetSize(780, 620)
frame:SetPoint("CENTER")
frame:EnableMouse(true)
-- Keep the window above most other UI/addons.
frame:SetFrameStrata("DIALOG")
frame:SetToplevel(true)
frame:SetMovable(true)
frame:RegisterForDrag("LeftButton")
frame:SetScript("OnDragStart", frame.StartMoving)
frame:SetScript("OnDragStop", frame.StopMovingOrSizing)
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("|cffc868ffWhat Lockout|r")

-- 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 WhatLockout_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("|cffc868ffWhatLockout:|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", "WhatLockoutVaultButton", 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()
    WhatLockout_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 What Lockout.", 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()
    WhatLockout_OpenGreatVaultUI()
end)

-- 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 -> WhatLockout)
------------------------------------------------------------
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 whatLockoutOptionsSettingsAddOnCategory = nil

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

    -- 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, result = 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, result2 = pcall(Settings.OpenToCategory, id)
                    if not ok2 then
                        -- Some versions subtract 1 for canvas-layout IDs.
                        local ok3, result3 = 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 WhatLockout_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", "WhatLockoutOptionsPanel", UIParent)
    _G["WhatLockoutOptionsPanel"] = whatLockoutOptionsPanel
    whatLockoutOptionsPanel.name = "WhatLockout"
    whatLockoutOptionsPanel:SetSize(550, 360)

    local title = whatLockoutOptionsPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    title:SetPoint("TOPLEFT", 16, -16)
    title:SetText("WhatLockout 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 = (WhatLockoutDB.settings and WhatLockoutDB.settings.showPlayerHoverTooltip) and true or false
    whatLockoutOptionsTooltipCheckbox:SetChecked(checked)

    whatLockoutOptionsTooltipCheckbox:SetScript("OnClick", function(self)
        local newChecked = self:GetChecked() and true or false
        WhatLockoutDB.settings = WhatLockoutDB.settings or {}
        WhatLockoutDB.settings.showPlayerHoverTooltip = newChecked
        if not newChecked and GameTooltip then
            GameTooltip:Hide()
        end
    end)

    y = y - 26
    local whatLockoutOptionsRaiderIOLockoutDialogCheckbox = CreateFrame("CheckButton", nil, whatLockoutOptionsPanel, "UICheckButtonTemplate")
    whatLockoutOptionsRaiderIOLockoutDialogCheckbox:SetPoint("TOPLEFT", 16, y)
    whatLockoutOptionsRaiderIOLockoutDialogCheckbox.text:SetText("Show RaiderIO lockout list")
    whatLockoutOptionsRaiderIOLockoutDialogCheckbox.text:SetFontObject("GameFontNormalSmall")
    whatLockoutOptionsRaiderIOLockoutDialogCheckbox.tooltipText = "Show your locked base Mythic dungeons as a small list under RaiderIO's profile tooltip."
    whatLockoutOptionsRaiderIOLockoutDialogCheckbox:SetChecked(
        not (WhatLockoutDB.settings and WhatLockoutDB.settings.showRaiderIOLockoutDialog == false)
    )
    whatLockoutOptionsRaiderIOLockoutDialogCheckbox:SetScript("OnClick", function(self)
        WhatLockoutDB.settings = WhatLockoutDB.settings or {}
        WhatLockoutDB.settings.showRaiderIOLockoutDialog = self:GetChecked() and true or false
        local f = _G.WhatLockoutRaiderIOLockoutDialogFrame
        if f and f.Hide then f:Hide() end
    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 WhatLockout. Saved persistently in addon settings."
    whatLockoutOptionsElvUISkinCheckbox:SetChecked((WhatLockoutDB.settings and WhatLockoutDB.settings.useElvUISkinDefaults) and true or false)
    whatLockoutOptionsElvUISkinCheckbox:SetScript("OnClick", function(self)
        local newChecked = self:GetChecked() and true or false
        WhatLockoutDB.settings = WhatLockoutDB.settings or {}
        WhatLockoutDB.settings.useElvUISkinDefaults = newChecked
        -- Only applies if enabled and not already skinned.
        if newChecked then
            WhatLockout_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((WhatLockoutDB.settings and WhatLockoutDB.settings.useInstanceAbbreviations) and true or false)
    whatLockoutOptionsAbbrevCheckbox:SetScript("OnClick", function(self)
        local newChecked = self:GetChecked() and true or false
        WhatLockoutDB.settings = WhatLockoutDB.settings or {}
        WhatLockoutDB.settings.useInstanceAbbreviations = newChecked
        if frame and frame:IsShown() then
            RenderList()
        end
        WhatLockout_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), WhatLockout whispers back a list of Mythic dungeons you can still run this week (Available / not locked). You can test by whispering yourself."
    whatLockoutOptionsWhisperReplyCheckbox:SetChecked((WhatLockoutDB.settings and WhatLockoutDB.settings.whisperAutoReplyEnabled) and true or false)
    whatLockoutOptionsWhisperReplyCheckbox:SetScript("OnClick", function(self)
        WhatLockoutDB.settings = WhatLockoutDB.settings or {}
        WhatLockoutDB.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()
        WhatLockoutDB.settings = WhatLockoutDB.settings or {}
        local t = whatLockoutOptionsWhisperKeywordEdit:GetText() or ""
        t = t:match("^%s*(.-)%s*$") or ""
        if t == "" then
            t = "whatlockout"
            whatLockoutOptionsWhisperKeywordEdit:SetText(t)
        end
        WhatLockoutDB.settings.whisperAutoReplyKeyword = t
    end
    whatLockoutOptionsWhisperKeywordEdit:SetText(
        (WhatLockoutDB.settings and type(WhatLockoutDB.settings.whisperAutoReplyKeyword) == "string" and WhatLockoutDB.settings.whisperAutoReplyKeyword ~= "" and WhatLockoutDB.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 = WhatLockoutDB and WhatLockoutDB.settings
        if not st then return end
        if whatLockoutOptionsWhisperReplyCheckbox then
            whatLockoutOptionsWhisperReplyCheckbox:SetChecked(st.whisperAutoReplyEnabled and true or 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 the Settings API; older ones use InterfaceOptions_AddCategory.
    if SettingsPanel and Settings and type(Settings.RegisterCanvasLayoutCategory) == "function" and
       type(Settings.RegisterAddOnCategory) == "function" then
        local ok1, category = pcall(Settings.RegisterCanvasLayoutCategory, whatLockoutOptionsPanel, "WhatLockout")
        if ok1 and category then
            whatLockoutOptionsSettingsCategory = category
            local ok2, addOnCategory = pcall(Settings.RegisterAddOnCategory, category)
            if not ok2 then
                -- ignore; we'll still have a registered canvas category if available
            end
            whatLockoutOptionsSettingsAddOnCategory = (ok2 and addOnCategory) or category
            route = "Settings"
        else
            -- Fall back to legacy API if possible.
            whatLockoutOptionsPanel = nil
            return
        end
    elseif type(InterfaceOptions_AddCategory) == "function" then
        local ok, err = pcall(InterfaceOptions_AddCategory, whatLockoutOptionsPanel)
        if not ok then
            whatLockoutOptionsPanel = nil
            return
        end
        route = "InterfaceOptions"
    else
        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 = WhatLockoutDB.settings and WhatLockoutDB.settings.activeTab or "DUNGEONS"

local function SetTabVisual(tab, active)
    if active then
        tab.bg:SetVertexColor(0.6, 0.4, 1.0, 1)
        tab.label:SetTextColor(1, 1, 1)
        tab.label:SetFontObject("GameFontHighlightLarge")
    else
        tab.bg:SetVertexColor(0.2, 0.2, 0.3, 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)
    btn:SetWidth(btn.label:GetStringWidth() + 40)

    btn:SetScript("OnEnter", function(self)
        if id ~= activeTab then
            self.bg:SetVertexColor(0.4, 0.4, 0.6, 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)

------------------------------------------------------------
-- Scroll frame & content area
------------------------------------------------------------
local listContainer = CreateFrame("Frame", nil, frame, "BackdropTemplate")
listContainer:SetPoint("TOPLEFT", frame, "TOPLEFT", 10, -120)
-- Leave more vertical room for footer (buttons + text)
listContainer:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -10, 80)
listContainer:SetBackdrop({
    bgFile   = "Interface\\Buttons\\WHITE8x8",
    edgeFile = nil,
    tile = true, tileSize = 16, edgeSize = 16,
    insets = { left = 0, right = 0, top = 0, bottom = 0 },
})
listContainer:SetBackdropColor(0.03, 0.03, 0.06, 0.9)

-- 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\\WhatLockout\\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")
-- Softer opacity so it feels like a watermark but is still visible
bgLogo:SetAlpha(0.10)
-- 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", "WhatLockoutShareDropdown", 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", "WhatLockoutPlayersDropdown", 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")
scrollFrame:SetPoint("TOPLEFT", 4, -4)
scrollFrame:SetPoint("BOTTOMRIGHT", -26, 4)

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.
-- Rows are rendered inside `scrollFrame` with an extra X offset, so we shift the header too.
headerRow:SetPoint("BOTTOMLEFT", listContainer, "TOPLEFT", 12, 4)
headerRow:SetPoint("BOTTOMRIGHT", listContainer, "TOPRIGHT", 0, 4)
headerRow:SetHeight(22)

local function CreateColumnHeader(parent, text, width, point, offsetX)
    local fs = parent:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    fs:SetTextColor(0.8, 0.8, 1)
    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", 260, nil, 16)
local bossHeader  = CreateColumnHeader(headerRow, "Progress", 100, nil, 16 + 260 + 12)
local resetHeader = CreateColumnHeader(headerRow, "Reset In", 90,  nil, 16 + 260 + 12 + 100 + 12)
local diffHeader  = CreateColumnHeader(headerRow, "Type", 60,    nil, 16 + 260 + 12 + 100 + 12 + 90 + 12)

-- 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, frame, "UICheckButtonTemplate")
filterCheckbox:SetScale(0.85)
-- Put it top-right so it never overlaps Players "Last sync".
filterCheckbox:ClearAllPoints()
filterCheckbox:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -110, -44)
filterCheckbox.text:SetText("Hide locked")
filterCheckbox.text:SetFontObject("GameFontNormalSmall")
filterCheckbox.tooltipText = "Hide locked instances from this list."

-- Anchor Players info to the left of the checkbox.
playersInfo:ClearAllPoints()
playersInfo:SetPoint("RIGHT", filterCheckbox, "LEFT", -12, 0)

------------------------------------------------------------
-- Optional skinning: ElvUI
------------------------------------------------------------
function WhatLockout_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
WhatLockout_TryApplyElvUISkin = function()
    -- Ensure persistence backend is initialized before reading settings.
    -- (ElvUI can load before/around our other events.)
    pcall(WhatLockout_TryInitAceDB)
    if whatLockoutElvUISkinApplied then return end
    if WhatLockoutDB.settings and WhatLockoutDB.settings.useElvUISkinDefaults == false then
        return
    end
    local S = WhatLockout_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 WhatLockout_BuildMythicPlusStats -- forward declaration (used by tab switch)
local WhatLockout_NormalizeName -- forward declaration (used by tooltip filters)
local WhatLockout_GetCurrentRaidNameSet -- forward declaration (used by tooltip filter)
local data = {
    RAIDS = {},
    DUNGEONS = {},
    PLAYERS = {},
    SHARED = {},
    MPLUS = {},
    KEYSTONE = {},
}
local whatLockoutCurrentRaidNameSetCache = {}
-- Spurious zone save (instance id 6006) — string fallback per locale for Shared Party / sync.
-- Matching uses WhatLockout_NormalizeName, WhatLockout_NormalizeInstanceKeyLoose, and Util.FoldForBlacklistKey (accent + apostrophe fold).
local whatLockoutJunkInstanceNameBlacklist = {
    -- English (enUS/enGB)
    ["the veiled stair"] = true,
    ["theveiledstair"] = true,
    -- German (deDE) — Die Verhüllte Treppe
    ["die verhüllte treppe"] = true,
    ["verhüllte treppe"] = true,
    ["dieverhülltetreppe"] = true,
    ["verhülltetreppe"] = true,
    ["die verhullte treppe"] = true,
    ["verhullte treppe"] = true,
    ["dieverhulltetreppe"] = true,
    ["verhulltetreppe"] = true,
    -- French (frFR)
    ["l'escalier voilé"] = true,
    ["l'escalier voile"] = true,
    ["lescaliervoile"] = true,
    ["lescalier voile"] = true,
    -- Spanish (esES / esMX)
    ["la escalera velada"] = true,
    ["laescaleravelada"] = true,
    -- Italian (itIT)
    ["la scala velata"] = true,
    ["lascalavelata"] = true,
    -- Portuguese (ptBR)
    ["a escadaria velada"] = true,
    ["aescadariavelada"] = true,
    -- Russian (ruRU)
    ["затуманенная лестница"] = true,
    ["затуманеннаялестница"] = true,
    -- Simplified Chinese (zhCN) — 雾纱栈道
    ["雾纱栈道"] = true,
    -- Traditional Chinese (zhTW)
    ["霧紗棧道"] = true,
    -- Korean (koKR)
    ["장막의 계단"] = true,
    ["장막의계단"] = true,
    ["장막으로 덮인 계단"] = true,
    ["장막으로 덮인계단"] = true,
}
local whatLockoutDungeonBlacklistNormSet = whatLockoutJunkInstanceNameBlacklist
local whatLockoutRaidBlacklistNormSet = whatLockoutJunkInstanceNameBlacklist

-- Per-tab filters
local filters = {
    RAIDS = {
        hideLocked = (WhatLockoutDB.settings.hideLockedByTab.RAIDS and true or false),
        typeFilter = (WhatLockoutDB.settings.typeFilterByTab.RAIDS or "ALL"),
    },
    DUNGEONS = {
        hideLocked = (WhatLockoutDB.settings.hideLockedByTab.DUNGEONS and true or false),
        typeFilter = (WhatLockoutDB.settings.typeFilterByTab.DUNGEONS or "ALL"),
    },
    PLAYERS = {
        hideLocked = (WhatLockoutDB.settings.hideLockedByTab.PLAYERS and true or false),
        typeFilter = (WhatLockoutDB.settings.typeFilterByTab.PLAYERS or "ALL"),
    },
    SHARED = {
        hideLocked = (WhatLockoutDB.settings.hideLockedByTab.SHARED and true or false),
        typeFilter = (WhatLockoutDB.settings.typeFilterByTab.SHARED or "ALL"),
    },
}

------------------------------------------------------------
-- Header filters (click headers to toggle)
------------------------------------------------------------
local function WhatLockout_CycleTypeFilter(current)
    if current == "Raid" then
        return "Dungeon"
    elseif current == "Dungeon" then
        return "Mythic"
    elseif current == "Mythic" then
        return "ALL"
    else
        -- "ALL" or unexpected
        return "Raid"
    end
end

local function WhatLockout_GetBossHeaderBaseText()
    if activeTab == "SHARED" then
        return "Locked by"
    elseif activeTab == "MPLUS" then
        return "Key level"
    elseif activeTab == "KEYSTONE" then
        return "Key"
    end
    return "Progress"
end

local function WhatLockout_GetDiffHeaderBaseText()
    if activeTab == "PLAYERS" then
        return "Category"
    elseif activeTab == "MPLUS" then
        return "Info"
    elseif activeTab == "KEYSTONE" then
        return "Dungeon"
    end
    return "Type"
end

local function WhatLockout_GetResetHeaderBaseText()
    if activeTab == "SHARED" then
        return "Available by"
    elseif activeTab == "KEYSTONE" then
        return "Seen"
    end
    return "Reset In"
end

-- Put filter state directly into the header text (no separate badges),
-- so it looks like "header row filtering on header text".
local function WhatLockout_UpdateHeaderFilterText()
    if not filters or not activeTab then return end
    if activeTab == "MPLUS" or activeTab == "KEYSTONE" then return end

    local fh = filters[activeTab]
    if not fh then return end

    local bossBase = WhatLockout_GetBossHeaderBaseText()
    local diffBase = WhatLockout_GetDiffHeaderBaseText()
    local resetBase = WhatLockout_GetResetHeaderBaseText()

    local bossSuffix = fh.hideLocked and " (Avail only)" or ""
    local resetSuffix = fh.hideLocked and " (Avail only)" or ""
    local tf = fh.typeFilter or "ALL"
    local diffSuffix = (tf and tf ~= "ALL") and (" (" .. tf .. ")") or ""

    if activeTab == "SHARED" then
        -- On Shared tab, "Available by" is the more meaningful column when hiding locked.
        bossHeader:SetText(bossBase)
        resetHeader:SetText(resetBase .. resetSuffix)
    else
        bossHeader:SetText(bossBase .. bossSuffix)
        resetHeader:SetText(resetBase)
    end
    diffHeader:SetText(diffBase .. diffSuffix)
end

-- Click "Progress" header to toggle hide-locked filter for the current tab.
bossHeader:EnableMouse(true)
bossHeader:SetScript("OnMouseUp", function()
    if not activeTab or activeTab == "MPLUS" or activeTab == "KEYSTONE" or not filters[activeTab] then return end

    filters[activeTab].hideLocked = not filters[activeTab].hideLocked
    WhatLockoutDB.settings = WhatLockoutDB.settings or {}
    WhatLockoutDB.settings.hideLockedByTab = WhatLockoutDB.settings.hideLockedByTab or {}
    WhatLockoutDB.settings.hideLockedByTab[activeTab] = filters[activeTab].hideLocked

    if filterCheckbox and filterCheckbox.SetChecked then
        filterCheckbox:SetChecked(filters[activeTab].hideLocked)
    end

    WhatLockout_UpdateHeaderFilterText()
    RenderList()
end)

-- Click "Type" header to cycle All -> Raid -> Dungeon -> Mythic -> All.
-- This is primarily meaningful on tabs where difficulty varies (e.g., SHARED and PLAYERS).
diffHeader:EnableMouse(true)
diffHeader:SetScript("OnMouseUp", function()
    if not activeTab or activeTab == "MPLUS" or activeTab == "KEYSTONE" or not filters[activeTab] then return end

    local nextType = WhatLockout_CycleTypeFilter(filters[activeTab].typeFilter or "ALL")
    filters[activeTab].typeFilter = nextType

    WhatLockoutDB.settings = WhatLockoutDB.settings or {}
    WhatLockoutDB.settings.typeFilterByTab = WhatLockoutDB.settings.typeFilterByTab or {}
    WhatLockoutDB.settings.typeFilterByTab[activeTab] = nextType

    WhatLockout_UpdateHeaderFilterText()
    RenderList()
end)

WhatLockout_UpdateHeaderFilterText()

------------------------------------------------------------
-- Addon comms: share lockouts between players using addon
------------------------------------------------------------
local COMM_PREFIX = "WhatLockout"
-- Dungeon lines: optional 7th = challenge mapID, 8th = saved instance id (locale-stable fallback).
-- Raid lines: optional 7th = raidKeyID (LFG activity/group id or saved instance id).
-- Older clients ignore extra tab columns; they fall back to name-based merge until updated.
local COMM_VERSION = "1"
local WhatLockoutDebugComm = false
local WhatLockoutDebugTooltip = false
local WhatLockoutDebugPremade = 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

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

local commPending = {} -- [sender][section] = { total = n, chunks = { [i]=payload } }
local selectedRemotePlayer = (WhatLockoutDB.settings and WhatLockoutDB.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 WhatLockout_GetRaidBossDisplayCounts(e)
    local ed, et = 0, 0
    if type(e) == "table" and type(e.encounters) == "table" then
        for _, enc in ipairs(e.encounters) do
            if enc and enc.name then
                et = et + 1
                if enc.killed then
                    ed = ed + 1
                end
            end
        end
    end
    local pd, pt = 0, 0
    if type(e) == "table" and type(e.progress) == "string" then
        local p1, p2 = e.progress:match("^(%d+)%s*/%s*(%d+)$")
        if p1 and p2 then
            pt = tonumber(p2) or 0
            pd = tonumber(p1) or 0
        end
    end
    local sd = tonumber(e and e.bossDefeatedCount) or 0
    local st = tonumber(e and e.bossTotalCount) or 0

    if et > 0 then
        local t = (pt > 0) and math.max(pt, et) or et
        local d = math.max(pd, ed, sd)
        return d, t
    end
    if pt > 0 then
        return math.max(pd, sd), pt
    end
    return sd, st
end

local function WhatLockout_RaidDifficultyLetter(diff)
    local s = tostring(diff or ""):lower()
    if s == "" then return nil end
    if s:find("mythic", 1, true) or s:find("mythisch", 1, true) or s:find("mítico", 1, true) then
        return "M"
    end
    if s:find("hero", 1, true) or s:find("heroisch", 1, true) or s:find("héro", 1, true) or s:find("heroico", 1, true) then
        return "H"
    end
    if s:find("lfr", 1, true) or s:find("finder", 1, true) or s:find("sucher", 1, true) or s:find("raid browser", 1, true) then
        return "L"
    end
    if s:find("normal", 1, true) or s:find("normaal", 1, true) then
        return "N"
    end
    return nil
end

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

    local highestRow = nil
    for _, r in ipairs(rows) do
        if type(r) == "table" and r.isHighest then
            highestRow = r
            break
        end
    end
    if not highestRow then return nil end

    local highestKey = nil
    if type(highestRow.progress) == "string" then
        highestKey = tonumber(highestRow.progress:match("^%+?(%d+)$")) or nil
    end

    local mapsAtMax = highestRow.mapsAtMax
    if type(mapsAtMax) ~= "table" then
        mapsAtMax = {}
    end

    if highestKey and highestKey > 0 then
        return { highestKey = highestKey, mapsAtMax = mapsAtMax }
    end

    return { highestKey = nil, mapsAtMax = mapsAtMax }
end

local function WhatLockout_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 WhatLockout_BuildExport(section)
    section = WhatLockout_EncodeSection(section)
    if not section then return "" end

    if section == "KEYSTONE" then
        local ks = WhatLockout_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(WhatLockout_BuildMythicPlusStats) == "function" then
            pcall(WhatLockout_BuildMythicPlusStats)
        end

        local summary = WhatLockout_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 = WhatLockout_GetRaidBossDisplayCounts(entry)
            if bossTotalCount > 0 then
                progress = tostring(bossDefeatedCount) .. "/" .. tostring(bossTotalCount)
            end
        end
        local bossSummary = tostring(bossDefeatedCount) .. "/" .. tostring(bossTotalCount)

        -- Dungeons: ... \t challengeMapID \t savedInstanceID (API id; optional, locale-stable fallback).
        -- Raids: ... \t raidKeyID \t journalInstanceID (saved row instanceId = GetSavedInstanceInfo #14; optional).
        local mapID = tonumber(entry.mapID or 0) or 0
        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 ""),
            }, "\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 ""),
            }, "\t")
        end
    end

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

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

local function WhatLockout_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 = WhatLockout_GetBestCommChannel()
    end
    if not channel then
        print("|cffc868ffWhatLockout:|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 WhatLockout_RequestSync()
    local channel = WhatLockout_GetBestCommChannel()
    if not channel then
        return
    end
    WhatLockout_SendComm("REQ", "RAIDS", "", channel)
    WhatLockout_SendComm("REQ", "DUNGEONS", "", channel)
    WhatLockout_SendComm("REQ", "MPLUS", "", channel)
    WhatLockout_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 WhatLockout_TryAutoSync()
    local channel = WhatLockout_GetBestCommChannel()
    if not channel then return end

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

    lastAutoSyncAt = now
    WhatLockout_RequestSync()
end

local function WhatLockout_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
        WhatLockout_TryAutoSync()
    end)
end

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

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

    local t = WhatLockoutDB.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]

                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 row = {
                        name = name,
                        progress = progress or "",
                        reset = tonumber(reset or 0) or 0,
                        difficulty = difficulty or "",
                        locked = (locked == "1"),
                        bossDefeatedCount = bossDefeatedCount,
                        bossTotalCount = bossTotalCount,
                    }
                    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 = WhatLockout_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

    WhatLockoutDB.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 = WhatLockoutDB.shared.players[short]
        if not existing or existing == t then
            WhatLockoutDB.shared.players[short] = t
        end
    end

    if WhatLockoutDebugComm 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("|cffc868ffWhatLockout debugcomm:|r from=" .. tostring(senderFull)
            .. " section=" .. tostring(section)
            .. " entries=" .. tostring(cnt)
            .. " locked=" .. tostring(lockedCnt))
    end
end

local function WhatLockout_SaveSelfSnapshot()
    local fullName = WhatLockout_GetLocalPlayerFullName()
    if not fullName or fullName == "" then return end

    WhatLockoutDB = WhatLockoutDB or {}
    WhatLockoutDB.shared = WhatLockoutDB.shared or {}
    WhatLockoutDB.shared.players = WhatLockoutDB.shared.players or {}
    WhatLockoutDB.ownCharacters = WhatLockoutDB.ownCharacters or {}
    WhatLockoutDB.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 = WhatLockout_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,
        }
    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,
        }
    end

    local t = WhatLockoutDB.shared.players[fullName] or {}
    t.raids = raidsCopy
    t.dungeons = dungeonsCopy
    local ks = WhatLockout_GetOwnedKeystoneSnapshot()
    t.keystone = ks and {
        level = ks.level,
        mapID = ks.mapID,
        mapName = ks.mapName,
    } or nil
    local nowEpoch = time and time() or 0
    t.updatedAt = nowEpoch
    t.updatedAtRaids = nowEpoch
    t.updatedAtDungeons = nowEpoch
    t.updatedAtKeystone = nowEpoch
    WhatLockoutDB.shared.players[fullName] = t

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

local function WhatLockout_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 = WhatLockout_SafeFullPlayerName(sender)
    section = tostring(section or ""):upper()

    if msgType == "REQ" then
        section = WhatLockout_EncodeSection(section)
        if not section then return end
        local export = WhatLockout_BuildExport(section)
        WhatLockout_SendComm("DATA", section, export, "WHISPER", senderFull)
        return
    end

    if msgType ~= "DATA" 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
    WhatLockout_HandleAssembledData(senderFull, section, full)
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 WhatLockout_GetCurrentPartyMemberKeys

local function WhatLockout_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 WhatLockout_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 WhatLockout_GetTooltipPlayerKey(tt)
    -- Avoid Unit* APIs in tooltips to prevent "secret string" taint.
    if not tt then return nil end
    local fs = tt.TextLeft1
    if not fs and tt.GetName then
        local n = tt:GetName()
        fs = n and _G[n .. "TextLeft1"] or nil
    end
    if not fs or not fs.GetText then return nil end

    local ok, raw = pcall(fs.GetText, fs)
    if not ok or raw == nil 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

    -- Often already "Name-Realm" on cross-realm tooltips.
    return cleaned
end

local function WhatLockout_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(WhatLockout_GetCurrentPartyMemberKeys) == "function" then
        local short = chosen:lower()
        local keys = WhatLockout_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 = WhatLockoutDB and WhatLockoutDB.shared and WhatLockoutDB.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 = WhatLockout_GetBestCommChannel and WhatLockout_GetBestCommChannel() or nil
    if groupChannel == "PARTY" or groupChannel == "RAID" then
        WhatLockout_SendComm("REQ", "RAIDS", "", groupChannel)
        WhatLockout_SendComm("REQ", "DUNGEONS", "", groupChannel)
        WhatLockout_SendComm("REQ", "MPLUS", "", groupChannel)
        WhatLockout_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 WhatLockoutDebugTooltip then
            print("|cffc868ffWhatLockout debugtooltip:|r skip hover sync, unresolved whisper target=" .. tostring(targetKey))
        end
        return
    end

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

local function WhatLockout_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(WhatLockout_GetCurrentPartyMemberKeys) == "function" then
        local lowerText = key:lower()
        for _, partyKey in ipairs(WhatLockout_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 WhatLockout_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 updated = tonumber(p.updatedAt or 0) or 0
        -- Prefer raids presence first, then dungeons, then freshness.
        return (hasRaids * 1000000) + (hasDungeons * 100000) + (hasMplus * 10000) + 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 WhatLockout_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 }
    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
    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
            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
        end
    end

    local hasMplus = type(merged.mplus) == "table" and tonumber(merged.mplus.highestKey or 0) and tonumber(merged.mplus.highestKey or 0) > 0
    if (#merged.raids == 0) and (#merged.dungeons == 0) and not hasMplus 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 WhatLockout_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(WhatLockout_GetCurrentPartyMemberKeys) == "function") then return end

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

    local selfKey = WhatLockout_GetLocalPlayerFullName and WhatLockout_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
                WhatLockout_RequestSyncToTarget(k)
            end
        end
    end
end

local function WhatLockout_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 WhatLockout_IsEffectivelyLockedSharedEntry(entry, snapshotUpdatedAt)
    if not entry or not entry.locked then return false end
    return WhatLockout_GetEffectiveSharedResetSeconds(entry, snapshotUpdatedAt) > 0
end

-- Shared: show instance names in the local client language when mapID / raidKeyID are known (tooltips + Shared Party).
local function WhatLockout_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 WhatLockout_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

-- Tooltip append (in-tooltip, flicker-safe)
local function WhatLockout_AppendDungeonLinesToTooltip(tt, fullName, payload)
    if not tt or not fullName then return end
    if not payload then return end
    local function WhatLockout_IsLikelyPvPActivityName(name)
        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()
        return l:find("battleground", 1, true) ~= nil or
               l:find("arena", 1, true) ~= nil or
               l:find("pvp", 1, true) ~= nil or
               l:find("brawl", 1, true) ~= nil or
               l:find("skirmish", 1, true) ~= nil or
               l:find("battle for", 1, true) ~= nil
    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 = WhatLockout_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 = WhatLockout_NormalizeName(e.name)
                local isPvP = WhatLockout_IsLikelyPvPActivityName(e.name)
                if norm and currentRaidSet[norm] and not isPvP then
                    raidListFiltered[#raidListFiltered + 1] = e
                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 WhatLockout_IsLikelyPvPActivityName(e.name) then
                dungeonListFiltered[#dungeonListFiltered + 1] = e
            end
        end
    end
    local hasRaids = (type(raidListFiltered) == "table" and #raidListFiltered > 0)
    local hasDungeons = (type(dungeonListFiltered) == "table" and #dungeonListFiltered > 0)
    if not hasRaids and not hasDungeons then
        if tt.WhatLockoutAppendedFor == fullName then return end
        tt.WhatLockoutAppendedFor = fullName
        if not tt.WhatLockoutHeaderAdded then
            tt:AddLine(" ")
            tt:AddLine("|cffc868ffWhatLockout|r" .. mplusSuffix, 1, 0.82, 1)
            tt.WhatLockoutHeaderAdded = true
        end
        -- Keep a stable section layout so users can see if raid data is just
        -- not synced yet vs truly absent.
        local raidsAt = WhatLockout_GetPayloadUpdatedAtForSection(payload, "RAIDS")
        local dungeonsAt = WhatLockout_GetPayloadUpdatedAtForSection(payload, "DUNGEONS")
        local syncing = WhatLockout_ShouldShowSyncingHint(fullName, math.max(raidsAt, dungeonsAt))
        tt:AddLine("Raids:", 1, 0.82, 0)
        tt:AddLine(syncing and "- syncing..." or "- none", 0.75, 0.75, 0.75)
        tt:AddLine("Dungeons:", 1, 0.82, 0)
        tt:AddLine(syncing and "- syncing..." or "- none", 0.75, 0.75, 0.75)
        return
    end

    -- Only append once per tooltip "session".
    if tt.WhatLockoutAppendedFor == fullName then return end
    tt.WhatLockoutAppendedFor = 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.WhatLockoutHeaderAdded then return end

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

    -- Keep tooltip compact to avoid spilling outside the frame.
    local startHeight = tt.GetHeight and tt:GetHeight() or 0
    local shown = 0

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

        local raidMax = math.min(#raidList, 8) -- keep tooltip compact
        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 = WhatLockout_GetDisplayInstanceName(e.name or "Unknown")
                local diffLetter = WhatLockout_RaidDifficultyLetter(e.difficulty)
                local diffSuffix = diffLetter and (" (" .. diffLetter .. ")") or ""
                local defeated, total = WhatLockout_GetRaidBossDisplayCounts(e)

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

                local effectiveLocked = WhatLockout_IsEffectivelyLockedSharedEntry(e, WhatLockout_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
            end
        end
    end

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

    tt:AddLine("Dungeons:", 1, 0.82, 0)
    shown = shown + 1

    if type(dungeonList) == "table" and #dungeonList > 0 then
            -- No cap for dungeons in the player hover tooltip.
            for _, e in ipairs(dungeonList) do
                local name = WhatLockout_GetDisplayInstanceName(e.name or "Unknown")
                local effectiveLocked = WhatLockout_IsEffectivelyLockedSharedEntry(e, WhatLockout_GetPayloadUpdatedAtForSection(payload, "DUNGEONS"))
                if effectiveLocked then
                    tt:AddLine("- " .. name .. " (Locked)", 1, 0.3, 0.3)
                else
                    tt:AddLine("- " .. name .. " (Ready)", 0.3, 1, 0.3)
                end
                shown = shown + 1
            end
    else
        local syncing = WhatLockout_ShouldShowSyncingHint(fullName, WhatLockout_GetPayloadUpdatedAtForSection(payload, "DUNGEONS"))
        tt:AddLine(syncing and "- syncing..." or "- none", 0.75, 0.75, 0.75)
        shown = shown + 1
    end

    -- Ensure the tooltip frame grows enough to contain all added lines.
    -- Some Blizzard tooltip layouts can under-size, which causes text to spill.
    if tt.SetHeight and shown > 0 then
        local expectedMin = startHeight + (shown * 14) + 22
        local endHeight = tt.GetHeight and tt:GetHeight() or expectedMin
        if endHeight < expectedMin then
            tt:SetHeight(expectedMin)
        end
    end
end

WhatLockout_RefreshTooltipIfShown = function()
    -- Force tooltip to re-run our append when data changes (e.g. after dungeon).
    local tt = GameTooltip
    if not tt or type(tt.IsShown) ~= "function" or not tt:IsShown() then return end

    if WhatLockoutDB.settings and WhatLockoutDB.settings.showPlayerHoverTooltip == false then
        return
    end

    -- If we haven't appended yet, we still want to update when data arrives.
    -- (Earlier we returned early here, which could block late refreshes.)
    -- Taint-safe heuristic: just verify the tooltip has a left text line.
    local hasPlayerText = false
    if tt.TextLeft1 and tt.TextLeft1.GetText and type(tt.TextLeft1.GetText) == "function" then
        hasPlayerText = true
    elseif tt.GetName and type(tt.GetName) == "function" then
        local n = tt:GetName()
        if n and _G[n .. "TextLeft1"] and _G[n .. "TextLeft1"].GetText then
            hasPlayerText = true
        end
    end
    if not hasPlayerText then return end

    -- Debounce Hide/Show calls: they can visibly flicker if triggered frequently.
    local now = (type(GetTime) == "function") and GetTime() or 0
    WhatLockout_LastTooltipRefreshAt = WhatLockout_LastTooltipRefreshAt or 0
    if (now - (WhatLockout_LastTooltipRefreshAt or 0)) < 6 then
        return
    end
    WhatLockout_LastTooltipRefreshAt = now

    tt.WhatLockoutAppendedFor = nil
    tt.WhatLockoutHeaderAdded = nil

    if C_Timer and type(C_Timer.After) == "function" then
        C_Timer.After(0, function()
            if not tt or type(tt.IsShown) ~= "function" or not tt:IsShown() then return end
            tt:Hide()
            tt:Show()
        end)
    else
        tt:Hide()
        tt:Show()
    end
end

local function WhatLockout_HookUnitTooltip()
    if tooltipHooked then return end
    if not GameTooltip then return end

    local inAppend = false

    local function tryAppendNow(tt)
        if not tt then return end
        if inAppend then return end

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

        -- Reset marker when tooltip clears/hides.
        if not tt.WhatLockoutClearHooked and type(tt.HookScript) == "function" then
            tt.WhatLockoutClearHooked = true
            tt:HookScript("OnTooltipCleared", function(t)
                t.WhatLockoutAppendedFor = nil
                t.WhatLockoutHeaderAdded = nil
            end)
            tt:HookScript("OnHide", function(t)
                t.WhatLockoutAppendedFor = nil
                t.WhatLockoutHeaderAdded = nil
            end)
        end

        local players = WhatLockoutDB.shared and WhatLockoutDB.shared.players
        if type(players) ~= "table" then return end

        local key = WhatLockout_GetTooltipPlayerKey(tt)
        if not key then return end

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

        -- Special case: when hovering yourself, always use live data.*
        local me = WhatLockout_GetLocalPlayerFullName and WhatLockout_GetLocalPlayerFullName()
        if me then
            local meShort = me:match("^([^%-]+)") or me
            if key == me or keyToken == me or keyShort == meShort then
                if not inAppend then
                    inAppend = true
                    local mp = WhatLockout_ExtractMplusSummaryFromRows(data and data.MPLUS)
                    if not mp or not mp.highestKey then
                        if type(WhatLockout_BuildMythicPlusStats) == "function" then
                            pcall(WhatLockout_BuildMythicPlusStats)
                        end
                        mp = WhatLockout_ExtractMplusSummaryFromRows(data and data.MPLUS)
                    end
                    local payload = { raids = data.RAIDS or {}, dungeons = data.DUNGEONS or {}, mplus = mp }
                    pcall(WhatLockout_AppendDungeonLinesToTooltip, tt, me, payload)
                    inAppend = false
                end
                return
            end
        end

        local candidates = WhatLockout_CollectTooltipCandidates(key)

        local payload, shownKey = WhatLockout_ResolveMergedSharedPayload(players, candidates)
        if not payload then
            payload, shownKey = WhatLockout_ResolveBestSharedPayload(players, candidates)
        end

        -- Force freshness: if we're hovering a party member, request updated
        -- lockouts from them (throttled). This avoids stale tooltip cache.
        local syncKey = (type(payload) == "table" and payload._syncTargetKey) or shownKey
        local now = (type(GetTime) == "function") and GetTime() or 0
        local isStale = false
        if payload and type(time) == "function" then
            local raidsAt = WhatLockout_GetPayloadUpdatedAtForSection(payload, "RAIDS")
            local dungeonsAt = WhatLockout_GetPayloadUpdatedAtForSection(payload, "DUNGEONS")
            local mplusAt = WhatLockout_GetPayloadUpdatedAtForSection(payload, "MPLUS")
            local referenceAt = 0
            if raidsAt > 0 and dungeonsAt > 0 and mplusAt > 0 then
                referenceAt = math.min(raidsAt, dungeonsAt, mplusAt)
            else
                referenceAt = math.max(raidsAt, dungeonsAt, mplusAt)
            end
            if referenceAt > 0 then
                local snapshotAge = (time() or 0) - referenceAt
                isStale = snapshotAge > HOVER_STALE_DATA_THRESHOLD
            end
        end

        local reqCooldown = isStale and HOVER_STALE_SYNC_COOLDOWN or HOVER_SYNC_COOLDOWN
        if syncKey and syncKey ~= "" and ((now - (hoverLastSyncRequestAt[syncKey] or 0)) >= reqCooldown) then
            hoverLastSyncRequestAt[syncKey] = now
            hoverSyncPendingUntil[syncKey] = now + HOVER_SYNC_PENDING_WINDOW
            pcall(WhatLockout_RequestSyncToTarget, syncKey)
            if WhatLockoutDebugTooltip then
                print("|cffc868ffWhatLockout debugtooltip:|r hover request to " .. tostring(syncKey)
                    .. " stale=" .. tostring(isStale))
            end
        end

        if payload then
            inAppend = true
            pcall(WhatLockout_AppendDungeonLinesToTooltip, tt, shownKey, payload)
            inAppend = false
        end
    end

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

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

        -- Reset marker when tooltip clears/hides.
        if not tt.WhatLockoutClearHooked and type(tt.HookScript) == "function" then
            tt.WhatLockoutClearHooked = true
            tt:HookScript("OnTooltipCleared", function(t)
                t.WhatLockoutAppendedFor = nil
            end)
            tt:HookScript("OnHide", function(t)
                t.WhatLockoutAppendedFor = nil
            end)
        end

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

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

            local players = WhatLockoutDB.shared and WhatLockoutDB.shared.players
            if type(players) ~= "table" then return true end -- stop

            local key = WhatLockout_GetTooltipPlayerKey(tt)
            if not key then return false end -- tooltip not populated yet; retry

            -- Tooltips sometimes include extra tokens; try multiple candidates.
            local candidates = WhatLockout_CollectTooltipCandidates(key)

            local payload, shownKey = WhatLockout_ResolveMergedSharedPayload(players, candidates)
            if not payload then
                payload, shownKey = WhatLockout_ResolveBestSharedPayload(players, candidates)
            end

            if payload then
                inAppend = true
                pcall(WhatLockout_AppendDungeonLinesToTooltip, tt, shownKey, payload)
                inAppend = false
            end

            return true -- stop (we either appended or there is no payload)
        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
            local delays = { 0, 0.05, 0.12 }
            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 do NOT touch unit tokens here
    -- (avoids "secret string" taint); we only schedule an append based on
    -- tooltip text.
    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)
                -- PostCall is already after the tooltip is populated; append immediately.
                tryAppendNow(tt)
            end)
        end)
    end

    -- 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)
            scheduleForTooltip(tt)
        end)
    end

    -- Also hook SetUnit (don’t touch unit args; read tooltip text instead).
    if type(hooksecurefunc) == "function" and GameTooltip.SetUnit then
        hooksecurefunc(GameTooltip, "SetUnit", function(tt)
            if not tt then return end
            scheduleForTooltip(tt)
        end)
    end
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.WhatLockout; never replace the global with a fresh {} here (would drop Util/helpers).
local WhatLockout = _G.WhatLockout
if type(WhatLockout) ~= "table" then
    WhatLockout = {}
    _G.WhatLockout = WhatLockout
end
WhatLockout.State = WhatLockout.State or {}

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

local function WhatLockout_GetSelectedChatChannel()
    local selectedShareChannel = WhatLockout.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 WhatLockout_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 = "WhatLockout - Shared Party (Everyone Available)"
    else
        title = "WhatLockout - " .. (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 prog = entry.progress or ""
                        -- When "Available only" is enabled, include dungeons you can run:
                        -- "Available" and "Expired" (expired means your previous lockout is gone).
                        if hideLockedHere then
                            if (prog == "Available" or prog == "Expired") and not entry.locked then
                                local dn = entry.name or "Unknown"
                                table.insert(lines, "- " .. dn)
                                any = true
                            end
                        elseif prog ~= "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 WhatLockout_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 WhatLockout_PostSummary()
    local lines = WhatLockout_BuildSummaryLines()
    if #lines == 0 then return end

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

    if ch == "WHISPER" then
        local target = WhatLockout_NormalizeWhisperTarget(WhatLockout.State.selectedWhisperTarget)
        if not target then
            print("|cffc868ffWhatLockout:|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 WhatLockout_GetWhisperAutoReplyKeywordSetting()
    local st = WhatLockoutDB and WhatLockoutDB.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 WhatLockout_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 WhatLockout_BuildAvailableMythicDungeonWhisperLines()
    local lines = {}
    -- Bracket prefix so this line never matches the default trigger "whatlockout" (avoids echo if you /w yourself).
    lines[#lines + 1] = "[WhatLockout] Mythic dungeons you can still run:"
    local list = data.DUNGEONS or {}
    local any = false
    for _, entry in ipairs(list) do
        local prog = entry.progress or ""
        if (prog == "Available" or prog == "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 WhatLockout_TryWhisperAutoReply(text, sender)
    pcall(WhatLockout_TryInitAceDB)
    local st = WhatLockoutDB and WhatLockoutDB.settings
    if not st or not st.whisperAutoReplyEnabled then return end
    if not text or not sender or sender == "" then return end
    local kw = WhatLockout_GetWhisperAutoReplyKeywordSetting()
    if not WhatLockout_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 = WhatLockout_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 WhatLockout_ShouldAutoUncheckLockedPremadeDungeons()
    return false
end
_G.WhatLockout_ShouldAutoUncheckLockedPremadeDungeons = WhatLockout_ShouldAutoUncheckLockedPremadeDungeons

-- Normalize instance/dungeon/raid names for stable lookups.
-- This function is referenced via a file-local upvalue (`local WhatLockout_NormalizeName`)
-- throughout the monolith, so it must be defined here even if moved to other modules.
WhatLockout_NormalizeName = function(name)
    local fn = _G.WhatLockout and _G.WhatLockout.NormalizeName and _G.WhatLockout.NormalizeName or _G.WhatLockout_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 WhatLockout_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 WhatLockout_TryUncheckLFGDungeonButton(button, dungeonID, name)
    if not button then return false end

    local checkedBefore = WhatLockout_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 WhatLockout_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 WhatLockout_IsButtonCheckedLike(button) and type(button.Click) == "function" then
        pcall(button.Click, button)
    end

    local unchecked = not WhatLockout_IsButtonCheckedLike(button)
    if WhatLockoutDebugPremade then
        print("|cffc868ffWhatLockout 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 WhatLockout_ForceLockedDungeonRowUnchecked(button, dungeonID, name)
    if not button then return end
    if not WhatLockout_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.WhatLockoutForceUncheckHooked and type(button.HookScript) == "function" then
        button.WhatLockoutForceUncheckHooked = true
        button:HookScript("OnClick", function(self)
            if not self or not self.data then return end
            if self.data.WhatLockoutLockedDungeon then
                if C_Timer and type(C_Timer.After) == "function" then
                    C_Timer.After(0, function() WhatLockout_ForceLockedDungeonRowUnchecked(self, self.data.dungeonID, self.data.name) end)
                else
                    WhatLockout_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.WhatLockoutLockedDungeon = 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 = WhatLockout_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 = WhatLockout_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.WhatLockoutDungeonIsLocked = isLocked and true or false
        button.data.WhatLockoutMatchedMapID = didMatchedMapID and true or false

        if WhatLockoutDebugPremade then
            print("|cffc868ffWhatLockout 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.WhatLockoutDungeonLockTooltipHooked and type(button.HookScript) == "function" then
            button.WhatLockoutDungeonLockTooltipHooked = 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.WhatLockoutDungeonIsLocked and true or false
                local doTryColor = lockedNow or WhatLockoutDebugPremade
                if WhatLockoutDebugPremade then
                    print("|cffc868ffWhatLockout debug DF OnEnter:|r dungeonKey=" .. tostring(key) ..
                        " lockedNow=" .. tostring(lockedNow) ..
                        " doTryColor=" .. tostring(doTryColor))
                end
            end)
            button:HookScript("OnLeave", function()
                if GameTooltip then
                    GameTooltip.WhatLockoutDungeonLockFor = 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
                WhatLockout_TryUncheckLFGDungeonButton(button, dungeonID, name)
                WhatLockout_ForceLockedDungeonRowUnchecked(button, dungeonID, name)
            elseif WhatLockoutDebugPremade then
                print("|cffc868ffWhatLockout premade (DF):|r skipped auto-uncheck (setting disabled) for dungeonID=" .. tostring(dungeonID))
            end

            if not button.WhatLockoutWarningIcon 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.WhatLockoutWarningIcon = icon

                button:HookScript("OnEnter", function(self)
                    if self.WhatLockoutWarningIcon and self.WhatLockoutWarningIcon:IsShown() then
                        -- Marker so we only hide this tooltip when it was our own warning.
                        GameTooltip.WhatLockoutWarningIconFor = 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.WhatLockoutWarningIconFor ~= nil and GameTooltip.WhatLockoutWarningIconFor ~= "" then
                        GameTooltip:Hide()
                    end
                end)
            end
            button.WhatLockoutWarningIcon:Show()
        elseif button.WhatLockoutWarningIcon then
            if button.data then
                button.data.WhatLockoutLockedDungeon = false
            end
            button.WhatLockoutWarningIcon: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.WhatLockoutRaiderIOColorPollActive = _G.WhatLockoutRaiderIOColorPollActive or false
_G.WhatLockout_PollRaiderIOProfileTooltip = _G.WhatLockout_PollRaiderIOProfileTooltip or function()
    if _G.WhatLockoutRaiderIOColorPollActive then return end
    _G.WhatLockoutRaiderIOColorPollActive = 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 (WhatLockoutDB and WhatLockoutDB.settings and WhatLockoutDB.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 = WhatLockout_NormalizeName,
            useElvUISkinDefaults = (WhatLockoutDB and WhatLockoutDB.settings and WhatLockoutDB.settings.useElvUISkinDefaults) and true or false,
            debug = WhatLockoutDebugPremade,
        })
    end

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

_G.WhatLockout_PollRaiderIOProfileTooltip()

local whatLockoutPremadeUncheckGuard = false
local whatLockoutPremadeFilterHooked = false
local whatLockoutPremadeLastForcedClickAtByName = {}
local function WhatLockout_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 WhatLockout_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 WhatLockout_IsDungeonNameLocked(name)
    if not name or name == "" then return false end
    local norm = WhatLockout_NormalizeName(name)
    if norm and lockedMythicDungeonsByName[norm] then
        return true
    end

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

local function WhatLockout_FindLockedDungeonInText(text)
    if not text or text == "" then return nil end
    local looseText = WhatLockout_NormalizeDungeonKeyLoose(text)
    if not looseText then return nil end

    for lockedNorm, _ in pairs(lockedMythicDungeonsByName) do
        local lockedLoose = WhatLockout_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 WhatLockout_TryUncheckLockedButton(button, resolvedName)
    if not button or not resolvedName or resolvedName == "" then return false end
    if not WhatLockout_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 WhatLockoutDebugPremade then
            print("|cffc868ffWhatLockout premade:|r unchecking locked dungeon \"" .. tostring(resolvedName) .. "\"")
        end
        button:Click()
        return true
    end
    return true
end

local function WhatLockout_AutoUncheckLockedDungeonButtons(dropdownListFrame)
    if whatLockoutPremadeUncheckGuard or not dropdownListFrame or not dropdownListFrame:IsShown() then
        return
    end
    if not WhatLockout_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 = WhatLockout_GetCheckButtonText(button) or ""
            end

            local isLockedName = WhatLockout_IsDungeonNameLocked(resolvedName)
            if isLockedName then
                lockedRows = lockedRows + 1
            end
            if WhatLockoutDebugPremade 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("|cffc868ffWhatLockout 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 WhatLockout_TryUncheckLockedButton(button, resolvedName) then
                uncheckedRows = uncheckedRows + 1
            end
        end
    end
    if WhatLockoutDebugPremade then
        print("|cffc868ffWhatLockout premade:|r dropdown scan frame=" .. tostring(listName)
            .. " seenRows=" .. tostring(seenRows)
            .. " lockedRows=" .. tostring(lockedRows)
            .. " uncheckedRows=" .. tostring(uncheckedRows))
    end
    whatLockoutPremadeUncheckGuard = false
end

local function WhatLockout_AutoUncheckLockedDungeonVisibleCheckboxes()
    if not WhatLockout_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 = WhatLockout_GetCheckButtonText(frameObj)
                if resolvedName and resolvedName ~= "" then
                    if WhatLockout_IsDungeonNameLocked(resolvedName) then
                        matched = matched + 1
                    end
                end
                if WhatLockout_TryUncheckLockedButton(frameObj, resolvedName) then
                    unchecked = unchecked + 1
                end
            end
        end
    end
    if WhatLockoutDebugPremade then
        print("|cffc868ffWhatLockout premade:|r scan visible checkbuttons seen=" .. tostring(seen)
            .. " matchedLocked=" .. tostring(matched) .. " unchecked=" .. tostring(unchecked))
    end
end

local function WhatLockout_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 WhatLockout_GetAnyFrameText(frame)
    if not frame then return nil end
    -- Try common direct accessors first.
    local t = WhatLockout_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 WhatLockout_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 WhatLockout_ForceUncheckVisibleLFGDungeonButtons()
    if not WhatLockout_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 = WhatLockout_GetAnyFrameText(frameObj)
            end

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

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

local function WhatLockout_TryAutoUncheckBlizzardMenuButtons()
    if not WhatLockout_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 = WhatLockout_GetAnyFrameText(frameObj)
            if txt and txt ~= "" then
                local lower = txt:lower()
                local isDungeonMenuHeader = (lower == "dungeons")
                local isLockedDungeonLabel = WhatLockout_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 = WhatLockout_IsButtonSelectedLike(frameObj)
                        if WhatLockoutDebugPremade then
                            print("|cffc868ffWhatLockout 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 WhatLockoutDebugPremade then
                                print("|cffc868ffWhatLockout premade (menu):|r toggled \"" .. tostring(txt) .. "\"")
                            end
                        end
                    end
                end
            end
        end
    end
    if WhatLockoutDebugPremade then
        print("|cffc868ffWhatLockout premade (menu):|r seen=" .. tostring(seen)
            .. " matchedLocked=" .. tostring(matched)
            .. " toggled=" .. tostring(toggled))
    end
end

local function WhatLockout_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 WhatLockoutDebugPremade then
            local lockedCount = 0
            for _ in pairs(lockedMythicDungeonsByName) do
                lockedCount = lockedCount + 1
            end
            print("|cffc868ffWhatLockout 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 WhatLockoutDebugPremade then
                    print("|cffc868ffWhatLockout premade:|r visible dropdown level=" .. tostring(level)
                        .. " name=" .. tostring(listFrame:GetName()))
                end
                WhatLockout_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.WhatLockoutPremadeHoverHooked and button.HookScript then
                        button.WhatLockoutPremadeHoverHooked = 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
        WhatLockout_AutoUncheckLockedDungeonVisibleCheckboxes()
        WhatLockout_TryAutoUncheckBlizzardMenuButtons()
        WhatLockout_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 WhatLockoutDebugPremade then
            print("|cffc868ffWhatLockout premade:|r ToggleDropDownMenu fired")
        end
        runOnVisibleDropdownsDelayed()
    end)
    hooksecurefunc("UIDropDownMenu_Refresh", function()
        if WhatLockoutDebugPremade then
            print("|cffc868ffWhatLockout 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 WhatLockoutDebugPremade then
                print("|cffc868ffWhatLockout 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 WhatLockoutDebugPremade then
            print("|cffc868ffWhatLockout premade:|r FilterButton hook installed")
        end
    elseif WhatLockoutDebugPremade then
        print("|cffc868ffWhatLockout 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 WhatLockout_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 WhatLockout_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, WhatLockout_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 = WhatLockout_NormalizeName(name)
        local isLocked = norm and lockedMythicDungeonsByName[norm]
        if not isLocked then return end

        local msg = "|cffc868ffWhatLockout:|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 WhatLockout_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 WhatLockoutDebugPremade then
            print("|cffc868ffWhatLockout premade:|r ListGroupButton NOT ready yet, retrying hook...")
        end
        if C_Timer and type(C_Timer.After) == "function" then
            C_Timer.After(3, WhatLockout_HookPremadeCreateWarning)
        end
        return
    end

    whatLockoutPremadeCreateHooked = true

    if WhatLockoutDebugPremade then
        print("|cffc868ffWhatLockout premade:|r ListGroupButton hook installed")
    end

    listButton:HookScript("OnClick", function()
        if WhatLockoutDebugPremade then
            print("|cffc868ffWhatLockout 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 WhatLockoutDebugPremade then
            print("|cffc868ffWhatLockout premade:|r entryFrame.selectedActivity =", tostring(activityID))
            print("|cffc868ffWhatLockout 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 WhatLockoutDebugPremade then
                print("|cffc868ffWhatLockout 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 WhatLockoutDebugPremade then
                print("|cffc868ffWhatLockout 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 = WhatLockout_IsDungeonNameLocked(activityName)
        local lockedFromGroup = WhatLockout_IsDungeonNameLocked(groupName)
        local lockedFromTitle = WhatLockout_IsDungeonNameLocked(listingTitle)
        local lockedFromInstance = WhatLockout_IsDungeonNameLocked(instanceName)
        local matchedFromTitle = WhatLockout_FindLockedDungeonInText(listingTitle)
        local isLocked = lockedFromActivity or lockedFromGroup or lockedFromTitle or lockedFromInstance or (matchedFromTitle ~= nil)

        if WhatLockoutDebugPremade then
            local actNorm = WhatLockout_NormalizeName(activityName)
            local groupNorm = WhatLockout_NormalizeName(groupName)
            local titleNorm = WhatLockout_NormalizeName(listingTitle)
            local instanceNorm = WhatLockout_NormalizeName(instanceName)
            print("|cffc868ffWhatLockout premade:|r activity name =", tostring(activityName))
            print("|cffc868ffWhatLockout premade:|r group name =", tostring(groupName))
            print("|cffc868ffWhatLockout premade:|r listing title =", tostring(listingTitle))
            print("|cffc868ffWhatLockout premade:|r instance name =", tostring(instanceName))
            print("|cffc868ffWhatLockout premade:|r activityNorm =", tostring(actNorm), " activityLocked =", tostring(lockedFromActivity))
            print("|cffc868ffWhatLockout premade:|r groupNorm =", tostring(groupNorm), " groupLocked =", tostring(lockedFromGroup))
            print("|cffc868ffWhatLockout premade:|r titleNorm =", tostring(titleNorm), " titleLocked =", tostring(lockedFromTitle), " titleMatchLockedDungeon =", tostring(matchedFromTitle))
            print("|cffc868ffWhatLockout 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 = "|cffc868ffWhatLockout:|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(22)

    if index == 1 then
        row:SetPoint("TOPLEFT", content, "TOPLEFT", 8, -4)
        row:SetPoint("TOPRIGHT", content, "TOPRIGHT", -8, -4)
    else
        row:SetPoint("TOPLEFT", rowPool[index - 1], "BOTTOMLEFT", 0, -2)
        row:SetPoint("TOPRIGHT", rowPool[index - 1], "BOTTOMRIGHT", 0, -2)
    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", "GameFontNormal")
    -- Smaller left margin so rows are less indented
    row.name:SetPoint("LEFT", 16, 0)
    row.name:SetWidth(260)
    row.name:SetJustifyH("LEFT")
    -- 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(90)
    row.reset:SetJustifyH("LEFT")

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

    -- 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
            if type(self.data.mapsAtMax) == "table" and #self.data.mapsAtMax > 0 then
                local max = 8
                local list = self.data.mapsAtMax
                local suffix = (#list > max) and ", ..." or ""
                GameTooltip:AddLine("Maps: " .. table.concat(list, ", ", 1, max) .. suffix, 0.8, 0.8, 1)
                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)
------------------------------------------------------------
local MYTHIC_DUNGEON_ID = 23  -- Base Mythic 5-man

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

local function WhatLockout_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 = WhatLockout_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 WhatLockout_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 WhatLockout_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 = WhatLockout_NormalizeName(useName)
        local loose = WhatLockout_NormalizeInstanceKeyLoose and WhatLockout_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, WhatLockout_NormalizeName, WhatLockout_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

    -- Last resort: challenge mode map table (timed only). Still filtered by blacklist and, when
    -- available, LFG M+ names to keep out stray scenarios. Falls back only when nothing else found.
    if #out == 0 and 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 WhatLockout_GetMythicDungeonDisplayList()
    local entries = WhatLockout_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 WhatLockout_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*%([^)]+%)", "")
        local lower = raw:lower()
        if lower:find("battleground", 1, true) ~= nil or
           lower:find("arena", 1, true) ~= nil or
           lower:find("pvp", 1, true) ~= nil or
           lower:find("brawl", 1, true) ~= nil or
           lower:find("skirmish", 1, true) ~= nil or
           lower:match("^battle for%s+") ~= nil then
            return
        end
        local norm = WhatLockout_NormalizeName(name)
        if not norm or norm == "" then return end
        if Util and Util.InstanceNameHitsStringBlacklist and whatLockoutRaidBlacklistNormSet then
            if Util.InstanceNameHitsStringBlacklist(name, whatLockoutRaidBlacklistNormSet, WhatLockout_NormalizeName, WhatLockout_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 = WhatLockout_NormalizeName(name)
                            local loose = WhatLockout_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 = WhatLockout_NormalizeName(name)
        if norm and ejNormSet[norm] then return true end
        local loose = WhatLockout_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 WhatLockout_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

WhatLockout_GetCurrentRaidNameSet = function()
    local set = {}
    for _, row in ipairs(data.RAIDS or {}) do
        if row and row.name then
            local norm = WhatLockout_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 = WhatLockout_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 = WhatLockout_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 = WhatLockout_NormalizeName(dungeonName)
                local expired = (reset == 0)
                if norm and norm ~= "" then
                    local mapID = nil
                    for _, se in ipairs(seasonEntries) do
                        if WhatLockout_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 WhatLockout_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"),
                        reset           = reset,
                        difficulty      = difficultyName or "Mythic",
                        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 = WhatLockout_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,
                reset           = saved.reset or 0,
                difficulty      = saved.difficulty or "Mythic",
                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",
                reset      = 0,
                difficulty = "Mythic",
                locked     = false,
            })
        end
        end
    end

    -- Build raid overview: include current raids even when not locked.
    local raidDisplayList = WhatLockout_GetCurrentRaidDisplayList()
    local function savedRaidMatchesDisplay(savedEntry, displayName)
        local norm = WhatLockout_NormalizeName(displayName)
        if not norm or norm == "" then return false end
        if savedEntry.normRaid == norm then return true end
        local looseDisplay = WhatLockout_NormalizeInstanceKeyLoose(displayName)
        local looseSaved = WhatLockout_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",
                        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",
                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.
    WhatLockout_SaveSelfSnapshot()
end

------------------------------------------------------------
-- Mythic+ stats tab (highest key achieved + distribution)
------------------------------------------------------------
local function WhatLockout_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

WhatLockout_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 = WhatLockout_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

    local bestLevelByMap = {}
    local maxLevel = 0

    for _, mapID in ipairs(mapIDs) do
        local ok, _durationSec, level = pcall(C_MythicPlus.GetSeasonBestForMap, mapID)
        if ok and type(level) == "number" and level > 0 then
            bestLevelByMap[mapID] = level
            if level > maxLevel then
                maxLevel = level
            end
        end
    end

    if maxLevel == 0 then
        data.MPLUS[1] = { mode = "MPLUS", order = 1, name = "Highest Key", progress = "None", reset = "No season completions found", difficulty = "Current season" }
        return
    end

    local distByLevel = {}
    local mapsAtMax = {}

    for mapID, level in pairs(bestLevelByMap) do
        distByLevel[level] = (distByLevel[level] or 0) + 1
        if level == maxLevel then
            local name = C_ChallengeMode.GetMapUIInfo(mapID) or tostring(mapID)
            mapsAtMax[#mapsAtMax + 1] = name
        end
    end

    table.sort(mapsAtMax)

    local countAtMax = distByLevel[maxLevel] or #mapsAtMax

    -- Row 1: Highest key
    data.MPLUS[#data.MPLUS + 1] = {
        mode = "MPLUS",
        order = 1,
        name = "Highest Key",
        progress = "+" .. tostring(maxLevel),
        reset = tostring(countAtMax) .. " dungeons at this key",
        difficulty = "Current season",
        isHighest = true,
        mapsAtMax = mapsAtMax,
    }

    -- Row 2..: Top levels distribution (based on "best per dungeon" because
    -- Blizzard's public APIs don't give per-run counts for all levels).
    local levels = {}
    for lvl, _count in pairs(distByLevel) do
        levels[#levels + 1] = lvl
    end
    table.sort(levels, function(a, b) return a > b end)

    local maxRows = 4
    local shown = 0
    for _, lvl in ipairs(levels) do
        if lvl ~= maxLevel then
            shown = shown + 1
            local c = distByLevel[lvl] or 0
            data.MPLUS[#data.MPLUS + 1] = {
                mode = "MPLUS",
                order = 10 + shown,
                name = "Key level",
                progress = "+" .. tostring(lvl),
                reset = tostring(c) .. " dungeons",
                difficulty = "Best per dungeon",
            }
            if shown >= maxRows then break end
        end
    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

    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

    if activeTab == "MPLUS" then
        -- Keep deterministic order for stats rows.
        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)
    else
        -- Sort so that locked entries are grouped (locked 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

    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

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

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

                row.progress:SetText(p)
                if entry.isHighest then
                    row.progress:SetTextColor(0.4, 1, 0.4)
                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)
                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 ""
                if raidGuiT > 0 then
                    progress = tostring(raidGuiD) .. "/" .. tostring(raidGuiT)
                end
                row.progress:SetText(progress)
                if not entry.locked or progress == "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 resetText == "Ready" 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 ""
                row.progress:SetText(progress)
                if progress == "Available" then
                    row.progress:SetTextColor(0.4, 1, 0.4)
                elseif progress == "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 resetText == "Ready" 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

            -- Raid (en Players→raid): groenachtige tint bij lockout met nog bosses over.
            if raidGuiRow and raidGuiInProg then
                row.bg:SetVertexColor(0.06, 0.14, 0.10, 0.92)
            elseif entry.locked then
                row.bg:SetVertexColor(0.15, 0.05, 0.10, 0.95)
            else
                row.bg:SetVertexColor(0.06, 0.08, 0.16, 0.9)
            end

            -- Zachte zebra-streep bovenop de status-kleur
            if shown % 2 == 0 then
                row.bg:SetAlpha(0.95)
            else
                row.bg:SetAlpha(0.8)
            end

            totalHeight = totalHeight + row:GetHeight() + (shown > 1 and 2 or 4)
        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 WhatLockout_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

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

    local selfKey = WhatLockout_GetLocalPlayerFullName and WhatLockout_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 = WhatLockout_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 = WhatLockout_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 WhatLockout_Util.lua (avoids WoW Lua 60-upvalue limit).
local function WhatLockout_PartyMemberBestCacheKey(players, partyKey)
    if Util and Util.PartyMemberBestCacheKey then
        return Util.PartyMemberBestCacheKey(players, partyKey)
    end
    return nil
end

local function WhatLockout_BuildSharedPartyData()
    if Util and Util.BuildSharedPartyData then
        Util.BuildSharedPartyData({
            data = data,
            db = WhatLockoutDB,
            normalizeName = WhatLockout_NormalizeName,
            normalizeLoose = WhatLockout_NormalizeInstanceKeyLoose,
            dungeonBlacklist = whatLockoutDungeonBlacklistNormSet,
            raidBlacklist = whatLockoutRaidBlacklistNormSet,
            localizeDungeon = WhatLockout_LocalizeDungeonNameForMapID,
            localizeRaid = WhatLockout_LocalizeRaidNameForKey,
            getPartyKeys = WhatLockout_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 WhatLockout_BuildGroupKeystoneSummaryText()
    local players = WhatLockoutDB and WhatLockoutDB.shared and WhatLockoutDB.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 = WhatLockout_GetLocalPlayerFullName and WhatLockout_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 = WhatLockout_GetOwnedKeystoneSnapshot and WhatLockout_GetOwnedKeystoneSnapshot() or nil
        if not own then
            own = findOwnSharedKeystone()
        end
        if not own and WhatLockoutDB and WhatLockoutDB.settings then
            own = keystoneFromRow({ keystone = WhatLockoutDB.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 WhatLockoutDB and WhatLockoutDB.settings then
            local ownMapID = own and tonumber(own.mapID or 0) or 0
            WhatLockoutDB.settings.lastOwnedKeystone = {
                level = ownLevel,
                mapID = (ownMapID > 0) and ownMapID or nil,
                mapName = ownMap,
            }
        end

        if ownLevel > 0 then
            if ownMap == "" then ownMap = "Unknown" end
            ownText = "Keystone: " .. tostring(ownMap) .. " (" .. tostring(ownLevel) .. ")"
        else
            ownText = "Keystone: none"
        end
    end

    if type(players) ~= "table" then
        return ownText
    end

    local partyKeys = WhatLockout_GetCurrentPartyMemberKeys()
    if type(partyKeys) ~= "table" or #partyKeys == 0 then
        return ownText
    end

    local rows, seen = {}, {}
    for _, partyKey in ipairs(partyKeys) do
        local cacheKey = WhatLockout_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
            rows[#rows + 1] = {
                who = who,
                level = lvl,
                mapName = (mapName ~= "" and mapName) or "Unknown",
            }
        end
    end

    if #rows == 0 then
        return ownText
    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)

    local maxShown = 4
    local parts = {}
    for i, r in ipairs(rows) do
        if i > maxShown then break end
        parts[#parts + 1] = tostring(r.who) .. " +" .. tostring(r.level) .. " " .. tostring(r.mapName)
    end
    if #rows > maxShown then
        parts[#parts + 1] = "+" .. tostring(#rows - maxShown) .. " more"
    end

    return ownText .. "  |  Group keys: " .. table.concat(parts, " | ")
end

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

function WhatLockout_BuildGroupKeystoneTabData()
    data.KEYSTONE = {}

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

    local partyKeys = WhatLockout_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 = WhatLockout_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 = WhatLockout_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

local function SetActiveTab(id)
    activeTab = id
    if WhatLockoutDB and WhatLockoutDB.settings then
        WhatLockoutDB.settings.activeTab = id
    end
    for tabId, btn in pairs(tabs) do
        SetTabVisual(btn, tabId == id)
    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 WhatLockout_UpdateHeaderFilterText then
        WhatLockout_UpdateHeaderFilterText()
    end

    if id ~= "PLAYERS" then
        if id == "MPLUS" then
            WhatLockout_BuildMythicPlusStats()
            WhatLockout_SetPlayersInfoText()
        elseif id == "KEYSTONE" then
            ScanLockouts()
            WhatLockout_BuildGroupKeystoneTabData()
            WhatLockout_SetPlayersInfoText()
        else
            ScanLockouts()
            if id == "SHARED" then
                WhatLockout_BuildSharedPartyData()
                WhatLockout_SetPlayersInfoText()
            else
                WhatLockout_SetPlayersInfoText()
            end
        end
    else
        -- Rebuild view from cached shared data
        data.PLAYERS = {}
        local players = WhatLockoutDB.shared and WhatLockoutDB.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 = WhatLockout_GetLocalPlayerFullName and WhatLockout_GetLocalPlayerFullName() or nil
                local meIsOwn = (me and type(WhatLockoutDB.ownCharacters) == "table" and WhatLockoutDB.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
                    WhatLockoutDB.settings = WhatLockoutDB.settings or {}
                    WhatLockoutDB.settings.selectedRemotePlayer = me
                end
            end

            local partyKeys = WhatLockout_GetCurrentPartyMemberKeys()
            if partyKeys and #partyKeys > 0 then
                -- Choose current selection only if it has cached data.
                local isOwnSelection = false
                if selectedRemotePlayer and type(WhatLockoutDB.ownCharacters) == "table" then
                    isOwnSelection = WhatLockoutDB.ownCharacters[selectedRemotePlayer] and true or false
                end
                if not isOwnSelection and selectedRemotePlayer and type(players) == "table" then
                    local me = WhatLockout_GetLocalPlayerFullName and WhatLockout_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 WhatLockout_PartyMemberBestCacheKey(players, selectedRemotePlayer))
                if not hasSelection then
                    selectedRemotePlayer = nil
                    WhatLockoutDB.settings = WhatLockoutDB.settings or {}
                    WhatLockoutDB.settings.selectedRemotePlayer = nil
                    for _, k in ipairs(partyKeys) do
                        local best = WhatLockout_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
                            WhatLockoutDB.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 WhatLockout_PartyMemberBestCacheKey(players, selectedRemotePlayer)
            end
            if not selectedRemotePlayer or not fallbackCacheKey then
                local own = {}
                if type(WhatLockoutDB.ownCharacters) == "table" then
                    for k, _ in pairs(WhatLockoutDB.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 = WhatLockout_GetLocalPlayerFullName and WhatLockout_GetLocalPlayerFullName() or nil
                    local pick = me and (players[me] and me) or own[1]
                    selectedRemotePlayer = pick
                    WhatLockoutDB.settings = WhatLockoutDB.settings or {}
                    WhatLockoutDB.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 = WhatLockout_PartyMemberBestCacheKey(players, resolvedSelKey)
            end
            local p = resolvedSelKey and players[resolvedSelKey]
            if p then
                local function addSection(sectionName, list)
                    local sectionUpdatedAt = WhatLockout_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 = WhatLockout_GetRaidBossDisplayCounts(e)
                        local effectiveLocked = WhatLockout_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
                        WhatLockout_SetPlayersInfoText("Last sync: " .. date("%H:%M", p.updatedAt))
                    else
                        WhatLockout_SetPlayersInfoText("Last sync: unknown")
                    end
                end
            else
                if playersInfo then
                    WhatLockout_SetPlayersInfoText("This person doesn't have WhatLockout (no synced data).")
                end
                data.PLAYERS[1] = { name = "(not synced)", progress = "", reset = 0, difficulty = "", locked = false }
            end
        else
            WhatLockout_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

    RenderList()
end

if shareDropdown then
    UIDropDownMenu_SetWidth(shareDropdown, 90)
    local shownShareText = "Party"
    for _, ch in ipairs(shareChannels) do
        if ch.key == WhatLockout.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()
                WhatLockout.State.selectedShareChannel = ch.key
                WhatLockoutDB.settings = WhatLockoutDB.settings or {}
                WhatLockoutDB.settings.shareChannel = ch.key
                UIDropDownMenu_SetText(shareDropdown, ch.text)
                if ch.key ~= "WHISPER" then
                    WhatLockout.State.selectedWhisperTarget = nil
                    WhatLockoutDB.settings.whisperTarget = nil
                end
            end
            info.checked = (WhatLockout.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()

        WhatLockout_RequestSync()
        local channel = WhatLockout_GetBestCommChannel()
        if channel then
            print("|cffc868ffWhatLockout:|r Sync request sent to " .. channel .. ".")
        else
            print("|cffc868ffWhatLockout:|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 = WhatLockoutDB.shared and WhatLockoutDB.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 = WhatLockout_GetLocalPlayerFullName and WhatLockout_GetLocalPlayerFullName() or nil
            local meIsOwn = (me and type(WhatLockoutDB.ownCharacters) == "table" and WhatLockoutDB.ownCharacters[me]) and true or false

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

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

        if type(players) == "table" then
            local partyKeys = WhatLockout_GetCurrentPartyMemberKeys and WhatLockout_GetCurrentPartyMemberKeys() or {}
            local seen = {}
            for _, k in ipairs(partyKeys) do
                local best = WhatLockout_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
                    WhatLockoutDB.settings = WhatLockoutDB.settings or {}
                    WhatLockoutDB.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
                    WhatLockoutDB.settings = WhatLockoutDB.settings or {}
                    WhatLockoutDB.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 = WhatLockout_NormalizeWhisperTarget(WhatLockout.State.selectedWhisperTarget)
        if WhatLockout.State.selectedShareChannel == "WHISPER" and not target then
            StaticPopupDialogs["WHATLOCKOUT_WHISPER_TARGET"] = StaticPopupDialogs["WHATLOCKOUT_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 = WhatLockout_NormalizeWhisperTarget(text)
                    if norm then
                        WhatLockout.State.selectedWhisperTarget = norm
                        WhatLockoutDB.settings = WhatLockoutDB.settings or {}
                        WhatLockoutDB.settings.whisperTarget = norm
                        WhatLockout_PostSummary()
                    end
                end,
            }
            StaticPopup_Show("WHATLOCKOUT_WHISPER_TARGET")
        else
            WhatLockout_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
            WhatLockoutDB.settings = WhatLockoutDB.settings or {}
            WhatLockoutDB.settings.hideLockedByTab = WhatLockoutDB.settings.hideLockedByTab or {}
            WhatLockoutDB.settings.hideLockedByTab[activeTab] = checked
        end
        if WhatLockout_UpdateHeaderFilterText then
            WhatLockout_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)

------------------------------------------------------------
-- Events & slash command
------------------------------------------------------------
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 WhatLockout_RegisterSlashCommands

-- Season pool data can arrive a moment after login; retry a few times.
local whatLockoutSeasonRetryAttempts = 0
local whatLockoutSeasonRetryScheduled = false
local function WhatLockout_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
            WhatLockout_ScheduleSeasonPoolRetry()
        else
            whatLockoutNeedsSeasonPoolRetry = false
        end
    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(WhatLockout_TryInitAceDB)
        WhatLockout_TryApplyElvUISkin()
        WhatLockout_RegisterSlashCommands()
        if C_ChatInfo and type(C_ChatInfo.RegisterAddonMessagePrefix) == "function" then
            pcall(C_ChatInfo.RegisterAddonMessagePrefix, COMM_PREFIX)
        end
        WhatLockout_HookPremadeResultFilter()
        WhatLockout_HookPremadeSignupWarning()
        WhatLockout_HookPremadeCreateWarning()
        WhatLockout_HookPremadeDungeonFilterDropdown()
        WhatLockout_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 = (WhatLockoutDB.settings and WhatLockoutDB.settings.activeTab) or "DUNGEONS"
        SetActiveTab(initialTab)
        WhatLockout_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(WhatLockout_TryCreateOptionsPanel)
        if whatLockoutOptionsCreateTries < 6 and C_Timer and type(C_Timer.After) == "function" then
            C_Timer.After(2, function()
                pcall(WhatLockout_TryCreateOptionsPanel)
            end)
        end

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

        -- Initial auto-sync shortly after login.
        WhatLockout_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 WhatLockoutDB._keepSyncTickerStarted then
                WhatLockoutDB._keepSyncTickerStarted = true
                C_Timer.NewTicker(KEEP_SYNC_INTERVAL, function()
                    WhatLockout_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()
                WhatLockout_CheckLockedMythicDungeonEntry()
            end)
        else
            WhatLockout_CheckLockedMythicDungeonEntry()
        end

    elseif event == "PLAYER_LOGIN" then
        -- Final retry after login; by now OptionsUI is usually ready.
        pcall(WhatLockout_TryInitAceDB)
        pcall(WhatLockout_TryCreateOptionsPanel)
        if C_Timer and type(C_Timer.After) == "function" then
            C_Timer.After(1, function() pcall(WhatLockout_TryCreateOptionsPanel) end)
            C_Timer.After(3, function() pcall(WhatLockout_TryCreateOptionsPanel) end)
        end
    elseif event == "UPDATE_INSTANCE_INFO" then
        ScanLockouts()
        RenderList()
        WhatLockout_ScheduleSeasonPoolRetry()
        WhatLockout_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
            WhatLockout_BuildMythicPlusStats()
            if frame and frame:IsShown() then
                RenderList()
            end
        else
            ScanLockouts()
            if frame and frame:IsShown() then
                RenderList()
            end
            WhatLockout_ScheduleSeasonPoolRetry()
            WhatLockout_CheckLockedMythicDungeonEntry()
        end
    elseif event == "CHALLENGE_MODE_COMPLETED" then
        -- After a Mythic+ completion, refresh season-best stats.
        if activeTab == "MPLUS" then
            WhatLockout_BuildMythicPlusStats()
            if frame and frame:IsShown() then
                RenderList()
            end
        end
    elseif event == "ADDON_LOADED" then
        local addonName = ...
        if addonName == "ElvUI" then
            WhatLockout_TryApplyElvUISkin()
        end

        -- Ensure Options UI is available before adding our category.
        if addonName == "Blizzard_InterfaceOptions" or addonName == "Blizzard_OptionsUI" then
            pcall(WhatLockout_TryCreateOptionsPanel)
            if C_Timer and type(C_Timer.After) == "function" then
                C_Timer.After(1, function()
                    pcall(WhatLockout_TryCreateOptionsPanel)
                end)
            end
        elseif addonName == ADDON_NAME then
            -- Ensure our own addon load also triggers options panel creation.
            pcall(WhatLockout_TryCreateOptionsPanel)
        end
    elseif event == "CHAT_MSG_WHISPER" then
        local text, sender = ...
        WhatLockout_TryWhisperAutoReply(text, sender)
    elseif event == "CHAT_MSG_ADDON" then
        local prefix, msg, channel, sender = ...
        WhatLockout_OnCommMessage(prefix, msg, channel, sender)
        -- If the tooltip is currently open, refresh it so new shared data
        -- (from synced party members) shows immediately.
        WhatLockout_RefreshTooltipIfShown()
        if frame and frame:IsShown() then
            if activeTab == "PLAYERS" then
                SetActiveTab("PLAYERS")
            elseif activeTab == "SHARED" then
                WhatLockout_BuildSharedPartyData()
                WhatLockout_SetPlayersInfoText()
                RenderList()
            elseif activeTab == "KEYSTONE" then
                WhatLockout_BuildGroupKeystoneTabData()
                WhatLockout_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(WhatLockoutDB.ownCharacters) == "table" then
            isOwnSelection = WhatLockoutDB.ownCharacters[selectedRemotePlayer] and true or false
        end
        if not isOwnSelection and selectedRemotePlayer and WhatLockoutDB.shared and WhatLockoutDB.shared.players then
            local me = WhatLockout_GetLocalPlayerFullName and WhatLockout_GetLocalPlayerFullName() or nil
            local players = WhatLockoutDB.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 WhatLockoutDB.shared and WhatLockoutDB.shared.players then
                local players = WhatLockoutDB.shared.players
                local selCacheKey = WhatLockout_PartyMemberBestCacheKey(players, selectedRemotePlayer) or selectedRemotePlayer
                local partyKeys = WhatLockout_GetCurrentPartyMemberKeys and WhatLockout_GetCurrentPartyMemberKeys() or {}
                if type(partyKeys) == "table" then
                    for _, k in ipairs(partyKeys) do
                        if k == selectedRemotePlayer then
                            stillInParty = true
                            break
                        end
                        local kCacheKey = WhatLockout_PartyMemberBestCacheKey(players, k) or k
                        if kCacheKey == selCacheKey then
                            stillInParty = true
                            break
                        end
                    end
                end
            end

            if not stillInParty then
                selectedRemotePlayer = nil
                WhatLockoutDB.settings = WhatLockoutDB.settings or {}
                WhatLockoutDB.settings.selectedRemotePlayer = nil
            end
        end
        WhatLockout_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
                WhatLockout_RequestSyncToAllPartyMembers()
                WhatLockout_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.
        if C_Timer and type(C_Timer.After) == "function" then
            C_Timer.After(2, function()
                WhatLockout_CheckLockedMythicDungeonEntry()
            end)
        else
            WhatLockout_CheckLockedMythicDungeonEntry()
        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.
        if IsInGroup and IsInGroup() then
            if C_Timer and type(C_Timer.After) == "function" then
                C_Timer.After(4, function()
                    if RequestRaidInfo then RequestRaidInfo() end
                    WhatLockout_QueueAutoSync(2)
                    -- If the tooltip is currently open, refresh it once after data updates.
                    WhatLockout_RefreshTooltipIfShown()
                end)
            else
                if RequestRaidInfo then RequestRaidInfo() end
                WhatLockout_QueueAutoSync(2)
                WhatLockout_RefreshTooltipIfShown()
            end
        end
    end
end)

-- Slash commands (register after UI is ready)
local slashRegistered = false
function WhatLockout_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("|cffc868ffWhatLockout:|r " .. table.concat(parts, " | "))
end

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

    slashRegistered = true
    SLASH_WHATLOCKOUT1 = "/lockouts"
    SLASH_WHATLOCKOUT2 = "/whatlockout"

    SlashCmdList["WHATLOCKOUT"] = 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
            local ok, err = pcall(WhatLockout_TryCreateOptionsPanel)
            local hasPanel = whatLockoutOptionsPanel ~= nil
            if not hasPanel then
                -- silently fail; we tried to create but cannot find the needed WoW settings API
            end
            -- 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(WhatLockout_OpenOptionsPanel)
            end
            return
        end

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

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

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

        if lower == "clearlogs" or lower == "clear logs" or lower == "clearluaerrors" then
            WhatLockoutNS.State._luaErrorLogs = {}
            if WhatLockoutNS.State._logsEnabled then
                WhatLockoutNS.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 = WhatLockout_GetLocalPlayerFullName and WhatLockout_GetLocalPlayerFullName()
        if not fullName or fullName == "" then
            print("|cffc868ffWhatLockout:|r Couldn't determine your character name for selftest.")
            return
        end

        local raidsPayload = WhatLockout_BuildExport("RAIDS")
        local dungeonsPayload = WhatLockout_BuildExport("DUNGEONS")
        local keystonePayload = WhatLockout_BuildExport("KEYSTONE")
        WhatLockout_HandleAssembledData(fullName, "RAIDS", raidsPayload)
        WhatLockout_HandleAssembledData(fullName, "DUNGEONS", dungeonsPayload)
        WhatLockout_HandleAssembledData(fullName, "KEYSTONE", keystonePayload)

        selectedRemotePlayer = fullName
        WhatLockoutDB.settings = WhatLockoutDB.settings or {}
        WhatLockoutDB.settings.selectedRemotePlayer = fullName
        if playersDropdown then
            UIDropDownMenu_SetText(playersDropdown, fullName)
        end
        SetActiveTab("PLAYERS")
        frame:Show()
        if frame.Raise then frame:Raise() end
        print("|cffc868ffWhatLockout:|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

        WhatLockoutDB.shared = WhatLockoutDB.shared or { players = {} }
        WhatLockoutDB.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
        WhatLockoutDB.settings = WhatLockoutDB.settings or {}
        WhatLockoutDB.settings.selectedRemotePlayer = who
        if playersDropdown then
            UIDropDownMenu_SetText(playersDropdown, who)
        end
        SetActiveTab("PLAYERS")
        frame:Show()
        if frame.Raise then frame:Raise() end
        print("|cffc868ffWhatLockout:|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("|cffc868ffWhatLockout 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 = WhatLockout_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
        WhatLockoutDebugComm = not WhatLockoutDebugComm
        print("|cffc868ffWhatLockout:|r debugcomm=" .. tostring(WhatLockoutDebugComm))
        return
    end

    if lower:find("debugtooltip") then
        WhatLockoutDebugTooltip = not WhatLockoutDebugTooltip
        print("|cffc868ffWhatLockout:|r debugtooltip=" .. tostring(WhatLockoutDebugTooltip))
        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("|cffc868ffWhatLockout:|r keep sync=" .. tostring(KEEP_SYNC_ENABLED))
        return
    end

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

    if lower:find("debug") then
        print("|cffc868ffWhatLockout:|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("|cffc868ffWhatLockout 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 = WhatLockout_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.
            local initialTab = (WhatLockoutDB.settings and WhatLockoutDB.settings.activeTab) or "DUNGEONS"
            SetActiveTab(initialTab)
            frame:Show()
            if frame.Raise then frame:Raise() end
            WhatLockout_ScheduleSeasonPoolRetry()
        end
    end
end

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

-- Start hidden
frame:Hide()

-- Minimap button via LibDBIcon (same behavior as RaiderIO)
------------------------------------------------------------
local function WhatLockout_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\\WhatLockout\\media\\minimap"

    local dataObject = LDB:NewDataObject("WhatLockout", {
        type = "launcher",
        text = "WhatLockout",
        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("|cffc868ffWhat Lockout|r")
            tt:AddLine("Left-click: show/hide window.", 1, 1, 1)
        end,
    })

    LDBI:Register("WhatLockout", dataObject, WhatLockoutDB.minimapIcon)
end

WhatLockout_SetupMinimap()

