-- Portal Pro :: Modules/Leads.lua
-- Chat scanning and the lead queue.
--
-- Intent detection is a scoring rule set, not a string.find for "port". A single substring
-- match treats "the portal room" and "selling portals to SW" as customers; the first wastes
-- your time and the second makes you whisper a competitor. Rules are additive and tunable,
-- and every lead records the score that produced it so false positives are debuggable.

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

local Leads = PP:NewModule("Leads")
PP.Leads = Leads

Leads.queue = {}          -- newest first
local lastSeen = {}       -- [playerName] = timestamp, for the per-player cooldown
local pruneTicker

local SCORE_THRESHOLD = 3

--------------------------------------------------------------------------------
-- Rules
--------------------------------------------------------------------------------

-- Matched against the normalised message, space-padded on both sides so that plain
-- word patterns like " port " behave.
local POSITIVE = {
	{ "lf%s+porta?l?s?%f[%A]",              4, "lf port" },
	{ "%f[%a]lfp%f[%A]",                    3, "lfp" },
	{ "wtb%s+a?%s*porta?l?",                4, "wtb port" },
	{ "need%s+a?%s*porta?l?",               4, "need port" },
	{ "need%s+a?%s*tele",                   4, "need tele" },
	{ "want%s+a?%s*porta?l?",               3, "want port" },
	{ "looking%s+for%s+a?%s*porta?l?",      4, "looking for port" },
	{ "can%s+i%s+get%s+a?%s*porta?l?",      4, "can i get a port" },
	{ "anyone%s+porta?l?",                  3, "anyone port" },
	{ "any%s+mages?%f[%A]",                 3, "any mage" },
	{ "porta?l?s?%s+plz",                   4, "port plz" },
	{ "porta?l?s?%s+pls",                   4, "port pls" },
	{ "porta?l?%s+to%s",                    3, "port to X" },
	{ "teleport%s+to%s",                    3, "teleport to X" },
	{ "tele%s+to%s",                        3, "tele to X" },
	{ "mage%s+porta?l?",                    2, "mage port" },
	{ "%s+porta?l?%s*%?",                   2, "port?" },
	{ "%f[%a]porta?l?s?%f[%A]",             1, "port mention" },
	{ "%f[%a]teleports?%f[%A]",             1, "teleport mention" },
}

-- Other mages advertising. Without these you spend the evening whispering your competition.
local NEGATIVE = {
	{ "%f[%a]wts%f[%A]",                   -6, "wts" },
	{ "%f[%a]sell%a*%f[%A]",               -6, "selling" },
	{ "porta?l?s?%s+up%f[%A]",             -5, "portals up" },
	{ "porta?l?%s+services?",              -5, "portal service" },
	{ "opening%s+porta?l?s?",              -5, "opening portals" },
	{ "porting%s+for",                     -5, "porting for" },
	{ "for%s+sale%f[%A]",                  -5, "for sale" },
	{ "%f[%a]offering%f[%A]",              -4, "offering" },
	{ "whisper%s+me",                      -3, "whisper me" },
	{ "%f[%a]w%s+me%f[%A]",                -3, "w me" },
	{ "%f[%a]wtt%f[%A]",                   -2, "wtt" },
	{ "%f[%a]lfm%f[%A]",                   -2, "lfm" },
	{ "%f[%a]summon%a*%f[%A]",             -3, "summon" },
	{ "portal%s+room",                     -4, "portal room" },
}

--- Score a message. Exposed so /pp debug can explain why something did or didn't match.
--- @return number score, table|nil dest, table reasons
function Leads:Score(text)
	local norm = U.Normalize(text)
	local padded = " " .. norm .. " "
	local score, reasons = 0, {}

	for _, rule in ipairs(POSITIVE) do
		if padded:find(rule[1]) then
			score = score + rule[2]
			reasons[#reasons + 1] = ("+%d %s"):format(rule[2], rule[3])
		end
	end

	local checkNegative = not PP.profile or PP.profile.detection.ignoreCompetitors
	if checkNegative then
		for _, rule in ipairs(NEGATIVE) do
			if padded:find(rule[1]) then
				score = score + rule[2]
				reasons[#reasons + 1] = ("%d %s"):format(rule[2], rule[3])
			end
		end
	end

	local dest = PP.Dest:Resolve(norm)
	if dest then
		score = score + 2
		reasons[#reasons + 1] = "+2 destination: " .. dest.name
	end

	return score, dest, reasons
end

--------------------------------------------------------------------------------
-- Chat intake
--------------------------------------------------------------------------------

local CHANNEL_PREFIX = {
	["general"]         = "GENERAL",
	["trade"]           = "TRADE",
	["lookingforgroup"] = "LFG",
}

local function resolveChannelKey(baseName)
	if not baseName then return nil end
	local lower = baseName:lower()
	for prefix, key in pairs(CHANNEL_PREFIX) do
		if lower:find(prefix, 1, true) then return key end
	end
	return nil
end

local function classFromGUID(guid)
	if not guid or not _G.GetPlayerInfoByGUID then return nil end
	local ok, _, englishClass = pcall(GetPlayerInfoByGUID, guid)
	if ok then return englishClass end
	return nil
end

--- Common entry point for every chat source.
function Leads:Consider(text, sender, channelKey, guid, channelLabel)
	local cfg = PP.profile and PP.profile.detection
	if not cfg or not cfg.enabled then return end
	if not channelKey or not cfg.channels[channelKey] then return end
	if not text or text == "" then return end

	local name = C.ShortName(sender)
	if not name or name == "" then return end
	if name == PP.playerName then return end

	local score, dest, reasons = self:Score(text)

	-- Publish every scanned line, whatever the verdict. A rival's advert scores well below
	-- the lead threshold and would otherwise be thrown away — but it contains their price,
	-- which is the only source of live market data anyone has. Fired before the blacklist
	-- and cooldown checks on purpose: those decide whether YOU get bothered, not whether
	-- the message is worth observing.
	PP:Fire("CHAT_SCANNED", text, name, channelKey, score, dest)

	if PP.Ledger:IsBlacklisted(name) then
		PP:Debug("ignoring blacklisted", name)
		return
	end

	-- Per-player cooldown: one person spamming Trade shouldn't produce ten cards.
	local last = lastSeen[name]
	if last and (time() - last) < (cfg.playerCooldown or 300) then return end

	if score < SCORE_THRESHOLD then return end
	if cfg.requireDest and not dest then return end

	lastSeen[name] = time()
	self:Add({
		name    = name,
		class   = classFromGUID(guid),
		dest    = dest,
		destKey = dest and dest.key or nil,
		message = text,
		channel = channelKey,
		channelLabel = channelLabel or channelKey,
		score   = score,
		reasons = reasons,
		guid    = guid,
	})
end

function Leads:OnEnable()
	local function simple(key)
		return function(event, text, sender, _, _, _, _, _, _, _, _, _, guid)
			Leads:Consider(text, sender, key, guid, key)
		end
	end

	PP:RegisterEvent("CHAT_MSG_SAY",     simple("SAY"))
	PP:RegisterEvent("CHAT_MSG_YELL",    simple("YELL"))
	PP:RegisterEvent("CHAT_MSG_GUILD",   simple("GUILD"))
	PP:RegisterEvent("CHAT_MSG_PARTY",   simple("PARTY"))
	PP:RegisterEvent("CHAT_MSG_PARTY_LEADER", simple("PARTY"))
	PP:RegisterEvent("CHAT_MSG_WHISPER", simple("WHISPER"))

	PP:RegisterEvent("CHAT_MSG_CHANNEL", function(event, text, sender, _, channelName,
			_, _, _, _, channelBaseName, _, _, guid)
		local key = resolveChannelKey(channelBaseName) or resolveChannelKey(channelName)
		Leads:Consider(text, sender, key, guid, channelBaseName or channelName)
	end)

	pruneTicker = C_Timer.NewTicker(5, function() Leads:Prune() end)

	-- A paid customer is a closed job, so their card comes off the queue. Handled here
	-- rather than in the payment watchers so trades and mail behave identically — the
	-- watchers only report that money arrived; deciding what that means for the queue is
	-- the queue's business.
	PP:On("PAYMENT_RECEIVED", function(customer)
		local lead = Leads:FindByName(customer)
		if not lead then return end

		lead.paid = true
		-- Clear their cooldown on the way out. They've just proved they're a real customer;
		-- if they ask for another portal inside the five-minute window, that should produce
		-- a fresh card rather than being silently suppressed.
		Leads:ResetCooldown(customer)
		Leads:Remove(lead.id)
		PP:Debug("lead cleared after payment:", customer)
	end)
end

--------------------------------------------------------------------------------
-- Queue
--------------------------------------------------------------------------------

function Leads:Add(lead)
	lead.id     = U.NextID()
	lead.ts     = time()
	lead.status = "new"
	lead.vip    = PP.Ledger:IsVIP(lead.name)

	local customer = PP.Ledger:GetCustomer(lead.name)
	lead.returning = customer and (customer.count or 0) > 0 or false

	-- VIPs and returning customers go to the front; everyone else is newest-first.
	if lead.vip then
		table.insert(self.queue, 1, lead)
	else
		local insertAt = #self.queue + 1
		for i = 1, #self.queue do
			if not self.queue[i].vip then
				insertAt = i
				break
			end
		end
		table.insert(self.queue, insertAt, lead)
	end

	PP.global.counters.leads = (PP.global.counters.leads or 0) + 1

	PP:Fire("LEAD_ADDED", lead)
	PP:Fire("LEADS_CHANGED")

	local cfg = PP.profile.detection
	if cfg.sound then
		pcall(PlaySound, SOUNDKIT and SOUNDKIT.TELL_MESSAGE or 3081, "Master")
	end
	if cfg.flash then
		pcall(FlashClientIcon)
	end

	-- Auto actions are opt-in. See PLAN.md §1.4.
	if cfg.autoWhisper then
		PP.Whispers:SendGreeting(lead.name, lead)
		lead.whispered = true
	end
	if cfg.autoInvite then
		PP.Group:Invite(lead.name, lead)
		lead.invited = true
	end

	return lead
end

function Leads:Get(id)
	for _, lead in ipairs(self.queue) do
		if lead.id == id then return lead end
	end
	return nil
end

function Leads:Remove(id)
	for i = #self.queue, 1, -1 do
		if self.queue[i].id == id then
			local lead = table.remove(self.queue, i)
			PP:Fire("LEAD_REMOVED", lead)
			PP:Fire("LEADS_CHANGED")
			return lead
		end
	end
	return nil
end

function Leads:Clear()
	wipe(self.queue)
	PP:Fire("LEADS_CHANGED")
end

--- Drop leads past their expiry. VIP leads get double the window.
function Leads:Prune()
	local cfg = PP.profile and PP.profile.detection
	if not cfg then return end
	local expiry = cfg.leadExpiry or 240
	local now, changed = time(), false

	for i = #self.queue, 1, -1 do
		local lead = self.queue[i]
		local window = lead.vip and expiry * 2 or expiry
		if lead.status ~= "engaged" and (now - lead.ts) > window then
			table.remove(self.queue, i)
			changed = true
		end
	end

	if changed then PP:Fire("LEADS_CHANGED") end
end

function Leads:Count()
	return #self.queue
end

--- The most recent lead we actually engaged with — used to attribute payments and casts.
function Leads:MostRecentEngaged()
	local best
	for _, lead in ipairs(self.queue) do
		if lead.status == "engaged" and (not best or lead.engagedAt > best.engagedAt) then
			best = lead
		end
	end
	return best
end

function Leads:FindByName(name)
	for _, lead in ipairs(self.queue) do
		if lead.name == name then return lead end
	end
	return nil
end

--------------------------------------------------------------------------------
-- Actions (thin delegates so the UI has one place to call)
--------------------------------------------------------------------------------

function Leads:MarkEngaged(lead)
	if not lead then return end
	lead.status = "engaged"
	lead.engagedAt = time()
	PP:Fire("LEADS_CHANGED")
end

function Leads:Whisper(lead, templateKey)
	if not lead then return end

	-- The greeting is the one template that depends on which ad brought them in, so it goes
	-- through SendGreeting; the other six are fixed roles and go straight out.
	if not templateKey or templateKey == "greeting" then
		PP.Whispers:SendGreeting(lead.name, lead)
	else
		PP.Whispers:SendTemplate(templateKey, lead.name, lead)
	end

	lead.whispered = true
	self:MarkEngaged(lead)
end

function Leads:Invite(lead)
	if not lead then return end
	PP.Group:Invite(lead.name, lead)
	lead.invited = true
	self:MarkEngaged(lead)
end

function Leads:Blacklist(lead)
	if not lead then return end
	PP.Ledger:SetBlacklisted(lead.name, true)
	self:Remove(lead.id)
	PP:Print(PP.L["Blacklisted %s"]:format(lead.name))
end

--- Clear the cooldown for a player so they can generate a fresh lead immediately.
function Leads:ResetCooldown(name)
	lastSeen[name] = nil
end
