-- Portal Pro :: Modules/Broadcaster.lua
--
-- SAY, YELL and CHANNEL are protected: SendChatMessage for those types must originate from
-- a hardware event (Patch 8.2.5 / Classic 1.13.3). There is no timer that can post to Trade
-- chat while you're AFK, and any addon claiming otherwise is broken.
--
-- So the ad is composed and staged here, and released from a real input:
--   1. The Broadcast button's OnClick  — always a hardware event.
--   2. The PORTALPRO_BROADCAST keybind — always a hardware event.
--   3. Optional "arm" mode: the message waits, and the next keypress you make while
--      playing normally releases it. Opt-in, off by default.
--
-- This is not a workaround for the restriction — every send still happens inside a genuine
-- input handler. It just means you don't have to aim for a button.

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

local B = PP:NewModule("Broadcaster")
PP.Broadcaster = B

local MAX_LEN = 255

local lastSentPerChannel = {}   -- [channelKey] = GetTime()

-- Which ad went where, so a reply can be traced back to the ad that earned it. Session-only:
-- an attribution from before a reload is not worth persisting and would go stale anyway.
local lastAdPerChannel = {}     -- [channelKey] = { ad = <table>, at = GetTime() }
local lastAdAnywhere            -- { ad = <table>, at = GetTime() }

-- After this long, a reply is no longer plausibly about the ad we posted.
local ATTRIBUTION_WINDOW = 1800   -- 30 minutes
local lastBroadcastAt = 0
local armed = false
local armFrame

--------------------------------------------------------------------------------
-- Ads
--------------------------------------------------------------------------------

function B:List()
	if not PP.profile then return {} end
	-- Create it rather than handing back a throwaway table. Returning `{}` meant NewAd()
	-- could append to something unreachable from the saved variables, and the ad would
	-- vanish without a trace on reload.
	PP.profile.ads.list = PP.profile.ads.list or {}
	return PP.profile.ads.list
end

function B:Selected()
	local ads = self:List()
	local index = PP.profile.ads.selected or 1
	if index < 1 or index > #ads then index = 1 end
	return ads[index], index
end

function B:Select(index)
	PP.profile.ads.selected = index
	PP:Fire("ADS_CHANGED")
end

function B:NewAd()
	local ads = self:List()
	ads[#ads + 1] = {
		name = "Ad " .. (#ads + 1),
		text = "{rt1} Portals to any city - {price} {rt1} Whisper me!",
		channels = { TRADE = true },
	}
	PP.profile.ads.selected = #ads
	PP:Fire("ADS_CHANGED")
	return ads[#ads]
end

function B:DeleteAd(index)
	local ads = self:List()
	if #ads <= 1 then
		PP:Warn("Keep at least one ad.")
		return false
	end
	table.remove(ads, index)
	PP.profile.ads.selected = math.min(PP.profile.ads.selected or 1, #ads)
	PP:Fire("ADS_CHANGED")
	return true
end

--- Substitute {price} etc. Raid icon tokens like {rt1} are left alone so chat renders them.
function B:Expand(text)
	return PP.Whispers:Expand(text, {
		name = "",
		price = PP.profile.prices.default,
	})
end

--- @return boolean ok, string|nil problem
function B:Validate(ad)
	if not ad then return false, PP.L["No ad selected"] end
	local text = B:Expand(ad.text or "")
	if U.Trim(text) == "" then return false, PP.L["Nothing to broadcast"] end
	if #text > MAX_LEN then
		return false, ("Message is %d characters (max %d)"):format(#text, MAX_LEN)
	end
	local any = false
	for _, enabled in pairs(ad.channels or {}) do
		if enabled then
			any = true
			break
		end
	end
	if not any then return false, "No channels selected for this ad" end
	return true
end

--------------------------------------------------------------------------------
-- Channel resolution
--------------------------------------------------------------------------------

-- GetChannelList returns a flat run of values whose arity has differed across builds
-- (id, name) on some, (id, name, disabled) on others. Walk it defensively.
local function joinedChannels()
	local out = {}
	local list = { GetChannelList() }
	local i = 1
	while i <= #list do
		local id, name = list[i], list[i + 1]
		if type(id) == "number" and type(name) == "string" then
			out[#out + 1] = { id = id, name = name }
			i = i + (type(list[i + 2]) == "boolean" and 3 or 2)
		else
			i = i + 1
		end
	end
	return out
end

local CHANNEL_MATCH = {
	TRADE   = "trade",
	GENERAL = "general",
	LFG     = "lookingforgroup",
}

local function channelIDFor(key)
	local needle = CHANNEL_MATCH[key]
	if not needle then return nil end
	for _, ch in ipairs(joinedChannels()) do
		if ch.name:lower():find(needle, 1, true) then return ch.id, ch.name end
	end
	return nil
end

--------------------------------------------------------------------------------
-- Throttling
--------------------------------------------------------------------------------

function B:Cooldown()
	local throttle = PP.profile.ads.throttle or 60
	local elapsed = GetTime() - lastBroadcastAt
	return math.max(0, throttle - elapsed), throttle
end

function B:IsReady()
	return select(1, self:Cooldown()) <= 0
end

--------------------------------------------------------------------------------
-- Sending
--------------------------------------------------------------------------------

--- MUST be called from a hardware event handler (OnClick / OnKeyDown / keybind).
--- @return boolean sent
function B:Broadcast()
	local ad, index = self:Selected()
	local ok, problem = self:Validate(ad)
	if not ok then
		PP:Warn(problem)
		return false
	end

	local remaining = self:Cooldown()
	if remaining > 0 then
		PP:Warn(PP.L["Broadcast on cooldown (%s)"]:format(U.Duration(remaining)))
		return false
	end

	local text = self:Expand(ad.text)
	local sentAny = false

	for key, enabled in pairs(ad.channels) do
		if enabled then
			if key == "SAY" or key == "YELL" then
				SendChatMessage(text, key)
				sentAny = true
			else
				local channelID, channelName = channelIDFor(key)
				if channelID then
					SendChatMessage(text, "CHANNEL", nil, channelID)
					sentAny = true
				else
					PP:Warn(("Not in a %s channel - skipped."):format(key))
				end
			end
			lastSentPerChannel[key] = GetTime()
			-- Recorded before Rotate() moves the selection: this is the ad that actually
			-- went out, not whatever is selected by the time somebody answers it.
			lastAdPerChannel[key] = { ad = ad, at = GetTime() }
		end
	end

	if sentAny then
		lastAdAnywhere = { ad = ad, at = GetTime() }
		lastBroadcastAt = GetTime()
		PP.global.counters.broadcasts = (PP.global.counters.broadcasts or 0) + 1
		self:Rotate()
		PP:Fire("BROADCAST_SENT", ad, text)
	end

	return sentAny
end

--- Which ad is this lead most likely answering?
---
--- A request seen in a channel belongs to whatever we last posted *in that channel*. A whisper
--- has no channel of its own - they read the ad wherever we last put it - so it falls back to
--- the most recent broadcast anywhere.
---
--- Returns nil rather than guessing when nothing is fresh enough, and callers treat nil as
--- "use the default greeting". Being wrong about this sends someone the wrong opening line,
--- so it declines rather than reaching.
--- @param lead table|nil
--- @return table|nil ad
function B:AdForLead(lead)
	local now = GetTime()

	local function usable(record)
		if not record or not record.ad then return nil end
		if (now - record.at) > ATTRIBUTION_WINDOW then return nil end

		-- The ad may have been deleted since it was broadcast; a stale reference would still
		-- carry a greetingId and quietly resolve to text the user removed.
		for _, ad in ipairs(PP.profile.ads.list) do
			if ad == record.ad then return ad end
		end
		return nil
	end

	local channel = lead and lead.channel
	if channel and channel ~= "WHISPER" then
		local byChannel = usable(lastAdPerChannel[channel])
		if byChannel then return byChannel end
	end

	return usable(lastAdAnywhere)
end

--- Advance the selection after a send, when rotation is enabled.
---
--- Rotation moves what's selected, which is also what the editor is showing and what the
--- next Broadcast will send. That's fine when you asked for it and baffling when you
--- didn't — you pick an ad, send it, and the next click posts something else. So it's off
--- unless explicitly chosen.
function B:Rotate()
	local mode = PP.profile.ads.rotation
	if mode == "off" or mode == nil then return end

	local ads = self:List()
	if #ads < 2 then return end

	if mode == "random" then
		PP.profile.ads.selected = math.random(#ads)
	else
		PP.profile.ads.selected = ((PP.profile.ads.selected or 1) % #ads) + 1
	end
	PP:Fire("ADS_CHANGED")
end

--------------------------------------------------------------------------------
-- Arm mode
--------------------------------------------------------------------------------

function B:IsArmed()
	return armed
end

function B:Arm()
	if not PP.profile.ads.armEnabled then
		PP:Warn("Arm mode is off - enable it in Settings, or use the Broadcast button.")
		return false
	end
	if not self:IsReady() then
		PP:Warn(PP.L["Broadcast on cooldown (%s)"]:format(U.Duration(self:Cooldown())))
		return false
	end
	armed = true
	armFrame:Show()
	PP:Fire("ARM_CHANGED", true)
	PP:Print("Armed - your next keypress sends the ad.")
	return true
end

function B:Disarm()
	if not armed then return end
	armed = false
	if armFrame then armFrame:Hide() end
	PP:Fire("ARM_CHANGED", false)
end

function B:ToggleArm()
	if armed then self:Disarm() else self:Arm() end
end

function B:OnEnable()
	armFrame = CreateFrame("Frame", "PortalProArmFrame", UIParent)
	armFrame:Hide()
	armFrame:EnableKeyboard(true)
	armFrame:SetPropagateKeyboardInput(true)

	armFrame:SetScript("OnKeyDown", function(self, key)
		-- Re-assert propagation first: the key must still reach the game, or arming
		-- would eat movement input.
		self:SetPropagateKeyboardInput(true)

		if not armed then return end
		-- Don't fire while the user is typing.
		if ChatEdit_GetActiveWindow and ChatEdit_GetActiveWindow() then return end
		if key == "ESCAPE" then
			B:Disarm()
			return
		end

		armed = false
		self:Hide()
		PP:Fire("ARM_CHANGED", false)
		B:Broadcast()
	end)

	-- Arming survives nothing: a reload or a cooldown change clears it.
	PP:On("PROFILE_CHANGED", function() B:Disarm() end)
end

--------------------------------------------------------------------------------
-- Keybinding entry points (referenced by Bindings.xml)
--------------------------------------------------------------------------------

-- Called straight from a keybind, which is a hardware event, so this send is legal.
function PortalPro_Binding_Broadcast()
	PP.Broadcaster:Broadcast()
end

function PortalPro_Binding_Toggle()
	if PP.UI and PP.UI.Toggle then PP.UI:Toggle() end
end

function PortalPro_Binding_AcceptLead()
	local lead = PP.Leads.queue[1]
	if not lead then return end
	PP.Leads:Whisper(lead)
	PP.Leads:Invite(lead)
end

_G.BINDING_HEADER_PORTALPRO         = "Portal Pro"
_G.BINDING_NAME_PORTALPRO_BROADCAST = "Broadcast current ad"
_G.BINDING_NAME_PORTALPRO_TOGGLE    = "Toggle Portal Pro window"
_G.BINDING_NAME_PORTALPRO_ACCEPT_LEAD = "Whisper + invite top lead"
