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

local _, TSM = ...
local AuctionTracking = TSM.Init("Service.AuctionTracking")
local L = TSM.Include("Locale").GetTable()
local Database = TSM.Include("Util.Database")
local Delay = TSM.Include("Util.Delay")
local Event = TSM.Include("Util.Event")
local Log = TSM.Include("Util.Log")
local ItemString = TSM.Include("Util.ItemString")
local TempTable = TSM.Include("Util.TempTable")
local Sound = TSM.Include("Util.Sound")
local Money = TSM.Include("Util.Money")
local Analytics = TSM.Include("Util.Analytics")
local ItemInfo = TSM.Include("Service.ItemInfo")
local Settings = TSM.Include("Service.Settings")
local AuctionHouseWrapper = TSM.Include("Service.AuctionHouseWrapper")
local private = {
	settings = nil,
	indexDB = nil,
	quantityDB = nil,
	updateQuery = nil, -- luacheck: ignore 1004 - just stored for GC reasons
	isAHOpen = false,
	callbacks = {},
	expiresCallbacks = {},
	indexUpdates = {
		list = {},
		pending = {},
	},
	cancelAuctionId = nil,
	lastScanNum = nil,
	ignoreUpdateEvent = nil,
	lastPurchase = {},
	prevLineId = nil,
	prevLineResult = nil,
	origChatFrameOnEvent = nil,
	pendingFuture = nil,
	auctionIdToLink = {},
	auctionIdToItemBuyout = {},
	prevLogTime = 0,
	prevLogNum = math.huge,
}
local PLAYER_NAME = UnitName("player")
local SALE_HINT_SEP = "\001"
local SALE_HINT_EXPIRE_TIME = 33 * 24 * 60 * 60
local SORT_ORDER = not TSM.IsWowClassic() and {
	{ sortOrder = Enum.AuctionHouseSortOrder.Name, reverseSort = false },
	{ sortOrder = Enum.AuctionHouseSortOrder.Price, reverseSort = false },
}
local AUCTIONABLE_WOW_TOKEN_ITEM_ID = 122270



-- ============================================================================
-- Module Loading
-- ============================================================================

AuctionTracking:OnSettingsLoad(function()
	private.settings = Settings.NewView()
		:AddKey("char", "internalData", "auctionSaleHints")
		:AddKey("char", "internalData", "auctionPrices")
		:AddKey("char", "internalData", "auctionMessages")
		:AddKey("factionrealm", "internalData", "expiringAuction")
		:AddKey("sync", "internalData", "auctionQuantity")
		:AddKey("global", "coreOptions", "auctionSaleSound")
	Event.Register("AUCTION_HOUSE_SHOW", private.AuctionHouseShowHandler)
	Event.Register("AUCTION_HOUSE_CLOSED", private.AuctionHouseClosedHandler)
	if TSM.IsWowClassic() then
		Event.Register("AUCTION_OWNED_LIST_UPDATE", private.AuctionOwnedListUpdateHandler)
	else
		Event.Register("OWNED_AUCTIONS_UPDATED", private.AuctionOwnedListUpdateHandler)
		Event.Register("AUCTION_CANCELED", private.AuctionCanceledHandler)
	end
	private.indexDB = Database.NewSchema("AUCTION_TRACKING_INDEXES")
		:AddUniqueNumberField("index")
		:AddStringField("itemString")
		:AddSmartMapField("baseItemString", ItemString.GetBaseMap(), "itemString")
		:AddStringField("itemLink")
		:AddNumberField("itemTexture")
		:AddStringField("itemName")
		:AddNumberField("itemQuality")
		:AddNumberField("duration")
		:AddStringField("highBidder")
		:AddNumberField("currentBid")
		:AddNumberField("buyout")
		:AddNumberField("stackSize")
		:AddNumberField("saleStatus")
		:AddNumberField("auctionId")
		:AddIndex("index")
		:AddIndex("saleStatus")
		:AddIndex("auctionId")
		:Commit()
	private.quantityDB = Database.NewSchema("AUCTION_TRACKING_QUANTITY")
		:AddUniqueStringField("itemString")
		:AddNumberField("quantity")
		:Commit()
	private.updateQuery = private.indexDB:NewQuery()
		:SetUpdateCallback(private.OnCallbackQueryUpdated)

	private.RebuildQuantityDB()
	for info, timestamp in pairs(private.settings.auctionSaleHints) do
		if time() > timestamp + SALE_HINT_EXPIRE_TIME then
			private.settings.auctionSaleHints[info] = nil
		end
	end

	if TSM.IsWowClassic() then
		hooksecurefunc("PostAuction", function(_, _, duration)
			private.PostAuctionHookHandler(duration)
		end)
	else
		hooksecurefunc(C_AuctionHouse, "PostCommodity", function(_, duration)
			private.PostAuctionHookHandler(duration)
		end)
		hooksecurefunc(C_AuctionHouse, "PostItem", function(_, duration)
			private.PostAuctionHookHandler(duration)
		end)
		hooksecurefunc(C_AuctionHouse, "CancelAuction", function(auctionId)
			private.cancelAuctionId = auctionId
		end)
	end

	-- setup enhanced sale / buy messages
	ChatFrame_AddMessageEventFilter("CHAT_MSG_SYSTEM", private.FilterSystemMsg)
	if TSM.IsWowClassic() then
		hooksecurefunc("PlaceAuctionBid", function(_, index, amountPaid)
			local link = GetAuctionItemLink("list", index)
			local name, _, stackSize, _, _, _, _, _, _, buyout = GetAuctionItemInfo("list", index)
			if amountPaid == buyout then
				wipe(private.lastPurchase)
				private.lastPurchase.name = name
				private.lastPurchase.link = link
				private.lastPurchase.stackSize = stackSize
				private.lastPurchase.buyout = buyout
			end
		end)
	else
		Event.Register("ITEM_SEARCH_RESULTS_UPDATED", function(_, itemKey)
			wipe(private.auctionIdToLink)
			wipe(private.auctionIdToItemBuyout)
			for i = 1, C_AuctionHouse.GetNumItemSearchResults(itemKey) do
				local info = C_AuctionHouse.GetItemSearchResultInfo(itemKey, i)
				if info.buyoutAmount then
					private.auctionIdToLink[info.auctionID] = info.itemLink
					private.auctionIdToItemBuyout[info.auctionID] = info.buyoutAmount
				end
			end
		end)
		hooksecurefunc(C_AuctionHouse, "PlaceBid", function(auctionId, bidPlaced)
			local link = private.auctionIdToLink[auctionId]
			local buyout = private.auctionIdToItemBuyout[auctionId]
			if not link or buyout ~= bidPlaced then
				return
			end
			wipe(private.lastPurchase)
			private.lastPurchase.name = ItemInfo.GetName(link)
			private.lastPurchase.link = link
			private.lastPurchase.stackSize = 1
			private.lastPurchase.buyout = bidPlaced
		end)
		hooksecurefunc(C_AuctionHouse, "ConfirmCommoditiesPurchase", function(itemId, quantity)
			local link = ItemInfo.GetLink("i:"..itemId)
			if not link then
				return
			end
			local origQuantity = quantity
			local buyout = 0
			for i = 1, C_AuctionHouse.GetNumCommoditySearchResults(itemId) do
				local info = C_AuctionHouse.GetCommoditySearchResultInfo(itemId, i)
				local resultQuantity = min(quantity, info.quantity - info.numOwnerItems)
				buyout = buyout + resultQuantity * info.unitPrice
				quantity = quantity - resultQuantity
				if quantity == 0 then
					break
				end
			end
			if quantity > 0 then
				return
			end
			private.lastPurchase.name = ItemInfo.GetName(link)
			private.lastPurchase.link = link
			private.lastPurchase.stackSize = origQuantity
			private.lastPurchase.buyout = buyout
		end)
	end
end)

AuctionTracking:OnGameDataLoad(function()
	-- setup auction created / cancelled filtering
	-- NOTE: this is delayed until the game is loaded to avoid taint issues
	local ElvUIChat, ElvUIChatIsEnabled = nil, nil
	if IsAddOnLoaded("ElvUI") and ElvUI then
		ElvUIChat = ElvUI[1]:GetModule("Chat")
		if ElvUI[3].chat.enable then
			ElvUIChatIsEnabled = true
		end
	end
	if ElvUIChatIsEnabled then
		private.origChatFrameOnEvent = ElvUIChat.ChatFrame_OnEvent
		ElvUIChat.ChatFrame_OnEvent = private.ChatFrameOnEvent
	else
		private.origChatFrameOnEvent = ChatFrame_OnEvent
		ChatFrame_OnEvent = private.ChatFrameOnEvent
	end
end)



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

function AuctionTracking.RegisterCallback(callback)
	tinsert(private.callbacks, callback)
end

function AuctionTracking.RegisterExpiresCallback(callback)
	tinsert(private.expiresCallbacks, callback)
end

function AuctionTracking.DatabaseFieldIterator()
	return private.indexDB:FieldIterator()
end

function AuctionTracking.BaseItemIterator()
	return private.quantityDB:NewQuery()
		:Select("itemString")
		:IteratorAndRelease()
end

function AuctionTracking.CreateQuery()
	return private.indexDB:NewQuery()
end

function AuctionTracking.CreateQueryUnsold()
	return AuctionTracking.CreateQuery()
		:Equal("saleStatus", 0)
end

function AuctionTracking.CreateQueryUnsoldItem(itemString)
	return AuctionTracking.CreateQueryUnsold()
		:Equal(itemString == ItemString.GetBaseFast(itemString) and "baseItemString" or "itemString", itemString)
end

function AuctionTracking.GetSaleHintItemString(name, stackSize, buyout)
	for info in pairs(private.settings.auctionSaleHints) do
		local infoName, itemString, infoStackSize, infoBuyout = strsplit(SALE_HINT_SEP, info)
		if infoName == name and tonumber(infoStackSize) == stackSize and tonumber(infoBuyout) == buyout then
			return itemString
		end
	end
end

function AuctionTracking.GetQuantityByBaseItemString(baseItemString)
	return private.quantityDB:GetUniqueRowField("itemString", baseItemString, "quantity") or 0
end

function AuctionTracking.QueryOwnedAuctions()
	if not private.isAHOpen then
		return
	end
	if TSM.IsWowClassic() then
		GetOwnerAuctionItems()
	else
		if private.pendingFuture then
			return
		end
		private.pendingFuture = AuctionHouseWrapper.QueryOwnedAuctions(SORT_ORDER)
		if not private.pendingFuture then
			Delay.AfterTime(0.5, AuctionTracking.QueryOwnedAuctions)
			return
		end
		private.pendingFuture:SetScript("OnDone", private.PendingFutureOnDone)
	end
end



-- ============================================================================
-- Event Handlers
-- ============================================================================

function private.AuctionHouseShowHandler()
	private.isAHOpen = true
	if TSM.IsWowClassic() then
		AuctionTracking.QueryOwnedAuctions()
		-- We don't always get AUCTION_OWNED_LIST_UPDATE events, so do our own scanning if needed
		Delay.AfterTime("AUCTION_BACKGROUND_SCAN", 1, private.DoBackgroundScan, 1)
	else
		Delay.AfterTime(0.1, AuctionTracking.QueryOwnedAuctions)
	end
end

function private.AuctionHouseClosedHandler()
	private.isAHOpen = false
	Delay.Cancel("AUCTION_BACKGROUND_SCAN")
end

function private.DoBackgroundScan()
	if private.GetNumOwnedAuctions() ~= private.lastScanNum then
		private.AuctionOwnedListUpdateHandler()
	end
end

function private.AuctionOwnedListUpdateHandler()
	if private.ignoreUpdateEvent then
		return
	end
	wipe(private.indexUpdates.pending)
	wipe(private.indexUpdates.list)
	local numOwned = private.GetNumOwnedAuctions()
	for i = 1, numOwned do
		if not private.indexUpdates.pending[i] then
			private.indexUpdates.pending[i] = true
			tinsert(private.indexUpdates.list, i)
		end
	end
	if numOwned == 0 and private.settings.expiringAuction[PLAYER_NAME] then
		private.settings.expiringAuction[PLAYER_NAME] = nil
		for _, callback in ipairs(private.expiresCallbacks) do
			callback()
		end
	end
	Delay.AfterFrame("AUCTION_OWNED_LIST_SCAN", 2, private.AuctionOwnedListUpdateDelayed)
end

function private.AuctionCanceledHandler(_, auctionId)
	if not private.cancelAuctionId or auctionId ~= 0 then
		-- an auction was bought, so rescan the owned auctions
		AuctionTracking.QueryOwnedAuctions()
		return
	end
	local row = private.indexDB:NewQuery()
		:Equal("auctionId", private.cancelAuctionId)
		:GetFirstResultAndRelease()
	private.cancelAuctionId = nil
	if not row then
		return
	end

	local baseItemString = row:GetField("baseItemString")
	local stackSize = row:GetField("stackSize")
	assert(stackSize <= private.settings.auctionQuantity[baseItemString])
	private.settings.auctionQuantity[baseItemString] = private.settings.auctionQuantity[baseItemString] - stackSize
	private.RebuildQuantityDB()
	private.indexDB:DeleteRow(row)
	row:Release()
end

function private.AuctionOwnedListUpdateDelayed()
	if not private.isAHOpen then
		return
	elseif AuctionFrame and AuctionFrame:IsVisible() and AuctionFrame.selectedTab == 3 then
		-- default UI auctions tab is visible, so scan later
		Delay.AfterFrame("AUCTION_OWNED_LIST_SCAN", 2, private.AuctionOwnedListUpdateDelayed)
		return
	elseif not TSM.IsWowClassic() and not C_AuctionHouse.HasFullOwnedAuctionResults() then
		-- don't have all the results yet, so try again in a moment
		Delay.AfterFrame("AUCTION_OWNED_LIST_SCAN", 0.1, private.AuctionOwnedListUpdateDelayed)
		return
	end
	if TSM.IsWowClassic() then
		-- check if we need to change the sort
		local needsSort = false
		local numColumns = #AuctionSort.owner_duration
		for i, info in ipairs(AuctionSort.owner_duration) do
			local col, reversed = GetAuctionSort("owner", numColumns - i + 1)
			-- we want to do the opposite order
			reversed = not reversed
			if col ~= info.column or info.reverse ~= reversed then
				needsSort = true
				break
			end
		end
		if needsSort then
			Log.Info("Sorting owner auctions")
			-- ignore events while changing the sort
			private.ignoreUpdateEvent = true
			AuctionFrame_SetSort("owner", "duration", true)
			SortAuctionApplySort("owner")
			private.ignoreUpdateEvent = nil
		end
	end

	-- scan the auctions
	local shouldLog = GetTime() - private.prevLogTime > 5
	if shouldLog then
		private.prevLogTime = GetTime()
	end
	wipe(private.settings.auctionQuantity)
	private.indexDB:TruncateAndBulkInsertStart()
	local expire = math.huge
	for i = #private.indexUpdates.list, 1, -1 do
		local index = private.indexUpdates.list[i]
		local auctionId, link, name, texture, stackSize, quality, minBid, buyout, bid, highBidder, saleStatus, duration, shouldIgnore = private.GetOwnedAuctionInfo(index)
		if shouldIgnore then
			private.indexUpdates.pending[index] = nil
			tremove(private.indexUpdates.list, i)
		else
			name = name or ItemInfo.GetName(link)
			texture = texture or ItemInfo.GetTexture(link)
			quality = quality or ItemInfo.GetQuality(link)
			if link and name and texture and quality then
				assert(saleStatus == 0 or saleStatus == 1)
				highBidder = highBidder or ""
				local itemString = ItemString.Get(link)
				local currentBid = highBidder ~= "" and bid or minBid
				if not currentBid and saleStatus == 1 and not TSM.IsWowClassic() then
					-- sometimes wow doesn't tell us the current bid on sold auctions on retail
					currentBid = 0
				end
				if saleStatus == 0 then
					if TSM.IsWowClassic() then
						if duration == 1 then -- 30 min
							expire = min(expire, time() + 0.5 * 60 * 60)
						elseif duration == 2 then -- 2 hours
							expire = min(expire, time() + 2 * 60 * 60)
						elseif duration == 3 then -- 12 hours
							expire = min(expire, time() + 12 * 60 * 60)
						end
					else
						duration = time() + duration
						expire = min(expire, duration)
					end
					local baseItemString = ItemString.GetBaseFast(itemString)
					private.settings.auctionQuantity[baseItemString] = (private.settings.auctionQuantity[baseItemString] or 0) + stackSize
					local hintInfo = strjoin(SALE_HINT_SEP, ItemInfo.GetName(link), itemString, stackSize, buyout)
					private.settings.auctionSaleHints[hintInfo] = time()
				else
					duration = time() + duration
				end
				private.indexUpdates.pending[index] = nil
				tremove(private.indexUpdates.list, i)
				private.indexDB:BulkInsertNewRow(index, itemString, link, texture, name, quality, duration, highBidder, currentBid, buyout, stackSize, saleStatus, auctionId)
			elseif shouldLog then
				Log.Warn("Missing info (%s, %s, %s, %s)", gsub(tostring(link), "\124", "\\124"), tostring(name), tostring(texture), tostring(quality))
				if link and strmatch(link, "item:") and not TSM.IsWowClassic() then
					Analytics.Action("AUCTION_TRACKING_MISSING_INFO", link)
				end
			end
		end
	end
	private.RebuildQuantityDB()
	private.indexDB:BulkInsertEnd()

	if expire ~= math.huge and (private.settings.expiringAuction[PLAYER_NAME] or math.huge) > expire then
		private.settings.expiringAuction[PLAYER_NAME] = expire
		for _, callback in ipairs(private.expiresCallbacks) do
			callback()
		end
	end

	if shouldLog or #private.indexUpdates.list ~= private.prevLogNum then
		Log.Info("Scanned auctions (left=%d)", #private.indexUpdates.list)
		private.prevLogNum = #private.indexUpdates.list
	end
	if #private.indexUpdates.list > 0 then
		-- some failed to scan so try again
		Delay.AfterFrame("AUCTION_OWNED_LIST_SCAN", 2, private.AuctionOwnedListUpdateDelayed)
	else
		private.lastScanNum = private.GetNumOwnedAuctions()
	end
end



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

function private.RebuildQuantityDB()
	private.quantityDB:TruncateAndBulkInsertStart()
	for itemString, quantity in pairs(private.settings.auctionQuantity) do
		if quantity > 0 then
			private.quantityDB:BulkInsertNewRow(itemString, quantity)
		else
			private.settings.auctionQuantity[itemString] = nil
		end
	end
	private.quantityDB:BulkInsertEnd()
end

function private.GetNumOwnedAuctions()
	if TSM.IsWowClassic() then
		return GetNumAuctionItems("owner")
	else
		return C_AuctionHouse.GetNumOwnedAuctions()
	end
end

function private.GetOwnedAuctionInfo(index)
	if TSM.IsWowClassic() then
		local name, texture, stackSize, quality, _, _, _, minBid, _, buyout, bid, highBidder, _, _, _, saleStatus = GetAuctionItemInfo("owner", index)
		local link = name and name ~= "" and GetAuctionItemLink("owner", index)
		if not link then
			return
		end
		local duration = GetAuctionItemTimeLeft("owner", index)
		return index, link, name, texture, stackSize, quality, minBid, buyout, bid, highBidder, saleStatus, duration
	else
		local info = C_AuctionHouse.GetOwnedAuctionInfo(index)
		if info.itemKey.itemID == AUCTIONABLE_WOW_TOKEN_ITEM_ID then
			-- this is a token, so just ignore it
			return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, true
		end
		local link = info and info.itemLink
		if not link then
			return
		end
		local bid = info.bidAmount or info.buyoutAmount
		local minBid = bid
		return info.auctionID, link, nil, nil, info.quantity, nil, minBid, info.buyoutAmount or 0, bid, info.bidder or "", info.status, info.timeLeftSeconds
	end
end

function private.OnCallbackQueryUpdated()
	for _, callback in ipairs(private.callbacks) do
		callback()
	end
	-- updating the auction prices / messages is very low-priority, so throttle it to at most every 0.5 seconds
	Delay.AfterTime("UPDATE_AUCTION_PRICES_MESSAGES_THROTTLE", 0.5, private.UpdateAuctionPricesMessages)
end

function private.PostAuctionHookHandler(duration)
	local days = nil
	if duration == 1 then
		days = 0.5
	elseif duration == 2 then
		days = 1
	elseif duration == 3 then
		days = 2
	end

	local expiration = time() + (days * 24 * 60 * 60)
	if (private.settings.expiringAuction[PLAYER_NAME] or math.huge) < expiration then
		return
	end
	private.settings.expiringAuction[PLAYER_NAME] = expiration
	for _, callback in ipairs(private.expiresCallbacks) do
		callback()
	end
end

function private.UpdateAuctionPricesMessages()
	local INVALID_STACK_SIZE = -1
	-- recycle tables from private.settings.auctionPrices if we can so we're not creating a ton of garbage
	local freeTables = TempTable.Acquire()
	for _, tbl in pairs(private.settings.auctionPrices) do
		wipe(tbl)
		tinsert(freeTables, tbl)
	end
	wipe(private.settings.auctionPrices)
	wipe(private.settings.auctionMessages)
	local auctionPrices = TempTable.Acquire()
	local auctionStackSizes = TempTable.Acquire()
	local query = AuctionTracking.CreateQueryUnsold()
		:Select("itemLink", "stackSize", "buyout")
		:GreaterThan("buyout", 0)
		:OrderBy("index", true)
	for _, link, stackSize, buyout in query:IteratorAndRelease() do
		auctionPrices[link] = auctionPrices[link] or tremove(freeTables) or {}
		if stackSize ~= auctionStackSizes[link] then
			auctionStackSizes[link] = stackSize
		end
		tinsert(auctionPrices[link], buyout)
	end
	for link, prices in pairs(auctionPrices) do
		local name = ItemInfo.GetName(link)
		if auctionStackSizes[link] ~= INVALID_STACK_SIZE then
			sort(prices)
			private.settings.auctionPrices[link] = prices
			private.settings.auctionMessages[format(ERR_AUCTION_SOLD_S, name)] = link
		end
	end
	TempTable.Release(freeTables)
	TempTable.Release(auctionPrices)
	TempTable.Release(auctionStackSizes)
end

function private.ChatFrameOnEvent(self, event, msg, ...)
	-- surpress auction created / cancelled spam
	if event == "CHAT_MSG_SYSTEM" and (msg == ERR_AUCTION_STARTED or msg == ERR_AUCTION_REMOVED) then
		return
	end
	return private.origChatFrameOnEvent(self, event, msg, ...)
end

function private.FilterSystemMsg(_, _, msg, ...)
	local lineID = select(10, ...)
	if lineID ~= private.prevLineId then
		private.prevLineId = lineID
		private.prevLineResult = nil
		local link = private.settings.auctionMessages and private.settings.auctionMessages[msg]
		if private.lastPurchase.name and msg == format(ERR_AUCTION_WON_S, private.lastPurchase.name) then
			-- we just bought an auction
			private.prevLineResult = format(L["You won an auction for %sx%d for %s"], private.lastPurchase.link, private.lastPurchase.stackSize, Money.ToString(private.lastPurchase.buyout, "|cffffffff"))
			return nil, private.prevLineResult, ...
		elseif link then
			-- we may have just sold an auction
			local price = tremove(private.settings.auctionPrices[link], 1)
			local numAuctions = #private.settings.auctionPrices[link]
			if not price then
				-- couldn't determine the price, so just replace the link
				private.prevLineResult = format(ERR_AUCTION_SOLD_S, link)
				Sound.PlaySound(private.settings.auctionSaleSound)
				return nil, private.prevLineResult, ...
			end
			if numAuctions == 0 then -- this was the last auction
				private.settings.auctionMessages[msg] = nil
			end
			private.prevLineResult = format(L["Your auction of %s has sold for %s!"], link, Money.ToString(price, "|cffffffff"))
			Sound.PlaySound(private.settings.auctionSaleSound)
			return nil, private.prevLineResult, ...
		end
	end
end

function private.PendingFutureOnDone()
	-- we also hook the event, so don't care what the result is
	private.pendingFuture:GetValue()
	private.pendingFuture = nil
end
