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

--- AuctionScrollingTable UI Element Class.
-- An auction scrolling table displays a scrollable list of auctions with a fixed set of columns. It operations on
-- auction records returned by the scanning code. It is a subclass of the @{ScrollingTable} class.
-- @classmod AuctionScrollingTable

local _, TSM = ...
local L = TSM.Include("Locale").GetTable()
local Math = TSM.Include("Util.Math")
local Money = TSM.Include("Util.Money")
local String = TSM.Include("Util.String")
local TempTable = TSM.Include("Util.TempTable")
local Theme = TSM.Include("Util.Theme")
local ScriptWrapper = TSM.Include("Util.ScriptWrapper")
local ItemInfo = TSM.Include("Service.ItemInfo")
local PlayerInfo = TSM.Include("Service.PlayerInfo")
local AuctionScrollingTable = TSM.Include("LibTSMClass").DefineClass("AuctionScrollingTable", TSM.UI.ScrollingTable)
local UIElements = TSM.Include("UI.UIElements")
UIElements.Register(AuctionScrollingTable)
TSM.UI.AuctionScrollingTable = AuctionScrollingTable
local private = {
	sortContext = {
		rowSortValue = {},
		baseSortValue = {},
		sortValue = {},
		self = nil,
	},
	subRowsTemp = {},
}
local ICON_SIZE = 12



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

function AuctionScrollingTable.__init(self)
	self.__super:__init()
	self._auctionScan = nil
	self._marketValueFunc = nil
	self._sortCol = nil
	self._sortAscending = nil
	self._expanded = {}
	self._firstSubRowByItem = {}
	self._rowByItem = {}
	self._browseResultsVisible = false
	self._firstUnscannedItem = nil
	self._selectionBaseItemString = nil
	self._selectionBaseSortValue = nil
	self._selectionSubRowIndex = nil
	self._currentSearchItem = nil
	self._onResultsUpdated = function()
		self:UpdateData(true)
	end
	self._getNextSearchItem = function()
		if self._selectionBaseItemString and not self._firstSubRowByItem[self._selectionBaseItemString] then
			-- the selected row has priority
			return self._selectionBaseItemString
		end
		return self._firstUnscannedItem
	end
	self._currentSearchChanged = function(_, baseItemString)
		if not baseItemString then
			-- the search was paused or unpaused, so just update the current item
			baseItemString = self._currentSearchItem
		end
		self._currentSearchItem = baseItemString
		-- layout the new row to update it's action icon state
		for _, row in ipairs(self._rows) do
			local data = row:GetData()
			if data and not data:IsSubRow() and data:GetBaseItemString() == baseItemString then
				row:_LayoutDataRow()
			end
		end
	end
end

function AuctionScrollingTable.Acquire(self)
	self.__super:Acquire()
	-- temporarily set a context table so we can create the table columns (should be overridden later)
	self:GetScrollingTableInfo()
		:NewColumn("item")
			:SetHeaderIndent("8")
			:SetTitle(L["Item"])
			:SetFont("ITEM_BODY3")
			:SetJustifyH("LEFT")
			:SetIconSize(ICON_SIZE)
			:SetTextFunction(private.GetItemCellText)
			:SetIconFunction(private.GetItemCellIcon)
			:SetTooltipFunction(private.GetItemCellTooltip)
			:SetExpanderStateFunction(private.GetExpanderState)
			:SetBadgeStateFunction(private.GetBadgeState)
			:SetActionIconInfo(1, 12, private.GetPendingIcon)
			:SetActionIconClickHandler(private.OnPendingIconClick)
			:DisableHiding()
			:Commit()
		:NewColumn("ilvl")
			:SetTitle(L["ilvl"])
			:SetFont("TABLE_TABLE1")
			:SetJustifyH("RIGHT")
			:SetTextFunction(private.GetItemLevelCellText)
			:Commit()
	if not TSM.IsWowClassic() then
		self:GetScrollingTableInfo()
			:NewColumn("qty")
				:SetTitle(L["Qty"])
				:SetFont("TABLE_TABLE1")
				:SetJustifyH("RIGHT")
				:SetTextFunction(private.GetAuctionsQuantityText)
				:Commit()
	else
		self:GetScrollingTableInfo()
			:NewColumn("posts")
				:SetTitle(L["Posts"])
				:SetFont("TABLE_TABLE1")
				:SetJustifyH("RIGHT")
				:SetTextFunction(private.GetAuctionsPostsText)
				:Commit()
			:NewColumn("stack")
				:SetTitle(L["Stack"])
				:SetFont("TABLE_TABLE1")
				:SetJustifyH("RIGHT")
				:SetTextFunction(private.GetAuctionsStackText)
				:Commit()
	end
	self:GetScrollingTableInfo()
		:NewColumn("timeLeft")
			:SetTitleIcon("iconPack.14x14/Clock")
			:SetFont("TABLE_TABLE1")
			:SetJustifyH("CENTER")
			:SetTextFunction(private.GetTimeLeftCellText)
			:Commit()
		:NewColumn("seller")
			:SetTitle(L["Seller"])
			:SetFont("ITEM_BODY3")
			:SetJustifyH("LEFT")
			:SetTextFunction(private.GetSellerCellText)
			:Commit()
		:NewColumn("itemBid")
			:SetTitle(L["Bid (item)"])
			:SetFont("TABLE_TABLE1")
			:SetJustifyH("RIGHT")
			:SetTextFunction(private.GetItemBidCellText)
			:Commit()
		:NewColumn("bid")
			:SetTitle(TSM.IsWowClassic() and L["Bid (stack)"] or L["Bid (total)"])
			:SetFont("TABLE_TABLE1")
			:SetJustifyH("RIGHT")
			:SetTextFunction(private.GetBidCellText)
			:Commit()
		:NewColumn("itemBuyout")
			:SetTitle(L["Buyout (item)"])
			:SetFont("TABLE_TABLE1")
			:SetJustifyH("RIGHT")
			:SetTextFunction(private.GetItemBuyoutCellText)
			:Commit()
		:NewColumn("buyout")
			:SetTitle(TSM.IsWowClassic() and L["Buyout (stack)"] or L["Buyout (total)"])
			:SetFont("TABLE_TABLE1")
			:SetJustifyH("RIGHT")
			:SetTextFunction(private.GetBuyoutCellText)
			:Commit()
		:NewColumn("bidPct")
			:SetTitle(BID.." %")
			:SetFont("TABLE_TABLE1")
			:SetJustifyH("RIGHT")
			:SetTextFunction(private.GetBidPercentCellText)
			:Commit()
		:NewColumn("pct")
			:SetTitle("%")
			:SetFont("TABLE_TABLE1")
			:SetJustifyH("RIGHT")
			:SetTextFunction(private.GetPercentCellText)
			:Commit()
		:Commit()
	self._sortCol = "pct"
	self._sortAscending = true
end

function AuctionScrollingTable.Release(self)
	if self._auctionScan then
		self._auctionScan:RemoveResultsUpdateCallback(self._onResultsUpdated)
		self._auctionScan:SetNextSearchItemFunction(nil)
		self._auctionScan:SetScript("OnCurrentSearchChanged", nil)
	end
	self._auctionScan = nil
	self._sortCol = nil
	self._sortAscending = nil
	self._marketValueFunc = nil
	self._browseResultsVisible = false
	self._firstUnscannedItem = nil
	wipe(self._expanded)
	wipe(self._firstSubRowByItem)
	wipe(self._rowByItem)
	for _, row in ipairs(self._rows) do
		ScriptWrapper.Clear(row._frame, "OnDoubleClick")
		for _, tooltipFrame in pairs(row._buttons) do
			ScriptWrapper.Clear(tooltipFrame, "OnDoubleClick")
		end
	end
	self._selectionBaseItemString = nil
	self._selectionBaseSortValue = nil
	self._selectionSubRowIndex = nil
	self._currentSearchItem = nil
	self.__super:Release()
end

--- Sets the @{DatabaseQuery} source for this table.
-- This query is used to populate the entries in the auction scrolling table.
-- @tparam AuctionScrollingTable self The auction scrolling table object
-- @tparam AuctionScanManager auctionScan The auction scan object
-- @tparam[opt=false] bool redraw Whether or not to redraw the scrolling table
-- @treturn AuctionScrollingTable The auction scrolling table object
function AuctionScrollingTable.SetAuctionScan(self, auctionScan, redraw)
	if auctionScan == self._auctionScan and not redraw then
		return self
	end
	if self._auctionScan then
		self._auctionScan:RemoveResultsUpdateCallback(self._onResultsUpdated)
		self._auctionScan:SetNextSearchItemFunction(nil)
		self._auctionScan:SetScript("OnCurrentSearchChanged", nil)
	end
	self._auctionScan = auctionScan
	self._auctionScan:AddResultsUpdateCallback(self._onResultsUpdated)
	self._auctionScan:SetNextSearchItemFunction(self._getNextSearchItem)
	self._auctionScan:SetScript("OnCurrentSearchChanged", self._currentSearchChanged)
	wipe(self._expanded)
	self:UpdateData(redraw)
	return self
end

--- Sets whether or not browse results are visible.
-- @tparam AuctionScrollingTable self The auction scrolling table object
-- @tparam boolean visible Whether or not browse results should be visible
-- @treturn AuctionScrollingTable The auction scrolling table object
function AuctionScrollingTable.SetBrowseResultsVisible(self, visible)
	self._browseResultsVisible = visible
	return self
end

--- Sets the market value function.
-- @tparam AuctionScrollingTable self The auction scrolling table object
-- @tparam function func The function to call with the item DB record to get the market value
-- @treturn AuctionScrollingTable The auction scrolling table object
function AuctionScrollingTable.SetMarketValueFunction(self, func)
	if self._marketValueFunc ~= func then
		self._marketValueFunc = func
		self:UpdateData(false)
	end
	return self
end

--- Gets the selected result row.
-- @tparam AuctionScrollingTable self The auction scrolling table object
-- @return The selected result row or nil if there's no selection
function AuctionScrollingTable.GetSelection(self)
	local selection = self.__super:GetSelection()
	if not selection then
		return nil
	end
	local baseItemString = selection:GetBaseItemString()
	if not selection:IsSubRow() then
		selection = self._firstSubRowByItem[baseItemString] or selection
	end
	return selection
end

--- Sets the selected result row.
-- @tparam AuctionScrollingTable self The auction scrolling table object
-- @param selection The selected result row or nil to clear the selection
-- @treturn AuctionScrollingTable The auction scrolling table object
function AuctionScrollingTable.SetSelection(self, selection)
	if not selection then
		self._selectionBaseItemString = nil
		self._selectionBaseSortValue = nil
		self._selectionSubRowIndex = nil
		return self.__super:SetSelection(selection)
	end
	local baseItemString = selection:GetBaseItemString()
	if selection == self._firstSubRowByItem[baseItemString] then
		selection = self._rowByItem[baseItemString]
		assert(selection)
	end
	self._selectionBaseItemString = baseItemString
	self._selectionBaseSortValue = self:_GetSortValue(selection, self._sortCol, self._sortAscending)
	local firstIndex = nil
	for i, data in ipairs(self._data) do
		if not firstIndex and data:GetBaseItemString() == baseItemString then
			firstIndex = i
		end
		if data == selection then
			self._selectionSubRowIndex = i - firstIndex + 1
			break
		end
	end
	assert(self._selectionSubRowIndex)
	return self.__super:SetSelection(selection)
end

--- Expands a single auction result.
-- If there is a single top-level auction result, this will cause it to be expanded. Otherwise, this does nothing.
-- @tparam AuctionScrollingTable self The auction scrolling table object
-- @treturn AuctionScrollingTable The auction scrolling table object
function AuctionScrollingTable.ExpandSingleResult(self)
	-- if only one result, expand it
	local singleResult = nil
	for baseItemString in pairs(self._firstSubRowByItem) do
		if not singleResult then
			singleResult = baseItemString
		elseif singleResult then
			singleResult = nil
			break
		end
	end
	if singleResult then
		self._expanded[singleResult] = true
		self:UpdateData(true)
	end
end

--- Sets the % column header tooltip.
-- @tparam AuctionScrollingTable self The auction scrolling table object
-- @param tooltip The tooltip
-- @treturn AuctionScrollingTable The auction scrolling table object
function AuctionScrollingTable.SetPctTooltip(self, tooltip)
	self._tableInfo:GetColById("pct"):SetHeaderTooltip(tooltip)
	return self
end

function AuctionScrollingTable.Draw(self)
	self.__super:Draw()
	self._header:SetSort(self._sortCol, self._sortAscending)
end



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

function AuctionScrollingTable._UpdateData(self)
	if not self._auctionScan then
		return
	end
	local sortKey = self._sortCol
	wipe(self._data)
	wipe(self._firstSubRowByItem)
	wipe(self._rowByItem)
	self._firstUnscannedItem = nil
	private.sortContext.self = self

	local rows = TempTable.Acquire()
	local subRows = TempTable.Acquire()
	local subRowsStart = TempTable.Acquire()
	local subRowsEnd = TempTable.Acquire()
	local sortAscending = self._sortAscending
	for _, query in self._auctionScan:QueryIterator() do
		for baseItemString, row in query:BrowseResultsIterator() do
			if not self._rowByItem[baseItemString] and row:HasItemInfo() then
				assert(not next(private.subRowsTemp))
				local hasSubRows = false
				for _, subRow in row:SubRowIterator() do
					hasSubRows = true
					local sortValue = self:_GetSortValue(subRow, sortKey, sortAscending)
					private.sortContext.sortValue[subRow] = sortValue
					tinsert(private.subRowsTemp, subRow)
				end
				if hasSubRows then
					-- sort all the subRows
					sort(private.subRowsTemp, sortAscending and private.SortSubRowAscendingHelper or private.SortSubRowDescendingHelper)
					-- grab the first subRow which is shown when this item is collapsed
					assert(not self._firstSubRowByItem[baseItemString])
					local firstSubRow = private.subRowsTemp[1]
					self._firstSubRowByItem[baseItemString] = firstSubRow
					private.sortContext.baseSortValue[baseItemString] = private.sortContext.sortValue[firstSubRow]
					private.sortContext.rowSortValue[row] = private.sortContext.baseSortValue[baseItemString]
					-- add all the subRows if this item is expanded
					if self._expanded[baseItemString] and #private.subRowsTemp > 1 then
						subRowsStart[baseItemString] = #subRows + 1
						for i = 2, #private.subRowsTemp do
							tinsert(subRows, private.subRowsTemp[i])
						end
						subRowsEnd[baseItemString] = #subRows
					end
				elseif self._browseResultsVisible then
					private.sortContext.baseSortValue[baseItemString] = self:_GetSortValue(row, sortKey, sortAscending)
					private.sortContext.rowSortValue[row] = private.sortContext.baseSortValue[baseItemString]
				end

				-- insert this row
				if hasSubRows or self._browseResultsVisible then
					self._rowByItem[baseItemString] = row
					tinsert(rows, row)
				else
					self._rowByItem[baseItemString] = private.subRowsTemp[1]
				end
				wipe(private.subRowsTemp)
			end
		end
	end

	-- sort the rows
	sort(rows, sortAscending and private.SortRowAscendingHelper or private.SortRowDescendingHelper)

	-- insert all the data
	local hasPrevSelection, nextIndexSelection = false, nil
	for _, row in ipairs(rows) do
		local baseItemString = row:GetBaseItemString()
		if not self._firstSubRowByItem[baseItemString] and not self._firstUnscannedItem then
			self._firstUnscannedItem = baseItemString
		end
		tinsert(self._data, row)
		hasPrevSelection = hasPrevSelection or self._selection == row
		local startIndex = subRowsStart[baseItemString]
		if startIndex then
			for i = startIndex, subRowsEnd[baseItemString] do
				local subRow = subRows[i]
				tinsert(self._data, subRow)
				hasPrevSelection = hasPrevSelection or self._selection == subRow
			end
			if not hasPrevSelection and not nextIndexSelection then
				if self._selectionBaseItemString == baseItemString and self._selectionSubRowIndex then
					-- check if we can find the new selection based on the sub row index (subtract 2 because subRows start from index 2)
					local index = startIndex + self._selectionSubRowIndex - 2
					if index <= subRowsEnd[baseItemString] then
						nextIndexSelection = subRows[index]
					end
				end
			end
		end
	end
	TempTable.Release(rows)
	TempTable.Release(subRows)
	TempTable.Release(subRowsStart)
	TempTable.Release(subRowsEnd)

	if self._selection and not hasPrevSelection then
		-- the previous selection doesn't exist anymore
		local firstSubRow = self._firstSubRowByItem[self._selectionBaseItemString]
		if nextIndexSelection then
			-- we can select the next subRow for the same item
			self:SetSelection(nextIndexSelection)
		elseif firstSubRow then
			-- select the first row of the same item
			self:SetSelection(firstSubRow)
		elseif self._selectionBaseSortValue then
			-- select the next row by sort value (if it exists)
			local bestSortValue, bestNewSelection = nil, nil
			for _, row in ipairs(self._data) do
				local baseItemString = row:GetBaseItemString()
				local sortValue = private.sortContext.baseSortValue[baseItemString]
				if sortValue and (row == self._firstSubRowByItem[baseItemString] or row == self._rowByItem[baseItemString]) then
					local isBetterSortValue = nil
					if self._sortAscending then
						isBetterSortValue = (not bestSortValue or sortValue < bestSortValue) and (sortValue > self._selectionBaseSortValue or (sortValue == self._selectionBaseSortValue and baseItemString > self._selectionBaseItemString))
					else
						isBetterSortValue = (not bestSortValue or sortValue > bestSortValue) and (sortValue < self._selectionBaseSortValue or (sortValue == self._selectionBaseSortValue and baseItemString < self._selectionBaseItemString))
					end
					if isBetterSortValue then
						bestSortValue = sortValue
						bestNewSelection = row
					end
				end
			end
			self:SetSelection(bestNewSelection)
		else
			self:SetSelection(nil)
		end
	elseif self._selection then
		self._selectionBaseSortValue = private.sortContext.baseSortValue[self._selection:GetBaseItemString()]
	end

	wipe(private.sortContext.sortValue)
	wipe(private.sortContext.rowSortValue)
	wipe(private.sortContext.baseSortValue)
	private.sortContext.self = nil
end

function AuctionScrollingTable._GetSortValue(self, row, id, isAscending)
	if id == "item" then
		local baseItemString = row:GetBaseItemString()
		return ItemInfo.GetName(baseItemString)
	elseif id == "ilvl" then
		return ItemInfo.GetItemLevel(row:GetItemString() or row:GetBaseItemString())
	elseif id == "posts" then
		local _, numAuctions = row:GetQuantities()
		return numAuctions or (isAscending and math.huge or -math.huge)
	elseif id == "stack" then
		local quantity = row:GetQuantities()
		return quantity or (isAscending and math.huge or -math.huge)
	elseif id == "qty" then
		local quantity, numAuctions = row:GetQuantities()
		if not quantity or not numAuctions then
			return isAscending and math.huge or -math.huge
		end
		return quantity * numAuctions
	elseif id == "timeLeft" then
		if not row:IsSubRow() then
			return isAscending and math.huge or -math.huge
		end
		local timeLeft = row:GetListingInfo()
		return timeLeft
	elseif id == "seller" then
		if not row:IsSubRow() then
			return ""
		end
		local ownerStr = row:GetOwnerInfo()
		return ownerStr
	elseif id == "itemBid" then
		if not row:IsSubRow() then
			return isAscending and math.huge or -math.huge
		end
		local _, itemDisplayedBid = row:GetDisplayedBids()
		return itemDisplayedBid
	elseif id == "bid" then
		if not row:IsSubRow() then
			return isAscending and math.huge or -math.huge
		end
		local displayedBid = row:GetDisplayedBids()
		return displayedBid
	elseif id == "itemBuyout" then
		local _, itemBuyout, minItemBuyout = row:GetBuyouts()
		itemBuyout = itemBuyout or minItemBuyout or 0
		return itemBuyout == 0 and (isAscending and math.huge or -math.huge) or itemBuyout
	elseif id == "buyout" then
		local buyout = row:GetBuyouts() or 0
		return buyout == 0 and (isAscending and math.huge or -math.huge) or buyout
	elseif id == "bidPct" then
		local _, pct = self:_GetMarketValuePct(row)
		return pct or (isAscending and math.huge or -math.huge)
	elseif id == "pct" then
		local pct = self:_GetMarketValuePct(row)
		return pct or (isAscending and math.huge or -math.huge)
	else
		error("Invalid sort col id: "..tostring(id))
	end
end

function AuctionScrollingTable._GetTableRow(self, isHeader)
	local row = self.__super:_GetTableRow(isHeader)
	if not isHeader then
		for _, tooltipFrame in pairs(row._buttons) do
			ScriptWrapper.SetPropagate(tooltipFrame, "OnDoubleClick")
		end
		ScriptWrapper.Set(row._frame, "OnDoubleClick", private.RowOnDoubleClick, row)
	end
	return row
end

function AuctionScrollingTable._GetMarketValuePct(self, row)
	if not self._marketValueFunc then
		-- no market value function was set
		return nil, nil
	end
	local marketValue = self._marketValueFunc(row) or 0
	if marketValue == 0 then
		-- this item doesn't have a market value
		return nil, nil
	end
	local _, itemBuyout, minItemBuyout = row:GetBuyouts()
	itemBuyout = itemBuyout or minItemBuyout or 0
	local bidPct = nil
	if row:IsSubRow() then
		local _, itemDisplayedBid = row:GetDisplayedBids()
		bidPct = itemDisplayedBid / marketValue
	end
	return itemBuyout > 0 and itemBuyout / marketValue or nil, bidPct
end

function AuctionScrollingTable._ToggleSort(self, id)
	if not self._sortCol or not self._auctionScan then
		-- sorting disabled so ignore
		return
	end

	local sortCol = nil
	for _, col in ipairs(self._tableInfo:_GetCols()) do
		if col:_GetId() == id then
			sortCol = col:_GetId()
		end
	end
	assert(sortCol)
	if sortCol == self._sortCol then
		self._sortAscending = not self._sortAscending
	else
		self._sortCol = sortCol
		self._sortAscending = true
	end
	if self._selection then
		self._selectionBaseSortValue = self:_GetSortValue(self._selection, self._sortCol, self._sortAscending)
	end
	self:UpdateData(true)
end

function AuctionScrollingTable._SetRowData(self, row, data)
	local ownerStr = private.GetSellerCellText(self, data)
	local isPlayer = false
	if ownerStr ~= "" then
		for owner in String.SplitIterator(ownerStr, ",") do
			if PlayerInfo.IsPlayer(owner, true, true, true) then
				isPlayer = true
				break
			end
		end
	end
	if isPlayer then
		row._texts.seller:SetTextColor(0.3, 0.6, 1, 1)
	else
		row._texts.seller:SetTextColor(1, 1, 1, 1)
	end
	self.__super:_SetRowData(row, data)
end



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

function private.RowOnDoubleClick(row, mouseButton)
	if mouseButton ~= "LeftButton" then
		return
	end
	local self = row._scrollingTable
	local subRow = row:GetData()
	local baseItemString = subRow:GetBaseItemString()
	self._expanded[baseItemString] = not self._expanded[baseItemString]
	self:UpdateData(true)
end



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

function private.SortRowAscendingHelper(a, b)
	local sortContext = private.sortContext
	local aSortValue = sortContext.rowSortValue[a]
	local bSortValue = sortContext.rowSortValue[b]
	if aSortValue == bSortValue then
		return a:GetBaseItemString() < b:GetBaseItemString()
	end
	return aSortValue < bSortValue
end

function private.SortRowDescendingHelper(a, b)
	local sortContext = private.sortContext
	local aSortValue = sortContext.rowSortValue[a]
	local bSortValue = sortContext.rowSortValue[b]
	if aSortValue == bSortValue then
		return a:GetBaseItemString() > b:GetBaseItemString()
	end
	return aSortValue > bSortValue
end

function private.SortSubRowAscendingHelper(a, b)
	local sortContext = private.sortContext
	local aBaseItemString = a:GetBaseItemString()
	local bBaseItemString = b:GetBaseItemString()
	if aBaseItemString == bBaseItemString then
		local aSortValue = sortContext.sortValue[a]
		local bSortValue = sortContext.sortValue[b]
		if aSortValue == bSortValue then
			-- always show base records first
			local self = sortContext.self
			if self._firstSubRowByItem[aBaseItemString] == a then
				return true
			elseif self._firstSubRowByItem[bBaseItemString] == b then
				return false
			else
				-- order by buyout
				local _, aItemBuyout = a:GetBuyouts()
				local _, bItemBuyout = b:GetBuyouts()
				if aItemBuyout ~= bItemBuyout then
					return aItemBuyout < bItemBuyout
				end
				-- show the higher auctionId first
				local _, aAuctionId, aBrowseId = a:GetListingInfo()
				local _, bAuctionId, bBrowseId = b:GetListingInfo()
				if aAuctionId ~= bAuctionId then
					return aAuctionId > bAuctionId
				else
					return aBrowseId > bBrowseId
				end
			end
		end
		return aSortValue < bSortValue
	else
		-- we're sorting different items
		local aSortValue = sortContext.baseSortValue[aBaseItemString]
		local bSortValue = sortContext.baseSortValue[bBaseItemString]
		if aSortValue == bSortValue then
			return aBaseItemString < bBaseItemString
		end
		return aSortValue < bSortValue
	end
end

function private.SortSubRowDescendingHelper(a, b)
	local sortContext = private.sortContext
	local aBaseItemString = a:GetBaseItemString()
	local bBaseItemString = b:GetBaseItemString()
	if aBaseItemString == bBaseItemString then
		local aSortValue = sortContext.sortValue[a]
		local bSortValue = sortContext.sortValue[b]
		if aSortValue == bSortValue then
			-- always show base records first
			local self = sortContext.self
			if self._firstSubRowByItem[aBaseItemString] == a then
				return true
			elseif self._firstSubRowByItem[bBaseItemString] == b then
				return false
			else
				-- order by buyout
				local _, aItemBuyout = a:GetBuyouts()
				local _, bItemBuyout = b:GetBuyouts()
				if aItemBuyout ~= bItemBuyout then
					return aItemBuyout < bItemBuyout
				end
				-- show the higher auctionId first
				local _, aAuctionId, aBrowseId = a:GetListingInfo()
				local _, bAuctionId, bBrowseId = b:GetListingInfo()
				if aAuctionId ~= bAuctionId then
					return aAuctionId > bAuctionId
				else
					return aBrowseId > bBrowseId
				end
			end
		end
		return aSortValue > bSortValue
	else
		-- we're sorting different items
		local aSortValue = sortContext.baseSortValue[aBaseItemString]
		local bSortValue = sortContext.baseSortValue[bBaseItemString]
		if aSortValue == bSortValue then
			return aBaseItemString > bBaseItemString
		end
		return aSortValue > bSortValue
	end
end

function private.GetItemCellText(self, subRow)
	local isIndented = subRow:IsSubRow()
	if not subRow:IsSubRow() then
		local baseItemString = subRow:GetBaseItemString()
		local itemString = subRow:GetItemString()
		subRow = self._firstSubRowByItem[baseItemString]
		if not subRow then
			return TSM.UI.GetColoredItemName(itemString or baseItemString, 0)
		end
	end
	local itemLink = subRow:GetLinks()
	-- TODO: use theme constant for indented tint pct
	return TSM.UI.GetColoredItemName(itemLink, isIndented and -20 or 0)
end

function private.GetItemLevelCellText(self, subRow)
	if not subRow:IsSubRow() then
		local baseItemString = subRow:GetBaseItemString()
		local itemString = subRow:GetItemString()
		subRow = self._firstSubRowByItem[baseItemString]
		if not subRow then
			return ItemInfo.GetItemLevel(itemString or baseItemString)
		end
	end
	local itemLink = subRow:GetLinks()
	return ItemInfo.GetItemLevel(itemLink)
end

function private.GetAuctionsQuantityText(self, subRow)
	if not subRow:IsSubRow() then
		local baseItemString = subRow:GetBaseItemString()
		subRow = self._firstSubRowByItem[baseItemString] or subRow
	end
	local quantity, numAuctions = subRow:GetQuantities()
	if not quantity or not numAuctions then
		return ""
	end
	return quantity * numAuctions
end

function private.GetAuctionsPostsText(self, subRow)
	if not subRow:IsSubRow() then
		local baseItemString = subRow:GetBaseItemString()
		subRow = self._firstSubRowByItem[baseItemString] or subRow
	end
	local _, numAuctions = subRow:GetQuantities()
	return numAuctions
end

function private.GetAuctionsStackText(self, subRow)
	if not subRow:IsSubRow() then
		local baseItemString = subRow:GetBaseItemString()
		subRow = self._firstSubRowByItem[baseItemString] or subRow
	end
	local quantity = subRow:GetQuantities()
	return quantity
end

function private.GetTimeLeftCellText(self, subRow)
	if not subRow:IsSubRow() then
		local baseItemString = subRow:GetBaseItemString()
		subRow = self._firstSubRowByItem[baseItemString]
		if not subRow then
			return ""
		end
	end
	local timeLeft = subRow:GetListingInfo()
	return TSM.UI.GetTimeLeftString(timeLeft)
end

function private.GetSellerCellText(self, subRow)
	if not subRow:IsSubRow() then
		local baseItemString = subRow:GetBaseItemString()
		subRow = self._firstSubRowByItem[baseItemString]
		if not subRow then
			return ""
		end
	end
	local ownerStr = subRow:GetOwnerInfo()
	return ownerStr
end

function private.GetItemBidCellText(self, subRow)
	if not subRow:IsSubRow() then
		local baseItemString = subRow:GetBaseItemString()
		subRow = self._firstSubRowByItem[baseItemString]
		if not subRow then
			return ""
		end
	end
	local _, itemDisplayedBid = subRow:GetDisplayedBids()
	local _, _, _, isHighBidder = subRow:GetBidInfo()
	return Money.ToString(itemDisplayedBid, isHighBidder and Theme.GetFeedbackColor("GREEN"):GetTextColorPrefix() or nil, "OPT_83_NO_COPPER")
end

function private.GetBidCellText(self, subRow)
	if not subRow:IsSubRow() then
		local baseItemString = subRow:GetBaseItemString()
		subRow = self._firstSubRowByItem[baseItemString]
		if not subRow then
			return ""
		end
	end
	local displayedBid = subRow:GetDisplayedBids()
	local _, _, _, isHighBidder = subRow:GetBidInfo()
	return Money.ToString(displayedBid, isHighBidder and Theme.GetFeedbackColor("GREEN"):GetTextColorPrefix() or nil, "OPT_83_NO_COPPER")
end

function private.GetItemBuyoutCellText(self, subRow)
	if not subRow:IsSubRow() then
		local baseItemString = subRow:GetBaseItemString()
		subRow = self._firstSubRowByItem[baseItemString] or subRow
	end
	local _, itemBuyout, minItemBuyout = subRow:GetBuyouts()
	local value = itemBuyout or minItemBuyout
	if not value then
		return ""
	end
	return Money.ToString(value, nil, "OPT_83_NO_COPPER")
end

function private.GetBuyoutCellText(self, subRow)
	if not subRow:IsSubRow() then
		local baseItemString = subRow:GetBaseItemString()
		subRow = self._firstSubRowByItem[baseItemString] or subRow
	end
	local buyout = subRow:GetBuyouts()
	if not buyout then
		return ""
	end
	return Money.ToString(buyout, nil, "OPT_83_NO_COPPER")
end

function private.GetBidPercentCellText(self, subRow)
	if not subRow:IsSubRow() then
		local baseItemString = subRow:GetBaseItemString()
		subRow = self._firstSubRowByItem[baseItemString] or subRow
	end
	local _, pct = self:_GetMarketValuePct(subRow)
	pct = pct and Math.Round(pct * 100) or nil
	if not pct then
		return "---"
	end
	local pctColor = Theme.GetAuctionPercentColor(pct)
	if pct > 999 then
		pct = ">999"
	end
	return pctColor:ColorText(pct.."%")
end

function private.GetPercentCellText(self, subRow)
	if not subRow:IsSubRow() then
		local baseItemString = subRow:GetBaseItemString()
		subRow = self._firstSubRowByItem[baseItemString] or subRow
	end
	local pct, bidPct = self:_GetMarketValuePct(subRow)
	pct = pct and Math.Round(pct * 100) or nil
	bidPct = bidPct and Math.Round(bidPct * 100) or nil
	if pct then
		local pctColor = Theme.GetAuctionPercentColor(pct)
		if pct > 999 then
			pct = ">999"
		end
		return pctColor:ColorText(pct.."%")
	elseif bidPct then
		local pctColor = Theme.GetAuctionPercentColor("BID")
		if bidPct > 999 then
			bidPct = ">999"
		end
		return pctColor:ColorText(bidPct.."%")
	else
		return "---"
	end
end

function private.GetItemCellIcon(self, subRow)
	if not subRow:IsSubRow() then
		local baseItemString = subRow:GetBaseItemString()
		subRow = self._firstSubRowByItem[baseItemString]
		if not subRow then
			return ItemInfo.GetTexture(baseItemString)
		end
	end
	local itemLink = subRow:GetLinks()
	return ItemInfo.GetTexture(itemLink)
end

function private.GetItemCellTooltip(self, subRow)
	local baseItemString = subRow:GetBaseItemString()
	local itemString = subRow:GetItemString()
	if not subRow:IsSubRow() then
		subRow = self._firstSubRowByItem[baseItemString] or subRow
		baseItemString = subRow:GetBaseItemString()
		itemString = subRow:GetItemString()
	end
	if subRow:IsSubRow() then
		local _, rawLink = subRow:GetLinks()
		return rawLink or itemString or baseItemString
	else
		return itemString or baseItemString
	end
end

function private.GetExpanderState(self, subRow)
	if not subRow:IsSubRow() then
		local baseItemString = subRow:GetBaseItemString()
		subRow = self._firstSubRowByItem[baseItemString]
		if not subRow then
			return false, false, 0
		end
	end
	local baseItemString = subRow:GetBaseItemString()
	local isExpanded = self._expanded[baseItemString]
	local isIndented = isExpanded and subRow ~= self._firstSubRowByItem[baseItemString]
	local numSubRows = subRow:GetResultRow():GetNumSubRows()
	local expanderVisible = not isIndented and numSubRows > 1
	return expanderVisible, expanderVisible and isExpanded, isIndented and 1 or 0
end

function private.GetBadgeState(self, subRow)
	if not subRow:IsSubRow() then
		local baseItemString = subRow:GetBaseItemString()
		subRow = self._firstSubRowByItem[baseItemString]
		if not subRow then
			return false
		end
	end
	local baseItemString = subRow:GetBaseItemString()
	local numSubRows = subRow:GetResultRow():GetNumSubRows()
	local isVisible = not self._expanded[baseItemString] and numSubRows > 1
	return isVisible, numSubRows > 999 and "(999+)" or ("("..numSubRows..")")
end

function private.GetPendingIcon(self, subRow, iconIndex)
	assert(iconIndex == 1)
	if not subRow:IsSubRow() then
		local baseItemString = subRow:GetBaseItemString()
		subRow = self._firstSubRowByItem[baseItemString]
		if not subRow then
			local texture, shouldRotate = nil, nil
			if baseItemString == self._currentSearchItem then
				local _, isPaused = self._auctionScan:GetProgress()
				texture = "iconPack.12x12/Running"
				shouldRotate = not isPaused
			else
				texture = TSM.UI.TexturePacks.GetColoredKey("iconPack.12x12/Running", "ACTIVE_BG_ALT")
				shouldRotate = false
			end
			return true, texture, true, nil, shouldRotate
		end
	end
	return false
end

function private.OnPendingIconClick(self, data)
	self:SetSelection(data)
end
