-- KiraUI-Plugin_Externals.lua
-- External-defensive tracker (Midnight-style).
--
-- Shows a row of icons for the external defensives currently on YOU (Pain
-- Suppression, Blessing of Sacrifice/Protection, Ironbark, Guardian Spirit, Life
-- Cocoon, etc.), each with a reverse cooldown swipe + countdown text and an
-- optional pixel glow.
-- Sounds come from LibSharedMedia (e.g. your Kiratank_SharedMedia pack); the
-- default is that pack's "Kira - External" sound.

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

local CreateFrame = CreateFrame
local C_Timer = C_Timer
local C_UnitAuras = C_UnitAuras
local GetTime = GetTime
local PlaySoundFile = PlaySoundFile
local pairs, ipairs = pairs, ipairs
local wipe = wipe
local tinsert, tsort = table.insert, table.sort
local mmin, mfloor = math.min, math.floor
local hooksecurefunc = hooksecurefunc

local EXTERNAL_FILTER = 'HELPFUL|EXTERNAL_DEFENSIVE'
local MAX_ICONS = 8  -- internal safety cap (no user "max" option)
local DEFAULT_SOUND = '|cFF00FF00Kira - External|r'  -- Kiratank_SharedMedia name

-- Available only on clients that have the new filtered-aura API (Midnight 12.0+).
local hasExt = C_UnitAuras and C_UnitAuras.GetAuraSlots and C_UnitAuras.GetAuraDataBySlot
	and C_UnitAuras.GetAuraDuration and true or false
ns.hasExternals = hasExt
if not hasExt then return end

-- 12.1: the slot/instance-ID scan HARD-ERRORS in restricted instanced content
-- (taint contagion) - the pcalls made it silently dark, which is why the
-- tracker showed nothing in M+. On 12.1+ the DISPLAY is engine-driven
-- instead: a CustomAuraContainer filtered to HELPFUL|EXTERNAL_DEFENSIVE on
-- the player (same tech as Buff Watch), which works even when aura data is
-- hidden from addons. The Lua scan stays as the pre-12.1 display path and as
-- the sound trigger (with a by-spell-ID fallback for restricted content).
local IS_121 = (select(4, _G.GetBuildInfo()) or 0) >= 120100
ns.ExtEngineMode = IS_121

local issecretvalue = issecretvalue
local function secret(v) return issecretvalue and issecretvalue(v) end

local UNIT = 'player'
local PREVIEW_ICONS = { 135936, 135966, 627485, 237542, 136120, 615341 }

local LSM = ns.LSM
local function LCG() return _G.LibStub and _G.LibStub('LibCustomGlow-1.0', true) end

----------------------------------------------------------------------
-- Settings
----------------------------------------------------------------------
local DEFAULTS = {
	enable = false,
	lock = false,
	iconSize = 40,
	textSize = 16,
	spacing = 4,
	grow = 'RIGHT',         -- 'LEFT' or 'RIGHT'
	soundEnabled = true,
	sound = DEFAULT_SOUND,  -- LibSharedMedia sound name
	x = 0,                  -- first icon's offset from screen centre (0 = centred)
	y = 200,
	-- glow (pixel glow defaults, color F2F252)
	glow = true,
	glowType = 'pixel',     -- pixel / autocast / button / proc
	glowColor = { 242 / 255, 242 / 255, 82 / 255, 1 },
	glowLines = 8,
	glowFreq = 0.2,
	glowLength = 0,         -- 0 = auto
	glowThickness = 2,
}

local function Migrate(cfg)
	-- migrate the old bundled default name to the Kiratank pack name
	if cfg.sound == 'External' then cfg.sound = DEFAULT_SOUND end
	-- repair a saved colour that an older build left as a non-table
	if type(cfg.glowColor) ~= 'table' then cfg.glowColor = { 242 / 255, 242 / 255, 82 / 255, 1 } end
end

local function GetCfg() return ns.ModuleCfg('externals', DEFAULTS, Migrate) end
ns.ExtGetCfg = GetCfg


----------------------------------------------------------------------
-- Sound dropdown options (all registered LSM sounds + None + current)
----------------------------------------------------------------------
function ns.ExtSoundOptions()
	local out = {}
	local lib = LSM()
	local hash = lib and lib.HashTable and lib:HashTable('sound')
	if hash then
		local names = {}
		for name in pairs(hash) do names[#names + 1] = name end
		-- sort case-insensitively, ignoring colour codes so "Kira - X" group together
		tsort(names, function(a, b)
			local sa = tostring(a):gsub('|c%x%x%x%x%x%x%x%x', ''):gsub('|r', ''):lower()
			local sb = tostring(b):gsub('|c%x%x%x%x%x%x%x%x', ''):gsub('|r', ''):lower()
			return sa < sb
		end)
		for _, name in ipairs(names) do out[#out + 1] = { value = name, text = name } end
	end
	tinsert(out, 1, { value = 'None', text = 'None' })
	local cur = GetCfg().sound
	if cur and cur ~= 'None' then
		local found = false
		for _, o in ipairs(out) do if o.value == cur then found = true break end end
		if not found then tinsert(out, 2, { value = cur, text = cur }) end
	end
	return out
end

-- Play a sound by its LSM name (used for config previews).
function ns.ExtPlaySound(name)
	if not name or name == 'None' then return end
	local lib = LSM()
	local path = lib and lib.Fetch and lib:Fetch('sound', name, true)
	if path then PlaySoundFile(path, 'Master') end
end

local lastRing = 0
local function PlayExtSound()
	local cfg = GetCfg()
	if not cfg.soundEnabled then return end
	-- several triggers can fire for the same external; ring at most once/sec
	if GetTime() - lastRing < 1 then return end
	lastRing = GetTime()
	ns.ExtPlaySound(cfg.sound)
end

----------------------------------------------------------------------
-- Scriptless "pixel glow" (shared - Buff Watch uses it too via ns).
-- LibCustomGlow is not usable here: it moves its lines from OnUpdate script
-- handlers, which are sealed on 12.1 engine AuraButton descendants
-- (UntrustedScriptExecution). Honors lines / frequency (negative = reverse
-- direction) / length (0 = auto) / thickness / colour - the same knobs as the
-- LCG pixel glow.
--
-- There is no per-dash corner handling at all, so nothing can ever sit ON a
-- corner - which is what produced the "plus" artifact before: one run of dashes
-- per EDGE, each clipped to that edge by a mask and slid along by exactly one
-- dash cycle on a loop. The snap-back is invisible because the pattern repeats
-- every cycle. Each edge starts at the running perimeter phase, so dash spacing
-- carries across a corner unbroken.
--
-- THE RULE THAT MAKES IT WORK: the mask must live on something that does NOT
-- move, and the animation must be on the dash TEXTURES. Animating a frame
-- offsets its entire subtree, so a mask parented to a moving holder travels
-- with the dashes and clips nothing - they then run off their edge and pile up
-- at the corners, which looks exactly like the artifact this replaced.
--
-- Everything is C-side Translation animations: no OnUpdate, which is what lets
-- it keep running on 12.1 engine aura buttons where script handlers are sealed.
local ANTS_SOLID = [[Interface\Buttons\WHITE8X8]]

-- edge order clockwise from TOPLEFT; dir is the travel direction along it
local function AntsEdgeDefs(w, h, reverse)
	local e = {
		{ vert = false, len = w, base = 0,         anchor = 'TOPLEFT',     sx =  1, sy =  0 },
		{ vert = true,  len = h, base = w,         anchor = 'TOPRIGHT',    sx =  0, sy = -1 },
		{ vert = false, len = w, base = w + h,     anchor = 'BOTTOMRIGHT', sx = -1, sy =  0 },
		{ vert = true,  len = h, base = w + h + w, anchor = 'BOTTOMLEFT',  sx =  0, sy =  1 },
	}
	if reverse then
		for _, d in ipairs(e) do d.sx, d.sy = -d.sx, -d.sy end
	end
	return e
end

function ns.AntsStart(owner, w, h, opts)
	opts = opts or {}
	if not (w and h) or w <= 0 or h <= 0 then return end
	local st = owner.__kiraAnts
	if not st then
		st = { edges = {} }
		owner.__kiraAnts = st
		st.frame = CreateFrame('Frame', nil, owner)
	end
	st.frame:ClearAllPoints()
	st.frame:SetPoint('CENTER', owner, 'CENTER', 0, 0)
	st.frame:SetSize(w, h)
	st.frame:Show()

	local color = opts.color or { 1, 1, 1, 1 }
	local lines = math.max(1, mfloor(opts.lines or 8))
	local freq = opts.freq or 0.25
	if freq == 0 then freq = 0.25 end
	local reverse = freq < 0
	local period = 1 / math.abs(freq)          -- seconds for one full lap
	local th = math.max(1, opts.thickness or 2)
	local perim = 2 * (w + h)
	local P = perim / lines                    -- pixels between dash starts
	local dashLen = (opts.length and opts.length > 0) and opts.length or (P * 0.5)
	dashLen = math.max(1, mmin(dashLen, P))    -- never longer than one cycle
	local step = math.max(0.001, period / lines) -- seconds to travel one cycle

	-- only re-plot when the geometry actually changes; colour re-tints live
	local sig = w .. ':' .. h .. ':' .. lines .. ':' .. period .. ':' .. th
		.. ':' .. dashLen .. ':' .. (reverse and 1 or 0)
	local rebuild = st.sig ~= sig
	st.sig = sig

	local defs = AntsEdgeDefs(w, h, reverse)
	for i = 1, 4 do
		local d = defs[i]
		local E = st.edges[i]
		if not E then
			E = { segs = {} }
			st.edges[i] = E
			-- The mask lives on the STATIC frame, never on anything that moves.
			-- Animating a frame offsets its whole subtree, so a mask parented to
			-- a moving holder travels with the dashes and clips nothing - which
			-- is why they ran off their edge and piled up at the corners.
			E.mask = st.frame:CreateMaskTexture()
			E.mask:SetTexture(ANTS_SOLID, 'CLAMPTOBLACKADDITIVE', 'CLAMPTOBLACKADDITIVE')
		end

		if rebuild then
			E.mask:ClearAllPoints()
			E.mask:SetPoint(d.anchor, st.frame, d.anchor, 0, 0)
			E.mask:SetSize(d.vert and th or d.len, d.vert and d.len or th)

			-- start this edge's pattern at the running perimeter phase so the
			-- dash spacing carries across the corner unbroken
			local phase = d.base % P
			local first = -phase - P
			local count = mfloor((d.len + 2 * P) / P) + 1

			for j = 1, count do
				local s = E.segs[j]
				if not s then
					-- texture AND its animation both on the static frame, so the
					-- segment moves under a mask that stays put (EUI's structure)
					s = st.frame:CreateTexture(nil, 'OVERLAY', nil, 7)
					s:SetTexture(ANTS_SOLID)
					s:AddMaskTexture(E.mask)
					s.ag = s:CreateAnimationGroup()
					s.ag:SetLooping('REPEAT')
					s.tr = s.ag:CreateAnimation('Translation')
					s.tr:SetSmoothing('NONE')
					E.segs[j] = s
				end
				s.ag:Stop()
				local o = first + (j - 1) * P
				s:SetSize(d.vert and th or dashLen, d.vert and dashLen or th)
				s:ClearAllPoints()
				if d.vert then
					s:SetPoint(d.anchor, st.frame, d.anchor, 0, d.sy >= 0 and o or -o)
				else
					s:SetPoint(d.anchor, st.frame, d.anchor, d.sx >= 0 and o or -o, 0)
				end
				-- one cycle per step, then loop: the snap-back is invisible
				-- because the pattern repeats every P pixels
				s.tr:SetOffset(d.sx * P, d.sy * P)
				s.tr:SetDuration(step)
				s:Show()
			end
			for j = count + 1, #E.segs do
				E.segs[j].ag:Stop()
				E.segs[j]:Hide()
			end
			E.count = count
		end

		for j = 1, (E.count or 0) do
			local s = E.segs[j]
			if s then
				s:SetVertexColor(color[1] or 1, color[2] or 1, color[3] or 1, color[4] or 1)
				s:Show()
				if not s.ag:IsPlaying() then s.ag:Play() end
			end
		end
	end
end

function ns.AntsStop(owner)
	local st = owner.__kiraAnts
	if not st then return end
	for i = 1, 4 do
		local E = st.edges and st.edges[i]
		if E then
			for _, s in ipairs(E.segs) do
				s.ag:Stop()
				s:Hide()
			end
		end
	end
	st.frame:Hide()
end

----------------------------------------------------------------------
-- Glow. Pre-12.1: LibCustomGlow, all four types. 12.1+: the scriptless
-- pixel glow above, live AND preview so they always match. Lines /
-- frequency / length / thickness / color all still apply; only the glow
-- TYPE dropdown is ignored on 12.1 (always pixel-style).
----------------------------------------------------------------------
local function SetPulse(b, on)
	local cfg = GetCfg()
	if on and cfg.glow then
		local sz = cfg.iconSize or 40
		-- pcall: glow must never abort an initializeFrame batch
		pcall(ns.AntsStart, b, sz, sz, {
			color = cfg.glowColor,
			lines = cfg.glowLines,
			freq = cfg.glowFreq,
			thickness = cfg.glowThickness,
			length = cfg.glowLength,
		})
	else
		ns.AntsStop(b)
	end
end

local function StopGlow(b)
	if b.__kiraAnts then SetPulse(b, false) end
	local lib = LCG()
	if not lib or not b.__glow then return end
	if lib.PixelGlow_Stop then lib.PixelGlow_Stop(b) end
	if lib.AutoCastGlow_Stop then lib.AutoCastGlow_Stop(b) end
	if lib.ButtonGlow_Stop then lib.ButtonGlow_Stop(b) end
	if lib.ProcGlow_Stop then lib.ProcGlow_Stop(b) end
	b.__glow = false
end

local function StartGlow(b)
	local cfg = GetCfg()
	if not cfg.glow then StopGlow(b) return end
	if IS_121 then
		SetPulse(b, true)
		return
	end
	local lib = LCG()
	if not lib then return end
	StopGlow(b)
	local c = cfg.glowColor or { 1, 1, 1, 1 }
	local t = cfg.glowType or 'pixel'
	if t == 'autocast' and lib.AutoCastGlow_Start then
		lib.AutoCastGlow_Start(b, c, cfg.glowLines or 8, cfg.glowFreq or 0.2, 1, 0, 0, nil)
	elseif t == 'button' and lib.ButtonGlow_Start then
		lib.ButtonGlow_Start(b, c, cfg.glowFreq or 0.2)
	elseif t == 'proc' and lib.ProcGlow_Start then
		lib.ProcGlow_Start(b, { color = c })
	elseif lib.PixelGlow_Start then
		local len = (cfg.glowLength and cfg.glowLength > 0) and cfg.glowLength or nil
		lib.PixelGlow_Start(b, c, cfg.glowLines or 8, cfg.glowFreq or 0.2, len, cfg.glowThickness or 2, 0, 0, false, nil)
	end
	b.__glow = true
end

----------------------------------------------------------------------
-- Frame + button pool
----------------------------------------------------------------------
local frame
local pool = {}
local previewActive = false
local pendingUpdate = false
local soundPlayedFor = {}
local auraCache = {}
local seen = {}

local function EnforceCDFont(fs, want)
	if not (fs and fs.GetFont and fs.SetFont) then return end
	if not fs.__kiraExtHook then
		fs.__kiraExtHook = true
		hooksecurefunc(fs, 'SetFont', function(self)
			if self.__kiraExtGuard then return end
			local font, size, flags = self:GetFont()
			local w = self.__kiraExtWant or 16
			if font and size ~= w then
				self.__kiraExtGuard = true
				self:SetFont(ns.GetFont(font), w, flags)
				self.__kiraExtGuard = false
			end
		end)
	end
	fs.__kiraExtWant = want
	local font, size, flags = fs:GetFont()
	if font and size ~= want then fs:SetFont(ns.GetFont(font), want, flags) end
end

local function StyleCD(cd, textSize)
	if not cd then return end
	for _, r in ipairs({ cd:GetRegions() }) do
		if r and r.GetFont and r.SetFont then EnforceCDFont(r, textSize) end
	end
end

local function MakeButton()
	local b = CreateFrame('Frame', nil, frame)
	b.border = b:CreateTexture(nil, 'BACKGROUND')
	b.border:SetColorTexture(0, 0, 0, 1)
	b.border:SetAllPoints()
	b.Icon = b:CreateTexture(nil, 'ARTWORK')
	b.Icon:SetPoint('TOPLEFT', 1, -1)
	b.Icon:SetPoint('BOTTOMRIGHT', -1, 1)
	b.Icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
	b.Cooldown = CreateFrame('Cooldown', nil, b, 'CooldownFrameTemplate')
	b.Cooldown:SetAllPoints()
	b.Cooldown:SetDrawEdge(false)
	b.Cooldown:SetDrawBling(false)
	b.Cooldown:SetReverse(true)  -- icon stays bright, dark swipe grows as it expires
	b:Hide()
	pool[#pool + 1] = b
	return b
end

-- Icon 1 sits exactly at the frame (so Position X/Y = the first icon's spot);
-- the rest grow left or right from there.
local function Layout()
	local cfg = GetCfg()
	local size, gap, dir = cfg.iconSize or 40, cfg.spacing or 4, cfg.grow or 'RIGHT'
	frame:SetSize(size, size)
	for i, b in ipairs(pool) do
		b:SetSize(size, size)
		b:ClearAllPoints()
		-- The 1px border overhangs the icon on each side, so a button's visible
		-- width is size + 2. Add that to the step so the gap you set is the gap
		-- you actually see between borders (not 2px narrower).
		local off = (i - 1) * (size + 2 + gap)
		if dir == 'LEFT' then
			b:SetPoint('CENTER', frame, 'CENTER', -off, 0)
		else
			b:SetPoint('CENTER', frame, 'CENTER', off, 0)
		end
		b.Cooldown:SetReverse(true)
		StyleCD(b.Cooldown, cfg.textSize or 16)
	end
end

local function ApplyPosition()
	if not frame then return end
	local cfg = GetCfg()
	frame:ClearAllPoints()
	frame:SetPoint('CENTER', _G.UIParent, 'CENTER', cfg.x or 0, cfg.y or 200)
end

----------------------------------------------------------------------
-- Aura scan
----------------------------------------------------------------------
local function FillButton(b, data)
	b.auraInstanceID = data.auraInstanceID
	b.Icon:SetTexture(data.icon)
	-- pcall: instance-ID lookups hard-error under 12.1 combat restrictions
	local okDur, dur = pcall(C_UnitAuras.GetAuraDuration, UNIT, data.auraInstanceID)
	dur = okDur and dur or nil
	if dur then
		b.Cooldown:SetCooldownFromDurationObject(dur)
		b.Cooldown:Show()
	else
		b.Cooldown:Hide()
	end
	b:Show()
	StartGlow(b)
	-- one-shot sound when a new external first appears
	if not soundPlayedFor[data.auraInstanceID] then
		soundPlayedFor[data.auraInstanceID] = true
		PlayExtSound()
	end
end

local function SortAuras(a, b) return a.auraInstanceID < b.auraInstanceID end

local function UpdateAuras()
	if IS_121 then return end -- the engine container drives the display on 12.1+
	if not frame or previewActive then return end
	local cfg = GetCfg()
	if not cfg.enable then frame:Hide() return end

	-- 12.1: while aura data is restricted (instanced combat) these lookups
	-- HARD-ERROR instead of returning nothing (same behavior the Elixir module
	-- documents for instance-ID lookups). pcall them and keep the last known
	-- display when the scan is dark - the Cooldown widgets keep ticking on
	-- their own, so existing icons stay correct.
	local okS, slots = pcall(function() return { C_UnitAuras.GetAuraSlots(UNIT, EXTERNAL_FILTER) } end)
	if not okS or not slots then return end

	wipe(auraCache)
	wipe(seen)
	local n = 0

	for i = 2, #slots do
		local okD, data = pcall(C_UnitAuras.GetAuraDataBySlot, UNIT, slots[i])
		if okD and data and data.auraInstanceID and not seen[data.auraInstanceID] then
			seen[data.auraInstanceID] = true
			n = n + 1
			auraCache[n] = data
		end
	end

	for id in pairs(soundPlayedFor) do
		if not seen[id] then soundPlayedFor[id] = nil end
	end

	if n > 1 then tsort(auraCache, SortAuras) end
	local show = mmin(n, MAX_ICONS)
	while #pool < show do MakeButton() end
	Layout()

	for i = 1, #pool do
		if i <= show and auraCache[i] then
			FillButton(pool[i], auraCache[i])
		else
			StopGlow(pool[i])
			pool[i]:Hide()
		end
	end
	frame:SetShown(show > 0)
end

----------------------------------------------------------------------
-- Preview (always 3 icons so you can judge the layout)
----------------------------------------------------------------------
local function ShowPreview()
	local n = 1
	while #pool < n do MakeButton() end
	Layout()
	for i = 1, #pool do
		if i <= n then
			local b = pool[i]
			b.auraInstanceID = nil
			b.Icon:SetTexture(PREVIEW_ICONS[((i - 1) % #PREVIEW_ICONS) + 1])
			local duration = 8 + i * 4
			b.Cooldown:SetCooldown(GetTime() - (duration * 0.25 * i), duration)
			b.Cooldown:Show()
			b:Show()
			StartGlow(b)
		else
			StopGlow(pool[i])
			pool[i]:Hide()
		end
	end
	frame:Show()
end

----------------------------------------------------------------------
-- Engine-driven display (12.1+): a CustomAuraContainer on the player,
-- filtered to HELPFUL|EXTERNAL_DEFENSIVE. Blizzard's trusted code reads the
-- auras (legal even when secret) and fills the widgets we hand it: icon,
-- cooldown swipe, countdown text - so this keeps working in M+/raid combat
-- where the Lua scan above goes dark.
----------------------------------------------------------------------
local engineButtons = {}   -- { frame, text } for resize/refont on apply
local extContainer

-- whole-seconds countdown ('12', not '12 s') - same formatter pattern as
-- Buff Watch / the CDM timer
local extDurFmt
local function ExtDurationTextOptions()
	if extDurFmt == nil then
		extDurFmt = false
		local CSU = _G.C_StringUtil
		if CSU and CSU.CreateNumericRuleFormatter then
			local f = CSU.CreateNumericRuleFormatter()
			if f and f.SetBreakpoints then
				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 ok then extDurFmt = f end
			end
		end
	end
	-- old builds read options.formatter; the 2026-07 PTR build reads
	-- options.textFormatter (old key silently ignored) - pass both.
	if extDurFmt then return { formatter = extDurFmt, textFormatter = extDurFmt } end
	-- string textFormat is the OLD shape only; the new build wants a table
	-- and has C_AuraContainerUtil - fall back to engine default there
	if _G.C_AuraContainerUtil then return {} end
	return { textFormat = '%d' }
end

local function MakeExtContainer()
	local cfg = GetCfg()
	local c = CreateFrame('AuraContainer', 'KiraUIExternalsEngine', frame, 'CustomAuraContainerTemplate')
	c:SetSize(1, 1)
	c:AddAuraGroup('ext', EXTERNAL_FILTER, {
		maxFrameCount = MAX_ICONS,
		-- +2 matches the legacy layout: the 1px border overhangs each side
		-- (old + new (2026-07 PTR) spacing key names; validators skip unknown keys)
		layout = { elementSpacingX = (cfg.spacing or 4) + 2, elementSpacingY = (cfg.spacing or 4) + 2,
			elementSpacing = (cfg.spacing or 4) + 2, lineSpacing = (cfg.spacing or 4) + 2 },
		initializeFrame = function(auraFrame)
			local cfg2 = GetCfg()
			local sz = cfg2.iconSize or 40
			auraFrame:SetSize(sz, sz)
			-- no hover tooltip, clicks pass through (same as Buff Watch)
			pcall(auraFrame.SetMouseMotionEnabled, auraFrame, false)
			pcall(auraFrame.SetMouseClickEnabled, auraFrame, false)
			local border = auraFrame:CreateTexture(nil, 'BACKGROUND')
			border:SetColorTexture(0, 0, 0, 1)
			border:SetAllPoints()
			local icon = auraFrame:CreateTexture(nil, 'ARTWORK')
			icon:SetPoint('TOPLEFT', 1, -1)
			icon:SetPoint('BOTTOMRIGHT', -1, 1)
			icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
			auraFrame:SetIcon(icon)
			local cd = CreateFrame('Cooldown', nil, auraFrame, 'CooldownFrameTemplate')
			cd:SetAllPoints()
			cd:SetReverse(true)
			cd:SetDrawEdge(false)
			cd:SetDrawBling(false)
			cd:SetHideCountdownNumbers(true)
			cd.noCooldownCount = true
			auraFrame:SetDurationCooldown(cd)
			local lvl = CreateFrame('Frame', nil, auraFrame)
			lvl:SetAllPoints()
			lvl:SetFrameLevel(cd:GetFrameLevel() + 5)
			local txt = lvl:CreateFontString(nil, 'OVERLAY')
			txt:SetPoint('CENTER')
			txt:SetFont(ns.GetFont(), cfg2.textSize or 16, 'OUTLINE')
			auraFrame:SetDurationText(txt, ExtDurationTextOptions())
			SetPulse(auraFrame, true)
			engineButtons[#engineButtons + 1] = { frame = auraFrame, text = txt }
		end,
	})
	c:SetEnabled(false)
	c:SetUnit(UNIT)
	return c
end

-- 2026-07 PTR build renamed the flow-layout API (SetAuraLayout* ->
-- SetFlowLayout*); call whichever this client has.
local function CAnchor(c, p)
	local f = c.SetFlowLayoutAnchorPoint or c.SetAuraLayoutAnchorPoint
	if f then f(c, p) end
end
local function CGrowth(c, h, v)
	local f = c.SetFlowLayoutGrowthDirection or c.SetAuraLayoutGrowthDirection
	if f then f(c, h, v) end
end

local function ApplyEngine()
	if not (extContainer and frame) then return end
	local cfg = GetCfg()
	local sz = cfg.iconSize or 40
	frame:SetSize(sz, sz)
	for _, b in ipairs(engineButtons) do
		-- b.frame is an ENGINE AuraContainer button captured inside
		-- initializeFrame. Outside that callback the engine treats it as a
		-- forbidden object, so from tainted code SetSize throws
		--   "calling 'SetSize' on bad self (Attempt to access forbidden object)"
		-- pcall per button: one unreachable frame must not abort the styling of
		-- the rest, and it degrades to "engine keeps its own size" rather than
		-- spamming the error frame.
		pcall(function()
			b.frame:SetSize(sz, sz)
			b.text:SetFont(ns.GetFont(), cfg.textSize or 16, 'OUTLINE')
			SetPulse(b.frame, true)
		end)
	end
	-- old + new (2026-07 PTR) spacing key names; both validators skip unknown keys
	pcall(extContainer.SetAuraGroupLayout, extContainer, 'ext',
		{ elementSpacingX = (cfg.spacing or 4) + 2, elementSpacingY = (cfg.spacing or 4) + 2,
		  elementSpacing = (cfg.spacing or 4) + 2, lineSpacing = (cfg.spacing or 4) + 2 })
	local FD = _G.AnchorUtil.FlowDirection
	extContainer:ClearAllPoints()
	if (cfg.grow or 'RIGHT') == 'LEFT' then
		CAnchor(extContainer, 'TOPRIGHT')
		CGrowth(extContainer, FD.Left, FD.Down)
		extContainer:SetPoint('TOPRIGHT', frame, 'TOPRIGHT', 0, 0)
	else
		CAnchor(extContainer, 'TOPLEFT')
		CGrowth(extContainer, FD.Right, FD.Down)
		extContainer:SetPoint('TOPLEFT', frame, 'TOPLEFT', 0, 0)
	end
	local on = (cfg.enable and not previewActive) and true or false
	extContainer:SetEnabled(on)
	if on then pcall(extContainer.UpdateAllAuras, extContainer) end
	frame:SetShown((cfg.enable or previewActive) and true or false)
end

----------------------------------------------------------------------
-- Sound (12.1): ENGINE-REGISTERED via C_UnitAuras.AddAuraAppliedSound
-- (the 12.1 rename of AddPrivateAuraAppliedSound - no longer limited to
-- private auras). One registration per spell ID in the user-editable
-- sound list below; the ENGINE plays the sound when that aura lands on
-- you, so it keeps working in restricted instanced combat where every
-- Lua-side trigger (aura scan, combat log, icon counting) goes dark on
-- secret values. Registrations are re-made whenever the sound, the spell
-- list, or the enable toggles change. The API is AllowedWhenUntainted
-- only - never (re)register in combat; a pending flag defers the work to
-- PLAYER_REGEN_ENABLED.
----------------------------------------------------------------------
-- The list is USER-EDITABLE (Externals tab -> Sound spell list) and saved in
-- cfg.soundSpells; these are just the pre-filled defaults - the common
-- single-target externals cast ON you (no raid-wide defensives).
-- NOTE: the combat log reports the BUFF's spell ID, which is not always the
-- cast's ID; where they differ, both are listed - extra entries cost nothing.
-- /kiraext prints the exact ID of anything that lands on you.
local DEFAULT_SOUND_SPELLS = {
	-- Priest
	33206,  -- Pain Suppression
	47788,  -- Guardian Spirit
	197268, -- Ray of Hope (PvP)
	-- Paladin
	1022,   -- Blessing of Protection
	6940,   -- Blessing of Sacrifice
	204018, -- Blessing of Spellwarding
	-- Druid
	102342, -- Ironbark
	-- Monk
	116849, -- Life Cocoon
	-- Evoker
	357170, -- Time Dilation
	-- Warrior
	147833, -- Intervene (buff)
	3411,   -- Intervene (cast)
	223658, -- Safeguard (PvP)
	-- Hunter
	53480,  -- Roar of Sacrifice (pet, PvP)
}
local SOUND_SEED_VERSION = 3

function ns.ExtSoundSpells()
	local cfg = GetCfg()
	if type(cfg.soundSpells) ~= 'table' then cfg.soundSpells = {} end
	-- additive seeding: newly added defaults merge into an existing saved
	-- list exactly once (user removals stay removed until the seed bumps)
	if (cfg.soundSeedVersion or 0) < SOUND_SEED_VERSION then
		cfg.soundSeedVersion = SOUND_SEED_VERSION
		-- v3: seeded by v2, later dropped from the defaults - remove them
		local retired = { [432496] = true, [432502] = true, [443113] = true, [360827] = true }
		for i = #cfg.soundSpells, 1, -1 do
			if retired[cfg.soundSpells[i]] then table.remove(cfg.soundSpells, i) end
		end
		local have = {}
		for _, id in ipairs(cfg.soundSpells) do have[id] = true end
		for _, id in ipairs(DEFAULT_SOUND_SPELLS) do
			if not have[id] then cfg.soundSpells[#cfg.soundSpells + 1] = id end
		end
	end
	return cfg.soundSpells
end

local SOUND_SET = {}
local function RebuildSoundSet()
	wipe(SOUND_SET)
	for _, id in ipairs(ns.ExtSoundSpells()) do SOUND_SET[id] = true end
end

-- 12.1 PTR rename: AddAuraAppliedSound(info) became AddAuraSound(trigger, info)
-- where trigger is Enum.UnitAuraSoundTrigger (0 Added / 1 ApplicationsIncreased
-- / 2 Removed). Support both names so a client update can't silence us.
local AddAuraSoundNew   = IS_121 and C_UnitAuras.AddAuraSound or nil
local AddAuraSoundOld   = IS_121 and C_UnitAuras.AddAuraAppliedSound or nil
local RemoveAuraSoundFn = IS_121 and (C_UnitAuras.RemoveAuraSound
	or C_UnitAuras.RemoveAuraAppliedSound) or nil
local HAS_APPLIED_SOUND = (AddAuraSoundNew or AddAuraSoundOld)
	and RemoveAuraSoundFn and true or false

local InCombatLockdown = _G.InCombatLockdown
local registeredSoundIDs = {}
local registerPending = false

local function ClearAuraSounds()
	for i = #registeredSoundIDs, 1, -1 do
		pcall(RemoveAuraSoundFn, registeredSoundIDs[i])
		registeredSoundIDs[i] = nil
	end
end

local function RegisterAuraSounds()
	if not HAS_APPLIED_SOUND then return end
	if InCombatLockdown and InCombatLockdown() then
		registerPending = true
		return
	end
	registerPending = false
	ClearAuraSounds()
	local cfg = GetCfg()
	if not (cfg.enable and cfg.soundEnabled) then return end
	if not cfg.sound or cfg.sound == 'None' then return end
	local lib = LSM()
	local path = lib and lib.Fetch and lib:Fetch('sound', cfg.sound, true)
	if not path then return end
	local trigAdded = (_G.Enum and _G.Enum.UnitAuraSoundTrigger
		and _G.Enum.UnitAuraSoundTrigger.Added) or 0
	for _, id in ipairs(ns.ExtSoundSpells()) do
		local info = {
			unitToken = UNIT,
			spellID = id,
			soundFileName = path,
			outputChannel = 'Master',
		}
		local ok, soundID
		if AddAuraSoundNew then
			ok, soundID = pcall(AddAuraSoundNew, trigAdded, info)
		else
			ok, soundID = pcall(AddAuraSoundOld, info)
		end
		if ok and soundID then registeredSoundIDs[#registeredSoundIDs + 1] = soundID end
	end
end

-- Options calls this after the sound spell list is edited.
ns.ExtRebuildSoundSet = function()
	RebuildSoundSet()
	RegisterAuraSounds()
end

local regen = CreateFrame('Frame')
regen:RegisterEvent('PLAYER_REGEN_ENABLED')
regen:SetScript('OnEvent', function()
	if registerPending then RegisterAuraSounds() end
end)

----------------------------------------------------------------------
-- /kiraext spell-ID discovery: the combat-log watcher exists ONLY for the
-- 15-second debug window now (it no longer drives the sound) - registered
-- on demand by the slash command, unregistered when the window closes.
----------------------------------------------------------------------
local CombatLogGetCurrentEventInfo = _G.CombatLogGetCurrentEventInfo
local UnitGUID = _G.UnitGUID
local playerGUID



-- Diagnostic: /kiraext - checks the whole sound chain (settings, sound file,
-- combat-log visibility) so a silent external points at exactly one link.
SLASH_KIRAEXT1 = '/kiraext'
SlashCmdList.KIRAEXT = function()
	local cfg = GetCfg()
	local lib = LSM()
	local path = lib and lib.Fetch and lib:Fetch('sound', cfg.sound, true)
	print('|cffff7d0aKiraExt|r module enable=' .. tostring(cfg.enable)
		.. '  sound enable=' .. tostring(cfg.soundEnabled)
		.. '  sound file=' .. (path and 'OK' or 'MISSING'))
	if HAS_APPLIED_SOUND then
		print('|cffff7d0aKiraExt|r engine sound registrations: '
			.. #registeredSoundIDs .. ' spell(s)'
			.. (registerPending
				and ' |cffff5555(pending - re-registers when combat ends)|r' or ''))
	else
		print('|cffff7d0aKiraExt|r AddAuraSound/AddAuraAppliedSound not available on this client'
			.. ' - sound rides the pre-12.1 aura-scan path')
	end
	if not path then
		print('|cffff7d0aKiraExt|r the sound "' .. tostring(cfg.sound)
			.. '" does not resolve - LibSharedMedia or the Kiratank_SharedMedia pack is not loaded. Pick any other sound in the Externals tab and it will ring.')
	elseif cfg.soundEnabled then
		ns.ExtPlaySound(cfg.sound)
		print('|cffff7d0aKiraExt|r played the configured sound just now - if you heard nothing, check the Master sound channel / game audio.')
	end
	-- The combat-log watch is gone. On 12.x COMBAT_LOG_EVENT_UNFILTERED is
	-- flagged HasRestrictions, so an addon registering it raises
	-- ADDON_ACTION_FORBIDDEN - and pcall does not help, because that is a
	-- client-side forbidden action rather than a Lua error.
	print('|cffff7d0aKiraExt|r combat-log watching is unavailable on this client: addons may no longer register COMBAT_LOG_EVENT_UNFILTERED. Externals detects auras through the aura API instead, so use the sound test above to check the audio chain.')
end

----------------------------------------------------------------------
-- Apply / drivers
----------------------------------------------------------------------
local function EnsureFrame()
	if frame then return end
	frame = CreateFrame('Frame', 'KiraUIExternals', _G.UIParent)
	frame:SetFrameStrata('MEDIUM')
	frame:SetClampedToScreen(true)
	frame:SetMovable(true)
	frame:EnableMouse(false)
	frame:RegisterForDrag('RightButton')
	ns.MakeDraggable(frame, GetCfg, 'x', 'y', function() ApplyPosition() end)
end

local function Apply()
	EnsureFrame()
	if IS_121 and not extContainer then extContainer = MakeExtContainer() end
	RebuildSoundSet()
	RegisterAuraSounds()
	ApplyPosition()
	frame:EnableMouse(previewActive and not GetCfg().lock or false)
	if previewActive then
		if IS_121 then ApplyEngine() end -- container off while previewing
		ShowPreview()
	elseif IS_121 then
		for _, b in ipairs(pool) do StopGlow(b) b:Hide() end -- preview leftovers
		ApplyEngine()
	else
		UpdateAuras()
	end
end
ns.ExtApply = function() C_Timer.After(0, Apply) end

ns.ExtIsPreview = function() return previewActive end
ns.ExtSetPreview = function(state)
	previewActive = state and true or false
	Apply()
end

local evt = CreateFrame('Frame')
evt:RegisterUnitEvent('UNIT_AURA', UNIT)
evt:RegisterEvent('PLAYER_ENTERING_WORLD')
evt:SetScript('OnEvent', function(_, event)
	if event == 'PLAYER_ENTERING_WORLD' then
		C_Timer.After(0.5, Apply)
		return
	end
	if previewActive or pendingUpdate then return end
	pendingUpdate = true
	C_Timer.After(0.05, function()
		pendingUpdate = false
		if previewActive then return end
		if IS_121 then
			-- the container refreshes its own CONTENTS off UNIT_AURA, but the
			-- flow layout does not reliably re-pack when an aura drops - poke
			-- a full refresh so icons slide toward the anchor (dynamic-group
			-- behavior). Nothing reads the event payload.
			if extContainer and GetCfg().enable then
				pcall(extContainer.UpdateAllAuras, extContainer)
			end
		else
			UpdateAuras()
		end
	end)
end)
