--core/unitframes_auras.lua, debuff scan and render engine for the party/raid unit frames (backend).
--two halves: a per-unit HARMFUL-only scan on UNIT_AURA that fills a guid-keyed cache, and a single
--shared 0.2s ticker that renders changed caches with three throttles (tick rate -> changed-recently
--gate -> icon diff guard). buffs are never scanned: that omission is the feature's main CPU saving.
--all work here is insecure and may run freely in combat.

local _ = nil
local addonName, private = ...

private.Core = private.Core or {}
private.Core.UnitFrames = private.Core.UnitFrames or {}

---@class FPSPP_UnitFrames_Auras
local Auras = {}
private.Core.UnitFrames.Auras = Auras

--constants
local TICK_INTERVAL = 0.2

--debuff border colors by dispel type. the global DebuffTypeColor table was removed in 12.0
--(Midnight), so fall back to the built-in values when the global is absent.
local dispelTypeColors = {}
local dispelTypeColorSource = _G["DebuffTypeColor"] or {
	["none"]    = {r = 0.80, g = 0.00, b = 0.00},
	[""]        = {r = 0.80, g = 0.00, b = 0.00},
	["Magic"]   = {r = 0.20, g = 0.60, b = 1.00},
	["Curse"]   = {r = 0.60, g = 0.00, b = 1.00},
	["Disease"] = {r = 0.60, g = 0.40, b = 0.00},
	["Poison"]  = {r = 0.00, g = 0.60, b = 0.00},
}
for dispelType, color in pairs(dispelTypeColorSource) do
	dispelTypeColors[dispelType] = {color.r, color.g, color.b}
end

--Midnight (12.0) secret-value laundering. scrubsecretvalues replaces a secret-tagged input with nil;
--issecretvalue reports whether a value is still secret. on pre-12.0 clients both fall back to a
--passthrough, so the scan behaves identically across versions. only single-value calls are used here.
local scrubSecretValues = _G.scrubsecretvalues or function(value) return value end
local isSecretValue = _G.issecretvalue or function() return false end

--reused per-icon visual template passed to SetAuraWithIconTemplate. icon size/spacing are applied
--from the profile when the row is created (see core/unitframes_visual.lua); only texture and border
--color vary per aura. reusing one table avoids allocating in the render path.
local debuffIconTemplate = {
	coords = {0.1, 0.9, 0.1, 0.9},
}

--guid -> {debuffs = aurainfo[], latestUpdate = number, unitName = string}. read by the ticker.
---@type table<string, table>
Auras.cacheData = {}

--set of unit frames the ticker renders. only populated while the feature is enabled.
---@type table<table, boolean>
Auras.registeredFrames = {}

--the ForEachAura callback writes into this module-scoped array, so no closure is allocated per scan.
local scanDestination

--called once per aura by AuraUtil.ForEachAura (usePackedAura = true -> receives an AuraData table).
--must NOT return a truthy value or ForEachAura stops iterating early.
local onAuraScanned = function(auraData)
	if not (auraData and auraData.auraInstanceID) then
		return
	end

	--launder the fields used downstream: dispelName is truthy-tested and used as a color key.
	auraData.dispelName = scrubSecretValues(auraData.dispelName)

	--tonumber strips the secret tag on most builds; spellId is a table key in the diff guard and in
	--the icon row's GetSpellInfo cache, so if the tag survives, drop the aura rather than poison a key.
	local decodedSpellId = tonumber(auraData.spellId)
	if decodedSpellId and not isSecretValue(decodedSpellId) then
		auraData.spellId = decodedSpellId
		scanDestination[#scanDestination + 1] = auraData
	end
end

--(re)scans a unit's HARMFUL auras into the guid-keyed cache. HELPFUL is deliberately never scanned;
--that is the 30-40-buff-per-player cost this build avoids.
---@param unit string
---@param guid string
function Auras:ScanUnit(unit, guid)
	local cacheEntry = self.cacheData[guid]
	if not cacheEntry then
		cacheEntry = {debuffs = {}}
		self.cacheData[guid] = cacheEntry
	else
		wipe(cacheEntry.debuffs)
	end

	scanDestination = cacheEntry.debuffs
	AuraUtil.ForEachAura(unit, "HARMFUL", nil, onAuraScanned, true)
	scanDestination = nil

	cacheEntry.unitName = GetUnitName(unit, true)
	cacheEntry.latestUpdate = GetTime()
end

--scans the auras of the unit a unit frame is showing; called on UNIT_AURA and on unit (re)assign.
---@param unitFrame table
function Auras:ScanUnitFrame(unitFrame)
	local unit = unitFrame.unit
	local guid = unitFrame.guid
	--unit/guid are nil only in the brief window between recycle and reassign; nothing to scan then.
	if not unit or not guid then
		return
	end

	self:ScanUnit(unit, guid)
end

--adds a unit frame to the ticker render set.
---@param unitFrame table
function Auras:RegisterUnitFrame(unitFrame)
	self.registeredFrames[unitFrame] = true
end

--removes a unit frame from the ticker render set.
---@param unitFrame table
function Auras:UnRegisterUnitFrame(unitFrame)
	self.registeredFrames[unitFrame] = nil
end

--renders the cached debuffs onto a unit frame's icon row, skipping all work when the shown icons
--already match the intended ones (throttle 3, the diff guard).
---@param unitFrame table
---@param cacheEntry table
---@param maxIcons number
local renderDebuffs = function(unitFrame, cacheEntry, maxIcons)
	local debuffs = cacheEntry.debuffs
	local iconCount = math.min(#debuffs, maxIcons)
	local iconRow = unitFrame.debuffIcons

	--diff guard: rebuild only when the count changed or some slot shows a different spell/expiration.
	local mustUpdate = (iconRow.NextIcon - 1 ~= iconCount)
	if not mustUpdate then
		for i = 1, iconCount do
			local icon = iconRow.IconPool[i]
			local auraData = debuffs[i]
			local isStale = not icon or not icon:IsShown() or icon.spellId ~= auraData.spellId or icon.expirationTime ~= auraData.expirationTime
			if isStale then
				mustUpdate = true
				break
			end
		end
	end

	--nothing changed since the last render -> zero render work.
	if not mustUpdate then
		return
	end

	iconRow:ClearIcons()

	for i = 1, iconCount do
		local auraData = debuffs[i]
		debuffIconTemplate.texture = auraData.icon

		--color the border by dispel type when known, otherwise hide it.
		if auraData.dispelName then
			debuffIconTemplate.borderColor = dispelTypeColors[auraData.dispelName]
		else
			debuffIconTemplate.borderColor = nil
		end

		iconRow:SetAuraWithIconTemplate(auraData, debuffIconTemplate)
	end

	iconRow:AlignAuraIcons()
end

--the single shared render loop: runs every TICK_INTERVAL (throttle 1) and renders only the unit
--frames whose auras changed within the last interval (throttle 2). installed by StartTicker.
local updateTick = function()
	local cutoffTime = GetTime() - TICK_INTERVAL
	--read the profile-derived cap once per tick rather than once per unit (hot path).
	local maxIcons = private.Core.Profile:GetValue("unit_frames.max_debuff_icons")

	for unitFrame in pairs(Auras.registeredFrames) do
		local cacheEntry = Auras.cacheData[unitFrame.guid]
		if cacheEntry and cacheEntry.latestUpdate > cutoffTime then
			renderDebuffs(unitFrame, cacheEntry, maxIcons)
		end
	end
end

--starts the single shared aura ticker if it is not already running.
function Auras:StartTicker()
	if self.ticker then
		return
	end

	--one timer for the whole feature; there are no per-frame timers or OnUpdate scripts.
	self.ticker = C_Timer.NewTicker(TICK_INTERVAL, updateTick)
end

--stops the shared ticker and forgets all per-unit render state.
function Auras:StopTicker()
	if self.ticker then
		self.ticker:Cancel()
		self.ticker = nil
	end

	wipe(self.registeredFrames)
	wipe(self.cacheData)
end
