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

--- Custom Price Functions
-- @module CustomPrice

local _, TSM = ...
local CustomPrice = TSM.Init("Service.CustomPrice")
local L = TSM.Include("Locale").GetTable()
local DisenchantInfo = TSM.Include("Data.DisenchantInfo")
local TempTable = TSM.Include("Util.TempTable")
local Table = TSM.Include("Util.Table")
local Math = TSM.Include("Util.Math")
local Money = TSM.Include("Util.Money")
local String = TSM.Include("Util.String")
local Log = TSM.Include("Util.Log")
local Theme = TSM.Include("Util.Theme")
local ItemString = TSM.Include("Util.ItemString")
local SmartMap = TSM.Include("Util.SmartMap")
local ItemInfo = TSM.Include("Service.ItemInfo")
local Settings = TSM.Include("Service.Settings")
local Conversions = TSM.Include("Service.Conversions")
local private = {
	context = {},
	priceSourceKeys = {},
	priceSourceInfo = {},
	customPriceCache = {},
	proxyData = {},
	settings = nil,
	sanitizeCache = {},
	customSourceCallbacks = {},
}
local ITEM_STRING_PATTERN = "[ip]:[0-9:%-]+"
local MONEY_PATTERNS = {
	"[^%.]([0-9]+g[ ]*[0-9]+s[ ]*[0-9]+c)", -- g/s/c
	"[^%.]([0-9]+g[ ]*[0-9]+s)", -- g/s
	"[^%.]([0-9]+g[ ]*[0-9]+c)", -- g/c
	"[^%.]([0-9]+s[ ]*[0-9]+c)", -- s/c
	"[^%.]([0-9]+g)", -- g
	"[^%.]([0-9]+s)", -- s
	"[^%.]([0-9]+c)", -- c
}
local MATH_FUNCTIONS = {
	["avg"] = "self._avg",
	["min"] = "self._min",
	["max"] = "self._max",
	["first"] = "self._first",
	["check"] = "self._check",
	["ifgt"] = "self._ifgt",
	["ifgte"] = "self._ifgte",
	["iflt"] = "self._iflt",
	["iflte"] = "self._iflte",
	["ifeq"] = "self._ifeq",
	["round"] = "self._round",
	["roundup"] = "self._roundup",
	["rounddown"] = "self._rounddown",
}
local CUSTOM_PRICE_FUNC_TEMPLATE = [[
	return function(self, _item, _baseitem)
		local isTop
		local context = self.globalContext
		if not context.num then
			context.num = 0
			isTop = true
		end
		context.num = context.num + 1
		if context.num > 100 then
			if (context.lastPrint or 0) + 1 < time() then
				context.lastPrint = time()
				self.loopError(self.origStr)
			end
			return
		end

		local result = floor((%s) + 0.5)
		if context.num then
			context.num = context.num - 1
		end
		if isTop then
			context.num = nil
		end
		if not result or self.IsInvalid(result) or result <= 0 then return end
		return result
	end
]]
local function IsInvalid(num)
	-- We want to treat math.huge/-math.huge/NAN as invalid.
	return num == math.huge or num == -math.huge or Math.IsNan(num)
end
-- Make sure our IsInvalid function continues to work as expected
assert(IsInvalid(Math.GetNan()) and IsInvalid(math.huge) and IsInvalid(math.huge) and not IsInvalid(0) and not IsInvalid(1000))
local COMPARISONS = {
	["gt"] = 1,
	["gte"] = 2,
	["lt"] = 3,
	["lte"] = 4,
	["eq"] = 5,
}



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

CustomPrice:OnSettingsLoad(function()
	private.settings = Settings.NewView()
		:AddKey("global", "userData", "customPriceSources")

	for name, str in pairs(private.settings.customPriceSources) do
		if CustomPrice.ValidateName(name, true) then
			str = private.SanitizeCustomPriceString(str)
			private.settings.customPriceSources[name] = str
			for _, data in pairs(private.proxyData) do
				if data.origStr == str then
					data.customPriceSourceNames[name] = true
				end
			end
		else
			Log.PrintfUser(L["Removed custom price source (%s) which has an invalid name."], name)
			CustomPrice.DeleteCustomPriceSource(name)
		end
	end
end)



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

function CustomPrice.OnEnable()
	for name in pairs(TSM.db.global.userData.customPriceSources) do
		if not CustomPrice.ValidateName(name, true) then
			Log.PrintfUser(L["Removed custom price source (%s) which has an invalid name."], name)
			CustomPrice.DeleteCustomPriceSource(name)
		end
	end
end

--- Register a built-in price source.
-- @tparam string moduleName The name of the module which provides this source
-- @tparam string key The key for this price source (i.e. DBMarket)
-- @tparam string label The label which describes this price source for display to the user
-- @tparam function callback The price source callback
-- @tparam[opt=false] boolean fullLink Whether or not the full itemLink is required instead of just the itemString
-- @param[opt] arg An additional argument which is passed to the callback
-- @tparam[opt=false] booolean isVolatile Should be set if the price source may change without CustomPrice.OnSourceChange being called
function CustomPrice.RegisterSource(moduleName, key, label, callback, fullLink, arg, isVolatile)
	tinsert(private.priceSourceKeys, strlower(key))
	private.priceSourceInfo[strlower(key)] = {
		moduleName = moduleName,
		key = key,
		label = label,
		callback = callback,
		takeItemString = not fullLink,
		arg = arg,
		isVolatile = isVolatile,
		cache = {},
	}
end

function CustomPrice.RegisterCustomSourceCallback(callback)
	tinsert(private.customSourceCallbacks, callback)
end

--- Create a new custom price source.
-- @tparam string name The name of the custom price source
-- @tparam string value The value of the custom price source
function CustomPrice.CreateCustomPriceSource(name, value)
	assert(name ~= "")
	assert(gsub(name, "([a-z]+)", "") == "")
	assert(not private.settings.customPriceSources[name])
	value = private.SanitizeCustomPriceString(value)
	private.settings.customPriceSources[name] = value
	for _, data in pairs(private.proxyData) do
		if data.origStr == value then
			data.customPriceSourceNames[name] = true
		end
	end
	wipe(private.customPriceCache)
	private.CallCustomSourceCallbacks()
end

--- Rename a custom price source.
-- @tparam string oldName The old name of the custom price source
-- @tparam string newName The new name of the custom price source
function CustomPrice.RenameCustomPriceSource(oldName, newName)
	if oldName == newName then
		return
	end
	local value = private.settings.customPriceSources[oldName]
	assert(value)
	private.settings.customPriceSources[newName] = value
	private.settings.customPriceSources[oldName] = nil
	for _, data in pairs(private.proxyData) do
		data.customPriceSourceNames[oldName] = nil
		if data.origStr == value then
			data.customPriceSourceNames[newName] = true
		end
	end
	wipe(private.customPriceCache)
	CustomPrice.OnSourceChange(oldName)
	CustomPrice.OnSourceChange(newName)
	private.CallCustomSourceCallbacks()
end

--- Delete a custom price source.
-- @tparam string name The name of the custom price source
function CustomPrice.DeleteCustomPriceSource(name)
	assert(private.settings.customPriceSources[name])
	private.settings.customPriceSources[name] = nil
	for _, data in pairs(private.proxyData) do
		data.customPriceSourceNames[name] = nil
	end
	wipe(private.customPriceCache)
	CustomPrice.OnSourceChange(name)
	private.CallCustomSourceCallbacks()
end

--- Sets the value of a custom price source.
-- @tparam string name The name of the custom price source
-- @tparam string value The value of the custom price source
function CustomPrice.SetCustomPriceSource(name, value)
	assert(private.settings.customPriceSources[name])
	value = private.SanitizeCustomPriceString(value)
	private.settings.customPriceSources[name] = value
	for _, data in pairs(private.proxyData) do
		data.customPriceSourceNames[name] = data.origStr == value or nil
	end
	wipe(private.customPriceCache)
	CustomPrice.OnSourceChange(name)
end

function CustomPrice.BulkCreateCustomPriceSourcesFromImport(customSources, replaceExisting)
	for name, value in pairs(customSources) do
		value = private.SanitizeCustomPriceString(value)
		assert(not private.settings.customPriceSources[name] or replaceExisting)
		if private.settings.customPriceSources[name] then
			CustomPrice.SetCustomPriceSource(name, value)
		else
			CustomPrice.CreateCustomPriceSource(name, value)
		end
	end
end

--- Print built-in price sources to chat.
function CustomPrice.PrintSources()
	Log.PrintUser(L["Below is a list of all available price sources, along with a brief description of what they represent."])
	local moduleList = TempTable.Acquire()

	for _, info in pairs(private.priceSourceInfo) do
		if not tContains(moduleList, info.moduleName) then
			tinsert(moduleList, info.moduleName)
		end
	end
	sort(moduleList, private.ModuleSortFunc)

	for _, module in ipairs(moduleList) do
		Log.PrintUserRaw("|cffffff00"..module..":|r")
		local lines = TempTable.Acquire()
		for _, info in pairs(private.priceSourceInfo) do
			if info.moduleName == module then
				tinsert(lines, format("  %s (%s)", Log.ColorUserAccentText(info.key), info.label))
			end
		end
		sort(lines)
		for _, line in ipairs(lines) do
			Log.PrintfUserRaw(line)
		end
		TempTable.Release(lines)
	end

	TempTable.Release(moduleList)
end

function CustomPrice.GetDescription(key)
	local info = private.priceSourceInfo[key]
	return info and info.label or nil
end

--- Validate a custom price name.
-- @tparam string customPriceName The custom price name
-- @tparam[opt=false] boolean ignoreExistingCustomPriceSources Whether or not to ignore existing custom price sources
-- @treturn boolean Whether or not the custom price name is valid
function CustomPrice.ValidateName(customPriceName, ignoreExistingCustomPriceSources)
	-- custom price names must be lowercase
	if strlower(customPriceName) ~= customPriceName then
		return false, L["Custom price names can only contain lowercase letters."]
	end
	-- User defined price sources
	if not ignoreExistingCustomPriceSources and private.settings.customPriceSources[customPriceName] then
		return false, format(L["Custom price name %s already exists."], Theme.GetColor("INDICATOR"):ColorText(customPriceName))
	end
	-- TSM defined price sources
	for source in CustomPrice.Iterator() do
		if strlower(source) == strlower(customPriceName) then
			return false, format(L["Custom price name %s is a reserved word which cannot be used."], Theme.GetColor("INDICATOR"):ColorText(customPriceName))
		end
	end
	-- Math Functions
	for mathFunction in pairs(MATH_FUNCTIONS) do
		if strlower(mathFunction) == strlower(customPriceName) then
			return false, format(L["Custom price name %s is a reserved word which cannot be used."], Theme.GetColor("INDICATOR"):ColorText(customPriceName))
		end
	end
	-- Comparisons
	for comparison in pairs(COMPARISONS) do
		if strlower(comparison) == strlower(customPriceName) then
			return false, format(L["Custom price name %s is a reserved word which cannot be used."], Theme.GetColor("INDICATOR"):ColorText(customPriceName))
		end
	end
	return true
end

--- Validate a custom price string.
-- @tparam string customPriceStr The custom price string
-- @tparam ?table badPriceSources A table of price sources (as keys) which aren't allowed to be used
-- @treturn boolean Whether or not the custom price string is valid
-- @treturn ?string The error message if the custom price string was invalid
function CustomPrice.Validate(customPriceStr, badPriceSources)
	local proxy, err = private.ParseCustomPrice(customPriceStr, badPriceSources)
	return proxy and true or false, err
end

--- Evaulates a custom price source for an item.
-- @tparam string customPriceStr The custom price string
-- @tparam string itemString The item to evalulate the custom price string for
-- @tparam[opt=false] boolean allowZero If true, allows the result to be 0
-- @treturn ?number The resulting value or nil if the custom price string is invalid
-- @treturn ?string The error message if the custom price string was invalid
function CustomPrice.GetValue(customPriceStr, itemString, allowZero)
	local proxy, err = private.ParseCustomPrice(customPriceStr)
	if not proxy then
		return nil, err
	end
	local value = nil
	local mapReader = private.proxyData[proxy].mapReader
	if mapReader then
		value = mapReader[itemString]
	else
		value = proxy(itemString)
	end
	if not value or value < 0 or (not allowZero and value == 0) then
		return nil, L["No value was returned by the custom price for the specified item."]
	end
	return value
end

--- Gets a built-in price source's value for an item.
-- @tparam string itemString The item to evalulate the price source for
-- @tparam string key The key of the price source
-- @treturn ?number The resulting value or nil if no price was found for the item
function CustomPrice.GetItemPrice(itemString, key)
	itemString = ItemString.Get(itemString)
	if not itemString then
		return
	end

	local info = private.priceSourceInfo[strlower(key)]
	if not info then
		return
	end
	local cachedValue = info.cache[itemString]
	if cachedValue ~= nil then
		assert(not info.isVolatile)
		return cachedValue or nil
	end
	if not info.takeItemString then
		-- this price source does not take an itemString, so pass it an itemLink instead
		itemString = ItemInfo.GetLink(itemString)
		if not itemString then
			return
		end
	end
	local value = info.callback(itemString, info.arg)
	value = type(value) == "number" and value or nil
	if not info.isVolatile then
		info.cache[itemString] = value or false
	end
	return value
end

function CustomPrice.GetConversionsValue(sourceItemString, customPrice, method)
	if not customPrice then
		return
	end

	-- calculate disenchant value first
	if (not method or method == Conversions.METHOD.DISENCHANT) and ItemInfo.IsDisenchantable(sourceItemString) then
		local quality = ItemInfo.GetQuality(sourceItemString)
		local ilvl = ItemInfo.GetItemLevel(ItemString.GetBase(sourceItemString)) or 0
		local classId = ItemInfo.GetClassId(sourceItemString)
		local value = 0
		if quality and ilvl and classId then
			for targetItemString in DisenchantInfo.TargetItemIterator() do
				local amountOfMats = DisenchantInfo.GetTargetItemSourceInfo(targetItemString, classId, quality, ilvl)
				if amountOfMats then
					local matValue = CustomPrice.GetValue(customPrice, targetItemString)
					if not matValue or matValue == 0 then
						return
					end
					value = value + matValue * amountOfMats
				end
			end
		end

		value = floor(value)
		if value > 0 then
			return value
		end
	end

	-- calculate other conversion values
	local value = 0
	for targetItemString, rate in Conversions.TargetItemsByMethodIterator(sourceItemString, method) do
		local matValue = CustomPrice.GetValue(customPrice, targetItemString)
		value = value + (matValue or 0) * rate
	end

	value = Math.Round(value)
	return value > 0 and value or nil
end

local function CustomPriceIteratorHelper(_, key)
	local info = private.priceSourceInfo[key]
	return info.key, info.moduleName, info.label
end
--- Iterate over the price sources.
-- @return An iterator which provides the following fields: `key, moduleName, label`
function CustomPrice.Iterator()
	return Table.Iterator(private.priceSourceKeys, CustomPriceIteratorHelper)
end

--- Should be called when the value of a registered source changes.
-- @tparam string key The key of the price source
-- @tparam[opt=nil] string itemString The item which the source changed for or nil if it changed for all items
function CustomPrice.OnSourceChange(key, itemString)
	key = strlower(key)
	if private.priceSourceInfo[key] then
		if itemString then
			private.priceSourceInfo[key].cache[itemString] = nil
		else
			wipe(private.priceSourceInfo[key].cache)
		end
	end
	local isSpecificItem = itemString ~= ItemString.GetBase(itemString) or not ItemString.HasNonBase(itemString)
	for _, data in pairs(private.proxyData) do
		if data.map then
			local clearAll, clearItem, clearBaseItem = false, false, false
			if data.dependantPriceSources[key] then
				if not itemString then
					-- clear all items
					clearAll = true
				else
					for dependantItemString in pairs(data.dependantPriceSources[key]) do
						if dependantItemString == "_item" then
							if isSpecificItem then
								-- just clear the specific item
								clearItem = true
							else
								-- clear all items which have a matching baseItemString
								clearBaseItem = true
							end
						elseif dependantItemString == "_baseitem" then
							if not isSpecificItem then
								-- just clear the item which was passed (and was a base item)
								clearItem = true
							end
						else
							if dependantItemString == (isSpecificItem and itemString or ItemString.GetBase(dependantItemString)) then
								-- clear all items
								clearAll = true
							end
						end
					end
				end
			end

			if data.dependantPriceSources._convertPriceSource == key then
				-- TODO: could optimize this to only clear the items which have the specified item as a source item, but this should be pretty rare
				clearAll = true
			end

			data.map:SetCallbacksPaused(true)
			if clearAll then
				for mapItemString in data.map:Iterator() do
					data.map:ValueChanged(mapItemString)
				end
				for name in pairs(data.customPriceSourceNames) do
					CustomPrice.OnSourceChange(name)
				end
			elseif clearBaseItem then
				for mapItemString in data.map:Iterator() do
					if ItemString.GetBase(mapItemString) == itemString then
						data.map:ValueChanged(mapItemString)
						for name in pairs(data.customPriceSourceNames) do
							CustomPrice.OnSourceChange(name, mapItemString)
						end
					end
				end
			end
			if not clearAll and clearItem then
				data.map:ValueChanged(itemString)
				for name in pairs(data.customPriceSourceNames) do
					CustomPrice.OnSourceChange(name, itemString)
				end
			end
			data.map:SetCallbacksPaused(false)
		end
	end
end

function CustomPrice.DependantCustomSourceIterator(str)
	local result = TempTable.Acquire()
	local proxy = private.ParseCustomPrice(str)
	if proxy then
		local data = private.proxyData[proxy]
		for name, customSourceStr in pairs(TSM.db.global.userData.customPriceSources) do
			if data.dependantPriceSources[name] then
				tinsert(result, name)
				tinsert(result, customSourceStr)
			end
		end
	end
	return TempTable.Iterator(result, 2)
end



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

private.customPriceFunctions = {
	IsInvalid = IsInvalid,
	loopError = function(str)
		Log.PrintUser(L["Loop detected in the following custom price:"].." "..Log.ColorUserAccentText(str))
	end,
	_avg = function(...)
		local total, count = 0, 0
		for i = 1, select('#', ...) do
			local num = select(i, ...)
			if type(num) == "number" and not IsInvalid(num) then
				total = total + num
				count = count + 1
			end
		end
		return count == 0 and Math.GetNan() or (total / count)
	end,
	_min = function(...)
		local minVal = nil
		for i = 1, select('#', ...) do
			local num = select(i, ...)
			if type(num) == "number" and not IsInvalid(num) and (not minVal or num < minVal) then
				minVal = num
			end
		end
		return minVal or Math.GetNan()
	end,
	_max = function(...)
		local maxVal = nil
		for i = 1, select('#', ...) do
			local num = select(i, ...)
			if type(num) == "number" and not IsInvalid(num) and (not maxVal or num > maxVal) then
				maxVal = num
			end
		end
		return maxVal or Math.GetNan()
	end,
	_first = function(...)
		for i = 1, select('#', ...) do
			local num = select(i, ...)
			if type(num) == "number" and not IsInvalid(num) then
				return num
			end
		end
		return Math.GetNan()
	end,
	_check = function(check, ...)
		return private.RunComparison(COMPARISONS.gt, check, 0, ...)
	end,
	_ifgt = function(...)
		return private.RunComparison(COMPARISONS.gt, ...)
	end,
	_ifgte = function(...)
		return private.RunComparison(COMPARISONS.gte, ...)
	end,
	_iflt = function(...)
		return private.RunComparison(COMPARISONS.lt, ...)
	end,
	_iflte = function(...)
		return private.RunComparison(COMPARISONS.lte, ...)
	end,
	_ifeq = function(...)
		return private.RunComparison(COMPARISONS.eq, ...)
	end,
	_round = function(...)
		if select('#', ...) < 1 or select('#', ...) > 2 then return Math.GetNan() end
		return Math.Round(...)
	end,
	_roundup = function(...)
		if select('#', ...) < 1 or select('#', ...) > 2 then return Math.GetNan() end
		return Math.Ceil(...)
	end,
	_rounddown = function(...)
		if select('#', ...) < 1 or select('#', ...) > 2 then return Math.GetNan() end
		return Math.Floor(...)
	end,
	_priceHelper = function(itemString, key, extraParam)
		itemString = ItemString.Get(itemString)
		if not itemString then
			return Math.GetNan()
		end
		if key == "convert" then
			local conversions = Conversions.GetSourceItems(itemString)
			if not conversions then
				return Math.GetNan()
			end
			local minPrice = nil
			for sourceItemString, rate in pairs(conversions) do
				local price = CustomPrice.GetItemPrice(sourceItemString, extraParam)
				if price then
					price = price / rate
					minPrice = min(minPrice or price, price)
				end
			end
			return minPrice or Math.GetNan()
		elseif extraParam == "custom" then
			local customPriceSourceStr = private.settings.customPriceSources[key]
			if not customPriceSourceStr then
				-- custom price source has since been deleted
				return Math.GetNan()
			end
			return CustomPrice.GetValue(customPriceSourceStr, itemString) or Math.GetNan()
		else
			return CustomPrice.GetItemPrice(itemString, key) or Math.GetNan()
		end
	end,
}
local PROXY_MT = {
	__index = function(self, index)
		local data = private.proxyData[self]
		if private.customPriceFunctions[index] then
			return private.customPriceFunctions[index]
		elseif index == "globalContext" or index == "origStr" then
			-- these keys can always be accessed
			return data[index]
		end
		if not data.isUnlocked then
			error("Attempt to access a hidden table", 2)
		end
		return data[index]
	end,
	__newindex = function(self, index, value)
		local data = private.proxyData[self]
		if not data.isUnlocked then
			error("Attempt to modify a hidden table", 2)
		end
		data[index] = value
	end,
	__call = function(self, item)
		local data = private.proxyData[self]
		data.isUnlocked = true
		local result = data.func(self, item, ItemString.GetBase(item)) or 0
		data.isUnlocked = false
		return result
	end,
	__metatable = false,
}

function private.RunComparison(comparison, ...)
	if select('#', ...) > 4 then return Math.GetNan() end

	local leftCheck, rightCheck, ifValue, elseValue = ...
	leftCheck = leftCheck or Math.GetNan()
	rightCheck = rightCheck or Math.GetNan()
	ifValue = ifValue or Math.GetNan()
	elseValue = elseValue or Math.GetNan()

	if IsInvalid(leftCheck) or IsInvalid(rightCheck) then
		return Math.GetNan()
	elseif comparison == COMPARISONS.gt then
		return leftCheck > rightCheck and ifValue or elseValue
	elseif comparison == COMPARISONS.gte then
		return leftCheck >= rightCheck and ifValue or elseValue
	elseif comparison == COMPARISONS.lt then
		return leftCheck < rightCheck and ifValue or elseValue
	elseif comparison == COMPARISONS.lte then
		return leftCheck <= rightCheck and ifValue or elseValue
	elseif comparison == COMPARISONS.eq then
		return leftCheck == rightCheck and ifValue or elseValue
	else
		error("Error in custom price comparison")
	end
end

function private.CreateCustomPriceObj(func, origStr, dependantPriceSources, canCache)
	local customPriceSourceNames = {}
	for name, str in pairs(private.settings.customPriceSources) do
		if str == origStr then
			customPriceSourceNames[name] = true
		end
	end
	local proxy = newproxy(true)
	private.proxyData[proxy] = {
		isUnlocked = false,
		globalContext = private.context,
		origStr = origStr,
		customPriceSourceNames = customPriceSourceNames,
		func = func,
		dependantPriceSources = dependantPriceSources,
		map = nil,
		mapReader = nil,
	}
	local mt = getmetatable(proxy)
	for key, value in pairs(PROXY_MT) do
		mt[key] = value
	end
	if canCache then
		local map = SmartMap.New("string", "number", proxy)
		private.proxyData[proxy].map = map
		private.proxyData[proxy].mapReader = map:CreateReader()
	end
	return proxy
end

function private.ParsePriceString(str, badPriceSources)
	if tonumber(str) then
		return private.CreateCustomPriceObj(function() return Math.Round(tonumber(str)) end, str, {}, true)
	end

	local origStr = str
	-- put a space at the start and end
	str = " "..str.." "
	-- remove any colors around gold/silver/copper
	while true do
		local num1, num2, num3
		str, num1 = gsub(str, "\124cff[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]g\124r", "g")
		str, num2 = gsub(str, "\124cff[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]s\124r", "s")
		str, num3 = gsub(str, "\124cff[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]c\124r", "c")
		if num1 + num2 + num3 == 0 then break end
	end
	-- replace old itemStrings with the new format
	str = gsub(str, "([^h]i)tem:([0-9:%-]+)", "%1:%2")

	-- replace all formatted gold amount with their copper value
	local start = 1
	local goldAmountContinue = true
	while goldAmountContinue do
		goldAmountContinue = false
		local minFindStart, minFindEnd, minFindSub = nil, nil, nil
		for _, pattern in ipairs(MONEY_PATTERNS) do
			local s, e, sub = strfind(str, pattern, start)
			if s and (not minFindStart or minFindStart > s + 1) then
				minFindStart = s + 1
				minFindEnd = e
				minFindSub = sub
			end
		end
		if minFindStart then
			if strmatch(strsub(str, minFindStart-1, minFindStart-1), "[0-9a-zA-Z]") or strmatch(strsub(str, minFindEnd+1, minFindEnd+1), "[0-9a-zA-Z]") then
				return nil, L["Invalid gold value."]
			end
			local value = Money.FromString(minFindSub)
			if not value then
				-- sanity check
				return nil, L["Invalid function."]
			end
			local preStr = strsub(str, 1, minFindStart - 1)
			local postStr = strsub(str, minFindEnd + 1)
			str = preStr .. value .. postStr
			start = #str - #postStr + 1
			goldAmountContinue = true
		end
	end

	-- remove up to 1 occurance of convert(priceSource[, item])
	local convertPriceSource, convertItem
	local convertParams = strmatch(str, "[^a-z]convert%((.-)%)")
	if convertParams then
		local convertItemLink = strmatch(convertParams, "\124c.-\124r")
		local convertItemString = strmatch(convertParams, ITEM_STRING_PATTERN)
		if convertItemLink then -- check for itemLink in convert params
			convertItem = ItemString.Get(convertItemLink)
			if not convertItem then
				return nil, L["Invalid item link."]  -- there's an invalid item link in the convertParams
			end
			convertPriceSource = strmatch(convertParams, "^ *(.-) *,")
		elseif convertItemString then -- check for itemString in convert params
			convertItem = convertItemString
			convertPriceSource = strmatch(convertParams, "^ *(.-) *,")
		else
			convertPriceSource = gsub(convertParams, ", *$", "")
			convertPriceSource = strtrim(convertPriceSource)
		end
		if convertPriceSource and ((badPriceSources and badPriceSources[convertPriceSource]) or convertPriceSource == "matprice") then
			return nil, format(L["You cannot use %s within convert() as part of this custom price."], convertPriceSource)
		end

		-- can't allow custom price sources in convert, so just check regular ones
		if not private.priceSourceInfo[convertPriceSource] then
			return nil, L["Invalid price source in convert."]
		end
		local num = 0
		str, num = gsub(str, "([^a-z])convert%(.-%)", "%1~convert~")
		if num > 1 then
			return nil, L["A maximum of 1 convert() function is allowed."]
		end
	end

	while true do
		local itemLink = strmatch(str, "\124c.*\124r")
		if not itemLink then break end
		local _, endIndex = strfind(itemLink, "\124r")
		itemLink = strsub(itemLink, 1, endIndex)
		local itemString = ItemString.Get(itemLink)
		if not itemString then
			-- there's an invalid item link in the str
			return nil, L["Invalid item link."]
		end
		str = gsub(str, String.Escape(itemLink), itemString)
	end

	-- make sure there's spaces on either side of math operators
	str = gsub(str, "[%-%+%/%*%^]", " %1 ")
	-- make sure there's a space to the right of % signs
	str = gsub(str, "[%%]", "%1 ")
	-- convert percentages to decimal numbers
	str = gsub(str, "([0-9%.]+)%%", "( %1 / 100 ) *")
	-- ensure a space on either side of item strings and remove parentheses around them
	str = gsub(str, "%([ ]*("..ITEM_STRING_PATTERN..")[ ]*%)", " %1 ")
	-- ensure a space on either side of baseitem arguments and remove parentheses around them
	str = gsub(str, "%([ ]*(baseitem)[ ]*%)", " ~baseitem~ ")
	-- ensure a space on either side of parentheses and commas
	str = gsub(str, "[%(%),]", " %1 ")
	-- remove any occurances of more than one consecutive space
	str = gsub(str, " [ ]+", " ")

	-- ensure equal number of left/right parenthesis
	if select(2, gsub(str, "%(", "")) ~= select(2, gsub(str, "%)", "")) then
		return nil, L["Unbalanced parentheses."]
	end

	-- validate all words in the string
	local parts = String.SafeSplit(strtrim(str), " ")
	local i = 1
	while i <= #parts do
		local word = parts[i]
		if strmatch(word, "^[%-%+%/%*%^]$") then
			if i == #parts then
				return nil, L["Invalid operator at end of custom price."]
			end
			-- valid math operator
		elseif badPriceSources and badPriceSources[word] then
			-- price source that's explicitly invalid
			return nil, format(L["You cannot use %s as part of this custom price."], word)
		elseif private.priceSourceInfo[word] or private.settings.customPriceSources[word] then
			-- make sure we're not trying to take the price source of a number
			if parts[i+1] == "(" and type(parts[i+2]) == "string" and not strfind(parts[i+2], "^[ip].*:") then
				return nil, L["Invalid parameter to price source."]
			end
			-- valid price source
		elseif tonumber(word) then
			-- make sure it's not an itemID (incorrect)
			if i > 2 and parts[i-1] == "(" and (private.priceSourceInfo[parts[i-2]] or private.settings.customPriceSources[parts[i-2]]) then
				return nil, L["Invalid parameter to price source."]
			end
			-- valid number
		elseif strmatch(word, "^"..ITEM_STRING_PATTERN.."$") or word == "~baseitem~" then
			-- make sure previous word was a price source
			if i > 1 and (private.priceSourceInfo[parts[i-1]] or private.settings.customPriceSources[parts[i-1]]) then
				-- valid item parameter
			else
				return nil, L["Item links may only be used as parameters to price sources."]
			end
		elseif word == "(" then
			-- empty parenthesis are not allowed
			if not parts[i+1] or parts[i+1] == ")" then
				return nil, L["Empty parentheses are not allowed"]
			end
			-- should never have ") ("
			if i > 1 and parts[i-1] == ")" then
				return nil, L["Missing operator between sets of parenthesis"]
			end
		elseif word == ")" then
			-- valid parenthesis
		elseif word == "," then
			if not parts[i+1] or parts[i+1] == ")" then
				return nil, L["Misplaced comma"]
			else
				-- we're hoping this is a valid comma within a function, will be caught by loadstring otherwise
			end
		elseif MATH_FUNCTIONS[word] then
			if not parts[i+1] or parts[i+1] ~= "(" then
				return nil, format(L["Invalid word: '%s'"], word)
			end
			-- valid math function
		elseif word == "~convert~" then
			-- valid convert statement
		elseif strtrim(word) == "" then
			-- harmless extra spaces
		else
			if strfind(word, "^%^1%^t%^") then
				-- this is an operation export that they tried to use as a custom price
				return nil, L["This looks like an exported operation and not a custom price."]
			elseif strfind(word, "global") then
				-- this is an old global price
				return nil, L["It looks like you're trying to reference an old global price source which no longer exists."]
			end
			return nil, format(L["Invalid word: '%s'"], word)
		end
		i = i + 1
	end

	local canCache = true
	local dependantPriceSources = {}
	for key, value in pairs(private.settings.customPriceSources) do
		key = strlower(key)
		local usedKey = nil
		str, usedKey = private.PriceSourceParsingHelper(str, key, "custom", dependantPriceSources)
		if usedKey then
			local customPriceProxy, errMsg = private.ParseCustomPrice(value, badPriceSources)
			if not customPriceProxy then
				return nil, format(L["The '%s' custom price source is invalid."], key).." "..errMsg
			end
			canCache = canCache and private.proxyData[customPriceProxy].map
		end
	end

	for key, info in pairs(private.priceSourceInfo) do
		local usedKey = nil
		str, usedKey = private.PriceSourceParsingHelper(str, key, nil, dependantPriceSources)
		canCache = canCache and (not usedKey or not info.isVolatile)
	end

	-- replace "~convert~" appropriately
	if convertPriceSource then
		canCache = canCache and not private.priceSourceInfo[convertPriceSource].isVolatile
		dependantPriceSources._convertPriceSource = convertPriceSource
		convertItem = convertItem and ('"'..convertItem..'"') or "_item"
		str = gsub(str, "~convert~", format("self._priceHelper(%s, \"convert\", \"%s\")", convertItem, convertPriceSource))
	end

	-- replace math functions with special custom function names
	for word, funcName in pairs(MATH_FUNCTIONS) do
		str = gsub(str, " "..word.." ", " "..funcName.." ")
	end

	-- finally, create and return the function
	local func, loadErr = loadstring(format(CUSTOM_PRICE_FUNC_TEMPLATE, str), "TSMCustomPrice: "..origStr)
	if loadErr then
		loadErr = gsub(strtrim(loadErr), "([^:]+):.", "")
		return nil, L["Invalid function."].." "..L["Details"]..": "..loadErr
	end
	local success = nil
	success, func = pcall(func)
	if not success then
		return nil, L["Invalid function."]
	end
	return private.CreateCustomPriceObj(func, origStr, dependantPriceSources, canCache)
end

function private.PriceSourceParsingHelper(str, key, extraArg, dependantPriceSources)
	extraArg = extraArg and (",\""..extraArg.."\"") or ""
	local numReplacements, usedKey = nil, false
	-- replace all "<priceSource> <itemString>" occurances with the proper parameters (with the itemString)
	str, numReplacements = gsub(str, format(" %s (%s) ", key, ITEM_STRING_PATTERN), format(" self._priceHelper(\"%%1\",\"%s\"%s) ", key, extraArg))
	if numReplacements > 0 then
		for itemString in gmatch(str, " self%._priceHelper%(\"("..ITEM_STRING_PATTERN..")\",\""..key.."\""..String.Escape(extraArg).."%) ") do
			-- add all the items used for this key
			dependantPriceSources[key] = dependantPriceSources[key] or {}
			dependantPriceSources[key][itemString] = true
		end
		usedKey = true
	end
	-- replace all "<priceSource> baseitem" occurances with the proper parameters (with _baseitem for the item)
	str, numReplacements = gsub(str, format(" %s ~baseitem~ ", key), format(" self._priceHelper(_baseitem,\"%s\"%s) ", key, extraArg))
	if numReplacements > 0 then
		dependantPriceSources[key] = dependantPriceSources[key] or {}
		dependantPriceSources[key]._baseitem = true
		usedKey = true
	end
	-- replace all "<priceSource>" occurances with the proper parameters (with _item for the item)
	str, numReplacements = gsub(str, format(" %s ", key), format(" self._priceHelper(_item,\"%s\"%s) ", key, extraArg))
	if numReplacements > 0 then
		dependantPriceSources[key] = dependantPriceSources[key] or {}
		dependantPriceSources[key]._item = true
		usedKey = true
	end
	return str, usedKey
end

function private.ParseCustomPrice(customPriceStr, badPriceSources)
	customPriceStr = private.SanitizeCustomPriceString(customPriceStr)
	if not customPriceStr or customPriceStr == "" then
		return nil, L["Empty price string."]
	end

	if badPriceSources then
		return private.ParsePriceString(customPriceStr, badPriceSources)
	end

	if not private.customPriceCache[customPriceStr] then
		private.customPriceCache[customPriceStr] = {private.ParsePriceString(customPriceStr)}
	end
	return unpack(private.customPriceCache[customPriceStr])
end

function private.ModuleSortFunc(a, b)
	if a == "TSM" then
		return true
	elseif b == "TSM" then
		return false
	else
		return a < b
	end
end

function private.SanitizeCustomPriceString(customPriceStr)
	assert(customPriceStr)
	local result = private.sanitizeCache[customPriceStr]
	if not result then
		result = strlower(strtrim(tostring(customPriceStr)))
		result = Money.FromString(result) and gsub(result, String.Escape(LARGE_NUMBER_SEPERATOR), "") or result
		private.sanitizeCache[customPriceStr] = result
	end
	return result
end

function private.CallCustomSourceCallbacks()
	for _, callback in ipairs(private.customSourceCallbacks) do
		callback()
	end
end
