local ADDON_NAME = ...

local MAX_APPLICATIONS = MAX_LFG_LIST_APPLICATIONS or 5
local DUNGEON_CATEGORY_ID = 2
local EXPIRING_APPLICATION_THRESHOLD = 3 * 60
local BUTTON_REFRESH_INTERVAL = 1
local ACTION_CONTEXT_CACHE_TTL = 0.1
local CANCELLED_GROUP_MEMORY_TTL = 10 * 60
local NOTIFICATION_DURATION = 10
local SEARCH_RESULTS_MAX_AGE = 30
local NOTIFICATION_FALLBACK_WIDTH = 470
local NOTIFICATION_STANDARD_FRAME_WIDTH_RATIO = 0.5
local SCORE_REFRESH_INTERVAL = 5
local SCORE_TANK = 75
local SCORE_HEALER = 35
local SCORE_DAMAGER = 5
local SCORE_BLOODLUST = 10
local SCORE_FIRST_BATTLE_REZ = 8
local SCORE_SECOND_BATTLE_REZ = 2
local SCORE_POWER_INFUSION = 4
local SCORE_EXTRA_POWER_INFUSION = 2
local SCORE_DUPLICATE_CLASS_ROLE = -12
local SCORE_UNKNOWN_CLASS = -2
local APPLICATION_SUBMIT_INTERVAL = 1
local HOST_SORT_REFRESH_INTERVAL = 5
local HOST_WORK_BUDGET_MS = 1.5
local HOST_TOOLTIP_REFRESH_INTERVAL = 0.5
local HOST_ITEM_LEVEL_MAX_SCORE = 30
local HOST_RATING_PER_POINT = 100
local HOST_MAX_UPGRADE_RANK = 6
local HOST_ITEM_LEVELS_PER_FOUR_UPGRADES = 13
local HOST_MYTHIC_PLUS_LEVEL_MIN = 2
local HOST_MYTHIC_PLUS_LEVEL_MAX = 40
local HOST_LEAVER_PENALTY_MULTIPLIER = 2
local HOST_LEAVER_BASE_PENALTY = -20
local HOST_BUNDLE_SCORE_DIVISOR = 10
local HOST_BUNDLE_MAX_SCORE = 6

local hostedSeasonItemLevelScaleCache = {}

local Core = {
    NOTIFICATION_COLORS = {
        success = GREEN_FONT_COLOR,
        danger = RED_FONT_COLOR,
        neutral = { r = 0.72, g = 0.72, b = 0.76 },
    },
    HOST_RECORD_SORT_FIELDS = {
        "priorityScore",
        "directContribution",
        "averageBestRunLevel",
        "averageRating",
        "averageItemLevel",
    },
    COMPOSITION_VALUE_FIELDS = {
        "TANK",
        "HEALER",
        "DAMAGER",
        "total",
        "bloodlustCount",
        "battleRezCount",
        "powerInfusionCount",
        "unknownClassCount",
    },
    HOST_DUNGEON_EXPERIENCE_TIERS = {
        { maximum = -2, score = 0 },
        { maximum = -1, score = 10 },
        { maximum = 0, score = 20 },
        { maximum = math.huge, score = 30 },
    },
    HOST_DUNGEON_MOTIVATION_TIERS = {
        { maximum = -3, score = 0 },
        { maximum = -2, score = 10 },
        { maximum = -1, score = 20 },
        { maximum = 0, score = 0 },
        { maximum = 1, score = -10 },
        { maximum = math.huge, score = -20 },
    },
}

local L = GetLocale() == "ruRU" and {
    ROLE_TANK = "танк",
    ROLE_HEALER = "лекарь",
    ROLE_DAMAGER = "боец",
    READY_TO_APPLY = "Готово к подаче заявок",
    NO_APPLICATIONS_TO_CANCEL = "Нет заявок для снятия",
    APPLICATION_ROLE_UNKNOWN = "роль заявки не определена",
    NO_ROLE_SLOTS_COUNTER = "штатный счётчик: свободных мест для роли нет",
    ROLE_FILLED_COMPOSITION = "состав группы: роль уже занята",
    ROLE_FILLED_MEMBERS = "список участников: роль уже занята",
    ROLE_SLOT_COUNTER = "штатный счётчик: место для роли свободно",
    ROLE_SLOT_COMPOSITION = "состав группы: лимит роли не заполнен",
    ROLE_SLOT_MEMBERS = "список участников: место для роли свободно",
    ROLE_DATA_UNAVAILABLE = "данные роли участников недоступны",
    FILTERED_AND_EXPIRING = "строка больше не соответствует фильтрам; до автоотказа осталось %d:%02d",
    FILTERED_OUT = "строка больше не соответствует установленным фильтрам",
    EXPIRING = "до автоотказа осталось %d:%02d",
    GROUP_NAME_UNAVAILABLE = "Название группы недоступно",
    DUNGEON_UNKNOWN = "Подземелье не определено",
    SPECIALIZATION_UNKNOWN = "Специализация неизвестна",
    EXCLUSION_10_MINUTES = "Регистрация в эту группу отключена на 10 минут.",
    CANCELLED_MANUALLY = "Заявка снята вручную",
    APPLIED_MANUALLY = "Заявка подана вручную",
    CANCELLED_ONE = "Снята одна неактуальная заявка",
    CANCELLED_AUTOMATICALLY = "Заявка снята автоматически",
    CANCEL_FAILED = "Не удалось отменить заявку",
    ERROR_CANCEL_FAILED = "One Button LFG: отмена не выполнена",
    RESULTS_PROTECTED = "Результаты защищены клиентом",
    ERROR_RESULTS_PROTECTED = "One Button LFG: клиент не дал прочитать результаты",
    APPLIED_ONE = "Подана одна новая заявка",
    APPLIED_AUTOMATICALLY = "Заявка подана автоматически",
    NO_SUITABLE_GROUPS = "Подходящих свободных групп нет",
    ERROR_NO_SUITABLE_GROUPS = "One Button LFG: подходящих свободных групп нет",
    OPEN_GROUP_FINDER = "Откройте стандартный поиск групп",
    ERROR_OPEN_GROUP_FINDER = "One Button LFG: откройте стандартный поиск групп",
    DUNGEONS_ONLY = "Кнопка настроена для подземелий",
    ERROR_OPEN_DUNGEONS = "One Button LFG: откройте поиск подземелий",
    ROLE_DETECTION_FAILED = "Не удалось определить текущую роль",
    ERROR_ROLE_DETECTION_FAILED = "One Button LFG: не удалось определить текущую роль",
    PARTY_MEMBER_ROLE_UNKNOWN = "Не определена роль участника вашей группы",
    ERROR_PARTY_MEMBER_ROLE_UNKNOWN = "One Button LFG: не удалось определить роль одного из участников вашей группы",
    PARTY_LEADER_REQUIRED = "Подать заявку может только лидер группы",
    ERROR_PARTY_LEADER_REQUIRED = "One Button LFG: подать заявку может только лидер группы",
    PARTY_TOO_LARGE = "Текущая группа слишком велика для подземелья",
    ERROR_PARTY_TOO_LARGE = "One Button LFG: текущая группа слишком велика для подземелья",
    PARTY_FULL = "В вашей группе нет свободных мест",
    ERROR_PARTY_FULL = "One Button LFG: в вашей группе нет свободных мест",
    PARTY_COMPOSITION_INVALID = "Текущий состав превышает лимит одной из ролей",
    ERROR_PARTY_COMPOSITION_INVALID = "One Button LFG: текущий состав превышает лимит одной из ролей",
    ROLE_POLL_REQUESTED = "Запрошено подтверждение ролей",
    ROLE_POLL_WAITING = "ожидание подтверждения ролей",
    ALL_APPLICATIONS_FULL = "Все 5 заявок уже заняты",
    ERROR_ALL_APPLICATIONS_FULL = "One Button LFG: все 5 заявок уже заняты",
    APPLY_BUTTON = "Подать %d/%d",
    ACTIVE_APPLICATIONS = "Активные заявки",
    ONE_ACTION_PER_CLICK = "Blizzard разрешает только одно защищённое действие за нажатие.",
    CAN_CANCEL = "Можно снять",
    SEARCH_AGE = "Возраст списка",
    SEARCH_AGE_SECONDS = "%d сек.",
    SEARCH_AGE_UNKNOWN = "неизвестен",
    NEXT_ACTION = "Следующее действие",
    NEXT_REFRESH = "обновить список",
    NEXT_APPLY = "подать заявку",
    NEXT_APPLY_DELAYED = "подать через %d сек.",
    NEXT_CANCEL = "снять заявку",
    NEXT_NONE = "нет доступных действий",
    APPLICATION_COOLDOWN = "Следующую заявку можно подать через %d сек.",
    COOLDOWN_NOTIFICATION_TITLE = "Следующая заявка",
    COOLDOWN_NOTIFICATION_ACTIVITY = "Ожидание следующего разрешённого действия",
    RIGHT_CLICK_CANCEL_LATEST = "ПКМ: снять последнюю заявку",
    SEARCH_REFRESHING = "Обновление списка...",
    SEARCH_REFRESHED = "Список обновлён",
    ERROR_SEARCH_REFRESHING = "One Button LFG: список обновляется; после завершения нажмите кнопку ещё раз",
    SEARCH_REFRESH_FAILED = "Не удалось обновить список",
    ERROR_SEARCH_REFRESH_FAILED = "One Button LFG: не удалось обновить список",
    APPLICATION_DECLINED = "Получен отказ в приёме в группу",
    APPLICATION_DECLINED_FULL = "Заявка отклонена: группа уже заполнена",
    GROUP_DELISTED = "Группа больше не находится в списке",
    APPLICANT_SORT_COUNTDOWN = "%dс",
    APPLICANT_SORT_RUNNING = "...",
    APPLICANT_SORT_TOOLTIP_ACTIVE = "Активные заявки",
    APPLICANT_SORT_TOOLTIP_ROLES = "Кандидаты: %s %d  •  %s %d  •  %s %d",
    APPLICANT_SORT_TOOLTIP_WAITING = "Следующий анализ через %d сек.",
    APPLICANT_SORT_TOOLTIP_RUNNING = "Сейчас идёт анализ и пересчёт рекомендаций.",
    APPLICANT_SORT_TOOLTIP_INTERVAL = "Каждые 5 секунд алгоритм добавляет в рекомендации новые и изменившиеся заявки.",
    APPLICANT_SORT_TOOLTIP_FULL = "Изменение состава группы, назначенных ролей или приглашённых игроков запускает полный пересчёт всей очереди.",
} or {
    ROLE_TANK = "tank",
    ROLE_HEALER = "healer",
    ROLE_DAMAGER = "damage",
    READY_TO_APPLY = "Ready to apply",
    NO_APPLICATIONS_TO_CANCEL = "No applications to cancel",
    APPLICATION_ROLE_UNKNOWN = "application role could not be determined",
    NO_ROLE_SLOTS_COUNTER = "default counter: no slots remain for this role",
    ROLE_FILLED_COMPOSITION = "group composition: this role is already filled",
    ROLE_FILLED_MEMBERS = "member list: this role is already filled",
    ROLE_SLOT_COUNTER = "default counter: a slot remains for this role",
    ROLE_SLOT_COMPOSITION = "group composition: role limit is not filled",
    ROLE_SLOT_MEMBERS = "member list: a slot remains for this role",
    ROLE_DATA_UNAVAILABLE = "member role data is unavailable",
    FILTERED_AND_EXPIRING = "result no longer matches the filters; automatic timeout in %d:%02d",
    FILTERED_OUT = "result no longer matches the selected filters",
    EXPIRING = "automatic timeout in %d:%02d",
    GROUP_NAME_UNAVAILABLE = "Group name unavailable",
    DUNGEON_UNKNOWN = "Dungeon unavailable",
    SPECIALIZATION_UNKNOWN = "Specialization unavailable",
    EXCLUSION_10_MINUTES = "Applications to this group are disabled for 10 minutes.",
    CANCELLED_MANUALLY = "Application cancelled manually",
    APPLIED_MANUALLY = "Application submitted manually",
    CANCELLED_ONE = "Cancelled one outdated application",
    CANCELLED_AUTOMATICALLY = "Application cancelled automatically",
    CANCEL_FAILED = "Could not cancel the application",
    ERROR_CANCEL_FAILED = "One Button LFG: cancellation failed",
    RESULTS_PROTECTED = "Search results are protected by the client",
    ERROR_RESULTS_PROTECTED = "One Button LFG: the client did not allow reading search results",
    APPLIED_ONE = "Submitted one new application",
    APPLIED_AUTOMATICALLY = "Application submitted automatically",
    NO_SUITABLE_GROUPS = "No suitable available groups",
    ERROR_NO_SUITABLE_GROUPS = "One Button LFG: no suitable available groups",
    OPEN_GROUP_FINDER = "Open the default Group Finder",
    ERROR_OPEN_GROUP_FINDER = "One Button LFG: open the default Group Finder",
    DUNGEONS_ONLY = "This button is configured for dungeons",
    ERROR_OPEN_DUNGEONS = "One Button LFG: open the dungeon search",
    ROLE_DETECTION_FAILED = "Could not determine your current role",
    ERROR_ROLE_DETECTION_FAILED = "One Button LFG: could not determine your current role",
    PARTY_MEMBER_ROLE_UNKNOWN = "A party member's role is not assigned",
    ERROR_PARTY_MEMBER_ROLE_UNKNOWN = "One Button LFG: could not determine a role for one of your party members",
    PARTY_LEADER_REQUIRED = "Only the party leader can apply",
    ERROR_PARTY_LEADER_REQUIRED = "One Button LFG: only the party leader can apply",
    PARTY_TOO_LARGE = "Your current group is too large for a dungeon",
    ERROR_PARTY_TOO_LARGE = "One Button LFG: your current group is too large for a dungeon",
    PARTY_FULL = "Your party has no available slots",
    ERROR_PARTY_FULL = "One Button LFG: your party has no available slots",
    PARTY_COMPOSITION_INVALID = "Your current party exceeds a role limit",
    ERROR_PARTY_COMPOSITION_INVALID = "One Button LFG: your current party exceeds a role limit",
    ROLE_POLL_REQUESTED = "Role confirmation requested",
    ROLE_POLL_WAITING = "waiting for role confirmation",
    ALL_APPLICATIONS_FULL = "All 5 application slots are occupied",
    ERROR_ALL_APPLICATIONS_FULL = "One Button LFG: all 5 application slots are occupied",
    APPLY_BUTTON = "Apply %d/%d",
    ACTIVE_APPLICATIONS = "Active applications",
    ONE_ACTION_PER_CLICK = "Blizzard allows only one protected action per click.",
    CAN_CANCEL = "Can cancel",
    SEARCH_AGE = "Result age",
    SEARCH_AGE_SECONDS = "%d sec.",
    SEARCH_AGE_UNKNOWN = "unknown",
    NEXT_ACTION = "Next action",
    NEXT_REFRESH = "refresh results",
    NEXT_APPLY = "submit application",
    NEXT_APPLY_DELAYED = "apply in %d sec.",
    NEXT_CANCEL = "cancel application",
    NEXT_NONE = "no action available",
    APPLICATION_COOLDOWN = "The next application can be submitted in %d sec.",
    COOLDOWN_NOTIFICATION_TITLE = "Next application",
    COOLDOWN_NOTIFICATION_ACTIVITY = "Waiting for the next permitted action",
    RIGHT_CLICK_CANCEL_LATEST = "Right-click: cancel newest application",
    SEARCH_REFRESHING = "Refreshing results...",
    SEARCH_REFRESHED = "Results refreshed",
    ERROR_SEARCH_REFRESHING = "One Button LFG: results are refreshing; click the button again when complete",
    SEARCH_REFRESH_FAILED = "Could not refresh results",
    ERROR_SEARCH_REFRESH_FAILED = "One Button LFG: could not refresh results",
    APPLICATION_DECLINED = "Application declined by the group",
    APPLICATION_DECLINED_FULL = "Application declined: the group is full",
    GROUP_DELISTED = "The group is no longer listed",
    APPLICANT_SORT_COUNTDOWN = "%ds",
    APPLICANT_SORT_RUNNING = "...",
    APPLICANT_SORT_TOOLTIP_ACTIVE = "Active applications",
    APPLICANT_SORT_TOOLTIP_ROLES = "Candidates: %s %d  •  %s %d  •  %s %d",
    APPLICANT_SORT_TOOLTIP_WAITING = "Next analysis in %d sec.",
    APPLICANT_SORT_TOOLTIP_RUNNING = "Applications are being analyzed and recommendations recalculated.",
    APPLICANT_SORT_TOOLTIP_INTERVAL = "Every 5 seconds, the algorithm adds new and changed applications to the recommendations.",
    APPLICANT_SORT_TOOLTIP_FULL = "Changing the group composition, assigned roles, or invited players fully recalculates the entire queue.",
}

local ROLE_REMAINING_KEYS = {
    TANK = "TANK_REMAINING",
    HEALER = "HEALER_REMAINING",
    DAMAGER = "DAMAGER_REMAINING",
}

local ROLE_SLOT_LIMITS = {
    TANK = { memberKey = "TANK", maximum = 1 },
    HEALER = { memberKey = "HEALER", maximum = 1 },
    DAMAGER = { memberKey = "DAMAGER", maximum = 3 },
}

local BLOODLUST_CLASSES = {
    EVOKER = true,
    HUNTER = true,
    MAGE = true,
    SHAMAN = true,
}

local BATTLE_REZ_CLASSES = {
    DEATHKNIGHT = true,
    DRUID = true,
    PALADIN = true,
    WARLOCK = true,
}

local POWER_INFUSION_CLASSES = {
    PRIEST = true,
}

local ROLE_NAMES = {
    TANK = L.ROLE_TANK,
    HEALER = L.ROLE_HEALER,
    DAMAGER = L.ROLE_DAMAGER,
}

local ROLE_CARD_ICONS = {
    TANK = INLINE_TANK_ICON or CreateAtlasMarkup("groupfinder-icon-role-large-tank", 16, 16),
    HEALER = INLINE_HEALER_ICON or CreateAtlasMarkup("groupfinder-icon-role-large-heal", 16, 16),
    DAMAGER = INLINE_DAMAGER_ICON or CreateAtlasMarkup("groupfinder-icon-role-large-dps", 16, 16),
}

local ROLE_NOTIFICATION_ORDER = {
    TANK = 1,
    HEALER = 2,
    DAMAGER = 3,
    NONE = 4,
}

local function GetSpellIconMarkup(spellID)
    local texture = C_Spell and C_Spell.GetSpellTexture and C_Spell.GetSpellTexture(spellID)
    if not texture and type(GetSpellTexture) == "function" then
        texture = GetSpellTexture(spellID)
    end

    return texture and string.format("|T%s:15:15:0:0|t", tostring(texture)) or ""
end

local BLOODLUST_SPELL_ICON = GetSpellIconMarkup(2825)
local BATTLE_REZ_SPELL_ICON = GetSpellIconMarkup(20484)
local POWER_INFUSION_SPELL_ICON = GetSpellIconMarkup(10060)

local function GetClassIconMarkup(classFile)
    if type(classFile) ~= "string" or classFile == "" then
        return ""
    end

    return CreateAtlasMarkup("classicon-" .. string.lower(classFile), 16, 16)
end

local ACTIVE_APPLICATION_STATUSES = {
    applied = true,
    invited = true,
}

local APPLICATION_STATUS_ACTIONS = {
    declined_delisted = L.GROUP_DELISTED,
    declined_full = L.APPLICATION_DECLINED_FULL,
    declined = L.APPLICATION_DECLINED,
}

local state = {
    applyButton = nil,
    lastAction = L.READY_TO_APPLY,
    unreadableRoleData = false,
    sortingHooked = false,
    dungeonContextHooked = false,
    applicantSortingHooked = false,
    applicantViewerHooked = false,
    applicantRefreshElapsed = 0,
    applicantSortCountdown = nil,
    applicantSortCountdownValue = nil,
    applicantContextRefreshElapsed = 0,
    applicantDungeonListingActive = false,
    cachedApplicantOrder = nil,
    applicantRecordCache = {},
    applicantGroupSignature = nil,
    applicantListingSignature = nil,
    applicantBaseSignature = nil,
    applicantSortJob = nil,
    applicantForceFull = true,
    lastApplicantSortError = nil,
    cancellationHooked = false,
    applicationHooked = false,
    addonCancellationInProgress = false,
    addonApplyInProgress = false,
    lastApplicationRequestedAt = nil,
    applicationDetails = {},
    applicationKeys = {},
    applicationInactiveAt = {},
    notificationFrames = {},
    activeNotifications = {},
    notificationSerial = 0,
    updatePending = false,
    buttonRefreshElapsed = 0,
    actionContext = nil,
    actionContextAt = nil,
    actionContextPanel = nil,
    lastSearchResultsAt = nil,
    searchRefreshPending = false,
    searchRefreshStartedAt = nil,
    refreshRequiredAfterOpen = true,
    rolePollRequestedForSignature = nil,
    homePartySignature = nil,
    scoredCandidates = nil,
    scoredCandidatesAt = nil,
    scoredCompositionSignature = nil,
    rioTooltipHooked = nil,
    gameTooltipHooked = false,
    notificationWidthFrameHooked = nil,
}

local UpdateButtons
local QueueButtonUpdate
local EnsureRaiderIOTooltipHook

local function SafeCall(callback, ...)
    local results = { pcall(callback, ...) }
    if not results[1] then
        return false, results[2]
    end

    table.remove(results, 1)
    return true, unpack(results)
end

function Core.SafeCallWithStateFlag(flagName, callback, ...)
    state[flagName] = true
    local results = { SafeCall(callback, ...) }
    state[flagName] = false
    return unpack(results)
end

local function IsDungeonSearchVisible()
    local panel = LFGListFrame and LFGListFrame.SearchPanel
    return panel
        and panel:IsShown()
        and panel.categoryID == DUNGEON_CATEGORY_ID
end

local function GetPlayerRole()
    local specializationIndex
    if type(GetSpecialization) == "function" then
        specializationIndex = GetSpecialization()
    elseif C_SpecializationInfo and type(C_SpecializationInfo.GetSpecialization) == "function" then
        specializationIndex = C_SpecializationInfo.GetSpecialization()
    end

    local role = specializationIndex and GetSpecializationRole(specializationIndex)
    if ROLE_REMAINING_KEYS[role] then
        return role
    end

    return nil
end

local function GetUnitApplicationRole(unit)
    local role = type(UnitGroupRolesAssigned) == "function" and UnitGroupRolesAssigned(unit)
    if ROLE_SLOT_LIMITS[role] then
        return role
    end

    if unit == "player" then
        return GetPlayerRole()
    end

    return nil
end

function Core.NormalizeClassFile(classFile)
    if type(classFile) ~= "string" or classFile == "" then
        return nil
    end

    return string.upper(classFile)
end

function Core.GetUnitClassFile(unit)
    local ok, classFile = pcall(function()
        local _, unitClassFile = UnitClass(unit)
        return Core.NormalizeClassFile(unitClassFile)
    end)
    return ok and classFile or nil
end

function Core.GetHomePartyUnits()
    local inHomeParty = IsInGroup(LE_PARTY_CATEGORY_HOME)
    local units = { "player" }
    if inHomeParty then
        for index = 1, 4 do
            local unit = "party" .. index
            if UnitExists(unit) then
                units[#units + 1] = unit
            end
        end
    end

    return units, inHomeParty
end

local function CreateEmptyComposition()
    return {
        TANK = 0,
        HEALER = 0,
        DAMAGER = 0,
        total = 0,
        playerRole = nil,
        classRoleCounts = {},
        bloodlustCount = 0,
        battleRezCount = 0,
        powerInfusionCount = 0,
        unknownClassCount = 0,
    }
end

local function CopyComposition(source)
    local copy = CreateEmptyComposition()
    for index = 1, #Core.COMPOSITION_VALUE_FIELDS do
        local field = Core.COMPOSITION_VALUE_FIELDS[index]
        copy[field] = source[field]
    end
    copy.playerRole = source.playerRole
    for classRoleKey, count in pairs(source.classRoleCounts) do
        copy.classRoleCounts[classRoleKey] = count
    end
    return copy
end

function Core.AddMemberIdentityToComposition(composition, role, classFile)
    classFile = Core.NormalizeClassFile(classFile)
    if not classFile then
        composition.unknownClassCount = composition.unknownClassCount + 1
        return
    end

    if BLOODLUST_CLASSES[classFile] then
        composition.bloodlustCount = composition.bloodlustCount + 1
    end
    if BATTLE_REZ_CLASSES[classFile] then
        composition.battleRezCount = composition.battleRezCount + 1
    end
    if POWER_INFUSION_CLASSES[classFile] then
        composition.powerInfusionCount = composition.powerInfusionCount + 1
    end

    if ROLE_SLOT_LIMITS[role] then
        local classRoleKey = role .. ":" .. classFile
        composition.classRoleCounts[classRoleKey] = (composition.classRoleCounts[classRoleKey] or 0) + 1
    else
        composition.unknownClassCount = composition.unknownClassCount + 1
    end
end

local function AddMemberToComposition(composition, role, classFile)
    if not ROLE_SLOT_LIMITS[role] then
        return false
    end

    composition[role] = composition[role] + 1
    composition.total = composition.total + 1
    Core.AddMemberIdentityToComposition(composition, role, classFile)

    return true
end

function Core.AddUnitsToComposition(composition, units, trackPlayerRole)
    for index = 1, #units do
        local unit = units[index]
        local role = GetUnitApplicationRole(unit)
        if not role then
            return false, unit
        end

        AddMemberToComposition(composition, role, Core.GetUnitClassFile(unit))
        if trackPlayerRole and unit == "player" then
            composition.playerRole = role
        end
    end

    return true
end

function Core.CompositionExceedsRoleLimits(composition)
    for role, slotLimit in pairs(ROLE_SLOT_LIMITS) do
        if composition[role] > slotLimit.maximum then
            return true
        end
    end
    return false
end

function Core.GetStackedScore(count, primaryLimit, primaryScore, extraLimit, extraScore)
    local primaryCount = math.min(count, primaryLimit)
    local extraCount = math.max(0, count - primaryLimit)
    if extraLimit ~= nil then
        extraCount = math.min(extraCount, extraLimit)
    end
    return primaryCount * primaryScore + extraCount * extraScore
end

local function GetCompositionScore(composition)
    local duplicateClassRoles = 0
    for _, count in pairs(composition.classRoleCounts) do
        if count > 1 then
            duplicateClassRoles = duplicateClassRoles + count - 1
        end
    end

    local score = math.min(composition.TANK, 1) * SCORE_TANK
        + math.min(composition.HEALER, 1) * SCORE_HEALER
        + math.min(composition.DAMAGER, 3) * SCORE_DAMAGER
        + Core.GetStackedScore(composition.bloodlustCount, 1, SCORE_BLOODLUST, 0, 0)
        + Core.GetStackedScore(
            composition.battleRezCount,
            1,
            SCORE_FIRST_BATTLE_REZ,
            1,
            SCORE_SECOND_BATTLE_REZ
        )
        + Core.GetStackedScore(
            composition.powerInfusionCount,
            2,
            SCORE_POWER_INFUSION,
            nil,
            SCORE_EXTRA_POWER_INFUSION
        )
        + duplicateClassRoles * SCORE_DUPLICATE_CLASS_ROLE
        + composition.unknownClassCount * SCORE_UNKNOWN_CLASS

    return score, duplicateClassRoles
end

function Core.GetCompositionSignature(composition, includePlayerRole)
    local parts = {}
    for index = 1, #Core.COMPOSITION_VALUE_FIELDS do
        parts[#parts + 1] = tostring(composition[Core.COMPOSITION_VALUE_FIELDS[index]])
    end
    if includePlayerRole then
        table.insert(parts, 5, tostring(composition.playerRole))
    end

    local classRoleKeys = {}
    for classRoleKey in pairs(composition.classRoleCounts) do
        classRoleKeys[#classRoleKeys + 1] = classRoleKey
    end
    table.sort(classRoleKeys)
    for index = 1, #classRoleKeys do
        local classRoleKey = classRoleKeys[index]
        parts[#parts + 1] = classRoleKey .. "=" .. tostring(composition.classRoleCounts[classRoleKey])
    end
    return table.concat(parts, "|")
end

local function GetApplicantPartyComposition()
    local composition = CreateEmptyComposition()

    local units, inHomeParty = Core.GetHomePartyUnits()
    if inHomeParty then
        local isLeaderOK, isLeader = pcall(UnitIsGroupLeader, "player", LE_PARTY_CATEGORY_HOME)
        if not isLeaderOK or not isLeader then
            return nil, L.PARTY_LEADER_REQUIRED, L.ERROR_PARTY_LEADER_REQUIRED, "leader_required"
        end

        local memberCountOK, memberCount = pcall(GetNumGroupMembers, LE_PARTY_CATEGORY_HOME)
        if memberCountOK and type(memberCount) == "number" and memberCount > 5 then
            return nil, L.PARTY_TOO_LARGE, L.ERROR_PARTY_TOO_LARGE, "party_too_large"
        end
    end

    local compositionOK, failedUnit = Core.AddUnitsToComposition(composition, units, true)
    if not compositionOK then
        local message = failedUnit == "player" and L.ROLE_DETECTION_FAILED or L.PARTY_MEMBER_ROLE_UNKNOWN
        local errorMessage = failedUnit == "player" and L.ERROR_ROLE_DETECTION_FAILED or L.ERROR_PARTY_MEMBER_ROLE_UNKNOWN
        return nil, message, errorMessage, "roles_unknown"
    end

    if inHomeParty then
        local memberCountOK, memberCount = pcall(GetNumGroupMembers, LE_PARTY_CATEGORY_HOME)
        if memberCountOK and type(memberCount) == "number" and memberCount ~= composition.total then
            return nil, L.PARTY_MEMBER_ROLE_UNKNOWN, L.ERROR_PARTY_MEMBER_ROLE_UNKNOWN, "roles_unknown"
        end
    end

    if composition.total >= 5 then
        return nil, L.PARTY_FULL, L.ERROR_PARTY_FULL, "party_full"
    end

    if Core.CompositionExceedsRoleLimits(composition) then
        return nil, L.PARTY_COMPOSITION_INVALID, L.ERROR_PARTY_COMPOSITION_INVALID, "composition_invalid"
    end

    return composition
end

local function GetHomePartySignature()
    local units, inHomeParty = Core.GetHomePartyUnits()
    if not inHomeParty then
        return nil
    end

    local identifiers = {}
    for index = 1, #units do
        local unit = units[index]
        identifiers[#identifiers + 1] = UnitGUID(unit) or UnitName(unit) or unit
    end
    table.sort(identifiers)

    return table.concat(identifiers, ":")
end

local function SyncHomePartySignature()
    local signature = GetHomePartySignature()
    if signature ~= state.homePartySignature then
        state.homePartySignature = signature
        state.rolePollRequestedForSignature = nil
    end

    return signature
end

local function MaybeInitiatePartyRolePoll(failureCode)
    if failureCode ~= "roles_unknown" or type(InitiateRolePoll) ~= "function" then
        return false
    end

    local signature = SyncHomePartySignature()
    if not signature or signature == "" or state.rolePollRequestedForSignature == signature then
        return false
    end

    state.rolePollRequestedForSignature = signature
    local ok, started = SafeCall(InitiateRolePoll)
    if ok and started then
        state.lastAction = L.ROLE_POLL_REQUESTED
        return true
    end

    return false
end

local RoleHasRemainingSlot

local function PartyCompositionFitsResult(resultID, applicantComposition)
    if not applicantComposition then
        return false
    end

    if applicantComposition.total == 1 then
        local hasRemaining = RoleHasRemainingSlot(resultID, applicantComposition.playerRole)
        if hasRemaining == false then
            return false
        end

        local confirmedCompositionOK, confirmedCompositionFits = pcall(function()
            local searchResultInfo = C_LFGList.GetSearchResultInfo(resultID)
            local numMembers = searchResultInfo and searchResultInfo.numMembers
            if type(numMembers) == "number" and numMembers + applicantComposition.total > 5 then
                return false
            end

            local counts = C_LFGList.GetSearchResultMemberCounts(resultID)
            if counts then
                for role, slotLimit in pairs(ROLE_SLOT_LIMITS) do
                    local currentMembers = counts[slotLimit.memberKey]
                    if type(currentMembers) == "number"
                        and currentMembers + applicantComposition[role] > slotLimit.maximum
                    then
                        return false
                    end
                end
            end
            return true
        end)
        if confirmedCompositionOK and confirmedCompositionFits == false then
            return false
        end

        return true
    end

    local searchResultInfo = C_LFGList.GetSearchResultInfo(resultID)
    local numMembers = searchResultInfo and searchResultInfo.numMembers
    if type(numMembers) ~= "number" then
        state.unreadableRoleData = true
        return false
    end

    if numMembers + applicantComposition.total > 5 then
        return false
    end

    local resultComposition = {
        TANK = 0,
        HEALER = 0,
        DAMAGER = 0,
    }
    local exactRolesReadable = true

    for memberIndex = 1, numMembers do
        local memberInfo = C_LFGList.GetSearchResultPlayerInfo(resultID, memberIndex)
        local assignedRole = memberInfo and memberInfo.assignedRole
        if ROLE_SLOT_LIMITS[assignedRole] then
            resultComposition[assignedRole] = resultComposition[assignedRole] + 1
        else
            exactRolesReadable = false
            break
        end
    end

    if not exactRolesReadable then
        local counts = C_LFGList.GetSearchResultMemberCounts(resultID)
        if not counts then
            state.unreadableRoleData = true
            return false
        end

        local countedMembers = 0
        for role, slotLimit in pairs(ROLE_SLOT_LIMITS) do
            local count = counts[slotLimit.memberKey]
            if type(count) ~= "number" then
                state.unreadableRoleData = true
                return false
            end
            resultComposition[role] = count
            countedMembers = countedMembers + count
        end

        if countedMembers ~= numMembers then
            state.unreadableRoleData = true
            return false
        end
    end

    for role, slotLimit in pairs(ROLE_SLOT_LIMITS) do
        if resultComposition[role] + applicantComposition[role] > slotLimit.maximum then
            return false
        end
    end

    return true
end

local function GetResultScoreMetadata(resultID, applicantComposition)
    local metadata = CopyComposition(applicantComposition)
    metadata.combinedMembers = applicantComposition.total

    local countOK, numMembers = pcall(function()
        local searchResultInfo = C_LFGList.GetSearchResultInfo(resultID)
        local count = searchResultInfo and searchResultInfo.numMembers
        if type(count) == "number" and count >= 0 and count <= 5 then
            return count
        end
    end)
    if not countOK or type(numMembers) ~= "number" then
        metadata.unknownClassCount = metadata.unknownClassCount + 1
        numMembers = 0
    end
    metadata.combinedMembers = math.min(5, applicantComposition.total + numMembers)

    local aggregateRolesOK, aggregateRoles = pcall(function()
        local counts = C_LFGList.GetSearchResultMemberCounts(resultID)
        if not counts then
            return nil
        end

        local roleCounts = {}
        for role, slotLimit in pairs(ROLE_SLOT_LIMITS) do
            local count = counts[slotLimit.memberKey]
            if type(count) ~= "number" then
                return nil
            end
            roleCounts[role] = count
        end
        return roleCounts
    end)
    if aggregateRolesOK and aggregateRoles then
        for role in pairs(ROLE_SLOT_LIMITS) do
            metadata[role] = applicantComposition[role] + aggregateRoles[role]
        end
    end

    local exactRoleCounts = { TANK = 0, HEALER = 0, DAMAGER = 0 }
    local exactRolesReadable = numMembers > 0
    for memberIndex = 1, numMembers do
        local memberOK, assignedRole, classFile = pcall(function()
            local memberInfo = C_LFGList.GetSearchResultPlayerInfo(resultID, memberIndex)
            if not memberInfo then
                return nil, nil
            end

            local role = ROLE_SLOT_LIMITS[memberInfo.assignedRole] and memberInfo.assignedRole or nil
            local memberClassFile = Core.NormalizeClassFile(memberInfo.classFilename)
            return role, memberClassFile
        end)

        if memberOK and assignedRole then
            exactRoleCounts[assignedRole] = exactRoleCounts[assignedRole] + 1
        else
            exactRolesReadable = false
        end

        Core.AddMemberIdentityToComposition(
            metadata,
            memberOK and assignedRole or nil,
            memberOK and classFile or nil
        )
    end

    if (not aggregateRolesOK or not aggregateRoles) and exactRolesReadable then
        for role in pairs(ROLE_SLOT_LIMITS) do
            metadata[role] = applicantComposition[role] + exactRoleCounts[role]
        end
    end

    local score, duplicateClassRoles = GetCompositionScore(metadata)
    metadata.score = score
    metadata.duplicateClassRoles = duplicateClassRoles
    return metadata
end

local function IsSecretValue(value)
    if type(issecretvalue) ~= "function" then
        return false
    end

    local ok, secret = pcall(issecretvalue, value)
    return ok and secret == true
end

local function IsReadableNumber(value)
    return type(value) == "number" and not IsSecretValue(value)
end

local function GetStableApplicantID(applicantInfo)
    if type(applicantInfo) ~= "table" then
        return nil
    end

    local applicantID = applicantInfo.applicantID
    if IsSecretValue(applicantID) then
        return nil
    end

    local applicantIDType = type(applicantID)
    if applicantIDType == "number" then
        return applicantID
    end
    if applicantIDType == "string" and applicantID ~= "" then
        return applicantID
    end

    return nil
end

local function GetApplicantInfoSignature(applicantInfo)
    if type(applicantInfo) ~= "table" then
        return nil
    end

    local values = {
        applicantInfo.applicationStatus,
        applicantInfo.pendingApplicationStatus,
        applicantInfo.numMembers,
    }
    local parts = {}
    for index = 1, 3 do
        local value = values[index]
        if IsSecretValue(value) then
            return nil
        end
        local valueType = type(value)
        if valueType ~= "nil" and valueType ~= "string" and valueType ~= "number" and valueType ~= "boolean" then
            return nil
        end
        parts[index] = valueType == "nil" and "nil" or tostring(value)
    end
    return table.concat(parts, "|")
end

function Core.GetApplicantInfoSafely(token)
    local ok, applicantInfo = pcall(C_LFGList.GetApplicantInfo, token)
    if ok and type(applicantInfo) == "table" then
        return applicantInfo
    end
    return nil
end

local function Clamp(value, minimum, maximum)
    return math.max(minimum, math.min(maximum, value))
end

function Core.GetTieredScore(value, tiers)
    for index = 1, #tiers do
        local tier = tiers[index]
        if value <= tier.maximum then
            return tier.score
        end
    end
    return 0
end

local function GetCurrentRewardSeasonID()
    if not C_MythicPlus or type(C_MythicPlus.GetCurrentSeasonValues) ~= "function" then
        return 0
    end

    local ok, displaySeasonID, _, rewardSeasonID = pcall(C_MythicPlus.GetCurrentSeasonValues)
    if not ok then
        return 0
    end
    if IsReadableNumber(rewardSeasonID) then
        return rewardSeasonID
    end
    return IsReadableNumber(displaySeasonID) and displaySeasonID or 0
end

local function GetMythicPlusRewardItemLevelBounds()
    if not C_MythicPlus or type(C_MythicPlus.GetRewardLevelForDifficultyLevel) ~= "function" then
        return nil, nil
    end

    local minimumEndOfRunItemLevel
    local maximumWeeklyItemLevel
    for keyLevel = HOST_MYTHIC_PLUS_LEVEL_MIN, HOST_MYTHIC_PLUS_LEVEL_MAX do
        local ok, weeklyItemLevel, endOfRunItemLevel = pcall(
            C_MythicPlus.GetRewardLevelForDifficultyLevel,
            keyLevel
        )
        if ok then
            if IsReadableNumber(endOfRunItemLevel) and endOfRunItemLevel > 0 then
                minimumEndOfRunItemLevel = minimumEndOfRunItemLevel
                    and math.min(minimumEndOfRunItemLevel, endOfRunItemLevel)
                    or endOfRunItemLevel
            end
            if IsReadableNumber(weeklyItemLevel) and weeklyItemLevel > 0 then
                maximumWeeklyItemLevel = maximumWeeklyItemLevel
                    and math.max(maximumWeeklyItemLevel, weeklyItemLevel)
                    or weeklyItemLevel
            end
        end
    end
    return minimumEndOfRunItemLevel, maximumWeeklyItemLevel
end

local function GetWeeklyRewardUpgradeRanks(targetItemLevel)
    if not C_WeeklyRewards
        or type(C_WeeklyRewards.GetActivities) ~= "function"
        or type(C_WeeklyRewards.GetExampleRewardItemHyperlinks) ~= "function"
        or not C_Item
        or type(C_Item.GetDetailedItemLevelInfo) ~= "function"
        or type(C_Item.GetItemUpgradeInfo) ~= "function"
    then
        return 1, HOST_MAX_UPGRADE_RANK
    end

    local thresholdTypes = Enum and Enum.WeeklyRewardChestThresholdType
    local activityType = thresholdTypes and (thresholdTypes.Activities or thresholdTypes.MythicPlus)
    if activityType == nil or IsSecretValue(activityType) then
        return 1, HOST_MAX_UPGRADE_RANK
    end

    local activitiesOK, activities = pcall(C_WeeklyRewards.GetActivities, activityType)
    if not activitiesOK or type(activities) ~= "table" then
        return 1, HOST_MAX_UPGRADE_RANK
    end

    for index = 1, #activities do
        local activity = activities[index]
        local activityID = type(activity) == "table" and activity.id or nil
        if IsReadableNumber(activityID) then
            local linksOK, hyperlink = pcall(C_WeeklyRewards.GetExampleRewardItemHyperlinks, activityID)
            if linksOK and type(hyperlink) == "string" and hyperlink ~= "" then
                local levelOK, itemLevel = pcall(C_Item.GetDetailedItemLevelInfo, hyperlink)
                local upgradeOK, upgradeInfo = pcall(C_Item.GetItemUpgradeInfo, hyperlink)
                if levelOK
                    and IsReadableNumber(itemLevel)
                    and (not IsReadableNumber(targetItemLevel) or itemLevel == targetItemLevel)
                    and upgradeOK
                    and type(upgradeInfo) == "table"
                    and IsReadableNumber(upgradeInfo.currentLevel)
                    and IsReadableNumber(upgradeInfo.maxLevel)
                    and upgradeInfo.currentLevel > 0
                    and upgradeInfo.maxLevel >= upgradeInfo.currentLevel
                then
                    return upgradeInfo.currentLevel, math.min(upgradeInfo.maxLevel, HOST_MAX_UPGRADE_RANK)
                end
            end
        end
    end

    return 1, HOST_MAX_UPGRADE_RANK
end

local function GetHostedSeasonItemLevelScale()
    local rewardSeasonID = GetCurrentRewardSeasonID()
    local cached = hostedSeasonItemLevelScaleCache[rewardSeasonID]
    if cached then
        return cached.floor, cached.ceiling, rewardSeasonID
    end

    local itemLevelFloor, maximumWeeklyItemLevel = GetMythicPlusRewardItemLevelBounds()
    if not IsReadableNumber(itemLevelFloor)
        or not IsReadableNumber(maximumWeeklyItemLevel)
        or maximumWeeklyItemLevel <= 0
    then
        return nil, nil, rewardSeasonID
    end

    local currentRank, maximumRank = GetWeeklyRewardUpgradeRanks(maximumWeeklyItemLevel)
    local remainingRanks = math.max(0, maximumRank - currentRank)
    local upgradeItemLevels = math.floor(
        remainingRanks * HOST_ITEM_LEVELS_PER_FOUR_UPGRADES / 4 + 0.5
    )
    local itemLevelCeiling = maximumWeeklyItemLevel + upgradeItemLevels
    if itemLevelCeiling <= itemLevelFloor then
        return nil, nil, rewardSeasonID
    end

    cached = {
        floor = itemLevelFloor,
        ceiling = itemLevelCeiling,
    }
    hostedSeasonItemLevelScaleCache[rewardSeasonID] = cached
    return cached.floor, cached.ceiling, rewardSeasonID
end

local function GetHostedItemLevelScore(itemLevel, context)
    local itemLevelFloor = context.itemLevelFloor
    local itemLevelCeiling = context.itemLevelCeiling
    if IsReadableNumber(itemLevelFloor)
        and IsReadableNumber(itemLevelCeiling)
        and itemLevelCeiling > itemLevelFloor
    then
        return Clamp(
            HOST_ITEM_LEVEL_MAX_SCORE
                * (itemLevel - itemLevelFloor)
                / (itemLevelCeiling - itemLevelFloor),
            0,
            HOST_ITEM_LEVEL_MAX_SCORE
        )
    end

    local fallbackFloor = IsReadableNumber(context.requiredItemLevel) and context.requiredItemLevel or 0
    return Clamp(itemLevel - fallbackFloor, 0, HOST_ITEM_LEVEL_MAX_SCORE)
end

local function GetHostedPartyComposition()
    local composition = CreateEmptyComposition()
    local units, inHomeParty = Core.GetHomePartyUnits()

    if not Core.AddUnitsToComposition(composition, units, false) then
        MaybeInitiatePartyRolePoll("roles_unknown")
        return nil
    end

    if inHomeParty then
        local countOK, memberCount = pcall(GetNumGroupMembers, LE_PARTY_CATEGORY_HOME)
        if not countOK or not IsReadableNumber(memberCount) or memberCount ~= composition.total then
            return nil
        end
    end

    if composition.total > 5 then
        return nil
    end
    if Core.CompositionExceedsRoleLimits(composition) then
        return nil
    end

    return composition
end

local function GetActiveHostedMythicPlusEntryContext()
    local ok, context = pcall(function()
        if not C_LFGList.HasActiveEntryInfo() then
            return nil
        end

        local activeEntryInfo = C_LFGList.GetActiveEntryInfo()
        local activityID = activeEntryInfo and activeEntryInfo.activityIDs and activeEntryInfo.activityIDs[1]
        if not IsReadableNumber(activityID) then
            return nil
        end

        local activityInfo = C_LFGList.GetActivityInfoTable(activityID, activeEntryInfo.questID)
        if not activityInfo
            or activityInfo.categoryID ~= DUNGEON_CATEGORY_ID
            or activityInfo.isMythicPlusActivity ~= true
        then
            return nil
        end

        local requiredItemLevel = activeEntryInfo.requiredItemLevel
        if not IsReadableNumber(requiredItemLevel) then
            requiredItemLevel = 0
        end

        return {
            activityID = activityID,
            requiredItemLevel = requiredItemLevel,
        }
    end)
    if not ok or not context then
        return nil
    end

    return context
end

local function IsHostedMythicPlusDungeonListing()
    return GetActiveHostedMythicPlusEntryContext() ~= nil
end

local function GetHostedListingContext()
    local context = GetActiveHostedMythicPlusEntryContext()
    if not context then
        return nil
    end

    context.itemLevelFloor, context.itemLevelCeiling, context.rewardSeasonID =
        GetHostedSeasonItemLevelScale()

    local keyLevelOK, keyLevel = pcall(function()
        if C_MythicPlus and type(C_MythicPlus.GetOwnedKeystoneLevel) == "function" then
            return C_MythicPlus.GetOwnedKeystoneLevel()
        end
    end)
    if keyLevelOK and IsReadableNumber(keyLevel) and keyLevel > 0 then
        context.keyLevel = keyLevel
    end

    return context
end

local function GetSpecificDungeonScores(bestDungeon, targetKeyLevel)
    if type(bestDungeon) ~= "table" then
        return 0, 0, 0
    end

    local bestRunLevel = bestDungeon.bestRunLevel
    local mapScore = bestDungeon.mapScore
    local finishedSuccess = bestDungeon.finishedSuccess
    if IsSecretValue(finishedSuccess)
        or not IsReadableNumber(bestRunLevel)
        or not IsReadableNumber(mapScore)
        or mapScore <= 0
        or finishedSuccess ~= true
    then
        return 0, 0, 0
    end

    if not IsReadableNumber(targetKeyLevel) then
        return 0, 0, bestRunLevel
    end

    local difference = bestRunLevel - targetKeyLevel
    return Core.GetTieredScore(difference, Core.HOST_DUNGEON_EXPERIENCE_TIERS),
        Core.GetTieredScore(difference, Core.HOST_DUNGEON_MOTIVATION_TIERS),
        bestRunLevel
end

local function ReadHostedApplicantMember(applicantID, memberIndex, context)
    local memberOK, memberData = pcall(function()
        local _, classFile, _, _, itemLevel, _, _, _, _, assignedRole, _, dungeonScore,
            _, _, _, _, isLeaver = C_LFGList.GetApplicantMemberInfo(applicantID, memberIndex)
        return {
            classFile = classFile,
            itemLevel = itemLevel,
            assignedRole = assignedRole,
            dungeonScore = dungeonScore,
            isLeaver = isLeaver,
            bestDungeon = C_LFGList.GetApplicantDungeonScoreForListing(
                applicantID,
                memberIndex,
                context.activityID
            ),
        }
    end)
    if not memberOK or type(memberData) ~= "table" then
        return nil
    end

    local classFile = memberData.classFile
    local assignedRole = memberData.assignedRole
    local itemLevel = memberData.itemLevel
    local dungeonScore = memberData.dungeonScore
    local dungeonScoreSecret = IsSecretValue(dungeonScore)
    local normalizedClassFile = not IsSecretValue(classFile) and Core.NormalizeClassFile(classFile) or nil
    if not dungeonScoreSecret and dungeonScore == nil then
        dungeonScore = 0
    end
    if not normalizedClassFile
        or IsSecretValue(assignedRole)
        or not ROLE_SLOT_LIMITS[assignedRole]
        or not IsReadableNumber(itemLevel)
        or dungeonScoreSecret
        or not IsReadableNumber(dungeonScore)
        or IsSecretValue(memberData.isLeaver)
    then
        return nil
    end

    classFile = normalizedClassFile
    local itemLevelScore = GetHostedItemLevelScore(itemLevel, context)
    local ratingScore = math.max(0, dungeonScore / HOST_RATING_PER_POINT)
    local experienceScore, motivationScore, bestRunLevel = GetSpecificDungeonScores(
        memberData.bestDungeon,
        context.keyLevel
    )
    local strength = itemLevelScore + ratingScore + experienceScore
    local leaver = memberData.isLeaver == true
    local personalScore = strength + motivationScore
    if leaver then
        if motivationScore < 0 then
            personalScore = personalScore
                + motivationScore * (HOST_LEAVER_PENALTY_MULTIPLIER - 1)
        end
        personalScore = personalScore + HOST_LEAVER_BASE_PENALTY
    end

    return {
        role = assignedRole,
        classFile = classFile,
        strength = strength,
        personalScore = personalScore,
        itemLevel = itemLevel,
        dungeonScore = dungeonScore,
        bestRunLevel = bestRunLevel,
        motivationScore = motivationScore,
        isLeaver = leaver,
    }
end

local function AddHostedMemberToRecord(record, member)
    record.members[#record.members + 1] = member
    record.qualityScore = record.qualityScore + member.personalScore
    record.itemLevelSum = record.itemLevelSum + member.itemLevel
    record.ratingSum = record.ratingSum + member.dungeonScore
    record.bestRunLevelSum = record.bestRunLevelSum + member.bestRunLevel
    record.hasLeaver = record.hasLeaver or member.isLeaver
end

local function FinalizeHostedApplicantRecord(record)
    record.qualityScore = record.qualityScore / record.numMembers
    record.averageItemLevel = record.itemLevelSum / record.numMembers
    record.averageRating = record.ratingSum / record.numMembers
    record.averageBestRunLevel = record.bestRunLevelSum / record.numMembers
    record.valid = true
end

local function ReadHostedApplicantRecord(token, sourceIndex, context, knownApplicantInfo)
    local record = {
        token = token,
        sourceIndex = sourceIndex,
        valid = false,
        isWaiting = false,
        isCommitted = false,
    }

    local applicantInfo = knownApplicantInfo
    if type(applicantInfo) ~= "table" then
        applicantInfo = Core.GetApplicantInfoSafely(token)
        if not applicantInfo then
            return record
        end
    end

    local status = applicantInfo.applicationStatus
    local pendingStatus = applicantInfo.pendingApplicationStatus
    if IsSecretValue(status) or IsSecretValue(pendingStatus) then
        return record
    end
    record.status = status
    record.pendingStatus = pendingStatus
    record.applicantID = GetStableApplicantID(applicantInfo)
    record.infoSignature = GetApplicantInfoSignature(applicantInfo)
    record.isCommitted = status == "invited" or pendingStatus == "invited"
    record.isWaiting = status == "applied" and (pendingStatus == nil or pendingStatus == "applied")

    if not record.isWaiting and not record.isCommitted then
        record.valid = true
        return record
    end

    local numMembers = applicantInfo.numMembers
    if not IsReadableNumber(numMembers)
        or numMembers < 1
        or numMembers > 5
        or record.applicantID == nil
    then
        return record
    end

    record.numMembers = numMembers
    record.members = {}
    record.qualityScore = 0
    record.itemLevelSum = 0
    record.ratingSum = 0
    record.bestRunLevelSum = 0
    record.hasLeaver = false

    for memberIndex = 1, numMembers do
        local member = ReadHostedApplicantMember(record.applicantID, memberIndex, context)
        if not member then
            return record
        end
        AddHostedMemberToRecord(record, member)
    end

    FinalizeHostedApplicantRecord(record)
    return record
end

local function CompositionCanAcceptApplicant(composition, record)
    if not record.valid or not record.members or composition.total + record.numMembers > 5 then
        return false
    end

    local roleAdds = { TANK = 0, HEALER = 0, DAMAGER = 0 }
    for index = 1, #record.members do
        local role = record.members[index].role
        if not ROLE_SLOT_LIMITS[role] then
            return false
        end
        roleAdds[role] = roleAdds[role] + 1
    end

    for role, slotLimit in pairs(ROLE_SLOT_LIMITS) do
        if composition[role] + roleAdds[role] > slotLimit.maximum then
            return false
        end
    end
    return true
end

local function AddApplicantToComposition(composition, record)
    for index = 1, #record.members do
        local member = record.members[index]
        AddMemberToComposition(composition, member.role, member.classFile)
    end
end

local function GetLeaverDuplicatePenaltyAdjustment(composition, record)
    local applicationCounts = {}
    local leaverCounts = {}
    for index = 1, #record.members do
        local member = record.members[index]
        local classRoleKey = member.role .. ":" .. member.classFile
        applicationCounts[classRoleKey] = (applicationCounts[classRoleKey] or 0) + 1
        if member.isLeaver then
            leaverCounts[classRoleKey] = (leaverCounts[classRoleKey] or 0) + 1
        end
    end

    local extraDuplicatePenalties = 0
    for classRoleKey, applicationCount in pairs(applicationCounts) do
        local baseCount = composition.classRoleCounts[classRoleKey] or 0
        local duplicatesBefore = math.max(0, baseCount - 1)
        local duplicatesAfter = math.max(0, baseCount + applicationCount - 1)
        local newDuplicates = duplicatesAfter - duplicatesBefore
        extraDuplicatePenalties = extraDuplicatePenalties
            + math.min(newDuplicates, leaverCounts[classRoleKey] or 0)
    end

    return extraDuplicatePenalties
        * SCORE_DUPLICATE_CLASS_ROLE
        * (HOST_LEAVER_PENALTY_MULTIPLIER - 1)
end

local function SetApplicantBundleBonus(record)
    record.bundleBonus = 0
    if record.numMembers <= 1
        or record.hasLeaver
    then
        return
    end

    local totalDifference = 0
    local pairCount = 0
    for leftIndex = 1, #record.members - 1 do
        local leftStrength = record.members[leftIndex].strength
        for rightIndex = leftIndex + 1, #record.members do
            local rightStrength = record.members[rightIndex].strength
            totalDifference = totalDifference + math.abs(leftStrength - rightStrength)
            pairCount = pairCount + 1
        end
    end

    if pairCount > 0 then
        local averagePairDifference = totalDifference / pairCount
        record.bundleBonus = math.min(
            HOST_BUNDLE_MAX_SCORE,
            averagePairDifference / HOST_BUNDLE_SCORE_DIVISOR
        )
    end
end

local function GetHostedListingSignature(context)
    return table.concat({
        tostring(context.activityID or 0),
        tostring(context.requiredItemLevel or 0),
        tostring(context.itemLevelFloor or 0),
        tostring(context.itemLevelCeiling or 0),
        tostring(context.rewardSeasonID or 0),
        tostring(context.keyLevel or 0),
    }, "|")
end

local function GetHostProfileMilliseconds()
    if type(debugprofilestop) == "function" then
        return debugprofilestop()
    end
    return GetTime() * 1000
end

local function YieldHostedSortWork(workState)
    if GetHostProfileMilliseconds() - workState.sliceStartedAt < HOST_WORK_BUDGET_MS then
        return
    end

    coroutine.yield()
    workState.sliceStartedAt = GetHostProfileMilliseconds()
end

local function SortHostedRecordList(records)
    table.sort(records, function(left, right)
        local leftRanked = left.isWaiting and left.priorityScore ~= nil
        local rightRanked = right.isWaiting and right.priorityScore ~= nil
        if leftRanked ~= rightRanked then
            return leftRanked
        end
        if leftRanked then
            for index = 1, #Core.HOST_RECORD_SORT_FIELDS do
                local field = Core.HOST_RECORD_SORT_FIELDS[index]
                if left[field] ~= right[field] then
                    return left[field] > right[field]
                end
            end
        end
        return left.sourceIndex < right.sourceIndex
    end)
end

local function GetHostedApplicantSnapshot(workState)
    local applicantsOK, applicants = pcall(C_LFGList.GetApplicants)
    if not applicantsOK or type(applicants) ~= "table" then
        return nil
    end

    local entries = {}
    for index = 1, #applicants do
        local token = applicants[index]
        local applicantInfo = Core.GetApplicantInfoSafely(token)
        if not applicantInfo then
            return nil
        end

        local displayOrderID = applicantInfo.displayOrderID
        if not IsReadableNumber(displayOrderID) then
            displayOrderID = index
        end
        local isNew = applicantInfo.isNew
        if IsSecretValue(isNew) or type(isNew) ~= "boolean" then
            isNew = false
        end
        entries[#entries + 1] = {
            token = token,
            applicantInfo = applicantInfo,
            applicantID = GetStableApplicantID(applicantInfo),
            infoSignature = GetApplicantInfoSignature(applicantInfo),
            stockIndex = index,
            displayOrderID = displayOrderID,
            isNew = isNew,
        }
        YieldHostedSortWork(workState)
    end

    table.sort(entries, function(left, right)
        if left.isNew ~= right.isNew then
            return not left.isNew
        end
        if left.displayOrderID ~= right.displayOrderID then
            return left.displayOrderID < right.displayOrderID
        end
        return left.stockIndex < right.stockIndex
    end)
    for index = 1, #entries do
        entries[index].sourceIndex = index
    end
    return entries
end

local function BuildHostedApplicantOrder(workState)
    local context = GetHostedListingContext()
    local currentComposition = GetHostedPartyComposition()
    if not context or not currentComposition then
        return false
    end

    local entries = GetHostedApplicantSnapshot(workState)
    if not entries then
        return false
    end

    local listingSignature = GetHostedListingSignature(context)
    local groupSignature = Core.GetCompositionSignature(currentComposition, false)
    local listingChanged = state.applicantListingSignature ~= listingSignature
    local groupChanged = state.applicantGroupSignature ~= groupSignature
    local forceFull = state.applicantForceFull or listingChanged or groupChanged
    local previousCache = state.applicantRecordCache or {}
    local nextCache = {}
    local records = {}
    local waitingRecords = {}
    local committedRecords = {}
    local changedIDSet = {}

    for index = 1, #entries do
        local entry = entries[index]
        local applicantID = entry.applicantID
        if applicantID == nil or entry.infoSignature == nil then
            return false
        end

        local cachedRecord = previousCache[applicantID]
        local canReuse = cachedRecord
            and not listingChanged
            and cachedRecord.infoSignature == entry.infoSignature
        local record
        if canReuse then
            record = cachedRecord
            record.token = entry.token
            record.sourceIndex = entry.sourceIndex
        else
            record = ReadHostedApplicantRecord(entry.token, entry.sourceIndex, context, entry.applicantInfo)
            if not record.valid then
                return false
            end
            if cachedRecord then
                changedIDSet[applicantID] = true
            end
        end

        nextCache[applicantID] = record
        records[#records + 1] = record
        if record.isCommitted then
            committedRecords[#committedRecords + 1] = record
        elseif record.isWaiting then
            waitingRecords[#waitingRecords + 1] = record
        end
        YieldHostedSortWork(workState)
    end

    local baseComposition = CopyComposition(currentComposition)
    for index = 1, #committedRecords do
        local record = committedRecords[index]
        if not CompositionCanAcceptApplicant(baseComposition, record) then
            return false
        end
        AddApplicantToComposition(baseComposition, record)
    end
    local baseSignature = Core.GetCompositionSignature(baseComposition, false)
    if state.applicantBaseSignature ~= baseSignature then
        forceFull = true
    end

    local baseCompositionScore = GetCompositionScore(baseComposition)
    for index = 1, #waitingRecords do
        local record = waitingRecords[index]
        if record.bundleBonus == nil then
            SetApplicantBundleBonus(record)
        end
        local mustRefreshDirect = forceFull
            or record.directScore == nil
            or changedIDSet[record.applicantID]
        if mustRefreshDirect then
            record.directScore = nil
            record.directContribution = nil
            record.priorityScore = nil
            if CompositionCanAcceptApplicant(baseComposition, record) then
                local directComposition = CopyComposition(baseComposition)
                AddApplicantToComposition(directComposition, record)
                record.directScore = GetCompositionScore(directComposition)
                    + record.qualityScore
                    + record.bundleBonus
                    + GetLeaverDuplicatePenaltyAdjustment(baseComposition, record)
                record.directContribution = record.directScore - baseCompositionScore
                record.priorityScore = record.directScore
            end
        end
        YieldHostedSortWork(workState)
    end

    SortHostedRecordList(records)
    local cachedOrder = {}
    for index = 1, #records do
        if records[index].applicantID ~= nil then
            cachedOrder[#cachedOrder + 1] = records[index].applicantID
        end
    end

    state.applicantRecordCache = nextCache
    state.applicantListingSignature = listingSignature
    state.applicantGroupSignature = groupSignature
    state.applicantBaseSignature = baseSignature
    state.cachedApplicantOrder = #cachedOrder > 0 and cachedOrder or nil
    state.applicantForceFull = false
    return true
end

local function RestoreCachedApplicantOrder(applicants)
    local cachedOrder = state.cachedApplicantOrder
    if type(applicants) ~= "table" or type(cachedOrder) ~= "table" or #cachedOrder == 0 then
        return false
    end

    local currentRecords = {}
    local recordsByID = {}
    for index = 1, #applicants do
        local token = applicants[index]
        local applicantInfo = Core.GetApplicantInfoSafely(token)
        local applicantID = GetStableApplicantID(applicantInfo)
        local record = {
            token = token,
            applicantID = applicantID,
            sourceIndex = index,
            inserted = false,
        }
        currentRecords[#currentRecords + 1] = record
        if applicantID ~= nil and recordsByID[applicantID] == nil then
            recordsByID[applicantID] = record
        end
    end

    local restored = {}
    for index = 1, #cachedOrder do
        local record = recordsByID[cachedOrder[index]]
        if record and not record.inserted then
            restored[#restored + 1] = record.token
            record.inserted = true
        end
    end

    -- Applicants not present during the last full score pass retain Blizzard's
    -- relative order and wait at the bottom until the next five-second cycle.
    for index = 1, #currentRecords do
        local record = currentRecords[index]
        if not record.inserted then
            restored[#restored + 1] = record.token
        end
    end

    if #restored ~= #applicants then
        return false
    end
    for index = 1, #restored do
        applicants[index] = restored[index]
    end
    return true
end

local function SortHostedApplicants(applicants)
    if not IsHostedMythicPlusDungeonListing() then
        return
    end

    -- Blizzard still performs its inexpensive stock sort. Reapply the last
    -- scored order without recalculating composition or candidate quality.
    pcall(RestoreCachedApplicantOrder, applicants)
end

RoleHasRemainingSlot = function(resultID, role)
    local remainingKey = ROLE_REMAINING_KEYS[role]
    local slotLimit = ROLE_SLOT_LIMITS[role]
    if not remainingKey or not slotLimit then
        return nil, L.APPLICATION_ROLE_UNKNOWN
    end

    -- Blizzard's own result sorting uses the *_REMAINING fields.  Read those
    -- first, but also keep the actual member total as an independent signal.
    -- Only return plain booleans from the protected call: some clients expose
    -- the underlying values as protected data that cannot safely be compared
    -- outside pcall.
    local countsOK, countStatus = pcall(function()
        local counts = C_LFGList.GetSearchResultMemberCounts(resultID)
        if not counts then
            return nil
        end

        local currentMembers = counts[slotLimit.memberKey]
        local remaining = counts[remainingKey]

        return {
            noRemaining = remaining ~= nil and remaining <= 0,
            full = currentMembers ~= nil and currentMembers >= slotLimit.maximum,
            hasRemaining = remaining ~= nil and remaining > 0,
            underLimit = currentMembers ~= nil and currentMembers < slotLimit.maximum,
        }
    end)

    if not countsOK then
        state.unreadableRoleData = true
        countStatus = nil
    end

    local memberDataOK, memberStatus = pcall(function()
        local searchResultInfo = C_LFGList.GetSearchResultInfo(resultID)
        local numMembers = searchResultInfo and searchResultInfo.numMembers
        if type(numMembers) == "number" then
            local matchingMembers = 0
            local allMembersReadable = true

            -- Use the same per-player role data that Blizzard uses for the
            -- "Members" list in the standard search-result tooltip.  The
            -- aggregate counts can temporarily reserve the applicant's role
            -- and therefore hide a healer/tank who joined after the request.
            for memberIndex = 1, numMembers do
                local memberInfo = C_LFGList.GetSearchResultPlayerInfo(resultID, memberIndex)
                local assignedRole = memberInfo and memberInfo.assignedRole
                if assignedRole then
                    if assignedRole == role then
                        matchingMembers = matchingMembers + 1
                    end
                else
                    allMembersReadable = false
                end
            end

            return {
                full = matchingMembers >= slotLimit.maximum,
                underLimit = allMembersReadable and matchingMembers < slotLimit.maximum,
            }
        end

        return nil
    end)

    if not memberDataOK then
        state.unreadableRoleData = true
        memberStatus = nil
    end

    -- Any confirmed "filled" signal wins.  In particular, an incomplete or
    -- stale per-player list must never short-circuit the official counters and
    -- incorrectly declare the healer/tank slot free.
    if countStatus and countStatus.noRemaining then
        return false, L.NO_ROLE_SLOTS_COUNTER
    end
    if countStatus and countStatus.full then
        return false, L.ROLE_FILLED_COMPOSITION
    end
    if memberStatus and memberStatus.full then
        return false, L.ROLE_FILLED_MEMBERS
    end

    if countStatus and countStatus.hasRemaining then
        return true, L.ROLE_SLOT_COUNTER
    end
    if countStatus and countStatus.underLimit then
        return true, L.ROLE_SLOT_COMPOSITION
    end
    if memberStatus and memberStatus.underLimit then
        return true, L.ROLE_SLOT_MEMBERS
    end

    return nil, L.ROLE_DATA_UNAVAILABLE
end

local function IsApplicationActive(status, pendingStatus)
    if pendingStatus == "cancelled" then
        return false
    end

    return ACTIVE_APPLICATION_STATUSES[status] or pendingStatus == "applied"
end

function Core.GetApplicationState(resultID)
    local ok, _, status, pendingStatus, applicationDuration = pcall(
        C_LFGList.GetApplicationInfo,
        resultID
    )
    if not ok then
        return false
    end

    return true, status, pendingStatus, applicationDuration
end

function Core.IsResultApplicationActive(resultID)
    local ok, status, pendingStatus = Core.GetApplicationState(resultID)
    return ok and IsApplicationActive(status, pendingStatus) == true
end

local function GetRedResultSet()
    local redResults = {}
    local panel = LFGListFrame and LFGListFrame.SearchPanel
    local scrollBox = panel and panel.ScrollBox
    if not scrollBox then
        return redResults
    end

    local framesOK, frames = pcall(function()
        if scrollBox.GetFrames then
            return scrollBox:GetFrames()
        end

        return scrollBox.buttons
    end)

    if not framesOK or not frames then
        return redResults
    end

    -- Blizzard sets isNowFilteredOut before applying the red background atlas.
    -- Read the state flag instead of sampling the texture colour: selection or
    -- mouse-over can visually turn a red row purple without changing this flag.
    for _, frame in pairs(frames) do
        if frame.resultID and frame.isNowFilteredOut then
            redResults[frame.resultID] = true
        end
    end

    return redResults
end

local function GetApplicationsSnapshot()
    local snapshot = {
        activeCount = 0,
        cancellable = {},
        invalid = {},
        invalidReasons = {},
        redRowCount = 0,
        expiringCount = 0,
    }

    local redResults = GetRedResultSet()
    local applications = C_LFGList.GetApplications() or {}
    for index = 1, #applications do
        local resultID = applications[index]
        local infoOK, status, pendingStatus, applicationDuration = Core.GetApplicationState(resultID)

        if infoOK and IsApplicationActive(status, pendingStatus) then
            snapshot.activeCount = snapshot.activeCount + 1

            -- applicationDuration is the number of seconds remaining before
            -- Blizzard automatically times out an established application.
            -- Do not inspect a request that is still only pending submission.
            local isWaiting = status == "applied" and pendingStatus ~= "cancelled"
            if isWaiting then
                snapshot.cancellable[#snapshot.cancellable + 1] = {
                    resultID = resultID,
                    applicationDuration = applicationDuration,
                    sourceIndex = index,
                }
                local redRow = redResults[resultID] == true

                local expiring = type(applicationDuration) == "number"
                    and applicationDuration < EXPIRING_APPLICATION_THRESHOLD

                if redRow or expiring then
                    snapshot.invalid[#snapshot.invalid + 1] = resultID

                    if redRow and expiring then
                        snapshot.invalidReasons[resultID] = string.format(
                            L.FILTERED_AND_EXPIRING,
                            math.floor(applicationDuration / 60),
                            math.floor(applicationDuration % 60)
                        )
                    elseif redRow then
                        snapshot.invalidReasons[resultID] = L.FILTERED_OUT
                    else
                        snapshot.invalidReasons[resultID] = string.format(
                            L.EXPIRING,
                            math.floor(applicationDuration / 60),
                            math.floor(applicationDuration % 60)
                        )
                    end
                end
                if redRow then
                    snapshot.redRowCount = snapshot.redRowCount + 1
                end
                if expiring then
                    snapshot.expiringCount = snapshot.expiringCount + 1
                end
            end
        end
    end

    table.sort(snapshot.cancellable, function(left, right)
        local leftDuration = type(left.applicationDuration) == "number" and left.applicationDuration or -1
        local rightDuration = type(right.applicationDuration) == "number" and right.applicationDuration or -1
        if leftDuration ~= rightDuration then
            return leftDuration > rightDuration
        end

        return left.sourceIndex > right.sourceIndex
    end)

    return snapshot
end

local function CopyFilteredResultIDs()
    local ok, copied = pcall(function()
        local _, results = C_LFGList.GetFilteredSearchResults()
        local output = {}

        for index = 1, #(results or {}) do
            output[#output + 1] = results[index]
        end

        return output
    end)

    if ok and #copied > 0 then
        return copied
    end

    local panel = LFGListFrame and LFGListFrame.SearchPanel
    local panelResults = panel and panel.results
    local panelOK, panelCopy = pcall(function()
        local output = {}
        for index = 1, #(panelResults or {}) do
            output[#output + 1] = panelResults[index]
        end
        return output
    end)

    if panelOK then
        return panelCopy
    end

    return nil
end

local function ShowLocalMessage(message, red)
    if not IsDungeonSearchVisible() then
        return
    end

    local color = red and RED_FONT_COLOR or GREEN_FONT_COLOR
    UIErrorsFrame:AddMessage(message, color.r, color.g, color.b, 1.0)
end

function Core.PrepareGameTooltip(owner, point, relativePoint, offsetX, offsetY)
    GameTooltip:SetOwner(owner, "ANCHOR_NONE")
    GameTooltip:ClearLines()
    GameTooltip:ClearAllPoints()
    GameTooltip:SetPoint(point, owner, relativePoint, offsetX or 0, offsetY or 0)
end

local function GetRaiderIOCurrentScore(playerName)
    local ok, score = pcall(function()
        if type(playerName) ~= "string" or not RaiderIO or type(RaiderIO.GetProfile) ~= "function" then
            return nil
        end

        local profile = RaiderIO.GetProfile(playerName)
        local keystoneProfile = profile and profile.mythicKeystoneProfile
        if not keystoneProfile or keystoneProfile.blocked or keystoneProfile.hasRenderableData == false then
            return nil
        end

        return tonumber(keystoneProfile.currentScore)
            or (keystoneProfile.mplusCurrent and tonumber(keystoneProfile.mplusCurrent.score))
    end)

    return ok and score or nil
end

local function GetDungeonScoreColor(score)
    local rioOK, r, g, b = pcall(function()
        if RaiderIO and type(RaiderIO.GetScoreColor) == "function" then
            return RaiderIO.GetScoreColor(score)
        end
    end)
    if rioOK and r and g and b then
        return { r, g, b }
    end

    local blizzardOK, color = pcall(C_ChallengeMode.GetDungeonScoreRarityColor, score)
    if blizzardOK and color then
        return { color.r, color.g, color.b }
    end

    return { 1.0, 0.82, 0.0 }
end

local function GetResultNotificationDetails(resultID)
    local details = {
        listingName = L.GROUP_NAME_UNAVAILABLE,
        activityName = L.DUNGEON_UNKNOWN,
        categoryID = nil,
        members = {},
    }

    local infoOK, resultInfo = pcall(function()
        local searchResultInfo = C_LFGList.GetSearchResultInfo(resultID)
        if not searchResultInfo then
            return nil
        end

        local listingName = searchResultInfo.name
        local activityID = searchResultInfo.activityIDs and searchResultInfo.activityIDs[1]
        local activityName = activityID and C_LFGList.GetActivityFullName(
            activityID,
            nil,
            searchResultInfo.isWarMode
        )
        local activityInfo = activityID and C_LFGList.GetActivityInfoTable(activityID)

        return {
            listingName = listingName and listingName ~= "" and tostring(listingName) or nil,
            activityName = activityName and activityName ~= "" and tostring(activityName) or nil,
            categoryID = activityInfo and activityInfo.categoryID or nil,
            numMembers = type(searchResultInfo.numMembers) == "number" and searchResultInfo.numMembers or nil,
            leaderName = searchResultInfo.leaderName and tostring(searchResultInfo.leaderName) or nil,
            leaderScore = tonumber(searchResultInfo.leaderOverallDungeonScore),
        }
    end)

    if infoOK and resultInfo then
        details.listingName = resultInfo.listingName or details.listingName
        details.activityName = resultInfo.activityName or details.activityName
        details.categoryID = resultInfo.categoryID
    end

    local numMembers = infoOK and resultInfo and resultInfo.numMembers or 0
    for memberIndex = 1, math.min(numMembers or 0, 5) do
        local memberOK, member = pcall(function()
            local memberInfo = C_LFGList.GetSearchResultPlayerInfo(resultID, memberIndex)
            if not memberInfo then
                return nil
            end

            local role = memberInfo.assignedRole
            local classFile = memberInfo.classFilename
            local specName = memberInfo.specName
            local score = GetRaiderIOCurrentScore(memberInfo.name)
            local isLeader = memberInfo.isLeader == true
            if not score and isLeader and resultInfo then
                score = resultInfo.leaderScore
            end
            local classColor = classFile and RAID_CLASS_COLORS and RAID_CLASS_COLORS[classFile]

            return {
                role = role and tostring(role) or "NONE",
                memberIndex = memberIndex,
                classFile = classFile,
                classIcon = GetClassIconMarkup(classFile),
                specName = specName and specName ~= "" and tostring(specName) or L.SPECIALIZATION_UNKNOWN,
                classColor = classColor and { classColor.r, classColor.g, classColor.b } or nil,
                score = score and string.format("%.0f", score) or "—",
                scoreColor = score and GetDungeonScoreColor(score) or nil,
                isLeader = isLeader,
                hasBloodlust = BLOODLUST_CLASSES[classFile] == true,
                hasBattleRez = BATTLE_REZ_CLASSES[classFile] == true,
                hasPowerInfusion = POWER_INFUSION_CLASSES[classFile] == true,
            }
        end)

        if memberOK and member then
            details.members[#details.members + 1] = member
        end
    end

    return details
end

local function CreateNotificationFrame()
    local searchPanel = LFGListFrame and LFGListFrame.SearchPanel
    local parent = searchPanel or UIParent
    local frameIndex = #state.notificationFrames + 1
    local frame = CreateFrame(
        "Frame",
        "LFGAutoPilotNotificationFrame" .. frameIndex,
        parent,
        "BackdropTemplate"
    )
    frame:SetSize(470, 120)
    frame:SetFrameStrata("DIALOG")
    frame:SetClampedToScreen(false)
    frame:SetBackdrop({
        bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        tile = true,
        tileSize = 16,
        edgeSize = 16,
        insets = { left = 4, right = 4, top = 4, bottom = 4 },
    })
    frame:SetBackdropColor(0.035, 0.035, 0.055, 0.96)

    frame.accent = frame:CreateTexture(nil, "ARTWORK")
    frame.accent:SetPoint("TOPLEFT", frame, "TOPLEFT", 7, -7)
    frame.accent:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 7, 7)
    frame.accent:SetWidth(4)
    frame.accent:SetColorTexture(0.2, 1.0, 0.35, 1.0)

    frame.groupName = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    frame.groupName:SetPoint("TOPLEFT", frame, "TOPLEFT", 20, -14)
    frame.groupName:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -18, -14)
    frame.groupName:SetJustifyH("LEFT")
    frame.groupName:SetWordWrap(false)
    frame.groupName:SetTextColor(1.0, 0.82, 0.0)

    frame.activity = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    frame.activity:SetPoint("TOPLEFT", frame.groupName, "BOTTOMLEFT", 0, -3)
    frame.activity:SetPoint("TOPRIGHT", frame.groupName, "BOTTOMRIGHT", 0, -3)
    frame.activity:SetJustifyH("LEFT")
    frame.activity:SetWordWrap(false)
    frame.activity:SetTextColor(0.68, 0.84, 1.0)

    frame.memberRows = {}
    for index = 1, 5 do
        local row = {}
        row.role = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        row.role:SetPoint("TOPLEFT", frame, "TOPLEFT", 20, -48 - ((index - 1) * 18))
        row.role:SetWidth(18)
        row.role:SetJustifyH("CENTER")

        row.description = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        row.description:SetPoint("LEFT", row.role, "RIGHT", 3, 0)
        row.description:SetJustifyH("LEFT")
        row.description:SetWordWrap(false)

        row.score = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        row.score:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -18, -48 - ((index - 1) * 18))
        row.score:SetWidth(54)
        row.score:SetJustifyH("RIGHT")
        row.score:SetTextColor(1.0, 0.82, 0.0)
        row.description:SetPoint("RIGHT", row.score, "LEFT", -8, 0)

        frame.memberRows[index] = row
    end

    frame.action = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    frame.action:SetJustifyH("LEFT")

    frame.exclusion = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    frame.exclusion:SetJustifyH("LEFT")
    frame.exclusion:SetTextColor(1.0, 0.55, 0.2)

    frame:Hide()
    state.notificationFrames[#state.notificationFrames + 1] = frame
    return frame
end

local function AcquireNotificationFrame()
    for index = 1, #state.notificationFrames do
        local frame = state.notificationFrames[index]
        if not frame:IsShown() then
            return frame
        end
    end

    return CreateNotificationFrame()
end

local function GetRaiderIOProfileTooltip()
    local tooltip = _G.RaiderIO_ProfileTooltip
    if tooltip and tooltip.IsShown and tooltip.GetWidth then
        return tooltip
    end

    return nil
end

local function IsFrameAttachedToGroupFinder(frame)
    while frame do
        if frame == LFGListFrame or frame == PVEFrame then
            return true
        end

        if not frame.GetParent then
            break
        end
        frame = frame:GetParent()
    end

    return false
end

local function IsRaiderIOTooltipAttachedToGroupFinder(tooltip)
    local tooltipAnchor = tooltip and tooltip:GetParent()
    local attachedFrame = tooltipAnchor and tooltipAnchor:GetParent()
    return IsFrameAttachedToGroupFinder(attachedFrame)
end

local function GetHorizontalFrameDistance(frame, standardFrame)
    local frameLeft = frame and frame:GetLeft()
    local frameRight = frame and frame:GetRight()
    local standardLeft = standardFrame and standardFrame:GetLeft()
    local standardRight = standardFrame and standardFrame:GetRight()
    if not frameLeft or not frameRight or not standardLeft or not standardRight then
        return math.huge
    end

    if frameLeft > standardRight then
        return frameLeft - standardRight
    end
    if frameRight < standardLeft then
        return standardLeft - frameRight
    end

    return 0
end

local function GetNearestNotificationTooltip(standardFrame)
    local nearestTooltip
    local nearestDistance = math.huge

    local gameTooltipOwner = GameTooltip and GameTooltip:GetOwner()
    if GameTooltip
        and GameTooltip:IsShown()
        and GameTooltip:GetWidth() > 0
        and gameTooltipOwner ~= state.applyButton
        and IsFrameAttachedToGroupFinder(gameTooltipOwner)
    then
        nearestTooltip = GameTooltip
        nearestDistance = GetHorizontalFrameDistance(GameTooltip, standardFrame)
    end

    local rioTooltip = GetRaiderIOProfileTooltip()
    if rioTooltip
        and rioTooltip:IsShown()
        and rioTooltip:GetWidth() > 0
        and IsRaiderIOTooltipAttachedToGroupFinder(rioTooltip)
    then
        local rioDistance = GetHorizontalFrameDistance(rioTooltip, standardFrame)
        if not nearestTooltip or rioDistance < nearestDistance then
            nearestTooltip = rioTooltip
        end
    end

    return nearestTooltip
end

local function GetNotificationCardWidth(standardFrame)
    local widthFrame = PVEFrame or standardFrame
    local widthOK, frameWidth = pcall(function()
        return widthFrame and widthFrame:GetWidth()
    end)
    if widthOK and type(frameWidth) == "number" and frameWidth > 0 then
        return frameWidth * NOTIFICATION_STANDARD_FRAME_WIDTH_RATIO
    end

    return NOTIFICATION_FALLBACK_WIDTH
end

local function LayoutNotificationFrames()
    local searchPanel = LFGListFrame and LFGListFrame.SearchPanel
    local standardFrame = LFGListFrame or searchPanel
    local tooltipAnchor = GetNearestNotificationTooltip(standardFrame)
    local notificationWidth = GetNotificationCardWidth(standardFrame)
    local previousFrame = nil

    EnsureRaiderIOTooltipHook()

    for index = 1, #state.activeNotifications do
        local frame = state.activeNotifications[index]
        frame:ClearAllPoints()
        frame:SetWidth(notificationWidth)

        if previousFrame then
            frame:SetPoint("TOPLEFT", previousFrame, "BOTTOMLEFT", 0, -7)
        elseif tooltipAnchor then
            frame:SetPoint("TOPLEFT", tooltipAnchor, "BOTTOMLEFT", 0, -7)
        elseif standardFrame then
            frame:SetPoint("TOPLEFT", standardFrame, "TOPRIGHT", 7, 0)
        else
            frame:SetPoint("TOP", UIParent, "TOP", 0, -145)
        end

        previousFrame = frame
    end
end

EnsureRaiderIOTooltipHook = function()
    local widthFrame = PVEFrame or LFGListFrame
    if widthFrame and state.notificationWidthFrameHooked ~= widthFrame then
        widthFrame:HookScript("OnSizeChanged", LayoutNotificationFrames)
        state.notificationWidthFrameHooked = widthFrame
    end

    if GameTooltip and not state.gameTooltipHooked then
        GameTooltip:HookScript("OnShow", LayoutNotificationFrames)
        GameTooltip:HookScript("OnHide", LayoutNotificationFrames)
        GameTooltip:HookScript("OnSizeChanged", LayoutNotificationFrames)
        state.gameTooltipHooked = true
    end

    local rioTooltip = GetRaiderIOProfileTooltip()
    if not rioTooltip or state.rioTooltipHooked == rioTooltip then
        return
    end

    rioTooltip:HookScript("OnShow", LayoutNotificationFrames)
    rioTooltip:HookScript("OnHide", LayoutNotificationFrames)
    rioTooltip:HookScript("OnSizeChanged", LayoutNotificationFrames)
    state.rioTooltipHooked = rioTooltip
end


local function RemoveNotificationFrame(frame)
    for index = #state.activeNotifications, 1, -1 do
        if state.activeNotifications[index] == frame then
            table.remove(state.activeNotifications, index)
            break
        end
    end

    frame.notificationSerial = nil
    frame:Hide()
    frame:SetAlpha(1.0)
    LayoutNotificationFrames()
end

local GROUP_LEADER_ICON = "|TInterface\\GROUPFRAME\\UI-Group-LeaderIcon:14:14:0:0:16:16:0:16:0:16|t"

local function GetSortedNotificationMembers(members)
    local sorted = {}
    for index = 1, #members do
        sorted[index] = members[index]
    end

    table.sort(sorted, function(left, right)
        local leftOrder = ROLE_NOTIFICATION_ORDER[left.role] or ROLE_NOTIFICATION_ORDER.NONE
        local rightOrder = ROLE_NOTIFICATION_ORDER[right.role] or ROLE_NOTIFICATION_ORDER.NONE
        if leftOrder ~= rightOrder then
            return leftOrder < rightOrder
        end

        return (left.memberIndex or 99) < (right.memberIndex or 99)
    end)

    return sorted
end

function Core.GetNotificationMemberIdentity(member)
    local identity = {}
    if member.classIcon and member.classIcon ~= "" then
        identity[#identity + 1] = member.classIcon
    end
    if member.isLeader then
        identity[#identity + 1] = GROUP_LEADER_ICON
    end
    identity[#identity + 1] = member.specName or L.SPECIALIZATION_UNKNOWN
    if member.hasBloodlust and BLOODLUST_SPELL_ICON ~= "" then
        identity[#identity + 1] = BLOODLUST_SPELL_ICON
    end
    if member.hasBattleRez and BATTLE_REZ_SPELL_ICON ~= "" then
        identity[#identity + 1] = BATTLE_REZ_SPELL_ICON
    end
    if member.hasPowerInfusion and POWER_INFUSION_SPELL_ICON ~= "" then
        identity[#identity + 1] = POWER_INFUSION_SPELL_ICON
    end
    return table.concat(identity, " ")
end

function Core.SetNotificationMemberRow(row, member)
    if not member then
        row.role:Hide()
        row.description:Hide()
        row.score:Hide()
        return
    end

    local scoreColor = member.scoreColor or { 1.0, 0.82, 0.0 }
    local classColor = member.classColor or { 1.0, 1.0, 1.0 }
    row.role:SetText(ROLE_CARD_ICONS[member.role] or "")
    row.description:SetText(Core.GetNotificationMemberIdentity(member))
    row.description:SetTextColor(classColor[1], classColor[2], classColor[3])
    row.score:SetText(member.score or "—")
    row.score:SetTextColor(scoreColor[1], scoreColor[2], scoreColor[3])
    row.role:Show()
    row.description:Show()
    row.score:Show()
end

local function ShowActionNotification(action, details, style, excluded)
    if not IsDungeonSearchVisible()
        or IsSecretValue(details.categoryID)
        or details.categoryID ~= DUNGEON_CATEGORY_ID
    then
        return
    end

    local frame = AcquireNotificationFrame()
    local actionColor = Core.NOTIFICATION_COLORS[style] or Core.NOTIFICATION_COLORS.success
    local members = GetSortedNotificationMembers(details.members or {})
    local displayedMembers = math.min(#members, 5)

    frame.accent:SetColorTexture(actionColor.r, actionColor.g, actionColor.b, 1.0)
    frame:SetBackdropBorderColor(actionColor.r, actionColor.g, actionColor.b, 0.85)
    frame.groupName:SetText(details.listingName or L.GROUP_NAME_UNAVAILABLE)
    frame.activity:SetText(details.activityName or L.DUNGEON_UNKNOWN)

    for index = 1, 5 do
        Core.SetNotificationMemberRow(frame.memberRows[index], members[index])
    end

    local actionOffset = 50 + displayedMembers * 18
    frame.action:ClearAllPoints()
    frame.action:SetPoint("TOPLEFT", frame, "TOPLEFT", 20, -actionOffset)
    frame.action:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -20, -actionOffset)
    frame.action:SetText(action)
    frame.action:SetTextColor(actionColor.r, actionColor.g, actionColor.b)

    frame.exclusion:ClearAllPoints()
    frame.exclusion:SetPoint("TOPLEFT", frame.action, "BOTTOMLEFT", 0, -4)
    frame.exclusion:SetPoint("TOPRIGHT", frame.action, "BOTTOMRIGHT", 0, -4)
    if excluded then
        frame.exclusion:SetText(L.EXCLUSION_10_MINUTES)
        frame.exclusion:Show()
    else
        frame.exclusion:Hide()
    end

    local frameHeight = actionOffset + 26 + (excluded and 17 or 0)
    frame:SetHeight(math.max(frameHeight, 96))

    state.notificationSerial = state.notificationSerial + 1
    local serial = state.notificationSerial
    frame.notificationSerial = serial
    frame:SetAlpha(1.0)
    frame:Show()
    table.insert(state.activeNotifications, 1, frame)
    LayoutNotificationFrames()

    C_Timer.After(NOTIFICATION_DURATION - 0.5, function()
        if frame.notificationSerial ~= serial or not frame:IsShown() then
            return
        end

        UIFrameFadeOut(frame, 0.5, 1.0, 0.0)
        C_Timer.After(0.5, function()
            if frame.notificationSerial == serial then
                RemoveNotificationFrame(frame)
            end
        end)
    end)
end

local function GetGroupMemoryKey(resultID)
    local ok, key = pcall(function()
        local searchResultInfo = C_LFGList.GetSearchResultInfo(resultID)
        local partyGUID = searchResultInfo and searchResultInfo.partyGUID
        if partyGUID and partyGUID ~= "" then
            return "party:" .. partyGUID
        end

        return "result:" .. tostring(resultID)
    end)

    if ok then
        return key
    end

    return "result:" .. tostring(resultID)
end

local function GetCurrentTimestamp()
    if GetServerTime then
        return GetServerTime()
    end

    return time()
end

local function GetCancelledGroupsStore()
    if type(LFGAutoPilotDB) ~= "table" then
        LFGAutoPilotDB = {}
    end
    if type(LFGAutoPilotDB.cancelledGroups) ~= "table" then
        LFGAutoPilotDB.cancelledGroups = {}
    end

    return LFGAutoPilotDB.cancelledGroups
end

local function RememberCancelledGroupByKey(groupKey)
    if not groupKey then
        return
    end

    Core.PruneCancelledGroups()
    GetCancelledGroupsStore()[groupKey] = GetCurrentTimestamp()
end

local function ForgetCancelledGroupByKey(groupKey)
    if not groupKey then
        return
    end

    GetCancelledGroupsStore()[groupKey] = nil
end

local function WasGroupCancelled(resultID)
    local groupKey = GetGroupMemoryKey(resultID)
    local cancelledGroups = GetCancelledGroupsStore()
    local cancelledAt = groupKey and cancelledGroups[groupKey]
    if type(cancelledAt) ~= "number" then
        return false
    end

    if GetCurrentTimestamp() - cancelledAt >= CANCELLED_GROUP_MEMORY_TTL then
        cancelledGroups[groupKey] = nil
        return false
    end

    return true
end

function Core.PruneCancelledGroups()
    local cancelledGroups = GetCancelledGroupsStore()
    local now = GetCurrentTimestamp()
    for groupKey, cancelledAt in pairs(cancelledGroups) do
        if type(cancelledAt) ~= "number" or now - cancelledAt >= CANCELLED_GROUP_MEMORY_TTL then
            cancelledGroups[groupKey] = nil
        end
    end
end

function Core.SweepInactiveApplicationCache(resultID, activeResults, now)
    if activeResults[resultID] then
        state.applicationInactiveAt[resultID] = nil
        return
    end

    local inactiveAt = state.applicationInactiveAt[resultID]
    if type(inactiveAt) ~= "number" then
        state.applicationInactiveAt[resultID] = now
        return
    end
    if now - inactiveAt < NOTIFICATION_DURATION then
        return
    end

    state.applicationDetails[resultID] = nil
    state.applicationKeys[resultID] = nil
    state.applicationInactiveAt[resultID] = nil
end

local function RefreshApplicationDetailsCache()
    local applications = C_LFGList.GetApplications() or {}
    local activeResults = {}
    for index = 1, #applications do
        local resultID = applications[index]
        if Core.IsResultApplicationActive(resultID) then
            activeResults[resultID] = true
            state.applicationInactiveAt[resultID] = nil
            if state.applicationDetails[resultID] == nil then
                state.applicationDetails[resultID] = GetResultNotificationDetails(resultID)
            end
            if state.applicationKeys[resultID] == nil then
                state.applicationKeys[resultID] = GetGroupMemoryKey(resultID)
            end
        end
    end

    local checkedResults = {}
    local now = GetTime()
    for resultID in pairs(state.applicationDetails) do
        checkedResults[resultID] = true
        Core.SweepInactiveApplicationCache(resultID, activeResults, now)
    end
    for resultID in pairs(state.applicationKeys) do
        if not checkedResults[resultID] then
            Core.SweepInactiveApplicationCache(resultID, activeResults, now)
        end
    end
end

local function GetApplicationCooldownRemaining()
    if type(state.lastApplicationRequestedAt) ~= "number" then
        return 0
    end

    return math.max(0, APPLICATION_SUBMIT_INTERVAL - (GetTime() - state.lastApplicationRequestedAt))
end

local function RecordApplicationRequest()
    local requestedAt = GetTime()
    state.lastApplicationRequestedAt = requestedAt

    C_Timer.After(APPLICATION_SUBMIT_INTERVAL, function()
        if state.lastApplicationRequestedAt == requestedAt and QueueButtonUpdate then
            QueueButtonUpdate()
        end
    end)
end

local function EnsureCancellationHook()
    if state.cancellationHooked then
        return
    end

    hooksecurefunc(C_LFGList, "CancelApplication", function(resultID)
        local details = state.applicationDetails[resultID] or GetResultNotificationDetails(resultID)
        local groupKey = state.applicationKeys[resultID] or GetGroupMemoryKey(resultID)
        RememberCancelledGroupByKey(groupKey)

        if not state.addonCancellationInProgress then
            state.lastAction = L.CANCELLED_MANUALLY
            ShowActionNotification(
                L.CANCELLED_MANUALLY,
                details,
                "danger",
                true
            )
        end

        C_Timer.After(0.15, UpdateButtons)
    end)

    state.cancellationHooked = true
end

local function EnsureApplicationHook()
    if state.applicationHooked then
        return
    end

    hooksecurefunc(C_LFGList, "ApplyToGroup", function(resultID)
        RecordApplicationRequest()

        if state.addonApplyInProgress then
            return
        end

        state.applicationDetails[resultID] = GetResultNotificationDetails(resultID)
        state.applicationKeys[resultID] = GetGroupMemoryKey(resultID)
        state.lastAction = L.APPLIED_MANUALLY

        C_Timer.After(0.2, function()
            local details = GetResultNotificationDetails(resultID)
            state.applicationDetails[resultID] = details
            ShowActionNotification(L.APPLIED_MANUALLY, details, "success", false)
            UpdateButtons()
        end)
    end)

    state.applicationHooked = true
end

local function IsApplyCandidate(resultID, applicantComposition)
    if WasGroupCancelled(resultID) then
        return false
    end

    local applicationOK, status, pendingStatus = Core.GetApplicationState(resultID)
    if not applicationOK or status ~= "none" or pendingStatus then
        return false
    end

    local ok, candidate = pcall(function()
        local compositionFits = PartyCompositionFitsResult(resultID, applicantComposition)
        local selectable = type(LFGListSearchPanelUtil_CanSelectResult) ~= "function"
            or LFGListSearchPanelUtil_CanSelectResult(resultID)

        -- Impossible applications remain hard filters. Everything that can
        -- form a legal dungeon party is ranked later by its combined score.
        return selectable
            and compositionFits == true
    end)

    return ok and candidate == true
end

local function InvalidateCandidateScores()
    state.scoredCandidates = nil
    state.scoredCandidatesAt = nil
    state.scoredCompositionSignature = nil
end

function Core.InvalidateActionContext()
    state.actionContext = nil
    state.actionContextAt = nil
    state.actionContextPanel = nil
end

function Core.InvalidateSearchCaches()
    InvalidateCandidateScores()
    Core.InvalidateActionContext()
end

local function GetPrioritizedApplyCandidates(applicantComposition, forceRefresh)
    if not applicantComposition then
        return nil
    end

    local compositionSignature = Core.GetCompositionSignature(applicantComposition, true)
    local now = GetTime()
    if not forceRefresh
        and state.scoredCandidates
        and state.scoredCompositionSignature == compositionSignature
        and type(state.scoredCandidatesAt) == "number"
        and now - state.scoredCandidatesAt < SCORE_REFRESH_INTERVAL
    then
        return state.scoredCandidates
    end

    local resultIDs = CopyFilteredResultIDs()
    if not resultIDs then
        return nil
    end

    local candidates = {}
    for index = 1, #resultIDs do
        local resultID = resultIDs[index]
        if IsApplyCandidate(resultID, applicantComposition) then
            local metadata = GetResultScoreMetadata(resultID, applicantComposition)
            metadata.resultID = resultID
            metadata.sourceIndex = index
            candidates[#candidates + 1] = metadata
        end
    end

    table.sort(candidates, function(left, right)
        if left.score ~= right.score then
            return left.score > right.score
        end
        return left.sourceIndex < right.sourceIndex
    end)

    state.scoredCandidates = candidates
    state.scoredCandidatesAt = now
    state.scoredCompositionSignature = compositionSignature
    return candidates
end

local function HasApplyCandidate(applicantComposition)
    local candidates = GetPrioritizedApplyCandidates(applicantComposition)
    return candidates and #candidates > 0
end

local function GetSearchResultAge()
    if type(state.lastSearchResultsAt) ~= "number" then
        return nil
    end

    return math.max(0, GetTime() - state.lastSearchResultsAt)
end

local function IsSearchStale()
    local age = GetSearchResultAge()
    return age == nil or age > SEARCH_RESULTS_MAX_AGE
end

local function RefreshSearchResults(panel)
    if state.searchRefreshPending or panel.searching then
        state.lastAction = L.SEARCH_REFRESHING
        ShowLocalMessage(L.ERROR_SEARCH_REFRESHING, true)
        return
    end

    state.searchRefreshPending = true
    state.searchRefreshStartedAt = GetTime()
    state.lastAction = L.SEARCH_REFRESHING
    Core.InvalidateActionContext()

    local ok = type(LFGListSearchPanel_DoSearch) == "function"
        and SafeCall(LFGListSearchPanel_DoSearch, panel)
    if not ok then
        state.searchRefreshPending = false
        state.searchRefreshStartedAt = nil
        state.lastAction = L.SEARCH_REFRESH_FAILED
        ShowLocalMessage(L.ERROR_SEARCH_REFRESH_FAILED, true)
        QueueButtonUpdate()
        return
    end

    state.refreshRequiredAfterOpen = false

    local refreshStartedAt = state.searchRefreshStartedAt
    C_Timer.After(10, function()
        if state.searchRefreshPending and state.searchRefreshStartedAt == refreshStartedAt then
            state.searchRefreshPending = false
            state.searchRefreshStartedAt = nil
            state.refreshRequiredAfterOpen = true
            state.lastAction = L.SEARCH_REFRESH_FAILED
            ShowLocalMessage(L.ERROR_SEARCH_REFRESH_FAILED, true)
            UpdateButtons()
        end
    end)

    QueueButtonUpdate()
end

function Core.CancelApplicationAndNotify(resultID, successAction, notificationAction)
    local details = state.applicationDetails[resultID] or GetResultNotificationDetails(resultID)
    local ok = Core.SafeCallWithStateFlag(
        "addonCancellationInProgress",
        C_LFGList.CancelApplication,
        resultID
    )

    if ok then
        state.lastAction = successAction
        ShowActionNotification(notificationAction, details, "danger", true)
    else
        state.lastAction = L.CANCEL_FAILED
        ShowLocalMessage(L.ERROR_CANCEL_FAILED, true)
    end

    C_Timer.After(0.15, UpdateButtons)
end

local function CancelInvalidApplications(applications)
    Core.CancelApplicationAndNotify(
        applications.invalid[1],
        L.CANCELLED_ONE,
        L.CANCELLED_AUTOMATICALLY
    )
end

local function CancelLatestApplication(applications)
    local latestApplication = applications.cancellable[1]
    if not latestApplication then
        state.lastAction = L.NO_APPLICATIONS_TO_CANCEL
        ShowLocalMessage(L.NO_APPLICATIONS_TO_CANCEL, true)
        return
    end

    Core.CancelApplicationAndNotify(
        latestApplication.resultID,
        L.CANCELLED_MANUALLY,
        L.CANCELLED_MANUALLY
    )
end

function Core.GetCooldownNotificationDetails(applicantComposition)
    local candidates = GetPrioritizedApplyCandidates(applicantComposition)
    local candidate = candidates and candidates[1]
    if candidate then
        return GetResultNotificationDetails(candidate.resultID)
    end

    return {
        listingName = L.COOLDOWN_NOTIFICATION_TITLE,
        activityName = L.COOLDOWN_NOTIFICATION_ACTIVITY,
        categoryID = DUNGEON_CATEGORY_ID,
        members = {},
    }
end

local function ApplyToFilteredGroups(applicantComposition)
    local cooldownRemaining = GetApplicationCooldownRemaining()
    if cooldownRemaining > 0 then
        local seconds = math.max(1, math.ceil(cooldownRemaining))
        local action = string.format(L.APPLICATION_COOLDOWN, seconds)
        state.lastAction = action
        ShowActionNotification(
            action,
            Core.GetCooldownNotificationDetails(applicantComposition),
            "neutral",
            false
        )
        QueueButtonUpdate()
        return
    end

    local candidates = GetPrioritizedApplyCandidates(applicantComposition, true)
    if not candidates then
        state.lastAction = L.RESULTS_PROTECTED
        ShowLocalMessage(L.ERROR_RESULTS_PROTECTED, true)
        return
    end

    local requestedDetails = nil
    local requestedResultID = nil
    for index = 1, #candidates do
        local resultID = candidates[index].resultID
        local details = GetResultNotificationDetails(resultID)
        local role = applicantComposition.playerRole
        local ok = Core.SafeCallWithStateFlag(
            "addonApplyInProgress",
            C_LFGList.ApplyToGroup,
            resultID,
            role == "TANK",
            role == "HEALER",
            role == "DAMAGER"
        )

        if ok then
            state.applicationDetails[resultID] = details
            state.applicationKeys[resultID] = GetGroupMemoryKey(resultID)
            requestedDetails = details
            requestedResultID = resultID
            break
        end
    end

    if requestedDetails then
        state.lastAction = L.APPLIED_ONE
        C_Timer.After(0.2, function()
            local refreshedDetails = GetResultNotificationDetails(requestedResultID)
            state.applicationDetails[requestedResultID] = refreshedDetails
            ShowActionNotification(L.APPLIED_AUTOMATICALLY, refreshedDetails or requestedDetails, "success", false)
            UpdateButtons()
        end)
        return
    end

    state.lastAction = L.NO_SUITABLE_GROUPS
    ShowLocalMessage(L.ERROR_NO_SUITABLE_GROUPS, true)
    C_Timer.After(0.15, UpdateButtons)
end

function Core.GetActionContext(panel, forceRefresh)
    local now = GetTime()
    if not forceRefresh
        and state.actionContext
        and state.actionContextPanel == panel
        and type(state.actionContextAt) == "number"
        and now - state.actionContextAt < ACTION_CONTEXT_CACHE_TTL
    then
        return state.actionContext
    end

    local applications = GetApplicationsSnapshot()
    local freeSlots = math.max(0, MAX_APPLICATIONS - applications.activeCount)
    local applicantComposition, compositionMessage, compositionError, failureCode =
        GetApplicantPartyComposition()
    local refreshing = state.searchRefreshPending or (panel and panel.searching) or false
    local needsRefresh = state.refreshRequiredAfterOpen or IsSearchStale()
    local canApply = not refreshing
        and not needsRefresh
        and applicantComposition
        and freeSlots > 0
        and HasApplyCandidate(applicantComposition)
        or false
    local canCancel = #applications.invalid > 0
    local canCancelLatest = #applications.cancellable > 0
    local cooldownRemaining = GetApplicationCooldownRemaining()
    local canApplyNow = canApply and cooldownRemaining <= 0
    local canActWithLeftClick = not refreshing
        and (needsRefresh or canApplyNow or (not canApply and canCancel))

    local context = {
        applications = applications,
        freeSlots = freeSlots,
        applicantComposition = applicantComposition,
        compositionMessage = compositionMessage,
        compositionError = compositionError,
        failureCode = failureCode,
        canApply = canApply,
        canCancel = canCancel,
        canCancelLatest = canCancelLatest,
        refreshing = refreshing,
        needsRefresh = needsRefresh,
        cooldownRemaining = cooldownRemaining,
        canActWithLeftClick = canActWithLeftClick,
        canAct = canCancelLatest or canActWithLeftClick,
    }
    state.actionContext = context
    state.actionContextAt = now
    state.actionContextPanel = panel
    return context
end

local function OnActionButtonClick(_, mouseButton)
    local panel = LFGListFrame and LFGListFrame.SearchPanel
    if not panel or not panel:IsShown() then
        state.lastAction = L.OPEN_GROUP_FINDER
        ShowLocalMessage(L.ERROR_OPEN_GROUP_FINDER, true)
        return
    end

    if panel.categoryID ~= DUNGEON_CATEGORY_ID then
        state.lastAction = L.DUNGEONS_ONLY
        ShowLocalMessage(L.ERROR_OPEN_DUNGEONS, true)
        return
    end

    if mouseButton == "RightButton" then
        CancelLatestApplication(GetApplicationsSnapshot())
        return
    end

    if state.searchRefreshPending or panel.searching then
        state.lastAction = L.SEARCH_REFRESHING
        ShowLocalMessage(L.ERROR_SEARCH_REFRESHING, true)
        return
    end

    if state.refreshRequiredAfterOpen or IsSearchStale() then
        RefreshSearchResults(panel)
        return
    end

    local context = Core.GetActionContext(panel, true)

    if context.canApply then
        ApplyToFilteredGroups(context.applicantComposition)
        return
    end

    if context.canCancel then
        CancelInvalidApplications(context.applications)
        return
    end

    if not context.applicantComposition then
        if MaybeInitiatePartyRolePoll(context.failureCode) then
            QueueButtonUpdate()
            return
        end
        state.lastAction = context.compositionMessage or L.ROLE_DETECTION_FAILED
        ShowLocalMessage(context.compositionError or L.ERROR_ROLE_DETECTION_FAILED, true)
    elseif context.freeSlots == 0 then
        state.lastAction = L.ALL_APPLICATIONS_FULL
        ShowLocalMessage(L.ERROR_ALL_APPLICATIONS_FULL, true)
    else
        state.lastAction = L.NO_SUITABLE_GROUPS
        ShowLocalMessage(L.ERROR_NO_SUITABLE_GROUPS, true)
    end
end

local function SetButtonTextColor(button, red, disabled)
    local fontString = button and button:GetFontString()
    if not fontString then
        return
    end

    if disabled then
        fontString:SetTextColor(0.5, 0.5, 0.5)
    elseif red then
        fontString:SetTextColor(1.0, 0.35, 0.35)
    else
        fontString:SetTextColor(1.0, 0.82, 0.0)
    end
end

UpdateButtons = function()
    state.updatePending = false

    local applyButton = state.applyButton
    if not applyButton then
        return
    end

    local panel = LFGListFrame and LFGListFrame.SearchPanel
    local enabled = panel and panel:IsShown() and panel.categoryID == DUNGEON_CATEGORY_ID

    if not enabled then
        applyButton:Hide()
        if GameTooltip:IsShown() and GameTooltip:GetOwner() == applyButton then
            GameTooltip:Hide()
        end
        applyButton:SetText(string.format(L.APPLY_BUTTON, 0, MAX_APPLICATIONS))
        applyButton:Disable()
        SetButtonTextColor(applyButton, false, true)
        return
    end

    applyButton:Show()

    RefreshApplicationDetailsCache()
    local context = Core.GetActionContext(panel)
    MaybeInitiatePartyRolePoll(context.failureCode)

    applyButton:SetText(string.format(L.APPLY_BUTTON, context.applications.activeCount, MAX_APPLICATIONS))
    if context.canAct then
        applyButton:Enable()
    else
        applyButton:Disable()
    end
    SetButtonTextColor(
        applyButton,
        not context.needsRefresh and not context.canApply and context.canCancel,
        not context.canActWithLeftClick
    )
end

QueueButtonUpdate = function()
    if state.updatePending then
        return
    end

    state.updatePending = true
    C_Timer.After(0.05, UpdateButtons)
end

function Core.GetNextActionText(context)
    if context.refreshing then
        return L.SEARCH_REFRESHING
    end
    if context.needsRefresh then
        return L.NEXT_REFRESH
    end
    if context.canApply and context.cooldownRemaining > 0 then
        return string.format(
            L.NEXT_APPLY_DELAYED,
            math.max(1, math.ceil(context.cooldownRemaining))
        )
    end
    if context.canApply then
        return L.NEXT_APPLY
    end
    if context.canCancel then
        return L.NEXT_CANCEL
    end
    if context.failureCode == "roles_unknown"
        and state.rolePollRequestedForSignature == GetHomePartySignature()
    then
        return L.ROLE_POLL_WAITING
    end
    if not context.applicantComposition then
        return context.compositionMessage or L.NEXT_NONE
    end
    return L.NEXT_NONE
end

local function ShowActionButtonTooltip(button)
    local panel = LFGListFrame and LFGListFrame.SearchPanel
    local context = Core.GetActionContext(panel)
    local age = GetSearchResultAge()
    local ageText = age and string.format(L.SEARCH_AGE_SECONDS, math.floor(age)) or L.SEARCH_AGE_UNKNOWN
    local nextAction = Core.GetNextActionText(context)

    Core.PrepareGameTooltip(button, "BOTTOMRIGHT", "TOPRIGHT", 0, 6)
    GameTooltip:AddDoubleLine(L.ACTIVE_APPLICATIONS, string.format("%d / %d", context.applications.activeCount, MAX_APPLICATIONS), 0.8, 0.8, 0.8, 1, 1, 1)
    GameTooltip:AddDoubleLine(L.CAN_CANCEL, tostring(#context.applications.invalid), 0.8, 0.8, 0.8, 1, 1, 1)
    GameTooltip:AddDoubleLine(L.SEARCH_AGE, ageText, 0.8, 0.8, 0.8, 1, 1, 1)
    GameTooltip:AddDoubleLine(L.NEXT_ACTION, nextAction, 0.8, 0.8, 0.8, 1, 0.82, 0)
    if context.canCancelLatest then
        GameTooltip:AddLine(L.RIGHT_CLICK_CANCEL_LATEST, 0.65, 0.85, 1.0)
    else
        GameTooltip:AddLine(L.RIGHT_CLICK_CANCEL_LATEST, 0.5, 0.5, 0.5)
    end
    GameTooltip:AddLine(" ")
    GameTooltip:AddLine(L.ONE_ACTION_PER_CLICK, 1, 0.55, 0.2, true)

    GameTooltip:Show()
end

local function PrioritizeActiveApplications(panel)
    if not panel or panel.categoryID ~= DUNGEON_CATEGORY_ID or not panel.results then
        return
    end

    local activeApplicationOrder = {}
    local activeApplicationSet = {}
    local applications = panel.applications or C_LFGList.GetApplications() or {}

    for index = 1, #applications do
        local resultID = applications[index]
        if Core.IsResultApplicationActive(resultID) then
            activeApplicationOrder[#activeApplicationOrder + 1] = resultID
            activeApplicationSet[resultID] = true
        end
    end

    if #activeApplicationOrder == 0 then
        return
    end

    local prioritized = {}
    local inserted = {}

    -- Keep Blizzard's order inside the active block whenever the application
    -- is still present in the current filtered results.
    for index = 1, #panel.results do
        local resultID = panel.results[index]
        if activeApplicationSet[resultID] then
            prioritized[#prioritized + 1] = resultID
            inserted[resultID] = true
        end
    end

    -- Blizzard normally appends applications excluded by the current filters
    -- at the bottom. Put those into the same pinned block instead.
    for index = 1, #activeApplicationOrder do
        local resultID = activeApplicationOrder[index]
        if not inserted[resultID] then
            prioritized[#prioritized + 1] = resultID
            inserted[resultID] = true
        end
    end

    -- Preserve the standard sorting for every result without an active app.
    for index = 1, #panel.results do
        local resultID = panel.results[index]
        if not inserted[resultID] then
            prioritized[#prioritized + 1] = resultID
        end
    end

    panel.results = prioritized
end

local function EnsureApplicationSortingHook()
    if state.sortingHooked or type(LFGListUtil_SortSearchResults) ~= "function" then
        return
    end

    hooksecurefunc("LFGListUtil_SortSearchResults", PrioritizeActiveApplications)
    state.sortingHooked = true
end

local function EnsureDungeonContextHook()
    if state.dungeonContextHooked then
        return
    end

    local panel = LFGListFrame and LFGListFrame.SearchPanel
    if not panel or type(LFGListSearchPanel_SetCategory) ~= "function" then
        return
    end

    hooksecurefunc("LFGListSearchPanel_SetCategory", function(changedPanel)
        if changedPanel ~= panel then
            return
        end

        QueueButtonUpdate()
    end)
    panel:HookScript("OnShow", QueueButtonUpdate)
    state.dungeonContextHooked = true
end

local function ApplyCachedApplicantOrderToViewer()
    local viewer = LFGListFrame and LFGListFrame.ApplicationViewer
    if not viewer
        or not viewer:IsShown()
        or not IsHostedMythicPlusDungeonListing()
    then
        return
    end

    if type(LFGListApplicationViewer_UpdateResultList) == "function"
        and type(LFGListApplicationViewer_UpdateResults) == "function"
    then
        local updated = SafeCall(LFGListApplicationViewer_UpdateResultList, viewer)
        if updated then
            SafeCall(LFGListApplicationViewer_UpdateResults, viewer)
        end
    end
end

local function ResetHostedApplicantCache()
    state.applicantSortJob = nil
    state.cachedApplicantOrder = nil
    state.applicantRecordCache = {}
    state.applicantGroupSignature = nil
    state.applicantListingSignature = nil
    state.applicantBaseSignature = nil
    state.applicantForceFull = true
end

local function RefreshHostedApplicantMode()
    local active = IsHostedMythicPlusDungeonListing()
    if active ~= state.applicantDungeonListingActive then
        state.applicantDungeonListingActive = active
        state.applicantRefreshElapsed = 0
        state.applicantSortCountdownValue = nil
        if active then
            state.applicantForceFull = true
        else
            ResetHostedApplicantCache()
        end
    end

    local counter = state.applicantSortCountdown
    if counter then
        if active then
            counter:Show()
        else
            if GameTooltip.GetOwner and GameTooltip:GetOwner() == counter then
                GameTooltip_Hide()
            end
            counter:Hide()
        end
    end
    return active
end

local function StartHostedApplicantSortJob()
    local viewer = LFGListFrame and LFGListFrame.ApplicationViewer
    if state.applicantSortJob
        or not viewer
        or not viewer:IsShown()
        or not IsHostedMythicPlusDungeonListing()
    then
        return false
    end

    local workState = { sliceStartedAt = GetHostProfileMilliseconds() }
    local thread = coroutine.create(function()
        return BuildHostedApplicantOrder(workState)
    end)
    state.applicantSortJob = {
        thread = thread,
        workState = workState,
    }
    return true
end

local function RunHostedApplicantSortJobSlice()
    local job = state.applicantSortJob
    if not job then
        return
    end

    job.workState.sliceStartedAt = GetHostProfileMilliseconds()
    local ok, completedSuccessfully = coroutine.resume(job.thread)
    if not ok then
        state.applicantSortJob = nil
        state.applicantForceFull = true
        if completedSuccessfully ~= state.lastApplicantSortError then
            state.lastApplicantSortError = completedSuccessfully
            local errorHandler = type(geterrorhandler) == "function" and geterrorhandler()
            if type(errorHandler) == "function" then
                errorHandler(completedSuccessfully)
            end
        end
        return
    end

    if coroutine.status(job.thread) ~= "dead" then
        return
    end

    state.applicantSortJob = nil
    if completedSuccessfully then
        state.lastApplicantSortError = nil
        ApplyCachedApplicantOrderToViewer()
    else
        state.applicantForceFull = true
    end
end

local function RequestFullHostedApplicantRecalculation()
    state.applicantSortJob = nil
    state.applicantForceFull = true
    state.applicantRefreshElapsed = HOST_SORT_REFRESH_INTERVAL
end

local function RequestHostedRecalculationIfPartyChanged()
    if not state.applicantDungeonListingActive then
        return
    end

    local composition = GetHostedPartyComposition()
    local signature = composition and Core.GetCompositionSignature(composition, false) or nil
    if signature ~= state.applicantGroupSignature then
        RequestFullHostedApplicantRecalculation()
    end
end

local function GetApplicantSortCountdownSeconds()
    return math.max(0, math.ceil(HOST_SORT_REFRESH_INTERVAL - state.applicantRefreshElapsed))
end

local function GetLiveWaitingApplicantIDs()
    local applicantsOK, applicants = pcall(C_LFGList.GetApplicants)
    if not applicantsOK or type(applicants) ~= "table" then
        return nil, nil
    end

    local applicationCount = 0
    local waitingApplicantIDs = {}
    for index = 1, #applicants do
        local applicantInfo = Core.GetApplicantInfoSafely(applicants[index])
        if applicantInfo then
            local status = applicantInfo.applicationStatus
            local pendingStatus = applicantInfo.pendingApplicationStatus
            if not IsSecretValue(status)
                and not IsSecretValue(pendingStatus)
                and status == "applied"
                and (pendingStatus == nil or pendingStatus == "applied")
            then
                applicationCount = applicationCount + 1
                local applicantID = GetStableApplicantID(applicantInfo)
                if applicantID ~= nil then
                    waitingApplicantIDs[applicantID] = true
                end
            end
        end
    end

    return applicationCount, waitingApplicantIDs
end

local function GetCurrentApplicantQueueCounts()
    local liveApplicationCount, waitingApplicantIDs = GetLiveWaitingApplicantIDs()
    local cachedApplicationCount = 0
    local roleCounts = {
        TANK = 0,
        HEALER = 0,
        DAMAGER = 0,
    }

    for applicantID, record in pairs(state.applicantRecordCache or {}) do
        local stillWaiting = waitingApplicantIDs == nil or waitingApplicantIDs[applicantID] == true
        if stillWaiting and record.isWaiting and record.valid and type(record.members) == "table" then
            cachedApplicationCount = cachedApplicationCount + 1
            for index = 1, #record.members do
                local role = record.members[index].role
                if roleCounts[role] ~= nil then
                    roleCounts[role] = roleCounts[role] + 1
                end
            end
        end
    end

    return liveApplicationCount or cachedApplicationCount, roleCounts
end

local function ShowApplicantSortCountdownTooltip(counter)
    if not counter then
        return
    end

    Core.PrepareGameTooltip(counter, "BOTTOM", "TOP", 0, 6)

    local applicationCount, roleCounts = GetCurrentApplicantQueueCounts()
    GameTooltip:AddDoubleLine(
        L.APPLICANT_SORT_TOOLTIP_ACTIVE,
        tostring(applicationCount),
        1,
        1,
        1,
        1,
        0.82,
        0
    )
    GameTooltip:AddLine(
        string.format(
            L.APPLICANT_SORT_TOOLTIP_ROLES,
            ROLE_CARD_ICONS.TANK,
            roleCounts.TANK,
            ROLE_CARD_ICONS.HEALER,
            roleCounts.HEALER,
            ROLE_CARD_ICONS.DAMAGER,
            roleCounts.DAMAGER
        ),
        1,
        1,
        1,
        true
    )
    GameTooltip:AddLine(" ")

    if state.applicantSortJob then
        GameTooltip:AddLine(L.APPLICANT_SORT_TOOLTIP_RUNNING, 1, 1, 1, true)
    else
        GameTooltip:AddLine(
            string.format(L.APPLICANT_SORT_TOOLTIP_WAITING, GetApplicantSortCountdownSeconds()),
            1,
            1,
            1,
            true
        )
    end

    GameTooltip:AddLine(" ")
    GameTooltip:AddLine(L.APPLICANT_SORT_TOOLTIP_INTERVAL, 0.65, 0.82, 1, true)
    GameTooltip:AddLine(L.APPLICANT_SORT_TOOLTIP_FULL, 0.65, 0.82, 1, true)
    GameTooltip:Show()
end

local function UpdateApplicantSortCountdown()
    local counter = state.applicantSortCountdown
    if not counter then
        return
    end

    local value
    if state.applicantSortJob then
        value = L.APPLICANT_SORT_RUNNING
    else
        value = string.format(L.APPLICANT_SORT_COUNTDOWN, GetApplicantSortCountdownSeconds())
    end

    if value == state.applicantSortCountdownValue then
        return
    end

    state.applicantSortCountdownValue = value
    counter.Text:SetText(value)

    if GameTooltip.GetOwner and GameTooltip:GetOwner() == counter then
        ShowApplicantSortCountdownTooltip(counter)
    end
end

local function EnsureApplicantSortCountdown(viewer)
    if state.applicantSortCountdown or not viewer or not viewer.RefreshButton then
        return
    end

    local counter = CreateFrame(
        "Frame",
        "LFGAutoPilotApplicantSortCountdown",
        viewer,
        "BackdropTemplate"
    )
    counter:SetSize(38, 22)
    counter:SetPoint("RIGHT", viewer.RefreshButton, "LEFT", -2, 0)
    counter:SetFrameLevel(viewer.RefreshButton:GetFrameLevel() + 1)
    counter:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8X8",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        edgeSize = 9,
        insets = { left = 2, right = 2, top = 2, bottom = 2 },
    })
    counter:SetBackdropColor(0.03, 0.03, 0.03, 0.9)
    counter:SetBackdropBorderColor(0.75, 0.58, 0.12, 0.9)
    counter:EnableMouse(true)
    counter:Hide()

    local text = counter:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    text:SetPoint("CENTER", 0, 0)
    counter.Text = text
    counter:SetScript("OnEnter", function(self)
        self.tooltipRefreshElapsed = 0
        ShowApplicantSortCountdownTooltip(self)
    end)
    counter:SetScript("OnLeave", function(self)
        self.tooltipRefreshElapsed = 0
        GameTooltip_Hide()
    end)
    counter:SetScript("OnUpdate", function(self, elapsed)
        if GameTooltip.GetOwner and GameTooltip:GetOwner() == self then
            self.tooltipRefreshElapsed = (self.tooltipRefreshElapsed or 0) + elapsed
            if self.tooltipRefreshElapsed >= HOST_TOOLTIP_REFRESH_INTERVAL then
                self.tooltipRefreshElapsed = 0
                ShowApplicantSortCountdownTooltip(self)
            end
        else
            self.tooltipRefreshElapsed = 0
        end
    end)

    state.applicantSortCountdown = counter
    state.applicantSortCountdownValue = nil
    RefreshHostedApplicantMode()
    UpdateApplicantSortCountdown()
end

local function EnsureApplicantSortingHook()
    if not state.applicantSortingHooked and type(LFGListUtil_SortApplicants) == "function" then
        hooksecurefunc("LFGListUtil_SortApplicants", SortHostedApplicants)
        state.applicantSortingHooked = true
    end

    local viewer = LFGListFrame and LFGListFrame.ApplicationViewer
    EnsureApplicantSortCountdown(viewer)
    if state.applicantViewerHooked or not viewer then
        return
    end

    viewer:HookScript("OnUpdate", function(_, elapsed)
        state.applicantContextRefreshElapsed = state.applicantContextRefreshElapsed + elapsed
        if state.applicantContextRefreshElapsed >= HOST_TOOLTIP_REFRESH_INTERVAL then
            state.applicantContextRefreshElapsed = 0
            RefreshHostedApplicantMode()
        end

        if state.applicantDungeonListingActive then
            if state.applicantSortJob then
                RunHostedApplicantSortJobSlice()
            else
                state.applicantRefreshElapsed = state.applicantRefreshElapsed + elapsed
            end

            if not state.applicantSortJob
                and state.applicantRefreshElapsed >= HOST_SORT_REFRESH_INTERVAL
            then
                state.applicantRefreshElapsed = 0
                if StartHostedApplicantSortJob() then
                    RunHostedApplicantSortJobSlice()
                end
            end

            UpdateApplicantSortCountdown()
        end
    end)
    viewer:HookScript("OnShow", function()
        state.applicantRefreshElapsed = 0
        state.applicantContextRefreshElapsed = 0
        state.applicantSortCountdownValue = nil
        if RefreshHostedApplicantMode() then
            UpdateApplicantSortCountdown()
        end
    end)
    viewer:HookScript("OnHide", function()
        state.applicantRefreshElapsed = 0
        state.applicantContextRefreshElapsed = 0
        state.applicantSortCountdownValue = nil
        state.applicantDungeonListingActive = false
        if state.applicantSortCountdown then
            state.applicantSortCountdown:Hide()
        end
        ResetHostedApplicantCache()
    end)
    state.applicantViewerHooked = true
end

local function EnsureButtons()
    EnsureApplicationSortingHook()
    EnsureDungeonContextHook()
    EnsureApplicantSortingHook()
    EnsureCancellationHook()
    EnsureApplicationHook()

    if state.applyButton or not LFGListFrame or not LFGListFrame.SearchPanel then
        return
    end

    local panel = LFGListFrame.SearchPanel
    local filterButton = panel.FilterButton
    if not filterButton then
        return
    end

    local applyButton = CreateFrame("Button", "LFGAutoPilotApplyButton", panel, "UIPanelButtonTemplate")
    applyButton:Hide()
    applyButton:SetHeight(filterButton:GetHeight() > 0 and filterButton:GetHeight() or 22)
    applyButton:SetPoint("BOTTOMLEFT", filterButton, "TOPLEFT", 0, 4)
    applyButton:SetPoint("BOTTOMRIGHT", filterButton, "TOPRIGHT", 0, 4)
    applyButton:SetFrameLevel(panel:GetFrameLevel() + 10)
    applyButton:SetNormalFontObject(GameFontNormalSmall)
    applyButton:SetHighlightFontObject(GameFontHighlightSmall)
    applyButton:SetDisabledFontObject(GameFontDisableSmall)
    if applyButton.SetMotionScriptsWhileDisabled then
        applyButton:SetMotionScriptsWhileDisabled(true)
    end
    applyButton:RegisterForClicks("LeftButtonUp", "RightButtonUp")
    applyButton:SetScript("OnClick", OnActionButtonClick)
    applyButton:SetScript("OnEnter", ShowActionButtonTooltip)
    applyButton:SetScript("OnLeave", GameTooltip_Hide)
    applyButton:SetScript("OnShow", function()
        state.refreshRequiredAfterOpen = true
        Core.InvalidateActionContext()
        QueueButtonUpdate()
    end)
    applyButton:SetScript("OnUpdate", function(_, elapsed)
        state.buttonRefreshElapsed = state.buttonRefreshElapsed + elapsed
        if state.buttonRefreshElapsed >= BUTTON_REFRESH_INTERVAL then
            state.buttonRefreshElapsed = 0
            QueueButtonUpdate()
            if GameTooltip:IsShown() and GameTooltip:GetOwner() == applyButton then
                ShowActionButtonTooltip(applyButton)
            end
        end
    end)

    state.applyButton = applyButton
    QueueButtonUpdate()
end

function Core.ScheduleApplicationCacheCleanup(resultID)
    C_Timer.After(1, function()
        if not Core.IsResultApplicationActive(resultID) then
            state.applicationDetails[resultID] = nil
            state.applicationKeys[resultID] = nil
            state.applicationInactiveAt[resultID] = nil
        end
    end)
end

local function HandleApplicationStatusUpdated(resultID, newStatus)
    local action = APPLICATION_STATUS_ACTIONS[newStatus]
    if action then
        if newStatus == "declined_delisted" then
            ForgetCancelledGroupByKey(state.applicationKeys[resultID] or GetGroupMemoryKey(resultID))
        end

        local details = state.applicationDetails[resultID] or GetResultNotificationDetails(resultID)
        state.lastAction = action
        ShowActionNotification(action, details, "danger", false)
    end

    Core.ScheduleApplicationCacheCleanup(resultID)
end

local EVENT_HANDLERS = {
    ADDON_LOADED = function(addonName)
        if addonName == "Blizzard_GroupFinder" then
            EnsureButtons()
        end
    end,
    PLAYER_LOGIN = function()
        if C_AddOns.IsAddOnLoaded("Blizzard_GroupFinder") then
            EnsureButtons()
        end
    end,
    GROUP_ROSTER_UPDATE = SyncHomePartySignature,
    LFG_LIST_SEARCH_RESULTS_RECEIVED = function()
        local requestedByAddon = state.searchRefreshPending
        state.lastSearchResultsAt = GetTime()
        state.searchRefreshPending = false
        state.searchRefreshStartedAt = nil
        if requestedByAddon then
            state.lastAction = L.SEARCH_REFRESHED
        end
    end,
    LFG_LIST_SEARCH_FAILED = function()
        if state.searchRefreshPending then
            state.searchRefreshPending = false
            state.searchRefreshStartedAt = nil
            state.refreshRequiredAfterOpen = true
            state.lastAction = L.SEARCH_REFRESH_FAILED
            ShowLocalMessage(L.ERROR_SEARCH_REFRESH_FAILED, true)
        end
    end,
    LFG_LIST_SEARCH_RESULT_UPDATED = function(resultID)
        if not IsSecretValue(resultID)
            and (type(resultID) == "number" or type(resultID) == "string")
        then
            state.applicationDetails[resultID] = nil
        end
    end,
    LFG_LIST_APPLICATION_STATUS_UPDATED = HandleApplicationStatusUpdated,
    LFG_LIST_ACTIVE_ENTRY_UPDATE = function()
        if RefreshHostedApplicantMode() then
            state.cachedApplicantOrder = nil
            RequestFullHostedApplicantRecalculation()
        end
    end,
}

local HOST_PARTY_RECALC_EVENTS = {
    GROUP_ROSTER_UPDATE = true,
    PARTY_LEADER_CHANGED = true,
    PLAYER_ROLES_ASSIGNED = true,
    PLAYER_SPECIALIZATION_CHANGED = true,
}

local REGISTERED_EVENTS = {
    "ADDON_LOADED",
    "PLAYER_LOGIN",
    "PLAYER_SPECIALIZATION_CHANGED",
    "PLAYER_ROLES_ASSIGNED",
    "GROUP_ROSTER_UPDATE",
    "PARTY_LEADER_CHANGED",
    "LFG_LIST_SEARCH_RESULTS_RECEIVED",
    "LFG_LIST_SEARCH_FAILED",
    "LFG_LIST_SEARCH_RESULT_UPDATED",
    "LFG_LIST_APPLICATION_STATUS_UPDATED",
    "LFG_LIST_ACTIVE_ENTRY_UPDATE",
}

local eventFrame = CreateFrame("Frame")
for index = 1, #REGISTERED_EVENTS do
    eventFrame:RegisterEvent(REGISTERED_EVENTS[index])
end

eventFrame:SetScript("OnEvent", function(_, event, ...)
    Core.InvalidateSearchCaches()
    local handler = EVENT_HANDLERS[event]
    if handler then
        handler(...)
    end

    if HOST_PARTY_RECALC_EVENTS[event] then
        RequestHostedRecalculationIfPartyChanged()
    end

    QueueButtonUpdate()
end)

GetCancelledGroupsStore()
LFGAutoPilotDB.version = "0.4.0"
LFGAutoPilotDB.loadedFor = UnitName("player")
Core.PruneCancelledGroups()
