-- KiraUI-Plugin_EUI.lua
-- EllesmereUI unitframe integration. Active only when EllesmereUI's unitframes
-- (global "EllesmereUF") are loaded.
--
-- Feature 1: cap the TARGET frame name's width (secret-safe truncation).
-- Feature 2: raid target marker on the FOCUS cast bar (shows with the bar).
-- (Font outline overrides and character-based name truncation removed in 1.05
--  - 12.1 makes names secret in combat, so only width clamping can work.)

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

local EUF = _G.EllesmereUF
local hasEUI = (_G.EllesmereUI ~= nil) and EUF and EUF.Tags and type(EUF.Tags.Methods) == 'table'

ns.hasEUI = hasEUI
if not hasEUI then return end

local issecretvalue = issecretvalue
local UnitName = UnitName
local UnitExists = UnitExists
local GetRaidTargetIndex = GetRaidTargetIndex
local SetRaidTargetIconTexture = SetRaidTargetIconTexture
local RAID_ICON_TEXTURE = [[Interface\TargetingFrame\UI-RaidTargetingIcons]]
local ipairs = ipairs
local pairs = pairs
local pcall = pcall
local hooksecurefunc = hooksecurefunc
local CreateFrame = CreateFrame
local C_Timer = C_Timer
local InCombatLockdown = InCombatLockdown

----------------------------------------------------------------------
-- Defaults are file-level constants rather than table literals inside the
-- getters: these run from range/debuff refresh paths, and a literal in the
-- argument list allocates a throwaway table on every call.
local NAME_DEFAULTS        = { enable = false, maxWidth = 110 }
local FOCUS_MARK_DEFAULTS  = { enable = false, size = 40, x = -40, y = 0 }

local function GetNameCfg()
	local cfg = ns.ModuleCfg('euiName', NAME_DEFAULTS)
	-- 0 meant 'off' in the build that introduced maxWidth; the toggle owns
	-- on/off now, so heal a saved 0 to the default width
	if (cfg.maxWidth or 0) <= 0 then cfg.maxWidth = 110 end
	return cfg
end
local function GetFocusMarkCfg() return ns.ModuleCfg('euiFocusMark', FOCUS_MARK_DEFAULTS) end

ns.EUIGetNameCfg      = GetNameCfg
ns.EUIGetFocusMarkCfg = GetFocusMarkCfg

-- EllesmereUI spawns each unit frame with a fixed global name. Prefer that
-- (always present) and fall back to scanning EllesmereUF.objects.
local UNIT_GLOBAL = {
	player = 'EllesmereUIUnitFrames_Player',
	target = 'EllesmereUIUnitFrames_Target',
	focus  = 'EllesmereUIUnitFrames_Focus',
}
local function GetUnitFrame(unit)
	local g = UNIT_GLOBAL[unit]
	if g and _G[g] then return _G[g] end
	if EUF.objects then
		for _, obj in ipairs(EUF.objects) do
			if obj.unit == unit then return obj end
		end
	end
end

----------------------------------------------------------------------
-- Feature 1: target name limit (wrap the [name] tag once).
----------------------------------------------------------------------
-- Width cap on the target name fontstring (frame.LeftText). This is the
-- 12.1-proof truncation: names can be SECRET in restricted content, and no
-- code may shorten a secret string by characters (length would leak) - but
-- clamping the FONTSTRING is pure rendering, so it clips any name with '...'
-- regardless of secrecy. 0 = off. Width 0 restores natural auto-sizing.
local function ApplyNameWidth()
	local cfg = GetNameCfg()
	local uf = GetUnitFrame('target')
	local fs = uf and uf.LeftText
	if not fs or not fs.SetWidth then return end
	local w = (cfg.enable and cfg.maxWidth) or 0
	if w > 0 then
		fs:SetWordWrap(false)
		fs:SetWidth(w)
	else
		fs:SetWidth(0)
	end
end
ns.EUIApplyNameWidth = ApplyNameWidth

local function RefreshEUITarget()
	ApplyNameWidth()
end
ns.EUIRefreshTarget = RefreshEUITarget

----------------------------------------------------------------------
-- Feature 2: raid marker on the focus cast bar.
-- The marker is parented to the castbar, so it is only visible while the bar is
-- (i.e. while the focus is casting). SetRaidTargetIconTexture only picks the
-- sprite-sheet cell, so the texture file is set on creation. The raid index is
-- SECRET in restricted content, so (like oUF) we never read or compare it: a
-- plain non-nil check drives show/hide and the index goes straight to the setter.
----------------------------------------------------------------------
local focusMark, markCastbar

local function UpdateFocusMark()
	local ff = GetUnitFrame('focus')
	local cb = ff and ff.Castbar
	if not cb then return end

	if not focusMark or markCastbar ~= cb then
		markCastbar = cb
		focusMark = cb:CreateTexture(nil, 'OVERLAY', nil, 7)
		focusMark:SetTexture(RAID_ICON_TEXTURE)
		focusMark:Hide()
	end

	local cfg = GetFocusMarkCfg()
	if not cfg.enable or not UnitExists('focus') then focusMark:Hide() return end

	local idx = GetRaidTargetIndex('focus')
	if idx then
		local sz = cfg.size or 20
		focusMark:SetSize(sz, sz)
		focusMark:ClearAllPoints()
		focusMark:SetPoint('RIGHT', cb, 'LEFT', (cfg.x or 2), cfg.y or 0)
		SetRaidTargetIconTexture(focusMark, idx)
		focusMark:Show()
	else
		focusMark:Hide()
	end
end
ns.EUIApplyFocusMark = UpdateFocusMark

local markFrame = CreateFrame('Frame')
markFrame:RegisterEvent('RAID_TARGET_UPDATE')
markFrame:RegisterEvent('PLAYER_FOCUS_CHANGED')
markFrame:RegisterEvent('PLAYER_ENTERING_WORLD')
markFrame:RegisterUnitEvent('UNIT_SPELLCAST_START', 'focus')
markFrame:RegisterUnitEvent('UNIT_SPELLCAST_CHANNEL_START', 'focus')
markFrame:SetScript('OnEvent', function()
	UpdateFocusMark()
	C_Timer.After(0, UpdateFocusMark)
end)

----------------------------------------------------------------------
-- Feature 2b: focus frame out-of-range fade. The focus is usually an
-- ENEMY, which sits outside UnitInRange's group-member domain - so range
-- is measured against a known spell instead, the same pattern as
-- EllesmereUI's own boss-frame dimmer.
--
-- For an attackable focus it asks "can I INTERRUPT this right now", since that
-- is what a focus is usually for; specs with no interrupt fall back to a
-- per-class harm-spell chain. Friendly focus uses the class baseline heal on
-- healer specs, with UnitInRange as the last resort.
-- IsSpellInRange can return a SECRET boolean in instances - it is only
-- ever fed to SetAlphaFromBoolean (sanctioned sink), never compared.
-- The 0.4s ticker exists ONLY while the feature is on and a focus exists.
----------------------------------------------------------------------
local FOCUS_RANGE_DEFAULTS = { enable = false, alpha = 0.5 }
local function GetFocusRangeCfg() return ns.ModuleCfg('euiFocusRange', FOCUS_RANGE_DEFAULTS) end
ns.EUIGetFocusRangeCfg = GetFocusRangeCfg

-- Range-check spells, chosen independently for this addon.
--
-- HOW THE PICK WORKS: the resolver below walks each class's list in order and
-- keeps the FIRST one the character actually knows, so the list is ordered
-- "most universally known first, spec-specific after". Listing extra
-- candidates is free - an unknown spell simply falls through - so each class
-- gets more than it strictly needs rather than exactly one fragile choice.
--
-- SELECTION RULE: prefer an ability every spec of the class has, with a range
-- band around 30-40yd. Melee-only abilities are useless here (they would report
-- "out of range" at 6yd) and 100yd utility like Hunter's Mark is equally
-- useless in the other direction.
--
-- NEVER LIST A SPELL WITH A MINIMUM RANGE. Charge (8-25yd) and Harpoon (8-30yd)
-- report OUT OF RANGE when you are standing on top of the target, which is the
-- exact opposite of what this reads as "far away" - a Warrior who fell through
-- to Charge saw the focus frame permanently dimmed while in melee. A gap-closer
-- answers "can I leap to it", not "is it near me", and those are different
-- questions.
--
-- When nothing in a class's list is known the caller falls through to full
-- alpha. Not fading is the correct failure: a frame stuck at 50% because the
-- range probe is wrong is worse than one that never fades at all.
--
-- Every spell the earlier version of this table used is still present, kept as
-- a trailing fallback. That makes this list a strict SUPERSET of what shipped
-- before: it cannot fail to find a spell in any case where the old one found
-- one, so the focus dimmer cannot regress.
-- INTERRUPT range, tried FIRST.
--
-- You focus something in order to kick it, so "can I interrupt this right now"
-- is the question the fade should be answering - more useful than "could I
-- reach it with some arbitrary 30yd ability".
--
-- Consequence worth knowing: melee interrupts are 5yd, so on a Rogue, Warrior,
-- Paladin or Monk the focus frame sits dim unless you are standing on the
-- target. That is accurate rather than broken - you genuinely cannot kick from
-- there - but it does mean dim becomes the resting state on those specs.
--
-- Specs with NO interrupt fall through to the harm list below: Discipline and
-- Holy Priest (Silence is Shadow-only), Restoration Druid (Skull Bash is
-- Feral/Guardian, Solar Beam is Balance), and Warlock, whose interrupt lives
-- on the pet and so is not in the player's own spellbook.
--
-- Spec selection is automatic: the resolver keeps the first entry the character
-- actually knows, so listing every spec's interrupt per class is enough.
local RANGE_INTERRUPT = {
	DEATHKNIGHT = { 47528 },          -- Mind Freeze, 15yd
	DEMONHUNTER = { 183752 },         -- Disrupt, 10yd, both specs
	DRUID       = { 78675, 106839 },  -- Solar Beam 40yd (Balance), Skull Bash 13yd (Feral/Guardian)
	EVOKER      = { 351338 },         -- Quell, 25yd
	HUNTER      = { 147362, 187707 }, -- Counter Shot 40yd (BM/MM), Muzzle 5yd (Survival)
	MAGE        = { 2139 },           -- Counterspell, 40yd
	MONK        = { 116705 },         -- Spear Hand Strike, 5yd
	PALADIN     = { 96231 },          -- Rebuke, 5yd
	PRIEST      = { 15487 },          -- Silence, 30yd, Shadow only
	ROGUE       = { 1766 },           -- Kick, 5yd
	SHAMAN      = { 57994 },          -- Wind Shear, 30yd
	WARRIOR     = { 6552 },           -- Pummel, 5yd
	-- WARLOCK omitted on purpose: Spell Lock and Axe Toss belong to the pet,
	-- not the player, so they are never in the player's spellbook and a lookup
	-- here would always miss. Warlocks use the harm list.
}

local RANGE_HARM = {
	DEATHKNIGHT = {
		45524,   -- Chains of Ice, 30yd, every spec
		49576,   -- Death Grip, 30yd
		47541,   -- Death Coil
	},
	DEMONHUNTER = {
		185245,  -- Torment, 30yd taunt, both specs
		204596,  -- Sigil of Flame, 30yd
		185123,  -- Throw Glaive (Havoc)
		204157,  -- Throw Glaive (Vengeance)
		183752,  -- Disrupt
		204021,  -- Fiery Brand
	},
	DRUID = {
		8921,    -- Moonfire, 40yd, every spec and every form
		339,     -- Entangling Roots
		6795,    -- Growl
		5176,    -- Wrath
	},
	EVOKER = {
		362969,  -- Azure Strike, 25yd
		361469,  -- Living Flame
	},
	HUNTER = {
		185358,  -- Arcane Shot, 40yd
		75,      -- Auto Shot
		193455,  -- Cobra Shot (BM)
		466930,  -- Black Arrow
		-- Harpoon (190925) deliberately NOT listed: 8-30yd minimum range, same
		-- trap as Charge. Unreachable behind Auto Shot anyway, but it should
		-- never be the answer if the list above it ever changes.
	},
	MAGE = {
		108853,  -- Fire Blast, 40yd, every spec
		116,     -- Frostbolt
		133,     -- Fireball
		30451,   -- Arcane Blast
		44425,   -- Arcane Barrage
		118,     -- Polymorph
	},
	MONK = {
		117952,  -- Crackling Jade Lightning, 40yd, every spec
		115546,  -- Provoke, 30yd
	},
	PALADIN = {
		20271,   -- Judgment, 30yd, every spec
		62124,   -- Hand of Reckoning
	},
	PRIEST = {
		585,     -- Smite, 40yd
		589,     -- Shadow Word: Pain
		8092,    -- Mind Blast
	},
	ROGUE = {
		2094,    -- Blind, 30yd, every spec
		36554,   -- Shadowstep
		185763,  -- Pistol Shot (Outlaw)
	},
	SHAMAN = {
		188389,  -- Flame Shock, 40yd, every spec
		188196,  -- Lightning Bolt
		370,     -- Purge
	},
	WARLOCK = {
		172,     -- Corruption, 40yd, every spec
		686,     -- Shadow Bolt
		5782,    -- Fear
		234153,  -- Drain Life
		232670,  -- Shadow Bolt (Destruction)
		348,     -- Immolate
	},
	-- Warrior is the awkward one: almost everything it has is melee, and the
	-- obvious ranged pick (Charge) has a minimum range - see the note above.
	WARRIOR = {
		57755,   -- Heroic Throw, 30yd, no minimum
		355,     -- Taunt, 30yd (Protection)
		64382,   -- Shattering Throw, 30yd
		107570,  -- Storm Bolt, 20yd
	},
}

-- Friendly-side check for healer specs: each class's cheap baseline heal, which
-- shares the 40yd band the harmful list targets.
local RANGE_HEAL = {
	DRUID   = 8936,    -- Regrowth
	EVOKER  = 361469,  -- Living Flame
	MONK    = 116670,  -- Vivify
	PALADIN = 19750,   -- Flash of Light
	PRIEST  = 2061,    -- Flash Heal
	SHAMAN  = 8004,    -- Healing Surge
}

local frHarm, frHelp
local function FRResolveSpells()
	frHarm, frHelp = nil, nil
	-- UnitClass can return a SECRET string in restricted content; indexing a
	-- table with one is not safe, so an unreadable class means "no spell" and
	-- the caller falls back to UnitInRange.
	local okC, _, pClass = pcall(_G.UnitClass, 'player')
	if not okC or (issecretvalue and issecretvalue(pClass)) then return end
	local CSB, bank = _G.C_SpellBook, _G.Enum and _G.Enum.SpellBookSpellBank
	local function Known(sid)
		if CSB and CSB.IsSpellInSpellBook and bank then
			return CSB.IsSpellInSpellBook(sid, bank.Player, true)
		end
		return _G.IsSpellKnown and _G.IsSpellKnown(sid)
	end
	-- Interrupt first, harm chain as the fallback for specs that have none.
	for _, sid in ipairs(RANGE_INTERRUPT[pClass] or {}) do
		if Known(sid) then frHarm = sid break end
	end
	if not frHarm then
		for _, sid in ipairs(RANGE_HARM[pClass] or {}) do
			if Known(sid) then frHarm = sid break end
		end
	end
	local spec = _G.GetSpecialization and _G.GetSpecialization()
	local role = spec and _G.GetSpecializationRole and _G.GetSpecializationRole(spec)
	if role == 'HEALER' then frHelp = RANGE_HEAL[pClass] end
end

local function FocusRangeTick()
	local ff = GetUnitFrame('focus')
	if not ff then return end
	local cfg = GetFocusRangeCfg()
	if not cfg.enable or not UnitExists('focus') then
		ff:SetAlpha(1)
		return
	end
	local oor = cfg.alpha or 0.5
	-- attackability decides which spell measures the range; a secret
	-- answer defaults to harm (a focus in keys is virtually always an enemy)
	local canAtk = _G.UnitCanAttack('player', 'focus')
	if issecretvalue and issecretvalue(canAtk) then canAtk = true end
	local spell = canAtk and frHarm or frHelp
	local CS = _G.C_Spell
	if spell and CS and CS.IsSpellInRange and ff.SetAlphaFromBoolean then
		-- secrecy check FIRST: the nil check must never touch a secret
		local inRange = CS.IsSpellInRange(spell, 'focus')
		if (issecretvalue and issecretvalue(inRange)) or inRange ~= nil then
			ff:SetAlphaFromBoolean(inRange, 1, oor)
		else
			ff:SetAlpha(1) -- momentarily not evaluable: never strand the fade
		end
	elseif not canAtk and _G.UnitInRange and ff.SetAlphaFromBoolean then
		-- friendly focus without a known heal: group-member range check
		local inR, checked = _G.UnitInRange('focus')
		-- BOTH returns must be secrecy-checked before either is touched. The
		-- old code guarded inR but then truthiness-tested `checked` directly,
		-- and UnitInRange can return a SECRET boolean there - which throws
		-- "attempt to perform boolean test on a secret value" on every tick.
		local secretIn = issecretvalue and issecretvalue(inR)
		local secretChk = issecretvalue and issecretvalue(checked)
		if not secretChk and not secretIn and checked then
			ff:SetAlphaFromBoolean(inR, 1, oor)
		else
			ff:SetAlpha(1)
		end
	else
		ff:SetAlpha(1)
	end
end

local frTicker
local function FRUpdateTicker()
	local cfg = GetFocusRangeCfg()
	local want = (cfg.enable and UnitExists('focus')) and true or false
	if want and not frTicker then
		frTicker = C_Timer.NewTicker(0.4, FocusRangeTick)
	elseif not want and frTicker then
		frTicker:Cancel()
		frTicker = nil
	end
	FocusRangeTick() -- immediate correction either way (also restores alpha 1)
end
ns.EUIApplyFocusRange = function()
	FRResolveSpells()
	FRUpdateTicker()
end

local frEvt = CreateFrame('Frame')
frEvt:RegisterEvent('PLAYER_FOCUS_CHANGED')
frEvt:RegisterEvent('PLAYER_ENTERING_WORLD')
frEvt:RegisterUnitEvent('PLAYER_SPECIALIZATION_CHANGED', 'player')
frEvt:SetScript('OnEvent', function(_, event)
	if event ~= 'PLAYER_FOCUS_CHANGED' then FRResolveSpells() end
	FRUpdateTicker()
end)

----------------------------------------------------------------------
-- Feature 3: dispel-type coloured 1px borders on the player DEBUFFS
-- (Blizzard's DebuffFrame, which EllesmereUI reskins above the player
-- frame). Colors come from Blizzard's own 12.1 palette via
-- AuraUtil.GetAuraBorderColor - Magic blue, Curse purple, Disease brown,
-- Poison green, Bleed / non-dispellable red - the exact table the
-- default UI uses. We recolor
-- EllesmereUI's existing 1px border edges when present, or draw our own.
----------------------------------------------------------------------
-- Read from the per-button debuff border path, which runs for every debuff on
-- every refresh - hence the hoisted defaults table.
local DEBUFF_COLOR_DEFAULTS = { enable = false, thick = false, aboveSwipe = false }
local function GetDebuffColorCfg()
	return ns.ModuleCfg('euiDebuffColors', DEBUFF_COLOR_DEFAULTS)
end
ns.EUIGetDebuffColorCfg = GetDebuffColorCfg

-- Which dispel type is this button showing? The button carries its dispel
-- name (BuffFrame.lua: buttonInfo.debuffType); secret-guard it, then fall
-- back to parsing the border atlas Blizzard's trusted code set on it.
-- Returns a dispel name, nil (= 'None', red), or false (unknown: skip).
local function DispelTypeOf(btn)
	local info = btn.buttonInfo
	local dt = type(info) == 'table' and info.debuffType or nil
	if not (issecretvalue and issecretvalue(dt)) then
		if dt ~= nil then return dt end
		if type(info) == 'table' then return nil end -- known button, no type
	end
	local border = btn.DebuffBorder
	local atlas = border and border.GetAtlas and border:GetAtlas()
	if atlas and not (issecretvalue and issecretvalue(atlas)) then
		local t = atlas:match('ui%-debuff%-border%-(%a+)%-')
		if t == 'default' then return nil end
		if t then return t:sub(1, 1):upper() .. t:sub(2) end
	end
	return false
end

-- Blizzard palette via AuraUtil; static fallback with the classic values.
local FALLBACK_COLORS = {
	Magic   = { 0.20, 0.60, 1.00 },
	Curse   = { 0.60, 0.00, 1.00 },
	Disease = { 0.60, 0.40, 0.00 },
	Poison  = { 0.00, 0.60, 0.00 },
	Bleed   = { 0.80, 0.00, 0.00 },
	None    = { 0.80, 0.00, 0.00 },
}
local function DispelColor(dt)
	local AU = _G.AuraUtil
	if AU and AU.GetAuraBorderColor then
		local ok, c = pcall(AU.GetAuraBorderColor, dt)
		if ok and c and c.GetRGB then return c:GetRGB() end
	end
	local f = FALLBACK_COLORS[dt] or FALLBACK_COLORS.None
	return f[1], f[2], f[3]
end

-- Our own 1px edges for when EllesmereUI's player-aura border is off.
local function OwnEdges(btn)
	local e = btn.__kiraDCEdges
	if e then return e end
	local anchor = btn.Icon or btn
	local function tex() return btn:CreateTexture(nil, 'OVERLAY', nil, 7) end
	e = { top = tex(), bottom = tex(), left = tex(), right = tex() }
	e.top:SetPoint('TOPLEFT', anchor, 'TOPLEFT', 0, 0)
	e.top:SetPoint('TOPRIGHT', anchor, 'TOPRIGHT', 0, 0)
	e.top:SetHeight(1)
	e.bottom:SetPoint('BOTTOMLEFT', anchor, 'BOTTOMLEFT', 0, 0)
	e.bottom:SetPoint('BOTTOMRIGHT', anchor, 'BOTTOMRIGHT', 0, 0)
	e.bottom:SetHeight(1)
	e.left:SetPoint('TOPLEFT', anchor, 'TOPLEFT', 0, 0)
	e.left:SetPoint('BOTTOMLEFT', anchor, 'BOTTOMLEFT', 0, 0)
	e.left:SetWidth(1)
	e.right:SetPoint('TOPRIGHT', anchor, 'TOPRIGHT', 0, 0)
	e.right:SetPoint('BOTTOMRIGHT', anchor, 'BOTTOMRIGHT', 0, 0)
	e.right:SetWidth(1)
	btn.__kiraDCEdges = e
	return e
end

local function ApplyDebuffColors()
	local cfg = GetDebuffColorCfg()
	local DF = _G.DebuffFrame
	if not (DF and DF.auraFrames) then return end
	for _, btn in pairs(DF.auraFrames) do
		if btn and btn.Icon and not btn.isAuraAnchor then
			if not cfg.enable then
				-- disabled: hide only OUR edges (EllesmereUI repaints its own)
				if btn.__kiraDCEdges then
					for _, t in pairs(btn.__kiraDCEdges) do t:Hide() end
				end
			else
				-- color only REAL dispel types; nil = non-dispellable, which
				-- keeps the static border (red is reserved for Bleed)
				local dt = DispelTypeOf(btn)
				if dt then
					local r, g, b = DispelColor(dt)
					-- prefer EllesmereUI's own border edges (exact geometry,
					-- no doubled lines); ours only when it has none
					local getFFD = _G.EllesmereUI and _G.EllesmereUI._GetFFD
					local ffd = getFFD and getFFD(btn)
					local edges = ffd and ffd._paEdges or OwnEdges(btn)
					for _, t in pairs(edges) do
						t:SetColorTexture(r, g, b, 1)
						t:Show()
					end
				end
			end
		end
	end
end
----------------------------------------------------------------------
-- EUI's OWN player unitframe debuff icons (frame.Debuffs, an oUF aura
-- element) - this is what actually sits above the player frame. The
-- bundled oUF has NATIVE engine-driven dispel coloring: with
-- element.showDebuffType set, the engine vertex-colors button.Overlay
-- through element.dispelColorCurve (legal even when the dispel type is
-- SECRET). We turn that on and restyle the chunky stock Overlay into a
-- 1px border: its texture is blanked (draws nothing) and its
-- SetVertexColor / Show / Hide calls are mirrored onto four edge lines.
----------------------------------------------------------------------
-- Engine color curve: Blizzard palette, with ALPHA 0 for everything that is
-- not a real dispel type - so plain non-dispellable debuffs get NO colored
-- border/badge and red is reserved for BLEED. Indices are Blizzard's dispel
-- enum: 0 none, 1 Magic, 2 Curse, 3 Disease, 4 Poison, 9 Enrage, 11 Bleed.
-- (Step curve: index 5 at alpha 0 also covers the unused 5-10 gap.)
local tbCurve
local function TypeBorderCurve()
	if tbCurve then return tbCurve end
	local CU, En, CC = _G.C_CurveUtil, _G.Enum, _G.CreateColor
	if not (CU and CU.CreateColorCurve and En and En.LuaCurveType and CC) then return nil end
	local c = CU.CreateColorCurve()
	c:SetType(En.LuaCurveType.Step)
	c:AddPoint(0,  CC(0.00, 0.00, 0.00, 0)) -- none: nothing
	c:AddPoint(1,  CC(0.20, 0.60, 1.00, 1)) -- Magic blue
	c:AddPoint(2,  CC(0.60, 0.00, 1.00, 1)) -- Curse purple
	c:AddPoint(3,  CC(0.60, 0.40, 0.00, 1)) -- Disease brown
	c:AddPoint(4,  CC(0.00, 0.60, 0.00, 1)) -- Poison green
	c:AddPoint(5,  CC(0.00, 0.00, 0.00, 0)) -- 5-10: nothing
	c:AddPoint(11, CC(0.80, 0.00, 0.00, 1)) -- Bleed red
	tbCurve = c
	return c
end

local function ConvertUFButton(btn)
	if btn.__kiraTBDone or not btn.Overlay then return end
	btn.__kiraTBDone = true
	local ov = btn.Overlay
	ov:SetTexture(nil) -- color carrier only; draws nothing itself
	-- edges/badge live on a HOLDER child frame raised above the Cooldown
	-- frame - textures created on the button itself would render UNDER the
	-- cooldown swipe (child frames always draw above parent textures)
	local holder = CreateFrame('Frame', nil, btn)
	holder:SetAllPoints(btn)
	holder:SetFrameLevel((btn.Cooldown and btn.Cooldown:GetFrameLevel() or btn:GetFrameLevel()) + 3)
	btn.__kiraTBHolder = holder
	local function tex()
		local t = holder:CreateTexture(nil, 'OVERLAY', nil, 7)
		t:SetColorTexture(1, 1, 1, 1)
		t:Hide()
		return t
	end
	local e = { top = tex(), bottom = tex(), left = tex(), right = tex() }
	e.top:SetPoint('TOPLEFT', btn, 'TOPLEFT', 0, 0)
	e.top:SetPoint('TOPRIGHT', btn, 'TOPRIGHT', 0, 0)
	e.top:SetHeight(1)
	e.bottom:SetPoint('BOTTOMLEFT', btn, 'BOTTOMLEFT', 0, 0)
	e.bottom:SetPoint('BOTTOMRIGHT', btn, 'BOTTOMRIGHT', 0, 0)
	e.bottom:SetHeight(1)
	e.left:SetPoint('TOPLEFT', btn, 'TOPLEFT', 0, 0)
	e.left:SetPoint('BOTTOMLEFT', btn, 'BOTTOMLEFT', 0, 0)
	e.left:SetWidth(1)
	e.right:SetPoint('TOPRIGHT', btn, 'TOPRIGHT', 0, 0)
	e.right:SetPoint('BOTTOMRIGHT', btn, 'BOTTOMRIGHT', 0, 0)
	e.right:SetWidth(1)
	btn.__kiraTBEdges = e

	-- corner TYPE BADGE: a small dot on a 1px black ring (top-right), colored
	-- by the same engine curve - green poison, red bleed, blue magic, etc.
	-- Circular via mask, like the Expel Harm orbs.
	local function circle(size, sub)
		local t = holder:CreateTexture(nil, 'OVERLAY', nil, sub)
		t:SetTexture('Interface\\Buttons\\WHITE8x8')
		local m = holder:CreateMaskTexture()
		m:SetTexture('Interface\\Masks\\CircleMaskScalable',
			'CLAMPTOBLACKADDITIVE', 'CLAMPTOBLACKADDITIVE')
		m:SetAllPoints(t)
		t:AddMaskTexture(m)
		t:SetSize(size, size)
		t:Hide()
		return t
	end
	local ring = circle(9, 6)
	ring:SetPoint('CENTER', btn, 'TOPRIGHT', -2, -2)
	ring:SetVertexColor(0, 0, 0, 1)
	local dot = circle(7, 7)
	dot:SetPoint('CENTER', ring, 'CENTER', 0, 0)
	btn.__kiraTBBadge = { ring = ring, dot = dot }

	hooksecurefunc(ov, 'SetVertexColor', function(_, r, g, b, a)
		-- components (incl. the gating ALPHA from the curve) may be SECRET;
		-- SetVertexColor forwards them natively, nothing reads or branches
		e.top:SetVertexColor(r, g, b, a)
		e.bottom:SetVertexColor(r, g, b, a)
		e.left:SetVertexColor(r, g, b, a)
		e.right:SetVertexColor(r, g, b, a)
		dot:SetVertexColor(r, g, b, a)
		ring:SetVertexColor(0, 0, 0, a) -- ring follows the same gate
	end)
	hooksecurefunc(ov, 'Show', function()
		e.top:Show() e.bottom:Show() e.left:Show() e.right:Show()
		if GetDebuffColorCfg().badge then ring:Show() dot:Show() end
	end)
	hooksecurefunc(ov, 'Hide', function()
		e.top:Hide() e.bottom:Hide() e.left:Hide() e.right:Hide()
		ring:Hide() dot:Hide()
	end)
end

local function PlayerDebuffsElement()
	local pf = GetUnitFrame('player')
	local el = pf and pf.Debuffs
	if el then return el end
	-- fallback: any oUF object claiming the player unit that has the element
	if EUF.objects then
		for _, obj in ipairs(EUF.objects) do
			if obj.unit == 'player' and obj.Debuffs then return obj.Debuffs end
		end
	end
end

local function ApplyUnitframeDebuffColors()
	local cfg = GetDebuffColorCfg()
	local el = PlayerDebuffsElement()
	if not el then return end
	el.showDebuffType = (cfg.enable and true) or nil
	-- our curve gates non-dispellable debuffs to alpha 0 (no border, no
	-- badge) - overrides the oUF default where 'none' would get a color
	local curve = TypeBorderCurve()
	if curve then el.dispelColorCurve = curve end
	for i = 1, #el do
		local b = el[i]
		if b then ConvertUFButton(b) end
	end
	-- future buttons: wrap EUI's PostCreateButton (plain table field)
	if not el.__kiraTBPostHook then
		el.__kiraTBPostHook = true
		local orig = el.PostCreateButton
		el.PostCreateButton = function(self, button, ...)
			if orig then orig(self, button, ...) end
			ConvertUFButton(button)
		end
	end
	if el.ForceUpdate then pcall(el.ForceUpdate, el) end
	for i = 1, #el do
		local b = el[i]
		if b then
			if not cfg.enable and b.__kiraTBEdges then
				for _, t in pairs(b.__kiraTBEdges) do t:Hide() end
			end
			if (not cfg.enable or not cfg.badge) and b.__kiraTBBadge then
				b.__kiraTBBadge.ring:Hide()
				b.__kiraTBBadge.dot:Hide()
			end
		end
	end
end

----------------------------------------------------------------------
-- CONTAINER PATH: this EllesmereUI build renders the player debuff row
-- with a 12.1 engine AuraContainer parented to the player frame (found
-- via /kiratb fingerprint - buttons expose SetAuraBorder/SetIcon/etc).
-- CustomAuraButton:SetAuraBorder(texture, { style = Color }) makes the
-- ENGINE color the texture by dispel type (Blizzard palette) and show it
-- ONLY for real dispel types: non-dispellable debuffs get nothing, red
-- means Bleed. Zero aura data touches Lua. The badge dot piggybacks on
-- the border texture's engine-driven color/visibility calls.
----------------------------------------------------------------------
-- Square 4px-edge border baked into our own TGA; the badge variant has the
-- type dot baked into the top-right corner of the SAME texture, so the
-- engine colors and shows border + badge as one piece. (Runtime hooks on
-- the border cannot work: the engine writes through a protected wrapper.)
local KV_BORDER = 'Interface\\AddOns\\KiraUI-Plugin\\Media\\KiraSquareBorder.tga'
local KV_BORDER_THICK = 'Interface\\AddOns\\KiraUI-Plugin\\Media\\KiraSquareBorderThick.tga' -- 8px edges (2x)
local KV_BORDER_BADGE = 'Interface\\AddOns\\KiraUI-Plugin\\Media\\KiraSquareBorderBadge.tga'

-- 12.1 REALITY: SetAuraBorder/SetIcon wiring is only accepted INSIDE the
-- engine's initializeFrame callback (docs/02 working rules, "Button wiring").
-- EllesmereUI pre-creates each group's 10-button engine batch the moment it
-- declares the group, so any hook installed afterwards (applyExtra wrap +
-- Restyle) reaches every button OUTSIDE initializeFrame and the registration
-- is rejected. On top of that, EUI REPLACES AK.styles[key] wholesale on every
-- build/reload pass, silently wiping table-level hooks. The one stable
-- interception point is AK.MakeInitializer: it is consulted fresh at every
-- AddAuraGroup, and the initializer it returns IS the engine's
-- initializeFrame callback. We wrap it once (this addon loads after the
-- EllesmereUI suite but before its post-login container builds); for the
-- player-debuff style we run EUI's initializer, then build + register our
-- border texture right there - inside initializeFrame, where creating the
-- texture directly ON the button and registering it is the documented-legal
-- path.
local KIRA_STYLE_KEYS = { ['uf:player:HARMFUL'] = true }

local function GetAuraKit()
	local E = _G.EllesmereUI
	return E and E.AuraKit
end

-- Buttons we registered a border on, for live option pushes. Weak keys:
-- the engine owns button lifetime.
local kiraTBButtons = setmetatable({}, { __mode = 'k' })

-- Reflect the CURRENT config onto one registered button. Registration is
-- init-time-only, so enable/disable is gated by region alpha instead: the
-- engine only ever SetShown()s and SetVertexColor()s the texture
-- (AuraUtil.SetAuraBorderColor), so region alpha stays ours. Thickness =
-- which TGA file; we own the texture, so swapping the file re-skins live.

-- Find the button's cooldown region (the thing drawing the swipe). Engine aura
-- buttons expose it as .Cooldown; fall back to a child scan for odd layouts.
local function FindCooldown(button)
	local cd = button.Cooldown
	if type(cd) == 'table' and cd.GetFrameLevel then return cd end
	local ok, n = pcall(function() return select('#', button:GetChildren()) end)
	if not ok then return nil end
	for i = 1, n do
		local child = select(i, button:GetChildren())
		if child and child.GetObjectType and child:GetObjectType() == 'Cooldown' then
			return child
		end
	end
	return nil
end

-- "Border ignores cooldown swipe". A child frame always draws above its
-- parent's regions, so the Cooldown swipe darkens our border no matter which
-- OVERLAY sublevel the texture sits on. The only way to get the border on top
-- is to move it onto a frame that outranks the Cooldown.
--
-- We do NOT re-create the texture: SetAuraBorder registration is only accepted
-- inside initializeFrame (see the note above), so the engine's reference must
-- survive. Re-parenting a region keeps that reference intact - the engine goes
-- on calling SetShown/SetVertexColor on the same object - it only changes where
-- it draws. Anchors are re-set explicitly because SetParent drops points that
-- referenced the old parent.
local function BorderHost(button)
	local host = button.__kiraCBHost
	if not host then
		host = CreateFrame('Frame', nil, button)
		host:SetAllPoints(button)
		button.__kiraCBHost = host
	end
	-- re-sync every push: the engine can relevel the cooldown on a relayout,
	-- and a stale host level would put the border back under the swipe
	local cd = FindCooldown(button)
	local base = (cd and cd.GetFrameLevel and cd:GetFrameLevel()) or button:GetFrameLevel()
	if host:GetFrameLevel() ~= base + 1 then host:SetFrameLevel(base + 1) end
	return host
end

local function PushBorderCfg(button)
	local tex = button.__kiraCBBorder
	if not tex then return end
	local cfg = GetDebuffColorCfg()
	tex:SetTexture((cfg.thick and KV_BORDER_THICK) or KV_BORDER)
	tex:SetAlpha(cfg.enable and 1 or 0)

	-- park the border above or below the swipe to match the option
	local want = cfg.aboveSwipe and true or false
	-- BorderHost also re-syncs its frame level against the cooldown, so call it
	-- on every push while the option is on, not just on the transition
	local parent = want and BorderHost(button) or button
	if want ~= (button.__kiraCBAbove or false) then
		local ok = pcall(function()
			tex:SetParent(parent)
			tex:SetDrawLayer('OVERLAY', 7)
			tex:ClearAllPoints()
			tex:SetAllPoints(button)
		end)
		if ok then
			button.__kiraCBAbove = want
			button.__kiraCBAboveFailed = nil
		elseif want then
			-- engine refused the re-parent: leave it where it was and remember
			-- so /kiratb can report it rather than retrying silently
			button.__kiraCBAboveFailed = true
		end
	end
end

-- Runs INSIDE the engine's initializeFrame for each new player-debuff
-- button: build our square-edge TGA on the button and register it. The
-- ENGINE then colors it by (secret) dispel type and shows it only for real
-- dispel types (nothing for non-dispellable, red = Bleed); zero aura data
-- touches Lua. Always registers, even while the option is off - late
-- registration is impossible, so an off-state button keeps an alpha-0
-- border ready to light up.
local function RegisterKiraBorder(button)
	if not button.SetAuraBorder or button.__kiraCBActive then return end
	local tex = button.__kiraCBBorder
	if not tex then
		tex = button:CreateTexture(nil, 'OVERLAY', nil, 7)
		button.__kiraCBBorder = tex
	end
	tex:SetAllPoints(button)
	local ABS = _G.AuraButtonBorderStyle
	local ok, err = pcall(button.SetAuraBorder, button, tex, {
		style = (ABS and ABS.Color) or 1,
		showIcon = false,
		showWhenHarmful = true,
		showWhenHelpful = false,
	})
	button.__kiraCBActive = ok or nil
	if not ok then _G.__kiraTBLastErr = tostring(err) end
	kiraTBButtons[button] = true
	PushBorderCfg(button)
end

-- Wrap AK.MakeInitializer ONCE. Pass-through for every style but ours; a
-- failure in our half must never abort EUI's init (an error inside the
-- engine's batch kills the whole AddAuraGroup call), hence the pcall.
local function InstallInitializerHook()
	local AK = GetAuraKit()
	if not AK or not AK.MakeInitializer then return false end
	if AK.__kiraTBWrapped then return true end
	local orig = AK.MakeInitializer
	AK.MakeInitializer = function(styleKey, extra)
		local init = orig(styleKey, extra)
		if not KIRA_STYLE_KEYS[styleKey] then return init end
		return function(button)
			init(button)
			pcall(RegisterKiraBorder, button)
		end
	end
	AK.__kiraTBWrapped = true
	return true
end
InstallInitializerHook()

-- Called on option changes: push enable/thickness onto every live border.
local function ApplyContainerDebuffColors()
	InstallInitializerHook() -- no-op once wrapped
	for button in pairs(kiraTBButtons) do pcall(PushBorderCfg, button) end
end

-- Safety net: if AuraKit was not up at file load, keep trying until the
-- wrapper is in (then stop - registration happens per-button at init).
C_Timer.NewTicker(2, function(t)
	if InstallInitializerHook() then t:Cancel() end
end)

----------------------------------------------------------------------
-- Debuff-border PREVIEW: five fake debuff icons (? icon) wearing the same
-- Blizzard per-type border atlases the engine puts on the real player
-- debuffs - Magic, Curse, Disease, Poison, Bleed - honoring the Border
-- size setting. Real debuffs can't be faked (the engine only borders live
-- auras), so these are our own frames, anchored one row above where the
-- live debuff row sits so both can be compared side by side.
----------------------------------------------------------------------
local TB_PREVIEW_TYPES = {
	{ 'Magic',   'ui-debuff-border-magic-noicon' },
	{ 'Curse',   'ui-debuff-border-curse-noicon' },
	{ 'Disease', 'ui-debuff-border-disease-noicon' },
	{ 'Poison',  'ui-debuff-border-poison-noicon' },
	{ 'Bleed',   'ui-debuff-border-bleed-noicon' },
}
local TB_PREVIEW_ICON = 134400 -- question mark
local tbPreview
local tbPreviewActive = false

-- EllesmereUI player-frame debuff settings, read straight from EUI's saved
-- profile so the preview mirrors the LIVE row instead of guessing. All EUI
-- unitframe data lives in the central EllesmereUIDB under the active profile's
-- EllesmereUIUnitFrames addon table (see EllesmereUI_Lite.NewDB) - the SAME
-- table the container layout (EUI_UnitFrames_AuraContainers) reads from.
local function EUIPlayerAuraSettings()
	local root = _G.EllesmereUIDB
	local profiles = root and root.profiles
	if not profiles then return nil end
	local pdata = profiles[root.activeProfile or 'Default']
	local addons = pdata and pdata.addons
	local uf = addons and addons.EllesmereUIUnitFrames
	return uf and uf.player
end

-- Port of EllesmereUI's ResolveLayout tables (EUI_UnitFrames_AuraContainers):
-- maps debuffAnchor + debuffGrowth to the same internal anchor corner and
-- row/wrap growth tokens the live container flows its icons along.
local EUI_ANCHOR_IA = {
	topleft = 'BOTTOMLEFT', topright = 'BOTTOMRIGHT',
	bottomleft = 'TOPLEFT', bottomright = 'TOPRIGHT',
	left = 'RIGHT', right = 'LEFT',
}
-- frame point + base offset the container is pinned to (EUI's ANCHOR_FP): the
-- row's ia corner sits at this point on the player frame, with a 1px gap.
local EUI_ANCHOR_FP = {
	topleft = { 'TOPLEFT', 0, 1 }, topright = { 'TOPRIGHT', 0, 1 },
	bottomleft = { 'BOTTOMLEFT', 0, -1 }, bottomright = { 'BOTTOMRIGHT', 0, -1 },
	left = { 'LEFT', -1, 0 }, right = { 'RIGHT', 1, 0 },
}
local EUI_AUTO_GROWTH = {
	topleft = { 'RIGHT', 'UP' }, topright = { 'LEFT', 'UP' },
	bottomleft = { 'RIGHT', 'DOWN' }, bottomright = { 'LEFT', 'DOWN' },
	left = { 'LEFT', 'DOWN' }, right = { 'RIGHT', 'DOWN' },
}
local EUI_EXPLICIT_GROWTH = {
	right = { 'RIGHT', 'UP' }, left = { 'LEFT', 'UP' },
	up = { 'RIGHT', 'UP' }, down = { 'RIGHT', 'DOWN' },
}
-- returns ia (row anchor corner), fp point + base ox/oy on the frame, gX/gY growth
local function EUIResolveDebuffLayout(anchor, growth)
	if not anchor or anchor == 'none' then anchor = 'topright' end
	local ia = EUI_ANCHOR_IA[anchor] or 'BOTTOMLEFT'
	local fp = EUI_ANCHOR_FP[anchor] or EUI_ANCHOR_FP.topleft
	local g
	if growth and growth ~= 'auto' then g = EUI_EXPLICIT_GROWTH[growth] end
	g = g or EUI_AUTO_GROWTH[anchor] or EUI_AUTO_GROWTH.topleft
	return ia, fp[1], fp[2], fp[3], g[1], g[2]
end

-- where the live player debuff row lives: the engine AuraContainer this EUI
-- build parents to the player frame, else the oUF Debuffs element, else the
-- player frame itself (nil = screen center fallback)
local function PlayerDebuffAnchor()
	local pf = GetUnitFrame('player')
	if pf then
		for _, c in ipairs({ pf:GetChildren() }) do
			if c.AddAuraSlot and c.GetUnit and c.UpdateAllAuras then return c end
		end
	end
	return PlayerDebuffsElement() or pf
end

-- Live debuff geometry pulled from EllesmereUI's own player settings (the same
-- keys the container layout reads): icon size, crop height, X/Y spacing, anchor,
-- growth, per-row cap and the row's offsets. Falls back to a real button's width
-- then 32 so the preview still renders if the profile can't be read.
local function LiveDebuffMetrics(anchor)
	local s = EUIPlayerAuraSettings()
	local w = s and s.debuffSize
	if not (type(w) == 'number' and w > 4) then
		w = nil
		if anchor and anchor.GetChildren then
			for _, btn in ipairs({ anchor:GetChildren() }) do
				local ok, bw = pcall(btn.GetWidth, btn)
				if ok and type(bw) == 'number' and bw > 4 then w = bw break end
			end
		end
	end
	w = math.floor((w or 32) + 0.5)
	-- EUI crops debuff icon height to 80% when 'Crop Icons' is on (AURA_CROP_HEIGHT)
	local h = w
	if s and s.debuffCropIcons then h = math.floor(w * 0.80 + 0.5) end
	return w, h,
		(s and s.debuffSpacingX) or 1,
		(s and s.debuffSpacingY) or 1,
		(s and s.debuffAnchor) or 'topleft',
		(s and s.debuffGrowth) or 'auto',
		s and s.debuffMaxPerRow,
		(s and s.debuffOffsetX) or 0,
		(s and s.debuffOffsetY) or 0
end

local function EnsureTBPreview()
	if tbPreview then return tbPreview end
	local f = CreateFrame('Frame', 'KiraUITBPreview', _G.UIParent)
	f:SetFrameStrata('MEDIUM')
	f:SetSize(1, 1)
	f:Hide()
	local buttons = {}
	for i, info in ipairs(TB_PREVIEW_TYPES) do
		local b = CreateFrame('Frame', nil, f)
		b.Icon = b:CreateTexture(nil, 'ARTWORK')
		b.Icon:SetAllPoints()
		b.Icon:SetTexture(TB_PREVIEW_ICON)
		b.Icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
		b.border = b:CreateTexture(nil, 'OVERLAY', nil, 7)
		b.label = b:CreateFontString(nil, 'OVERLAY')
		b.label:SetFont(ns.GetFont(), 10, 'OUTLINE')
		b.label:SetPoint('TOP', b, 'BOTTOM', 0, -3)
		b.label:SetText(info[1])
		local r, g, bl = DispelColor(info[1])
		b.label:SetTextColor(r, g, bl)
		buttons[i] = b
	end
	tbPreview = { frame = f, buttons = buttons }
	return tbPreview
end

local function StyleTBPreview()
	if not (tbPreview and tbPreviewActive) then return end
	local cfg = GetDebuffColorCfg()
	local liveAnchor = PlayerDebuffAnchor()          -- live buttons -> size fallback
	local pf = GetUnitFrame('player')                -- the frame EUI pins the row to
	-- everything the live row uses, straight from EllesmereUI's profile
	local w, h, spX, spY, anchorSet, growth, maxPerRow, offX, offY = LiveDebuffMetrics(liveAnchor)
	local ia, fpPoint, ox, oy, gX, gY = EUIResolveDebuffLayout(anchorSet, growth)

	local n = #tbPreview.buttons
	-- EUI's ResolveColumns: explicit per-row cap wins, vertical growth = 1 col
	local cols = n
	if maxPerRow and maxPerRow >= 1 and maxPerRow < n then
		cols = maxPerRow
	elseif growth == 'up' or growth == 'down' then
		cols = 1
	end
	local rows = math.ceil(n / cols)

	-- per-step deltas along EUI's growth axes (row = gX, wrap = gY)
	local dx = (gX == 'LEFT') and -(w + spX) or (w + spX)
	local dy = (gY == 'DOWN') and -(h + spY) or (h + spY)

	local fW = cols * w + (cols - 1) * spX
	local fH = rows * h + (rows - 1) * spY

	-- castbar push: EUI shifts bottom/side-anchored rows clear of the cast bar
	local s = EUIPlayerAuraSettings()
	local cbOff = 0
	if s and s.showPlayerCastbar
		and (anchorSet == 'bottomleft' or anchorSet == 'bottomright'
			or anchorSet == 'left' or anchorSet == 'right') then
		local cbH = s.playerCastbarHeight
		if not cbH or cbH <= 0 then cbH = 14 end
		cbOff = -cbH
	end

	local f = tbPreview.frame
	f:SetSize(fW, fH)
	f:ClearAllPoints()
	if pf then
		-- EXACTLY where EllesmereUI pins the live debuff row: the preview frame's
		-- ia corner sits at the frame's fp point, honoring the row's base gap and
		-- the user's own X/Y offsets (offset 0 = flush on top of the frame)
		f:SetPoint(ia, pf, fpPoint, ox + offX, oy + cbOff + offY)
	else
		f:SetPoint('CENTER', _G.UIParent, 'CENTER', 0, 120)
	end

	for i, b in ipairs(tbPreview.buttons) do
		local idx = i - 1
		local col = idx % cols
		local row = math.floor(idx / cols)
		b:SetSize(w, h)
		b:ClearAllPoints()
		-- flow from the same internal-anchor corner the live container uses
		b:SetPoint(ia, f, ia, col * dx, row * dy)
		-- same geometry and art as the live path: our square TGA flush with
		-- the icon, tinted with the type's color (the engine does the same
		-- tinting on the real buttons via style = Color)
		b.border:ClearAllPoints()
		b.border:SetPoint('TOPLEFT', b, 'TOPLEFT', 0, 0)
		b.border:SetPoint('BOTTOMRIGHT', b, 'BOTTOMRIGHT', 0, 0)
		b.border:SetTexture(cfg.thick and KV_BORDER_THICK or KV_BORDER)
		local r, g, bl = DispelColor(TB_PREVIEW_TYPES[i][1])
		b.border:SetVertexColor(r, g, bl, 1)
		b.border:Show()
		b:Show()
	end
end

ns.EUITBIsPreview = function() return tbPreviewActive end
ns.EUITBSetPreview = function(state)
	tbPreviewActive = state and true or false
	if tbPreviewActive then
		EnsureTBPreview()
		StyleTBPreview()
		tbPreview.frame:Show()
	elseif tbPreview then
		tbPreview.frame:Hide()
	end
end

ns.EUIApplyDebuffColors = function()
	ApplyDebuffColors()           -- Blizzard DebuffFrame (EUI 'Player Auras' skin)
	ApplyUnitframeDebuffColors()  -- oUF Debuffs element (older EUI builds)
	ApplyContainerDebuffColors()  -- engine AuraContainer row (this EUI build)
	StyleTBPreview()              -- keep the preview in step with the sliders
	-- start/stop the 0.3s repaint poll to match the new enable state
	if ns.EUISyncDebuffColorTicker then ns.EUISyncDebuffColorTicker() end
end


-- Diagnostic: /kiratb - reports each stage of the unitframe border chain so a
-- failure points at exactly one link.
SLASH_KIRATB1 = '/kiratb'
SlashCmdList.KIRATB = function()
	local function p(...) print('|cffff7d0aKiraTB|r', ...) end
	local cfg = GetDebuffColorCfg()
	p('enabled:', cfg.enable and 'yes' or 'NO', '| badge:', cfg.badge and 'yes' or 'no')
	-- "Border ignores cooldown swipe": report how many live borders actually
	-- made it onto the above-swipe host, so a silent refusal is visible
	do
		local want, moved, failed, total = cfg.aboveSwipe and true or false, 0, 0, 0
		for b in pairs(kiraTBButtons) do
			total = total + 1
			if b.__kiraCBAbove then moved = moved + 1 end
			if b.__kiraCBAboveFailed then failed = failed + 1 end
		end
		p(('above swipe: setting=%s | borders moved=%d/%d | refused=%d')
			:format(want and 'on' or 'off', moved, total, failed))
		if want and failed > 0 then
			p('|cffff5555some borders could not be re-parented - turn the option off to restore the default look.|r')
		end
	end
	local pf = _G.EllesmereUIUnitFrames_Player
	if not pf then p('player frame: MISSING (EllesmereUIUnitFrames_Player)') return end
	-- CONTAINER PATH report: recursively count SetAuraBorder-capable buttons
	-- under the player frame and how many we've converted. If rows=0 the aura
	-- row moved/changed and detection needs the fingerprint below.
	do
		local rows, btns, conv, forb, maxd = 0, 0, 0, 0, 0
		local function walk(f, d)
			if d > 6 or not f then return end
			if d > maxd then maxd = d end
			local ok, kids = pcall(function() return { f:GetChildren() } end)
			if not ok then return end
			for _, c in ipairs(kids) do
				if c.IsForbidden and c:IsForbidden() then
					forb = forb + 1
				else
					local ok2, ck = pcall(function() return { c:GetChildren() } end)
					if ok2 and ck[1] and ck[1].SetAuraBorder then
						rows = rows + 1
						for _, b in ipairs(ck) do
							if b.SetAuraBorder then
								btns = btns + 1
								if b.__kiraCBActive then conv = conv + 1 end
							end
						end
					end
					walk(c, d + 1)
				end
			end
		end
		walk(pf, 0)
		p(('container path: rows=%d borderButtons=%d converted=%d forbiddenSkipped=%d depth<=%d')
			:format(rows, btns, conv, forb, maxd))
		local AKh = GetAuraKit()
		p('initializer hook:', (AKh and AKh.__kiraTBWrapped) and 'wrapped' or 'NOT WRAPPED')
		local reg = 0
		for _ in pairs(kiraTBButtons) do reg = reg + 1 end
		p('registered borders:', reg)
		if _G.__kiraTBLastErr then p('SetAuraBorder error:', _G.__kiraTBLastErr) end
		local AK = _G.EllesmereUI and _G.EllesmereUI.AuraKit
		if AK and AK.styles then
			local keys = {}
			for k in pairs(AK.styles) do keys[#keys + 1] = tostring(k) end
			table.sort(keys)
			p('AuraKit styles:', table.concat(keys, ', '))
		else
			p('AuraKit: not reachable (EllesmereUI.AuraKit.styles nil)')
		end
	end
	local el = pf.Debuffs
	if not el then
		p('player frame found, Debuffs element: MISSING - scanning for candidates...')
		-- every EUI unit object claiming to be the player
		if EUF and EUF.objects then
			for i, obj in ipairs(EUF.objects) do
				if obj.unit == 'player' then
					p(('  oUF object %d: %s | Debuffs: %s | Buffs: %s'):format(
						i, obj.GetDebugName and obj:GetDebugName() or '?',
						obj.Debuffs and ('yes (' .. #obj.Debuffs .. ' buttons)') or 'no',
						obj.Buffs and ('yes (' .. #obj.Buffs .. ' buttons)') or 'no'))
					if obj.Debuffs then el = obj.Debuffs end
				end
			end
		end
		-- aura-element-shaped keys on the player frame itself
		for k, v in pairs(pf) do
			if type(v) == 'table' and type(k) == 'string' and v.PostCreateButton ~= nil then
				p('  player frame field with PostCreateButton:', k)
			end
		end
		if not el then
			-- fingerprint the player frame's anonymous children: their table
			-- keys reveal which module owns the icon row above the frame
			p('fingerprinting player frame children (shown only):')
			local kids = { pf:GetChildren() }
			for i = 1, math.min(#kids, 15) do
				local c = kids[i]
				if c and c:IsShown() then
					local keys = {}
					for k in pairs(c) do
						if type(k) == 'string' then keys[#keys + 1] = k end
					end
					table.sort(keys)
					local okW, w = pcall(c.GetWidth, c)
					local okH, h = pcall(c.GetHeight, c)
					local dims = (okW and okH and not (issecretvalue and (issecretvalue(w) or issecretvalue(h))))
						and (math.floor((w or 0) + 0.5) .. 'x' .. math.floor((h or 0) + 0.5)) or '?x?'
					local nkids = select('#', c:GetChildren())
					p(('  child %d: %s %s subframes=%d'):format(i, c:GetObjectType(), dims, nkids))
					if #keys > 0 then p('    keys: ' .. table.concat(keys, ', ')) end
					-- one level deeper: first subframe's keys (the icon buttons)
					local g = select(1, c:GetChildren())
					if g then
						local gk = {}
						for k in pairs(g) do
							if type(k) == 'string' then gk[#gk + 1] = k end
						end
						table.sort(gk)
						if #gk > 0 then p('    first subframe keys: ' .. table.concat(gk, ', ')) end
					end
				end
			end
			p('=> screenshot/paste ALL of this to Claude')
			return
		end
		p('=> found a Debuffs element on an oUF object - retrying attach through it')
	end
	p('Debuffs element: found | buttons created:', #el,
		'| showDebuffType:', tostring(el.showDebuffType),
		'| curve set:', el.dispelColorCurve ~= nil and 'yes' or 'NO',
		'| post-hook:', el.__kiraTBPostHook and 'yes' or 'NO')
	local shown = 0
	for i = 1, #el do
		local b = el[i]
		if b and b:IsShown() then
			shown = shown + 1
			local conv = b.__kiraTBDone and 'converted' or 'NOT converted'
			local ovShown = b.Overlay and (b.Overlay:IsShown() and 'shown' or 'hidden') or 'NO Overlay'
			local edge = b.__kiraTBEdges and (b.__kiraTBEdges.top:IsShown() and 'shown' or 'hidden') or 'none'
			p(('  button %d: %s | Overlay: %s | edges: %s'):format(i, conv, ovShown, edge))
		end
	end
	p('buttons currently shown:', shown)
	if shown == 0 then p('=> no debuff buttons visible right now - get a debuff on you and rerun') end
end

local dcPending
local function QueueDebuffColors()
	if dcPending then return end
	dcPending = true
	C_Timer.After(0.05, function() dcPending = nil ApplyDebuffColors() end)
end

local dcEvt = CreateFrame('Frame')
dcEvt:RegisterUnitEvent('UNIT_AURA', 'player')
dcEvt:RegisterEvent('PLAYER_ENTERING_WORLD')
dcEvt:SetScript('OnEvent', function(_, event)
	QueueDebuffColors()
	if event == 'PLAYER_ENTERING_WORLD' then
		-- EllesmereUI spawns its unitframes around login, AFTER our file-load
		-- timer - retry until the player frame's aura displays exist.
		-- Attaching is one-time; the engine/oUF drive all updates after.
		local function attach()
			ApplyUnitframeDebuffColors()
			ApplyContainerDebuffColors()
		end
		C_Timer.After(1, attach)
		C_Timer.After(3, attach)
		C_Timer.After(7, attach)
	end
end)

-- EllesmereUI's own skin pass repaints its static border color after every
-- grid update; this hook is registered LATE (after EUI's, login +2s) so our
-- recolor always lands after its repaint. The slow ticker is the safety net.
C_Timer.After(2, function()
	local DF = _G.DebuffFrame
	if DF and DF.AuraContainer and DF.AuraContainer.UpdateGridLayout then
		hooksecurefunc(DF.AuraContainer, 'UpdateGridLayout', function()
			C_Timer.After(0, ApplyDebuffColors)
		end)
	end
	ApplyDebuffColors()
	ApplyUnitframeDebuffColors() -- EUI player frame is built by now
end)
-- Repaint poll for the Blizzard debuff frame. Only runs while the debuff-colour
-- feature is switched on; ns.EUIApplyDebuffColors starts and stops it.
local dcTicker
local function SyncDebuffColorTicker()
	local want = GetDebuffColorCfg().enable and true or false
	if want and not dcTicker then
		dcTicker = C_Timer.NewTicker(0.3, function()
			local DF = _G.DebuffFrame
			if DF and DF:IsShown() then ApplyDebuffColors() end
		end)
	elseif not want and dcTicker then
		dcTicker:Cancel()
		dcTicker = nil
	end
end
ns.EUISyncDebuffColorTicker = SyncDebuffColorTicker
SyncDebuffColorTicker()

----------------------------------------------------------------------
-- Initial / zone-change application (after EllesmereUI has built its frames).
----------------------------------------------------------------------
local loginFrame = CreateFrame('Frame')
loginFrame:RegisterEvent('PLAYER_ENTERING_WORLD')
loginFrame:SetScript('OnEvent', function()
	C_Timer.After(1, UpdateFocusMark)
	C_Timer.After(1, RefreshEUITarget)
end)
