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

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

local ROW_GAP, PADDING = 5, 10

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

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 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 GetDungeonObjectives()
	local result = {
		active = false, title = nil, description = nil, criteria = {}, encounters = {},
		sectionTitle = "Dungeon", isDelve = false, tierText = nil,
		delveCurrencies = {}, delveSpells = {},
	}
	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
			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
			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]
				isWeightedProgress = values[14]
			end
			if type(description) == "string" and description ~= "" then
				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
				result.criteria[#result.criteria + 1] = {
					description = description,
					completed = criterionComplete == true,
					progress = progress,
				}
			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)
	end
	return y
end

local function HideBlizzardTracker()
	local tracker = _G.ObjectiveTrackerFrame
	if not tracker then return end
	if tracker.SlickUIOriginalAlpha == nil then tracker.SlickUIOriginalAlpha = tracker:GetAlpha() end
	tracker:SetAlpha(0)
	if tracker.EnableMouse then tracker:EnableMouse(false) end
end

local function RestoreBlizzardTracker()
	local tracker = _G.ObjectiveTrackerFrame
	if not tracker then return end
	tracker:SetAlpha(tracker.SlickUIOriginalAlpha or 1)
	if tracker.EnableMouse then tracker:EnableMouse(true) end
end

function QuestTracker:AcquireRow()
	local row = table.remove(self.RowPool)
	if row then
		for _, button in ipairs(row.DelveSpellButtons or {}) do button:Hide() 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 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
	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
	row:SetScript("OnClick", function(self, button)
		if button == "LeftButton" and self.QuestPopupType and self.QuestID then
			if InCombatLockdown() then
				SlickUI:Print("Quest completion cannot be opened during combat.")
				return
			end
			if C_QuestLog and C_QuestLog.SetSelectedQuest then
				pcall(C_QuestLog.SetSelectedQuest, self.QuestID)
			end
			local handler = self.QuestPopupType == "COMPLETE" and _G.ShowQuestComplete or _G.ShowQuestOffer
			if type(handler) == "function" then
				local opened = pcall(handler, self.QuestID)
				if opened then return end
			end
			if QuestMapFrame_OpenToQuestDetails then
				pcall(QuestMapFrame_OpenToQuestDetails, self.QuestID)
			end
			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

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)
	local row = self:AcquireRow(); self.ActiveRows[#self.ActiveRows + 1] = row
	row.QuestID = info.questID
	row.QuestLogIndex = info.questLogIndex
	row.QuestPopupType = nil
	row.Icon:SetTexture("Interface\\GossipFrame\\AvailableQuestIcon")
	row.Icon:SetTexture("Interface\\GossipFrame\\AvailableQuestIcon")
	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 rightInset = showItem and 40 or 2
	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 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(); 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(30, row.Title:GetStringHeight() + 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, y = GetTrackedQuests(), GetDungeonObjectives(), 5
	if SlickUI.DB.questTracker.currentZoneOnly then
		quests.quests = FilterQuestsToCurrentZone(quests.quests)
	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
	y = self:AddHeader("Campaign", "campaign", y)
	if self.Sections.campaign then
		y = self:AddQuestList(quests.campaign, "campaign", y)
	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 not dungeon.active and #quests.campaign + #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 HideBlizzardTracker(); self:Refresh() else RestoreBlizzardTracker() end
end

function QuestTracker:Initialize()
	self:CreateFrame()
	self:ApplySettings()
	self:SetEnabled(SlickUI.DB.questTracker.enabled)
	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_REGEN_ENABLED" and not QuestTracker.RefreshPending then return end
		C_Timer.After(0.1, function() if SlickUI.DB.questTracker.enabled then QuestTracker:Refresh() end end)
	end)
	self.EventFrame = events
end

SlickUI:RegisterModule("QuestTracker", QuestTracker)
