-- MythicMate applicant sorter: a leader-side window that replaces Blizzard's applicant list
-- with a sortable, filterable view + per-row Invite/Decline. This is the flagship OAK LFG Sorter
-- feature; MythicMate previously only had the applicant-SIDE browser (the LFG tab).
--
-- Reading applicants (C_LFGList.GetApplicants / GetApplicantInfo / GetApplicantMemberInfo) and
-- Invite/DeclineApplicant are NOT hardware-event gated (unlike C_LFGList.Search), so we can
-- refresh from events and act from our own button clicks safely.
--
-- Split file so its locals get a fresh Lua chunk. Exposes MythicMateNS.Applicants =
-- { Show, Hide, Toggle, Refresh } so the rest of the addon can drive it.

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

------------------------------------------------------------
-- Small helpers (file-local copies; cross-file locals aren't shared)
------------------------------------------------------------
local ROLE_ATLAS = {
    TANK = "groupfinder-icon-role-large-tank",
    HEALER = "groupfinder-icon-role-large-heal",
    DAMAGER = "groupfinder-icon-role-large-dps",
}

local function roleRank(r)
    if r == "TANK" then return 1 elseif r == "HEALER" then return 2 else return 3 end
end

-- Colored short labels for the tooltip, keyed by the same tokens as ROLE_ATLAS.
local ROLE_LABEL = {
    TANK = "|cff5599ffTank|r",
    HEALER = "|cff55ff88Healer|r",
    DAMAGER = "|cffff8866DPS|r",
}

-- Every role a member SIGNED UP for (tank/healer/damage flags), not just the one auto-assigned.
-- An applicant can queue as several roles; we want to surface all of them. Returns an ordered
-- list of role tokens (Tank, Healer, DPS), falling back to the assigned role if no flags are set.
local function queuedRoleList(mem)
    local t = {}
    if mem.canTank then t[#t + 1] = "TANK" end
    if mem.canHeal then t[#t + 1] = "HEALER" end
    if mem.canDPS then t[#t + 1] = "DAMAGER" end
    if #t == 0 then t[#t + 1] = mem.role or "DAMAGER" end
    return t
end

local function queuedRoleTag(mem)
    local parts = {}
    for _, r in ipairs(queuedRoleList(mem)) do parts[#parts + 1] = ROLE_LABEL[r] or ROLE_LABEL.DAMAGER end
    return table.concat(parts, "/")
end

local function classColorHex(class)
    if class and 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
    return "|cffffffff"
end

local function rioHex(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

local function accentRGB()
    if type(_G.MythicMate_GetLFGAccent) == "function" then
        local r, g, b = _G.MythicMate_GetLFGAccent()
        if r then return r, g, b end
    end
    return 0.66, 0.40, 1.0
end

------------------------------------------------------------
-- State
------------------------------------------------------------
local frame = nil
local MAX_ROWS = 12
local ROW_H = 24
local scrollOffset = 0
local lastCount = 0
local sortKey = "role"  -- role | name | ilvl | rio
local sortAsc = true

-- Optimistic UI: InviteApplicant/DeclineApplicant only SEND a request; the row's real status
-- flips a moment later via LFG_LIST_APPLICANT_UPDATED, which made clicks feel laggy. We record
-- the intended action here and apply it immediately (invited row shows as invited, declined row
-- disappears), then the server refresh reconciles. applicantID → "invited" | "declined".
local pendingAction = {}

local function autoOpenEnabled()
    local st = MythicMateDB and MythicMateDB.settings
    if st and st.applicantsAutoOpen ~= nil then return st.applicantsAutoOpen and true or false end
    return true -- default on
end

local function setAutoOpen(v)
    MythicMateDB = MythicMateDB or {}
    MythicMateDB.settings = MythicMateDB.settings or {}
    MythicMateDB.settings.applicantsAutoOpen = v and true or false
end

------------------------------------------------------------
-- Data
------------------------------------------------------------
local function hasActiveListing()
    return type(C_LFGList) == "table" and type(C_LFGList.HasActiveEntryInfo) == "function"
        and C_LFGList.HasActiveEntryInfo() and true or false
end

-- Only the group LEADER may invite/decline applicants (solo = you're your own leader). In a
-- party, a non-leader can see applicants but must NOT be able to act on them.
local function canManageApplicants()
    if not (type(IsInGroup) == "function" and IsInGroup()) then return true end -- solo listing
    if type(UnitIsGroupLeader) == "function" then return UnitIsGroupLeader("player") and true or false end
    return false
end

-- Names currently in your group (you + party/raid members), as a lookup set. Keyed by both the
-- lowercase short name ("necronx") and, when known, the full "name-realm" ("necronx-kazzak"), so
-- an applicant who already joined can be matched and dropped from the list.
local function groupMemberNames()
    local set = {}
    if not (type(IsInGroup) == "function" and IsInGroup()) then return set end
    local inRaid = (type(IsInRaid) == "function") and IsInRaid() or false
    local total = (type(GetNumGroupMembers) == "function" and GetNumGroupMembers()) or 0
    local function add(unit)
        if type(UnitExists) ~= "function" or not UnitExists(unit) then return end
        local name, realm = UnitName(unit)
        if name and name ~= "" then
            set[name:lower()] = true
            if realm and realm ~= "" then set[(name .. "-" .. realm):lower()] = true end
        end
    end
    add("player")
    for i = 1, total do add((inRaid and "raid" or "party") .. i) end
    return set
end

-- True if an applicant member name (which may be "Name" or "Name-Realm") is already in the group.
local function nameInGroup(set, name)
    if not name then return false end
    local low = tostring(name):lower()
    if set[low] then return true end
    local short = low:match("^([^-]+)")
    return short ~= nil and set[short] == true
end

-- Build the applicant list. Each entry aggregates one applicant (which may be a small party of
-- several members). We key sorting/display off the applicant's leader (member 1) but keep every
-- member for the tooltip. Only pending/invited applications are shown, and applicants who have
-- already joined the group are dropped (Blizzard can leave them lingering in the list).
local function gatherApplicants()
    local out = {}
    if not (type(C_LFGList) == "table" and type(C_LFGList.GetApplicants) == "function"
        and type(C_LFGList.GetApplicantInfo) == "function"
        and type(C_LFGList.GetApplicantMemberInfo) == "function") then
        return out
    end
    if not hasActiveListing() then return out end
    local ok, ids = pcall(C_LFGList.GetApplicants)
    if not ok or type(ids) ~= "table" then return out end

    -- Reconcile optimistic actions: once the server drops an applicant from the list (decline
    -- processed), forget our pending flag for it so it can't linger.
    if next(pendingAction) then
        local present = {}
        for _, id in ipairs(ids) do present[id] = true end
        for pid in pairs(pendingAction) do
            if not present[pid] then pendingAction[pid] = nil end
        end
    end

    local inGroup = groupMemberNames()

    for _, applicantID in ipairs(ids) do
        -- Optimistic: a just-declined applicant is hidden until the server prunes it.
        if pendingAction[applicantID] == "declined" then
            -- skip this entry entirely
        else
        -- GetApplicantInfo returns ONE LfgApplicantData table now (12.0+), not positional values.
        -- Reading it positionally left numMembers nil → every applicant looked solo (no duos/
        -- groups ever showed) and the comment was lost.
        local aok, info = pcall(C_LFGList.GetApplicantInfo, applicantID)
        if aok and type(info) == "table" then
            local status = info.applicationStatus
            local comment = info.comment
            local numMembers = tonumber(info.numMembers) or 1
            local s = tostring(status or "")
            -- Optimistic: a just-invited applicant shows as invited immediately.
            if pendingAction[applicantID] == "invited" then s = "invited" end
            if s == "applied" or s == "invited" or s == "" then
                local members = {}
                for m = 1, numMembers do
                    -- Return order per Blizzard's LFGListApplicationViewer_UpdateApplicantMember:
                    -- name, class, localizedClass, level, itemLevel, HONORLEVEL, tank, healer,
                    -- damage, assignedRole, relationship, dungeonScore, ... Missing honorLevel
                    -- here once shifted every later field by one (honorLevel landed in `tank`, so
                    -- every non-assigned applicant showed as tank and Rio read `relationship`).
                    local mok, name, class, _locClass, _level, ilvl, _honorLevel, tank, healer, damage, assignedRole, _rel, dungeonScore =
                        pcall(C_LFGList.GetApplicantMemberInfo, applicantID, m)
                    if mok and name then
                        -- assignedRole = the role they queued as ("TANK"/"HEALER"/"DAMAGER"/"NONE").
                        local role = assignedRole
                        if type(role) ~= "string" or role == "NONE" or role == "" then
                            role = tank and "TANK" or (healer and "HEALER") or "DAMAGER"
                        end
                        members[#members + 1] = {
                            name = name, class = class,
                            ilvl = tonumber(ilvl) or 0,
                            role = role,
                            score = tonumber(dungeonScore) or 0,
                            -- Every role this member is available for, so a multi-role sign-up is visible.
                            canTank = tank and true or false,
                            canHeal = healer and true or false,
                            canDPS = damage and true or false,
                        }
                    end
                end
                -- Drop applicants who are already in the group (invite accepted but Blizzard hasn't
                -- pruned the entry yet). If ANY member already joined, skip the whole entry.
                local alreadyJoined = false
                for _, mem in ipairs(members) do
                    if nameInGroup(inGroup, mem.name) then alreadyJoined = true; break end
                end
                -- comment is a kstringLfgListApplicant (render-only token): SetText renders it, but
                -- never compare/concatenate its content. Keep it raw and carry a "present" flag.
                local hasComment = false
                if comment ~= nil then
                    if type(issecretvalue) == "function" and issecretvalue(comment) then
                        hasComment = true
                    else
                        hasComment = tostring(comment) ~= ""
                    end
                end
                local lead = members[1]
                if lead and not alreadyJoined then
                    out[#out + 1] = {
                        applicantID = applicantID,
                        status = s,
                        numMembers = numMembers,
                        comment = comment,
                        hasComment = hasComment,
                        members = members,
                        name = lead.name, class = lead.class,
                        role = lead.role, ilvl = lead.ilvl, score = lead.score,
                        -- All roles the leader queued for (drives the multi-icon role column).
                        leadRoles = queuedRoleList(lead),
                    }
                end
            end
        end
        end -- else (not optimistically declined)
    end

    table.sort(out, function(a, b)
        if sortKey == "name" then
            local an, bn = tostring(a.name):lower(), tostring(b.name):lower()
            if an ~= bn then if sortAsc then return an < bn else return an > bn end end
            return a.ilvl > b.ilvl
        elseif sortKey == "ilvl" then
            if a.ilvl ~= b.ilvl then if sortAsc then return a.ilvl < b.ilvl else return a.ilvl > b.ilvl end end
            return roleRank(a.role) < roleRank(b.role)
        elseif sortKey == "rio" then
            if a.score ~= b.score then if sortAsc then return a.score < b.score else return a.score > b.score end end
            return a.ilvl > b.ilvl
        else -- role
            local ra, rb = roleRank(a.role), roleRank(b.role)
            if ra ~= rb then if sortAsc then return ra < rb else return ra > rb end end
            return a.ilvl > b.ilvl
        end
    end)
    return out
end

------------------------------------------------------------
-- Actions
------------------------------------------------------------
local refreshApplicants -- forward

local function inviteApplicant(applicantID)
    if not applicantID or type(C_LFGList) ~= "table" then return end
    if not canManageApplicants() then
        print("|cffc868ffMythicMate:|r only the group leader can invite applicants.")
        return
    end
    if type(C_LFGList.InviteApplicant) == "function" then
        pcall(C_LFGList.InviteApplicant, applicantID)
        pendingAction[applicantID] = "invited" -- optimistic: reflect it now, reconcile on event
        if refreshApplicants then refreshApplicants() end
    end
end

local function declineApplicant(applicantID)
    if not applicantID or type(C_LFGList) ~= "table" then return end
    if not canManageApplicants() then
        print("|cffc868ffMythicMate:|r only the group leader can decline applicants.")
        return
    end
    if type(C_LFGList.DeclineApplicant) == "function" then
        pcall(C_LFGList.DeclineApplicant, applicantID)
        pendingAction[applicantID] = "declined" -- optimistic: drop the row now, reconcile on event
        if refreshApplicants then refreshApplicants() end
    end
end

------------------------------------------------------------
-- UI
------------------------------------------------------------
-- ElvUI / EllesmereUI: skin the window frame, close button and auto-open checkbox to match the
-- user's UI (same shared skins module the rest of the addon uses). No-op without ElvUI.
local function skinElvUI(f)
    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.HandleFrame then S:HandleFrame(f) end
        if S.HandleCloseButton and f.close then S:HandleCloseButton(f.close) end
        if S.HandleCheckBox and f.autoCheck then S:HandleCheckBox(f.autoCheck) end
    end)
end

local function applyAccent()
    if not frame then return end
    local r, g, b = accentRGB()
    if frame.SetBackdropBorderColor then
        frame:SetBackdropBorderColor(r * 0.7 + 0.05, g * 0.7 + 0.05, b * 0.7 + 0.05, 0.95)
    end
    if frame.titleBar and frame.titleBar.SetGradient then
        frame.titleBar:SetGradient("VERTICAL",
            CreateColor(r * 0.45, g * 0.45, b * 0.45, 1),
            CreateColor(r * 0.14, g * 0.14, b * 0.14, 1))
    end
    if frame.hdrLine then frame.hdrLine:SetColorTexture(r, g, b, 0.55) end
end

-- One clickable, sortable column header.
local function makeHeader(parent, text, xoff, key, rightAligned)
    local b = CreateFrame("Button", nil, parent)
    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)
    b.fs:SetJustifyH(rightAligned and "RIGHT" or "LEFT")
    if rightAligned then b:SetPoint("TOPRIGHT", xoff, -34) else b:SetPoint("TOPLEFT", xoff, -34) end
    b.arrow = b:CreateTexture(nil, "OVERLAY")
    b.arrow:SetSize(10, 10)
    b.arrow:SetPoint("LEFT", b, "RIGHT", 1, 0)
    b.arrow:Hide()
    b._key = key
    if key then
        parent.headers = parent.headers or {}
        parent.headers[key] = b
        b:SetScript("OnClick", function(self)
            if sortKey == self._key then
                sortAsc = not sortAsc
            else
                sortKey = self._key
                sortAsc = not (self._key == "ilvl" or self._key == "rio") -- numeric cols default desc
            end
            if refreshApplicants then refreshApplicants() 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

-- A small square icon-button (Invite = green check, Decline = red X).
local function makeActionButton(parent, atlasOrTex, isAtlas)
    local btn = CreateFrame("Button", nil, parent)
    btn:SetSize(20, 20)
    local tex = btn:CreateTexture(nil, "ARTWORK")
    tex:SetAllPoints(btn)
    if isAtlas then tex:SetAtlas(atlasOrTex) else tex:SetTexture(atlasOrTex) end
    btn.tex = tex
    local hi = btn:CreateTexture(nil, "HIGHLIGHT")
    hi:SetAllPoints(btn)
    hi:SetColorTexture(1, 1, 1, 0.15)
    return btn
end

local function buildFrame()
    if frame then return frame end

    local f = CreateFrame("Frame", "MythicMate_ApplicantsFrame", UIParent, "BackdropTemplate")
    f:SetSize(478, 40 + MAX_ROWS * ROW_H + 40)
    f:SetPoint("CENTER", UIParent, "CENTER", 260, 0)
    f:SetFrameStrata("DIALOG")
    f:SetToplevel(true)
    f:EnableMouse(true)
    f:SetClampedToScreen(true)
    -- Remember where the user drags this window (persists across reloads).
    if type(_G.MythicMate_PersistFrame) == "function" then
        _G.MythicMate_PersistFrame(f, "applicants")
    else
        f:SetMovable(true); f:RegisterForDrag("LeftButton")
        f:SetScript("OnDragStart", f.StartMoving); f:SetScript("OnDragStop", f.StopMovingOrSizing)
    end
    f:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        tile = true, tileSize = 16, edgeSize = 12,
        insets = { left = 3, right = 3, top = 3, bottom = 3 },
    })
    f:SetBackdropColor(0.035, 0.04, 0.075, 0.97)
    f:SetBackdropBorderColor(0.5, 0.4, 0.85, 0.95)

    -- Title bar (accent gradient) + title + close.
    f.titleBar = f:CreateTexture(nil, "ARTWORK")
    f.titleBar:SetPoint("TOPLEFT", 4, -4)
    f.titleBar:SetPoint("TOPRIGHT", -4, -4)
    f.titleBar:SetHeight(24)
    f.titleBar:SetTexture("Interface\\Buttons\\WHITE8x8")

    f.title = f:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    f.title:SetPoint("LEFT", f.titleBar, "LEFT", 10, 0)
    f.title:SetText("Applicants")

    f.close = CreateFrame("Button", nil, f, "UIPanelCloseButton")
    f.close:SetPoint("TOPRIGHT", 2, 2)
    f.close:SetScript("OnClick", function() f:Hide() end)

    -- Column headers. Role is just the 16px icon column; Name starts far enough right that the
    -- Role header's sort-arrow can't bleed into it.
    makeHeader(f, "Role", 14, "role", false)
    makeHeader(f, "Name", 64, "name", false)
    makeHeader(f, "iLvl", 210, "ilvl", false)
    makeHeader(f, "Rio", 258, "rio", false)
    local noteHdr = f:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    noteHdr:SetPoint("TOPLEFT", 322, -34)
    noteHdr:SetTextColor(0.72, 0.72, 0.85)
    noteHdr:SetText("Note")

    f.hdrLine = f:CreateTexture(nil, "ARTWORK")
    f.hdrLine:SetColorTexture(0.4, 0.4, 0.55, 0.6)
    f.hdrLine:SetPoint("TOPLEFT", 8, -50)
    f.hdrLine:SetPoint("TOPRIGHT", -8, -50)
    f.hdrLine:SetHeight(1)

    -- Empty state.
    f.emptyMsg = f:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    f.emptyMsg:SetPoint("CENTER", f, "CENTER", 0, 6)
    f.emptyMsg:SetTextColor(0.8, 0.8, 0.9)
    f.emptyMsg:Hide()

    -- Rows.
    f.rows = {}
    local ry = -54
    for i = 1, MAX_ROWS do
        local row = CreateFrame("Frame", nil, f)
        row:SetPoint("TOPLEFT", 4, ry)
        row:SetPoint("TOPRIGHT", -4, 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.roleIcon = row:CreateTexture(nil, "ARTWORK")
        row.roleIcon:SetSize(16, 16)
        row.roleIcon:SetPoint("LEFT", 12, 0)

        -- Up to two extra role icons for applicants who queued as multiple roles (e.g. Tank+DPS),
        -- so a multi-role sign-up is obvious at a glance instead of hidden behind the assigned role.
        row.roleIcon2 = row:CreateTexture(nil, "ARTWORK")
        row.roleIcon2:SetSize(13, 13)
        row.roleIcon2:SetPoint("LEFT", row.roleIcon, "RIGHT", 1, 0)
        row.roleIcon2:Hide()
        row.roleIcon3 = row:CreateTexture(nil, "ARTWORK")
        row.roleIcon3:SetSize(13, 13)
        row.roleIcon3:SetPoint("LEFT", row.roleIcon2, "RIGHT", 1, 0)
        row.roleIcon3:Hide()

        row.name = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        row.name:SetPoint("LEFT", 62, 0)
        row.name:SetWidth(144)
        row.name:SetJustifyH("LEFT")
        if row.name.SetWordWrap then row.name:SetWordWrap(false) end

        row.ilvl = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        row.ilvl:SetPoint("LEFT", 210, 0)
        row.ilvl:SetWidth(44)
        row.ilvl:SetJustifyH("LEFT")

        row.rio = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        row.rio:SetPoint("LEFT", 258, 0)
        row.rio:SetWidth(56)
        row.rio:SetJustifyH("LEFT")

        row.note = row:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
        row.note:SetPoint("LEFT", 322, 0)
        row.note:SetPoint("RIGHT", row, "RIGHT", -70, 0)
        row.note:SetJustifyH("LEFT")
        if row.note.SetWordWrap then row.note:SetWordWrap(false) end

        row.decline = makeActionButton(row, "Interface\\RAIDFRAME\\ReadyCheck-NotReady", false)
        row.decline:SetPoint("RIGHT", row, "RIGHT", -8, 0)
        row.decline:SetScript("OnClick", function() if row._applicantID then declineApplicant(row._applicantID) end end)
        row.decline:SetScript("OnEnter", function(self)
            if GameTooltip then GameTooltip:SetOwner(self, "ANCHOR_RIGHT"); GameTooltip:SetText("Decline"); GameTooltip:Show() end
        end)
        row.decline:SetScript("OnLeave", function() if GameTooltip then GameTooltip:Hide() end end)

        row.invite = makeActionButton(row, "Interface\\RAIDFRAME\\ReadyCheck-Ready", false)
        row.invite:SetPoint("RIGHT", row.decline, "LEFT", -4, 0)
        row.invite:SetScript("OnClick", function() if row._applicantID then inviteApplicant(row._applicantID) end end)
        row.invite:SetScript("OnEnter", function(self)
            if GameTooltip then GameTooltip:SetOwner(self, "ANCHOR_RIGHT"); GameTooltip:SetText("Invite"); GameTooltip:Show() end
        end)
        row.invite:SetScript("OnLeave", function() if GameTooltip then GameTooltip:Hide() end end)

        -- Row hover + member tooltip.
        row:EnableMouse(true)
        row:SetScript("OnEnter", function(self)
            self.bg:SetColorTexture(1, 1, 1, 0.10)
            if not (self._members and GameTooltip) then return end
            GameTooltip:SetOwner(self, "ANCHOR_LEFT")
            GameTooltip:AddLine("Applicant group")
            for _, mem in ipairs(self._members) do
                -- Show ALL roles the member queued for (Tank/Healer/DPS), not just the assigned one.
                local line = ("%s%s|r  %s  ilvl %d"):format(classColorHex(mem.class), mem.name, queuedRoleTag(mem), mem.ilvl or 0)
                if mem.score and mem.score > 0 then line = line .. ("  %s%d|r"):format(rioHex(mem.score), mem.score) end
                GameTooltip:AddLine(line)
            end
            if self._hasComment then
                -- Never concatenate the comment (kstring token) — add it as its own line.
                GameTooltip:AddLine(" ")
                GameTooltip:AddLine("Note:", 0.6, 0.85, 1)
                GameTooltip:AddLine(self._comment, 0.8, 0.9, 1, true)
            end
            -- Append the leader's Raider.IO profile block — full M+ score + best runs, including the
            -- best TIMED keys per dungeon (chest/star markers). Same rich panel RaiderIO adds to
            -- Blizzard's own applicant tooltips. No-op without the RaiderIO addon installed.
            local lead = self._members[1]
            if lead and lead.name and _G.RaiderIO and type(_G.RaiderIO.ShowProfile) == "function" then
                local nm, realm = tostring(lead.name):match("^(.-)%-(.+)$")
                if not nm or nm == "" then
                    nm = lead.name
                    realm = (type(GetNormalizedRealmName) == "function") and GetNormalizedRealmName() or nil
                end
                GameTooltip:AddLine(" ")
                pcall(_G.RaiderIO.ShowProfile, GameTooltip, nm, realm)
            end
            GameTooltip:Show()
        end)
        row:SetScript("OnLeave", function(self)
            self.bg:SetColorTexture(1, 1, 1, (i % 2 == 0) and 0.05 or 0.015)
            if GameTooltip then GameTooltip:Hide() end
        end)

        row:Hide()
        f.rows[i] = row
        ry = ry - ROW_H
    end

    -- Footer: auto-open toggle (right) + status (left). Build the toggle first so the status
    -- can bound its right edge against it and never overlap the "Auto-open" label.
    f.autoCheck = CreateFrame("CheckButton", nil, f, "UICheckButtonTemplate")
    f.autoCheck:SetSize(20, 20)
    f.autoCheck:SetPoint("BOTTOMRIGHT", -10, 6)
    f.autoCheck.text = f.autoCheck:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    f.autoCheck.text:SetPoint("RIGHT", f.autoCheck, "LEFT", -2, 0)
    f.autoCheck.text:SetText("Auto-open")
    f.autoCheck:SetChecked(autoOpenEnabled())
    f.autoCheck:SetScript("OnClick", function(self) setAutoOpen(self:GetChecked()) end)

    f.status = f:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
    f.status:SetPoint("BOTTOMLEFT", 12, 10)
    f.status:SetPoint("RIGHT", f.autoCheck.text, "LEFT", -10, 0) -- stop before the toggle
    f.status:SetJustifyH("LEFT")
    if f.status.SetWordWrap then f.status:SetWordWrap(false) end

    f:EnableMouseWheel(true)
    f:SetScript("OnMouseWheel", function(_, delta)
        local maxOff = math.max(0, lastCount - MAX_ROWS)
        scrollOffset = math.max(0, math.min(maxOff, scrollOffset - (delta or 0) * 3))
        if refreshApplicants then refreshApplicants() end
    end)

    frame = f
    applyAccent()
    f:Hide()
    return f
end

function refreshApplicants()
    local f = buildFrame()
    if not f then return end
    applyAccent()

    local list = gatherApplicants()
    lastCount = #list
    local maxOff = math.max(0, lastCount - MAX_ROWS)
    if scrollOffset > maxOff then scrollOffset = maxOff end

    -- Invite/Decline are leader-only. Non-leaders see the list read-only (no action buttons).
    local canManage = canManageApplicants()

    -- Sort arrows.
    if f.headers then
        for k, hb in pairs(f.headers) do
            if hb.arrow then
                if sortKey == 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

    for i = 1, MAX_ROWS do
        local row = f.rows[i]
        local e = list[scrollOffset + i]
        if e then
            row._applicantID = e.applicantID
            row._members = e.members
            row._comment = e.comment
            row._hasComment = e.hasComment
            local roles = e.leadRoles or { e.role }
            row.roleIcon:SetAtlas(ROLE_ATLAS[roles[1]] or ROLE_ATLAS.DAMAGER)
            if roles[2] then
                row.roleIcon2:SetAtlas(ROLE_ATLAS[roles[2]] or ROLE_ATLAS.DAMAGER); row.roleIcon2:Show()
            else
                row.roleIcon2:Hide()
            end
            if roles[3] then
                row.roleIcon3:SetAtlas(ROLE_ATLAS[roles[3]] or ROLE_ATLAS.DAMAGER); row.roleIcon3:Show()
            else
                row.roleIcon3:Hide()
            end
            local nameTxt = classColorHex(e.class) .. tostring(e.name) .. "|r"
            if (e.numMembers or 1) > 1 then nameTxt = nameTxt .. (" |cffffd000+%d|r"):format(e.numMembers - 1) end
            row.name:SetText(nameTxt)
            row.ilvl:SetText(e.ilvl > 0 and tostring(e.ilvl) or "")
            row.rio:SetText(e.score > 0 and (rioHex(e.score) .. tostring(e.score) .. "|r") or "")
            row.note:SetText(e.hasComment and e.comment or "")
            row.invite:SetShown(canManage and e.status ~= "invited")
            row.decline:SetShown(canManage)
            row:Show()
        else
            row._applicantID = nil
            row._members = nil
            row:Hide()
        end
    end

    if not hasActiveListing() then
        f.emptyMsg:SetText("You have no active group listing.\nList a group in Premade Groups to review applicants.")
        f.emptyMsg:Show()
        f.status:SetText("")
    elseif lastCount == 0 then
        f.emptyMsg:SetText("No applicants yet.")
        f.emptyMsg:Show()
        f.status:SetText("Waiting for applicants…")
    else
        f.emptyMsg:Hide()
        local from = scrollOffset + 1
        local to = math.min(scrollOffset + MAX_ROWS, lastCount)
        local word = (lastCount == 1) and "applicant" or "applicants"
        local base = ("%d %s — showing %d-%d"):format(lastCount, word, from, to)
        if not canManage then
            base = base .. "   |cffff8080• Leader only — read-only|r"
        end
        f.status:SetText(base)
    end
end

------------------------------------------------------------
-- Show / hide / toggle
------------------------------------------------------------
local function showApplicants()
    local f = buildFrame()
    if not f then return end
    scrollOffset = 0
    refreshApplicants()
    f:Show()
    f:Raise()
end

local function hideApplicants()
    if frame then frame:Hide() end
end

local function toggleApplicants()
    if frame and frame:IsShown() then hideApplicants() else showApplicants() end
end

------------------------------------------------------------
-- Events + auto-open
------------------------------------------------------------
local evt = CreateFrame("Frame")
evt:RegisterEvent("LFG_LIST_APPLICANT_LIST_UPDATED")
evt:RegisterEvent("LFG_LIST_APPLICANT_UPDATED")
evt:RegisterEvent("LFG_LIST_ACTIVE_ENTRY_UPDATE")
-- Leadership can change while the window is open (lead passed) → re-evaluate invite/decline
-- permission and re-skin the action buttons.
evt:RegisterEvent("PARTY_LEADER_CHANGED")
evt:RegisterEvent("GROUP_ROSTER_UPDATE")
evt:SetScript("OnEvent", function(_, event)
    if event == "PARTY_LEADER_CHANGED" or event == "GROUP_ROSTER_UPDATE" then
        if frame and frame:IsShown() then refreshApplicants() end
        return
    end
    if event == "LFG_LIST_ACTIVE_ENTRY_UPDATE" then
        -- Listing removed → close; created → nothing (opens when applicants arrive).
        if not hasActiveListing() then
            hideApplicants()
        elseif frame and frame:IsShown() then
            refreshApplicants()
        end
        return
    end
    -- Applicant list changed. Auto-open the window when you have applicants (opt-out via the
    -- Auto-open checkbox), otherwise just refresh if it's already shown.
    if hasActiveListing() then
        local hasApplicants = false
        if type(C_LFGList.GetApplicants) == "function" then
            local ok, ids = pcall(C_LFGList.GetApplicants)
            hasApplicants = ok and type(ids) == "table" and #ids > 0
        end
        if hasApplicants and autoOpenEnabled() and not (frame and frame:IsShown()) then
            showApplicants()
        elseif frame and frame:IsShown() then
            refreshApplicants()
        end
    elseif frame and frame:IsShown() then
        refreshApplicants()
    end
end)

------------------------------------------------------------
-- Slash: /wlapps and /wlapplicants toggle the window.
------------------------------------------------------------
_G.SLASH_MYTHICMATEAPPS1 = "/wlapps"
_G.SLASH_MYTHICMATEAPPS2 = "/wlapplicants"
_G.SlashCmdList = _G.SlashCmdList or {}
_G.SlashCmdList["MYTHICMATEAPPS"] = function() toggleApplicants() end

------------------------------------------------------------
-- Open our applicant window when the player clicks Blizzard's minimap "eye" (QueueStatusButton) —
-- the icon that lights up when applicants arrive. Only when enabled (settings.applicantsOpenOnEye,
-- default ON) and you actually have an active group listing. Runs alongside Blizzard's own click.
------------------------------------------------------------
local function openOnEyeEnabled()
    local st = MythicMateDB and MythicMateDB.settings
    if st and st.applicantsOpenOnEye ~= nil then return st.applicantsOpenOnEye and true or false end
    return true -- default on
end

local eyeHooked = false
local function hookQueueEye()
    if eyeHooked then return end
    local btn = _G.QueueStatusButton or _G.QueueStatusMinimapButton
    if not btn or type(btn.HookScript) ~= "function" then return end
    eyeHooked = true
    btn:HookScript("OnClick", function()
        if openOnEyeEnabled() and hasActiveListing() then
            pcall(showApplicants)
        end
    end)
end

local eyeEvt = CreateFrame("Frame")
eyeEvt:RegisterEvent("PLAYER_ENTERING_WORLD")
eyeEvt:SetScript("OnEvent", function() hookQueueEye() end)
hookQueueEye() -- also try now (the button usually exists already on a /reload)

------------------------------------------------------------
-- Public interface
------------------------------------------------------------
MythicMateNS.Applicants = {
    Show = showApplicants,
    Hide = hideApplicants,
    Toggle = toggleApplicants,
    Refresh = refreshApplicants,
}
