-- Portal Pro :: Modules/Market.lua
--
-- Live price intelligence, built entirely from data the addon was already throwing away.
--
-- The lead scanner reads every portal-related line in Trade in order to REJECT rival adverts
-- as customers. Those adverts contain prices. Keeping them turns a filter into a market feed:
-- what the competition is asking right now, and what customers are offering to pay.
--
-- Two sides are tracked separately, because they answer different questions:
--   ask - a mage advertising a price. This is your competition.
--   bid - a customer naming a price they'll pay. This is demand.
--
-- Nothing here needs an API the addon wasn't already using.

local ADDON_NAME, PP = ...
local U, C = PP.U, PP.C

local M = PP:NewModule("Market")
PP.Market = M

local MAX_OBSERVATIONS = 600
local MAX_AGE          = 14 * 86400
local DEDUPE_WINDOW    = 600      -- same advertiser + side inside 10 min counts once
local STORE_TEXT_LEN   = 255      -- a full chat line, so nothing is lost from the record

-- Plausibility bounds. Filters out "1000g" boasts, item prices and stray numbers that
-- happen to sit next to a 'g'.
local MIN_PRICE = 1000      -- 10s
local MAX_PRICE = 2000000   -- 200g

-- How far a price may sit from the word "port" and still be read as its price. A genuine
-- portal advert puts them together ("ports 10g", "10g per port"); a general goods listing
-- has prices scattered far from any incidental mention.
local MAX_PRICE_DISTANCE = 40

--------------------------------------------------------------------------------
-- Price extraction
--------------------------------------------------------------------------------

-- Ordered most-specific first. The FIRST rule that matches anything wins, so "1g50s" is
-- read as one price rather than as 1g and 50s separately.
--
-- The bare 'g' and 's' rules require a digit immediately before and a non-letter after,
-- which is what stops "ports" matching silver and "10gold" matching bare gold.
local PRICE_RULES = {
	{ "(%d+)%s*g%s*(%d+)%s*s%f[%A]", function(a, b) return tonumber(a) * 10000 + tonumber(b) * 100 end },
	{ "(%d+)%s*gold%f[%A]",          function(a)    return tonumber(a) * 10000 end },
	{ "(%d+)%s*g%f[%A]",             function(a)    return tonumber(a) * 10000 end },
	{ "(%d+)%s*silver%f[%A]",        function(a)    return tonumber(a) * 100 end },
	{ "(%d+)%s*s%f[%A]",             function(a)    return tonumber(a) * 100 end },
}

--- Every plausible price in a normalised message, with where it was found.
--- @return table prices array of { copper, pos }, cheapest first
function M:ExtractPrices(normalized)
	local padded = " " .. normalized .. " "
	for _, rule in ipairs(PRICE_RULES) do
		local found = {}
		local init = 1
		while true do
			local s, e, a, b = padded:find(rule[1], init)
			if not s then break end
			local copper = rule[2](a, b)
			if copper and copper >= MIN_PRICE and copper <= MAX_PRICE then
				found[#found + 1] = { copper = copper, pos = s }
			end
			init = e + 1
		end
		if #found > 0 then
			table.sort(found, function(x, y) return x.copper < y.copper end)
			return found
		end
	end
	return {}
end

--------------------------------------------------------------------------------
-- Classification
--------------------------------------------------------------------------------

local PORTAL_MENTION = {
	"%f[%a]porta?l?s?%f[%A]",
	"%f[%a]teleports?%f[%A]",
	"%f[%a]tele%f[%A]",
}

-- A mage advertising, rather than a customer asking.
local SELL_MARKERS = {
	"%f[%a]wts%f[%A]",
	"%f[%a]sell%a*",
	"porta?l?s?%s+up%f[%A]",
	"porta?l?s?%s+available",
	"porta?l?%s+services?",
	"opening%s+porta?l?s?",
	"porting%s+for",
	"for%s+sale%f[%A]",
	"%f[%a]offering%f[%A]",
	"%f[%a]mage%s+porta?l?s?",
	"porta?l?s?%s+to%s+any",
	"%f[%a]inv%s+for%f[%A]",
	"%f[%a]each%f[%A]",
	"per%s+p%a*%f[%A]",
}

local function matchesAny(padded, patterns)
	for _, pattern in ipairs(patterns) do
		if padded:find(pattern) then return true end
	end
	return false
end

--- Character offsets of every portal/teleport word.
local function mentionPositions(padded)
	local out = {}
	for _, pattern in ipairs(PORTAL_MENTION) do
		local init = 1
		while true do
			local s, e = padded:find(pattern, init)
			if not s then break end
			out[#out + 1] = s
			init = e + 1
		end
	end
	return out
end

--- The price sitting closest to a portal mention, and how far away it was.
---
--- Nearest beats cheapest. In "ports 5g, teles 3g" the portal costs 5g — taking the
--- minimum would quietly report the teleport price as the market rate for portals.
--- @return table|nil price, number|nil distance
function M:NearestPrice(normalized, prices)
	local padded = " " .. normalized .. " "
	local mentions = mentionPositions(padded)
	if #mentions == 0 or #prices == 0 then return nil, nil end

	local best, bestDistance
	for _, price in ipairs(prices) do
		for _, pos in ipairs(mentions) do
			local distance = math.abs(price.pos - pos)
			if not bestDistance or distance < bestDistance then
				best, bestDistance = price, distance
			end
		end
	end
	return best, bestDistance
end

--- Item and enchant links mean someone is selling goods, not portals. Checked against the
--- RAW text, because normalisation strips the escape sequences that identify a link.
local function containsLink(rawText)
	return rawText and rawText:find("|H", 1, true) ~= nil
end

--- @param hasPrice boolean whether a plausible price was found in the line
function M:Classify(normalized, leadScore, hasPrice)
	local padded = " " .. normalized .. " "
	if not matchesAny(padded, PORTAL_MENTION) then return nil end

	if matchesAny(padded, SELL_MARKERS) then return "ask" end
	-- Scored high enough to be a customer, and they named a number: that's a bid.
	if leadScore and leadScore >= 3 then return "bid" end
	-- Fallback: portals named alongside a price, by someone showing no buying intent, is
	-- someone selling. Catches bare listings like "ports 5g, teles 3g" that carry none of
	-- the usual advertising words.
	if hasPrice then return "ask" end
	return nil
end

--------------------------------------------------------------------------------
-- Storage
--------------------------------------------------------------------------------

local idsBackfilled = false

local function store()
	PP.global.market = PP.global.market or {}
	PP.global.market.observations = PP.global.market.observations or {}
	PP.global.market.ignored = PP.global.market.ignored or {}

	-- Observations saved before ids existed would otherwise be unaddressable. Stamp them
	-- once so every record can be referenced individually.
	if not idsBackfilled then
		idsBackfilled = true
		for _, o in ipairs(PP.global.market.observations) do
			o.id = o.id or U.NextID()
		end
	end

	return PP.global.market.observations
end

local function ignoredList()
	store()
	return PP.global.market.ignored
end

local function prune()
	local obs = store()
	local cutoff = time() - MAX_AGE
	for i = #obs, 1, -1 do
		if (obs[i].ts or 0) < cutoff then table.remove(obs, i) end
	end
	while #obs > MAX_OBSERVATIONS do table.remove(obs, 1) end
end

function M:Record(name, side, copper, destKey, channel, text)
	local obs = store()

	-- Advertisers repeat the same line every 30 seconds. Collapse a repeat from the same
	-- person at the same price into an updated timestamp instead of a new data point,
	-- or one persistent spammer would define the whole market.
	local cutoff = time() - DEDUPE_WINDOW
	for i = #obs, 1, -1 do
		local o = obs[i]
		if o.ts < cutoff then break end
		if o.name == name and o.side == side and o.copper == copper then
			o.ts = time()
			o.destKey = destKey or o.destKey
			PP:Fire("MARKET_UPDATED")
			return o
		end
	end

	local entry = {
		id      = U.NextID(),
		ts      = time(),
		name    = name,
		side    = side,
		copper  = copper,
		destKey = destKey,
		channel = channel,
		realm   = PP.playerRealm,
		text    = U.Truncate(text or "", STORE_TEXT_LEN),
	}
	obs[#obs + 1] = entry
	prune()

	PP:Debug(("market %s %s from %s"):format(side, U.MoneyPlain(copper), name))
	PP:Fire("MARKET_UPDATED", entry)
	return entry
end

--------------------------------------------------------------------------------
-- Manual curation
--------------------------------------------------------------------------------

--- Drop a single observation. For a line that slipped through the classifier.
--- Accepts the observation table itself or its id; identity is checked first so this
--- still works on records written before ids existed.
function M:Remove(target)
	if not target then return false end
	local obs = store()
	for i = #obs, 1, -1 do
		local o = obs[i]
		if o == target or (o.id ~= nil and o.id == target) then
			table.remove(obs, i)
			PP:Fire("MARKET_UPDATED")
			return true
		end
	end
	return false
end

--- Ignore an advertiser entirely: forget what they've posted and never count them again.
--- For the goods trader whose listing happens to mention a portal.
function M:Ignore(name)
	if not name then return end
	ignoredList()[name] = true

	local obs = store()
	for i = #obs, 1, -1 do
		if obs[i].name == name then table.remove(obs, i) end
	end
	PP:Fire("MARKET_UPDATED")
end

function M:Unignore(name)
	ignoredList()[name] = nil
	PP:Fire("MARKET_UPDATED")
end

function M:IsIgnored(name)
	return ignoredList()[name] and true or false
end

function M:IgnoredCount()
	return U.Count(ignoredList())
end

function M:ClearIgnored()
	PP.global.market.ignored = {}
	PP:Fire("MARKET_UPDATED")
end

--------------------------------------------------------------------------------
-- Queries
--------------------------------------------------------------------------------

--- @param window number seconds to look back
--- @param side string|nil "ask" | "bid" | nil for both
--- @param destKey string|nil restrict to one destination
function M:Observations(window, side, destKey)
	local out = {}
	local cutoff = time() - (window or 3600)
	local realm = PP.playerRealm
	local ignored = ignoredList()
	for _, o in ipairs(store()) do
		if o.ts >= cutoff
			and not ignored[o.name]
			and (o.realm == nil or o.realm == realm)
			and (side == nil or o.side == side)
			and (destKey == nil or o.destKey == destKey)
		then
			out[#out + 1] = o
		end
	end
	return out
end

--- Collapse to one observation per advertiser, keeping their most recent price.
---
--- This is the statistically honest move: the median should describe what the mages in
--- town are charging, not how often each of them typed it. Without this, whoever spams
--- hardest sets the "market rate".
local function latestPerAdvertiser(observations)
	local byName = {}
	for _, o in ipairs(observations) do
		local existing = byName[o.name]
		if not existing or o.ts > existing.ts then byName[o.name] = o end
	end
	local out = {}
	for _, o in pairs(byName) do out[#out + 1] = o end
	return out
end

local function median(values)
	if #values == 0 then return nil end
	table.sort(values)
	local mid = math.floor(#values / 2)
	if #values % 2 == 1 then return values[mid + 1] end
	return math.floor((values[mid] + values[mid + 1]) / 2)
end

--- @return table stats { count, advertisers, median, low, high }
function M:Stats(window, side, destKey)
	local collapsed = latestPerAdvertiser(self:Observations(window, side, destKey))
	local values = {}
	for _, o in ipairs(collapsed) do values[#values + 1] = o.copper end

	local low, high
	for _, v in ipairs(values) do
		if not low or v < low then low = v end
		if not high or v > high then high = v end
	end

	return {
		count       = #values,
		advertisers = #collapsed,
		median      = median(values),
		low         = low,
		high        = high,
	}
end

--- Headline figures for the current window.
function M:Summary(window)
	return {
		ask = self:Stats(window, "ask"),
		bid = self:Stats(window, "bid"),
	}
end

--- How your asking price compares. Verdict is nil when there's no data to compare against.
--- @return string|nil verdict "under" | "at" | "over"
--- @return number|nil delta copper, positive means you're asking more than the market
--- @return table stats the underlying sample
function M:CompareToMarket(destKey, window)
	local stats = self:Stats(window or 3600, "ask", destKey)
	-- Fall back to the all-destination median: most rival ads say "any city" and carry no
	-- destination at all, so a per-city median is usually empty.
	if not stats.median then
		stats = self:Stats(window or 3600, "ask")
	end
	if not stats.median then return nil, nil, stats end

	local mine = PP.Dest:Price(destKey)
	local delta = mine - stats.median
	local verdict
	-- Within 5% counts as matching the market rather than beating it.
	if math.abs(delta) <= stats.median * 0.05 then
		verdict = "at"
	elseif delta < 0 then
		verdict = "under"
	else
		verdict = "over"
	end
	return verdict, delta, stats
end

function M:Clear()
	PP.global.market = { observations = {}, ignored = PP.global.market and PP.global.market.ignored or {} }
	PP:Fire("MARKET_UPDATED")
end

--------------------------------------------------------------------------------
-- Wiring
--------------------------------------------------------------------------------

--- The full intake decision, exposed so the sandbox can exercise the real path.
--- @return string|nil side, number|nil copper, string|nil rejection reason
function M:Evaluate(rawText, leadScore)
	-- A linked item or enchant means goods, not portals. Cheapest and most reliable
	-- rejection available, and it costs one string search.
	if containsLink(rawText) then return nil, nil, "contains an item link" end

	local normalized = U.Normalize(rawText)

	local prices = M:ExtractPrices(normalized)
	if #prices == 0 then return nil, nil, "no price" end

	-- Several different prices and no city named is a goods shop listing that happens to
	-- mention a portal somewhere, not a portal advert. A mage who genuinely quotes per-city
	-- prices names the cities, so those still get through.
	local distinct = {}
	for _, p in ipairs(prices) do distinct[p.copper] = true end
	if U.Count(distinct) >= 3 and PP.Dest:CountMentioned(normalized) <= 1 then
		return nil, nil, "multi-item listing with no destination named"
	end

	local side = M:Classify(normalized, leadScore, true)
	if not side then return nil, nil, "not a portal advert or bid" end

	-- Which price belongs to the portal, and is it actually near the word?
	local price, distance = M:NearestPrice(normalized, prices)
	if not price then return nil, nil, "no portal mention" end
	if distance > MAX_PRICE_DISTANCE then
		return nil, nil, ("price is %d characters from any mention of a portal"):format(distance)
	end

	return side, price.copper, nil
end

function M:OnEnable()
	PP:On("CHAT_SCANNED", function(text, name, channelKey, score, dest)
		if M:IsIgnored(name) then return end

		local side, copper, reason = M:Evaluate(text, score)
		if not side then
			if reason and reason ~= "no price" and reason ~= "not a portal advert or bid" then
				PP:Debug("market rejected:", reason)
			end
			return
		end

		M:Record(name, side, copper, dest and dest.key or nil, channelKey, text)
	end)
end
