local _, GF = ...

GF.Search = {}

local NEXT_SCOPE_DELAY = 0
local RECOMMENDED_SEARCH_MASK = GF.RECOMMENDED_SEARCH_MASK
local MemoryDiagnostics = GF.SearchMemoryDiagnostics

local function getLanguages()
	return C_LFGList.GetLanguageSearchFilter and C_LFGList.GetLanguageSearchFilter() or nil
end

local function hasKeywordSearch()
	local hasKeyword = false
	if GF.FindGroupTab and GF.FindGroupTab.GetBrowseSearchState then
		hasKeyword = GF.FindGroupTab:GetBrowseSearchState()
	end
	return hasKeyword == true
end

local function readCurrentResultIDs()
	local total, ids
	if hasKeywordSearch() and C_LFGList.GetFilteredSearchResults then
		total, ids = C_LFGList.GetFilteredSearchResults()
	elseif C_LFGList.GetSearchResults then
		total, ids = C_LFGList.GetSearchResults()
	end
	return total or 0, ids or {}
end

local function normalizeScope(selection, scope)
	if not scope then
		return nil
	end
	local categoryID = scope.categoryID or (selection and selection.categoryID)
	if not categoryID then
		return nil
	end
	local preferredFilters = scope.preferredFilters
		or (selection and selection.preferredFilters)
		or Enum.LFGListFilter.PvE
	local activityID = scope.activityID or (selection and selection.activityID)
	local activityIDsFilter = scope.activityIDsFilter
	local questID
	if selection and selection._gfQuestSearch == true then
		local rawQuestID = scope.questID or selection.questID
		questID = GF.QuestSearch and GF.QuestSearch.NormalizeQuestID
			and GF.QuestSearch:NormalizeQuestID(rawQuestID) or nil
	end
	if not activityIDsFilter and not scope.resultActivityIDsFilter
		and activityID and not scope.categoryBrowse
		and not (selection and selection.categoryBrowse) then
		activityIDsFilter = { activityID }
	end

	local baseFilters = scope.filters or (selection and selection.filters) or 0
	local filters = GF.Filter and GF.Filter:ResolveCategoryFilters(categoryID, baseFilters) or baseFilters
	if GF.Filter and GF.Filter.ResolvePreferredFilters then
		preferredFilters = GF.Filter:ResolvePreferredFilters(categoryID, preferredFilters)
	end

	return {
		categoryID = categoryID,
		filters = filters,
		preferredFilters = preferredFilters,
		activityIDsFilter = activityIDsFilter,
		resultActivityIDsFilter = scope.resultActivityIDsFilter,
		navKind = scope.navKind or (selection and selection.navKind),
		questID = questID,
	}
end

local function mergeIDsInto(target, seen, ids)
	if not target or not seen or not ids then
		return
	end
	for _, id in ipairs(ids) do
		if id and not seen[id] then
			seen[id] = true
			target[#target + 1] = id
		end
	end
end

local function getScopeMergeFilters(categoryID, filters, postFilterActivities)
	filters = filters or 0
	if postFilterActivities and (categoryID == GF.CAT_RAID or categoryID == GF.CAT_DUNGEON) then
		return bit.band(filters, bit.bnot(RECOMMENDED_SEARCH_MASK))
	end
	return filters
end

local function mergeScopeFilters(categoryID, prev, nextFilters, postFilterActivities)
	prev = prev or 0
	nextFilters = nextFilters or 0
	if postFilterActivities and (categoryID == GF.CAT_RAID or categoryID == GF.CAT_DUNGEON) then
		return bit.band(prev, bit.bnot(RECOMMENDED_SEARCH_MASK))
	end
	return prev
end

local function shouldPostFilterActivityIDs(scope, postFilterRaidActivities, postFilterDungeonActivities)
	if not scope then
		return false
	end
	if scope.categoryID == GF.CAT_RAID then
		return postFilterRaidActivities
	end
	if scope.categoryID == GF.CAT_DUNGEON then
		return postFilterDungeonActivities and scope.navKind == "dungeon"
	end
	return false
end

local function mergeNormalizedScopes(scopes)
	local raidScopeCount = 0
	local dungeonScopeCount = 0
	for _, scope in ipairs(scopes or {}) do
		if scope and scope.categoryID == GF.CAT_RAID then
			raidScopeCount = raidScopeCount + 1
		elseif scope and scope.categoryID == GF.CAT_DUNGEON and scope.navKind == "dungeon" then
			dungeonScopeCount = dungeonScopeCount + 1
		end
	end
	local postFilterRaidActivities = raidScopeCount > 1
	local postFilterDungeonActivities = dungeonScopeCount > 1
	local merged = {}
	local order = {}
	for _, scope in ipairs(scopes or {}) do
		if scope and scope.categoryID then
			local postFilterActivities = shouldPostFilterActivityIDs(scope, postFilterRaidActivities, postFilterDungeonActivities)
			local key = table.concat({
				tostring(scope.categoryID),
				tostring(getScopeMergeFilters(scope.categoryID, scope.filters, postFilterActivities)),
				tostring(scope.preferredFilters or 0),
				postFilterActivities and "post" or "native",
				tostring(scope.questID or ""),
			}, ":")
			local out = merged[key]
			if not out then
				out = {
					categoryID = scope.categoryID,
					filters = getScopeMergeFilters(scope.categoryID, scope.filters, postFilterActivities),
					preferredFilters = scope.preferredFilters,
					navKind = scope.navKind,
					questID = scope.questID,
					activityIDsFilter = (not postFilterActivities and scope.activityIDsFilter) and {} or nil,
					resultActivityIDsFilter = (postFilterActivities and (scope.activityIDsFilter or scope.resultActivityIDsFilter))
						and {} or (scope.resultActivityIDsFilter and {} or nil),
					_seenActivityIDs = {},
					_seenResultActivityIDs = {},
				}
				merged[key] = out
				order[#order + 1] = key
			else
				out.filters = mergeScopeFilters(out.categoryID, out.filters, scope.filters, postFilterActivities)
			end
			if postFilterActivities then
				if not out.resultActivityIDsFilter and (scope.activityIDsFilter or scope.resultActivityIDsFilter) then
					out.resultActivityIDsFilter = {}
				end
				if out.resultActivityIDsFilter then
					mergeIDsInto(out.resultActivityIDsFilter, out._seenResultActivityIDs, scope.activityIDsFilter)
				end
			elseif scope.activityIDsFilter == nil then
				out.activityIDsFilter = nil
			elseif out.activityIDsFilter then
				mergeIDsInto(out.activityIDsFilter, out._seenActivityIDs, scope.activityIDsFilter)
			end
			if postFilterActivities then
				if out.resultActivityIDsFilter then
					mergeIDsInto(out.resultActivityIDsFilter, out._seenResultActivityIDs, scope.resultActivityIDsFilter)
				end
			elseif scope.resultActivityIDsFilter == nil then
				out.resultActivityIDsFilter = nil
			elseif out.resultActivityIDsFilter then
				mergeIDsInto(out.resultActivityIDsFilter, out._seenResultActivityIDs, scope.resultActivityIDsFilter)
			end
		end
	end

	local result = {}
	for _, key in ipairs(order) do
		local scope = merged[key]
		scope._seenActivityIDs = nil
		scope._seenResultActivityIDs = nil
		result[#result + 1] = scope
	end
	return result
end

function GF.Search:Reset()
	if MemoryDiagnostics and MemoryDiagnostics.AbortSearch then
		MemoryDiagnostics:AbortSearch("reset")
	end
	self.pending = nil
	self.aggregateResultIDs = nil
	self.aggregateTotal = nil
	self.aggregateInfoByID = nil
	self.aggregateMemberCountsByID = nil
	self.nextScopeScheduled = nil
	self.context = nil
	self.inFlight = false
end

function GF.Search:GetRequestBarrierRemaining()
	local expiresAt = tonumber(self.abandonedUntil)
	if not expiresAt or not GetTime then
		return 0
	end
	local remaining = expiresAt - GetTime()
	if remaining <= 0 then
		self.abandonedUntil = nil
		self.abandonedContext = nil
		return 0
	end
	return remaining
end

function GF.Search:GetCooldownRemaining()
	if not self.lastNativeSearchAt or not GetTime then
		return 0
	end
	local remaining = (GF.SEARCH_COOLDOWN or 3) - (GetTime() - self.lastNativeSearchAt)
	return math.max(0, remaining)
end

function GF.Search:AbandonActiveRequest()
	local wasInFlight = self.inFlight == true
	if wasInFlight and GetTime then
		self.abandonedUntil = GetTime() + (GF.SEARCH_TIMEOUT_SECONDS or 8)
		self.abandonedContext = self.context
	end
	self:Reset()
	return wasInFlight
end

function GF.Search:ConsumeAbandonedEvent()
	if self:GetRequestBarrierRemaining() <= 0 then
		return false
	end
	self.abandonedUntil = nil
	self.abandonedContext = nil
	return true
end

function GF.Search:ClearAggregatedResultIDs()
	self.aggregateResultIDs = nil
	self.aggregateTotal = nil
	self.aggregateInfoByID = nil
	self.aggregateMemberCountsByID = nil
end

function GF.Search:GetAggregatedResultIDs()
	return self.aggregateResultIDs, self.aggregateTotal, self.aggregateInfoByID, self.aggregateMemberCountsByID
end

-- GF and Blizzard's hidden SearchPanel share the same C_LFGList result store.
-- Release its retained ID before an API call can synchronously invalidate it.
function GF.Search:ReleaseNativeSearchSelection()
	local finderFrame = _G and _G.LFGListFrame
	local searchPanel = finderFrame and finderFrame.SearchPanel
	if not searchPanel then
		return false
	end
	searchPanel.selectedResult = nil
	return true
end

function GF.Search:BuildScopes(selection, context)
	local rawScopes
	if GF.NavData and GF.NavData.ResolveSearchScopes then
		rawScopes = GF.NavData.ResolveSearchScopes(selection, { forSearch = true })
	elseif selection and GF.NavData and GF.NavData.ResolveSearchScope then
		rawScopes = { GF.NavData.ResolveSearchScope(selection, { forSearch = true }) }
	elseif selection then
		rawScopes = { selection }
	else
		rawScopes = {}
	end

	local scopes = {}
	for _, scope in ipairs(rawScopes or {}) do
		local normalized = normalizeScope(selection, scope)
		if normalized then
			scopes[#scopes + 1] = normalized
		end
	end
	scopes = mergeNormalizedScopes(scopes)
	if GF.LFGWorkspacePolicy and GF.LFGWorkspacePolicy.ConstrainSearchScopes then
		scopes = GF.LFGWorkspacePolicy:ConstrainSearchScopes(
			context and context.workspaceID,
			selection,
			scopes
		)
	end
	return scopes
end

function GF.Search:ClearNativeQuestSearch()
	if self.nativeQuestSearchActive == nil then
		return true
	end
	local clearResults = C_LFGList and C_LFGList.ClearSearchResults
	local clearText = C_LFGList and C_LFGList.ClearSearchTextFields
	if type(clearResults) ~= "function" or type(clearText) ~= "function" then
		return false
	end
	self:ReleaseNativeSearchSelection()
	local resultsOK = pcall(clearResults)
	local textOK = pcall(clearText)
	if not resultsOK or not textOK then
		return false
	end
	self.nativeQuestSearchActive = nil
	return true
end

function GF.Search:SuspendNativeQuestSearch()
	if self.nativeQuestSearchActive == nil then
		return true
	end
	local clearText = C_LFGList and C_LFGList.ClearSearchTextFields
	if type(clearText) ~= "function" then
		return false
	end
	-- A normally hidden Browse page keeps its result store so that result IDs
	-- remain actionable when the same context is shown again.  Only release the
	-- native quest field here; changing scope/resetting still performs the full
	-- ClearSearchResults + ClearSearchTextFields teardown above.
	return pcall(clearText)
end

function GF.Search:PrepareNativeQuestSearch(questID)
	questID = GF.QuestSearch and GF.QuestSearch.NormalizeQuestID
		and GF.QuestSearch:NormalizeQuestID(questID) or nil
	if not questID then
		return false
	end
	if self.nativeQuestSearchActive ~= questID then
		local clearResults = C_LFGList and C_LFGList.ClearSearchResults
		local clearText = C_LFGList and C_LFGList.ClearSearchTextFields
		if type(clearResults) ~= "function" or type(clearText) ~= "function" then
			return false
		end
		self:ReleaseNativeSearchSelection()
		local resultsOK = pcall(clearResults)
		local textOK = pcall(clearText)
		if not resultsOK or not textOK then
			return false
		end
	end
	local setter = C_LFGList and C_LFGList.SetSearchToQuestID
	if type(setter) ~= "function" or not pcall(setter, questID) then
		return false
	end
	self.nativeQuestSearchActive = questID
	return true
end

local function scopeNeedsAggregatedResults(scope)
	return scope and scope.resultActivityIDsFilter and #scope.resultActivityIDsFilter > 0
end

function GF.Search:_RunScope(scope)
	if not scope or not scope.categoryID then
		return false
	end
	if GF.Availability and GF.Availability.ShouldProcessLfgEvent
		and not GF.Availability:ShouldProcessLfgEvent() then
		return false
	end
	local search = C_LFGList and C_LFGList.Search
	if type(search) ~= "function" then
		return false
	end
	local languagesOK, languages = pcall(getLanguages)
	if not languagesOK then
		return false
	end
	if scope.questID ~= nil then
		if not self:PrepareNativeQuestSearch(scope.questID) then
			return false
		end
	elseif self.nativeQuestSearchActive ~= nil then
		if not self:ClearNativeQuestSearch() then
			return false
		end
	end
	self:ReleaseNativeSearchSelection()
	local ok = pcall(
		search,
		scope.categoryID,
		scope.filters or 0,
		scope.preferredFilters or Enum.LFGListFilter.PvE,
		languages,
		nil,
		nil,
		scope.activityIDsFilter
	)
	if not ok then
		if scope.questID ~= nil then
			self:ClearNativeQuestSearch()
		end
		return false
	end
	if GetTime then
		self.lastNativeSearchAt = GetTime()
	end
	return true
end

function GF.Search:_RunPendingScope()
	local pending = self.pending
	if not pending then
		return false
	end
	return self:_RunScope(pending.scopes[pending.index])
end

function GF.Search:_SchedulePendingScope()
	if self.nextScopeScheduled then
		return true
	end
	local pending = self.pending
	if not pending then
		return false
	end
	self.nextScopeScheduled = true
	C_Timer.After(NEXT_SCOPE_DELAY, function()
		self.nextScopeScheduled = nil
		local currentPending = self.pending
		if not currentPending or currentPending ~= pending then
			return
		end
		if self:_RunPendingScope() then
			return
		end
		self.pending = nil
		self.aggregateResultIDs = nil
		self.aggregateTotal = nil
		self.aggregateInfoByID = nil
		self.aggregateMemberCountsByID = nil
		if MemoryDiagnostics and MemoryDiagnostics.AbortSearch then
			MemoryDiagnostics:AbortSearch("scope-start-failed")
		end
		if GF.FindGroupTab and GF.FindGroupTab.OnSearchFailed then
			GF.FindGroupTab:OnSearchFailed()
		end
	end)
	return true
end

function GF.Search:GetContext()
	return self.context
end

function GF.Search:MatchesContext(context)
	local active = self.context
	if not active or not context then
		return false
	end
	return active.key == context.key
		and active.workspaceID == context.workspaceID
		and tonumber(active.generation) == tonumber(context.generation)
		and tonumber(active.searchToken) == tonumber(context.searchToken)
end

function GF.Search:Run(selection, context)
	if self.inFlight then
		self:AbandonActiveRequest()
		return false
	end
	if self:GetRequestBarrierRemaining() > 0 or self:GetCooldownRemaining() > 0 then
		return false
	end
	local reusableAggregateInfo = self.aggregateInfoByID
	local needsFreshAggregateInfo = false
	if GF.Result and GF.Result.NeedsFreshSearchResultInfo then
		needsFreshAggregateInfo = GF.Result:NeedsFreshSearchResultInfo()
	elseif GF.Result and GF.Result.NeedsPostFilters then
		needsFreshAggregateInfo = GF.Result:NeedsPostFilters()
	end
	local canReuseAggregateInfo = reusableAggregateInfo ~= nil
		and needsFreshAggregateInfo ~= true
	self:Reset()
	if GF.FindGroup and GF.FindGroup.IsSearchableSelection
		and not GF.FindGroup:IsSearchableSelection(selection) then
		return false
	end
	local scopes = self:BuildScopes(selection, context)
	if #scopes == 0 then
		return false
	end
	self.context = context
	self.inFlight = true
	if MemoryDiagnostics and MemoryDiagnostics.BeginSearch then
		MemoryDiagnostics:BeginSearch(#scopes)
	end
	if #scopes == 1 then
		if scopeNeedsAggregatedResults(scopes[1]) then
			self.pending = {
				scopes = scopes,
				index = 1,
				resultIDs = {},
				seen = {},
				infoByID = {},
				memberCountsByID = {},
				reusableInfoByID = canReuseAggregateInfo
					and reusableAggregateInfo or nil,
				total = 0,
			}
			local started = self:_RunPendingScope()
			if not started then
				self:Reset()
			end
			return started
		end
		local started = self:_RunScope(scopes[1])
		if not started then
			self:Reset()
		end
		return started
	end
	self.pending = {
		scopes = scopes,
		index = 1,
		resultIDs = {},
		seen = {},
		infoByID = {},
		memberCountsByID = {},
		reusableInfoByID = canReuseAggregateInfo
			and reusableAggregateInfo or nil,
		total = 0,
	}
	local started = self:_RunPendingScope()
	if not started then
		self:Reset()
	end
	return started
end

local function resultMatchesActivitySet(info, activitySet)
	if not info then
		return false
	end
	if type(info.activityIDs) == "table" then
		for _, activityID in ipairs(info.activityIDs) do
			if activityID and activitySet[activityID] == true then
				return true
			end
		end
	end
	local activityID = GF.SearchResultSnapshot
		and GF.SearchResultSnapshot.GetPrimaryActivityID
		and GF.SearchResultSnapshot.GetPrimaryActivityID(info)
	if not activityID then
		activityID = info.activityID
	end
	return activityID and activitySet[activityID] == true or false
end

local function buildActivityIDSet(activityIDs)
	if not activityIDs or #activityIDs == 0 then
		return nil
	end
	local set = {}
	for _, activityID in ipairs(activityIDs) do
		if activityID then
			set[activityID] = true
		end
	end
	return set
end

local function getSearchResultMemberCounts(resultID)
	if not resultID or not (C_LFGList and C_LFGList.GetSearchResultMemberCounts) then
		return nil
	end
	if MemoryDiagnostics and MemoryDiagnostics.current then
		MemoryDiagnostics:Add("searchMemberCountReads", 1)
	end
	local ok, counts = pcall(C_LFGList.GetSearchResultMemberCounts, resultID)
	if ok and type(counts) == "table" then
		return counts
	end
	return nil
end

local function getSearchResultInfo(resultID)
	if not resultID or not (C_LFGList and C_LFGList.GetSearchResultInfo) then
		return nil
	end
	if MemoryDiagnostics and MemoryDiagnostics.current then
		MemoryDiagnostics:Add("freshSearchInfo", 1)
	end
	return C_LFGList.GetSearchResultInfo(resultID)
end

local function updateSummaryMemberTotal(info, counts)
	if type(info) ~= "table" or type(counts) ~= "table" then
		return
	end
	local total = (tonumber(counts.TANK) or 0)
		+ (tonumber(counts.HEALER) or 0)
		+ (tonumber(counts.DAMAGER) or 0)
	info.numMembers = total
end

function GF.Search:OnSearchResults()
	local pending = self.pending
	if not pending then
		self.inFlight = false
		return "complete"
	end

	local scope = pending.scopes[pending.index]
	local isFinalScope = pending.index >= #pending.scopes
	local resultActivitySet = buildActivityIDSet(scope and scope.resultActivityIDsFilter)
	local total, ids = readCurrentResultIDs()
	local scopeKept, scopeReused = 0, 0
	for _, resultID in ipairs(ids or {}) do
		local info = pending.reusableInfoByID
			and pending.reusableInfoByID[resultID]
		if info then
			scopeReused = scopeReused + 1
		end
		local keep = true
		if resultActivitySet then
			info = info or getSearchResultInfo(resultID)
			keep = resultMatchesActivitySet(info, resultActivitySet)
		end
		if keep then
			scopeKept = scopeKept + 1
			if not pending.seen[resultID] then
				pending.seen[resultID] = true
				pending.resultIDs[#pending.resultIDs + 1] = resultID
				pending.total = (pending.total or 0) + 1
			end
			if resultID and C_LFGList.GetSearchResultInfo and not pending.infoByID[resultID] then
				local capturedInfo = info or getSearchResultInfo(resultID)
				if capturedInfo and GF.ResolveSearchResultSocialCounts then
					GF.ResolveSearchResultSocialCounts(capturedInfo, resultID)
				end
				local compactedInfo = capturedInfo
				if capturedInfo and GF.SearchResultSnapshot
					and GF.SearchResultSnapshot.CompactSearchResultInfo
				then
					compactedInfo = GF.SearchResultSnapshot.CompactSearchResultInfo(
						capturedInfo, true)
					if compactedInfo ~= capturedInfo
						and MemoryDiagnostics and MemoryDiagnostics.current
					then
						MemoryDiagnostics:Add("summariesCompacted", 1)
					end
				end
				pending.infoByID[resultID] = compactedInfo
			end
			-- The final scope remains in Blizzard's native result store after the
			-- aggregate search completes. Its role counts can therefore stay lazy
			-- and be read only by visible rows or active predicates. Earlier scopes
			-- must snapshot counts before the next native search replaces them.
			if resultID and not isFinalScope
				and not pending.memberCountsByID[resultID]
			then
				local counts = getSearchResultMemberCounts(resultID)
				pending.memberCountsByID[resultID] = counts
				updateSummaryMemberTotal(pending.infoByID[resultID], counts)
			end
		end
	end
	if MemoryDiagnostics and MemoryDiagnostics.current then
		MemoryDiagnostics:RecordScope(
			total, #ids, scopeKept, scopeReused)
	end

	if pending.index < #pending.scopes then
		pending.index = pending.index + 1
		if self:_SchedulePendingScope() then
			return "continue"
		end
		self.pending = nil
		self.aggregateResultIDs = nil
		self.aggregateTotal = nil
		self.aggregateInfoByID = nil
		self.aggregateMemberCountsByID = nil
		self.inFlight = false
		if MemoryDiagnostics and MemoryDiagnostics.AbortSearch then
			MemoryDiagnostics:AbortSearch("scope-failed")
		end
		return "failed"
	end

	self.aggregateResultIDs = pending.resultIDs
	self.aggregateTotal = #pending.resultIDs
	self.aggregateInfoByID = pending.infoByID
	self.aggregateMemberCountsByID = pending.memberCountsByID
	self.pending = nil
	self.inFlight = false
	return "complete"
end

function GF.Search:OnSearchFailed()
	if MemoryDiagnostics and MemoryDiagnostics.AbortSearch then
		MemoryDiagnostics:AbortSearch("search-failed")
	end
	self.pending = nil
	self.aggregateResultIDs = nil
	self.aggregateTotal = nil
	self.aggregateInfoByID = nil
	self.aggregateMemberCountsByID = nil
	self.nextScopeScheduled = nil
	self.context = nil
	self.inFlight = false
end
