-- MythicMate LFG group finder (own UI, rendered inside the main panel's "LFG" tab).
-- Split file so its locals get a fresh Lua chunk (MythicMate.lua is near the
-- 200-locals-per-chunk limit).
--
-- Blizzard's Group Finder filter menu is a protected Menu we cannot safely drive.
-- Reading premade results via C_LFGList is NOT protected, so we present our OWN grid +
-- our OWN "Filter" popout (Blizzard-style: difficulty, require, playstyle, min rating,
-- dungeons) and apply it client-side. No hooks/clicks on Blizzard's protected frames.

local MythicMateNS = _G.MythicMate
if not MythicMateNS then return end

------------------------------------------------------------
-- Shared helpers
------------------------------------------------------------
local function isDungeonLocked(name)
    if type(MythicMateNS.IsDungeonNameLocked) == "function" then
        local ok, v = pcall(MythicMateNS.IsDungeonNameLocked, name)
        if ok then return v and true or false end
    end
    return false
end

local function normalizeName(name)
    if type(MythicMateNS.NormalizeName) == "function" then
        local ok, v = pcall(MythicMateNS.NormalizeName, name)
        if ok and v then return v end
    end
    if type(name) ~= "string" then return "" end
    return (name:gsub("%s*%b()", ""):lower():gsub("^%s+", ""):gsub("%s+$", ""))
end

-- Dungeons that CURRENT party members are locked to (base Mythic weekly lockout). Lets the
-- LFG finder treat those like your own lockouts — the group can't complete a dungeon a member
-- is already saved to. Only works when party members run this addon and have synced their
-- lockouts (data comes from MythicMateDB.shared.players). Returns { [normName] = "MemberName" }.
-- Cached briefly since the party roster + synced data change rarely.
local partyLockCache = nil
local partyLockCacheAt = 0
local function computePartyLockedDungeons()
    local set = {}
    if not (type(IsInGroup) == "function" and IsInGroup()) then return set end
    local players = MythicMateDB and MythicMateDB.shared and MythicMateDB.shared.players
    if type(players) ~= "table" then return set end
    local inRaid = (type(IsInRaid) == "function") and IsInRaid() or false
    local total = (type(GetNumGroupMembers) == "function") and GetNumGroupMembers() or 0
    for i = 1, total do
        local unit = inRaid and ("raid" .. i) or ("party" .. i)
        if type(UnitExists) == "function" and UnitExists(unit)
           and not (type(UnitIsUnit) == "function" and UnitIsUnit(unit, "player")) then
            local nm, realm = UnitName(unit)
            if nm and nm ~= "" then
                local t = (realm and realm ~= "" and players[nm .. "-" .. realm]) or players[nm]
                if type(t) == "table" and type(t.dungeons) == "table" then
                    for _, e in ipairs(t.dungeons) do
                        if type(e) == "table" and e.name and e.locked then
                            local n = normalizeName(e.name)
                            if n ~= "" and not set[n] then set[n] = nm end
                        end
                    end
                end
            end
        end
    end
    return set
end

local function partyLockedDungeons()
    local now = (type(GetTime) == "function") and GetTime() or 0
    if partyLockCache and (now - partyLockCacheAt) < 2 then return partyLockCache end
    partyLockCache = computePartyLockedDungeons()
    partyLockCacheAt = now
    return partyLockCache
end

local function displayName(name)
    local util = MythicMateNS.Util
    if util and type(util.GetDisplayInstanceName) == "function" then
        local ok, v = pcall(util.GetDisplayInstanceName, name)
        if ok and v and v ~= "" then return v end
    end
    return name
end

local function firstNonEmpty(...)
    for i = 1, select("#", ...) do
        local v = select(i, ...)
        if type(v) == "string" and v ~= "" then return v end
    end
    return nil
end

-- True als de speler in een instance zit (dungeon/raid/scenario). Dan schakelen we LFG uit:
-- geen zoeken/queue/aanmelden terwijl je al binnen bent.
local function isInDungeon()
    if type(IsInInstance) ~= "function" then return false end
    local inInstance, instanceType = IsInInstance()
    if not inInstance then return false end
    return instanceType == "party" or instanceType == "raid" or instanceType == "scenario"
end

-- Compact display of the keystone you currently hold, e.g. "+12 Ara-Kara, City of Echoes".
-- Returns "" when you hold no keystone. Level first so it survives truncation in a tight toolbar.
local function ownKeystoneText()
    if type(C_MythicPlus) ~= "table" then return "" end
    local lvl = (type(C_MythicPlus.GetOwnedKeystoneLevel) == "function") and C_MythicPlus.GetOwnedKeystoneLevel() or nil
    if not lvl or lvl <= 0 then return "" end
    local mapID = nil
    if type(C_MythicPlus.GetOwnedKeystoneChallengeMapID) == "function" then
        mapID = C_MythicPlus.GetOwnedKeystoneChallengeMapID()
    elseif type(C_MythicPlus.GetOwnedKeystoneMapID) == "function" then
        mapID = C_MythicPlus.GetOwnedKeystoneMapID()
    end
    local name = nil
    if mapID and type(C_ChallengeMode) == "table" and type(C_ChallengeMode.GetMapUIInfo) == "function" then
        local ok, n = pcall(C_ChallengeMode.GetMapUIInfo, mapID)
        if ok and type(n) == "string" and n ~= "" then name = n end
    end
    if name then return ("|cff66ff66+%d|r %s"):format(lvl, name) end
    return ("|cff66ff66+%d|r keystone"):format(lvl)
end

local function classifyDifficulty(act)
    if type(act) ~= "table" then return "OTHER" end
    if act.isMythicPlusActivity then return "MYTHICPLUS" end
    local s = tostring(firstNonEmpty(act.fullName, act.shortName, act.name) or ""):lower()
    if s:find("keystone", 1, true) then return "MYTHICPLUS" end
    if s:find("mythic", 1, true) then return "MYTHIC" end
    if s:find("heroic", 1, true) then return "HEROIC" end
    if s:find("normal", 1, true) then return "NORMAL" end
    return "OTHER"
end

-- Best-effort keystone level for a Mythic+ listing, parsed from the title/comment.
--
-- KSTRING REALITY (12.0+): listing name/comment are typed `kstringLfgListSearch` — the Lua
-- value is a render-only token (like "|Ks123|k"). It DISPLAYS as "+11 Competitive" in any
-- FontString/chat (which is why every diagnostic LOOKED readable), it is a normal string
-- (issecretvalue() = false), but its bytes contain no level, so string.match can never find
-- one. Addons fundamentally cannot read the key level (Premade Groups Filter FAQ confirms).
-- This parser therefore returns nil for virtually every 12.x M+ listing; it is kept as a
-- harmless fallback. The WORKING key filter is Blizzard's engine-side text filter: the secure
-- search box (hosted in our toolbar, see embedBlizzSearchBox) + C_LFGList.GetFilteredSearchResults.
local function parseKeyLevel(info)
    local function inRange(n) n = tonumber(n); if n and n >= 2 and n <= 40 then return n end end
    local function scan(s)
        -- WoW 12.1 "secret" strings: a listing title/comment can be a protected value that
        -- string.match/compare can't touch (that's why the key filter read 0/N levels). tostring()
        -- launders it into a plain readable string — the exact trick the row display already uses —
        -- WITHOUT any raw compare on the secret. tostring(nil) == "nil". [[wow-12-1-secret-strings]]
        s = tostring(s)
        if s == "" or s == "nil" then return nil end
        s = s:lower()
        -- Strongest signal first: an explicit "+N" (how leaders write the key level).
        local n = inRange(s:match("%+%s*(%d%d?)"))
        if n then return n end
        -- Keyword forms: "key 10", "10 key", "keystone 10", "m10", "mythic 10", "lvl 10".
        n = inRange(s:match("key%s*(%d%d?)")) or inRange(s:match("(%d%d?)%s*key"))
            or inRange(s:match("keystone%s*(%d%d?)")) or inRange(s:match("m%+?%s*(%d%d?)"))
            or inRange(s:match("mythic%s*(%d%d?)")) or inRange(s:match("lvl%s*(%d%d?)"))
        if n then return n end
        -- Last resort: a standalone 1-2 digit number in a typical key range (avoids item level /
        -- rating which are 3-4 digits).
        for num in s:gmatch("%f[%w](%d%d?)%f[%W]") do
            local v = inRange(num)
            if v and v <= 30 then return v end
        end
        return nil
    end
    return scan(info and info.name) or scan(info and info.comment)
end

-- Parse a free-text range like "2-10" into (min, max). Also accepts "2-" (min only), "-10"
-- (max only) and a bare "5" (treated as a minimum). 0 means "unbounded" on that end.
local function parseRange(text)
    text = tostring(text or ""):gsub("%s+", "")
    if text == "" then return 0, 0 end
    local a, b = text:match("^(%d*)%-(%d*)$")
    if a ~= nil then return tonumber(a) or 0, tonumber(b) or 0 end
    local n = tonumber(text)
    if n then return n, 0 end
    return 0, 0
end

-- Inverse of parseRange: turn a stored (min, max) pair back into the box's display text.
local function formatRange(lo, hi)
    lo = tonumber(lo) or 0
    hi = tonumber(hi) or 0
    if lo > 0 and hi > 0 then return lo .. "-" .. hi end
    if lo > 0 then return tostring(lo) end
    if hi > 0 then return "-" .. hi end
    return ""
end

local function compositionOf(resultID)
    if type(C_LFGList) ~= "table" or type(C_LFGList.GetSearchResultMemberCounts) ~= "function" then
        return nil
    end
    local ok, c = pcall(C_LFGList.GetSearchResultMemberCounts, resultID)
    if not ok or type(c) ~= "table" then return nil end
    return { t = tonumber(c.TANK) or 0, h = tonumber(c.HEALER) or 0, d = tonumber(c.DAMAGER) or 0 }
end

-- Best-effort leader M+ score (Rio) and item level. Blizzard's search API exposes the
-- leader's M+ score directly; leader item level is not always exposed, so we try member
-- info (some builds include it) and fall back to the group's required ilvl. Rio also
-- falls back to the RaiderIO addon when installed.
local function leaderRioIlvl(resultID, info)
    local rio = tonumber(info and info.leaderOverallDungeonScore) or 0
    local ilvl = 0
    local class = nil
    if type(C_LFGList) == "table" and type(C_LFGList.GetSearchResultMemberInfo) == "function" then
        local ok, r1, r2 = pcall(C_LFGList.GetSearchResultMemberInfo, resultID, 1) -- 1 = leader
        if ok then
            if type(r1) == "table" then
                class = r1.classFilename or r1.class
                ilvl = tonumber(r1.itemLevel or r1.ilvl) or ilvl
                if rio == 0 then rio = tonumber(r1.dungeonScore or r1.overallDungeonScore) or rio end
            elseif type(r2) == "string" then
                class = r2 -- (assignedRole, classFilename, ...) multi-return form
            end
        end
    end
    if rio == 0 and _G.RaiderIO and type(_G.RaiderIO.GetProfile) == "function" and info and info.leaderName then
        local nm, realm = tostring(info.leaderName):match("^([^-]+)-?(.*)$")
        local ok, prof = pcall(_G.RaiderIO.GetProfile, nm, (realm ~= "" and realm) or nil)
        if ok and type(prof) == "table" and prof.mythicKeystoneProfile then
            rio = tonumber(prof.mythicKeystoneProfile.currentScore) or rio
        end
    end
    if ilvl == 0 then ilvl = tonumber(info and info.requiredItemLevel) or 0 end
    return rio, ilvl, class
end

------------------------------------------------------------
-- State + filter model
------------------------------------------------------------
local panel = nil
local MAX_ROWS = 13
local ROW_H = 26
-- View mode: "all" = the full LFG finder (search results + Quick Join); "quickjoin" = a
-- dedicated Quick Join tab that shows ONLY Social Queue groups (friends/guild), unfiltered by
-- difficulty/dungeon so you always see what your friends are doing. Set by showLFG(mode).
local viewMode = "all"
local scrollOffset = 0
local lastFilteredCount = 0
local lastDiag = ""
local lastSearchDiag = "search=?"
local lastSocialCount = 0  -- Quick Join (Social Queue) groups gathered this refresh
local lastSocialShown = 0  -- ...of those, how many survived the filters (for /wl debugpremade)
local lastKeyTotal = 0     -- Mythic+ groups this refresh (for /wl debugpremade)
local lastKeyReadable = 0  -- ...of those, how many had a parseable advertised key level
local lastRoleFitHidden = 0 -- groups hidden by the party role-fit rule (to explain empty lists)
local lastAutoSearchAt = 0    -- GetTime() van de laatste ECHTE search (voedt de stale-pulse)
local lastAutoRefreshAt = 0   -- GetTime() van de laatste interactie-refresh (eigen throttle)
local AUTO_SEARCH_EVERY = 10  -- default sec tussen auto (hover/scroll) searches; min 8 (Blizzard throttle)
-- Live interval uit settings (0/uit = auto-search uit; anders geklemd op >= 8s zodat we
-- Blizzards eigen search-throttle niet raken → LFG_LIST_SEARCH_FAILED).
local function autoSearchInterval()
    local st = MythicMateDB and MythicMateDB.settings
    local v = st and st.lfgAutoSearchEvery
    if v == nil then return AUTO_SEARCH_EVERY end
    v = tonumber(v) or AUTO_SEARCH_EVERY
    if v <= 0 then return nil end      -- expliciet uitgezet
    if v < 8 then v = 8 end
    return v
end
local seenDungeons = {}  -- [norm] = displayName
local notifiedInvite = {}  -- [resultID] = true once we've flashed/pinged an incoming invite
local lastPartyLockCount = 0  -- base-Mythic groups a party member is saved to (for the status note)
local sortCol = "fit" -- fit | diff | dungeon | group | leader | num | rio | ilvl (fit = OAK-style best-fit default)
local sortAsc = true

local filter = {
    diff = { NORMAL = false, HEROIC = false, MYTHIC = true, MYTHICPLUS = false },
    hideLocked = true,
    reqHasTank = false, reqHasHealer = false,
    reqTankAvail = false, reqHealerAvail = false, reqDamageAvail = false,
    reqMyRole = false,  -- only show groups that still need one of MY selected roles
    minRating = 0,
    maxRating = 0,      -- 0 = no upper bound on leader M+ rating
    minKey = 0, maxKey = 0,  -- keystone-level range for Mythic+ groups (0 = unbounded)
    keyText = "",  -- last text typed in the hosted Blizzard search box (engine Key filter)
    playstyle = { [1] = true, [2] = true, [3] = true, [4] = true },
    dungeons = {},  -- [norm] = false to exclude; default (nil) = included
    hideRegions = {},  -- [regionCode] = true to hide high-ping regions (OCE/BR/LATAM)
}

-- Default filter values, used by the "Reset" button to restore a clean state.
local function resetFilterToDefaults()
    filter.diff.NORMAL, filter.diff.HEROIC = false, false
    filter.diff.MYTHIC, filter.diff.MYTHICPLUS = true, false
    filter.hideLocked = true
    filter.reqHasTank, filter.reqHasHealer = false, false
    filter.reqTankAvail, filter.reqHealerAvail, filter.reqDamageAvail = false, false, false
    filter.reqMyRole = false
    filter.minRating = 0
    filter.maxRating = 0
    filter.minKey, filter.maxKey = 0, 0
    filter.keyText = ""
    filter.playstyle = { [1] = true, [2] = true, [3] = true, [4] = true }
    filter.dungeons = {}
    filter.hideRegions = {}
end

-- Leaders the player chose to ignore (right-click a row). Their groups are hidden.
-- Keyed by short lowercase leader name; persisted per character.
local ignoredLeaders = {}

-- The player's currently selected LFG roles (leader, tank, healer, dps). Used by the
-- "needs my role" filter to only show groups with an open slot for a role you queue as.
local function playerRoles()
    if type(GetLFGRoles) == "function" then
        local ok, _leader, tank, healer, dps = pcall(GetLFGRoles)
        if ok then return tank and true or false, healer and true or false, dps and true or false end
    end
    return false, false, false
end

-- Assigned role of a unit as a single letter: "T" | "H" | "D" | nil.
local function assignedRoleLetter(unit)
    if type(UnitGroupRolesAssigned) ~= "function" then return nil end
    local ok, r = pcall(UnitGroupRolesAssigned, unit)
    if not ok then return nil end
    if r == "TANK" then return "T" end
    if r == "HEALER" then return "H" end
    if r == "DAMAGER" then return "D" end
    return nil
end

-- Combined assigned-role counts for your whole party (you + members). Members with no
-- assigned role are counted as DPS so a slot is still reserved for them. Returns tank,heal,dps.
local function partyRoleCounts()
    local t, h, d = 0, 0, 0
    local function add(unit)
        local r = assignedRoleLetter(unit)
        if r == "T" then t = t + 1
        elseif r == "H" then h = h + 1
        else d = d + 1 end -- "D" or unknown/none → reserve a DPS slot
    end
    add("player")
    if type(IsInGroup) == "function" and IsInGroup() then
        local inRaid = (type(IsInRaid) == "function") and IsInRaid() or false
        local total = (type(GetNumGroupMembers) == "function") and GetNumGroupMembers() or 0
        for i = 1, total do
            local unit = inRaid and ("raid" .. i) or ("party" .. i)
            if type(UnitExists) == "function" and UnitExists(unit)
               and not (type(UnitIsUnit) == "function" and UnitIsUnit(unit, "player")) then
                add(unit)
            end
        end
    end
    return t, h, d
end

-- A full party (5-man content) can't fit into ANY listing, so applying the role-fit filter
-- would blank the entire list — which reads as "LFG is broken". Used to bypass that filter.
local function partyIsFull()
    if not (type(IsInGroup) == "function" and IsInGroup()) then return false end
    local n = (type(GetNumGroupMembers) == "function") and (GetNumGroupMembers() or 0) or 0
    return n >= 5
end

-- Whether a group can accommodate your roles.
-- In a party: the group's open slots (5-man caps 1 tank / 1 healer / 3 dps) must fit your
--   WHOLE party's assigned roles — otherwise you can't all join together.
-- Solo: the group just needs an open slot for a role you queue as.
-- Unknown composition → treated as "fits" (don't hide).
local function groupNeedsMyRole(e)
    local c = e.comp
    if not c then return true end

    if type(IsInGroup) == "function" and IsInGroup() then
        local pt, ph, pd = partyRoleCounts()
        local tankOpen = 1 - (c.t or 0)
        local healOpen = 1 - (c.h or 0)
        local dpsOpen  = 3 - (c.d or 0)
        return pt <= tankOpen and ph <= healOpen and pd <= dpsOpen
    end

    local tank, healer, dps = playerRoles()
    if not (tank or healer or dps) then
        return (c.t < 1) or (c.h < 1) or (c.d < 3)
    end
    if tank and c.t < 1 then return true end
    if healer and c.h < 1 then return true end
    if dps and c.d < 3 then return true end
    return false
end

local PLAYSTYLES = { { 1, "Learning" }, { 2, "Relaxed" }, { 3, "Competitive" }, { 4, "Carry offered" } }

-- Dungeons die we automatisch hebben uitgevinkt omdat je erop gelockt bent. We onthouden
-- welke we al deden, zodat je binnen een sessie een locked dungeon toch weer kunt aanvinken
-- zonder dat 'ie meteen terug-uitvinkt; en zodra 'ie opnieuw locked wordt (na completion)
-- vinken we 'm weer uit.
local autoUncheckedLocked = {}

------------------------------------------------------------
-- Friend / guild / Battle.net leader highlighting
-- (LFG search results only expose the LEADER's name, not member names.)
------------------------------------------------------------
local friendNames = {}  -- [shortNameLower] = "BNET" | "GUILD" | "FRIEND"

local function shortLower(name)
    local s = tostring(name or ""):match("^([^-]+)")
    return s and s:lower() or nil
end

local function rebuildFriends()
    for k in pairs(friendNames) do friendNames[k] = nil end

    -- Guild
    if type(IsInGuild) == "function" and IsInGuild() and type(GetNumGuildMembers) == "function" then
        local n = GetNumGuildMembers() or 0
        for i = 1, n do
            local ok, name = pcall(GetGuildRosterInfo, i)
            if ok then
                local s = shortLower(name)
                if s then friendNames[s] = "GUILD" end
            end
        end
    end

    -- Character friends
    if C_FriendList and type(C_FriendList.GetNumFriends) == "function" then
        local n = C_FriendList.GetNumFriends() or 0
        for i = 1, n do
            local ok, info = pcall(C_FriendList.GetFriendInfoByIndex, i)
            if ok and type(info) == "table" then
                local s = shortLower(info.name)
                if s then friendNames[s] = "FRIEND" end
            end
        end
    end

    -- Battle.net friends currently playing a WoW character (takes priority = bnet blue)
    if type(BNGetNumFriends) == "function" and C_BattleNet and type(C_BattleNet.GetFriendAccountInfo) == "function" then
        local n = BNGetNumFriends() or 0
        for i = 1, n do
            local ok, acc = pcall(C_BattleNet.GetFriendAccountInfo, i)
            if ok and type(acc) == "table" and type(acc.gameAccountInfo) == "table" then
                local g = acc.gameAccountInfo
                if (g.clientProgram == "WoW") and g.characterName then
                    local s = shortLower(g.characterName)
                    if s then friendNames[s] = "BNET" end
                end
            end
        end
    end
end

------------------------------------------------------------
-- Persistence: PER CHARACTER onder MythicMateDB.settings.perChar["Naam-Realm"].
------------------------------------------------------------
local VALID_SORT_COLS = { fit = true, diff = true, dungeon = true, group = true, leader = true, num = true, rio = true, ilvl = true }

-- Per-character opslag-tabel (aangemaakt indien nodig). Filters/sort zijn per personage,
-- want lockouts en voorkeuren verschillen per char.
local function lfgCharStore()
    MythicMateDB = MythicMateDB or {}
    MythicMateDB.settings = MythicMateDB.settings or {}
    local perChar = MythicMateDB.settings.perChar
    if type(perChar) ~= "table" then perChar = {}; MythicMateDB.settings.perChar = perChar end
    local name = (UnitName and UnitName("player")) or "?"
    local realm = (GetRealmName and GetRealmName()) or "?"
    local key = tostring(name) .. "-" .. tostring(realm)
    local store = perChar[key]
    if type(store) ~= "table" then store = {}; perChar[key] = store end
    return store
end

local function loadFilterFromDB()
    local store = lfgCharStore()
    local st = MythicMateDB and MythicMateDB.settings
    -- Ignored leaders (per char) — load regardless of whether a filter was saved.
    for k in pairs(ignoredLeaders) do ignoredLeaders[k] = nil end
    if type(store.lfgIgnored) == "table" then
        for k, v in pairs(store.lfgIgnored) do
            if type(k) == "string" and v then ignoredLeaders[k] = true end
        end
    end
    -- Sorteervoorkeur (per char; migreer eenmalig van de oude account-brede sleutel).
    local savedSort = store.lfgSort or (st and st.lfgSort)
    if type(savedSort) == "table" then
        if type(savedSort.col) == "string" and VALID_SORT_COLS[savedSort.col] then sortCol = savedSort.col end
        if savedSort.asc ~= nil then sortAsc = savedSort.asc and true or false end
    end
    local saved = store.lfgFilter or (st and st.lfgFilter)
    if type(saved) ~= "table" then
        -- Geen filter opgeslagen: laad wél de auto-locked set (indien aanwezig).
        for k in pairs(autoUncheckedLocked) do autoUncheckedLocked[k] = nil end
        if type(store.lfgAutoLocked) == "table" then
            for k, v in pairs(store.lfgAutoLocked) do
                if type(k) == "string" and v then autoUncheckedLocked[k] = true end
            end
        end
        return
    end
    if type(saved.diff) == "table" then
        for _, k in ipairs({ "NORMAL", "HEROIC", "MYTHIC", "MYTHICPLUS" }) do
            if saved.diff[k] ~= nil then filter.diff[k] = saved.diff[k] and true or false end
        end
    end
    if saved.hideLocked ~= nil then filter.hideLocked = saved.hideLocked and true or false end
    filter.reqHasTank = saved.reqHasTank and true or false
    filter.reqHasHealer = saved.reqHasHealer and true or false
    filter.reqTankAvail = saved.reqTankAvail and true or false
    filter.reqHealerAvail = saved.reqHealerAvail and true or false
    filter.reqDamageAvail = saved.reqDamageAvail and true or false
    filter.reqMyRole = saved.reqMyRole and true or false
    filter.minRating = tonumber(saved.minRating) or 0
    filter.maxRating = tonumber(saved.maxRating) or 0
    filter.minKey = tonumber(saved.minKey) or 0
    filter.maxKey = tonumber(saved.maxKey) or 0
    filter.keyText = (type(saved.keyText) == "string") and saved.keyText or ""
    if type(saved.playstyle) == "table" then
        for i = 1, 4 do
            if saved.playstyle[i] ~= nil then filter.playstyle[i] = saved.playstyle[i] and true or false end
        end
    end
    if type(saved.dungeons) == "table" then
        filter.dungeons = {}
        for k, v in pairs(saved.dungeons) do
            if type(k) == "string" then filter.dungeons[k] = v and true or false end
        end
    end
    filter.hideRegions = {}
    if type(saved.hideRegions) == "table" then
        for k, v in pairs(saved.hideRegions) do
            if type(k) == "string" and v then filter.hideRegions[k] = true end
        end
    end
    -- Auto-uitgevinkte set: welke dungeons WIJ uitvinkten (om ze bij unlock terug aan te
    -- zetten). Bij eerste keer per char (nog geen lfgAutoLocked opgeslagen) migreren we door
    -- alle nu-uitgevinkte dungeons als "door ons beheerd" te bestempelen — zo worden oude,
    -- inmiddels niet-locked dungeons alsnog netjes teruggevinkt.
    for k in pairs(autoUncheckedLocked) do autoUncheckedLocked[k] = nil end
    if type(store.lfgAutoLocked) == "table" then
        for k, v in pairs(store.lfgAutoLocked) do
            if type(k) == "string" and v then autoUncheckedLocked[k] = true end
        end
    else
        for k, v in pairs(filter.dungeons) do
            if v == false then autoUncheckedLocked[k] = true end
        end
    end
end

local function saveFilterToDB()
    local store = lfgCharStore()
    store.lfgFilter = {
        diff = {
            NORMAL = filter.diff.NORMAL, HEROIC = filter.diff.HEROIC,
            MYTHIC = filter.diff.MYTHIC, MYTHICPLUS = filter.diff.MYTHICPLUS,
        },
        hideLocked = filter.hideLocked,
        reqHasTank = filter.reqHasTank, reqHasHealer = filter.reqHasHealer,
        reqTankAvail = filter.reqTankAvail, reqHealerAvail = filter.reqHealerAvail,
        reqDamageAvail = filter.reqDamageAvail,
        reqMyRole = filter.reqMyRole,
        minRating = filter.minRating,
        maxRating = filter.maxRating,
        minKey = filter.minKey, maxKey = filter.maxKey,
        keyText = filter.keyText,
        playstyle = { filter.playstyle[1], filter.playstyle[2], filter.playstyle[3], filter.playstyle[4] },
        dungeons = filter.dungeons,
        hideRegions = filter.hideRegions,
    }
    store.lfgSort = { col = sortCol, asc = sortAsc }
    local al = {}
    for k in pairs(autoUncheckedLocked) do al[k] = true end
    store.lfgAutoLocked = al
    local ig = {}
    for k in pairs(ignoredLeaders) do ig[k] = true end
    store.lfgIgnored = ig
end

-- Current-season dungeon pool ONLY (dynamic, not hardcoded): the M+ challenge map table
-- is exactly this season's dungeons, so it auto-updates every season/patch.
local function collectAllDungeons()
    if type(C_ChallengeMode) ~= "table" or type(C_ChallengeMode.GetMapTable) ~= "function"
       or type(C_ChallengeMode.GetMapUIInfo) ~= "function" then
        return
    end
    local ok, maps = pcall(C_ChallengeMode.GetMapTable)
    if not ok or type(maps) ~= "table" then return end
    for _, mapID in ipairs(maps) do
        local okN, name = pcall(C_ChallengeMode.GetMapUIInfo, mapID)
        if okN and type(name) == "string" and name ~= "" then
            local norm = normalizeName(name)
            if norm ~= "" and seenDungeons[norm] == nil then
                seenDungeons[norm] = tostring(displayName(name)):gsub("%s*%b()%s*$", "")
            end
        end
    end
end

-- Vink dungeons waar je op gelockt bent (gesaved) automatisch uit in het Dungeons-filter,
-- zodat hun groepen niet in de lijst verschijnen. Draait bij openen én na een dungeon-
-- completion (nieuwe lockout). Alleen "net gelockt" wordt uitgevinkt (zie autoUncheckedLocked)
-- zodat een handmatige her-vink binnen de sessie behouden blijft. Retourneert true als er iets
-- veranderde. NB: dit zijn ONZE eigen checkboxes, niet Blizzards beschermde vinkjes.
local function applyAutoUncheckLocked()
    collectAllDungeons()
    local changed = false
    -- Respect the "Hide locked dungeons" checkbox. When it is OFF, we must NOT keep locked
    -- dungeons auto-unchecked — otherwise a player saved to every dungeon sees an empty list with
    -- no way to reveal groups. So restore anything WE auto-unchecked and stop here.
    if not filter.hideLocked then
        for norm in pairs(autoUncheckedLocked) do
            autoUncheckedLocked[norm] = nil
            if filter.dungeons[norm] == false then
                filter.dungeons[norm] = nil -- nil = checked (default)
                changed = true
            end
        end
        return changed
    end
    for norm, disp in pairs(seenDungeons) do
        if isDungeonLocked(disp) then
            if not autoUncheckedLocked[norm] then
                autoUncheckedLocked[norm] = true
                if filter.dungeons[norm] ~= false then
                    filter.dungeons[norm] = false
                    changed = true
                end
            end
        else
            -- Niet (meer) locked: als WIJ 'm ooit auto-uitvinkten → weer AANVINKEN (default),
            -- zodat niet-locked instances altijd aangevinkt staan. Handmatige unchecks van
            -- niet-locked dungeons (die wij niet beheren) blijven ongemoeid.
            if autoUncheckedLocked[norm] then
                autoUncheckedLocked[norm] = nil
                if filter.dungeons[norm] == false then
                    filter.dungeons[norm] = nil -- nil = aangevinkt (default)
                    changed = true
                end
            end
        end
    end
    return changed
end

------------------------------------------------------------
-- Result gathering + filtering
------------------------------------------------------------
-- Build one list entry from a search result. Shared by the premade-browser search
-- (gatherAll) and the Social Queue gather (gatherSocialGroups), so both produce rows
-- with identical shape/behavior. Returns nil when the result has no usable activity.
local function buildEntryFromSearchInfo(resultID, info, partyLocks, social)
    local activityID = info.activityID
    if not activityID and type(info.activityIDs) == "table" then
        activityID = info.activityIDs[1]
    end
    if not activityID then return nil end
    local act = nil
    if type(C_LFGList.GetActivityInfoTable) == "function" then
        local okA, a2 = pcall(C_LFGList.GetActivityInfoTable, activityID, info.questID, info.isWarMode)
        if okA and type(a2) == "table" then act = a2 end
    end
    local dungeon = (act and firstNonEmpty(act.fullName, act.shortName, act.name)) or "?"
    local norm = normalizeName(dungeon)
    local appStatus = nil
    if type(C_LFGList.GetApplicationInfo) == "function" then
        local okApp, _id, aStat = pcall(C_LFGList.GetApplicationInfo, resultID)
        if okApp then appStatus = aStat end
    end
    local fLeader = shortLower(info.leaderName)
    local dgDiff = classifyDifficulty(act)
    -- Weekly lockout only applies to base Mythic (M0). Mythic+/Normal/Heroic have none.
    -- A base-Mythic dungeon is "locked for the group" if YOU or any synced party
    -- member is already saved to it (the group can't complete it together).
    local youLocked = (dgDiff == "MYTHIC") and isDungeonLocked(dungeon) or false
    local partyLockName = (dgDiff == "MYTHIC") and partyLocks[norm] or nil
    local dgLocked = youLocked or (partyLockName ~= nil)
    local ldrRio, ldrIlvl, ldrClass = leaderRioIlvl(resultID, info)
    -- Deserter/leaver flag on the group LEADER (Blizzard marks these with a warning triangle).
    -- The search-result leader info carries isLeaver; readable except in chat-messaging lockdown,
    -- so pcall + a plain boolean coerce is enough (never compare a possibly-secret value).
    local ldrLeaver = false
    if type(C_LFGList.GetSearchResultLeaderInfo) == "function" then
        local okL, li = pcall(C_LFGList.GetSearchResultLeaderInfo, resultID)
        if okL and type(li) == "table" then ldrLeaver = li.isLeaver and true or false end
    end
    return {
        class = ldrClass,
        friendKind = (fLeader and friendNames[fLeader]) or nil,
        social = social or nil,
        resultID = resultID,
        dungeon = dungeon,
        dungeonNorm = norm,
        groupName = info.name or "",
        leader = tostring(info.leaderName or ""),
        diff = dgDiff,
        keyLevel = (dgDiff == "MYTHICPLUS") and parseKeyLevel(info) or nil,
        locked = dgLocked,
        lockedBy = youLocked and "you" or partyLockName,
        num = tonumber(info.numMembers) or 0,
        comp = compositionOf(resultID),
        -- The playstyle word shown in listings ("Relaxed"/"Competitive"/…) is the GENERAL
        -- playstyle (Enum.LFGEntryGeneralPlaystyle: 1=Learning 2=FunRelaxed 3=FunSerious
        -- 4=Expert), which Blizzard renders via GetGeneralPlaystyleString. Our Playstyle filter
        -- labels (PLAYSTYLES: Learning/Relaxed/Competitive/Carry = 1..4) map to THIS enum — so
        -- read generalPlaystyle, not info.playstyle (that's the unrelated Standard/Casual/Hardcore
        -- axis, which made "strip Competitive" hide the wrong groups).
        playstyle = tonumber(info.generalPlaystyle) or 0,
        leaderScore = ldrRio,
        ilvl = ldrIlvl,
        leaderLeaver = ldrLeaver,
        appStatus = appStatus,
        note = tostring(info.comment or ""),
        ageSec = tonumber(info.age) or 0,
        region = (type(MythicMate_RegionForLeader) == "function")
            and MythicMate_RegionForLeader(info.leaderName) or nil,
    }
end

-- Compact "listed X ago" for at-a-glance context (OAK shows listing age).
local function formatAge(sec)
    sec = tonumber(sec) or 0
    if sec <= 0 then return "" end
    if sec < 60 then return "just now" end
    if sec < 3600 then return math.floor(sec / 60) .. "m" end
    if sec < 86400 then return math.floor(sec / 3600) .. "h" end
    return math.floor(sec / 86400) .. "d"
end

local function gatherAll()
    local out = {}
    if type(C_LFGList) ~= "table" or type(C_LFGList.GetSearchResults) ~= "function"
       or type(C_LFGList.GetSearchResultInfo) ~= "function" then
        lastDiag = "C_LFGList API missing"
        return out
    end
    local ids = {}
    -- Prefer GetFilteredSearchResults (12.x): the same result list, but with Blizzard's
    -- ENGINE-side text filter applied (whatever is typed in the secure Group Finder search
    -- box, e.g. "8-10"). That engine filter is the ONLY way to filter on keystone level now
    -- that listing titles are render-only kstrings (see the kstring note at parseKeyLevel).
    local srcTag = "raw"
    local ok, a, b
    if type(C_LFGList.GetFilteredSearchResults) == "function" then
        ok, a, b = pcall(C_LFGList.GetFilteredSearchResults)
        srcTag = "filt"
    else
        ok, a, b = pcall(C_LFGList.GetSearchResults)
    end
    if ok then
        if type(a) == "table" then ids = a
        elseif type(b) == "table" then ids = b end
    end

    -- Keep your own ACTIVE applications visible, even when the engine text filter (e.g. "8-10")
    -- would exclude the group you applied to. We append only applied/invited/inviteaccepted IDs
    -- that aren't already in the filtered set. IMPORTANT: filter by status — GetApplications()
    -- also returns finished ones (declined/cancelled/timedout), and because the Key filter lives
    -- ENGINE-side (not in passesFilter), merging those would let a stale +2 you once applied to
    -- slip past an 8-10 filter with no [Applied] tag. Only live applications get the pass.
    local nApps = 0
    if type(C_LFGList.GetApplications) == "function" then
        local okApp, apps = pcall(C_LFGList.GetApplications)
        if okApp and type(apps) == "table" then
            local present = {}
            for _, rid in ipairs(ids) do present[rid] = true end
            for _, rid in ipairs(apps) do
                if not present[rid] then
                    local active = false
                    if type(C_LFGList.GetApplicationInfo) == "function" then
                        local okS, _id, st = pcall(C_LFGList.GetApplicationInfo, rid)
                        if okS then
                            local s = st and tostring(st) or ""
                            active = (s == "applied" or s == "invited" or s == "inviteaccepted")
                        end
                    end
                    if active then
                        present[rid] = true
                        ids[#ids + 1] = rid
                        nApps = nApps + 1
                    end
                end
            end
        end
    end

    local partyLocks = partyLockedDungeons()
    local resolved, nDelisted, nNoAct, nInfoFail = 0, 0, 0, 0
    for _, resultID in ipairs(ids) do
        local okI, info = pcall(C_LFGList.GetSearchResultInfo, resultID)
        if not okI or type(info) ~= "table" then
            nInfoFail = nInfoFail + 1
        elseif info.isDelisted then
            nDelisted = nDelisted + 1
        end
        if okI and type(info) == "table" and not info.isDelisted then
            local e = buildEntryFromSearchInfo(resultID, info, partyLocks, false)
            if e then
                resolved = resolved + 1
                out[#out + 1] = e
            else
                nNoAct = nNoAct + 1
            end
        end
    end
    lastDiag = ("%s=%d ok=%d delisted=%d noAct=%d infoFail=%d apps=%d"):format(srcTag, #ids, resolved, nDelisted, nNoAct, nInfoFail, nApps)
    return out
end

-- Resolve a friendly display name for a Social Queue group (the friend/guildmate who is queued).
-- Search-result leaderName is frequently EMPTY for Quick Join entries — that's why the alert used
-- to show "?". C_SocialQueue exposes the actual group members, and Blizzard's relationship resolver
-- (or the GUID) turns them into a Battle.net / character / guild name.
local function socialGroupLeaderName(groupGUID)
    if not groupGUID or type(C_SocialQueue) ~= "table" or type(C_SocialQueue.GetGroupMembers) ~= "function" then
        return nil
    end
    local okM, members = pcall(C_SocialQueue.GetGroupMembers, groupGUID)
    if not okM or type(members) ~= "table" then return nil end
    for _, m in ipairs(members) do
        local guid = (type(m) == "table") and m.guid or nil
        if guid then
            if type(_G.SocialQueueUtil_GetRelationshipInfo) == "function" then
                local okR, name = pcall(_G.SocialQueueUtil_GetRelationshipInfo, guid)
                if okR and type(name) == "string" and name ~= "" then return name end
            end
            if type(GetPlayerInfoByGUID) == "function" then
                local okP, _, _, _, _, _, name = pcall(GetPlayerInfoByGUID, guid)
                if okP and type(name) == "string" and name ~= "" then return name end
            end
        end
    end
    return nil
end

-- Social Queue groups (the same source Blizzard's "Quick Join" uses): premade groups your
-- Battle.net/character friends and guildmates are queued in. These come from C_SocialQueue
-- (read-only, NOT hardware-event-gated), independent of our own premade search — so a friend's
-- group that's under a different category/filter than we searched still shows up here.
-- Only LFGList queues are included (they map to a search result we can apply to on click).
local function gatherSocialGroups()
    local out = {}
    if type(C_SocialQueue) ~= "table" or type(C_SocialQueue.GetAllGroups) ~= "function"
       or type(C_SocialQueue.GetGroupQueues) ~= "function"
       or type(C_LFGList) ~= "table" or type(C_LFGList.GetSearchResultInfo) ~= "function" then
        return out
    end
    local okG, guids = pcall(C_SocialQueue.GetAllGroups)
    if not okG or type(guids) ~= "table" then return out end

    local partyLocks = partyLockedDungeons()
    local seenResult = {}
    for _, groupGUID in ipairs(guids) do
        local okQ, queues = pcall(C_SocialQueue.GetGroupQueues, groupGUID)
        if okQ and type(queues) == "table" then
            for _, q in ipairs(queues) do
                local qd = q and q.queueData
                local resultID = (type(qd) == "table" and qd.queueType == "lfglist") and qd.lfgListID or nil
                if resultID and not seenResult[resultID] then
                    seenResult[resultID] = true
                    local okI, info = pcall(C_LFGList.GetSearchResultInfo, resultID)
                    if okI and type(info) == "table" and not info.isDelisted then
                        local e = buildEntryFromSearchInfo(resultID, info, partyLocks, true)
                        if e then
                            e.socialGUID = groupGUID
                            -- Quick Join search-result leaderName is usually empty → fill it from
                            -- the actual social-queue member so rows + alerts show a real name.
                            if not e.leader or e.leader == "" then
                                local nm = socialGroupLeaderName(groupGUID)
                                if nm and nm ~= "" then e.leader = nm end
                            end
                            out[#out + 1] = e
                        end
                    end
                end
            end
        end
    end
    return out
end

-- An application you can still act on / are waiting on — must always stay visible.
local function isActiveApp(status)
    if not status then return false end
    local s = tostring(status)
    return s == "applied" or s == "invited" or s == "inviteaccepted"
end

local function passesFilter(e)
    -- Your own active applications ALWAYS show, regardless of any filter (difficulty, dungeon,
    -- role, rating, or the engine Key text). You applied to it — you must be able to see and
    -- manage it. Mirrors Blizzard, which appends applications on top of the filtered results.
    if isActiveApp(e.appStatus) then return true end

    -- Social Queue groups (friends/guild via Quick Join) obey the same content/role filters as
    -- regular results (difficulty, dungeon, role-fit, region, rating, playstyle) and are marked
    -- with a star + pinned to the top (see frank()). EXCEPTION: "Hide locked" must never hide a
    -- friend's group — you still want to see what your friends are doing even for a dungeon you
    -- are saved to (it stays visibly tagged "(locked)").
    if filter.hideLocked and e.locked and not e.social then return false end
    if not filter.diff[e.diff] then return false end
    if e.dungeonNorm and filter.dungeons[e.dungeonNorm] == false then return false end
    if e.region and filter.hideRegions and filter.hideRegions[e.region] then return false end

    -- Ignored leaders (right-click a row to toggle).
    local ldrShort = shortLower(e.leader)
    if ldrShort and ignoredLeaders[ldrShort] then return false end

    -- Role fit. In a PARTIAL party this is always applied: only show groups whose open slots can
    -- seat your whole party's roles (you can't join a group that can't take all of you). With a
    -- FULL party (5+) no listing can ever fit you, so the rule would blank the whole list and
    -- look broken — skip it there (you're browsing/relisting, not joining as five). Solo it's
    -- opt-in via the "Fits my role" filter.
    local inParty = (type(IsInGroup) == "function") and IsInGroup() or false
    local fitApplies = (inParty and not partyIsFull()) or (not inParty and filter.reqMyRole)
    if fitApplies and not groupNeedsMyRole(e) then
        lastRoleFitHidden = lastRoleFitHidden + 1
        return false
    end

    local c = e.comp
    local t = c and c.t or 0
    local h = c and c.h or 0
    local d = c and c.d or 0
    if filter.reqHasTank and t <= 0 then return false end
    if filter.reqHasHealer and h <= 0 then return false end
    if filter.reqTankAvail and t >= 1 then return false end
    if filter.reqHealerAvail and h >= 1 then return false end
    if filter.reqDamageAvail and d >= 3 then return false end

    if filter.minRating > 0 and e.leaderScore < filter.minRating then return false end
    if filter.maxRating and filter.maxRating > 0 and e.leaderScore > filter.maxRating then return false end

    -- Keystone-level range: only constrains Mythic+ groups. We can only read the advertised level
    -- from the title/comment, which many groups omit — so a group whose level is UNKNOWN stays
    -- visible (hiding them would empty the list). Only a KNOWN level outside the range is hidden.
    if e.diff == "MYTHICPLUS" and e.keyLevel and ((filter.minKey and filter.minKey > 0) or (filter.maxKey and filter.maxKey > 0)) then
        if filter.minKey and filter.minKey > 0 and e.keyLevel < filter.minKey then return false end
        if filter.maxKey and filter.maxKey > 0 and e.keyLevel > filter.maxKey then return false end
    end

    if e.playstyle and e.playstyle > 0 and not filter.playstyle[e.playstyle] then return false end
    return true
end

local function buildFiltered()
    -- Quick Join tab: show ONLY Social Queue groups (friends/guild), unfiltered by content so you
    -- always see what your friends are doing. We still drop ignored leaders. No premade search is
    -- needed here — the data comes from C_SocialQueue (read-only, not hardware-gated).
    if viewMode == "quickjoin" then
        local socials = gatherSocialGroups()
        lastSocialCount = #socials
        local out = {}
        for _, e in ipairs(socials) do
            e.social = true
            local ldrShort = shortLower(e.leader)
            if not (ldrShort and ignoredLeaders[ldrShort]) then
                out[#out + 1] = e
            end
        end
        lastSocialShown = #out
        lastPartyLockCount = 0
        table.sort(out, function(x, y)
            local xs = x.appStatus and tostring(x.appStatus) or ""
            local ys = y.appStatus and tostring(y.appStatus) or ""
            local xa = (xs == "applied" or xs == "invited" or xs == "inviteaccepted") and 1 or 0
            local ya = (ys == "applied" or ys == "invited" or ys == "inviteaccepted") and 1 or 0
            if xa ~= ya then return xa > ya end -- your pending applications first
            local xr, yr = x.leaderScore or 0, y.leaderScore or 0
            if xr ~= yr then return xr > yr end
            return (x.num or 0) > (y.num or 0)
        end)
        return out
    end

    local all = gatherAll()
    -- Merge Social Queue groups (Quick Join source). If a friend's group is already in our
    -- search results, just flag that existing row as social instead of adding a duplicate.
    local byResult = {}
    for _, e in ipairs(all) do
        if e.resultID then byResult[e.resultID] = e end
    end
    local socials = gatherSocialGroups()
    lastSocialCount = #socials
    for _, e in ipairs(socials) do
        local existing = e.resultID and byResult[e.resultID]
        if existing then
            existing.social = true
            existing.socialGUID = existing.socialGUID or e.socialGUID
        else
            if e.resultID then byResult[e.resultID] = e end
            all[#all + 1] = e
        end
    end
    -- Best-effort re-parse of the M+ key level from the stored title/note. NOTE: on 12.x this
    -- yields nil for nearly all listings — titles are render-only kstrings (see parseKeyLevel),
    -- the earlier "reads fine by then" theory was wrong (the diagnostic itself was rendered
    -- through SetText, which substitutes kstrings). Kept as a fallback for readable strings;
    -- real key filtering happens engine-side via the hosted Blizzard search box.
    for _, e in ipairs(all) do
        if e.diff == "MYTHICPLUS" then
            e.keyLevel = parseKeyLevel({ name = e.groupName, comment = e.note })
        end
    end

    lastKeyTotal, lastKeyReadable = 0, 0
    for _, e in ipairs(all) do
        if e.diff == "MYTHICPLUS" then
            lastKeyTotal = lastKeyTotal + 1
            if e.keyLevel then lastKeyReadable = lastKeyReadable + 1 end
        end
    end
    if _G.MythicMateDebugPremade then
        print(("|cffc868ffWL keyfilter:|r Key range = %s..%s   readable=%d/%d M+")
            :format(tostring(filter.minKey), tostring(filter.maxKey), lastKeyReadable, lastKeyTotal))
        local isSecret = _G.issecretvalue
        local dbgN = 0
        for _, e in ipairs(all) do
            if e.diff == "MYTHICPLUS" and dbgN < 8 then
                dbgN = dbgN + 1
                local secretTag = (type(isSecret) == "function" and isSecret(e.groupName)) and "SECRET " or ""
                -- Escape "|" as "||" so a kstring title prints as its literal token (e.g.
                -- "|Ks123|k") instead of being substituted by the renderer — this is how you
                -- SEE that the title bytes contain no key level.
                local okRaw, raw = pcall(function() return tostring(e.groupName):gsub("|", "||") end)
                print(("  name=%s%q  key=%s  pass=%s")
                    :format(secretTag, okRaw and raw or "<unreadable>", tostring(e.keyLevel), tostring(passesFilter(e))))
            end
        end
    end

    local out = {}
    lastPartyLockCount = 0
    lastRoleFitHidden = 0 -- reset here (after the debug dump, which also calls passesFilter)
    local socialShown = 0
    for _, e in ipairs(all) do
        -- Track how many groups are affected by a PARTY member's lockout (not your own), so
        -- the status line can explain why they're hidden/marked.
        if e.locked and e.lockedBy and e.lockedBy ~= "you" then
            lastPartyLockCount = lastPartyLockCount + 1
        end
        if passesFilter(e) then
            out[#out + 1] = e
            if e.social then socialShown = socialShown + 1 end
        end
    end
    lastSocialShown = socialShown
    local function frank(e)
        local r = 0
        local s = e.appStatus and tostring(e.appStatus) or ""
        if s == "applied" or s == "invited" or s == "inviteaccepted" then r = 5 end -- your applications on top
        if e.social and r < 4 then r = 4 end -- friends' groups (Quick Join) pinned just below your applications
        if e.friendKind == "BNET" and r < 3 then r = 3 end
        if (e.friendKind == "GUILD" or e.friendKind == "FRIEND") and r < 2 then r = 2 end
        return r
    end
    table.sort(out, function(x, y)
        local fx, fy = frank(x), frank(y)
        if fx ~= fy then return fx > fy end -- friends always pinned to the top
        -- OAK-style "best fit" default: groups that can seat my/party role first, then the
        -- highest leader M+ rating, then the fullest group. Multi-key, so handle it up front.
        if sortCol == "fit" then
            local xf = groupNeedsMyRole(x) and 1 or 0
            local yf = groupNeedsMyRole(y) and 1 or 0
            if xf ~= yf then return xf > yf end
            local xr, yr = x.leaderScore or 0, y.leaderScore or 0
            if xr ~= yr then return xr > yr end
            return (x.num or 0) > (y.num or 0)
        end
        local a, b
        if sortCol == "diff" then a, b = tostring(x.diff), tostring(y.diff)
        elseif sortCol == "group" then a, b = tostring(x.groupName):lower(), tostring(y.groupName):lower()
        elseif sortCol == "leader" then a, b = tostring(x.leader):lower(), tostring(y.leader):lower()
        elseif sortCol == "num" then a, b = x.num or 0, y.num or 0
        elseif sortCol == "rio" then a, b = x.leaderScore or 0, y.leaderScore or 0
        elseif sortCol == "ilvl" then a, b = x.ilvl or 0, y.ilvl or 0
        else a, b = tostring(x.dungeon):lower(), tostring(y.dungeon):lower() end
        if a == b then return (x.num or 0) > (y.num or 0) end
        if sortAsc then return a < b else return a > b end
    end)
    return out
end

------------------------------------------------------------
-- Sign up
------------------------------------------------------------
-- Forward decl: the sign-up helpers below (and a couple of async callbacks above) reference
-- refreshLFG; declaring it here binds them to the real local defined much further down.
local refreshLFG
-- Configurable default note (set via /wl lfgnote <text>). Prefilled into Blizzard's
-- application dialog so you don't retype the same note for every group.
local function getAutoNote()
    local st = MythicMateDB and MythicMateDB.settings
    local n = st and st.lfgAutoNote
    if type(n) == "string" and n ~= "" then return n end
    return nil
end

-- Best-effort: drop the note into the dialog's edit box (path varies across builds).
local function prefillApplicationNote()
    local note = getAutoNote()
    if not note then return end
    local dlg = _G.LFGListApplicationDialog
    if not dlg then return end
    local eb = (dlg.Description and dlg.Description.EditBox)
        or (dlg.Comment and dlg.Comment.EditBox)
        or dlg.EditBox
    if eb and type(eb.SetText) == "function" then
        -- Only prefill when the user hasn't already typed something.
        local cur = (type(eb.GetText) == "function") and eb:GetText() or ""
        if not cur or cur == "" then
            pcall(eb.SetText, eb, note)
        end
    end
end

local function signUp(resultID)
    if not resultID then return end
    if isInDungeon() then
        print("|cffc868ffMythicMate:|r you're in a dungeon — LFG/signing up is disabled.")
        return
    end
    -- Preferred: Blizzard's own application dialog, which already has role selection AND a
    -- note field for the leader. We raise it above our panel elsewhere.
    if type(_G.LFGListApplicationDialog_Show) == "function" and _G.LFGListApplicationDialog then
        local ok = pcall(_G.LFGListApplicationDialog_Show, _G.LFGListApplicationDialog, resultID)
        if ok then
            prefillApplicationNote()
            return
        end
    end
    -- Fallback: direct apply with your current roles (no note).
    if type(C_LFGList) == "table" and type(C_LFGList.ApplyToGroup) == "function" then
        local ok = pcall(function()
            if type(GetLFGRoles) == "function" then
                local _, tank, healer, dps = GetLFGRoles()
                C_LFGList.ApplyToGroup(resultID, tank, healer, dps)
            else
                C_LFGList.ApplyToGroup(resultID)
            end
        end)
        if ok then
            print("|cffc868ffMythicMate:|r signed up (pending leader response).")
            return
        end
    end
    if type(_G.LFGListSearchPanel_SelectResult) == "function" and _G.LFGListFrame then
        pcall(_G.LFGListSearchPanel_SelectResult, _G.LFGListFrame.SearchPanel, resultID)
        print("|cffc868ffMythicMate:|r selected in Premade Groups — click Sign Up there.")
    end
end

------------------------------------------------------------
-- Quick sign-up (OAK-style): a compact tank/heal/dps picker that applies directly via
-- C_LFGList.ApplyToGroup (no Blizzard dialog). Opt-in via the panel's "Quick" toggle; the
-- Sign-up runs from the button's OnClick (a real hardware event), which ApplyToGroup needs.
------------------------------------------------------------
local signupPopup -- forward (single reusable frame)

local function quickApply(resultID, tank, healer, dps)
    if not resultID then return false end
    if isInDungeon() then
        print("|cffc868ffMythicMate:|r you're in a dungeon — signing up is disabled.")
        return false
    end
    if not (tank or healer or dps) then
        print("|cffc868ffMythicMate:|r pick at least one role.")
        return false
    end
    if type(C_LFGList) ~= "table" or type(C_LFGList.ApplyToGroup) ~= "function" then return false end
    local ok = pcall(C_LFGList.ApplyToGroup, resultID, tank and true or false, healer and true or false, dps and true or false)
    if ok then
        local roles = {}
        if tank then roles[#roles + 1] = "Tank" end
        if healer then roles[#roles + 1] = "Healer" end
        if dps then roles[#roles + 1] = "DPS" end
        print("|cffc868ffMythicMate:|r signed up as " .. table.concat(roles, "/") .. " (pending leader response).")
        return true
    end
    return false
end

local function ensureSignupPopup()
    if signupPopup then return signupPopup end
    local f = CreateFrame("Frame", "MythicMate_QuickSignupPopup", UIParent, "BackdropTemplate")
    f:SetSize(190, 136)
    f:SetFrameStrata("TOOLTIP")
    f:SetToplevel(true)
    f:EnableMouse(true)
    f:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        tile = true, tileSize = 16, edgeSize = 12,
        insets = { left = 3, right = 3, top = 3, bottom = 3 },
    })
    f:SetBackdropColor(0.05, 0.06, 0.10, 1)
    f:SetBackdropBorderColor(0.5, 0.4, 0.85, 1)

    -- Dungeon + M+ level of the group you're signing up for (set per open in openSignupPopup).
    f.dungeonLine = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    f.dungeonLine:SetPoint("TOPLEFT", 10, -8)
    f.dungeonLine:SetPoint("TOPRIGHT", -10, -8)
    f.dungeonLine:SetJustifyH("LEFT")
    if f.dungeonLine.SetWordWrap then f.dungeonLine:SetWordWrap(false) end

    f.title = f:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    f.title:SetPoint("TOPLEFT", 10, -24)
    f.title:SetText("Sign up as:")

    local roleDefs = { { "tank", "Tank" }, { "healer", "Healer" }, { "dps", "DPS" } }
    f.checks = {}
    local yy = -42
    for _, rd in ipairs(roleDefs) do
        local cb = CreateFrame("CheckButton", nil, f, "UICheckButtonTemplate")
        cb:SetSize(20, 20)
        cb:SetPoint("TOPLEFT", 10, yy)
        cb.text = cb:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        cb.text:SetPoint("LEFT", cb, "RIGHT", 2, 0)
        cb.text:SetText(rd[2])
        f.checks[rd[1]] = cb
        yy = yy - 20
    end

    f.go = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
    f.go:SetSize(60, 20)
    f.go:SetPoint("BOTTOMLEFT", 10, 8)
    f.go:SetText("Sign up")
    f.go:SetScript("OnClick", function()
        local rid = f._resultID
        if not rid then f:Hide(); return end
        local t = f.checks.tank:GetChecked() and true or false
        local h = f.checks.healer:GetChecked() and true or false
        local d = f.checks.dps:GetChecked() and true or false
        if quickApply(rid, t, h, d) then
            f:Hide()
            if refreshLFG then refreshLFG() end
        end
    end)

    f.cancel = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
    f.cancel:SetSize(60, 20)
    f.cancel:SetPoint("BOTTOMRIGHT", -10, 8)
    f.cancel:SetText("Cancel")
    f.cancel:SetScript("OnClick", function() f:Hide() end)

    f:Hide()
    signupPopup = f
    return f
end

-- Show the quick-signup picker for a result, anchored near the clicked row, pre-checked from
-- your current LFG roles (Blizzard's role checkboxes).
local function openSignupPopup(resultID, anchor, dungeon, keyLevel, groupTitle)
    if not resultID then return end
    local f = ensureSignupPopup()
    f._resultID = resultID
    -- Header: which dungeon you're signing up for, plus the M+ key level when we know it.
    local dn = tostring(displayName(dungeon or "") or "")
    dn = dn:gsub("%s*%b()%s*$", "")
    if dn == "" then dn = "Group" end
    if tonumber(keyLevel) and tonumber(keyLevel) > 0 then
        f.dungeonLine:SetText("|cffffd200" .. dn .. "|r  |cff66ff66+" .. tostring(keyLevel) .. "|r")
    else
        -- No readable level (12.x kstring titles). Append the raw listing title instead: Lua
        -- can't read it, but the renderer substitutes the real "+11 Competitive" on screen.
        local shown = false
        if type(groupTitle) == "string" and groupTitle ~= "" then
            shown = pcall(function()
                f.dungeonLine:SetText("|cffffd200" .. dn .. "|r  |cffd8d8d8" .. groupTitle .. "|r")
            end)
        end
        if not shown then
            f.dungeonLine:SetText("|cffffd200" .. dn .. "|r")
        end
    end
    local t, h, d = playerRoles()
    f.checks.tank:SetChecked(t)
    f.checks.healer:SetChecked(h)
    f.checks.dps:SetChecked(d)
    f:ClearAllPoints()
    if anchor then
        f:SetPoint("TOPLEFT", anchor, "BOTTOMLEFT", 20, -2)
    else
        f:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
    end
    f:Show()
    f:Raise()
end

-- Trigger a fresh dungeon search using Blizzard's own search engine (correct params),
-- so GetSearchResults() returns a full, current result set to mirror.
-- The Group Finder search UI (LFGListFrame) is a load-on-demand addon; without it
-- LFGListSearchPanel_DoSearch/SetCategory don't exist. Load it once so a cold-start
-- search can use Blizzard's own (correctly-parameterised) search engine.
local function ensureGroupFinderLoaded()
    if _G.LFGListFrame then return end
    local load = (C_AddOns and C_AddOns.LoadAddOn) or _G.LoadAddOn
    if type(load) == "function" then
        pcall(load, "Blizzard_GroupFinder")
        pcall(load, "Blizzard_PVPUI")
    end
end

local DUNGEON_CATEGORY = 2 -- Group Finder category id for Dungeons

-- Zorg dat Blizzards SearchPanel klaarstaat om te zoeken: addon geladen + Dungeons-category
-- gezet. LFGListSearchPanel_DoSearch heeft een categoryID nodig; die moet vóór de search al
-- staan, anders draait de eerste zoekpoging niet (en zien we alleen stale resultaten).
-- Vroeg (na login) aanroepen zorgt dat de eerste tab-klik meteen resultaten oplevert.
local function prepareSearchPanel()
    ensureGroupFinderLoaded()
    local sp = _G.LFGListFrame and _G.LFGListFrame.SearchPanel
    if sp and not sp.categoryID and type(_G.LFGListSearchPanel_SetCategory) == "function" then
        pcall(_G.LFGListSearchPanel_SetCategory, sp, DUNGEON_CATEGORY, 0, 0)
    end
    return sp
end

-- BELANGRIJK: C_LFGList.Search is sinds patch 7.2 "hardware event" beveiligd — het mag
-- alleen draaien als direct gevolg van een muisklik/toetsaanslag, NIET vanuit C_Timer/
-- OnUpdate/OnEvent. Daarom zoeken we uitsluitend via een echte klik (tab/Search/Refresh)
-- en gebruiken timers alleen om de reeds ontvangen resultaten te herlezen (dat mag wel).
-- We gebruiken Blizzards LFGListSearchPanel_DoSearch (juiste signature voor deze client);
-- de rauwe C_LFGList.Search met gegokte params wist de lijst (zie CLAUDE.md) → niet doen.
local function triggerSearch()
    if type(C_LFGList) ~= "table" then return false end
    if isInDungeon() then lastSearchDiag = "search=IN_DUNGEON"; return false end -- geen LFG in een dungeon
    local sp = prepareSearchPanel()
    if sp and sp.categoryID and type(_G.LFGListSearchPanel_DoSearch) == "function" then
        if pcall(_G.LFGListSearchPanel_DoSearch, sp) then
            lastSearchDiag = "search=DoSearch cat=" .. tostring(sp.categoryID)
            return true
        end
    end
    -- Geen category/DoSearch beschikbaar: NIET raden met C_LFGList.Search (wist resultaten).
    lastSearchDiag = "search=NOTREADY sp=" .. tostring(sp ~= nil) .. " catID=" .. tostring(sp and sp.categoryID)
    return false
end

------------------------------------------------------------
-- Hosted Blizzard search box = the ONLY working key-level filter on 12.x
------------------------------------------------------------
-- Listing titles are render-only kstrings (see parseKeyLevel): no addon can read or compare
-- the advertised key level. Blizzard's own Group Finder search box CAN filter on it ("8-10",
-- "+9") because the matching runs engine-side, surfaced through GetFilteredSearchResults().
-- The box refuses programmatic SetText (securityDisableSetText in LFGList.xml), so the only
-- way to offer the feature in our UI is to host Blizzard's REAL SearchBox in our toolbar:
-- the user's keystrokes go into the secure editbox (engine-trusted regardless of who parents
-- it), and gatherAll() mirrors the engine-filtered result list.
local blizzBoxHome = nil -- original parent/points/scripts, captured once at first embed

local function blizzSearchBox()
    return _G.LFGListFrame and _G.LFGListFrame.SearchPanel and _G.LFGListFrame.SearchPanel.SearchBox
end

local function isBlizzBoxEmbedded()
    return (blizzBoxHome and blizzBoxHome.embedded) and true or false
end

-- Current engine text filter (what the user typed in the secure box), "" when none/unknown.
-- Read regardless of embedding: text typed in the real Group Finder filters our list too,
-- since gatherAll mirrors GetFilteredSearchResults.
local function engineFilterText()
    local sb = blizzSearchBox()
    if not sb then return "" end
    local ok, t = pcall(sb.GetText, sb)
    return (ok and type(t) == "string") and t or ""
end

local function releaseBlizzSearchBox()
    local sb = blizzSearchBox()
    if not (sb and isBlizzBoxEmbedded()) then return end
    blizzBoxHome.embedded = false
    pcall(function()
        sb:ClearFocus()
        sb:Hide()
        sb:SetParent(blizzBoxHome.parent)
        sb:ClearAllPoints()
        for _, pt in ipairs(blizzBoxHome.points) do
            sb:SetPoint(pt[1], pt[2], pt[3], pt[4], pt[5])
        end
        sb:SetSize(blizzBoxHome.width, blizzBoxHome.height)
        for name, fn in pairs(blizzBoxHome.scripts) do sb:SetScript(name, fn) end
        if sb.Instructions and blizzBoxHome.instructions then
            sb.Instructions:SetText(blizzBoxHome.instructions)
        end
        sb:Show()
    end)
end

local BLIZZBOX_SCRIPTS = { "OnEnterPressed", "OnTabPressed", "OnArrowPressed", "OnTextChanged", "OnEditFocusGained", "OnEditFocusLost" }

local function embedBlizzSearchBox(p)
    ensureGroupFinderLoaded()
    prepareSearchPanel()
    local sb = blizzSearchBox()
    if not (sb and p and p.qfBoxes and p.qfBoxes.keyLbl) then return false end
    -- Real Group Finder open: leave its search box where it belongs.
    if _G.PVEFrame and _G.PVEFrame:IsShown() then return false end
    if isBlizzBoxEmbedded() then return true end
    if not blizzBoxHome then
        blizzBoxHome = {
            parent = sb:GetParent(), width = sb:GetWidth(), height = sb:GetHeight(),
            points = {}, scripts = {},
            instructions = (sb.Instructions and sb.Instructions.GetText) and sb.Instructions:GetText() or nil,
        }
        for i = 1, sb:GetNumPoints() do
            blizzBoxHome.points[i] = { sb:GetPoint(i) }
        end
        for _, name in ipairs(BLIZZBOX_SCRIPTS) do
            blizzBoxHome.scripts[name] = sb:GetScript(name)
        end
    end
    local ok = pcall(function()
        sb:ClearFocus()
        sb:SetParent(p)
        sb:ClearAllPoints()
        -- Sits exactly in our old Key box slot (that box is hidden while hosted). A
        -- SearchBoxTemplate reserves ~20px left (magnifier) + ~16px right (clear button), so
        -- it needs more width than the plain legacy box to show "8-10" without clipping. The
        -- legacy box is widened to match (see tbRangeBox call) so the Rio field stays aligned.
        sb:SetPoint("LEFT", p.qfBoxes.keyLbl, "RIGHT", 8, 0)
        sb:SetSize(100, 20)
        -- Placeholder = the last-used range (persisted). We cannot programmatically re-fill the
        -- box after a /reload — it's flagged securityDisableSetText, so any SetText from addon
        -- code is blocked. Showing the saved value as the grey hint is the best we can do: the
        -- user sees "8-10" and re-applies with one keystroke + Enter. Within a session the box
        -- keeps its real text (we only reparent it), so this hint only surfaces after a reload.
        if sb.Instructions then
            sb.Instructions:SetText((filter.keyText ~= "") and filter.keyText or "8-10")
        end
        -- While hosted, the box's own XML handlers would error: they expect GetParent() to be
        -- Blizzard's SearchPanel (autocomplete etc.). Replace them with panel-independent
        -- equivalents; the typed TEXT stays engine-trusted (it enters via secure hardware
        -- input, not via Lua), so the title filter keeps working.
        sb:SetScript("OnTextChanged", function(box)
            if type(_G.SearchBoxTemplate_OnTextChanged) == "function" then _G.SearchBoxTemplate_OnTextChanged(box) end
            -- Remember what was typed so it survives a reload (as the placeholder hint).
            local okT, t = pcall(box.GetText, box)
            if okT and type(t) == "string" then
                filter.keyText = t
                saveFilterToDB()
            end
            -- The engine re-filters the already-received results as the text changes; a plain
            -- re-read (never a search) is enough to narrow the list live.
            scrollOffset = 0
            if refreshLFG then pcall(refreshLFG) end
        end)
        sb:SetScript("OnEnterPressed", function(box)
            -- Enter is a genuine hardware event → a fresh search with the new filter is allowed.
            triggerSearch()
            box:ClearFocus()
            if refreshLFG then pcall(refreshLFG) end
        end)
        sb:SetScript("OnTabPressed", function(box) box:ClearFocus() end)
        sb:SetScript("OnArrowPressed", nil)
        sb:SetScript("OnEditFocusGained", function(box)
            if type(_G.SearchBoxTemplate_OnEditFocusGained) == "function" then _G.SearchBoxTemplate_OnEditFocusGained(box) end
        end)
        sb:SetScript("OnEditFocusLost", function(box)
            if type(_G.SearchBoxTemplate_OnEditFocusLost) == "function" then _G.SearchBoxTemplate_OnEditFocusLost(box) end
        end)
        sb:Show()
    end)
    -- On a half-failed move, run the restore path so the box never lingers mid-flight.
    blizzBoxHome.embedded = true
    if not ok then
        releaseBlizzSearchBox()
        return false
    end
    return true
end

-- Interactie (scroll/klik op de lijst) mag ALLEEN de reeds ontvangen resultaten HERLEZEN, nooit
-- opnieuw C_LFGList.Search draaien. Ondanks dat scroll/klik hardware events zijn, wordt een search
-- van hieruit door Blizzard geblokkeerd → "blocked from an action only available to the Blizzard
-- UI"-popup. Zoeken gebeurt uitsluitend synchroon vanuit een echte klik op de LFG-tab / Search /
-- Refresh-knop of slash+Enter (zie CLAUDE.md). Deze helper doet dus enkel een throttled refresh.
local function maybeAutoSearch()
    if isInDungeon() then return end
    local every = autoSearchInterval()
    if not every then return end -- auto-refresh uitgezet via settings
    local now = GetTime and GetTime() or 0
    if (now - lastAutoRefreshAt) < 2 then return end -- own throttle; leaves the search timestamp alone
    lastAutoRefreshAt = now
    if C_Timer and type(C_Timer.After) == "function" then
        C_Timer.After(0.0, function()
            if panel and panel:IsShown() and refreshLFG then pcall(refreshLFG) end
        end)
    end
end

------------------------------------------------------------
-- UI helpers
------------------------------------------------------------
local rebuildDungeonChecks -- forward decl (refreshLFG is forward-declared earlier)

local function diffTag(diff)
    if diff == "MYTHICPLUS" then return "|cffff8000M+|r" end
    if diff == "MYTHIC" then return "|cffa335eeM|r" end
    if diff == "HEROIC" then return "|cff0070ddH|r" end
    if diff == "NORMAL" then return "|cff1eff00N|r" end
    return "|cff888888?|r"
end

-- RaiderIO-style rarity color for an M+ score (Blizzard's native score color).
local function rioColorTag(score)
    if score and score > 0 and type(C_ChallengeMode) == "table"
       and type(C_ChallengeMode.GetDungeonScoreRarityColor) == "function" then
        local ok, col = pcall(C_ChallengeMode.GetDungeonScoreRarityColor, score)
        if ok and col and col.GenerateHexColor then
            return "|c" .. col:GenerateHexColor()
        end
    end
    return "|cff88ccff"
end

-- Class color hex tag ("|cffRRGGBB") for a class filename token (e.g. "MAGE").
local function classColorTag(class)
    if not class then return nil end
    if type(C_ClassColor) == "table" and type(C_ClassColor.GetClassColor) == "function" then
        local ok, col = pcall(C_ClassColor.GetClassColor, class)
        if ok and col and col.GenerateHexColor then return "|c" .. col:GenerateHexColor() end
    end
    local c = _G.RAID_CLASS_COLORS and _G.RAID_CLASS_COLORS[class]
    if c then
        if c.colorStr then return "|c" .. c.colorStr end
        return string.format("|cff%02x%02x%02x", (c.r or 1) * 255, (c.g or 1) * 255, (c.b or 1) * 255)
    end
    return nil
end

-- Role composition as filled/needed slots (5-man caps: 1 tank, 1 healer, 3 dps).
-- Open slots (what the group still needs) are green; filled roles are dimmed.
local function compMarkup(c)
    if not c then return "" end
    local function slot(atlas, cur, cap)
        local open = (cur or 0) < cap
        local col = open and "|cff40ff40" or "|cff707070"
        return ("|A:%s:20:20|a%s%d/%d|r"):format(atlas, col, cur or 0, cap)
    end
    return slot("groupfinder-icon-role-large-tank", c.t, 1) .. "  "
        .. slot("groupfinder-icon-role-large-heal", c.h, 1) .. "  "
        .. slot("groupfinder-icon-role-large-dps", c.d, 3)
end

local function isDeclined(appStatus)
    return appStatus ~= nil and tostring(appStatus):find("declined") ~= nil
end

local function isApplied(appStatus)
    local s = appStatus and tostring(appStatus) or ""
    return s == "applied" or s == "invited" or s == "inviteaccepted"
end

local function cancelApplication(resultID)
    if not resultID or type(C_LFGList) ~= "table" then return end
    if type(C_LFGList.CancelApplication) == "function" then
        pcall(C_LFGList.CancelApplication, resultID)
        print("|cffc868ffMythicMate:|r application cancelled.")
    end
end

-- Count your currently-open applications (Blizzard caps this at 5) and return the oldest one's
-- ID, for the OAK-style "Applied X/5" counter + Cancel-oldest button. GetApplications() returns
-- the application IDs in application order, so the first active one is the oldest.
local function activeApplications()
    if type(C_LFGList) ~= "table" or type(C_LFGList.GetApplications) ~= "function" then return 0, nil end
    local ok, apps = pcall(C_LFGList.GetApplications)
    if not ok or type(apps) ~= "table" then return 0, nil end
    local count, oldest = 0, nil
    for _, id in ipairs(apps) do
        local st
        if type(C_LFGList.GetApplicationInfo) == "function" then
            local okA, _id, aStat = pcall(C_LFGList.GetApplicationInfo, id)
            if okA then st = aStat end
        end
        local s = st and tostring(st) or ""
        if s == "applied" or s == "invited" or s == "inviteaccepted" then
            count = count + 1
            if not oldest then oldest = id end
        end
    end
    return count, oldest
end

local function appLabel(appStatus)
    if not appStatus then return nil end
    local s = tostring(appStatus)
    if s:find("declined") then return "|cffff3030[Declined]|r" end
    if s == "invited" or s == "inviteaccepted" then return "|cff40ff40[Invited]|r" end
    if s == "applied" then return "|cffffd000[Applied]|r" end
    if s == "timedout" then return "|cff999999[Timed out]|r" end
    return nil
end

local function mkCheck(parent, x, y, label, checked, onToggle)
    local cb = CreateFrame("CheckButton", nil, parent, "UICheckButtonTemplate")
    cb:SetSize(22, 22)
    cb:SetPoint("TOPLEFT", x, y)
    cb.text = cb:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    cb.text:SetPoint("LEFT", cb, "RIGHT", 2, 0)
    cb.text:SetText(label)
    cb:SetChecked(checked and true or false)
    cb:SetScript("OnClick", function(self)
        onToggle(self:GetChecked() and true or false)
        scrollOffset = 0
        if refreshLFG then refreshLFG() end
    end)
    return cb
end

local function sectionLabel(parent, x, y, text)
    local fs = parent:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    fs:SetPoint("TOPLEFT", x, y)
    fs:SetTextColor(1, 0.82, 0)
    fs:SetText(text)
    return fs
end

------------------------------------------------------------
-- Filter popout
------------------------------------------------------------
local function buildFilterPopout(p)
    if p.filterPop then return p.filterPop end
    local fp = CreateFrame("Frame", nil, p, "BackdropTemplate")
    fp:SetSize(240, 566)
    fp:SetPoint("TOPLEFT", p.filterBtn, "BOTTOMLEFT", 0, -2)
    -- Higher strata than the grid rows (which sit at the panel's DIALOG strata) so the
    -- rows never show through the popout.
    fp:SetFrameStrata("FULLSCREEN_DIALOG")
    fp:SetToplevel(true)
    fp:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        tile = true, tileSize = 16, edgeSize = 12,
        insets = { left = 3, right = 3, top = 3, bottom = 3 },
    })
    fp:SetBackdropColor(0.05, 0.06, 0.10, 1)
    fp:SetBackdropBorderColor(0.5, 0.4, 0.85, 1)
    fp:EnableMouse(true)
    fp:Hide()

    -- Click-away closes the popout: while shown, watch every mouse-down (GLOBAL_MOUSE_DOWN
    -- fires regardless of which frame is clicked) and hide unless the click lands on the
    -- popout itself. The Filter button is excluded — its own OnClick toggles the popout, and
    -- closing here first would make that toggle instantly reopen it.
    fp.closeWatcher = CreateFrame("Frame")
    fp.closeWatcher:SetScript("OnEvent", function()
        if fp:IsShown() and not fp:IsMouseOver() and not (p.filterBtn and p.filterBtn:IsMouseOver()) then
            fp:Hide()
        end
    end)
    fp:SetScript("OnShow", function() fp.closeWatcher:RegisterEvent("GLOBAL_MOUSE_DOWN") end)
    fp:SetScript("OnHide", function() fp.closeWatcher:UnregisterEvent("GLOBAL_MOUSE_DOWN") end)

    -- Reset all filters to defaults. Rebuilds the popout so every checkbox reflects the
    -- fresh state (simplest correct approach: orphan the old frame, build a new one next open).
    fp.reset = CreateFrame("Button", nil, fp, "UIPanelButtonTemplate")
    fp.reset:SetSize(60, 18)
    fp.reset:SetPoint("TOPRIGHT", -10, -8)
    fp.reset:SetText("Reset")
    fp.reset:SetScript("OnClick", function()
        resetFilterToDefaults()
        scrollOffset = 0
        if p.filterPop then p.filterPop:Hide(); p.filterPop = nil end
        if type(p.refreshQuickFilters) == "function" then p.refreshQuickFilters() end
        saveFilterToDB()
        if refreshLFG then refreshLFG() end
    end)

    local x = 12
    local y = -10

    sectionLabel(fp, x, y, "Difficulty"); y = y - 20
    for _, d in ipairs({ { "NORMAL", "Normal" }, { "HEROIC", "Heroic" }, { "MYTHIC", "Mythic" }, { "MYTHICPLUS", "Mythic+" } }) do
        mkCheck(fp, x, y, d[2], filter.diff[d[1]], function(v) filter.diff[d[1]] = v end)
        y = y - 20
    end

    y = y - 4
    mkCheck(fp, x, y, "Hide locked dungeons", filter.hideLocked, function(v)
        filter.hideLocked = v
        -- Toggling this must re-apply/restore the locked-dungeon auto-uncheck immediately, so a
        -- player saved everywhere can reveal groups by unchecking it. Refresh the dungeon list too.
        applyAutoUncheckLocked()
        if fp.dgChecks then rebuildDungeonChecks(fp) end
    end)
    y = y - 18
    -- Document that "locked" also covers party members' base-Mythic saves.
    local hlNote = fp:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
    hlNote:SetPoint("TOPLEFT", x + 4, y)
    hlNote:SetWidth(210)
    hlNote:SetJustifyH("LEFT")
    hlNote:SetText("Incl. base-Mythic dungeons a party member is saved to (if they run the addon).")
    y = y - 26

    sectionLabel(fp, x, y, "Require"); y = y - 20
    mkCheck(fp, x, y, "Has Tank", filter.reqHasTank, function(v) filter.reqHasTank = v end); y = y - 20
    mkCheck(fp, x, y, "Has Healer", filter.reqHasHealer, function(v) filter.reqHasHealer = v end); y = y - 20
    mkCheck(fp, x, y, "Tank slot open", filter.reqTankAvail, function(v) filter.reqTankAvail = v end); y = y - 20
    mkCheck(fp, x, y, "Healer slot open", filter.reqHealerAvail, function(v) filter.reqHealerAvail = v end); y = y - 20
    mkCheck(fp, x, y, "DPS slot open", filter.reqDamageAvail, function(v) filter.reqDamageAvail = v end); y = y - 20
    mkCheck(fp, x, y, "Fits my role (solo)", filter.reqMyRole, function(v) filter.reqMyRole = v end); y = y - 18
    -- Clarify: solo this checkbox is opt-in; in a party role-fit is ALWAYS applied.
    local roleNote = fp:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
    roleNote:SetPoint("TOPLEFT", x + 4, y)
    roleNote:SetWidth(210)
    roleNote:SetJustifyH("LEFT")
    roleNote:SetText("In a party: only groups with room for ALL your party's roles (always on).")
    y = y - 28

    -- Region (ping) tags: within the Americas region, OCE/BR/LATAM realms share your listings
    -- but mean high ping. Uncheck a region to hide those groups. (No effect if your region
    -- never shows them.) A checkbox is "show this region"; unchecked = hidden.
    sectionLabel(fp, x, y, "Region (ping)"); y = y - 20
    local regionCodes = _G.MythicMate_RegionCodes or { "OCE", "BR", "LATAM" }
    local rx = x
    for _, code in ipairs(regionCodes) do
        local rcode = code
        mkCheck(fp, rx, y, rcode, not filter.hideRegions[rcode], function(v)
            filter.hideRegions[rcode] = (not v) and true or nil
        end)
        rx = rx + 76
    end
    y = y - 22

    -- Accent color (OAK-style): one color tints the panel border, toolbar gradient, header
    -- underline and sort arrows. CLASS = your class color; the rest are fixed presets. Applied
    -- immediately on click. The active swatch gets a white frame.
    sectionLabel(fp, x, y, "Accent color"); y = y - 20
    local presets = _G.MythicMate_LFGAccentPresets or { "VIOLET" }
    fp.accentSwatches = {}
    local function refreshAccentSel()
        local active = (_G.MythicMate_GetLFGAccentName and _G.MythicMate_GetLFGAccentName()) or "VIOLET"
        for _, sw in ipairs(fp.accentSwatches) do
            sw.sel:SetShown(sw._name == active)
        end
    end
    local swx = x
    for _, nm in ipairs(presets) do
        local sw = CreateFrame("Button", nil, fp)
        sw:SetSize(24, 16)
        sw:SetPoint("TOPLEFT", swx, y)
        sw._name = nm
        local sel = sw:CreateTexture(nil, "BACKGROUND", nil, 0) -- white frame behind the swatch
        sel:SetPoint("TOPLEFT", -2, 2)
        sel:SetPoint("BOTTOMRIGHT", 2, -2)
        sel:SetColorTexture(1, 1, 1, 1)
        sel:Hide()
        sw.sel = sel
        local bg = sw:CreateTexture(nil, "BACKGROUND", nil, 1)
        bg:SetAllPoints(sw)
        local rr, gg, bb = 0.66, 0.40, 1.0
        if _G.MythicMate_LFGAccentRGB then rr, gg, bb = _G.MythicMate_LFGAccentRGB(nm) end
        bg:SetColorTexture(rr, gg, bb, 1)
        sw:SetScript("OnClick", function()
            if _G.MythicMate_SetLFGAccent and _G.MythicMate_SetLFGAccent(nm) then
                if p.applyAccent then p.applyAccent() end
                -- Also re-tint the main window's title bar so the whole UI follows the accent.
                if type(_G.MythicMate_ApplyMainHeaderAccent) == "function" then
                    pcall(_G.MythicMate_ApplyMainHeaderAccent)
                end
                refreshAccentSel()
                if refreshLFG then refreshLFG() end
            end
        end)
        sw:SetScript("OnEnter", function(self)
            if not GameTooltip then return end
            GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
            GameTooltip:SetText(nm == "CLASS" and "Class color" or nm)
            GameTooltip:Show()
        end)
        sw:SetScript("OnLeave", function() if GameTooltip then GameTooltip:Hide() end end)
        fp.accentSwatches[#fp.accentSwatches + 1] = sw
        swx = swx + 28
    end
    refreshAccentSel()
    y = y - 24

    y = y - 6
    sectionLabel(fp, x, y, "Playstyle"); y = y - 20
    for _, ps in ipairs(PLAYSTYLES) do
        mkCheck(fp, x, y, ps[2], filter.playstyle[ps[1]], function(v) filter.playstyle[ps[1]] = v end)
        y = y - 20
    end

    y = y - 6
    sectionLabel(fp, x, y, "Minimum M+ Rating"); y = y - 20
    local eb = CreateFrame("EditBox", nil, fp, "InputBoxTemplate")
    eb:SetSize(70, 20)
    eb:SetPoint("TOPLEFT", x + 4, y)
    eb:SetAutoFocus(false)
    eb:SetNumeric(true)
    eb:SetText(tostring(filter.minRating or 0))
    eb:SetScript("OnEnterPressed", function(self)
        filter.minRating = tonumber(self:GetText()) or 0
        self:ClearFocus()
        scrollOffset = 0
        saveFilterToDB()
        if type(p.refreshQuickFilters) == "function" then p.refreshQuickFilters() end
        if refreshLFG then refreshLFG() end
    end)
    eb:SetScript("OnEditFocusLost", function(self)
        filter.minRating = tonumber(self:GetText()) or 0
        saveFilterToDB()
        if type(p.refreshQuickFilters) == "function" then p.refreshQuickFilters() end
        if refreshLFG then refreshLFG() end
    end)
    y = y - 28

    sectionLabel(fp, x, y, "Dungeons"); y = y - 18
    fp.dgAll = CreateFrame("Button", nil, fp, "UIPanelButtonTemplate")
    fp.dgAll:SetSize(70, 18)
    fp.dgAll:SetPoint("TOPLEFT", x, y)
    fp.dgAll:SetText("Check all")
    fp.dgNone = CreateFrame("Button", nil, fp, "UIPanelButtonTemplate")
    fp.dgNone:SetSize(78, 18)
    fp.dgNone:SetPoint("LEFT", fp.dgAll, "RIGHT", 6, 0)
    fp.dgNone:SetText("Uncheck all")
    y = y - 22

    fp.dgTop = y
    fp.dgChecks = {}
    fp.dgAll:SetScript("OnClick", function()
        for norm in pairs(seenDungeons) do filter.dungeons[norm] = true end
        rebuildDungeonChecks(fp)
        if refreshLFG then refreshLFG() end
    end)
    fp.dgNone:SetScript("OnClick", function()
        for norm in pairs(seenDungeons) do filter.dungeons[norm] = false end
        rebuildDungeonChecks(fp)
        if refreshLFG then refreshLFG() end
    end)

    p.filterPop = fp
    return fp
end

function rebuildDungeonChecks(fp)
    collectAllDungeons()
    applyAutoUncheckLocked() -- locked dungeons uitgevinkt tonen
    for _, cb in ipairs(fp.dgChecks) do cb:Hide() end
    local list = {}
    for norm, disp in pairs(seenDungeons) do list[#list + 1] = { norm = norm, name = disp } end
    table.sort(list, function(a, b) return tostring(a.name):lower() < tostring(b.name):lower() end)

    local y = fp.dgTop
    local needed = #list
    -- grow popout height to fit dungeon list
    fp:SetHeight(math.max(520, (fp.dgTop * -1) + needed * 19 + 20))
    for i, d in ipairs(list) do
        local cb = fp.dgChecks[i]
        if not cb then
            cb = CreateFrame("CheckButton", nil, fp, "UICheckButtonTemplate")
            cb:SetSize(20, 20)
            cb.text = cb:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
            cb.text:SetPoint("LEFT", cb, "RIGHT", 2, 0)
            fp.dgChecks[i] = cb
        end
        cb:ClearAllPoints()
        cb:SetPoint("TOPLEFT", 14, y)
        -- (locked) only matters for base Mythic (M0); hide it when Mythic isn't selected.
        if filter.diff.MYTHIC and isDungeonLocked(d.name) then
            cb.text:SetText("|cffff6060" .. tostring(d.name) .. " (locked)|r")
        else
            cb.text:SetText(tostring(d.name))
        end
        cb._norm = d.norm
        cb:SetChecked(filter.dungeons[d.norm] ~= false)
        cb:SetScript("OnClick", function(self)
            filter.dungeons[self._norm] = self:GetChecked() and true or false
            scrollOffset = 0
            if refreshLFG then refreshLFG() end
        end)
        cb:Show()
        y = y - 19
    end
end

------------------------------------------------------------
-- Main panel + grid
------------------------------------------------------------
local function skinElvUI(p)
    local st = MythicMateDB and MythicMateDB.settings
    if st and st.useElvUISkinDefaults == false then return end
    if type(_G.MythicMate_GetElvUISkinsModule) ~= "function" then return end
    local okS, S = pcall(_G.MythicMate_GetElvUISkinsModule)
    if not okS or not S then return end
    pcall(function()
        if S.HandleButton then
            S:HandleButton(p.filterBtn); S:HandleButton(p.refresh); S:HandleButton(p.search)
            if p.cancelOldest then S:HandleButton(p.cancelOldest) end
            if p.listGroup then S:HandleButton(p.listGroup) end
        end
        if S.HandleCheckBox and p.quick then S:HandleCheckBox(p.quick) end
    end)
end

local function buildPanel()
    if panel then return panel end
    local parent = _G.MythicMateFrame
    if not parent then return nil end

    local p = CreateFrame("Frame", "MythicMateLFGPanel", parent, "BackdropTemplate")
    -- Top aligned with the main list container (-96) so the gap above the toolbar matches every
    -- other tab (no oversized empty band above the Filter button).
    p:SetPoint("TOPLEFT", parent, "TOPLEFT", 10, -96)
    p:SetPoint("BOTTOMRIGHT", parent, "BOTTOMRIGHT", -10, 80)
    p:SetFrameLevel((parent.GetFrameLevel and parent:GetFrameLevel() or 1) + 5)
    p:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        tile = true, tileSize = 16, edgeSize = 12,
        insets = { left = 3, right = 3, top = 3, bottom = 3 },
    })
    p:SetBackdropColor(0.035, 0.04, 0.075, 0.96)
    p:SetBackdropBorderColor(0.42, 0.34, 0.62, 0.9)

    -- Modern flat skin (OAK-style): a subtle violet toolbar gradient at the top and a faint
    -- strip behind the column headers, matching the main frame's theme. Cosmetic textures only;
    -- the button frames + header label buttons render above them.
    -- Single-row toolbar: Filter · Key · Rio (left) and Quick · Search · Refresh (right). A faint
    -- strip sits behind the column headers just below it.
    p.toolbarBar = p:CreateTexture(nil, "ARTWORK")
    p.toolbarBar:SetPoint("TOPLEFT", 4, -4)
    p.toolbarBar:SetPoint("TOPRIGHT", -4, -4)
    p.toolbarBar:SetHeight(30)
    p.toolbarBar:SetTexture("Interface\\Buttons\\WHITE8x8")
    if p.toolbarBar.SetGradient then
        p.toolbarBar:SetGradient("VERTICAL",
            CreateColor(0.16, 0.07, 0.30, 0.95), CreateColor(0.06, 0.03, 0.13, 0.95))
    end
    p.headerBar = p:CreateTexture(nil, "ARTWORK")
    p.headerBar:SetPoint("TOPLEFT", 4, -33)
    p.headerBar:SetPoint("TOPRIGHT", -4, -33)
    p.headerBar:SetHeight(17)
    p.headerBar:SetColorTexture(1, 1, 1, 0.05)

    p.filterBtn = CreateFrame("Button", nil, p, "UIPanelButtonTemplate")
    p.filterBtn:SetSize(90, 22)
    p.filterBtn:SetPoint("TOPLEFT", 10, -8)
    p.filterBtn:SetText("Filter")
    p.filterBtn:SetScript("OnClick", function()
        local fp = buildFilterPopout(p)
        if fp:IsShown() then
            fp:Hide()
        else
            rebuildDungeonChecks(fp)
            fp:Show()
        end
    end)

    -- Beide knoppen zijn een klik (hardware event) → ze mogen écht zoeken. Puur herlezen kan
    -- de lijst niet bijvullen (Blizzards cache slinkt: delisted groepen vallen weg), dus laten
    -- we óók Refresh een verse zoekopdracht draaien. Direct in OnClick aanroepen zodat de
    -- hardware-event context behouden blijft.
    local function searchAndRefresh()
        lastAutoSearchAt = GetTime and GetTime() or 0
        triggerSearch()
        if C_Timer and type(C_Timer.After) == "function" then
            C_Timer.After(0.4, function() if refreshLFG then refreshLFG() end end)
        elseif refreshLFG then refreshLFG() end
    end

    p.refresh = CreateFrame("Button", nil, p, "UIPanelButtonTemplate")
    p.refresh:SetSize(70, 22)
    p.refresh:SetPoint("TOPRIGHT", -10, -8)
    p.refresh:SetText("Refresh")
    p.refresh:SetScript("OnClick", searchAndRefresh)
    -- "Stale" pulse: Blizzard blocks timer-driven searches, so we can't auto-fetch new groups
    -- on a pure timer. Instead we pulse the Refresh button once the shown results are older than
    -- the auto-refresh interval, so a single click gets fresh groups (driven in refreshLFG).
    p.refresh.staleGlow = p.refresh:CreateTexture(nil, "OVERLAY")
    p.refresh.staleGlow:SetAllPoints(p.refresh)
    p.refresh.staleGlow:SetColorTexture(1, 0.82, 0, 1)
    p.refresh.staleGlow:SetBlendMode("ADD")
    p.refresh.staleGlow:SetAlpha(0)
    p.refresh.staleGlow:Hide()
    local ag = p.refresh.staleGlow:CreateAnimationGroup()
    ag:SetLooping("BOUNCE")
    local aPulse = ag:CreateAnimation("Alpha")
    aPulse:SetFromAlpha(0.06)
    aPulse:SetToAlpha(0.42)
    aPulse:SetDuration(0.7)
    p.refresh.staleAnim = ag

    p.search = CreateFrame("Button", nil, p, "UIPanelButtonTemplate")
    p.search:SetSize(80, 22)
    p.search:SetPoint("RIGHT", p.refresh, "LEFT", -6, 0)
    p.search:SetText("Search")
    p.search:SetScript("OnClick", searchAndRefresh)

    -- Quick sign-up toggle (OAK-style): when on, clicking a group opens a compact role picker
    -- and applies directly; when off, Blizzard's application dialog opens (has a note field).
    p.quick = CreateFrame("CheckButton", nil, p, "UICheckButtonTemplate")
    p.quick:SetSize(22, 22)
    p.quick:SetPoint("RIGHT", p.search, "LEFT", -66, 0)
    p.quick.text = p.quick:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    p.quick.text:SetPoint("LEFT", p.quick, "RIGHT", 1, 0)
    p.quick.text:SetText("Quick")
    p.quick:SetChecked(MythicMateDB and MythicMateDB.settings and MythicMateDB.settings.lfgQuickSignup and true or false)
    p.quick:SetScript("OnClick", function(self)
        MythicMateDB.settings = MythicMateDB.settings or {}
        MythicMateDB.settings.lfgQuickSignup = self:GetChecked() and true or false
    end)
    p.quick:SetScript("OnEnter", function(self)
        if not GameTooltip then return end
        GameTooltip:SetOwner(self, "ANCHOR_BOTTOMLEFT")
        GameTooltip:AddLine("Quick sign-up")
        GameTooltip:AddLine("On: clicking a group shows a tank/heal/dps picker and applies directly.", 0.8, 0.8, 0.8, true)
        GameTooltip:AddLine("Off: opens Blizzard's application dialog (with a note field).", 0.8, 0.8, 0.8, true)
        GameTooltip:Show()
    end)
    p.quick:SetScript("OnLeave", function() if GameTooltip then GameTooltip:Hide() end end)

    -- Applied X/5 counter + Cancel-oldest button (OAK-style), left of the toolbar's mid gap.
    -- Both hide when you have no open applications (updated in refreshLFG).
    p.appCount = p:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    p.appCount:SetPoint("LEFT", p.filterBtn, "RIGHT", 12, 0)
    p.appCount:Hide()
    p.cancelOldest = CreateFrame("Button", nil, p, "UIPanelButtonTemplate")
    p.cancelOldest:SetSize(96, 20)
    p.cancelOldest:SetPoint("LEFT", p.appCount, "RIGHT", 6, 0)
    p.cancelOldest:SetText("Cancel oldest")
    p.cancelOldest:SetScript("OnClick", function()
        local _, oldest = activeApplications()
        if oldest then
            cancelApplication(oldest)
            if refreshLFG then refreshLFG() end
        end
    end)
    p.cancelOldest:SetScript("OnEnter", function(self)
        if not GameTooltip then return end
        GameTooltip:SetOwner(self, "ANCHOR_BOTTOMLEFT")
        GameTooltip:AddLine("Cancel your oldest pending application")
        GameTooltip:AddLine("You can have at most 5 open applications at once.", 0.8, 0.8, 0.8, true)
        GameTooltip:Show()
    end)
    p.cancelOldest:SetScript("OnLeave", function() if GameTooltip then GameTooltip:Hide() end end)
    p.cancelOldest:Hide()

    ------------------------------------------------------------
    -- Inline quick-filters, on the SAME toolbar row as the Filter button to save vertical space.
    -- One free-text box each for Key and Rio where you TYPE a range like "2-10" (or "5", "2-",
    -- "-10"). Writes straight into the same `filter` model, so the Filter popout stays in sync.
    ------------------------------------------------------------
    local function tbLabel(text, anchorTo, relx)
        local fs = p:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        fs:SetPoint("LEFT", anchorTo, "RIGHT", relx or 6, 0)
        fs:SetText(text)
        return fs
    end
    -- Free-text range box ("2-10"). commit() parses it into (min,max) and refreshes.
    local function tbRangeBox(anchorTo, relx, width, tip, getMin, getMax, setRange)
        local eb = CreateFrame("EditBox", nil, p, "InputBoxTemplate")
        eb:SetSize(width or 56, 18)
        eb:SetPoint("LEFT", anchorTo, "RIGHT", relx or 6, 0)
        eb:SetAutoFocus(false)
        eb:SetJustifyH("CENTER")
        eb:SetMaxLetters(11)
        eb:SetText(formatRange(getMin(), getMax()))
        -- Apply the range LIVE while typing. Clicking Search/Refresh doesn't clear an EditBox's
        -- focus in WoW, so OnEditFocusLost may never fire — relying on it left the value uncommitted
        -- (the filter appeared to do nothing). OnTextChanged(userInput) commits on each keystroke;
        -- programmatic SetText passes userInput=false so it never recurses.
        local function apply(self)
            local lo, hi = parseRange(self:GetText())
            setRange(lo, hi)
            scrollOffset = 0
            saveFilterToDB()
            if refreshLFG then refreshLFG() end
        end
        eb:SetScript("OnTextChanged", function(self, userInput)
            if userInput then apply(self) end
        end)
        eb:SetScript("OnEnterPressed", function(self)
            apply(self)
            self:SetText(formatRange(parseRange(self:GetText()))) -- tidy up "8-" etc.
            self:ClearFocus()
        end)
        eb:SetScript("OnEditFocusLost", function(self)
            self:SetText(formatRange(parseRange(self:GetText())))
        end)
        eb:SetScript("OnEnter", function(self)
            if not GameTooltip then return end
            GameTooltip:SetOwner(self, "ANCHOR_BOTTOM")
            GameTooltip:AddLine(tip)
            GameTooltip:AddLine('Type a range like "2-10", or a single value.', 0.8, 0.8, 0.8, true)
            GameTooltip:Show()
        end)
        eb:SetScript("OnLeave", function() if GameTooltip then GameTooltip:Hide() end end)
        return eb
    end
    p.qfBoxes = {}

    -- Anchored to the right of the Filter button so Key/Rio share row 1 (no second toolbar row).
    local keyLbl = tbLabel("Key", p.filterBtn, 12)
    p.qfBoxes.keyLbl = keyLbl
    -- Fallback only: shown when Blizzard's secure search box can't be hosted here (see
    -- embedBlizzSearchBox). On 12.x this client-side range is blind to most listings
    -- (kstring titles), which is exactly why the hosted box replaces it.
    p.qfBoxes.key = tbRangeBox(keyLbl, 6, 100, "Keystone level range (limited: most 12.x listings hide their level from addons)",
        function() return filter.minKey end, function() return filter.maxKey end,
        function(lo, hi) filter.minKey, filter.maxKey = lo, hi end)

    local rioLbl = tbLabel("Rio", p.qfBoxes.key, 12)
    p.qfBoxes.rioLbl = rioLbl
    p.qfBoxes.rio = tbRangeBox(rioLbl, 6, 78, "Leader M+ rating range",
        function() return filter.minRating end, function() return filter.maxRating end,
        function(lo, hi) filter.minRating, filter.maxRating = lo, hi end)

    -- "List Key": one-click quick list/delist of your own keystone (MythicMate_ListGroup
    -- QuickToggle); shift-click opens the full create-a-listing window for custom settings.
    -- Sits right of the Rio box; the label flips to "Delist" while you have an active listing.
    p.listGroup = CreateFrame("Button", nil, p, "UIPanelButtonTemplate")
    p.listGroup:SetSize(92, 22)
    p.listGroup:SetPoint("LEFT", p.qfBoxes.rio, "RIGHT", 14, 0)
    -- Stored as a frame field, not a local: this builder is close to Lua's 200-locals budget.
    p.listGroup.UpdateLabel = function(self)
        local listed = MythicMateNS.ListGroup
            and type(MythicMateNS.ListGroup.IsListed) == "function"
            and MythicMateNS.ListGroup.IsListed()
        self:SetText(listed and "Delist" or "List Key")
    end
    p.listGroup:UpdateLabel()
    p.listGroup:RegisterEvent("LFG_LIST_ACTIVE_ENTRY_UPDATE")
    p.listGroup:SetScript("OnEvent", p.listGroup.UpdateLabel)
    p.listGroup:SetScript("OnClick", function()
        if not (MythicMateNS.ListGroup and type(MythicMateNS.ListGroup.QuickToggle) == "function") then
            -- ListGroup module failed to load (file error) — say so instead of dying silently.
            print("|cffc868ffMythicMate:|r List Group module not loaded — check for addon errors (/err 1 + /reload).")
            return
        end
        if IsShiftKeyDown and IsShiftKeyDown() then
            if type(MythicMateNS.ListGroup.Toggle) == "function" then
                MythicMateNS.ListGroup.Toggle()
            end
        else
            MythicMateNS.ListGroup.QuickToggle() -- click = hardware event → CreateListing allowed
        end
    end)
    p.listGroup:SetScript("OnEnter", function(self)
        if not GameTooltip then return end
        GameTooltip:SetOwner(self, "ANCHOR_BOTTOMLEFT")
        GameTooltip:AddLine("List / delist your keystone")
        GameTooltip:AddLine("Click: opens Premade Groups with your key pre-selected — press the green List Group button to go live (or delist if already listed).", 0.8, 0.8, 0.8, true)
        GameTooltip:AddLine("Shift-click: pick a different dungeon/difficulty first.", 0.8, 0.8, 0.8, true)
        GameTooltip:Show()
    end)
    p.listGroup:SetScript("OnLeave", function() if GameTooltip then GameTooltip:Hide() end end)

    -- Your current keystone, shown in the toolbar (like the other tabs show it). Fills the gap
    -- between List Group and the Quick toggle; updated every refreshLFG via updateKeystoneText.
    p.keystone = p:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    p.keystone:SetPoint("LEFT", p.listGroup, "RIGHT", 12, 0)
    p.keystone:SetPoint("RIGHT", p.quick, "LEFT", -10, 0)
    p.keystone:SetJustifyH("LEFT")
    if p.keystone.SetWordWrap then p.keystone:SetWordWrap(false) end

    -- The toolbar row is full (Filter · Key · Rio · List Group | Quick · Search · Refresh), so the
    -- "Applied X/5" counter + Cancel-oldest move to the bottom-right, by the status line. Only shown
    -- when you have open applications; refreshLFG shrinks the status text to make room for them.
    p.appCount:ClearAllPoints()
    p.cancelOldest:ClearAllPoints()
    p.cancelOldest:SetPoint("BOTTOMRIGHT", p, "BOTTOMRIGHT", -12, 6)
    p.appCount:SetPoint("RIGHT", p.cancelOldest, "LEFT", -8, 0)
    p.appCount:SetJustifyH("RIGHT")

    -- Push the current filter values back into the inline widgets (called after loads/resets).
    function p.refreshQuickFilters()
        if not p.qfBoxes then return end
        if p.qfBoxes.key then p.qfBoxes.key:SetText(formatRange(filter.minKey, filter.maxKey)) end
        if p.qfBoxes.rio then p.qfBoxes.rio:SetText(formatRange(filter.minRating, filter.maxRating)) end
    end

    -- Grid header
    local function hdr(text, hx, right, key)
        local b = CreateFrame("Button", nil, p)
        b.fs = b:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        b.fs:SetTextColor(0.72, 0.72, 0.85)
        b.fs:SetText(text)
        b:SetHeight(14)
        b:SetWidth(math.max(20, (b.fs:GetStringWidth() or 20) + 6))
        b.fs:SetAllPoints(b)
        if right then
            b.fs:SetJustifyH("RIGHT"); b:SetPoint("TOPRIGHT", hx, -35)
        else
            b.fs:SetJustifyH("LEFT"); b:SetPoint("TOPLEFT", hx, -35)
        end
        b._key = key
        -- Sort-direction arrow, shown only on the active sort column (updated in refreshLFG).
        -- Anchored just outside the label so it never overlaps the header text.
        b.arrow = b:CreateTexture(nil, "OVERLAY")
        b.arrow:SetSize(10, 10)
        if right then
            b.arrow:SetPoint("RIGHT", b, "LEFT", -1, 0)
        else
            b.arrow:SetPoint("LEFT", b, "RIGHT", 1, 0)
        end
        b.arrow:Hide()
        if key then
            p.headers = p.headers or {}
            p.headers[key] = b
            b:SetScript("OnClick", function(self)
                if sortCol == self._key then sortAsc = not sortAsc else sortCol = self._key; sortAsc = true end
                if refreshLFG then refreshLFG() end
            end)
            b:SetScript("OnEnter", function(self) self.fs:SetTextColor(1, 1, 1) end)
            b:SetScript("OnLeave", function(self) self.fs:SetTextColor(0.72, 0.72, 0.85) end)
        end
        return b
    end
    hdr("Diff", 16, false, "diff"); hdr("Dungeon", 40, false, "dungeon")
    hdr("Group", 250, false, "group"); hdr("Leader", 480, false, "leader")
    hdr("Rio", 650, false, "rio")
    -- The Roles header sorts by OAK-style "best fit" (fits my/party role + rating + size).
    hdr("Roles", -14, true, "fit")
    p.hdrLine = p:CreateTexture(nil, "ARTWORK")
    p.hdrLine:SetColorTexture(0.4, 0.4, 0.55, 0.6)
    p.hdrLine:SetPoint("TOPLEFT", 8, -50)
    p.hdrLine:SetPoint("TOPRIGHT", -8, -50)
    p.hdrLine:SetHeight(1)

    -- Tint the panel chrome (border, toolbar gradient, header strip + underline) with the active
    -- accent color (class color or a preset). Re-applied on open and whenever the accent changes.
    function p.applyAccent()
        local ar, ag2, ab2 = 0.66, 0.40, 1.0
        if type(_G.MythicMate_GetLFGAccent) == "function" then
            local r, g, b = _G.MythicMate_GetLFGAccent()
            if r then ar, ag2, ab2 = r, g, b end
        end
        if p.SetBackdropBorderColor then
            p:SetBackdropBorderColor(ar * 0.65 + 0.06, ag2 * 0.65 + 0.06, ab2 * 0.65 + 0.06, 0.9)
        end
        if p.toolbarBar and p.toolbarBar.SetGradient then
            p.toolbarBar:SetGradient("VERTICAL",
                CreateColor(ar * 0.34, ag2 * 0.34, ab2 * 0.34, 0.95),
                CreateColor(0.05, 0.03, 0.09, 0.95))
        end
        if p.headerBar then p.headerBar:SetColorTexture(ar, ag2, ab2, 0.12) end
        if p.hdrLine then p.hdrLine:SetColorTexture(ar, ag2, ab2, 0.55) end
        if p.emptyMsg then p.emptyMsg:SetTextColor(ar * 0.5 + 0.5, ag2 * 0.5 + 0.5, ab2 * 0.5 + 0.5) end
    end
    p.applyAccent()

    p.status = p:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
    p.status:SetPoint("BOTTOMLEFT", 12, 8)
    p.status:SetPoint("BOTTOMRIGHT", -12, 8)
    p.status:SetJustifyH("LEFT")

    -- Centered in the grid area (not the toolbar) so it never overlaps the Filter/Key/Rio row.
    p.warn = p:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    p.warn:SetPoint("CENTER", p, "CENTER", 0, 0)
    p.warn:Hide()

    -- Green full-panel flash, pulsed when a group invites you (see notifyInvites).
    p.flash = p:CreateTexture(nil, "OVERLAY")
    p.flash:SetAllPoints(p)
    p.flash:SetColorTexture(0.2, 1.0, 0.3, 0.25)
    p.flash:Hide()

    -- Centered empty-state hint: shown when the list is empty (e.g. first time the LFG tab
    -- is opened and no results have arrived yet) so the panel doesn't look broken/blank.
    p.emptyMsg = p:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
    p.emptyMsg:SetPoint("CENTER", p, "CENTER", 0, 0)
    p.emptyMsg:SetTextColor(0.8, 0.8, 0.9)
    p.emptyMsg:SetText("Click on Search to find groups")
    p.emptyMsg:Hide()

    p.rows = {}
    local ry = -54
    for i = 1, MAX_ROWS do
        local row = CreateFrame("Button", nil, p)
        -- Zero horizontal inset so the row's column coordinates line up exactly with the
        -- header positions (which are anchored to the panel).
        row:SetPoint("TOPLEFT", 0, ry)
        row:SetPoint("TOPRIGHT", 0, ry)
        row:SetHeight(ROW_H)
        row.bg = row:CreateTexture(nil, "BACKGROUND")
        row.bg:SetAllPoints(row)
        row.bg:SetColorTexture(1, 1, 1, (i % 2 == 0) and 0.05 or 0.015)
        row:SetHighlightTexture("Interface\\Buttons\\UI-Listbox-Highlight", "ADD")
        -- Class/friend-colored accent bar (OAK-style). Inset to x=6 so it sits INSIDE the grid
        -- (just left of the Diff column at x=10), not under the panel's left border. Color is set
        -- per refresh in refreshLFG based on the leader's class or friend/social kind.
        row.accent = row:CreateTexture(nil, "ARTWORK")
        row.accent:SetPoint("TOPLEFT", row, "TOPLEFT", 6, -1)
        row.accent:SetPoint("BOTTOMLEFT", row, "BOTTOMLEFT", 6, 1)
        row.accent:SetWidth(3)
        row.accent:SetColorTexture(1, 1, 1, 0)

        local function col(cx, w)
            local fs = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
            fs:SetPoint("LEFT", cx, 0)
            if w then fs:SetWidth(w) end
            fs:SetJustifyH("LEFT")
            if fs.SetWordWrap then fs:SetWordWrap(false) end
            return fs
        end
        row.diff = col(16, 24)
        row.dungeon = col(40, 200)
        row.group = col(250, 220)
        row.leader = col(480, 140)
        row.leader:SetTextColor(0.82, 0.82, 0.62)
        row.rio = col(650, 70)
        -- Role composition as 5 slot boxes (OAK-style): 1 tank / 1 heal / 3 dps, right-aligned.
        -- Filled slots show a bright role icon; open slots are dimmed/desaturated. Colors are
        -- updated per refresh from the group's member counts.
        row.slots = {}
        local slotRoles = { "tank", "heal", "dps", "dps", "dps" }
        for s = 1, 5 do
            local box = row:CreateTexture(nil, "ARTWORK")
            box:SetSize(16, 16)
            box:SetPoint("LEFT", row, "RIGHT", -106 + (s - 1) * 19, 0)
            box:SetColorTexture(0, 0, 0, 0.35)
            local ic = row:CreateTexture(nil, "OVERLAY")
            ic:SetSize(15, 15)
            ic:SetPoint("CENTER", box, "CENTER", 0, 0)
            ic:SetAtlas("groupfinder-icon-role-large-" .. slotRoles[s])
            row.slots[s] = { box = box, icon = ic }
        end

        row:RegisterForClicks("LeftButtonUp", "RightButtonUp")
        row:SetScript("OnClick", function(self, button)
            -- Right-click: toggle "ignore this leader" (their groups are hidden).
            if button == "RightButton" then
                local s = shortLower(self._leader)
                if s then
                    if ignoredLeaders[s] then
                        ignoredLeaders[s] = nil
                        print("|cffc868ffMythicMate:|r no longer ignoring " .. tostring(self._leader) .. ".")
                    else
                        ignoredLeaders[s] = true
                        print("|cffc868ffMythicMate:|r ignoring " .. tostring(self._leader) .. " (right-click again to undo).")
                    end
                    scrollOffset = 0
                    if refreshLFG then refreshLFG() end
                end
                return
            end
            -- Shift-click: whisper the group leader instead of signing up.
            if IsShiftKeyDown and IsShiftKeyDown() and self._leader and self._leader ~= "" then
                if type(ChatFrame_SendTell) == "function" then
                    ChatFrame_SendTell(self._leader)
                end
                return
            end
            if isDeclined(self._appStatus) then
                print("|cffc868ffMythicMate:|r you were declined by this group — you can't re-apply.")
                return
            end
            if isApplied(self._appStatus) then
                print("|cffc868ffMythicMate:|r already applied — double-click to cancel.")
                return
            end
            if self._resultID then
                -- Quick sign-up mode (OAK-style): show the compact role picker and apply
                -- directly. Otherwise fall back to Blizzard's application dialog (has a note).
                local st = MythicMateDB and MythicMateDB.settings
                if st and st.lfgQuickSignup then
                    openSignupPopup(self._resultID, self, self._dungeon, self._keyLevel, self._groupName)
                else
                    signUp(self._resultID)
                end
            end
        end)
        row:SetScript("OnDoubleClick", function(self)
            if isApplied(self._appStatus) and self._resultID then
                cancelApplication(self._resultID)
            end
        end)
        row:SetScript("OnEnter", function(self)
            -- NB: hovering (mouse MOVEMENT) is NOT a hardware event for C_LFGList.Search, so a
            -- search from here is silently blocked and shows up as "Interface actions failed because
            -- of this AddOn". Only real clicks/scroll (OnMouseDown/OnMouseWheel) may auto-search.
            if not self._resultID or not GameTooltip then return end
            GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
            -- Blizzard's own rich search-entry tooltip: leader name, ilvl, M+ score,
            -- roles, members, comment, etc.
            local shown = false
            if type(_G.LFGListUtil_SetSearchEntryTooltip) == "function" then
                shown = pcall(_G.LFGListUtil_SetSearchEntryTooltip, GameTooltip, self._resultID)
            end
            if not shown then
                -- Blizzard's rich tooltip usually covers ilvl/note/age; this is our fallback
                -- for when it isn't available, so the context still shows at a glance.
                GameTooltip:AddLine(self._groupName ~= "" and self._groupName or tostring(self._dungeon))
                local bits = {}
                if self._ilvl and self._ilvl > 0 then bits[#bits + 1] = "ilvl " .. tostring(self._ilvl) end
                local age = formatAge(self._age)
                if age ~= "" then bits[#bits + 1] = "listed " .. age .. " ago" end
                if #bits > 0 then GameTooltip:AddLine(table.concat(bits, "  •  "), 0.7, 0.7, 0.7) end
                if self._note and self._note ~= "" then
                    GameTooltip:AddLine("Note: " .. tostring(self._note), 0.6, 0.85, 1, true)
                end
            end
            -- Append the Raider.IO profile block for the group LEADER — the same rich score panel
            -- (M+ score, best runs, etc.) RaiderIO adds to Blizzard's own listings. RaiderIO hooks
            -- Blizzard's search buttons, not LFGListUtil_SetSearchEntryTooltip, so it never fires on
            -- our custom rows; we call its public ShowProfile API directly. No-op without RaiderIO.
            if _G.RaiderIO and type(_G.RaiderIO.ShowProfile) == "function"
                and self._leader and self._leader ~= "" then
                local name, realm = self._leader:match("^(.-)%-(.+)$")
                if not name or name == "" then
                    name = self._leader
                    realm = (type(GetNormalizedRealmName) == "function") and GetNormalizedRealmName() or nil
                end
                pcall(_G.RaiderIO.ShowProfile, GameTooltip, name, realm)
            end
            if self._social then
                GameTooltip:AddLine("|TInterface\\COMMON\\FavoritesIcon:14:14|t|cffc868ffQuick Join: a friend/guildmate is in this group|r")
            end
            if self._leaver then
                local mk = (type(CreateAtlasMarkup) == "function")
                    and CreateAtlasMarkup("groupfinder-icon-leaver", 14, 14)
                    or "|A:groupfinder-icon-leaver:14:14|a"
                GameTooltip:AddLine(mk .. " |cffff6060Leader recently deserted a Mythic+ group.|r")
            end
            GameTooltip:AddLine(" ")
            GameTooltip:AddLine("Click to sign up • double-click an application to cancel", 0.5, 1, 0.5)
            GameTooltip:AddLine("Shift-click to whisper the leader • right-click to ignore", 0.5, 1, 0.5)
            GameTooltip:Show()
        end)
        row:SetScript("OnLeave", function() if GameTooltip then GameTooltip:Hide() end end)
        -- Clicking a row (to sign up) is a real hardware event → also kick off a throttled
        -- fresh search, so ordinary interaction keeps the list current with new groups.
        row:HookScript("OnMouseDown", function() maybeAutoSearch() end)
        row:Hide()
        p.rows[i] = row
        ry = ry - ROW_H
    end

    p:EnableMouseWheel(true)
    p:SetScript("OnMouseWheel", function(_, delta)
        local maxOff = math.max(0, lastFilteredCount - MAX_ROWS)
        scrollOffset = math.max(0, math.min(maxOff, scrollOffset - (delta or 0) * 3))
        maybeAutoSearch() -- scroll = écht hardware event → mag (throttled) opnieuw zoeken
        if refreshLFG then refreshLFG() end
    end)
    -- BELANGRIJK: alleen een échte muisKNOP/toets/scroll telt als "hardware event" voor
    -- C_LFGList.Search. Muisbeweging (OnEnter/hover) telt NIET → die search wordt stil
    -- geblokkeerd en herleest enkel oude resultaten. Daarom hangen we de auto-search aan
    -- OnMouseDown: elke klik in de lijst (op een rij of lege ruimte) doet, throttled, een
    -- verse zoekopdracht — net als de Search-knop. Een pure timer kan géén nieuwe groepen
    -- ophalen (zie CLAUDE.md / lfg-search-hardware-event).
    p:EnableMouse(true)
    p:HookScript("OnMouseDown", function() maybeAutoSearch() end)

    skinElvUI(p)
    panel = p
    p:Hide()
    return p
end

-- Flash the panel + play a sound the first time a group's invite arrives, so you notice
-- an invite even if you're not staring at the list. Cleared when the status changes back.
local function notifyInvites(p, list)
    local anyNew = false
    for _, e in ipairs(list) do
        local rid = e.resultID
        local s = e.appStatus and tostring(e.appStatus) or ""
        local invited = (s == "invited" or s == "inviteaccepted")
        if rid then
            if invited then
                if not notifiedInvite[rid] then
                    notifiedInvite[rid] = true
                    anyNew = true
                end
            else
                notifiedInvite[rid] = nil
            end
        end
    end
    if anyNew then
        if type(PlaySound) == "function" then
            pcall(PlaySound, (SOUNDKIT and SOUNDKIT.READY_CHECK) or 8960)
        end
        if p and p.flash then
            if type(UIFrameFlash) == "function" then
                pcall(UIFrameFlash, p.flash, 0.25, 0.25, 1.5, false, 0, 0.25)
            else
                p.flash:Show()
            end
        end
    end
end

function refreshLFG()
    local p = buildPanel()
    if not p then return end
    if p.keystone then p.keystone:SetText(ownKeystoneText()) end
    local list = buildFiltered()
    notifyInvites(p, list)
    lastFilteredCount = #list
    local maxOff = math.max(0, lastFilteredCount - MAX_ROWS)
    if scrollOffset > maxOff then scrollOffset = maxOff end

    -- Sort-direction arrow on the active column header (up = ascending, down = descending).
    if p.headers then
        for k, hb in pairs(p.headers) do
            if hb.arrow then
                if sortCol == k then
                    hb.arrow:SetTexture(sortAsc and "Interface\\Buttons\\Arrow-Up-Up" or "Interface\\Buttons\\Arrow-Down-Up")
                    hb.arrow:Show()
                else
                    hb.arrow:Hide()
                end
            end
        end
    end

    -- Applied X/5 counter + Cancel-oldest button (OAK-style). Hidden when you have no
    -- open applications; shown with the count while you have pending sign-ups.
    if p.appCount and p.cancelOldest then
        local appN = activeApplications()
        if appN > 0 then
            p.appCount:SetText(("|cffffd000Applied %d/5|r"):format(appN))
            p.appCount:Show()
            p.cancelOldest:Show()
            -- Shrink the status line so it stops before the counter/button (they share the bottom row).
            if p.status then
                p.status:SetPoint("BOTTOMRIGHT", p, "BOTTOMRIGHT", -184, 8)
            end
        else
            p.appCount:Hide()
            p.cancelOldest:Hide()
            if p.status then
                p.status:SetPoint("BOTTOMRIGHT", p, "BOTTOMRIGHT", -12, 8)
            end
        end
    end

    for i = 1, MAX_ROWS do
        local row = p.rows[i]
        local e = list[scrollOffset + i]
        if e then
            row._resultID = e.resultID
            row._dungeon = e.dungeon
            row._keyLevel = e.keyLevel
            row._groupName = e.groupName
            row._leader = e.leader
            row._note = e.note
            row._age = e.ageSec
            row._ilvl = e.ilvl
            row._leaver = e.leaderLeaver
            row.diff:SetText(diffTag(e.diff))
            local dn = tostring(displayName(e.dungeon) or "")
            dn = dn:gsub("%s*%b()%s*$", "") -- drop trailing "(Mythic)" etc; Diff column already shows it
            if dn == "" then dn = "—" end
            if e.locked then
                -- Show who is locked: your own lockout is just "(locked)"; a party member's
                -- lockout (only known when they run the addon and synced) shows their name.
                local tag = " (locked)"
                if e.lockedBy and e.lockedBy ~= "you" then
                    tag = " (locked: " .. tostring(e.lockedBy) .. ")"
                end
                row.dungeon:SetText("|cffff6060" .. dn .. tag .. "|r")
            else
                row.dungeon:SetText(dn)
            end
            -- Group column: prefer the listing title; if it's empty, fall back to the note/
            -- comment so the row isn't blank (many leaders put the info in the note). Append a
            -- compact listing age for OAK-style at-a-glance context.
            local gbody, gcolor
            if e.groupName ~= "" then gbody, gcolor = tostring(e.groupName), "ffd8d8d8"
            elseif e.note ~= "" then gbody, gcolor = tostring(e.note), "ff9a9a9a"
            else gbody, gcolor = "", nil end
            local gtxt = (gbody ~= "" and gcolor) and ("|c" .. gcolor .. gbody .. "|r") or ""
            -- OAK-style region ("ping") tag in front of the group text.
            if e.region then
                local rc = (MythicMate_RegionColors and MythicMate_RegionColors[e.region]) or "ffcccccc"
                local tag = "|c" .. rc .. "[" .. e.region .. "]|r"
                gtxt = tag .. (gtxt ~= "" and (" " .. gtxt) or "")
            end
            local age = formatAge(e.ageSec)
            if age ~= "" then gtxt = (gtxt ~= "" and (gtxt .. " ") or "") .. "|cff707070(" .. age .. ")|r" end
            local al = appLabel(e.appStatus)
            if al then gtxt = al .. " " .. gtxt end
            row.group:SetText(gtxt)
            row._appStatus = e.appStatus
            local leaderShort = tostring(e.leader or ""):match("^([^-]+)") or ""
            local kind = (leaderShort ~= "" and friendNames[leaderShort:lower()]) or nil
            -- Deserter/leaver: Blizzard flags these leaders with a warning-triangle atlas
            -- ("groupfinder-icon-leaver"). Prepend it to the display name (after the friendNames
            -- lookup, which needs the bare name). Atlas markup — NOT a UTF-8 glyph, which the WoW
            -- font renders as a box. All render branches below build on leaderShort, so they all
            -- pick it up. [[no-utf8-glyphs-in-wow-strings]]
            if e.leaderLeaver then
                local mk = (type(CreateAtlasMarkup) == "function")
                    and CreateAtlasMarkup("groupfinder-icon-leaver", 14, 14)
                    or "|A:groupfinder-icon-leaver:14:14|a"
                leaderShort = mk .. leaderShort
            end
            row._social = e.social
            row._socialGUID = e.socialGUID
            if e.social then
                -- Quick Join group (a friend/guildmate is in it). Star icon + purple tint so it
                -- stands out. Use an inline texture (|T..|t), NOT a UTF-8 glyph — the WoW font
                -- renders many UTF-8 symbols as a "box".
                row.leader:SetText("|TInterface\\COMMON\\FavoritesIcon:14:14|t|cffc868ff" .. leaderShort .. "|r")
                row.bg:SetColorTexture(0.66, 0.4, 1.0, 0.14)
                if row.accent then row.accent:SetColorTexture(0.66, 0.4, 1.0, 0.95) end
            elseif kind == "BNET" then
                row.leader:SetText("|TInterface\\ChatFrame\\UI-ChatIcon-Battlenet:14:14|t|cff00a2ff" .. leaderShort .. "|r")
                row.bg:SetColorTexture(0.0, 0.45, 1.0, 0.16)
                if row.accent then row.accent:SetColorTexture(0.0, 0.64, 1.0, 0.95) end
            elseif kind == "GUILD" then
                row.leader:SetText("|cff40ff5a" .. leaderShort .. "|r")
                row.bg:SetColorTexture(0.15, 1.0, 0.25, 0.10)
                if row.accent then row.accent:SetColorTexture(0.15, 1.0, 0.25, 0.95) end
            elseif kind == "FRIEND" then
                row.leader:SetText("|cffffd200" .. leaderShort .. "|r")
                row.bg:SetColorTexture(1.0, 0.82, 0.2, 0.10)
                if row.accent then row.accent:SetColorTexture(1.0, 0.82, 0.2, 0.95) end
            else
                local cc = classColorTag(e.class)
                row.leader:SetText(cc and (cc .. leaderShort .. "|r") or leaderShort)
                row.bg:SetColorTexture(1, 1, 1, (i % 2 == 0) and 0.05 or 0.015)
                -- Accent bar = leader's class color (falls back to a muted violet).
                if row.accent then
                    local ar, ag, ab = 0.45, 0.42, 0.55
                    if e.class and type(C_ClassColor) == "table" and type(C_ClassColor.GetClassColor) == "function" then
                        local okc, col = pcall(C_ClassColor.GetClassColor, e.class)
                        if okc and col then ar, ag, ab = col.r or ar, col.g or ag, col.b or ab end
                    end
                    row.accent:SetColorTexture(ar, ag, ab, 0.9)
                end
            end
            if isDeclined(e.appStatus) then
                row.bg:SetColorTexture(0.6, 0.1, 0.1, 0.24) -- declined stands out
                if row.accent then row.accent:SetColorTexture(0.85, 0.15, 0.15, 0.95) end
            end
            -- Comp slot boxes: bright icon = filled, dimmed/desaturated = open slot.
            if row.slots then
                local c = e.comp
                if c then
                    local filled = { (c.t or 0) >= 1, (c.h or 0) >= 1, (c.d or 0) >= 1, (c.d or 0) >= 2, (c.d or 0) >= 3 }
                    for s = 1, 5 do
                        local sl = row.slots[s]
                        sl.box:Show()
                        if filled[s] then
                            sl.icon:SetAlpha(1.0)
                            if sl.icon.SetDesaturated then sl.icon:SetDesaturated(false) end
                            sl.box:SetColorTexture(0.10, 0.10, 0.10, 0.5)
                        else
                            sl.icon:SetAlpha(0.30)
                            if sl.icon.SetDesaturated then sl.icon:SetDesaturated(true) end
                            sl.box:SetColorTexture(0.0, 0.0, 0.0, 0.30)
                        end
                    end
                else
                    for s = 1, 5 do row.slots[s].box:Hide(); row.slots[s].icon:SetAlpha(0) end
                end
            end
            row.rio:SetText((e.leaderScore and e.leaderScore > 0) and (rioColorTag(e.leaderScore) .. tostring(e.leaderScore) .. "|r") or "")
            row:Show()
        else
            row._resultID = nil
            row._social = nil
            row._socialGUID = nil
            row:Hide()
        end
    end

    -- Party-lockout note: make the behavior visible so it's clear WHY groups are hidden/marked.
    -- Only relevant while grouped; explains that base-Mythic groups a saved party member has
    -- are hidden (Hide locked) or marked "(locked: name)".
    -- Compact tags (the footer was overflowing to two lines). Party note only appears when a
    -- party member's lockout actually hides/marks groups; otherwise it's silent.
    local function partyNote()
        if not (type(IsInGroup) == "function" and IsInGroup()) then return "" end
        if lastPartyLockCount > 0 then
            local verb = filter.hideLocked and "hidden" or "marked"
            return ("   |cff9fd3ff• %d party-locked %s|r"):format(lastPartyLockCount, verb)
        end
        return ""
    end

    -- Role-fit tag (grouped only): the list is limited to groups that seat your whole party.
    -- With a full party the fit rule is bypassed (nothing could ever fit) — say so instead.
    local function roleNote()
        if not (type(IsInGroup) == "function" and IsInGroup()) then return "" end
        if partyIsFull() then return "   |cff808080• Fit off (party full)|r" end
        local t, h, d = partyRoleCounts()
        return ("   |cffffd200• Fit %dT/%dH/%dD|r"):format(t, h, d)
    end

    -- Auto-refresh tag. Full detail lives in the Refresh button tooltip / CLAUDE notes.
    local function autoNote()
        local every = autoSearchInterval()
        if not every then return "   |cff808080• Auto off|r" end
        return ("   |cff9fd3ff• Auto ~%ds|r"):format(every)
    end

    -- Stale-pulse the Refresh button once the shown results age past the auto-refresh interval
    -- (and there are results to refresh). This is the honest "auto-refresh" cue given Blizzard
    -- blocks timer-driven searches: it just needs one click. Runs every second via the ticker.
    if p.refresh and p.refresh.staleGlow then
        local every = autoSearchInterval()
        local now = GetTime and GetTime() or 0
        local stale = every and lastFilteredCount > 0 and (now - lastAutoSearchAt) >= every and not isInDungeon()
        if stale then
            p.refresh.staleGlow:Show()
            if p.refresh.staleAnim and not p.refresh.staleAnim:IsPlaying() then p.refresh.staleAnim:Play() end
        else
            if p.refresh.staleAnim then p.refresh.staleAnim:Stop() end
            p.refresh.staleGlow:SetAlpha(0)
            p.refresh.staleGlow:Hide()
        end
    end

    -- Technical diagnostics (raw/ok/…) only shown with /wl debugpremade on.
    local dbg = _G.MythicMateDebugPremade and ("   [" .. tostring(lastDiag) .. " | " .. tostring(lastSearchDiag)
        .. " | social=" .. tostring(lastSocialCount) .. " shown=" .. tostring(lastSocialShown) .. "]") or ""
    local quickView = (viewMode == "quickjoin")
    if isInDungeon() then
        p.emptyMsg:Hide()
        p.status:SetText("You're in a dungeon — LFG is disabled." .. dbg)
    elseif lastFilteredCount == 0 then
        -- No groups (yet): show the centered hint so the panel doesn't look blank.
        p.emptyMsg:Show()
        if quickView then
            p.status:SetText("No Quick Join groups — a friend/guildmate must be queued in a premade." .. dbg)
        else
            -- Special case: saved to (almost) every dungeon with "Hide locked" on → every dungeon
            -- is filtered out and the list looks broken. Show the reason in the CENTER (big text)
            -- instead of "Click on Search to find groups", and keep the footer clean.
            local anyEnabled, anyDungeon = false, false
            for norm in pairs(seenDungeons) do
                anyDungeon = true
                if filter.dungeons[norm] ~= false then anyEnabled = true break end
            end
            if filter.hideLocked and anyDungeon and not anyEnabled then
                p.emptyMsg:SetText("All your dungeons are locked & hidden.\nUncheck 'Hide locked dungeons' in Filter to see them\n(Mythic+ has no lockout).")
                p.emptyMsg:SetTextColor(1, 0.82, 0)
                p.status:SetText((dbg ~= "" and dbg) or "")
            elseif lastRoleFitHidden > 0 then
                -- The search DID return groups; the party role-fit rule hid them all. Without this
                -- message the blank list reads as "search is broken" when you're in a group.
                local t, h, d = partyRoleCounts()
                p.emptyMsg:SetText(("All %d found groups are hidden:\nnone has open slots for your whole party (%dT/%dH/%dD).\nGroups fill fast — Refresh, or browse solo to see everything."):format(lastRoleFitHidden, t, h, d))
                p.emptyMsg:SetTextColor(1, 0.82, 0)
                p.status:SetText("0 groups fit your party." .. autoNote() .. partyNote() .. roleNote() .. dbg)
            else
                p.emptyMsg:SetText("Click on Search to find groups")
                p.emptyMsg:SetTextColor(0.8, 0.8, 0.9)
                p.status:SetText("No groups found — click Search to search." .. autoNote() .. partyNote() .. roleNote() .. dbg)
            end
        end
    else
        p.emptyMsg:Hide()
        local from = scrollOffset + 1
        local to = math.min(scrollOffset + MAX_ROWS, lastFilteredCount)
        local groups = (lastFilteredCount == 1) and "group" or "groups"
        -- Show the active key/text filter in the footer so it's obvious the filter is engaged.
        -- Primary: Blizzard's engine-side text filter (the hosted search box) — the only filter
        -- that can see key levels. Fallback: the legacy client-side Key range (kstring-blind).
        local keyInfo = ""
        local engTxt = engineFilterText()
        if engTxt ~= "" then
            keyInfo = ("   |cff9fd3ff• Key '%s'|r"):format(engTxt)
        elseif (filter.minKey and filter.minKey > 0) or (filter.maxKey and filter.maxKey > 0) then
            keyInfo = ("   |cffff9f40• Key %s (no effect: type it in the Key box)|r"):format(formatRange(filter.minKey, filter.maxKey))
        end
        if quickView then
            p.status:SetText(("Quick Join: %d %s (friends/guild) — showing %d-%d"):format(lastFilteredCount, groups, from, to) .. dbg)
        else
            p.status:SetText(("%d %s found — showing %d-%d (scroll for more)"):format(lastFilteredCount, groups, from, to) .. keyInfo .. autoNote() .. partyNote() .. roleNote() .. dbg)
        end
    end
    saveFilterToDB()
end

-- Make sure Blizzard's own invite / role-check / application dialogs always sit ABOVE
-- our LFG panel + filter popout.
local invitesRaised = false
local function raiseBlizzardInviteDialogs()
    if invitesRaised then return end
    local hookedAny = false
    for _, name in ipairs({ "LFGListInviteDialog", "LFGListApplicationDialog", "LFGListInviteDialogRoleButtonTank" }) do
        local dlg = _G[name]
        if dlg and type(dlg.HookScript) == "function" and not dlg.MythicMateRaiseHooked then
            dlg.MythicMateRaiseHooked = true
            dlg:HookScript("OnShow", function(self)
                pcall(function()
                    self:SetFrameStrata("FULLSCREEN_DIALOG")
                    if self.SetToplevel then self:SetToplevel(true) end
                    if self.Raise then self:Raise() end
                end)
            end)
            hookedAny = true
        end
    end
    if hookedAny then invitesRaised = true end
end

local function hasDeserter()
    if type(AuraUtil) == "table" and type(AuraUtil.FindAuraByName) == "function" then
        local ok, name = pcall(AuraUtil.FindAuraByName, "Deserter", "player", "HARMFUL")
        if ok and name then return true end
    end
    return false
end

-- Combineert de LFG-beschikbaarheid: in een dungeon → alles uit (knoppen disabled + melding);
-- anders deserter-waarschuwing. Knoppen worden ge-/ontgrijsd zodat "disabled" ook zichtbaar is.
local function updateDeserterWarn()
    if not (panel and panel.warn) then return end
    local function setEnabled(btn, en)
        if not btn then return end
        if en then if btn.Enable then btn:Enable() end
        else if btn.Disable then btn:Disable() end end
    end
    if isInDungeon() then
        setEnabled(panel.search, false)
        setEnabled(panel.refresh, false)
        panel.warn:SetText("|cffff3030You're in a dungeon — LFG is disabled.|r")
        panel.warn:Show()
        if panel.emptyMsg then panel.emptyMsg:Hide() end -- warn owns the panel center
        return
    end
    setEnabled(panel.search, true)
    setEnabled(panel.refresh, true)
    if hasDeserter() then
        panel.warn:SetText("|cffff3030Deserter active — you can't queue right now.|r")
        panel.warn:Show()
        if panel.emptyMsg then panel.emptyMsg:Hide() end -- both center on the panel; warn wins
    else
        panel.warn:Hide()
    end
end

-- Toggle which toolbar controls are relevant for the current view. In Quick Join mode the
-- premade Search/Filter don't apply (Social Queue is read-only and unfiltered), so we hide them
-- and retitle the empty-state hint. Called from showLFG whenever the mode changes.
local function applyViewModeControls(p)
    local quick = (viewMode == "quickjoin")
    if p.filterBtn then p.filterBtn:SetShown(not quick) end
    if p.search then p.search:SetShown(not quick) end
    if p.listGroup then p.listGroup:SetShown(not quick) end
    if p.qfBoxes then
        -- Key slot: the hosted Blizzard search box when embedded (the only working key filter
        -- on 12.x), otherwise our legacy client-side range box.
        local emb = isBlizzBoxEmbedded()
        if p.qfBoxes.key then p.qfBoxes.key:SetShown(not quick and not emb) end
        if emb then
            local sb = blizzSearchBox()
            if sb then sb:SetShown(not quick) end
        end
        if p.qfBoxes.rio then p.qfBoxes.rio:SetShown(not quick) end
        if p.qfBoxes.keyLbl then p.qfBoxes.keyLbl:SetShown(not quick) end
        if p.qfBoxes.rioLbl then p.qfBoxes.rioLbl:SetShown(not quick) end
    end
    if quick then
        if p.filterPop then p.filterPop:Hide() end
        if p.emptyMsg then p.emptyMsg:SetText("No Quick Join groups — none of your friends or guildmates are in a premade right now.") end
    else
        if p.emptyMsg then p.emptyMsg:SetText("Click on Search to find groups") end
    end
end

-- Hand Blizzard back its search box the moment the real Group Finder opens, and take it back
-- when that closes while our LFG tab is still up. Hooked once, lazily (PVEFrame always exists
-- after login, but guard anyway).
local pveHooksDone = false
local function hookPVEFrameForSearchBox()
    if pveHooksDone then return end
    local pve = _G.PVEFrame
    if not (pve and type(pve.HookScript) == "function") then return end
    pve:HookScript("OnShow", function()
        releaseBlizzSearchBox()
        if panel and panel:IsShown() then applyViewModeControls(panel) end
    end)
    pve:HookScript("OnHide", function()
        if panel and panel:IsShown() and viewMode ~= "quickjoin" then
            if embedBlizzSearchBox(panel) then applyViewModeControls(panel) end
        end
    end)
    pveHooksDone = true
end

-- allowSearch MUST be true only when this is reached from a genuine hardware event (LFG-tab
-- click, Search/Refresh button, slash+Enter). C_LFGList.Search is hardware-event gated since
-- patch 7.2; calling it from a non-hardware path (e.g. the login tab-restore via
-- PLAYER_ENTERING_WORLD → SetActiveTab) is silently BLOCKED and shows up as "Interface actions
-- failed because of this AddOn". Default = no search, so any programmatic show is safe; the user
-- populates the list with the Search button (which pulses) from a real click.
local function showLFG(mode, allowSearch)
    viewMode = (mode == "quickjoin") and "quickjoin" or "all"
    loadFilterFromDB()
    local p = buildPanel()
    if not p then return end
    if type(p.refreshQuickFilters) == "function" then p.refreshQuickFilters() end
    -- Host Blizzard's secure search box as our Key filter (kstring titles: the engine-side
    -- text filter is the only one that can see key levels). Falls back to the legacy box
    -- when the Group Finder UI is open or unavailable.
    if viewMode ~= "quickjoin" then embedBlizzSearchBox(p) end
    hookPVEFrameForSearchBox()
    applyViewModeControls(p)
    raiseBlizzardInviteDialogs()
    updateDeserterWarn()
    if p.applyAccent then p.applyAccent() end -- re-tint chrome (class color may have changed)
    if type(_G.ScanLockouts) == "function" then pcall(_G.ScanLockouts) end
    applyAutoUncheckLocked() -- gesavede/locked dungeons meteen uitvinken
    rebuildFriends()
    if type(C_GuildInfo) == "table" and type(C_GuildInfo.GuildRoster) == "function" then
        pcall(C_GuildInfo.GuildRoster)
    end
    -- Quick Join needs no premade search (Social Queue arrives via events); only the full LFG
    -- view searches, and ONLY when reached from a real hardware event (allowSearch). Otherwise we
    -- just show the panel + refresh already-received results; the user searches via the button.
    lastAutoSearchAt = GetTime and GetTime() or 0
    if viewMode ~= "quickjoin" and allowSearch then triggerSearch() end
    refreshLFG()
    p:Show()
    -- Zoekresultaten komen asynchroon binnen. Alleen HERLEZEN (refresh) is toegestaan vanuit
    -- timers — NIET opnieuw zoeken (dat blokkeert WoW). Een paar refreshes vullen de lijst
    -- zodra Blizzards search-resultaten binnen zijn.
    if C_Timer and type(C_Timer.After) == "function" then
        for _, delay in ipairs({ 0.3, 0.8, 1.5, 2.5 }) do
            C_Timer.After(delay, function()
                if panel and panel:IsShown() then pcall(refreshLFG) end
            end)
        end
    end
end

local function hideLFG()
    releaseBlizzSearchBox() -- give the Group Finder its search box back
    if panel then
        if panel.filterPop then panel.filterPop:Hide() end
        panel:Hide()
    end
end

------------------------------------------------------------
-- Auto-refresh when new search results arrive (only while visible)
------------------------------------------------------------
-- Result-events kunnen in bursts binnenkomen (LFG_LIST_SEARCH_RESULT_UPDATED vuurt per
-- resultaat). Coalesce ze naar één refresh op de eerstvolgende frame: responsive zonder
-- de sorteer/rebuild per event te herhalen.
local refreshPending = false
local function scheduleRefresh()
    if refreshPending then return end
    if not (panel and panel:IsShown()) then return end
    refreshPending = true
    if C_Timer and type(C_Timer.After) == "function" then
        C_Timer.After(0, function()
            refreshPending = false
            if panel and panel:IsShown() then pcall(refreshLFG) end
        end)
    else
        refreshPending = false
        pcall(refreshLFG)
    end
end

local evt = CreateFrame("Frame")
evt:RegisterEvent("PLAYER_ENTERING_WORLD")
evt:RegisterEvent("LFG_LIST_SEARCH_RESULTS_RECEIVED")
evt:RegisterEvent("LFG_LIST_SEARCH_RESULT_UPDATED")
evt:RegisterEvent("LFG_LIST_SEARCH_FAILED")
evt:RegisterEvent("LFG_LIST_APPLICATION_STATUS_UPDATED")
evt:RegisterEvent("FRIENDLIST_UPDATE")
evt:RegisterEvent("GUILD_ROSTER_UPDATE")
evt:RegisterEvent("BN_FRIEND_INFO_CHANGED")
-- Quick Join / Social Queue changed → a friend joined/left a premade → re-read + re-merge.
evt:RegisterEvent("SOCIAL_QUEUE_UPDATE")
-- Party changed → recompute which dungeons party members are locked to.
evt:RegisterEvent("GROUP_ROSTER_UPDATE")
-- Party role assignment changed → re-filter (role-fit depends on assigned roles).
evt:RegisterEvent("PLAYER_ROLES_ASSIGNED")
evt:RegisterEvent("UNIT_AURA")
-- Lockout-wijzigingen (o.a. na dungeon completion) → locked dungeons opnieuw uitvinken.
evt:RegisterEvent("UPDATE_INSTANCE_INFO")
evt:RegisterEvent("LFG_LOCK_INFO_RECEIVED")
evt:RegisterEvent("ENCOUNTER_END")
evt:RegisterEvent("BOSS_KILL")
evt:RegisterEvent("CHALLENGE_MODE_COMPLETED")
-- Betreden/verlaten van een instance → LFG in/uitschakelen.
evt:RegisterEvent("ZONE_CHANGED_NEW_AREA")
evt:SetScript("OnEvent", function(_, event, unit)
    if event == "PLAYER_ENTERING_WORLD" or event == "ZONE_CHANGED_NEW_AREA" then
        -- Zet de SearchPanel vast klaar (addon + Dungeons-category) zodat de eerste
        -- tab-klik meteen kan zoeken i.p.v. een lege lijst te tonen.
        if event == "PLAYER_ENTERING_WORLD" then
            if C_Timer and type(C_Timer.After) == "function" then
                C_Timer.After(2.0, function() pcall(prepareSearchPanel) end)
            else
                pcall(prepareSearchPanel)
            end
        end
        -- LFG in/uit schakelen op basis van dungeon-status.
        if panel and panel:IsShown() then
            pcall(updateDeserterWarn)
            scheduleRefresh()
        end
        return
    end
    if event == "LFG_LIST_SEARCH_FAILED" then
        -- Kan hier niet vanzelf opnieuw zoeken (C_LFGList.Search vereist een hardware event).
        -- Alleen de status herlezen; de speler zoekt opnieuw via de Search-knop.
        scheduleRefresh()
        return
    end
    if event == "ENCOUNTER_END" or event == "BOSS_KILL" or event == "CHALLENGE_MODE_COMPLETED" then
        -- Net iets afgerond: vraag verse lockout-info op. De echte verwerking gebeurt zodra
        -- UPDATE_INSTANCE_INFO (async) binnenkomt.
        if type(RequestRaidInfo) == "function" then pcall(RequestRaidInfo) end
        return
    end
    if event == "UPDATE_INSTANCE_INFO" or event == "LFG_LOCK_INFO_RECEIVED" then
        -- Lockout kan gewijzigd zijn (o.a. na dungeon completion) → locked dungeons uitvinken.
        -- Defer één frame zodat de main-addon zijn eigen UPDATE_INSTANCE_INFO-rescan (die de
        -- lock-state bijwerkt) zeker heeft gedraaid vóór wij die uitlezen.
        local function processLockChange()
            applyAutoUncheckLocked()
            if panel and panel.filterPop and panel.filterPop:IsShown() then
                pcall(rebuildDungeonChecks, panel.filterPop)
            end
            scheduleRefresh()
        end
        if C_Timer and type(C_Timer.After) == "function" then
            C_Timer.After(0, processLockChange)
        else
            processLockChange()
        end
        return
    end
    if event == "FRIENDLIST_UPDATE" or event == "GUILD_ROSTER_UPDATE" or event == "BN_FRIEND_INFO_CHANGED" then
        rebuildFriends()
    end
    if event == "GROUP_ROSTER_UPDATE" then
        partyLockCache = nil -- force recompute of party lockouts on next refresh
    end
    if event == "UNIT_AURA" then
        if unit == "player" and panel and panel:IsShown() then pcall(updateDeserterWarn) end
        return
    end
    scheduleRefresh()
end)

-- Elke paar seconden de reeds ontvangen resultaten HERLEZEN (delisted/verdwenen groepen
-- vallen weg). Opnieuw ZOEKEN mag hier niet: C_LFGList.Search vereist een hardware event.
local ticker = CreateFrame("Frame")
ticker.acc = 0
ticker:SetScript("OnUpdate", function(self, elapsed)
    if not (panel and panel:IsShown()) then return end
    self.acc = (self.acc or 0) + (elapsed or 0)
    if self.acc >= 1 then
        self.acc = 0
        pcall(refreshLFG)
    end
end)

------------------------------------------------------------
-- "You joined" notification: when an LFG application is accepted (you join the group), pop up
-- which dungeon it was so it stays visible. Configurable via settings.notifyJoinedDungeon
-- (default ON). Works regardless of whether the LFG panel is open.
------------------------------------------------------------
StaticPopupDialogs["MYTHICMATE_JOINED_GROUP"] = {
    text = "|cffc868ffMythicMate|r\n\nYou joined:\n\n|cff40ff40%s|r",
    button1 = _G.OKAY or "Okay",
    timeout = 0,
    whileDead = true,
    hideOnEscape = true,
    preferredIndex = 3,
}

-- Shared fading center-top text toast (well visible, auto-fades, no click needed). Factory so the
-- joined-group and Quick Join alerts each get their own frame/position/size/duration.
local function makeFadingToast(frameName, yOffset, fontObject, holdSec)
    local f
    return function(msg)
        if not f then
            f = CreateFrame("Frame", frameName, UIParent)
            f:SetSize(600, 44)
            f:SetPoint("TOP", UIParent, "TOP", 0, yOffset)
            -- Topmost: highest strata + toplevel so the alert always renders above other frames.
            f:SetFrameStrata("TOOLTIP")
            f:SetToplevel(true)
            f:SetFrameLevel(9000)
            f.text = f:CreateFontString(nil, "OVERLAY", fontObject or "GameFontNormalLarge")
            f.text:SetPoint("CENTER")
            f.text:SetShadowColor(0, 0, 0, 1)
            f.text:SetShadowOffset(1, -1)
            f.ag = f:CreateAnimationGroup()
            local hold = f.ag:CreateAnimation("Alpha")
            hold:SetFromAlpha(1); hold:SetToAlpha(1); hold:SetDuration(holdSec or 2.2); hold:SetOrder(1)
            local fade = f.ag:CreateAnimation("Alpha")
            fade:SetFromAlpha(1); fade:SetToAlpha(0); fade:SetDuration(1.6); fade:SetOrder(2)
            f.ag:SetScript("OnFinished", function() f:Hide() end)
            f:Hide()
        end
        f.text:SetText(msg)
        f.ag:Stop()
        f:SetAlpha(1)
        f:Show()
        if f.Raise then f:Raise() end -- keep it above anything shown after it
        f.ag:Play()
    end
end

-- Joined = prominent (huge font, held longer); Quick Join = a touch smaller, just above it.
local showJoinedText = makeFadingToast("MythicMate_JoinedToast", -170, "GameFontNormalHuge", 3.2)
local showQuickJoinToast = makeFadingToast("MythicMate_QuickJoinToast", -215, "GameFontNormalLarge", 2.2)

-- Resolve a "Dungeon (Difficulty)" label from a search result the moment you join it.
local function resolveDungeonLabelForResult(resultID)
    if type(C_LFGList) ~= "table" or type(C_LFGList.GetSearchResultInfo) ~= "function" then return nil end
    local ok, info = pcall(C_LFGList.GetSearchResultInfo, resultID)
    if not ok or type(info) ~= "table" then return nil end
    local activityID = info.activityID or (type(info.activityIDs) == "table" and info.activityIDs[1]) or nil
    local dungeon, diff
    if activityID and type(C_LFGList.GetActivityInfoTable) == "function" then
        local okA, a = pcall(C_LFGList.GetActivityInfoTable, activityID, info.questID, info.isWarMode)
        if okA and type(a) == "table" then
            dungeon = firstNonEmpty(a.fullName, a.shortName, a.name)
            diff = classifyDifficulty(a)
        end
    end
    dungeon = dungeon or info.name
    if not dungeon or dungeon == "" then return nil end
    local label = tostring(displayName(dungeon)):gsub("%s*%b()%s*$", "")
    local diffText = ({ MYTHICPLUS = "Mythic+", MYTHIC = "Mythic", HEROIC = "Heroic", NORMAL = "Normal" })[diff or ""]
    if diffText then
        -- Append the advertised keystone level for M+ groups (e.g. "Mythic+ +10") when we can read it.
        local keyLvl = (diff == "MYTHICPLUS") and parseKeyLevel(info) or nil
        -- Parse the level from the laundered title/note (see the buildFiltered note): reading them
        -- via tostring works even when the raw search-result strings are momentarily protected.
        if not (keyLvl and keyLvl > 0) then
            keyLvl = (diff == "MYTHICPLUS") and parseKeyLevel({ name = tostring(info.name), comment = tostring(info.comment) }) or nil
        end
        if keyLvl and keyLvl > 0 then
            label = label .. " (" .. diffText .. " +" .. tostring(keyLvl) .. ")"
        else
            label = label .. " (" .. diffText .. ")"
            -- 12.x: the level lives only in the kstring title — unreadable, but it RENDERS.
            -- Append it so the joined-popup still shows "+11 Competitive".
            if diff == "MYTHICPLUS" and type(info.name) == "string" and info.name ~= "" then
                local okC, withTitle = pcall(function() return label .. "  " .. info.name end)
                if okC then label = withTitle end
            end
        end
    end
    return label
end

local notifiedJoin = {}
local function notifyJoinedGroup(resultID)
    if not resultID then return end
    local st = MythicMateDB and MythicMateDB.settings
    if st and st.notifyJoinedDungeon == false then return end -- opt-out (default on)
    if notifiedJoin[resultID] then return end
    notifiedJoin[resultID] = true
    local label = resolveDungeonLabelForResult(resultID) or "a group"
    -- Always echo to the chat console.
    print("|cffc868ffMythicMate:|r you joined |cff40ff40" .. label .. "|r.")
    if type(PlaySound) == "function" then pcall(PlaySound, (SOUNDKIT and SOUNDKIT.READY_CHECK) or 8960) end
    -- Style: click-to-dismiss popup (default) or a fading on-screen text.
    if st and st.joinedNotifyStyle == "text" then
        showJoinedText("|cffc868ffYou joined:|r  |cff40ff40" .. label .. "|r")
    else
        if type(StaticPopup_Show) == "function" then pcall(StaticPopup_Show, "MYTHICMATE_JOINED_GROUP", label) end
    end
end

local joinEvt = CreateFrame("Frame")
joinEvt:RegisterEvent("LFG_LIST_APPLICATION_STATUS_UPDATED")
joinEvt:SetScript("OnEvent", function(_, _, resultID, newStatus)
    -- "inviteaccepted" = you accepted the group's invite and joined.
    if newStatus == "inviteaccepted" then
        notifyJoinedGroup(resultID)
    end
end)

------------------------------------------------------------
-- Quick Join alert: fading center-screen text when a friend/guildmate goes into a Quick Join
-- (Social Queue) premade. Configurable via settings.notifyQuickJoin (default ON). Uses the
-- shared showQuickJoinToast factory defined above.
------------------------------------------------------------
-- Track which Social Queue groups we've already seen so we only alert on NEW ones. The first
-- pass establishes a baseline WITHOUT alerting (so login doesn't flood with existing groups).
local seenSocial = {}
local seenSocialInit = false
local lastQJCheckAt = 0
local function checkNewQuickJoin()
    local now = (type(GetTime) == "function") and GetTime() or 0
    if (now - lastQJCheckAt) < 0.5 then return end -- light throttle (SOCIAL_QUEUE_UPDATE can burst)
    lastQJCheckAt = now

    local socials = gatherSocialGroups()
    local current, newOnes = {}, {}
    for _, e in ipairs(socials) do
        local k = tostring(e.socialGUID or e.resultID or (tostring(e.leader) .. "|" .. tostring(e.dungeon)))
        current[k] = e
        if seenSocialInit and not seenSocial[k] then newOnes[#newOnes + 1] = e end
    end
    seenSocial = current

    if not seenSocialInit then seenSocialInit = true; return end -- baseline only
    local st = MythicMateDB and MythicMateDB.settings
    if st and st.notifyQuickJoin == false then return end -- opt-out (default on)
    if #newOnes == 0 then return end

    -- Never flash the fading Quick Join text (or play its sound) while inside a dungeon/raid.
    -- It's distracting mid-pull; the LFG tab still reflects the queue when you look for it.
    if type(IsInInstance) == "function" then
        local inInstance, instanceType = IsInInstance()
        if inInstance and (instanceType == "party" or instanceType == "raid" or instanceType == "scenario") then
            return
        end
    end

    if type(PlaySound) == "function" then
        pcall(PlaySound, (SOUNDKIT and SOUNDKIT.UI_QUICK_JOIN_APPLICATION_ICON) or 6457)
    end
    if #newOnes == 1 then
        local e = newOnes[1]
        local leader = tostring(e.leader or ""):match("^([^-]+)")
        if not leader or leader == "" then leader = "A friend" end
        local dungeon = tostring(displayName(e.dungeon) or e.dungeon or "?"):gsub("%s*%b()%s*$", "")
        showQuickJoinToast(("|cffc868ffQuick Join:|r |cff40ff40%s|r — %s"):format(leader, dungeon))
    else
        showQuickJoinToast(("|cffc868ffQuick Join:|r %d friends are queuing"):format(#newOnes))
    end
end

local qjEvt = CreateFrame("Frame")
qjEvt:RegisterEvent("SOCIAL_QUEUE_UPDATE")
qjEvt:RegisterEvent("PLAYER_ENTERING_WORLD")
qjEvt:SetScript("OnEvent", function() pcall(checkNewQuickJoin) end)

------------------------------------------------------------
-- Public interface (used by the main panel's LFG tab)
------------------------------------------------------------
MythicMateNS.LFG = {
    Show = function(fromUser) showLFG("all", fromUser and true or false) end,
    ShowQuickJoin = function() showLFG("quickjoin") end,
    Hide = hideLFG,
    Refresh = refreshLFG,
}
