--[[--------------------------------------------------------------------------
	Boom - Modern Healing & Support Raid Frames
	Support/Support.lua

	Support-role features. The half of a raid frame that traditionally does not
	exist.

	A modern raid is not "healers heal, everyone else presses buttons". An
	Augmentation Evoker keeps Ebon Might and Prescience rolling on the right
	targets; a Protection Paladin hands out Blessing of Sacrifice and Freedom; a
	Warrior places Intervene; a Death Knight drops Anti-Magic Zone; half the raid
	carries a battle resurrection. Every one of those decisions is made by
	looking at a specific player - which is exactly what a raid frame is for.

	So Boom treats support abilities as first-class:
	  * the buffs YOU apply are tracked on the frames with duration and expiry warning
	  * players missing your support buff are flagged
	  * defensive externals from anyone are visible on the target
	  * group-wide cooldowns get an optional bar

	Nothing here is Evoker-specific. A spec with no support toolkit simply has an
	empty tracked set and the whole module stays out of the way.
----------------------------------------------------------------------------]]

local ADDON_NAME, Boom = ...

local Support = Boom:NewModule("Support")

local Util   = Boom.Util
local Skin   = Boom.Skin
local Media  = Boom.Media
local L      = Boom.L
local UPDATE = Boom.UPDATE

local pairs, ipairs, wipe, tsort = pairs, ipairs, wipe, table.sort
local issecretvalue = _G.issecretvalue or function() return false end
local GetTime = GetTime
local UnitGUID, UnitExists = UnitGUID, UnitExists
local UnitIsDeadOrGhost, UnitIsConnected = UnitIsDeadOrGhost, UnitIsConnected

--------------------------------------------------------------------------------
-- Tracked spell sets for the current specialization
--------------------------------------------------------------------------------

local trackedBuffs   = {}   -- array of spellID: buffs the player applies to allies
local trackedSet     = {}   -- spellID -> true
local primaryBuff    = nil  -- the one used for the "missing" check

--- Recalculate what this character can actually put on other players.
function Support:RefreshTrackedSpells()
	wipe(trackedBuffs)
	wipe(trackedSet)
	primaryBuff = nil

	local SpellDB = Boom.SpellDB
	local specID  = Boom.Config:GetPlayerSpecID()
	if not SpellDB or not specID then return end

	-- Support buffs first, then externals: both belong on the frames, but the
	-- "missing buff" nudge only makes sense for maintained support buffs.
	local best, bestPriority = nil, math.huge

	for _, category in ipairs({ "support_buff", "external_defensive" }) do
		local list = SpellDB:GetKnownSpells(specID, category)
		for _, entry in ipairs(list) do
			-- Raid-wide buffs are cast once and sit on everybody; they are not a
			-- per-target job, so they never become the thing we nag about.
			local permanent = SpellDB:IsPermanentBuff(entry.id)

			if not trackedSet[entry.id] and not permanent then
				trackedSet[entry.id] = true
				trackedBuffs[#trackedBuffs + 1] = entry.id

				if category == "support_buff" and (entry.prio or 99) < bestPriority then
					bestPriority = entry.prio or 99
					best = entry.id
				end
			end
		end
	end

	-- User additions.
	for spellID in pairs(Boom.profile.support.extraTracked) do
		if not trackedSet[spellID] and Util.SpellExists(spellID) then
			trackedSet[spellID] = true
			trackedBuffs[#trackedBuffs + 1] = spellID
		end
	end

	primaryBuff = best

	Boom:MarkAllDirty(UPDATE.AURA)
end

function Support:GetTrackedBuffs()
	return trackedBuffs
end

function Support:IsTracked(spellID)
	return trackedSet[spellID] or false
end

function Support:GetPrimaryBuff()
	return primaryBuff
end

--------------------------------------------------------------------------------
-- Missing buff detection
--------------------------------------------------------------------------------

--- True when this unit is a sensible target for the player's primary support
--- buff and does not currently have it. Alive, connected, in range, not us
--- when the buff cannot be self-cast.
function Support:IsMissingBuff(unit)
	if not Boom.profile.support.enabled then return false end
	if not Boom.profile.support.missingBuffAlert then return false end

	-- With aura data withheld, "no buff found" means "we cannot see", not
	-- "they do not have it" - flagging the whole raid would be worse than
	-- staying quiet.
	if Boom.Auras.secret then return false end

	local spellID = primaryBuff
	if not spellID or not unit then return false end

	-- Both flags are secret-restricted inside instances and testing a secret is
	-- a hard error; Util.IsTrue/IsFalse read "withheld" as "no" so a raid keeps
	-- being scanned instead of the whole support module throwing.
	if not UnitExists(unit)
		or Util.IsTrue(UnitIsDeadOrGhost(unit))
		or Util.IsFalse(UnitIsConnected(unit)) then
		return false
	end

	if Boom.profile.range.enabled and Boom.Range and not Boom.Range:IsInRange(unit) then
		return false
	end

	return not Boom.Auras:HasBuff(unit, spellID, true)
end

--- Every group member currently missing `spellID` from you.
function Support:GetMissingUnits(spellID)
	spellID = spellID or primaryBuff
	local out = {}
	if not spellID or not Boom.Frames then return out end

	for _, button in Boom.Frames:IterateButtons() do
		local unit = button.unit
		if unit and UnitExists(unit) and not Util.IsTrue(UnitIsDeadOrGhost(unit)) then
			if not Boom.Auras:HasBuff(unit, spellID, true) then
				out[#out + 1] = unit
			end
		end
	end
	return out
end

--------------------------------------------------------------------------------
-- Externals on allies
--------------------------------------------------------------------------------

--- Single source of truth for whether external tracking is on. The externals
--- indicator asks this, so the setting and the drawing can never disagree.
function Support:ShouldTrackExternals()
	local cfg = Boom.profile and Boom.profile.support
	if not cfg then return false end
	return cfg.enabled ~= false and cfg.trackExternals ~= false
end

function Support:IsExternalActive(unit)
	if not self:ShouldTrackExternals() then return false end
	local list = Boom.Auras:GetExternals(unit)
	return list and #list > 0
end

--------------------------------------------------------------------------------
-- Group cooldown tracking
--
-- There is no API that reports another player's cooldowns, so we watch the
-- combat log for casts and start our own timer from the known base duration.
-- It is not perfect - a reset or a talent that shortens the cooldown will drift
-- - but it is the same technique every raid cooldown addon uses, and being
-- roughly right about who has Rallying Cry available is far better than nothing.
--------------------------------------------------------------------------------

local cooldowns = {}   -- guid -> { [spellID] = expiryTime }

local function noteCast(guid, spellID)
	local SpellDB = Boom.SpellDB
	if not SpellDB then return end

	local info = SpellDB.raidCooldowns and SpellDB.raidCooldowns[spellID]
	if not info then return end

	local store = cooldowns[guid]
	if not store then
		store = {}
		cooldowns[guid] = store
	end
	store[spellID] = GetTime() + (info.cd or 180)

	Boom:SendMessage("BOOM_RAID_COOLDOWN_USED", guid, spellID)
	Support:RefreshCooldownBar()
end

--- Everything the group could bring, with readiness.
--- Returns array of { spellID, unit, name, ready, expires }
function Support:GetRaidCooldowns()
	local out = {}
	local SpellDB = Boom.SpellDB
	if not SpellDB or not SpellDB.raidCooldowns then return out end
	if not Boom.Roster then return out end

	local now = GetTime()

	for unit in Boom.Roster:IterateUnits() do
		local info = Boom.Roster:GetUnitInfo(unit)
		if info and info.classToken then
			for spellID, data in pairs(SpellDB.raidCooldowns) do
				if data.class == info.classToken then
					local guid = info.guid
					local expires = guid and cooldowns[guid] and cooldowns[guid][spellID]
					if expires and expires <= now then expires = nil end

					out[#out + 1] = {
						spellID = spellID,
						unit    = unit,
						name    = info.shortName,
						ready   = expires == nil,
						expires = expires,
						duration= data.cd,
					}
				end
			end
		end
	end

	tsort(out, function(a, b)
		if a.ready ~= b.ready then return a.ready end
		return (a.spellID or 0) < (b.spellID or 0)
	end)

	return out
end

--------------------------------------------------------------------------------
-- Cooldown bar UI
--------------------------------------------------------------------------------

local bar

local function buildBar()
	if bar then return bar end

	local cfg = Boom.profile.support.cooldownBar

	bar = CreateFrame("Frame", "BoomCooldownBar", UIParent)
	bar:SetSize(cfg.iconSize * cfg.maxIcons, cfg.iconSize)
	bar.icons = {}

	-- Deliberately takes no mouse input: it floats over the play area. Its
	-- separate mover handles positioning while the frames are unlocked.
	bar:EnableMouse(false)

	Support:PlaceCooldownBar()
	return bar
end

--- Re-anchor the bar from its saved position.
function Support:PlaceCooldownBar()
	if not bar then return end
	local cfg = Boom.profile.support.cooldownBar

	bar:ClearAllPoints()
	bar:SetPoint(cfg.point or "CENTER", UIParent,
		cfg.relativePoint or cfg.point or "CENTER", cfg.x or 0, cfg.y or 260)
end

local function acquireIcon(index)
	local icon = bar.icons[index]
	if icon then return icon end

	icon = CreateFrame("Frame", nil, bar)

	icon.texture = icon:CreateTexture(nil, "ARTWORK")
	icon.texture:SetPoint("TOPLEFT", 1, -1)
	icon.texture:SetPoint("BOTTOMRIGHT", -1, 1)
	icon.texture:SetTexCoord(0.08, 0.92, 0.08, 0.92)

	icon.border = Util.CreateBorder(icon, "OVERLAY")
	icon.border:SetSize(1)
	icon.border:SetColor(0, 0, 0, 1)

	icon.cooldown = CreateFrame("Cooldown", nil, icon, "CooldownFrameTemplate")
	icon.cooldown:SetAllPoints(icon.texture)
	icon.cooldown:SetDrawEdge(false)

	icon.label = Skin:CreateFontString(icon, "OVERLAY", 9, "OUTLINE")
	icon.label:SetPoint("BOTTOM", 0, -1)

	bar.icons[index] = icon
	return icon
end

function Support:RefreshCooldownBar()
	local cfg = Boom.profile.support.cooldownBar
	if not Boom.profile.support.enabled or not cfg.enabled then
		if bar then bar:Hide() end
		return
	end

	buildBar()
	self:PlaceCooldownBar()

	local list = self:GetRaidCooldowns()
	local size = cfg.iconSize or 28
	local shown = 0
	local dx = (cfg.growth == "LEFT") and -1 or 1

	for _, entry in ipairs(list) do
		if shown >= (cfg.maxIcons or 12) then break end
		if not cfg.showOnlyReady or entry.ready then
			shown = shown + 1

			local icon = acquireIcon(shown)
			icon:SetSize(size, size)
			icon:ClearAllPoints()
			icon:SetPoint("LEFT", bar, "LEFT", dx * (size + 2) * (shown - 1), 0)

			icon.texture:SetTexture(Util.GetSpellIcon(entry.spellID) or Media.ICONS.question)
			icon.label:SetText(entry.name or "")

			if entry.ready then
				icon.texture:SetDesaturated(false)
				icon.texture:SetVertexColor(1, 1, 1)
				icon.cooldown:Clear()
			else
				icon.texture:SetDesaturated(true)
				icon.texture:SetVertexColor(0.6, 0.6, 0.6)
				icon.cooldown:SetCooldown(entry.expires - entry.duration, entry.duration)
			end

			icon:Show()
		end
	end

	for i = shown + 1, #bar.icons do
		bar.icons[i]:Hide()
	end

	bar:SetSize(math.max(1, size * shown + 2 * shown), size + 12)
	bar:SetShown(shown > 0)
end

function Support:GetCooldownBar()
	return bar
end

--------------------------------------------------------------------------------
-- Cooldown bar mover
--
-- The bar itself takes no mouse input - it sits over the play area and must not
-- eat clicks - so it gets a separate handle that only exists while the frames
-- are unlocked, exactly like the unit panels. Without this the bar could only be
-- positioned by typing numbers into two sliders.
--------------------------------------------------------------------------------

local barMover

local function createBarMover()
	if barMover then return barMover end

	barMover = CreateFrame("Frame", nil, UIParent)
	barMover:SetFrameStrata("HIGH")
	barMover:SetClampedToScreen(true)
	barMover:EnableMouse(true)
	barMover:SetMovable(true)
	barMover:RegisterForDrag("LeftButton")
	barMover:Hide()

	Skin:CreateBackdrop(barMover, {
		color = { 0, 0.83, 1, 0.12 },
		borderColor = { 0, 0.83, 1, 0.9 },
	})

	local label = Skin:CreateFontString(barMover, "OVERLAY", 12, "OUTLINE")
	label:SetPoint("CENTER")
	label:SetText(L["Cooldown Bar"])
	barMover.label = label

	barMover:SetScript("OnDragStart", function(self) self:StartMoving() end)
	barMover:SetScript("OnDragStop", function(self)
		self:StopMovingOrSizing()

		local point, _, relativePoint, x, y = self:GetPoint(1)
		if not point then return end

		Boom.Config:Set("support.cooldownBar.point", point)
		Boom.Config:Set("support.cooldownBar.relativePoint", relativePoint)
		Boom.Config:Set("support.cooldownBar.x", Util.Round(x, 0))
		Boom.Config:Set("support.cooldownBar.y", Util.Round(y, 0))

		Support:RefreshCooldownBar()
	end)

	return barMover
end

--- Show the handle while the frames are unlocked and the bar is switched on.
function Support:UpdateCooldownBarMover()
	local cfg = Boom.profile and Boom.profile.support and Boom.profile.support.cooldownBar
	if not cfg then return end

	local unlocked = not Boom.profile.general.lockFrames
	local wanted = unlocked and Boom.profile.support.enabled ~= false and cfg.enabled ~= false

	if not wanted then
		if barMover then barMover:Hide() end
		return
	end

	local mover = createBarMover()
	local size = cfg.iconSize or 28

	-- Sized to the full bar the user has configured, so the handle shows the
	-- footprint even when the group brings no cooldowns to display.
	mover:SetSize(math.max(120, (size + 2) * (cfg.maxIcons or 12)), size + 12)
	mover:ClearAllPoints()
	mover:SetPoint(cfg.point or "CENTER", UIParent, cfg.relativePoint or cfg.point or "CENTER",
		cfg.x or 0, cfg.y or 0)
	mover:Show()
end

--------------------------------------------------------------------------------
-- Lifecycle
--------------------------------------------------------------------------------

function Support:OnEnable()
	self:RefreshTrackedSpells()

	self:RegisterMessage("BOOM_SPEC_CHANGED", function() Support:RefreshTrackedSpells() end)
	self:RegisterMessage("BOOM_PROFILE_CHANGED", function()
		Support:RefreshTrackedSpells()
		Support:RefreshCooldownBar()
	end)
	self:RegisterEvent("SPELLS_CHANGED", function()
		Boom:Throttle("support-spells", 1, function() Support:RefreshTrackedSpells() end)
	end)
	self:RegisterEvent("TRAIT_CONFIG_UPDATED", function()
		Boom:Throttle("support-spells", 1, function() Support:RefreshTrackedSpells() end)
	end)

	self:RegisterMessage("BOOM_ROSTER_UPDATED", function()
		Boom:Throttle("support-cds", 0.5, function() Support:RefreshCooldownBar() end)
	end)

	self:RegisterMessage("BOOM_CONFIG_CHANGED", function(_, _, path)
		if not path then return end
		if path:find("^support%.") then
			Support:RefreshCooldownBar()
			Support:UpdateCooldownBarMover()
		elseif path == "general.lockFrames" then
			Support:UpdateCooldownBarMover()
		end
	end)

	-- The drag handle's caption is written once when it is created.
	self:RegisterMessage("BOOM_LOCALE_CHANGED", function()
		if barMover and barMover.label then
			barMover.label:SetText(L["Cooldown Bar"])
		end
	end)

	-- NOT the combat log. Midnight forbids addons from registering
	-- COMBAT_LOG_EVENT_UNFILTERED outright - the attempt raises
	-- ADDON_ACTION_FORBIDDEN the instant the addon loads, which is the popup
	-- telling the player to disable it. (Blizzard restricted it because it
	-- leaks unit health, power, auras and GUIDs; DBM gates it behind its own
	-- IsPostMidnight check for the same reason.)
	--
	-- UNIT_SPELLCAST_SUCCEEDED carries exactly what the cooldown bar needs -
	-- which unit cast which spell - is not restricted, and costs less: the
	-- client filters to real unit tokens instead of handing us every combat
	-- log line in the zone.
	self:RegisterEvent("UNIT_SPELLCAST_SUCCEEDED", function(_, _, unit, _, spellID)
		if not Boom.profile.support.trackRaidCooldowns then return end
		if not unit or not spellID then return end

		-- A secret value cannot be used as a table key, so it can never reach
		-- the lookup below.
		if issecretvalue(spellID) then return end

		-- Cheapest possible rejection first: this fires for every cast by every
		-- tracked unit, and almost none of them are raid cooldowns.
		local SpellDB = Boom.SpellDB
		if not SpellDB or not SpellDB.raidCooldowns or not SpellDB.raidCooldowns[spellID] then
			return
		end

		local info = Boom.Roster and Boom.Roster:GetUnitInfo(unit)
		if info and info.guid then
			noteCast(info.guid, spellID)
		end
	end)

	-- Expire finished cooldowns so the bar does not need an OnUpdate.
	Boom:NewTicker(1, function()
		if not Boom.profile.support.enabled then return end
		local now = GetTime()
		local changed = false
		for guid, store in pairs(cooldowns) do
			for spellID, expires in pairs(store) do
				if expires <= now then
					store[spellID] = nil
					changed = true
				end
			end
		end
		if changed then Support:RefreshCooldownBar() end
	end)
end
