local _, GF = ...

local NavCatalog = GF.NavCatalog or {}
GF.NavCatalog = NavCatalog

local EMPTY = {}
local PVE = Enum.LFGListFilter.PvE
local RECOMMENDED = Enum.LFGListFilter.Recommended
local NOT_RECOMMENDED = Enum.LFGListFilter.NotRecommended
local CURRENT_SEASON = Enum.LFGListFilter.CurrentSeason
local CURRENT_EXPANSION = Enum.LFGListFilter.CurrentExpansion
local NOT_CURRENT_SEASON = Enum.LFGListFilter.NotCurrentSeason
local TIMERUNNING = Enum.LFGListFilter.Timerunning or 0
local RECOMMENDED_MASK = bit.bor(RECOMMENDED, NOT_RECOMMENDED)
local STATIC_RAID_SOLO_ORDER_BASE = 100000
local JOURNAL_LORE_IMAGE_TEX_COORDS = {
	0.052734375,
	0.703125,
	0.091796875,
	0.556640625,
}

local runtimeCatalogCache = {}
local journalCatalogCache = {}
local staticCatalogLookupCache = {}
local manualCatalogLookupCache = {}
local expansionHintLookupCache = {}

local function clearTable(t)
	for k in pairs(t) do
		t[k] = nil
	end
end

local function currentExpansionIndex()
	if GetServerExpansionLevel then
		return GetServerExpansionLevel()
	end
	return 11
end

local function clientDisplayExpansionIndex()
	-- PTR realm progression may trail the client data set. Use the larger
	-- display/server value only for classifying normal Encounter Journal tiers.
	local serverExpansion = tonumber(currentExpansionIndex()) or 0
	if type(GetClientDisplayExpansionLevel) ~= "function" then
		return serverExpansion
	end
	local ok, clientExpansion = pcall(GetClientDisplayExpansionLevel)
	clientExpansion = ok and tonumber(clientExpansion) or nil
	if clientExpansion == nil then
		return serverExpansion
	end
	return math.max(serverExpansion, clientExpansion)
end

local function expansionLabel(expansionIndex)
	if GetExpansionName then
		local name = GetExpansionName(expansionIndex)
		if name and name ~= "" then
			return name
		end
	end
	return tostring(expansionIndex)
end

local function bor(...)
	local value = 0
	for i = 1, select("#", ...) do
		local flag = select(i, ...)
		if flag and flag ~= 0 then
			value = bit.bor(value, flag)
		end
	end
	return value
end

local function addUnique(out, seen, value)
	value = value or 0
	if seen[value] then
		return
	end
	seen[value] = true
	out[#out + 1] = value
end

local function pcallList(fn, ...)
	if type(fn) ~= "function" then
		return EMPTY
	end
	local ok, result = pcall(fn, ...)
	if ok and type(result) == "table" then
		return result
	end
	return EMPTY
end

local function getAvailableActivityGroups(categoryID, filterFlags)
	return pcallList(C_LFGList and C_LFGList.GetAvailableActivityGroups, categoryID, filterFlags or 0)
end

local function getAvailableActivities(categoryID, groupID, filterFlags, searchText)
	local getter = C_LFGList and C_LFGList.GetAvailableActivities
	if searchText ~= nil then
		return pcallList(getter, categoryID, groupID, filterFlags or 0, searchText)
	end
	return pcallList(getter, categoryID, groupID, filterFlags or 0)
end

local function getActivityInfo(activityID)
	if not (C_LFGList and C_LFGList.GetActivityInfoTable and activityID) then
		return nil
	end
	local ok, info = pcall(C_LFGList.GetActivityInfoTable, activityID)
	if ok and type(info) == "table" then
		return info
	end
	return nil
end

local function getActivityGroupInfo(groupID)
	if not (C_LFGList and C_LFGList.GetActivityGroupInfo and groupID) then
		return nil, nil
	end
	local ok, name, orderIndex = pcall(C_LFGList.GetActivityGroupInfo, groupID)
	if ok then
		return name, orderIndex
	end
	return nil, nil
end

local function cleanText(value)
	if type(value) ~= "string" then
		return nil
	end
	if value == "" then
		return nil
	end
	return value
end

local function normalizedName(value)
	value = cleanText(value)
	if not value then
		return nil
	end
	value = value:gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", "")
	value = value:gsub("[%s%p%c]+", "")
	value = value:gsub("[：:（）()%[%]【】「」『』《》〈〉、，。；;！!？?%-_—–·]+", "")
	if value == "" then
		return nil
	end
	return string.lower(value)
end

local function trimText(value)
	value = cleanText(value)
	if not value then
		return nil
	end
	value = value:gsub("^%s+", ""):gsub("%s+$", "")
	return value ~= "" and value or nil
end

local function escapePattern(value)
	value = cleanText(value)
	if not value then
		return nil
	end
	return (value:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1"))
end

local function extractParentheticalHint(value)
	value = cleanText(value)
	if not value then
		return nil
	end
	local hint = value:match("（([^（）]+)）")
	if cleanText(hint) then
		return hint
	end
	hint = value:match("%(([^()]+)%)")
	if cleanText(hint) then
		return hint
	end
	return nil
end

local function removeParentheticalHints(value)
	value = cleanText(value)
	if not value then
		return nil
	end
	value = value:gsub("%s*（[^（）]+）", "")
	value = value:gsub("%s*%([^()]+%)", "")
	return trimText(value)
end

local function addLabelCandidate(out, seen, value)
	value = trimText(value)
	if not value or seen[value] then
		return
	end
	seen[value] = true
	out[#out + 1] = value
end

local function difficultyLabelCandidates(info)
	local labels = {}
	local seen = {}
	if GF.ActivityInfo and GF.ActivityInfo.GetDifficultyLabel then
		addLabelCandidate(labels, seen, GF.ActivityInfo.GetDifficultyLabel(info, { includeMplus = true }))
	end
	local L = GF.L or {}
	addLabelCandidate(labels, seen, L.DIFF_NORMAL)
	addLabelCandidate(labels, seen, L.DIFF_HEROIC)
	addLabelCandidate(labels, seen, L.DIFF_MYTHIC)
	addLabelCandidate(labels, seen, L.DIFF_MYTHIC_PLUS)
	addLabelCandidate(labels, seen, "Normal")
	addLabelCandidate(labels, seen, "Heroic")
	addLabelCandidate(labels, seen, "Mythic")
	addLabelCandidate(labels, seen, "Mythic Keystone")
	addLabelCandidate(labels, seen, "普通")
	addLabelCandidate(labels, seen, "英雄")
	addLabelCandidate(labels, seen, "史诗")
	addLabelCandidate(labels, seen, "傳奇")
	addLabelCandidate(labels, seen, "传说钥石")
	addLabelCandidate(labels, seen, "傳奇鑰石")
	return labels
end

local function stripKnownDifficultySuffix(value, info)
	value = trimText(value)
	if not value then
		return nil
	end
	for _, label in ipairs(difficultyLabelCandidates(info)) do
		local escaped = escapePattern(label)
		if escaped then
			local stripped = trimText(value:gsub("%s*[-–—]%s*" .. escaped .. "%s*$", ""))
			if stripped and stripped ~= value then
				return stripped
			end
			stripped = trimText(value:gsub("%s+" .. escaped .. "%s*$", ""))
			if stripped and stripped ~= value then
				return stripped
			end
			stripped = trimText(value:gsub("%s*（" .. escaped .. "）%s*$", ""))
			if stripped and stripped ~= value then
				return stripped
			end
			stripped = trimText(value:gsub("%s*%(" .. escaped .. "%)%s*$", ""))
			if stripped and stripped ~= value then
				return stripped
			end
		end
	end
	return value
end

local function soloDisplayName(info)
	if type(info) ~= "table" then
		return nil
	end
	local name = GF.ActivityInfo and GF.ActivityInfo.GetActivityBaseName and GF.ActivityInfo.GetActivityBaseName(info)
		or cleanText(info.fullName)
		or cleanText(info.shortName)
	name = removeParentheticalHints(name) or name
	name = trimText(name and name:gsub("%s*[-–—]%s*", "-"))
	return name
end

local function soloPairBaseKey(info)
	local name = soloDisplayName(info)
	name = stripKnownDifficultySuffix(name, info)
	return normalizedName(name)
end

local function addExpansionCandidate(out, seen, expansionIndex)
	expansionIndex = tonumber(expansionIndex)
	if expansionIndex == nil or seen[expansionIndex] then
		return
	end
	seen[expansionIndex] = true
	out[#out + 1] = expansionIndex
end

local function collectExpansionCandidates(kind)
	local candidates = {}
	local seen = {}
	local source = GF.NAV_CATALOG and GF.NAV_CATALOG[kind]
	for _, expansion in ipairs((source and source.expansions) or EMPTY) do
		addExpansionCandidate(candidates, seen, expansion.expansionIndex)
	end
	source = GF.NAV_CATALOG_SUPPLEMENT and GF.NAV_CATALOG_SUPPLEMENT[kind]
	for _, expansion in ipairs((source and source.expansions) or EMPTY) do
		addExpansionCandidate(candidates, seen, expansion.expansionIndex)
	end
	source = GF.NAV_MANUAL_CATALOG and GF.NAV_MANUAL_CATALOG[kind]
	for _, expansion in ipairs((source and source.expansions) or EMPTY) do
		addExpansionCandidate(candidates, seen, expansion.expansionIndex)
	end
	local maxExpansion = (currentExpansionIndex() or 0) + 1
	for expansionIndex = 0, maxExpansion do
		addExpansionCandidate(candidates, seen, expansionIndex)
	end
	return candidates
end

local function expansionIndexFromHint(kind, hint)
	local key = kind .. ":" .. tostring(normalizedName(hint) or "")
	local cached = expansionHintLookupCache[key]
	if cached ~= nil then
		return cached ~= false and cached or nil
	end

	local hintKey = normalizedName(hint)
	if not hintKey then
		expansionHintLookupCache[key] = false
		return nil
	end
	for _, expansionIndex in ipairs(collectExpansionCandidates(kind)) do
		if normalizedName(expansionLabel(expansionIndex)) == hintKey then
			expansionHintLookupCache[key] = expansionIndex
			return expansionIndex
		end
	end
	expansionHintLookupCache[key] = false
	return nil
end

local function catalogMeta(kind)
	if kind == "dungeon" then
		return {
			categoryID = GF.CAT_DUNGEON,
			baseFilters = bor(CURRENT_EXPANSION, NOT_CURRENT_SEASON, PVE),
			preferredFilters = PVE,
		}
	elseif kind == "raid" then
		return {
			categoryID = GF.CAT_RAID,
			baseFilters = bor(RECOMMENDED, PVE),
			preferredFilters = PVE,
		}
	end
	return nil
end

local function resolveEntryCreationFilter(kind, filterFlags)
	filterFlags = tonumber(filterFlags) or 0
	if kind == "dungeon" then
		-- Blizzard LFGList.lua ResolveCategoryFilters(): dungeon Activity Finder
		-- forces Recommended and removes NotRecommended before enumeration.
		return bit.band(
			bit.bnot(NOT_RECOMMENDED),
			bit.bor(filterFlags, RECOMMENDED)
		)
	end
	return filterFlags
end

local function buildFilterSets(kind)
	local filters = {}
	local seen = {}
	if kind == "dungeon" then
		addUnique(filters, seen, bor(CURRENT_SEASON, PVE))
		if PlayerIsTimerunning and PlayerIsTimerunning() and TIMERUNNING ~= 0 then
			addUnique(filters, seen, bor(TIMERUNNING, PVE))
		end
		addUnique(filters, seen, bor(CURRENT_EXPANSION, NOT_CURRENT_SEASON, PVE))
		addUnique(filters, seen, bor(CURRENT_EXPANSION, PVE))
		addUnique(filters, seen, bor(RECOMMENDED, PVE))
		addUnique(filters, seen, bor(NOT_RECOMMENDED, PVE))
		addUnique(filters, seen, PVE)
		-- EntryCreation may enumerate an activity with no base filter, or with
		-- only its Recommended partition. These are native authorization paths
		-- too and must not be mistaken for an unavailable Journal entry.
		addUnique(filters, seen, 0)
		addUnique(filters, seen, RECOMMENDED)
		addUnique(filters, seen, NOT_RECOMMENDED)
	elseif kind == "raid" then
		addUnique(filters, seen, bor(RECOMMENDED, PVE))
		addUnique(filters, seen, bor(NOT_RECOMMENDED, PVE))
		addUnique(filters, seen, bor(CURRENT_EXPANSION, PVE))
		addUnique(filters, seen, bor(CURRENT_EXPANSION, NOT_CURRENT_SEASON, PVE))
		addUnique(filters, seen, PVE)
		addUnique(filters, seen, 0)
		addUnique(filters, seen, RECOMMENDED)
		addUnique(filters, seen, NOT_RECOMMENDED)
	end
	local nativeSourceCount = #filters
	for index = 1, nativeSourceCount do
		addUnique(filters, seen, resolveEntryCreationFilter(kind, filters[index]))
	end
	return filters
end

local function activityGroupID(info)
	local groupID = tonumber(info and info.groupFinderActivityGroupID)
	if groupID and groupID > 0 then
		return groupID
	end
	return nil
end

local function pcallFirst(fn, ...)
	if type(fn) ~= "function" then
		return nil
	end
	local ok, value = pcall(fn, ...)
	if ok then
		return value
	end
	return nil
end

local function journalExpansionIndexForTier(tier)
	local displayExpansion = clientDisplayExpansionIndex()
	local clientTier = displayExpansion + 1
	if tier <= clientTier then
		return tier - 1, false
	end
	return displayExpansion, true
end

local function addJournalLookup(list, key, instance)
	if key == nil then
		return
	end
	local bucket = list[key]
	if not bucket then
		bucket = {}
		list[key] = bucket
	end
	bucket[#bucket + 1] = instance
end

local function buildJournalCatalog(kind)
	if kind ~= "dungeon" and kind ~= "raid" then
		return nil
	end
	if type(EJ_GetNumTiers) ~= "function"
		or type(EJ_GetTierInfo) ~= "function"
		or type(EJ_SelectTier) ~= "function"
		or type(EJ_GetInstanceByIndex) ~= "function" then
		if C_AddOns and C_AddOns.LoadAddOn then
			pcall(C_AddOns.LoadAddOn, "Blizzard_EncounterJournal")
		end
	end
	if type(EJ_GetNumTiers) ~= "function"
		or type(EJ_GetTierInfo) ~= "function"
		or type(EJ_SelectTier) ~= "function"
		or type(EJ_GetInstanceByIndex) ~= "function" then
		return nil
	end

	local numTiers = tonumber(pcallFirst(EJ_GetNumTiers)) or 0
	if numTiers <= 0 then
		return nil
	end

	local showRaid = kind == "raid"
	local previousTier = pcallFirst(EJ_GetCurrentTier)
	local catalog = {
		expansions = {},
		seasonInstances = {},
		byJournalInstanceID = {},
		byMapID = {},
		byName = {},
	}

	for tier = 1, numTiers do
		local tierName = pcallFirst(EJ_GetTierInfo, tier)
		local expansionIndex, isSeason = journalExpansionIndexForTier(tier)
		local okSelect = pcall(EJ_SelectTier, tier)
		if okSelect then
			local expansion = {
				expansionIndex = expansionIndex,
				label = tierName,
				tier = tier,
				instances = {},
			}
			local dataIndex = 1
			while dataIndex <= 300 do
				-- Return slot 8 is the Encounter Journal dungeon-area map; slot 11
				-- is the game instance map. Preserve both for cross-system matching.
				local ok, instanceID, name, description, backgroundImage, buttonImage,
					loreImage, secondaryButtonImage, dungeonAreaMapID, unused1, unused2, gameMapID =
					pcall(EJ_GetInstanceByIndex, dataIndex, showRaid)
				if not ok or not instanceID then
					break
				end
				local visualTexture = loreImage or backgroundImage or secondaryButtonImage or buttonImage
				local instance = {
					journalInstanceID = instanceID,
					label = name,
					description = description,
					mapID = tonumber(dungeonAreaMapID),
					instanceMapID = tonumber(gameMapID),
					visualTexture = visualTexture,
					visualTexCoords = loreImage and JOURNAL_LORE_IMAGE_TEX_COORDS or nil,
					visualSource = loreImage and "loreImage"
						or (backgroundImage and "backgroundImage")
						or (secondaryButtonImage and "buttonImage2")
						or (buttonImage and "buttonImage1")
						or nil,
					orderIndex = dataIndex,
					tier = tier,
					expansionIndex = expansionIndex,
					expansionLabel = tierName,
					isSeason = isSeason,
				}
				if isSeason then
					catalog.seasonInstances[#catalog.seasonInstances + 1] = instance
				else
					expansion.instances[#expansion.instances + 1] = instance
				end
				addJournalLookup(catalog.byJournalInstanceID, instanceID, instance)
				addJournalLookup(catalog.byMapID, instance.mapID, instance)
				addJournalLookup(catalog.byMapID, instance.instanceMapID, instance)
				addJournalLookup(catalog.byName, normalizedName(name), instance)
				dataIndex = dataIndex + 1
			end
			if not isSeason and #expansion.instances > 0 then
				catalog.expansions[#catalog.expansions + 1] = expansion
			end
		end
	end

	if previousTier then
		pcall(EJ_SelectTier, previousTier)
	end

	table.sort(catalog.expansions, function(a, b)
		if a.expansionIndex ~= b.expansionIndex then
			return a.expansionIndex > b.expansionIndex
		end
		return (tonumber(a.tier) or 0) > (tonumber(b.tier) or 0)
	end)
	return catalog
end

local function journalCatalog(kind)
	local cached = journalCatalogCache[kind]
	if cached ~= nil then
		return cached ~= false and cached or nil
	end
	local catalog = buildJournalCatalog(kind)
	journalCatalogCache[kind] = catalog or false
	return catalog
end

local function journalInstanceIDForMapID(mapID)
	mapID = tonumber(mapID)
	if not mapID or mapID <= 0 then
		return nil
	end
	if not (C_EncounterJournal and C_EncounterJournal.GetInstanceForGameMap) then
		return nil
	end
	local ok, journalInstanceID = pcall(C_EncounterJournal.GetInstanceForGameMap, mapID)
	if ok and journalInstanceID then
		return journalInstanceID
	end
	return nil
end

local function addNameKey(keys, seen, value)
	local key = normalizedName(value)
	if key and not seen[key] then
		seen[key] = true
		keys[#keys + 1] = key
	end
end

local function activityBaseName(info)
	if GF.ActivityInfo and GF.ActivityInfo.GetActivityBaseName then
		return GF.ActivityInfo.GetActivityBaseName(info)
	end
	return cleanText(info and info.fullName) or cleanText(info and info.shortName)
end

local function makeEntryNameKeys(groupName, info)
	local keys = {}
	local seen = {}
	addNameKey(keys, seen, groupName)
	if type(info) == "table" then
		addNameKey(keys, seen, activityBaseName(info))
		addNameKey(keys, seen, info.fullName)
		addNameKey(keys, seen, info.shortName)
	end
	return keys
end

local function isWorldBossLabel(value)
	value = cleanText(value)
	if not value then
		return false
	end
	local lower = string.lower(value)
	return value:find("世界首领", 1, true) ~= nil
		or value:find("世界首領", 1, true) ~= nil
		or lower:find("world boss", 1, true) ~= nil
end

local function worldBossLabelForInfo(info)
	if type(info) ~= "table" then
		return nil
	end
	local candidates = {
		activityBaseName(info),
		cleanText(info.fullName),
		cleanText(info.shortName),
	}
	for index = 1, 3 do
		local candidate = candidates[index]
		if isWorldBossLabel(candidate) then
			return stripKnownDifficultySuffix(candidate, info) or candidate
		end
	end
	return nil
end

local function worldBossContainerLabel(info, sourceLabel, journalLabel)
	journalLabel = trimText(journalLabel)
	if not journalLabel then
		return trimText(sourceLabel)
	end

	local fullName = cleanText(info and info.fullName) or trimText(sourceLabel)
	local shortName = cleanText(info and info.shortName)
	local base = stripKnownDifficultySuffix(shortName, info)
		or removeParentheticalHints(stripKnownDifficultySuffix(fullName, info))
		or removeParentheticalHints(sourceLabel)
		or cleanText(_G and _G.RAID_INFO_WORLD_BOSS)
		or "World Boss"
	base = trimText(base)
	if fullName and fullName:find("（", 1, true) then
		return base .. "（" .. journalLabel .. "）"
	end
	return base .. " (" .. journalLabel .. ")"
end

local function activityRecommendedBits(info)
	local filters = tonumber(info and info.filters) or 0
	return bit.band(filters, RECOMMENDED_MASK)
end

local function deriveListFilters(categoryID, filterFlags, activityIDs)
	local listFilters = filterFlags or 0
	if bit.band(listFilters, PVE) == 0 then
		listFilters = bit.bor(listFilters, PVE)
	end
	if categoryID ~= GF.CAT_DUNGEON and categoryID ~= GF.CAT_RAID then
		return listFilters
	end
	if bit.band(listFilters, RECOMMENDED_MASK) ~= 0 then
		return listFilters
	end

	local foundRecommended = false
	local foundNotRecommended = false
	for _, activityID in ipairs(activityIDs or EMPTY) do
		local info = getActivityInfo(activityID)
		local recBits = activityRecommendedBits(info)
		foundRecommended = foundRecommended or bit.band(recBits, RECOMMENDED) ~= 0
		foundNotRecommended = foundNotRecommended or bit.band(recBits, NOT_RECOMMENDED) ~= 0
	end

	if foundNotRecommended and not foundRecommended then
		return bit.bor(listFilters, NOT_RECOMMENDED)
	end
	if foundRecommended and not foundNotRecommended then
		return bit.bor(listFilters, RECOMMENDED)
	end
	return listFilters
end

local function addExpansion(catalog, expansionIndex, label)
	local expansionsByIndex = catalog._expansionsByIndex
	local expansion = expansionsByIndex[expansionIndex]
	if expansion then
		if label and label ~= "" then
			expansion.label = label
		end
		return expansion
	end
	expansion = {
		expansionIndex = expansionIndex,
		label = label or tostring(expansionIndex),
		instances = {},
	}
	expansionsByIndex[expansionIndex] = expansion
	catalog.expansions[#catalog.expansions + 1] = expansion
	return expansion
end

local function instanceOrder(a)
	return tonumber(a and a.orderIndex) or tonumber(a and a.groupID) or tonumber(a and a.activityID) or 0
end

local function finalizeCatalog(catalog, kind)
	for _, expansion in ipairs(catalog.expansions) do
		table.sort(expansion.instances, function(a, b)
			local oa = instanceOrder(a)
			local ob = instanceOrder(b)
			if oa ~= ob then
				return oa < ob
			end
			return (tonumber(a.groupID or a.activityID) or 0) < (tonumber(b.groupID or b.activityID) or 0)
		end)
	end
	table.sort(catalog.expansions, function(a, b)
		if a.expansionIndex ~= b.expansionIndex then
			return a.expansionIndex > b.expansionIndex
		end
		return (a.label or "") < (b.label or "")
	end)
	catalog._expansionsByIndex = nil
	return catalog
end

local function addEntryLookup(lookup, key, entry)
	if key == nil then
		return
	end
	local bucket = lookup[key]
	if not bucket then
		bucket = {}
		lookup[key] = bucket
	end
	bucket[#bucket + 1] = entry
end

local function addEntryLookups(lookup, entry)
	addEntryLookup(lookup.byJournalInstanceID, entry.journalInstanceID, entry)
	addEntryLookup(lookup.byMapID, entry.mapID, entry)
	for _, key in ipairs(entry.nameKeys or EMPTY) do
		addEntryLookup(lookup.byName, key, entry)
	end
end

local function mergeActivityList(out, seen, activities)
	for _, activityID in ipairs(activities or EMPTY) do
		if not seen[activityID] then
			seen[activityID] = true
			out[#out + 1] = activityID
		end
	end
end

local function addCatalogCandidateFilter(candidate, filterFlags)
	filterFlags = tonumber(filterFlags)
	if filterFlags == nil or candidate._filterSeen[filterFlags] then
		return
	end
	candidate._filterSeen[filterFlags] = true
	candidate.filterSets[#candidate.filterSets + 1] = filterFlags
	if candidate.listFilters == nil then
		candidate.listFilters = filterFlags
	end
end

local function ensureCatalogGroupCandidate(groups, groupsByID, groupID)
	groupID = tonumber(groupID)
	if groupID == nil or groupID <= 0 then
		return nil
	end
	local candidate = groupsByID[groupID]
	if not candidate then
		candidate = {
			groupID = groupID,
			filterSets = {},
			_filterSeen = {},
		}
		groupsByID[groupID] = candidate
		groups[#groups + 1] = candidate
	end
	return candidate
end

local function addCatalogCandidates(groups, groupsByID, solos, solosByID, source)
	for _, expansion in ipairs((source and source.expansions) or EMPTY) do
		for _, instance in ipairs(expansion.instances or EMPTY) do
			local groupID = tonumber(instance and instance.groupID)
			local activityID = tonumber(instance and instance.activityID)
			if groupID and groupID > 0 then
				local candidate = ensureCatalogGroupCandidate(groups, groupsByID, groupID)
				addCatalogCandidateFilter(candidate, instance.listFilters)
			elseif activityID and activityID > 0 then
				local candidate = solosByID[activityID]
				if not candidate then
					candidate = {
						activityID = activityID,
						filterSets = {},
						_filterSeen = {},
					}
					solosByID[activityID] = candidate
					solos[#solos + 1] = candidate
				end
				addCatalogCandidateFilter(candidate, instance.listFilters)
			end
		end
	end
end

local function addCatalogActivitySeedCandidates(groups, groupsByID, source)
	-- The 12.1 DB2 snapshot supplies candidate group keys only. Its activity IDs
	-- must never bypass the same GetAvailableActivities calls used by Blizzard's
	-- EntryCreation dropdown; current LFG availability is the display authority.
	local groupIDs = {}
	for groupID, activityIDs in pairs(source or EMPTY) do
		local normalizedGroupID = tonumber(groupID)
		if normalizedGroupID and normalizedGroupID > 0
			and type(activityIDs) == "table" and #activityIDs > 0 then
			groupIDs[#groupIDs + 1] = normalizedGroupID
		end
	end
	table.sort(groupIDs)
	for _, groupID in ipairs(groupIDs) do
		ensureCatalogGroupCandidate(groups, groupsByID, groupID)
	end
end

local function catalogCandidates(kind)
	local groups, solos = {}, {}
	local groupsByID, solosByID = {}, {}
	addCatalogCandidates(groups, groupsByID, solos, solosByID, GF.NAV_CATALOG and GF.NAV_CATALOG[kind])
	addCatalogCandidates(groups, groupsByID, solos, solosByID,
		GF.NAV_CATALOG_SUPPLEMENT and GF.NAV_CATALOG_SUPPLEMENT[kind])
	addCatalogCandidates(groups, groupsByID, solos, solosByID,
		GF.NAV_MANUAL_CATALOG and GF.NAV_MANUAL_CATALOG[kind])
	addCatalogActivitySeedCandidates(groups, groupsByID,
		GF.NAV_CATALOG_ACTIVITY_SEEDS and GF.NAV_CATALOG_ACTIVITY_SEEDS[kind])
	for _, candidate in ipairs(groups) do
		candidate._filterSeen = nil
	end
	for _, candidate in ipairs(solos) do
		candidate._filterSeen = nil
	end
	return groups, solos
end

local function archiveGroupExcluded(kind, groupID)
	local excluded = GF.NAV_CATALOG_ARCHIVE_EXCLUDED_GROUPS
		and GF.NAV_CATALOG_ARCHIVE_EXCLUDED_GROUPS[kind]
	return type(excluded) == "table" and excluded[tonumber(groupID)] == true
end

local function buildArchiveFilterSets(kind, candidate)
	local filters, seen = {}, {}
	for _, filterFlags in ipairs(buildFilterSets(kind)) do
		addUnique(filters, seen, filterFlags)
	end
	if kind == "dungeon" or kind == "raid" then
		-- Cover the rotation/current-expansion endpoints even when a PTR build
		-- requires the complete record mask instead of accepting a broad subset.
		addUnique(filters, seen, bor(RECOMMENDED, CURRENT_SEASON, PVE))
		addUnique(filters, seen, bor(NOT_RECOMMENDED, CURRENT_SEASON, PVE))
		addUnique(filters, seen, bor(RECOMMENDED, CURRENT_EXPANSION, CURRENT_SEASON, PVE))
		addUnique(filters, seen, bor(NOT_RECOMMENDED, NOT_CURRENT_SEASON, PVE))
		addUnique(filters, seen, bor(RECOMMENDED, CURRENT_EXPANSION, NOT_CURRENT_SEASON, PVE))
		addUnique(filters, seen, bor(NOT_RECOMMENDED, CURRENT_EXPANSION, NOT_CURRENT_SEASON, PVE))
	end
	for _, filterFlags in ipairs(candidate and candidate.filterSets or EMPTY) do
		addUnique(filters, seen, filterFlags)
	end
	local nativeSourceCount = #filters
	for index = 1, nativeSourceCount do
		addUnique(filters, seen, resolveEntryCreationFilter(kind, filters[index]))
	end
	return filters
end

local function buildRuntimeEntries(kind, filterSets, options)
	local meta = catalogMeta(kind)
	if not meta or not meta.categoryID then
		return nil, nil
	end
	options = options or EMPTY
	local exactFilters = options.exactFilters == true
	local probeOnly = options.probeOnly == true
	filterSets = filterSets or buildFilterSets(kind)
	local groupCandidates, soloCandidates = EMPTY, EMPTY
	if not exactFilters then
		groupCandidates, soloCandidates = catalogCandidates(kind)
	end

	local groupStates = {}
	local seenActivities = {}
	local topLevelActivitiesByFilter = {}
	local entries = {}
	local lookup = {
		byJournalInstanceID = {},
		byMapID = {},
		byName = {},
		reusableJournalEntries = {},
		enumeratedGroupIDs = {},
	}
	local addGroup
	local reusableActivityIDs = GF.NAV_CATALOG_REUSABLE_JOURNAL_ACTIVITIES
		and GF.NAV_CATALOG_REUSABLE_JOURNAL_ACTIVITIES[kind]

	local function reusableJournalEntry(entry)
		return type(reusableActivityIDs) == "table"
			and entry and entry.activityID
			and reusableActivityIDs[tonumber(entry.activityID)] == true
	end

	local function infoMatchesCategory(info)
		return type(info) == "table" and tonumber(info.categoryID) == meta.categoryID
	end

	local function addEntry(entry)
		entries[#entries + 1] = entry
		addEntryLookups(lookup, entry)
		if reusableJournalEntry(entry) then
			lookup.reusableJournalEntries[#lookup.reusableJournalEntries + 1] = entry
		end
	end

	local function getTopLevelActivities(filterFlags)
		filterFlags = tonumber(filterFlags) or 0
		local cached = topLevelActivitiesByFilter[filterFlags]
		if cached then
			return cached
		end
		local activities, seen = {}, {}
		mergeActivityList(activities, seen,
			getAvailableActivities(meta.categoryID, 0, filterFlags))
		mergeActivityList(activities, seen,
			getAvailableActivities(meta.categoryID, nil, filterFlags))
		-- EntryCreation's Activity Finder always supplies its text argument,
		-- starting with an empty string when the dialog first opens. Retail can
		-- return legacy standalone activities through that four-argument path
		-- even when both three-argument category-level calls omit them.
		mergeActivityList(activities, seen,
			getAvailableActivities(meta.categoryID, nil, filterFlags, ""))
		topLevelActivitiesByFilter[filterFlags] = activities
		return activities
	end

	local function addSoloActivity(activityID, filterFlags, info)
		activityID = tonumber(activityID)
		if not activityID or seenActivities[activityID] then
			return
		end
		info = info or getActivityInfo(activityID)
		if not infoMatchesCategory(info) then
			return
		end
		-- Availability can expose an activity ID one call before its info table is
		-- readable during login/native Group Finder warm-up. Only memoize a
		-- successfully validated record; otherwise later filter/query passes must
		-- be allowed to retry the same ID instead of permanently greying its
		-- Encounter Journal row.
		seenActivities[activityID] = true
		local groupID = activityGroupID(info)
		if groupID then
			addGroup(groupID, filterFlags, info, activityID)
			return
		end
		local worldBossLabel
		if kind == "raid" then
			worldBossLabel = worldBossLabelForInfo(info)
		end
		local listFilters = deriveListFilters(meta.categoryID, filterFlags, { activityID })
		local entry = {
			activityID = activityID,
			listFilters = listFilters,
			orderIndex = tonumber(info.orderIndex) or activityID,
			activityIDs = { activityID },
			filterFlags = filterFlags,
			info = info,
			mapID = tonumber(info.mapID),
			journalInstanceID = journalInstanceIDForMapID(info.mapID),
			nameKeys = makeEntryNameKeys(nil, info),
			worldBoss = worldBossLabel ~= nil,
			worldBossLabel = worldBossLabel,
		}
		addEntry(entry)
	end

	local function soloActivityMatchesCandidate(candidate, candidateInfo, activityID, info)
		local candidateActivityID = tonumber(candidate and candidate.activityID)
		activityID = tonumber(activityID)
		if not candidateActivityID or not activityID
			or not infoMatchesCategory(info) or activityGroupID(info)
		then
			return false
		end
		if activityID == candidateActivityID then
			return true
		end
		if not infoMatchesCategory(candidateInfo) or activityGroupID(candidateInfo) then
			return false
		end

		local candidateName = normalizedName(activityBaseName(candidateInfo))
		local activityName = normalizedName(activityBaseName(info))
		if not candidateName or candidateName ~= activityName then
			return false
		end
		local candidateMapID = tonumber(candidateInfo.mapID)
		local activityMapID = tonumber(info.mapID)
		if candidateMapID and candidateMapID > 0 and activityMapID and activityMapID > 0
			and candidateMapID ~= activityMapID
		then
			return false
		end
		local difficultyGetter = GF.ActivityInfo and GF.ActivityInfo.GetDifficultyIndex
		if difficultyGetter then
			local candidateDifficulty = tonumber(difficultyGetter(candidateInfo, { includeMplus = true }))
			local activityDifficulty = tonumber(difficultyGetter(info, { includeMplus = true }))
			if candidateDifficulty and candidateDifficulty > 0
				and activityDifficulty and activityDifficulty > 0
				and candidateDifficulty ~= activityDifficulty
			then
				return false
			end
		end
		return true
	end

	local function legacySoloFallbackForCandidate(candidate)
		local fallbacks = GF.NAV_CATALOG_LEGACY_SOLO_ACTIVITY_FALLBACKS
			and GF.NAV_CATALOG_LEGACY_SOLO_ACTIVITY_FALLBACKS[kind]
		local activityID = tonumber(candidate and candidate.activityID)
		return type(fallbacks) == "table" and activityID and fallbacks[activityID] or nil
	end

	local function legacySoloFallbackJournalNames(fallback)
		local names, seen = {}, {}
		local mapID = tonumber(fallback and fallback.mapID)
		local journal = mapID and journalCatalog(kind)
		for _, journalInstance in ipairs(
			journal and journal.byMapID and journal.byMapID[mapID] or EMPTY
		) do
			local label = cleanText(journalInstance and journalInstance.label)
			local key = normalizedName(label)
			if label and key and not seen[key] then
				seen[key] = true
				names[#names + 1] = label
			end
		end
		return names
	end

	local function activityMatchesLegacySoloFallback(info, fallback)
		if not fallback or not infoMatchesCategory(info) or activityGroupID(info) then
			return false
		end
		local expectedMapID = tonumber(fallback.mapID)
		local activityMapID = tonumber(info.mapID)
		if not expectedMapID or expectedMapID <= 0 or activityMapID ~= expectedMapID then
			return false
		end
		local activityName = normalizedName(activityBaseName(info))
		if not activityName then
			return false
		end
		for _, journalName in ipairs(legacySoloFallbackJournalNames(fallback)) do
			if normalizedName(journalName) == activityName then
				return true
			end
		end
		return false
	end

	local function authorizeSoloCandidateBySearch(candidate, filterFlags, candidateInfo)
		local searchTexts, searchTextSeen = {}, {}
		local function addSearchText(value)
			value = cleanText(value)
			local key = normalizedName(value)
			if value and key and not searchTextSeen[key] then
				searchTextSeen[key] = true
				searchTexts[#searchTexts + 1] = value
			end
		end
		addSearchText(activityBaseName(candidateInfo))
		addSearchText(candidateInfo and candidateInfo.fullName)
		addSearchText(candidateInfo and candidateInfo.shortName)
		local fallback = legacySoloFallbackForCandidate(candidate)
		for _, journalName in ipairs(legacySoloFallbackJournalNames(fallback)) do
			addSearchText(journalName)
		end
		if #searchTexts == 0 then
			return false
		end

		-- Blizzard's EntryCreation Activity Finder passes its text box as the
		-- fourth GetAvailableActivities argument. Some group-less legacy records
		-- (for example Blackwing Lair) are discoverable through that native path
		-- even when the unfiltered category-level enumeration omits them. Static
		-- data remains identity evidence only: the returned current activity must
		-- still match its category, group-less state, base name, map, and difficulty.
		for _, searchText in ipairs(searchTexts) do
			for _, activityID in ipairs(getAvailableActivities(
				meta.categoryID,
				nil,
				filterFlags,
				searchText
			)) do
				local info = getActivityInfo(activityID)
				if soloActivityMatchesCandidate(candidate, candidateInfo, activityID, info)
					or activityMatchesLegacySoloFallback(info, fallback)
				then
					addSoloActivity(tonumber(activityID), filterFlags, info)
					return true
				end
			end
		end
		return false
	end

	local function authorizeLegacySoloFallback(candidate, filterFlags, candidateInfo)
		local fallback = legacySoloFallbackForCandidate(candidate)
		if not activityMatchesLegacySoloFallback(candidateInfo, fallback) then
			return false
		end
		addSoloActivity(tonumber(candidate.activityID), filterFlags, candidateInfo)
		return true
	end

	local function getGroupState(groupID)
		groupID = tonumber(groupID)
		if not groupID or groupID <= 0 then
			return nil
		end
		local state = groupStates[groupID]
		if not state then
			state = {
				groupID = groupID,
				activityIDs = {},
				activitySeen = {},
				queriedFilters = {},
			}
			groupStates[groupID] = state
		end
		return state
	end

	local function authorizeGroupActivity(state, activityID, filterFlags, info)
		activityID = tonumber(activityID)
		if not activityID then
			return false
		end
		info = info or getActivityInfo(activityID)
		if not infoMatchesCategory(info) or activityGroupID(info) ~= state.groupID then
			return false
		end
		if not state.activitySeen[activityID] then
			state.activitySeen[activityID] = true
			state.activityIDs[#state.activityIDs + 1] = activityID
		end
		if not state.firstInfo then
			state.firstInfo = info
		end
		if state.firstFilter == nil then
			state.firstFilter = tonumber(filterFlags) or 0
		end
		return true
	end

	local function queryGroupFilter(state, filterFlags)
		filterFlags = tonumber(filterFlags) or 0
		if state.queriedFilters[filterFlags] then
			return
		end
		state.queriedFilters[filterFlags] = true
		for _, activityID in ipairs(getAvailableActivities(meta.categoryID, state.groupID, filterFlags)) do
			authorizeGroupActivity(state, activityID, filterFlags)
		end
	end

	local function queryGroupFilters(state, filterFlags)
		queryGroupFilter(state, filterFlags)
		if exactFilters then
			return
		end
		-- Retail/PTR can partition a category by its Recommended bit even when
		-- bare PvE yields no activities. Union the native PvE partitions while
		-- retaining the record-specific season/expansion filter above.
		queryGroupFilter(state, PVE)
		queryGroupFilter(state, bor(RECOMMENDED, PVE))
		queryGroupFilter(state, bor(NOT_RECOMMENDED, PVE))
	end

	local function legacyGroupSearchFallback(groupID)
		local fallbacks = GF.NAV_CATALOG_LEGACY_GROUP_SEARCH_FALLBACKS
			and GF.NAV_CATALOG_LEGACY_GROUP_SEARCH_FALLBACKS[kind]
		return type(fallbacks) == "table" and fallbacks[tonumber(groupID)] or nil
	end

	local function legacyGroupSearchTexts(groupID, fallback)
		local texts, seen = {}, {}
		local function add(value)
			value = cleanText(value)
			local key = normalizedName(value)
			if value and key and not seen[key] then
				seen[key] = true
				texts[#texts + 1] = value
			end
		end

		local groupName = getActivityGroupInfo(groupID)
		add(groupName)

		-- GetInstanceForGameMap is a system API, while EJ_GetInstanceInfo is
		-- supplied by Blizzard_EncounterJournal. Building the cached Journal
		-- catalog first safely loads that Blizzard addon when necessary.
		journalCatalog(kind)
		local journalInstanceID = journalInstanceIDForMapID(fallback and fallback.mapID)
		if journalInstanceID and type(EJ_GetInstanceInfo) == "function" then
			add(pcallFirst(EJ_GetInstanceInfo, journalInstanceID))
		end
		return texts
	end

	local function authorizeLegacyGroupBySearch(candidate, state)
		local fallback = legacyGroupSearchFallback(candidate and candidate.groupID)
		local expectedMapID = tonumber(fallback and fallback.mapID)
		if not state or #state.activityIDs > 0 or not expectedMapID or expectedMapID <= 0 then
			return false
		end
		local searchTexts = legacyGroupSearchTexts(state.groupID, fallback)
		if #searchTexts == 0 then
			return false
		end
		local searchNameKeys = {}
		for _, searchText in ipairs(searchTexts) do
			searchNameKeys[normalizedName(searchText)] = true
		end

		local filterSets, filterSeen = {}, {}
		local queriedFilters = {}
		for filterFlags in pairs(state.queriedFilters) do
			queriedFilters[#queriedFilters + 1] = filterFlags
		end
		table.sort(queriedFilters)
		for _, filterFlags in ipairs(queriedFilters) do
			addUnique(filterSets, filterSeen, filterFlags)
		end
		for _, filterFlags in ipairs(candidate.filterSets or EMPTY) do
			addUnique(filterSets, filterSeen, filterFlags)
		end
		addUnique(filterSets, filterSeen, meta.baseFilters)
		addUnique(filterSets, filterSeen, meta.preferredFilters)
		addUnique(filterSets, filterSeen, 0)

		local function authorizeResults(activityIDs, filterFlags)
			for _, activityID in ipairs(activityIDs or EMPTY) do
				local info = getActivityInfo(activityID)
				local activityNameKey = normalizedName(activityBaseName(info))
				if infoMatchesCategory(info)
					and tonumber(info.mapID) == expectedMapID
					and activityNameKey and searchNameKeys[activityNameKey]
				then
					-- Native Activity Finder can return a currently creatable legacy
					-- dungeon as the old group, a replacement group, or a top-level
					-- activity. Preserve that current structure instead of requiring the
					-- 12.0.7 group identity to remain unchanged.
					addSoloActivity(tonumber(activityID), filterFlags, info)
					for _, entry in ipairs(entries) do
						if tonumber(entry.activityID) == tonumber(activityID) then
							return true
						end
						for _, entryActivityID in ipairs(entry.activityIDs or EMPTY) do
							if tonumber(entryActivityID) == tonumber(activityID) then
								return true
							end
						end
					end
				end
			end
			return false
		end

		for _, filterFlags in ipairs(filterSets) do
			for _, searchText in ipairs(searchTexts) do
				-- The group-scoped form mirrors EntryCreation's Activity Finder;
				-- the category-scoped form mirrors Blizzard's autocomplete path.
				if authorizeResults(getAvailableActivities(
					meta.categoryID, state.groupID, filterFlags, searchText
				), filterFlags) or authorizeResults(getAvailableActivities(
					meta.categoryID, nil, filterFlags, searchText
				), filterFlags) then
					return true
				end
			end
		end
		return false
	end

	addGroup = function(groupID, filterFlags, seedInfo, seedActivityID)
		if not exactFilters and archiveGroupExcluded(kind, groupID) then
			return
		end
		local state = getGroupState(groupID)
		if not state then
			return
		end
		-- The category-level GetAvailableActivities query is exactly what
		-- Blizzard's Activity Finder consumes. Keep that returned activity even
		-- when a second group-scoped query does not echo the same ID.
		if seedActivityID then
			authorizeGroupActivity(state, seedActivityID, filterFlags, seedInfo)
		end
		queryGroupFilters(state, filterFlags)
		if state.entry or #state.activityIDs == 0 then
			return
		end

		local firstInfo = infoMatchesCategory(seedInfo) and seedInfo or state.firstInfo
		if not firstInfo then
			return
		end
		local groupName, groupOrder = getActivityGroupInfo(groupID)
		groupName = cleanText(groupName) or activityBaseName(firstInfo)
		if not groupName then
			return
		end
		local listFilters = deriveListFilters(
			meta.categoryID, state.firstFilter or filterFlags, state.activityIDs)
		local mapID = tonumber(firstInfo and firstInfo.mapID)
		local journalInstanceID = journalInstanceIDForMapID(mapID)
		if not journalInstanceID then
			for _, activityID in ipairs(state.activityIDs) do
				local info = getActivityInfo(activityID)
				journalInstanceID = journalInstanceIDForMapID(info and info.mapID)
				if journalInstanceID then
					mapID = tonumber(info and info.mapID)
					break
				end
			end
		end
		local entry = {
			groupID = groupID,
			listFilters = listFilters,
			orderIndex = tonumber(groupOrder) or tonumber(firstInfo and firstInfo.orderIndex) or groupID,
			activityIDs = state.activityIDs,
			filterFlags = state.firstFilter or filterFlags,
			info = firstInfo,
			groupName = groupName,
			label = kind == "dungeon" and groupName or nil,
			mapID = mapID,
			journalInstanceID = journalInstanceID,
			nameKeys = makeEntryNameKeys(groupName, firstInfo),
		}
		state.entry = entry
		addEntry(entry)
	end

	if not probeOnly then
		for _, filterFlags in ipairs(filterSets) do
			for _, groupID in ipairs(getAvailableActivityGroups(meta.categoryID, filterFlags)) do
				groupID = tonumber(groupID)
				if groupID and groupID > 0 then
					lookup.enumeratedGroupIDs[groupID] = true
				end
				addGroup(groupID, filterFlags)
			end
			for _, activityID in ipairs(getTopLevelActivities(filterFlags)) do
				addSoloActivity(activityID, filterFlags)
			end
		end
	end
	-- Exact catalogs deliberately ignore the ordinary archive candidate graph,
	-- but a current group may still be omitted from GetAvailableActivityGroups
	-- while its group-scoped GetAvailableActivities call is valid. Allow callers
	-- to supply read-only probe keys. A key never authorizes itself: addGroup still
	-- requires a currently returned activity with strict category/group identity.
	if exactFilters then
		for _, groupID in ipairs(options.probeGroupIDs or EMPTY) do
			for _, filterFlags in ipairs(filterSets) do
				addGroup(groupID, filterFlags)
			end
		end
	end
	if not exactFilters then
		-- Ordinary dungeon/raid archives are Encounter Journal/catalog projections,
		-- not merely copies of GetAvailableActivityGroups. Probe every known group
		-- across its generated/manual and Blizzard season/expansion filter family.
		for _, candidate in ipairs(groupCandidates) do
			for _, filterFlags in ipairs(buildArchiveFilterSets(kind, candidate)) do
				addGroup(candidate.groupID, filterFlags)
				-- Activity Finder resolves the same candidate filter and queries at
				-- category level. Those returned IDs are independent of whether the
				-- candidate's group-scoped query echoes them.
				for _, activityID in ipairs(getTopLevelActivities(filterFlags)) do
					addSoloActivity(activityID, filterFlags)
				end
			end
			local state = getGroupState(candidate.groupID)
			local fallbackByGroup = GF.NAV_CATALOG_LEGACY_GROUP_ACTIVITY_FALLBACKS
				and GF.NAV_CATALOG_LEGACY_GROUP_ACTIVITY_FALLBACKS[kind]
			local fallbackActivityIDs = fallbackByGroup
				and fallbackByGroup[candidate.groupID]
			if state and #state.activityIDs == 0
				and type(fallbackActivityIDs) == "table"
			then
				for _, activityID in ipairs(fallbackActivityIDs) do
					local info = getActivityInfo(activityID)
					if infoMatchesCategory(info)
						and activityGroupID(info) == candidate.groupID
					then
						addGroup(
							candidate.groupID,
							tonumber(info.filters) or candidate.filterSets[1] or meta.baseFilters or 0,
							info,
							activityID
						)
					end
				end
			end
			if state and #state.activityIDs == 0 then
				authorizeLegacyGroupBySearch(candidate, state)
			end
		end
		-- Group-less records (notably world bosses and legacy standalone dungeon
		-- activities) remain API-authorized: the ID must be returned by a current
		-- category-level availability query before its runtime info may form a node.
		for _, candidate in ipairs(soloCandidates) do
			local added = false
			local info = getActivityInfo(candidate.activityID)
			local archiveFilterSets = buildArchiveFilterSets(kind, candidate)
			for _, filterFlags in ipairs(archiveFilterSets) do
				for _, activityID in ipairs(getTopLevelActivities(filterFlags)) do
					local activityInfo = getActivityInfo(activityID)
					-- Every category-level Activity Finder result is live authorization.
					-- Add it before consulting the static candidate so a replaced legacy
					-- ID can still match its current Journal row by runtime map/name.
					addSoloActivity(tonumber(activityID), filterFlags, activityInfo)
					if soloActivityMatchesCandidate(candidate, info, activityID, activityInfo) then
						added = true
						break
					end
				end
				if added then
					break
				end
			end
			if not added then
				local searchFilterSets, searchFilterSeen = {}, {}
				addUnique(searchFilterSets, searchFilterSeen, tonumber(info and info.filters))
				for _, filterFlags in ipairs(candidate.filterSets or EMPTY) do
					addUnique(searchFilterSets, searchFilterSeen, filterFlags)
				end
				local nativeSourceCount = #searchFilterSets
				for index = 1, nativeSourceCount do
					addUnique(searchFilterSets, searchFilterSeen,
						resolveEntryCreationFilter(kind, searchFilterSets[index]))
				end
				addUnique(searchFilterSets, searchFilterSeen, meta.baseFilters)
				addUnique(searchFilterSets, searchFilterSeen, meta.preferredFilters)
				addUnique(searchFilterSets, searchFilterSeen, 0)
				for _, filterFlags in ipairs(searchFilterSets) do
					if authorizeSoloCandidateBySearch(candidate, filterFlags, info)
						or authorizeLegacySoloFallback(candidate, filterFlags, info)
					then
						break
					end
				end
			end
		end
	end

	return entries, lookup, meta
end

local function copyActivityInfoSnapshot(info)
	if type(info) ~= "table" then
		return nil
	end
	local snapshot = {}
	for key, value in pairs(info) do
		snapshot[key] = value
	end
	return snapshot
end

local function copyEntryForCatalog(entry, journalInstance, kind)
	local out = {
		challengeModeID = entry.challengeModeID,
		groupID = entry.groupID,
		activityID = entry.activityID,
		-- Runtime entry construction already required this exact API snapshot to
		-- pass category/group identity checks. Carry it across the catalog boundary
		-- so a later transient GetActivityInfoTable() miss cannot erase an otherwise
		-- authorized group-less activity while NavData builds the visible branch.
		activityInfo = copyActivityInfoSnapshot(entry.info),
		listFilters = entry.listFilters,
		orderIndex = journalInstance and journalInstance.orderIndex or entry.orderIndex,
		activityIDs = entry.activityIDs,
		journalInstanceID = journalInstance and journalInstance.journalInstanceID or entry.journalInstanceID,
		mapID = journalInstance and journalInstance.mapID or entry.mapID,
		instanceMapID = journalInstance and journalInstance.instanceMapID or entry.instanceMapID,
		label = journalInstance and journalInstance.label or entry.label,
		visualTexture = journalInstance and journalInstance.visualTexture or entry.visualTexture,
		visualTexCoords = journalInstance and journalInstance.visualTexCoords or entry.visualTexCoords,
		visualSource = journalInstance and journalInstance.visualSource or entry.visualSource,
	}
	if kind == "dungeon" then
		local info = entry.info or getActivityInfo(entry.activityID)
		out.activityInfo = out.activityInfo or copyActivityInfoSnapshot(info)
		out.label = cleanText(activityBaseName(info)) or out.label
	end
	return out
end

local function copyWorldBossEntryForCatalog(entry, journalInstance, label)
	local out = copyEntryForCatalog(entry, journalInstance)
	out.label = cleanText(label) or out.label
	out.worldBossContainer = true
	return out
end

local function copyUnavailableJournalInstance(journalInstance, label, worldBossContainer)
	return {
		challengeModeID = journalInstance and journalInstance.challengeModeID,
		journalInstanceID = journalInstance and journalInstance.journalInstanceID,
		mapID = journalInstance and journalInstance.mapID,
		instanceMapID = journalInstance and journalInstance.instanceMapID,
		orderIndex = journalInstance and journalInstance.orderIndex,
		label = cleanText(label) or (journalInstance and journalInstance.label),
		visualTexture = journalInstance and journalInstance.visualTexture,
		visualTexCoords = journalInstance and journalInstance.visualTexCoords,
		visualSource = journalInstance and journalInstance.visualSource,
		catalogUnavailable = true,
		worldBossContainer = worldBossContainer == true and true or nil,
	}
end

local function activityListContainsID(activities, targetActivityID)
	targetActivityID = tonumber(targetActivityID)
	for _, activityID in ipairs(activities or EMPTY) do
		if tonumber(activityID) == targetActivityID then
			return true
		end
	end
	return false
end

local function strictLegacySoloEntryForJournalBoundary(kind, meta, journalInstance)
	local fallbacks = GF.NAV_CATALOG_LEGACY_SOLO_ACTIVITY_FALLBACKS
		and GF.NAV_CATALOG_LEGACY_SOLO_ACTIVITY_FALLBACKS[kind]
	if type(fallbacks) ~= "table" or type(meta) ~= "table"
		or type(journalInstance) ~= "table"
	then
		return nil
	end

	for configuredActivityID, fallback in pairs(fallbacks) do
		local activityID = tonumber(configuredActivityID)
		local expectedMapID = tonumber(fallback and fallback.mapID)
		local journalUsesMap = expectedMapID and expectedMapID > 0
			and (tonumber(journalInstance.mapID) == expectedMapID
				or tonumber(journalInstance.instanceMapID) == expectedMapID)
		if activityID and journalUsesMap then
			local info = getActivityInfo(activityID)
			local infoName = normalizedName(activityBaseName(info))
			local journalName = normalizedName(journalInstance.label)
			local exactIdentity = type(info) == "table"
				and tonumber(info.categoryID) == tonumber(meta.categoryID)
				and activityGroupID(info) == nil
				and tonumber(info.mapID) == expectedMapID
				and infoName ~= nil
				and journalName ~= nil
				and infoName == journalName
			if exactIdentity then
				local filterSets, filterSeen = {}, {}
				addUnique(filterSets, filterSeen, tonumber(info.filters))
				addUnique(filterSets, filterSeen, tonumber(meta.baseFilters))
				addUnique(filterSets, filterSeen, tonumber(meta.preferredFilters))
				addUnique(filterSets, filterSeen, 0)
				local searchTexts, searchSeen = {}, {}
				local function addSearchText(value)
					value = cleanText(value)
					local key = normalizedName(value)
					if value and key and not searchSeen[key] then
						searchSeen[key] = true
						searchTexts[#searchTexts + 1] = value
					end
				end
				addSearchText(journalInstance.label)
				addSearchText(activityBaseName(info))
				addSearchText(info.fullName)
				addSearchText(info.shortName)

				local liveAvailable = false
				for _, filterFlags in ipairs(filterSets) do
					liveAvailable = activityListContainsID(
						getAvailableActivities(meta.categoryID, 0, filterFlags), activityID)
						or activityListContainsID(
							getAvailableActivities(meta.categoryID, nil, filterFlags), activityID)
						or activityListContainsID(
							getAvailableActivities(meta.categoryID, nil, filterFlags, ""), activityID)
					if not liveAvailable then
						for _, searchText in ipairs(searchTexts) do
							if activityListContainsID(getAvailableActivities(
								meta.categoryID,
								nil,
								filterFlags,
								searchText
							), activityID) then
								liveAvailable = true
								break
							end
						end
					end
					if liveAvailable then
						local listFilters = deriveListFilters(
							meta.categoryID, filterFlags, { activityID })
						return {
							activityID = activityID,
							listFilters = listFilters,
							orderIndex = tonumber(info.orderIndex) or activityID,
							activityIDs = { activityID },
							filterFlags = filterFlags,
							info = info,
							mapID = tonumber(info.mapID),
							journalInstanceID = journalInstance.journalInstanceID,
							nameKeys = makeEntryNameKeys(nil, info),
						}, activityID
					end
				end
			end
		end
	end
	return nil
end

local function addStaticCatalogRecords(lookup, source, sourceName)
	local added = false
	for _, expansion in ipairs((source and source.expansions) or EMPTY) do
		local expansionIndex = tonumber(expansion.expansionIndex)
		if expansionIndex ~= nil then
			for orderIndex, instance in ipairs(expansion.instances or EMPTY) do
				if type(instance) == "table" then
					local record = {
						expansionIndex = tonumber(instance.expansionIndex) or expansionIndex,
						orderIndex = tonumber(instance.orderIndex) or orderIndex,
						label = cleanText(instance.label),
						listFilters = tonumber(instance.listFilters),
						mapID = tonumber(instance.mapID),
						source = sourceName,
					}
					local activityID = tonumber(instance.activityID)
					if activityID then
						lookup.byActivityID[activityID] = record
						added = true
					end
					local groupID = tonumber(instance.groupID)
					if groupID then
						lookup.byGroupID[groupID] = record
						added = true
					end
					if record.mapID and record.mapID > 0 then
						lookup.byMapID[record.mapID] = record
						added = true
					end
				end
			end
		end
	end
	return added
end

local function manualCatalogLookup(kind)
	local cached = manualCatalogLookupCache[kind]
	if cached ~= nil then
		return cached ~= false and cached or nil
	end

	local source = GF.NAV_MANUAL_CATALOG and GF.NAV_MANUAL_CATALOG[kind]
	if not source or type(source.expansions) ~= "table" then
		manualCatalogLookupCache[kind] = false
		return nil
	end

	local lookup = {
		byActivityID = {},
		byGroupID = {},
		byMapID = {},
	}
	if not addStaticCatalogRecords(lookup, source, "manual") then
		manualCatalogLookupCache[kind] = false
		return nil
	end
	manualCatalogLookupCache[kind] = lookup
	return lookup
end

local function staticCatalogLookup(kind)
	local cached = staticCatalogLookupCache[kind]
	if cached ~= nil then
		return cached ~= false and cached or nil
	end

	local source = GF.NAV_CATALOG and GF.NAV_CATALOG[kind]
	local supplementSource = GF.NAV_CATALOG_SUPPLEMENT and GF.NAV_CATALOG_SUPPLEMENT[kind]
	local manualSource = GF.NAV_MANUAL_CATALOG and GF.NAV_MANUAL_CATALOG[kind]
	if (not source or type(source.expansions) ~= "table")
		and (not supplementSource or type(supplementSource.expansions) ~= "table")
		and (not manualSource or type(manualSource.expansions) ~= "table") then
		staticCatalogLookupCache[kind] = false
		return nil
	end

	local lookup = {
		byActivityID = {},
		byGroupID = {},
		byMapID = {},
	}
	addStaticCatalogRecords(lookup, source, "static")
	addStaticCatalogRecords(lookup, supplementSource, "supplement")
	addStaticCatalogRecords(lookup, manualSource, "manual")

	staticCatalogLookupCache[kind] = lookup
	return lookup
end

local function applyCatalogRecordOverrides(out, record)
	if not out or not record then
		return out
	end
	if record.label then
		out.label = record.label
	end
	if out.listFilters == nil and record.listFilters ~= nil then
		out.listFilters = record.listFilters
	end
	if record.orderIndex then
		out.orderIndex = record.orderIndex
	end
	return out
end

local function journalEntryCanBeReused(kind, entry)
	local reusableGroups = GF.NAV_CATALOG_REUSABLE_JOURNAL_GROUPS
		and GF.NAV_CATALOG_REUSABLE_JOURNAL_GROUPS[kind]
	local groupID = tonumber(entry and entry.groupID)
	if type(reusableGroups) == "table"
		and groupID and reusableGroups[groupID] == true
	then
		return true
	end
	local reusable = GF.NAV_CATALOG_REUSABLE_JOURNAL_ACTIVITIES
		and GF.NAV_CATALOG_REUSABLE_JOURNAL_ACTIVITIES[kind]
	return type(reusable) == "table"
		and entry and entry.activityID
		and reusable[tonumber(entry.activityID)] == true
end

local function reusableJournalActivityID(kind, entry)
	local reusable = GF.NAV_CATALOG_REUSABLE_JOURNAL_ACTIVITIES
		and GF.NAV_CATALOG_REUSABLE_JOURNAL_ACTIVITIES[kind]
	local activityID = tonumber(entry and entry.activityID)
	if type(reusable) == "table" and activityID and reusable[activityID] == true then
		return activityID
	end
	return nil
end

local function takeEntryFromBucket(kind, bucket, used, predicate)
	for _, entry in ipairs(bucket or EMPTY) do
		if (not used[entry] or journalEntryCanBeReused(kind, entry))
			and (not predicate or predicate(entry)) then
			used[entry] = true
			return entry
		end
	end
	return nil
end

local function canMatchJournalInstance(kind, entry)
	-- World-boss activities have their own typed Journal-container path. Keep
	-- them out of ordinary raid matching, but allow other group-less legacy raid
	-- activities to match their real Journal instance.
	if kind == "raid" and entry and entry.worldBoss == true then
		return false
	end
	return true
end

local function takeEntryForJournalInstance(kind, lookup, journalInstance, used, extraPredicate)
	local function predicate(entry)
		return canMatchJournalInstance(kind, entry)
			and (not extraPredicate or extraPredicate(entry))
	end
	local entry = takeEntryFromBucket(
		kind,
		lookup.byJournalInstanceID[journalInstance.journalInstanceID],
		used,
		predicate
	)
	if entry then
		return entry
	end
	entry = takeEntryFromBucket(kind, lookup.byMapID[journalInstance.mapID], used, predicate)
	if entry then
		return entry
	end
	entry = takeEntryFromBucket(kind, lookup.byMapID[journalInstance.instanceMapID], used, predicate)
	if entry then
		return entry
	end
	entry = takeEntryFromBucket(
		kind,
		lookup.byName[normalizedName(journalInstance.label)],
		used,
		predicate
	)
	if entry then
		return entry
	end

	-- Generic legacy activities do not always share a usable map ID with each
	-- entrance-specific Journal row. Restrict localized prefix matching to the
	-- small manually maintained reuse allow-list.
	local journalNameKey = normalizedName(journalInstance.label)
	if journalNameKey then
		for _, reusableEntry in ipairs(lookup.reusableJournalEntries or EMPTY) do
			if predicate(reusableEntry) then
				for _, entryNameKey in ipairs(reusableEntry.nameKeys or EMPTY) do
					if entryNameKey ~= ""
						and journalNameKey:sub(1, #entryNameKey) == entryNameKey
					then
						used[reusableEntry] = true
						return reusableEntry
					end
				end
			end
		end
	end
	return nil
end

local function staticRecordForEntry(lookup, entry)
	if not lookup or not entry then
		return nil
	end
	local mapID = tonumber(entry.mapID or (entry.info and entry.info.mapID))
	if mapID and lookup.byMapID then
		local record = lookup.byMapID[mapID]
		if record then
			return record
		end
	end
	if entry.groupID then
		local record = lookup.byGroupID[entry.groupID]
		if record then
			return record
		end
	end
	if entry.activityID then
		local record = lookup.byActivityID[entry.activityID]
		if record then
			return record
		end
	end
	for _, activityID in ipairs(entry.activityIDs or EMPTY) do
		local record = lookup.byActivityID[activityID]
		if record then
			return record
		end
	end
	return nil
end

local function entryExpansionIndexFromRuntimeName(kind, entry)
	if kind ~= "raid" or not (entry and entry.activityID) or entry.groupID then
		return nil
	end
	local function readInfo(info)
		if type(info) ~= "table" then
			return nil
		end
		local hint = extractParentheticalHint(info.fullName) or extractParentheticalHint(info.shortName)
		return expansionIndexFromHint(kind, hint)
	end
	local expansionIndex = readInfo(entry.info)
	if expansionIndex ~= nil then
		return expansionIndex
	end
	for _, activityID in ipairs(entry.activityIDs or EMPTY) do
		expansionIndex = readInfo(getActivityInfo(activityID))
		if expansionIndex ~= nil then
			return expansionIndex
		end
	end
	return nil
end

local function entrySoloPairBaseKey(entry)
	if not (entry and entry.activityID) or entry.groupID then
		return nil
	end
	local info = entry.info or getActivityInfo(entry.activityID)
	local key = soloPairBaseKey(info)
	if key then
		return key
	end
	for _, activityID in ipairs(entry.activityIDs or EMPTY) do
		key = soloPairBaseKey(getActivityInfo(activityID))
		if key then
			return key
		end
	end
	return nil
end

local function buildRuntimeSoloExpansionPairs(kind, entries)
	local pairs = {}
	if kind ~= "raid" then
		return pairs
	end
	for _, entry in ipairs(entries or EMPTY) do
		if entry.activityID and not entry.groupID then
			local expansionIndex = entryExpansionIndexFromRuntimeName(kind, entry)
			local baseKey = entrySoloPairBaseKey(entry)
			if expansionIndex ~= nil and baseKey then
				local existing = pairs[baseKey]
				if existing == nil or expansionIndex > existing then
					pairs[baseKey] = expansionIndex
				end
			end
		end
	end
	return pairs
end

local function pairedRuntimeExpansionIndex(pairLookup, entry, record)
	if not (pairLookup and record and record.expansionIndex ~= nil) then
		return nil
	end
	local baseKey = entrySoloPairBaseKey(entry)
	local expansionIndex = baseKey and pairLookup[baseKey]
	if expansionIndex == nil then
		return nil
	end
	if math.abs((tonumber(record.expansionIndex) or expansionIndex) - expansionIndex) > 1 then
		return nil
	end
	return expansionIndex
end

local function buildWorldBossJournalDescriptors(kind, entries)
	local byJournalInstanceID = {}
	if kind ~= "raid" then
		return byJournalInstanceID
	end

	local staticLookup = staticCatalogLookup(kind)
	local configured = GF.NAV_CATALOG_WORLD_BOSS_CONTAINERS
		and GF.NAV_CATALOG_WORLD_BOSS_CONTAINERS.raid or EMPTY
	local byActivityID = {}
	local byExpansion = {}
	for _, record in ipairs(configured) do
		local journalInstanceID = tonumber(record and record.journalInstanceID)
		local expansionIndex = tonumber(record and record.expansionIndex)
		if journalInstanceID and expansionIndex ~= nil then
			local descriptor = {
				journalInstanceID = journalInstanceID,
				expansionIndex = expansionIndex,
				entries = {},
				entrySeen = {},
			}
			byJournalInstanceID[journalInstanceID] = descriptor
			byExpansion[expansionIndex] = descriptor
			for _, activityID in ipairs(record.activityIDs or EMPTY) do
				activityID = tonumber(activityID)
				if activityID then
					byActivityID[activityID] = descriptor
					local info = getActivityInfo(activityID)
					local label = worldBossLabelForInfo(info)
					if label and not descriptor.label then
						descriptor.label = label
						descriptor.info = info
					end
				end
			end
		end
	end

	for _, entry in ipairs(entries or EMPTY) do
		if entry.activityID and not entry.groupID and entry.worldBoss == true then
			local descriptor = byActivityID[tonumber(entry.activityID)]
			if not descriptor then
				local record = staticRecordForEntry(staticLookup, entry)
				local expansionIndex = entryExpansionIndexFromRuntimeName(kind, entry)
					or (record and record.expansionIndex)
				descriptor = byExpansion[tonumber(expansionIndex)]
			end
			if descriptor and not descriptor.entrySeen[entry] then
				descriptor.entrySeen[entry] = true
				descriptor.entries[#descriptor.entries + 1] = entry
				if not descriptor.label then
					descriptor.label = entry.worldBossLabel
					descriptor.info = entry.info or getActivityInfo(entry.activityID)
				end
			end
		end
	end

	for _, descriptor in pairs(byJournalInstanceID) do
		descriptor.entrySeen = nil
	end
	return byJournalInstanceID
end

local function worldBossDescriptorForJournal(byJournalInstanceID, journalInstance)
	local journalInstanceID = tonumber(journalInstance and journalInstance.journalInstanceID)
	return journalInstanceID and byJournalInstanceID and byJournalInstanceID[journalInstanceID] or nil
end

local function isWorldBossJournalContainer(kind, journalInstance)
	if kind ~= "raid" or type(journalInstance) ~= "table" then
		return false
	end
	local journalInstanceID = tonumber(journalInstance.journalInstanceID)
	local configured = GF.NAV_CATALOG_WORLD_BOSS_CONTAINERS
		and GF.NAV_CATALOG_WORLD_BOSS_CONTAINERS.raid or EMPTY
	for _, record in ipairs(configured) do
		if journalInstanceID
			and tonumber(record and record.journalInstanceID) == journalInstanceID
		then
			return true
		end
	end
	-- The stable Journal identity above is authoritative for known containers.
	-- Keep the localized label check only as a forward-compatible guard for a
	-- newly added container that has not yet reached NavManualData.
	return isWorldBossLabel(journalInstance.label)
end

local function runtimeSoloOrder(entry)
	local info = entry and (entry.info or getActivityInfo(entry.activityID))
	local difficultyIndex = GF.ActivityInfo and GF.ActivityInfo.GetDifficultyIndex
		and GF.ActivityInfo.GetDifficultyIndex(info, { includeMplus = true }) or 0
	if not difficultyIndex or difficultyIndex <= 0 then
		difficultyIndex = 1
	end
	return STATIC_RAID_SOLO_ORDER_BASE + 9000 + (difficultyIndex * 100) + ((tonumber(entry and entry.activityID) or 0) % 100)
end

local function appendManualMappedEntries(kind, catalog, entries, used)
	local lookup = manualCatalogLookup(kind)
	if not lookup then
		return
	end

	for _, entry in ipairs(entries or EMPTY) do
		if not used[entry] then
			local record = staticRecordForEntry(lookup, entry)
			if record and record.expansionIndex ~= nil then
				used[entry] = true
				local expansionIndex = record.expansionIndex
				local expansion = addExpansion(catalog, expansionIndex, expansionLabel(expansionIndex))
				local out = copyEntryForCatalog(entry, nil, kind)
				applyCatalogRecordOverrides(out, record)
				expansion.instances[#expansion.instances + 1] = out
			end
		end
	end
end

local function appendStaticMappedEntries(kind, catalog, entries, used)
	local lookup = staticCatalogLookup(kind)
	if not lookup then
		return
	end
	local pairLookup = buildRuntimeSoloExpansionPairs(kind, entries)

	for _, entry in ipairs(entries or EMPTY) do
		if not used[entry] then
			local record = staticRecordForEntry(lookup, entry)
			local runtimeExpansionIndex = entryExpansionIndexFromRuntimeName(kind, entry)
				or pairedRuntimeExpansionIndex(pairLookup, entry, record)
			if runtimeExpansionIndex ~= nil or (record and record.expansionIndex ~= nil) then
				used[entry] = true
				local expansionIndex = runtimeExpansionIndex ~= nil and runtimeExpansionIndex or record.expansionIndex
				local expansion = addExpansion(catalog, expansionIndex, expansionLabel(expansionIndex))
				local out = copyEntryForCatalog(entry, nil, kind)
				applyCatalogRecordOverrides(out, record)
				if runtimeExpansionIndex ~= nil then
					out.orderIndex = runtimeSoloOrder(entry)
				elseif record.orderIndex then
					out.orderIndex = record.orderIndex
					if kind == "raid" and entry.activityID and not entry.groupID then
						out.orderIndex = STATIC_RAID_SOLO_ORDER_BASE + record.orderIndex
					end
				end
				expansion.instances[#expansion.instances + 1] = out
			end
		end
	end
end

local function buildRuntimeCatalog(kind)
	local entries, lookup, meta = buildRuntimeEntries(kind)
	if not meta then
		return nil
	end

	local catalog = {
		categoryID = meta.categoryID,
		baseFilters = meta.baseFilters or 0,
		preferredFilters = meta.preferredFilters or PVE,
		expansions = {},
		_expansionsByIndex = {},
	}
	local used = {}
	local journal = journalCatalog(kind)
	local worldBossDescriptors = buildWorldBossJournalDescriptors(kind, entries)
	appendManualMappedEntries(kind, catalog, entries, used)
	if journal then
		for _, journalExpansion in ipairs(journal.expansions or EMPTY) do
			local expansion = addExpansion(
				catalog,
				journalExpansion.expansionIndex,
				journalExpansion.label
			)
			local collapsedJournalActivities = {}
			for _, journalInstance in ipairs(journalExpansion.instances or EMPTY) do
				local worldBoss = worldBossDescriptorForJournal(worldBossDescriptors, journalInstance)
				if worldBoss then
					local worldBossLabel = worldBossContainerLabel(
						worldBoss.info,
						worldBoss.label,
						journalInstance.label
					)
					local found = false
					for _, entry in ipairs(worldBoss.entries or EMPTY) do
						if not used[entry] then
							used[entry] = true
							found = true
							expansion.instances[#expansion.instances + 1] = copyWorldBossEntryForCatalog(
								entry,
								journalInstance,
								worldBossLabel
							)
						end
					end
					if not found then
						expansion.instances[#expansion.instances + 1] = copyUnavailableJournalInstance(
							journalInstance,
							worldBossLabel,
							true
						)
					end
				else
					local entry = takeEntryForJournalInstance(kind, lookup, journalInstance, used)
					if not entry then
						entry = strictLegacySoloEntryForJournalBoundary(kind, meta, journalInstance)
					end
					if entry then
						local collapsedActivityID = reusableJournalActivityID(kind, entry)
						if not collapsedActivityID or not collapsedJournalActivities[collapsedActivityID] then
							if collapsedActivityID then
								collapsedJournalActivities[collapsedActivityID] = true
							end
							expansion.instances[#expansion.instances + 1] = copyEntryForCatalog(
								entry,
								journalInstance,
								kind
							)
						end
					else
						expansion.instances[#expansion.instances + 1] = copyUnavailableJournalInstance(journalInstance)
					end
				end
			end
		end
	end
	appendStaticMappedEntries(kind, catalog, entries, used)
	return finalizeCatalog(catalog, kind)
end

local function sortSeasonInstances(kind, instances)
	table.sort(instances, function(a, b)
		local oa = instanceOrder(a)
		local ob = instanceOrder(b)
		if oa ~= ob then
			return oa < ob
		end
		return (tonumber(a.groupID or a.activityID) or 0) < (tonumber(b.groupID or b.activityID) or 0)
	end)
end

local function seasonJournalInstances(journal)
	if type(journal) ~= "table" then
		return EMPTY
	end
	if #(journal.seasonInstances or EMPTY) > 0 then
		return journal.seasonInstances
	end

	-- Retail can expose no extra Encounter Journal "current season" tier when
	-- the season is the current expansion itself. Blizzard's own Journal falls
	-- back to the current expansion for an out-of-range season tier. Mirror that
	-- structural fallback here; the live exact LFG filter and activity flags
	-- below still decide which of these Journal rows are actually seasonal.
	local currentExpansion = clientDisplayExpansionIndex()
	for _, expansion in ipairs(journal.expansions or EMPTY) do
		if tonumber(expansion.expansionIndex) == currentExpansion then
			return expansion.instances or EMPTY
		end
	end
	return EMPTY
end

local function seasonRaidFilterSets()
	local filters, seen = {}, {}
	-- A current raid activity can require the complete record mask rather than
	-- answering a broad subset query. Retail 12.1, for example, exposes the
	-- Tidebound Grotto and The Venomous Abyss raid activities with filter 165:
	-- Recommended | PvE | CurrentExpansion | NotCurrentSeason. Keep the native
	-- Activity Finder family here, but consume its results only while matching
	-- the already selected current Journal instances below; these queries expand
	-- difficulties, never the seasonal raid roster.
	local scopes = {
		0,
		CURRENT_EXPANSION,
		NOT_CURRENT_SEASON,
		bor(CURRENT_EXPANSION, NOT_CURRENT_SEASON),
	}
	local recommendations = { 0, RECOMMENDED, NOT_RECOMMENDED }
	for _, scope in ipairs(scopes) do
		for _, recommendation in ipairs(recommendations) do
			addUnique(filters, seen, bor(PVE, scope, recommendation))
		end
	end
	addUnique(filters, seen, 0)
	addUnique(filters, seen, RECOMMENDED)
	addUnique(filters, seen, NOT_RECOMMENDED)
	return filters
end

local function seasonRaidProbeGroupIDs()
	local groups, seen = {}, {}
	local currentExpansion = clientDisplayExpansionIndex()
	local source = GF.NAV_CATALOG_SUPPLEMENT and GF.NAV_CATALOG_SUPPLEMENT.raid
	for _, expansion in ipairs(source and source.expansions or EMPTY) do
		if tonumber(expansion and expansion.expansionIndex) == currentExpansion then
			for _, instance in ipairs(expansion.instances or EMPTY) do
				local groupID = tonumber(instance and instance.groupID)
				if groupID and groupID > 0 and not seen[groupID] then
					seen[groupID] = true
					groups[#groups + 1] = groupID
				end
			end
		end
	end
	return groups
end

local function currentMythicPlusSeasonID()
	if not (C_MythicPlus and C_MythicPlus.GetCurrentSeason) then
		return nil
	end
	local ok, seasonID = pcall(C_MythicPlus.GetCurrentSeason)
	seasonID = ok and tonumber(seasonID) or nil
	return seasonID and seasonID > 0 and seasonID or nil
end

local function localizedChallengeModeIdentity(challengeModeID, fallbackName)
	if not (C_ChallengeMode and C_ChallengeMode.GetMapUIInfo) then
		return fallbackName, nil, false
	end
	local ok, name, _, _, _, _, instanceMapID = pcall(
		C_ChallengeMode.GetMapUIInfo, challengeModeID)
	if not ok then
		return fallbackName, nil, false
	end
	local localizedName = cleanText(name)
	return localizedName or fallbackName, tonumber(instanceMapID),
		localizedName ~= nil
end

local function stableSeasonDungeonRoster()
	local currentSeasonID = currentMythicPlusSeasonID()
	local dungeons = GF.MythicPlusSeason
		and GF.MythicPlusSeason.GetDungeons
		and GF.MythicPlusSeason:GetDungeons() or EMPTY
	local serviceSeasonID = GF.MythicPlusSeason
		and GF.MythicPlusSeason.GetSeasonID
		and GF.MythicPlusSeason:GetSeasonID() or nil
	local fallbackSeasonID = GF.MythicPlusSeasonData
		and GF.MythicPlusSeasonData.GetFallbackSeasonID
		and GF.MythicPlusSeasonData.GetFallbackSeasonID() or nil
	if type(dungeons) == "table" and #dungeons > 0
		and ((currentSeasonID and (not serviceSeasonID
			or tonumber(serviceSeasonID) == currentSeasonID))
			or (not currentSeasonID and (not serviceSeasonID
				or not fallbackSeasonID
				or tonumber(serviceSeasonID) == tonumber(fallbackSeasonID))))
	then
		return dungeons
	end

	local data = GF.MythicPlusSeasonData
	if not (data and data.GetFallbackDungeons) then
		return EMPTY
	end
	local fallbackDungeons = data.GetFallbackDungeons(
		currentSeasonID)
	local roster = {}
	for orderIndex, fallback in ipairs(fallbackDungeons or EMPTY) do
		local challengeModeID = tonumber(fallback.challengeModeID)
		if challengeModeID then
			local name, instanceMapID, mapInfoReady = localizedChallengeModeIdentity(
				challengeModeID, fallback.fallbackName)
			roster[#roster + 1] = {
				challengeModeID = challengeModeID,
				seasonMapOrder = orderIndex,
				orderIndex = orderIndex,
				name = name,
				mapInfoReady = mapInfoReady,
				mapID = instanceMapID,
				instanceMapID = instanceMapID,
				visualTexture = fallback.fallbackTexture,
				visualTexCoords = fallback.fallbackTexCoords,
			}
		end
	end
	return roster
end

local function seasonDungeonEntryByKnownIdentity(entries, dungeon, used)
	local groupID = tonumber(dungeon and dungeon.groupID)
	local activityIDs = {}
	for _, activityID in ipairs(dungeon and dungeon.activityIDs or EMPTY) do
		activityID = tonumber(activityID)
		if activityID then
			activityIDs[activityID] = true
		end
	end
	if dungeon and dungeon.activityID then
		local activityID = tonumber(dungeon.activityID)
		if activityID then
			activityIDs[activityID] = true
		end
	end
	for _, entry in ipairs(entries or EMPTY) do
		if not used[entry]
			and ((groupID and tonumber(entry.groupID) == groupID)
				or (entry.activityID and activityIDs[tonumber(entry.activityID)]))
		then
			used[entry] = true
			return entry
		end
	end
	return nil
end

local function copyStableSeasonDungeon(dungeon, entry, journalInstance)
	local stableLabel = cleanText(dungeon.name) or cleanText(dungeon.label)
	if entry and dungeon.mapInfoReady ~= true then
		-- A live LFG activity carries the current locale. Prefer it over an
		-- English emergency fallback until Challenge Mode map info is readable.
		stableLabel = nil
	end
	local identity = {
		challengeModeID = tonumber(dungeon.challengeModeID),
		journalInstanceID = tonumber(journalInstance
			and journalInstance.journalInstanceID)
			or tonumber(dungeon.journalInstanceID),
		mapID = tonumber(journalInstance and journalInstance.mapID)
			or tonumber(dungeon.mapID),
		instanceMapID = tonumber(journalInstance
			and journalInstance.instanceMapID)
			or tonumber(dungeon.instanceMapID),
		orderIndex = tonumber(dungeon.seasonMapOrder)
			or tonumber(dungeon.orderIndex),
		label = stableLabel,
		visualTexture = journalInstance and journalInstance.visualTexture
			or dungeon.visualTexture
			or dungeon.backgroundTexture or dungeon.texture,
		visualTexCoords = journalInstance and journalInstance.visualTexCoords
			or dungeon.visualTexCoords
			or dungeon.fallbackTexCoords,
		visualSource = journalInstance and journalInstance.visualSource
			or dungeon.visualSource,
	}
	local out = entry
		and copyEntryForCatalog(entry, identity, "dungeon")
		or copyUnavailableJournalInstance(identity)
	out.challengeModeID = identity.challengeModeID
	out.seasonMapOrder = identity.orderIndex
	out.orderIndex = identity.orderIndex
	out.label = identity.label or out.label
	out.mapID = identity.mapID or out.mapID
	out.instanceMapID = identity.instanceMapID or out.instanceMapID
	out.journalInstanceID = identity.journalInstanceID
		or out.journalInstanceID
	out.visualTexture = identity.visualTexture or out.visualTexture
	out.visualTexCoords = identity.visualTexCoords or out.visualTexCoords
	out.visualSource = identity.visualSource or out.visualSource
	return out
end

local function journalIdentityForStableDungeon(dungeon, journal)
	for _, journalInstance in ipairs(seasonJournalInstances(journal)) do
		local dungeonMapID = tonumber(dungeon.mapID)
			or tonumber(dungeon.instanceMapID)
		local journalMapID = tonumber(journalInstance.mapID)
			or tonumber(journalInstance.instanceMapID)
		if (dungeonMapID and journalMapID and dungeonMapID == journalMapID)
			or (normalizedName(dungeon.name or dungeon.label)
				== normalizedName(journalInstance.label))
		then
			return journalInstance
		end
	end
	return nil
end

local function buildStableSeasonDungeonInstances(entries, lookup)
	local roster = stableSeasonDungeonRoster()
	if #roster == 0 then
		return nil
	end
	local used = {}
	local instances = {}
	local journal = journalCatalog("dungeon")
	for _, dungeon in ipairs(roster) do
		local journalInstance = journalIdentityForStableDungeon(
			dungeon, journal)
		local identity = {
			challengeModeID = dungeon.challengeModeID,
			journalInstanceID = journalInstance
				and journalInstance.journalInstanceID
				or dungeon.journalInstanceID,
			mapID = journalInstance and journalInstance.mapID
				or dungeon.mapID,
			instanceMapID = journalInstance
				and journalInstance.instanceMapID
				or dungeon.instanceMapID,
			label = dungeon.name or dungeon.label,
		}
		local entry = takeEntryForJournalInstance(
			"dungeon", lookup, identity, used)
			or seasonDungeonEntryByKnownIdentity(
				entries, dungeon, used)
		instances[#instances + 1] = copyStableSeasonDungeon(
			dungeon, entry, journalInstance)
	end
	return instances
end

local function buildSeasonInstances(kind)
	local filterSets
	local raidProbeGroupIDs
	if kind == "dungeon" then
		filterSets = { bor(CURRENT_SEASON, PVE) }
	elseif kind == "raid" then
		raidProbeGroupIDs = seasonRaidProbeGroupIDs()
		if #raidProbeGroupIDs > 0 then
			filterSets = seasonRaidFilterSets()
		else
			-- Older supported clients do not carry the 12.1 current-group
			-- supplement. Preserve their previous narrow exact path rather than
			-- enumerating the complete archive filter family.
			filterSets = { bor(RECOMMENDED, PVE), PVE }
		end
	else
		return EMPTY
	end

	local entries, lookup = buildRuntimeEntries(kind, filterSets, {
		exactFilters = true,
		probeGroupIDs = raidProbeGroupIDs,
		probeOnly = raidProbeGroupIDs and #raidProbeGroupIDs > 0 or false,
	})
	-- The exact Blizzard filter defines seasonal roster membership, not a
	-- single difficulty. Before Mythic+ or a raid difficulty opens, Retail can
	-- legitimately return only the currently usable Normal/Heroic/Mythic
	-- activities. Difficulty flags are therefore interpreted later by NavData;
	-- filtering the catalog to Mythic+ / current-raid flags here would erase the
	-- whole seasonal entry during a phased unlock.
	if kind == "dungeon" then
		local stableInstances = buildStableSeasonDungeonInstances(entries, lookup)
		if stableInstances then
			return stableInstances
		end
	end

	local used = {}
	local instances = {}
	local journal = journalCatalog(kind)
	if journal then
		for _, journalInstance in ipairs(seasonJournalInstances(journal)) do
			-- Encounter Journal exposes regional world-boss containers as raid
			-- instances. They belong to the ordinary raid directory even when their
			-- activities are Recommended/current; never turn them into seasonal raids.
			if not isWorldBossJournalContainer(kind, journalInstance) then
				local entry = takeEntryForJournalInstance(kind, lookup, journalInstance, used)
				if entry then
					instances[#instances + 1] = copyEntryForCatalog(entry, journalInstance, kind)
				elseif kind == "raid" then
					-- Seasonal raids remain discoverable before their first premade
					-- difficulty is authorized. The row carries no activity identity;
					-- NavData projects disabled difficulty placeholders below it.
					instances[#instances + 1] = copyUnavailableJournalInstance(journalInstance)
				end
			end
		end
	end
	if kind == "dungeon" then
		-- A current-season rotation can contain dungeons from older expansions.
		-- When Retail has no dedicated season Journal tier, the current-expansion
		-- fallback above cannot locate those rows. Keep every still-unconsumed
		-- entry from the exact CurrentSeason filter; it is already live-authorized
		-- and must not disappear merely because its Journal row lives elsewhere.
		for _, entry in ipairs(entries or EMPTY) do
			if not used[entry] then
				used[entry] = true
				instances[#instances + 1] = copyEntryForCatalog(entry, nil, kind)
			end
		end
	end
	sortSeasonInstances(kind, instances)
	return instances
end

local function runtimeCatalog(kind)
	if kind == nil then
		return nil
	end
	local cached = runtimeCatalogCache[kind]
	if cached ~= nil then
		return cached ~= false and cached or nil
	end
	local catalog = buildRuntimeCatalog(kind)
	runtimeCatalogCache[kind] = catalog or false
	return catalog
end

function NavCatalog.ClearRuntimeCache()
	clearTable(runtimeCatalogCache)
	clearTable(journalCatalogCache)
	clearTable(staticCatalogLookupCache)
	clearTable(manualCatalogLookupCache)
	clearTable(expansionHintLookupCache)
	if GF.MythicPlusLFGScope and GF.MythicPlusLFGScope.Invalidate then
		GF.MythicPlusLFGScope:Invalidate()
	end
end

function NavCatalog.GetMeta(kind)
	return runtimeCatalog(kind)
end

function NavCatalog.GetExpansions(kind)
	local meta = NavCatalog.GetMeta(kind)
	return (meta and meta.expansions) or EMPTY
end

function NavCatalog.GetInstances(kind, expansionIndex)
	if expansionIndex == nil then
		return EMPTY
	end
	for _, expansion in ipairs(NavCatalog.GetExpansions(kind)) do
		if expansion.expansionIndex == expansionIndex then
			return expansion.instances or EMPTY
		end
	end
	return EMPTY
end

function NavCatalog.GetSeasonInstances(kind)
	local cacheKey = "season:" .. tostring(kind)
	local cached = runtimeCatalogCache[cacheKey]
	if cached ~= nil then
		return cached ~= false and cached or EMPTY
	end
	local instances = buildSeasonInstances(kind)
	runtimeCatalogCache[cacheKey] = instances or false
	return instances or EMPTY
end

function NavCatalog.GetJournalInstance(kind, mapID, instanceMapID, name)
	local catalog = journalCatalog(kind)
	if not catalog then
		return nil
	end
	local nameKey = normalizedName(name)
	local function choose(bucket)
		if type(bucket) ~= "table" then
			return nil
		end
		if nameKey then
			for _, instance in ipairs(bucket) do
				if normalizedName(instance and instance.label) == nameKey then
					return instance
				end
			end
		end
		return bucket[1]
	end
	local instance = choose(catalog.byMapID[tonumber(mapID)])
		or choose(catalog.byMapID[tonumber(instanceMapID)])
		or choose(catalog.byName[nameKey])
	return instance
end

function NavCatalog.CollectGroupIDs(kind)
	local seen = {}
	local groupIDs = {}
	for _, expansion in ipairs(NavCatalog.GetExpansions(kind)) do
		for _, instance in ipairs(expansion.instances or EMPTY) do
			local groupID = instance.groupID
			if groupID and not seen[groupID] then
				seen[groupID] = true
				groupIDs[#groupIDs + 1] = groupID
			end
		end
	end
	table.sort(groupIDs)
	return groupIDs
end
