local addonName, GF = ...

-- ApplicationService owns the authoritative application lifecycle.  It is
-- deliberately independent from row frames and dialogs so every Find Group
-- surface observes the same native state, retry ledger, and group identity.
GF.ApplicationService = {}
local Service = GF.ApplicationService

local INACTIVE_STATUS = {
	cancelled = true,
	failed = true,
	declined = true,
	timedout = true,
	invitedeclined = true,
	inviteaccepted = true,
}

local LOCAL_CANCELLATION_SECONDS = 2

local function normalizedResultID(candidate)
	local resultID = tonumber(candidate)
	return resultID and resultID > 0 and resultID or nil
end

local function isDeclinedStatus(status)
	return status == "declined"
		or status == "declined_delisted"
		or status == "declined_full"
end

local function isInactiveStatus(status)
	if status and INACTIVE_STATUS[status] then
		return true
	end
	if type(LFGListUtil_IsStatusInactive) == "function" then
		return LFGListUtil_IsStatusInactive(status)
	end
	return false
end

local function isJoinedStatus(status)
	return status == "invited" or status == "inviteaccepted"
end

local function isApplicationStatus(status, pendingStatus)
	return (status ~= nil and status ~= "none") or pendingStatus ~= nil
end

local function readSnapshotField(owner, key)
	local snapshot = GF.SearchResultSnapshot
	if snapshot and type(snapshot.ReadField) == "function" then
		return snapshot.ReadField(owner, key)
	end
	if type(owner) ~= "table" then
		return nil, "unavailable"
	end
	if type(issecretvaluekey) == "function" then
		local secretOK, secret = pcall(issecretvaluekey, owner, key)
		if secretOK and secret == true then
			return nil, "secret"
		end
	end
	local ok, value = pcall(function()
		return owner[key]
	end)
	if not ok then
		return nil, "error"
	end
	if type(issecretvalue) == "function" then
		local secretOK, secret = pcall(issecretvalue, value)
		if secretOK and secret == true then
			return nil, "secret"
		end
	end
	return value, value == nil and "missing" or "value"
end

local function usableIdentity(value)
	if value == nil or (type(value) == "string" and value == "") then
		return nil
	end
	if type(issecretvalue) == "function" then
		local ok, secret = pcall(issecretvalue, value)
		if ok and secret == true then
			return nil
		end
	end
	return value
end

local function resultPartyGUID(info)
	local value, state = readSnapshotField(info, "partyGUID")
	return state == "value" and usableIdentity(value) or nil
end

local function playerIsInHomeGroup()
	if type(IsInGroup) == "function" then
		local ok, inGroup
		if LE_PARTY_CATEGORY_HOME ~= nil then
			ok, inGroup = pcall(IsInGroup, LE_PARTY_CATEGORY_HOME)
		else
			ok, inGroup = pcall(IsInGroup)
		end
		if ok then
			return inGroup == true
		end
	end
	if type(GetNumGroupMembers) == "function" then
		local ok, count
		if LE_PARTY_CATEGORY_HOME ~= nil then
			ok, count = pcall(GetNumGroupMembers, LE_PARTY_CATEGORY_HOME)
		else
			ok, count = pcall(GetNumGroupMembers)
		end
		return ok and (tonumber(count) or 0) > 0
	end
	return false
end

local function isHomePartyCategory(category)
	return category == (LE_PARTY_CATEGORY_HOME or 1)
end

local function queryCurrentGroupPartyGUID()
	local socialQueue = C_SocialQueue
	if not (socialQueue and type(socialQueue.GetGroupForPlayer) == "function")
		or type(UnitGUID) ~= "function"
	then
		return nil
	end
	local guidOK, playerGUID = pcall(UnitGUID, "player")
	playerGUID = guidOK and usableIdentity(playerGUID) or nil
	if not playerGUID then
		return nil
	end
	local ok, partyGUID = pcall(socialQueue.GetGroupForPlayer, playerGUID)
	return ok and usableIdentity(partyGUID) or nil
end

function Service:NormalizeResultID(candidate)
	return normalizedResultID(candidate)
end

function Service:GetAuthoritativeResultInfo(resultID)
	local resultService = GF.Result
	if resultService
		and type(resultService.GetAuthoritativeSearchResultInfo) == "function"
	then
		local info = resultService:GetAuthoritativeSearchResultInfo(resultID)
		if info ~= nil then
			return info
		end
	end
	local getter = C_LFGList and C_LFGList.GetSearchResultInfo
	if type(getter) ~= "function" then
		return nil
	end
	local ok, info = pcall(getter, resultID)
	return ok and info or nil
end

function Service:GetNativeResultInfo(resultID)
	resultID = normalizedResultID(resultID)
	local api = C_LFGList
	local getter = api and api.GetSearchResultInfo
	if resultID == nil or type(getter) ~= "function" then
		return nil
	end
	if type(api.HasSearchResultInfo) == "function" then
		local ok, available = pcall(api.HasSearchResultInfo, resultID)
		if ok ~= true or available ~= true then
			return nil
		end
	end
	local ok, info = pcall(getter, resultID)
	return ok and info or nil
end

function Service:GetResultPartyGUID(info)
	return resultPartyGUID(info)
end

function Service:IsPlayerInHomeGroup()
	return playerIsInHomeGroup()
end

function Service:SetPresentationPort(port)
	self._presentationPort = type(port) == "table" and port or nil
end

function Service:ObserveApplicationPriority(resultID)
	resultID = normalizedResultID(resultID)
	if not resultID then
		return nil
	end
	self._applicationPriorityByResultID =
		self._applicationPriorityByResultID or {}
	local priority = self._applicationPriorityByResultID[resultID]
	if priority == nil then
		self._applicationPrioritySequence =
			(self._applicationPrioritySequence or 0) + 1
		priority = self._applicationPrioritySequence
		self._applicationPriorityByResultID[resultID] = priority
	end
	return priority
end

function Service:ForgetApplicationPriority(resultID)
	resultID = normalizedResultID(resultID)
	if resultID and self._applicationPriorityByResultID then
		self._applicationPriorityByResultID[resultID] = nil
	end
end

function Service:GetApplicationPriority(resultID)
	resultID = normalizedResultID(resultID)
	if not resultID then
		return nil
	end
	local priority = self._applicationPriorityByResultID
		and self._applicationPriorityByResultID[resultID]
	return priority or self:ObserveApplicationPriority(resultID)
end

function Service:PeekApplicationPriority(resultID)
	resultID = normalizedResultID(resultID)
	return resultID and self._applicationPriorityByResultID
		and self._applicationPriorityByResultID[resultID] or nil
end

function Service:ResolveApplyTarget(index, resultID)
	local results = GF.Result
	local numericID = normalizedResultID(resultID)
	if numericID and results
		and type(results.GetIndexForResultID) == "function"
	then
		local currentIndex = results:GetIndexForResultID(numericID)
		if currentIndex ~= nil then
			return currentIndex, numericID
		end
		return nil, numericID, "stale"
	end
	local numericIndex = tonumber(index)
	if numericIndex and results and type(results.GetResultID) == "function" then
		local indexedID = results:GetResultID(numericIndex)
		if indexedID ~= nil then
			return numericIndex, indexedID
		end
	end
	return nil, nil, "missing"
end

function Service:IsDelisted(index, resultID)
	local _, resolvedID = self:ResolveApplyTarget(index, resultID)
	if resolvedID == nil then
		return true
	end
	local results = GF.Result
	local cached = results and results.entryCache
		and results.entryCache[resolvedID] or nil
	if cached and cached.info
		and type(results.IsSoftUnavailable) == "function"
		and results:IsSoftUnavailable(cached.info)
	then
		return true
	end
	local info = self:GetNativeResultInfo(resolvedID)
	if info == nil then
		return true
	end
	if results
		and type(results.ShouldHideUnavailableResult) == "function"
		and results:ShouldHideUnavailableResult(resolvedID, info)
	then
		return true
	end
	local delisted, state = readSnapshotField(info, "isDelisted")
	if state == "secret" or state == "error" then
		return true
	end
	return state == "value" and delisted == true
end

function Service:CanSelectRow(index, resultID)
	local resolvedIndex, resolvedID = self:ResolveApplyTarget(index, resultID)
	if resolvedIndex == nil
		or self:IsCurrentGroupResult(resolvedID)
		or self:IsDelisted(resolvedIndex, resolvedID)
	then
		return false
	end
	return resolvedID == nil or not self:HasApplication(resolvedID)
end

function Service:GetCurrentGroupPartyGUID()
	if not playerIsInHomeGroup() then
		return nil
	end
	local partyGUID = usableIdentity(self.currentGroupPartyGUID)
	if not partyGUID then
		partyGUID = queryCurrentGroupPartyGUID()
		if partyGUID then
			self.currentGroupPartyGUID = partyGUID
		end
	end
	return partyGUID
end

function Service:SetCurrentGroupResultID(resultID, info)
	resultID = normalizedResultID(resultID)
	if not resultID then
		return false
	end
	local changed = self.currentGroupResultID ~= resultID
	local previousID = self.currentGroupResultID
	if previousID and previousID ~= resultID then
		changed = self:SuppressJoinedApplication(previousID) or changed
	end
	self.currentGroupResultID = resultID
	if self.suppressedJoinedApplications then
		self.suppressedJoinedApplications[resultID] = nil
	end
	self:TrackJoinedApplication(resultID, "inviteaccepted", true, true)
	local tracked = self.joinedApplications and self.joinedApplications[resultID]
	if tracked then
		tracked.currentGroupConfirmed = true
	end
	local partyGUID = resultPartyGUID(info)
	if partyGUID and self.currentGroupPartyGUID ~= partyGUID then
		self.currentGroupPartyGUID = partyGUID
		changed = true
	end
	local projection = GF.CurrentGroupProjection
	if projection and type(projection.Observe) == "function" and info then
		projection:Observe(resultID, info, nil, GF.Result)
	end
	return changed
end

function Service:IsCurrentGroupResult(resultID, info)
	resultID = normalizedResultID(resultID)
	if not resultID or not playerIsInHomeGroup() then
		return false
	end
	if self.suppressedJoinedApplications
		and self.suppressedJoinedApplications[resultID]
	then
		return false
	end
	info = info or self:GetAuthoritativeResultInfo(resultID)
	local currentPartyGUID = self:GetCurrentGroupPartyGUID()
	local searchPartyGUID = resultPartyGUID(info)
	if currentPartyGUID and searchPartyGUID then
		return currentPartyGUID == searchPartyGUID
	end
	if self.currentGroupResultID == resultID then
		return true
	end
	local tracked = self.joinedApplications and self.joinedApplications[resultID]
	if tracked and tracked.currentGroupConfirmed == true then
		return true
	end
	local hasSelf, state = readSnapshotField(info, "hasSelf")
	return state == "value" and hasSelf == true
end

function Service:OnJoinedGroup(resultID)
	resultID = normalizedResultID(resultID)
	if not resultID then
		return false
	end
	local changed = self:SetCurrentGroupResultID(
		resultID, self:GetAuthoritativeResultInfo(resultID))
	self._wasInHomeGroup = playerIsInHomeGroup()
	return changed
end

function Service:OnGroupJoined(category, partyGUID)
	if not isHomePartyCategory(category) then
		return false
	end
	partyGUID = usableIdentity(partyGUID)
	local changed = self._wasInHomeGroup ~= true
	local previousGUID = usableIdentity(self.currentGroupPartyGUID)
	if partyGUID and previousGUID and partyGUID ~= previousGUID then
		if self.currentGroupResultID then
			changed = self:SuppressJoinedApplication(self.currentGroupResultID)
				or changed
		end
		self.currentGroupResultID = nil
	end
	if partyGUID and partyGUID ~= previousGUID then
		self.currentGroupPartyGUID = partyGUID
		changed = true
	end
	local projection = GF.CurrentGroupProjection
	if projection and type(projection.OnGroupJoined) == "function" then
		projection:OnGroupJoined(partyGUID)
	end
	self._wasInHomeGroup = true
	return changed
end

function Service:OnGroupLeft(category, partyGUID)
	if not isHomePartyCategory(category) then
		return false
	end
	partyGUID = usableIdentity(partyGUID)
	local currentGUID = usableIdentity(self.currentGroupPartyGUID)
	if partyGUID and currentGUID and partyGUID ~= currentGUID then
		return false
	end
	local changed = self._wasInHomeGroup == true
	if self.currentGroupResultID then
		changed = self:SuppressJoinedApplication(self.currentGroupResultID)
			or changed
	end
	if self.joinedApplications then
		local observedIDs = {}
		for resultID, entry in pairs(self.joinedApplications) do
			if entry and entry.observedGroup == true then
				observedIDs[#observedIDs + 1] = resultID
			end
		end
		for _, resultID in ipairs(observedIDs) do
			changed = self:SuppressJoinedApplication(resultID) or changed
		end
	end
	local projection = GF.CurrentGroupProjection
	if projection and type(projection.OnGroupLeft) == "function" then
		changed = projection:OnGroupLeft(partyGUID) or changed
	end
	self.currentGroupResultID = nil
	self.currentGroupPartyGUID = nil
	self._wasInHomeGroup = false
	return changed
end

function Service:TrackJoinedApplication(
	resultID, status, observedGroup, acceptRequested)
	resultID = normalizedResultID(resultID)
	if not resultID then
		return false
	end
	self.joinedApplications = self.joinedApplications or {}
	local entry = self.joinedApplications[resultID] or {}
	entry.status = status or entry.status
	entry.observedGroup = entry.observedGroup or observedGroup == true
	entry.acceptRequested = entry.acceptRequested or acceptRequested == true
	self.joinedApplications[resultID] = entry
	return true
end

function Service:ClearJoinedApplication(resultID)
	resultID = normalizedResultID(resultID)
	if not resultID then
		return
	end
	if self.joinedApplications then
		self.joinedApplications[resultID] = nil
	end
	if self.suppressedJoinedApplications then
		self.suppressedJoinedApplications[resultID] = nil
	end
end

function Service:IsJoinedApplicationTracked(resultID)
	resultID = normalizedResultID(resultID)
	if not resultID then
		return false
	end
	return self.currentGroupResultID == resultID
		or (self.joinedApplications
			and self.joinedApplications[resultID] ~= nil)
		or (self.suppressedJoinedApplications
			and self.suppressedJoinedApplications[resultID] == true)
end

function Service:SuppressJoinedApplication(resultID)
	resultID = normalizedResultID(resultID)
	if not resultID then
		return false
	end
	self.suppressedJoinedApplications = self.suppressedJoinedApplications or {}
	if self.suppressedJoinedApplications[resultID] then
		return false
	end
	self.suppressedJoinedApplications[resultID] = true
	if self.joinedApplications then
		self.joinedApplications[resultID] = nil
	end
	return true
end

function Service:IsJoinedApplicationSuppressed(
	resultID, applicationStatus, pendingStatus)
	resultID = normalizedResultID(resultID)
	if not resultID or not self.suppressedJoinedApplications
		or not self.suppressedJoinedApplications[resultID]
	then
		return false
	end
	if isJoinedStatus(applicationStatus) or isJoinedStatus(pendingStatus) then
		return true
	end
	self.suppressedJoinedApplications[resultID] = nil
	return false
end

function Service:MarkApplicationCancelled(resultID, onExpired)
	resultID = normalizedResultID(resultID)
	if not resultID then
		return false
	end
	self.localCancelled = self.localCancelled or {}
	self.localCancelledTokens = self.localCancelledTokens or {}
	self.localCancelled[resultID] = GetTime() + LOCAL_CANCELLATION_SECONDS
	local token = 1 + (self.localCancelledTokens[resultID] or 0)
	self.localCancelledTokens[resultID] = token
	if C_Timer and type(C_Timer.After) == "function" then
		C_Timer.After(LOCAL_CANCELLATION_SECONDS, function()
			local tokens = Service.localCancelledTokens
			if not tokens or tokens[resultID] ~= token then
				return
			end
			tokens[resultID] = nil
			if Service.localCancelled then
				Service.localCancelled[resultID] = nil
			end
			if type(onExpired) == "function" then
				onExpired(resultID)
			end
		end)
	end
	return true
end

local function appendUniqueResultIDs(target, seen, source)
	if type(source) ~= "table" then
		return
	end
	for _, candidate in ipairs(source) do
		local resultID = normalizedResultID(candidate)
		if resultID and not seen[resultID] then
			seen[resultID] = true
			target[#target + 1] = resultID
		end
	end
end

local RejectionLedger = {}
RejectionLedger.__index = RejectionLedger

local function rejectionLedger(owner)
	if owner._rejectionLedger == nil then
		owner._rejectionLedger = setmetatable({
			records = {},
			signals = {},
		}, RejectionLedger)
	end
	return owner._rejectionLedger
end

function RejectionLedger:GetRecord(partyGUID, create)
	if partyGUID == nil then
		return nil
	end
	local record = self.records[partyGUID]
	if record == nil and create == true then
		record = { revision = 0, retryReady = false }
		self.records[partyGUID] = record
	end
	return record
end

function RejectionLedger:GetRevision(partyGUID)
	local record = self:GetRecord(partyGUID, false)
	return record and record.revision or 0
end

function RejectionLedger:GetStatus(partyGUID)
	local record = self:GetRecord(partyGUID, false)
	return record and record.status or nil
end

function RejectionLedger:SeedNativeDecline(partyGUID, status)
	local record = self:GetRecord(partyGUID, false)
	if record == nil then
		record = self:GetRecord(partyGUID, true)
		record.status = status
	end
	return record.revision
end

function RejectionLedger:IsRetryReady(partyGUID)
	local record = self:GetRecord(partyGUID, false)
	return record ~= nil and record.retryReady == true
end

function RejectionLedger:Remember(partyGUID, status)
	local record = self:GetRecord(partyGUID, true)
	record.revision = record.revision + 1
	record.status = status
	record.retryReady = false
	return record.revision
end

function RejectionLedger:Unlock(partyGUID, expectedRevision)
	local record = self:GetRecord(partyGUID, false)
	if record == nil or record.revision ~= expectedRevision then
		return false, false
	end
	local newlyUnlocked = record.retryReady ~= true
	record.retryReady = true
	record.status = nil
	return true, newlyUnlocked
end

function RejectionLedger:ResetRetryPermissions()
	for _, record in pairs(self.records) do
		record.retryReady = false
	end
end

function RejectionLedger:RemoveSignal(resultID, expected, keepTimer)
	local entry = self.signals[resultID]
	if entry == nil or (expected ~= nil and entry ~= expected) then
		return false
	end
	self.signals[resultID] = nil
	local timer = entry.timer
	entry.timer = nil
	if keepTimer ~= true and timer and type(timer.Cancel) == "function" then
		timer:Cancel()
	end
	return true
end

function RejectionLedger:ClearSignals(partyGUID)
	local cleared = false
	for resultID, entry in pairs(self.signals) do
		if partyGUID == nil or entry.partyGUID == partyGUID then
			cleared = self:RemoveSignal(resultID, entry, false) or cleared
		end
	end
	return cleared
end

function Service:IsRetryAllowed(resultID, resultInfo)
	resultID = normalizedResultID(resultID)
	resultInfo = resultInfo or (resultID
		and self:GetAuthoritativeResultInfo(resultID))
	return rejectionLedger(self):IsRetryReady(resultPartyGUID(resultInfo))
end

function Service:SnapshotRejectedParties()
	local results = GF.Result
	local resultIDs, seen = {}, {}
	if results then
		appendUniqueResultIDs(resultIDs, seen, results.resultIDs)
		appendUniqueResultIDs(resultIDs, seen, results.apiResultIDs)
		appendUniqueResultIDs(resultIDs, seen, results.frozenOrder)
	end
	local captured = {}
	local ledger = rejectionLedger(self)
	for _, resultID in ipairs(resultIDs) do
		local resultInfo = self:GetAuthoritativeResultInfo(resultID)
		local partyGUID = resultPartyGUID(resultInfo)
		local rememberedStatus = ledger:GetStatus(partyGUID)
		local nativeDeclined, nativeStatus = false, nil
		local reader = C_LFGList and C_LFGList.GetApplicationInfo
		if type(reader) == "function" then
			local ok, _, status = pcall(reader, resultID)
			nativeDeclined = ok == true and isDeclinedStatus(status)
			nativeStatus = nativeDeclined and status or nil
		end
		if partyGUID
			and (nativeDeclined or isDeclinedStatus(rememberedStatus))
		then
			captured[partyGUID] = nativeDeclined
				and ledger:SeedNativeDecline(partyGUID, nativeStatus)
				or ledger:GetRevision(partyGUID)
		end
	end
	return next(captured) ~= nil and captured or nil
end

function Service:ApplyManualRefreshUnlocks(captured, resultIDs)
	if type(captured) ~= "table" or type(resultIDs) ~= "table" then
		return 0
	end
	local ledger = rejectionLedger(self)
	local unlocked = 0
	for _, resultID in ipairs(resultIDs) do
		local info = self:GetAuthoritativeResultInfo(resultID)
		local partyGUID = resultPartyGUID(info)
		local capturedRevision = partyGUID and captured[partyGUID]
		if capturedRevision ~= nil then
			local matched, newlyUnlocked = ledger:Unlock(
				partyGUID, capturedRevision)
			if matched and newlyUnlocked then
				unlocked = unlocked + 1
			end
		end
	end
	self:ClearRejectionFeedback()
	return unlocked
end

function Service:GetApplicationState(resultID, suppliedResultInfo)
	local reader = C_LFGList and C_LFGList.GetApplicationInfo
	if resultID == nil or type(reader) ~= "function" then
		return nil
	end
	local _, appStatus, pendingStatus, appDuration = reader(resultID)
	local departed = self:IsJoinedApplicationSuppressed(
		resultID, appStatus, pendingStatus)
	local cancelledUntil = self.localCancelled and self.localCancelled[resultID]
	local showLocalCancellation = cancelledUntil ~= nil
		and cancelledUntil > GetTime()
	if cancelledUntil and not showLocalCancellation then
		self.localCancelled[resultID] = nil
	end
	local resultInfo = suppliedResultInfo
		or self:GetAuthoritativeResultInfo(resultID)
	local partyGUID = resultPartyGUID(resultInfo)
	local declined = isDeclinedStatus(appStatus)
	local rememberedDecline = rejectionLedger(self):GetStatus(partyGUID)
	if not declined and rememberedDecline ~= nil then
		appStatus = rememberedDecline
		declined = true
	end
	if declined and rememberedDecline == nil
		and self:IsRetryAllowed(resultID, resultInfo)
	then
		appStatus = "none"
		declined = false
	end
	local present = isApplicationStatus(appStatus, pendingStatus)
	if showLocalCancellation and not present then
		appStatus = "cancelled"
		present = true
	end
	local finished = declined
		or isInactiveStatus(appStatus)
		or isInactiveStatus(pendingStatus)
	return {
		appStatus = appStatus,
		pendingStatus = pendingStatus,
		appDuration = appDuration,
		isDeclined = declined,
		isApplication = present,
		isActiveApp = present and not finished,
		isDepartedApplication = departed,
	}
end

function Service:HasApplication(resultID)
	local state = self:GetApplicationState(resultID)
	return state ~= nil and state.isApplication == true
end

function Service:IsDeclinedApplication(resultID)
	local state = self:GetApplicationState(resultID)
	return state ~= nil and state.isDeclined == true
end

function Service:HasActiveApplication()
	local api = C_LFGList
	if not api or type(api.GetApplications) ~= "function"
		or type(api.GetApplicationInfo) ~= "function"
	then
		return false
	end
	for _, applicationID in ipairs(api.GetApplications() or {}) do
		local state = self:GetApplicationState(applicationID)
		if state and state.isActiveApp == true then
			return true
		end
	end
	return false
end

function Service:GetActiveApplicationCount()
	local api = C_LFGList
	if not api or type(api.GetApplications) ~= "function"
		or type(api.GetApplicationInfo) ~= "function"
	then
		return 0
	end
	local count = 0
	for _, applicationID in ipairs(api.GetApplications() or {}) do
		local state = self:GetApplicationState(applicationID)
		if state and state.isActiveApp == true then
			count = count + 1
		end
	end
	return count
end

function Service:ShouldPinApplication(resultID)
	local state = self:GetApplicationState(resultID)
	if not state or state.isApplication ~= true then
		return false
	end
	return state.isDeclined == true or not isInactiveStatus(state.appStatus)
end

local function currentFeedbackScope(owner)
	local port = owner._presentationPort
	if not port or type(port.GetRejectionFeedbackScope) ~= "function" then
		return nil
	end
	return port:GetRejectionFeedbackScope()
end

local function feedbackScopeMatches(owner, entry)
	if type(entry) ~= "table" then
		return false
	end
	local current = currentFeedbackScope(owner)
	return current ~= nil
		and current.searchToken == entry.searchToken
		and current.activeKey == entry.activeKey
end

local function feedbackEntryMatches(owner, resultID, entry, ignoreDeadline)
	if type(entry) ~= "table" or not feedbackScopeMatches(owner, entry) then
		return false
	end
	if not ignoreDeadline then
		local expiresAt = tonumber(entry.expiresAt)
		if expiresAt == nil or expiresAt <= GetTime() then
			return false
		end
	end
	local info = owner:GetAuthoritativeResultInfo(resultID)
	local partyGUID = resultPartyGUID(info)
	if partyGUID == nil or partyGUID ~= entry.partyGUID then
		return false
	end
	return rejectionLedger(owner):GetRevision(partyGUID) == entry.revision
end

function Service:ClearRejectionFeedback(partyGUID)
	return rejectionLedger(self):ClearSignals(partyGUID)
end

function Service:HasRejectionFeedback(resultID)
	resultID = normalizedResultID(resultID)
	local ledger = rejectionLedger(self)
	local entry = resultID and ledger.signals[resultID] or nil
	if entry == nil then
		return false
	end
	if not feedbackEntryMatches(self, resultID, entry, false) then
		ledger:RemoveSignal(resultID, entry, false)
		return false
	end
	return true
end

function Service:StartRejectionFeedback(resultID, partyGUID, revision)
	resultID = normalizedResultID(resultID)
	local context = currentFeedbackScope(self)
	local results = GF.Result
	if resultID == nil or partyGUID == nil or context == nil
		or not C_Timer or type(C_Timer.NewTimer) ~= "function"
		or not results or type(results.IsFrozenResult) ~= "function"
		or results:IsFrozenResult(resultID) ~= true
	then
		return false
	end
	local ledger = rejectionLedger(self)
	ledger:ClearSignals(partyGUID)
	ledger:RemoveSignal(resultID, nil, false)
	local delay = GF.BROWSE_DECLINED_FILTER_FEEDBACK_SECONDS or 0.8
	local entry = {
		partyGUID = partyGUID,
		revision = revision,
		searchToken = context.searchToken,
		activeKey = context.activeKey,
		expiresAt = GetTime() + delay,
	}
	ledger.signals[resultID] = entry
	if type(results.InvalidateAsyncJobs) == "function" then
		results:InvalidateAsyncJobs()
	end
	entry.timer = C_Timer.NewTimer(delay, function()
		entry.timer = nil
		local activeLedger = rejectionLedger(Service)
		if activeLedger.signals[resultID] ~= entry then
			return
		end
		local shouldReapply = feedbackEntryMatches(
			Service, resultID, entry, true)
		activeLedger:RemoveSignal(resultID, entry, true)
		local port = Service._presentationPort
		if shouldReapply and port
			and type(port.OnRejectionFeedbackExpired) == "function"
		then
			port:OnRejectionFeedbackExpired()
		end
	end)
	return true
end

function Service:ObserveRejectedApplication(resultID, status)
	local info = self:GetAuthoritativeResultInfo(resultID)
	local partyGUID = resultPartyGUID(info)
	local ledger = rejectionLedger(self)
	local revision
	if partyGUID then
		revision = ledger:Remember(partyGUID, status)
		ledger:ClearSignals(partyGUID)
	else
		ledger:ClearSignals()
		ledger:ResetRetryPermissions()
		local port = self._presentationPort
		if port and type(port.DiscardManualDeclineIntent) == "function" then
			port:DiscardManualDeclineIntent()
		end
	end
	self:StartRejectionFeedback(resultID, partyGUID, revision)
end

function Service:OnApplicationStatusUpdated(resultID, newStatus, onCancelExpired)
	if resultID == nil then
		return { changed = false }
	end
	local needsInviteHook = false
	if isJoinedStatus(newStatus) then
		local normalizedID = normalizedResultID(resultID)
		if newStatus == "invited" then
			resultID = normalizedID or resultID
			if normalizedID and self.suppressedJoinedApplications then
				self.suppressedJoinedApplications[normalizedID] = nil
			end
			self:TrackJoinedApplication(resultID, newStatus, false, false)
			needsInviteHook = true
		else
			local observedGroup = playerIsInHomeGroup()
			self:TrackJoinedApplication(
				resultID, newStatus, observedGroup, true)
			if observedGroup then
				self._wasInHomeGroup = true
			end
		end
	else
		self:ClearJoinedApplication(resultID)
	end
	local state = self:GetApplicationState(resultID)
	if state and state.isActiveApp == true then
		self:ObserveApplicationPriority(resultID)
	end
	local declined = isDeclinedStatus(newStatus)
	if newStatus ~= "inviteaccepted"
		and isInactiveStatus(newStatus) and not declined
	then
		self:MarkApplicationCancelled(resultID, onCancelExpired)
	end
	if not declined then
		if not self:IsDeclinedApplication(resultID) then
			rejectionLedger(self):RemoveSignal(
				normalizedResultID(resultID), nil, false)
		end
	else
		self:ObserveRejectedApplication(resultID, newStatus)
	end
	return {
		changed = false,
		needsInviteHook = needsInviteHook,
	}
end

function Service:OnGroupRosterChanged()
	local inGroup = playerIsInHomeGroup()
	local changed = self._wasInHomeGroup ~= nil
		and self._wasInHomeGroup ~= inGroup
	self._wasInHomeGroup = inGroup
	if not inGroup then
		if self.currentGroupResultID then
			changed = self:SuppressJoinedApplication(self.currentGroupResultID)
				or changed
		end
		if self.currentGroupResultID ~= nil
			or self.currentGroupPartyGUID ~= nil
		then
			changed = true
		end
		self.currentGroupResultID = nil
		self.currentGroupPartyGUID = nil
	end
	local api = C_LFGList
	local canRead = api and type(api.GetApplications) == "function"
		and type(api.GetApplicationInfo) == "function"
	if canRead then
		local seen = {}
		local applications = api.GetApplications() or {}
		for index = 1, #applications do
			local resultID = normalizedResultID(applications[index])
			if resultID then
				seen[resultID] = true
				local _, status, pendingStatus = api.GetApplicationInfo(resultID)
				local joined = isJoinedStatus(status)
					or isJoinedStatus(pendingStatus)
				local accepted = status == "inviteaccepted"
					or pendingStatus == "inviteaccepted"
				local tracked = self.joinedApplications
					and self.joinedApplications[resultID]
				if joined and (accepted or tracked) then
					local wasObserved = tracked
						and tracked.observedGroup == true
					self:TrackJoinedApplication(
						resultID,
						status or pendingStatus,
						inGroup and (accepted
							or (tracked and tracked.acceptRequested)),
						tracked and tracked.acceptRequested)
					tracked = self.joinedApplications
						and self.joinedApplications[resultID]
					if inGroup and tracked
						and (accepted or tracked.acceptRequested)
					then
						tracked.observedGroup = true
						if accepted then
							changed = self:SetCurrentGroupResultID(
								resultID,
								self:GetNativeResultInfo(resultID)) or changed
						end
						if not wasObserved then
							changed = true
						end
					elseif not inGroup and tracked
						and (tracked.observedGroup or accepted)
					then
						changed = self:SuppressJoinedApplication(resultID)
							or changed
					end
				elseif tracked then
					self:ClearJoinedApplication(resultID)
				end
			end
		end
		if not inGroup and self.joinedApplications then
			for resultID, entry in pairs(self.joinedApplications) do
				if not seen[resultID] and entry and entry.observedGroup then
					changed = self:SuppressJoinedApplication(resultID)
						or changed
				end
			end
		end
	end
	local projection = GF.CurrentGroupProjection
	if projection and type(projection.OnRosterChanged) == "function" then
		changed = projection:OnRosterChanged(
			inGroup, self:GetCurrentGroupPartyGUID()) or changed
	end
	return changed
end

function Service:CancelApplication(resultID, onCancelExpired)
	local api = C_LFGList or {}
	local cancel = api.CancelApplication or api.WithdrawApplication
	if resultID == nil or type(cancel) ~= "function" then
		local locale = GF.L or {}
		return false,
			locale.APPLY_CANCEL_UNAVAILABLE or "取消申请接口不可用。"
	end
	local ok, reason = pcall(cancel, resultID)
	if ok ~= true then
		return false, tostring(reason
			or ((GF.L or {}).APPLY_CANCEL_FAILED or "取消申请失败。"))
	end
	self:MarkApplicationCancelled(resultID, onCancelExpired)
	return true
end

function Service:PinApplicationsToTop(ids, topID)
	local api = C_LFGList
	local applicationIDs = api and type(api.GetApplications) == "function"
		and api.GetApplications() or {}
	local pinnedOrder, pinnedSet = {}, {}
	local function addPinned(candidateID)
		if candidateID == nil or pinnedSet[candidateID] then
			return
		end
		local cachedInfo = GF.Result
			and type(GF.Result.GetCachedSearchResultInfo) == "function"
			and GF.Result:GetCachedSearchResultInfo(candidateID) or nil
		local state = self:GetApplicationState(candidateID, cachedInfo)
		if not state or state.isActiveApp ~= true then
			return
		end
		self:ObserveApplicationPriority(candidateID)
		pinnedSet[candidateID] = true
		pinnedOrder[#pinnedOrder + 1] = candidateID
	end
	for _, applicationID in ipairs(applicationIDs) do
		addPinned(applicationID)
	end
	addPinned(topID)
	if self._applicationPriorityByResultID then
		for applicationID in pairs(self._applicationPriorityByResultID) do
			if not pinnedSet[applicationID] then
				self._applicationPriorityByResultID[applicationID] = nil
			end
		end
	end
	table.sort(pinnedOrder, function(leftID, rightID)
		local leftPriority = self:GetApplicationPriority(leftID)
		local rightPriority = self:GetApplicationPriority(rightID)
		if leftPriority ~= rightPriority then
			return (leftPriority or math.huge)
				< (rightPriority or math.huge)
		end
		return leftID < rightID
	end)
	local currentSet, currentCount = {}, 0
	for _, resultID in ipairs(type(ids) == "table" and ids or {}) do
		local cachedInfo = GF.Result
			and type(GF.Result.GetCachedSearchResultInfo) == "function"
			and GF.Result:GetCachedSearchResultInfo(resultID) or nil
		if self:IsCurrentGroupResult(resultID, cachedInfo) then
			currentSet[resultID] = true
			currentCount = currentCount + 1
		end
	end
	if #pinnedOrder == 0 and currentCount == 0 then
		return ids
	end
	local ordered, orderedSet = {}, {}
	local function append(resultID)
		if resultID ~= nil and not orderedSet[resultID] then
			orderedSet[resultID] = true
			ordered[#ordered + 1] = resultID
		end
	end
	for _, resultID in ipairs(type(ids) == "table" and ids or {}) do
		if currentSet[resultID] then
			append(resultID)
		end
	end
	for _, resultID in ipairs(pinnedOrder) do
		if not currentSet[resultID] then
			append(resultID)
		end
	end
	for _, resultID in ipairs(type(ids) == "table" and ids or {}) do
		if not pinnedSet[resultID] and not currentSet[resultID] then
			append(resultID)
		end
	end
	return ordered
end

function Service:FindExpiryCandidate()
	local api = C_LFGList or {}
	if type(api.GetApplications) ~= "function"
		or type(api.GetApplicationInfo) ~= "function"
	then
		return nil
	end
	local selectedID, shortestRemaining = nil, math.huge
	for _, applicationID in ipairs(api.GetApplications() or {}) do
		local _, status, pendingStatus, secondsRemaining =
			api.GetApplicationInfo(applicationID)
		secondsRemaining = tonumber(secondsRemaining)
		if status == "applied" and not pendingStatus
			and secondsRemaining ~= nil
			and secondsRemaining < shortestRemaining
		then
			selectedID = applicationID
			shortestRemaining = secondsRemaining
		end
	end
	return selectedID
end

function Service:ShouldReleaseSlotFor(resultID)
	local db = GF.GetDB and GF.GetDB()
	if not db or db.replaceOldestApplication ~= true or not resultID then
		return false
	end
	local counter = C_LFGList and C_LFGList.GetNumApplications
	if type(counter) ~= "function" then
		return false
	end
	local _, activeCount = counter()
	if not activeCount or not MAX_LFG_LIST_APPLICATIONS
		or activeCount < MAX_LFG_LIST_APPLICATIONS
	then
		return false
	end
	local state = self:GetApplicationState(resultID)
	if state and state.isApplication == true then
		return false
	end
	local resultInfo = self:GetAuthoritativeResultInfo(resultID)
	local delisted, delistedState = readSnapshotField(resultInfo, "isDelisted")
	if resultInfo == nil
		or delistedState == "secret" or delistedState == "error"
	then
		return false
	end
	return not (delistedState == "value" and delisted == true)
end

function Service:GetBlizzardApplyBlockReason()
	if type(LFGListUtil_GetActiveQueueMessage) == "function" then
		local queueMessage = LFGListUtil_GetActiveQueueMessage(true)
		if queueMessage ~= nil then
			local hasActiveEntry = false
			local session = GF.RecruitmentSession
			if session and type(session.HasActive) == "function" then
				local ok, active = pcall(session.HasActive, session)
				hasActiveEntry = ok == true and active == true
			end
			if hasActiveEntry
				and queueMessage == CANNOT_DO_THIS_WHILE_LFGLIST_LISTED
			then
				return (GF.L or {}).APPLY_FAILED_ALREADY_IN_GROUP
					or "Application failed: you have already joined a group."
			end
			return queueMessage
		end
	end
	if type(LFGListUtil_IsAppEmpowered) == "function"
		and LFGListUtil_IsAppEmpowered() ~= true
	then
		return LFG_LIST_APP_UNEMPOWERED
	end
	local api = C_LFGList or {}
	local home = LE_PARTY_CATEGORY_HOME
	if type(IsInGroup) == "function" and IsInGroup(home)
		and type(api.IsCurrentlyApplying) == "function"
		and api.IsCurrentlyApplying()
	then
		return LFG_LIST_APP_CURRENTLY_APPLYING
	end
	if type(api.GetNumApplications) == "function" then
		local _, activeCount = api.GetNumApplications()
		if activeCount ~= nil and MAX_LFG_LIST_APPLICATIONS ~= nil
			and activeCount >= MAX_LFG_LIST_APPLICATIONS
		then
			return string.format(
				LFG_LIST_HIT_MAX_APPLICATIONS,
				MAX_LFG_LIST_APPLICATIONS)
		end
	end
	if type(GetNumGroupMembers) == "function"
		and MAX_PARTY_MEMBERS ~= nil
		and GetNumGroupMembers(home) > (MAX_PARTY_MEMBERS + 1)
	then
		return LFG_LIST_MAX_MEMBERS
	end
	if type(api.GetAvailableRoles) == "function" then
		local canTank, canHeal, canDealDamage = api.GetAvailableRoles()
		if canTank ~= true and canHeal ~= true and canDealDamage ~= true then
			return LFG_LIST_MUST_CHOOSE_SPEC
		end
	end
	if type(GroupHasOfflineMember) == "function"
		and GroupHasOfflineMember(home)
	then
		return LFG_LIST_OFFLINE_MEMBER
	end
	return nil
end

function Service:GetSelectedRoleFlags()
	local api = C_LFGList
	if type(GetLFGRoles) ~= "function" or not api
		or type(api.GetAvailableRoles) ~= "function"
	then
		return nil
	end
	local rolesOK, _, tankSelected, healerSelected, damageSelected =
		pcall(GetLFGRoles)
	if rolesOK ~= true then
		return nil
	end
	local availableOK, tankAvailable, healerAvailable, damageAvailable =
		pcall(api.GetAvailableRoles)
	if availableOK ~= true then
		return nil
	end
	local tank = tankSelected == true and tankAvailable == true
	local healer = healerSelected == true and healerAvailable == true
	local damage = damageSelected == true and damageAvailable == true
	if not tank and not healer and not damage then
		return nil
	end
	return tank, healer, damage
end

function Service:ApplyToGroup(resultID, tank, healer, damage)
	local submit = C_LFGList and C_LFGList.ApplyToGroup
	if resultID == nil or type(submit) ~= "function" then
		return false
	end
	return submit(resultID, tank, healer, damage)
end

function Service:ClearApplicationTextFields()
	local clear = C_LFGList and C_LFGList.ClearApplicationTextFields
	if type(clear) ~= "function" then
		return false
	end
	return pcall(clear)
end

function Service:TryAutoAcceptInvite(enabled, onAccepted)
	local api = C_LFGList
	local ready = api
		and type(api.GetApplications) == "function"
		and type(api.GetApplicationInfo) == "function"
		and type(api.AcceptInvite) == "function"
	if enabled ~= true or self._acceptInFlight == true or not ready then
		return false
	end
	if type(LFGListUtil_IsAppEmpowered) == "function" then
		local ok, empowered = pcall(LFGListUtil_IsAppEmpowered)
		if not ok or empowered ~= true then
			return false
		end
	end
	self._acceptInFlight = true
	local scanOK, accepted = pcall(function()
		local listOK, applications = pcall(api.GetApplications)
		if not listOK or type(applications) ~= "table" then
			return false
		end
		for _, applicationID in ipairs(applications) do
			local infoOK, _, status, pendingStatus = pcall(
				api.GetApplicationInfo, applicationID)
			if infoOK and status == "invited" and not pendingStatus then
				local acceptOK = pcall(api.AcceptInvite, applicationID)
				if acceptOK then
					if type(onAccepted) == "function" then
						onAccepted(applicationID, status)
					end
					return true
				end
			end
		end
		return false
	end)
	self._acceptInFlight = false
	return scanOK and accepted == true
end

function Service:IsLfgListRoleCheckActive()
	local api = C_LFGList
	if not api or type(api.GetRoleCheckInfo) ~= "function"
		or type(CompleteLFGRoleCheck) ~= "function"
	then
		return false
	end
	if type(GetLFGRoleUpdate) == "function" then
		local ok, inProgress = pcall(GetLFGRoleUpdate)
		if not ok or inProgress ~= true then
			return false
		end
	end
	local ok, isLFGList = pcall(api.GetRoleCheckInfo)
	return ok and isLFGList == true
end

function Service:TryAutoConfirmLfgListRoleCheck(enabled)
	if enabled ~= true or self._roleCheckConfirmInFlight
		or not self:IsLfgListRoleCheckActive()
	then
		return false
	end
	local tank, healer, damage = self:GetSelectedRoleFlags()
	if not (tank or healer or damage) then
		return false
	end
	if type(LFDPopupCheckRoleSelectionValid) == "function" then
		local ok, valid = pcall(
			LFDPopupCheckRoleSelectionValid, tank, healer, damage)
		if not ok or valid ~= true then
			return false
		end
	end
	self._roleCheckConfirmInFlight = true
	local roleOK = true
	if type(SetLFGRoles) == "function" and type(GetLFGRoles) == "function" then
		local leaderOK, leader = pcall(GetLFGRoles)
		roleOK = leaderOK
			and pcall(SetLFGRoles, leader, tank, healer, damage)
	end
	local completeOK, completed = false, false
	if roleOK then
		completeOK, completed = pcall(CompleteLFGRoleCheck, true)
	end
	self._roleCheckConfirmInFlight = false
	if completeOK and completed then
		if type(StaticPopupSpecial_Hide) == "function" and LFDRoleCheckPopup then
			StaticPopupSpecial_Hide(LFDRoleCheckPopup)
		end
		return true
	end
	return false
end
