-- KiraUI-Plugin_CDMStacks.lua
-- "CDM Changes": custom text labels on Cooldown Manager icons plus the
-- Blood DK Death and Decay timer. Works on Blizzard's CooldownViewer frames
-- (Essential / Utility / BuffIcon), which is what EllesmereUI CDM skins -
-- so it composes with EUI CDM freely.
-- (The old Strength in Adversity stack-math feature was removed: 12.1
-- reworked it and the tracking is no longer applicable.)

local addonName, ns = ...
local _G = _G

local CreateFrame = CreateFrame
local issecretvalue = issecretvalue
local pairs, type = pairs, type
local pcall = pcall
local format = string.format

local hasCDM = _G.C_CooldownViewer and _G.C_CooldownViewer.GetCooldownViewerCooldownInfo and true or false
ns.hasCDMStacks = hasCDM
if not hasCDM then return end

local VIEWERS = { 'EssentialCooldownViewer', 'UtilityCooldownViewer', 'BuffIconCooldownViewer' }
local BUFF_VIEWERS = { 'BuffIconCooldownViewer' }  -- custom texts attach here only

----------------------------------------------------------------------
-- Settings
----------------------------------------------------------------------
local DEFAULTS = {
	enable = false,
	-- Colored on cooldown: keep Essential/Utility icons full-colour while on
	-- cooldown (action-bar look) instead of Blizzard's grey desaturation.
	-- Independent of `enable` above - it's a standalone visual override.
	colorOnCD = false,
	-- Death and Decay timer (Blood DK)
	dndEnable = false,
	dndSize = 16,
	dndX = 0,
	dndY = 0,
	-- "Show 1 stack" text style (the spell list lives in cfg.stackOnes)
	stackOneSize = 14,
	stackOneX = 0,
	stackOneY = 30,
}

-- Neither list lives in DEFAULTS, so this is where they are created:
-- customTexts is { spellID, text, x, y, size, color }, stackOnes is { spellID }.
local function Migrate(cfg)
	if type(cfg.customTexts) ~= 'table' then cfg.customTexts = {} end
	if type(cfg.stackOnes) ~= 'table' then cfg.stackOnes = {} end
end

local function GetCfg() return ns.ModuleCfg('cdmStacks', DEFAULTS, Migrate) end
ns.CDSGetCfg = GetCfg
-- exported below (after FindItemFrame): CDSFindBuffFrame(spellID)

----------------------------------------------------------------------
-- CDM item frame lookup
----------------------------------------------------------------------
-- Matches a CDM entry against a spell ID through EVERY identity field the
-- entry has - the primary spellID, linkedSpellID (how buff entries point at
-- the aura they track, per Blizzard's own GetSpellID resolution), override
-- IDs and the linkedSpellIDs list. This lets users enter the BUFF's spell ID
-- (the one on the buff tooltip) and still find the icon.
local function CooldownMatches(cooldownID, want)
	if not cooldownID then return false end
	local info = _G.C_CooldownViewer.GetCooldownViewerCooldownInfo(cooldownID)
	if not info then return false end
	local function eq(v)
		return not (issecretvalue and issecretvalue(v)) and v == want
	end
	if eq(info.spellID) or eq(info.linkedSpellID) or eq(info.overrideSpellID) or eq(info.overrideTooltipSpellID) then
		return true
	end
	local linked = info.linkedSpellIDs
	if type(linked) == 'table' then
		for i = 1, #linked do
			if eq(linked[i]) then return true end
		end
	end
	return false
end

local function FindItemFrame(want, viewerList)
	local vlist = viewerList or VIEWERS
	for v = 1, #vlist do
		local viewer = _G[vlist[v]]
		if viewer and viewer.GetItemFrames then
			local frames = viewer:GetItemFrames()
			if type(frames) == 'table' then
				for i = 1, #frames do
					local f = frames[i]
					if f and f.GetCooldownID and CooldownMatches(f:GetCooldownID(), want) then
						return f
					end
				end
			end
		end
	end
end

-- Cross-module export: locate the CDM BUFF icon frame for a spell (used by
-- the Mage Alter Time snapshot to ride the Alter Time buff icon).
ns.CDSFindBuffFrame = function(spellID)
	return FindItemFrame(spellID, BUFF_VIEWERS)
end

----------------------------------------------------------------------
-- Custom icon texts: static labels attached to any CDM icon (e.g. "-10"
-- above a defensive). Pure presentation - no aura data is ever read, so
-- no Midnight restrictions apply. The label is parented to the icon's
-- item frame and therefore shows exactly while that icon shows.
----------------------------------------------------------------------
local customRecs = {}  -- entry table -> { holder, fs, frame }

local function ApplyCustoms()
	local cfg = GetCfg()
	local list = cfg.customTexts or {}
	-- drop records whose entry was removed
	for entry, rec in pairs(customRecs) do
		local keep
		for i = 1, #list do if list[i] == entry then keep = true break end end
		if not keep then
			if rec.fs then rec.fs:Hide() end
			customRecs[entry] = nil
		end
	end
	for i = 1, #list do
		local e = list[i]
		-- buff icons ONLY: the text belongs to the tracked-buff icon (shown
		-- while the buff is up), never to the Essential/Utility cooldown icons.
		-- Cheap path first: keep the cached frame while it still matches, and
		-- only rescan the viewers when it does not (pooled frames recycle).
		local rec = customRecs[e]
		local frame
		if cfg.enable and e.spellID then
			if rec and rec.frame and rec.frame.GetCooldownID
				and CooldownMatches(rec.frame:GetCooldownID(), e.spellID) then
				frame = rec.frame
			else
				frame = FindItemFrame(e.spellID, BUFF_VIEWERS)
			end
		end
		if frame then
			if not rec then rec = {} customRecs[e] = rec end
			if rec.frame ~= frame or not rec.holder then
				if not rec.holder then
					rec.holder = CreateFrame('Frame', nil, frame)
					rec.fs = rec.holder:CreateFontString(nil, 'OVERLAY')
				else
					rec.holder:SetParent(frame)
				end
				rec.frame = frame
				rec.holder:SetAllPoints(frame)
			end
			rec.holder:SetFrameLevel(frame:GetFrameLevel() + 30)
			local c = e.color or { 1, 1, 1, 1 }
			rec.fs:SetFont(ns.GetFont(), e.size or 14, 'OUTLINE')
			rec.fs:SetTextColor(c[1] or 1, c[2] or 1, c[3] or 1, c[4] or 1)
			rec.fs:ClearAllPoints()
			rec.fs:SetPoint('CENTER', frame, 'CENTER', e.x or 0, e.y or 30)
			rec.fs:SetText(e.text or '')
			rec.fs:Show()
		elseif rec and rec.fs then
			rec.fs:Hide()
		end
	end
end

----------------------------------------------------------------------
-- "Show 1 stack": EllesmereUI/Blizzard render a buff icon's stack count
-- only from 2 upward - at exactly one stack the icon is bare. For the
-- buffs the user adds, draw our own "1" on the CDM buff icon WHILE THE
-- ICON IS SHOWING AS AN ACTIVE BUFF; the moment the real stack text
-- appears (2+), ours hides. No aura reads at all - fully combat-proof:
--   * "buff is up"  = the Blizzard buff-icon frame is visible (pooled
--     frames only show while their aura is active; EUI's inactive
--     placeholders are separate frames that never match here)
--   * "2+ stacks"   = Blizzard's own Applications fontstring is
--     non-empty. GetApplicationsText() writes "" (a PLAIN empty string)
--     at 0-1 stacks and the count from 2 up - so plain "" means show
--     our 1, and anything else (a number, or a SECRET string in a key)
--     means the real count is being rendered - hide ours.
----------------------------------------------------------------------
local stackRecs = {}  -- entry table -> { holder, fs, frame }

local function ApplyStackOnes()
	local cfg = GetCfg()
	local list = cfg.stackOnes or {}
	-- drop records whose entry was removed
	for entry, rec in pairs(stackRecs) do
		local keep
		for i = 1, #list do if list[i] == entry then keep = true break end end
		if not keep then
			if rec.fs then rec.fs:Hide() end
			stackRecs[entry] = nil
		end
	end
	for i = 1, #list do
		local e = list[i]
		local rec = stackRecs[e]
		local frame
		if cfg.enable and e.spellID then
			if rec and rec.frame and rec.frame.GetCooldownID
				and CooldownMatches(rec.frame:GetCooldownID(), e.spellID) then
				frame = rec.frame
			else
				frame = FindItemFrame(e.spellID, BUFF_VIEWERS)
			end
		end
		local show = false
		if frame and frame:IsVisible() then
			show = true
			-- hide while the real stack count (2+) is being rendered
			local appFrame = frame.Applications
			local afs = appFrame and appFrame.Applications
			if afs and afs.GetText then
				local ok, txt = pcall(afs.GetText, afs)
				if ok then
					if issecretvalue and issecretvalue(txt) then
						show = false -- secret = engine is rendering a count
					elseif txt ~= nil and txt ~= '' then
						show = false -- plain number = 2+ stacks visible
					end
				end
			end
		end
		if show then
			if not rec then rec = {} stackRecs[e] = rec end
			if rec.frame ~= frame or not rec.holder then
				if not rec.holder then
					rec.holder = CreateFrame('Frame', nil, frame)
					rec.fs = rec.holder:CreateFontString(nil, 'OVERLAY')
				else
					rec.holder:SetParent(frame)
				end
				rec.frame = frame
				rec.holder:SetAllPoints(frame)
			end
			rec.holder:SetFrameLevel(frame:GetFrameLevel() + 30)
			rec.fs:SetFont(ns.GetFont(), cfg.stackOneSize or 14, 'OUTLINE')
			rec.fs:SetTextColor(1, 1, 1, 1)
			rec.fs:ClearAllPoints()
			rec.fs:SetPoint('CENTER', frame, 'CENTER', cfg.stackOneX or 0, cfg.stackOneY or 30)
			rec.fs:SetText('1')
			rec.fs:Show()
		elseif rec and rec.fs then
			rec.fs:Hide()
		end
	end
end
ns.CDSStackOnesApply = ApplyStackOnes

-- live updates: player aura changes drive the 1-stack window; debounced to
-- one pass per burst, and a no-op unless the feature has entries
local stackSyncQueued
local function QueueStackSync()
	if stackSyncQueued then return end
	stackSyncQueued = true
	C_Timer.After(0.05, function()
		stackSyncQueued = nil
		ApplyStackOnes()
	end)
end
local auraEvt = CreateFrame('Frame')
auraEvt:RegisterUnitEvent('UNIT_AURA', 'player')
auraEvt:SetScript('OnEvent', function()
	local cfg = GetCfg()
	if cfg.enable and cfg.stackOnes and #cfg.stackOnes > 0 then QueueStackSync() end
end)

-- Event-driven re-attach: CooldownViewerMixin:RefreshData() is the exact
-- place Blizzard reassigns cooldownIDs to the pooled item frames (spec
-- change, CDM edits, zone). Hooking it re-attaches once per REAL reshuffle
-- instead of rescanning on a fast ticker + every aura burst.
-- hooksecurefunc is taint-free and combat-safe; the resync only touches
-- our own overlay/fontstrings.
local ResyncAttachments -- fwd (defined after ApplySettings)
local hookedViewers = {}
local resyncQueued
local function QueueResync()
	if resyncQueued then return end
	resyncQueued = true
	-- next-frame debounce: RefreshData often runs for several viewers
	-- back-to-back; one resync covers them all
	C_Timer.After(0, function()
		resyncQueued = nil
		if ResyncAttachments then ResyncAttachments() end
	end)
end
local function HookViewers()
	for i = 1, #VIEWERS do
		local v = _G[VIEWERS[i]]
		if v and v.RefreshData and not hookedViewers[v] then
			hookedViewers[v] = true
			_G.hooksecurefunc(v, 'RefreshData', QueueResync)
		end
	end
end

-- slow fallback: catches any reshuffle path the hook might miss (worst
-- case a text re-appears a few seconds late; never an error)
C_Timer.NewTicker(10, function()
	local cfg = GetCfg()
	if cfg.enable and cfg.customTexts and #cfg.customTexts > 0 then ApplyCustoms() end
	if cfg.enable and cfg.stackOnes and #cfg.stackOnes > 0 then ApplyStackOnes() end
end)

----------------------------------------------------------------------
-- Death and Decay timer (Blood DK): casting 43265 starts OUR OWN 10s
-- countdown (plain numbers - restriction-proof), rendered as text riding
-- the exact CDM buff icon for that spell. Hides when the timer ends.
----------------------------------------------------------------------
local DND_ID = 43265
local DND_DUR = 10
local dndExpire = 0
local dndRec

-- Blood DK only (spec 1). Runtime check; if the spec index is SECRET,
-- stay permissive rather than eating a legit cast.
local function IsBloodDK()
	local _, cls = _G.UnitClass('player')
	if cls ~= 'DEATHKNIGHT' then return false end
	local spec
	if _G.C_SpecializationInfo and _G.C_SpecializationInfo.GetSpecialization then
		spec = _G.C_SpecializationInfo.GetSpecialization()
	elseif _G.GetSpecialization then
		spec = _G.GetSpecialization()
	end
	if issecretvalue and issecretvalue(spec) then return true end
	return spec == 1
end

local function ApplyDndStyle()
	if not (dndRec and dndRec.fs and dndRec.frame) then return end
	local cfg = GetCfg()
	dndRec.holder:SetFrameLevel(dndRec.frame:GetFrameLevel() + 30)
	dndRec.fs:SetFont(ns.GetFont(), cfg.dndSize or 16, 'OUTLINE')
	dndRec.fs:SetTextColor(1, 1, 1)
	dndRec.fs:ClearAllPoints()
	dndRec.fs:SetPoint('CENTER', dndRec.frame, 'CENTER', cfg.dndX or 0, cfg.dndY or 0)
end

-- whole-seconds countdown formatter (rounds UP like the old math.ceil)
local dndFmt
local function DndFormatter()
	if dndFmt ~= nil then return dndFmt or nil end
	local CSU = _G.C_StringUtil
	if not (CSU and CSU.CreateNumericRuleFormatter) then dndFmt = false return nil end
	local f = CSU.CreateNumericRuleFormatter()
	if not (f and f.SetBreakpoints) then dndFmt = false return nil end
	local up = (_G.Enum and _G.Enum.NumericRuleFormatRounding
		and _G.Enum.NumericRuleFormatRounding.Up) or 1
	local ok = pcall(f.SetBreakpoints, f, {
		{ threshold = 0, format = '%d', components = { { step = 1, rounding = up } } },
	})
	if not ok then dndFmt = false return nil end
	dndFmt = f
	return f
end

local function EnsureDnd(frame)
	if not dndRec then dndRec = {} end
	if dndRec.frame ~= frame or not dndRec.holder then
		if not dndRec.holder then
			dndRec.holder = CreateFrame('Frame', nil, frame)
			dndRec.fs = dndRec.holder:CreateFontString(nil, 'OVERLAY')
			-- engine-driven countdown (12.1 DurationTextBinding): the ENGINE
			-- updates the text itself. Clients without the API (retail) keep
			-- the old 0.1s OnUpdate fallback below.
			local DU = _G.C_DurationUtil
			if DU and DU.CreateDuration and DU.CreateDurationTextBinding then
				local okB = pcall(function()
					local d = DU.CreateDuration()
					local b = DU.CreateDurationTextBinding()
					local fmtr = DndFormatter()
					if fmtr then b:SetFormatter(fmtr) else b:SetTextFormat('%d') end
					b:SetExpiredText('')
					b:SetZeroDurationText('')
					b:SetFontString(dndRec.fs)
					b:SetDuration(d)
					b:SetEnabled(true)
					dndRec.dur, dndRec.bind = d, b
				end)
				if not okB then dndRec.dur, dndRec.bind = nil, nil end
			end
			if not dndRec.bind then
				local acc = 0
				dndRec.holder:SetScript('OnUpdate', function(self, elapsed)
					acc = acc + elapsed
					if acc < 0.1 then return end
					acc = 0
					local left = dndExpire - _G.GetTime()
					if left > 0 then
						dndRec.fs:SetText(format('%d', math.ceil(left)))
					else
						dndRec.fs:SetText('')
						self:Hide()
					end
				end)
			end
		else
			dndRec.holder:SetParent(frame)
		end
		dndRec.frame = frame
		dndRec.holder:SetAllPoints(frame)
	end
	ApplyDndStyle()
end

local function StartDnd()
	local cfg = GetCfg()
	-- independent of cfg.enable: the timer lives on the Class > Blood page
	if not cfg.dndEnable then return end
	if not IsBloodDK() then return end
	dndExpire = _G.GetTime() + DND_DUR
	local function attach()
		-- the buff icon's entry may identify by the cast spell (43265) or by
		-- the standing-in-it aura (188290); try both, buff viewer first
		local f = FindItemFrame(DND_ID, BUFF_VIEWERS)
			or FindItemFrame(188290, BUFF_VIEWERS)
			or FindItemFrame(DND_ID)
		if f then
			EnsureDnd(f)
			dndRec.holder:Show()
			-- binding path: hand the remaining time to the engine once;
			-- it renders the whole countdown from here
			if dndRec.bind then
				pcall(function()
					local left = dndExpire - _G.GetTime()
					if left > 0 then
						dndRec.dur:SetTimeFromStart(dndRec.dur:GetClockTime(), left)
					else
						dndRec.dur:SetToDefaults()
					end
				end)
			end
			return true
		end
	end
	if not attach() then
		-- the buff icon can appear a moment after the cast: retry briefly
		C_Timer.After(0.3, attach)
	end
end

local function ApplySettings()
	local cfg = GetCfg()
	-- D&D timer (Class > Blood page) is independent of the master toggle
	if cfg.dndEnable then
		ApplyDndStyle()
	elseif dndRec and dndRec.holder then
		dndRec.holder:Hide()
	end
	ApplyCustoms()    -- self-gate on cfg.enable (hide while disabled)
	ApplyStackOnes()
end
ns.CDSApply = ApplySettings

----------------------------------------------------------------------
-- Colored on cooldown (Essential + Utility)
----------------------------------------------------------------------
-- Blizzard greys (desaturates) a cooldown icon while it is on its actual
-- cooldown: CooldownViewerCooldownItemMixin:RefreshIconDesaturation() does
--   iconTexture:SetDesaturated(self.cooldownDesaturated and not IsExpired()).
-- EllesmereUI's CDM skin layers its own SetDesaturated / SetDesaturation
-- hooks on top for its cooldown-state effects. We want the action-bar look:
-- the icon stays FULLY COLOURED on cooldown while the sweep/swipe (a separate
-- Cooldown frame we never touch) and the timer text keep running.
--
-- Implementation: a taint-safe post-hook on each icon texture's SetDesaturated
-- AND SetDesaturation. Because KiraUI-Plugin loads after EllesmereUI, our hook
-- registers last and therefore runs last - it wins over EUI's desaturation.
-- Covered textures: Blizzard's Essential/Utility viewer items PLUS every
-- EUI-owned icon hosted on an EUI cooldown/utility bar (trinkets, racials,
-- item/spell presets, manually added spells) - EUI greys those through its
-- own tickers on its own textures, so viewer enumeration alone misses them.
--
-- We force the icon FULLY COLOURED unconditionally while the feature is on.
-- We deliberately do NOT try to gate on "is on cooldown": in 12.x the item's
-- cooldownDesaturated / cooldown fields are SECRET values, so any attempt to
-- read them just makes the override no-op (this was the earlier bug - icons
-- stayed grey). Forcing colour outright is also the exact action-bar look:
-- ready and on-cooldown are both colour, only the sweep/timer overlay differs.
-- Out-of-range / low-mana tinting is a SEPARATE channel (SetVertexColor in
-- RefreshIconColor), so it is untouched. The sweep is a separate Cooldown
-- frame we never touch.
local COLOR_VIEWERS = { 'EssentialCooldownViewer', 'UtilityCooldownViewer' }
-- weak keys: pooled textures. tex -> rec { ov = shield texture, masks = set }
local hookedTex = setmetatable({}, { __mode = 'k' })
local applyingColor = false  -- reentrancy guard for our own SetDesaturated calls

-- Resolve the visible icon texture the same way EllesmereUI does: normally the
-- item's .Icon IS the texture (what Blizzard's RefreshIconDesaturation greys),
-- but some frame variants nest it one level deeper (.Icon.Icon).
local function GetItemTex(f)
	local w
	if f.GetIconTexture then
		local ok, t = pcall(f.GetIconTexture, f)
		if ok and t then w = t end
	end
	w = w or f.Icon
	if w and w ~= f and w.Icon and w.Icon.SetDesaturated then w = w.Icon end
	return w
end

local function ForceColor(tex)
	if applyingColor then return end
	if not GetCfg().colorOnCD then return end
	applyingColor = true
	pcall(tex.SetDesaturated, tex, false)
	if tex.SetDesaturation then pcall(tex.SetDesaturation, tex, 0) end
	applyingColor = false
end

-- Saturation shield: SOME desaturation sources cannot be out-hooked. EUI's
-- active-state CD-model override re-asserts SetDesaturated(true) INSIDE any
-- SetDesaturated(false) we issue (its recursion guard is up while it answers
-- our write, ours is up while we would answer its), so for spells in an EUI
-- active-state window the write war is structurally unwinnable - the icon
-- stays grey until the active state ends. Instead of fighting, we draw OUR
-- OWN fully-coloured copy of the icon directly above the greyed texture
-- (same draw layer, top sub-level: below the Cooldown swipe, borders and
-- texts) whenever the underlying texture ends up desaturated despite
-- ForceColor. Mirrors texture, texcoords, masks (shaped icons), vertex
-- colour (out-of-range/mana tint) and alpha, so it is pixel-identical except
-- saturated. Hidden the moment the real icon is saturated again.
-- Uniform grey vertex dim = Blizzard's OTHER grey channel: RefreshIconColor
-- paints ITEM_NOT_USABLE_COLOR (r=g=b, below white) - notably for spells whose
-- active state is a DEBUFF ON THE TARGET (actively-cast target auras keep the
-- icon desaturated AND vertex-dimmed while the debuff runs). Range red and
-- no-mana blue are NOT uniform, so they never match this test.
local function IsUniformDim(r, g, b)
	return r <= 0.9 and math.abs(r - g) < 0.02 and math.abs(g - b) < 0.02
end

-- SetDesaturation(amount) is a SEPARATE channel from SetDesaturated(bool):
-- a partial shader desaturation that leaves IsDesaturated() false. EUI's
-- desaturation-curve path greys icons through it (debuff-on-mob windows).
local function IsDesatAmount(a)
	return type(a) == 'number' and a > 0.05
end

local function SyncOverlay(tex)
	local rec = hookedTex[tex]
	local ov = rec and rec.ov
	if not ov then return end
	if not GetCfg().colorOnCD then ov:Hide() return end
	local okD, desat = pcall(tex.IsDesaturated, tex)
	desat = (okD and desat) and true or false
	-- partial desaturation amount (channel invisible to IsDesaturated)
	if not desat and tex.GetDesaturation then
		local okDA, amt = pcall(tex.GetDesaturation, tex)
		if okDA then
			-- pcall: the amount can be SECRET; comparison then errors -> not dim
			local okCmp, res = pcall(IsDesatAmount, amt)
			desat = (okCmp and res) and true or false
		end
	end
	local okV, r, g, b, a = pcall(tex.GetVertexColor, tex)
	local dimGrey = false
	if okV then
		-- pcall: any of these can be SECRET numbers; comparisons then error
		local okCmp, res = pcall(IsUniformDim, r, g, b)
		dimGrey = (okCmp and res) and true or false
	end
	if not desat and not dimGrey then ov:Hide() return end
	local okT, t = pcall(tex.GetTexture, tex)
	if not okT or not t then ov:Hide() return end
	pcall(ov.SetTexture, ov, t)
	local ok8, c1, c2, c3, c4, c5, c6, c7, c8 = pcall(tex.GetTexCoord, tex)
	if ok8 and c1 then pcall(ov.SetTexCoord, ov, c1, c2, c3, c4, c5, c6, c7, c8) end
	-- copy any masks (EUI icon shapes) so the shield keeps the icon's shape
	if tex.GetNumMaskTextures and ov.AddMaskTexture then
		local okN, n = pcall(tex.GetNumMaskTextures, tex)
		if okN and type(n) == 'number' then
			for i = 1, n do
				local okM, m = pcall(tex.GetMaskTexture, tex, i)
				if okM and m and not rec.masks[m] then
					rec.masks[m] = true
					pcall(ov.AddMaskTexture, ov, m)
				end
			end
		end
	end
	if dimGrey then
		-- override the grey dim outright (frame alpha is mirrored separately)
		pcall(ov.SetVertexColor, ov, 1, 1, 1, 1)
	elseif okV then
		-- mirror real tints (out-of-range red, no-mana blue) so they stay visible
		pcall(ov.SetVertexColor, ov, r, g, b, a)
	end
	local okA, al = pcall(tex.GetAlpha, tex)
	if okA and type(al) == 'number' then ov:SetAlpha(al) end
	pcall(ov.SetDesaturated, ov, false)
	if ov.SetDesaturation then pcall(ov.SetDesaturation, ov, 0) end
	ov:Show()
end

local function InstallColorHook(f)
	if not f then return end
	local tex = GetItemTex(f)
	if not tex or not tex.SetDesaturated or hookedTex[tex] then return end
	local rec = { masks = {} }
	hookedTex[tex] = rec
	-- shield texture: our own region on the item frame, taint-free
	local okC, ov = pcall(f.CreateTexture, f, nil, 'ARTWORK', nil, 7)
	if okC and ov then
		rec.ov = ov
		local okL, layer = pcall(tex.GetDrawLayer, tex)
		if okL and layer then pcall(ov.SetDrawLayer, ov, layer, 7) end
		ov:SetAllPoints(tex)
		ov:Hide()
	end
	local function onChange()
		ForceColor(tex)   -- wins against plain (non-reasserting) greys
		SyncOverlay(tex)  -- covers whatever survived (EUI active-state war)
	end
	_G.hooksecurefunc(tex, 'SetDesaturated', onChange)
	if tex.SetDesaturation then
		_G.hooksecurefunc(tex, 'SetDesaturation', onChange)
	end
	-- keep the shield's art/tint current while it is showing (spell
	-- transforms swap the texture; range/mana tint arrives via vertex color)
	_G.hooksecurefunc(tex, 'SetTexture', function() SyncOverlay(tex) end)
	_G.hooksecurefunc(tex, 'SetVertexColor', function() SyncOverlay(tex) end)
	-- push the current state immediately in case no SetDesaturated call is
	-- pending (e.g. an icon already sitting grey mid-cooldown at install time).
	onChange()
end

-- EllesmereUI CDM bars can host icons Blizzard's viewers do NOT own: trinkets,
-- racials, item/spell presets and manually added custom spells. EUI greys those
-- itself (SetDesaturated / SetDesaturation on the frame's own texture from its
-- cooldown tickers), so they need the same post-hooks or they stay grey on CD
-- while the Blizzard-owned icons next to them stay coloured. EUI exposes its
-- bar containers via the global accessor _ECME_GetBarFrame (keys come from its
-- AceDB profile); every EUI icon frame is reparented onto its bar container,
-- so enumerating bar children catches them all. Buff-family frames are SKIPPED
-- on purpose: a buff icon greys because the aura is INACTIVE (a signal worth
-- keeping), not because a cooldown is running.
local function ForEachEUIBarItem(fn)
	local getBar = _G._ECME_GetBarFrame
	local db = _G._ECME_AceDB
	local bars = db and db.profile and db.profile.cdmBars and db.profile.cdmBars.bars
	if not getBar or type(bars) ~= 'table' then return end
	for b = 1, #bars do
		local bd = bars[b]
		local bt = bd and bd.barType
		-- only cooldown-family bars; buff bars grey by (in)activity, not CD
		if bd and bd.key and (bt == 'cooldowns' or bt == 'utility' or bt == nil) then
			local ok, bar = pcall(getBar, bd.key)
			if ok and bar and bar.GetChildren then
				local kids = { bar:GetChildren() }
				for k = 1, #kids do
					local f = kids[k]
					if f and not f._isCustomBuffFrame and not f._isPlaceholderFrame
						and f.viewerFrame ~= _G.BuffIconCooldownViewer
						and f.viewerFrame ~= _G.BuffBarCooldownViewer then
						fn(f)  -- non-icon children have no .Icon -> hook is a no-op
					end
				end
			end
		end
	end
end

local function ForEachColorItem(fn)
	for v = 1, #COLOR_VIEWERS do
		local viewer = _G[COLOR_VIEWERS[v]]
		if viewer and viewer.GetItemFrames then
			local ok, frames = pcall(viewer.GetItemFrames, viewer)
			if ok and type(frames) == 'table' then
				for i = 1, #frames do fn(frames[i]) end
			end
		end
	end
	-- EUI-owned icons hosted on EUI CDM bars (hookedTex dedupes any frame
	-- already seen through the viewers above)
	ForEachEUIBarItem(fn)
end

-- Install hooks on every current item icon (idempotent: hookedTex guards
-- against double-hooking recycled textures) and push the current state.
-- Hooking is ONE-WAY: hooksecurefunc can never be undone, and InstallColorHook
-- adds four hooks per icon texture on methods the Cooldown Manager calls
-- constantly in combat (SetDesaturated / SetDesaturation / SetTexture /
-- SetVertexColor). Installing them while the feature is off bought nothing and
-- charged for it every frame a cooldown ticked, for the rest of the session.
-- So nothing is hooked until the feature is actually switched on; from then on
-- ForceColor and SyncOverlay stay gated, which is what makes a later toggle-off
-- cheap without needing to remove hooks we cannot remove.
local colorHooksInstalled = false

local function RefreshColorOnCD()
	local on = GetCfg().colorOnCD
	if not on and not colorHooksInstalled then return end
	if on then
		ForEachColorItem(InstallColorHook)
		colorHooksInstalled = true
	end
	ForEachColorItem(function(f)
		if not f then return end
		local tex = GetItemTex(f)
		if not tex then return end
		if on then
			ForceColor(tex)
			SyncOverlay(tex)
		else
			-- toggle-off: drop the shield and restore normal desaturation
			local rec = hookedTex[tex]
			if rec and rec.ov then rec.ov:Hide() end
			if f.RefreshIconDesaturation then
				pcall(f.RefreshIconDesaturation, f)
			end
		end
	end)
end
ns.CDSColorApply = RefreshColorOnCD

-- EUI rebuilds its bars OUTSIDE the Blizzard RefreshData path too (settings
-- apply, profile import, spec profile load) - hook its global apply entry so
-- freshly (re)built EUI icons get colour hooks. _ECME_Apply is assigned in
-- EUI's OnEnable (after our file body runs), so install lazily from events.
local ecmeApplyHooked
local function TryHookECMEApply()
	if ecmeApplyHooked then return end
	if type(_G._ECME_Apply) == 'function' then
		ecmeApplyHooked = true
		_G.hooksecurefunc(_G, '_ECME_Apply', QueueResync)
	end
end

-- Slow safety net: trinket/racial/preset frames are created lazily by EUI and
-- a later-registered EUI hook could out-order ours on a fresh frame. This
-- pass is idempotent (hookedTex) and re-forces colour, so any icon that
-- slipped through is corrected within 10s. No-ops entirely while the
-- feature is off.
C_Timer.NewTicker(10, function()
	if GetCfg().colorOnCD then RefreshColorOnCD() end
end)

-- the viewer RefreshData hook funnels here: re-find any custom-text frames
-- that moved when CDM reshuffled its icons, and (re)install colour hooks on
-- any freshly pooled item icons.
ResyncAttachments = function()
	ApplySettings()
	RefreshColorOnCD()
end

----------------------------------------------------------------------
-- Events
----------------------------------------------------------------------
local evt = CreateFrame('Frame')
evt:RegisterUnitEvent('UNIT_SPELLCAST_SUCCEEDED', 'player')
evt:RegisterEvent('PLAYER_ENTERING_WORLD')
evt:RegisterUnitEvent('PLAYER_SPECIALIZATION_CHANGED', 'player')
evt:SetScript('OnEvent', function(_, event, _, _, arg3)
	if event == 'UNIT_SPELLCAST_SUCCEEDED' then
		-- arg3 = spellID; secrecy check FIRST
		local sid = arg3
		if not (issecretvalue and issecretvalue(sid)) and sid == DND_ID then
			StartDnd()
		end
		return
	end
	-- PLAYER_ENTERING_WORLD / spec change: kill a running D&D timer
	dndExpire = 0
	if dndRec and dndRec.holder then dndRec.holder:Hide() end
	HookViewers()          -- viewers exist by now (incl. late loads)
	TryHookECMEApply()     -- EUI's OnEnable has run by PEW; hook its apply
	C_Timer.After(1, ApplySettings)
	C_Timer.After(1, RefreshColorOnCD)   -- install colour hooks once icons exist
end)

HookViewers() -- viewers usually exist already at addon load
