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

	The aura cache and every aura query the UI makes.

	This is the hottest path in the addon. In a 20-player raid pull, UNIT_AURA
	fires hundreds of times a second. Two things keep that affordable:

	  1. INCREMENTAL UPDATES. The event carries addedAuras / updatedAuraInstanceIDs
	     / removedAuraInstanceIDs. We only rescan a unit from scratch when the
	     game says isFullUpdate, or the first time we see the unit.

	  2. LAZY DERIVATION. Cheap bookkeeping happens on the event; the expensive
	     part (sorting HoTs, picking the dispellable debuff) happens on demand,
	     once, and only for units that are actually drawn.

	Engines do not draw. This file marks buttons dirty and answers questions.
----------------------------------------------------------------------------]]

local ADDON_NAME, Boom = ...

local Auras = Boom:NewModule("Auras")

local Util   = Boom.Util
local UPDATE = Boom.UPDATE

local pairs, ipairs, wipe, tsort = pairs, ipairs, wipe, table.sort
local UnitIsUnit = UnitIsUnit

local GetAuraDataByAuraInstanceID = C_UnitAuras and C_UnitAuras.GetAuraDataByAuraInstanceID
local ForEachAura = AuraUtil and AuraUtil.ForEachAura

--------------------------------------------------------------------------------
-- Aura secrecy
--
-- Midnight can withhold aura data from addons entirely, on a switch separate
-- from the one covering health. When it does, `spellId`, `duration` and
-- `expirationTime` come back as secret values - and a secret cannot be used as
-- a TABLE KEY, which is exactly what every classification in this file does
-- (SpellDB.hots[spellID], blacklist[spellID], ...).
--
-- No addon can classify auras under those conditions; Blizzard's own frames do
-- it from untainted code. So Boom detects the state and shows nothing, rather
-- than throwing once per aura event per unit.
--------------------------------------------------------------------------------

local issecretvalue = _G.issecretvalue or function() return false end

Auras.secret = false

local EMPTY = {}

function Auras:UpdateSecrecy()
	local secret = false

	if C_Secrets and C_Secrets.ShouldAurasBeSecret then
		local ok, value = pcall(C_Secrets.ShouldAurasBeSecret)
		secret = ok and value or false
	end

	if secret ~= self.secret then
		self.secret = secret
		self:InvalidateAll()
		Boom:MarkAllDirty(UPDATE.AURA)
		Boom:Debug("aura secrecy is now", tostring(secret))
	end

	return secret
end

--------------------------------------------------------------------------------
-- Cache
--------------------------------------------------------------------------------

local cache = {}   -- unit -> entry

-- A player driving a vehicle is drawn from their pet token, so aura events
-- arrive under "raidpet3" while the button that has to repaint is "raid3".
-- Engine/Status.lua carries the same table for the health path.
local PET_OWNER = { pet = "player" }
for i = 1, 40 do PET_OWNER["raidpet" .. i]  = "raid" .. i end
for i = 1, 4  do PET_OWNER["partypet" .. i] = "party" .. i end

local function newEntry()
	return {
		helpful = {},
		harmful = {},

		-- Derived lists, rebuilt lazily when `stale` is set.
		myHots      = {},
		important   = {},
		externals   = {},
		support     = {},
		shield      = nil,
		dispellable = nil,

		stale    = true,
		scanned  = false,
	}
end

local function getEntry(unit)
	local entry = cache[unit]
	if not entry then
		entry = newEntry()
		cache[unit] = entry
	end
	return entry
end

--- A unit token can be reassigned to a different player at any roster change,
--- so the safe move is to throw the whole cache away and rescan on demand.
function Auras:InvalidateAll()
	for _, entry in pairs(cache) do
		wipe(entry.helpful)
		wipe(entry.harmful)
		entry.scanned = false
		entry.stale = true
	end
end

function Auras:Invalidate(unit)
	local entry = cache[unit]
	if not entry then return end
	wipe(entry.helpful)
	wipe(entry.harmful)
	entry.scanned = false
	entry.stale = true
end

--------------------------------------------------------------------------------
-- Scanning
--------------------------------------------------------------------------------

local function storeAura(entry, aura)
	if not aura or not aura.auraInstanceID then return end
	-- auraInstanceID is documented NeverSecret, but spellId is not; an aura we
	-- cannot key on is an aura we cannot classify, so it never enters the cache.
	if issecretvalue(aura.spellId) then return end
	if aura.isHelpful then
		entry.helpful[aura.auraInstanceID] = aura
	else
		entry.harmful[aura.auraInstanceID] = aura
	end
end

local scanTarget

local function collectHelpful(aura)
	if aura and not issecretvalue(aura.spellId) then
		scanTarget.helpful[aura.auraInstanceID] = aura
	end
end

local function collectHarmful(aura)
	if aura and not issecretvalue(aura.spellId) then
		scanTarget.harmful[aura.auraInstanceID] = aura
	end
end

local function fullScan(unit, entry)
	wipe(entry.helpful)
	wipe(entry.harmful)

	-- Nothing readable to scan; leave the cache empty so every query is a
	-- cheap miss instead of an error.
	if Auras.secret then
		entry.scanned = true
		entry.stale = true
		return
	end

	scanTarget = entry

	if ForEachAura then
		ForEachAura(unit, "HELPFUL", nil, collectHelpful, true)
		ForEachAura(unit, "HARMFUL", nil, collectHarmful, true)
	elseif C_UnitAuras and C_UnitAuras.GetAuraDataByIndex then
		for i = 1, 60 do
			local aura = C_UnitAuras.GetAuraDataByIndex(unit, i, "HELPFUL")
			if not aura then break end
			entry.helpful[aura.auraInstanceID] = aura
		end
		for i = 1, 60 do
			local aura = C_UnitAuras.GetAuraDataByIndex(unit, i, "HARMFUL")
			if not aura then break end
			entry.harmful[aura.auraInstanceID] = aura
		end
	end

	scanTarget = nil
	entry.scanned = true
	entry.stale = true
end

--- Handle one UNIT_AURA payload.
local function applyUpdate(unit, entry, info)
	if not info or info.isFullUpdate or not entry.scanned then
		fullScan(unit, entry)
		return
	end

	if info.removedAuraInstanceIDs then
		for _, id in ipairs(info.removedAuraInstanceIDs) do
			entry.helpful[id] = nil
			entry.harmful[id] = nil
		end
	end

	if info.addedAuras then
		for _, aura in ipairs(info.addedAuras) do
			storeAura(entry, aura)
		end
	end

	if info.updatedAuraInstanceIDs and GetAuraDataByAuraInstanceID then
		for _, id in ipairs(info.updatedAuraInstanceIDs) do
			local aura = GetAuraDataByAuraInstanceID(unit, id)
			if aura then
				storeAura(entry, aura)
			else
				-- Gone between the event and our read.
				entry.helpful[id] = nil
				entry.harmful[id] = nil
			end
		end
	end

	entry.stale = true
end

--- Guarantee the cache for `unit` reflects reality, then return it.
local function ensure(unit)
	local entry = getEntry(unit)
	if not entry.scanned then
		fullScan(unit, entry)
	end
	return entry
end

--------------------------------------------------------------------------------
-- Derived lists
--------------------------------------------------------------------------------

local function isMine(aura)
	local source = aura.sourceUnit
	if not source then return false end
	return UnitIsUnit(source, "player") or UnitIsUnit(source, "pet")
end

local function sortByPriority(a, b)
	if a.boomPriority == b.boomPriority then
		return (a.spellId or 0) < (b.spellId or 0)
	end
	return a.boomPriority > b.boomPriority
end

local function rebuild(unit, entry)
	local SpellDB  = Boom.SpellDB
	local AuraData = Boom.AuraData
	local profile  = Boom.profile.auras

	wipe(entry.myHots)
	wipe(entry.important)
	wipe(entry.externals)
	wipe(entry.support)
	entry.shield = nil
	entry.dispellable = nil

	if Auras.secret then
		entry.stale = false
		return
	end

	-- Two indicator settings change what the engine should collect, not just how
	-- it is drawn, so they are read here. Without this they were dead switches:
	-- the checkboxes saved but nothing ever looked at them.
	local indicators = Boom.profile.indicators
	local hotsOnlyMine = indicators.hots.onlyMine ~= false
	local debuffsOnlyImportant = indicators.debuffs.onlyImportant ~= false

	------------------------------------------------------------------ helpful
	for _, aura in pairs(entry.helpful) do
		local spellID = aura.spellId
		local mine = isMine(aura)

		if SpellDB then
			if (mine or not hotsOnlyMine)
				and (SpellDB:IsHot(spellID) or profile.extraHots[spellID]) then
				-- Yours always sort above somebody else's, so the icons nearest
				-- the anchor stay the ones you can actually refresh.
				aura.boomPriority = (SpellDB:GetHotPriority(spellID) or 1) + (mine and 100 or 0)
				entry.myHots[#entry.myHots + 1] = aura
			end

			if SpellDB:IsShield(spellID) then
				-- Prefer our own shield: it is the one we can refresh.
				if not entry.shield or (mine and not isMine(entry.shield)) then
					entry.shield = aura
				end
			end

			if SpellDB:IsExternal(spellID) then
				entry.externals[#entry.externals + 1] = aura
			end

			-- Only YOUR support buffs. The indicator has a handful of slots and
			-- exists to answer "who still needs mine?" - another Augmentation
			-- Evoker's Prescience filling those slots would both hide your own
			-- and suppress the missing-buff placeholder. An explicit entry in
			-- the user's watch list is a deliberate exception.
			if (mine and SpellDB:IsSupportBuff(spellID) and not SpellDB:IsPermanentBuff(spellID))
				or profile.buffWatch[spellID] then
				aura.boomPriority = mine and 100 or 50
				entry.support[#entry.support + 1] = aura
			end
		end
	end

	if #entry.myHots > 1 then tsort(entry.myHots, sortByPriority) end
	if #entry.support > 1 then tsort(entry.support, sortByPriority) end

	------------------------------------------------------------------ harmful
	local canDispel = Auras.canDispel
	local bestDispel, bestDispelPriority = nil, -1

	for _, aura in pairs(entry.harmful) do
		local spellID = aura.spellId

		local blacklisted = (AuraData and AuraData:IsBlacklisted(spellID))
			or profile.blacklist[spellID]

		if not blacklisted then
			local dispelType = aura.dispelName
			local dispellable = dispelType and canDispel[dispelType] or false

			if dispellable then
				local priority = (AuraData and AuraData:GetPriority(spellID, aura)) or 0
				if priority > bestDispelPriority then
					bestDispelPriority = priority
					bestDispel = aura
				end
			end

			local important = false
			if aura.isBossAura and profile.showBossAuras then
				important = true
			elseif AuraData and AuraData:IsImportant(spellID, aura) then
				important = true
			elseif profile.extraImportant[spellID] then
				important = true
			elseif dispellable then
				important = true
			elseif not debuffsOnlyImportant then
				-- The user asked to see everything, not just the curated set.
				important = true
			end

			if important and not profile.showOnlyDispellable then
				aura.boomPriority = (AuraData and AuraData:GetPriority(spellID, aura)) or 0
				if aura.isBossAura then aura.boomPriority = aura.boomPriority + 1000 end
				entry.important[#entry.important + 1] = aura
			elseif important and profile.showOnlyDispellable and dispellable then
				aura.boomPriority = (AuraData and AuraData:GetPriority(spellID, aura)) or 0
				entry.important[#entry.important + 1] = aura
			end
		end
	end

	if #entry.important > 1 then tsort(entry.important, sortByPriority) end
	entry.dispellable = bestDispel

	entry.stale = false
end

local function derived(unit)
	local entry = ensure(unit)
	if entry.stale then rebuild(unit, entry) end
	return entry
end

--------------------------------------------------------------------------------
-- Public queries
--------------------------------------------------------------------------------

function Auras:GetMyHots(unit)
	return derived(unit).myHots
end

function Auras:GetDispellable(unit)
	return derived(unit).dispellable
end

function Auras:GetImportantDebuffs(unit)
	return derived(unit).important
end

function Auras:GetShield(unit)
	return derived(unit).shield
end

function Auras:GetExternals(unit)
	return derived(unit).externals
end

function Auras:GetSupportBuffs(unit)
	return derived(unit).support
end

--- Direct lookup of one specific aura.
function Auras:GetAura(unit, spellID, onlyMine)
	local entry = ensure(unit)
	for _, aura in pairs(entry.helpful) do
		if aura.spellId == spellID and (not onlyMine or isMine(aura)) then
			return aura
		end
	end
	for _, aura in pairs(entry.harmful) do
		if aura.spellId == spellID and (not onlyMine or isMine(aura)) then
			return aura
		end
	end
	return nil
end

function Auras:HasBuff(unit, spellID, onlyMine)
	return Auras:GetAura(unit, spellID, onlyMine) ~= nil
end

--------------------------------------------------------------------------------
-- Dispel capability
--
-- There is no API that reports what the player can remove, so it is derived
-- from which dispel spells are currently known. Talents change this mid-session
-- (Nature's Cure vs Remove Corruption, Improved Purify, ...), hence the
-- recalculation on every event that can alter the spellbook.
--------------------------------------------------------------------------------

Auras.canDispel = { Magic = false, Curse = false, Disease = false, Poison = false, Bleed = false }

function Auras:UpdateDispelCapability()
	local canDispel = self.canDispel
	local changed = false

	local previous = {}
	for key, value in pairs(canDispel) do
		previous[key] = value
		canDispel[key] = false
	end

	local SpellDB = Boom.SpellDB
	if SpellDB and SpellDB.dispelSpells then
		for spellID, types in pairs(SpellDB.dispelSpells) do
			if Util.IsPlayerSpell(spellID) then
				for dispelType in pairs(types) do
					canDispel[dispelType] = true
				end

				-- Types this spell only removes with a talent. Claiming them
				-- unconditionally would light the dispel indicator up on debuffs
				-- the player cannot actually clear.
				local conditional = SpellDB.conditionalDispels
					and SpellDB.conditionalDispels[spellID]
				if conditional then
					for dispelType, talentID in pairs(conditional) do
						if Util.IsPlayerSpell(talentID) then
							canDispel[dispelType] = true
						end
					end
				end
			end
		end
	end

	for key, value in pairs(canDispel) do
		if previous[key] ~= value then changed = true end
	end

	if changed then
		-- Every cached "is this dispellable" answer just became wrong.
		for _, entry in pairs(cache) do entry.stale = true end
		Boom:MarkAllDirty(UPDATE.AURA)
		Boom:SendMessage("BOOM_DISPEL_CHANGED")
	end

	return changed
end

function Auras:CanDispel(dispelType)
	return dispelType and self.canDispel[dispelType] or false
end

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

function Auras:OnEnable()
	self:RegisterEvent("UNIT_AURA", function(_, _, unit, info)
		local frames = Boom.Frames
		if not frames then return end

		-- The cache stays keyed on the EVENT token, because the indicators read
		-- auras from button.displayUnit - which is the pet token for a unit in a
		-- vehicle. Only the repaint target is remapped to the owner.
		local target = unit
		if not frames:GetButtonsForUnit(unit) then
			local owner = PET_OWNER[unit]
			if not owner or not frames:GetButtonsForUnit(owner) then
				-- Genuinely not displayed: drop the cache so it rescans if it
				-- ever comes back.
				if cache[unit] then cache[unit].scanned = false end
				return
			end
			target = owner
		end

		applyUpdate(unit, getEntry(unit), info)
		Boom:MarkUnitDirty(target, UPDATE.AURA)

		-- When the token is a pet the owner's button is what draws it, but a
		-- separate pet-panel button may exist for the pet itself.
		if target ~= unit then Boom:MarkUnitDirty(unit, UPDATE.AURA) end
	end)

	local function refreshDispels()
		Boom:Throttle("dispel-check", 0.5, function()
			Auras:UpdateDispelCapability()
		end)
	end

	self:RegisterEvent("SPELLS_CHANGED", refreshDispels)
	self:RegisterEvent("PLAYER_SPECIALIZATION_CHANGED", refreshDispels)
	self:RegisterEvent("TRAIT_CONFIG_UPDATED", refreshDispels)
	self:RegisterEvent("PLAYER_TALENT_UPDATE", refreshDispels)
	-- Aura secrecy is a property of the content you are in, so re-test it
	-- wherever that can change.
	local function refreshSecrecy()
		Boom:Throttle("aura-secrecy", 0.5, function() Auras:UpdateSecrecy() end)
	end

	self:RegisterEvent("PLAYER_ENTERING_WORLD", function()
		Auras:UpdateSecrecy()
		Auras:InvalidateAll()
		refreshDispels()
	end)
	self:RegisterEvent("ENCOUNTER_START", refreshSecrecy)
	self:RegisterEvent("ENCOUNTER_END", refreshSecrecy)
	self:RegisterEvent("CHALLENGE_MODE_START", refreshSecrecy)
	self:RegisterEvent("CHALLENGE_MODE_COMPLETED", refreshSecrecy)
	self:RegisterEvent("ZONE_CHANGED_NEW_AREA", refreshSecrecy)

	self:RegisterMessage("BOOM_ROSTER_UPDATED", function()
		Auras:InvalidateAll()
	end)

	self:RegisterMessage("BOOM_UNIT_CHANGED", function(_, _, _, unit)
		Auras:Invalidate(unit)
	end)

	-- Profile changes can alter blacklists and watched buffs.
	self:RegisterMessage("BOOM_PROFILE_CHANGED", function()
		for _, entry in pairs(cache) do entry.stale = true end
		Boom:MarkAllDirty(UPDATE.AURA)
	end)

	-- A few settings decide what the engine COLLECTS, not merely how it draws,
	-- so flipping one has to invalidate the derived lists rather than waiting
	-- for the next aura event.
	local RECOLLECT = {
		["indicators.hots.onlyMine"] = true,
		["indicators.debuffs.onlyImportant"] = true,
	}

	self:RegisterMessage("BOOM_CONFIG_CHANGED", function(_, _, path)
		if not path then return end
		if RECOLLECT[path] or path:find("^auras%.") then
			for _, entry in pairs(cache) do entry.stale = true end
			Boom:MarkAllDirty(UPDATE.AURA)
		end
	end)

	Auras:UpdateSecrecy()
	Auras:UpdateDispelCapability()
end
