GuildHelper = GuildHelper or {}

local addon = GuildHelper

addon.MaintenanceService = addon.MaintenanceService or {}

local STALE_AFTER_DAYS = 30
local PURGE_AFTER_DAYS = 90
local CLEANUP_MACRO_NAME = "GH3Cleanup"
local PROMOTION_MACRO_NAME = "GH3Promote"
local EXECUTION_MACRO_NAME = "GH3BatchRun"
local CLEANUP_MACRO_BODY_LIMIT = 255
local KICK_SCOPE_ALL = "all"
local KICK_SCOPE_MAINS = "mains"
local KICK_SCOPE_ALTS = "alts"
local INVITE_PENDING_LIMIT = 100
local INVITE_RECENT_LIMIT = 50
local INVITE_BLOCK_LIMIT = 50
local INVITE_REPEAT_COOLDOWN_DAYS = 30
local DEFAULT_INVITE_MIN_LEVEL = 10
local DEFAULT_INVITE_BATCH_SIZE = 5
local INVITE_SCAN_INTERVAL_SECONDS = 8
local INVITE_BATCH_RESULT_GRACE_SECONDS = 2
local INVITE_SCAN_TIMEOUT_SECONDS = 8
local INVITE_WHO_PARSE_DELAY_SECONDS = 0.75
local INVITE_WHO_RESULT_LIMIT = 50
local AUDIT_DEFAULT_MAX_ROWS = 500
local AUDIT_DEFAULT_RETENTION_DAYS = 90
local AUDIT_RECENT_FINGERPRINT_WINDOW_SECONDS = 15
local AUDIT_PENDING_KICK_WINDOW_SECONDS = 180
local AUDIT_RECENT_DEPARTURE_WINDOW_SECONDS = 900
local AUDIT_DEFAULT_COLOR_THEME = "classic"
local AUDIT_TEXT_COLOR_KEYS = {
    "join",
    "leave",
    "kick",
    "rank",
    "note",
    "officerNote",
}
local AUDIT_DEFAULT_TEXT_COLORS = {
    join = { 0.24, 0.84, 0.36, 1 },
    leave = { 0.66, 0.66, 0.66, 1 },
    kick = { 0.96, 0.28, 0.28, 1 },
    rank = { 0.96, 0.84, 0.24, 1 },
    note = { 0.76, 0.5, 0.92, 1 },
    officerNote = { 0.14, 0.56, 0.22, 1 },
}
local RECRUITER_SYNC_PREFIX = "GH3RECRUIT"
local DO_NOT_KICK_TAG_PATTERNS = {
    "#nokick",
    "[nokick]",
    "gh3:nokick",
    "#keep",
    "[keep]",
    "gh3:keep",
}

local function normalizeDayThreshold(value, defaultValue, minimum, maximum)
    local numericValue = math.floor(tonumber(value) or defaultValue)
    local minValue = tonumber(minimum) or 7
    local maxValue = tonumber(maximum) or 365

    if numericValue < minValue then
        return minValue
    end

    if numericValue > maxValue then
        return maxValue
    end

    return numericValue
end

local function normalizeKickScope(value)
    local normalizedValue = string.lower(tostring(value or ""))

    if normalizedValue == KICK_SCOPE_MAINS or normalizedValue == KICK_SCOPE_ALTS then
        return normalizedValue
    end

    return KICK_SCOPE_ALL
end

local function normalizeInviteName(name)
    local trimmed = string.match(tostring(name or ""), "^%s*(.-)%s*$") or ""

    if trimmed == "" then
        return ""
    end

    return addon.Store:NormalizeFullName(trimmed, GetRealmName() or "Unknown")
end

local function trimText(value)
    return string.match(tostring(value or ""), "^%s*(.-)%s*$") or ""
end

local function lowerText(value)
    return string.lower(trimText(value))
end

local function escapePatternText(value)
    return (tostring(value or ""):gsub("([%(%)%.%%%+%-%*%?%[%]%^%$])", "%%%1"))
end

local AUDIT_EVENT_LABELS = {
    join = "Join",
    leave = "Left",
    kick = "Kick",
    rank = "Rank",
    note = "Note",
    officerNote = "Officer Note",
}

local function normalizeAuditEventType(value)
    local normalizedValue = lowerText(value)

    if AUDIT_EVENT_LABELS[normalizedValue] then
        return normalizedValue
    end

    return ""
end

local function normalizeAuditMaxRows(value)
    local numericValue = math.floor(tonumber(value) or AUDIT_DEFAULT_MAX_ROWS)

    if numericValue < 25 then
        return 25
    end

    if numericValue > 5000 then
        return 5000
    end

    return numericValue
end

local function normalizeAuditRetentionDays(value)
    local numericValue = math.floor(tonumber(value) or AUDIT_DEFAULT_RETENTION_DAYS)

    if numericValue < 1 then
        return 1
    end

    if numericValue > 3650 then
        return 3650
    end

    return numericValue
end

local function normalizeAuditColorTheme(value)
    local normalizedValue = lowerText(value)

    if normalizedValue == "muted" or normalizedValue == "high-contrast" or normalizedValue == "mono" then
        return normalizedValue
    end

    return AUDIT_DEFAULT_COLOR_THEME
end

local function clampAuditColorChannel(value, fallback)
    local numericValue = tonumber(value)

    if numericValue == nil then
        return tonumber(fallback) or 1
    end

    if numericValue < 0 then
        return 0
    end

    if numericValue > 1 then
        return 1
    end

    return numericValue
end

local function normalizeAuditTextColorValue(value, fallback)
    local fallbackColor = type(fallback) == "table" and fallback or { 0.84, 0.84, 0.84, 1 }
    local source = type(value) == "table" and value or fallbackColor

    return {
        clampAuditColorChannel(source.r or source[1], fallbackColor[1]),
        clampAuditColorChannel(source.g or source[2], fallbackColor[2]),
        clampAuditColorChannel(source.b or source[3], fallbackColor[3]),
        clampAuditColorChannel(source.a or source[4], fallbackColor[4] or 1),
    }
end

local function normalizeAuditTextColors(value)
    local source = type(value) == "table" and value or {}
    local normalized = {}

    for _, eventType in ipairs(AUDIT_TEXT_COLOR_KEYS) do
        normalized[eventType] = normalizeAuditTextColorValue(source[eventType], AUDIT_DEFAULT_TEXT_COLORS[eventType])
    end

    return normalized
end

local function areAuditTextColorsEqual(left, right)
    local leftColor = normalizeAuditTextColorValue(left, { 0.84, 0.84, 0.84, 1 })
    local rightColor = normalizeAuditTextColorValue(right, { 0.84, 0.84, 0.84, 1 })

    for index = 1, 4 do
        if math.abs((leftColor[index] or 0) - (rightColor[index] or 0)) > 0.0001 then
            return false
        end
    end

    return true
end

local function normalizeAuditPreviewText(value)
    local text = trimText(value)

    if text == "" then
        return ""
    end

    text = text:gsub("|", "||")
    text = text:gsub("%s+", " ")
    return trimText(text)
end

local function buildAuditTextSignature(value)
    local text = normalizeAuditPreviewText(value)
    local hash = 0

    for index = 1, #text do
        hash = ((hash * 131) + string.byte(text, index)) % 2147483647
    end

    return string.format("%d:%d", #text, hash)
end

local function buildAuditTextPreview(value, maxLength)
    local text = normalizeAuditPreviewText(value)
    local limit = math.max(8, math.floor(tonumber(maxLength) or 72))

    if text == "" then
        return "(blank)"
    end

    if #text > limit then
        text = text:sub(1, limit - 3) .. "..."
    end

    text = text:gsub('"', "'")
    return '"' .. text .. '"'
end

local function buildOfficerNoteAuditState(value, sourceValue)
    local normalizedSourceValue = sourceValue ~= nil and trimText(sourceValue) or trimText(value)
    local normalizedValue = normalizeAuditPreviewText(normalizedSourceValue)

    return {
        hasValue = normalizedValue ~= "",
        sourceValue = normalizedSourceValue,
        value = normalizedValue,
    }
end

local function getOfficerNoteAuditSignature(state)
    if type(state) ~= "table" then
        return ""
    end

    if trimText(state.signature) ~= "" then
        return trimText(state.signature)
    end

    return buildAuditTextSignature(state.value)
end

local function normalizeAuditName(name)
    local trimmed = trimText(name)
    local localizedYou = type(YOU) == "string" and trimText(YOU) or ""

    if trimmed == "" then
        return ""
    end

    if trimmed == localizedYou or string.lower(trimmed) == "you" then
        return addon.Store:GetPlayerName()
    end

    return addon.Store:NormalizeFullName(trimmed, GetRealmName() or "Unknown")
end

local function buildAuditNoteChangeDetail(previousValue, nextValue)
    local previousText = normalizeAuditPreviewText(previousValue)
    local nextText = normalizeAuditPreviewText(nextValue)

    if previousText == nextText then
        return ""
    end

    if previousText == "" then
        return string.format("Public note set to %s.", buildAuditTextPreview(nextText, 72))
    end

    if nextText == "" then
        return string.format("Public note cleared from %s.", buildAuditTextPreview(previousText, 72))
    end

    return string.format(
        "Public note changed from %s to %s.",
        buildAuditTextPreview(previousText, 56),
        buildAuditTextPreview(nextText, 56)
    )
end

local function buildAuditOfficerNoteChangeDetail(previousState, nextState)
    local previousHasValue = type(previousState) == "table" and previousState.hasValue and true or false
    local nextHasValue = type(nextState) == "table" and nextState.hasValue and true or false

    if previousHasValue and not nextHasValue then
        return "Officer note cleared."
    end

    if not previousHasValue and nextHasValue then
        return "Officer note set."
    end

    return "Officer note changed."
end

local function buildAuditRankChangeDetail(previousRank, nextRank)
    local previousText = trimText(previousRank)
    local nextText = trimText(nextRank)

    if previousText == nextText then
        return ""
    end

    if previousText == "" then
        return string.format("Guild rank set to %s.", nextText ~= "" and nextText or "Unknown")
    end

    if nextText == "" then
        return string.format("Guild rank cleared from %s.", previousText)
    end

    return string.format("Guild rank changed from %s to %s.", previousText, nextText)
end

local function buildAuditFingerprint(eventType, memberId, actorId, detail)
    return table.concat({
        tostring(normalizeAuditEventType(eventType) or ""),
        tostring(memberId or ""),
        tostring(actorId or ""),
        string.lower(normalizeAuditPreviewText(detail)),
    }, "|")
end

local function isAuditEventEnabled(settings, eventType)
    local normalizedEventType = normalizeAuditEventType(eventType)

    if normalizedEventType == "join" then
        return settings.auditLogJoins ~= false
    end

    if normalizedEventType == "leave" then
        return settings.auditLogLeaves ~= false
    end

    if normalizedEventType == "kick" then
        return settings.auditLogKicks ~= false
    end

    if normalizedEventType == "rank" then
        return settings.auditLogRanks ~= false
    end

    if normalizedEventType == "note" then
        return settings.auditLogNotes ~= false
    end

    if normalizedEventType == "officerNote" then
        return settings.auditLogOfficerNotes ~= false
    end

    return false
end

local function ensureAuditRuntimeState()
    addon.MaintenanceService._auditRuntime = type(addon.MaintenanceService._auditRuntime) == "table" and addon.MaintenanceService._auditRuntime or {}

    local runtime = addon.MaintenanceService._auditRuntime
    local now = time()

    runtime.recentFingerprints = type(runtime.recentFingerprints) == "table" and runtime.recentFingerprints or {}
    runtime.pendingKicks = type(runtime.pendingKicks) == "table" and runtime.pendingKicks or {}

    for fingerprint, recordedAt in pairs(runtime.recentFingerprints) do
        if (now - (tonumber(recordedAt) or 0)) > (AUDIT_RECENT_FINGERPRINT_WINDOW_SECONDS * 4) then
            runtime.recentFingerprints[fingerprint] = nil
        end
    end

    for memberId, pendingRecord in pairs(runtime.pendingKicks) do
        if type(pendingRecord) ~= "table" or (now - (tonumber(pendingRecord.queuedAt) or 0)) > AUDIT_PENDING_KICK_WINDOW_SECONDS then
            runtime.pendingKicks[memberId] = nil
        end
    end

    return runtime
end

local function getPendingKickRecord(memberId, consume)
    local runtime = ensureAuditRuntimeState()
    local normalizedMemberId = normalizeAuditName(memberId)
    local pendingRecord = runtime.pendingKicks[normalizedMemberId]

    if type(pendingRecord) ~= "table" then
        return nil
    end

    if (time() - (tonumber(pendingRecord.queuedAt) or 0)) > AUDIT_PENDING_KICK_WINDOW_SECONDS then
        runtime.pendingKicks[normalizedMemberId] = nil
        return nil
    end

    if consume then
        runtime.pendingKicks[normalizedMemberId] = nil
    end

    return pendingRecord
end

local function findRecentSystemDepartureEntry(maintenance, memberId, recordedAt)
    if type(maintenance) ~= "table" or type(maintenance.auditLog) ~= "table" then
        return nil
    end

    local normalizedMemberId = normalizeAuditName(memberId)
    local cutoffAt = (tonumber(recordedAt) or time()) - AUDIT_RECENT_DEPARTURE_WINDOW_SECONDS

    if normalizedMemberId == "" then
        return nil
    end

    for index = #maintenance.auditLog, 1, -1 do
        local entry = maintenance.auditLog[index]

        if type(entry) == "table" then
            local entryRecordedAt = tonumber(entry.recordedAt) or 0

            if entryRecordedAt > 0 and entryRecordedAt < cutoffAt then
                break
            end

            if normalizeAuditName(entry.memberId) == normalizedMemberId then
                local entryEventType = normalizeAuditEventType(entry.eventType)
                local entrySource = string.lower(trimText(entry.source))

                if entryEventType == "join" then
                    return nil
                end

                if (entryEventType == "leave" or entryEventType == "kick") and entrySource == "system" then
                    return entry
                end
            end
        end
    end

    return nil
end

local function pruneAuditLog(maintenance, settings)
    if type(maintenance) ~= "table" then
        return {}
    end

    maintenance.auditLog = type(maintenance.auditLog) == "table" and maintenance.auditLog or {}

    local keptEntries = {}
    local now = time()
    local retentionDays = type(settings) == "table" and normalizeAuditRetentionDays(settings.auditRetentionDays) or AUDIT_DEFAULT_RETENTION_DAYS
    local maxRows = type(settings) == "table" and normalizeAuditMaxRows(settings.auditMaxRows) or AUDIT_DEFAULT_MAX_ROWS
    local cutoffAt = now - (retentionDays * 86400)

    for _, entry in ipairs(maintenance.auditLog) do
        if type(entry) == "table" then
            local recordedAt = tonumber(entry.recordedAt) or now

            if recordedAt >= cutoffAt then
                entry.recordedAt = recordedAt
                keptEntries[#keptEntries + 1] = entry
            end
        end
    end

    table.sort(keptEntries, function(left, right)
        local leftAt = tonumber(left and left.recordedAt) or 0
        local rightAt = tonumber(right and right.recordedAt) or 0

        if leftAt == rightAt then
            return tostring(left and left.eventType or "") < tostring(right and right.eventType or "")
        end

        return leftAt < rightAt
    end)

    while #keptEntries > maxRows do
        table.remove(keptEntries, 1)
    end

    maintenance.auditLog = keptEntries
    return keptEntries
end

local function getShortName(value)
    local text = trimText(value)

    if text == "" then
        return ""
    end

    return trimText(text:match("^([^-]+)") or text)
end

local function getInviteMaxLevel()
    if GetMaxPlayerLevel then
        local ok, maxLevel = pcall(GetMaxPlayerLevel)

        if ok and tonumber(maxLevel) then
            return tonumber(maxLevel)
        end
    end

    return tonumber(MAX_PLAYER_LEVEL) or 80
end

local function normalizeInviteLevel(value, defaultValue)
    local maxLevel = getInviteMaxLevel()
    local numericValue = math.floor(tonumber(value) or tonumber(defaultValue) or DEFAULT_INVITE_MIN_LEVEL)

    if numericValue < 1 then
        return 1
    end

    if numericValue > maxLevel then
        return maxLevel
    end

    return numericValue
end

local function normalizeInviteBatchSize(value)
    local numericValue = math.floor(tonumber(value) or DEFAULT_INVITE_BATCH_SIZE)

    if numericValue < 1 then
        return 1
    end

    if numericValue > 25 then
        return 25
    end

    return numericValue
end

local function canGuildInviteMembers()
    if C_GuildInfo and C_GuildInfo.CanInvite then
        local ok, allowed = pcall(C_GuildInfo.CanInvite)

        if ok then
            return allowed and true or false
        end
    end

    if CanGuildInvite then
        local ok, allowed = pcall(CanGuildInvite)

        if ok then
            return allowed and true or false
        end
    end

    return false
end

local function canSendRecruiterInviteWhisper(message)
    return SendChatMessage ~= nil and trimText(message) ~= ""
end

local function performGuildInvite(candidateName)
    local normalizedName = normalizeInviteName(candidateName)

    if normalizedName == "" then
        return false, "enter a character name before sending an invite"
    end

    if C_GuildInfo and C_GuildInfo.Invite then
        local ok, inviteError = pcall(C_GuildInfo.Invite, normalizedName)

        if ok then
            return true
        end

        return false, tostring(inviteError or "unable to send the guild invite")
    end

    if GuildInvite then
        local ok, inviteError = pcall(GuildInvite, normalizedName)

        if ok then
            return true
        end

        return false, tostring(inviteError or "unable to send the guild invite")
    end

    return false, "guild invite API is unavailable on this client"
end

local function canEditOfficerNote()
    if C_GuildInfo and C_GuildInfo.CanEditOfficerNote then
        local ok, allowed = pcall(C_GuildInfo.CanEditOfficerNote)

        if ok then
            return allowed and true or false
        end
    end

    if CanEditOfficerNote then
        local ok, allowed = pcall(CanEditOfficerNote)

        if ok then
            return allowed and true or false
        end
    end

    return false
end

local function canViewOfficerNote()
    if C_GuildInfo and C_GuildInfo.CanViewOfficerNote then
        local ok, allowed = pcall(C_GuildInfo.CanViewOfficerNote)

        if ok then
            return allowed and true or false
        end
    end

    if CanViewOfficerNote then
        local ok, allowed = pcall(CanViewOfficerNote)

        if ok then
            return allowed and true or false
        end
    end

    return false
end

local function requestGuildRosterSnapshot()
    if C_GuildInfo and C_GuildInfo.GuildRoster then
        local ok = pcall(C_GuildInfo.GuildRoster)

        if ok then
            return true
        end
    end

    if GuildRoster then
        local ok = pcall(GuildRoster)

        if ok then
            return true
        end
    end

    return false
end

local function getGuildRosterStatusText(statusValue, isMobile)
    if isMobile then
        return "Mobile"
    end

    local status = tonumber(statusValue) or 0

    if status == 1 then
        return "AFK"
    end

    if status == 2 then
        return "Busy"
    end

    return "Online"
end

local function getGuildRosterRepStandingText(standingId)
    local numericStandingId = tonumber(standingId)

    if not numericStandingId then
        return ""
    end

    return tostring(_G["FACTION_STANDING_LABEL" .. numericStandingId] or "")
end

local function formatGuildRosterOfflineText(years, months, days, hours, isOnline)
    if isOnline then
        return "Online"
    end

    local parts = {}
    local yearCount = tonumber(years) or 0
    local monthCount = tonumber(months) or 0
    local dayCount = tonumber(days) or 0
    local hourCount = tonumber(hours) or 0

    if yearCount > 0 then
        parts[#parts + 1] = yearCount == 1 and "1 year" or string.format("%d years", yearCount)
    end

    if monthCount > 0 then
        parts[#parts + 1] = monthCount == 1 and "1 month" or string.format("%d months", monthCount)
    end

    if dayCount > 0 then
        parts[#parts + 1] = dayCount == 1 and "1 day" or string.format("%d days", dayCount)
    end

    if hourCount > 0 then
        parts[#parts + 1] = hourCount == 1 and "1 hour" or string.format("%d hours", hourCount)
    end

    if #parts <= 0 then
        return "Offline"
    end

    return table.concat(parts, " ")
end

local function canAccessRecruiterSync()
    if not GetGuildInfo then
        return false
    end

    local _, _, rankIndex = GetGuildInfo("player")
    return tonumber(rankIndex) == 0 or canEditOfficerNote()
end

local function canExportRawGuildRoster()
    if not GetGuildInfo then
        return false
    end

    local _, _, rankIndex = GetGuildInfo("player")
    return tonumber(rankIndex) == 0 or canViewOfficerNote() or canEditOfficerNote()
end

local function formatRecruiterJoinedDate(joinedAt)
    local timestamp = tonumber(joinedAt) or time()

    if timestamp <= 0 then
        timestamp = time()
    end

    return date("%m/%d/%y", timestamp)
end

local function buildRecruiterJoinedOfficerNoteLine(memberId, joinedAt)
    local shortName = addon.Store:SplitFullName(memberId, GetRealmName() or "Unknown")
    return string.format("%s Joined: %s", shortName, formatRecruiterJoinedDate(joinedAt))
end

local function buildRecruiterJoinedOfficerNote(existingNote, memberId, joinedAt)
    local nextLines = {}
    local shortName = addon.Store:SplitFullName(memberId, GetRealmName() or "Unknown")
    local escapedName = escapePatternText(shortName)
    local joinLinePattern = "^" .. escapedName .. "%s+[Jj]oined:%s+%d%d?/%d%d?/%d%d%d?%d?%s*$"
    local stampedLine = buildRecruiterJoinedOfficerNoteLine(memberId, joinedAt)

    for line in tostring(existingNote or ""):gmatch("([^\r\n]+)") do
        local trimmedLine = trimText(line)

        if trimmedLine ~= "" and not trimmedLine:match(joinLinePattern) then
            nextLines[#nextLines + 1] = trimmedLine
        end
    end

    nextLines[#nextLines + 1] = stampedLine
    return table.concat(nextLines, "\n")
end

local function findGuildRosterMemberIndex(memberId)
    local normalizedMemberId = normalizeInviteName(memberId)
    local count = GetNumGuildMembers and tonumber(GetNumGuildMembers()) or 0

    if normalizedMemberId == "" or not GetGuildRosterInfo or count <= 0 then
        return nil, nil
    end

    for index = 1, math.max(0, math.floor(count)) do
        local fullName, _, _, _, _, _, _, officerNote = GetGuildRosterInfo(index)

        if tostring(fullName or "") ~= ""
            and addon.Store:NormalizeFullName(fullName, GetRealmName() or "Unknown") == normalizedMemberId then
            return index, tostring(officerNote or "")
        end
    end

    return nil, nil
end

local function ensureRecruiterJoinedOfficerNote(memberId, joinedAt)
    if not canEditOfficerNote() then
        return false, false, "you do not have permission to edit officer notes"
    end

    if not GuildRosterSetOfficerNote then
        return false, false, "guild officer note API is unavailable on this client"
    end

    local rosterIndex, officerNote = findGuildRosterMemberIndex(memberId)

    if not rosterIndex then
        return false, false, "member is not available in the live guild roster yet"
    end

    local nextOfficerNote = buildRecruiterJoinedOfficerNote(officerNote, memberId, joinedAt)

    if trimText(officerNote) == trimText(nextOfficerNote) then
        return true, false, nil
    end

    local ok, updateError = pcall(GuildRosterSetOfficerNote, rosterIndex, nextOfficerNote)

    if not ok then
        return false, false, tostring(updateError or "unable to update the officer note")
    end

    requestGuildRosterSnapshot()
    return true, true, nil
end

local function sendRecruiterAddonMessage(prefix, payload, channel)
    if C_ChatInfo and C_ChatInfo.SendAddonMessage then
        local ok, result = pcall(C_ChatInfo.SendAddonMessage, prefix, payload, channel)

        if ok then
            return result
        end

        local parameterSets = {
            { prefix = prefix, message = payload, chatType = channel },
            { prefix = prefix, text = payload, chatType = channel },
            { prefix = prefix, message = payload, distribution = channel },
            { prefix = prefix, text = payload, distribution = channel },
        }

        for _, params in ipairs(parameterSets) do
            local tableOk, tableResult = pcall(C_ChatInfo.SendAddonMessage, params)

            if tableOk then
                return tableResult
            end
        end

        return nil
    end

    if SendAddonMessage then
        return SendAddonMessage(prefix, payload, channel)
    end

    return nil
end

local function ensureInviteState()
    local profile = addon.Store:GetProfile()
    profile.recruiter = type(profile.recruiter) == "table" and profile.recruiter or {}

    local recruiter = profile.recruiter
    recruiter.pending = type(recruiter.pending) == "table" and recruiter.pending or {}
    recruiter.blacklist = type(recruiter.blacklist) == "table" and recruiter.blacklist or {}
    recruiter.recentContacts = type(recruiter.recentContacts) == "table" and recruiter.recentContacts or {}
    recruiter.settings = type(recruiter.settings) == "table" and recruiter.settings or {}

    local settings = recruiter.settings
    settings.lowLevel = normalizeInviteLevel(settings.lowLevel, DEFAULT_INVITE_MIN_LEVEL)
    settings.highLevel = normalizeInviteLevel(settings.highLevel, getInviteMaxLevel())

    if settings.highLevel < settings.lowLevel then
        settings.highLevel = settings.lowLevel
    end

    settings.batchSize = normalizeInviteBatchSize(settings.batchSize)
    settings.repeatInviteCooldownDays = normalizeDayThreshold(settings.repeatInviteCooldownDays, INVITE_REPEAT_COOLDOWN_DAYS, 0, 365)
    settings.currentZoneOnly = settings.currentZoneOnly and true or false
    settings.whisperOnly = settings.whisperOnly and true or false
    settings.usePrimaryWhisper = settings.usePrimaryWhisper and true or false
    settings.announceJoins = settings.announceJoins and true or false
    settings.blacklistLeavers = settings.blacklistLeavers and true or false
    settings.syncBlacklist = settings.syncBlacklist and true or false
    settings.syncInviteHistory = settings.syncInviteHistory and true or false
    settings.primaryMessage = tostring(settings.primaryMessage or "")
    settings.welcomeMessage = tostring(settings.welcomeMessage or "")
    settings.querySuffix = tostring(settings.querySuffix or "")

    return recruiter, settings
end

local function ensureInviteRuntimeState()
    addon.MaintenanceService._inviteRuntime = type(addon.MaintenanceService._inviteRuntime) == "table" and addon.MaintenanceService._inviteRuntime or {}

    local runtime = addon.MaintenanceService._inviteRuntime
    runtime.batchAwaitingResponseAt = tonumber(runtime.batchAwaitingResponseAt) or 0
    runtime.batchCurrentName = tostring(runtime.batchCurrentName or "")
    runtime.batchCurrentWhisper = tostring(runtime.batchCurrentWhisper or "")
    runtime.batchErrors = type(runtime.batchErrors) == "table" and runtime.batchErrors or {}
    runtime.batchIndex = tonumber(runtime.batchIndex) or 0
    runtime.batchInvitedNames = type(runtime.batchInvitedNames) == "table" and runtime.batchInvitedNames or {}
    runtime.batchQueue = type(runtime.batchQueue) == "table" and runtime.batchQueue or {}
    runtime.pendingInviteWhispers = type(runtime.pendingInviteWhispers) == "table" and runtime.pendingInviteWhispers or {}
    runtime.pendingWhoParseGeneration = tonumber(runtime.pendingWhoParseGeneration) or 0
    runtime.nextScanAllowedAt = tonumber(runtime.nextScanAllowedAt) or 0
    runtime.lastBlockedActionAt = tonumber(runtime.lastBlockedActionAt) or 0
    runtime.lastBlockedActionMessage = tostring(runtime.lastBlockedActionMessage or "")
    runtime.lastPassiveRosterSweepAt = tonumber(runtime.lastPassiveRosterSweepAt) or 0
    runtime.results = type(runtime.results) == "table" and runtime.results or {}
    runtime.selected = type(runtime.selected) == "table" and runtime.selected or {}
    runtime.pendingInvites = type(runtime.pendingInvites) == "table" and runtime.pendingInvites or {}
    runtime.query = tostring(runtime.query or "")
    runtime.scanDeadlineAt = tonumber(runtime.scanDeadlineAt) or 0
    runtime.status = tostring(runtime.status or "idle")
    runtime.lastMessage = tostring(runtime.lastMessage or "")
    runtime.startedAt = tonumber(runtime.startedAt) or 0
    runtime.updatedAt = tonumber(runtime.updatedAt) or 0
    runtime.totalResults = tonumber(runtime.totalResults) or 0
    runtime.batchGeneration = tonumber(runtime.batchGeneration) or 0
    runtime.batchInviteInProgress = runtime.batchInviteInProgress and true or false
    runtime.queryGeneration = tonumber(runtime.queryGeneration) or 0
    runtime.hadFriendsFrameOpen = runtime.hadFriendsFrameOpen and true or false
    runtime.hadWhoFrameOpen = runtime.hadWhoFrameOpen and true or false
    runtime.suppressedFriendsWhoUpdate = runtime.suppressedFriendsWhoUpdate and true or false
    return runtime
end

local function getMaintenanceClockSeconds()
    if type(GetTime) == "function" then
        return tonumber(GetTime()) or 0
    end

    return tonumber(time()) or 0
end

local function getInviteWatchTime()
    if GetTime then
        local ok, value = pcall(GetTime)

        if ok and tonumber(value) then
            return tonumber(value)
        end
    end

    return tonumber(time()) or 0
end

local finalizeInviteScanTimeout
local refreshInviteView

local function getInviteScanCooldownRemainingSeconds(runtime)
    local nextScanAllowedAt = type(runtime) == "table" and tonumber(runtime.nextScanAllowedAt) or 0

    if not nextScanAllowedAt or nextScanAllowedAt <= 0 then
        return 0
    end

    return math.max(0, math.ceil(nextScanAllowedAt - getInviteWatchTime()))
end

local function scheduleInviteScanCooldownRefresh(runtime)
    if not (C_Timer and C_Timer.After) or type(runtime) ~= "table" then
        return
    end

    local remainingSeconds = getInviteScanCooldownRemainingSeconds(runtime)
    local expectedReadyAt = tonumber(runtime.nextScanAllowedAt) or 0

    if remainingSeconds <= 0 or expectedReadyAt <= 0 then
        return
    end

    C_Timer.After(remainingSeconds + 0.05, function()
        local liveRuntime = ensureInviteRuntimeState()

        if tonumber(liveRuntime.nextScanAllowedAt) ~= expectedReadyAt then
            return
        end

        refreshInviteView()
    end)
end

local function isFrameVisible(frame)
    return frame and type(frame.IsShown) == "function" and frame:IsShown() and true or false
end

local function setWhoToUi(enabled)
    if not (C_FriendList and C_FriendList.SetWhoToUi) then
        return false
    end

    return pcall(C_FriendList.SetWhoToUi, enabled and true or false)
end

local function prepareInviteWhoQueryUIState(runtime)
    runtime.hadFriendsFrameOpen = isFrameVisible(FriendsFrame)
    runtime.hadWhoFrameOpen = isFrameVisible(WhoFrame)
    runtime.suppressedFriendsWhoUpdate = false

    if not runtime.hadFriendsFrameOpen and FriendsFrame and type(FriendsFrame.IsEventRegistered) == "function" and FriendsFrame:IsEventRegistered("WHO_LIST_UPDATE") then
        FriendsFrame:UnregisterEvent("WHO_LIST_UPDATE")
        runtime.suppressedFriendsWhoUpdate = true
    end

    setWhoToUi(true)
end

local function restoreInviteWhoQueryUIState(runtime)
    if runtime.suppressedFriendsWhoUpdate and FriendsFrame and type(FriendsFrame.RegisterEvent) == "function" then
        FriendsFrame:RegisterEvent("WHO_LIST_UPDATE")
    end

    setWhoToUi(false)
    runtime.suppressedFriendsWhoUpdate = false
end

refreshInviteView = function()
    if not addon.MainFrame then
        return
    end

    if addon.MainFrame.MarkBackgroundRefreshPending then
        addon.MainFrame:MarkBackgroundRefreshPending()
    end

    if addon.MaintenanceService._inviteRefreshQueued or not (C_Timer and C_Timer.After) then
        return
    end

    addon.MaintenanceService._inviteRefreshQueued = true
    C_Timer.After(0, function()
        addon.MaintenanceService._inviteRefreshQueued = false

        if addon.MainFrame
            and addon.MainFrame.frame
            and addon.MainFrame.frame:IsShown()
            and addon.Router
            and addon.Router.GetCurrentView
            and addon.Router:GetCurrentView() == "maintenance"
            and not (InCombatLockdown and InCombatLockdown()) then
            addon.MainFrame:RefreshCurrentView(true)
        end
    end)
end

local function countInviteBatchEntries(entries)
    local count = 0

    if type(entries) ~= "table" then
        return 0
    end

    for _ in pairs(entries) do
        count = count + 1
    end

    return count
end

local function clearInviteBatchState(runtime)
    if type(runtime) ~= "table" then
        return
    end

    runtime.batchInviteInProgress = false
    runtime.batchAwaitingResponseAt = 0
    runtime.batchCurrentName = ""
    runtime.batchCurrentWhisper = ""
    runtime.batchErrors = {}
    runtime.batchIndex = 0
    runtime.batchInvitedNames = {}
    runtime.batchQueue = {}
end

local function clearInviteBatchCandidate(runtime, candidateName)
    if type(runtime) ~= "table" then
        return nil
    end

    local normalizedName = normalizeInviteName(candidateName)

    if normalizedName == "" then
        return nil
    end

    local batchRecord = runtime.batchQueue[normalizedName]
    runtime.batchQueue[normalizedName] = nil

    if runtime.batchCurrentName == normalizedName then
        runtime.batchCurrentName = ""
        runtime.batchCurrentWhisper = ""
    end

    return batchRecord
end

local function finishInviteBatchIfComplete(runtime)
    if type(runtime) ~= "table" or not runtime.batchInviteInProgress then
        return false
    end

    if countInviteBatchEntries(runtime.batchQueue) > 0 then
        return false
    end

    clearInviteBatchState(runtime)
    return true
end

local function scheduleInviteBatchResultCheck(runtime)
    if not (C_Timer and C_Timer.After) or type(runtime) ~= "table" or not runtime.batchInviteInProgress then
        return
    end

    local expectedGeneration = tonumber(runtime.batchGeneration) or 0

    if expectedGeneration <= 0 then
        return
    end

    C_Timer.After(INVITE_BATCH_RESULT_GRACE_SECONDS, function()
        local service = addon.MaintenanceService
        local liveRuntime = ensureInviteRuntimeState()

        if tonumber(liveRuntime.batchGeneration) ~= expectedGeneration or not liveRuntime.batchInviteInProgress then
            return
        end

        local confirmedCount = countInviteBatchEntries(liveRuntime.batchInvitedNames)
        local remainingCount = countInviteBatchEntries(liveRuntime.batchQueue)
        local remainingNames = {}

        for candidateName, batchRecord in pairs(liveRuntime.batchQueue) do
            remainingNames[#remainingNames + 1] = {
                candidateName = candidateName,
                note = tostring(type(batchRecord) == "table" and batchRecord.note or ""),
            }
        end

        table.sort(remainingNames, function(left, right)
            return tostring(left and left.candidateName or "") < tostring(right and right.candidateName or "")
        end)

        for _, entry in ipairs(remainingNames) do
            if service and service.SkipInviteCandidate then
                service:SkipInviteCandidate(entry.candidateName)
            else
                liveRuntime.selected[entry.candidateName] = nil
            end
        end

        if remainingCount > 0 then
            liveRuntime.lastMessage = confirmedCount > 0
                and string.format("%d recruiter invite(s) were confirmed. %d additional invite(s) are still being tracked without a confirmation message.", confirmedCount, remainingCount)
                or string.format("%d recruiter invite(s) are still being tracked without a confirmation message.", remainingCount)
        elseif confirmedCount > 0 then
            liveRuntime.lastMessage = string.format("%d recruiter invite(s) were confirmed. Awaiting responses.", confirmedCount)
        end

        clearInviteBatchState(liveRuntime)
        liveRuntime.updatedAt = time()
        refreshInviteView()
    end)
end

local function buildInviteBatchCommand(candidateName)
    local normalizedName = normalizeInviteName(candidateName)

    if normalizedName == "" then
        return ""
    end

    return "/ginvite " .. normalizedName
end

local function ensureInviteSession(runtime)
    runtime.session = type(runtime.session) == "table" and runtime.session or {}

    local session = runtime.session
    session.queries = type(session.queries) == "table" and session.queries or {}
    session.queryKeys = type(session.queryKeys) == "table" and session.queryKeys or {}
    session.uniqueCandidates = type(session.uniqueCandidates) == "table" and session.uniqueCandidates or {}
    session.cooldownFilteredNames = type(session.cooldownFilteredNames) == "table" and session.cooldownFilteredNames or {}
    session.blockedNames = type(session.blockedNames) == "table" and session.blockedNames or {}
    session.invitedNames = type(session.invitedNames) == "table" and session.invitedNames or {}
    session.acceptedNames = type(session.acceptedNames) == "table" and session.acceptedNames or {}
    session.declinedNames = type(session.declinedNames) == "table" and session.declinedNames or {}
    session.active = session.active and true or false
    session.completed = session.completed and true or false
    session.queryIndex = tonumber(session.queryIndex) or 0
    session.pendingQueryIndex = tonumber(session.pendingQueryIndex) or 0
    session.startedAt = tonumber(session.startedAt) or 0
    session.completedAt = tonumber(session.completedAt) or 0
    session.lastQueryLabel = tostring(session.lastQueryLabel or "")
    session.foundCount = tonumber(session.foundCount) or 0
    session.cooldownFilteredCount = tonumber(session.cooldownFilteredCount) or 0
    session.blockedCount = tonumber(session.blockedCount) or 0
    session.invitedCount = tonumber(session.invitedCount) or 0
    session.acceptedCount = tonumber(session.acceptedCount) or 0
    session.declinedCount = tonumber(session.declinedCount) or 0
    session.timeoutCount = tonumber(session.timeoutCount) or 0
    return session
end

local function shouldProcessRecruiterRosterUpdate(invites, settings, runtime, session)
    if next((invites and invites.pending) or {}) ~= nil then
        return true, true
    end

    if next((runtime and runtime.pendingInvites) or {}) ~= nil then
        return true, true
    end

    if countInviteBatchEntries(runtime and runtime.batchQueue) > 0 then
        return true, true
    end

    if type(session) == "table" and session.active then
        return true, true
    end

    local hasRecentContacts = next((invites and invites.recentContacts) or {}) ~= nil

    if not hasRecentContacts then
        return false, false
    end

    if settings and settings.blacklistLeavers then
        return true, false
    end

    if trimText(settings and settings.welcomeMessage) ~= "" then
        return true, false
    end

    if settings and settings.announceJoins then
        return true, false
    end

    if canEditOfficerNote() then
        return true, false
    end

    return false, false
end

local function resetInviteSession(runtime)
    runtime.session = {
        acceptedCount = 0,
        acceptedNames = {},
        active = false,
        blockedCount = 0,
        blockedNames = {},
        completed = false,
        completedAt = 0,
        cooldownFilteredCount = 0,
        cooldownFilteredNames = {},
        declinedCount = 0,
        declinedNames = {},
        foundCount = 0,
        invitedCount = 0,
        invitedNames = {},
        lastQueryLabel = "",
        pendingQueryIndex = 0,
        queries = {},
        queryKeys = {},
        queryIndex = 0,
        startedAt = 0,
        timeoutCount = 0,
        uniqueCandidates = {},
    }

    return runtime.session
end

local function trimInviteFilterToken(value)
    return tostring(value or ""):match("^%s*(.-)%s*$") or ""
end

local function collectInviteFilterQueries(rawValue)
    local filters = {}
    local seen = {}

    for token in string.gmatch(tostring(rawValue or ""), "[^\r\n;]+") do
        local normalizedToken = trimInviteFilterToken(token)

        if normalizedToken ~= "" and not seen[normalizedToken] then
            seen[normalizedToken] = true
            filters[#filters + 1] = normalizedToken
        end
    end

    return filters
end

local DEFAULT_INVITE_CLASS_NAMES = {
    "Warrior",
    "Paladin",
    "Hunter",
    "Rogue",
    "Priest",
    "Death Knight",
    "Shaman",
    "Mage",
    "Warlock",
    "Monk",
    "Druid",
    "Demon Hunter",
    "Evoker",
}

local DEFAULT_INVITE_RACE_NAMES = {
    "Human",
    "Dwarf",
    "Night Elf",
    "Gnome",
    "Draenei",
    "Worgen",
    "Pandaren",
    "Void Elf",
    "Lightforged Draenei",
    "Dark Iron Dwarf",
    "Kul Tiran",
    "Mechagnome",
    "Orc",
    "Undead",
    "Tauren",
    "Troll",
    "Blood Elf",
    "Goblin",
    "Nightborne",
    "Highmountain Tauren",
    "Mag'har Orc",
    "Zandalari Troll",
    "Vulpera",
    "Dracthyr",
    "Earthen",
}

local cachedInviteClassNames
local cachedInviteRaceNames

local function getInviteCurrentZoneName()
    return tostring(GetRealZoneText and GetRealZoneText() or GetZoneText and GetZoneText() or "")
end

local function getInviteQueryTokenValue(filterToken, tokenKey)
    local safeKey = tostring(tokenKey or ""):gsub("(%W)", "%%%1")
    return tostring(filterToken or ""):match(safeKey .. '%-%"([^"]+)%"') or ""
end

local function getInviteQueryRaceName(queryRecord)
    local raceName = trimInviteFilterToken(type(queryRecord) == "table" and queryRecord.raceName or "")

    if raceName ~= "" then
        return raceName
    end

    return getInviteQueryTokenValue(type(queryRecord) == "table" and queryRecord.filterToken or "", "r")
end

local function getInviteQueryClassName(queryRecord)
    local className = trimInviteFilterToken(type(queryRecord) == "table" and queryRecord.className or "")

    if className ~= "" then
        return className
    end

    return getInviteQueryTokenValue(type(queryRecord) == "table" and queryRecord.filterToken or "", "c")
end

local function getInviteQueryZoneName(queryRecord)
    local zoneName = trimInviteFilterToken(type(queryRecord) == "table" and queryRecord.zoneName or "")

    if zoneName ~= "" then
        return zoneName
    end

    return getInviteQueryTokenValue(type(queryRecord) == "table" and queryRecord.filterToken or "", "z")
end

local function getInviteClassNames()
    if cachedInviteClassNames then
        return cachedInviteClassNames
    end

    local classNames = {}
    local seen = {}
    local classCount = tonumber(GetNumClasses and GetNumClasses() or 0) or 0

    if GetClassInfo then
        for classID = 1, classCount do
            local className = select(1, GetClassInfo(classID))

            if type(className) == "string" and className ~= "" and not seen[className] then
                seen[className] = true
                classNames[#classNames + 1] = className
            end
        end
    end

    if #classNames <= 0 then
        for _, className in ipairs(DEFAULT_INVITE_CLASS_NAMES) do
            if not seen[className] then
                seen[className] = true
                classNames[#classNames + 1] = className
            end
        end
    end

    cachedInviteClassNames = classNames
    return cachedInviteClassNames
end

local function getInviteRaceNames()
    if cachedInviteRaceNames then
        return cachedInviteRaceNames
    end

    local raceNames = {}
    local seen = {}

    if C_CreatureInfo and C_CreatureInfo.GetRaceInfo then
        for raceID = 1, 80 do
            local ok, raceInfo = pcall(C_CreatureInfo.GetRaceInfo, raceID)
            local raceName = ok and raceInfo and raceInfo.raceName or nil

            if type(raceName) == "string" and raceName ~= "" and not seen[raceName] then
                seen[raceName] = true
                raceNames[#raceNames + 1] = raceName
            end
        end
    end

    if #raceNames <= 0 then
        for _, raceName in ipairs(DEFAULT_INVITE_RACE_NAMES) do
            if not seen[raceName] then
                seen[raceName] = true
                raceNames[#raceNames + 1] = raceName
            end
        end
    end

    cachedInviteRaceNames = raceNames
    return cachedInviteRaceNames
end

local function buildInviteWhoQueryParts(settings, filterToken, queryRecord)
    local minLevel = normalizeInviteLevel(type(queryRecord) == "table" and queryRecord.minLevel or settings.lowLevel, settings.lowLevel)
    local maxLevel = normalizeInviteLevel(type(queryRecord) == "table" and queryRecord.maxLevel or settings.highLevel, settings.highLevel)
    local queryParts = {
        string.format("%d-%d", minLevel, math.max(minLevel, maxLevel)),
    }

    local zoneName = getInviteQueryZoneName(queryRecord)

    if zoneName ~= "" then
        queryParts[#queryParts + 1] = string.format('z-"%s"', zoneName)
    elseif settings.currentZoneOnly then
        zoneName = getInviteCurrentZoneName()

        if zoneName ~= "" then
            queryParts[#queryParts + 1] = string.format('z-"%s"', zoneName)
        end
    end

    local raceName = getInviteQueryRaceName(queryRecord)

    if raceName ~= "" then
        queryParts[#queryParts + 1] = string.format('r-"%s"', raceName)
    end

    local className = getInviteQueryClassName(queryRecord)

    if className ~= "" then
        queryParts[#queryParts + 1] = string.format('c-"%s"', className)
    end

    local normalizedToken = trimInviteFilterToken(filterToken)

    if normalizedToken ~= "" then
        queryParts[#queryParts + 1] = normalizedToken
    end

    return queryParts
end

local function buildInviteQueryLabel(queryRecord)
    local labelParts = {
        string.format("L%d-%d", tonumber(queryRecord.minLevel) or 0, tonumber(queryRecord.maxLevel) or 0),
    }

    local raceName = getInviteQueryRaceName(queryRecord)

    if raceName ~= "" then
        labelParts[#labelParts + 1] = raceName
    end

    local className = getInviteQueryClassName(queryRecord)

    if className ~= "" then
        labelParts[#labelParts + 1] = className
    end

    local zoneName = getInviteQueryZoneName(queryRecord)

    if zoneName ~= "" then
        labelParts[#labelParts + 1] = zoneName
    end

    if trimInviteFilterToken(queryRecord.filterLabel) ~= "" then
        labelParts[#labelParts + 1] = queryRecord.filterLabel
    end

    return table.concat(labelParts, " | ")
end

local function createInviteQueryRecord(settings, payload)
    local record = {
        className = trimInviteFilterToken(type(payload) == "table" and payload.className or ""),
        filterLabel = trimInviteFilterToken(type(payload) == "table" and payload.filterLabel or ""),
        filterToken = trimInviteFilterToken(type(payload) == "table" and payload.filterToken or ""),
        maxLevel = normalizeInviteLevel(type(payload) == "table" and payload.maxLevel or settings.highLevel, settings.highLevel),
        minLevel = normalizeInviteLevel(type(payload) == "table" and payload.minLevel or settings.lowLevel, settings.lowLevel),
        raceName = trimInviteFilterToken(type(payload) == "table" and payload.raceName or ""),
        zoneName = trimInviteFilterToken(type(payload) == "table" and payload.zoneName or ""),
    }

    if record.maxLevel < record.minLevel then
        record.maxLevel = record.minLevel
    end

    if getInviteQueryTokenValue(record.filterToken, "z") ~= "" then
        record.zoneName = ""
    end

    record.query = table.concat(buildInviteWhoQueryParts(settings, record.filterToken, record), " ")
    record.label = buildInviteQueryLabel(record)
    return record
end

local function appendInviteQueryRecord(queryList, queryKeys, settings, payload, insertIndex)
    local record = createInviteQueryRecord(settings, payload)
    local queryKey = tostring(record.query or "")

    if queryKey == "" or queryKeys[queryKey] then
        return false
    end

    queryKeys[queryKey] = true

    if tonumber(insertIndex) then
        table.insert(queryList, insertIndex, record)
    else
        queryList[#queryList + 1] = record
    end

    return true
end

local function buildInviteQueryPlan(settings)
    local filterQueries = collectInviteFilterQueries(settings.querySuffix)
    local queries = {}
    local queryKeys = {}
    local zoneName = settings.currentZoneOnly and getInviteCurrentZoneName() or ""

    if #filterQueries <= 0 then
        filterQueries[1] = ""
    end

    for index, filterToken in ipairs(filterQueries) do
        appendInviteQueryRecord(queries, queryKeys, settings, {
            filterLabel = #filterQueries > 1 and string.format("Filter %d/%d", index, #filterQueries) or "",
            filterToken = filterToken,
            maxLevel = settings.highLevel,
            minLevel = settings.lowLevel,
            zoneName = zoneName,
        })
    end

    return queries, queryKeys
end

local function buildInviteRefinedQueries(queryRecord)
    local refinedQueries = {}
    local minLevel = tonumber(queryRecord and queryRecord.minLevel) or 0
    local maxLevel = tonumber(queryRecord and queryRecord.maxLevel) or minLevel
    local raceName = getInviteQueryRaceName(queryRecord)
    local className = getInviteQueryClassName(queryRecord)
    local sharedPayload = {
        filterLabel = type(queryRecord) == "table" and queryRecord.filterLabel or "",
        filterToken = type(queryRecord) == "table" and queryRecord.filterToken or "",
        zoneName = type(queryRecord) == "table" and queryRecord.zoneName or "",
    }

    if maxLevel > minLevel then
        local difference = maxLevel - minLevel
        local lowerMax = maxLevel - math.floor(difference / 2) - 1
        local upperMin = minLevel + math.ceil(difference / 2)

        refinedQueries[1] = {
            className = type(queryRecord) == "table" and queryRecord.className or "",
            filterLabel = sharedPayload.filterLabel,
            filterToken = sharedPayload.filterToken,
            maxLevel = lowerMax,
            minLevel = minLevel,
            raceName = type(queryRecord) == "table" and queryRecord.raceName or "",
            zoneName = sharedPayload.zoneName,
        }
        refinedQueries[2] = {
            className = type(queryRecord) == "table" and queryRecord.className or "",
            filterLabel = sharedPayload.filterLabel,
            filterToken = sharedPayload.filterToken,
            maxLevel = maxLevel,
            minLevel = upperMin,
            raceName = type(queryRecord) == "table" and queryRecord.raceName or "",
            zoneName = sharedPayload.zoneName,
        }
        return refinedQueries
    end

    if raceName == "" then
        for _, candidateRaceName in ipairs(getInviteRaceNames()) do
            refinedQueries[#refinedQueries + 1] = {
                className = type(queryRecord) == "table" and queryRecord.className or "",
                filterLabel = sharedPayload.filterLabel,
                filterToken = sharedPayload.filterToken,
                maxLevel = maxLevel,
                minLevel = minLevel,
                raceName = candidateRaceName,
                zoneName = sharedPayload.zoneName,
            }
        end

        return refinedQueries
    end

    if className == "" then
        for _, candidateClassName in ipairs(getInviteClassNames()) do
            refinedQueries[#refinedQueries + 1] = {
                className = candidateClassName,
                filterLabel = sharedPayload.filterLabel,
                filterToken = sharedPayload.filterToken,
                maxLevel = maxLevel,
                minLevel = minLevel,
                raceName = type(queryRecord) == "table" and queryRecord.raceName or "",
                zoneName = sharedPayload.zoneName,
            }
        end
    end

    return refinedQueries
end

local function expandInviteQueryPlan(session, settings, queryIndex, queryRecord, totalResults)
    if type(session) ~= "table" or type(queryRecord) ~= "table" or (tonumber(totalResults) or 0) < INVITE_WHO_RESULT_LIMIT then
        return 0
    end

    local refinedQueries = buildInviteRefinedQueries(queryRecord)
    local insertedCount = 0
    local insertIndex = (tonumber(queryIndex) or 0) + 1

    for _, payload in ipairs(refinedQueries) do
        if appendInviteQueryRecord(session.queries, session.queryKeys, settings, payload, insertIndex) then
            insertedCount = insertedCount + 1
            insertIndex = insertIndex + 1
        end
    end

    return insertedCount
end

local function escapeMessagePatternText(value)
    return tostring(value or ""):gsub("([%(%)%.%%%+%-%*%?%[%]%^%$])", "%%%1")
end

local function buildMessageCapturePattern(templateText)
    local value = tostring(templateText or "")
    local patternParts = { "^" }
    local cursor = 1

    while cursor <= #value do
        local orderedStart, orderedEnd = value:find("%%(%d+)%$s", cursor)
        local simpleStart, simpleEnd = value:find("%%s", cursor)
        local matchStart = nil
        local matchEnd = nil

        if orderedStart and simpleStart then
            if orderedStart < simpleStart then
                matchStart = orderedStart
                matchEnd = orderedEnd
            else
                matchStart = simpleStart
                matchEnd = simpleEnd
            end
        elseif orderedStart then
            matchStart = orderedStart
            matchEnd = orderedEnd
        elseif simpleStart then
            matchStart = simpleStart
            matchEnd = simpleEnd
        end

        if not matchStart then
            patternParts[#patternParts + 1] = escapeMessagePatternText(value:sub(cursor))
            break
        end

        if matchStart > cursor then
            patternParts[#patternParts + 1] = escapeMessagePatternText(value:sub(cursor, matchStart - 1))
        end

        patternParts[#patternParts + 1] = "(.+)"
        cursor = matchEnd + 1
    end

    patternParts[#patternParts + 1] = "$"
    return table.concat(patternParts)
end

local function matchMessageCaptures(message, template, fallbackPatterns)
    local value = tostring(message or "")

    if type(template) == "string" and template ~= "" then
        local captures = { string.match(value, buildMessageCapturePattern(template)) }

        if captures[1] ~= nil then
            return captures
        end
    end

    for _, fallbackPattern in ipairs(fallbackPatterns or {}) do
        local captures = { string.match(value, fallbackPattern) }

        if captures[1] ~= nil then
            return captures
        end
    end

    return nil
end

local function matchRecruiterMessageName(message, template, fallbackPattern)
    local captures = matchMessageCaptures(message, template, fallbackPattern and { fallbackPattern } or nil)

    if captures and captures[1] and captures[1] ~= "" then
        return captures[1]
    end

    return ""
end

local function extractRecruiterInviteEvent(message)
    local inviteName = matchRecruiterMessageName(message, ERR_GUILD_INVITE_S, "^You have invited (.+) to join your guild%.$")

    if inviteName ~= "" then
        return "invite", normalizeInviteName(inviteName)
    end

    local declineName = matchRecruiterMessageName(message, ERR_GUILD_DECLINE_S, "^(.-) declines your guild invitation%.?$")

    if declineName ~= "" then
        return "decline", normalizeInviteName(declineName)
    end

    local autoDeclineName = matchRecruiterMessageName(message, ERR_GUILD_DECLINE_AUTO_S, "^(.-) has already been invited to a guild%.$")

    if autoDeclineName ~= "" then
        return "auto-decline", normalizeInviteName(autoDeclineName)
    end

    local notFoundName = matchRecruiterMessageName(message, ERR_GUILD_PLAYER_NOT_FOUND_S, "^No player named '(.+)' is currently playing%.$")

    if notFoundName == "" then
        notFoundName = matchRecruiterMessageName(message, ERR_CHAT_PLAYER_NOT_FOUND_S, "^No player named '(.+)' is currently playing%.$")
    end

    if notFoundName ~= "" then
        return "not-found", normalizeInviteName(notFoundName)
    end

    return "", ""
end

local function extractAuditSystemEvent(message)
    local captures = matchMessageCaptures(message, ERR_GUILD_JOIN_S, {
        "^(.-) joins the guild%.?$",
    })

    if captures and captures[1] then
        return {
            detail = "Joined the guild.",
            eventType = "join",
            memberId = normalizeAuditName(captures[1]),
        }
    end

    captures = matchMessageCaptures(message, ERR_GUILD_LEAVE_S, {
        "^(.-) leaves the guild%.?$",
    })

    if captures and captures[1] then
        return {
            detail = "Left the guild.",
            eventType = "leave",
            memberId = normalizeAuditName(captures[1]),
        }
    end

    captures = matchMessageCaptures(message, ERR_GUILD_REMOVE_SS, {
        "^(.-) has been kicked out of the guild by (.-)%.?$",
    })

    if captures and captures[1] then
        local actorName = trimText(captures[2])

        return {
            actorId = normalizeAuditName(actorName),
            actorName = actorName,
            detail = actorName ~= "" and string.format("Removed by %s.", actorName) or "Removed from the guild.",
            eventType = "kick",
            memberId = normalizeAuditName(captures[1]),
        }
    end

    return nil
end

local function scheduleInviteScanTimeout(runtime, queryIndex, queryLabel)
    if not (C_Timer and C_Timer.After) then
        return
    end

    local queryGeneration = runtime.queryGeneration

    C_Timer.After(INVITE_SCAN_TIMEOUT_SECONDS, function()
        local liveRuntime = ensureInviteRuntimeState()
        local session = ensureInviteSession(liveRuntime)

        if liveRuntime.queryGeneration ~= queryGeneration or liveRuntime.status ~= "scanning" or session.pendingQueryIndex ~= queryIndex or liveRuntime.pendingWhoParseGeneration == queryGeneration then
            return
        end

        if finalizeInviteScanTimeout(liveRuntime, session, queryIndex, queryLabel) then
            refreshInviteView()
        end
    end)
end

finalizeInviteScanTimeout = function(runtime, session, queryIndex, queryLabel)
    if type(runtime) ~= "table" or type(session) ~= "table" then
        return false
    end

    restoreInviteWhoQueryUIState(runtime)

    session.queryIndex = math.max(tonumber(session.queryIndex) or 0, tonumber(queryIndex) or tonumber(session.pendingQueryIndex) or 0)
    session.pendingQueryIndex = 0
    session.timeoutCount = (tonumber(session.timeoutCount) or 0) + 1
    session.completed = session.queryIndex >= #session.queries
    session.completedAt = session.completed and time() or 0

    runtime.pendingWhoParseGeneration = 0
    runtime.scanDeadlineAt = 0
    runtime.status = session.completed and "complete" or "ready"
    runtime.updatedAt = time()
    runtime.lastMessage = string.format(
        "%s timed out after %d seconds. %s",
        tostring(queryLabel or ("Pass " .. tostring(queryIndex or session.queryIndex or 0))),
        INVITE_SCAN_TIMEOUT_SECONDS,
        session.completed and "Recruiter session finished." or "Click Scan Next to continue."
    )
    addon:Print(runtime.lastMessage)

    return true
end

local function resolveStaleInviteScan(runtime, session)
    if type(runtime) ~= "table" or type(session) ~= "table" then
        return false
    end

    if runtime.status ~= "scanning" or not session.active or (tonumber(session.pendingQueryIndex) or 0) <= 0 then
        return false
    end

    local deadlineAt = tonumber(runtime.scanDeadlineAt) or 0

    if deadlineAt <= 0 or getInviteWatchTime() < deadlineAt then
        return false
    end

    local pendingQueryIndex = tonumber(session.pendingQueryIndex) or 0
    local queryLabel = tostring(session.lastQueryLabel or ("Pass " .. pendingQueryIndex))

    return finalizeInviteScanTimeout(runtime, session, pendingQueryIndex, queryLabel)
end

local function ensureInviteScanWatchdog()
    if addon.MaintenanceService._inviteScanWatchdogStarted or not (C_Timer and C_Timer.NewTicker) then
        return
    end

    addon.MaintenanceService._inviteScanWatchdogStarted = true
    addon.MaintenanceService._inviteScanWatchdog = C_Timer.NewTicker(1, function()
        local runtime = ensureInviteRuntimeState()
        local session = ensureInviteSession(runtime)

        if runtime.status ~= "scanning" and not session.active and (tonumber(session.pendingQueryIndex) or 0) <= 0 then
            if addon.MaintenanceService._inviteScanWatchdog then
                addon.MaintenanceService._inviteScanWatchdog:Cancel()
                addon.MaintenanceService._inviteScanWatchdog = nil
            end

            addon.MaintenanceService._inviteScanWatchdogStarted = false
            return
        end

        if resolveStaleInviteScan(runtime, session) then
            refreshInviteView()
        end
    end)
end

local function trimInviteBucket(bucket, limit, sortField)
    local entries = {}

    for key, value in pairs(bucket or {}) do
        if type(value) == "table" then
            entries[#entries + 1] = {
                key = key,
                sortValue = tonumber(value[sortField]) or 0,
            }
        end
    end

    table.sort(entries, function(left, right)
        if left.sortValue == right.sortValue then
            return tostring(left.key or "") < tostring(right.key or "")
        end

        return left.sortValue > right.sortValue
    end)

    for index = (limit or 0) + 1, #entries do
        bucket[entries[index].key] = nil
    end
end

local function formatInviteMessage(template, candidateName)
    local name = normalizeInviteName(candidateName)
    local shortName = name ~= "" and addon.Store:SplitFullName(name, GetRealmName() or "Unknown") or "there"
    local guildName = GetGuildInfo("player") or addon.Store:GetDisplayGuildKey() or addon.Store:EnsureHomeGuild() or "the guild"
    local officerName = addon.Store:GetPlayerName() or "Officer"
    local shortOfficerName = addon.Store:SplitFullName(officerName, GetRealmName() or "Unknown")
    local realmName = addon.Store:NormalizeRealmName(GetRealmName() or "Unknown") or "Unknown"
    local message = tostring(template or "")

    local replacements = {
        name = shortName,
        guild = guildName,
        guildlink = guildName,
        officer = shortOfficerName,
        realm = realmName,
    }

    for token, replacement in pairs(replacements) do
        local value = tostring(replacement or "")
        message = message:gsub("{" .. token .. "}", value)
        message = message:gsub("{" .. string.upper(token) .. "}", value)
        message = message:gsub("%[" .. token .. "%]", value)
        message = message:gsub("%[" .. string.upper(token) .. "%]", value)
    end

    return message
end

local function formatWelcomeMessage(template, candidateName)
    return formatInviteMessage(template, candidateName)
end

local function ensureMaintenanceState()
    local guild = addon.Store:GetGuild()

    guild.maintenance = type(guild.maintenance) == "table" and guild.maintenance or {}
    guild.maintenance.exclusions = type(guild.maintenance.exclusions) == "table" and guild.maintenance.exclusions or {}
    guild.maintenance.familyBlocks = type(guild.maintenance.familyBlocks) == "table" and guild.maintenance.familyBlocks or {}
    guild.maintenance.selectedMembers = type(guild.maintenance.selectedMembers) == "table" and guild.maintenance.selectedMembers or {}
    guild.maintenance.selectedPromotionMembers = type(guild.maintenance.selectedPromotionMembers) == "table" and guild.maintenance.selectedPromotionMembers or {}
    guild.maintenance.staleMembers = type(guild.maintenance.staleMembers) == "table" and guild.maintenance.staleMembers or {}
    guild.maintenance.pendingDeletes = type(guild.maintenance.pendingDeletes) == "table" and guild.maintenance.pendingDeletes or {}
    guild.maintenance.auditLog = type(guild.maintenance.auditLog) == "table" and guild.maintenance.auditLog or {}
    guild.maintenance.auditMeta = type(guild.maintenance.auditMeta) == "table" and guild.maintenance.auditMeta or {}
    guild.maintenance.auditOfficerNoteSignatures = type(guild.maintenance.auditOfficerNoteSignatures) == "table" and guild.maintenance.auditOfficerNoteSignatures or {}
    guild.maintenance.settings = type(guild.maintenance.settings) == "table" and guild.maintenance.settings or {}
    local settings = guild.maintenance.settings
    local auditMeta = guild.maintenance.auditMeta

    auditMeta.baselineGuild = tostring(auditMeta.baselineGuild or "")
    auditMeta.baselineCapturedAt = tonumber(auditMeta.baselineCapturedAt) or 0
    auditMeta.lastRosterCaptureCount = math.max(0, math.floor(tonumber(auditMeta.lastRosterCaptureCount) or 0))

    settings.staleAfterDays = normalizeDayThreshold(settings.staleAfterDays, STALE_AFTER_DAYS, 7, 365)
    settings.purgeAfterDays = normalizeDayThreshold(settings.purgeAfterDays, PURGE_AFTER_DAYS, 30, 365)
    settings.kickScope = normalizeKickScope(settings.kickScope)
    settings.kickLinkedAltsWithMain = settings.kickLinkedAltsWithMain and true or false
    settings.promoteFromRank = tostring(settings.promoteFromRank or "Initiate")
    settings.promoteToRank = tostring(settings.promoteToRank or "Member")
    settings.promoteMinJoinDays = normalizeDayThreshold(settings.promoteMinJoinDays, 0, 0, 3650)
    settings.auditEnabled = settings.auditEnabled ~= false
    settings.auditLogJoins = settings.auditLogJoins ~= false
    settings.auditLogLeaves = settings.auditLogLeaves ~= false
    settings.auditLogKicks = settings.auditLogKicks ~= false
    settings.auditLogRanks = settings.auditLogRanks ~= false
    settings.auditLogNotes = settings.auditLogNotes ~= false
    settings.auditLogOfficerNotes = settings.auditLogOfficerNotes ~= false
    settings.auditUseColors = settings.auditUseColors ~= false
    settings.auditTextColors = normalizeAuditTextColors(settings.auditTextColors)
    settings.auditColorTheme = normalizeAuditColorTheme(settings.auditColorTheme)
    settings.auditMaxRows = normalizeAuditMaxRows(settings.auditMaxRows)
    settings.auditRetentionDays = normalizeAuditRetentionDays(settings.auditRetentionDays)
    pruneAuditLog(guild.maintenance, settings)
    local invites = ensureInviteState()
    trimInviteBucket(invites.pending, INVITE_PENDING_LIMIT, "invitedAt")
    trimInviteBucket(invites.recentContacts, INVITE_RECENT_LIMIT, "lastContactAt")
    trimInviteBucket(invites.blacklist, INVITE_BLOCK_LIMIT, "addedAt")

    return guild, settings
end

local function ensureHelperRuntimeState()
    addon.MaintenanceService._helperRuntime = type(addon.MaintenanceService._helperRuntime) == "table" and addon.MaintenanceService._helperRuntime or {}

    local runtime = addon.MaintenanceService._helperRuntime
    runtime.manualKickMembers = type(runtime.manualKickMembers) == "table" and runtime.manualKickMembers or {}
    runtime.manualKickSelectedMembers = type(runtime.manualKickSelectedMembers) == "table" and runtime.manualKickSelectedMembers or {}
    runtime.rosterScanDirty = runtime.rosterScanDirty ~= false
    runtime.rosterScanUpdatedAt = tonumber(runtime.rosterScanUpdatedAt) or 0
    return runtime
end

local function markHelperRosterScanDirty()
    local runtime = ensureHelperRuntimeState()
    runtime.rosterScanDirty = true
    return runtime
end

local function ensureHelperRosterScanUpToDate(service)
    local runtime = ensureHelperRuntimeState()
    local maintenance = addon.Store:GetGuild().maintenance or {}

    if runtime.rosterScanDirty
        or type(maintenance.staleMembers) ~= "table"
        or type(maintenance.pendingDeletes) ~= "table"
        or runtime.rosterScanUpdatedAt <= 0 then
        return service:ScanRoster()
    end

    return maintenance.staleMembers, maintenance.pendingDeletes
end

local function ageInDays(unixTime)
    if not unixTime then
        return math.huge
    end

    return math.floor((time() - unixTime) / 86400)
end

local function buildDisplayName(memberId, record)
    if record and record.fullName then
        return addon.Store:NormalizeFullName(record.fullName, record.realm)
    end

    return addon.Store:NormalizeFullName(memberId, record and record.realm)
end

local function noteHasCleanupExclusionTag(noteText)
    local normalizedNote = string.lower(tostring(noteText or ""))

    if normalizedNote == "" then
        return false
    end

    for _, pattern in ipairs(DO_NOT_KICK_TAG_PATTERNS) do
        if normalizedNote:find(pattern, 1, true) then
            return true
        end
    end

    return false
end

local function getMainCharacterId(memberId, record, normalizedMemberId, fallbackRealm)
    local resolvedFallbackRealm = fallbackRealm or GetRealmName() or "Unknown"
    local resolvedMemberId = normalizedMemberId

    if resolvedMemberId == nil or resolvedMemberId == "" then
        resolvedMemberId = addon.Store:NormalizeFullName(memberId, resolvedFallbackRealm)
    end

    local rawMainCharacter = type(record) == "table" and tostring(record.mainCharacter or "") or ""
    local trimmedMainCharacter = rawMainCharacter:match("^%s*(.-)%s*$") or ""

    if trimmedMainCharacter == "" then
        return resolvedMemberId
    end

    return addon.Store:NormalizeFullName(trimmedMainCharacter, resolvedFallbackRealm)
end

local function isMainCharacterRecord(memberId, record, normalizedMemberId, mainCharacterId, fallbackRealm)
    local resolvedFallbackRealm = fallbackRealm or GetRealmName() or "Unknown"
    local resolvedMemberId = normalizedMemberId

    if resolvedMemberId == nil or resolvedMemberId == "" then
        resolvedMemberId = addon.Store:NormalizeFullName(memberId, resolvedFallbackRealm)
    end

    local resolvedMainCharacterId = mainCharacterId

    if resolvedMainCharacterId == nil or resolvedMainCharacterId == "" then
        resolvedMainCharacterId = getMainCharacterId(memberId, record, resolvedMemberId, resolvedFallbackRealm)
    end

    return resolvedMainCharacterId == resolvedMemberId
end

local function matchesKickScope(settings, memberId, record, isMainCharacter, normalizedMemberId, mainCharacterId, fallbackRealm)
    local scope = type(settings) == "table" and settings.kickScope or KICK_SCOPE_ALL

    if scope == KICK_SCOPE_ALL then
        return true
    end

    local resolvedIsMainCharacter = isMainCharacter

    if resolvedIsMainCharacter == nil then
        resolvedIsMainCharacter = isMainCharacterRecord(memberId, record, normalizedMemberId, mainCharacterId, fallbackRealm)
    end

    if scope == KICK_SCOPE_MAINS then
        return resolvedIsMainCharacter
    end

    if scope == KICK_SCOPE_ALTS then
        return not resolvedIsMainCharacter
    end

    return true
end

local function buildMaintenanceEntry(memberId, record, classification, daysOld, overrides)
    local data = type(overrides) == "table" and overrides or {}
    local normalizedMemberId = data.normalizedMemberId or addon.Store:NormalizeFullName(memberId, GetRealmName() or "Unknown")

    return {
        memberId = normalizedMemberId,
        displayName = data.displayName or buildDisplayName(normalizedMemberId, record),
        guildName = data.guildName or (type(record) == "table" and record.guildName or nil),
        reason = data.reason or classification.reason,
        statusLabel = data.statusLabel or classification.label,
        detail = data.detail or classification.detail,
        daysOld = tonumber(data.daysOld) or daysOld,
        lastSeenAt = data.lastSeenAt or (type(record) == "table" and record.lastSeenAt or nil),
        updatedAt = data.updatedAt or (type(record) == "table" and record.updatedAt or nil),
        mainCharacter = data.mainCharacter or getMainCharacterId(normalizedMemberId, record, normalizedMemberId),
        isMainCharacter = data.isMainCharacter,
        includedViaMain = data.includedViaMain,
        includedViaSearch = data.includedViaSearch,
        manualKick = data.manualKick and true or false,
        manualRootMemberId = data.manualRootMemberId,
    }
end

local function shouldIncludeLinkedKickAlt(record, currentGuildKey, exclusions, familyBlocks)
    if type(record) ~= "table" then
        return false
    end

    if exclusions[record.id or ""] or noteHasCleanupExclusionTag(record.note) or isFamilyBlocked(familyBlocks, record.id or "", record) then
        return false
    end

    return currentGuildKey ~= nil
        and record.guildName == currentGuildKey
        and record.guildRosterConfirmed
        and not record.deleted
        and not record.leftGuild
end

local function getFamilyKey(memberId, record, normalizedMemberId, mainCharacterId, fallbackRealm)
    return mainCharacterId or getMainCharacterId(memberId, record, normalizedMemberId, fallbackRealm)
end

local function isFamilyBlocked(familyBlocks, memberId, record, normalizedMemberId, mainCharacterId, fallbackRealm)
    if type(familyBlocks) ~= "table" or next(familyBlocks) == nil then
        return false
    end

    local familyKey = getFamilyKey(memberId, record, normalizedMemberId, mainCharacterId, fallbackRealm)
    return familyBlocks[familyKey] ~= nil
end

local function listFamilyMemberIds(guild, familyKey)
    local memberIds = {}

    for memberId, record in pairs(guild.roster or {}) do
        if getFamilyKey(memberId, record) == familyKey then
            memberIds[#memberIds + 1] = addon.Store:NormalizeFullName(memberId, GetRealmName() or "Unknown")
        end
    end

    table.sort(memberIds)
    return memberIds
end

local function classifyMaintenanceRecord(record, daysOld, staleAfterDays)
    if type(record) ~= "table" then
        return {
            reason = "unknown",
            label = "Review",
            detail = "Stored member record needs review.",
        }
    end

    if record.deleted then
        return {
            reason = "deleted",
            label = "Deleted",
            detail = "Record is already marked deleted in stored data.",
        }
    end

    if record.leftGuild then
        return {
            reason = "missing-roster",
            label = "Missing From Roster",
            detail = "Not present in the latest home guild roster scan. This usually means left, transferred, or renamed rather than simple inactivity.",
        }
    end

    if daysOld >= (tonumber(staleAfterDays) or STALE_AFTER_DAYS) then
        return {
            reason = "inactive",
            label = "Inactive",
            detail = "No roster refresh has updated this member for the stale threshold.",
        }
    end

    return {
        reason = "review",
        label = "Review",
        detail = "Record needs manual review.",
    }
end

local function canGuildRemoveMembers()
    if C_GuildInfo and C_GuildInfo.CanRemoveMember then
        local ok, allowed = pcall(C_GuildInfo.CanRemoveMember)

        if ok then
            return allowed and true or false
        end
    end

    if CanGuildRemove then
        local ok, allowed = pcall(CanGuildRemove)

        if ok then
            return allowed and true or false
        end
    end

    return false
end

local function buildKickCommand(memberId)
    return "/gremove " .. addon.Store:NormalizeFullName(memberId, GetRealmName() or "Unknown")
end

local function buildPromoteCommand(memberId)
    return "/gpromote " .. addon.Store:NormalizeFullName(memberId, GetRealmName() or "Unknown")
end

local function removeGuildMember(memberId)
    local normalizedMemberId = addon.Store:NormalizeFullName(memberId, GetRealmName() or "Unknown")

    if C_GuildInfo and C_GuildInfo.RemoveFromGuild then
        local ok = pcall(C_GuildInfo.RemoveFromGuild, normalizedMemberId)
        return ok, ok and nil or "unable to remove guild member"
    end

    if C_GuildInfo and C_GuildInfo.Uninvite then
        local ok = pcall(C_GuildInfo.Uninvite, normalizedMemberId)
        return ok, ok and nil or "unable to uninvite guild member"
    end

    if GuildUninvite then
        local ok = pcall(GuildUninvite, normalizedMemberId)
        return ok, ok and nil or "unable to uninvite guild member"
    end

    return false, "guild removal API is unavailable on this client"
end

local function promoteGuildMember(memberId)
    local normalizedMemberId = addon.Store:NormalizeFullName(memberId, GetRealmName() or "Unknown")

    if GuildPromote then
        local ok = pcall(GuildPromote, normalizedMemberId)
        return ok, ok and nil or "unable to promote guild member"
    end

    if C_GuildInfo and C_GuildInfo.Promote then
        local ok = pcall(C_GuildInfo.Promote, normalizedMemberId)
        return ok, ok and nil or "unable to promote guild member"
    end

    return false, "guild promotion API is unavailable on this client"
end

local function getGuildRankNames()
    local rankNames = {}
    local seen = {}

    if GuildControlGetNumRanks and GuildControlGetRankName then
        for index = 2, GuildControlGetNumRanks() do
            local rankName = tostring(GuildControlGetRankName(index) or "")

            if rankName ~= "" and not seen[rankName] then
                seen[rankName] = true
                rankNames[#rankNames + 1] = rankName
            end
        end
    end

    if #rankNames <= 0 then
        for _, record in pairs(addon.Store:GetGuild().roster or {}) do
            local rankName = type(record) == "table" and tostring(record.rank or "") or ""

            if rankName ~= "" and not seen[rankName] then
                seen[rankName] = true
                rankNames[#rankNames + 1] = rankName
            end
        end
    end

    return rankNames
end

local function getJoinDateAgeInDays(dateValue)
    local text = tostring(dateValue or "")
    local year, month, day = text:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)$")

    if not year or not month or not day or not time then
        return nil
    end

    local joinedAt = time({
        year = tonumber(year),
        month = tonumber(month),
        day = tonumber(day),
        hour = 12,
    })

    if not joinedAt then
        return nil
    end

    return math.floor((time() - joinedAt) / 86400)
end

local function parseIsoCalendarDate(dateValue)
    local text = tostring(dateValue or "")
    local year, month, day = text:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)$")

    year = tonumber(year)
    month = tonumber(month)
    day = tonumber(day)

    if not year or not month or not day then
        return nil
    end

    if month < 1 or month > 12 then
        return nil
    end

    local monthDays = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }
    local maxDay = monthDays[month] or 31

    if month == 2 and ((year % 4 == 0 and year % 100 ~= 0) or (year % 400 == 0)) then
        maxDay = 29
    end

    if day < 1 or day > maxDay then
        return nil
    end

    return year, month, day
end

local function getCalendarMonthDayCount(year, month)
    local monthDays = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }

    if month == 2 and ((year % 4 == 0 and year % 100 ~= 0) or (year % 400 == 0)) then
        return 29
    end

    return monthDays[month] or 31
end

local function formatAuditGuildTenure(dateValue, referenceTime)
    if not date or not time then
        return nil
    end

    local joinYear, joinMonth, joinDay = parseIsoCalendarDate(dateValue)

    if not joinYear then
        return nil
    end

    local resolvedReferenceTime = tonumber(referenceTime)

    if not resolvedReferenceTime or resolvedReferenceTime <= 0 then
        resolvedReferenceTime = time()
    end

    local currentDate = date("*t", resolvedReferenceTime)

    if type(currentDate) ~= "table" then
        return nil
    end

    local currentYear = tonumber(currentDate.year)
    local currentMonth = tonumber(currentDate.month)
    local currentDay = tonumber(currentDate.day)

    if not currentYear or not currentMonth or not currentDay then
        return nil
    end

    if currentYear < joinYear
        or (currentYear == joinYear and currentMonth < joinMonth)
        or (currentYear == joinYear and currentMonth == joinMonth and currentDay < joinDay) then
        return nil
    end

    local years = currentYear - joinYear
    local months = currentMonth - joinMonth
    local days = currentDay - joinDay

    if days < 0 then
        months = months - 1

        local borrowMonth = currentMonth - 1
        local borrowYear = currentYear

        if borrowMonth < 1 then
            borrowMonth = 12
            borrowYear = borrowYear - 1
        end

        days = days + getCalendarMonthDayCount(borrowYear, borrowMonth)
    end

    if months < 0 then
        years = years - 1
        months = months + 12
    end

    if years < 0 then
        return nil
    end

    local parts = {}

    if years > 0 then
        parts[#parts + 1] = years == 1 and "1 year" or string.format("%d years", years)
    end

    if months > 0 then
        parts[#parts + 1] = months == 1 and "1 month" or string.format("%d months", months)
    end

    if days > 0 then
        parts[#parts + 1] = days == 1 and "1 day" or string.format("%d days", days)
    end

    if #parts <= 0 then
        return "less than 1 day"
    end

    return table.concat(parts, " ")
end

local function appendAuditGuildTenureDetail(detailText, guildJoinDate, referenceTime)
    local detail = trimText(detailText)
    local tenureText = formatAuditGuildTenure(guildJoinDate, referenceTime)

    if not tenureText or tenureText == "" then
        return detail
    end

    if detail:find("Guild tenure:", 1, true) then
        return detail
    end

    if detail == "" then
        return string.format("Guild tenure: %s.", tenureText)
    end

    return string.format("%s Guild tenure: %s.", detail, tenureText)
end

local function updateDepartureAuditEntryTenure(entry, guildJoinDate)
    if type(entry) ~= "table" then
        return false
    end

    local nextDetail = appendAuditGuildTenureDetail(entry.detail, guildJoinDate, entry.recordedAt)

    if trimText(nextDetail) == trimText(entry.detail) then
        return false
    end

    entry.detail = nextDetail
    return true
end

local function getRankIndexByName(rankName)
    if tostring(rankName or "") == "" or not (GuildControlGetNumRanks and GuildControlGetRankName) then
        return nil
    end

    for index = 1, GuildControlGetNumRanks() do
        if tostring(GuildControlGetRankName(index) or "") == tostring(rankName or "") then
            return index
        end
    end

    return nil
end

local function getPlayerGuildRankControlIndex()
    if not GetGuildInfo then
        return nil
    end

    local _, _, rankIndex = GetGuildInfo("player")
    rankIndex = tonumber(rankIndex)

    if rankIndex == nil then
        return nil
    end

    return rankIndex + 1
end

local function canRemoveGuildRecord(record)
    local playerRankIndex = getPlayerGuildRankControlIndex()
    local targetRankIndex = getRankIndexByName(type(record) == "table" and record.rank or nil)

    if not playerRankIndex or not targetRankIndex then
        return true
    end

    return targetRankIndex > playerRankIndex
end

local function isCurrentGuildKickCandidate(memberId, record, currentGuildKey)
    return currentGuildKey ~= nil
        and type(record) == "table"
        and record.guildName == currentGuildKey
        and record.guildRosterConfirmed
        and not record.deleted
        and not record.leftGuild
        and canRemoveGuildRecord(record)
end

local function listCurrentGuildKickFamilyMemberIds(guild, familyKey, currentGuildKey)
    local memberIds = {}

    for memberId, record in pairs(guild.roster or {}) do
        local normalizedMemberId = addon.Store:NormalizeFullName(memberId, GetRealmName() or "Unknown")

        if getFamilyKey(normalizedMemberId, record) == familyKey and isCurrentGuildKickCandidate(normalizedMemberId, record, currentGuildKey) then
            memberIds[#memberIds + 1] = normalizedMemberId
        end
    end

    table.sort(memberIds, function(left, right)
        return lowerText(buildDisplayName(left, guild.roster[left])) < lowerText(buildDisplayName(right, guild.roster[right]))
    end)

    return memberIds
end

local function countCurrentGuildKickFamilyMembers(guild, familyKey, currentGuildKey)
    local count = 0

    for memberId, record in pairs(guild.roster or {}) do
        local normalizedMemberId = addon.Store:NormalizeFullName(memberId, GetRealmName() or "Unknown")

        if getFamilyKey(normalizedMemberId, record) == familyKey and isCurrentGuildKickCandidate(normalizedMemberId, record, currentGuildKey) then
            count = count + 1
        end
    end

    return count
end

local function findMacroIndexByName(macroName)
    if not GetNumMacros or not GetMacroInfo then
        return nil
    end

    local globalCount, characterCount = GetNumMacros()
    local totalCount = (tonumber(globalCount) or 0) + (tonumber(characterCount) or 0)

    for index = 1, totalCount do
        local existingName = GetMacroInfo(index)

        if existingName == macroName then
            return index
        end
    end

    return nil
end

function addon.MaintenanceService:ScanRoster()
    local guild, settings = ensureMaintenanceState()
    local runtime = ensureHelperRuntimeState()
    local staleMembers = {}
    local pendingDeletes = {}
    local exclusions = guild.maintenance.exclusions or {}
    local familyBlocks = guild.maintenance.familyBlocks or {}
    local currentGuildKey = addon.Store:GetGuildKey()

    for memberId, record in pairs(guild.roster or {}) do
        if type(record) == "table" then
            local isTaggedExclusion = noteHasCleanupExclusionTag(record.note)

            if not exclusions[memberId] and not isTaggedExclusion and not isFamilyBlocked(familyBlocks, memberId, record) then
                local isCurrentGuildRecord = currentGuildKey and record.guildName == currentGuildKey
                local isGuildRosterConfirmed = record.guildRosterConfirmed and true or false

                if isCurrentGuildRecord and isGuildRosterConfirmed then
                    local daysOld = ageInDays(record.lastSeenAt or record.updatedAt)
                    local includeStaleCandidate = daysOld >= settings.staleAfterDays and canRemoveGuildRecord(record)
                    local includePurgeCandidate = (record.deleted or record.leftGuild) and daysOld >= settings.purgeAfterDays

                    if includeStaleCandidate or includePurgeCandidate then
                        local classification = classifyMaintenanceRecord(record, daysOld, settings.staleAfterDays)
                        local normalizedMemberId = addon.Store:NormalizeFullName(memberId, GetRealmName() or "Unknown")
                        local mainCharacterId = nil
                        local isMainCharacter = nil
                        local includeStale = false

                        if includeStaleCandidate then
                            if settings.kickScope == KICK_SCOPE_ALL then
                                includeStale = true
                            else
                                mainCharacterId = getMainCharacterId(memberId, record, normalizedMemberId)
                                isMainCharacter = isMainCharacterRecord(memberId, record, normalizedMemberId, mainCharacterId)
                                includeStale = matchesKickScope(settings, memberId, record, isMainCharacter, normalizedMemberId, mainCharacterId)
                            end
                        end

                        if includeStale or includePurgeCandidate then
                            if mainCharacterId == nil or mainCharacterId == "" then
                                mainCharacterId = getMainCharacterId(memberId, record, normalizedMemberId)
                            end

                            if isMainCharacter == nil then
                                isMainCharacter = (mainCharacterId == normalizedMemberId)
                            end
                        end

                        if includeStale then
                            staleMembers[memberId] = buildMaintenanceEntry(memberId, record, classification, daysOld, {
                                isMainCharacter = isMainCharacter,
                                mainCharacter = mainCharacterId,
                                normalizedMemberId = normalizedMemberId,
                            })
                        end

                        if includePurgeCandidate then
                            pendingDeletes[memberId] = buildMaintenanceEntry(memberId, record, classification, daysOld, {
                                isMainCharacter = isMainCharacter,
                                mainCharacter = mainCharacterId,
                                normalizedMemberId = normalizedMemberId,
                            })
                        end
                    end
                end
            end
        end
    end

    guild.maintenance.staleMembers = staleMembers
    guild.maintenance.pendingDeletes = pendingDeletes
    runtime.rosterScanDirty = false
    runtime.rosterScanUpdatedAt = time()
    return staleMembers, pendingDeletes
end

function addon.MaintenanceService:MarkRosterScanDirty()
    markHelperRosterScanDirty()
    return true
end

function addon.MaintenanceService:GetSummary()
    ensureHelperRosterScanUpToDate(self)

    local guild, settings = ensureMaintenanceState()
    local runtime = ensureHelperRuntimeState()
    local maintenance = guild.maintenance
    local staleCount = 0
    local purgeCount = 0
    local manualKickCount = 0

    for _ in pairs(maintenance.staleMembers) do
        staleCount = staleCount + 1
    end

    for _ in pairs(maintenance.pendingDeletes) do
        purgeCount = purgeCount + 1
    end

    for _ in pairs(runtime.manualKickMembers or {}) do
        manualKickCount = manualKickCount + 1
    end

    return {
        staleCount = staleCount,
        purgeCount = purgeCount,
        excludedCount = self:GetExclusionCount(),
        familyBlockedCount = self:GetFamilyBlockCount(),
        manualKickCount = manualKickCount,
        staleAfterDays = settings.staleAfterDays,
        purgeAfterDays = settings.purgeAfterDays,
        kickScope = settings.kickScope,
        kickLinkedAltsWithMain = settings.kickLinkedAltsWithMain,
    }
end

function addon.MaintenanceService:GetInviteSummary()
    local invites, settings = ensureInviteState()
    local runtime = ensureInviteRuntimeState()
    local session = ensureInviteSession(runtime)
    resolveStaleInviteScan(runtime, session)
    local pendingCount = 0
    local activeSendCount = 0
    local blockedCount = 0
    local recentCount = 0
    local lastInviteAt = 0
    local selectedCount = 0

    for _, record in pairs(invites.pending or {}) do
        pendingCount = pendingCount + 1
        lastInviteAt = math.max(lastInviteAt, tonumber(type(record) == "table" and record.invitedAt or 0) or 0)
    end

    for _ in pairs(invites.blacklist or {}) do
        blockedCount = blockedCount + 1
    end

    for _ in pairs(invites.recentContacts or {}) do
        recentCount = recentCount + 1
    end

    for _, entry in ipairs(runtime.results or {}) do
        if runtime.pendingInvites[entry.candidateName] then
            activeSendCount = activeSendCount + 1
        end

        if runtime.selected[entry.candidateName] then
            selectedCount = selectedCount + 1
        end
    end

    return {
        canSendContact = settings.whisperOnly and canSendRecruiterInviteWhisper(settings.primaryMessage) or canGuildInviteMembers(),
        canInvite = canGuildInviteMembers(),
        pendingCount = pendingCount,
        blockedCount = blockedCount,
        recentCount = recentCount,
        lastInviteAt = lastInviteAt,
        hasPrimaryMessage = settings.primaryMessage ~= "",
        hasWelcomeMessage = settings.welcomeMessage ~= "",
        repeatInviteCooldownDays = settings.repeatInviteCooldownDays,
        scanCooldownRemainingSeconds = getInviteScanCooldownRemainingSeconds(runtime),
        batchSize = settings.batchSize,
        lowLevel = settings.lowLevel,
        highLevel = settings.highLevel,
        currentZoneOnly = settings.currentZoneOnly,
        whisperOnly = settings.whisperOnly,
        announceJoins = settings.announceJoins,
        blacklistLeavers = settings.blacklistLeavers,
        syncBlacklist = settings.syncBlacklist,
        syncInviteHistory = settings.syncInviteHistory,
        activeSendCount = activeSendCount,
        acceptedCount = session.acceptedCount or 0,
        batchInviteInProgress = runtime.batchInviteInProgress,
        blockedDuringSessionCount = session.blockedCount or 0,
        cooldownFilteredCount = session.cooldownFilteredCount or 0,
        declinedCount = session.declinedCount or 0,
        foundCount = session.foundCount or 0,
        invitedCount = session.invitedCount or 0,
        timeoutCount = session.timeoutCount or 0,
        sessionActive = session.active and true or false,
        sessionCompleted = session.completed and true or false,
        sessionProgress = session.queryIndex or 0,
        sessionTotalQueries = #session.queries,
        sessionQueryLabel = session.lastQueryLabel,
        scanCount = #runtime.results,
        selectedCount = selectedCount,
        scanStatus = runtime.status,
        query = runtime.query,
        lastScanAt = runtime.updatedAt,
        lastMessage = runtime.lastMessage,
    }
end

function addon.MaintenanceService:GetInviteSettings()
    local _, settings = ensureInviteState()
    return {
        batchSize = settings.batchSize,
        currentZoneOnly = settings.currentZoneOnly,
        highLevel = settings.highLevel,
        lowLevel = settings.lowLevel,
        primaryMessage = settings.primaryMessage,
        querySuffix = settings.querySuffix,
        repeatInviteCooldownDays = settings.repeatInviteCooldownDays,
        whisperOnly = settings.whisperOnly,
        usePrimaryWhisper = settings.usePrimaryWhisper,
        welcomeMessage = settings.welcomeMessage,
        announceJoins = settings.announceJoins,
        blacklistLeavers = settings.blacklistLeavers,
        syncBlacklist = settings.syncBlacklist,
        syncInviteHistory = settings.syncInviteHistory,
    }
end

function addon.MaintenanceService:UpdateInviteSettings(payload)
    local _, settings = ensureInviteState()
    local changed = false

    if type(payload) ~= "table" then
        return self:GetInviteSettings(), changed
    end

    if payload.primaryMessage ~= nil then
        local nextValue = tostring(payload.primaryMessage or "")

        if settings.primaryMessage ~= nextValue then
            settings.primaryMessage = nextValue
            changed = true
        end
    end

    if payload.querySuffix ~= nil then
        local nextValue = tostring(payload.querySuffix or "")

        if settings.querySuffix ~= nextValue then
            settings.querySuffix = nextValue
            changed = true
        end
    end

    if payload.welcomeMessage ~= nil then
        local nextValue = tostring(payload.welcomeMessage or "")

        if settings.welcomeMessage ~= nextValue then
            settings.welcomeMessage = nextValue
            changed = true
        end
    end

    if payload.repeatInviteCooldownDays ~= nil then
        local nextValue = normalizeDayThreshold(payload.repeatInviteCooldownDays, settings.repeatInviteCooldownDays, 0, 365)

        if settings.repeatInviteCooldownDays ~= nextValue then
            settings.repeatInviteCooldownDays = nextValue
            changed = true
        end
    end

    if payload.lowLevel ~= nil then
        local nextValue = normalizeInviteLevel(payload.lowLevel, settings.lowLevel)

        if settings.lowLevel ~= nextValue then
            settings.lowLevel = nextValue
            changed = true
        end
    end

    if payload.highLevel ~= nil then
        local nextValue = normalizeInviteLevel(payload.highLevel, settings.highLevel)

        if settings.highLevel ~= nextValue then
            settings.highLevel = nextValue
            changed = true
        end
    end

    if settings.highLevel < settings.lowLevel then
        settings.highLevel = settings.lowLevel
        changed = true
    end

    if payload.batchSize ~= nil then
        local nextValue = normalizeInviteBatchSize(payload.batchSize)

        if settings.batchSize ~= nextValue then
            settings.batchSize = nextValue
            changed = true
        end
    end

    if payload.currentZoneOnly ~= nil then
        local nextValue = payload.currentZoneOnly and true or false

        if settings.currentZoneOnly ~= nextValue then
            settings.currentZoneOnly = nextValue
            changed = true
        end
    end

    if payload.whisperOnly ~= nil then
        local nextValue = payload.whisperOnly and true or false

        if settings.whisperOnly ~= nextValue then
            settings.whisperOnly = nextValue
            changed = true
        end
    end

    if payload.usePrimaryWhisper ~= nil then
        local nextValue = payload.usePrimaryWhisper and true or false

        if settings.usePrimaryWhisper ~= nextValue then
            settings.usePrimaryWhisper = nextValue
            changed = true
        end
    end

    if payload.announceJoins ~= nil then
        local nextValue = payload.announceJoins and true or false

        if settings.announceJoins ~= nextValue then
            settings.announceJoins = nextValue
            changed = true
        end
    end

    if payload.blacklistLeavers ~= nil then
        local nextValue = payload.blacklistLeavers and true or false

        if settings.blacklistLeavers ~= nextValue then
            settings.blacklistLeavers = nextValue
            changed = true
        end
    end

    if payload.syncBlacklist ~= nil then
        local nextValue = payload.syncBlacklist and true or false

        if settings.syncBlacklist ~= nextValue then
            settings.syncBlacklist = nextValue
            changed = true
        end
    end

    if payload.syncInviteHistory ~= nil then
        local nextValue = payload.syncInviteHistory and true or false

        if settings.syncInviteHistory ~= nextValue then
            settings.syncInviteHistory = nextValue
            changed = true
        end
    end

    return self:GetInviteSettings(), changed
end

function addon.MaintenanceService:ListPendingInvites()
    local invites = ensureInviteState()
    local entries = {}

    for candidateName, record in pairs(invites.pending or {}) do
        if type(record) == "table" then
            entries[#entries + 1] = {
                candidateName = candidateName,
                note = tostring(record.note or ""),
                invitedAt = tonumber(record.invitedAt) or 0,
                invitedBy = tostring(record.invitedBy or ""),
                whisperSentAt = tonumber(record.whisperSentAt) or 0,
            }
        end
    end

    table.sort(entries, function(left, right)
        if left.invitedAt == right.invitedAt then
            return tostring(left.candidateName or "") < tostring(right.candidateName or "")
        end

        return left.invitedAt > right.invitedAt
    end)

    return entries
end

function addon.MaintenanceService:ListBlockedInviteEntries()
    local invites = ensureInviteState()
    local entries = {}

    for candidateName, record in pairs(invites.blacklist or {}) do
        if type(record) == "table" then
            entries[#entries + 1] = {
                candidateName = candidateName,
                note = tostring(record.note or ""),
                addedAt = tonumber(record.addedAt) or 0,
                addedBy = tostring(record.addedBy or ""),
            }
        end
    end

    table.sort(entries, function(left, right)
        return tostring(left.candidateName or "") < tostring(right.candidateName or "")
    end)

    return entries
end

function addon.MaintenanceService:ListRecentInviteContacts()
    local invites = ensureInviteState()
    local entries = {}

    for candidateName, record in pairs(invites.recentContacts or {}) do
        if type(record) == "table" then
            entries[#entries + 1] = {
                candidateName = candidateName,
                lastContactAt = tonumber(record.lastContactAt) or 0,
                count = tonumber(record.count) or 0,
                source = tostring(record.source or "manual"),
                note = tostring(record.note or ""),
            }
        end
    end

    table.sort(entries, function(left, right)
        if left.lastContactAt == right.lastContactAt then
            return tostring(left.candidateName or "") < tostring(right.candidateName or "")
        end

        return left.lastContactAt > right.lastContactAt
    end)

    return entries
end

function addon.MaintenanceService:IsInviteBlocked(candidateName)
    local invites = ensureInviteState()
    local normalizedName = normalizeInviteName(candidateName)
    return normalizedName ~= "" and invites.blacklist[normalizedName] ~= nil or false
end

function addon.MaintenanceService:AddPendingInvite(candidateName, note, whisperSent)
    local invites = ensureInviteState()
    local normalizedName = normalizeInviteName(candidateName)
    local existing = normalizedName ~= "" and invites.pending[normalizedName] or nil

    if normalizedName == "" then
        return nil, "enter a character name before saving a pending invite"
    end

    invites.pending[normalizedName] = {
        candidateName = normalizedName,
        note = tostring(note or existing and existing.note or ""),
        invitedAt = tonumber(existing and existing.invitedAt) or time(),
        invitedBy = tostring(existing and existing.invitedBy or addon.Store:GetPlayerName()),
        whisperSentAt = whisperSent and time() or (tonumber(existing and existing.whisperSentAt) or 0),
    }
    trimInviteBucket(invites.pending, INVITE_PENDING_LIMIT, "invitedAt")
    return invites.pending[normalizedName]
end

function addon.MaintenanceService:RemovePendingInvite(candidateName)
    local invites = ensureInviteState()
    local normalizedName = normalizeInviteName(candidateName)

    if normalizedName == "" or not invites.pending[normalizedName] then
        return false
    end

    invites.pending[normalizedName] = nil
    return true
end

function addon.MaintenanceService:BlockInviteName(candidateName, note)
    local invites = ensureInviteState()
    local settings = self:GetInviteSettings()
    local normalizedName = normalizeInviteName(candidateName)

    if normalizedName == "" then
        return nil, "enter a character name before blocking it"
    end

    invites.blacklist[normalizedName] = {
        candidateName = normalizedName,
        note = tostring(note or ""),
        addedAt = time(),
        addedBy = addon.Store:GetPlayerName(),
    }
    trimInviteBucket(invites.blacklist, INVITE_BLOCK_LIMIT, "addedAt")

    if settings.syncBlacklist then
        self:BroadcastRecruiterSync("blacklist", invites.blacklist[normalizedName])
    end

    return invites.blacklist[normalizedName]
end

function addon.MaintenanceService:UnblockInviteName(candidateName)
    local invites = ensureInviteState()
    local settings = self:GetInviteSettings()
    local normalizedName = normalizeInviteName(candidateName)

    if normalizedName == "" or not invites.blacklist[normalizedName] then
        return false
    end

    invites.blacklist[normalizedName] = nil

    if settings.syncBlacklist then
        self:BroadcastRecruiterSync("blacklist-remove", { candidateName = normalizedName, removedAt = time() })
    end

    return true
end

function addon.MaintenanceService:RecordInviteContact(candidateName, source, note)
    local invites = ensureInviteState()
    local settings = self:GetInviteSettings()
    local normalizedName = normalizeInviteName(candidateName)

    if normalizedName == "" then
        return nil, "enter a character name first"
    end

    local existing = invites.recentContacts[normalizedName] or {}
    invites.recentContacts[normalizedName] = {
        candidateName = normalizedName,
        lastContactAt = time(),
        count = (tonumber(existing.count) or 0) + 1,
        source = tostring(source or existing.source or "manual"),
        note = tostring(note or existing.note or ""),
    }
    trimInviteBucket(invites.recentContacts, INVITE_RECENT_LIMIT, "lastContactAt")

    if settings.syncInviteHistory then
        self:BroadcastRecruiterSync("history", invites.recentContacts[normalizedName])
    end

    return invites.recentContacts[normalizedName]
end

function addon.MaintenanceService:ClearInviteRecentContacts()
    local invites = ensureInviteState()
    invites.recentContacts = {}
end

function addon.MaintenanceService:FormatInviteMessage(template, candidateName)
    return formatInviteMessage(template, candidateName)
end

function addon.MaintenanceService:FormatWelcomeMessage(template, candidateName)
    return formatWelcomeMessage(template, candidateName)
end

function addon.MaintenanceService:GetRecruiterSyncPrefix()
    return RECRUITER_SYNC_PREFIX
end

function addon.MaintenanceService:BroadcastRecruiterSync(kind, record)
    if not canAccessRecruiterSync() then
        return false, "only officers and the guild master can sync recruiter data"
    end

    if type(record) ~= "table" then
        return false, "sync record is invalid"
    end

    local payload = addon.Json:Encode({
        guildKey = addon.Store:GetGuildKey(),
        kind = tostring(kind or ""),
        record = record,
    })

    if not payload or payload == "" then
        return false, "unable to encode recruiter sync payload"
    end

    sendRecruiterAddonMessage(RECRUITER_SYNC_PREFIX, payload, "GUILD")
    return true
end

function addon.MaintenanceService:HandleIncoming(prefix, payload, channel, sender)
    if tostring(prefix or "") ~= RECRUITER_SYNC_PREFIX then
        return false
    end

    if not canAccessRecruiterSync() then
        return false
    end

    local senderName = normalizeInviteName(sender)

    if senderName == addon.Store:GetPlayerName() then
        return false
    end

    local decoded = addon.Json:Decode(payload or "")

    if type(decoded) ~= "table" or tostring(decoded.guildKey or "") ~= tostring(addon.Store:GetGuildKey() or "") then
        return false
    end

    local invites = ensureInviteState()
    local kind = tostring(decoded.kind or "")
    local record = type(decoded.record) == "table" and decoded.record or nil
    local candidateName = record and normalizeInviteName(record.candidateName) or ""

    if candidateName == "" then
        return false
    end

    if kind == "blacklist" then
        local existing = invites.blacklist[candidateName]

        if not existing or (tonumber(record.addedAt) or 0) >= (tonumber(existing.addedAt) or 0) then
            invites.blacklist[candidateName] = {
                candidateName = candidateName,
                note = tostring(record.note or ""),
                addedAt = tonumber(record.addedAt) or time(),
                addedBy = tostring(record.addedBy or senderName),
            }
        end

        return true
    end

    if kind == "history" then
        local existing = invites.recentContacts[candidateName]

        if not existing or (tonumber(record.lastContactAt) or 0) >= (tonumber(existing.lastContactAt) or 0) then
            invites.recentContacts[candidateName] = {
                candidateName = candidateName,
                count = math.max(tonumber(record.count) or 1, tonumber(existing and existing.count) or 0),
                joinedGuildAt = math.max(tonumber(record.joinedGuildAt) or 0, tonumber(existing and existing.joinedGuildAt) or 0),
                lastContactAt = tonumber(record.lastContactAt) or time(),
                note = tostring(record.note or ""),
                officerJoinNoteAt = math.max(tonumber(record.officerJoinNoteAt) or 0, tonumber(existing and existing.officerJoinNoteAt) or 0),
                source = tostring(record.source or "sync"),
                joinAnnouncedAt = tonumber(record.joinAnnouncedAt) or tonumber(existing and existing.joinAnnouncedAt) or 0,
                leaveBlacklistedAt = tonumber(record.leaveBlacklistedAt) or tonumber(existing and existing.leaveBlacklistedAt) or 0,
                welcomeSentAt = tonumber(record.welcomeSentAt) or tonumber(existing and existing.welcomeSentAt) or 0,
            }
        end

        return true
    end

    if kind == "blacklist-remove" then
        invites.blacklist[candidateName] = nil
        return true
    end

    return false
end

function addon.MaintenanceService:HandleRecruiterRosterUpdate()
    local perfScopeName = "maintenance:recruiter-roster-update"
    local perfStartedAt = addon.BeginPerfScope and addon:BeginPerfScope(perfScopeName) or nil
    local function finishPerf(result)
        if perfStartedAt ~= nil and addon.EndPerfScope then
            addon:EndPerfScope(perfScopeName, perfStartedAt)
        end

        return result
    end

    local guild = addon.Store:GetGuild()
    local invites, settings = ensureInviteState()
    local runtime = ensureInviteRuntimeState()
    local session = ensureInviteSession(runtime)
    local currentGuildKey = addon.Store:GetGuildKey()
    local changed = false
    local shouldProcess, isUrgent = shouldProcessRecruiterRosterUpdate(invites, settings, runtime, session)

    if not shouldProcess then
        return finishPerf(false)
    end

    if not isUrgent then
        local now = getMaintenanceClockSeconds()

        if (now - (tonumber(runtime.lastPassiveRosterSweepAt) or 0)) < 10 then
            return finishPerf(false)
        end

        runtime.lastPassiveRosterSweepAt = now
    end

    for candidateName, pending in pairs(invites.pending or {}) do
        local rosterRecord = guild.roster[candidateName]
        local recentRecord = invites.recentContacts[candidateName] or {}

        if type(rosterRecord) == "table"
            and rosterRecord.guildName == currentGuildKey
            and rosterRecord.guildRosterConfirmed
            and not rosterRecord.deleted
            and not rosterRecord.leftGuild then
            if (tonumber(recentRecord.joinedGuildAt) or 0) <= 0 then
                recentRecord.joinedGuildAt = time()
            end

            if settings.welcomeMessage ~= "" and SendChatMessage and (tonumber(recentRecord.welcomeSentAt) or 0) <= 0 then
                pcall(SendChatMessage, self:FormatWelcomeMessage(settings.welcomeMessage, candidateName), "WHISPER", nil, candidateName)
                recentRecord.welcomeSentAt = time()
            end

            if settings.announceJoins and SendChatMessage and (tonumber(recentRecord.joinAnnouncedAt) or 0) <= 0 then
                pcall(SendChatMessage, string.format("Please welcome %s to the guild.", addon.Store:SplitFullName(candidateName, GetRealmName() or "Unknown")), "GUILD")
                recentRecord.joinAnnouncedAt = time()
            end

            invites.recentContacts[candidateName] = recentRecord
            invites.pending[candidateName] = nil
            runtime.pendingInvites[candidateName] = nil
            runtime.pendingInviteWhispers[candidateName] = nil

            if session.active and not session.acceptedNames[candidateName] then
                session.acceptedNames[candidateName] = true
                session.acceptedCount = session.acceptedCount + 1
            end

            runtime.lastMessage = string.format("%s joined the guild.", addon.Store:SplitFullName(candidateName, GetRealmName() or "Unknown"))
            runtime.updatedAt = time()
            changed = true

            if settings.syncInviteHistory then
                self:BroadcastRecruiterSync("history", invites.recentContacts[candidateName])
            end
        end
    end

    if canEditOfficerNote() then
        for candidateName, recentRecord in pairs(invites.recentContacts or {}) do
            local rosterRecord = guild.roster[candidateName]

            if type(recentRecord) == "table"
                and type(rosterRecord) == "table"
                and rosterRecord.guildName == currentGuildKey
                and rosterRecord.guildRosterConfirmed
                and not rosterRecord.deleted
                and not rosterRecord.leftGuild
                and (tonumber(recentRecord.officerJoinNoteAt) or 0) <= 0 then
                local joinedGuildAt = tonumber(recentRecord.joinedGuildAt) or 0

                if joinedGuildAt <= 0 then
                    joinedGuildAt = tonumber(rosterRecord.updatedAt or rosterRecord.lastSeenAt) or time()
                    recentRecord.joinedGuildAt = joinedGuildAt
                end

                local ok = ensureRecruiterJoinedOfficerNote(candidateName, joinedGuildAt)

                if ok then
                    recentRecord.officerJoinNoteAt = time()
                    invites.recentContacts[candidateName] = recentRecord
                    changed = true

                    if settings.syncInviteHistory then
                        self:BroadcastRecruiterSync("history", recentRecord)
                    end
                end
            end
        end
    end

    if settings.blacklistLeavers then
        for memberId, record in pairs(guild.roster or {}) do
            local recentRecord = invites.recentContacts[memberId]
            local leftAt = tonumber(record and (record.updatedAt or record.lastSeenAt) or 0) or 0

            if type(record) == "table"
                and record.leftGuild
                and leftAt > 0
                and (time() - leftAt) <= (30 * 86400)
                and recentRecord
                and (tonumber(recentRecord.leaveBlacklistedAt) or 0) <= 0
                and not invites.blacklist[memberId] then
                self:BlockInviteName(memberId, "left guild")
                recentRecord.leaveBlacklistedAt = time()
                invites.recentContacts[memberId] = recentRecord
                changed = true

                if settings.syncInviteHistory then
                    self:BroadcastRecruiterSync("history", recentRecord)
                end
            end
        end
    end

    return finishPerf(changed)
end

function addon.MaintenanceService:HandleRecruiterSystemMessage(message)
    local eventType, normalizedName = extractRecruiterInviteEvent(message)

    if eventType ~= "" and normalizedName ~= "" then
        local runtime = ensureInviteRuntimeState()
        local session = ensureInviteSession(runtime)
        local batchRecord = runtime.batchQueue[normalizedName]
        local hasRuntimePending = runtime.pendingInvites[normalizedName] and true or batchRecord ~= nil

        local function clearRuntimePending(removeInviteRecord)
            runtime.pendingInviteWhispers[normalizedName] = nil
            runtime.pendingInvites[normalizedName] = nil
            clearInviteBatchCandidate(runtime, normalizedName)

            if removeInviteRecord then
                self:RemovePendingInvite(normalizedName)
            end

            finishInviteBatchIfComplete(runtime)
        end

        local function recordRuntimeInviteIfNeeded()
            local whisperMessage = runtime.pendingInviteWhispers[normalizedName]

            if whisperMessage == nil and batchRecord then
                whisperMessage = tostring(batchRecord.whisperMessage or "")
                runtime.pendingInvites[normalizedName] = true
                runtime.pendingInviteWhispers[normalizedName] = whisperMessage
                runtime.batchInvitedNames[normalizedName] = true
                clearInviteBatchCandidate(runtime, normalizedName)
            end

            if whisperMessage == nil then
                return false
            end

            self:RecordGuildInviteSent(normalizedName, whisperMessage, "")
            runtime.pendingInviteWhispers[normalizedName] = nil
            finishInviteBatchIfComplete(runtime)
            return true
        end

        if eventType == "invite" then
            if hasRuntimePending then
                recordRuntimeInviteIfNeeded()
                self:SkipInviteCandidate(normalizedName)
                runtime.updatedAt = time()
                refreshInviteView()
                return true
            end

            return false
        end

        if eventType == "auto-decline" or eventType == "not-found" then
            if hasRuntimePending then
                clearRuntimePending(true)
                self:SkipInviteCandidate(normalizedName)
                runtime.lastMessage = eventType == "auto-decline"
                    and string.format("%s already has a pending guild invite.", addon.Store:SplitFullName(normalizedName, GetRealmName() or "Unknown"))
                    or string.format("%s was not found.", addon.Store:SplitFullName(normalizedName, GetRealmName() or "Unknown"))
                runtime.updatedAt = time()
                refreshInviteView()
                return true
            end

            return false
        end

        if hasRuntimePending then
            recordRuntimeInviteIfNeeded()
            clearRuntimePending(true)
            self:SkipInviteCandidate(normalizedName)
        else
            self:RemovePendingInvite(normalizedName)
        end

        if session.active and not session.declinedNames[normalizedName] then
            session.declinedNames[normalizedName] = true
            session.declinedCount = session.declinedCount + 1
        end

        runtime.lastMessage = string.format("%s declined the guild invite.", addon.Store:SplitFullName(normalizedName, GetRealmName() or "Unknown"))
        runtime.updatedAt = time()
        refreshInviteView()
        return true
    end

    local auditEvent = extractAuditSystemEvent(message)

    if type(auditEvent) == "table" and auditEvent.eventType then
        if auditEvent.eventType == "kick" and auditEvent.memberId ~= "" then
            ensureAuditRuntimeState().pendingKicks[auditEvent.memberId] = {
                actorId = auditEvent.actorId,
                actorName = auditEvent.actorName,
                queuedAt = time(),
                source = "system",
            }
        end

        return self:RecordAuditEvent(auditEvent.eventType, {
            actorId = auditEvent.actorId,
            actorName = auditEvent.actorName,
            detail = auditEvent.detail,
            fingerprint = buildAuditFingerprint(auditEvent.eventType, auditEvent.memberId, auditEvent.actorId, auditEvent.detail),
            memberId = auditEvent.memberId,
            source = "system",
        }) and true or false
    end

    return false
end

function addon.MaintenanceService:HandleRecruiterBlockedAction(blockingAddon, blockedFunction, isForbidden)
    local runtime = ensureInviteRuntimeState()
    local session = ensureInviteSession(runtime)
    local addonName = tostring(blockingAddon or "")
    local rawFunctionName = tostring(blockedFunction or "")
    local functionName = (rawFunctionName == "UNKNOWN()" or rawFunctionName == "UNKNOWN") and "" or rawFunctionName
    local queuedBatchCount = countInviteBatchEntries(runtime.batchQueue)
    local isRecruiterActive = runtime.status ~= "idle"
        or next(runtime.pendingInvites or {}) ~= nil
        or queuedBatchCount > 0
        or (session.active and true or false)

    if not isRecruiterActive then
        return false
    end

    if addonName ~= "" then
        local matchesAddon = false

        for _, knownName in ipairs(addon.GetKnownAddonNames and addon:GetKnownAddonNames() or {}) do
            if knownName == addonName then
                matchesAddon = true
                break
            end
        end

        if not matchesAddon then
            return false
        end
    end

    if session.active then
        session.blockedCount = (tonumber(session.blockedCount) or 0) + 1
    end

    runtime.updatedAt = time()
    runtime.lastMessage = queuedBatchCount > 0
        and string.format(
            "Recruiter batch invite hit a blocked action%s. If no confirmation arrives, %d candidate(s) will be marked as contacted and removed from the queue.",
            functionName ~= "" and string.format(" (%s)", functionName) or (isForbidden and " (forbidden)" or ""),
            queuedBatchCount
        )
        or (functionName ~= ""
            and string.format("Recruiter blocked action: %s%s", functionName, isForbidden and " (forbidden)" or "")
            or string.format("Recruiter blocked action%s", isForbidden and " (forbidden)" or ""))

    local blockedAt = getInviteWatchTime()

    if runtime.lastBlockedActionMessage == runtime.lastMessage and math.abs(blockedAt - (tonumber(runtime.lastBlockedActionAt) or 0)) < 1 then
        refreshInviteView()
        return true
    end

    runtime.lastBlockedActionAt = blockedAt
    runtime.lastBlockedActionMessage = runtime.lastMessage
    addon:Print(runtime.lastMessage)
    refreshInviteView()
    return true
end

function addon.MaintenanceService:GetInviteCooldownStatus(candidateName)
    local invites, settings = ensureInviteState()
    local normalizedName = normalizeInviteName(candidateName)
    local cooldownDays = tonumber(settings.repeatInviteCooldownDays) or 0
    local recentRecord = normalizedName ~= "" and invites.recentContacts[normalizedName] or nil
    local lastContactAt = type(recentRecord) == "table" and tonumber(recentRecord.lastContactAt) or 0

    if normalizedName == "" or cooldownDays <= 0 or not lastContactAt or lastContactAt <= 0 then
        return {
            isSuppressed = false,
            cooldownDays = cooldownDays,
            remainingDays = 0,
            lastContactAt = lastContactAt or 0,
        }
    end

    local elapsedDays = math.floor((time() - lastContactAt) / 86400)
    local remainingDays = math.max(cooldownDays - elapsedDays, 0)

    return {
        isSuppressed = elapsedDays < cooldownDays,
        cooldownDays = cooldownDays,
        remainingDays = remainingDays,
        lastContactAt = lastContactAt,
    }
end

local function refreshInviteEntryCooldown(entry)
    if type(entry) ~= "table" then
        return {
            cooldownDays = 0,
            isSuppressed = false,
            lastContactAt = 0,
            remainingDays = 0,
        }
    end

    local cooldownStatus = addon.MaintenanceService:GetInviteCooldownStatus(entry.candidateName)
    entry.isSuppressed = cooldownStatus.isSuppressed and true or false
    entry.remainingDays = tonumber(cooldownStatus.remainingDays) or 0

    return cooldownStatus
end

function addon.MaintenanceService:BuildInviteWhoQuery()
    local _, settings = ensureInviteState()
    local queries = buildInviteQueryPlan(settings)
    return queries[1] and queries[1].query or ""
end

function addon.MaintenanceService:GetInviteScanState()
    local runtime = ensureInviteRuntimeState()
    local session = ensureInviteSession(runtime)
    resolveStaleInviteScan(runtime, session)
    local summary = self:GetInviteSummary()
    return {
        activeSendCount = summary.activeSendCount or 0,
        acceptedCount = session.acceptedCount or 0,
        batchInviteInProgress = runtime.batchInviteInProgress and true or false,
        blockedCount = session.blockedCount or 0,
        cooldownFilteredCount = session.cooldownFilteredCount or 0,
        declinedCount = session.declinedCount or 0,
        foundCount = session.foundCount or 0,
        invitedCount = session.invitedCount or 0,
        timeoutCount = session.timeoutCount or 0,
        lastMessage = runtime.lastMessage,
        lastScanAt = runtime.updatedAt,
        lastQueryLabel = session.lastQueryLabel,
        query = runtime.query,
        scanCount = #runtime.results,
        scanCooldownRemainingSeconds = summary.scanCooldownRemainingSeconds or 0,
        selectedCount = summary.selectedCount or 0,
        sessionActive = session.active and true or false,
        sessionCompleted = session.completed and true or false,
        sessionProgress = session.queryIndex or 0,
        sessionTotalQueries = #session.queries,
        startedAt = runtime.startedAt,
        status = runtime.status,
    }
end

function addon.MaintenanceService:ClearInviteScan()
    local runtime = ensureInviteRuntimeState()
    restoreInviteWhoQueryUIState(runtime)
    runtime.batchAwaitingResponseAt = 0
    runtime.batchCurrentName = ""
    runtime.batchCurrentWhisper = ""
    runtime.batchErrors = {}
    runtime.batchIndex = 0
    runtime.batchInvitedNames = {}
    runtime.batchQueue = {}
    runtime.pendingInviteWhispers = {}
    runtime.lastBlockedActionAt = 0
    runtime.lastBlockedActionMessage = ""
    runtime.pendingWhoParseGeneration = 0
    runtime.results = {}
    runtime.scanDeadlineAt = 0
    runtime.selected = {}
    runtime.pendingInvites = {}
    runtime.query = ""
    runtime.status = "idle"
    runtime.lastMessage = "Invite scan cleared."
    runtime.updatedAt = time()
    runtime.totalResults = 0
    runtime.batchGeneration = runtime.batchGeneration + 1
    runtime.batchInviteInProgress = false
    runtime.queryGeneration = runtime.queryGeneration + 1
    runtime.hadFriendsFrameOpen = false
    runtime.hadWhoFrameOpen = false
    runtime.suppressedFriendsWhoUpdate = false
    resetInviteSession(runtime)
end

function addon.MaintenanceService:StartInviteScan()
    if not (C_FriendList and C_FriendList.SendWho and C_FriendList.GetWhoInfo and C_FriendList.GetNumWhoResults) then
        return false, "WHO scan APIs are unavailable on this client"
    end

    local runtime = ensureInviteRuntimeState()
    local session = ensureInviteSession(runtime)
    resolveStaleInviteScan(runtime, session)

    if runtime.status == "scanning" and session.active then
        return false, "A recruiter scan is already in progress"
    end

    local scanCooldownRemainingSeconds = getInviteScanCooldownRemainingSeconds(runtime)

    if scanCooldownRemainingSeconds > 0 then
        return false, string.format("Wait %d more second(s) before starting another recruiter scan.", scanCooldownRemainingSeconds)
    end

    restoreInviteWhoQueryUIState(runtime)
    ensureInviteScanWatchdog()
    local _, settings = ensureInviteState()
    local queries, queryKeys = buildInviteQueryPlan(settings)

    if #queries <= 0 then
        return false, "No recruiter scan queries are available with the current settings"
    end

    runtime.results = {}
    runtime.selected = {}
    runtime.pendingInvites = {}
    runtime.pendingInviteWhispers = {}
    runtime.pendingWhoParseGeneration = 0
    runtime.query = ""
    runtime.scanDeadlineAt = 0
    runtime.status = "ready"
    runtime.startedAt = time()
    runtime.updatedAt = time()
    runtime.totalResults = 0
    runtime.batchGeneration = runtime.batchGeneration + 1
    runtime.batchInviteInProgress = false
    runtime.queryGeneration = runtime.queryGeneration + 1
    runtime.lastMessage = string.format("Recruiter session ready. %d scan(s) queued.", #queries)
    runtime.hadFriendsFrameOpen = false
    runtime.hadWhoFrameOpen = false
    runtime.suppressedFriendsWhoUpdate = false

    session = resetInviteSession(runtime)
    session.active = true
    session.queries = queries
    session.queryKeys = queryKeys or {}
    session.startedAt = time()

    return self:ScanNextInviteQuery()
end

function addon.MaintenanceService:ScanNextInviteQuery()
    if not (C_FriendList and C_FriendList.SendWho and C_FriendList.GetWhoInfo and C_FriendList.GetNumWhoResults) then
        return false, "WHO scan APIs are unavailable on this client"
    end

    local runtime = ensureInviteRuntimeState()
    local session = ensureInviteSession(runtime)
    resolveStaleInviteScan(runtime, session)

    if runtime.status == "scanning" then
        return false, "A recruiter scan is already in progress"
    end

    if not session.active or #session.queries <= 0 then
        return false, "Start a recruiter scan session first"
    end

    if session.queryIndex >= #session.queries then
        session.completed = true
        session.completedAt = session.completedAt > 0 and session.completedAt or time()
        runtime.status = "complete"
        runtime.lastMessage = "All recruiter scans in this session are complete."
        return false, runtime.lastMessage
    end

    local scanCooldownRemainingSeconds = getInviteScanCooldownRemainingSeconds(runtime)

    if scanCooldownRemainingSeconds > 0 then
        return false, string.format("Wait %d more second(s) before the next recruiter scan.", scanCooldownRemainingSeconds)
    end

    local nextIndex = session.queryIndex + 1
    local queryRecord = session.queries[nextIndex]
    runtime.query = tostring(queryRecord and queryRecord.query or "")
    runtime.pendingWhoParseGeneration = 0
    runtime.scanDeadlineAt = getInviteWatchTime() + INVITE_SCAN_TIMEOUT_SECONDS
    runtime.status = "scanning"
    runtime.updatedAt = time()
    runtime.totalResults = #runtime.results
    runtime.lastMessage = string.format("Scanning %s...", tostring(queryRecord and queryRecord.label or ("Pass " .. nextIndex)))
    runtime.queryGeneration = runtime.queryGeneration + 1
    session.pendingQueryIndex = nextIndex
    session.lastQueryLabel = tostring(queryRecord and queryRecord.label or ("Pass " .. nextIndex))

    prepareInviteWhoQueryUIState(runtime)

    local ok = pcall(C_FriendList.SendWho, runtime.query)

    scheduleInviteScanTimeout(runtime, nextIndex, session.lastQueryLabel)
    if not ok then
        restoreInviteWhoQueryUIState(runtime)
        runtime.status = "error"
        runtime.lastMessage = "Unable to send the WHO query."
        session.pendingQueryIndex = 0
        return false, runtime.lastMessage
    end

    runtime.nextScanAllowedAt = getInviteWatchTime() + INVITE_SCAN_INTERVAL_SECONDS
    scheduleInviteScanCooldownRefresh(runtime)

    return true, runtime.query
end

local function parseInviteWhoResults(expectedQueryGeneration)
    local service = addon.MaintenanceService
    local runtime = ensureInviteRuntimeState()
    local session = ensureInviteSession(runtime)
    local _, settings = ensureInviteState()

    if runtime.status ~= "scanning"
        or runtime.query == ""
        or not session.active
        or (tonumber(expectedQueryGeneration) and runtime.queryGeneration ~= expectedQueryGeneration) then
        return false
    end

    runtime.pendingWhoParseGeneration = 0
    restoreInviteWhoQueryUIState(runtime)
    runtime.scanDeadlineAt = 0

    local totalResults, visibleResults = 0, 0

    if C_FriendList and C_FriendList.GetNumWhoResults then
        totalResults, visibleResults = C_FriendList.GetNumWhoResults()
    end

    totalResults = tonumber(totalResults) or 0
    visibleResults = tonumber(visibleResults) or totalResults
    local queryIndex = tonumber(session.pendingQueryIndex) or 0
    local queryRecord = session.queries[queryIndex]
    local existingCandidates = {}
    local seen = {}
    local addedCount = 0
    local playerName = addon.Store:GetPlayerName()

    for _, entry in ipairs(runtime.results or {}) do
        existingCandidates[entry.candidateName] = true
    end

    for index = 1, math.max(visibleResults, 0) do
        local info = C_FriendList.GetWhoInfo(index)
        local candidateName = normalizeInviteName(info and (info.fullName or info.Name or info.fullNameWithRealm or info.name) or "")
        local guildName = tostring(info and (info.fullGuildName or info.Guild or info.guildName) or "")

        if candidateName ~= "" and candidateName ~= playerName and guildName == "" and not seen[candidateName] then
            local cooldownStatus = service:GetInviteCooldownStatus(candidateName)
            local isBlacklisted = service:IsInviteBlocked(candidateName)
            seen[candidateName] = true

            if cooldownStatus.isSuppressed then
                if not session.cooldownFilteredNames[candidateName] then
                    session.cooldownFilteredNames[candidateName] = true
                    session.cooldownFilteredCount = session.cooldownFilteredCount + 1
                end
            else
                if not existingCandidates[candidateName] then
                    runtime.results[#runtime.results + 1] = {
                        candidateName = candidateName,
                        className = tostring(info and (info.classStr or info.Class or "") or ""),
                        classToken = tostring(info and (info.filename or info.NoLocaleClass or "") or ""),
                        isBlacklisted = isBlacklisted,
                        isSuppressed = false,
                        level = tonumber(info and (info.level or info.Level) or 0) or 0,
                        remainingDays = 0,
                        raceName = tostring(info and (info.raceStr or info.Race or "") or ""),
                        zoneName = tostring(info and (info.area or info.Zone or "") or ""),
                    }
                    existingCandidates[candidateName] = true
                    addedCount = addedCount + 1
                end

                if not session.uniqueCandidates[candidateName] then
                    session.uniqueCandidates[candidateName] = true
                    session.foundCount = session.foundCount + 1
                end

                if isBlacklisted and not session.blockedNames[candidateName] then
                    session.blockedNames[candidateName] = true
                    session.blockedCount = session.blockedCount + 1
                end

                if not isBlacklisted and not runtime.pendingInvites[candidateName] then
                    runtime.selected[candidateName] = true
                end
            end
        end
    end

    table.sort(runtime.results, function(left, right)
        local leftLevel = tonumber(left.level) or 0
        local rightLevel = tonumber(right.level) or 0

        if leftLevel == rightLevel then
            return tostring(left.candidateName or "") < tostring(right.candidateName or "")
        end

        return leftLevel > rightLevel
    end)

    local followUpCount = expandInviteQueryPlan(session, settings, queryIndex, queryRecord, totalResults)

    session.queryIndex = math.max(session.queryIndex, session.pendingQueryIndex)
    session.pendingQueryIndex = 0
    session.completed = session.queryIndex >= #session.queries
    session.completedAt = session.completed and time() or 0
    runtime.status = session.completed and "complete" or "ready"
    runtime.updatedAt = time()
    runtime.totalResults = #runtime.results
    if followUpCount > 0 then
        runtime.lastMessage = string.format(
            "%s hit the WHO cap and queued %d follow-up scan(s). %d candidate(s) added. %d queued total.",
            session.lastQueryLabel ~= "" and session.lastQueryLabel or string.format("Scan %d", session.queryIndex),
            followUpCount,
            addedCount,
            #runtime.results
        )
    else
        runtime.lastMessage = string.format(
            "%s added %d candidate(s). %d queued total.",
            session.lastQueryLabel ~= "" and session.lastQueryLabel or string.format("Scan %d", session.queryIndex),
            addedCount,
            #runtime.results
        )
    end
    refreshInviteView()
    return true
end

function addon.MaintenanceService:ListInviteScanEntries()
    local runtime = ensureInviteRuntimeState()
    local entries = {}

    for _, entry in ipairs(runtime.results or {}) do
        refreshInviteEntryCooldown(entry)

        entries[#entries + 1] = {
            candidateName = entry.candidateName,
            className = entry.className,
            classToken = entry.classToken,
            isBlacklisted = entry.isBlacklisted and true or false,
            isPending = runtime.pendingInvites[entry.candidateName] and true or false,
            isSelected = runtime.selected[entry.candidateName] == true,
            isSuppressed = entry.isSuppressed and true or false,
            level = entry.level,
            raceName = entry.raceName,
            remainingDays = entry.remainingDays,
            zoneName = entry.zoneName,
        }
    end

    return entries
end

function addon.MaintenanceService:ToggleInviteCandidateSelected(candidateName)
    local runtime = ensureInviteRuntimeState()
    local normalizedName = normalizeInviteName(candidateName)

    if normalizedName == "" then
        return false
    end

    if runtime.selected[normalizedName] then
        runtime.selected[normalizedName] = nil
        return false
    end

    runtime.selected[normalizedName] = true
    return true
end

function addon.MaintenanceService:SetAllInviteCandidateSelected(selected)
    local runtime = ensureInviteRuntimeState()
    runtime.selected = {}

    if not selected then
        return 0
    end

    local selectedCount = 0

    for _, entry in ipairs(runtime.results or {}) do
        refreshInviteEntryCooldown(entry)

        if not entry.isBlacklisted and not entry.isSuppressed and not runtime.pendingInvites[entry.candidateName] then
            runtime.selected[entry.candidateName] = true
            selectedCount = selectedCount + 1
        end
    end

    return selectedCount
end

function addon.MaintenanceService:SkipInviteCandidate(candidateName)
    local runtime = ensureInviteRuntimeState()
    local normalizedName = normalizeInviteName(candidateName)

    if normalizedName == "" then
        return false
    end

    local filtered = {}

    for _, entry in ipairs(runtime.results or {}) do
        if entry.candidateName ~= normalizedName then
            filtered[#filtered + 1] = entry
        end
    end

    runtime.results = filtered
    runtime.selected[normalizedName] = nil
    runtime.updatedAt = time()
    return true
end

function addon.MaintenanceService:InviteSelectedCandidates(limit)
    local _, settings = ensureInviteState()
    local runtime = ensureInviteRuntimeState()
    local batchLimit = math.max(1, math.floor(tonumber(limit) or settings.batchSize or DEFAULT_INVITE_BATCH_SIZE))
    local whisperOnly = settings.whisperOnly and true or false

    if batchLimit > 1 and not whisperOnly then
        local payload = self:BuildInviteBatchMacro(batchLimit)
        local queuedCount = tonumber(payload and payload.includedCount) or 0

        runtime.lastMessage = queuedCount > 0
            and string.format("%d recruiter invite(s) are ready. Use the Invite Batch button to run them.", queuedCount)
            or "No selected invite candidates were eligible."
        runtime.updatedAt = time()

        return {
            errors = { runtime.lastMessage },
            invitedCount = 0,
            invitedNames = {},
            payload = payload,
            queuedCount = queuedCount,
            requiresSecureBatch = queuedCount > 0,
        }
    end

    local candidateBatch = {}
    local invitedCount = 0
    local invitedNames = {}
    local errors = {}

    for _, entry in ipairs(runtime.results or {}) do
        if #candidateBatch >= batchLimit then
            break
        end

        refreshInviteEntryCooldown(entry)

        if runtime.selected[entry.candidateName] and not entry.isBlacklisted and not entry.isSuppressed and not runtime.pendingInvites[entry.candidateName] then
            candidateBatch[#candidateBatch + 1] = entry.candidateName
        end
    end

    if #candidateBatch <= 0 then
        runtime.lastMessage = "No selected invite candidates were eligible."
        runtime.updatedAt = time()

        return {
            errors = { runtime.lastMessage },
            invitedCount = 0,
            invitedNames = {},
        }
    end

    for _, candidateName in ipairs(candidateBatch) do
        local whisperMessage = (whisperOnly or settings.usePrimaryWhisper) and self:FormatInviteMessage(settings.primaryMessage, candidateName) or ""

        if whisperOnly then
            local ok, sendError = self:SendGuildInvite(candidateName, whisperMessage, "", { whisperOnly = true })

            if ok then
                self:SkipInviteCandidate(candidateName)
                invitedCount = invitedCount + 1
                invitedNames[#invitedNames + 1] = candidateName
            else
                errors[#errors + 1] = string.format("%s: %s", candidateName, tostring(sendError or "whisper failed"))
            end
        else
            local ok, inviteError = performGuildInvite(candidateName)

            if ok then
                self:RecordGuildInviteSent(candidateName, whisperMessage, "")
                runtime.pendingInvites[candidateName] = true
                runtime.pendingInviteWhispers[candidateName] = nil
                runtime.selected[candidateName] = nil
                invitedCount = invitedCount + 1
                invitedNames[#invitedNames + 1] = candidateName
            else
                errors[#errors + 1] = string.format("%s: %s", candidateName, tostring(inviteError or "invite failed"))
            end
        end
    end

    runtime.batchInviteInProgress = false
    runtime.batchAwaitingResponseAt = 0
    runtime.batchCurrentName = ""
    runtime.batchCurrentWhisper = ""
    runtime.batchErrors = {}
    runtime.batchIndex = 0
    runtime.batchInvitedNames = {}
    runtime.batchQueue = {}
    runtime.lastMessage = invitedCount > 0
        and (whisperOnly
            and string.format("Sent %d recruiter whisper(s).", invitedCount)
            or string.format("Sent %d invite request(s). Awaiting responses.", invitedCount))
        or (errors[1] or "No selected invite candidates were eligible.")
    runtime.updatedAt = time()
    refreshInviteView()

    return {
        actionLabel = whisperOnly and "whisper" or "invite",
        errors = errors,
        invitedCount = invitedCount,
        invitedNames = invitedNames,
        queuedCount = #candidateBatch,
    }
end

function addon.MaintenanceService:InviteNextCandidate()
    return self:InviteSelectedCandidates(1)
end

function addon.MaintenanceService:BuildInviteBatchMacro(limit)
    local _, settings = ensureInviteState()
    local runtime = ensureInviteRuntimeState()
    local batchLimit = math.max(1, math.floor(tonumber(limit) or settings.batchSize or DEFAULT_INVITE_BATCH_SIZE))
    local macroLines = {}
    local candidateEntries = {}
    local bodyLength = 0
    local seen = {}
    local skippedCount = 0
    local whisperOnly = settings.whisperOnly and true or false

    for _, entry in ipairs(runtime.results or {}) do
        if #candidateEntries >= batchLimit then
            break
        end

        local candidateName = normalizeInviteName(entry.candidateName)
        refreshInviteEntryCooldown(entry)

        if candidateName ~= ""
            and runtime.selected[candidateName]
            and not entry.isBlacklisted
            and not entry.isSuppressed
            and not runtime.pendingInvites[candidateName]
            and not seen[candidateName] then
            local command = whisperOnly and "" or buildInviteBatchCommand(candidateName)
            local nextLine = #macroLines > 0 and ("\n" .. command) or command

            if whisperOnly or (command ~= "" and (bodyLength + #nextLine) <= CLEANUP_MACRO_BODY_LIMIT) then
                local whisperMessage = (whisperOnly or settings.usePrimaryWhisper) and self:FormatInviteMessage(settings.primaryMessage, candidateName) or ""

                if not whisperOnly then
                    macroLines[#macroLines + 1] = command
                    bodyLength = bodyLength + #nextLine
                end

                candidateEntries[#candidateEntries + 1] = {
                    candidateName = candidateName,
                    note = "",
                    whisperMessage = whisperMessage,
                }
                seen[candidateName] = true
            else
                skippedCount = skippedCount + 1
            end
        end
    end

    return {
        body = table.concat(macroLines, "\n"),
        candidateEntries = candidateEntries,
        includedCount = #candidateEntries,
        skippedCount = skippedCount,
        totalCount = #candidateEntries + skippedCount,
    }
end

function addon.MaintenanceService:PrepareInviteBatchRun(payload)
    if not canGuildInviteMembers() then
        return {
            message = "you do not have permission to send guild invites",
            ok = false,
            queuedCount = 0,
        }
    end

    local batchPayload = type(payload) == "table" and payload or nil
    local candidateEntries = batchPayload and batchPayload.candidateEntries or nil

    if type(candidateEntries) ~= "table" or #candidateEntries <= 0 or tostring(batchPayload.body or "") == "" then
        return {
            message = "No eligible selected candidates are ready for invite.",
            ok = false,
            queuedCount = 0,
        }
    end

    local runtime = ensureInviteRuntimeState()

    runtime.batchGeneration = runtime.batchGeneration + 1
    clearInviteBatchState(runtime)
    runtime.batchInviteInProgress = true
    runtime.batchAwaitingResponseAt = getInviteWatchTime()
    runtime.batchCurrentName = tostring(candidateEntries[1] and candidateEntries[1].candidateName or "")
    runtime.batchCurrentWhisper = tostring(candidateEntries[1] and candidateEntries[1].whisperMessage or "")
    local batchSummaryMessage = string.format("Recruiter batch invites sent for %d candidate(s).", #candidateEntries)
    runtime.lastMessage = batchSummaryMessage
    runtime.updatedAt = time()

    for _, entry in ipairs(candidateEntries) do
        self:RecordGuildInviteSent(entry.candidateName, "", entry.note, { skipWhisperSend = true })
        runtime.pendingInvites[entry.candidateName] = true
        runtime.pendingInviteWhispers[entry.candidateName] = tostring(entry.whisperMessage or "")
        runtime.selected[entry.candidateName] = nil
        runtime.batchQueue[entry.candidateName] = {
            candidateName = entry.candidateName,
            note = tostring(entry.note or ""),
            whisperMessage = tostring(entry.whisperMessage or ""),
        }
    end

    runtime.lastMessage = batchSummaryMessage

    scheduleInviteBatchResultCheck(runtime)
    refreshInviteView()

    return {
        message = runtime.lastMessage,
        ok = true,
        queuedCount = #candidateEntries,
        skippedCount = tonumber(batchPayload.skippedCount) or 0,
    }
end

function addon.MaintenanceService:RecordGuildInviteSent(candidateName, whisperMessage, note, options)
    local normalizedName = normalizeInviteName(candidateName)
    local skipWhisperSend = type(options) == "table" and options.skipWhisperSend == true

    if normalizedName == "" then
        return false, "enter a character name before sending an invite"
    end

    local invites = ensureInviteState()
    local existingPending = invites.pending[normalizedName]
    local whisperSent = false
    local resolvedMessage = tostring(whisperMessage or "")
    local hasWhisperRecorded = type(existingPending) == "table" and (tonumber(existingPending.whisperSentAt) or 0) > 0

    if not skipWhisperSend and resolvedMessage ~= "" and SendChatMessage and not hasWhisperRecorded then
        whisperSent = pcall(SendChatMessage, resolvedMessage, "WHISPER", nil, normalizedName) and true or false
    end

    self:AddPendingInvite(normalizedName, note, whisperSent)

    local runtime = ensureInviteRuntimeState()
    local session = ensureInviteSession(runtime)
    local alreadyCounted = (tonumber(session.invitedNames[normalizedName]) or 0) > 0

    if not existingPending then
        self:RecordInviteContact(normalizedName, whisperSent and "invite+whisper" or (skipWhisperSend and "invite-attempt" or "invite"), note)
    end

    if session.active and not alreadyCounted then
        session.invitedCount = session.invitedCount + 1
        session.invitedNames[normalizedName] = (tonumber(session.invitedNames[normalizedName]) or 0) + 1
    end

    runtime.updatedAt = time()
    runtime.lastMessage = whisperSent and "guild invite and whisper sent" or "guild invite sent"

    return true, runtime.lastMessage
end

function addon.MaintenanceService:SendInviteWhisper(candidateName, whisperMessage, note, options)
    local normalizedName = normalizeInviteName(candidateName)
    local allowCooldownOverride = type(options) == "table" and options.allowCooldownOverride == true

    if normalizedName == "" then
        return false, "enter a character name before sending a whisper"
    end

    local cooldownStatus = self:GetInviteCooldownStatus(normalizedName)

    if cooldownStatus.isSuppressed and not allowCooldownOverride then
        return false, string.format("that character was contacted recently; wait %d more day(s) before inviting again", cooldownStatus.remainingDays)
    end

    local resolvedMessage = tostring(whisperMessage or "")

    if trimText(resolvedMessage) == "" then
        return false, "save an Invite Whisper message before using whisper-only mode"
    end

    if not SendChatMessage then
        return false, "whisper chat is unavailable on this client"
    end

    local ok, whisperError = pcall(SendChatMessage, resolvedMessage, "WHISPER", nil, normalizedName)

    if not ok then
        return false, tostring(whisperError or "unable to send the invite whisper")
    end

    self:RecordInviteContact(normalizedName, "whisper", note)

    local runtime = ensureInviteRuntimeState()
    local session = ensureInviteSession(runtime)

    if session.active then
        session.invitedCount = session.invitedCount + 1
        session.invitedNames[normalizedName] = (tonumber(session.invitedNames[normalizedName]) or 0) + 1
    end

    runtime.updatedAt = time()
    runtime.lastMessage = "invite whisper sent"

    return true, runtime.lastMessage
end

function addon.MaintenanceService:SendGuildInvite(candidateName, whisperMessage, note, options)
    local normalizedName = normalizeInviteName(candidateName)
    local allowCooldownOverride = type(options) == "table" and options.allowCooldownOverride == true
    local whisperOnly = type(options) == "table" and options.whisperOnly == true

    if normalizedName == "" then
        return false, "enter a character name before sending an invite"
    end

    if self:IsInviteBlocked(normalizedName) then
        return false, "that character is on the blocked invite list"
    end

    local cooldownStatus = self:GetInviteCooldownStatus(normalizedName)

    if cooldownStatus.isSuppressed and not allowCooldownOverride then
        return false, string.format("that character was contacted recently; wait %d more day(s) before inviting again", cooldownStatus.remainingDays)
    end

    if whisperOnly then
        return self:SendInviteWhisper(normalizedName, whisperMessage, note, options)
    end

    if not canGuildInviteMembers() then
        return false, "you do not have permission to send guild invites"
    end

    local invitedOk, inviteError = performGuildInvite(normalizedName)

    if not invitedOk then
        return false, inviteError or "unable to send the guild invite"
    end

    return self:RecordGuildInviteSent(normalizedName, whisperMessage, note)
end

function addon.MaintenanceService:GetSettings()
    local _, settings = ensureMaintenanceState()
    return settings
end

function addon.MaintenanceService:UpdateSettings(payload)
    local guild, settings = ensureMaintenanceState()
    local changed = false
    local rescanNeeded = false

    if type(payload) ~= "table" then
        return settings, changed
    end

    if payload.staleAfterDays ~= nil then
        local nextValue = normalizeDayThreshold(payload.staleAfterDays, settings.staleAfterDays, 7, 365)

        if settings.staleAfterDays ~= nextValue then
            settings.staleAfterDays = nextValue
            changed = true
            rescanNeeded = true
        end
    end

    if payload.purgeAfterDays ~= nil then
        local nextValue = normalizeDayThreshold(payload.purgeAfterDays, settings.purgeAfterDays, 30, 365)

        if settings.purgeAfterDays ~= nextValue then
            settings.purgeAfterDays = nextValue
            changed = true
            rescanNeeded = true
        end
    end

    if payload.kickScope ~= nil then
        local nextValue = normalizeKickScope(payload.kickScope)

        if settings.kickScope ~= nextValue then
            settings.kickScope = nextValue
            changed = true
            rescanNeeded = true
        end
    end

    if payload.kickLinkedAltsWithMain ~= nil then
        local nextValue = payload.kickLinkedAltsWithMain and true or false

        if settings.kickLinkedAltsWithMain ~= nextValue then
            settings.kickLinkedAltsWithMain = nextValue
            changed = true
            rescanNeeded = true
        end
    end

    if payload.promoteFromRank ~= nil then
        local nextValue = tostring(payload.promoteFromRank or "")

        if nextValue ~= "" and settings.promoteFromRank ~= nextValue then
            settings.promoteFromRank = nextValue
            changed = true
            rescanNeeded = true
        end
    end

    if payload.promoteToRank ~= nil then
        local nextValue = tostring(payload.promoteToRank or "")

        if nextValue ~= "" and settings.promoteToRank ~= nextValue then
            settings.promoteToRank = nextValue
            changed = true
            rescanNeeded = true
        end
    end

    if payload.promoteMinJoinDays ~= nil then
        local nextValue = normalizeDayThreshold(payload.promoteMinJoinDays, settings.promoteMinJoinDays, 0, 3650)

        if settings.promoteMinJoinDays ~= nextValue then
            settings.promoteMinJoinDays = nextValue
            changed = true
            rescanNeeded = true
        end
    end

    if payload.auditEnabled ~= nil then
        local nextValue = payload.auditEnabled and true or false

        if settings.auditEnabled ~= nextValue then
            settings.auditEnabled = nextValue
            changed = true
        end
    end

    if payload.auditLogJoins ~= nil then
        local nextValue = payload.auditLogJoins and true or false

        if settings.auditLogJoins ~= nextValue then
            settings.auditLogJoins = nextValue
            changed = true
        end
    end

    if payload.auditLogLeaves ~= nil then
        local nextValue = payload.auditLogLeaves and true or false

        if settings.auditLogLeaves ~= nextValue then
            settings.auditLogLeaves = nextValue
            changed = true
        end
    end

    if payload.auditLogKicks ~= nil then
        local nextValue = payload.auditLogKicks and true or false

        if settings.auditLogKicks ~= nextValue then
            settings.auditLogKicks = nextValue
            changed = true
        end
    end

    if payload.auditLogRanks ~= nil then
        local nextValue = payload.auditLogRanks and true or false

        if settings.auditLogRanks ~= nextValue then
            settings.auditLogRanks = nextValue
            changed = true
        end
    end

    if payload.auditLogNotes ~= nil then
        local nextValue = payload.auditLogNotes and true or false

        if settings.auditLogNotes ~= nextValue then
            settings.auditLogNotes = nextValue
            changed = true
        end
    end

    if payload.auditLogOfficerNotes ~= nil then
        local nextValue = payload.auditLogOfficerNotes and true or false

        if settings.auditLogOfficerNotes ~= nextValue then
            settings.auditLogOfficerNotes = nextValue
            changed = true
        end
    end

    if payload.auditUseColors ~= nil then
        local nextValue = payload.auditUseColors and true or false

        if settings.auditUseColors ~= nextValue then
            settings.auditUseColors = nextValue
            changed = true
        end
    end

    if type(payload.auditTextColors) == "table" then
        local nextAuditTextColors = normalizeAuditTextColors(settings.auditTextColors)
        local colorsChanged = false

        for _, eventType in ipairs(AUDIT_TEXT_COLOR_KEYS) do
            if payload.auditTextColors[eventType] ~= nil then
                local nextColor = normalizeAuditTextColorValue(payload.auditTextColors[eventType], AUDIT_DEFAULT_TEXT_COLORS[eventType])

                if not areAuditTextColorsEqual(settings.auditTextColors[eventType], nextColor) then
                    nextAuditTextColors[eventType] = nextColor
                    colorsChanged = true
                end
            end
        end

        if colorsChanged then
            settings.auditTextColors = nextAuditTextColors
            changed = true
        end
    end

    if payload.auditColorTheme ~= nil then
        local nextValue = normalizeAuditColorTheme(payload.auditColorTheme)

        if settings.auditColorTheme ~= nextValue then
            settings.auditColorTheme = nextValue
            changed = true
        end
    end

    if payload.auditMaxRows ~= nil then
        local nextValue = normalizeAuditMaxRows(payload.auditMaxRows)

        if settings.auditMaxRows ~= nextValue then
            settings.auditMaxRows = nextValue
            changed = true
        end
    end

    if payload.auditRetentionDays ~= nil then
        local nextValue = normalizeAuditRetentionDays(payload.auditRetentionDays)

        if settings.auditRetentionDays ~= nextValue then
            settings.auditRetentionDays = nextValue
            changed = true
        end
    end

    if changed then
        pruneAuditLog(guild.maintenance, settings)

        if rescanNeeded then
            self:ScanRoster()
        end
    end

    return settings, changed
end

function addon.MaintenanceService:GetAuditSummary()
    local guild, settings = ensureMaintenanceState()
    local entries = pruneAuditLog(guild.maintenance, settings)
    local counts = {
        join = 0,
        leave = 0,
        kick = 0,
        rank = 0,
        note = 0,
        officerNote = 0,
    }
    local lastRecordedAt = 0

    for _, entry in ipairs(entries) do
        local eventType = normalizeAuditEventType(entry and entry.eventType)

        if eventType ~= "" then
            counts[eventType] = (counts[eventType] or 0) + 1
        end

        lastRecordedAt = math.max(lastRecordedAt, tonumber(entry and entry.recordedAt) or 0)
    end

    return {
        byType = counts,
        lastRecordedAt = lastRecordedAt,
        maxRows = settings.auditMaxRows,
        retentionDays = settings.auditRetentionDays,
        totalCount = #entries,
    }
end

function addon.MaintenanceService:ListAuditEntries()
    local guild, settings = ensureMaintenanceState()
    local entries = pruneAuditLog(guild.maintenance, settings)
    local decoratedEntries = {}

    for index = #entries, 1, -1 do
        local entry = entries[index]

        if type(entry) == "table" then
            local eventType = normalizeAuditEventType(entry.eventType)
            local memberId = normalizeAuditName(entry.memberId)
            local actorId = normalizeAuditName(entry.actorId)
            local memberRecord = memberId ~= "" and guild.roster[memberId] or nil
            local actorRecord = actorId ~= "" and guild.roster[actorId] or nil
            local actorDisplayName = actorId ~= "" and buildDisplayName(actorId, actorRecord) or trimText(entry.actorName)
            local detail = trimText(entry.detail)

            if detail == "" and eventType == "kick" and actorDisplayName ~= "" then
                detail = string.format("Removed by %s.", actorDisplayName)
            end

            if eventType == "leave" or eventType == "kick" then
                local baseDetail = detail

                if baseDetail == "" then
                    baseDetail = eventType == "kick" and "Removed from the guild." or "Left the guild."
                end

                detail = appendAuditGuildTenureDetail(baseDetail, type(memberRecord) == "table" and memberRecord.guildJoinDate or nil, entry.recordedAt)
            end

            decoratedEntries[#decoratedEntries + 1] = {
                actorDisplayName = actorDisplayName,
                actorId = actorId ~= "" and actorId or nil,
                detail = detail ~= "" and detail or ((AUDIT_EVENT_LABELS[eventType] or "Audit") .. "."),
                displayName = memberId ~= "" and buildDisplayName(memberId, memberRecord) or "Unknown",
                eventLabel = AUDIT_EVENT_LABELS[eventType] or "Audit",
                eventType = eventType,
                memberId = memberId ~= "" and memberId or nil,
                recordedAt = tonumber(entry.recordedAt) or 0,
                source = trimText(entry.source) ~= "" and trimText(entry.source) or "roster",
            }
        end
    end

    return decoratedEntries
end

function addon.MaintenanceService:RecordAuditEvent(eventType, payload)
    local guild, settings = ensureMaintenanceState()
    local normalizedEventType = normalizeAuditEventType(eventType)

    if normalizedEventType == "" or settings.auditEnabled == false or not isAuditEventEnabled(settings, normalizedEventType) then
        return false
    end

    local runtime = ensureAuditRuntimeState()
    local record = type(payload) == "table" and payload or {}
    local memberId = normalizeAuditName(record.memberId or record.memberName)
    local actorId = normalizeAuditName(record.actorId or record.actorName)
    local actorName = trimText(record.actorName)
    local detail = trimText(record.detail)
    local recordedAt = tonumber(record.recordedAt) or 0
    local fingerprint = trimText(record.fingerprint)

    if recordedAt <= 0 then
        recordedAt = time()
    end

    if fingerprint == "" then
        fingerprint = buildAuditFingerprint(normalizedEventType, memberId, actorId, detail)
    end

    if fingerprint ~= "" then
        local previousAt = tonumber(runtime.recentFingerprints[fingerprint]) or 0

        if previousAt > 0 and math.abs(recordedAt - previousAt) <= AUDIT_RECENT_FINGERPRINT_WINDOW_SECONDS then
            return false
        end

        runtime.recentFingerprints[fingerprint] = recordedAt
    end

    guild.maintenance.auditLog[#guild.maintenance.auditLog + 1] = {
        actorId = actorId ~= "" and actorId or nil,
        actorName = actorName ~= "" and actorName or nil,
        detail = detail,
        eventType = normalizedEventType,
        memberId = memberId ~= "" and memberId or nil,
        recordedAt = recordedAt,
        source = trimText(record.source) ~= "" and trimText(record.source) or "roster",
    }

    pruneAuditLog(guild.maintenance, settings)
    return true
end

function addon.MaintenanceService:ClearAuditLog()
    local guild = ensureMaintenanceState()
    local runtime = ensureAuditRuntimeState()

    guild.maintenance.auditLog = {}
    runtime.recentFingerprints = {}
    return true
end

function addon.MaintenanceService:BeginRosterAuditCapture(guildKey)
    local guild, settings = ensureMaintenanceState()
    local auditMeta = guild.maintenance.auditMeta
    local normalizedGuildKey = trimText(guildKey)

    if normalizedGuildKey == "" then
        return false
    end

    if auditMeta.baselineGuild ~= normalizedGuildKey then
        auditMeta.baselineGuild = normalizedGuildKey
        auditMeta.baselineCapturedAt = 0
        auditMeta.lastRosterCaptureCount = 0
    end

    if settings.auditEnabled == false then
        return false
    end

    return (tonumber(auditMeta.baselineCapturedAt) or 0) > 0
end

function addon.MaintenanceService:CompleteRosterAuditCapture(guildKey, seenCount)
    local guild = ensureMaintenanceState()
    local auditMeta = guild.maintenance.auditMeta
    local normalizedGuildKey = trimText(guildKey)
    local capturedCount = math.max(0, math.floor(tonumber(seenCount) or 0))

    if normalizedGuildKey ~= "" then
        auditMeta.baselineGuild = normalizedGuildKey
    end

    auditMeta.lastRosterCaptureCount = capturedCount

    if (tonumber(auditMeta.baselineCapturedAt) or 0) <= 0 and capturedCount > 0 then
        auditMeta.baselineCapturedAt = time()
    end

    return true
end

function addon.MaintenanceService:TrackRosterOfficerNote(memberId, officerNote, auditReady, previousState, sourceValue)
    local guild = addon.Store:GetGuild()

    guild.maintenance = type(guild.maintenance) == "table" and guild.maintenance or {}
    guild.maintenance.auditOfficerNoteSignatures = type(guild.maintenance.auditOfficerNoteSignatures) == "table" and guild.maintenance.auditOfficerNoteSignatures or {}

    local settings = type(guild.maintenance.settings) == "table" and guild.maintenance.settings or nil

    if type(settings) ~= "table" then
        _, settings = ensureMaintenanceState()
        guild = addon.Store:GetGuild()
    end

    if settings.auditEnabled == false or settings.auditLogOfficerNotes == false then
        return false
    end

    local trimmedMemberId = trimText(memberId)
    local normalizedMemberId = ""

    if trimmedMemberId == "" then
        return false
    end

    if trimmedMemberId:find("-", 1, true) and string.lower(trimmedMemberId) ~= "you" then
        normalizedMemberId = trimmedMemberId
    else
        normalizedMemberId = normalizeAuditName(trimmedMemberId)
    end

    if normalizedMemberId == "" then
        return false
    end

    local signatures = guild.maintenance.auditOfficerNoteSignatures
    local resolvedPreviousState = type(previousState) == "table" and previousState or signatures[normalizedMemberId]
    local resolvedSourceValue = sourceValue ~= nil and tostring(sourceValue) or trimText(officerNote)

    if type(resolvedPreviousState) == "table" and resolvedPreviousState.sourceValue == resolvedSourceValue then
        return false
    end

    local nextState = buildOfficerNoteAuditState(officerNote, resolvedSourceValue)
    signatures[normalizedMemberId] = nextState

    if not auditReady or type(resolvedPreviousState) ~= "table" then
        return false
    end

    local previousValue = type(resolvedPreviousState.value) == "string" and resolvedPreviousState.value or nil
    local nextValue = nextState.value

    if previousValue == nil then
        return false
    end

    if previousValue == nextValue and (resolvedPreviousState.hasValue and true or false) == (nextState.hasValue and true or false) then
        return false
    end

    local previousSignature = getOfficerNoteAuditSignature(resolvedPreviousState)
    local nextSignature = getOfficerNoteAuditSignature(nextState)

    return self:RecordAuditEvent("officerNote", {
        detail = buildAuditOfficerNoteChangeDetail(resolvedPreviousState, nextState),
        fingerprint = table.concat({
            "officer-note",
            normalizedMemberId,
            tostring(previousSignature or ""),
            tostring(nextSignature or ""),
        }, "|"),
        memberId = normalizedMemberId,
        source = "roster",
    })
end

function addon.MaintenanceService:RecordRosterJoin(memberId)
    local normalizedMemberId = normalizeAuditName(memberId)

    if normalizedMemberId == "" then
        return false
    end

    return self:RecordAuditEvent("join", {
        detail = "Joined the guild.",
        fingerprint = buildAuditFingerprint("join", normalizedMemberId, "", "Joined the guild."),
        memberId = normalizedMemberId,
        source = "roster",
    })
end

function addon.MaintenanceService:RecordRosterDeparture(memberId)
    local normalizedMemberId = normalizeAuditName(memberId)

    if normalizedMemberId == "" then
        return false
    end

    local guild = ensureMaintenanceState()
    local pendingKick = getPendingKickRecord(normalizedMemberId, false)
    local memberRecord = type(guild.roster) == "table" and guild.roster[normalizedMemberId] or nil
    local guildJoinDate = type(memberRecord) == "table" and memberRecord.guildJoinDate or nil
    local recordedAt = time()
    local recentSystemDeparture = findRecentSystemDepartureEntry(guild.maintenance, normalizedMemberId, recordedAt)

    if recentSystemDeparture then
        if pendingKick then
            getPendingKickRecord(normalizedMemberId, true)
        end

        updateDepartureAuditEntryTenure(recentSystemDeparture, guildJoinDate)
        return false
    end

    if pendingKick then
        pendingKick = getPendingKickRecord(normalizedMemberId, true)
    end

    if pendingKick then
        local actorId = normalizeAuditName(pendingKick.actorId or pendingKick.actorName)
        local actorName = trimText(pendingKick.actorName)
        local actorLabel = actorName ~= "" and actorName or (actorId ~= "" and addon.Store:SplitFullName(actorId, GetRealmName() or "Unknown") or "")
        local detail = actorLabel ~= "" and string.format("Removed by %s.", actorLabel) or "Removed from the guild."

        detail = appendAuditGuildTenureDetail(detail, guildJoinDate, recordedAt)

        return self:RecordAuditEvent("kick", {
            actorId = actorId,
            actorName = actorName,
            detail = detail,
            fingerprint = buildAuditFingerprint("kick", normalizedMemberId, actorId, detail),
            memberId = normalizedMemberId,
            recordedAt = recordedAt,
            source = trimText(pendingKick.source) ~= "" and trimText(pendingKick.source) or "roster",
        })
    end

    local detail = appendAuditGuildTenureDetail("Left the guild.", guildJoinDate, recordedAt)

    return self:RecordAuditEvent("leave", {
        detail = detail,
        fingerprint = buildAuditFingerprint("leave", normalizedMemberId, "", detail),
        memberId = normalizedMemberId,
        recordedAt = recordedAt,
        source = "roster",
    })
end

function addon.MaintenanceService:RecordRosterRankChange(memberId, previousRank, nextRank)
    local normalizedMemberId = normalizeAuditName(memberId)
    local detail = buildAuditRankChangeDetail(previousRank, nextRank)

    if normalizedMemberId == "" or detail == "" then
        return false
    end

    return self:RecordAuditEvent("rank", {
        detail = detail,
        fingerprint = table.concat({
            "rank",
            normalizedMemberId,
            trimText(previousRank),
            trimText(nextRank),
        }, "|"),
        memberId = normalizedMemberId,
        source = "roster",
    })
end

function addon.MaintenanceService:RecordRosterNoteChange(memberId, previousNote, nextNote)
    local normalizedMemberId = normalizeAuditName(memberId)
    local detail = buildAuditNoteChangeDetail(previousNote, nextNote)

    if normalizedMemberId == "" or detail == "" then
        return false
    end

    local recorded = self:RecordAuditEvent("note", {
        detail = detail,
        fingerprint = table.concat({
            "note",
            normalizedMemberId,
            buildAuditTextSignature(previousNote),
            buildAuditTextSignature(nextNote),
        }, "|"),
        memberId = normalizedMemberId,
        source = "roster",
    })

    if recorded then
        local shortName = (addon.Store:SplitFullName(normalizedMemberId, GetRealmName() or "Unknown"))
        DEFAULT_CHAT_FRAME:AddMessage(string.format("|cffcc55ff[GH3] %s: %s|r", shortName, detail))
    end

    return recorded
end

function addon.MaintenanceService:MarkPendingKick(memberId, source, actorName)
    local normalizedMemberId = normalizeAuditName(memberId)

    if normalizedMemberId == "" then
        return false
    end

    ensureAuditRuntimeState().pendingKicks[normalizedMemberId] = {
        actorId = addon.Store:GetPlayerName(),
        actorName = trimText(actorName),
        queuedAt = time(),
        source = trimText(source) ~= "" and trimText(source) or "gh3-helper",
    }

    return true
end

function addon.MaintenanceService:MarkPendingKickBatch(memberIds, source, actorName)
    local markedCount = 0

    for _, memberId in ipairs(type(memberIds) == "table" and memberIds or {}) do
        if self:MarkPendingKick(memberId, source, actorName) then
            markedCount = markedCount + 1
        end
    end

    return markedCount
end

function addon.MaintenanceService:CanGuildRemoveMembers()
    return canGuildRemoveMembers()
end

function addon.MaintenanceService:GetCleanupMacroName()
    return CLEANUP_MACRO_NAME
end

function addon.MaintenanceService:GetPromotionMacroName()
    return PROMOTION_MACRO_NAME
end

function addon.MaintenanceService:GetGuildRankNames()
    return getGuildRankNames()
end

function addon.MaintenanceService:CanExportRawGuildRoster()
    return canExportRawGuildRoster()
end

function addon.MaintenanceService:ListRawGuildRosterEntries()
    if not canExportRawGuildRoster() then
        return nil, "Only officers and the guild master can export the raw guild roster."
    end

    requestGuildRosterSnapshot()

    if not GetGuildRosterInfo then
        return nil, "Guild roster APIs are unavailable on this client."
    end

    local reportedCount = GetNumGuildMembers and tonumber(GetNumGuildMembers()) or 0
    local canSeeOfficerNote = canViewOfficerNote() or canEditOfficerNote()
    local guildKey = addon.Store:GetGuildKey() or addon.Store:EnsureHomeGuild() or ""
    local exportedAt = time()
    local entries = {}
    local memberIdSet = {}
    local scanLimit = (reportedCount and reportedCount > 0) and math.floor(reportedCount) or 500

    for index = 1, math.max(0, scanLimit) do
        local fullName,
            rankName,
            rankIndex,
            level,
            classDisplayName,
            zone,
            publicNote,
            officerNote,
            isOnline,
            status,
            classFile,
            achievementPoints,
            achievementRank,
            isMobile,
            canSoR,
            repStanding,
            guid = GetGuildRosterInfo(index)

        if tostring(fullName or "") ~= "" then
            local memberId = addon.Store:NormalizeFullName(fullName, GetRealmName() or "Unknown")
            local offlineYears, offlineMonths, offlineDays, offlineHours = nil, nil, nil, nil

            if memberId ~= "" then
                memberIdSet[memberId] = true
            end

            if GetGuildRosterLastOnline then
                local ok, years, months, days, hours = pcall(GetGuildRosterLastOnline, index)

                if ok then
                    offlineYears = years
                    offlineMonths = months
                    offlineDays = days
                    offlineHours = hours
                end
            end

            entries[#entries + 1] = {
                achievementPoints = tonumber(achievementPoints) or 0,
                achievementRank = tonumber(achievementRank) or 0,
                canSoR = canSoR and true or false,
                classDisplayName = classDisplayName or "",
                classFile = classFile or "",
                exportedAt = exportedAt,
                fullName = fullName,
                guid = guid or "",
                guildName = guildKey,
                isMobile = isMobile and true or false,
                isOnline = isOnline and true or false,
                joinedDate = "",
                joinedDateSource = "",
                level = tonumber(level) or 0,
                lookupMemberId = memberId,
                officerNote = canSeeOfficerNote and (officerNote or "") or "",
                offlineDays = tonumber(offlineDays) or 0,
                offlineHours = tonumber(offlineHours) or 0,
                offlineMonths = tonumber(offlineMonths) or 0,
                offlineText = formatGuildRosterOfflineText(offlineYears, offlineMonths, offlineDays, offlineHours, isOnline and true or false),
                offlineYears = tonumber(offlineYears) or 0,
                publicNote = publicNote or "",
                rankIndex = tonumber(rankIndex) or 0,
                rankName = rankName or "",
                repStanding = tonumber(repStanding) or 0,
                repStandingText = getGuildRosterRepStandingText(repStanding),
                rosterIndex = index,
                shortName = addon.Store:SplitFullName(fullName, GetRealmName() or "Unknown"),
                statusCode = tonumber(status) or 0,
                statusText = getGuildRosterStatusText(status, isMobile and true or false),
                zone = zone or "",
            }
        end
    end

    if #entries <= 0 then
        return nil, "Guild roster snapshot is not ready yet. GH3 requested a refresh; try Export Raw Roster CSV again in a moment."
    end

    local joinDateLookup = {}

    if type(addon.RosterService) == "table"
        and type(addon.RosterService.GetGuildNewsJoinDateLookup) == "function" then
        joinDateLookup = addon.RosterService:GetGuildNewsJoinDateLookup(true, memberIdSet) or {}
    end

    for _, entry in ipairs(entries) do
        local joinedDate = type(joinDateLookup[entry.lookupMemberId]) == "string" and joinDateLookup[entry.lookupMemberId] or ""
        local joinedDateSource = joinedDate ~= "" and "guildNews" or ""

        if joinedDate == ""
            and type(addon.RosterService) == "table"
            and type(addon.RosterService.GetMember) == "function" then
            local memberRecord = addon.RosterService:GetMember(entry.lookupMemberId)
            local storedJoinDate = type(memberRecord) == "table" and tostring(memberRecord.guildJoinDate or "") or ""

            if storedJoinDate ~= "" then
                joinedDate = storedJoinDate
                joinedDateSource = "rosterRecord"
            end
        end

        entry.joinedDate = joinedDate
        entry.joinedDateSource = joinedDateSource
        entry.lookupMemberId = nil
    end

    return entries
end

function addon.MaintenanceService:ListPromotionEntries()
    local guild, settings = ensureMaintenanceState()
    local entries = {}
    local currentGuildKey = addon.Store:GetGuildKey()
    local fromRank = tostring(settings.promoteFromRank or "")
    local minJoinDays = tonumber(settings.promoteMinJoinDays) or 0

    for memberId, record in pairs(guild.roster or {}) do
        local joinDays = type(record) == "table" and getJoinDateAgeInDays(record.guildJoinDate) or nil

        if type(record) == "table"
            and currentGuildKey
            and record.guildName == currentGuildKey
            and record.guildRosterConfirmed
            and not record.deleted
            and not record.leftGuild
            and tostring(record.rank or "") == fromRank
            and (minJoinDays <= 0 or (joinDays and joinDays >= minJoinDays)) then
            entries[#entries + 1] = buildMaintenanceEntry(memberId, record, {
                reason = "promotion",
                label = "Promote",
                detail = string.format("Promote from %s to %s.", fromRank, tostring(settings.promoteToRank or "")),
            }, ageInDays(record.lastSeenAt or record.updatedAt), {
                isMainCharacter = isMainCharacterRecord(memberId, record),
                joinedDays = joinDays,
            })
        end
    end

    table.sort(entries, function(left, right)
        return tostring(left and left.displayName or "") < tostring(right and right.displayName or "")
    end)

    return entries
end

function addon.MaintenanceService:BuildPromotionMacro(memberIds)
    local _, settings = ensureMaintenanceState()
    local ids = {}
    local seen = {}
    local macroLines = {}
    local includedMemberIds = {}
    local bodyLength = 0
    local includedCount = 0
    local skippedCount = 0
    local fromIndex = getRankIndexByName(settings.promoteFromRank)
    local toIndex = getRankIndexByName(settings.promoteToRank)
    local requestedPromoteSteps = 1
    local promoteSteps = 1

    if fromIndex and toIndex and fromIndex > toIndex then
        requestedPromoteSteps = fromIndex - toIndex
    end

    if type(memberIds) ~= "table" then
        for _, entry in ipairs(self:ListPromotionEntries()) do
            if not seen[entry.memberId] then
                ids[#ids + 1] = entry.memberId
                seen[entry.memberId] = true
            end
        end
    else
        for _, memberId in ipairs(memberIds) do
            local normalizedMemberId = addon.Store:NormalizeFullName(memberId, GetRealmName() or "Unknown")

            if not seen[normalizedMemberId] then
                ids[#ids + 1] = normalizedMemberId
                seen[normalizedMemberId] = true
            end
        end
    end

    for _, memberId in ipairs(ids) do
        for _ = 1, promoteSteps do
            local command = buildPromoteCommand(memberId)
            local nextLine = #macroLines > 0 and ("\n" .. command) or command

            if (bodyLength + #nextLine) <= CLEANUP_MACRO_BODY_LIMIT then
                macroLines[#macroLines + 1] = command
                includedMemberIds[#includedMemberIds + 1] = memberId
                bodyLength = bodyLength + #nextLine
                includedCount = includedCount + 1
            else
                skippedCount = skippedCount + 1
            end
        end
    end

    return {
        body = table.concat(macroLines, "\n"),
        includedCount = includedCount,
        skippedCount = skippedCount,
        totalCount = #ids,
        macroName = PROMOTION_MACRO_NAME,
        memberIds = ids,
        includedMemberIds = includedMemberIds,
        fromRank = settings.promoteFromRank,
        minJoinDays = settings.promoteMinJoinDays,
        toRank = settings.promoteToRank,
        promoteSteps = promoteSteps,
        requestedPromoteSteps = requestedPromoteSteps,
    }
end

function addon.MaintenanceService:SavePromotionMacro(memberIds, macroName)
    if InCombatLockdown and InCombatLockdown() then
        return nil, "promotion macros cannot be built during combat"
    end

    if not CreateMacro or not EditMacro or not GetNumMacros or not GetMacroInfo then
        return nil, "macro APIs are unavailable on this client"
    end

    local macroPayload = self:BuildPromotionMacro(memberIds)
    local resolvedMacroName = tostring(macroName or PROMOTION_MACRO_NAME)

    if macroPayload.includedCount <= 0 or macroPayload.body == "" then
        return nil, "no promotion candidates fit in the macro body limit"
    end

    local macroIndex = findMacroIndexByName(resolvedMacroName)

    if macroIndex then
        local ok = pcall(EditMacro, macroIndex, resolvedMacroName, 134400, macroPayload.body, 1)

        if not ok then
            return nil, "unable to update promotion macro"
        end
    else
        local globalCount = GetNumMacros()

        if (tonumber(globalCount) or 0) >= 120 then
            return nil, "no free global macro slots are available"
        end

        local ok = pcall(CreateMacro, resolvedMacroName, 134400, macroPayload.body, nil)

        if not ok then
            return nil, "unable to create promotion macro"
        end
    end

    macroPayload.macroName = resolvedMacroName
    return macroPayload
end

function addon.MaintenanceService:ListCleanupEntries(mode)
    ensureHelperRosterScanUpToDate(self)

    local guild, settings = ensureMaintenanceState()
    local runtime = ensureHelperRuntimeState()
    local maintenance = guild.maintenance
    local entries = {}

    if mode == "purge" then
        for _, record in pairs(maintenance.pendingDeletes or {}) do
            if type(record) == "table" then
                entries[#entries + 1] = record
            end
        end
    else
        local byMemberId = {}
        local currentGuildKey = addon.Store:GetGuildKey()
        local exclusions = maintenance.exclusions or {}
        local familyCounts = {}
        local manualFamilyCounts = {}

        for memberId, record in pairs(maintenance.staleMembers or {}) do
            if type(record) == "table" and record.reason == "inactive" then
                byMemberId[memberId] = record
            end
        end

        if settings.kickLinkedAltsWithMain then
            for memberId, record in pairs(maintenance.staleMembers or {}) do
                local baseRecord = guild.roster[memberId]

                if type(record) == "table"
                    and record.reason == "inactive"
                    and type(baseRecord) == "table"
                    and record.isMainCharacter then
                    for linkedMemberId, linkedRecord in pairs(guild.roster or {}) do
                        if linkedMemberId ~= memberId
                            and getMainCharacterId(linkedMemberId, linkedRecord) == memberId
                            and not byMemberId[linkedMemberId]
                            and shouldIncludeLinkedKickAlt(linkedRecord, currentGuildKey, exclusions, guild.maintenance.familyBlocks or {}) then
                            byMemberId[linkedMemberId] = buildMaintenanceEntry(linkedMemberId, linkedRecord, {
                                reason = "linked-alt",
                                label = "Linked Alt",
                                detail = string.format("Included because main %s is queued and linked-alt cleanup is enabled.", record.displayName or memberId),
                            }, ageInDays(linkedRecord.lastSeenAt or linkedRecord.updatedAt), {
                                isMainCharacter = false,
                                includedViaMain = memberId,
                            })
                            familyCounts[memberId] = (familyCounts[memberId] or 0) + 1
                        end
                    end
                end
            end
        end

        for memberId, queuedData in pairs(runtime.manualKickMembers or {}) do
            local normalizedMemberId = addon.Store:NormalizeFullName(memberId, GetRealmName() or "Unknown")
            local record = guild.roster[normalizedMemberId]

            if type(queuedData) == "table" and isCurrentGuildKickCandidate(normalizedMemberId, record, currentGuildKey) then
                local sourceMemberId = addon.Store:NormalizeFullName(queuedData.sourceMemberId or normalizedMemberId, GetRealmName() or "Unknown")
                local sourceRecord = guild.roster[sourceMemberId]

                byMemberId[normalizedMemberId] = buildMaintenanceEntry(normalizedMemberId, record, {
                    reason = queuedData.includedViaSearch and "manual-family" or "manual-search",
                    label = queuedData.includedViaSearch and "Manual Family" or "Manual Search",
                    detail = queuedData.includedViaSearch
                        and string.format("Added from GH3 Helper kick search for %s.", buildDisplayName(sourceMemberId, sourceRecord))
                        or "Added from GH3 Helper kick search.",
                }, ageInDays(record.lastSeenAt or record.updatedAt), {
                    isMainCharacter = isMainCharacterRecord(normalizedMemberId, record),
                    includedViaSearch = queuedData.includedViaSearch and sourceMemberId or nil,
                    manualKick = true,
                    manualRootMemberId = sourceMemberId,
                })

                if normalizedMemberId ~= sourceMemberId then
                    manualFamilyCounts[sourceMemberId] = (manualFamilyCounts[sourceMemberId] or 0) + 1
                end
            else
                runtime.manualKickMembers[normalizedMemberId] = nil
            end
        end

        for memberId, record in pairs(byMemberId) do
            if type(record) == "table" then
                if record.manualKick and tostring(record.includedViaSearch or "") == "" then
                    record.linkedKickAltCount = manualFamilyCounts[memberId] or 0
                elseif not record.includedViaMain then
                    record.linkedKickAltCount = familyCounts[memberId] or 0
                end
            end
        end

        for _, record in pairs(byMemberId) do
            entries[#entries + 1] = record
        end
    end

    table.sort(entries, function(left, right)
        local leftManual = left and left.manualKick and true or false
        local rightManual = right and right.manualKick and true or false

        if leftManual ~= rightManual then
            return leftManual
        end

        local leftViaMain = tostring(left and (left.includedViaSearch or left.includedViaMain) or "")
        local rightViaMain = tostring(right and (right.includedViaSearch or right.includedViaMain) or "")

        if leftViaMain ~= rightViaMain then
            return leftViaMain == ""
        end

        local leftDays = tonumber(left and left.daysOld) or 0
        local rightDays = tonumber(right and right.daysOld) or 0

        if leftDays == rightDays then
            return tostring(left and left.displayName or left and left.memberId or "") < tostring(right and right.displayName or right and right.memberId or "")
        end

        return leftDays > rightDays
    end)

    return entries
end

function addon.MaintenanceService:ListKickSearchMatches(searchText, limit)
    local guild = ensureMaintenanceState()
    local runtime = ensureHelperRuntimeState()
    local currentGuildKey = addon.Store:GetGuildKey()
    local selectedMembers = guild.maintenance.selectedMembers or {}
    local query = trimText(searchText)
    local maxResults = math.max(1, math.floor(tonumber(limit) or 12))
    local matches = {}

    if query == "" then
        return matches
    end

    local queryLower = lowerText(query)
    local queryShortLower = lowerText(getShortName(query))

    for memberId, record in pairs(guild.roster or {}) do
        local normalizedMemberId = addon.Store:NormalizeFullName(memberId, GetRealmName() or "Unknown")

        if isCurrentGuildKickCandidate(normalizedMemberId, record, currentGuildKey) then
            local displayName = buildDisplayName(normalizedMemberId, record)
            local mainCharacterId = getMainCharacterId(normalizedMemberId, record)
            local displayLower = lowerText(displayName)
            local memberLower = lowerText(normalizedMemberId)
            local shortDisplayLower = lowerText(getShortName(displayName))
            local shortMemberLower = lowerText(getShortName(normalizedMemberId))
            local mainLower = lowerText(mainCharacterId)
            local shortMainLower = lowerText(getShortName(mainCharacterId))
            local haystack = table.concat({
                displayLower,
                memberLower,
                shortDisplayLower,
                shortMemberLower,
                mainLower,
                shortMainLower,
            }, " ")

            if haystack:find(queryLower, 1, true) or (queryShortLower ~= "" and haystack:find(queryShortLower, 1, true)) then
                local score = 2

                if queryLower == displayLower
                    or queryLower == memberLower
                    or (queryShortLower ~= "" and (queryShortLower == shortDisplayLower or queryShortLower == shortMemberLower)) then
                    score = 0
                elseif displayLower:find(queryLower, 1, true) == 1
                    or memberLower:find(queryLower, 1, true) == 1
                    or (queryShortLower ~= "" and (shortDisplayLower:find(queryShortLower, 1, true) == 1 or shortMemberLower:find(queryShortLower, 1, true) == 1)) then
                    score = 1
                end

                matches[#matches + 1] = {
                    memberId = normalizedMemberId,
                    displayName = displayName,
                    mainCharacter = mainCharacterId,
                    isMainCharacter = isMainCharacterRecord(normalizedMemberId, record),
                    familySize = countCurrentGuildKickFamilyMembers(guild, getFamilyKey(normalizedMemberId, record), currentGuildKey),
                    alreadyQueued = runtime.manualKickMembers[normalizedMemberId] ~= nil,
                    alreadySelected = selectedMembers[normalizedMemberId] == true,
                    score = score,
                }
            end
        end
    end

    table.sort(matches, function(left, right)
        if (left.score or 99) ~= (right.score or 99) then
            return (left.score or 99) < (right.score or 99)
        end

        if (left.familySize or 1) ~= (right.familySize or 1) then
            return (left.familySize or 1) > (right.familySize or 1)
        end

        return lowerText(left.displayName) < lowerText(right.displayName)
    end)

    while #matches > maxResults do
        table.remove(matches)
    end

    return matches
end

function addon.MaintenanceService:QueueManualKickMember(memberId, includeLinkedAlts)
    local guild = ensureMaintenanceState()
    local runtime = ensureHelperRuntimeState()
    local currentGuildKey = addon.Store:GetGuildKey()
    local normalizedMemberId = addon.Store:NormalizeFullName(memberId, GetRealmName() or "Unknown")
    local record = guild.roster[normalizedMemberId]

    if not isCurrentGuildKickCandidate(normalizedMemberId, record, currentGuildKey) then
        return nil, "That toon is not an eligible current-guild kick target."
    end

    local memberIds = { normalizedMemberId }

    if includeLinkedAlts then
        memberIds = listCurrentGuildKickFamilyMemberIds(guild, getFamilyKey(normalizedMemberId, record), currentGuildKey)
    end

    if #memberIds <= 0 then
        return nil, "No guild members matched that kick search."
    end

    local queuedAt = time()

    for _, queuedMemberId in ipairs(memberIds) do
        runtime.manualKickMembers[queuedMemberId] = {
            queuedAt = queuedAt,
            queuedBy = addon.Store:GetPlayerName(),
            sourceMemberId = normalizedMemberId,
            includedViaSearch = queuedMemberId ~= normalizedMemberId,
        }
        runtime.manualKickSelectedMembers[queuedMemberId] = true
        guild.maintenance.selectedMembers[queuedMemberId] = true
    end

    return memberIds
end

function addon.MaintenanceService:ClearManualKickMembers()
    local guild = ensureMaintenanceState()
    local runtime = ensureHelperRuntimeState()
    local clearedCount = 0

    for queuedMemberId in pairs(runtime.manualKickMembers) do
        if runtime.manualKickSelectedMembers[queuedMemberId] then
            guild.maintenance.selectedMembers[queuedMemberId] = nil
        end

        runtime.manualKickSelectedMembers[queuedMemberId] = nil
        clearedCount = clearedCount + 1
    end

    runtime.manualKickMembers = {}
    return clearedCount
end

function addon.MaintenanceService:RemoveManualKickMember(memberId)
    local guild = ensureMaintenanceState()
    local runtime = ensureHelperRuntimeState()
    local normalizedMemberId = addon.Store:NormalizeFullName(memberId, GetRealmName() or "Unknown")
    local sourceMemberId = nil
    local removedCount = 0

    if type(runtime.manualKickMembers[normalizedMemberId]) == "table" then
        sourceMemberId = addon.Store:NormalizeFullName(runtime.manualKickMembers[normalizedMemberId].sourceMemberId or normalizedMemberId, GetRealmName() or "Unknown")
    end

    for queuedMemberId, queuedData in pairs(runtime.manualKickMembers) do
        local removeQueuedMember = queuedMemberId == normalizedMemberId

        if not removeQueuedMember and sourceMemberId == normalizedMemberId and type(queuedData) == "table" then
            local queuedSourceMemberId = addon.Store:NormalizeFullName(queuedData.sourceMemberId or queuedMemberId, GetRealmName() or "Unknown")
            removeQueuedMember = queuedSourceMemberId == normalizedMemberId
        end

        if removeQueuedMember then
            runtime.manualKickMembers[queuedMemberId] = nil

            if runtime.manualKickSelectedMembers[queuedMemberId] then
                guild.maintenance.selectedMembers[queuedMemberId] = nil
            end

            runtime.manualKickSelectedMembers[queuedMemberId] = nil
            removedCount = removedCount + 1
        end
    end

    return removedCount > 0, removedCount
end

function addon.MaintenanceService:BuildCleanupMacro(memberIds)
    local ids = {}
    local seen = {}
    local macroLines = {}
    local includedMemberIds = {}
    local bodyLength = 0
    local includedCount = 0
    local skippedCount = 0

    if type(memberIds) ~= "table" then
        for _, entry in ipairs(self:ListCleanupEntries("kick")) do
            if not seen[entry.memberId] then
                ids[#ids + 1] = entry.memberId
                seen[entry.memberId] = true
            end
        end
    else
        for _, memberId in ipairs(memberIds) do
            local normalizedMemberId = addon.Store:NormalizeFullName(memberId, GetRealmName() or "Unknown")

            if not seen[normalizedMemberId] then
                ids[#ids + 1] = normalizedMemberId
                seen[normalizedMemberId] = true
            end
        end
    end

    for _, memberId in ipairs(ids) do
        local command = buildKickCommand(memberId)
        local nextLine = #macroLines > 0 and ("\n" .. command) or command

        if (bodyLength + #nextLine) <= CLEANUP_MACRO_BODY_LIMIT then
            macroLines[#macroLines + 1] = command
            includedMemberIds[#includedMemberIds + 1] = memberId
            bodyLength = bodyLength + #nextLine
            includedCount = includedCount + 1
        else
            skippedCount = skippedCount + 1
        end
    end

    return {
        body = table.concat(macroLines, "\n"),
        includedCount = includedCount,
        skippedCount = skippedCount,
        totalCount = #ids,
        macroName = CLEANUP_MACRO_NAME,
        memberIds = ids,
        includedMemberIds = includedMemberIds,
    }
end

function addon.MaintenanceService:ExecuteBatchPayload(mode, payload)
    local resolvedMode = tostring(mode or "kick")
    local batchPayload = type(payload) == "table" and payload or nil
    local memberIds = batchPayload and batchPayload.includedMemberIds or nil

    if type(memberIds) ~= "table" or #memberIds <= 0 then
        return false, "no batch commands are ready to execute"
    end

    if resolvedMode == "promote" and not CanGuildPromote() then
        return false, "you do not have permission to promote guild members"
    end

    if resolvedMode ~= "promote" and not self:CanGuildRemoveMembers() then
        return false, "you do not have permission to remove guild members"
    end

    if resolvedMode ~= "promote" then
        self:MarkPendingKickBatch(memberIds, "batch-execute")
    end

    local executor = resolvedMode == "promote" and promoteGuildMember or removeGuildMember
    local started = 0

    for index, memberId in ipairs(memberIds) do
        local function executeOne()
            local ok, errorMessage = executor(memberId)

            if ok then
                addon:Print((resolvedMode == "promote" and "Promoting" or "Removing"), memberId)
            elseif errorMessage then
                addon:Print(errorMessage)
            end
        end

        if index == 1 or not (C_Timer and C_Timer.After) then
            executeOne()
        else
            C_Timer.After((index - 1) * 0.2, executeOne)
        end

        started = started + 1
    end

    return started > 0, started > 0 and nil or "unable to execute batch"
end

local function getSelectionBucket(guild, mode)
    local normalizedMode = tostring(mode or "kick")

    if normalizedMode == "promote" then
        return guild.maintenance.selectedPromotionMembers
    end

    return guild.maintenance.selectedMembers
end

function addon.MaintenanceService:IsMemberSelected(mode, memberId)
    local guild = ensureMaintenanceState()
    local normalizedMode = memberId and tostring(mode or "kick") or "kick"
    local resolvedMemberId = memberId or mode
    local bucket = getSelectionBucket(guild, normalizedMode)
    local normalizedMemberId = addon.Store:NormalizeFullName(resolvedMemberId, GetRealmName() or "Unknown")
    return bucket[normalizedMemberId] == true
end

function addon.MaintenanceService:GetSelectedMemberIds(mode)
    local guild = ensureMaintenanceState()
    local selectedIds = {}
    local normalizedMode = tostring(mode or "kick")
    local selectedById = getSelectionBucket(guild, normalizedMode) or {}
    local entries = normalizedMode == "promote" and self:ListPromotionEntries() or self:ListCleanupEntries("kick")

    for _, entry in ipairs(entries) do
        if selectedById[entry.memberId] then
            selectedIds[#selectedIds + 1] = entry.memberId
        end
    end

    return selectedIds
end

function addon.MaintenanceService:GetEffectiveCleanupMacroMemberIds()
    local selectedIds = self:GetSelectedMemberIds("kick")
    return selectedIds, #selectedIds > 0
end

function addon.MaintenanceService:GetEffectivePromotionMacroMemberIds()
    local selectedIds = self:GetSelectedMemberIds("promote")
    return selectedIds, #selectedIds > 0
end

function addon.MaintenanceService:ToggleSelectedMember(mode, memberId)
    local guild = ensureMaintenanceState()
    local runtime = ensureHelperRuntimeState()
    local normalizedMode = memberId and tostring(mode or "kick") or "kick"
    local resolvedMemberId = memberId or mode
    local normalizedMemberId = addon.Store:NormalizeFullName(resolvedMemberId, GetRealmName() or "Unknown")
    local bucket = getSelectionBucket(guild, normalizedMode)
    local currentValue = bucket[normalizedMemberId] == true

    if normalizedMode == "kick" then
        runtime.manualKickSelectedMembers[normalizedMemberId] = nil
    end

    if currentValue then
        bucket[normalizedMemberId] = nil
        return false
    end

    bucket[normalizedMemberId] = true
    return true
end

function addon.MaintenanceService:ClearSelectedMembers(mode)
    local guild = ensureMaintenanceState()
    local runtime = ensureHelperRuntimeState()
    local normalizedMode = tostring(mode or "kick")

    if normalizedMode == "promote" then
        guild.maintenance.selectedPromotionMembers = {}
    else
        guild.maintenance.selectedMembers = {}
        runtime.manualKickSelectedMembers = {}
    end
    refreshInviteView()
    return true
end

function addon.MaintenanceService:HandleWhoListUpdate()
    local runtime = ensureInviteRuntimeState()
    local session = ensureInviteSession(runtime)

    if runtime.status ~= "scanning" or runtime.query == "" or not session.active then
        return false
    end

    local queryGeneration = runtime.queryGeneration

    if runtime.pendingWhoParseGeneration == queryGeneration then
        return true
    end

    runtime.pendingWhoParseGeneration = queryGeneration
    runtime.scanDeadlineAt = getInviteWatchTime() + INVITE_WHO_PARSE_DELAY_SECONDS + 1

    if C_Timer and C_Timer.After then
        C_Timer.After(INVITE_WHO_PARSE_DELAY_SECONDS, function()
            parseInviteWhoResults(queryGeneration)
        end)
        return true
    end

    return parseInviteWhoResults(queryGeneration)
end

function addon.MaintenanceService:SelectAllVisibleCleanupMembers()
    local guild = ensureMaintenanceState()
    local selectedMembers = {}

    for _, entry in ipairs(self:ListCleanupEntries("kick")) do
        selectedMembers[entry.memberId] = true
    end

    guild.maintenance.selectedMembers = selectedMembers
    return self:GetSelectedMemberIds("kick")
end

function addon.MaintenanceService:SelectAllVisiblePromotionMembers()
    local guild = ensureMaintenanceState()
    local selectedMembers = {}

    for _, entry in ipairs(self:ListPromotionEntries()) do
        selectedMembers[entry.memberId] = true
    end

    guild.maintenance.selectedPromotionMembers = selectedMembers
    return self:GetSelectedMemberIds("promote")
end

function addon.MaintenanceService:SaveCleanupMacro(memberIds, macroName)
    if InCombatLockdown and InCombatLockdown() then
        return nil, "cleanup macros cannot be built during combat"
    end

    if not CreateMacro or not EditMacro or not GetNumMacros or not GetMacroInfo then
        return nil, "macro APIs are unavailable on this client"
    end

    local macroPayload = self:BuildCleanupMacro(memberIds)
    local resolvedMacroName = tostring(macroName or CLEANUP_MACRO_NAME)

    if macroPayload.includedCount <= 0 or macroPayload.body == "" then
        return nil, "no cleanup candidates fit in the macro body limit"
    end

    local macroIndex = findMacroIndexByName(resolvedMacroName)

    if macroIndex then
        local ok = pcall(EditMacro, macroIndex, resolvedMacroName, 134400, macroPayload.body, 1)

        if not ok then
            return nil, "unable to update cleanup macro"
        end
    else
        local globalCount = GetNumMacros()

        if (tonumber(globalCount) or 0) >= 120 then
            return nil, "no free global macro slots are available"
        end

        local ok = pcall(CreateMacro, resolvedMacroName, 134400, macroPayload.body, nil)

        if not ok then
            return nil, "unable to create cleanup macro"
        end
    end

    macroPayload.macroName = resolvedMacroName
    return macroPayload
end

function addon.MaintenanceService:PrepareExecutionMacro(macroBody, macroName)
    if InCombatLockdown and InCombatLockdown() then
        return nil, "batch macros cannot be prepared during combat"
    end

    if not CreateMacro or not EditMacro or not GetNumMacros or not GetMacroInfo then
        return nil, "macro APIs are unavailable on this client"
    end

    local resolvedMacroBody = tostring(macroBody or "")
    local resolvedMacroName = tostring(macroName or EXECUTION_MACRO_NAME)

    if resolvedMacroBody == "" then
        return nil, "no batch macro body is available"
    end

    local macroIndex = findMacroIndexByName(resolvedMacroName)

    if macroIndex then
        local ok = pcall(EditMacro, macroIndex, resolvedMacroName, 134400, resolvedMacroBody, 1)

        if not ok then
            return nil, "unable to update batch execution macro"
        end
    else
        local globalCount = GetNumMacros()

        if (tonumber(globalCount) or 0) >= 120 then
            return nil, "no free global macro slots are available"
        end

        local ok = pcall(CreateMacro, resolvedMacroName, 134400, resolvedMacroBody, nil)

        if not ok then
            return nil, "unable to create batch execution macro"
        end
    end

    return {
        macroName = resolvedMacroName,
        body = resolvedMacroBody,
    }
end

function addon.MaintenanceService:GetExclusionCount()
    local count = 0

    for _ in pairs(addon.Store:GetGuild().maintenance.exclusions) do
        count = count + 1
    end

    return count
end

function addon.MaintenanceService:GetFamilyBlockCount()
    local count = 0
    local guild = addon.Store:GetGuild()

    for _ in pairs(guild.maintenance.familyBlocks or {}) do
        count = count + 1
    end

    return count
end

function addon.MaintenanceService:ExcludeMember(memberId)
    memberId = addon.Store:NormalizeFullName(memberId, GetRealmName() or "Unknown")
    addon.Store:GetGuild().maintenance.exclusions[memberId] = {
        excludedAt = time(),
        excludedBy = addon.Store:GetPlayerName(),
    }

    self:ScanRoster()
end

function addon.MaintenanceService:BlockFamily(memberId)
    local guild = addon.Store:GetGuild()
    local normalizedMemberId = addon.Store:NormalizeFullName(memberId, GetRealmName() or "Unknown")
    local record = guild.roster[normalizedMemberId]
    local familyKey = getFamilyKey(normalizedMemberId, record)

    guild.maintenance.familyBlocks[familyKey] = {
        blockedAt = time(),
        blockedBy = addon.Store:GetPlayerName(),
        memberIds = listFamilyMemberIds(guild, familyKey),
    }

    self:ScanRoster()
end

function addon.MaintenanceService:ClearExclusions()
    addon.Store:GetGuild().maintenance.exclusions = {}
    addon.Store:GetGuild().maintenance.familyBlocks = {}
    self:ScanRoster()
end

function addon.MaintenanceService:PurgeMember(memberId)
    memberId = addon.Store:NormalizeFullName(memberId, GetRealmName() or "Unknown")
    addon.Store:GetGuild().roster[memberId] = nil

    for _, group in pairs(addon.Store:GetGuild().groups) do
        if type(group) == "table" and type(group.members) == "table" then
            for index = #group.members, 1, -1 do
                if group.members[index] == memberId then
                    table.remove(group.members, index)
                end
            end
        end
    end

    addon.Store:GetGuild().maintenance.staleMembers[memberId] = nil
    addon.Store:GetGuild().maintenance.pendingDeletes[memberId] = nil
    addon.Store:GetGuild().maintenance.exclusions[memberId] = nil
    addon.Store:GetGuild().maintenance.auditOfficerNoteSignatures[memberId] = nil
    addon.Store:GetSync().tableMeta.roster.updatedAt = time()
    self:ScanRoster()
end

function addon.MaintenanceService:PurgeReadyMembers()
    local memberIds = {}

    for memberId in pairs(addon.Store:GetGuild().maintenance.pendingDeletes) do
        memberIds[#memberIds + 1] = memberId
    end

    for _, memberId in ipairs(memberIds) do
        self:PurgeMember(memberId)
    end

    return #memberIds
end
