local _, ns = ...
local SlickUI = ns.SlickUI
local Theme = SlickUI.Theme

local QuestTracker = { Rows = {}, Sections = { dungeon = true, campaign = true, worldQuests = true, quests = true } }
SlickUI.QuestTracker = QuestTracker

local BlizzardZoneTracking = { SyncPending = false, Syncing = false }
SlickUI.BlizzardZoneTracking = BlizzardZoneTracking

local ROW_GAP, PADDING = 5, 10
local IsWorldQuest

local function IsReadableValue(value)
	if canaccessvalue and not canaccessvalue(value) then return false end
	if issecretvalue and issecretvalue(value) then return false end
	return true
end

local function IsQuestWatched(questID)
	if not C_QuestLog or type(C_QuestLog.GetQuestWatchType) ~= "function" then return false end
	local ok, watchType = pcall(C_QuestLog.GetQuestWatchType, questID)
	return ok and IsReadableValue(watchType) and watchType ~= nil and watchType ~= 0
end

local function GetZoneMapID(mapID)
	local seen = {}
	local zoneMapType = Enum and Enum.UIMapType and Enum.UIMapType.Zone or 3
	while type(mapID) == "number" and mapID > 0 and not seen[mapID] do
		seen[mapID] = true
		local info = C_Map and C_Map.GetMapInfo and C_Map.GetMapInfo(mapID)
		if not info then return mapID end
		if info.mapType == zoneMapType then return mapID end
		if type(info.mapType) ~= "number" or info.mapType < zoneMapType then return nil end
		mapID = info.parentMapID
	end
end

local function QuestBelongsToCurrentZone(questID, headerName, currentZoneMapID, currentZoneName)
	local questMapID
	if type(_G.GetQuestUiMapID) == "function" then
		local ok, value = pcall(_G.GetQuestUiMapID, questID)
		if ok and IsReadableValue(value) and type(value) == "number" then questMapID = value end
	end
	if not questMapID and C_TaskQuest and type(C_TaskQuest.GetQuestZoneID) == "function" then
		local ok, value = pcall(C_TaskQuest.GetQuestZoneID, questID)
		if ok and IsReadableValue(value) and type(value) == "number" then questMapID = value end
	end
	if questMapID then
		local questZoneMapID = GetZoneMapID(questMapID)
		if questZoneMapID and questZoneMapID == currentZoneMapID then return true end
	end
	return type(headerName) == "string" and type(currentZoneName) == "string" and headerName == currentZoneName
end

function BlizzardZoneTracking:CaptureWatchBackup()
	local settings = SlickUI.DB.questTracker
	if settings.blizzardWatchBackupActive then return end
	wipe(settings.blizzardWatchBackup)
	local count = C_QuestLog and C_QuestLog.GetNumQuestLogEntries and C_QuestLog.GetNumQuestLogEntries() or 0
	for index = 1, count do
		local info = C_QuestLog.GetInfo(index)
		if info and info.questID and info.questID > 0 and not info.isHeader and IsQuestWatched(info.questID) then
			settings.blizzardWatchBackup[info.questID] = true
		end
	end
	settings.blizzardWatchBackupActive = true
end

function BlizzardZoneTracking:RestoreWatchBackup()
	local settings = SlickUI.DB.questTracker
	if not settings.blizzardWatchBackupActive then return end
	if InCombatLockdown() then self.RestorePending = true; return end
	self.Syncing = true
	for questID in pairs(settings.blizzardManagedWatches) do
		if IsQuestWatched(questID) and C_QuestLog.RemoveQuestWatch then pcall(C_QuestLog.RemoveQuestWatch, questID) end
	end
	for questID in pairs(settings.blizzardWatchBackup) do
		if not IsQuestWatched(questID) and C_QuestLog.AddQuestWatch then
			pcall(C_QuestLog.AddQuestWatch, questID, Enum.QuestWatchType.Manual)
		end
	end
	wipe(settings.blizzardManagedWatches)
	wipe(settings.blizzardWatchBackup)
	settings.blizzardWatchBackupActive = false
	self.Syncing = false
	self.RestorePending = nil
end

function BlizzardZoneTracking:SyncNow()
	if self.Syncing or not SlickUI.DB.questTracker.blizzardCurrentZoneTracking then return end
	if SlickUI.DB.questTracker.enabled or InCombatLockdown() then self.SyncPending = true; return end
	local currentMapID = C_Map and C_Map.GetBestMapForUnit and C_Map.GetBestMapForUnit("player")
	local currentZoneMapID = GetZoneMapID(currentMapID)
	if type(currentZoneMapID) ~= "number" then return end
	self:CaptureWatchBackup()
	local currentZoneInfo = C_Map.GetMapInfo(currentZoneMapID)
	local currentZoneName = currentZoneInfo and currentZoneInfo.name
	local desired, headerName = {}, nil
	local count = C_QuestLog.GetNumQuestLogEntries and C_QuestLog.GetNumQuestLogEntries() or 0
	for index = 1, count do
		local info = C_QuestLog.GetInfo(index)
		if info and info.isHeader then
			headerName = info.title
		elseif info and info.questID and info.questID > 0 and not info.isHidden and not IsWorldQuest(info.questID)
			and QuestBelongsToCurrentZone(info.questID, headerName, currentZoneMapID, currentZoneName) then
			desired[info.questID] = true
		end
	end
	self.Syncing = true
	for index = 1, count do
		local info = C_QuestLog.GetInfo(index)
		if info and info.questID and info.questID > 0 and not info.isHeader and not IsWorldQuest(info.questID) then
			local watched = IsQuestWatched(info.questID)
			if desired[info.questID] and not watched and C_QuestLog.AddQuestWatch then
				pcall(C_QuestLog.AddQuestWatch, info.questID, Enum.QuestWatchType.Manual)
			elseif watched and not desired[info.questID] and C_QuestLog.RemoveQuestWatch then
				pcall(C_QuestLog.RemoveQuestWatch, info.questID)
			end
		end
	end
	wipe(SlickUI.DB.questTracker.blizzardManagedWatches)
	for questID in pairs(desired) do SlickUI.DB.questTracker.blizzardManagedWatches[questID] = true end
	self.LastZoneMapID = currentZoneMapID
	self.Syncing = false
	self.SyncPending = nil
end

function BlizzardZoneTracking:QueueZoneSync(delay, force)
	if force then self.ForceZoneSync = true end
	if self.ZoneQueueActive then return end
	self.ZoneQueueActive = true
	C_Timer.After(delay or 0.3, function()
		BlizzardZoneTracking.ZoneQueueActive = nil
		local currentMapID = C_Map and C_Map.GetBestMapForUnit and C_Map.GetBestMapForUnit("player")
		local currentZoneMapID = GetZoneMapID(currentMapID)
		local shouldSync = BlizzardZoneTracking.ForceZoneSync
			or (type(currentZoneMapID) == "number" and currentZoneMapID ~= BlizzardZoneTracking.LastZoneMapID)
		BlizzardZoneTracking.ForceZoneSync = nil
		if shouldSync then BlizzardZoneTracking:SyncNow() end
	end)
end

function BlizzardZoneTracking:SetEnabled(enabled)
	SlickUI.DB.questTracker.blizzardCurrentZoneTracking = enabled == true
	if enabled then
		self.LastZoneMapID = nil
		self:QueueZoneSync(0, true)
	else
		self.LastZoneMapID = nil
		self:RestoreWatchBackup()
	end
end

local function FormatScenarioTime(seconds)
	seconds = math.max(0, math.floor((seconds or 0) + 0.5))
	return string.format("%d:%02d", math.floor(seconds / 60), seconds % 60)
end

StaticPopupDialogs.SLICKUI_ABANDON_QUEST = {
	text = "Abandon %s?",
	button1 = YES,
	button2 = NO,
	OnAccept = function(_, data)
		if InCombatLockdown() or not data or not data.questID then return end
		C_QuestLog.SetSelectedQuest(data.questID)
		C_QuestLog.SetAbandonQuest()
		C_QuestLog.AbandonQuest()
	end,
	timeout = 0,
	whileDead = true,
	hideOnEscape = true,
	preferredIndex = 3,
}

local function IsCampaignQuest(questID, info)
	if info and info.campaignID and info.campaignID > 0 then return true end
	if C_CampaignInfo and C_CampaignInfo.GetCampaignID then
		local ok, campaignID = pcall(C_CampaignInfo.GetCampaignID, questID)
		if ok and type(campaignID) == "number" and campaignID > 0 then return true end
	end
	if C_CampaignInfo and C_CampaignInfo.IsCampaignQuest then
		local ok, result = pcall(C_CampaignInfo.IsCampaignQuest, questID)
		if ok then return result == true end
	end
	return false
end

IsWorldQuest = function(questID)
	if not C_QuestLog then return false end
	-- The task map provider can return bonus objectives alongside world quests.
	-- worldQuestType is the same classification used by Blizzard's map pins and
	-- remains available even when IsWorldQuest is not useful for an untracked task.
	if type(C_QuestLog.GetQuestTagInfo) == "function" then
		local ok, tagInfo = pcall(C_QuestLog.GetQuestTagInfo, questID)
		if ok and type(tagInfo) == "table" and tagInfo.worldQuestType ~= nil then return true end
	end
	if type(C_QuestLog.IsWorldQuest) == "function" then
		local ok, value = pcall(C_QuestLog.IsWorldQuest, questID)
		if ok and value == true then return true end
	end
	return false
end

function QuestTracker:ShowQuestMenu(owner, questID, questTitle)
	if not MenuUtil or type(MenuUtil.CreateContextMenu) ~= "function" then return end
	MenuUtil.CreateContextMenu(owner, function(_, root)
		root:SetTag("SLICKUI_QUEST_TRACKER")
		root:CreateTitle(questTitle or "Quest")
		root:CreateButton("Untrack", function()
			if C_QuestLog and C_QuestLog.RemoveQuestWatch then C_QuestLog.RemoveQuestWatch(questID) end
		end)
		root:CreateButton("Abandon", function()
			if InCombatLockdown() then
				SlickUI:Print("Quests cannot be abandoned during combat.")
				return
			end
			StaticPopup_Show("SLICKUI_ABANDON_QUEST", questTitle or "this quest", nil, { questID = questID })
		end)
	end)
end

local function GetTrackedQuests()
	local result = { campaign = {}, quests = {}, popups = {} }
	if not C_QuestLog or not C_QuestLog.GetNumQuestLogEntries then return result end
	local count = C_QuestLog.GetNumQuestLogEntries() or 0
	local zoneName = "Other"
	for index = 1, count do
		local info = C_QuestLog.GetInfo(index)
		if info and info.isHeader and type(info.title) == "string" and info.title ~= "" then
			zoneName = info.title
		end
		if info and info.questID and info.questID > 0 and not info.isHeader and not info.isHidden then
			local watched = C_QuestLog.GetQuestWatchType and C_QuestLog.GetQuestWatchType(info.questID)
			if watched and watched ~= 0 and not IsWorldQuest(info.questID) then
				local bucket = IsCampaignQuest(info.questID, info) and result.campaign or result.quests
				bucket[#bucket + 1] = { info = info, zoneName = zoneName }
			end
		end
	end
	local getPopupCount = _G.GetNumAutoQuestPopUps or (C_QuestLog and C_QuestLog.GetNumAutoQuestPopUps)
	local getPopup = _G.GetAutoQuestPopUp or (C_QuestLog and C_QuestLog.GetAutoQuestPopUp)
	if type(getPopupCount) == "function" and type(getPopup) == "function" then
		local countValues = { pcall(getPopupCount) }
		local popupCount = countValues[2]
		if countValues[1] and type(popupCount) == "number" then
			for index = 1, popupCount do
				local popupValues = { pcall(getPopup, index) }
				local questID, popupType = popupValues[2], popupValues[3]
				if popupValues[1] and type(questID) == "number" and questID > 0 then
					local title = C_QuestLog.GetTitleForQuestID and C_QuestLog.GetTitleForQuestID(questID)
					result.popups[#result.popups + 1] = {
						questID = questID,
						popupType = popupType == "COMPLETE" and "COMPLETE" or "OFFER",
						title = type(title) == "string" and title ~= "" and title or ("Quest " .. questID),
					}
				end
			end
		end
	end
	if #result.popups > 0 then
		local popupQuestIDs = {}
		for _, popup in ipairs(result.popups) do popupQuestIDs[popup.questID] = true end
		local function RemovePopupDuplicates(bucket)
			for index = #bucket, 1, -1 do
				local questID = bucket[index].info and bucket[index].info.questID
				if popupQuestIDs[questID] then table.remove(bucket, index) end
			end
		end
		RemovePopupDuplicates(result.campaign)
		RemovePopupDuplicates(result.quests)
	end
	return result
end

local function NormalizeZoneName(name)
	if type(name) ~= "string" then return nil end
	name = name:match("^%s*(.-)%s*$")
	if name == "" then return nil end
	return name:lower()
end

local function GetCurrentZoneNames()
	local names = {}
	local function Add(name)
		name = NormalizeZoneName(name)
		if name then names[name] = true end
	end

	if type(GetZoneText) == "function" then Add(GetZoneText()) end
	if type(GetRealZoneText) == "function" then Add(GetRealZoneText()) end
	if type(GetSubZoneText) == "function" then Add(GetSubZoneText()) end

	if C_Map and C_Map.GetBestMapForUnit and C_Map.GetMapInfo then
		local ok, mapID = pcall(C_Map.GetBestMapForUnit, "player")
		local visited = {}
		for _ = 1, 8 do
			if not ok or type(mapID) ~= "number" or visited[mapID] then break end
			visited[mapID] = true
			local infoOK, info = pcall(C_Map.GetMapInfo, mapID)
			if not infoOK or type(info) ~= "table" then break end
			-- Keep micro/dungeon/zone names, but do not treat an entire continent
			-- as the current quest zone when walking up the map hierarchy.
			local zoneMapType = Enum and Enum.UIMapType and Enum.UIMapType.Zone
			if not zoneMapType or type(info.mapType) ~= "number" or info.mapType >= zoneMapType then
				Add(info.name)
			end
			mapID = info.parentMapID
		end
	end

	return names
end

local function FilterQuestsToCurrentZone(list)
	local zoneNames = GetCurrentZoneNames()
	local filtered = {}
	for _, entry in ipairs(list or {}) do
		local zoneName = NormalizeZoneName(entry.zoneName)
		if zoneName and zoneNames[zoneName] then
			filtered[#filtered + 1] = entry
		end
	end
	return filtered
end

local function GetCurrentZoneWorldQuests()
	local result, seen = {}, {}
	if not QuestTracker.WorldQuestsReady then return result end
	if not C_Map or type(C_Map.GetBestMapForUnit) ~= "function"
		or not C_TaskQuest then
		return result
	end
	local getTasks = type(C_TaskQuest.GetQuestsOnMap) == "function" and C_TaskQuest.GetQuestsOnMap
		or (type(C_TaskQuest.GetQuestsForPlayerByMapID) == "function" and C_TaskQuest.GetQuestsForPlayerByMapID)
	if not getTasks then return result end

	local mapOK, mapID = pcall(C_Map.GetBestMapForUnit, "player")
	if not mapOK or type(mapID) ~= "number" then return result end
	local visited = {}
	for _ = 1, 8 do
		if type(mapID) ~= "number" or visited[mapID] then break end
		visited[mapID] = true
		local mapInfo
		if type(C_Map.GetMapInfo) == "function" then
			local infoOK, value = pcall(C_Map.GetMapInfo, mapID)
			if infoOK and type(value) == "table" then mapInfo = value end
		end

		local tasksOK, tasks = pcall(getTasks, mapID)
		if tasksOK and type(tasks) == "table" then
			for _, task in pairs(tasks) do
				local questID = type(task) == "table" and (task.questId or task.questID) or nil
				if type(questID) == "number" and questID > 0 and not seen[questID] then
					local isWorldQuest = IsWorldQuest(questID)
					if isWorldQuest then
						seen[questID] = true
						local title
						if type(C_TaskQuest.GetQuestInfoByQuestID) == "function" then
							local titleOK, value = pcall(C_TaskQuest.GetQuestInfoByQuestID, questID)
							if titleOK and type(value) == "string" then title = value end
						end
						if (not title or title == "") and C_QuestLog and type(C_QuestLog.GetTitleForQuestID) == "function" then
							local titleOK, value = pcall(C_QuestLog.GetTitleForQuestID, questID)
							if titleOK and type(value) == "string" then title = value end
						end
						local logIndex
						if C_QuestLog and type(C_QuestLog.GetLogIndexForQuestID) == "function" then
							local indexOK, value = pcall(C_QuestLog.GetLogIndexForQuestID, questID)
							if indexOK and type(value) == "number" and value > 0 then logIndex = value end
						end
						local taskMapID = type(task.mapID) == "number" and task.mapID or mapID
						local taskMapInfo = mapInfo
						if taskMapID ~= mapID and type(C_Map.GetMapInfo) == "function" then
							local taskMapOK, value = pcall(C_Map.GetMapInfo, taskMapID)
							if taskMapOK and type(value) == "table" then taskMapInfo = value end
						end
						result[#result + 1] = {
							info = { questID = questID, questLogIndex = logIndex, title = title },
							zoneName = taskMapInfo and taskMapInfo.name or (GetZoneText and GetZoneText()) or "Current Zone",
						}
					end
				end
			end
		end

		-- A micro-map may own no tasks, so also inspect its containing zone. Stop
		-- before reaching continent/world maps, which would make quests persist
		-- after leaving their actual zone.
		if not mapInfo or type(mapInfo.parentMapID) ~= "number" or mapInfo.parentMapID <= 0 then break end
		local zoneMapType = Enum and Enum.UIMapType and Enum.UIMapType.Zone
		if zoneMapType and type(mapInfo.mapType) == "number" and mapInfo.mapType <= zoneMapType then break end
		mapID = mapInfo.parentMapID
	end

	table.sort(result, function(a, b)
		return (a.info.title or "") < (b.info.title or "")
	end)
	return result
end

local function GetDungeonObjectives()
	local result = {
		active = false, title = nil, description = nil, criteria = {}, encounters = {},
		sectionTitle = "Dungeon", isDelve = false, tierText = nil,
		delveCurrencies = {}, delveSpells = {}, timer = nil,
	}
	if not C_Scenario or type(C_Scenario.GetInfo) ~= "function" or type(C_Scenario.GetStepInfo) ~= "function" then return result end
	local infoValues = { pcall(C_Scenario.GetInfo) }
	local okInfo, scenarioName, completed = infoValues[1], infoValues[2], infoValues[8]
	if type(scenarioName) == "table" then
		local info = scenarioName
		scenarioName = info.name or info.title
		completed = info.completed
	end
	if not okInfo or type(scenarioName) ~= "string" or scenarioName == "" or completed == true then return result end
	local stepValues = { pcall(C_Scenario.GetStepInfo) }
	local okStep, stepTitle, stepDescription, numCriteria = stepValues[1], stepValues[2], stepValues[3], stepValues[4]
	local widgetSetID = stepValues[13]
	if type(stepTitle) == "table" then
		local step = stepTitle
		stepTitle = step.title or step.name
		stepDescription = step.description
		numCriteria = step.numCriteria
		widgetSetID = step.widgetSetID
	end
	if not okStep or type(numCriteria) ~= "number" or numCriteria < 1 then return result end
	result.active = true
	result.title = type(stepTitle) == "string" and stepTitle ~= "" and stepTitle or scenarioName
	result.description = type(stepDescription) == "string" and stepDescription ~= "" and stepDescription or nil

	-- Delves publish their tier, remaining lives/currencies, and modifiers through
	-- the scenario step's dedicated UI widget set. Preserve that information
	-- instead of flattening every active scenario into a generic Dungeon row.
	if type(widgetSetID) == "number" and C_UIWidgetManager
		and type(C_UIWidgetManager.GetAllWidgetsBySetID) == "function"
		and type(C_UIWidgetManager.GetScenarioHeaderDelvesWidgetVisualizationInfo) == "function" then
		local widgetValues = { pcall(C_UIWidgetManager.GetAllWidgetsBySetID, widgetSetID) }
		local widgets = widgetValues[1] and widgetValues[2]
		if type(widgets) == "table" then
			for _, widget in ipairs(widgets) do
				local widgetID = type(widget) == "table" and widget.widgetID or nil
				if type(widgetID) == "number" then
					local delveValues = { pcall(C_UIWidgetManager.GetScenarioHeaderDelvesWidgetVisualizationInfo, widgetID) }
					local delveInfo = delveValues[1] and delveValues[2]
					if type(delveInfo) == "table" then
						result.isDelve = true
						result.sectionTitle = "Delves"
						result.tierText = type(delveInfo.tierText) == "string" and delveInfo.tierText or nil
						if type(delveInfo.headerText) == "string" and delveInfo.headerText ~= "" then
							result.title = delveInfo.headerText
						end
						for _, currency in ipairs(type(delveInfo.currencies) == "table" and delveInfo.currencies or {}) do
							if type(currency) == "table" then
								result.delveCurrencies[#result.delveCurrencies + 1] = {
									icon = currency.iconFileID,
									text = currency.text,
									leadingText = currency.leadingText,
								}
							end
						end
						for _, spell in ipairs(type(delveInfo.spells) == "table" and delveInfo.spells or {}) do
							if type(spell) == "table" and type(spell.spellID) == "number" then
								result.delveSpells[#result.delveSpells + 1] = {
									spellID = spell.spellID,
									text = spell.text,
									tooltip = spell.tooltip,
								}
							end
						end
						break
					end
				end
			end
		end
	end
	local getCriteriaInfo = C_ScenarioInfo and C_ScenarioInfo.GetCriteriaInfo
	if type(getCriteriaInfo) ~= "function" then
		getCriteriaInfo = C_Scenario and C_Scenario.GetCriteriaInfo
	end
	for index = 1, numCriteria do
		local values = type(getCriteriaInfo) == "function" and { pcall(getCriteriaInfo, index) } or { false }
		if values[1] then
			local info = values[2]
			local description, quantity, totalQuantity, criterionComplete, quantityString, isWeightedProgress, duration, elapsed
			if type(info) == "table" then
				description = info.description or info.criteriaString
				quantity = info.quantity
				totalQuantity = info.totalQuantity
				criterionComplete = info.completed
				quantityString = info.quantityString
				isWeightedProgress = info.isWeightedProgress
				duration = info.duration
				elapsed = info.elapsed
			else
				-- C_Scenario.GetCriteriaInfo returns criteriaString, criteriaType,
				-- completed, quantity, totalQuantity, flags, assetID,
				-- quantityString, criteriaID, duration, elapsed,
				-- criteriaFailed, isWeightedProgress. Account for pcall's leading
				-- success value when reading those positions.
				description = values[2]
				criterionComplete = values[4]
				quantity = values[5]
				totalQuantity = values[6]
				quantityString = values[9]
				duration = values[11]
				elapsed = values[12]
				isWeightedProgress = values[14]
			end
			if type(description) == "string" and description ~= "" then
				local hasTimer = IsReadableValue(duration) and IsReadableValue(elapsed)
					and type(duration) == "number" and duration > 0
					and type(elapsed) == "number" and elapsed >= 0
				if hasTimer and not result.timer then
					result.timer = {
						title = description,
						duration = duration,
						endTime = GetTime() + math.max(0, duration - elapsed),
					}
				end
				local progress
				if isWeightedProgress == true and type(quantity) == "number" then
					-- Weighted Mythic+ criteria are enemy-forces percentages. Some
					-- dungeons omit quantityString, so format the numeric value.
					progress = string.format("%.1f%%", quantity)
				elseif type(quantity) == "number" and type(totalQuantity) == "number" and totalQuantity >= 1 then
					progress = string.format("%d/%d", quantity, totalQuantity)
				elseif type(quantityString) == "string" and quantityString ~= "" then
					progress = quantityString
				end
				if not hasTimer then
					result.criteria[#result.criteria + 1] = {
						description = description,
						completed = criterionComplete == true,
						progress = progress,
					}
				end
			end
		end
	end
	-- Scenario criteria describe only the active step in many modern dungeons.
	-- Add the LFG encounter roster so the tracker also shows every boss, like
	-- Blizzard's dungeon tracker. The tenth GetInstanceInfo return is the
	-- active LFG dungeon ID (shifted to index 11 by pcall's success value).
	if type(GetInstanceInfo) == "function" and type(GetLFGDungeonNumEncounters) == "function"
		and type(GetLFGDungeonEncounterInfo) == "function" then
		local instanceValues = { pcall(GetInstanceInfo) }
		local dungeonID = instanceValues[11]
		if instanceValues[1] and type(dungeonID) == "number" and dungeonID > 0 then
			local countValues = { pcall(GetLFGDungeonNumEncounters, dungeonID) }
			local encounterCount = countValues[2]
			if countValues[1] and type(encounterCount) == "number" then
				for encounterIndex = 1, encounterCount do
					local encounterValues = { pcall(GetLFGDungeonEncounterInfo, dungeonID, encounterIndex) }
					local name, texture, killed = encounterValues[2], encounterValues[3], encounterValues[4]
					if encounterValues[1] and type(name) == "string" and name ~= "" then
						local killedReadable = (not canaccessvalue or canaccessvalue(killed))
							and (not issecretvalue or not issecretvalue(killed))
						result.encounters[#result.encounters + 1] = {
							name = name,
							texture = texture,
							completed = killedReadable and killed == true or false,
						}
					end
				end
			end
		end
	end

	-- A live instance does not always expose an LFG dungeon ID (notably for
	-- manually entered and newly released dungeons). In that case Blizzard's
	-- tracker can still obtain the complete boss roster from Encounter Journal
	-- map data. Merge that roster here without duplicating LFG encounters.
	if type(EJ_GetInstanceForMap) == "function" and type(EJ_GetEncounterInfoByIndex) == "function"
		and C_Map and type(C_Map.GetBestMapForUnit) == "function" then
		local mapValues = { pcall(C_Map.GetBestMapForUnit, "player") }
		local mapID = mapValues[2]
		if mapValues[1] and type(mapID) == "number" and mapID > 0 then
			local journalInstanceID
			local checkedMaps = {}
			for _ = 1, 5 do
				if checkedMaps[mapID] then break end
				checkedMaps[mapID] = true
				local journalValues = { pcall(EJ_GetInstanceForMap, mapID) }
				if journalValues[1] and type(journalValues[2]) == "number" and journalValues[2] > 0 then
					journalInstanceID = journalValues[2]
					break
				end
				local mapInfoValues = { pcall(C_Map.GetMapInfo, mapID) }
				local mapInfo = mapInfoValues[2]
				mapID = mapInfoValues[1] and type(mapInfo) == "table" and mapInfo.parentMapID or nil
				if type(mapID) ~= "number" or mapID <= 0 then break end
			end
			if journalInstanceID then
				local known = {}
				for _, encounter in ipairs(result.encounters) do
					known[string.lower(encounter.name)] = true
				end
				for encounterIndex = 1, 30 do
					local encounterValues = { pcall(EJ_GetEncounterInfoByIndex, encounterIndex, journalInstanceID) }
					local name = encounterValues[2]
					if not encounterValues[1] or type(name) ~= "string" or name == "" then break end
					local key = string.lower(name)
					if not known[key] then
						known[key] = true
						result.encounters[#result.encounters + 1] = {
							name = name,
							completed = false,
						}
					end
				end
			end
		end
	end
	return result
end

function QuestTracker:AddZoneHeader(text, key, y)
	local header = self.ZoneHeaders[key]
	if not header then
		header = CreateFrame("Frame", nil, self.Content)
		header:SetHeight(22); header:SetPoint("LEFT", 12, 0); header:SetPoint("RIGHT", -8, 0)
		local label = Theme:CreateText(header, math.max(9, SlickUI.DB.questTracker.questFontSize - 1))
		label:SetPoint("LEFT", 4, 0); label:SetPoint("RIGHT", -4, 0); label:SetJustifyH("LEFT")
		label:SetTextColor(0.78, 0.67, 0.40); header.Label = label
		self.ZoneHeaders[key] = header
	end
	header:ClearAllPoints(); header:SetPoint("TOPLEFT", 12, -y); header:SetPoint("RIGHT", -8, 0)
	local zoneFontSize = math.max(9, SlickUI.DB.questTracker.questFontSize - 1)
	header.Label:SetText(text); Theme:SetFont(header.Label, zoneFontSize)
	local headerHeight = math.max(22, math.ceil(header.Label:GetStringHeight() or zoneFontSize) + 8)
	header:SetHeight(headerHeight)
	header:Show(); self.ActiveZoneHeaders[#self.ActiveZoneHeaders + 1] = header
	return y + headerHeight + 2
end

function QuestTracker:AddQuestList(list, sectionKey, y)
	local lastZone
	for _, entry in ipairs(list) do
		if SlickUI.DB.questTracker.groupByZone and entry.zoneName ~= lastZone then
			lastZone = entry.zoneName
			y = self:AddZoneHeader(lastZone or "Other", sectionKey .. ":" .. (lastZone or "Other"), y)
		end
		y = self:AddQuest(entry.info, y, sectionKey)
	end
	return y
end

local function HideBlizzardTracker()
	if InCombatLockdown() then return end
	local tracker = _G.ObjectiveTrackerFrame
	if tracker and tracker.Hide then
		-- Use Blizzard's ordinary visibility path only. Do not alter alpha,
		-- anchors, parents, scripts, events, or attach addon fields: those changes
		-- can contaminate the protected scenario-aura update chain in WoW 12.1.
		tracker:Hide()
	end
end

local function ApplyBlizzardTrackerSuppression()
	if InCombatLockdown() then
		QuestTracker.BlizzardTrackerSuppressionPending = true
		return
	end
	local tracker = _G.ObjectiveTrackerFrame
	if not tracker then return end
	QuestTracker.BlizzardTrackerSuppressionPending = nil
	-- A secure visibility state remains authoritative when Blizzard rebuilds or
	-- explicitly shows the objective tracker. This avoids the visible race made
	-- by repeatedly hiding the frame from a timer.
	if RegisterStateDriver and not QuestTracker.BlizzardTrackerStateDriver then
		local ok = pcall(RegisterStateDriver, tracker, "visibility", "hide")
		QuestTracker.BlizzardTrackerStateDriver = ok == true
	end
	HideBlizzardTracker()
end

local function RestoreBlizzardTracker()
	if InCombatLockdown() then return end
	local tracker = _G.ObjectiveTrackerFrame
	if tracker and QuestTracker.BlizzardTrackerStateDriver and UnregisterStateDriver then
		pcall(UnregisterStateDriver, tracker, "visibility")
	end
	QuestTracker.BlizzardTrackerStateDriver = nil
	QuestTracker.BlizzardTrackerSuppressionPending = nil
	if tracker and tracker.Show then tracker:Show() end
end

local blizzardTrackerHidePending = false
local function DeferBlizzardTrackerSuppression()
	if blizzardTrackerHidePending then return end
	blizzardTrackerHidePending = true
	C_Timer.After(0, function()
		blizzardTrackerHidePending = false
		if SlickUI.DB and SlickUI.DB.questTracker and SlickUI.DB.questTracker.enabled then
			HideBlizzardTracker()
		end
	end)
end

local function QueueBlizzardTrackerSuppressionBurst()
	-- Some Blizzard objective modules finish their own deferred layout after the
	-- originating quest/scenario event. Recheck a few times around that short
	-- update window without installing a permanent OnUpdate poll or Show hook.
	for _, delay in ipairs({ 0, 0.05, 0.20, 0.60 }) do
		C_Timer.After(delay, function()
			if SlickUI.DB and SlickUI.DB.questTracker and SlickUI.DB.questTracker.enabled then
				HideBlizzardTracker()
			end
		end)
	end
end

local function OpenAutoQuestPopup(questID, popupType)
	if InCombatLockdown() then
		SlickUI:Print("Quest offers and completions cannot be opened during combat.")
		return true
	end
	local handler = popupType == "COMPLETE" and _G.ShowQuestComplete or _G.ShowQuestOffer
	if type(handler) == "function" then
		local ok = pcall(handler, questID)
		if ok then return true end
	end
	-- This fallback is useful if Blizzard has already promoted the popup into
	-- the normal quest log between its creation and the player's click.
	if C_QuestLog and C_QuestLog.SetSelectedQuest then
		pcall(C_QuestLog.SetSelectedQuest, questID)
	end
	if type(QuestMapFrame_OpenToQuestDetails) == "function" then
		return pcall(QuestMapFrame_OpenToQuestDetails, questID)
	end
	return false
end

function QuestTracker:AcquireRow()
	local row = table.remove(self.RowPool)
	if row then
		for _, button in ipairs(row.DelveSpellButtons or {}) do button:Hide() end
		if row.ScenarioTimer then row.ScenarioTimer:Hide(); row.ScenarioTimer:SetScript("OnUpdate", nil) end
		-- An atlas can leave custom crop coordinates and dimensions behind on a
		-- pooled texture. Restore the neutral icon state before the row is reused.
		row.Icon:SetTexture(nil)
		row.Icon:SetTexCoord(0, 1, 0, 1)
		row.Icon:SetSize(22, 22)
		row.Icon:SetVertexColor(1, 1, 1, 1)
		if row.WorldQuestGlyph then row.WorldQuestGlyph:Hide() end
		if row.FindGroup then row.FindGroup:Hide(); row.FindGroup.QuestID = nil end
		row:Show()
		return row
	end
	row = CreateFrame("Button", nil, self.Content)
	row:SetPoint("LEFT", PADDING, 0); row:SetPoint("RIGHT", -PADDING, 0)
	row:RegisterForClicks("LeftButtonUp", "RightButtonUp")
	row:SetHighlightTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight", "ADD")
	local icon = row:CreateTexture(nil, "ARTWORK")
	icon:SetSize(22, 22); icon:SetPoint("TOPLEFT", 0, -2)
	icon:SetTexture("Interface\\GossipFrame\\AvailableQuestIcon")
	row.Icon = icon
	local worldQuestGlyph = Theme:CreateText(row, 13)
	worldQuestGlyph:SetPoint("CENTER", icon, "CENTER", 0, 1)
	-- U+25C8 (diamond with a centered dot), assembled as UTF-8 bytes so the
	-- source remains safe when edited by tools using different code pages.
	worldQuestGlyph:SetText(string.char(226, 151, 136))
	worldQuestGlyph:SetTextColor(0.18, 0.13, 0.02, 1)
	worldQuestGlyph:Hide()
	row.WorldQuestGlyph = worldQuestGlyph
	local title = Theme:CreateText(row, 12)
	title:SetPoint("TOPLEFT", icon, "TOPRIGHT", 7, 0); title:SetPoint("RIGHT", -2, 0)
	title:SetJustifyH("LEFT"); title:SetWordWrap(true); title:SetTextColor(unpack(Theme.Colors.goldText))
	row.Title = title
	local objectives = Theme:CreateText(row, 10)
	objectives:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -3); objectives:SetPoint("RIGHT", -2, 0)
	objectives:SetJustifyH("LEFT"); objectives:SetJustifyV("TOP"); objectives:SetWordWrap(true)
	row.Objectives = objectives
	local scenarioTimer = CreateFrame("Frame", nil, row)
	scenarioTimer:SetHeight(47)
	local timerBackground = scenarioTimer:CreateTexture(nil, "BACKGROUND")
	timerBackground:SetAllPoints(); timerBackground:SetColorTexture(0.10, 0.08, 0.24, 0.92)
	local timerTitle = Theme:CreateText(scenarioTimer, 10)
	timerTitle:SetPoint("TOPLEFT", 7, -5); timerTitle:SetPoint("RIGHT", -7, 0); timerTitle:SetJustifyH("LEFT")
	local timerText = Theme:CreateText(scenarioTimer, 13)
	timerText:SetPoint("TOPLEFT", timerTitle, "BOTTOMLEFT", 0, -1); timerText:SetJustifyH("LEFT")
	local timerBar = CreateFrame("StatusBar", nil, scenarioTimer)
	timerBar:SetPoint("BOTTOMLEFT", 4, 3); timerBar:SetPoint("BOTTOMRIGHT", -4, 3); timerBar:SetHeight(5)
	timerBar:SetStatusBarTexture("Interface\\TargetingFrame\\UI-StatusBar")
	timerBar:SetStatusBarColor(0.42, 0.22, 0.95)
	local timerBarBackground = timerBar:CreateTexture(nil, "BACKGROUND")
	timerBarBackground:SetAllPoints(); timerBarBackground:SetColorTexture(0.08, 0.08, 0.10, 0.9)
	scenarioTimer.Title, scenarioTimer.Time, scenarioTimer.Bar = timerTitle, timerText, timerBar
	scenarioTimer:Hide(); row.ScenarioTimer = scenarioTimer
	row.DelveSpellButtons = {}
	local item = CreateFrame("Button", nil, row, "SecureActionButtonTemplate")
	item:SetSize(32, 32); item:SetPoint("TOPRIGHT", -1, -1); item:RegisterForClicks("AnyUp", "AnyDown")
	local itemIcon = item:CreateTexture(nil, "ARTWORK"); itemIcon:SetAllPoints(); itemIcon:SetTexCoord(0.08, 0.92, 0.08, 0.92); item.Icon = itemIcon
	local border = item:CreateTexture(nil, "BACKGROUND"); border:SetPoint("TOPLEFT", -1, 1); border:SetPoint("BOTTOMRIGHT", 1, -1); border:SetColorTexture(1, 0.67, 0.12, 0.75); item.Border = border
	local count = Theme:CreateText(item, 10); count:SetPoint("BOTTOMRIGHT", -1, 1); count:SetJustifyH("RIGHT"); item.Count = count
	local cooldown = CreateFrame("Cooldown", nil, item, "CooldownFrameTemplate"); cooldown:SetAllPoints(); item.Cooldown = cooldown
	item:SetScript("OnEnter", function(self)
		if not self.ItemLink then return end
		GameTooltip:SetOwner(self, "ANCHOR_LEFT")
		GameTooltip:SetHyperlink(self.ItemLink)
		GameTooltip:AddLine("Click to use this quest item.", 1, 1, 1, true)
		GameTooltip:Show()
	end)
	item:SetScript("OnLeave", GameTooltip_Hide)
	item:Hide(); row.Item = item
	local findGroup = CreateFrame("Button", nil, row)
	findGroup:SetSize(30, 30)
	findGroup:RegisterForClicks("LeftButtonUp")
	local findGroupIcon = findGroup:CreateTexture(nil, "ARTWORK")
	findGroupIcon:SetAllPoints()
	findGroupIcon:SetTexture("Interface\\LFGFrame\\LFG-Eye")
	-- LFG-Eye is a 512x256 animation sheet containing 64x64 frames.
	-- Use one clean static frame for the quest-row action button rather than
	-- displaying the entire animation sheet at once.
	findGroupIcon:SetTexCoord(0, 0.125, 0, 0.25)
	findGroup.Icon = findGroupIcon
	local findGroupHighlight = findGroup:CreateTexture(nil, "HIGHLIGHT")
	findGroupHighlight:SetAllPoints()
	findGroupHighlight:SetColorTexture(1, 0.82, 0.20, 0.18)
	findGroup:SetScript("OnEnter", function(self)
		GameTooltip:SetOwner(self, "ANCHOR_LEFT")
		GameTooltip:SetText("Find a Group")
		GameTooltip:AddLine("Search Group Finder for this quest.", 1, 1, 1, true)
		GameTooltip:Show()
	end)
	findGroup:SetScript("OnLeave", GameTooltip_Hide)
	findGroup:SetScript("OnClick", function(self)
		local questID = self.QuestID
		if not questID then return end
		if InCombatLockdown() then
			SlickUI:Print("Group Finder cannot be opened during combat.")
			return
		end
		if type(LFGListUtil_FindQuestGroup) ~= "function" and UIParentLoadAddOn then
			pcall(UIParentLoadAddOn, "Blizzard_GroupFinder")
		end
		if type(LFGListUtil_FindQuestGroup) == "function" then
			LFGListUtil_FindQuestGroup(questID)
		else
			SlickUI:Print("Group Finder is not available yet. Please try again after opening Group Finder once.")
		end
	end)
	findGroup:Hide(); row.FindGroup = findGroup
	row:SetScript("OnMouseDown", function(self, button)
		-- Auto-quest popups can be rebuilt by QUEST_LOG_UPDATE between mouse-down
		-- and mouse-up. Open them on the hardware press, as Blizzard's tracker does,
		-- so discovered and remotely completed quests remain reliably clickable.
		if button == "LeftButton" and self.QuestPopupType and self.QuestID then
			self.QuestPopupOpenedOnMouseDown = OpenAutoQuestPopup(self.QuestID, self.QuestPopupType)
		end
	end)
	row:SetScript("OnClick", function(self, button)
		if button == "LeftButton" and self.QuestPopupType and self.QuestID then
			if self.QuestPopupOpenedOnMouseDown then
				self.QuestPopupOpenedOnMouseDown = nil
				return
			end
			OpenAutoQuestPopup(self.QuestID, self.QuestPopupType)
			return
		end
		if button == "RightButton" and self.QuestID then
			QuestTracker:ShowQuestMenu(self, self.QuestID, self.Title:GetText())
			return
		end
		if self.QuestID and QuestMapFrame_OpenToQuestDetails then
			pcall(QuestMapFrame_OpenToQuestDetails, self.QuestID)
		end
	end)
	row:SetScript("OnEnter", function(self)
		if not self.QuestID then return end
		GameTooltip:SetOwner(self, "ANCHOR_LEFT")
		GameTooltip:SetText(self.Title:GetText() or "Quest")
		if self.QuestPopupType then
			GameTooltip:AddLine(self.QuestPopupType == "COMPLETE" and "Click to complete this quest." or "Click to view this quest.", 1, 1, 1, true)
			GameTooltip:Show()
			return
		end
		GameTooltip:AddLine("Left-click to open the Quest Log.", 1, 1, 1, true)
		GameTooltip:AddLine("Right-click for Untrack and Abandon.", 1, 1, 1, true)
		GameTooltip:Show()
	end)
	row:SetScript("OnLeave", GameTooltip_Hide)
	return row
end

local function SetTrackerQuestIcon(row, sectionKey, questID)
	local texture = row.Icon
	-- Rows are pooled. SetAtlas changes both the texture coordinates and, when
	-- requested, its dimensions; reset all of that before assigning another
	-- quest type or a reused row can display a blank cropped texture.
	texture:SetTexture(nil)
	texture:SetTexCoord(0, 1, 0, 1)
	texture:SetSize(22, 22)
	texture:SetVertexColor(1, 1, 1, 1)
	texture:Show()
	row.WorldQuestGlyph:Hide()

	if sectionKey == "worldQuests" then
		-- This is the current Blizzard 12.1 world-quest marker used by the map
		-- legend and WorldQuestPin templates.  The similarly named tracker-ring
		-- atlases are filter-button artwork and must not be used for quest rows.
		texture:SetAtlas("worldquest-icon")
		texture:SetSize(28, 28)
		return
	end

	-- Let Blizzard resolve active quest classifications so standard,
	-- important, campaign, recurring, meta, and completed quests receive the
	-- same icons used by the current Quest Log and Objective Tracker.
	if questID and QuestUtil and type(QuestUtil.ApplyQuestIconActiveToTextureForQuestID) == "function" then
		local isComplete = C_QuestLog and C_QuestLog.IsComplete and C_QuestLog.IsComplete(questID) or false
		local applied = pcall(QuestUtil.ApplyQuestIconActiveToTextureForQuestID, texture, questID, isComplete)
		if applied then
			texture:SetSize(24, 24)
			return
		end
	end

	-- ActiveQuestIcon is the circular question-mark used for quests already
	-- in the player's log. AvailableQuestIcon is the yellow exclamation mark
	-- used by quest givers and was the wrong state for tracker rows.
	texture:SetTexture("Interface\\GossipFrame\\ActiveQuestIcon")
	texture:SetTexCoord(0, 1, 0, 1)
end

function QuestTracker:AddHeader(text, key, y)
	local header = self.Headers[key]
	if not header then
		header = CreateFrame("Button", nil, self.Content)
		header:SetHeight(30); header:SetPoint("LEFT", 4, 0); header:SetPoint("RIGHT", -4, 0)
		local bg = header:CreateTexture(nil, "BACKGROUND"); bg:SetAllPoints(); bg:SetColorTexture(0.18, 0.12, 0.02, 0.72)
		local label = Theme:CreateText(header, SlickUI.DB.questTracker.headerFontSize); label:SetPoint("LEFT", 9, 0); label:SetTextColor(unpack(Theme.Colors.goldText)); header.Label = label
		local marker = Theme:CreateText(header, SlickUI.DB.questTracker.headerFontSize); marker:SetPoint("RIGHT", -8, 0); header.Marker = marker
		header:SetScript("OnClick", function()
			QuestTracker.Sections[key] = not QuestTracker.Sections[key]
			QuestTracker:Refresh()
		end)
		self.Headers[key] = header
	end
	header:ClearAllPoints(); header:SetPoint("TOPLEFT", 4, -y); header:SetPoint("RIGHT", -4, 0)
	header.Label:SetText(text); header.Marker:SetText(self.Sections[key] and "−" or "+"); header:Show()
	Theme:SetFont(header.Label, SlickUI.DB.questTracker.headerFontSize)
	Theme:SetFont(header.Marker, SlickUI.DB.questTracker.headerFontSize)
	local headerHeight = math.max(30, math.ceil(math.max(header.Label:GetStringHeight() or 0, header.Marker:GetStringHeight() or 0)) + 12)
	header:SetHeight(headerHeight)
	return y + headerHeight + 4
end

function QuestTracker:AddQuest(info, y, sectionKey)
	local row = self:AcquireRow(); self.ActiveRows[#self.ActiveRows + 1] = row
	row.QuestID = info.questID
	row.QuestLogIndex = info.questLogIndex
	row.QuestPopupType = nil
	SetTrackerQuestIcon(row, sectionKey, info.questID)
	row:ClearAllPoints(); row:SetPoint("TOPLEFT", PADDING, -y); row:SetPoint("RIGHT", -PADDING, 0)
	row.Title:SetText(info.title or C_QuestLog.GetTitleForQuestID(info.questID) or ("Quest " .. info.questID))
	Theme:SetFont(row.Title, SlickUI.DB.questTracker.questFontSize)
	Theme:SetFont(row.Objectives, SlickUI.DB.questTracker.objectiveFontSize)
	Theme:SetFont(row.Item.Count, SlickUI.DB.questTracker.objectiveFontSize)
	local lines = {}
	local objectives = C_QuestLog.GetQuestObjectives and C_QuestLog.GetQuestObjectives(info.questID)
	for _, objective in ipairs(objectives or {}) do
		local text = objective.text
		if type(text) == "string" and text ~= "" then
			lines[#lines + 1] = (objective.finished and "|cff55dd88✓ |r" or "|cffdddddd• |r") .. text
		end
	end
	if #lines == 0 and C_QuestLog.IsComplete and C_QuestLog.IsComplete(info.questID) then lines[1] = "|cff55dd88Ready for turn-in|r" end
	row.Objectives:SetText(table.concat(lines, "\n"))
	local itemLink, itemTexture, charges, showWhenComplete
	if GetQuestLogSpecialItemInfo and info.questLogIndex then
		itemLink, itemTexture, charges, showWhenComplete = GetQuestLogSpecialItemInfo(info.questLogIndex)
	end
	local complete = C_QuestLog.IsComplete and C_QuestLog.IsComplete(info.questID)
	local showItem = itemLink and (not complete or showWhenComplete)
	row.Item:SetShown(showItem == true)
	if showItem then
		row.Item.ItemLink = itemLink
		row.Item.Icon:SetTexture(itemTexture)
		row.Item.Count:SetText(type(charges) == "number" and charges > 0 and charges or "")
		row.Item:SetAttribute("type", "item")
		row.Item:SetAttribute("item", itemLink)
		if GetQuestLogSpecialItemCooldown then
			local start, duration, enabled = GetQuestLogSpecialItemCooldown(info.questLogIndex)
			CooldownFrame_Set(row.Item.Cooldown, start or 0, duration or 0, enabled or 0)
		end
	else
		row.Item.ItemLink = nil
		row.Item:SetAttribute("type", nil)
		row.Item:SetAttribute("item", nil)
		CooldownFrame_Clear(row.Item.Cooldown)
	end
	local showFindGroup = false
	if C_LFGList and type(C_LFGList.CanCreateQuestGroup) == "function" then
		local ok, eligible = pcall(C_LFGList.CanCreateQuestGroup, info.questID)
		showFindGroup = ok and eligible == true
	end
	row.FindGroup.QuestID = showFindGroup and info.questID or nil
	row.FindGroup:SetShown(showFindGroup)
	row.FindGroup:ClearAllPoints()
	row.FindGroup:SetPoint("TOPRIGHT", showItem and -37 or -1, -1)
	local rightInset = (showItem and 40 or 2) + (showFindGroup and 34 or 0)
	row.Title:ClearAllPoints(); row.Title:SetPoint("TOPLEFT", row.Icon, "TOPRIGHT", 7, 0); row.Title:SetPoint("RIGHT", -rightInset, 0)
	row.Objectives:ClearAllPoints(); row.Objectives:SetPoint("TOPLEFT", row.Title, "BOTTOMLEFT", 0, -3); row.Objectives:SetPoint("RIGHT", -rightInset, 0)
	row.Title:SetWidth(math.max(120, self.Content:GetWidth() - 58 - rightInset))
	row.Objectives:SetWidth(math.max(120, self.Content:GetWidth() - 58 - rightInset))
	local height = math.max(30, row.Title:GetStringHeight() + row.Objectives:GetStringHeight() + 10)
	row:SetHeight(height)
	return y + height + ROW_GAP
end

function QuestTracker:AddQuestPopup(info, y)
	local row = self:AcquireRow(); self.ActiveRows[#self.ActiveRows + 1] = row
	row.QuestID, row.QuestLogIndex, row.QuestPopupType = info.questID, nil, info.popupType
	row:ClearAllPoints(); row:SetPoint("TOPLEFT", PADDING, -y); row:SetPoint("RIGHT", -PADDING, 0)
	row.Icon:SetTexture("Interface\\GossipFrame\\AvailableQuestIcon")
	row.Title:SetText(info.popupType == "COMPLETE" and "Click to complete quest" or "Quest Discovered!")
	row.Title:SetTextColor(1, 0.82, 0.22, 1)
	local actionText = info.popupType == "COMPLETE" and "Click to complete" or "Click to view quest"
	row.Objectives:SetText((info.title or "Quest") .. "\n|cffaaaaaa" .. actionText .. "|r")
	Theme:SetFont(row.Title, SlickUI.DB.questTracker.questFontSize)
	Theme:SetFont(row.Objectives, SlickUI.DB.questTracker.objectiveFontSize)
	row.Item:Hide(); row.Item.ItemLink = nil
	row.Item:SetAttribute("type", nil); row.Item:SetAttribute("item", nil)
	CooldownFrame_Clear(row.Item.Cooldown)
	row.Title:ClearAllPoints(); row.Title:SetPoint("TOPLEFT", row.Icon, "TOPRIGHT", 7, 0); row.Title:SetPoint("RIGHT", -2, 0)
	row.Objectives:ClearAllPoints(); row.Objectives:SetPoint("TOPLEFT", row.Title, "BOTTOMLEFT", 0, -3); row.Objectives:SetPoint("RIGHT", -2, 0)
	row.Title:SetWidth(math.max(120, self.Content:GetWidth() - 60))
	row.Objectives:SetWidth(math.max(120, self.Content:GetWidth() - 60))
	local height = math.max(44, row.Title:GetStringHeight() + row.Objectives:GetStringHeight() + 10)
	row:SetHeight(height)
	return y + height + ROW_GAP
end

function QuestTracker:AddDungeonStep(data, y)
	local row = self:AcquireRow(); self.ActiveRows[#self.ActiveRows + 1] = row
	row.QuestID, row.QuestLogIndex, row.QuestPopupType = nil, nil, nil
	row:ClearAllPoints(); row:SetPoint("TOPLEFT", PADDING, -y); row:SetPoint("RIGHT", -PADDING, 0)
	row.Icon:SetTexture("Interface\\LFGFrame\\LFGIcon-Dungeon")
	row.Title:SetText(data.title or "Dungeon Objectives")
	Theme:SetFont(row.Title, SlickUI.DB.questTracker.questFontSize)
	Theme:SetFont(row.Objectives, SlickUI.DB.questTracker.objectiveFontSize)
	local scenarioTimer = row.ScenarioTimer
	scenarioTimer:SetScript("OnUpdate", nil)
	scenarioTimer:Hide()
	if data.timer then
		Theme:SetFont(scenarioTimer.Title, SlickUI.DB.questTracker.objectiveFontSize)
		Theme:SetFont(scenarioTimer.Time, SlickUI.DB.questTracker.questFontSize)
		scenarioTimer.Title:SetText(data.timer.title or "Time Remaining")
		scenarioTimer.Duration = data.timer.duration
		scenarioTimer.EndTime = data.timer.endTime
		scenarioTimer.Elapsed = 0
		local function UpdateTimer(timerFrame)
			local remaining = math.max(0, (timerFrame.EndTime or 0) - GetTime())
			timerFrame.Time:SetText(FormatScenarioTime(remaining))
			timerFrame.Bar:SetMinMaxValues(0, math.max(1, timerFrame.Duration or 1))
			timerFrame.Bar:SetValue(remaining)
		end
		UpdateTimer(scenarioTimer)
		scenarioTimer:SetScript("OnUpdate", function(self, elapsed)
			self.Elapsed = (self.Elapsed or 0) + elapsed
			if self.Elapsed < 0.2 then return end
			self.Elapsed = 0
			UpdateTimer(self)
		end)
		scenarioTimer:Show()
	end
	local lines = {}
	if data.isDelve then
		local details = {}
		if data.tierText and data.tierText ~= "" then
			details[#details + 1] = "|cffffcc55Tier " .. data.tierText .. "|r"
		end
		for _, currency in ipairs(data.delveCurrencies or {}) do
			local icon = type(currency.icon) == "number" and currency.icon > 0
				and ("|T" .. currency.icon .. ":16:16:0:0|t ") or ""
			local text = (type(currency.leadingText) == "string" and currency.leadingText or "")
				.. (type(currency.text) == "string" and currency.text or "")
			if text ~= "" then details[#details + 1] = icon .. text end
		end
		for spellIndex, spell in ipairs(data.delveSpells or {}) do
			-- Modifier icons are real mouse-enabled frames rather than inline font
			-- textures, allowing the same spell-description hover as Blizzard.
			local index = spellIndex
			local button = row.DelveSpellButtons[index]
			if not button then
				button = CreateFrame("Frame", nil, row)
				button:SetSize(20, 20)
				button:EnableMouse(true)
				local icon = button:CreateTexture(nil, "ARTWORK")
				icon:SetAllPoints()
				icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
				button.Icon = icon
				button:SetScript("OnEnter", function(self)
					GameTooltip:SetOwner(self, "ANCHOR_LEFT")
					if self.SpellID and GameTooltip.SetSpellByID then
						GameTooltip:SetSpellByID(self.SpellID)
					elseif self.TooltipText and self.TooltipText ~= "" then
						GameTooltip:SetText(self.TooltipText, 1, 1, 1, true)
					end
					GameTooltip:Show()
				end)
				button:SetScript("OnLeave", GameTooltip_Hide)
				row.DelveSpellButtons[index] = button
			end
			local texture
			if C_Spell and type(C_Spell.GetSpellTexture) == "function" then
				local ok, value = pcall(C_Spell.GetSpellTexture, spell.spellID)
				if ok then texture = value end
			elseif type(GetSpellTexture) == "function" then
				local ok, value = pcall(GetSpellTexture, spell.spellID)
				if ok then texture = value end
			end
			button.SpellID, button.TooltipText = spell.spellID, spell.tooltip
			button.Icon:SetTexture(texture)
			button:ClearAllPoints()
			button:SetPoint("TOPLEFT", row.Objectives, "TOPLEFT", 112 + ((index - 1) * 25), 2)
			button:Show()
		end
		if #details > 0 then lines[#lines + 1] = table.concat(details, "   ") end
	end
	if data.description then lines[#lines + 1] = "|cffbbbbbb" .. data.description .. "|r" end
	if #(data.encounters or {}) > 0 then
		lines[#lines + 1] = "|cffffcc55Bosses|r"
		for _, encounter in ipairs(data.encounters) do
			local marker = encounter.completed and "|cff55dd88\226\156\147 |r" or "|cffdddddd\226\128\162 |r"
			lines[#lines + 1] = marker .. encounter.name
		end
	end
	for _, criterion in ipairs(data.criteria or {}) do
		local marker = criterion.completed and "|cff55dd88\226\156\147 |r" or "|cffdddddd\226\128\162 |r"
		local progress = criterion.progress and (" |cff55ccff" .. criterion.progress .. "|r") or ""
		lines[#lines + 1] = marker .. criterion.description .. progress
	end
	row.Objectives:SetText(table.concat(lines, "\n"))
	row.Item:Hide(); row.Item.ItemLink = nil
	row.Item:SetAttribute("type", nil); row.Item:SetAttribute("item", nil)
	CooldownFrame_Clear(row.Item.Cooldown)
	row.Item:SetAttribute("type", nil); row.Item:SetAttribute("item", nil)
	CooldownFrame_Clear(row.Item.Cooldown)
	row.Title:ClearAllPoints(); row.Title:SetPoint("TOPLEFT", row.Icon, "TOPRIGHT", 7, 0); row.Title:SetPoint("RIGHT", -2, 0)
	row.Objectives:ClearAllPoints()
	if data.timer then
		scenarioTimer:ClearAllPoints(); scenarioTimer:SetPoint("TOPLEFT", row.Title, "BOTTOMLEFT", 0, -4); scenarioTimer:SetPoint("RIGHT", -2, 0)
		row.Objectives:SetPoint("TOPLEFT", scenarioTimer, "BOTTOMLEFT", 0, -4)
	else
		row.Objectives:SetPoint("TOPLEFT", row.Title, "BOTTOMLEFT", 0, -3)
	end
	row.Objectives:SetPoint("RIGHT", -2, 0)
	row.Title:SetWidth(math.max(120, self.Content:GetWidth() - 60))
	row.Objectives:SetWidth(math.max(120, self.Content:GetWidth() - 60))
	local timerHeight = data.timer and 51 or 0
	local height = math.max(30, row.Title:GetStringHeight() + timerHeight + row.Objectives:GetStringHeight() + 10)
	row:SetHeight(height)
	return y + height + ROW_GAP
end

function QuestTracker:Refresh()
	if not self.Frame or not SlickUI.DB.questTracker.enabled then return end
	if InCombatLockdown() then self.RefreshPending = true; return end
	self.RefreshPending = false
	for _, row in ipairs(self.ActiveRows) do row:Hide(); self.RowPool[#self.RowPool + 1] = row end
	wipe(self.ActiveRows)
	for _, header in ipairs(self.ActiveZoneHeaders) do header:Hide() end
	wipe(self.ActiveZoneHeaders)
	local quests, dungeon, worldQuests, y = GetTrackedQuests(), GetDungeonObjectives(), GetCurrentZoneWorldQuests(), 5
	if SlickUI.DB.questTracker.currentZoneOnly then
		quests.campaign = FilterQuestsToCurrentZone(quests.campaign)
		quests.quests = FilterQuestsToCurrentZone(quests.quests)
		worldQuests = FilterQuestsToCurrentZone(worldQuests)
	end
	if dungeon.active then
		y = self:AddHeader(dungeon.sectionTitle or "Dungeon", "dungeon", y)
		if self.Sections.dungeon then y = self:AddDungeonStep(dungeon, y) end
	end
	if #quests.campaign > 0 then
		y = self:AddHeader("Campaign", "campaign", y)
		if self.Sections.campaign then
			y = self:AddQuestList(quests.campaign, "campaign", y)
		end
	elseif self.Headers.campaign then
		self.Headers.campaign:Hide()
	end
	local questHeading = SlickUI.DB.questTracker.currentZoneOnly and "Quest Current Zone" or "Quests"
	y = self:AddHeader(questHeading, "quests", y)
	if self.Sections.quests then
		y = self:AddQuestList(quests.quests, "quests", y)
		for _, popup in ipairs(quests.popups) do y = self:AddQuestPopup(popup, y) end
	end
	if #worldQuests > 0 then
		y = self:AddHeader("World Quests", "worldQuests", y)
		if self.Sections.worldQuests then
			y = self:AddQuestList(worldQuests, "worldQuests", y)
		end
	elseif self.Headers.worldQuests then
		self.Headers.worldQuests:Hide()
	end
	if not dungeon.active and #quests.campaign + #worldQuests + #quests.quests + #quests.popups == 0 then
		self.Empty:Show(); self.Empty:SetPoint("TOP", 0, -(y + 18)); y = y + 55
	else self.Empty:Hide() end
	self.Content:SetHeight(math.max(self.Scroll:GetHeight(), y + 8))
	HideBlizzardTracker()
end

function QuestTracker:CreateFrame()
	local frame = CreateFrame("Frame", "SlickUIQuestTracker", UIParent)
	frame:SetSize(330, 560); frame:SetFrameStrata("MEDIUM"); frame:SetClampedToScreen(true)
	Theme:CreatePanel(frame)
	local title = Theme:CreateText(frame, 17); title:SetPoint("TOPLEFT", 12, -11); title:SetText("SlickUI Quest Tracker"); title:SetTextColor(unpack(Theme.Colors.goldText))
	local collapse = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
	collapse:SetSize(24, 22); collapse:SetPoint("TOPRIGHT", -5, -5); collapse:SetText("-")
	collapse:SetFrameLevel(frame:GetFrameLevel() + 5)
	collapse:SetScript("OnClick", function() QuestTracker:SetCollapsed(not SlickUI.DB.questTracker.collapsed) end)
	collapse:SetScript("OnEnter", function(self)
		GameTooltip:SetOwner(self, "ANCHOR_LEFT")
		GameTooltip:SetText(SlickUI.DB.questTracker.collapsed and "Expand Quest Tracker" or "Collapse Quest Tracker")
		GameTooltip:Show()
	end)
	collapse:SetScript("OnLeave", GameTooltip_Hide)
	local scroll = CreateFrame("ScrollFrame", "SlickUIQuestTrackerScrollFrame", frame, "UIPanelScrollFrameTemplate")
	scroll:SetPoint("TOPLEFT", 7, -42); scroll:SetPoint("BOTTOMRIGHT", -28, 8); scroll:EnableMouseWheel(true)
	local content = CreateFrame("Frame", nil, scroll); content:SetSize(290, 1); scroll:SetScrollChild(content)
	scroll:SetScript("OnMouseWheel", function(self, delta)
		-- Quest rows contain protected interaction controls. WoW 12.1 therefore
		-- protects the owning scroll frame while in combat.
		if InCombatLockdown() then return end
		local range = math.max(0, self:GetVerticalScrollRange() or 0)
		self:SetVerticalScroll(math.max(0, math.min(range, (self:GetVerticalScroll() or 0) - delta * SlickUI.DB.questTracker.scrollStep)))
	end)
	local empty = Theme:CreateText(content, 11); empty:SetText("No watched quests."); empty:SetTextColor(unpack(Theme.Colors.mutedText)); empty:Hide()
	self.Frame, self.Title, self.CollapseButton, self.Scroll, self.Content, self.Empty = frame, title, collapse, scroll, content, empty
	self.Headers, self.ZoneHeaders, self.ActiveZoneHeaders, self.ActiveRows, self.RowPool = {}, {}, {}, {}, {}
	SlickUI.Movers:Register(frame, "questTracker", "Quest Tracker")
	return frame
end

function QuestTracker:SetCollapsed(collapsed)
	if not self.Frame then return end
	SlickUI.DB.questTracker.collapsed = collapsed == true
	self:ApplySettings()
end

function QuestTracker:ApplySettings()
	if not self.Frame then return end
	local settings = SlickUI.DB.questTracker
	self.Frame:SetSize(settings.width, settings.height)
	self.Frame:SetScale(settings.scale)
	if self.Frame.SlickBackground then
		self.Frame.SlickBackground:SetColorTexture(Theme.Colors.panel[1], Theme.Colors.panel[2], Theme.Colors.panel[3], settings.backgroundAlpha)
		self.Frame.SlickBackground:SetShown(not settings.collapsed)
	end
	for _, edge in ipairs(self.Frame.SlickEdges or {}) do edge:SetShown(settings.showBorder == true and not settings.collapsed) end
	self.Title:SetShown(settings.showHeader == true and not settings.collapsed)
	Theme:SetFont(self.Title, settings.headerFontSize + 2)
	Theme:SetFont(self.Empty, settings.objectiveFontSize + 1)
	for _, row in ipairs(self.ActiveRows) do Theme:SetFont(row.Item.Count, settings.objectiveFontSize) end
	self.CollapseButton:SetText(settings.collapsed and "+" or "-")
	self.Scroll:SetShown(not settings.collapsed)
	self.Scroll:ClearAllPoints()
	self.Scroll:SetPoint("TOPLEFT", 7, settings.showHeader == true and -42 or -8)
	self.Scroll:SetPoint("BOTTOMRIGHT", -28, 8)
	self.Content:SetWidth(math.max(180, settings.width - 40))
	self:Refresh()
end

function QuestTracker:SetEnabled(enabled)
	SlickUI.DB.questTracker.enabled = enabled == true
	if self.Frame then self.Frame:SetShown(enabled == true) end
	if enabled then
		BlizzardZoneTracking:RestoreWatchBackup()
		ApplyBlizzardTrackerSuppression()
		self:Refresh()
	else
		RestoreBlizzardTracker()
		if SlickUI.DB.questTracker.blizzardCurrentZoneTracking then BlizzardZoneTracking:QueueZoneSync(0.2, true) end
	end
end

function QuestTracker:Initialize()
	-- Register our ordinary refresh events before any tracker refresh can query
	-- task/world-quest map data. On WoW 12.1, touching that data during addon
	-- startup can leave the initialization path unable to register more events.
	local events = CreateFrame("Frame")
	for _, event in ipairs({ "PLAYER_ENTERING_WORLD", "PLAYER_REGEN_ENABLED", "QUEST_LOG_UPDATE", "QUEST_WATCH_LIST_CHANGED", "QUEST_ACCEPTED", "QUEST_AUTOCOMPLETE", "QUEST_DATA_LOAD_RESULT", "SUPER_TRACKING_CHANGED", "ZONE_CHANGED", "ZONE_CHANGED_INDOORS", "ZONE_CHANGED_NEW_AREA", "BAG_UPDATE_COOLDOWN", "SCENARIO_UPDATE", "SCENARIO_CRITERIA_UPDATE", "SCENARIO_COMPLETED", "UPDATE_UI_WIDGET", "BOSS_KILL", "ENCOUNTER_END", "LFG_COMPLETION_REWARD" }) do events:RegisterEvent(event) end
	events:SetScript("OnEvent", function(_, event)
		if event == "PLAYER_ENTERING_WORLD" then QuestTracker.WorldQuestsReady = true end
		if event == "PLAYER_REGEN_ENABLED" and QuestTracker.BlizzardTrackerSuppressionPending
			and SlickUI.DB.questTracker.enabled then
			ApplyBlizzardTrackerSuppression()
		end
		-- Never run addon code from ObjectiveTrackerFrame's Show/SetShown stack.
		-- Blizzard's scenario tracker reads protected aura data immediately after
		-- showing; an insecure post-hook contaminates that read. Defer suppression
		-- until the complete Blizzard event/update stack has returned instead.
		if SlickUI.DB.questTracker.enabled and not QuestTracker.BlizzardTrackerStateDriver then
			QueueBlizzardTrackerSuppressionBurst()
		end
		if event == "PLAYER_REGEN_ENABLED" and not QuestTracker.RefreshPending then return end
		C_Timer.After(event == "PLAYER_ENTERING_WORLD" and 0.5 or 0.1, function()
			if SlickUI.DB.questTracker.enabled then QuestTracker:Refresh() end
		end)
	end)
	self.EventFrame = events
	local zoneEvents = CreateFrame("Frame")
	for _, event in ipairs({ "PLAYER_ENTERING_WORLD", "PLAYER_REGEN_ENABLED", "ZONE_CHANGED", "ZONE_CHANGED_INDOORS", "ZONE_CHANGED_NEW_AREA" }) do zoneEvents:RegisterEvent(event) end
	zoneEvents:SetScript("OnEvent", function(_, event)
		if event == "PLAYER_REGEN_ENABLED" and BlizzardZoneTracking.RestorePending then
			BlizzardZoneTracking:RestoreWatchBackup()
		elseif event == "PLAYER_REGEN_ENABLED" and BlizzardZoneTracking.SyncPending then
			BlizzardZoneTracking:QueueZoneSync(0.1, true)
		elseif SlickUI.DB.questTracker.blizzardCurrentZoneTracking and not SlickUI.DB.questTracker.enabled then
			BlizzardZoneTracking:QueueZoneSync(event == "PLAYER_ENTERING_WORLD" and 1 or 0.3, event == "PLAYER_ENTERING_WORLD")
		end
	end)
	BlizzardZoneTracking.EventFrame = zoneEvents
	self:CreateFrame()
	self:ApplySettings()
	self:SetEnabled(SlickUI.DB.questTracker.enabled)
end

SlickUI:RegisterModule("QuestTracker", QuestTracker)
