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

-- This file contains code for scanning the auction house
local _, TSM = ...
local ScanManager = TSM.Init("Service.AuctionScanClasses.ScanManager")
local L = TSM.Include("Locale").GetTable()
local TempTable = TSM.Include("Util.TempTable")
local Log = TSM.Include("Util.Log")
local ItemString = TSM.Include("Util.ItemString")
local Math = TSM.Include("Util.Math")
local ObjectPool = TSM.Include("Util.ObjectPool")
local AuctionHouseWrapper = TSM.Include("Service.AuctionHouseWrapper")
local Threading = TSM.Include("Service.Threading")
local ItemInfo = TSM.Include("Service.ItemInfo")
local Query = TSM.Include("Service.AuctionScanClasses.Query")
local QueryUtil = TSM.Include("Service.AuctionScanClasses.QueryUtil")
local AuctionScanManager = TSM.Include("LibTSMClass").DefineClass("AuctionScanManager")
local private = {
	objectPool = ObjectPool.New("AUCTION_SCAN_MANAGER", AuctionScanManager),
}
-- arbitrary estimate that finishing the browse request is worth 10% of the query's progress
local BROWSE_PROGRESS = 0.1



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

function ScanManager.Get()
	return private.objectPool:Get()
end



-- ============================================================================
-- Class Meta Methods
-- ============================================================================

function AuctionScanManager.__init(self)
	self._resolveSellers = nil
	self._ignoreItemLevel = nil
	self._queries = {}
	self._queriesScanned = 0
	self._queryDidBrowse = false
	self._onProgressUpdateHandler = nil
	self._onQueryDoneHandler = nil
	self._resultsUpdateCallbacks = {}
	self._nextSearchItemFunction = nil
	self._currentSearchChangedCallback = nil
	self._findResult = {}
	self._cancelled = false
	self._shouldPause = false
	self._paused = false
	self._scanQuery = nil
	self._findQuery = nil
	self._numItems = nil
	self._queryCallback = function(query, searchRow)
		for func in pairs(self._resultsUpdateCallbacks) do
			func(self, query, searchRow)
		end
	end
end

function AuctionScanManager._Release(self)
	self._resolveSellers = nil
	self._ignoreItemLevel = nil
	for _, query in ipairs(self._queries) do
		query:Release()
	end
	wipe(self._queries)
	self._queriesScanned = 0
	self._queryDidBrowse = false
	self._onProgressUpdateHandler = nil
	self._onQueryDoneHandler = nil
	wipe(self._resultsUpdateCallbacks)
	self._nextSearchItemFunction = nil
	self._currentSearchChangedCallback = nil
	self._cancelled = false
	self._shouldPause = false
	self._paused = false
	wipe(self._findResult)
	self._scanQuery = nil
	if self._findQuery then
		self._findQuery:Release()
		self._findQuery = nil
	end
	self._numItems = nil
end



-- ============================================================================
-- Public Class Methods
-- ============================================================================

function AuctionScanManager.Release(self)
	self:_Release()
	private.objectPool:Recycle(self)
end

function AuctionScanManager.SetResolveSellers(self, resolveSellers)
	self._resolveSellers = resolveSellers
	return self
end

function AuctionScanManager.SetIgnoreItemLevel(self, ignoreItemLevel)
	self._ignoreItemLevel = ignoreItemLevel
	return self
end

function AuctionScanManager.SetScript(self, script, handler)
	if script == "OnProgressUpdate" then
		self._onProgressUpdateHandler = handler
	elseif script == "OnQueryDone" then
		self._onQueryDoneHandler = handler
	elseif script == "OnCurrentSearchChanged" then
		self._currentSearchChangedCallback = handler
	else
		error("Unknown AuctionScanManager script: "..tostring(script))
	end
	return self
end

function AuctionScanManager.AddResultsUpdateCallback(self, func)
	self._resultsUpdateCallbacks[func] = true
end

function AuctionScanManager.RemoveResultsUpdateCallback(self, func)
	self._resultsUpdateCallbacks[func] = nil
end

function AuctionScanManager.SetNextSearchItemFunction(self, func)
	self._nextSearchItemFunction = func
end

function AuctionScanManager.GetNumQueries(self)
	return #self._queries
end

function AuctionScanManager.QueryIterator(self, offset)
	return private.QueryIteratorHelper, self._queries, offset or 0
end

function AuctionScanManager.NewQuery(self)
	local query = Query.Get()
	self:_AddQuery(query)
	return query
end

function AuctionScanManager.AddItemListQueriesThreaded(self, itemList)
	assert(Threading.IsThreadContext())
	-- remove duplicates
	local usedItems = TempTable.Acquire()
	for i = #itemList, 1, -1 do
		local itemString = itemList[i]
		if usedItems[itemString] then
			tremove(itemList, i)
		end
		usedItems[itemString] = true
	end
	TempTable.Release(usedItems)
	self._numItems = #itemList
	QueryUtil.GenerateThreaded(itemList, private.NewQueryCallback, self)
end

function AuctionScanManager.ScanQueriesThreaded(self)
	assert(Threading.IsThreadContext())
	self._queriesScanned = 0
	self._cancelled = false
	AuctionHouseWrapper.GetAndResetTotalHookedTime()
	self:_NotifyProgressUpdate()

	-- loop through each filter to perform
	local allSuccess = true
	while self._queriesScanned < #self._queries do
		local query = self._queries[self._queriesScanned + 1]
		-- run the browse query
		local querySuccess, numNewResults = self:_ProcessQuery(query)
		if not querySuccess then
			allSuccess = false
			break
		end
		self._queriesScanned = self._queriesScanned + 1
		self:_NotifyProgressUpdate()
		if self._onQueryDoneHandler then
			self:_onQueryDoneHandler(query, numNewResults)
		end
		self:_Pause()
	end

	if allSuccess then
		local hookedTime, topAddon, topTime = AuctionHouseWrapper.GetAndResetTotalHookedTime()
		if hookedTime > 1 and topAddon ~= "Blizzard_AuctionHouseUI" then
			Log.PrintfUser(L["Scan was slowed down by %s seconds by other AH addons (%s seconds by %s)."], Math.Round(hookedTime, 0.1), Math.Round(topTime, 0.1), topAddon)
		end
	end
	return allSuccess
end

function AuctionScanManager.FindAuctionThreaded(self, findSubRow, noSeller)
	assert(Threading.IsThreadContext())
	wipe(self._findResult)
	if TSM.IsWowClassic() then
		return self:_FindAuctionThreaded(findSubRow, noSeller)
	else
		return self:_FindAuctionThreaded83(findSubRow, noSeller)
	end
end

function AuctionScanManager.PrepareForBidOrBuyout(self, index, subRow, noSeller, quantity, itemBuyout)
	if TSM.IsWowClassic() then
		return subRow:EqualsIndex(index, noSeller)
	else
		local itemString = subRow:GetItemString()
		if ItemInfo.IsCommodity(itemString) then
			local future = AuctionHouseWrapper.StartCommoditiesPurchase(ItemString.ToId(itemString), quantity, itemBuyout)
			if not future then
				return false
			end
			return true, future
		else
			return true
		end
	end
end

function AuctionScanManager.PlaceBidOrBuyout(self, index, bidBuyout, subRow, quantity)
	if TSM.IsWowClassic() then
		PlaceAuctionBid("list", index, bidBuyout)
		return true
	else
		local itemString = subRow:GetItemString()
		local future = nil
		if ItemInfo.IsCommodity(itemString) then
			local itemId = ItemString.ToId(itemString)
			future = AuctionHouseWrapper.ConfirmCommoditiesPurchase(itemId, quantity)
		else
			local _, auctionId = subRow:GetListingInfo()
			future = AuctionHouseWrapper.PlaceBid(auctionId, bidBuyout)
			quantity = 1
		end
		if not future then
			return false
		end
		-- TODO: return this future and record the buyout once the future is done
		future:Cancel()
		return true
	end
end

function AuctionScanManager.GetProgress(self)
	local numQueries = self:GetNumQueries()
	if self._queriesScanned == numQueries then
		return 1
	end
	local currentQuery = self._queries[self._queriesScanned + 1]
	local searchProgress = nil
	if not self._queryDidBrowse or TSM.IsWowClassic() then
		searchProgress = 0
	else
		searchProgress = currentQuery:GetSearchProgress() * (1 - BROWSE_PROGRESS) + BROWSE_PROGRESS
	end
	local queryStep = 1 / numQueries
	local progress = min((self._queriesScanned + searchProgress) * queryStep, 1)
	return progress, self._paused
end

function AuctionScanManager.Cancel(self)
	self._cancelled = true
	if self._scanQuery then
		self._scanQuery:CancelBrowseOrSearch()
		self._scanQuery = nil
	end
end

function AuctionScanManager.SetPaused(self, paused)
	self._shouldPause = paused
	if self._scanQuery then
		self._scanQuery:CancelBrowseOrSearch()
		self._scanQuery = nil
	end
end

function AuctionScanManager.GetNumItems(self)
	return self._numItems
end



-- ============================================================================
-- Private Class Methods
-- ============================================================================

function AuctionScanManager._AddQuery(self, query)
	query:SetResolveSellers(self._resolveSellers)
	query:SetCallback(self._queryCallback)
	tinsert(self._queries, query)
end

function AuctionScanManager._IsCancelled(self)
	return self._cancelled
end

function AuctionScanManager._Pause(self)
	if not self._shouldPause then
		return
	end
	self._paused = true
	self:_NotifyProgressUpdate()
	if self._currentSearchChangedCallback then
		self:_currentSearchChangedCallback()
	end
	while self._shouldPause do
		Threading.Yield(true)
	end
	self._paused = false
	self:_NotifyProgressUpdate()
	if self._currentSearchChangedCallback then
		self:_currentSearchChangedCallback()
	end
end

function AuctionScanManager._NotifyProgressUpdate(self)
	if self._onProgressUpdateHandler then
		self:_onProgressUpdateHandler()
	end
end

function AuctionScanManager._ProcessQuery(self, query)
	local prevMaxBrowseId = 0
	for _, row in query:BrowseResultsIterator() do
		prevMaxBrowseId = max(prevMaxBrowseId, row:GetMinBrowseId())
	end

	-- run the browse query
	self._queryDidBrowse = false
	while not self:_DoBrowse(query) do
		if self._shouldPause then
			-- this browse failed due to a pause request, so try again after we're resumed
			self:_Pause()
			-- wipe the browse results since we're going to do another search
			query:WipeBrowseResults()
		else
			return false, 0
		end
	end
	self._queryDidBrowse = true
	self:_NotifyProgressUpdate()

	local numNewResults = 0
	if TSM.IsWowClassic() then
		for _, row in query:BrowseResultsIterator() do
			if row:GetMinBrowseId() > prevMaxBrowseId then
				numNewResults = numNewResults + row:GetNumSubRows()
			end
		end
		return true, numNewResults
	end

	local rows = Threading.AcquireSafeTempTable()
	for baseItemString, row in query:BrowseResultsIterator() do
		rows[baseItemString] = row
	end
	while true do
		local baseItemString, row = nil, nil
		if self._nextSearchItemFunction then
			baseItemString = self._nextSearchItemFunction()
			row = baseItemString and rows[baseItemString]
		end
		if not row then
			baseItemString, row = next(rows)
		end
		if not row then
			break
		end
		rows[baseItemString] = nil
		if self._currentSearchChangedCallback then
			self:_currentSearchChangedCallback(baseItemString)
		end
		-- store all the existing auctionIds so we can see what changed
		local prevAuctionIds = Threading.AcquireSafeTempTable()
		for _, subRow in row:SubRowIterator() do
			local _, auctionId = subRow:GetListingInfo()
			assert(not prevAuctionIds[auctionId])
			prevAuctionIds[auctionId] = true
		end
		-- send the query for this item
		while not self:_DoSearch(query, row) do
			if self._shouldPause then
				-- this search failed due to a pause request, so try again after we're resumed
				self:_Pause()
				-- wipe the search results since we're going to do another search
				row:WipeSearchResults()
			else
				Threading.ReleaseSafeTempTable(prevAuctionIds)
				Threading.ReleaseSafeTempTable(rows)
				return false, numNewResults
			end
		end

		local numSubRows = row:GetNumSubRows()
		for _, subRow in row:SubRowIterator() do
			local _, auctionId = subRow:GetListingInfo()
			if not prevAuctionIds[auctionId] then
				numNewResults = numNewResults + 1
			end
		end
		Threading.ReleaseSafeTempTable(prevAuctionIds)
		if numSubRows == 0 then
			-- remove this row since there are no search results
			query:RemoveResultRow(row)
		end

		self:_NotifyProgressUpdate()
		self:_Pause()
		Threading.Yield()
	end
	Threading.ReleaseSafeTempTable(rows)
	return true, numNewResults
end

function AuctionScanManager._DoBrowse(self, query, ...)
	return self:_DoBrowseSearchHelper(query, query:Browse(...))
end

function AuctionScanManager._DoSearch(self, query, ...)
	return self:_DoBrowseSearchHelper(query, query:Search(...))
end

function AuctionScanManager._DoBrowseSearchHelper(self, query, future)
	if not future then
		return false
	end
	self._scanQuery = query
	local result = Threading.WaitForFuture(future)
	self._scanQuery = nil
	Threading.Yield()
	return result
end

function AuctionScanManager._FindAuctionThreaded(self, row, noSeller)
	self._cancelled = false
	-- make sure we're not in the middle of a query where the results are going to change on us
	Threading.WaitForFunction(CanSendAuctionQuery)

	-- search the current page for the auction
	if self:_FindAuctionOnCurrentPage(row, noSeller) then
		Log.Info("Found on current page")
		return self._findResult
	end

	-- search for the item
	local page, maxPage = 0, nil
	while true do
		-- query the AH
		if self._findQuery then
			self._findQuery:Release()
		end
		local itemString = row:GetItemString()
		local level = ItemInfo.GetMinLevel(itemString)
		local quality = ItemInfo.GetQuality(itemString)
		assert(level and quality)
		self._findQuery = Query.Get()
			:SetStr(ItemInfo.GetName(itemString), true)
			:SetQualityRange(quality, quality)
			:SetLevelRange(level, level)
			:SetClass(ItemInfo.GetClassId(itemString), ItemInfo.GetSubClassId(itemString))
			:SetItems(itemString)
			:SetResolveSellers(not noSeller)
			:SetPage(page)
		local filterSuccess = self:_DoBrowse(self._findQuery)
		if self._findQuery then
			self._findQuery:Release()
			self._findQuery = nil
		end
		if not filterSuccess then
			break
		end
		-- search this page for the row
		if self:_FindAuctionOnCurrentPage(row, noSeller) then
			Log.Info("Found auction (%d)", page)
			return self._findResult
		elseif self:_IsCancelled() then
			break
		end

		local numPages = ceil(select(2, GetNumAuctionItems("list")) / NUM_AUCTION_ITEMS_PER_PAGE)
		local canBeLater = private.FindAuctionCanBeOnLaterPage(row)
		maxPage = maxPage or numPages - 1
		if not canBeLater and page < maxPage then
			maxPage = page
		end
		if canBeLater and page < maxPage then
			Log.Info("Trying next page (%d)", page + 1)
			page = page + 1
		else
			return
		end
	end
end

function AuctionScanManager._FindAuctionOnCurrentPage(self, subRow, noSeller)
	local found = false
	for i = 1, GetNumAuctionItems("list") do
		if subRow:EqualsIndex(i, noSeller) then
			tinsert(self._findResult, i)
			found = true
		end
	end
	return found
end

function AuctionScanManager._FindAuctionThreaded83(self, findSubRow, noSeller)
	assert(findSubRow:IsSubRow())
	self._cancelled = false
	noSeller = noSeller or findSubRow:IsCommodity()

	local row = findSubRow:GetResultRow()
	local findHash, findHashNoSeller = findSubRow:GetHashes()

	if not self:_DoSearch(row:GetQuery(), row, false) then
		return nil
	end
	local result = nil
	-- first try to find a subRow with a full matching hash
	for _, subRow in row:SubRowIterator() do
		local quantity, numAuctions = subRow:GetQuantities()
		local hash = subRow:GetHashes()
		if hash == findHash then
			result = (result or 0) + quantity * numAuctions
		end
	end
	if result then
		return result
	end
	-- next try to find the first subRow with a matching no-seller hash
	local firstHash = nil
	for _, subRow in row:SubRowIterator() do
		local quantity, numAuctions = subRow:GetQuantities()
		local hash, hashNoSeller = subRow:GetHashes()
		if (not firstHash or hash == firstHash) and hashNoSeller == findHashNoSeller then
			firstHash = hash
			result = (result or 0) + quantity * numAuctions
		end
	end
	return result
end



-- ============================================================================
-- Private Helper Functions
-- ============================================================================

function private.NewQueryCallback(query, self)
	self:_AddQuery(query)
end

function private.FindAuctionCanBeOnLaterPage(row)
	local pageAuctions = GetNumAuctionItems("list")
	if pageAuctions == 0 then
		-- there are no auctions on this page, so it cannot be on a later one
		return false
	end
	local _, _, stackSize, _, _, _, _, _, _, buyout, _, _, _, seller, sellerFull = GetAuctionItemInfo("list", pageAuctions)

	local itemBuyout = (buyout > 0) and floor(buyout / stackSize) or 0
	local _, rowItemBuyout = row:GetBuyouts()
	if rowItemBuyout > itemBuyout then
		-- item must be on a later page since it would be sorted after the last auction on this page
		return true
	elseif rowItemBuyout < itemBuyout then
		-- item cannot be on a later page since it would be sorted before the last auction on this page
		return false
	end

	local rowStackSize = row:GetQuantities()
	if rowStackSize > stackSize then
		-- item must be on a later page since it would be sorted after the last auction on this page
		return true
	elseif rowStackSize < stackSize then
		-- item cannot be on a later page since it would be sorted before the last auction on this page
		return false
	end

	seller = private.FixSellerName(seller, sellerFull) or "?"
	local rowSeller = row:GetOwnerInfo()
	if rowSeller > seller then
		-- item must be on a later page since it would be sorted after the last auction on this page
		return true
	elseif rowSeller < seller then
		-- item cannot be on a later page since it would be sorted before the last auction on this page
		return false
	end

	-- all the things we are sorting on are the same, so the auction could be on a later page
	return true
end

function private.FixSellerName(seller, sellerFull)
	local realm = GetRealmName()
	if sellerFull and strjoin("-", seller, realm) ~= sellerFull then
		return sellerFull
	else
		return seller
	end
end

function private.QueryIteratorHelper(tbl, index)
	index = index + 1
	if index > #tbl then
		return
	end
	return index, tbl[index]
end