-- MarketWatch Pricing.lua
-- Auction House deal detection: reads Auctionator's (primary) and TSM's
-- (fallback) saved price data, compares it against a price mentioned in a
-- trade chat listing, and flags genuine deals.
--
-- Every external read (Auctionator/TSM globals, whose format we don't
-- control) is wrapped in pcall - a format change in either addon degrades
-- this feature to "no price data" silently, and never breaks
-- categorization/keywords/anything else in MarketWatch.

MW = MW or {}
MW.Pricing = MW.Pricing or {}

local dealsReady = false -- gated on PLAYER_LOGIN so we never read these
                          -- globals before the other addons have had a
                          -- chance to load their own SavedVariables

--------------------------------------------------------------------
-- Auctionator's actual live data, confirmed against a real installed
-- 2.6.8 copy: AUCTIONATOR_PRICE_DATABASE is the current, actively-
-- maintained price table (realm+faction -> item name -> price in copper,
-- e.g. AUCTIONATOR_PRICE_DATABASE["Icecrown_Alliance"]["Saronite Bar"]),
-- with AUCTIONATOR_MEAN_PRICE_DATABASE holding a parallel array of recent
-- historical values per item for sanity-checking against. Only used as a
-- fallback now - see GetMarketPrice below for the preferred path.
--------------------------------------------------------------------
local nameIndex
local indexBuiltAt = 0
local REFRESH_INTERVAL = 300 -- 5 minutes

-- Matches Auctionator's own key construction exactly (confirmed by reading
-- its source: GetRealmName().."_"..UnitFactionGroup("player")).
local function GetRealmFactionKey()
	local faction = UnitFactionGroup and UnitFactionGroup("player")
	local realm = GetRealmName and GetRealmName()
	if not faction or not realm then return nil end
	return realm .. "_" .. faction
end

local function GetPriceDB()
	local rf = GetRealmFactionKey()
	if not rf then return nil end
	return AUCTIONATOR_PRICE_DATABASE and AUCTIONATOR_PRICE_DATABASE[rf]
end

local function GetMeanPriceDB()
	local rf = GetRealmFactionKey()
	if not rf then return nil end
	return AUCTIONATOR_MEAN_PRICE_DATABASE and AUCTIONATOR_MEAN_PRICE_DATABASE[rf]
end

-- Lowercase, apostrophe-stripped name -> exact stored name. Our dictionary
-- keywords are always apostrophe-stripped (MW.NormalizeMsg deletes them,
-- e.g. "Blood Queen's Crimson Choker" -> "blood queens crimson choker"),
-- which is what reaches this index whenever GetItemInfo can't resolve a
-- real name itself. A plain :lower() alone would keep the apostrophe and
-- never match, so it's stripped here too.
local function NormalizeForIndex(name)
	return (name:lower():gsub("'", ""))
end

local function RebuildIndexIfNeeded()
	local now = time()
	if nameIndex and (now - indexBuiltAt) < REFRESH_INTERVAL then
		return
	end

	nameIndex = {}
	local priceDB = GetPriceDB()
	if priceDB then
		for name in pairs(priceDB) do
			if type(name) == "string" then
				nameIndex[NormalizeForIndex(name)] = name
			end
		end
	end

	indexBuiltAt = now
end

-- Sanity-checks a price against AUCTIONATOR_MEAN_PRICE_DATABASE's recent-
-- value history for the same item (median of up to the last 5 samples) -
-- if the price is < 20% or > 500% of that, it's treated as an outlier (a
-- 1-copper mislist, a scan glitch) rather than trusted as market price.
local function GetSanePrice(exactName, currentPrice)
	if not currentPrice or currentPrice <= 0 then return nil end

	local meanDB = GetMeanPriceDB()
	local history = meanDB and meanDB[exactName]
	if type(history) ~= "table" or #history == 0 then
		return currentPrice -- nothing to sanity-check against, trust it
	end

	local samples = {}
	for i = 1, math.min(#history, 5) do
		local v = history[i]
		if type(v) == "number" and v > 0 then
			table.insert(samples, v)
		end
	end
	if #samples == 0 then return currentPrice end

	table.sort(samples)
	local median = samples[math.ceil(#samples / 2)]
	if median <= 0 then return currentPrice end

	local ratio = currentPrice / median
	if ratio >= 0.2 and ratio <= 5.0 then
		return currentPrice
	end
	return nil
end

--------------------------------------------------------------------
-- TSM fallback - only ever consulted when Auctionator has zero data for
-- the item. Call signature confirmed working on this exact setup by
-- reading RealNibba.Pack's own live TSM integration: GetCustomPriceValue
-- with the "i:<id>" short itemString form and a lowercase "dbmarket" key.
--------------------------------------------------------------------
local function GetTSMPrice(itemId)
	if not itemId then return nil end
	if not TSM_API or not TSM_API.GetCustomPriceValue then return nil end

	local ok, price = pcall(TSM_API.GetCustomPriceValue, "dbmarket", "i:" .. itemId)
	if ok and type(price) == "number" and price > 0 then
		return price
	end
	return nil
end

--------------------------------------------------------------------
-- Public: market price for an item, in copper.
--
-- Primary path is Auctionator's own Atr_GetAuctionPrice - confirmed
-- working on this exact setup by reading RealNibba.Pack's Core.lua,
-- which calls it live and successfully. It accepts either an itemID or a
-- name and resolves internally against its own in-memory gAtr_ScanDB
-- (already realm/faction-scoped, already using the item's real
-- punctuation/casing) - passing the itemID when we have one sidesteps
-- every name-matching pitfall the manual AUCTIONATOR_PRICE_DATABASE
-- fallback below has to handle by hand.
--
-- The manual fallback stays in place for setups where Atr_GetAuctionPrice
-- isn't defined (an older/different Auctionator variant) and for text-
-- only matches with no item link to resolve a name from at all.
--------------------------------------------------------------------
function MW.Pricing.GetMarketPrice(itemName, itemId)
	if not dealsReady then return nil end

	local ok, price, source = pcall(function()
		local resolvedName = itemName
		if itemId and GetItemInfo then
			local nameFromId = GetItemInfo(itemId)
			if nameFromId then resolvedName = nameFromId end
		end

		if Atr_GetAuctionPrice then
			local p = Atr_GetAuctionPrice(itemId or resolvedName)
			if type(p) == "number" and p > 0 then
				local sane = resolvedName and GetSanePrice(resolvedName, p) or p
				if sane and sane > 0 then
					return sane, "Auctionator"
				end
			end
		end

		if resolvedName then
			local priceDB = GetPriceDB()
			if priceDB then
				RebuildIndexIfNeeded()
				local exactName = priceDB[resolvedName] and resolvedName or nameIndex[NormalizeForIndex(resolvedName)]
				if exactName then
					local raw = priceDB[exactName]
					local p = GetSanePrice(exactName, raw)
					if p and p > 0 then
						return p, "Auctionator"
					end
				end
			end
		end

		local tsmPrice = GetTSMPrice(itemId)
		if tsmPrice then
			return tsmPrice, "TSM"
		end

		return nil, nil
	end)

	if ok then return price, source end
	return nil
end

--------------------------------------------------------------------
-- Price-in-chat-text extraction: scans for "8k" / "8000g" / "8000 gold"
-- style amounts (commas stripped first so "8,000g" also matches) and
-- returns whichever match sits closest to where the item name/keyword
-- appears in the message - the best-effort answer for messages that
-- mention more than one number.
--
-- Takes the NORMALIZED message (hyperlink codes and apostrophes already
-- stripped), not the raw chat text - anchorText is a dictionary keyword,
-- which was itself matched against the normalized text in Detection.lua,
-- so searching for it anywhere else can silently fail to find it at all.
--------------------------------------------------------------------
local PRICE_PATTERNS = {
	{ pat = "(%d+%.?%d*)%s*k%f[%A]", mult = 1000 },
	{ pat = "(%d+)%s*gold%f[%A]",    mult = 1 },
	{ pat = "(%d+)%s*g%f[%A]",       mult = 1 },
}

function MW.Pricing.ExtractPriceNearText(normMsg, anchorText)
	if not normMsg or normMsg == "" then return nil end

	local s = normMsg:gsub(",", "")

	local anchorPos = nil
	if anchorText and anchorText ~= "" then
		anchorPos = s:find(anchorText:lower(), 1, true)
	end

	local best, bestDist = nil, nil
	for _, entry in ipairs(PRICE_PATTERNS) do
		local searchStart = 1
		while true do
			local ms, me, numStr = s:find(entry.pat, searchStart)
			if not ms then break end

			local n = tonumber(numStr)
			if n then
				local gold = n * entry.mult
				local dist = anchorPos and math.abs(ms - anchorPos) or 0
				if not best or dist < bestDist then
					best, bestDist = gold, dist
				end
			end

			searchStart = me + 1
		end
	end

	return best
end

--------------------------------------------------------------------
-- Public: evaluate a listing against known market price. Returns a
-- result table whenever a market price was found at all - even when it
-- doesn't clear the deal thresholds - so callers can show the market
-- price in a tooltip and let the user judge for themselves, not just
-- when it clears the bar for a flagged "DEAL" alert. Check the
-- returned table's `isDeal` field to tell the two cases apart. Returns
-- nil only when there's genuinely no market price to report (no
-- Auctionator/TSM data for this item, or Deal Alerts is disabled).
--------------------------------------------------------------------
function MW.Pricing.EvaluateDeal(normMsg, itemName, itemLinkString)
	if not dealsReady then return nil end

	local db = MarketWatch.db and MarketWatch.db.profile and MarketWatch.db.profile.dealAlerts
	if not db or not db.enabled then return nil end

	local ok, result = pcall(function()
		local itemId = nil
		if itemLinkString then
			itemId = tonumber(itemLinkString:match("item:(%d+)"))
		end

		local marketPrice, source = MW.Pricing.GetMarketPrice(itemName, itemId)
		if not marketPrice or marketPrice <= 0 then return nil end

		local listingGold = MW.Pricing.ExtractPriceNearText(normMsg, itemName)
		if not listingGold or listingGold <= 0 then
			-- Market price is known but there's no parseable listing price
			-- to compare it against - not a deal verdict either way, but
			-- still worth surfacing the market price itself in a tooltip.
			return { isDeal = false, marketPrice = marketPrice, source = source }
		end

		local listingCopper = listingGold * 10000
		local ratio = listingCopper / marketPrice
		local goldSaved = (marketPrice - listingCopper) / 10000

		-- Fixed safety rail, not user-configurable: a listing priced at
		-- less than half of market is far more likely a bad item match,
		-- a scan glitch, or a scam than a genuine bargain - still reported
		-- back (isDeal=false) rather than discarded, so the tooltip can
		-- show the market price and let a human make the call instead.
		local MIN_RATIO = 0.50

		local maxRatio
		if db.thresholdMode == "gold" then
			maxRatio = 0.95 -- generous - the real gate below is the flat gold amount
		else
			maxRatio = 1 - ((db.minDiscountPercent or 20) / 100)
		end

		local isDeal = ratio >= MIN_RATIO and ratio <= maxRatio and goldSaved >= 10 -- fixed absolute floor
		if isDeal and db.thresholdMode == "gold" and goldSaved < (db.minGoldSaved or 300) then
			isDeal = false
		end

		return {
			isDeal = isDeal,
			marketPrice = marketPrice,
			listingPrice = listingCopper,
			discountPercent = math.floor((1 - ratio) * 100 + 0.5),
			goldSaved = math.floor(goldSaved + 0.5),
			source = source,
		}
	end)

	if ok then return result end
	return nil
end

--------------------------------------------------------------------
-- Whether any currently-tracked listing is flagged as a deal right now -
-- kept as a single cheap flag the minimap glow ticker can poll, rather
-- than re-scanning every category on every OnUpdate tick.
--------------------------------------------------------------------
MW.Pricing.anyDealLive = false

function MW.Pricing.RecomputeAnyDealLive()
	local live = false

	local function scan(list)
		if live or not list then return end
		for _, e in ipairs(list) do
			if e.isDeal then
				live = true
				return
			end
		end
	end

	if MW.data then
		scan(MW.data.items and MW.data.items.BoE)
		scan(MW.data.items and MW.data.items.Mounts)
		scan(MW.data.transmog)
	end

	MW.Pricing.anyDealLive = live
end

--------------------------------------------------------------------
-- One-time-per-session staleness reminder. AUCTIONATOR_LAST_SCAN_TIME is
-- a real unix timestamp Auctionator itself maintains for its last full
-- AH scan - directly comparable against time(), no guessing needed.
--------------------------------------------------------------------
local function CheckStaleness()
	if type(AUCTIONATOR_LAST_SCAN_TIME) ~= "number" then return end

	if (time() - AUCTIONATOR_LAST_SCAN_TIME) >= (14 * 86400) then
		DEFAULT_CHAT_FRAME:AddMessage(
			"|cffffd200[MarketWatch]|r Your Auctionator scan is 2+ weeks old - " ..
			"consider rescanning the AH so deal alerts stay accurate."
		)
	end
end

--------------------------------------------------------------------
-- Gate everything on PLAYER_LOGIN so this never reads Auctionator/TSM's
-- globals before those addons (if installed) have loaded their own
-- SavedVariables - see the ## OptionalDeps hint in MarketWatch.toc.
--------------------------------------------------------------------
local loginFrame = CreateFrame("Frame")
loginFrame:RegisterEvent("PLAYER_LOGIN")
loginFrame:SetScript("OnEvent", function()
	dealsReady = true
	pcall(RebuildIndexIfNeeded)
	pcall(CheckStaleness)
end)
