local _, GF = ...

local Audit = {}
GF.LFGCoverageAudit = Audit

local SECOND_PASS_DELAY = 0.20
local DETAIL_LIMIT = 40

local function safeNumber(value)
	local ok, number = pcall(tonumber, value)
	return ok and number or nil
end

local function safeText(value)
	if value == nil then
		return nil
	end
	local ok, text = pcall(tostring, value)
	if not ok then
		return "<unreadable>"
	end
	return text:gsub("[\r\n]+", " ")
end

local function bitOr(...)
	if bit and type(bit.bor) == "function" then
		return bit.bor(...)
	end
	local value = 0
	for index = 1, select("#", ...) do
		value = value + (safeNumber(select(index, ...)) or 0)
	end
	return value
end

local function bitAnd(left, right)
	if bit and type(bit.band) == "function" then
		return bit.band(left or 0, right or 0)
	end
	return 0
	end

local function bitNot(value)
	if bit and type(bit.bnot) == "function" then
		return bit.bnot(value or 0)
	end
	return -(value or 0) - 1
	end

local function sortedNumericKeys(source)
	local keys = {}
	for key in pairs(source or {}) do
		local numeric = safeNumber(key)
		if numeric then
			keys[#keys + 1] = numeric
		end
	end
	table.sort(keys)
	return keys
	end

local function addUniqueNumber(values, seen, value)
	value = safeNumber(value)
	if not value or value <= 0 or seen[value] then
		return false
	end
	seen[value] = true
	values[#values + 1] = value
	return true
	end

local function addUniqueValue(values, seen, value)
	value = safeNumber(value)
	if value == nil or seen[value] then
		return false
	end
	seen[value] = true
	values[#values + 1] = value
	return true
	end

local function addSource(entry, source)
	if not (entry and source) then
		return
	end
	entry._sourceSeen = entry._sourceSeen or {}
	if entry._sourceSeen[source] or #(entry.sources or {}) >= 12 then
		return
	end
	entry._sourceSeen[source] = true
	entry.sources = entry.sources or {}
	entry.sources[#entry.sources + 1] = source
	end

local function enumFilter(name)
	return safeNumber(Enum and Enum.LFGListFilter
		and Enum.LFGListFilter[name]) or 0
	end

local function isPvpCategory(categoryID)
	for _, definition in ipairs(GF.PVP_CATEGORIES or {}) do
		if safeNumber(definition and definition.id) == categoryID then
			return true
		end
	end
	return false
	end

local function resolveFinderFilter(categoryID, filters)
	if categoryID ~= (safeNumber(GF.CAT_DUNGEON) or 2) then
		return filters
	end
	local recommended = enumFilter("Recommended")
	local notRecommended = enumFilter("NotRecommended")
	return bitAnd(bitNot(notRecommended), bitOr(filters, recommended))
	end

local function appendFilterVariants(filters, seen, base)
	base = safeNumber(base) or 0
	local recommended = enumFilter("Recommended")
	local notRecommended = enumFilter("NotRecommended")
	addUniqueValue(filters, seen, base)
	addUniqueValue(filters, seen, bitOr(base, recommended))
	addUniqueValue(filters, seen, bitOr(base, notRecommended))
	end

local function buildFilterFamily(categoryID, discoveredBases)
	local filters, seen = {}, {}
	local pve = enumFilter("PvE")
	local pvp = enumFilter("PvP")
	local timerunning = enumFilter("Timerunning")
	local currentExpansion = enumFilter("CurrentExpansion")
	local currentSeason = enumFilter("CurrentSeason")
	local notCurrentSeason = enumFilter("NotCurrentSeason")
	local dungeon = safeNumber(GF.CAT_DUNGEON) or 2
	local raid = safeNumber(GF.CAT_RAID) or 3

	appendFilterVariants(filters, seen, 0)
	for _, base in ipairs(discoveredBases or {}) do
		appendFilterVariants(filters, seen, base)
	end
	if categoryID == dungeon or categoryID == raid then
		appendFilterVariants(filters, seen, pve)
		appendFilterVariants(filters, seen, bitOr(pve, currentSeason))
		appendFilterVariants(filters, seen, bitOr(pve, currentExpansion))
		appendFilterVariants(filters, seen, bitOr(pve, notCurrentSeason))
		appendFilterVariants(filters, seen,
			bitOr(pve, currentExpansion, notCurrentSeason))
		if timerunning ~= 0 then
			appendFilterVariants(filters, seen, bitOr(pve, timerunning))
		end
	elseif isPvpCategory(categoryID) then
		appendFilterVariants(filters, seen, pvp)
	else
		appendFilterVariants(filters, seen, pve)
		appendFilterVariants(filters, seen, pvp)
	end
	return filters, seen
	end

local function newSnapshot()
	return {
		ready = nil,
		permission = nil,
		permissionReason = nil,
		nativeByID = {},
		unresolvedByID = {},
		categoryBases = {},
		categoryNames = {},
		menu = {
			directByID = {},
			scopeByID = {},
		},
		queryCount = 0,
		queryErrors = {},
		apiMissing = {},
	}
	end

local function recordError(snapshot, value)
	if #(snapshot.queryErrors or {}) < 30 then
		snapshot.queryErrors[#snapshot.queryErrors + 1] = safeText(value) or "unknown"
	end
	end

local function safeListCall(snapshot, label, fn, ...)
	if type(fn) ~= "function" then
		snapshot.apiMissing[label] = true
		return nil
	end
	snapshot.queryCount = snapshot.queryCount + 1
	local ok, values = pcall(fn, ...)
	if not ok or type(values) ~= "table" then
		recordError(snapshot, label .. "=" .. (ok and "not-table" or "error"))
		return nil
	end
	return values
	end

local function readActivityInfo(snapshot, activityID)
	local existing = snapshot.nativeByID[activityID]
	if existing and existing.info then
		return existing.info
	end
	local getter = C_LFGList and C_LFGList.GetActivityInfoTable
	if type(getter) ~= "function" then
		snapshot.apiMissing.GetActivityInfoTable = true
		return nil
	end
	local ok, info = pcall(getter, activityID)
	if ok and type(info) == "table" then
		return info
	end
	return nil
	end

local function recordActivities(snapshot, categoryID, values, source, onInfo)
	for _, rawID in ipairs(values or {}) do
		local activityID = safeNumber(rawID)
		if activityID and activityID > 0 then
			local entry = snapshot.nativeByID[activityID]
			if not entry then
				entry = {
					activityID = activityID,
					queryCategoryID = categoryID,
					sources = {},
				}
				snapshot.nativeByID[activityID] = entry
			end
			addSource(entry, source)
			if not entry.info and not entry._infoReadAttempted then
				entry._infoReadAttempted = true
				entry.info = readActivityInfo(snapshot, activityID)
			end
			if entry.info then
				snapshot.unresolvedByID[activityID] = nil
				if type(onInfo) == "function" then
					onInfo(entry.info)
				end
			else
				snapshot.unresolvedByID[activityID] = entry
			end
		end
	end
	end

local function categoryName(categoryID)
	local getter = C_LFGList and C_LFGList.GetLfgCategoryInfo
	if type(getter) == "function" then
		local ok, info = pcall(getter, categoryID)
		if ok and type(info) == "table" then
			return safeText(info.name)
		end
	end
	return nil
	end

local function addCategoryBase(snapshot, categoryOrder, categorySeen,
	categoryID, baseFilters)
	categoryID = safeNumber(categoryID)
	if not categoryID or categoryID <= 0 then
		return
	end
	if not categorySeen[categoryID] then
		categorySeen[categoryID] = true
		categoryOrder[#categoryOrder + 1] = categoryID
		snapshot.categoryBases[categoryID] = { values = {}, seen = {} }
		snapshot.categoryNames[categoryID] = categoryName(categoryID)
	end
	local bucket = snapshot.categoryBases[categoryID]
	addUniqueValue(bucket.values, bucket.seen, baseFilters or 0)
	end

local function collectCategorySeeds(snapshot)
	local categories, seen = {}, {}
	local pve = enumFilter("PvE")
	local pvp = enumFilter("PvP")
	local timerunning = enumFilter("Timerunning")
	local baseFilters, baseSeen = {}, {}
	addUniqueValue(baseFilters, baseSeen, 0)
	addUniqueValue(baseFilters, baseSeen, pve)
	addUniqueValue(baseFilters, baseSeen, pvp)
	if timerunning ~= 0 then
		addUniqueValue(baseFilters, baseSeen, bitOr(pve, timerunning))
	end

	local getCategories = C_LFGList and C_LFGList.GetAvailableCategories
	for _, base in ipairs(baseFilters) do
		local values = safeListCall(snapshot, "GetAvailableCategories",
			getCategories, base)
		for _, categoryID in ipairs(values or {}) do
			addCategoryBase(snapshot, categories, seen, categoryID, base)
		end
	end

	-- Known category IDs are query targets only. They do not authorize an
	-- activity; every activity still has to be returned by the independent
	-- native availability matrix below.
	for _, categoryID in ipairs({
		GF.CAT_QUEST or 1,
		GF.CAT_DUNGEON or 2,
		GF.CAT_RAID or 3,
		GF.CAT_CUSTOM or 6,
		GF.CAT_DELVE or 121,
	}) do
		addCategoryBase(snapshot, categories, seen, categoryID, 0)
	end
	for _, definition in ipairs(GF.PVP_CATEGORIES or {}) do
		addCategoryBase(snapshot, categories, seen,
			definition and definition.id, pvp)
	end
	table.sort(categories)
	return categories
	end

local function collectNativeCategory(snapshot, categoryID)
	local baseBucket = snapshot.categoryBases[categoryID]
	local filters, filterSeen = buildFilterFamily(
		categoryID, baseBucket and baseBucket.values)
	local groups, groupSeen = {}, {}
	local groupFilters = {}
	local getGroups = C_LFGList and C_LFGList.GetAvailableActivityGroups
	local getActivities = C_LFGList and C_LFGList.GetAvailableActivities

	local function addGroup(groupID, observedFilter)
		groupID = safeNumber(groupID)
		if not groupID or groupID <= 0 then
			return
		end
		addUniqueNumber(groups, groupSeen, groupID)
		local bucket = groupFilters[groupID]
		if not bucket then
			bucket = { values = {}, seen = {} }
			groupFilters[groupID] = bucket
		end
		addUniqueValue(bucket.values, bucket.seen, observedFilter or 0)
	end

	local function consumeInfo(info, observedFilter)
		local groupID = safeNumber(info and info.groupFinderActivityGroupID)
		if groupID and groupID > 0 then
			addGroup(groupID, observedFilter)
		end
		local infoFilters = safeNumber(info and info.filters)
		if infoFilters ~= nil then
			addUniqueValue(filters, filterSeen, infoFilters)
			if groupID and groupID > 0 then
				addGroup(groupID, infoFilters)
			end
		end
	end

	local filterIndex = 1
	while filterIndex <= #filters do
		local filterFlags = filters[filterIndex]
		local resolved = resolveFinderFilter(categoryID, filterFlags)
		local availableGroups = safeListCall(snapshot,
			"GetAvailableActivityGroups", getGroups, categoryID, filterFlags)
		for _, groupID in ipairs(availableGroups or {}) do
			addGroup(groupID, filterFlags)
		end

		local top = safeListCall(snapshot, "GetAvailableActivities",
			getActivities, categoryID, 0, filterFlags)
		recordActivities(snapshot, categoryID, top,
			("cat=%d group=0 f=%d"):format(categoryID, filterFlags), function(info)
				consumeInfo(info, filterFlags)
			end)
		local categoryWide = safeListCall(snapshot, "GetAvailableActivities",
			getActivities, categoryID, nil, filterFlags)
		recordActivities(snapshot, categoryID, categoryWide,
			("cat=%d group=nil f=%d"):format(categoryID, filterFlags), function(info)
				consumeInfo(info, filterFlags)
			end)
		local finderWide = safeListCall(snapshot, "GetAvailableActivities",
			getActivities, categoryID, nil, resolved, "")
		recordActivities(snapshot, categoryID, finderWide,
			("cat=%d finder=nil f=%d text=empty"):format(categoryID, resolved), function(info)
				consumeInfo(info, resolved)
			end)
		filterIndex = filterIndex + 1
	end

	local groupIndex = 1
	while groupIndex <= #groups do
		local groupID = groups[groupIndex]
		local bucket = groupFilters[groupID] or { values = { 0 } }
		for _, filterFlags in ipairs(bucket.values) do
			local resolved = resolveFinderFilter(categoryID, filterFlags)
			local groupActivities = safeListCall(snapshot,
				"GetAvailableActivities", getActivities,
				categoryID, groupID, filterFlags)
			recordActivities(snapshot, categoryID, groupActivities,
				("cat=%d group=%d f=%d"):format(categoryID, groupID, filterFlags), function(info)
					consumeInfo(info, filterFlags)
				end)
			local finderActivities = safeListCall(snapshot,
				"GetAvailableActivities", getActivities,
				categoryID, groupID, resolved, "")
			recordActivities(snapshot, categoryID, finderActivities,
				("cat=%d finder=%d f=%d text=empty"):format(categoryID, groupID, resolved), function(info)
					consumeInfo(info, resolved)
				end)
		end
		groupIndex = groupIndex + 1
	end
	end

local function retryUnresolvedInfo(snapshot)
	for _, activityID in ipairs(sortedNumericKeys(snapshot.unresolvedByID)) do
		snapshot.nativeByID[activityID]._infoRetryAttempted = true
		local info = readActivityInfo(snapshot, activityID)
		if info then
			snapshot.nativeByID[activityID].info = info
			snapshot.unresolvedByID[activityID] = nil
		end
	end
	end

local function menuPath(parentPath, node)
	local label = safeText(node and node.label)
	if not label or label == "" then
		label = safeText(node and node.key) or "?"
	end
	return parentPath and parentPath ~= "" and (parentPath .. " > " .. label)
		or label
	end

local function addMenuEntry(bucket, activityID, entry)
	activityID = safeNumber(activityID)
	if not activityID or activityID <= 0 then
		return
	end
	bucket[activityID] = bucket[activityID] or {}
	bucket[activityID][#bucket[activityID] + 1] = entry
	end

local function collectMenuSnapshot(snapshot)
	local navData = GF.NavData
	if not (navData and type(navData.GetTree) == "function") then
		snapshot.apiMissing.NavData = true
		return
	end
	local activeTree = GF.navTree
	GF.navTree = nil
	local ok, reason = pcall(function()
		local roots = navData.GetTree() or {}
		local visited = {}
		local function visit(node, parentPath, inheritedCategory, inheritedKind)
			if not node or visited[node] then
				return
			end
			visited[node] = true
			if node.lazyKind and node.childrenLoaded ~= true
				and type(navData.EnsureChildren) == "function"
			then
				navData.EnsureChildren(node)
			end
			local path = menuPath(parentPath, node)
			local categoryID = safeNumber(node.categoryID) or inheritedCategory
			local navKind = node.navKind or inheritedKind
			local activityID = safeNumber(node.activityID)
			local enabled = node.disabled ~= true and node.categoryBrowse ~= true
			local canCreate = false
			if enabled and activityID then
				if type(navData.CanCreateFromNode) == "function" then
					local createOK, result = pcall(navData.CanCreateFromNode, node)
					canCreate = createOK and result == true
				else
					canCreate = categoryID ~= nil
				end
			end
			local baseEntry = {
				path = path,
				categoryID = categoryID,
				navKind = navKind,
				primaryActivityID = activityID,
				isLeaf = node.isLeaf == true,
			}
			if canCreate and node.isLeaf == true then
				addMenuEntry(snapshot.menu.directByID, activityID, baseEntry)
			end
			if enabled then
				local scopeSeen = {}
				for _, list in ipairs({
					node.activityIDsFilter,
					node.resultActivityIDsFilter,
				}) do
					for _, scopedID in ipairs(list or {}) do
						scopedID = safeNumber(scopedID)
						if scopedID and scopedID > 0 and not scopeSeen[scopedID] then
							scopeSeen[scopedID] = true
							addMenuEntry(snapshot.menu.scopeByID, scopedID, baseEntry)
						end
					end
				end
			end
			for _, child in ipairs(node.children or {}) do
				visit(child, path, categoryID, navKind)
			end
		end
		for _, root in ipairs(roots) do
			if root and root.key ~= "quick_search"
				and root.key ~= "favorite_instances"
			then
				visit(root, "", nil, nil)
			end
		end
	end)
	GF.navTree = activeTree
	if not ok then
		recordError(snapshot, "menu=" .. (safeText(reason) or "error"))
	end
	end

local function nativePermission(snapshot)
	local getter = C_LFGInfo and C_LFGInfo.CanPlayerUsePremadeGroup
	if type(getter) ~= "function" then
		snapshot.permission = true
		return
	end
	local ok, allowed, reason = pcall(getter)
	if not ok then
		snapshot.permission = true
		recordError(snapshot, "CanPlayerUsePremadeGroup=error")
		return
	end
	snapshot.permission = allowed ~= false
	snapshot.permissionReason = allowed == false and safeText(reason) or nil
	end

local function activityListReady(snapshot)
	local getter = C_LFGList and C_LFGList.HasActivityList
	if type(getter) ~= "function" then
		snapshot.apiMissing.HasActivityList = true
		return false
	end
	local ok, ready = pcall(getter)
	if not ok then
		recordError(snapshot, "HasActivityList=error")
		return false
	end
	return ready == true
	end

function Audit:CollectSnapshot()
	local snapshot = newSnapshot()
	snapshot.ready = activityListReady(snapshot)
	nativePermission(snapshot)
	if not snapshot.ready or snapshot.permission == false then
		return snapshot
	end
	for _, categoryID in ipairs(collectCategorySeeds(snapshot)) do
		collectNativeCategory(snapshot, categoryID)
	end
	retryUnresolvedInfo(snapshot)
	collectMenuSnapshot(snapshot)
	return snapshot
	end

local function snapshotSignature(snapshot)
	local values = {
		"ready=" .. tostring(snapshot and snapshot.ready == true),
		"permission=" .. tostring(snapshot and snapshot.permission ~= false),
	}
	for _, activityID in ipairs(sortedNumericKeys(snapshot and snapshot.nativeByID)) do
		local entry = snapshot.nativeByID[activityID]
		local info = entry and entry.info
		values[#values + 1] = ("n:%d:%s:%s"):format(
			activityID,
			tostring(safeNumber(info and info.categoryID) or "?"),
			tostring(safeNumber(info and info.groupFinderActivityGroupID) or 0))
	end
	for _, activityID in ipairs(sortedNumericKeys(snapshot and snapshot.menu
		and snapshot.menu.directByID)) do
		local entries = snapshot.menu.directByID[activityID]
		values[#values + 1] = ("m:%d:%d"):format(activityID, #entries)
		for _, entry in ipairs(entries) do
			values[#values + 1] = "p:" .. (entry.path or "?")
		end
	end
	for _, activityID in ipairs(sortedNumericKeys(snapshot and snapshot.menu
		and snapshot.menu.scopeByID)) do
		local entries = snapshot.menu.scopeByID[activityID]
		values[#values + 1] = ("s:%d:%d"):format(activityID, #entries)
		for _, entry in ipairs(entries) do
			values[#values + 1] = "q:" .. (entry.path or "?")
				.. ":" .. tostring(entry.primaryActivityID or 0)
		end
	end
	for _, activityID in ipairs(sortedNumericKeys(snapshot and snapshot.unresolvedByID)) do
		values[#values + 1] = "u:" .. tostring(activityID)
	end
	values[#values + 1] = "errors=" .. tostring(#(snapshot and snapshot.queryErrors or {}))
	return table.concat(values, "|")
	end

local function appendIssue(bucket, activityID, data)
	bucket[#bucket + 1] = {
		activityID = activityID,
		data = data,
	}
	end

local function categoryMatches(entry, info)
	local expected = safeNumber(info and info.categoryID)
	local actual = safeNumber(entry and entry.categoryID)
	return expected ~= nil and actual == expected
end

local function unexpectedDuplicate(entries)
	if #(entries or {}) <= 1 then
		return false
	end
	local counts = {}
	local paths = {}
	for _, entry in ipairs(entries) do
		local kind = entry.navKind or "?"
		counts[kind] = (counts[kind] or 0) + 1
		local identity = kind .. ":" .. (entry.path or "?")
		if paths[identity] then
			return true
		end
		paths[identity] = true
	end
	local kindCount = 0
	for _ in pairs(counts) do
		kindCount = kindCount + 1
	end
	if kindCount == 2 then
		local dungeonPair = counts.season_dungeon and counts.dungeon
		local raidPair = counts.season_raid and counts.raid
		return not (dungeonPair or raidPair)
	end
	-- One native activity may intentionally back multiple Encounter Journal
	-- rows in the same archive (for example reusable legacy entrances).
	return kindCount > 1
	end

function Audit:BuildReport(snapshot, stable)
	local report = {
		snapshot = snapshot,
		stable = stable == true,
		status = "FAIL",
		missing = {},
		aggregateOnly = {},
		misplaced = {},
		menuOnly = {},
		duplicates = {},
		unresolved = {},
		policyExcluded = {},
		covered = 0,
	}
	if not snapshot.ready then
		report.status = "NOT_READY"
		return report
	end
	if snapshot.permission == false then
		report.status = "RESTRICTED"
		return report
	end

	local exclusions = GF.LFG_COVERAGE_POLICY_EXCLUSIONS or {}
	for _, activityID in ipairs(sortedNumericKeys(snapshot.nativeByID)) do
		local native = snapshot.nativeByID[activityID]
		local info = native and native.info
		if exclusions[activityID] then
			appendIssue(report.policyExcluded, activityID, exclusions[activityID])
		elseif not info or safeNumber(info.categoryID) == nil then
			appendIssue(report.unresolved, activityID, native)
		else
			local directEntries = snapshot.menu.directByID[activityID]
			local validDirect = false
			for _, entry in ipairs(directEntries or {}) do
				if categoryMatches(entry, info) then
					validDirect = true
				else
					appendIssue(report.misplaced, activityID, entry)
				end
			end
			if validDirect then
				report.covered = report.covered + 1
			elseif #(directEntries or {}) > 0 then
				-- Category-mismatched direct leaves were already recorded above.
			elseif snapshot.menu.scopeByID[activityID] then
				appendIssue(report.aggregateOnly, activityID,
					snapshot.menu.scopeByID[activityID])
			else
				appendIssue(report.missing, activityID, native)
			end
			if unexpectedDuplicate(directEntries) then
				appendIssue(report.duplicates, activityID, directEntries)
			end
		end
	end

	for _, activityID in ipairs(sortedNumericKeys(snapshot.menu.directByID)) do
		if not snapshot.nativeByID[activityID]
			and not exclusions[activityID]
		then
			appendIssue(report.menuOnly, activityID,
				snapshot.menu.directByID[activityID])
		end
	end

	local hasFailures = #report.missing > 0
		or #report.aggregateOnly > 0
		or #report.misplaced > 0
		or #report.menuOnly > 0
		or #report.duplicates > 0
		or #report.unresolved > 0
		or #(snapshot.queryErrors or {}) > 0
		or next(snapshot.apiMissing or {}) ~= nil
	if not report.stable then
		report.status = "UNSTABLE"
	elseif hasFailures then
		report.status = "FAIL"
	else
		report.status = "PASS"
	end
	return report
	end

local function pathSummary(entries)
	local out = {}
	for index = 1, math.min(#(entries or {}), 3) do
		local entry = entries[index]
		local primary = entry.primaryActivityID
		out[#out + 1] = (entry.path or "?")
			.. (primary and ("[primary=" .. tostring(primary) .. "]") or "")
	end
	if #(entries or {}) > 3 then
		out[#out + 1] = "+" .. tostring(#entries - 3)
	end
	return table.concat(out, " || ")
	end

local function nativeSummary(snapshot, activityID)
	local native = snapshot.nativeByID[activityID]
	local info = native and native.info
	return ("id=%d cat=%s group=%s map=%s name=%s"):format(
		activityID,
		tostring(safeNumber(info and info.categoryID)
			or native and native.queryCategoryID or "?"),
		tostring(safeNumber(info and info.groupFinderActivityGroupID) or 0),
		tostring(safeNumber(info and info.mapID) or "?"),
		safeText(info and (info.fullName or info.shortName)) or "?")
	end

local function issueLines(report, label, issues, formatter)
	local lines = {}
	for index = 1, math.min(#issues, DETAIL_LIMIT) do
		local issue = issues[index]
		lines[#lines + 1] = label .. " " .. formatter(issue)
	end
	if #issues > DETAIL_LIMIT then
		lines[#lines + 1] = label .. " omitted=" .. tostring(#issues - DETAIL_LIMIT)
	end
	return lines
	end

function Audit:FormatReport(report)
	local snapshot = report.snapshot or newSnapshot()
	local nativeCount = #sortedNumericKeys(snapshot.nativeByID)
	local menuCount = #sortedNumericKeys(snapshot.menu and snapshot.menu.directByID)
	local apiMissingCount = 0
	for _ in pairs(snapshot.apiMissing or {}) do
		apiMissingCount = apiMissingCount + 1
	end
	local lines = {
		("status=%s stable=%s ready=%s permission=%s queries=%d"):format(
			report.status,
			tostring(report.stable == true),
			tostring(snapshot.ready == true),
			tostring(snapshot.permission ~= false),
			snapshot.queryCount or 0),
	}
	if report.status == "NOT_READY" then
		lines[#lines + 1] = "activity list is not ready; no coverage verdict"
		return lines
	end
	if report.status == "RESTRICTED" then
		lines[#lines + 1] = "premade groups unavailable: "
			.. (snapshot.permissionReason or "unknown")
		return lines
	end
	lines[#lines + 1] = (
		"native=%d menuCreate=%d covered=%d missing=%d aggregateOnly=%d "
		.. "misplaced=%d menuOnly=%d duplicate=%d unresolved=%d excluded=%d errors=%d"
	):format(
		nativeCount,
		menuCount,
		report.covered or 0,
		#report.missing,
		#report.aggregateOnly,
		#report.misplaced,
		#report.menuOnly,
		#report.duplicates,
		#report.unresolved,
		#report.policyExcluded,
		#(snapshot.queryErrors or {}) + apiMissingCount)

	local function addAll(values)
		for _, value in ipairs(values) do
			lines[#lines + 1] = value
		end
	end
	addAll(issueLines(report, "MISSING", report.missing, function(issue)
		return nativeSummary(snapshot, issue.activityID)
	end))
	addAll(issueLines(report, "AGGREGATE_ONLY", report.aggregateOnly, function(issue)
		return nativeSummary(snapshot, issue.activityID)
			.. " paths=" .. pathSummary(issue.data)
	end))
	addAll(issueLines(report, "MISPLACED", report.misplaced, function(issue)
		return nativeSummary(snapshot, issue.activityID)
			.. " path=" .. pathSummary({ issue.data })
	end))
	addAll(issueLines(report, "MENU_ONLY", report.menuOnly, function(issue)
		return ("id=%d paths=%s"):format(
			issue.activityID, pathSummary(issue.data))
	end))
	addAll(issueLines(report, "DUPLICATE", report.duplicates, function(issue)
		return nativeSummary(snapshot, issue.activityID)
			.. " paths=" .. pathSummary(issue.data)
	end))
	addAll(issueLines(report, "UNRESOLVED", report.unresolved, function(issue)
		return ("id=%d sources=%s"):format(
			issue.activityID,
			table.concat(issue.data and issue.data.sources or {}, " || "))
	end))
	addAll(issueLines(report, "POLICY_EXCLUDED", report.policyExcluded, function(issue)
		return ("id=%d reason=%s"):format(
			issue.activityID, safeText(issue.data) or "?")
	end))
	for _, reason in ipairs(snapshot.queryErrors or {}) do
		lines[#lines + 1] = "ERROR " .. reason
	end
	for name in pairs(snapshot.apiMissing or {}) do
		lines[#lines + 1] = "API_MISSING " .. tostring(name)
	end
	return lines
	end

function Audit:IsRunning()
	return self._running == true
	end

function Audit:Run(callback)
	if self._running then
		return false, "busy"
	end
	self._running = true
	self._runToken = (self._runToken or 0) + 1
	local token = self._runToken
	local function collectSafely()
		local ok, snapshot = pcall(self.CollectSnapshot, self)
		if ok and type(snapshot) == "table" then
			return snapshot
		end
		local reason = snapshot
		snapshot = newSnapshot()
		snapshot.ready = true
		snapshot.permission = true
		recordError(snapshot, "audit=" .. (safeText(reason) or "error"))
		return snapshot
	end
	local first = collectSafely()

	local function finish(snapshot, stable)
		if self._runToken ~= token then
			return
		end
		self._running = false
		local report = self:BuildReport(snapshot, stable)
		self.lastReport = report
		if type(callback) == "function" then
			callback(report)
		end
	end

	if not first.ready or first.permission == false then
		finish(first, false)
		return true
	end

	local function runSecondPass()
		if self._runToken ~= token then
			return
		end
		local second = collectSafely()
		local stable = snapshotSignature(first) == snapshotSignature(second)
		finish(second, stable)
	end
	if C_Timer and type(C_Timer.After) == "function" then
		C_Timer.After(SECOND_PASS_DELAY, runSecondPass)
	else
		runSecondPass()
	end
	return true
	end
