-- ------------------------------------------------------------------------------ --
--                                TradeSkillMaster                                --
--                          https://tradeskillmaster.com                          --
--    All Rights Reserved - Detailed license information included with addon.     --
-- ------------------------------------------------------------------------------ --

local _, TSM = ...
local Sniper = TSM.UI.AuctionUI:NewPackage("Sniper")
local L = TSM.Include("Locale").GetTable()
local Delay = TSM.Include("Util.Delay")
local Event = TSM.Include("Util.Event")
local FSM = TSM.Include("Util.FSM")
local Sound = TSM.Include("Util.Sound")
local Money = TSM.Include("Util.Money")
local Log = TSM.Include("Util.Log")
local ItemString = TSM.Include("Util.ItemString")
local ItemInfo = TSM.Include("Service.ItemInfo")
local AuctionScan = TSM.Include("Service.AuctionScan")
local MailTracking = TSM.Include("Service.MailTracking")
local Settings = TSM.Include("Service.Settings")
local AuctionHouseWrapper = TSM.Include("Service.AuctionHouseWrapper")
local PlayerInfo = TSM.Include("Service.PlayerInfo")
local UIElements = TSM.Include("UI.UIElements")
local private = {
	settings = nil,
	fsm = nil,
	selectionFrame = nil,
	hasLastScan = nil,
	contentPath = "selection",
}
local PHASED_TIME = 60
local RETAIL_RESCAN_DELAY = 30



-- ============================================================================
-- Module Functions
-- ============================================================================

function Sniper.OnInitialize()
	private.settings = Settings.NewView()
		:AddKey("global", "auctionUIContext", "sniperScrollingTable")
		:AddKey("global", "sniperOptions", "sniperSound")
	TSM.UI.AuctionUI.RegisterTopLevelPage(L["Sniper"], private.GetSniperFrame, private.OnItemLinked)
	private.FSMCreate()
end



-- ============================================================================
-- Sniper UI
-- ============================================================================

function private.GetSniperFrame()
	TSM.UI.AnalyticsRecordPathChange("auction", "sniper")
	if not private.hasLastScan then
		private.contentPath = "selection"
	end
	return UIElements.New("ViewContainer", "sniper")
		:SetNavCallback(private.GetSniperContentFrame)
		:AddPath("selection")
		:AddPath("scan")
		:SetPath(private.contentPath)
end

function private.GetSniperContentFrame(viewContainer, path)
	private.contentPath = path
	if path == "selection" then
		return private.GetSelectionFrame()
	elseif path == "scan" then
		return private.GetScanFrame()
	else
		error("Unexpected path: "..tostring(path))
	end
end

function private.GetSelectionFrame()
	TSM.UI.AnalyticsRecordPathChange("auction", "sniper", "selection")
	local frame = UIElements.New("Frame", "selection")
		:SetLayout("VERTICAL")
		:SetBackgroundColor("PRIMARY_BG_ALT")
		:AddChildIf(TSM.IsWowClassic(), UIElements.New("Text", "text")
			:SetHeight(20)
			:SetMargin(8, 8, 12, 0)
			:SetFont("BODY_BODY2_MEDIUM")
			:SetJustifyH("CENTER")
			:SetText(L["Start either a 'Buyout' or 'Bid' sniper using the buttons above."])
		)
		:AddChild(UIElements.New("Frame", "buttons")
			:SetLayout("HORIZONTAL")
			:SetHeight(24)
			:SetMargin(8, 8, 12, 12)
			:AddChild(UIElements.New("ActionButton", "buyoutScanBtn")
				:SetMargin(0, TSM.IsWowClassic() and 8 or 0, 0, 0)
				:SetText(L["Run Buyout Sniper"])
				:SetScript("OnClick", private.BuyoutScanButtonOnClick)
			)
			:AddChildIf(TSM.IsWowClassic(), UIElements.New("ActionButton", "bidScanBtn")
				:SetText(L["Run Bid Sniper"])
				:SetScript("OnClick", private.BidScanButtonOnClick)
			)
		)
		:AddChild(UIElements.New("SniperScrollingTable", "auctions")
			:SetSettingsContext(private.settings, "sniperScrollingTable")
		)
		:SetScript("OnHide", private.SelectionFrameOnHide)
	private.selectionFrame = frame
	return frame
end

function private.GetScanFrame()
	TSM.UI.AnalyticsRecordPathChange("auction", "sniper", "scan")
	return UIElements.New("Frame", "scan")
		:SetLayout("VERTICAL")
		:SetBackgroundColor("PRIMARY_BG_ALT")
		:AddChild(UIElements.New("Frame", "header")
			:SetLayout("HORIZONTAL")
			:SetHeight(48)
			:SetPadding(8, 8, 14, 14)
			:AddChild(UIElements.New("ActionButton", "backBtn")
				:SetSize(64, 24)
				:SetMargin(0, 16, 0, 0)
				:SetIcon("iconPack.14x14/Chevron/Right@180")
				:SetText(BACK)
				:SetScript("OnClick", private.BackButtonOnClick)
			)
			:AddChild(UIElements.New("Text", "title")
				:SetFont("BODY_BODY2_MEDIUM")
				:SetJustifyH("CENTER")
			)
			:AddChild(UIElements.New("ActionButton", "restartBtn")
				:SetSize(80, 24)
				:SetText(L["Restart"])
				:SetScript("OnClick", private.RestartButtonOnClick)
			)
		)
		:AddChild(UIElements.New("SniperScrollingTable", "auctions")
			:SetSettingsContext(private.settings, "sniperScrollingTable")
			:SetScript("OnSelectionChanged", private.AuctionsOnSelectionChanged)
			:SetScript("OnRowRemoved", private.AuctionsOnRowRemoved)
		)
		:AddChild(UIElements.New("Texture", "line")
			:SetHeight(2)
			:SetTexture("ACTIVE_BG")
		)
		:AddChild(UIElements.New("Frame", "bottom")
			:SetLayout("HORIZONTAL")
			:SetHeight(40)
			:SetPadding(8)
			:SetBackgroundColor("PRIMARY_BG_ALT")
			:AddChild(UIElements.New("ActionButton", "pauseResumeBtn")
				:SetSize(24, 24)
				:SetMargin(0, 8, 0, 0)
				:SetIcon("iconPack.18x18/PlayPause")
				:SetScript("OnClick", private.PauseResumeBtnOnClick)
			)
			:AddChild(UIElements.New("ProgressBar", "progressBar")
				:SetHeight(24)
				:SetMargin(0, 8, 0, 0)
				:SetProgress(0)
				:SetText(L["Starting Scan..."])
			)
			:AddChild(UIElements.NewNamed("ActionButton", "actionBtn", "TSMSniperBtn")
				:SetSize(165, 24)
				:SetText(BID)
				:SetDisabled(true)
				:DisableClickCooldown(true)
				:SetScript("OnClick", private.ActionButtonOnClick)
			)
		)
		:SetScript("OnUpdate", private.ScanFrameOnUpdate)
		:SetScript("OnHide", private.ScanFrameOnHide)
end



-- ============================================================================
-- Local Script Handlers
-- ============================================================================

function private.OnItemLinked(name, itemLink)
	if private.selectionFrame then
		return false
	end
	private.fsm:ProcessEvent("EV_STOP_CLICKED")
	TSM.UI.AuctionUI.SetOpenPage(L["Browse"])
	TSM.UI.AuctionUI.Shopping.StartItemSearch(itemLink)
	return true
end

function private.SelectionFrameOnHide(frame)
	assert(frame == private.selectionFrame)
	private.selectionFrame = nil
end

function private.StartScanHelper(viewContainer, searchContext)
	if not TSM.UI.AuctionUI.StartingScan(L["Sniper"]) then
		return
	end
	viewContainer:SetPath("scan", true)
	private.fsm:ProcessEvent("EV_START_SCAN", searchContext)
end

function private.BuyoutScanButtonOnClick(button)
	local viewContainer = button:GetParentElement():GetParentElement():GetParentElement()
	local searchContext = TSM.Sniper.BuyoutSearch.GetSearchContext()
	private.StartScanHelper(viewContainer, searchContext)
end

function private.BidScanButtonOnClick(button)
	local viewContainer = button:GetParentElement():GetParentElement():GetParentElement()
	local searchContext = TSM.Sniper.BidSearch.GetSearchContext()
	private.StartScanHelper(viewContainer, searchContext)
end

function private.AuctionsOnSelectionChanged()
	private.fsm:ProcessEvent("EV_AUCTION_SELECTION_CHANGED")
end

function private.AuctionsOnRowRemoved(_, row)
	private.fsm:ProcessEvent("EV_AUCTION_ROW_REMOVED", row)
end

function private.BackButtonOnClick()
	private.fsm:ProcessEvent("EV_STOP_CLICKED")
end

function private.PauseResumeBtnOnClick(button)
	private.fsm:ProcessEvent("EV_PAUSE_RESUME_CLICKED")
end

function private.ActionButtonOnClick(button)
	private.fsm:ProcessEvent("EV_ACTION_CLICKED")
end

function private.RestartButtonOnClick(button)
	if not TSM.UI.AuctionUI.StartingScan(L["Sniper"]) then
		return
	end
	local lastScanType = private.hasLastScan
	local sniperFrame = button:GetParentElement():GetParentElement():GetParentElement()
	private.fsm:ProcessEvent("EV_STOP_CLICKED")
	if lastScanType == "bid" then
		sniperFrame:GetElement("selection.buttons.bidScanBtn"):Click()
	elseif lastScanType == "buyout" then
		sniperFrame:GetElement("selection.buttons.buyoutScanBtn"):Click()
	else
		error("Invalid last scan type: "..tostring(lastScanType))
	end
end

function private.ScanFrameOnUpdate(frame)
	frame:SetScript("OnUpdate", nil)
	private.fsm:ProcessEvent("EV_SCAN_FRAME_SHOWN", frame)
end

function private.ScanFrameOnHide(frame)
	private.fsm:ProcessEvent("EV_SCAN_FRAME_HIDDEN")
end



-- ============================================================================
-- FSM
-- ============================================================================

function private.FSMCreate()
	local fsmContext = {
		scanFrame = nil,
		auctionScan = nil,
		query = nil,
		progress = 0,
		progressText = L["Running Sniper Scan"],
		buttonsDisabled = true,
		findHash = nil,
		findAuction = nil,
		findResult = nil,
		numFound = 0,
		maxQuantity = 0,
		numActioned = 0,
		lastBuyQuantity = 0,
		numConfirmed = 0,
		searchContext = nil,
		scanDone = false,
	}
	Event.Register("CHAT_MSG_SYSTEM", private.FSMMessageEventHandler)
	Event.Register("UI_ERROR_MESSAGE", private.FSMMessageEventHandler)
	if not TSM.IsWowClassic() then
		Event.Register("COMMODITY_PURCHASE_SUCCEEDED", private.FSMBuyoutSuccess)
	end
	Event.Register("AUCTION_HOUSE_CLOSED", function()
		private.fsm:ProcessEvent("EV_AUCTION_HOUSE_CLOSED")
	end)
	AuctionHouseWrapper.RegisterAuctionIdUpdateCallback(function(...)
		private.fsm:ProcessEvent("EV_AUCTION_ID_UPDATE", ...)
	end)
	local function UpdateScanFrame(context)
		if not context.scanFrame or not context.searchContext then
			return
		end
		local actionText = nil
		if context.searchContext:IsBuyoutScan() then
			actionText = BUYOUT
		elseif context.searchContext:IsBidScan() then
			actionText = BID
		else
			error("Invalid scan type")
		end
		local bottom = context.scanFrame:GetElement("bottom")
		bottom:GetElement("actionBtn")
			:SetText(actionText)
			:SetDisabled(context.buttonsDisabled)
		bottom:GetElement("progressBar")
			:SetProgress(context.progress)
			:SetText(context.progressText or "")
		local auctionList = context.scanFrame:GetElement("auctions")
			:SetContext(context.auctionScan)
			:SetAuctionScan(context.auctionScan)
			:SetMarketValueFunction(context.searchContext:GetMarketValueFunc())
		if context.findAuction and not auctionList:GetSelection() then
			auctionList:SetSelection(context.findAuction)
		end
		local title = context.scanFrame:GetElement("header.title")
		if context.scanPausing or auctionList:GetSelection() then
			if context.searchContext:IsBuyoutScan() then
				title:SetText(L["Buyout Sniper Paused"])
			elseif context.searchContext:IsBidScan() then
				title:SetText(L["Bid Sniper Paused"])
			else
				error("Invalid scan type")
			end
		else
			if context.searchContext:IsBuyoutScan() then
				title:SetText(L["Buyout Sniper Running"])
			elseif context.searchContext:IsBidScan() then
				title:SetText(L["Bid Sniper Running"])
			else
				error("Invalid scan type")
			end
		end
		context.scanFrame:Draw()
	end
	local function ScanOnFilterDone(self, filter, numNewResults)
		if numNewResults > 0 then
			Sound.PlaySound(private.settings.sniperSound)
		end
	end
	private.fsm = FSM.New("SNIPER")
		:AddState(FSM.NewState("ST_INIT")
			:SetOnEnter(function(context, searchContext)
				private.hasLastScan = nil
				if context.searchContext then
					context.searchContext:KillThread()
					context.searchContext = nil
				end
				context.progress = 0
				context.progressText = L["Running Sniper Scan"]
				context.buttonsDisabled = true
				context.findHash = nil
				context.findAuction = nil
				context.findResult = nil
				context.numFound = 0
				context.numActioned = 0
				context.lastBuyQuantity = 0
				context.numConfirmed = 0
				if context.auctionScan then
					context.auctionScan:Release()
					context.auctionScan = nil
				end
				if searchContext then
					context.searchContext = searchContext
					return "ST_RUNNING_SCAN"
				elseif context.scanFrame then
					context.scanFrame:GetParentElement():SetPath("selection", true)
					context.scanFrame = nil
				end
				TSM.UI.AuctionUI.EndedScan(L["Sniper"])
			end)
			:AddTransition("ST_INIT")
			:AddTransition("ST_RUNNING_SCAN")
			:AddEventTransition("EV_START_SCAN", "ST_INIT")
		)
		:AddState(FSM.NewState("ST_RUNNING_SCAN")
			:SetOnEnter(function(context)
				context.scanDone = false
				if not context.searchContext then
					private.hasLastScan = nil
				elseif context.searchContext:IsBuyoutScan() then
					private.hasLastScan = "buyout"
				elseif context.searchContext:IsBidScan() then
					private.hasLastScan = "bid"
				else
					error("Invalid scan type")
				end
				if not context.auctionScan then
					context.auctionScan = AuctionScan.GetManager()
						:SetResolveSellers(false)
						:SetScript("OnQueryDone", ScanOnFilterDone)
				end
				if context.scanFrame then
					context.scanFrame:GetElement("bottom.progressBar"):SetProgressIconHidden(false)
				end
				UpdateScanFrame(context)
				context.searchContext:StartThread(private.FSMScanCallback, context.auctionScan)
				if TSM.IsWowClassic() then
					Delay.AfterTime("sniperPhaseDetect", PHASED_TIME, private.FSMPhasedCallback)
				end
			end)
			:SetOnExit(function(context)
				Delay.Cancel("sniperPhaseDetect")
			end)
			:AddTransition("ST_RESULTS")
			:AddTransition("ST_WAITING_FOR_PAUSE")
			:AddTransition("ST_FINDING_AUCTION")
			:AddTransition("ST_INIT")
			:AddEvent("EV_PAUSE_RESUME_CLICKED", function(context)
				return "ST_WAITING_FOR_PAUSE"
			end)
			:AddEvent("EV_SCAN_COMPLETE", function(context)
				local selection = context.scanFrame and context.scanFrame:GetElement("auctions"):GetSelection()
				if selection and selection:IsSubRow() then
					return "ST_FINDING_AUCTION"
				else
					if TSM.IsWowClassic() then
						return "ST_RESULTS"
					else
						-- wait 30 seconds before rescanning to avoid spamming the server with API calls
						context.scanDone = true
						Delay.AfterTime("SNIPER_RESCAN_DELAY", RETAIL_RESCAN_DELAY, private.FSMRescanDelayed)
					end
				end
			end)
			:AddEventTransition("EV_RESCAN_DELAYED", "ST_RESULTS")
			:AddEventTransition("EV_SCAN_FAILED", "ST_INIT")
			:AddEvent("EV_PHASED", function()
				Log.PrintUser(L["You've been phased which has caused the AH to stop working due to a bug on Blizzard's end. Please close and reopen the AH and restart Sniper."])
				return "ST_INIT"
			end)
			:AddEvent("EV_AUCTION_SELECTION_CHANGED", function(context)
				assert(context.scanFrame)
				if context.scanFrame:GetElement("auctions"):GetSelection() then
					if context.scanDone then
						return "ST_RESULTS"
					else
						-- the user selected something, so cancel the current scan
						context.auctionScan:Cancel()
					end
				end
			end)
		)
		:AddState(FSM.NewState("ST_WAITING_FOR_PAUSE")
			:SetOnEnter(function(context)
				context.scanPausing = true
				context.progressText = L["Scan Paused"]
				if context.scanFrame then
					context.scanFrame:GetElement("bottom.progressBar"):SetProgressIconHidden(true)
				end
				UpdateScanFrame(context)
			end)
			:SetOnExit(function(context)
				context.scanPausing = false
				context.progressText = L["Running Sniper Scan"]
				if context.scanFrame then
					context.scanFrame:GetElement("bottom.progressBar"):SetProgressIconHidden(false)
				end
				UpdateScanFrame(context)
			end)
			:AddEvent("EV_PAUSE_RESUME_CLICKED", function(context)
				return "ST_RESULTS"
			end)
			:AddTransition("ST_RESULTS")
			:AddTransition("ST_INIT")
		)
		:AddState(FSM.NewState("ST_RESULTS")
			:SetOnEnter(function(context)
				context.searchContext:KillThread()
				context.findAuction = nil
				context.findResult = nil
				context.numFound = 0
				context.numActioned = 0
				context.lastBuyQuantity = 0
				context.numConfirmed = 0
				context.progress = 0
				context.progressText = L["Running Sniper Scan"]
				context.buttonsDisabled = true
				UpdateScanFrame(context)
				local selection = context.scanFrame and context.scanFrame:GetElement("auctions"):GetSelection()
				if selection and selection:IsSubRow() then
					return "ST_FINDING_AUCTION"
				else
					return "ST_RUNNING_SCAN"
				end
			end)
			:AddTransition("ST_RUNNING_SCAN")
			:AddTransition("ST_AUCTION_FOUND")
			:AddTransition("ST_FINDING_AUCTION")
			:AddTransition("ST_INIT")
		)
		:AddState(FSM.NewState("ST_FINDING_AUCTION")
			:SetOnEnter(function(context)
				assert(context.scanFrame)
				context.findAuction = context.scanFrame:GetElement("auctions"):GetSelection()
				assert(context.findAuction:IsSubRow())
				context.findHash = context.findAuction:GetHashes()
				context.progress = 0
				context.progressText = L["Finding Selected Auction"]
				context.buttonsDisabled = true
				if context.scanFrame then
					context.scanFrame:GetElement("bottom.progressBar"):SetProgressIconHidden(false)
				end
				UpdateScanFrame(context)
				TSM.Shopping.SearchCommon.StartFindAuction(context.auctionScan, context.findAuction, private.FSMFindAuctionCallback, true)
			end)
			:SetOnExit(function(context)
				TSM.Shopping.SearchCommon.StopFindAuction()
			end)
			:AddTransition("ST_RESULTS")
			:AddTransition("ST_FINDING_AUCTION")
			:AddTransition("ST_AUCTION_FOUND")
			:AddTransition("ST_AUCTION_NOT_FOUND")
			:AddTransition("ST_INIT")
			:AddEventTransition("EV_AUCTION_FOUND", "ST_AUCTION_FOUND")
			:AddEventTransition("EV_AUCTION_NOT_FOUND", "ST_AUCTION_NOT_FOUND")
			:AddEvent("EV_AUCTION_SELECTION_CHANGED", function(context)
				assert(context.scanFrame)
				local selection = context.scanFrame and context.scanFrame:GetElement("auctions"):GetSelection()
				if selection and selection:IsSubRow() then
					return "ST_FINDING_AUCTION"
				else
					return "ST_RESULTS"
				end
			end)
			:AddEvent("EV_AUCTION_ROW_REMOVED", function(context, row)
				if not row:IsSubRow() then
					return
				end
				local removingFindAuction = context.findAuction == row
				row:GetResultRow():RemoveSubRow(row)
				context.scanFrame:GetElement("auctions"):UpdateData(true)
				if removingFindAuction then
					return "ST_RESULTS"
				end
			end)
			:AddEvent("EV_SCAN_FRAME_HIDDEN", function(context)
				context.scanFrame = nil
				context.findAuction = nil
				return "ST_RESULTS"
			end)
		)
		:AddState(FSM.NewState("ST_AUCTION_FOUND")
			:SetOnEnter(function(context, result)
				local selection = context.scanFrame:GetElement("auctions"):GetSelection()
				-- update the selection in case the result rows changed
				if context.findHash == selection:GetHashes() then
					context.findAuction = selection
				end
				if TSM.IsWowClassic() then
					context.findResult = result
					context.numFound = #result
				else
					local maxCommodity = context.findAuction:IsCommodity() and context.findAuction:GetResultRow():GetMaxQuantities()
					local numCanBuy = maxCommodity or result
					context.findResult = numCanBuy > 0
					context.numFound = numCanBuy
					context.maxQuantity = maxCommodity or 1
				end
				assert(context.numActioned == 0 and context.numConfirmed == 0)
				return "ST_BIDDING_BUYING"
			end)
			:AddTransition("ST_BIDDING_BUYING")
		)
		:AddState(FSM.NewState("ST_AUCTION_NOT_FOUND")
			:SetOnEnter(function(context)
				local _, rawLink = context.findAuction:GetLinks()
				context.findAuction:GetResultRow():RemoveSubRow(context.findAuction)
				Log.PrintfUser(L["Failed to find auction for %s, so removing it from the results."], rawLink)
				return "ST_RESULTS"
			end)
			:AddTransition("ST_RESULTS")
		)
		:AddState(FSM.NewState("ST_BIDDING_BUYING")
			:SetOnEnter(function(context, numToRemove)
				if numToRemove then
					-- remove the one we just bought
					context.findAuction:DecrementQuantity(numToRemove)
					context.scanFrame:GetElement("auctions"):UpdateData()
					context.findAuction = context.scanFrame and context.scanFrame:GetElement("auctions"):GetSelection()
					if context.findAuction and not context.findAuction:IsSubRow() then
						context.findAuction = nil
					end
				end
				local selection = context.scanFrame and context.scanFrame:GetElement("auctions"):GetSelection()
				if selection and not selection:IsSubRow() then
					selection = nil
				end
				local isPlayer, isHighBidder = false, false
				if selection then
					assert(selection:IsSubRow())
					local ownerStr = selection and selection:GetOwnerInfo() or nil
					isPlayer = PlayerInfo.IsPlayer(ownerStr, true, true, true)
					isHighBidder = select(4, selection:GetBidInfo())
				end
				local auctionSelected = selection and context.findHash == selection:GetHashes()
				local numCanAction = not auctionSelected and 0 or (context.numFound - context.numActioned)
				local numConfirming = context.numActioned - context.numConfirmed
				local progressText = nil
				local actionFormatStr = nil
				if context.searchContext:IsBuyoutScan() then
					actionFormatStr = L["Buy %d / %d"]
				elseif context.searchContext:IsBidScan()  then
					actionFormatStr = L["Bid %d / %d"]
				else
					error("Invalid scan type")
				end
				if numConfirming == 0 and numCanAction == 0 then
					-- we're done bidding/buying and confirming this batch
					return "ST_RESULTS"
				elseif numConfirming == 0 then
					-- we can still bid/buy more
					progressText = format(actionFormatStr, context.numActioned + 1, context.numFound)
				elseif numCanAction == 0 then
					-- we're just confirming
					progressText = format(L["Confirming %d / %d"], context.numConfirmed + 1, context.numFound)
				else
					-- we can bid/buy more while confirming
					progressText = format(actionFormatStr.." ("..L["Confirming %d / %d"]..")", context.numActioned + 1, context.numFound, context.numConfirmed + 1, context.numFound)
				end
				context.progress = context.numConfirmed / context.numFound
				context.progressText = L["Scan Paused"].." - "..progressText
				if numCanAction == 0 or isPlayer or (not TSM.IsWowClassic() and numConfirming > 0) then
					context.buttonsDisabled = true
				else
					if context.searchContext:IsBuyoutScan() then
						context.buttonsDisabled = not AuctionScan.CanBuyout(selection, context.auctionScan)
					elseif context.searchContext:IsBidScan() then
						context.buttonsDisabled = not AuctionScan.CanBid(selection)
					else
						error("Invalid scan type")
					end
				end
				if context.scanFrame then
					context.scanFrame:GetElement("bottom.progressBar")
						:SetProgressIconHidden(context.numConfirmed == context.numActioned)
					context.scanFrame:GetElement("bottom.actionBtn")
						:SetDisabled(isPlayer or (isHighBidder and context.searchContext:IsBidScan()))
						:Draw()
				end
				UpdateScanFrame(context)
			end)
			:AddTransition("ST_BID_BUY_CONFIRMATION")
			:AddTransition("ST_BIDDING_BUYING")
			:AddTransition("ST_PLACING_BID_BUY")
			:AddTransition("ST_CONFIRMING_BID_BUY")
			:AddTransition("ST_RESULTS")
			:AddTransition("ST_INIT")
			:AddEvent("EV_PAUSE_RESUME_CLICKED", function(context)
				context.scanFrame:GetElement("auctions"):SetSelection(nil)
				return "ST_RESULTS"
			end)
			:AddEventTransition("EV_AUCTION_SELECTION_CHANGED", "ST_RESULTS")
			:AddEventTransition("EV_ACTION_CLICKED", "ST_BID_BUY_CONFIRMATION")
			:AddEvent("EV_CONFIRMED", function(context, isBuy, quantity)
				assert(isBuy == context.searchContext:IsBuyoutScan())
				return "ST_PLACING_BID_BUY", quantity
			end)
			:AddEvent("EV_MSG", function(context, msg)
				local _, rawLink = context.findAuction:GetLinks()
				if msg == LE_GAME_ERR_AUCTION_HIGHER_BID or msg == LE_GAME_ERR_ITEM_NOT_FOUND or msg == LE_GAME_ERR_AUCTION_BID_OWN or msg == LE_GAME_ERR_NOT_ENOUGH_MONEY or msg == LE_GAME_ERR_ITEM_MAX_COUNT then
					-- failed to bid/buy an auction
					return "ST_CONFIRMING_BID_BUY", false
				elseif context.searchContext:IsBidScan() and msg == ERR_AUCTION_BID_PLACED then
					-- bid on an auction
					return "ST_CONFIRMING_BID_BUY", true
				elseif context.searchContext:IsBuyoutScan() and msg == format(ERR_AUCTION_WON_S, ItemInfo.GetName(rawLink)) then
					-- bought an auction
					return "ST_CONFIRMING_BID_BUY", true
				end
			end)
			:AddEvent("EV_BUYOUT_SUCCESS", function(context)
				return "ST_CONFIRMING_BID_BUY", true
			end)
			:AddEvent("EV_AUCTION_ID_UPDATE", function(context, oldAuctionId, newAuctionId, newResultInfo)
				if not context.findAuction or select(2, context.findAuction:GetListingInfo()) ~= oldAuctionId then
					return
				end
				context.findAuction:UpdateResultInfo(newAuctionId, newResultInfo)
				context.findHash = context.findAuction:GetHashes()
			end)
		)
		:AddState(FSM.NewState("ST_BID_BUY_CONFIRMATION")
			:SetOnEnter(function(context)
				local selection = context.scanFrame:GetElement("auctions"):GetSelection()
				local index = TSM.IsWowClassic() and context.findResult[#context.findResult] or nil
				if TSM.UI.AuctionUI.BuyUtil.ShowConfirmation(context.scanFrame, selection, context.searchContext:IsBuyoutScan(), context.numConfirmed + 1, context.numFound, context.maxQuantity, private.FSMConfirmationCallback, context.auctionScan, index, true, context.searchContext:GetMarketValueFunc()) then
					return "ST_BIDDING_BUYING"
				else
					local quantity = selection:GetQuantities()
					return "ST_PLACING_BID_BUY", quantity
				end
			end)
			:AddTransition("ST_PLACING_BID_BUY")
			:AddTransition("ST_BIDDING_BUYING")
		)
		:AddState(FSM.NewState("ST_PLACING_BID_BUY")
			:SetOnEnter(function(context, quantity)
				local index = TSM.IsWowClassic() and tremove(context.findResult, #context.findResult) or nil
				assert(not TSM.IsWowClassic() or index)
				local bidBuyout = nil
				if context.searchContext:IsBuyoutScan() then
					bidBuyout = context.findAuction:GetBuyouts()
				elseif context.searchContext:IsBidScan() then
					bidBuyout = context.findAuction:GetRequiredBid()
				else
					error("Invalid scan type")
				end
				local result = context.auctionScan:PlaceBidOrBuyout(index, bidBuyout, context.findAuction, quantity)
				if result then
					MailTracking.RecordAuctionBuyout(ItemString.GetBaseFast(context.findAuction:GetItemString()), quantity)
					context.numActioned = context.numActioned + (TSM.IsWowClassic() and 1 or quantity)
					context.lastBuyQuantity = quantity
				else
					local _, rawLink = context.findAuction:GetLinks()
					if context.searchContext:IsBuyoutScan() then
						Log.PrintfUser(L["Failed to buy auction of %s (x%s) for %s."], rawLink, quantity, Money.ToString(bidBuyout, nil, "OPT_83_NO_COPPER"))
					elseif context.searchContext:IsBidScan() then
						Log.PrintfUser(L["Failed to bid on auction of %s (x%s) for %s."], rawLink, quantity, Money.ToString(bidBuyout, nil, "OPT_83_NO_COPPER"))
					else
						error("Invalid scan type")
					end
				end
				return "ST_BIDDING_BUYING"
			end)
			:AddTransition("ST_BIDDING_BUYING")
		)
		:AddState(FSM.NewState("ST_CONFIRMING_BID_BUY")
			:SetOnEnter(function(context, success)
				if not success then
					local _, rawLink = context.findAuction:GetLinks()
					local quantity = context.findAuction:GetQuantities()
					local bidBuyout = nil
					if context.searchContext:IsBuyoutScan() then
						bidBuyout = context.findAuction:GetBuyouts()
					elseif context.searchContext:IsBidScan() then
						bidBuyout = context.findAuction:GetRequiredBid()
					else
						error("Invalid scan type")
					end
					if context.searchContext:IsBuyoutScan() then
						Log.PrintfUser(L["Failed to buy auction of %s (x%s) for %s."], rawLink, quantity, Money.ToString(bidBuyout, nil, "OPT_83_NO_COPPER"))
					elseif context.searchContext:IsBidScan() then
						Log.PrintfUser(L["Failed to bid on auction of %s (x%s) for %s."], rawLink, quantity, Money.ToString(bidBuyout, nil, "OPT_83_NO_COPPER"))
					else
						error("Invalid scan type")
					end
				end
				context.numConfirmed = context.numConfirmed + (TSM.IsWowClassic() and 1 or context.lastBuyQuantity)
				context.findAuction = context.scanFrame and context.scanFrame:GetElement("auctions"):GetSelection()
				return "ST_BIDDING_BUYING", context.lastBuyQuantity
			end)
			:AddTransition("ST_BIDDING_BUYING")
		)
		:AddDefaultEvent("EV_SCAN_FRAME_SHOWN", function(context, scanFrame)
			context.scanFrame = scanFrame
			UpdateScanFrame(context)
		end)
		:AddDefaultEvent("EV_SCAN_FRAME_HIDDEN", function(context)
			context.scanFrame = nil
			context.findAuction = nil
		end)
		:AddDefaultEventTransition("EV_AUCTION_HOUSE_CLOSED", "ST_INIT")
		:AddDefaultEventTransition("EV_STOP_CLICKED", "ST_INIT")
		:AddDefaultEvent("EV_AUCTION_ROW_REMOVED", function(context, row)
			if not row:IsSubRow() then
				return
			end
			row:GetResultRow():RemoveSubRow(row)
			context.scanFrame:GetElement("auctions"):UpdateData(true)
		end)
		:Init("ST_INIT", fsmContext)
end

function private.FSMMessageEventHandler(_, msg)
	private.fsm:SetLoggingEnabled(false)
	private.fsm:ProcessEvent("EV_MSG", msg)
	private.fsm:SetLoggingEnabled(true)
end

function private.FSMBuyoutSuccess()
	private.fsm:ProcessEvent("EV_BUYOUT_SUCCESS")
end

function private.FSMRescanDelayed()
	private.fsm:ProcessEvent("EV_RESCAN_DELAYED")
end

function private.FSMScanCallback(success)
	if success then
		private.fsm:ProcessEvent("EV_SCAN_COMPLETE")
	else
		private.fsm:ProcessEvent("EV_SCAN_FAILED")
	end
end

function private.FSMPhasedCallback()
	private.fsm:ProcessEvent("EV_PHASED")
end

function private.FSMFindAuctionCallback(result)
	if result then
		private.fsm:ProcessEvent("EV_AUCTION_FOUND", result)
	else
		private.fsm:ProcessEvent("EV_AUCTION_NOT_FOUND")
	end
end

function private.FSMConfirmationCallback(isBuy, quantity)
	private.fsm:ProcessEvent("EV_CONFIRMED", isBuy, quantity)
end
