local addonName = ...
local SlickUI = _G.SlickUI
if not SlickUI then return end

local Planner = {
	RecipeByItem = {},
	Queue = {},
	Groups = {},
	Rows = {},
	SelectedRecipeID = nil,
	SelectedQuantity = 1,
}
SlickUI.CraftingPlanner = Planner

function Planner:IsEnabled()
	return not SlickUI.DB.crafting or SlickUI.DB.crafting.enabled ~= false
end

function Planner:SetEnabled(enabled)
	SlickUI.DB.crafting.enabled = enabled == true
	if not enabled then
		if self.Window then self.Window:Hide() end
		if self.DashboardWindow then self.DashboardWindow:Hide() end
		if self.DashboardWindow then self.DashboardWindow:Hide() end
		if self.ShoppingWindow then self.ShoppingWindow:Hide() end
		if self.VendorPrompt then self.VendorPrompt:Hide() end
		if SlickUI.CraftingSalesLedger and SlickUI.CraftingSalesLedger.Window then SlickUI.CraftingSalesLedger.Window:Hide() end
		return
	end
	if _G.ProfessionsFrame and ProfessionsFrame:IsShown() then self:Attach() end
	if _G.AuctionHouseFrame and AuctionHouseFrame:IsShown() then self:ShowShoppingList() end
	if _G.MerchantFrame and MerchantFrame:IsShown() then self:HandleMerchant() end
end

local function SafeCall(func, ...)
	if type(func) ~= "function" then return nil end
	local ok, result = pcall(func, ...)
	if ok then return result end
end

local function ItemCount(itemID)
	return tonumber(SafeCall(C_Item.GetItemCount, itemID, true, false, true)) or 0
end

local function ItemName(itemID)
	local name = C_Item.GetItemNameByID(itemID)
	if not name and C_Item.RequestLoadItemDataByID then
		SafeCall(C_Item.RequestLoadItemDataByID, itemID)
	end
	return name or ("Item " .. tostring(itemID))
end

local function ShoppingList()
	SlickUI.DB.crafting = SlickUI.DB.crafting or {}
	SlickUI.DB.crafting.shoppingList = SlickUI.DB.crafting.shoppingList or {}
	return SlickUI.DB.crafting.shoppingList
end

local function SavedQueueGroups()
	SlickUI.DB.crafting = SlickUI.DB.crafting or {}
	SlickUI.DB.crafting.queueGroups = SlickUI.DB.crafting.queueGroups or {}
	return SlickUI.DB.crafting.queueGroups
end

local function SourceText(itemID)
	local vendorData = SlickUI.CraftingVendorData
	return vendorData and vendorData:IsVendorItem(itemID) and "|cff55bfffVendor|r" or "|cffffd24aAuction House / Other Source|r"
end

local function FormatMoney(copper)
	copper = math.max(0, math.floor(tonumber(copper) or 0))
	local gold = math.floor(copper / 10000)
	local silver = math.floor((copper % 10000) / 100)
	local coins = copper % 100
	if gold > 0 then return string.format("%dg %02ds", gold, silver) end
	if silver > 0 then return string.format("%ds %02dc", silver, coins) end
	return string.format("%dc", coins)
end

local function StoredValueText(itemID, quantity)
	local vendorData, auctionData = SlickUI.CraftingVendorData, SlickUI.CraftingAuctionData
	local price, record
	if vendorData then price, record = vendorData:GetUnitPrice(itemID) end
	local stale, fromAuction = false, false
	if not price then
		if auctionData then price, record = auctionData:GetUnitPrice(itemID) end
		fromAuction = price ~= nil
		stale = price and auctionData:IsStale(record) or false
	end
	if not price then return "|cff888888Value unknown|r" end
	local total = price * math.max(1, tonumber(quantity) or 1)
	local trend = ""
	local stats = fromAuction and auctionData:GetStats(itemID)
	if stats then
		if stats.outlier then trend = "  |cffffaa44low outlier|r"
		elseif stats.trend == "rising" then trend = "  |cff55dd88rising|r"
		elseif stats.trend == "falling" then trend = "  |cffff7777falling|r"
		elseif stats.trend == "stable" then trend = "  |cffaaaaaastable|r" end
	end
	return string.format("|cffcccccc%s each  %s total%s%s|r", FormatMoney(price), FormatMoney(total), stale and "  |cffffaa44(old)|r" or "", trend)
end

local RecipeOutput, ChooseReagent

local function AddPriceHistoryTooltip(tooltip, itemID)
	local auctionData = SlickUI.CraftingAuctionData
	local stats = auctionData and auctionData:GetStats(itemID)
	local recipeID = Planner.RecipeByItem[itemID]
	local craftCost, craftYield
	if recipeID then
		craftCost = Planner:EstimateRecipeCost(recipeID, 1)
		local _, minimumYield, _, maximumYield = RecipeOutput(recipeID)
		minimumYield = math.max(1, tonumber(minimumYield) or 1)
		maximumYield = math.max(minimumYield, tonumber(maximumYield) or minimumYield)
		craftYield = (minimumYield + maximumYield) / 2
	end
	if not stats and not craftCost then return end
	if tooltip.SlickUICraftingItemID == itemID then return end
	tooltip.SlickUICraftingItemID = itemID
	tooltip:AddLine(" ")
	tooltip:AddLine("SlickUI", 1, 0.77, 0.30)
	local tooltipName = tooltip:GetName()
	local brandLine = tooltipName and _G[tooltipName .. "TextLeft" .. tooltip:NumLines()]
	local baseFont = _G.GameTooltipText
	if brandLine and baseFont and baseFont.GetFont then
		local font, size, flags = baseFont:GetFont()
		if font and size then brandLine:SetFont(font, size + 1, flags) end
	end
	if craftCost then
		local unitCraftCost = craftCost / craftYield
		tooltip:AddLine("Crafting Cost", 0.35, 0.85, 1)
		tooltip:AddDoubleLine("Estimated craft", FormatMoney(craftCost), 0.7, 0.7, 0.7, 1, 1, 1)
		if craftYield > 1 then tooltip:AddDoubleLine("Per item", FormatMoney(unitCraftCost), 0.7, 0.7, 0.7, 1, 1, 1) end
		if stats and stats.current then
			local difference = stats.current - unitCraftCost
			local recommendation = difference >= 0 and "Craft is cheaper" or "Buying is cheaper"
			local color = difference >= 0 and "|cff55dd88" or "|cffffaa44"
			tooltip:AddDoubleLine("Best option", color .. recommendation .. "|r", 0.7, 0.7, 0.7, 1, 1, 1)
		end
	end
	if stats then
		tooltip:AddLine(string.format("Auction Price History (%d/%d)", stats.count, auctionData.MaxHistory), 1, 0.82, 0.2)
		tooltip:AddDoubleLine("Current", FormatMoney(stats.current), 0.35, 0.85, 1, 1, 1, 1)
		tooltip:AddDoubleLine("Average", FormatMoney(stats.average), 0.7, 0.7, 0.7, 1, 1, 1)
		tooltip:AddDoubleLine("Observed range", FormatMoney(stats.low) .. " - " .. FormatMoney(stats.high), 0.7, 0.7, 0.7, 1, 1, 1)
		local trendColor = stats.trend == "rising" and "|cff55dd88" or (stats.trend == "falling" and "|cffff7777" or "|cffcccccc")
		tooltip:AddDoubleLine("Trend", trendColor .. stats.trend .. string.format(" (%+.1f%%)|r", stats.changePercent), 0.7, 0.7, 0.7, 1, 1, 1)
		if stats.outlier then tooltip:AddLine("Current lowest listing is unusually far below the observed average.", 1, 0.55, 0.2, true) end
	end
end

function Planner:ObserveBrowseResults()
	local auctionData = SlickUI.CraftingAuctionData
	if not auctionData then return end
	local results = SafeCall(C_AuctionHouse.GetBrowseResults)
	if type(results) ~= "table" then return end
	for _, result in ipairs(results) do
		local itemID = result.itemKey and result.itemKey.itemID
		if itemID and tonumber(result.minPrice) and result.minPrice > 0 then
			auctionData:Observe(itemID, result.minPrice, "browse-result")
		end
	end
	self:RefreshRows()
end

function Planner:BuildFullMaterialList(recipeID, crafts)
	local totals, visiting = {}, {}
	local function Expand(id, count, depth)
		if depth > 8 or visiting[id] then return end
		local _, _, schematic = RecipeOutput(id)
		if not schematic then return end
		visiting[id] = true
		for _, slot in ipairs(schematic.reagentSlotSchematics or {}) do
			if slot.required ~= false and not slot.hiddenInCraftingForm then
				local needed = (tonumber(slot.quantityRequired) or 0) * count
				local itemID = needed > 0 and self:ChooseCostReagent(slot, needed, visiting) or nil
				if itemID and needed > 0 then
					local subRecipe = self.RecipeByItem[itemID]
					if subRecipe and subRecipe ~= id then
						local _, subYield = RecipeOutput(subRecipe)
						local subCrafts = math.ceil(needed / math.max(1, subYield or 1))
						local craftCost = self:EstimateRecipeCost(subRecipe, subCrafts)
						local buyCost = self:GetDirectCost(itemID, needed)
						if buyCost and (not craftCost or buyCost <= craftCost) then
							totals[itemID] = (totals[itemID] or 0) + needed
						else
							Expand(subRecipe, subCrafts, depth + 1)
						end
					else
						totals[itemID] = (totals[itemID] or 0) + needed
					end
				end
			end
		end
		visiting[id] = nil
	end
	Expand(recipeID, math.max(1, tonumber(crafts) or 1), 0)
	local materials = {}
	for itemID, count in pairs(totals) do materials[#materials + 1] = { itemID = itemID, count = count } end
	return materials
end

function Planner:GetGroupProfitability(group)
	local vendorData, auctionData = SlickUI.CraftingVendorData, SlickUI.CraftingAuctionData
	local summary = { vendorCost = 0, auctionCost = 0, acquisitionCost = 0, unknown = 0, stale = 0 }
	if not next(self.RecipeByItem) then self:BuildRecipeIndex() end
	for _, entry in ipairs(self:BuildFullMaterialList(group.recipeID, group.count)) do
		local cost, source, record = self:GetDirectCost(entry.itemID, entry.count)
		if cost and source == "vendor" then
			summary.vendorCost = summary.vendorCost + cost
		elseif cost and source == "auction" then
			summary.auctionCost = summary.auctionCost + cost
			if auctionData:IsStale(record) then summary.stale = summary.stale + 1 end
		else
			summary.unknown = summary.unknown + 1
		end
	end
	summary.knownCost = summary.vendorCost + summary.auctionCost
	for _, entry in ipairs(group.plan or {}) do
		if entry.kind == "material" then
			local cost = self:GetDirectCost(entry.itemID, entry.count)
			if cost then summary.acquisitionCost = summary.acquisitionCost + cost end
		end
	end
	local outputPrice, outputRecord
	if auctionData then outputPrice, outputRecord = auctionData:GetUnitPrice(group.itemID) end
	if outputPrice then
		local _, minimumYield, _, maximumYield = RecipeOutput(group.recipeID)
		minimumYield = math.max(1, tonumber(minimumYield) or 1)
		maximumYield = math.max(minimumYield, tonumber(maximumYield) or minimumYield)
		local crafts = math.max(1, tonumber(group.count) or 1)
		local expectedYield = (minimumYield + maximumYield) / 2
		summary.minimumOutputCount = crafts * minimumYield
		summary.maximumOutputCount = crafts * maximumYield
		summary.outputCount = crafts * expectedYield
		summary.variableYield = maximumYield > minimumYield
		summary.outputValue = outputPrice * summary.outputCount
		summary.outputStale = auctionData:IsStale(outputRecord)
		local cutPercent = math.max(0, math.min(100, tonumber(SlickUI.DB.crafting.auctionHouseCutPercent) or 5))
		summary.auctionCutPercent = cutPercent
		summary.auctionFee = math.floor(summary.outputValue * cutPercent / 100)
		summary.netSaleValue = summary.outputValue - summary.auctionFee
	end
	if summary.unknown == 0 and summary.netSaleValue then
		summary.profit = summary.netSaleValue - summary.knownCost
		summary.profitPerItem = summary.profit / math.max(1, summary.outputCount or 1)
		summary.marginPercent = summary.netSaleValue > 0 and (summary.profit / summary.netSaleValue * 100) or 0
	end
	return summary
end

function Planner:ProfitabilityText(group)
	local data = self:GetGroupProfitability(group)
	local age = (data.stale > 0 or data.outputStale) and " |cffffaa44(old)|r" or ""
	if data.profit then
		local color = data.profit >= 0 and "|cff55dd88+" or "|cffff6666-"
		return string.format("Cost %s  Net %s  Profit %s%s|r  %.1f%%%s", FormatMoney(data.knownCost), FormatMoney(data.netSaleValue), color, FormatMoney(math.abs(data.profit)), data.marginPercent or 0, age)
	end
	local missing = data.unknown > 0 and string.format("  |cffffaa44%d price%s missing|r", data.unknown, data.unknown == 1 and "" or "s") or ""
	local value = data.netSaleValue and ("  Net " .. FormatMoney(data.netSaleValue)) or "  |cffaaaaaaSale price unknown|r"
	return "Known cost " .. FormatMoney(data.knownCost) .. value .. missing .. age
end

local ShoppingProgress

local function SyncPendingMail(entry)
	local current = ItemCount(entry.itemID)
	local previous = tonumber(entry.lastObservedCount)
	if previous and current > previous and (tonumber(entry.awaitingMail) or 0) > 0 then
		entry.awaitingMail = math.max(0, (tonumber(entry.awaitingMail) or 0) - (current - previous))
	end
	entry.lastObservedCount = current
	return tonumber(entry.awaitingMail) or 0
end

function Planner:RecordAuctionPurchase(itemID, quantity)
	itemID = tonumber(itemID)
	quantity = math.max(1, tonumber(quantity) or 1)
	if not itemID then return end
	for _, entry in ipairs(ShoppingList()) do
		if entry.itemID == itemID then
			local now = GetTime()
			local recent = self.LastAuctionPurchase
			if recent and recent.itemID == itemID and recent.quantity == quantity and now - recent.time < 1 then return end
			SyncPendingMail(entry)
			local _, remaining = ShoppingProgress(entry)
			entry.awaitingMail = math.min(remaining, (tonumber(entry.awaitingMail) or 0) + quantity)
			entry.lastObservedCount = ItemCount(itemID)
			self.LastAuctionPurchase = { itemID = itemID, quantity = quantity, time = now }
			SlickUI:Print(string.format("%s x%d purchased; waiting for delivery. Open your mailbox.", entry.name or ItemName(itemID), quantity))
			self:RefreshShoppingRows()
			return
		end
	end
end

function Planner:RecordAuctionPurchaseByName(itemName, quantity)
	if type(itemName) ~= "string" then return end
	local wantedName = itemName:lower()
	for _, entry in ipairs(ShoppingList()) do
		local entryName = entry.name or ItemName(entry.itemID)
		if type(entryName) == "string" and entryName:lower() == wantedName then
			self:RecordAuctionPurchase(entry.itemID, quantity)
			return
		end
	end
end

function Planner:LearnCurrentMerchant()
	if SlickUI.CraftingVendorData then SlickUI.CraftingVendorData:ObserveMerchant() end
	self:RefreshRows()
	self:RefreshShoppingRows()
end

local function MerchantItemInfo(index)
	if C_MerchantFrame and C_MerchantFrame.GetItemInfo then
		return C_MerchantFrame.GetItemInfo(index)
	end
	local name, texture, price, stackCount, numAvailable, isPurchasable, _, extendedCost = GetMerchantItemInfo(index)
	return { name = name, texture = texture, price = price, stackCount = stackCount, numAvailable = numAvailable, isPurchasable = isPurchasable, extendedCost = extendedCost }
end

function Planner:BuildMerchantPurchaseQueue()
	local wanted = {}
	for _, entry in ipairs(ShoppingList()) do
		local _, remaining = ShoppingProgress(entry)
		if remaining > 0 then wanted[entry.itemID] = { entry = entry, remaining = remaining } end
	end
	local queue, total = {}, 0
	for index = 1, (GetMerchantNumItems() or 0) do
		local itemID = GetMerchantItemID(index)
		local match = itemID and wanted[itemID]
		if match then
			local info = MerchantItemInfo(index)
			local alternateCosts = (GetMerchantItemCostInfo and GetMerchantItemCostInfo(index) or 0) > 0
			local price = tonumber(info and info.price) or 0
			local stackCount = math.max(1, tonumber(info and info.stackCount) or 1)
			local available = tonumber(info and info.numAvailable) or -1
			local quantity = match.remaining
			if available >= 0 then quantity = math.min(quantity, available * stackCount) end
			if info and info.isPurchasable ~= false and price > 0 and not info.extendedCost and not alternateCosts and quantity > 0 then
				local batches = math.ceil(quantity / stackCount)
				queue[#queue + 1] = { index = index, itemID = itemID, name = match.entry.name or ItemName(itemID), quantity = quantity, batches = batches, cost = price * batches }
				total = total + (price * batches)
			end
		end
	end
	return queue, total
end

function Planner:BuyMerchantQueue(queue)
	if self.BuyingVendor or #queue == 0 then return end
	self.BuyingVendor = true
	local position = 1
	local function Next()
		if not MerchantFrame or not MerchantFrame:IsShown() then Planner.BuyingVendor = false; return end
		local purchase = queue[position]
		if not purchase then
			Planner.BuyingVendor = false
			Planner:RefreshShoppingRows()
			return
		end
		if (GetMoney() or 0) < purchase.cost then
			SlickUI:Print("Shopping-list purchase stopped: not enough gold for " .. purchase.name .. ".")
			Planner.BuyingVendor = false
			return
		end
		local maxStack = GetMerchantItemMaxStack and GetMerchantItemMaxStack(purchase.index) or purchase.quantity
		local amount = math.min(purchase.quantity, math.max(1, tonumber(maxStack) or purchase.quantity))
		local ok = pcall(BuyMerchantItem, purchase.index, amount)
		if not ok then
			SlickUI:Print("Could not purchase " .. purchase.name .. ".")
			Planner.BuyingVendor = false
			return
		end
		purchase.quantity = purchase.quantity - amount
		if purchase.quantity <= 0 then position = position + 1 end
		C_Timer.After(0.15, Next)
	end
	Next()
end

function Planner:CreateVendorPrompt()
	if self.VendorPrompt then return end
	local frame = CreateFrame("Frame", "SlickUICraftingVendorPrompt", UIParent, "BackdropTemplate")
	SlickUI.Theme:RegisterWindowFontControl(frame)
	frame:SetSize(430, 205); frame:SetFrameStrata("DIALOG")
	frame:SetMovable(true); frame:EnableMouse(true); frame:SetClampedToScreen(true); frame:RegisterForDrag("LeftButton")
	local savedPosition = SlickUI.DB.crafting.vendorPromptPosition
	if savedPosition and savedPosition.x and savedPosition.y then
		frame:SetPoint("CENTER", UIParent, "CENTER", savedPosition.x, savedPosition.y)
	else
		frame:SetPoint("CENTER")
	end
	frame:SetScript("OnDragStart", function(self) self:StartMoving() end)
	frame:SetScript("OnDragStop", function(self)
		self:StopMovingOrSizing()
		local centerX, centerY = self:GetCenter()
		local parentX, parentY = UIParent:GetCenter()
		SlickUI.DB.crafting.vendorPromptPosition = {
			x = (centerX or parentX) - parentX,
			y = (centerY or parentY) - parentY,
		}
	end)
	frame:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 2 })
	frame:SetBackdropColor(0.015, 0.018, 0.025, 0.98); frame:SetBackdropBorderColor(1, 0.58, 0, 1); frame:Hide()
	local title = frame:CreateFontString(nil, "ARTWORK", "GameFontNormalLarge"); title:SetPoint("TOPLEFT", 18, -17); title:SetText("Shopping List Vendor Items")
	local close = CreateFrame("Button", nil, frame, "UIPanelCloseButton"); close:SetPoint("TOPRIGHT", -3, -3)
	local text = frame:CreateFontString(nil, "ARTWORK", "GameFontHighlight"); text:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -12); text:SetPoint("RIGHT", -18, 0); text:SetJustifyH("LEFT"); text:SetJustifyV("TOP"); frame.Text = text
	local auto = CreateFrame("CheckButton", nil, frame, "UICheckButtonTemplate"); auto:SetPoint("BOTTOMLEFT", 14, 48); auto.Text:SetText("Automatically buy shopping-list vendor items"); auto:SetChecked(SlickUI.DB.crafting.autoBuyVendor == true)
	auto:SetScript("OnClick", function(self) SlickUI.DB.crafting.autoBuyVendor = self:GetChecked() == true end)
	local buy = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate"); buy:SetSize(155, 25); buy:SetPoint("BOTTOMRIGHT", -18, 16); buy:SetText("Buy Required Items")
	buy:SetScript("OnClick", function() frame:Hide(); Planner:BuyMerchantQueue(frame.Queue or {}) end)
	local later = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate"); later:SetSize(100, 25); later:SetPoint("RIGHT", buy, "LEFT", -8, 0); later:SetText("Not Now"); later:SetScript("OnClick", function() frame:Hide() end)
	frame.Auto = auto; self.VendorPrompt = frame
end

function Planner:HandleMerchant()
	if not self:IsEnabled() then return end
	self:LearnCurrentMerchant()
	local queue, total = self:BuildMerchantPurchaseQueue()
	if #queue == 0 then return end
	if SlickUI.DB.crafting.autoBuyVendor then
		self:BuyMerchantQueue(queue)
		return
	end
	self:CreateVendorPrompt()
	local names = {}
	for _, purchase in ipairs(queue) do names[#names + 1] = string.format("%s x%d", purchase.name, purchase.quantity) end
	self.VendorPrompt.Text:SetText("This merchant has shopping-list materials:\n\n" .. table.concat(names, ", ") .. "\n\nEstimated cost: " .. GetCoinTextureString(total))
	self.VendorPrompt.Queue = queue
	self.VendorPrompt.Auto:SetChecked(SlickUI.DB.crafting.autoBuyVendor == true)
	self.VendorPrompt:Show()
end

local function RecipeName(recipeID)
	local info = SafeCall(C_TradeSkillUI.GetRecipeInfo, recipeID)
	return info and info.name or ("Recipe " .. tostring(recipeID))
end

RecipeOutput = function(recipeID)
	local schematic = SafeCall(C_TradeSkillUI.GetRecipeSchematic, recipeID, false)
	if not schematic then return nil end
	local minimum = math.max(1, tonumber(schematic.quantityMin) or 1)
	local maximum = math.max(minimum, tonumber(schematic.quantityMax) or minimum)
	return schematic.outputItemID, minimum, schematic, maximum
end

function Planner:BuildRecipeIndex()
	wipe(self.RecipeByItem)
	local recipes = SafeCall(C_TradeSkillUI.GetAllRecipeIDs)
	if type(recipes) ~= "table" then return end
	for _, recipeID in ipairs(recipes) do
		local info = SafeCall(C_TradeSkillUI.GetRecipeInfo, recipeID)
		if info and info.learned then
			local outputItemID = RecipeOutput(recipeID)
			if outputItemID and not self.RecipeByItem[outputItemID] then
				self.RecipeByItem[outputItemID] = recipeID
			end
		end
	end
	for _, cached in pairs(self:GetDashboardCache()) do
		for _, entry in ipairs(type(cached) == "table" and cached.entries or {}) do
			local itemID, recipeID = tonumber(entry.itemID), tonumber(entry.recipeID)
			if itemID and recipeID and not self.RecipeByItem[itemID] then self.RecipeByItem[itemID] = recipeID end
		end
	end
end

ChooseReagent = function(slot, countProvider)
	if type(slot.reagents) ~= "table" then return nil end
	local best, bestCount, bestCost
	for _, reagent in ipairs(slot.reagents) do
		local itemID = reagent.itemID
		if itemID then
			local count = countProvider and countProvider(itemID) or ItemCount(itemID)
			local directCost = Planner:GetDirectCost(itemID, math.max(1, tonumber(slot.quantityRequired) or 1))
			if not best or count > bestCount or (count == bestCount and directCost and (not bestCost or directCost < bestCost)) then
				best, bestCount, bestCost = itemID, count, directCost
			end
		end
	end
	return best
end

function Planner:GetDirectUnitPrice(itemID)
	local price = SlickUI.CraftingVendorData and SlickUI.CraftingVendorData:GetUnitPrice(itemID)
	if not price then price = SlickUI.CraftingAuctionData and SlickUI.CraftingAuctionData:GetUnitPrice(itemID) end
	return tonumber(price)
end

function Planner:GetDirectCost(itemID, quantity)
	quantity = math.max(1, tonumber(quantity) or 1)
	local vendorData, auctionData = SlickUI.CraftingVendorData, SlickUI.CraftingAuctionData
	local cost, record
	if vendorData then cost, record = vendorData:GetCost(itemID, quantity) end
	if cost then return cost, "vendor", record end
	local price
	if auctionData then price, record = auctionData:GetUnitPrice(itemID) end
	if price then return price * quantity, "auction", record end
	return nil, nil, record
end

function Planner:ChooseCostReagent(slot, quantity, visiting)
	if type(slot.reagents) ~= "table" then return nil end
	local bestItemID, bestCost
	for _, reagent in ipairs(slot.reagents) do
		local itemID = tonumber(reagent.itemID)
		if itemID then
			local directCost = self:GetDirectCost(itemID, quantity)
			local craftCost
			local subRecipe = self.RecipeByItem[itemID]
			if subRecipe and not (visiting and visiting[subRecipe]) then
				local _, subYield = RecipeOutput(subRecipe)
				craftCost = self:EstimateRecipeCost(subRecipe, math.ceil(quantity / math.max(1, tonumber(subYield) or 1)), visiting)
			end
			local candidate = directCost and craftCost and math.min(directCost, craftCost) or directCost or craftCost
			if candidate and (not bestCost or candidate < bestCost) then
				bestItemID, bestCost = itemID, candidate
			elseif not bestItemID then
				bestItemID = itemID
			end
		end
	end
	return bestItemID, bestCost
end

function Planner:EstimateRecipeCost(recipeID, crafts, visiting)
	visiting = visiting or {}
	if visiting[recipeID] then return nil end
	local _, _, schematic = RecipeOutput(recipeID)
	if not schematic then return nil end
	visiting[recipeID] = true
	local total = 0
	for _, slot in ipairs(schematic.reagentSlotSchematics or {}) do
		if slot.required ~= false and not slot.hiddenInCraftingForm then
			local needed = (tonumber(slot.quantityRequired) or 0) * crafts
			local itemID = needed > 0 and self:ChooseCostReagent(slot, needed, visiting) or nil
			if itemID and needed > 0 then
				local directCost = self:GetDirectCost(itemID, needed)
				local craftCost
				local subRecipe = self.RecipeByItem[itemID]
				if subRecipe and subRecipe ~= recipeID then
					local _, subYield = RecipeOutput(subRecipe)
					craftCost = self:EstimateRecipeCost(subRecipe, math.ceil(needed / math.max(1, subYield or 1)), visiting)
				end
				local cheapest
				if directCost and craftCost then cheapest = math.min(directCost, craftCost) else cheapest = directCost or craftCost end
				if not cheapest then visiting[recipeID] = nil; return nil end
				total = total + cheapest
			end
		end
	end
	visiting[recipeID] = nil
	return total
end

function Planner:BuildPlan(recipeID, crafts, sharedStock)
	local plan, stock, visiting = {}, sharedStock or {}, {}
	local function GetStock(itemID)
		if stock[itemID] == nil then stock[itemID] = ItemCount(itemID) end
		return stock[itemID]
	end
	local function Consume(itemID, amount)
		local available = GetStock(itemID)
		local used = math.min(amount, available)
		stock[itemID] = available - used
		return amount - used
	end
	local function AddRecipe(id, count, depth)
		if depth > 8 or visiting[id] then return false end
		local outputItemID, yield, schematic = RecipeOutput(id)
		if not schematic then return false end
		visiting[id] = true
		for _, slot in ipairs(schematic.reagentSlotSchematics or {}) do
			if slot.required ~= false and not slot.hiddenInCraftingForm then
				local itemID = ChooseReagent(slot, GetStock)
				local needed = itemID and ((tonumber(slot.quantityRequired) or 0) * count) or 0
				if itemID and needed > 0 then
					local missing = Consume(itemID, needed)
					local subRecipe = self.RecipeByItem[itemID]
					if missing > 0 and subRecipe and subRecipe ~= id then
						local _, subYield = RecipeOutput(subRecipe)
						local subCrafts = math.ceil(missing / (subYield or 1))
						local buyCost = self:GetDirectCost(itemID, missing)
						local craftCost = self:EstimateRecipeCost(subRecipe, subCrafts)
						if not buyCost or (craftCost and craftCost < buyCost) then
							AddRecipe(subRecipe, subCrafts, depth + 1)
							stock[itemID] = GetStock(itemID) + (subCrafts * (subYield or 1))
							missing = Consume(itemID, missing)
						elseif craftCost then
							plan[#plan + 1] = { kind = "material", itemID = itemID, name = ItemName(itemID), count = missing, depth = depth + 1, buyInstead = true, craftCost = craftCost, buyCost = buyCost }
							missing = 0
						end
					end
					if missing > 0 then
						plan[#plan + 1] = { kind = "material", itemID = itemID, name = ItemName(itemID), count = missing, depth = depth + 1 }
					end
				end
			end
		end
		visiting[id] = nil
		plan[#plan + 1] = { kind = "recipe", recipeID = id, itemID = outputItemID, name = RecipeName(id), count = count, yield = yield, depth = depth }
		return true
	end
	AddRecipe(recipeID, crafts, 0)
	return plan
end

function Planner:GetSelectedRecipeID()
	local frame = _G.ProfessionsFrame
	local form = frame and frame.CraftingPage and frame.CraftingPage.SchematicForm
	if not form then return self.SelectedRecipeID end
	if type(form.GetRecipeInfo) == "function" then
		local info = SafeCall(form.GetRecipeInfo, form)
		if info and info.recipeID then return info.recipeID end
	end
	return form.recipeInfo and form.recipeInfo.recipeID or self.SelectedRecipeID
end

function Planner:RefreshSelection()
	local recipeID = self:GetSelectedRecipeID()
	if recipeID then self.SelectedRecipeID = recipeID end
	if self.Window then
		self.Window.Selected:SetText(self.SelectedRecipeID and ("Selected: " .. RecipeName(self.SelectedRecipeID)) or "Select a recipe")
	end
end

function Planner:QueueSelected()
	self:RefreshSelection()
	if not self.SelectedRecipeID then
		SlickUI:Print("Select a learned crafting recipe first.")
		return
	end
	self:QueueRecipe(self.SelectedRecipeID, self.SelectedQuantity)
end

function Planner:QueueRecipe(recipeID, quantity)
	recipeID, quantity = tonumber(recipeID), math.max(1, tonumber(quantity) or 1)
	if not recipeID then return end
	for _, group in ipairs(self.Groups) do
		if tonumber(group.recipeID) == recipeID then
			group.count = math.min(999, (tonumber(group.count) or 1) + quantity)
			self:RefreshQueue()
			return
		end
	end
	self:BuildRecipeIndex()
	local additions = self:BuildPlan(recipeID, quantity)
	local root = additions[#additions]
	self.Groups[#self.Groups + 1] = {
		recipeID = recipeID,
		itemID = root and root.itemID,
		name = root and root.name or RecipeName(recipeID),
		count = quantity,
		plan = additions,
		collapsed = true,
		collapsedEntries = {},
	}
	self:RefreshQueue()
end

function Planner:SaveQueue()
	local saved = SavedQueueGroups()
	wipe(saved)
	for _, group in ipairs(self.Groups) do
		local copy = {
			recipeID = group.recipeID,
			itemID = group.itemID,
			name = group.name,
			count = group.count,
			collapsed = group.collapsed == true,
			collapsedEntries = {},
			plan = {},
		}
		for planIndex, collapsed in pairs(group.collapsedEntries or {}) do
			if collapsed == true and type(planIndex) == "number" then copy.collapsedEntries[planIndex] = true end
		end
		for _, entry in ipairs(group.plan or {}) do
			copy.plan[#copy.plan + 1] = {
				kind = entry.kind,
				recipeID = entry.recipeID,
				itemID = entry.itemID,
				name = entry.name,
				count = entry.count,
				yield = entry.yield,
				depth = entry.depth,
				buyInstead = entry.buyInstead,
				craftCost = entry.craftCost,
				buyCost = entry.buyCost,
			}
		end
		saved[#saved + 1] = copy
	end
end

function Planner:RestoreQueue()
	wipe(self.Queue)
	wipe(self.Groups)
	for _, saved in ipairs(SavedQueueGroups()) do
		if type(saved) == "table" and type(saved.plan) == "table" then
			local group = {
				recipeID = saved.recipeID,
				itemID = saved.itemID,
				name = saved.name,
				count = tonumber(saved.count) or 1,
				collapsed = saved.collapsed ~= false,
				collapsedEntries = type(saved.collapsedEntries) == "table" and saved.collapsedEntries or {},
				plan = {},
			}
			for _, entry in ipairs(saved.plan) do
				if type(entry) == "table" then
					group.plan[#group.plan + 1] = entry
					self.Queue[#self.Queue + 1] = entry
				end
			end
			self.Groups[#self.Groups + 1] = group
		end
	end
end

function Planner:CreateShoppingList(resetBaseline)
	local aggregate = {}
	for _, entry in ipairs(self.Queue) do
		if entry.kind == "material" and entry.itemID and entry.count > 0 then
			aggregate[entry.itemID] = (aggregate[entry.itemID] or 0) + entry.count
		end
	end
	local list = ShoppingList()
	local previous = {}
	for _, entry in ipairs(list) do
		if entry.itemID then previous[entry.itemID] = entry end
	end
	wipe(list)
	for itemID, needed in pairs(aggregate) do
		local old = previous[itemID]
		list[#list + 1] = {
			itemID = itemID,
			name = ItemName(itemID),
			needed = needed,
			ownedAtCreation = resetBaseline and ItemCount(itemID) or (old and old.ownedAtCreation or ItemCount(itemID)),
			awaitingMail = old and old.awaitingMail or 0,
			lastObservedCount = old and old.lastObservedCount or ItemCount(itemID),
		}
	end
	table.sort(list, function(a, b) return a.name < b.name end)
end

function Planner:RefreshCachedItemName(itemID)
	itemID = tonumber(itemID)
	if not itemID then return false end
	local name = C_Item.GetItemNameByID(itemID)
	if type(name) ~= "string" or name == "" then return false end
	local changed = false
	for _, entry in ipairs(ShoppingList()) do
		if tonumber(entry.itemID) == itemID and entry.name ~= name then
			entry.name = name
			changed = true
		end
	end
	for _, group in ipairs(self.Groups) do
		if tonumber(group.itemID) == itemID and group.name ~= name then
			group.name = name
			changed = true
		end
		for _, entry in ipairs(group.plan or {}) do
			if tonumber(entry.itemID) == itemID and entry.name ~= name then
				entry.name = name
				changed = true
			end
		end
	end
	if changed then
		table.sort(ShoppingList(), function(left, right) return tostring(left.name or "") < tostring(right.name or "") end)
		self:SaveQueue()
		self:RefreshRows()
		self:RefreshShoppingRows()
	end
	return changed
end

ShoppingProgress = function(entry)
	local purchased = math.max(0, ItemCount(entry.itemID) - (entry.ownedAtCreation or 0))
	local credited = math.min(entry.needed or 0, purchased)
	return credited, math.max(0, (entry.needed or 0) - credited)
end

function Planner:SearchAuctionHouse(itemID, itemName, quantity)
	local auctionHouse = _G.AuctionHouseFrame
	if not auctionHouse or not auctionHouse:IsShown() then
		SlickUI:Print("Open the Auction House, then click the shopping-list item again.")
		return
	end

	local buyMode = _G.AuctionHouseFrameDisplayMode and AuctionHouseFrameDisplayMode.Buy
	local searchBar = auctionHouse.SearchBar
	if not buyMode or not searchBar or not searchBar.SetSearchText or not searchBar.StartSearch then
		SlickUI:Print("The Auction House search controls are not available.")
		return
	end
	itemID = tonumber(itemID)
	if self.PendingAuctionSearch and self.PendingAuctionSearch.itemID == itemID then
		SlickUI:Print("That Shopping List search is still loading.")
		return
	end

	auctionHouse:SetDisplayMode(buyMode)
	local categories = auctionHouse.GetCategoriesList and auctionHouse:GetCategoriesList()
	if categories and categories.SetSelectedCategory then categories:SetSelectedCategory(nil) end
	self.PendingAuctionSearch = {
		itemID = itemID,
		itemName = itemName,
		quantity = math.max(1, tonumber(quantity) or 1),
	}
	searchBar:SetSearchText(itemName or "")
	searchBar:StartSearch()
	SlickUI:Print("Searching the Auction House for " .. (itemName or ItemName(itemID)) .. "...")
	self:SchedulePendingAuctionRetry()
end

function Planner:CollectPriceUpdates()
	local auctionData, vendorData = SlickUI.CraftingAuctionData, SlickUI.CraftingVendorData
	local unique = {}
	local function Add(itemID, name)
		itemID = tonumber(itemID)
		if not itemID or unique[itemID] or (vendorData and vendorData:IsVendorItem(itemID)) then return end
		local price, record
		if auctionData then price, record = auctionData:GetUnitPrice(itemID) end
		if not record or auctionData:IsStale(record) then unique[itemID] = { itemID = itemID, name = name or ItemName(itemID) } end
	end
	for _, group in ipairs(self.Groups) do
		Add(group.itemID, group.name)
		for _, entry in ipairs(group.plan or {}) do Add(entry.itemID, entry.name) end
	end
	local updates = {}
	for _, entry in pairs(unique) do updates[#updates + 1] = entry end
	table.sort(updates, function(a, b) return a.name < b.name end)
	return updates
end

function Planner:RefreshPriceUpdateButton()
	local button = self.ShoppingWindow and self.ShoppingWindow.PriceUpdateButton
	if not button then return end
	local updates, index = self.PriceUpdateQueue, tonumber(self.PriceUpdateIndex) or 0
	if self.PendingAuctionSearch and self.PendingAuctionSearch.priceUpdate then
		button:SetText(string.format("Loading %d / %d", index, #updates))
	elseif updates and index > 0 and index < #updates then
		button:SetText(string.format("Next Price (%d/%d)", index, #updates))
	elseif updates and index >= #updates and #updates > 0 then
		button:SetText("Prices Updated")
	else
		button:SetText("Update Prices")
	end
end

function Planner:SearchNextPrice()
	local auctionHouse = _G.AuctionHouseFrame
	local searchBar = auctionHouse and auctionHouse.SearchBar
	local buyMode = _G.AuctionHouseFrameDisplayMode and AuctionHouseFrameDisplayMode.Buy
	if not auctionHouse or not auctionHouse:IsShown() or not buyMode or not searchBar or not searchBar.SetSearchText or not searchBar.StartSearch then
		SlickUI:Print("Open the Auction House to update crafting prices.")
		return
	end
	if self.PendingAuctionSearch then
		SlickUI:Print("The current Auction House search is still loading.")
		return
	end
	if not self.PriceUpdateQueue or (self.PriceUpdateIndex or 0) >= #self.PriceUpdateQueue then
		self.PriceUpdateQueue = self:CollectPriceUpdates()
		self.PriceUpdateIndex = 0
		if #self.PriceUpdateQueue == 0 then
			SlickUI:Print("All queued crafting prices are current.")
			self:RefreshPriceUpdateButton()
			return
		end
	end
	self.PriceUpdateIndex = self.PriceUpdateIndex + 1
	local entry = self.PriceUpdateQueue[self.PriceUpdateIndex]
	auctionHouse:SetDisplayMode(buyMode)
	local categories = auctionHouse.GetCategoriesList and auctionHouse:GetCategoriesList()
	if categories and categories.SetSelectedCategory then categories:SetSelectedCategory(nil) end
	self.PendingAuctionSearch = { itemID = entry.itemID, itemName = entry.name, quantity = 1, priceUpdate = true }
	searchBar:SetSearchText(entry.name)
	searchBar:StartSearch()
	self:RefreshPriceUpdateButton()
	SlickUI:Print(string.format("Updating price %d of %d: %s", self.PriceUpdateIndex, #self.PriceUpdateQueue, entry.name))
	self:SchedulePendingAuctionRetry()
end

function Planner:SchedulePendingAuctionRetry()
	local pending = self.PendingAuctionSearch
	if not pending or pending.selected or pending.retryScheduled then return end
	pending.retryScheduled = true
	C_Timer.After(0.15, function()
		if Planner.PendingAuctionSearch ~= pending then return end
		pending.retryScheduled = false
		pending.retryCount = (pending.retryCount or 0) + 1
		Planner:OpenPendingAuctionResult()
		if Planner.PendingAuctionSearch == pending and not pending.selected then
			if pending.retryCount < 20 then
				Planner:SchedulePendingAuctionRetry()
			else
				SlickUI:Print("No exact Auction House result became available. Try the search again.")
				if pending.priceUpdate then Planner.PriceUpdateIndex = math.max(0, (Planner.PriceUpdateIndex or 1) - 1) end
				Planner.PendingAuctionSearch = nil
				Planner:RefreshPriceUpdateButton()
			end
		end
	end)
end

function Planner:OpenPendingAuctionResult()
	local pending = self.PendingAuctionSearch
	local auctionHouse = _G.AuctionHouseFrame
	if not pending or pending.selected or not auctionHouse or not auctionHouse:IsShown() then return end
	local results = SafeCall(C_AuctionHouse.GetBrowseResults)
	if type(results) ~= "table" then return end
	self:ObserveBrowseResults()
	for _, result in ipairs(results) do
		local itemKey = result.itemKey
		if itemKey and itemKey.itemID == pending.itemID then
			if SlickUI.CraftingAuctionData then SlickUI.CraftingAuctionData:Observe(pending.itemID, result.minPrice, "shopping-search") end
			if pending.priceUpdate then
				self.PendingAuctionSearch = nil
				self:RefreshPriceUpdateButton()
				self:RefreshQueue()
				if self.PriceUpdateIndex >= #self.PriceUpdateQueue then SlickUI:Print("Crafting price update complete.") end
				return
			end
			local keyInfo = SafeCall(C_AuctionHouse.GetItemKeyInfo, itemKey)
			if not keyInfo then return end
			pending.selected = true
			pending.isCommodity = keyInfo.isCommodity == true
			auctionHouse:SelectBrowseResult(result)
			if not pending.isCommodity then
				SlickUI:Print("Choose the listing you want, then use Blizzard's Buyout confirmation.")
				self.PendingAuctionSearch = nil
			end
			return
		end
	end
end

function Planner:FillPendingCommodityQuantity()
	local pending = self.PendingAuctionSearch
	if not pending or not pending.selected or not pending.isCommodity or pending.fillScheduled then return end
	pending.fillScheduled = true
	C_Timer.After(0.1, function()
		if Planner.PendingAuctionSearch ~= pending then return end
		pending.fillScheduled = false
		local auctionHouse = _G.AuctionHouseFrame
		local buyFrame = auctionHouse and auctionHouse.CommoditiesBuyFrame
		local display = buyFrame and buyFrame.BuyDisplay
		if display and display.resultsLoaded and display.SetQuantitySelected then
			local result = SafeCall(C_AuctionHouse.GetCommoditySearchResultInfo, pending.itemID, 1)
			if result and SlickUI.CraftingAuctionData then SlickUI.CraftingAuctionData:Observe(pending.itemID, result.unitPrice, "commodity-result") end
			SafeCall(display.SetQuantitySelected, display, pending.quantity)
			local input = display.QuantityInput and display.QuantityInput.InputBox
			if input and input.SetText then
				SafeCall(input.SetText, input, tostring(pending.quantity))
				if input.OnTextChanged then SafeCall(input.OnTextChanged, input, true) end
			end
			pending.quantityPasses = (pending.quantityPasses or 0) + 1
			if pending.quantityPasses < 4 then
				Planner:FillPendingCommodityQuantity()
			else
				SlickUI:Print(string.format("Prepared %s x%d. Review Blizzard's price and press Buy to continue.", pending.itemName or ItemName(pending.itemID), pending.quantity))
				Planner.PendingAuctionSearch = nil
			end
		else
			pending.fillAttempts = (pending.fillAttempts or 0) + 1
			if pending.fillAttempts < 30 then
				Planner:FillPendingCommodityQuantity()
			else
				SlickUI:Print("The commodity quantity did not finish loading. Try the Shopping List item again.")
				Planner.PendingAuctionSearch = nil
			end
		end
	end)
end

function Planner:RefreshShoppingRows()
	if not self.ShoppingWindow then return end
	local list = ShoppingList()
	for _, row in ipairs(self.ShoppingRows) do row:Hide() end
	local complete = 0
	for index, entry in ipairs(list) do
		local resolvedName = C_Item.GetItemNameByID(entry.itemID)
		if resolvedName then
			entry.name = resolvedName
		elseif C_Item.RequestLoadItemDataByID then
			SafeCall(C_Item.RequestLoadItemDataByID, entry.itemID)
		end
		local awaitingMail = SyncPendingMail(entry)
		local row = self.ShoppingRows[index]
		if not row then
			row = CreateFrame("Button", nil, self.ShoppingWindow.List)
			row:SetHeight(38)
			row:SetPoint("TOPLEFT", 0, -((index - 1) * 39))
			row:SetPoint("RIGHT", 0, 0)
			row.Icon = row:CreateTexture(nil, "ARTWORK")
			row.Icon:SetSize(34, 34); row.Icon:SetPoint("LEFT", 2, 0)
			row.Name = row:CreateFontString(nil, "ARTWORK", "GameFontHighlight")
			row.Name:SetPoint("TOPLEFT", row.Icon, "TOPRIGHT", 8, -2)
			row.Status = row:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall")
			row.Status:SetPoint("BOTTOMLEFT", row.Icon, "BOTTOMRIGHT", 8, 2)
			row.Source = row:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall")
			row.Source:SetPoint("BOTTOMLEFT", row.Status, "BOTTOMRIGHT", 10, 0)
			row.Count = row:CreateFontString(nil, "ARTWORK", "GameFontNormal")
			row.Count:SetPoint("RIGHT", -8, 0)
			row:RegisterForClicks("LeftButtonUp")
			row:SetScript("OnClick", function(self)
				if self.CanAuctionSearch then Planner:SearchAuctionHouse(self.ItemID, self.ItemName, self.AuctionQuantity) end
			end)
			row:SetScript("OnEnter", function(self)
				if self.ItemID then
					GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
					GameTooltip:SetItemByID(self.ItemID)
					if self.CanAuctionSearch then GameTooltip:AddLine("Left-click to prepare the remaining Auction House quantity", 1, 0.82, 0) end
					GameTooltip:Show()
				end
			end)
			row:SetScript("OnLeave", GameTooltip_Hide)
			self.ShoppingRows[index] = row
		end
		local purchased, remaining = ShoppingProgress(entry)
		row.ItemID = entry.itemID
		row.ItemName = entry.name or ItemName(entry.itemID)
		local waiting = math.min(remaining, awaitingMail)
		row.CanAuctionSearch = remaining > waiting
		row.AuctionQuantity = math.max(0, remaining - waiting)
		row.Icon:SetTexture(C_Item.GetItemIconByID(entry.itemID) or 134400)
		row.Name:SetText(row.ItemName)
		row.Source:SetText(waiting > 0 and "|cff55bfffOpen your mailbox|r" or SourceText(entry.itemID))
		if remaining == 0 then
			row.Status:SetText("|cff42df72Purchased|r")
			complete = complete + 1
		elseif waiting > 0 then
			row.Status:SetText("|cff55bfffWaiting for mail|r")
		elseif purchased > 0 then
			row.Status:SetText(string.format("|cffffd24aPartially purchased: %d of %d|r", purchased, entry.needed))
		else
			row.Status:SetText("|cffff6666Needed|r")
		end
		if waiting > 0 then
			local stillNeeded = math.max(0, remaining - waiting)
			if stillNeeded > 0 then
				row.Count:SetText(string.format("%d incoming / %d still needed", waiting, stillNeeded))
			else
				row.Count:SetText(string.format("%d incoming", waiting))
			end
		else
			row.Count:SetText(string.format("%d remaining", remaining))
		end
		row:Show()
	end
	self.ShoppingWindow.Summary:SetText(string.format("Purchased: |cff42df72%d|r / %d", complete, #list))
	self.ShoppingWindow.Empty:SetShown(#list == 0)
	self.ShoppingWindow.List:SetHeight(math.max(1, #list * 39))
	local isEmpty = #list == 0
	if self.ShoppingWasEmpty == nil then
		if isEmpty then self:SetShoppingCollapsed(true) end
	elseif isEmpty ~= self.ShoppingWasEmpty then
		self:SetShoppingCollapsed(isEmpty)
	end
	self.ShoppingWasEmpty = isEmpty
end

function Planner:SetShoppingCollapsed(collapsed)
	if not self.ShoppingWindow then return end
	self.ShoppingCollapsed = collapsed == true
	self.ShoppingWindow:SetFrameStrata(self.ShoppingCollapsed and "MEDIUM" or "HIGH")
	self.ShoppingWindow:SetSize(self.ShoppingCollapsed and 36 or 390, 510)
	for _, region in ipairs(self.ShoppingWindow.CollapsibleRegions or {}) do
		region:SetShown(not self.ShoppingCollapsed)
	end
	local button = self.ShoppingWindow.CollapseButton
	button:ClearAllPoints()
	if self.ShoppingCollapsed then
		button:SetPoint("TOP", 0, -14)
	else
		button:SetPoint("RIGHT", self.ShoppingWindow.CloseButton, "LEFT", -2, 0)
	end
	button:SetText(self.ShoppingCollapsed and ">" or "<")
	button:SetScript("OnEnter", function(toggle)
		GameTooltip:SetOwner(toggle, "ANCHOR_RIGHT")
		GameTooltip:SetText(self.ShoppingCollapsed and "Expand Shopping List" or "Collapse Shopping List")
		GameTooltip:Show()
	end)
end

function Planner:CreateShoppingWindow()
	if self.ShoppingWindow then return end
	local window = CreateFrame("Frame", "SlickUICraftingShoppingList", UIParent, "BackdropTemplate")
	SlickUI.Theme:RegisterWindowFontControl(window)
	window:SetSize(390, 510); window:SetFrameStrata("HIGH"); window:SetClampedToScreen(true)
	window:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1 })
	window:SetBackdropColor(0.015, 0.018, 0.025, 0.96); window:SetBackdropBorderColor(1, 0.58, 0, 1); window:Hide()
	local logo = window:CreateTexture(nil, "ARTWORK")
	logo:SetSize(44, 44); logo:SetPoint("TOPLEFT", 10, -8)
	logo:SetTexture((SlickUI.Theme and SlickUI.Theme.Icon) or "Interface\\AddOns\\SlickUI\\Media\\Icons\\SlickUI_Icon.png")
	local title = window:CreateFontString(nil, "ARTWORK", "GameFontNormalLarge"); title:SetPoint("TOPLEFT", 62, -15); title:SetText("SlickUI Shopping List")
	local summary = window:CreateFontString(nil, "ARTWORK", "GameFontHighlight"); summary:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -10); window.Summary = summary
	local close = CreateFrame("Button", nil, window, "UIPanelCloseButton"); close:SetPoint("TOPRIGHT", -3, -3)
	window.CloseButton = close
	local collapse = CreateFrame("Button", nil, window, "UIPanelButtonTemplate")
	collapse:SetSize(26, 22); collapse:SetPoint("RIGHT", close, "LEFT", -2, 0); collapse:SetText("<")
	collapse:SetScript("OnClick", function() Planner:SetShoppingCollapsed(not Planner.ShoppingCollapsed) end)
	collapse:SetScript("OnLeave", GameTooltip_Hide)
	window.CollapseButton = collapse
	local scroll = CreateFrame("ScrollFrame", nil, window, "UIPanelScrollFrameTemplate")
	scroll:SetPoint("TOPLEFT", 16, -66); scroll:SetPoint("BOTTOMRIGHT", -32, 50)
	local list = CreateFrame("Frame", nil, scroll); list:SetWidth(336); list:SetHeight(1); scroll:SetScrollChild(list); window.List = list
	local empty = list:CreateFontString(nil, "ARTWORK", "GameFontDisable"); empty:SetPoint("TOP", 0, -35); empty:SetText("No missing materials are on the shopping list."); window.Empty = empty
	local clear = CreateFrame("Button", nil, window, "UIPanelButtonTemplate"); clear:SetSize(140, 25); clear:SetPoint("BOTTOMRIGHT", -16, 15); clear:SetText("Clear Purchased")
	clear:SetScript("OnClick", function()
		local source, kept = ShoppingList(), {}
		for _, entry in ipairs(source) do local _, remaining = ShoppingProgress(entry); if remaining > 0 then kept[#kept + 1] = entry end end
		wipe(source); for _, entry in ipairs(kept) do source[#source + 1] = entry end
		Planner:RefreshShoppingRows()
	end)
	local prices = CreateFrame("Button", nil, window, "UIPanelButtonTemplate")
	prices:SetSize(150, 25); prices:SetPoint("BOTTOMLEFT", 16, 15); prices:SetText("Update Prices")
	prices:SetScript("OnClick", function() Planner:SearchNextPrice() end)
	prices:SetScript("OnEnter", function(button)
		GameTooltip:SetOwner(button, "ANCHOR_RIGHT")
		GameTooltip:SetText("Update missing and old prices")
		GameTooltip:AddLine("Searches one queued item at a time and never purchases anything.", 1, 1, 1)
		GameTooltip:Show()
	end)
	prices:SetScript("OnLeave", GameTooltip_Hide)
	window.PriceUpdateButton = prices
	window.CollapsibleRegions = { logo, title, summary, close, scroll, clear, prices }
	self.ShoppingRows = {}; self.ShoppingWindow = window
end

function Planner:ShowShoppingList()
	if not self:IsEnabled() then return end
	self:CreateShoppingWindow()
	local parent = _G.AuctionHouseFrame
	self.ShoppingWindow:ClearAllPoints()
	if parent then self.ShoppingWindow:SetPoint("TOPLEFT", parent, "TOPRIGHT", 4, 0) else self.ShoppingWindow:SetPoint("CENTER") end
	self:RefreshShoppingRows()
	self:SetShoppingCollapsed(self.ShoppingCollapsed)
	self.ShoppingWindow:Show()
end

function Planner:GetCraftableCount(recipeID)
	-- Blizzard's result accounts for the currently selected quality reagents and
	-- other profession-form requirements more accurately than a bag-only ratio.
	local nativeCount = tonumber(SafeCall(C_TradeSkillUI.GetCraftableCount, recipeID))
	if nativeCount then return math.max(0, math.floor(nativeCount)) end
	local schematic = SafeCall(C_TradeSkillUI.GetRecipeSchematic, recipeID, false)
	if not schematic then return 0 end
	local requirements = {}
	for _, slot in ipairs(schematic.reagentSlotSchematics or {}) do
		if slot.required ~= false and not slot.hiddenInCraftingForm then
			local required = tonumber(slot.quantityRequired) or 0
			if required > 0 then
				local itemID = ChooseReagent(slot, function(candidateID) return ItemCount(candidateID) end)
				if not itemID then return 0 end
				requirements[itemID] = (requirements[itemID] or 0) + required
			end
		end
	end
	local maximum, hasRequiredReagent = math.huge, false
	for itemID, required in pairs(requirements) do
		hasRequiredReagent = true
		maximum = math.min(maximum, math.floor(ItemCount(itemID) / required))
	end
	if not hasRequiredReagent then return 1 end
	return math.max(0, maximum)
end

function Planner:SetProfessionCraftQuantity(recipeID, quantity)
	local frame = _G.ProfessionsFrame
	local page = frame and frame.CraftingPage
	local form = page and page.SchematicForm
	if not page or not form or self:GetSelectedRecipeID() ~= recipeID then return false end
	quantity = math.max(1, math.floor(tonumber(quantity) or 1))
	local candidates = {
		page.CreateMultipleInputBox,
		page.QuantityInput,
		page.CraftingQuantity,
		form.CreateMultipleInputBox,
		form.QuantityInput,
	}
	for _, control in pairs(candidates) do
		if control then
			if type(control.SetValue) == "function" and pcall(control.SetValue, control, quantity) then return true end
			if type(control.SetNumber) == "function" and pcall(control.SetNumber, control, quantity) then return true end
			local input = control.InputBox or control.EditBox
			if input and type(input.SetNumber) == "function" and pcall(input.SetNumber, input, quantity) then return true end
			if input and type(input.SetText) == "function" and pcall(input.SetText, input, tostring(quantity)) then return true end
		end
	end
	return false
end

function Planner:LoadNext()
	for _, entry in ipairs(self.Queue) do
		if entry.kind == "recipe" then
			local craftable = self:GetCraftableCount(entry.recipeID)
			if craftable > 0 then
				local amount = math.min(math.max(1, tonumber(entry.count) or 1), craftable)
				C_TradeSkillUI.OpenRecipe(entry.recipeID)
				for _, delay in ipairs({ 0, 0.08, 0.20 }) do
					C_Timer.After(delay, function() Planner:SetProfessionCraftQuantity(entry.recipeID, amount) end)
				end
				SlickUI:Print(string.format("Loaded %s and prepared %d craft%s. The queue will update after each successful craft.", entry.name, amount, amount == 1 and "" or "s"))
				return
			end
		end
	end
	SlickUI:Print("No queued recipe is ready yet. Acquire the missing materials shown in the queue or Shopping List.")
end

function Planner:CompleteQueuedCraft(recipeID)
	recipeID = tonumber(recipeID)
	if not recipeID then return end
	for index, group in ipairs(self.Groups) do
		if group.recipeID == recipeID then
			group.count = math.max(0, (tonumber(group.count) or 1) - 1)
			if group.count == 0 then
				table.remove(self.Groups, index)
				SlickUI:Print((group.name or RecipeName(recipeID)) .. " completed and removed from the Crafting Queue.")
			else
				SlickUI:Print(string.format("%s completed; %d still queued.", group.name or RecipeName(recipeID), group.count))
			end
			self:RefreshQueue()
			return
		end
	end
end

function Planner:RefreshQueue()
	if SafeCall(C_TradeSkillUI.IsTradeSkillReady) then
		for _, entry in ipairs(ShoppingList()) do SyncPendingMail(entry) end
		self:BuildRecipeIndex()
		wipe(self.Queue)
		local sharedStock = {}
		for _, group in ipairs(self.Groups) do
			local rebuilt = self:BuildPlan(group.recipeID, group.count, sharedStock)
			if #rebuilt > 0 then
				group.plan = rebuilt
				local root = rebuilt[#rebuilt]
				group.itemID = root and root.itemID or group.itemID
				group.name = root and root.name or group.name
			end
			for _, entry in ipairs(group.plan or {}) do self.Queue[#self.Queue + 1] = entry end
		end
		self:CreateShoppingList(true)
		self:SaveQueue()
	end
	self:RefreshRows()
	self:RefreshShoppingRows()
end

function Planner:RemoveGroup(group)
	if type(group) ~= "table" then return end
	for index, queuedGroup in ipairs(self.Groups) do
		if queuedGroup == group then
			local name = queuedGroup.name or RecipeName(queuedGroup.recipeID)
			table.remove(self.Groups, index)
			self:RefreshQueue()
			SlickUI:Print((name or "Crafting item") .. " removed from the Crafting Queue.")
			return
		end
	end
end

function Planner:AdjustGroupQuantity(group, delta)
	if type(group) ~= "table" then return end
	local quantity = math.max(0, math.min(999, (tonumber(group.count) or 1) + (tonumber(delta) or 0)))
	if quantity <= 0 then
		self:RemoveGroup(group)
		return
	end
	group.count = quantity
	self:RefreshQueue()
end

function Planner:BuildDisplayEntries()
	local display = {}
	for _, group in ipairs(self.Groups) do
		display[#display + 1] = {
			kind = "group",
			recipeID = group.recipeID,
			itemID = group.itemID,
			name = group.name,
			count = group.count,
			depth = 0,
			group = group,
			collapsible = true,
			collapsed = group.collapsed,
		}
		if not group.collapsed then
			local hiddenBelowDepth
			for planIndex = #group.plan - 1, 1, -1 do
				local entry = group.plan[planIndex]
				local depth = tonumber(entry.depth) or 0
				if hiddenBelowDepth and depth <= hiddenBelowDepth then hiddenBelowDepth = nil end
				if not hiddenBelowDepth then
					local view = {
						kind = entry.kind,
						recipeID = entry.recipeID,
						itemID = entry.itemID,
						name = entry.name,
						count = entry.count,
						yield = entry.yield,
						depth = depth,
						group = group,
						planEntry = entry,
						buyInstead = entry.buyInstead,
						buyCost = entry.buyCost,
						craftCost = entry.craftCost,
						collapsible = entry.kind == "recipe",
						planIndex = planIndex,
						collapsed = group.collapsedEntries[planIndex] == true,
					}
					display[#display + 1] = view
					if view.collapsible and view.collapsed then hiddenBelowDepth = depth end
				end
			end
		end
	end
	return display
end

function Planner:RefreshRows()
	if not self.Window then return end
	for _, row in ipairs(self.Rows) do row:Hide() end
	local display = self:BuildDisplayEntries()
	for index, entry in ipairs(display) do
		local row = self.Rows[index]
		if not row then
			row = CreateFrame("Button", nil, self.Window.List)
			row:SetHeight(30)
			row:SetPoint("TOPLEFT", 0, -((index - 1) * 31))
			row:SetPoint("RIGHT", 0, 0)
			row.Minus = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
			row.Minus:SetSize(26, 22)
			row.Minus:SetPoint("LEFT", 0, 0)
			row.Minus:SetText("-")
			row.Minus:RegisterForClicks("LeftButtonUp")
			row.Minus:SetScript("OnClick", function(button)
				local data = button:GetParent().EntryData
				if data and data.kind == "group" then Planner:AdjustGroupQuantity(data.group, -1) end
			end)
			row.Plus = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
			row.Plus:SetSize(26, 22)
			row.Plus:SetPoint("LEFT", row.Minus, "RIGHT", 2, 0)
			row.Plus:SetText("+")
			row.Plus:RegisterForClicks("LeftButtonUp")
			row.Plus:SetScript("OnClick", function(button)
				local data = button:GetParent().EntryData
				if data and data.kind == "group" then Planner:AdjustGroupQuantity(data.group, 1) end
			end)
			row.Remove = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
			row.Remove:SetSize(26, 22)
			row.Remove:SetPoint("LEFT", row.Plus, "RIGHT", 2, 0)
			row.Remove:SetText("X")
			row.Remove:RegisterForClicks("LeftButtonUp")
			row.Remove:SetScript("OnEnter", function(button)
				GameTooltip:SetOwner(button, "ANCHOR_RIGHT")
				GameTooltip:SetText("Remove from Crafting Queue")
				GameTooltip:AddLine("Removes this item and recalculates its required materials.", 1, 1, 1)
				GameTooltip:Show()
			end)
			row.Remove:SetScript("OnLeave", GameTooltip_Hide)
			row.Remove:SetScript("OnClick", function(button)
				local data = button:GetParent().EntryData
				if data and data.kind == "group" then Planner:RemoveGroup(data.group) end
			end)
			row.Icon = row:CreateTexture(nil, "ARTWORK")
			row.Icon:SetSize(26, 26)
			row.Icon:SetPoint("LEFT", 2, 0)
			row.IconHit = CreateFrame("Button", nil, row)
			row.IconHit:SetAllPoints(row.Icon)
			row.IconHit:RegisterForClicks("LeftButtonUp", "RightButtonUp")
			row.IconHit:SetScript("OnEnter", function(button)
				local data = button:GetParent().EntryData
				if not data or not data.itemID then return end
				GameTooltip:SetOwner(button, "ANCHOR_RIGHT")
				GameTooltip:SetItemByID(data.itemID)
				if data.recipeID then GameTooltip:AddLine("Right-click to load this recipe", 1, 0.82, 0.2) end
				AddPriceHistoryTooltip(GameTooltip, data.itemID)
				GameTooltip:Show()
			end)
			row.IconHit:SetScript("OnLeave", GameTooltip_Hide)
			row.IconHit:SetScript("OnClick", function(button, mouseButton)
				button:GetParent():Click(mouseButton)
			end)
			row.Text = row:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall")
			row.Text:SetPoint("TOPLEFT", row.Icon, "TOPRIGHT", 7, -1)
			row.Text:SetPoint("RIGHT", -4, 0)
			row.Text:SetJustifyH("LEFT")
			row.Source = row:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall")
			row.Source:SetPoint("BOTTOMLEFT", row.Icon, "BOTTOMRIGHT", 7, 1)
			row:RegisterForClicks("LeftButtonUp", "RightButtonUp")
			row:SetScript("OnEnter", function(self)
				local data = self.EntryData
				if not data then return end
				GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
				GameTooltip:SetText(data.name or "Crafting Queue")
				if data.collapsible then
					GameTooltip:AddLine(data.collapsed and "Left-click to expand" or "Left-click to collapse", 1, 0.82, 0.2)
				end
				if data.recipeID then
					GameTooltip:AddLine("Right-click to load recipe for crafting", 0.35, 0.85, 1)
				end
				if data.buyInstead then
					GameTooltip:AddLine("Buying this component is cheaper than crafting it", 0.35, 0.85, 1)
					GameTooltip:AddDoubleLine("Buy cost", FormatMoney(data.buyCost), 1, 0.82, 0.2, 1, 1, 1)
					GameTooltip:AddDoubleLine("Craft cost", FormatMoney(data.craftCost), 0.35, 0.85, 1, 1, 1, 1)
				end
				if data.kind == "group" then
					local value = Planner:GetGroupProfitability(data.group)
					GameTooltip:AddLine(" ")
					GameTooltip:AddDoubleLine("Full material cost", FormatMoney(value.knownCost), 1, 0.82, 0.2, 1, 1, 1)
					GameTooltip:AddDoubleLine("Vendor portion", FormatMoney(value.vendorCost), 0.35, 0.75, 1, 1, 1, 1)
					GameTooltip:AddDoubleLine("Auction portion", FormatMoney(value.auctionCost), 1, 0.82, 0.2, 1, 1, 1)
					GameTooltip:AddDoubleLine("Missing-items purchase cost", FormatMoney(value.acquisitionCost), 1, 0.65, 0.2, 1, 1, 1)
					if value.outputValue then
						GameTooltip:AddDoubleLine("Gross Auction House value", FormatMoney(value.outputValue), 0.35, 0.85, 1, 1, 1, 1)
						if value.variableYield then
							GameTooltip:AddDoubleLine("Expected output", string.format("%.1f (%d-%d)", value.outputCount, value.minimumOutputCount, value.maximumOutputCount), 0.7, 0.7, 0.7, 1, 1, 1)
						end
						GameTooltip:AddDoubleLine(string.format("Estimated AH fee (%.0f%%)", value.auctionCutPercent or 5), "-" .. FormatMoney(value.auctionFee), 1, 0.65, 0.2, 1, 0.5, 0.35)
						GameTooltip:AddDoubleLine("Net sale value", FormatMoney(value.netSaleValue), 0.35, 0.85, 1, 1, 1, 1)
					end
					if value.profit then
						local positive = value.profit >= 0
						GameTooltip:AddDoubleLine("Estimated net profit", (positive and "+" or "-") .. FormatMoney(math.abs(value.profit)), 0.35, 0.85, 1, positive and 0.35 or 1, positive and 0.85 or 0.35, positive and 0.45 or 0.35)
						GameTooltip:AddDoubleLine("Profit per finished item", (value.profitPerItem >= 0 and "+" or "-") .. FormatMoney(math.abs(value.profitPerItem)), 0.35, 0.85, 1, 1, 1, 1)
						GameTooltip:AddDoubleLine("Net profit margin", string.format("%.1f%%", value.marginPercent or 0), 0.35, 0.85, 1, positive and 0.35 or 1, positive and 0.85 or 0.35, positive and 0.45 or 0.35)
						GameTooltip:AddLine("Listing deposits are not included in this successful-sale estimate.", 0.7, 0.7, 0.7)
					end
					if value.unknown > 0 then GameTooltip:AddLine(string.format("%d material price%s not learned", value.unknown, value.unknown == 1 and "" or "s"), 1, 0.55, 0.25) end
					if value.stale > 0 or value.outputStale then GameTooltip:AddLine("Some Auction House prices are over 24 hours old", 1, 0.65, 0.25) end
				end
				AddPriceHistoryTooltip(GameTooltip, data.itemID)
				GameTooltip:Show()
			end)
			row:SetScript("OnLeave", GameTooltip_Hide)
			row:SetScript("OnClick", function(self, mouseButton)
				local data = self.EntryData
				if mouseButton == "RightButton" then
					if data and data.recipeID then
						C_TradeSkillUI.OpenRecipe(data.recipeID)
						SlickUI:Print("Loaded " .. (data.name or "recipe") .. ". Press Create in the profession window when ready.")
					end
					return
				end
				if not data or not data.collapsible then return end
				if data.kind == "group" then
					data.group.collapsed = not data.group.collapsed
				else
					data.group.collapsedEntries[data.planIndex] = not data.collapsed
				end
				Planner:SaveQueue()
				Planner:RefreshRows()
			end)
			self.Rows[index] = row
		end
		row.EntryData = entry
		row.Minus:SetShown(entry.kind == "group")
		row.Plus:SetShown(entry.kind == "group")
		row.Remove:SetShown(entry.kind == "group")
		row.Icon:ClearAllPoints()
		row.Icon:SetPoint("LEFT", 84 + (math.min(8, tonumber(entry.depth) or 0) * 20), 0)
		row.Icon:SetTexture(entry.itemID and C_Item.GetItemIconByID(entry.itemID) or 134400)
		local isCraft = entry.kind == "recipe" or entry.kind == "group"
		local prefix = isCraft and "CRAFT" or "NEED"
		local color = isCraft and "|cff55dd88" or "|cffff6666"
		local arrow = entry.collapsible and (entry.collapsed and "|cffffd24a+|r " or "|cffffd24a-|r ") or ""
		row.Text:SetText(arrow .. color .. prefix .. "|r  " .. entry.name .. " x" .. entry.count)
		if entry.kind == "group" then
			row.Source:SetText(self:ProfitabilityText(entry.group))
		elseif entry.kind == "material" then
			local valueText = StoredValueText(entry.itemID, entry.count)
			if entry.buyInstead then
				row.Source:SetText("|cff55bfffBuy directly|r  " .. valueText)
			else
				row.Source:SetText(SourceText(entry.itemID) .. "  " .. valueText)
			end
		else
			local outputCount = (tonumber(entry.count) or 1) * math.max(1, tonumber(entry.yield) or 1)
			row.Source:SetText("|cff55dd88Crafted subcomponent|r  " .. StoredValueText(entry.itemID, outputCount))
		end
		row:Show()
	end
	self.Window.Empty:SetShown(#self.Groups == 0)
	self.Window.List:SetHeight(math.max(1, #display * 31))
end

function Planner:SetCollapsed(collapsed)
	if not self.Window then return end
	self.Collapsed = collapsed == true
	self.Window:SetSize(self.Collapsed and 36 or 450, 520)
	for _, region in ipairs(self.Window.CollapsibleRegions or {}) do
		region:SetShown(not self.Collapsed)
	end
	self.Window.CollapseButton:ClearAllPoints()
	if self.Collapsed then
		self.Window.CollapseButton:SetPoint("TOP", 0, -14)
	else
		self.Window.CollapseButton:SetPoint("RIGHT", self.Window.CloseButton, "LEFT", -2, 0)
	end
	self.Window.CollapseButton:SetText(self.Collapsed and ">" or "<")
	self.Window.CollapseButton:SetScript("OnEnter", function(button)
		GameTooltip:SetOwner(button, "ANCHOR_RIGHT")
		GameTooltip:SetText(self.Collapsed and "Expand Crafting Queue" or "Collapse Crafting Queue")
		GameTooltip:Show()
	end)
end

function Planner:ClearQueue()
	wipe(self.Queue)
	wipe(self.Groups)
	wipe(ShoppingList())
	self:SaveQueue()
	self:RefreshRows()
	self:RefreshShoppingRows()
end

function Planner:SortDashboardEntries()
	local key = self.DashboardSortKey or "profit"
	local descending = self.DashboardSortDescending ~= false
	local entries = {}
	for _, entry in pairs(self.DashboardEntries or {}) do
		if type(entry) == "table" then entries[#entries + 1] = entry end
	end
	table.sort(entries, function(left, right)
		if left == right then return false end
		if type(left) ~= "table" then return false end
		if type(right) ~= "table" then return true end
		local a, b = left[key], right[key]
		if a == nil and b ~= nil then return false end
		if b == nil and a ~= nil then return true end
		local leftName, rightName = tostring(left.name or ""), tostring(right.name or "")
		if a == nil and b == nil then return leftName < rightName end
		if a == b then return leftName < rightName end
		if descending then return a > b end
		return a < b
	end)
	self.DashboardEntries = entries
end

local DashboardExpansionNames = {
	"Midnight", "The War Within", "Dragon Isles", "Shadowlands", "Kul Tiran", "Legion", "Draenor",
	"Mists of Pandaria", "Cataclysm", "Northrend", "Outland", "Classic",
}

local DashboardExpansionAliases = {
	["dragonflight"] = "Dragon Isles", ["battle for azeroth"] = "Kul Tiran",
	["warlords of draenor"] = "Draenor",
}

local function NormalizeDashboardExpansion(expansion)
	local name = tostring(expansion or "Other")
	if name == "Other" then return "Classic" end
	return DashboardExpansionAliases[string.lower(name)] or name
end

function Planner:GetRecipeExpansion(recipeID, recipeInfo)
	local info = recipeInfo or SafeCall(C_TradeSkillUI.GetRecipeInfo, recipeID)
	local categoryID = info and tonumber(info.categoryID)
	local visited = {}
	while categoryID and categoryID > 0 and not visited[categoryID] do
		visited[categoryID] = true
		local category = SafeCall(C_TradeSkillUI.GetCategoryInfo, categoryID)
		local name = category and tostring(category.name or "") or ""
		local lowered = string.lower(name)
		for _, expansion in ipairs(DashboardExpansionNames) do
			if string.find(lowered, string.lower(expansion), 1, true) then return expansion end
		end
		for alias, expansion in pairs(DashboardExpansionAliases) do
			if string.find(lowered, alias, 1, true) then return expansion end
		end
		categoryID = category and tonumber(category.parentCategoryID)
	end
	return "Classic"
end

function Planner:GetVisibleDashboardEntries()
	local visible = {}
	for _, entry in ipairs(self.DashboardEntries or {}) do
		local expansionMatches = self:IsDashboardExpansionSelected(entry.expansion)
		local profitMatches = not self.DashboardProfitableOnly or (entry.profit and entry.profit > 0)
		if expansionMatches and profitMatches then visible[#visible + 1] = entry end
	end
	return visible
end

function Planner:GetDashboardExpansions()
	local found, choices = {}, {}
	for _, entry in ipairs(self.DashboardEntries or {}) do
		local expansion = NormalizeDashboardExpansion(entry.expansion)
		if not found[expansion] then found[expansion] = true; choices[#choices + 1] = expansion end
	end
	table.sort(choices, function(left, right)
		local leftIndex, rightIndex = 999, 999
		for index, name in ipairs(DashboardExpansionNames) do
			if name == left then leftIndex = index end
			if name == right then rightIndex = index end
		end
		if leftIndex == rightIndex then return left < right end
		return leftIndex < rightIndex
	end)
	return choices
end

function Planner:IsDashboardExpansionSelected(expansion)
	local selected = self.DashboardSelectedExpansions
	return type(selected) ~= "table" or not next(selected) or selected[NormalizeDashboardExpansion(expansion)] == true
end

function Planner:GetDashboardExpansionButtonText()
	local selected, count, only = self.DashboardSelectedExpansions, 0
	for expansion, enabled in pairs(selected or {}) do
		if enabled then count, only = count + 1, expansion end
	end
	if count == 0 then return "All Expansions" end
	if count == 1 then return only end
	return count .. " Expansions"
end

function Planner:RefreshDashboardExpansionButton()
	if self.DashboardWindow and self.DashboardWindow.ExpansionButton then
		self.DashboardWindow.ExpansionButton:SetText(self:GetDashboardExpansionButtonText())
	end
end

function Planner:SetDashboardExpansionChecked(expansion, checked)
	self.DashboardSelectedExpansions = self.DashboardSelectedExpansions or {}
	expansion = NormalizeDashboardExpansion(expansion)
	self.DashboardSelectedExpansions[expansion] = checked and true or nil
	self.DashboardOffset = 0
	self:RefreshDashboardExpansionButton()
	self:RefreshDashboardRows()
end

function Planner:SelectAllDashboardExpansions()
	self.DashboardSelectedExpansions = {}
	self.DashboardOffset = 0
	self:RefreshDashboardExpansionButton()
	self:RefreshDashboardRows()
end

function Planner:OpenDashboardExpansionMenu(owner)
	if not MenuUtil or type(MenuUtil.CreateContextMenu) ~= "function" then return end
	local choices = self:GetDashboardExpansions()
	MenuUtil.CreateContextMenu(owner, function(_, root)
		root:SetTag("SLICKUI_CRAFTING_DASHBOARD_EXPANSION")
		root:CreateTitle("Select Expansion")
		root:CreateCheckbox("All Expansions",
			function() return not Planner.DashboardSelectedExpansions or not next(Planner.DashboardSelectedExpansions) end,
			function() Planner:SelectAllDashboardExpansions() end)
		for _, expansion in ipairs(choices) do
			local selected = expansion
			root:CreateCheckbox(selected,
				function() return Planner.DashboardSelectedExpansions and Planner.DashboardSelectedExpansions[selected] == true end,
				function() Planner:SetDashboardExpansionChecked(selected, not (Planner.DashboardSelectedExpansions and Planner.DashboardSelectedExpansions[selected])) end)
		end
	end)
end

function Planner:RefreshDashboardRows()
	local window = self.DashboardWindow
	if not window then return end
	local entries = self:GetVisibleDashboardEntries()
	local maximum = math.max(0, #entries - #window.Rows)
	self.DashboardOffset = math.max(0, math.min(maximum, tonumber(self.DashboardOffset) or 0))
	for rowIndex, row in ipairs(window.Rows) do
		local entry = entries[self.DashboardOffset + rowIndex]
		row.Entry = entry
		if entry then
			local ledger = SlickUI.CraftingSalesLedger
			if ledger then ledger:RegisterItem(entry.itemID, entry.name, self.DashboardProfessionID, self.DashboardProfessionName, entry.expansion) end
			row.Icon:SetTexture(C_Item.GetItemIconByID(entry.itemID) or 134400)
			row.Name:SetText(entry.name)
			row.Cost:SetText(entry.cost and FormatMoney(entry.cost) or "--")
			row.Net:SetText(entry.netValue and FormatMoney(entry.netValue) or "--")
			if entry.profit then
				row.Profit:SetText((entry.profit >= 0 and "|cff55dd88+" or "|cffff6666-") .. FormatMoney(math.abs(entry.profit)) .. "|r")
				row.Margin:SetText(string.format("%.1f%%", entry.margin or 0))
			else
				row.Profit:SetText("--"); row.Margin:SetText("--")
			end
			local inventory, auctions, total, target = 0, 0, 0, 3
			if ledger then inventory, auctions, total, target = ledger:GetStockInfo(entry.itemID) end
			row.Inventory:SetText(inventory)
			row.Auctions:SetText(auctions)
			row.Stock:SetText(string.format(total >= target and "|cff55dd88%d/%d|r" or "|cffffaa44%d/%d|r", total, target))
			local record = ledger and ledger:GetRecord(entry.itemID, true)
			row.Posts:SetText(record and (tonumber(record.posts) or 0) or 0)
			row.Status:SetText(entry.status)
			row:Show()
		else
			row:Hide()
		end
	end
	window.Page:SetText(string.format("Showing %d-%d of %d", math.min(#entries, self.DashboardOffset + 1), math.min(#entries, self.DashboardOffset + #window.Rows), #entries))
end

local CraftingProfessionSkillLines = {
	[164] = true, -- Blacksmithing
	[165] = true, -- Leatherworking
	[171] = true, -- Alchemy
	[185] = true, -- Cooking
	[197] = true, -- Tailoring
	[202] = true, -- Engineering
	[333] = true, -- Enchanting
	[755] = true, -- Jewelcrafting
	[773] = true, -- Inscription
}

function Planner:GetDashboardCache()
	SlickUI.DB.crafting = SlickUI.DB.crafting or {}
	SlickUI.DB.crafting.dashboardCache = SlickUI.DB.crafting.dashboardCache or {}
	return SlickUI.DB.crafting.dashboardCache
end

function Planner:SaveDashboardCache()
	local professionID = tonumber(self.DashboardProfessionID)
	if not professionID or type(self.DashboardEntries) ~= "table" then return end
	local entries = {}
	for _, entry in ipairs(self.DashboardEntries) do
		entries[#entries + 1] = {
			recipeID = tonumber(entry.recipeID), itemID = tonumber(entry.itemID), name = entry.name,
			cost = tonumber(entry.cost), netValue = tonumber(entry.netValue), profit = tonumber(entry.profit),
			margin = tonumber(entry.margin), status = entry.status, expansion = entry.expansion,
		}
	end
	self:GetDashboardCache()[tostring(professionID)] = {
		name = self.DashboardProfessionName or "Profession",
		updated = time(),
		entries = entries,
	}
	SlickUI.DB.crafting.dashboardProfessionID = professionID
	SlickUI.DB.crafting.dashboardProfessionName = self.DashboardProfessionName
end

function Planner:LoadDashboardCache(professionID)
	professionID = tonumber(professionID)
	local cached = professionID and self:GetDashboardCache()[tostring(professionID)]
	if not cached or type(cached.entries) ~= "table" then return false end
	local entries = {}
	for _, entry in ipairs(cached.entries) do
		if type(entry) == "table" and entry.recipeID and entry.itemID then
			entries[#entries + 1] = {
				recipeID = entry.recipeID, itemID = entry.itemID, name = entry.name or ItemName(entry.itemID),
				cost = entry.cost, netValue = entry.netValue, profit = entry.profit,
				margin = entry.margin, status = entry.status or "Cached", expansion = NormalizeDashboardExpansion(entry.expansion),
			}
		end
	end
	self.DashboardProfessionID = professionID
	self.DashboardProfessionName = cached.name or SlickUI.DB.crafting.dashboardProfessionName or "Profession"
	self.DashboardEntries = entries
	if SlickUI.CraftingSalesLedger then
		for _, entry in ipairs(entries) do SlickUI.CraftingSalesLedger:RegisterItem(entry.itemID, entry.name, professionID, self.DashboardProfessionName, entry.expansion) end
	end
	self.DashboardOffset = 0
	self:SortDashboardEntries()
	if self.DashboardWindow then
		self.DashboardWindow.ProfessionButton:SetText(self.DashboardProfessionName)
		self.DashboardWindow.Status:SetText(string.format("%d cached recipes. Open profession to refresh.", #entries))
		self:RefreshDashboardRows()
	end
	SlickUI.DB.crafting.dashboardProfessionID = professionID
	SlickUI.DB.crafting.dashboardProfessionName = self.DashboardProfessionName
	return true
end

function Planner:GetDashboardProfessions()
	local choices = {}
	for _, professionIndex in pairs({ GetProfessions() }) do
		if professionIndex then
			local name, icon, _, _, _, _, skillLineID = GetProfessionInfo(professionIndex)
			if name and skillLineID and CraftingProfessionSkillLines[skillLineID] then
				choices[#choices + 1] = { name = name, icon = icon, skillLineID = skillLineID, cached = self:GetDashboardCache()[tostring(skillLineID)] ~= nil }
			end
		end
	end
	table.sort(choices, function(left, right) return left.name < right.name end)
	return choices
end

function Planner:CaptureDashboardProfession()
	if not SafeCall(C_TradeSkillUI.IsTradeSkillReady) then return end
	local baseInfo = SafeCall(C_TradeSkillUI.GetBaseProfessionInfo)
	local childInfo = SafeCall(C_TradeSkillUI.GetChildProfessionInfo)
	local professionID = baseInfo and tonumber(baseInfo.professionID)
		or childInfo and (tonumber(childInfo.parentProfessionID) or tonumber(childInfo.professionID))
	local professionName = baseInfo and baseInfo.professionName
		or childInfo and (childInfo.parentProfessionName or childInfo.professionName)
	if not professionID then return end
	if self.DashboardProfessionID ~= professionID then self.DashboardSelectedExpansions = {} end
	self.DashboardProfessionID = professionID
	self.DashboardProfessionName = professionName or "Current Profession"
	SlickUI.DB.crafting.dashboardProfessionID = self.DashboardProfessionID
	SlickUI.DB.crafting.dashboardProfessionName = self.DashboardProfessionName
	if self.DashboardWindow and self.DashboardWindow.ProfessionButton then
		self.DashboardWindow.ProfessionButton:SetText(self.DashboardProfessionName)
		self:RefreshDashboardExpansionButton()
	end
end

function Planner:GetLoadedProfessionID()
	if not SafeCall(C_TradeSkillUI.IsTradeSkillReady) then return nil end
	local baseInfo = SafeCall(C_TradeSkillUI.GetBaseProfessionInfo)
	local childInfo = SafeCall(C_TradeSkillUI.GetChildProfessionInfo)
	return baseInfo and tonumber(baseInfo.professionID)
		or childInfo and (tonumber(childInfo.parentProfessionID) or tonumber(childInfo.professionID))
end

function Planner:SelectDashboardProfession(choice)
	if not choice or not choice.skillLineID then return end
	self.DashboardProfessionID, self.DashboardProfessionName = choice.skillLineID, choice.name
	self.DashboardSelectedExpansions = {}
	if self.DashboardWindow and self.DashboardWindow.ProfessionButton then
		self.DashboardWindow.ProfessionButton:SetText(choice.name)
		self:RefreshDashboardExpansionButton()
		self.DashboardWindow.Status:SetText("Opening " .. choice.name .. "...")
	end
	if self:LoadDashboardCache(choice.skillLineID) then return end
	local opened = SafeCall(C_TradeSkillUI.OpenTradeSkill, choice.skillLineID)
	if not opened then
		SlickUI:Print("Could not open " .. choice.name .. ". Try opening it from the Professions window once.")
	end
end

function Planner:OpenDashboardProfessionMenu(owner)
	if not MenuUtil or type(MenuUtil.CreateContextMenu) ~= "function" then
		SlickUI:Print("WoW's profession menu system is unavailable.")
		return
	end
	local choices = self:GetDashboardProfessions()
	MenuUtil.CreateContextMenu(owner, function(_, root)
		root:SetTag("SLICKUI_CRAFTING_DASHBOARD_PROFESSION")
		root:CreateTitle("Select Profession")
		if #choices == 0 then
			root:CreateTitle("No crafting professions found")
			return
		end
		for _, choice in ipairs(choices) do
			local selected = choice
			root:CreateRadio(selected.name .. (selected.cached and "  |cff55dd88(cached)|r" or ""),
				function() return Planner.DashboardProfessionID == selected.skillLineID end,
				function() Planner:SelectDashboardProfession(selected) end)
		end
	end)
end

function Planner:SetDashboardSort(key)
	if self.DashboardSortKey == key then self.DashboardSortDescending = not self.DashboardSortDescending else self.DashboardSortKey, self.DashboardSortDescending = key, true end
	self.DashboardOffset = 0
	self:SortDashboardEntries()
	self:RefreshDashboardRows()
end

function Planner:BuildAuctionScanQueue()
	if not next(self.RecipeByItem) then self:BuildRecipeIndex() end
	local found, queue, visiting = {}, {}, {}
	local function AddItem(itemID)
		itemID = tonumber(itemID)
		if not itemID or itemID <= 0 or found[itemID] then return end
		found[itemID] = true
		queue[#queue + 1] = { itemID = itemID, name = ItemName(itemID) }
	end
	local function VisitRecipe(recipeID, depth)
		if depth > 8 or visiting[recipeID] then return end
		local outputID, _, schematic = RecipeOutput(recipeID)
		if not schematic then return end
		visiting[recipeID] = true
		AddItem(outputID)
		for _, slot in ipairs(schematic.reagentSlotSchematics or {}) do
			if slot.required ~= false and not slot.hiddenInCraftingForm then
				local itemID = ChooseReagent(slot)
				if itemID then
					AddItem(itemID)
					local subRecipe = self.RecipeByItem[itemID]
					if subRecipe and subRecipe ~= recipeID then VisitRecipe(subRecipe, depth + 1) end
				end
			end
		end
		visiting[recipeID] = nil
	end
	for _, recipeID in ipairs(SafeCall(C_TradeSkillUI.GetAllRecipeIDs) or {}) do
		local info = SafeCall(C_TradeSkillUI.GetRecipeInfo, recipeID)
		local expansionMatches = self:IsDashboardExpansionSelected(self:GetRecipeExpansion(recipeID, info))
		if info and info.learned and expansionMatches then VisitRecipe(recipeID, 0) end
	end
	table.sort(queue, function(left, right) return tostring(left.name) < tostring(right.name) end)
	return queue
end

function Planner:UpdateAuctionScanProgress(message)
	local window, scan = self.DashboardWindow, self.AuctionScan
	if not window or not window.ScanProgress then return end
	if not scan then
		window.ScanProgress:SetMinMaxValues(0, 1)
		window.ScanProgress:SetValue(0)
		window.ScanProgress.Text:SetText(message or "Ready to scan")
		window.ScanButton:SetText("Scan AH")
		return
	end
	local total = math.max(1, #scan.queue)
	local completed = math.min(total, scan.completed or 0)
	window.ScanProgress:SetMinMaxValues(0, total)
	window.ScanProgress:SetValue(completed)
	local current = scan.current and scan.current.name
	window.ScanProgress.Text:SetText(message or string.format("%d / %d%s", completed, total, current and (" - " .. current) or ""))
	window.ScanButton:SetText("Stop")
end

function Planner:StopAuctionScan(message, completed)
	local scan = self.AuctionScan
	if not scan then return end
	self.AuctionScan = nil
	self.AuctionScanGeneration = (self.AuctionScanGeneration or 0) + 1
	self:UpdateAuctionScanProgress(message or "Scan stopped")
	if completed then
		self:RefreshDashboardData()
		SlickUI:Print(string.format("Auction scan complete: %d items checked, %d prices updated.", scan.completed or 0, scan.updated or 0))
	end
end

function Planner:FinishAuctionScanItem(unitPrice)
	local scan = self.AuctionScan
	if not scan or not scan.current then return end
	if tonumber(unitPrice) and unitPrice > 0 and SlickUI.CraftingAuctionData then
		SlickUI.CraftingAuctionData:Observe(scan.current.itemID, unitPrice, "dashboard-scan")
		scan.updated = (scan.updated or 0) + 1
	end
	scan.completed = (scan.completed or 0) + 1
	scan.current = nil
	scan.waiting = false
	self:UpdateAuctionScanProgress()
	C_Timer.After(0.05, function() Planner:ContinueAuctionScan() end)
end

function Planner:ContinueAuctionScan()
	local scan = self.AuctionScan
	if not scan or scan.waiting then return end
	if not (_G.AuctionHouseFrame and AuctionHouseFrame:IsShown()) then
		self:StopAuctionScan("Auction House closed")
		return
	end
	if C_AuctionHouse.IsThrottledMessageSystemReady and not C_AuctionHouse.IsThrottledMessageSystemReady() then
		self:UpdateAuctionScanProgress("Waiting for Auction House...")
		return
	end
	local entry = scan.queue[scan.completed + 1]
	if not entry then
		self:StopAuctionScan("Scan complete", true)
		return
	end
	local itemKey = SafeCall(C_AuctionHouse.MakeItemKey, entry.itemID)
	if not itemKey then
		scan.completed = scan.completed + 1
		self:UpdateAuctionScanProgress("Skipped " .. entry.name)
		C_Timer.After(0, function() Planner:ContinueAuctionScan() end)
		return
	end
	scan.current, scan.itemKey, scan.waiting = entry, itemKey, true
	self:UpdateAuctionScanProgress()
	local generation, itemID = scan.generation, entry.itemID
	local sent = pcall(C_AuctionHouse.SendSearchQuery, itemKey, {
		{ sortOrder = Enum.AuctionHouseSortOrder.Price, reverseSort = false },
	}, false)
	if not sent then
		scan.waiting = false
		self:UpdateAuctionScanProgress("Waiting for Auction House...")
		return
	end
	C_Timer.After(10, function()
		local active = Planner.AuctionScan
		if active and active.generation == generation and active.waiting and active.current and active.current.itemID == itemID then
			Planner:FinishAuctionScanItem(nil)
		end
	end)
end

function Planner:StartAuctionScan()
	if self.AuctionScan then
		self:StopAuctionScan("Scan stopped")
		return
	end
	if not (_G.AuctionHouseFrame and AuctionHouseFrame:IsShown()) then
		SlickUI:Print("Open the Auction House before starting a dashboard scan.")
		return
	end
	local selectedProfessionID = tonumber(self.DashboardProfessionID)
	if not selectedProfessionID then
		SlickUI:Print("Select a profession before starting a dashboard scan.")
		return
	end
	if self:GetLoadedProfessionID() ~= selectedProfessionID then
		self.PendingAuctionScanProfessionID = selectedProfessionID
		if self.DashboardWindow then self.DashboardWindow.Status:SetText("Opening selected profession for scan...") end
		local opened = SafeCall(C_TradeSkillUI.OpenTradeSkill, selectedProfessionID)
		if not opened then
			self.PendingAuctionScanProfessionID = nil
			SlickUI:Print("The selected profession could not be opened for scanning.")
		end
		return
	end
	local queue = self:BuildAuctionScanQueue()
	if #queue == 0 then
		SlickUI:Print("No learned recipe items were available to scan.")
		return
	end
	self.AuctionScanGeneration = (self.AuctionScanGeneration or 0) + 1
	self.AuctionScan = { queue = queue, completed = 0, updated = 0, generation = self.AuctionScanGeneration }
	self:UpdateAuctionScanProgress()
	SlickUI:Print(string.format("Auction scan started for %d profession items.", #queue))
	self:ContinueAuctionScan()
end

function Planner:HandleAuctionScanResults(event, payload)
	local scan = self.AuctionScan
	if not scan or not scan.waiting or not scan.current then return end
	local itemID = event == "COMMODITY_SEARCH_RESULTS_UPDATED" and tonumber(payload) or (type(payload) == "table" and tonumber(payload.itemID))
	if itemID ~= scan.current.itemID then return end
	local price
	if event == "COMMODITY_SEARCH_RESULTS_UPDATED" then
		local result = SafeCall(C_AuctionHouse.GetCommoditySearchResultInfo, itemID, 1)
		price = result and tonumber(result.unitPrice)
	else
		local itemKey = scan.itemKey
		local count = tonumber(SafeCall(C_AuctionHouse.GetNumItemSearchResults, itemKey)) or 0
		for index = 1, count do
			local result = SafeCall(C_AuctionHouse.GetItemSearchResultInfo, itemKey, index)
			local buyout = result and tonumber(result.buyoutAmount)
			if buyout and buyout > 0 then
				local unit = buyout / math.max(1, tonumber(result.quantity) or 1)
				if not price or unit < price then price = unit end
			end
		end
	end
	self:FinishAuctionScanItem(price)
end

function Planner:RefreshDashboardData()
	local window = self.DashboardWindow
	if not window or not SafeCall(C_TradeSkillUI.IsTradeSkillReady) then
		if window then window.Status:SetText("Select a profession to load its recipes.") end
		return
	end
	self:CaptureDashboardProfession()
	self:BuildRecipeIndex()
	local recipeIDs = SafeCall(C_TradeSkillUI.GetAllRecipeIDs) or {}
	self.DashboardEntries = {}
	self.DashboardOffset = 0
	self.DashboardGeneration = (self.DashboardGeneration or 0) + 1
	local generation, index = self.DashboardGeneration, 1
	window.Status:SetText(string.format("Analyzing %d learned recipes...", #recipeIDs))
	local function ProcessBatch()
		if Planner.DashboardGeneration ~= generation or not window:IsShown() then return end
		local last = math.min(#recipeIDs, index + 11)
		for position = index, last do
			local recipeID = recipeIDs[position]
			local info = SafeCall(C_TradeSkillUI.GetRecipeInfo, recipeID)
			local itemID = info and info.learned and RecipeOutput(recipeID)
			if itemID then
				local group = { recipeID = recipeID, itemID = itemID, count = 1, plan = Planner:BuildPlan(recipeID, 1) }
				local value = Planner:GetGroupProfitability(group)
				local status
				if value.unknown > 0 then status = string.format("|cffffaa44%d missing|r", value.unknown)
				elseif not value.outputValue then status = "|cffaaaaaaSale unknown|r"
				elseif value.stale > 0 or value.outputStale then status = "|cffffaa44Old prices|r"
				elseif value.profit and value.profit >= 0 then status = "|cff55dd88Profitable|r"
				else status = "|cffff6666Loss|r" end
				Planner.DashboardEntries[#Planner.DashboardEntries + 1] = {
					recipeID = recipeID, itemID = itemID, name = info.name or RecipeName(recipeID),
					cost = value.unknown == 0 and value.knownCost or nil, netValue = value.netSaleValue,
					profit = value.profit, margin = value.marginPercent, status = status,
					expansion = Planner:GetRecipeExpansion(recipeID, info),
				}
				local dashboardEntry = Planner.DashboardEntries[#Planner.DashboardEntries]
				if SlickUI.CraftingSalesLedger then SlickUI.CraftingSalesLedger:RegisterItem(itemID, dashboardEntry.name, Planner.DashboardProfessionID, Planner.DashboardProfessionName, dashboardEntry.expansion) end
			end
		end
		index = last + 1
		window.Status:SetText(string.format("Analyzing recipes: %d / %d", math.min(last, #recipeIDs), #recipeIDs))
		if index <= #recipeIDs then
			C_Timer.After(0, ProcessBatch)
		else
			Planner:SortDashboardEntries()
			window.Status:SetText(string.format("%d recipes analyzed. Left-click to load; right-click to add.", #Planner.DashboardEntries))
			Planner:RefreshDashboardRows()
			Planner:SaveDashboardCache()
		end
	end
	ProcessBatch()
end

function Planner:RequestDashboardRefresh()
	local professionID = tonumber(self.DashboardProfessionID)
	if not professionID then
		if self.DashboardWindow then self.DashboardWindow.Status:SetText("Select a profession to load its recipes.") end
		return
	end
	if SafeCall(C_TradeSkillUI.IsTradeSkillReady) and self:GetLoadedProfessionID() == professionID then
		self:RefreshDashboardData()
		return
	end
	self.PendingDashboardRefreshProfessionID = professionID
	if self.DashboardWindow then
		self.DashboardWindow.Status:SetText("Opening " .. (self.DashboardProfessionName or "selected profession") .. "...")
	end
	if type(C_TradeSkillUI.OpenTradeSkill) ~= "function" then
		self.PendingDashboardRefreshProfessionID = nil
		if self.DashboardWindow then self.DashboardWindow.Status:SetText("Could not open the selected profession.") end
		SlickUI:Print("Could not open the selected profession. Open it from the Professions window once, then click Refresh.")
		return
	end
	-- OpenTradeSkill normally succeeds without returning a value. Keep the
	-- request pending and let TRADE_SKILL_SHOW confirm that Blizzard is ready.
	SafeCall(C_TradeSkillUI.OpenTradeSkill, professionID)
end

function Planner:CreateDashboardWindow()
	if self.DashboardWindow then return end
	local window = CreateFrame("Frame", "SlickUIRecipeProfitDashboard", UIParent, "BackdropTemplate")
	SlickUI.Theme:RegisterWindowFontControl(window)
	window:SetSize(950, 620); window:SetPoint("CENTER"); window:SetFrameStrata("HIGH")
	window:SetMovable(true); window:EnableMouse(true); window:SetClampedToScreen(true); window:RegisterForDrag("LeftButton")
	window:SetScript("OnDragStart", window.StartMoving); window:SetScript("OnDragStop", window.StopMovingOrSizing)
	window:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1 })
	window:SetBackdropColor(0.015, 0.018, 0.025, 0.97); window:SetBackdropBorderColor(1, 0.58, 0, 1); window:Hide()
	local logo = window:CreateTexture(nil, "ARTWORK"); logo:SetSize(44, 44); logo:SetPoint("TOPLEFT", 10, -8); logo:SetTexture((SlickUI.Theme and SlickUI.Theme.Icon) or "Interface\\AddOns\\SlickUI\\Media\\Icons\\SlickUI_Icon.png")
	local title = window:CreateFontString(nil, "ARTWORK", "GameFontNormalLarge"); title:SetPoint("TOPLEFT", 62, -16); title:SetText("SlickUI Recipe Profit Dashboard")
	local close = CreateFrame("Button", nil, window, "UIPanelCloseButton"); close:SetPoint("TOPRIGHT", -3, -3)
	self.DashboardProfessionID = self.DashboardProfessionID or (SlickUI.DB.crafting and SlickUI.DB.crafting.dashboardProfessionID)
	self.DashboardProfessionName = self.DashboardProfessionName or (SlickUI.DB.crafting and SlickUI.DB.crafting.dashboardProfessionName)
	local profession = CreateFrame("Button", nil, window, "UIPanelButtonTemplate"); profession:SetSize(175, 24); profession:SetPoint("TOPLEFT", 14, -48); profession:SetText(self.DashboardProfessionName or "Select Profession"); profession:SetScript("OnClick", function(self) Planner:OpenDashboardProfessionMenu(self) end); window.ProfessionButton = profession
	local expansion = CreateFrame("Button", nil, window, "UIPanelButtonTemplate"); expansion:SetSize(135, 24); expansion:SetPoint("LEFT", profession, "RIGHT", 8, 0); expansion:SetText(self:GetDashboardExpansionButtonText()); expansion:SetScript("OnClick", function(self) Planner:OpenDashboardExpansionMenu(self) end); window.ExpansionButton = expansion
	local ledgerButton = CreateFrame("Button", nil, window, "UIPanelButtonTemplate"); ledgerButton:SetSize(92, 24); ledgerButton:SetPoint("LEFT", expansion, "RIGHT", 8, 0); ledgerButton:SetText("Sales Ledger"); ledgerButton:SetScript("OnClick", function() if SlickUI.CraftingSalesLedger then SlickUI.CraftingSalesLedger:Toggle(Planner.DashboardProfessionID, Planner.DashboardProfessionName) end end)
	local filter = CreateFrame("Button", nil, window, "UIPanelButtonTemplate"); filter:SetSize(125, 24); filter:SetPoint("TOPRIGHT", -42, -48); filter:SetText("Show Profitable")
	filter:SetScript("OnClick", function(self) Planner.DashboardProfitableOnly = not Planner.DashboardProfitableOnly; self:SetText(Planner.DashboardProfitableOnly and "Show All" or "Show Profitable"); Planner.DashboardOffset = 0; Planner:RefreshDashboardRows() end)
	local refresh = CreateFrame("Button", nil, window, "UIPanelButtonTemplate"); refresh:SetSize(85, 24); refresh:SetPoint("RIGHT", filter, "LEFT", -8, 0); refresh:SetText("Refresh"); refresh:SetScript("OnClick", function() Planner:RequestDashboardRefresh() end)
	local scanButton = CreateFrame("Button", nil, window, "UIPanelButtonTemplate"); scanButton:SetSize(85, 24); scanButton:SetPoint("RIGHT", refresh, "LEFT", -8, 0); scanButton:SetText("Scan AH"); scanButton:SetScript("OnClick", function() Planner:StartAuctionScan() end); window.ScanButton = scanButton
	local progress = CreateFrame("StatusBar", nil, window, "BackdropTemplate"); progress:SetPoint("TOPLEFT", 14, -78); progress:SetPoint("TOPRIGHT", -14, -78); progress:SetHeight(14); progress:SetStatusBarTexture("Interface\\Buttons\\WHITE8X8"); progress:SetStatusBarColor(0.15, 0.65, 1.00, 0.9); progress:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1 }); progress:SetBackdropColor(0.03, 0.04, 0.06, 0.95); progress:SetBackdropBorderColor(0.3, 0.4, 0.55, 1); progress:SetMinMaxValues(0, 1); progress:SetValue(0)
	local progressText = progress:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall"); progressText:SetPoint("CENTER"); progressText:SetText("Select a profession."); progress.Text = progressText; window.ScanProgress = progress; window.Status = progressText
	local header = CreateFrame("Frame", nil, window); header:SetPoint("TOPLEFT", 14, -98); header:SetPoint("TOPRIGHT", -14, -98); header:SetHeight(26)
	local columns = {{"Recipe",0,235,"name"},{"Cost",239,80,"cost"},{"Net Value",322,85,"netValue"},{"Profit",410,85,"profit"},{"Margin",498,58,"margin"},{"Bags/Bank",559,70,nil},{"At AH",632,55,nil},{"Stock",690,65,nil},{"Posts",758,50,nil},{"Status",811,105,nil}}
	for _, column in ipairs(columns) do local button=CreateFrame("Button",nil,header,"UIPanelButtonTemplate"); local sortKey=column[4]; button:SetPoint("LEFT",column[2],0); button:SetSize(column[3],24); button:SetText(column[1]); if sortKey then button:SetScript("OnClick",function() Planner:SetDashboardSort(sortKey) end) end end
	window.Rows = {}
	for index = 1, 16 do
		local row = CreateFrame("Button", nil, window); row:SetPoint("TOPLEFT", 14, -126 - (index - 1) * 28); row:SetPoint("TOPRIGHT", -14, -126 - (index - 1) * 28); row:SetHeight(27); row:RegisterForClicks("LeftButtonUp", "RightButtonUp")
		if index % 2 == 0 then local bg=row:CreateTexture(nil,"BACKGROUND"); bg:SetAllPoints(); bg:SetColorTexture(0.08,0.09,0.12,0.55) end
		row.Icon=row:CreateTexture(nil,"ARTWORK"); row.Icon:SetSize(24,24); row.Icon:SetPoint("LEFT",3,0)
		local function Field(x,width,justify) local text=row:CreateFontString(nil,"ARTWORK","GameFontHighlightSmall"); text:SetPoint("LEFT",x,0); text:SetWidth(width); text:SetJustifyH(justify or "LEFT"); return text end
		row.Name=Field(32,203); row.Cost=Field(239,80,"RIGHT"); row.Net=Field(322,85,"RIGHT"); row.Profit=Field(410,85,"RIGHT"); row.Margin=Field(498,58,"RIGHT"); row.Inventory=Field(559,70,"RIGHT"); row.Auctions=Field(632,55,"RIGHT"); row.Stock=Field(690,65,"RIGHT"); row.Posts=Field(758,50,"RIGHT"); row.Status=Field(811,105,"RIGHT")
		row:SetScript("OnClick",function(self,button) if not self.Entry then return end; if button=="RightButton" then Planner:QueueRecipe(self.Entry.recipeID,1); SlickUI:Print(self.Entry.name.." added to the Crafting Queue.") else C_TradeSkillUI.OpenRecipe(self.Entry.recipeID) end end)
		row:SetScript("OnEnter",function(self) if not self.Entry then return end; GameTooltip:SetOwner(self,"ANCHOR_RIGHT"); GameTooltip:SetItemByID(self.Entry.itemID); GameTooltip:AddLine("Left-click to load recipe",1,0.82,0.2); GameTooltip:AddLine("Right-click to add one to the Crafting Queue",0.35,0.85,1); AddPriceHistoryTooltip(GameTooltip,self.Entry.itemID); GameTooltip:Show() end); row:SetScript("OnLeave",GameTooltip_Hide)
		window.Rows[index]=row
	end
	window:EnableMouseWheel(true); window:SetScript("OnMouseWheel",function(_,delta) Planner.DashboardOffset=(Planner.DashboardOffset or 0)-(delta>0 and 3 or -3); Planner:RefreshDashboardRows() end)
	local page=window:CreateFontString(nil,"ARTWORK","GameFontHighlightSmall"); page:SetPoint("BOTTOM",0,18); window.Page=page
	self.DashboardWindow=window
end

function Planner:ToggleDashboard()
	self:CreateDashboardWindow()
	if self.DashboardWindow:IsShown() then
		self.DashboardWindow:Hide()
	else
		self.DashboardWindow:Show()
		if SafeCall(C_TradeSkillUI.IsTradeSkillReady) then
			self:CaptureDashboardProfession()
			self:RefreshDashboardData()
		elseif not self:LoadDashboardCache(self.DashboardProfessionID or (SlickUI.DB.crafting and SlickUI.DB.crafting.dashboardProfessionID)) then
			self:RefreshDashboardData()
		end
	end
end

function Planner:CreateWindow()
	if self.Window then return end
	local window = CreateFrame("Frame", "SlickUICraftingPlanner", UIParent, "BackdropTemplate")
	SlickUI.Theme:RegisterWindowFontControl(window)
	window:SetSize(450, 520)
	window:SetFrameStrata("HIGH")
	window:SetClampedToScreen(true)
	window:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1 })
	window:SetBackdropColor(0.015, 0.018, 0.025, 0.96)
	window:SetBackdropBorderColor(1, 0.58, 0, 1)
	window:Hide()

	local logo = window:CreateTexture(nil, "ARTWORK")
	logo:SetSize(42, 42); logo:SetPoint("TOPLEFT", 8, -7)
	logo:SetTexture((SlickUI.Theme and SlickUI.Theme.Icon) or "Interface\\AddOns\\SlickUI\\Media\\Icons\\SlickUI_Icon.png")
	local title = window:CreateFontString(nil, "ARTWORK", "GameFontNormalLarge")
	title:SetPoint("TOPLEFT", 58, -15)
	title:SetText("SlickUI Crafting Queue")
	local close = CreateFrame("Button", nil, window, "UIPanelCloseButton")
	close:SetPoint("TOPRIGHT", -3, -3)
	window.CloseButton = close
	local dashboard = CreateFrame("Button", nil, window, "UIPanelButtonTemplate")
	dashboard:SetSize(92, 24); dashboard:SetPoint("TOPRIGHT", -72, -9); dashboard:SetText("Dashboard")
	dashboard:SetScript("OnClick", function() Planner:ToggleDashboard() end)
	local collapse = CreateFrame("Button", nil, window, "UIPanelButtonTemplate")
	collapse:SetSize(26, 22); collapse:SetPoint("RIGHT", close, "LEFT", -2, 0); collapse:SetText("<")
	collapse:SetScript("OnClick", function() Planner:SetCollapsed(not Planner.Collapsed) end)
	collapse:SetScript("OnLeave", GameTooltip_Hide)
	window.CollapseButton = collapse
	local selected = window:CreateFontString(nil, "ARTWORK", "GameFontHighlight")
	selected:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -13)
	selected:SetPoint("RIGHT", -16, 0)
	selected:SetJustifyH("LEFT")
	window.Selected = selected

	local minus = CreateFrame("Button", nil, window, "UIPanelButtonTemplate")
	minus:SetSize(26, 22); minus:SetPoint("TOPLEFT", selected, "BOTTOMLEFT", 0, -10); minus:SetText("-")
	local quantity = window:CreateFontString(nil, "ARTWORK", "GameFontHighlight")
	quantity:SetPoint("LEFT", minus, "RIGHT", 10, 0); quantity:SetText("Quantity: 1")
	local plus = CreateFrame("Button", nil, window, "UIPanelButtonTemplate")
	plus:SetSize(26, 22); plus:SetPoint("LEFT", quantity, "RIGHT", 10, 0); plus:SetText("+")
	minus:SetScript("OnClick", function() Planner.SelectedQuantity = math.max(1, Planner.SelectedQuantity - 1); quantity:SetText("Quantity: " .. Planner.SelectedQuantity) end)
	plus:SetScript("OnClick", function() Planner.SelectedQuantity = math.min(999, Planner.SelectedQuantity + 1); quantity:SetText("Quantity: " .. Planner.SelectedQuantity) end)
	local queue = CreateFrame("Button", nil, window, "UIPanelButtonTemplate")
	queue:SetSize(105, 24); queue:SetPoint("TOPRIGHT", selected, "BOTTOMRIGHT", 0, -9); queue:SetText("Add")
	queue:SetScript("OnClick", function() Planner:QueueSelected() end)

	local scroll = CreateFrame("ScrollFrame", nil, window, "UIPanelScrollFrameTemplate")
	-- Center each queued row's minus button beneath the 42px header icon while
	-- leaving the selected-quantity controls in their existing position.
	scroll:SetPoint("TOPLEFT", minus, "BOTTOMLEFT", -42, -12)
	scroll:SetPoint("BOTTOMRIGHT", -32, 52)
	local list = CreateFrame("Frame", nil, scroll)
	list:SetWidth(396); list:SetHeight(1); scroll:SetScrollChild(list); window.List = list
	local empty = list:CreateFontString(nil, "ARTWORK", "GameFontDisable")
	empty:SetPoint("TOP", 0, -35); empty:SetText("Select a recipe and click Add."); window.Empty = empty

	local load = CreateFrame("Button", nil, window, "UIPanelButtonTemplate")
	load:SetSize(120, 25); load:SetPoint("BOTTOMLEFT", 16, 16); load:SetText("Load Next")
	load:SetScript("OnClick", function() Planner:LoadNext() end)
	local refresh = CreateFrame("Button", nil, window, "UIPanelButtonTemplate")
	refresh:SetSize(100, 25); refresh:SetPoint("LEFT", load, "RIGHT", 7, 0); refresh:SetText("Refresh")
	refresh:SetScript("OnClick", function() Planner:RefreshQueue() end)
	local shop = CreateFrame("Button", nil, window, "UIPanelButtonTemplate")
	shop:SetSize(100, 25); shop:SetPoint("BOTTOMRIGHT", -16, 16); shop:SetText("Shopping List")
	shop:SetScript("OnClick", function() Planner:ShowShoppingList() end)
	local clear = CreateFrame("Button", nil, window, "UIPanelButtonTemplate")
	clear:SetSize(90, 25); clear:SetPoint("RIGHT", shop, "LEFT", -7, 0); clear:SetText("Clear Queue")
	clear:SetScript("OnClick", function() Planner:ClearQueue() end)
	refresh:ClearAllPoints(); refresh:SetPoint("RIGHT", clear, "LEFT", -7, 0)
	load:ClearAllPoints(); load:SetPoint("RIGHT", refresh, "LEFT", -7, 0)
	window.CollapsibleRegions = { logo, title, close, dashboard, selected, minus, quantity, plus, queue, scroll, load, refresh, clear, shop }
	self.Window = window
	self:RefreshRows()
	self:SetCollapsed(self.Collapsed)
end

function Planner:Attach()
	if not self:IsEnabled() then return end
	self:CreateWindow()
	local parent = _G.ProfessionsFrame
	self.Window:ClearAllPoints()
	if parent then
		self.Window:SetPoint("TOPLEFT", parent, "TOPRIGHT", 4, 0)
	else
		self.Window:SetPoint("CENTER")
	end
	self.Window:Show()
	self:RefreshSelection()
	self:BuildRecipeIndex()
	self:RefreshQueue()
	if parent and parent.CraftingPage and parent.CraftingPage.SchematicForm and not self.Hooked then
		local form = parent.CraftingPage.SchematicForm
		if type(form.Init) == "function" then
			hooksecurefunc(form, "Init", function(_, recipeInfo)
				if recipeInfo and recipeInfo.recipeID then Planner.SelectedRecipeID = recipeInfo.recipeID end
				Planner:RefreshSelection()
			end)
		end
		self.Hooked = true
	end
end

Planner:RestoreQueue()

function Planner:AttachGlobalItemTooltips()
	if self.GlobalItemTooltipsAttached or not TooltipDataProcessor or not Enum.TooltipDataType.Item then return end
	self.GlobalItemTooltipsAttached = true
	for _, tooltip in ipairs({ GameTooltip, ItemRefTooltip, ShoppingTooltip1, ShoppingTooltip2 }) do
		if tooltip and tooltip.HookScript then
			tooltip:HookScript("OnTooltipCleared", function(owner) owner.SlickUICraftingItemID = nil end)
		end
	end
	TooltipDataProcessor.AddTooltipPostCall(Enum.TooltipDataType.Item, function(tooltip, data)
		local itemID = data and tonumber(data.id)
		if not itemID and tooltip and tooltip.GetItem then
			local _, link = tooltip:GetItem()
			itemID = link and tonumber(GetItemInfoInstant(link))
		end
		if not itemID then return end
		if not next(Planner.RecipeByItem) then Planner:BuildRecipeIndex() end
		AddPriceHistoryTooltip(tooltip, itemID)
	end)
end

Planner:AttachGlobalItemTooltips()

local events = CreateFrame("Frame")
events:RegisterEvent("TRADE_SKILL_SHOW")
events:RegisterEvent("TRADE_SKILL_CLOSE")
events:RegisterEvent("BAG_UPDATE_DELAYED")
events:RegisterEvent("AUCTION_HOUSE_SHOW")
events:RegisterEvent("AUCTION_HOUSE_CLOSED")
events:RegisterEvent("COMMODITY_PURCHASED")
events:RegisterEvent("ITEM_PURCHASED")
events:RegisterEvent("GET_ITEM_INFO_RECEIVED")
events:RegisterEvent("AUCTION_HOUSE_SHOW_COMMODITY_WON_NOTIFICATION")
events:RegisterEvent("TRADE_SKILL_CRAFT_BEGIN")
events:RegisterEvent("TRADE_SKILL_ITEM_CRAFTED_RESULT")
events:RegisterEvent("AUCTION_HOUSE_BROWSE_RESULTS_ADDED")
events:RegisterEvent("AUCTION_HOUSE_BROWSE_RESULTS_UPDATED")
events:RegisterEvent("COMMODITY_SEARCH_RESULTS_RECEIVED")
events:RegisterEvent("COMMODITY_SEARCH_RESULTS_UPDATED")
events:RegisterEvent("ITEM_SEARCH_RESULTS_UPDATED")
events:RegisterEvent("AUCTION_HOUSE_THROTTLED_SYSTEM_READY")
events:RegisterEvent("AUCTION_HOUSE_THROTTLED_MESSAGE_DROPPED")
events:SetScript("OnEvent", function(_, event, ...)
	if not Planner:IsEnabled() then
		if event == "TRADE_SKILL_CLOSE" and Planner.Window then Planner.Window:Hide() end
		if event == "AUCTION_HOUSE_CLOSED" and Planner.ShoppingWindow then Planner.ShoppingWindow:Hide() end
		return
	end
	if event == "TRADE_SKILL_SHOW" then
		C_Timer.After(0, function()
			Planner:Attach()
			Planner:CaptureDashboardProfession()
			local refreshProfessionID = tonumber(Planner.PendingDashboardRefreshProfessionID)
			if refreshProfessionID and Planner:GetLoadedProfessionID() == refreshProfessionID then
				Planner.PendingDashboardRefreshProfessionID = nil
				C_Timer.After(0.1, function() Planner:RefreshDashboardData() end)
			elseif Planner.DashboardWindow and Planner.DashboardWindow:IsShown() then
				Planner:RefreshDashboardData()
			end
			if Planner.PendingAuctionScanProfessionID and Planner:GetLoadedProfessionID() == Planner.PendingAuctionScanProfessionID then
				Planner.PendingAuctionScanProfessionID = nil
				C_Timer.After(0.1, function() Planner:StartAuctionScan() end)
			end
		end)
	elseif event == "TRADE_SKILL_CLOSE" then
		if Planner.Window then Planner.Window:Hide() end
	elseif event == "AUCTION_HOUSE_SHOW" then
		C_Timer.After(0, function() Planner:ShowShoppingList() end)
	elseif event == "AUCTION_HOUSE_CLOSED" then
		if Planner.AuctionScan then Planner:StopAuctionScan("Auction House closed") end
		Planner.PendingAuctionSearch = nil
		Planner.PriceUpdateQueue = nil
		Planner.PriceUpdateIndex = 0
		Planner:RefreshPriceUpdateButton()
		if Planner.ShoppingWindow then Planner.ShoppingWindow:Hide() end
	elseif event == "AUCTION_HOUSE_BROWSE_RESULTS_ADDED" or event == "AUCTION_HOUSE_BROWSE_RESULTS_UPDATED" then
		Planner:ObserveBrowseResults()
		Planner:OpenPendingAuctionResult()
	elseif event == "COMMODITY_SEARCH_RESULTS_RECEIVED" then
		Planner:FillPendingCommodityQuantity()
	elseif event == "COMMODITY_SEARCH_RESULTS_UPDATED" or event == "ITEM_SEARCH_RESULTS_UPDATED" then
		if event == "COMMODITY_SEARCH_RESULTS_UPDATED" then Planner:FillPendingCommodityQuantity() end
		Planner:HandleAuctionScanResults(event, ...)
	elseif event == "AUCTION_HOUSE_THROTTLED_SYSTEM_READY" then
		Planner:ContinueAuctionScan()
	elseif event == "AUCTION_HOUSE_THROTTLED_MESSAGE_DROPPED" then
		if Planner.AuctionScan then
			Planner.AuctionScan.waiting = false
			Planner:UpdateAuctionScanProgress("Query delayed; waiting to retry...")
		end
	elseif event == "COMMODITY_PURCHASED" then
		local itemID, quantity = ...
		Planner:RecordAuctionPurchase(itemID, quantity)
	elseif event == "ITEM_PURCHASED" then
		Planner:RecordAuctionPurchase(...)
	elseif event == "GET_ITEM_INFO_RECEIVED" then
		local itemID, succeeded = ...
		if succeeded ~= false then Planner:RefreshCachedItemName(itemID) end
	elseif event == "AUCTION_HOUSE_SHOW_COMMODITY_WON_NOTIFICATION" then
		local itemName, quantity = ...
		Planner:RecordAuctionPurchaseByName(itemName, quantity)
	elseif event == "TRADE_SKILL_CRAFT_BEGIN" then
		Planner.ActiveCraftRecipeID = ...
	elseif event == "TRADE_SKILL_ITEM_CRAFTED_RESULT" then
		local resultData = ...
		if Planner.ActiveCraftRecipeID and type(resultData) == "table" then
			local recipeID = Planner.ActiveCraftRecipeID
			local outputItemID = SafeCall(tonumber, resultData.itemID)
			if not outputItemID then outputItemID = RecipeOutput(recipeID) end
			local quantity = SafeCall(tonumber, resultData.quantity) or SafeCall(tonumber, resultData.numCrafted) or SafeCall(tonumber, resultData.quantityCrafted) or 1
			if SlickUI.CraftingSalesLedger and outputItemID then
				local recipeInfo = SafeCall(C_TradeSkillUI.GetRecipeInfo, recipeID)
				SlickUI.CraftingSalesLedger:RecordCraft(outputItemID, quantity, recipeInfo and recipeInfo.name or ItemName(outputItemID), Planner.DashboardProfessionID, Planner.DashboardProfessionName, Planner:GetRecipeExpansion(recipeID, recipeInfo))
			end
			Planner:CompleteQueuedCraft(Planner.ActiveCraftRecipeID)
		end
	elseif event == "BAG_UPDATE_DELAYED" then
		if Planner.Window and Planner.Window:IsShown() then Planner:RefreshQueue() end
		if Planner.ShoppingWindow and Planner.ShoppingWindow:IsShown() then Planner:RefreshShoppingRows() end
		if Planner.DashboardWindow and Planner.DashboardWindow:IsShown() then Planner:RefreshDashboardRows() end
	end
end)

if C_TradeSkillUI.IsTradeSkillReady and C_TradeSkillUI.IsTradeSkillReady() then
	C_Timer.After(0, function() Planner:Attach() end)
end

if AuctionHouseFrame and AuctionHouseFrame:IsShown() then
	C_Timer.After(0, function() Planner:ShowShoppingList() end)
end
