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

local _, TSM = ...
local AuctionHouseWrapper = TSM.Init("Service.AuctionHouseWrapper")
local LibTSMClass = TSM.Include("LibTSMClass")
local Log = TSM.Include("Util.Log")
local Delay = TSM.Include("Util.Delay")
local Event = TSM.Include("Util.Event")
local Table = TSM.Include("Util.Table")
local Future = TSM.Include("Util.Future")
local Vararg = TSM.Include("Util.Vararg")
local Analytics = TSM.Include("Util.Analytics")
local Math = TSM.Include("Util.Math")
local Debug = TSM.Include("Util.Debug")
local APIWrapper = LibTSMClass.DefineClass("APIWrapper")
local private = {
	wrappers = {},
	events = {},
	argsTemp = {},
	sortsPartsTemp = {},
	itemKeyPartsTemp = {},
	searchQueryAPITimes = {},
	isAHOpen = false,
	lastResponseReceived = 0,
	hookedTime = {},
	lastAuctionCanceledAuctionId = nil,
	lastAuctionCanceledTime = 0,
	auctionIdUpdateCallbacks = {},
}
local API_TIMEOUT = 5
local GET_ALL_TIMEOUT = 30
local SEARCH_QUERY_THROTTLE_INTERVAL = 60
local SEARCH_QUERY_THROTTLE_MAX = 100
local EMPTY_SORTS_TABLE = {}
local ITEM_KEY_KEYS = {
	"itemID",
	"itemLevel",
	"itemSuffix",
	"battlePetSpeciesID",
}
local SILENT_EVENTS = {
	AUCTION_ITEM_LIST_UPDATE = true,
	REPLICATE_ITEM_LIST_UPDATE = true,
}
local GENERIC_EVENTS = {
	CHAT_MSG_SYSTEM = 1,
	UI_ERROR_MESSAGE = 2,
}
local GENERIC_EVENT_SEP = "/"
local API_EVENT_INFO = TSM.IsWowClassic() and
	{ -- Classic
		QueryAuctionItems = {
			AUCTION_ITEM_LIST_UPDATE = { result = true },
		},
	} or
	{ -- Retail
		SendBrowseQuery = {
			AUCTION_HOUSE_BROWSE_RESULTS_UPDATED = { result = true },
		},
		SearchForFavorites = {
			AUCTION_HOUSE_BROWSE_RESULTS_UPDATED = { result = true },
		},
		SearchForItemKeys = {
			AUCTION_HOUSE_BROWSE_RESULTS_UPDATED = { result = true },
		},
		ReplicateItems = {
			REPLICATE_ITEM_LIST_UPDATE = { result = true },
		},
		RequestMoreBrowseResults = {
			AUCTION_HOUSE_BROWSE_RESULTS_ADDED = { result = 1 },
			["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_DATABASE_ERROR] = { timeoutChange = 1 },
		},
		SendSearchQuery = {
			COMMODITY_SEARCH_RESULTS_UPDATED = { result = true, eventArgIndex = 1, apiArgIndex = 1, apiArgKey = "itemID" },
			ITEM_SEARCH_RESULTS_UPDATED = { result = true, eventArgIndex = 1, apiArgIndex = 1 },
			["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_DATABASE_ERROR] = { timeoutChange = 1 },
		},
		SendSellSearchQuery = {
			COMMODITY_SEARCH_RESULTS_UPDATED = { result = true, eventArgIndex = 1, apiArgIndex = 1, apiArgKey = "itemID" },
			ITEM_SEARCH_RESULTS_UPDATED = { result = true, eventArgIndex = 1, apiArgIndex = 1 },
			["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_DATABASE_ERROR] = { timeoutChange = 1 },
		},
		RequestMoreCommoditySearchResults = {
			COMMODITY_SEARCH_RESULTS_ADDED = { result = true },
		},
		RequestMoreItemSearchResults = {
			ITEM_SEARCH_RESULTS_ADDED = { result = true },
		},
		RefreshCommoditySearchResults = {
			COMMODITY_SEARCH_RESULTS_UPDATED = { result = true },
		},
		RefreshItemSearchResults = {
			ITEM_SEARCH_RESULTS_UPDATED = { result = true },
		},
		QueryOwnedAuctions = {
			OWNED_AUCTIONS_UPDATED = { result = true },
		},
		QueryBids = {
			BIDS_UPDATED = { result = true },
		},
		CancelAuction = {
			AUCTION_CANCELED = { result = true, eventArgIndex = 1, apiArgIndex = 1, compareFunc = function(eventArg, apiArg) return eventArg == 0 or apiArg == eventArg end },
			["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_ITEM_NOT_FOUND] = { result = false },
			["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_NOT_ENOUGH_MONEY] = { result = false },
			["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_DATABASE_ERROR] = { result = false },
		},
		StartCommoditiesPurchase = {
			COMMODITY_PRICE_UPDATED = { result = 2, rawFilterFunc = function(apiArgs, unitPrice, totalPrice) return Math.Ceil((totalPrice / apiArgs[2]), COPPER_PER_SILVER) == apiArgs[3] and true end },
			COMMODITY_PRICE_UNAVAILABLE = { result = false },
		},
		ConfirmCommoditiesPurchase = {
			COMMODITY_PURCHASE_SUCCEEDED = { result = true },
			COMMODITY_PURCHASE_FAILED = { result = false },
			["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_DATABASE_ERROR] = { result = false },
			["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_HIGHER_BID] = { result = false },
			["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_ITEM_NOT_FOUND] = { result = false },
			["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_BID_OWN] = { result = false },
			["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_NOT_ENOUGH_MONEY] = { result = false },
		},
		PlaceBid = {
			AUCTION_CANCELED = { result = true, eventArgIndex = 1, apiArgIndex = 1 },
			["CHAT_MSG_SYSTEM"..GENERIC_EVENT_SEP..ERR_AUCTION_BID_PLACED] = { result = true },
			["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_DATABASE_ERROR] = { result = false },
			["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_HIGHER_BID] = { result = false },
			["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_ITEM_NOT_FOUND] = { result = false },
			["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_BID_OWN] = { result = false },
			["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_NOT_ENOUGH_MONEY] = { result = false },
			["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_ITEM_MAX_COUNT] = { result = false },
		},
		PostItem = {
			AUCTION_HOUSE_AUCTION_CREATED = { result = true, rawFilterFunc = function(apiArgs) return apiArgs[3] <= 1 end },
			AUCTION_MULTISELL_UPDATE = { result = true, rawFilterFunc = function(apiArgs, createdCount, totalToCreate) return createdCount == totalToCreate end },
			AUCTION_MULTISELL_FAILURE = { result = false },
			["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_ITEM_NOT_FOUND] = { result = false },
			["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_DATABASE_ERROR] = { result = false },
			["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_REPAIR_ITEM] = { result = nil },
			["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_LIMITED_DURATION_ITEM] = { result = nil },
			["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_USED_CHARGES] = { result = nil },
			["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_WRAPPED_ITEM] = { result = nil },
			["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_BAG] = { result = nil },
			["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_NOT_ENOUGH_MONEY] = { result = nil },
		},
		PostCommodity = {
			AUCTION_HOUSE_AUCTION_CREATED = { result = true },
			["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_ITEM_NOT_FOUND] = { result = false },
			["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_DATABASE_ERROR] = { result = false },
			["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_REPAIR_ITEM] = { result = nil },
			["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_LIMITED_DURATION_ITEM] = { result = nil },
			["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_USED_CHARGES] = { result = nil },
			["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_WRAPPED_ITEM] = { result = nil },
			["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_BAG] = { result = nil },
			["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_NOT_ENOUGH_MONEY] = { result = nil },
		},
	}



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

AuctionHouseWrapper:OnModuleLoad(function()
	Event.Register("AUCTION_HOUSE_SHOW", private.AuctionHouseShowHandler)
	Event.Register("AUCTION_HOUSE_CLOSED", private.AuctionHouseClosedHandler)

	-- setup wrappers
	for apiName in pairs(API_EVENT_INFO) do
		private.wrappers[apiName] = APIWrapper(apiName)
	end

	if not TSM.IsWowClassic() then
		-- extra hooks to track search query calls since they are limited
		hooksecurefunc(C_AuctionHouse, "SendSearchQuery", function()
			tinsert(private.searchQueryAPITimes, GetTime())
		end)
		hooksecurefunc(C_AuctionHouse, "SendSellSearchQuery", function()
			tinsert(private.searchQueryAPITimes, GetTime())
		end)

		-- events to track auction purchases
		Event.Register("AUCTION_CANCELED", private.AuctionCanceledHandler)
		Event.Register("ITEM_SEARCH_RESULTS_UPDATED", private.ItemSearchResultsUpdated)

		-- general events
		Event.Register("AUCTION_HOUSE_THROTTLED_MESSAGE_RESPONSE_RECEIVED", private.ResponseReceivedHandler)

		-- extra events that are interesting to log
		Event.Register("AUCTION_HOUSE_NEW_RESULTS_RECEIVED", private.UnusedEventHandler)
		Event.Register("AUCTION_HOUSE_THROTTLED_MESSAGE_DROPPED", private.UnusedEventHandler)
		Event.Register("AUCTION_HOUSE_THROTTLED_MESSAGE_QUEUED", private.UnusedEventHandler)
		Event.Register("AUCTION_HOUSE_THROTTLED_MESSAGE_SENT", private.UnusedEventHandler)
		if not TSM.IsShadowlands() then
			Event.Register("AUCTION_HOUSE_THROTTLED_SPECIFIC_SEARCH_READY", private.UnusedEventHandler)
		end
		Event.Register("AUCTION_HOUSE_THROTTLED_SYSTEM_READY", private.UnusedEventHandler)
	end
end)



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

function AuctionHouseWrapper.RegisterAuctionIdUpdateCallback(callback)
	tinsert(private.auctionIdUpdateCallbacks, callback)
end

function AuctionHouseWrapper.IsOpen()
	return private.isAHOpen
end

function AuctionHouseWrapper.GetAndResetTotalHookedTime()
	local total, topTime, topAddon = 0, nil, nil
	for addon, hookedTime in pairs(private.hookedTime) do
		total = total + hookedTime
		if hookedTime > (topTime or 0) then
			topTime = hookedTime
			topAddon = addon
		end
	end
	wipe(private.hookedTime)
	return total, topAddon, topTime
end

function AuctionHouseWrapper.SendBrowseQuery(query)
	assert(not TSM.IsWowClassic())
	if not private.CheckAllIdle() then
		return
	end
	return private.wrappers.SendBrowseQuery:Start(query)
end

function AuctionHouseWrapper.RequestMoreBrowseResults()
	assert(not TSM.IsWowClassic())
	if not private.CheckAllIdle() then
		return
	end
	return private.wrappers.RequestMoreBrowseResults:Start()
end

function AuctionHouseWrapper.SendSearchQuery(itemKey, isSell)
	assert(not TSM.IsWowClassic())
	if not private.CheckAllIdle() then
		return
	end
	-- remove times which are beyond the throttle interval
	for i = #private.searchQueryAPITimes, 1, -1 do
		if GetTime() - private.searchQueryAPITimes[i] >= SEARCH_QUERY_THROTTLE_INTERVAL then
			tremove(private.searchQueryAPITimes, i)
		end
	end
	if #private.searchQueryAPITimes >= SEARCH_QUERY_THROTTLE_MAX then
		local delayTime = private.searchQueryAPITimes[1] + SEARCH_QUERY_THROTTLE_INTERVAL - GetTime()
		assert(delayTime > 0, "Invalid delay time: "..tostring(delayTime))
		Log.Err("Search query can't be run for another %.3f seconds", delayTime)
		return nil, delayTime
	end
	assert(type(isSell) == "boolean")
	if isSell then
		return private.wrappers.SendSellSearchQuery:Start(itemKey, EMPTY_SORTS_TABLE, true)
	else
		return private.wrappers.SendSearchQuery:Start(itemKey, EMPTY_SORTS_TABLE, true)
	end
end

function AuctionHouseWrapper.RequestMoreCommoditySearchResults(itemId)
	assert(not TSM.IsWowClassic())
	if not private.CheckAllIdle() then
		return
	end
	return private.wrappers.RequestMoreCommoditySearchResults:Start(itemId)
end

function AuctionHouseWrapper.RequestMoreItemSearchResults(itemKey)
	assert(not TSM.IsWowClassic())
	if not private.CheckAllIdle() then
		return
	end
	return private.wrappers.RequestMoreItemSearchResults:Start(itemKey)
end

function AuctionHouseWrapper.QueryOwnedAuctions(sorts)
	assert(not TSM.IsWowClassic())
	if not private.CheckAllIdle() then
		return
	end
	return private.wrappers.QueryOwnedAuctions:Start(sorts)
end

function AuctionHouseWrapper.CancelAuction(auctionId)
	assert(not TSM.IsWowClassic())
	-- if QueryOwnedAuctions is pending, just cancel it
	private.wrappers.QueryOwnedAuctions:CancelIfPending()
	if not private.CheckAllIdle() then
		return
	end
	return private.wrappers.CancelAuction:Start(auctionId)
end

function AuctionHouseWrapper.StartCommoditiesPurchase(itemId, quantity, itemBuyout)
	assert(not TSM.IsWowClassic())
	if not private.CheckAllIdle() then
		return
	end
	return private.wrappers.StartCommoditiesPurchase:Start(itemId, quantity, itemBuyout)
end

function AuctionHouseWrapper.ConfirmCommoditiesPurchase(itemId, quantity, totalBuyout)
	assert(not TSM.IsWowClassic())
	if not private.CheckAllIdle() then
		return
	end
	return private.wrappers.ConfirmCommoditiesPurchase:Start(itemId, quantity, totalBuyout)
end

function AuctionHouseWrapper.PlaceBid(auctionId, bidBuyout)
	assert(not TSM.IsWowClassic())
	if not private.CheckAllIdle() then
		return
	end
	return private.wrappers.PlaceBid:Start(auctionId, bidBuyout)
end

function AuctionHouseWrapper.PostItem(itemLocation, postTime, stackSize, bid, buyout)
	assert(not TSM.IsWowClassic())
	if not private.CheckAllIdle() then
		return
	end
	return private.wrappers.PostItem:Start(itemLocation, postTime, stackSize, bid, buyout)
end

function AuctionHouseWrapper.PostCommodity(itemLocation, postTime, stackSize, itemBuyout)
	assert(not TSM.IsWowClassic())
	if not private.CheckAllIdle() then
		return
	end
	return private.wrappers.PostCommodity:Start(itemLocation, postTime, stackSize, itemBuyout)
end

function AuctionHouseWrapper.QueryAuctionItems(name, minLevel, maxLevel, page, usable, quality, getAll, exact, filterData)
	assert(TSM.IsWowClassic())
	local canSendQuery, canSendGetAll = CanSendAuctionQuery()
	if not canSendQuery or (getAll and not canSendGetAll) or not private.CheckAllIdle() then
		return
	end
	return private.wrappers.QueryAuctionItems:Start(name, minLevel, maxLevel, page, usable, quality, getAll, exact, filterData)
end



-- ============================================================================
-- APIWrapper Class
-- ============================================================================

function APIWrapper.__init(self, name)
	self._name = name
	self._args = {}
	self._state = "IDLE"
	self._callTime = nil
	self._future = Future.New(self._name.."_FUTURE")
	self._future:SetScript("OnCleanup", function()
		if self._state == "PENDING_REQUESTED" then
			-- switch the current call to a hooked call
			self._state = "PENDING_HOOKED"
		elseif self._state == "DONE" then
			self._state = "IDLE"
		end
	end)
	self._timeoutWrapper = function()
		Log.Err("API timed out: %s(%s)", self._name, private.ArgsToStr(unpack(self._args)))
		return self:_Done(false)
	end

	-- hook the API
	hooksecurefunc(TSM.IsWowClassic() and _G or C_AuctionHouse, self._name, function(...)
		Log.Info("%s(%s)", self._name, private.ArgsToStr(...))
		if self:_IsPending() and select("#", ...) == 0 then
			return
		end
		self:CancelIfPending()
		if self:_HandleAPICall(...) then
			for _, wrapper in pairs(private.wrappers) do
				if wrapper ~= self and GetTime() ~= private.lastResponseReceived then
					wrapper:CancelIfPending()
				end
			end
		end
	end)

	-- register related events
	for eventName in pairs(API_EVENT_INFO[self._name]) do
		private.RegisterForEvent(eventName, self)
	end
end

function APIWrapper.IsIdle(self)
	return self._state == "IDLE"
end

function APIWrapper.CancelIfPending(self)
	if not self:_IsPending() then
		return
	end
	Log.Warn("Canceling pending (%s, %s)", self._name, self._state)
	self:_Done(false)
end

function APIWrapper.Start(self, ...)
	if self._state ~= "IDLE" then
		Log.Err("API already in progress (%s)", self._name)
		return
	end
	self._state = "STARTING"
	self:_CallAPI(...)
	return self._future
end

function APIWrapper._IsPending(self)
	return self._state == "PENDING_REQUESTED" or self._state == "PENDING_HOOKED"
end

function APIWrapper._CallAPI(self, ...)
	return (TSM.IsWowClassic() and _G or C_AuctionHouse)[self._name](...)
end

function APIWrapper._HandleAPICall(self, ...)
	self._callTime = GetTime()
	if self._state == "IDLE" then
		self._state = "PENDING_HOOKED"
		self._hookAddon = strmatch(Debug.GetStackLevelLocation(3), "AddOns\\([^\\]+)\\")
	elseif self._state == "STARTING" then
		self._future:Start()
		self._state = "PENDING_REQUESTED"
	else
		error("Unexpected state: "..self._state)
	end
	Vararg.IntoTable(self._args, ...)
	local timeout = nil
	if not private.isAHOpen then
		timeout = 0
	elseif self._name == "QueryAuctionItems" and select(7, ...) then
		timeout = GET_ALL_TIMEOUT
	else
		timeout = API_TIMEOUT
	end
	Delay.AfterTime(self._name.."_TIMEOUT", timeout, self._timeoutWrapper)
	return true
end

function APIWrapper._HandleEvent(self, eventName, ...)
	if self._state ~= "PENDING_REQUESTED" and self._state ~= "PENDING_HOOKED" then
		return
	end
	local eventIsValid, result = self:_ValidateEvent(eventName, ...)
	if not eventIsValid then
		Log.Info("Ignoring invalidated event")
		return
	end
	self:_Done(result)
end

function APIWrapper._ValidateEvent(self, eventName, ...)
	local info = nil
	if GENERIC_EVENTS[eventName] then
		local arg = ...
		info = API_EVENT_INFO[self._name][eventName..GENERIC_EVENT_SEP..arg]
	else
		info = API_EVENT_INFO[self._name][eventName]
	end
	assert(info)
	if info.timeoutChange then
		Delay.Cancel(self._name.."_TIMEOUT")
		Delay.AfterTime(self._name.."_TIMEOUT", info.timeoutChange, self._timeoutWrapper)
		return false
	end
	local eventIsValid, result = true, nil
	if type(info.result) == "number" then
		result = select(info.result, ...)
	else
		result = info.result
	end
	if info.rawFilterFunc then
		if not info.rawFilterFunc(self._args, ...) then
			eventIsValid = false
		end
	elseif info.eventArgIndex then
		local eventValue = select(info.eventArgIndex, ...)
		local apiValue = self._args[info.apiArgIndex]
		if info.apiArgKey then
			apiValue = apiValue[info.apiArgKey]
		end
		local argMatches = nil
		assert(type(eventValue) == type(apiValue))
		if info.compareFunc then
			argMatches = info.compareFunc(eventValue, apiValue)
		elseif private.IsItemKey(eventValue) then
			argMatches = true
			for _, key in ipairs(ITEM_KEY_KEYS) do
				if eventValue[key] ~= apiValue[key] then
					argMatches = false
					break
				end
			end
		elseif type(eventValue) == "table" then
			argMatches = Table.Equal(eventValue, apiValue)
		else
			argMatches = eventValue == apiValue
		end
		if not argMatches then
			eventIsValid = false
		end
	end
	return eventIsValid, result
end

function APIWrapper._Done(self, result)
	wipe(self._args)
	local hookAddon = self._hookAddon
	self._hookAddon = nil
	local totalTime = Math.Round((GetTime() - (self._callTime or GetTime())) * 1000)
	self._callTime = nil
	Delay.Cancel(self._name.."_TIMEOUT")
	if self._state == "PENDING_REQUESTED" then
		if totalTime > 0 then
			Analytics.Action("AH_API_TIME", private.GetAnalyticsRegionRealm(), self._name, result and totalTime or -1)
		end
		self._state = "DONE"
		-- need to do this last as it might trigger another API call or OnCleanup on the future
		self._future:Done(result)
	elseif self._state == "PENDING_HOOKED" then
		self._state = "IDLE"
		if hookAddon then
			private.hookedTime[hookAddon] = (private.hookedTime[hookAddon] or 0) + totalTime / 1000
		end
	else
		error("Unexpected state: "..self._state)
	end
end



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

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

function private.AuctionHouseClosedHandler()
	private.isAHOpen = false
	for _, wrapper in pairs(private.wrappers) do
		wrapper:CancelIfPending()
	end
end

function private.IsItemKey(value)
	if type(value) ~= "table" then
		return false
	end
	for _, key in ipairs(ITEM_KEY_KEYS) do
		if not value[key] then
			return false
		end
	end
	return true
end

function private.ItemKeyToStr(itemKey)
	assert(#private.itemKeyPartsTemp == 0)
	if itemKey.itemID ~= 0 then
		tinsert(private.itemKeyPartsTemp, "itemID="..itemKey.itemID)
	end
	if itemKey.itemLevel ~= 0 then
		tinsert(private.itemKeyPartsTemp, "itemLevel="..itemKey.itemLevel)
	end
	if itemKey.itemSuffix ~= 0 then
		tinsert(private.itemKeyPartsTemp, "itemSuffix="..itemKey.itemSuffix)
	end
	if itemKey.battlePetSpeciesID ~= 0 then
		tinsert(private.itemKeyPartsTemp, "battlePetSpeciesID="..itemKey.battlePetSpeciesID)
	end
	local result = format("{%s}", table.concat(private.itemKeyPartsTemp, ","))
	wipe(private.itemKeyPartsTemp)
	return result
end

function private.SortsToStr(sorts)
	assert(#private.sortsPartsTemp == 0)
	for _, sort in ipairs(sorts) do
		local name = Table.KeyByValue(Enum.AuctionHouseSortOrder, sort.sortOrder) or "?"
		tinsert(private.sortsPartsTemp, format("%s%s", sort.reverseSort and "-" or "", name))
	end
	local result = format("{%s}", table.concat(private.sortsPartsTemp, ","))
	wipe(private.sortsPartsTemp)
	return result
end

function private.ArgToStr(arg)
	if type(arg) == "table" then
		local count = Table.Count(arg)
		if private.IsItemKey(arg) then
			return private.ItemKeyToStr(arg)
		elseif arg.searchString then
			return format("{searchString=\"%s\", sorts=%s, minLevel=%s, maxLevel=%s, filters=%s, itemClassFilters=%s}", arg.searchString, private.SortsToStr(arg.sorts), private.ArgToStr(arg.minLevel), private.ArgToStr(arg.maxLevel), private.ArgToStr(arg.filters), private.ArgToStr(arg.itemClassFilters))
		elseif arg.IsBagAndSlot then
			return format("{<ItemLocation:(%d,%d)>}", arg:GetBagAndSlot())
		elseif count == 0 then
			return "{}"
		elseif count == #arg then
			return format("{<%d items>}", count)
		else
			return "{...}"
		end
	else
		return tostring(arg)
	end
end

function private.ArgsToStr(...)
	assert(#private.argsTemp == 0)
	for _, arg in Vararg.Iterator(...) do
		tinsert(private.argsTemp, private.ArgToStr(arg))
	end
	local result = table.concat(private.argsTemp, ",")
	wipe(private.argsTemp)
	return result
end

function private.RegisterForEvent(eventName, wrapper)
	local genericEventArg = nil
	eventName, genericEventArg = strsplit(GENERIC_EVENT_SEP, eventName)
	if not private.events[eventName] then
		private.events[eventName] = {}
		Event.Register(eventName, private.EventHandler)
	end
	if genericEventArg then
		private.events[eventName][genericEventArg] = private.events[eventName][genericEventArg] or {}
		tinsert(private.events[eventName][genericEventArg], wrapper)
	else
		tinsert(private.events[eventName], wrapper)
	end
end

function private.EventHandler(eventName, ...)
	-- reduce the log spam of generic events by combining the message with the name and discarding arguments
	local genericEventArg = nil
	if eventName == "UI_ERROR_MESSAGE" and select(1, ...) == ERR_AUCTION_DATABASE_ERROR then
		-- log an analytics event for "Internal Auction Error" messages
		for apiName, wrapper in pairs(private.wrappers) do
			if not wrapper:IsIdle() then
				Analytics.Action("AH_INTERNAL_ERROR", private.GetAnalyticsRegionRealm(), apiName)
				break
			end
		end
	end
	if GENERIC_EVENTS[eventName] then
		genericEventArg = select(GENERIC_EVENTS[eventName], ...)
		assert(genericEventArg)
		if not private.events[eventName][genericEventArg] then
			return
		end
		private.EventHandlerHelper(private.events[eventName][genericEventArg], eventName, genericEventArg)
	else
		private.EventHandlerHelper(private.events[eventName], eventName, ...)
	end
end

function private.ResponseReceivedHandler(eventName, ...)
	Log.Info("%s (%s)", eventName, private.ArgsToStr(...))
	private.lastResponseReceived = GetTime()
end

function private.UnusedEventHandler(eventName, ...)
	Log.Info("%s (%s)", eventName, private.ArgsToStr(...))
end

function private.EventHandlerHelper(wrappers, eventName, ...)
	if not SILENT_EVENTS[eventName] then
		Log.Info("%s (%s)", eventName, private.ArgsToStr(...))
	end
	for _, wrapper in ipairs(wrappers) do
		wrapper:_HandleEvent(eventName, ...)
	end
end

function private.CheckAllIdle()
	for apiName, wrapper in pairs(private.wrappers) do
		if not wrapper:IsIdle() then
			Log.Err("Another wrapper is pending (%s)", apiName)
			return false
		end
	end
	return true
end

function private.AuctionCanceledHandler(_, auctionId)
	private.lastAuctionCanceledAuctionId = auctionId
	private.lastAuctionCanceledTime = GetTime()
end

function private.ItemSearchResultsUpdated(_, itemKey, auctionId)
	if private.lastAuctionCanceledTime == GetTime() and auctionId then
		Log.Info("Auction ID changed from %s to %s", tostring(private.lastAuctionCanceledAuctionId), tostring(auctionId))
		local newResultInfo = nil
		for i = 1, C_AuctionHouse.GetNumItemSearchResults(itemKey) do
			local info = C_AuctionHouse.GetItemSearchResultInfo(itemKey, i)
			if info.auctionID == auctionId then
				newResultInfo = info
				break
			end
		end
		if not newResultInfo then
			Log.Warn("Failed to find new result info")
		end
		for _, callback in ipairs(private.auctionIdUpdateCallbacks) do
			callback(private.lastAuctionCanceledAuctionId, auctionId, newResultInfo)
		end
		private.lastAuctionCanceledAuctionId = nil
		private.lastAuctionCanceledTime = 0
	end
end

function private.GetAnalyticsRegionRealm()
	return TSM.GetRegion().."-"..gsub(GetRealmName(), "\226", "'")
end
