local WhatLockoutNS = _G.WhatLockout
if not WhatLockoutNS then return end

local Util = {}
WhatLockoutNS.Util = Util

function Util.FormatTime(seconds)
    if not seconds or seconds <= 0 then
        return "Ready"
    end

    local d = math.floor(seconds / 86400)
    seconds = seconds % 86400
    local h = math.floor(seconds / 3600)
    seconds = seconds % 3600
    local m = math.floor(seconds / 60)

    local parts = {}
    if d > 0 then table.insert(parts, d .. "d") end
    if h > 0 then table.insert(parts, h .. "h") end
    if m > 0 then table.insert(parts, m .. "m") end

    return table.concat(parts, " ")
end

function Util.NowSeconds()
    if type(GetTime) == "function" then
        return GetTime()
    end
    if type(time) == "function" then
        return time()
    end
    return 0
end

function Util.GetBestCommChannel()
    if IsInRaid and IsInRaid() then return "RAID" end
    if IsInGroup and IsInGroup() then return "PARTY" end
    if IsInGuild and IsInGuild() then return "GUILD" end
    return nil
end

function Util.SafeFullPlayerName(sender)
    if not sender or sender == "" then return sender end
    if sender:find("-", 1, true) then return sender end
    local realm = GetRealmName and GetRealmName()
    if realm and realm ~= "" then
        realm = realm:gsub("%s+", "")
        return sender .. "-" .. realm
    end
    return sender
end

function Util.EncodeSection(section)
    section = tostring(section or ""):upper()
    if section ~= "RAIDS" and section ~= "DUNGEONS" and section ~= "MPLUS" and section ~= "KEYSTONE" then
        return nil
    end
    return section
end

function Util.GetLocalPlayerFullName()
    local fullName
    if UnitFullName then
        local n, r = UnitFullName("player")
        if n and n ~= "" then
            if r and r ~= "" then
                r = r:gsub("%s+", "")
                fullName = n .. "-" .. r
            else
                fullName = Util.SafeFullPlayerName(n)
            end
        end
    end
    if (not fullName or fullName == "") and UnitName then
        fullName = Util.SafeFullPlayerName(UnitName("player"))
    end
    return fullName
end

function Util.ShouldUseInstanceAbbreviations()
    local db = _G.WhatLockoutDB
    return (db and db.settings and db.settings.useInstanceAbbreviations) and true or false
end

function Util.BuildInstanceAbbreviation(name)
    if not name or name == "" then return name end
    local raw = tostring(name)
    raw = raw:gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", "")
    raw = raw:gsub("%s*%([^)]+%)", "")
    raw = raw:gsub("[%-_/%.,:;!%?%[%]%(%)]+", " ")

    local stopWords = {
        ["of"] = true, ["the"] = true, ["and"] = true, ["for"] = true,
        ["to"] = true, ["a"] = true, ["an"] = true, ["in"] = true,
    }

    local out = {}
    for word in raw:gmatch("%S+") do
        local lower = word:lower()
        if not stopWords[lower] then
            out[#out + 1] = word:sub(1, 1):upper()
        end
    end

    if #out == 0 then
        return raw
    end
    if #out == 1 then
        return raw:sub(1, 3):upper()
    end
    return table.concat(out, "")
end

function Util.GetDisplayInstanceName(name)
    if not Util.ShouldUseInstanceAbbreviations() then
        return name
    end
    return Util.BuildInstanceAbbreviation(name)
end

-- Strip UI color / reset tokens for stable display/substring logic elsewhere.
function Util.StripInstanceColorCodes(name)
    if name == nil then return "" end
    local s = tostring(name)
    s = s:gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", "")
    return s
end

-- Fold accents for string-blacklist matching (DE/FR/ES/…); not for display.
function Util.FoldLatinAscii(s)
    if s == nil then return "" end
    s = tostring(s):lower()
    local reps = {
        ["ß"] = "ss",
        ["ä"] = "a", ["à"] = "a", ["á"] = "a", ["â"] = "a", ["ã"] = "a", ["å"] = "a",
        ["é"] = "e", ["è"] = "e", ["ê"] = "e", ["ë"] = "e",
        ["ì"] = "i", ["í"] = "i", ["î"] = "i", ["ï"] = "i",
        ["ò"] = "o", ["ó"] = "o", ["ô"] = "o", ["õ"] = "o", ["ö"] = "o",
        ["ù"] = "u", ["ú"] = "u", ["û"] = "u", ["ü"] = "u",
        ["ý"] = "y", ["ÿ"] = "y",
        ["ñ"] = "n", ["ç"] = "c",
    }
    for from, to in pairs(reps) do
        s = s:gsub(from, to)
    end
    return s
end

-- Apostrophe / modifier letters (FR l'…) so folded keys line up with table entries.
function Util.FoldForBlacklistKey(s)
    s = Util.FoldLatinAscii(s)
    s = s:gsub("'", "")
    s = s:gsub("\226\128\153", "") -- Unicode ' (U+2019)
    s = s:gsub("\226\128\152", "") -- Unicode ‘ (U+2018)
    s = s:gsub("`", "")
    return s
end

-- True if normalized / loose name (and ASCII-folded forms) hit a blacklist set.
function Util.InstanceNameHitsStringBlacklist(name, bl, normalizeNameFn, normalizeLooseFn)
    if type(bl) ~= "table" or name == nil or name == "" then
        return false
    end
    local norm = type(normalizeNameFn) == "function" and normalizeNameFn(name) or nil
    local loose = type(normalizeLooseFn) == "function" and normalizeLooseFn(name) or nil
    local function hit(s)
        if not s or s == "" then return false end
        if bl[s] then return true end
        local f = Util.FoldForBlacklistKey(s)
        if f ~= "" and bl[f] then return true end
        return false
    end
    if hit(norm) then return true end
    if hit(loose) then return true end
    return false
end

-- Spurious saves / rows: Blizzard numeric ids only (no localized strings).
-- 6006: open-world zone save that leaks into raid UI (id may appear on returns 2/7/14+ depending on patch).
Util.LOCKOUT_HIDE_JOURNAL_INSTANCE_IDS = {
    [6006] = true,
}
Util.LOCKOUT_HIDE_CHALLENGE_MAP_IDS = {
    [6006] = true, -- if a zone save leaks in as a dungeon-style row with map/challenge id
}

-- True if id-like returns in GetSavedInstanceInfo match the hide set (not whole tuple: reset/progress could equal 6006 by chance).
-- #2 lockoutId, #7 instanceIDMostSig, #14+ instanceId / extra id returns (varies by patch).
local LOCKOUT_HIDE_GSI_INDEXES = { 2, 7, 14, 15, 16, 17 }

function Util.SavedInstanceGSIContainsHiddenId(gsi)
    if type(gsi) ~= "table" or not Util.LOCKOUT_HIDE_JOURNAL_INSTANCE_IDS then
        return false
    end
    for _, idx in ipairs(LOCKOUT_HIDE_GSI_INDEXES) do
        local v = gsi[idx]
        if type(v) == "number" and v > 0 and Util.LOCKOUT_HIDE_JOURNAL_INSTANCE_IDS[v] then
            return true
        end
    end
    return false
end

Util._lfgActivityHideCache = Util._lfgActivityHideCache or {}

local function utilTableTreeReferencesHiddenIds(t, depth, seen)
    if type(t) ~= "table" or depth > 5 then
        return false
    end
    if seen[t] then
        return false
    end
    seen[t] = true
    for _, v in pairs(t) do
        if type(v) == "number" and v > 0 then
            if Util.LOCKOUT_HIDE_JOURNAL_INSTANCE_IDS[v] or Util.LOCKOUT_HIDE_CHALLENGE_MAP_IDS[v] then
                return true
            end
        elseif type(v) == "string" then
            local n = tonumber(v)
            if n and n > 0 and (Util.LOCKOUT_HIDE_JOURNAL_INSTANCE_IDS[n] or Util.LOCKOUT_HIDE_CHALLENGE_MAP_IDS[n]) then
                return true
            end
        elseif type(v) == "table" then
            if utilTableTreeReferencesHiddenIds(v, depth + 1, seen) then
                return true
            end
        end
    end
    return false
end

-- raidKeyID is often an LFG activity id, not EJ/instance id — activity table still references the real map/instance (e.g. 6006).
function Util.LFGActivityInfoTableHasHiddenId(activityID)
    local aid = tonumber(activityID)
    if not aid or aid <= 0 then
        return false
    end
    local cached = Util._lfgActivityHideCache[aid]
    if cached ~= nil then
        return cached
    end
    if not _G.C_LFGList or type(_G.C_LFGList.GetActivityInfoTable) ~= "function" then
        Util._lfgActivityHideCache[aid] = false
        return false
    end
    local ok, info = pcall(_G.C_LFGList.GetActivityInfoTable, aid)
    if not ok or type(info) ~= "table" then
        Util._lfgActivityHideCache[aid] = false
        return false
    end
    local hid = utilTableTreeReferencesHiddenIds(info, 1, {})
    Util._lfgActivityHideCache[aid] = hid
    return hid
end

function Util.InstanceNameIsHiddenSeasonOrShared(rawName, ctx)
    if type(ctx) ~= "table" then
        return false
    end
    local jid = tonumber(ctx.journalInstanceID)
    if (not jid or jid <= 0) and ctx.raidKeyID then
        jid = tonumber(ctx.raidKeyID)
    end
    if (not jid or jid <= 0) and ctx.savedInstanceID then
        jid = tonumber(ctx.savedInstanceID)
    end
    if jid and jid > 0 and Util.LOCKOUT_HIDE_JOURNAL_INSTANCE_IDS[jid] then
        return true
    end
    local mid = tonumber(ctx.challengeMapID) or tonumber(ctx.mapID)
    if mid and mid > 0 and Util.LOCKOUT_HIDE_CHALLENGE_MAP_IDS[mid] then
        return true
    end
    local rk = tonumber(ctx.raidKeyID)
    if rk and rk > 0 and Util.LFGActivityInfoTableHasHiddenId(rk) then
        return true
    end
    return false
end

-- RaiderIO / dungeon finder tooltip append helper.
-- Keeping this in Util reduces the main WhatLockout.lua closure footprint.
function Util.AppendDungeonLockLineToTooltip(tt, dungeonKey, isLocked)
    if not tt or not dungeonKey then return end
    if not tt.AddLine then return end

    -- Only append once per tooltip+hover.
    local guardKey = tostring(dungeonKey) .. ":" .. (isLocked and "L" or "R")
    if tt.WhatLockoutDungeonLockFor == guardKey then return end
    tt.WhatLockoutDungeonLockFor = guardKey

    local db = _G.WhatLockoutDB
    if db and db.settings and db.settings.showPlayerHoverTooltip == false then
        return
    end

    if isLocked then
        tt:AddLine("|cffc868ffWhat Lockout|r: Locked (base Mythic)", 1, 0.3, 0.3)
    else
        tt:AddLine("|cffc868ffWhat Lockout|r: Ready (base Mythic)", 0.3, 1, 0.3)
    end

    -- Ensure our appended line is visible even if some other addon updates tooltip timing.
    if tt.Show and type(tt.Show) == "function" then
        tt:Show()
    end
end

-- RaiderIO dungeon tooltip contains a header like "Best Runs by Dungeon".
-- If the lock-state tooltip line is timing-sensitive (RaiderIO overwrites),
-- coloring the existing header red is still a clear, visible signal.
function Util.TryColorBestRunsByDungeonHeaderRed(dungeonKey)
    if not dungeonKey then return false end
    local guardKey = "bestRunsRed:" .. tostring(dungeonKey)

    local function scanFrameTree(root, guardFrame)
        if not root then return false end

        -- Scan regions attached to this node.
        if root.GetRegions then
            local regions = { root:GetRegions() }
            for i = 1, #regions do
                local r = regions[i]
                if r and r.GetObjectType and r:GetObjectType() == "FontString" and r.GetText then
                    local txt = r:GetText()
                    if txt and txt ~= "" then
                        local tl = string.lower(txt)
                        if (tl:find("best runs") and tl:find("dungeon")) or tl:find("best") then
                            r:SetTextColor(1, 0.1, 0.1)
                            if guardFrame then
                                guardFrame.WhatLockoutBestRunsByDungeonColoredFor = guardKey
                            end
                            return true
                        end
                    end
                end
            end
        end

        -- Recurse into children frames (FontStrings are often children under RaiderIO tooltip frames).
        if root.GetChildren then
            local children = { root:GetChildren() }
            for i = 1, #children do
                if scanFrameTree(children[i], guardFrame) then
                    return true
                end
            end
        end

        return false
    end

    local function colorInTooltip(tt)
        if not tt or tt.WhatLockoutBestRunsByDungeonColoredFor == guardKey then
            return false
        end
        if tt.IsShown and not tt:IsShown() then return false end
        return scanFrameTree(tt, tt)
    end

    -- First try the common tooltip frame.
    if GameTooltip then
        if colorInTooltip(GameTooltip) then return true end
    end

    -- Fallback: scan currently shown tooltip-like frames.
    if EnumerateFrames then
        for frame in EnumerateFrames() do
            if frame and frame.IsShown and frame:IsShown() then
                local nm = frame.GetName and frame:GetName() or ""
                nm = (nm and nm:lower()) or ""
                -- Focus on RaiderIO tooltip-like roots to avoid scanning the entire UI tree.
                if nm:find("raider") and (nm:find("tooltip") or nm:find("profile")) then
                    if colorInTooltip(frame) then return true end
                end
            end
        end
    end

    return false
end

-- RaiderIO tooltip usually contains a "M+ Score" label (commonly not localized).
-- Coloring that label is a robust "locked" visual even if other headers are localized.
function Util.TryColorMplusScoreHeaderRed(dungeonKey)
    if not dungeonKey then return false end
    local guardKey = "mplusScoreRed:" .. tostring(dungeonKey)

    local function scanFrameTree(root, guardFrame)
        if not root then return false end

        if root.GetRegions then
            local regions = { root:GetRegions() }
            for i = 1, #regions do
                local r = regions[i]
                if r and r.GetObjectType and r:GetObjectType() == "FontString" and r.GetText then
                    local txt = r:GetText()
                    if txt and txt ~= "" then
                        local tl = string.lower(txt):gsub("%s+", "")
                        if tl:find("m+") and tl:find("score") then
                            r:SetTextColor(1, 0.1, 0.1)
                            if guardFrame then
                                guardFrame.WhatLockoutMplusScoreColoredFor = guardKey
                            end
                            return true
                        end
                    end
                end
            end
        end

        if root.GetChildren then
            local children = { root:GetChildren() }
            for i = 1, #children do
                if scanFrameTree(children[i], guardFrame) then
                    return true
                end
            end
        end

        return false
    end

    local function colorInTooltip(tt)
        if not tt or tt.WhatLockoutMplusScoreColoredFor == guardKey then
            return false
        end
        if tt.IsShown and not tt:IsShown() then return false end
        return scanFrameTree(tt, tt)
    end

    if GameTooltip then
        if colorInTooltip(GameTooltip) then return true end
    end

    if EnumerateFrames then
        for frame in EnumerateFrames() do
            if frame and frame.IsShown and frame:IsShown() then
                local nm = frame.GetName and frame:GetName() or ""
                nm = (nm and nm:lower()) or ""
                if nm:find("raider") and (nm:find("tooltip") or nm:find("profile")) then
                    if colorInTooltip(frame) then return true end
                end
            end
        end
    end

    return false
end

-- Colors RaiderIO's M+ Score / Best Runs headers when the tooltip contains
-- a dungeon name that matches our locked mythic caches.
-- This avoids relying on LFG row hover hooks (which are UI build dependent).
function Util.TryColorRaiderIOHeadersIfLocked(tt, opts)
    if not tt or type(opts) ~= "table" then return false end
    local lockedMythicByName = opts.lockedMythicByName
    local normalizeNameFn = opts.normalizeNameFn
    local doDebug = opts.debug == true
    if type(lockedMythicByName) ~= "table" or type(normalizeNameFn) ~= "function" then
        return false
    end
    -- Some tooltips (and different addons / fonts / clients) may use a different apostrophe
    -- character or accent variants than Blizzard save-instance names.
    -- We build a folded-key lookup so matching survives those differences.
    local lockedMythicByFold = nil
    if Util and type(Util.FoldForBlacklistKey) == "function" then
        lockedMythicByFold = {}
        for k in pairs(lockedMythicByName) do
            if type(k) == "string" and k ~= "" then
                local fk = Util.FoldForBlacklistKey(k)
                if fk and fk ~= "" then
                    lockedMythicByFold[fk] = true
                end
            end
        end
    end
    -- RaiderIO's tooltip doesn't show full dungeon names; it uses short codes like AA/MT/NPX/POS/SEAT/SR/WS.
    -- We derive a small set of candidate codes from our locked dungeon names so the poll can still match.
    local lockedMythicByCodes = nil
    do
        local function addCode(set, code)
            if code and code ~= "" then set[code] = true end
        end

        lockedMythicByCodes = {}
        for k in pairs(lockedMythicByName) do
            if type(k) == "string" and k ~= "" then
                local s = k
                s = s:gsub("'", "")      -- drop apostrophes
                s = s:gsub("%-", " ")    -- hyphenated parts become separate words
                s = s:gsub("[^%a%s]", " ") -- keep only letters/spaces

                local words = {}
                for w in s:gmatch("%S+") do
                    if w and w ~= "" then
                        local ww = w:match("%a+")
                        if ww and ww ~= "" then
                            words[#words + 1] = ww
                        end
                    end
                end

                local initials = ""
                for i = 1, #words do
                    local w = words[i]
                    if w and w ~= "" then
                        initials = initials .. w:sub(1, 1)
                    end
                end
                addCode(lockedMythicByCodes, string.lower(initials))

                local first = words[1]
                if first and #first == 4 then
                    addCode(lockedMythicByCodes, string.lower(first))
                end

                if #words == 1 then
                    local w = words[1]
                    if w then
                        -- Skyreach: RaiderIO uses SR.
                        if w:sub(-5) == "reach" then
                            addCode(lockedMythicByCodes, string.lower(w:sub(1, 1) .. "r"))
                        end
                        if #w >= 2 then
                            addCode(lockedMythicByCodes, string.lower(w:sub(1, 2)))
                        end
                    end
                end
            end
        end
    end
    if tt.IsShown and not tt:IsShown() then
        return false
    end

    local lockedNow = false
    local scanned = 0
    local matchedText = nil
    local debugScanned = doDebug and {} or nil

    local function isLockedNorm(norm)
        if not norm or norm == "" then return false end
        if lockedMythicByName[norm] then return true end
        if lockedMythicByCodes and lockedMythicByCodes[norm] then return true end
        if lockedMythicByFold then
            local fk = Util.FoldForBlacklistKey(norm)
            if fk and lockedMythicByFold[fk] then return true end
        end
        return false
    end

    local function scan(root)
        if not root then return end

        if root.GetObjectType and root:GetObjectType() == "FontString" and root.GetText then
            local txt = root:GetText()
            if txt and txt ~= "" then
                scanned = scanned + 1
                local norm = normalizeNameFn(txt)
                if debugScanned and #debugScanned < 50 then
                    debugScanned[#debugScanned + 1] = { txt = txt, norm = norm }
                end
                if norm and isLockedNorm(norm) then
                    lockedNow = true
                    matchedText = txt
                    return
                end
            end
        end

        if root.GetRegions then
            local regions = { root:GetRegions() }
            for i = 1, #regions do
                local r = regions[i]
                if r and r.GetObjectType and r:GetObjectType() == "FontString" and r.GetText then
                    local txt = r:GetText()
                    if txt and txt ~= "" then
                        scanned = scanned + 1
                        local norm = normalizeNameFn(txt)
                        if debugScanned and #debugScanned < 50 then
                            debugScanned[#debugScanned + 1] = { txt = txt, norm = norm }
                        end
                        if norm and isLockedNorm(norm) then
                            lockedNow = true
                            matchedText = txt
                            return
                        end
                    end
                end
            end
        end

        if root.GetChildren then
            local children = { root:GetChildren() }
            for i = 1, #children do
                if lockedNow then return end
                scan(children[i])
            end
        end
    end

    scan(tt)
    if not lockedNow then
        if doDebug then
            local now = (GetTime and GetTime()) or 0
            local last = tt and tt.WhatLockoutDebugLastLockedScanListAt or 0
            local shouldPrint = tt and (not last or (now - last) > 1.0)
            if shouldPrint then
                if tt then tt.WhatLockoutDebugLastLockedScanListAt = now end
                print("|cffc868ffWhatLockout debug DF RaiderIO header scan:|r scanned=" .. tostring(scanned) .. " lockedNow=false")
                local limit = math.min(#debugScanned, 25)
                for i = 1, limit do
                    local e = debugScanned[i]
                    if e then
                        print("  scan[" .. tostring(i) .. "] txt=" .. tostring(e.txt) .. " norm=" .. tostring(e.norm))
                    end
                end
            end
        end
        return false
    end

    local didColor = false

    local function recolor(root)
        if not root then return end

        if root.GetObjectType and root:GetObjectType() == "FontString" and root.GetText then
            local txt = root:GetText()
            if txt and txt ~= "" then
                local norm = normalizeNameFn(txt)
                if norm and isLockedNorm(norm) then
                    root:SetTextColor(1, 0.1, 0.1)
                    didColor = true
                end
            end
        end

        if root.GetRegions then
            local regions = { root:GetRegions() }
            for i = 1, #regions do
                local r = regions[i]
                if r and r.GetObjectType and r:GetObjectType() == "FontString" and r.GetText then
                    local txt = r:GetText()
                    if txt and txt ~= "" then
                        local norm = normalizeNameFn(txt)
                        if norm and isLockedNorm(norm) then
                            r:SetTextColor(1, 0.1, 0.1)
                            didColor = true
                        end
                    end
                end
            end
        end

        if root.GetChildren then
            local children = { root:GetChildren() }
            for i = 1, #children do
                recolor(children[i])
            end
        end
    end

    recolor(tt)
    if doDebug then
        print("|cffc868ffWhatLockout debug DF RaiderIO header scan:|r scanned=" .. tostring(scanned) .. " matchedText=" .. tostring(matchedText) .. " colored=" .. tostring(didColor))
    end
    return didColor
end

-- Shared Party merge (kept here: WoW Lua limits locals/upvalues per function).
-- opts: data, db, normalizeName, normalizeLoose, dungeonBlacklist, raidBlacklist,
--       localizeDungeon, localizeRaid, getPartyKeys,
--       seedRaidHideLocked, seedRaidTypeFilter, seedDungeonHideLocked, seedDungeonTypeFilter
function Util.PartyMemberBestCacheKey(players, partyKey)
    if type(players) ~= "table" or not partyKey or partyKey == "" then return nil end
    if players[partyKey] then return partyKey end
    local short = tostring(partyKey):match("^([^%-]+)") or partyKey
    if players[short] then return short end
    return nil
end

function Util.BuildSharedPartyData(opts)
    if type(opts) ~= "table" or type(opts.data) ~= "table" then return end

    local data = opts.data
    local db = opts.db or _G.WhatLockoutDB
    local NormalizeName = opts.normalizeName
    local NormalizeLoose = opts.normalizeLoose
    local dungeonBL = opts.dungeonBlacklist
    local raidBL = opts.raidBlacklist
    local LocDungeon = opts.localizeDungeon
    local LocRaid = opts.localizeRaid
    local GetPartyKeys = opts.getPartyKeys
    local seedRaidHideLocked = opts.seedRaidHideLocked and true or false
    local seedRaidTypeFilter = opts.seedRaidTypeFilter or "ALL"
    local seedDungeonHideLocked = opts.seedDungeonHideLocked and true or false
    local seedDungeonTypeFilter = opts.seedDungeonTypeFilter or "ALL"

    data.SHARED = {}

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

    local partyKeys = (type(GetPartyKeys) == "function" and GetPartyKeys()) or {}
    if type(partyKeys) ~= "table" or #partyKeys == 0 then return end

    local function rowExcludedByListFilters(row, hideLocked, typeFilter)
        if not row then return true end
        if hideLocked and row.locked then return true end
        local tf = typeFilter or "ALL"
        if tf ~= "ALL" and (row.difficulty or "") ~= tf then return true end
        return false
    end

    local function junkStringBlacklistHit(nm, bl)
        if type(bl) ~= "table" or not nm or nm == "" then
            return false
        end
        if Util.InstanceNameHitsStringBlacklist then
            return Util.InstanceNameHitsStringBlacklist(nm, bl, NormalizeName, NormalizeLoose)
        end
        local n = type(NormalizeName) == "function" and NormalizeName(nm) or nil
        local lo = type(NormalizeLoose) == "function" and NormalizeLoose(nm) or nil
        return (n and bl[n]) or (lo and bl[lo])
    end

    local currentRaidNameSet = {}
    local currentRaidKeySet = {}
    for _, row in ipairs(data.RAIDS or {}) do
        if row and row.name and not Util.InstanceNameIsHiddenSeasonOrShared(row.name, { journalInstanceID = row.journalInstanceID, raidKeyID = row.raidKeyID }) and type(NormalizeName) == "function" then
            if not rowExcludedByListFilters(row, seedRaidHideLocked, seedRaidTypeFilter) then
                if not junkStringBlacklistHit(row.name, raidBL) then
                    local norm = NormalizeName(row.name)
                    if norm and norm ~= "" then currentRaidNameSet[norm] = true end
                    local rk = tonumber(row.raidKeyID or 0) or 0
                    if rk > 0 then currentRaidKeySet[rk] = true end
                end
            end
        end
    end

    local currentDungeonNameSet = {}
    local currentDungeonLooseSet = {}
    local currentDungeonMapSet = {}
    for _, row in ipairs(data.DUNGEONS or {}) do
        if row and row.name and not Util.InstanceNameIsHiddenSeasonOrShared(row.name, {
            mapID = row.mapID,
            challengeMapID = row.mapID,
            savedInstanceID = row.savedInstanceID,
        }) and type(NormalizeName) == "function" then
            if not rowExcludedByListFilters(row, seedDungeonHideLocked, seedDungeonTypeFilter) then
                if not junkStringBlacklistHit(row.name, dungeonBL) then
                    local norm = NormalizeName(row.name)
                    if norm and norm ~= "" then currentDungeonNameSet[norm] = true end
                    if type(NormalizeLoose) == "function" then
                        local loose = NormalizeLoose(row.name)
                        if loose and loose ~= "" then currentDungeonLooseSet[loose] = true end
                    end
                    local mid = tonumber(row.mapID or 0) or 0
                    if mid > 0 then currentDungeonMapSet[mid] = true end
                end
            end
        end
    end

    local instanceMap = {}
    local memberSet = {}

    local function AddToSet(set, arr, value)
        if not value or value == "" then return end
        if not set[value] then
            set[value] = true
            arr[#arr + 1] = value
        end
    end

    for _, partyKey in ipairs(partyKeys) do
        local cacheKey = Util.PartyMemberBestCacheKey(players, partyKey)
        local t = cacheKey and players[cacheKey] or nil
        if t and ((type(t.raids) == "table" and #t.raids > 0) or (type(t.dungeons) == "table" and #t.dungeons > 0)) then
            memberSet[cacheKey] = true
            local memberId = tostring(cacheKey)

            if type(t.dungeons) == "table" then
                for _, e in ipairs(t.dungeons) do
                    if e and e.name then
                        if not Util.InstanceNameIsHiddenSeasonOrShared(e.name, {
                            mapID = e.mapID,
                            challengeMapID = e.mapID,
                            savedInstanceID = e.savedInstanceID,
                        }) then
                            local mapID = tonumber(e.mapID or 0) or 0
                            if mapID <= 0 then mapID = nil end
                            local sid = tonumber(e.savedInstanceID or 0) or 0
                            if sid <= 0 then sid = nil end
                            local blocked = junkStringBlacklistHit(e.name, dungeonBL)
                            if not blocked then
                            local norm = type(NormalizeName) == "function" and NormalizeName(e.name) or nil
                            local loose = (type(NormalizeLoose) == "function" and NormalizeLoose(e.name)) or nil
                                local allowedDungeon = (mapID and currentDungeonMapSet[mapID])
                                    or (norm and currentDungeonNameSet[norm])
                                    or (loose and currentDungeonLooseSet[loose])
                                local k = allowedDungeon and (mapID and ("D:m:" .. tostring(mapID))
                                    or sid and ("D:s:" .. tostring(sid))
                                    or (norm and norm ~= "" and ("D:n:" .. norm) or nil)) or nil
                                if k and type(LocDungeon) == "function" then
                                    local it = instanceMap[k]
                                    if not it then
                                        instanceMap[k] = {
                                            type = "Dungeon",
                                            mapID = mapID,
                                            name = LocDungeon(mapID, e.name),
                                            difficulty = e.difficulty,
                                            lockedBy = {},
                                            availableBy = {},
                                            lockedBySet = {},
                                            availableBySet = {},
                                        }
                                        it = instanceMap[k]
                                    else
                                        if mapID then
                                            it.mapID = mapID
                                            it.name = LocDungeon(mapID, it.name or e.name)
                                        end
                                        if sid and (not it.savedInstanceID or it.savedInstanceID <= 0) then
                                            it.savedInstanceID = sid
                                        end
                                    end
                                    if e.difficulty and e.difficulty ~= "" then
                                        it.difficulty = e.difficulty
                                    end
                                    if e.locked then
                                        AddToSet(it.lockedBySet, it.lockedBy, memberId)
                                    elseif e.progress == "Available" or e.progress == "Expired" then
                                        AddToSet(it.availableBySet, it.availableBy, memberId)
                                    end
                                end
                            end
                        end
                    end
                end
            end

            if type(t.raids) == "table" then
                for _, e in ipairs(t.raids) do
                    if e and e.name then
                        if not Util.InstanceNameIsHiddenSeasonOrShared(e.name, { journalInstanceID = e.journalInstanceID, raidKeyID = e.raidKeyID }) then
                            local rk = tonumber(e.raidKeyID or 0) or 0
                            if rk <= 0 then rk = nil end
                            local blocked = junkStringBlacklistHit(e.name, raidBL)
                            if not blocked then
                            local norm = type(NormalizeName) == "function" and NormalizeName(e.name) or nil
                                local allowedRaid = (rk and currentRaidKeySet[rk]) or (norm and currentRaidNameSet[norm])
                                local k = allowedRaid and ((rk and ("R:k:" .. tostring(rk))) or (norm and norm ~= "" and ("R:n:" .. norm)) or nil) or nil
                                if k and type(LocRaid) == "function" then
                                    local it = instanceMap[k]
                                    if not it then
                                        instanceMap[k] = {
                                            type = "Raid",
                                            raidKeyID = rk,
                                            journalInstanceID = tonumber(e.journalInstanceID) or nil,
                                            name = rk and LocRaid(rk, e.name) or e.name,
                                            difficulty = e.difficulty,
                                            lockedBy = {},
                                            availableBy = {},
                                            lockedBySet = {},
                                            availableBySet = {},
                                        }
                                        it = instanceMap[k]
                                    else
                                        if rk then
                                            it.raidKeyID = rk
                                            it.name = LocRaid(rk, it.name or e.name)
                                        end
                                        local ej = tonumber(e.journalInstanceID) or nil
                                        if ej and ej > 0 then
                                            it.journalInstanceID = it.journalInstanceID or ej
                                        end
                                        if e.difficulty and e.difficulty ~= "" then
                                            it.difficulty = e.difficulty
                                        end
                                    end
                                    if e.locked then
                                        AddToSet(it.lockedBySet, it.lockedBy, memberId)
                                    end
                                end
                            end
                        end
                    end
                end
            end
        end
    end

    local memberCount = 0
    for _ in pairs(memberSet) do memberCount = memberCount + 1 end
    data.SHARED_MEMBER_COUNT = memberCount

    for _, it in pairs(instanceMap) do
        if it.mapID and type(LocDungeon) == "function" then
            it.name = LocDungeon(it.mapID, it.name) or it.name
        elseif it.type == "Raid" and it.raidKeyID and type(LocRaid) == "function" then
            it.name = LocRaid(it.raidKeyID, it.name) or it.name
        end
        if junkStringBlacklistHit(it.name, raidBL) or junkStringBlacklistHit(it.name, dungeonBL) then
            -- Locale / name fallback after LocDungeon/LocRaid renamed the row.
        elseif Util.InstanceNameIsHiddenSeasonOrShared(it.name, {
            mapID = it.mapID,
            challengeMapID = it.mapID,
            journalInstanceID = it.journalInstanceID,
            raidKeyID = it.raidKeyID,
            savedInstanceID = it.savedInstanceID,
        }) then
            -- skip junk rows after localization
        else
        local lockedCount = #it.lockedBy
        local availableCount = #it.availableBy
        data.SHARED[#data.SHARED + 1] = {
            mode = "SHARED",
            name = it.name,
            progress = tostring(lockedCount),
            reset = tostring(availableCount),
            difficulty = it.difficulty or it.type,
            locked = lockedCount > 0,
            lockedCount = lockedCount,
            availableCount = availableCount,
            lockedBy = it.lockedBy,
            availableBy = it.availableBy,
            memberCount = memberCount,
        }
        end
    end

    table.sort(data.SHARED, function(a, b)
        if a.locked ~= b.locked then return a.locked end
        if a.difficulty ~= b.difficulty then return a.difficulty < b.difficulty end
        return (a.name or "") < (b.name or "")
    end)

    if #data.SHARED == 0 then
        data.SHARED[1] = {
            mode = "SHARED",
            name = "(no synced party data)",
            progress = "0",
            reset = "0",
            difficulty = "",
            locked = false,
            lockedCount = 0,
            availableCount = 0,
            lockedBy = {},
            availableBy = {},
        }
    end
end

-- RaiderIO lockout list dialog.
-- Shows locked base-Mythic dungeon names under RaiderIO's profile tooltip.
-- Keeping this in Util reduces closure locals/upvalues in the main file.
local function WhatLockout_UtilGetElvUISkinsModule()
    if type(_G.WhatLockout_GetElvUISkinsModule) == "function" then
        local okGlobal, s = pcall(_G.WhatLockout_GetElvUISkinsModule)
        if okGlobal and s then
            return s
        end
    end

    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]
    elseif type(elv[2]) == "table" and type(elv[2].GetModule) == "function" then
        E = elv[2]
    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
        if not E then
            for _, v in pairs(elv) do
                if type(v) == "table" and type(v.GetModule) == "function" then
                    E = v
                    break
                end
            end
        end
    end
    if not E then return nil end

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

function Util.UpdateRaiderIOLockoutDialogForTooltip(tooltip, opts)
    if type(opts) ~= "table" then return end
    local DIALOG_STYLE_VERSION = 3
    local showDialog = opts.showDialog == true
    local d = opts.data
    local lockedMythicByName = opts.lockedMythicByName
    local normalizeNameFn = opts.normalizeNameFn
    local useElvUISkinDefaults = opts.useElvUISkinDefaults == true
    local doDebug = opts.debug == true

    local f = _G.WhatLockoutRaiderIOLockoutDialogFrame
    if not showDialog then
        if f and f.Hide then f:Hide() end
        return
    end

    -- Avoid dialog flicker when Blizzard briefly toggles tooltip visibility during UI rebuilds.
    if not tooltip or not tooltip.IsShown or not tooltip:IsShown() then
        local t = (GetTime and GetTime()) or 0
        if f then
            if not f.WhatLockoutDialogTooltipHiddenSince then
                f.WhatLockoutDialogTooltipHiddenSince = t
                return
            end
            if (t - f.WhatLockoutDialogTooltipHiddenSince) >= 0.4 then
                if f.Hide then f:Hide() end
                f.WhatLockoutDialogTooltipHiddenSince = nil
            end
        end
        return
    elseif f then
        f.WhatLockoutDialogTooltipHiddenSince = nil
    end

    if not f then
        f = CreateFrame("Frame", "WhatLockoutRaiderIOLockoutDialogFrame", tooltip, "BackdropTemplate")
        f:SetSize(235, 148)
        -- Make sure we render above the tooltip.
        f:SetFrameStrata("TOOLTIP")
        -- Prevent the dialog from capturing mouse/hover and blocking underlying UI (like filters).
        f:EnableMouse(false)
        f:SetAlpha(1)
        f:Hide()

        local title = f:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        title:SetPoint("TOPLEFT", 10, -7)
        title:SetText("Locked (base Mythic)")
        title:SetTextColor(1, 0.2, 0.2, 1)
        f.WhatLockoutDialogTitle = title

        _G.WhatLockoutRaiderIOLockoutDialogLines = {}
        local lineY = -24
        for i = 1, 10 do
            local fs = f:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            fs:SetPoint("TOPLEFT", 12, lineY - (i - 1) * 12)
            fs:SetWidth(210)
            fs:SetJustifyH("LEFT")
            fs:SetText("")
            fs:Hide()
            _G.WhatLockoutRaiderIOLockoutDialogLines[i] = fs
        end

        local footer = f:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        footer:SetPoint("BOTTOMLEFT", 10, 6)
        footer:SetText("WhatLockout")
        footer:SetTextColor(0.8, 0.8, 0.9, 0.9)
        f.WhatLockoutDialogFooter = footer

        _G.WhatLockoutRaiderIOLockoutDialogFrame = f
    end

    -- Ensure the dialog uses the same parent tooltip for consistent render order.
    if f and f.GetParent and f.SetParent and f:GetParent() ~= tooltip then
        f:SetParent(tooltip)
        f:SetAlpha(1)
    end

    -- Apply / refresh dialog style even if the frame already exists.
    -- This avoids keeping stale styling after setting changes.
    local canUseElvUI = false
    local S = nil
    -- Dungeon lockout tooltip display should always use ElvUI when available,
    -- regardless of the global skin toggle.
    S = WhatLockout_UtilGetElvUISkinsModule()
    canUseElvUI = S ~= nil

    local desiredStyleKey = (canUseElvUI and "ELVUI") or "DEFAULT"
    if f.WhatLockoutDialogStyleVersion ~= DIALOG_STYLE_VERSION or f.WhatLockoutDialogStyleKey ~= desiredStyleKey then
        if canUseElvUI and S and type(S.HandleFrame) == "function" then
            if type(f.SetBackdrop) == "function" then
                pcall(f.SetBackdrop, f, nil)
            end
            pcall(S.HandleFrame, S, f)
            -- Force ElvUI template when available to avoid fallback Blizzard border bleed-through.
            if type(f.SetTemplate) == "function" then
                pcall(f.SetTemplate, f, "Transparent")
            end
        else
            f:SetBackdrop({
                -- Match the RaiderIO tooltip look (Blizzard tooltip textures).
                bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
                edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
                tile = true, tileSize = 16, edgeSize = 16,
                -- Thinner border, closer to RaiderIO.
                insets = { left = 4, right = 4, top = 4, bottom = 4 },
            })
            -- RaiderIO-like dark tooltip styling (red accent).
            f:SetAlpha(1)
            f:SetBackdropColor(0.02, 0.02, 0.03, 0.82)
            f:SetBackdropBorderColor(0.75, 0.2, 0.2, 0.9)
        end

        f.WhatLockoutDialogStyleVersion = DIALOG_STYLE_VERSION
        f.WhatLockoutDialogStyleKey = desiredStyleKey
    end

    -- Update content after tooltip settles (avoid thrashing during dropdown rebuilds).
    local now = (GetTime and GetTime()) or 0
    local isShown = (f and f.IsShown and f:IsShown()) or false
    -- Update vaker als de dialog momenteel verborgen is, zodat hij snel kan verschijnen wanneer locks wél opduiken.
    local shouldUpdate = not tooltip.WhatLockoutLastDialogUpdateAt or (now - tooltip.WhatLockoutLastDialogUpdateAt) > 0.35 or (not isShown)

    if shouldUpdate then
        local lockedList = {}
        local dungeons = d and d.DUNGEONS or nil

        -- Fold lookup to survive apostrophe variants between UI and cache.
        local lockedMythicByFold = nil
        if type(Util) == "table" and type(Util.FoldForBlacklistKey) == "function" and type(lockedMythicByName) == "table" then
            lockedMythicByFold = {}
            for k in pairs(lockedMythicByName) do
                if type(k) == "string" and k ~= "" then
                    local fk = Util.FoldForBlacklistKey(k)
                    if fk and fk ~= "" then
                        lockedMythicByFold[fk] = true
                    end
                end
            end
        end

        if type(dungeons) == "table" then
            local seen = {}
            for _, e in ipairs(dungeons) do
                if e and e.name then
                    local norm = type(normalizeNameFn) == "function" and normalizeNameFn(e.name) or nil
                    local fk = (lockedMythicByFold and norm) and Util.FoldForBlacklistKey(norm) or nil
                    local locked =
                        (norm and lockedMythicByName and lockedMythicByName[norm]) or
                        (fk and lockedMythicByFold and lockedMythicByFold[fk]) or
                        (e.locked == true)

                    if locked and not seen[e.name] then
                        seen[e.name] = true
                        lockedList[#lockedList + 1] = e.name
                    end
                end
            end
        end

        if #lockedList == 0 and type(lockedMythicByName) == "table" then
            -- Last resort: show normalized keys so we at least see something.
            local seenKeys = {}
            for k in pairs(lockedMythicByName) do
                if type(k) == "string" and k ~= "" and not seenKeys[k] then
                    seenKeys[k] = true
                    lockedList[#lockedList + 1] = k
                end
            end
        end

        if doDebug then
            print("|cffc868ffWhatLockout debug DF RaiderIO dialog:|r lockedCount=" .. tostring(#lockedList))
            if #lockedList > 0 then
                for i = 1, math.min(#lockedList, 5) do
                    print("  - " .. tostring(lockedList[i]))
                end
            end
        end

        table.sort(lockedList, function(a, b) return tostring(a) < tostring(b) end)

        -- Hard rule: if there are no locks in the underlying data, the dialog must not be shown.
        -- (LockedList can temporarily be empty during UI rebuilds; this flag prevents "ghost" dialogs.)
        local anyLocksExist = false
        if type(lockedMythicByName) == "table" then
            for _, v in pairs(lockedMythicByName) do
                if v == true then
                    anyLocksExist = true
                    break
                end
            end
        end
        if not anyLocksExist and type(dungeons) == "table" then
            for _, e in ipairs(dungeons) do
                if e and e.locked == true then
                    anyLocksExist = true
                    break
                end
            end
        end

        -- If nothing is locked, do not hide immediately (tooltip content and caches can
        -- rebuild for a moment after search / dropdown updates, causing flicker).
        local forceHide = false
        if #lockedList == 0 then
            if not anyLocksExist then
                forceHide = true
            else
            local emptySince = tooltip.WhatLockoutDialogEmptySince or now
            tooltip.WhatLockoutDialogEmptySince = emptySince
            -- Only hide after we've stayed empty for long enough.
            if (now - emptySince) >= 1.2 then
                forceHide = true
            end
            end
        else
            tooltip.WhatLockoutDialogEmptySince = nil
        end

        -- Anti-flicker: once we have shown locked dungeons, keep the dialog
        -- visible until we explicitly force-hide after a stable empty interval.
        local stickVisible = f.WhatLockoutDialogStickVisible == true
        f.WhatLockoutDialogLockedCount = #lockedList
        if forceHide then
            f.WhatLockoutDialogStickVisible = false
            if f.Hide then f:Hide() end
            f.WhatLockoutDialogWantVisible = false
            tooltip.WhatLockoutLastDialogUpdateAt = now
            return
        end

        if #lockedList > 0 then
            f.WhatLockoutDialogStickVisible = true
            local lines = _G.WhatLockoutRaiderIOLockoutDialogLines or {}
            for i = 1, #lines do
                local name = lockedList[i]
                if name then
                    lines[i]:SetText(name)
                    lines[i]:Show()
                    -- Locked items: RaiderIO-style red.
                    lines[i]:SetTextColor(1, 0.1, 0.1, 1)
                else
                    lines[i]:Hide()
                end
            end
            f.WhatLockoutDialogWantVisible = true
        else
            -- During temporary empty intervals, keep the stickiness to prevent flicker.
            f.WhatLockoutDialogWantVisible = stickVisible
        end

        tooltip.WhatLockoutLastDialogUpdateAt = now
    end

    f:ClearAllPoints()
    if tooltip.GetFrameLevel and type(tooltip.GetFrameLevel) == "function" then
        -- Keep above the tooltip's own children so the backdrop is never obscured.
        f:SetFrameLevel(tooltip:GetFrameLevel() + 3)
    end
    f:SetPoint("TOPLEFT", tooltip, "BOTTOMLEFT", 0, -6)
    local wantVisible = f.WhatLockoutDialogWantVisible
    if wantVisible == nil then
        wantVisible = (f.IsShown and f:IsShown()) or false
    end

    if wantVisible then
        f:Show()
    else
        if f.Hide then f:Hide() end
    end
end
