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

local _, TSM = ...
local PostScan = TSM.Auctioning:NewPackage("PostScan")
local L = TSM.Include("Locale").GetTable()
local Database = TSM.Include("Util.Database")
local TempTable = TSM.Include("Util.TempTable")
local SlotId = TSM.Include("Util.SlotId")
local Delay = TSM.Include("Util.Delay")
local Math = TSM.Include("Util.Math")
local Log = TSM.Include("Util.Log")
local Event = TSM.Include("Util.Event")
local ItemString = TSM.Include("Util.ItemString")
local Threading = TSM.Include("Service.Threading")
local ItemInfo = TSM.Include("Service.ItemInfo")
local BagTracking = TSM.Include("Service.BagTracking")
local AuctionHouseWrapper = TSM.Include("Service.AuctionHouseWrapper")
local private = {
	scanThreadId = nil,
	queueDB = nil,
	nextQueueIndex = 1,
	bagDB = nil,
	itemList = {},
	operationDB = nil,
	debugLog = {},
	itemLocation = ItemLocation:CreateEmpty(),
	subRowsTemp = {},
	groupsQuery = nil, --luacheck: ignore 1004 - just stored for GC reasons
	operationsQuery = nil, --luacheck: ignore 1004 - just stored for GC reasons
	isAHOpen = false,
}
local RESET_REASON_LOOKUP = {
	minPrice = "postResetMin",
	maxPrice = "postResetMax",
	normalPrice = "postResetNormal"
}
local ABOVE_MAX_REASON_LOOKUP = {
	minPrice = "postAboveMaxMin",
	maxPrice = "postAboveMaxMax",
	normalPrice = "postAboveMaxNormal",
	none = "postAboveMaxNoPost"
}
local MAX_COMMODITY_STACKS_PER_AUCTION = 40



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

function PostScan.OnInitialize()
	BagTracking.RegisterCallback(private.UpdateOperationDB)
	Event.Register("AUCTION_HOUSE_SHOW", private.AuctionHouseShowHandler)
	Event.Register("AUCTION_HOUSE_CLOSED", private.AuctionHouseClosedHandler)
	private.operationDB = Database.NewSchema("AUCTIONING_OPERATIONS")
		:AddUniqueStringField("autoBaseItemString")
		:AddStringField("firstOperation")
		:Commit()
	private.scanThreadId = Threading.New("POST_SCAN", private.ScanThread)
	private.queueDB = Database.NewSchema("AUCTIONING_POST_QUEUE")
		:AddNumberField("auctionId")
		:AddStringField("itemString")
		:AddStringField("operationName")
		:AddNumberField("bid")
		:AddNumberField("buyout")
		:AddNumberField("itemBuyout")
		:AddNumberField("stackSize")
		:AddNumberField("numStacks")
		:AddNumberField("postTime")
		:AddNumberField("numProcessed")
		:AddNumberField("numConfirmed")
		:AddNumberField("numFailed")
		:AddIndex("auctionId")
		:AddIndex("itemString")
		:Commit()
	-- We maintain our own bag database rather than using the one in BagTracking since we need to be able to remove items
	-- as they are posted, without waiting for bag update events, and control when our DB updates.
	private.bagDB = Database.NewSchema("AUCTIONING_POST_BAGS")
		:AddStringField("itemString")
		:AddNumberField("bag")
		:AddNumberField("slot")
		:AddNumberField("quantity")
		:AddUniqueNumberField("slotId")
		:AddIndex("itemString")
		:AddIndex("slotId")
		:Commit()
	-- create a groups and operations query just to register for updates
	private.groupsQuery = TSM.Groups.CreateQuery()
		:SetUpdateCallback(private.OnGroupsOperationsChanged)
	private.operationsQuery = TSM.Operations.CreateQuery()
		:SetUpdateCallback(private.OnGroupsOperationsChanged)
end

function PostScan.CreateBagsQuery()
	return BagTracking.CreateQueryBagsAuctionable()
		:VirtualField("autoBaseItemString", "string", TSM.Groups.TranslateItemString, "itemString")
		:Distinct("autoBaseItemString")
		:LeftJoin(private.operationDB, "autoBaseItemString")
		:InnerJoin(ItemInfo.GetDBForJoin(), "itemString")
		:OrderBy("name", true)
end

function PostScan.Prepare()
	return private.scanThreadId
end

function PostScan.GetCurrentRow()
	return private.queueDB:NewQuery()
		:Custom(private.NextProcessRowQueryHelper)
		:OrderBy("auctionId", true)
		:GetFirstResultAndRelease()
end

function PostScan.GetStatus()
	return TSM.Auctioning.Util.GetQueueStatus(private.queueDB:NewQuery())
end

function PostScan.DoProcess()
	local result, noRetry = nil, false
	local postRow = PostScan.GetCurrentRow()
	local itemString, stackSize, bid, buyout, itemBuyout, postTime = postRow:GetFields("itemString", "stackSize", "bid", "buyout", "itemBuyout", "postTime")
	local bag, slot = private.GetPostBagSlot(itemString, stackSize)
	if bag then
		local _, bagQuantity = GetContainerItemInfo(bag, slot)
		Log.Info("Posting %s x %d from %d,%d (%d)", itemString, stackSize, bag, slot, bagQuantity or -1)
		if TSM.IsWowClassic() then
			-- need to set the duration in the default UI to avoid Blizzard errors
			AuctionFrameAuctions.duration = postTime
			ClearCursor()
			PickupContainerItem(bag, slot)
			ClickAuctionSellItemButton(AuctionsItemButton, "LeftButton")
			PostAuction(bid, buyout, postTime, stackSize, 1)
			ClearCursor()
			result = true
		else
			bid = Math.Round(bid / stackSize, COPPER_PER_SILVER)
			buyout = Math.Round(buyout / stackSize, COPPER_PER_SILVER)
			itemBuyout = Math.Round(itemBuyout, COPPER_PER_SILVER)
			private.itemLocation:Clear()
			private.itemLocation:SetBagAndSlot(bag, slot)
			local commodityStatus = C_AuctionHouse.GetItemCommodityStatus(private.itemLocation)
			if commodityStatus == Enum.ItemCommodityStatus.Item then
				result = AuctionHouseWrapper.PostItem(private.itemLocation, postTime, stackSize, bid < buyout and bid or nil, buyout)
			elseif commodityStatus == Enum.ItemCommodityStatus.Commodity then
				result = AuctionHouseWrapper.PostCommodity(private.itemLocation, postTime, stackSize, itemBuyout)
			else
				error("Unknown commodity status: "..tostring(itemString))
			end
			if not result then
				Log.Err("Failed to post (%s, %s, %s)", itemString, bag, slot)
			end
		end
	else
		-- we couldn't find this item, so mark this post as failed and we'll try again later
		result = false
		noRetry = slot
		if noRetry then
			Log.PrintfUser(L["Failed to post %sx%d as the item no longer exists in your bags."], ItemInfo.GetLink(itemString), stackSize)
		end
	end
	if result then
		private.DebugLogInsert(itemString, "Posting %d from %d, %d", stackSize, bag, slot)
		if postRow:GetField("numProcessed") + 1 == postRow:GetField("numStacks") then
			-- update the log
			local auctionId = postRow:GetField("auctionId")
			TSM.Auctioning.Log.UpdateRowByIndex(auctionId, "state", "POSTED")
		end
	end
	postRow:SetField("numProcessed", postRow:GetField("numProcessed") + 1)
		:Update()
	postRow:Release()
	return result, noRetry
end

function PostScan.DoSkip()
	local postRow = PostScan.GetCurrentRow()
	local auctionId = postRow:GetField("auctionId")
	local numStacks = postRow:GetField("numStacks")
	postRow:SetField("numProcessed", numStacks)
		:SetField("numConfirmed", numStacks)
		:Update()
	postRow:Release()
	-- update the log
	TSM.Auctioning.Log.UpdateRowByIndex(auctionId, "state", "SKIPPED")
end

function PostScan.HandleConfirm(success, canRetry)
	if not success then
		ClearCursor()
	end

	local confirmRow = private.queueDB:NewQuery()
		:Custom(private.ConfirmRowQueryHelper)
		:OrderBy("auctionId", true)
		:GetFirstResultAndRelease()
	if not confirmRow then
		-- we may have posted something outside of TSM
		return
	end

	private.DebugLogInsert(confirmRow:GetField("itemString"), "HandleConfirm(success=%s) x %d", tostring(success), confirmRow:GetField("stackSize"))
	if canRetry then
		assert(not success)
		confirmRow:SetField("numFailed", confirmRow:GetField("numFailed") + 1)
	end
	confirmRow:SetField("numConfirmed", confirmRow:GetField("numConfirmed") + 1)
		:Update()
	confirmRow:Release()
end

function PostScan.PrepareFailedPosts()
	private.queueDB:SetQueryUpdatesPaused(true)
	local query = private.queueDB:NewQuery()
		:GreaterThan("numFailed", 0)
		:OrderBy("auctionId", true)
	for _, row in query:Iterator() do
		local numFailed, numProcessed, numConfirmed = row:GetFields("numFailed", "numProcessed", "numConfirmed")
		assert(numProcessed >= numFailed and numConfirmed >= numFailed)
		private.DebugLogInsert(row:GetField("itemString"), "Preparing failed (%d, %d, %d)", numFailed, numProcessed, numConfirmed)
		row:SetField("numFailed", 0)
			:SetField("numProcessed", numProcessed - numFailed)
			:SetField("numConfirmed", numConfirmed - numFailed)
			:Update()
	end
	query:Release()
	private.queueDB:SetQueryUpdatesPaused(false)
	private.UpdateBagDB()
end

function PostScan.Reset()
	private.queueDB:Truncate()
	private.nextQueueIndex = 1
	private.bagDB:Truncate()
end

function PostScan.ChangePostDetail(field, value)
	local postRow = PostScan.GetCurrentRow()
	local isCommodity = ItemInfo.IsCommodity(postRow:GetField("itemString"))
	if field == "bid" then
		assert(not isCommodity)
		value = min(max(value, 1), postRow:GetField("buyout"))
	elseif field == "buyout" then
		if not isCommodity and value < postRow:GetField("bid") then
			postRow:SetField("bid", value)
		end
		TSM.Auctioning.Log.UpdateRowByIndex(postRow:GetField("auctionId"), field, value)
	end
	postRow:SetField((field == "buyout" and isCommodity) and "itemBuyout" or field, value)
		:Update()
	postRow:Release()
end



-- ============================================================================
-- Private Helper Functions (General)
-- ============================================================================

function private.AuctionHouseShowHandler()
	private.isAHOpen = true
	private.UpdateOperationDB()
end

function private.AuctionHouseClosedHandler()
	private.isAHOpen = false
end

function private.OnGroupsOperationsChanged()
	Delay.AfterFrame("POST_GROUP_OPERATIONS_CHANGED", 1, private.UpdateOperationDB)
end

function private.UpdateOperationDB()
	if not private.isAHOpen then
		return
	end
	private.operationDB:TruncateAndBulkInsertStart()
	local query = BagTracking.CreateQueryBagsAuctionable()
		:VirtualField("autoBaseItemString", "string", TSM.Groups.TranslateItemString, "itemString")
		:Select("autoBaseItemString")
		:Distinct("autoBaseItemString")
	for _, itemString in query:Iterator() do
		local firstOperation = TSM.Operations.GetFirstOperationByItem("Auctioning", itemString)
		if firstOperation then
			private.operationDB:BulkInsertNewRow(itemString, firstOperation)
		end
	end
	query:Release()
	private.operationDB:BulkInsertEnd()
end



-- ============================================================================
-- Scan Thread
-- ============================================================================

function private.ScanThread(auctionScan, scanContext)
	wipe(private.debugLog)
	auctionScan:SetScript("OnQueryDone", private.AuctionScanOnQueryDone)
	private.UpdateBagDB()

	-- get the state of the player's bags
	local bagCounts = TempTable.Acquire()
	local bagQuery = private.bagDB:NewQuery()
		:Select("itemString", "quantity")
	for _, itemString, quantity in bagQuery:Iterator() do
		bagCounts[itemString] = (bagCounts[itemString] or 0) + quantity
	end
	bagQuery:Release()

	-- generate the list of items we want to scan for
	wipe(private.itemList)
	for itemString, numHave in pairs(bagCounts) do
		private.DebugLogInsert(itemString, "Scan thread has %d", numHave)
		local groupPath = TSM.Groups.GetPathByItem(itemString)
		local contextFilter = scanContext.isItems and itemString or groupPath
		if groupPath and tContains(scanContext, contextFilter) and private.CanPostItem(itemString, groupPath, numHave) then
			tinsert(private.itemList, itemString)
		end
	end
	TempTable.Release(bagCounts)
	if #private.itemList == 0 then
		return
	end
	-- record this search
	TSM.Auctioning.SavedSearches.RecordSearch(scanContext, scanContext.isItems and "postItems" or "postGroups")

	-- run the scan
	auctionScan:AddItemListQueriesThreaded(private.itemList)
	for _, query in auctionScan:QueryIterator() do
		query:SetIsBrowseDoneFunction(private.QueryIsBrowseDoneFunction)
		query:AddCustomFilter(private.QueryBuyoutFilter)
	end
	if not auctionScan:ScanQueriesThreaded() then
		Log.PrintUser(L["TSM failed to scan some auctions. Please rerun the scan."])
	end
end



-- ============================================================================
-- Private Helper Functions for Scanning
-- ============================================================================

function private.UpdateBagDB()
	private.bagDB:TruncateAndBulkInsertStart()
	local query = BagTracking.CreateQueryBagsAuctionable()
		:OrderBy("slotId", true)
		:VirtualField("autoBaseItemString", "string", TSM.Groups.TranslateItemString, "itemString")
		:Select("slotId", "bag", "slot", "autoBaseItemString", "quantity")
	for _, slotId, bag, slot, itemString, quantity in query:Iterator() do
		private.DebugLogInsert(itemString, "Updating bag DB with %d in %d, %d", quantity, bag, slot)
		private.bagDB:BulkInsertNewRow(itemString, bag, slot, quantity, slotId)
	end
	query:Release()
	private.bagDB:BulkInsertEnd()
end

function private.CanPostItem(itemString, groupPath, numHave)
	local hasValidOperation, hasInvalidOperation = false, false
	for _, operationName, operationSettings in TSM.Operations.GroupOperationIterator("Auctioning", groupPath) do
		local isValid, numUsed = private.IsOperationValid(itemString, numHave, operationName, operationSettings)
		if isValid == true then
			assert(numUsed and numUsed > 0)
			numHave = numHave - numUsed
			hasValidOperation = true
		elseif isValid == false then
			hasInvalidOperation = true
		else
			-- we are ignoring this operation
			assert(isValid == nil, "Invalid return value")
		end
	end

	return hasValidOperation and not hasInvalidOperation
end

function private.IsOperationValid(itemString, num, operationName, operationSettings)
	local postCap = TSM.Auctioning.Util.GetPrice("postCap", operationSettings, itemString)
	if not postCap then
		-- invalid postCap setting
		if not TSM.db.global.auctioningOptions.disableInvalidMsg then
			Log.PrintfUser(L["Did not post %s because your post cap (%s) is invalid. Check your settings."], ItemInfo.GetLink(itemString), operationSettings.postCap)
		end
		TSM.Auctioning.Log.AddEntry(itemString, operationName, "invalidItemGroup", "", 0, math.huge)
		return nil
	elseif postCap == 0 then
		-- posting is disabled, so ignore this operation
		TSM.Auctioning.Log.AddEntry(itemString, operationName, "postDisabled", "", 0, math.huge)
		return nil
	end

	local stackSize = nil
	local minPostQuantity = nil
	if not TSM.IsWowClassic() then
		minPostQuantity = 1
	else
		-- check the stack size
		stackSize = TSM.Auctioning.Util.GetPrice("stackSize", operationSettings, itemString)
		if not stackSize then
			-- invalid stackSize setting
			if not TSM.db.global.auctioningOptions.disableInvalidMsg then
				Log.PrintfUser(L["Did not post %s because your stack size (%s) is invalid. Check your settings."], ItemInfo.GetLink(itemString), operationSettings.stackSize)
			end
			TSM.Auctioning.Log.AddEntry(itemString, operationName, "invalidItemGroup", "", 0, math.huge)
			return nil
		end
		local maxStackSize = ItemInfo.GetMaxStack(itemString)
		minPostQuantity = operationSettings.stackSizeIsCap and 1 or stackSize
		if not maxStackSize then
			-- couldn't lookup item info for this item (shouldn't happen)
			if not TSM.db.global.auctioningOptions.disableInvalidMsg then
				Log.PrintfUser(L["Did not post %s because Blizzard didn't provide all necessary information for it. Try again later."], ItemInfo.GetLink(itemString))
			end
			TSM.Auctioning.Log.AddEntry(itemString, operationName, "invalidItemGroup", "", 0, math.huge)
			return false
		elseif maxStackSize < minPostQuantity then
			-- invalid stack size
			return nil
		end
	end

	-- check that we have enough to post
	local keepQuantity = TSM.Auctioning.Util.GetPrice("keepQuantity", operationSettings, itemString)
	if not keepQuantity then
		-- invalid keepQuantity setting
		if not TSM.db.global.auctioningOptions.disableInvalidMsg then
			Log.PrintfUser(L["Did not post %s because your keep quantity (%s) is invalid. Check your settings."], ItemInfo.GetLink(itemString), operationSettings.keepQuantity)
		end
		TSM.Auctioning.Log.AddEntry(itemString, operationName, "invalidItemGroup", "", 0, math.huge)
		return nil
	end
	num = num - keepQuantity
	if num < minPostQuantity then
		-- not enough items to post for this operation
		TSM.Auctioning.Log.AddEntry(itemString, operationName, "postNotEnough", "", 0, math.huge)
		return nil
	end

	-- check the max expires
	local maxExpires = TSM.Auctioning.Util.GetPrice("maxExpires", operationSettings, itemString)
	if not maxExpires then
		-- invalid maxExpires setting
		if not TSM.db.global.auctioningOptions.disableInvalidMsg then
			Log.PrintfUser(L["Did not post %s because your max expires (%s) is invalid. Check your settings."], ItemInfo.GetLink(itemString), operationSettings.maxExpires)
		end
		TSM.Auctioning.Log.AddEntry(itemString, operationName, "invalidItemGroup", "", 0, math.huge)
		return nil
	end
	if maxExpires > 0 then
		local numExpires = TSM.Accounting.Auctions.GetNumExpiresSinceSale(itemString)
		if numExpires and numExpires > maxExpires then
			-- too many expires, so ignore this operation
			TSM.Auctioning.Log.AddEntry(itemString, operationName, "postMaxExpires", "", 0, math.huge)
			return nil
		end
	end

	local errMsg = nil
	local minPrice = TSM.Auctioning.Util.GetPrice("minPrice", operationSettings, itemString)
	local normalPrice = TSM.Auctioning.Util.GetPrice("normalPrice", operationSettings, itemString)
	local maxPrice = TSM.Auctioning.Util.GetPrice("maxPrice", operationSettings, itemString)
	local undercut = TSM.Auctioning.Util.GetPrice("undercut", operationSettings, itemString)
	if not minPrice then
		errMsg = format(L["Did not post %s because your minimum price (%s) is invalid. Check your settings."], ItemInfo.GetLink(itemString), operationSettings.minPrice)
	elseif not maxPrice then
		errMsg = format(L["Did not post %s because your maximum price (%s) is invalid. Check your settings."], ItemInfo.GetLink(itemString), operationSettings.maxPrice)
	elseif not normalPrice then
		errMsg = format(L["Did not post %s because your normal price (%s) is invalid. Check your settings."], ItemInfo.GetLink(itemString), operationSettings.normalPrice)
	elseif not undercut then
		errMsg = format(L["Did not post %s because your undercut (%s) is invalid. Check your settings."], ItemInfo.GetLink(itemString), operationSettings.undercut)
	elseif normalPrice < minPrice then
		errMsg = format(L["Did not post %s because your normal price (%s) is lower than your minimum price (%s). Check your settings."], ItemInfo.GetLink(itemString), operationSettings.normalPrice, operationSettings.minPrice)
	elseif maxPrice < minPrice then
		errMsg = format(L["Did not post %s because your maximum price (%s) is lower than your minimum price (%s). Check your settings."], ItemInfo.GetLink(itemString), operationSettings.maxPrice, operationSettings.minPrice)
	end

	if errMsg then
		if not TSM.db.global.auctioningOptions.disableInvalidMsg then
			Log.PrintUser(errMsg)
		end
		TSM.Auctioning.Log.AddEntry(itemString, operationName, "invalidItemGroup", "", 0, math.huge)
		return false
	else
		local vendorSellPrice = ItemInfo.GetVendorSell(itemString) or 0
		if vendorSellPrice > 0 and minPrice <= vendorSellPrice / 0.95 then
			-- just a warning, not an error
			Log.PrintfUser(L["WARNING: Your minimum price for %s is below its vendorsell price (with AH cut taken into account). Consider raising your minimum price, or vendoring the item."], ItemInfo.GetLink(itemString))
		end
		return true, (TSM.IsWowClassic() and stackSize or 1) * postCap
	end
end

function private.QueryBuyoutFilter(_, row)
	local _, itemBuyout, minItemBuyout = row:GetBuyouts()
	return (itemBuyout and itemBuyout == 0) or (minItemBuyout and minItemBuyout == 0)
end

function private.QueryIsBrowseDoneFunction(query)
	if not TSM.IsWowClassic() then
		return false
	end
	local isDone = true
	for itemString in query:ItemIterator() do
		isDone = isDone and private.QueryIsBrowseDoneForItem(query, itemString)
	end
	return isDone
end

function private.QueryIsBrowseDoneForItem(query, itemString)
	local groupPath = TSM.Groups.GetPathByItem(itemString)
	if not groupPath then
		return true
	end
	local isFilterDone = true
	for _, _, operationSettings in TSM.Operations.GroupOperationIterator("Auctioning", groupPath) do
		if isFilterDone then
			local numBuyouts, minItemBuyout, maxItemBuyout = 0, nil, nil
			for _, subRow in query:ItemSubRowIterator(itemString) do
				local _, itemBuyout = subRow:GetBuyouts()
				local timeLeft = subRow:GetListingInfo()
				if itemBuyout > 0 and timeLeft > operationSettings.ignoreLowDuration then
					numBuyouts = numBuyouts + 1
					minItemBuyout = min(minItemBuyout or math.huge, itemBuyout)
					maxItemBuyout = max(maxItemBuyout or 0, itemBuyout)
				end
			end
			if numBuyouts <= 1 then
				-- there is only one distinct item buyout, so can't stop yet
				isFilterDone = false
			else
				local minPrice = TSM.Auctioning.Util.GetPrice("minPrice", operationSettings, itemString)
				local undercut = TSM.Auctioning.Util.GetPrice("undercut", operationSettings, itemString)
				if not minPrice or not undercut then
					-- the min price or undercut is not valid, so just keep scanning
					isFilterDone = false
				elseif minItemBuyout - undercut <= minPrice then
					local resetPrice = TSM.Auctioning.Util.GetPrice("priceReset", operationSettings, itemString)
					if operationSettings.priceReset == "ignore" or (resetPrice and maxItemBuyout <= resetPrice) then
						-- we need to keep scanning to handle the reset price (always keep scanning for "ignore")
						isFilterDone = false
					end
				end
			end
		end
	end
	return isFilterDone
end

function private.AuctionScanOnQueryDone(_, query)
	for itemString in query:ItemIterator() do
		local groupPath = TSM.Groups.GetPathByItem(itemString)
		if groupPath then
			local numHave = 0
			local bagQuery = private.bagDB:NewQuery()
				:Select("quantity", "bag", "slot")
				:Equal("itemString", itemString)
			for _, quantity, bag, slot in bagQuery:Iterator() do
				numHave = numHave + quantity
				private.DebugLogInsert(itemString, "Filter done and have %d in %d, %d", numHave, bag, slot)
			end
			bagQuery:Release()

			for _, operationName, operationSettings in TSM.Operations.GroupOperationIterator("Auctioning", groupPath) do
				if private.IsOperationValid(itemString, numHave, operationName, operationSettings) then
					local keepQuantity = TSM.Auctioning.Util.GetPrice("keepQuantity", operationSettings, itemString)
					assert(keepQuantity)
					local operationNumHave = numHave - keepQuantity
					if operationNumHave > 0 then
						assert(not next(private.subRowsTemp))
						TSM.Auctioning.Util.GetFilteredSubRows(query, itemString, operationSettings, private.subRowsTemp)
						local reason, numUsed, itemBuyout, seller, auctionId = private.GeneratePosts(itemString, operationName, operationSettings, operationNumHave, private.subRowsTemp)
						wipe(private.subRowsTemp)
						numHave = numHave - (numUsed or 0)
						seller = seller or ""
						auctionId = auctionId or math.huge
						TSM.Auctioning.Log.AddEntry(itemString, operationName, reason, seller, itemBuyout or 0, auctionId)
					end
				end
			end
			assert(numHave >= 0)
		else
			Log.Warn("Item removed from group since start of scan: %s", itemString)
		end
	end
end

function private.GeneratePosts(itemString, operationName, operationSettings, numHave, subRows)
	if numHave == 0 then
		return "postNotEnough"
	end

	local perAuction, maxCanPost = nil, nil
	local postCap = TSM.Auctioning.Util.GetPrice("postCap", operationSettings, itemString)
	if not TSM.IsWowClassic() then
		perAuction = min(postCap, numHave)
		maxCanPost = 1
	else
		local stackSize = TSM.Auctioning.Util.GetPrice("stackSize", operationSettings, itemString)
		local maxStackSize = ItemInfo.GetMaxStack(itemString)
		if stackSize > maxStackSize and not operationSettings.stackSizeIsCap then
			return "postNotEnough"
		end

		perAuction = min(stackSize, maxStackSize)
		maxCanPost = min(floor(numHave / perAuction), postCap)
		if maxCanPost == 0 then
			if operationSettings.stackSizeIsCap then
				perAuction = numHave
				maxCanPost = 1
			else
				-- not enough for single post
				return "postNotEnough"
			end
		end
	end

	local lowestAuction = TempTable.Acquire()
	if not TSM.Auctioning.Util.GetLowestAuction(subRows, itemString, operationSettings, lowestAuction) then
		TempTable.Release(lowestAuction)
		lowestAuction = nil
	end
	local minPrice = TSM.Auctioning.Util.GetPrice("minPrice", operationSettings, itemString)
	local normalPrice = TSM.Auctioning.Util.GetPrice("normalPrice", operationSettings, itemString)
	local maxPrice = TSM.Auctioning.Util.GetPrice("maxPrice", operationSettings, itemString)
	local undercut = TSM.Auctioning.Util.GetPrice("undercut", operationSettings, itemString)
	local resetPrice = TSM.Auctioning.Util.GetPrice("priceReset", operationSettings, itemString)
	local aboveMax = TSM.Auctioning.Util.GetPrice("aboveMax", operationSettings, itemString)

	local reason, bid, buyout, seller, activeAuctions = nil, nil, nil, nil, 0
	if not lowestAuction then
		-- post as many as we can at the normal price
		reason = "postNormal"
		buyout = normalPrice
	elseif lowestAuction.hasInvalidSeller then
		-- we didn't get all the necessary seller info
		Log.PrintfUser(L["The seller name of the lowest auction for %s was not given by the server. Skipping this item."], ItemInfo.GetLink(itemString))
		TempTable.Release(lowestAuction)
		return "invalidSeller"
	elseif lowestAuction.isBlacklist and lowestAuction.isPlayer then
		Log.PrintfUser(L["Did not post %s because you or one of your alts (%s) is on the blacklist which is not allowed. Remove this character from your blacklist."], ItemInfo.GetLink(itemString), lowestAuction.seller)
		TempTable.Release(lowestAuction)
		return "invalidItemGroup"
	elseif lowestAuction.isBlacklist and lowestAuction.isWhitelist then
		Log.PrintfUser(L["Did not post %s because the owner of the lowest auction (%s) is on both the blacklist and whitelist which is not allowed. Adjust your settings to correct this issue."], ItemInfo.GetLink(itemString), lowestAuction.seller)
		TempTable.Release(lowestAuction)
		return "invalidItemGroup"
	elseif lowestAuction.buyout - undercut < minPrice then
		seller = lowestAuction.seller
		if resetPrice then
			-- lowest is below the min price, but there is a reset price
			assert(RESET_REASON_LOOKUP[operationSettings.priceReset], "Unexpected 'below minimum price' setting: "..tostring(operationSettings.priceReset))
			reason = RESET_REASON_LOOKUP[operationSettings.priceReset]
			buyout = resetPrice
			bid = max(bid or buyout * operationSettings.bidPercent, minPrice)
			activeAuctions = TSM.Auctioning.Util.GetPlayerAuctionCount(subRows, itemString, operationSettings, floor(bid), buyout, perAuction)
		elseif lowestAuction.isBlacklist then
			-- undercut the blacklisted player
			reason = "postBlacklist"
			buyout = lowestAuction.buyout - undercut
		else
			-- don't post this item
			TempTable.Release(lowestAuction)
			return "postBelowMin", nil, nil, seller
		end
	elseif lowestAuction.isPlayer or (lowestAuction.isWhitelist and TSM.db.global.auctioningOptions.matchWhitelist) then
		-- we (or a whitelisted play we should match) are lowest, so match the current price and post as many as we can
		activeAuctions = TSM.Auctioning.Util.GetPlayerAuctionCount(subRows, itemString, operationSettings, lowestAuction.bid, lowestAuction.buyout, perAuction)
		if lowestAuction.isPlayer then
			reason = "postPlayer"
		else
			reason = "postWhitelist"
		end
		bid = lowestAuction.bid
		buyout = lowestAuction.buyout
		seller = lowestAuction.seller
	elseif lowestAuction.isWhitelist then
		-- don't undercut a whitelisted player
		seller = lowestAuction.seller
		TempTable.Release(lowestAuction)
		return "postWhitelistNoPost", nil, nil, seller
	elseif (lowestAuction.buyout - undercut) > maxPrice then
		-- we'd be posting above the max price, so resort to the aboveMax setting
		seller = lowestAuction.seller
		if operationSettings.aboveMax == "none" then
			TempTable.Release(lowestAuction)
			return "postAboveMaxNoPost", nil, nil, seller
		end
		assert(ABOVE_MAX_REASON_LOOKUP[operationSettings.aboveMax], "Unexpected 'above max price' setting: "..tostring(operationSettings.aboveMax))
		reason = ABOVE_MAX_REASON_LOOKUP[operationSettings.aboveMax]
		buyout = aboveMax
	else
		-- we just need to do a normal undercut of the lowest auction
		reason = "postUndercut"
		buyout = lowestAuction.buyout - undercut
		seller = lowestAuction.seller
	end
	if reason == "postBlacklist" then
		bid = bid or buyout * operationSettings.bidPercent
	else
		buyout = max(buyout, minPrice)
		bid = max(bid or buyout * operationSettings.bidPercent, minPrice)
	end
	if lowestAuction then
		TempTable.Release(lowestAuction)
	end
	if TSM.IsWowClassic() then
		bid = floor(bid)
	else
		bid = max(Math.Round(bid, COPPER_PER_SILVER), COPPER_PER_SILVER)
		buyout = max(Math.Round(buyout, COPPER_PER_SILVER), COPPER_PER_SILVER)
	end

	bid = min(bid, TSM.IsWowClassic() and MAXIMUM_BID_PRICE or MAXIMUM_BID_PRICE - 99)
	buyout = min(buyout, TSM.IsWowClassic() and MAXIMUM_BID_PRICE or MAXIMUM_BID_PRICE - 99)

	-- check if we can't post anymore
	local queueQuery = private.queueDB:NewQuery()
		:Select("numStacks")
		:Equal("itemString", itemString)
		:Equal("stackSize", perAuction)
		:Equal("itemBuyout", buyout)
	for _, numStacks in queueQuery:Iterator() do
		activeAuctions = activeAuctions + numStacks
	end
	queueQuery:Release()
	if TSM.IsWowClassic() then
		maxCanPost = min(postCap - activeAuctions, maxCanPost)
	else
		perAuction = min(postCap - activeAuctions, perAuction)
	end
	if maxCanPost <= 0 or perAuction <= 0 then
		return "postTooMany"
	end

	if TSM.IsWowClassic() and (bid * perAuction > MAXIMUM_BID_PRICE or buyout * perAuction > MAXIMUM_BID_PRICE) then
		Log.PrintfUser(L["The buyout price for %s would be above the maximum allowed price. Skipping this item."], ItemInfo.GetLink(itemString))
		return "invalidItemGroup"
	end

	-- insert the posts into our DB
	local auctionId = private.nextQueueIndex
	local postTime = operationSettings.duration
	local extraStack = 0
	if TSM.IsWowClassic() then
		private.AddToQueue(itemString, operationName, bid, buyout, perAuction, maxCanPost, postTime)
		-- check if we can post an extra partial stack
		extraStack = (maxCanPost < postCap and operationSettings.stackSizeIsCap and (numHave % perAuction)) or 0
	else
		assert(maxCanPost == 1)
		if ItemInfo.IsCommodity(itemString) then
			local maxPerAuction = ItemInfo.GetMaxStack(itemString) * MAX_COMMODITY_STACKS_PER_AUCTION
			maxCanPost = floor(perAuction / maxPerAuction)
			-- check if we can post an extra partial stack
			extraStack = perAuction % maxPerAuction
			perAuction = min(perAuction, maxPerAuction)
		else
			-- post non-commodities as single stacks
			maxCanPost = perAuction
			perAuction = 1
		end
		assert(maxCanPost > 0 or extraStack > 0)
		if maxCanPost > 0 then
			private.AddToQueue(itemString, operationName, bid, buyout, perAuction, maxCanPost, postTime)
		end
	end
	if extraStack > 0 then
		private.AddToQueue(itemString, operationName, bid, buyout, extraStack, 1, postTime)
	end
	return reason, (perAuction * maxCanPost) + extraStack, buyout, seller, auctionId
end

function private.AddToQueue(itemString, operationName, itemBid, itemBuyout, stackSize, numStacks, postTime)
	private.DebugLogInsert(itemString, "Queued %d stacks of %d", stackSize, numStacks)
	private.queueDB:NewRow()
		:SetField("auctionId", private.nextQueueIndex)
		:SetField("itemString", itemString)
		:SetField("operationName", operationName)
		:SetField("bid", itemBid * stackSize)
		:SetField("buyout", itemBuyout * stackSize)
		:SetField("itemBuyout", itemBuyout)
		:SetField("stackSize", stackSize)
		:SetField("numStacks", numStacks)
		:SetField("postTime", postTime)
		:SetField("numProcessed", 0)
		:SetField("numConfirmed", 0)
		:SetField("numFailed", 0)
		:Create()
	private.nextQueueIndex = private.nextQueueIndex + 1
end



-- ============================================================================
-- Private Helper Functions for Posting
-- ============================================================================

function private.GetPostBagSlot(itemString, quantity)
	-- start with the slot which is closest to the desired stack size
	local bag, slot = private.bagDB:NewQuery()
		:Select("bag", "slot")
		:Equal("itemString", itemString)
		:GreaterThanOrEqual("quantity", quantity)
		:OrderBy("quantity", true)
		:GetFirstResultAndRelease()
	if not bag then
		bag, slot = private.bagDB:NewQuery()
			:Select("bag", "slot")
			:Equal("itemString", itemString)
			:LessThanOrEqual("quantity", quantity)
			:OrderBy("quantity", false)
			:GetFirstResultAndRelease()
	end
	if not bag or not slot then
		-- this item was likely removed from the player's bags, so just give up
		Log.Err("Failed to find initial bag / slot (%s, %d)", itemString, quantity)
		return nil, true
	end
	local removeContext = TempTable.Acquire()
	bag, slot = private.ItemBagSlotHelper(itemString, bag, slot, quantity, removeContext)

	local bagItemString = ItemString.Get(GetContainerItemLink(bag, slot))
	if not bagItemString or TSM.Groups.TranslateItemString(bagItemString) ~= itemString then
		-- something changed with the player's bags so we can't post the item right now
		TempTable.Release(removeContext)
		private.DebugLogInsert(itemString, "Bags changed")
		return nil, nil
	end
	local _, _, _, quality = GetContainerItemInfo(bag, slot)
	assert(quality)
	if quality == -1 then
		-- the game client doesn't have item info cached for this item, so we can't post it yet
		TempTable.Release(removeContext)
		private.DebugLogInsert(itemString, "No item info")
		return nil, nil
	end
	for slotId, removeQuantity in pairs(removeContext) do
		private.RemoveBagQuantity(slotId, removeQuantity)
	end
	TempTable.Release(removeContext)
	private.DebugLogInsert(itemString, "GetPostBagSlot(%d) -> %d, %d", quantity, bag, slot)
	return bag, slot
end

function private.ItemBagSlotHelper(itemString, bag, slot, quantity, removeContext)
	local slotId = SlotId.Join(bag, slot)

	-- try to post completely from the selected slot
	local found = private.bagDB:NewQuery()
		:Select("slotId")
		:Equal("slotId", slotId)
		:GreaterThanOrEqual("quantity", quantity)
		:GetFirstResultAndRelease()
	if found then
		removeContext[slotId] = quantity
		return bag, slot
	end

	-- try to find a stack at a lower slot which has enough to post from
	local foundSlotId, foundBag, foundSlot = private.bagDB:NewQuery()
		:Select("slotId", "bag", "slot")
		:Equal("itemString", itemString)
		:LessThan("slotId", slotId)
		:GreaterThanOrEqual("quantity", quantity)
		:OrderBy("slotId", true)
		:GetFirstResultAndRelease()
	if foundSlotId then
		removeContext[foundSlotId] = quantity
		return foundBag, foundSlot
	end

	-- try to post using the selected slot and the lower slots
	local selectedQuantity = private.bagDB:NewQuery()
		:Select("quantity")
		:Equal("slotId", slotId)
		:GetFirstResultAndRelease()
	local query = private.bagDB:NewQuery()
		:Select("slotId", "quantity")
		:Equal("itemString", itemString)
		:LessThan("slotId", slotId)
		:OrderBy("slotId", true)
	local numNeeded = quantity - selectedQuantity
	local numUsed = 0
	local usedSlotIds = TempTable.Acquire()
	for _, rowSlotId, rowQuantity in query:Iterator() do
		if numNeeded ~= numUsed then
			numUsed = min(numUsed + rowQuantity, numNeeded)
			tinsert(usedSlotIds, rowSlotId)
		end
	end
	query:Release()
	if numNeeded == numUsed then
		removeContext[slotId] = selectedQuantity
		for _, rowSlotId in TempTable.Iterator(usedSlotIds) do
			local rowQuantity = private.bagDB:GetUniqueRowField("slotId", rowSlotId, "quantity")
			local rowNumUsed = min(numUsed, rowQuantity)
			numUsed = numUsed - rowNumUsed
			removeContext[rowSlotId] = (removeContext[rowSlotId] or 0) + rowNumUsed
		end
		return bag, slot
	else
		TempTable.Release(usedSlotIds)
	end

	-- try posting from the next highest slot
	local rowBag, rowSlot = private.bagDB:NewQuery()
		:Select("bag", "slot")
		:Equal("itemString", itemString)
		:GreaterThan("slotId", slotId)
		:OrderBy("slotId", true)
		:GetFirstResultAndRelease()
	if not rowBag or not rowSlot then
		private.ErrorForItem(itemString, "Failed to find next highest bag / slot")
	end
	return private.ItemBagSlotHelper(itemString, rowBag, rowSlot, quantity, removeContext)
end

function private.RemoveBagQuantity(slotId, quantity)
	local row = private.bagDB:GetUniqueRow("slotId", slotId)
	local remainingQuantity = row:GetField("quantity") - quantity
	private.DebugLogInsert(row:GetField("itemString"), "Removing %d (%d remain) from %d", quantity, remainingQuantity, slotId)
	if remainingQuantity > 0 then
		row:SetField("quantity", remainingQuantity)
			:Update()
	else
		assert(remainingQuantity == 0)
		private.bagDB:DeleteRow(row)
	end
	row:Release()
end

function private.ConfirmRowQueryHelper(row)
	return row:GetField("numConfirmed") < row:GetField("numProcessed")
end

function private.NextProcessRowQueryHelper(row)
	return row:GetField("numProcessed") < row:GetField("numStacks")
end

function private.DebugLogInsert(itemString, ...)
	tinsert(private.debugLog, itemString)
	tinsert(private.debugLog, format(...))
end

function private.ErrorForItem(itemString, errorStr)
	for i = 1, #private.debugLog, 2 do
		if private.debugLog[i] == itemString then
			Log.Info(private.debugLog[i + 1])
		end
	end
	Log.Info("Bag state:")
	for b = 0, NUM_BAG_SLOTS do
		for s = 1, GetContainerNumSlots(b) do
			if ItemString.GetBase(GetContainerItemLink(b, s)) == itemString then
				local _, q = GetContainerItemInfo(b, s)
				Log.Info("%d in %d, %d", q, b, s)
			end
		end
	end
	error(errorStr, 2)
end
