-- KiraUI-Plugin_FieryBrand.lua
-- Vengeance DH "Fiery Brand phases" - ONE icon spot, TWO engine-driven layers.
--
-- The problem this solves: Blizzard CDM tracks Fiery Brand as a single entry
-- and silently switches from YOUR buff (short, the part that matters for
-- damage reduction) to the TARGET debuff (longer) with the same icon, so you
-- can't see the moment your personal DR window ends.
--
-- Design (WoG-bar pattern, 12.1 AuraContainer, display-only, NO aura reads):
--   * Buff slot  (HELPFUL, unit=player)  - colored icon + border + countdown,
--     drawn ON TOP.
--   * Debuff slot (HARMFUL|PLAYER, unit=target) - desaturated grey icon +
--     countdown, drawn UNDERNEATH at the same spot.
--   * While your buff is up you see the colored icon; the instant it expires
--     the engine hides that button and the grey debuff icon (with its own
--     live countdown) shows through. Durations are rendered by the engine
--     from the REAL auras - random/talent-modified durations just work.
--   * We never read aura state: both layers are engine-shown/hidden. All our
--     visuals (border, icon, text) live on the aura buttons themselves, so
--     everything vanishes together when no aura matches.
--
-- 12.1 rules honored (docs/02_KIRA_AURA_WORKING_RULES_12_1.md):
--   - containers created at file load (never in combat), display-only
--   - candidateFilters only bite on hostile+HARMFUL -> the debuff container
--     is force-disabled on friendly targets (UnitCanAttack, secrecy-guarded)
--   - no UNIT_AURA, no payload reads, no hooks, no visibility reads on slots

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

local CreateFrame = CreateFrame
local UIParent = UIParent
local issecretvalue = issecretvalue
local pairs = pairs
local pcall = pcall

local GetPlayerAuraBySpellID = _G.C_UnitAuras and _G.C_UnitAuras.GetPlayerAuraBySpellID
local GetUnitAuraBySpellID = _G.C_UnitAuras and _G.C_UnitAuras.GetUnitAuraBySpellID

ns.hasFieryBrand = true

----------------------------------------------------------------------
-- Version gate: AuraContainer + CustomAuraContainerTemplate are 12.1+.
-- Settings/profiles are kept on older clients; options page shows a notice.
----------------------------------------------------------------------
local IS_121 = (select(4, _G.GetBuildInfo()) or 0) >= 120100

-- CLASS gate. Fiery Brand is a Demon Hunter ability, but the module only ever
-- checked the client version - so on every other class it still built engine
-- AuraContainers and restyled their (forbidden) regions, which is what threw
-- "Attempt to access forbidden object" on characters that can never see the
-- icon. Evaluated at runtime, not file load: UnitClass is unreliable before
-- player data exists, and a SECRET class token is treated as "allow" rather
-- than silently disabling the module for someone who IS a DH.
local isDH
local function IsDemonHunter()
	if isDH == nil then
		local _, cls = _G.UnitClass('player')
		if issecretvalue and issecretvalue(cls) then return true end
		if cls then isDH = (cls == 'DEMONHUNTER') end
	end
	return isDH ~= false
end

ns.FBDisabled = not IS_121

-- Fiery Brand spell IDs. The self-buff and the target-debuff can ride on
-- different IDs depending on build/talents, so both slots get the whole
-- candidate set - the HELPFUL/player slot can only ever match the self buff,
-- the HARMFUL/target slot only the debuff. Harmless to over-include.
local FB_IDS = {
	[204021] = true, -- Fiery Brand (cast / aura)
	[207744] = true, -- Fiery Brand debuff (classic ID)
	[207771] = true, -- Fiery Brand DoT/spread (Burning Alive line)
}
local FB_ICON_FALLBACK = 1344647 -- ability_demonhunter_fierybrand

local Round = ns.Round

----------------------------------------------------------------------
-- Settings
----------------------------------------------------------------------
local DEFAULTS = {
	enable = false,
	lock = true,
	iconW = 44,
	iconH = 44,
	x = 0,
	y = -120,
	strata = 'MEDIUM',
	attachFrame = '',        -- frame name to anchor to ('' = free placement)
	attachSide = 'RIGHT',    -- LEFT/RIGHT/TOP/BOTTOM of that frame
	attachFallback = true,   -- anchor hidden -> icon parks at its center spot
	durSize = 0,             -- countdown text size (0 = auto from icon size)
	desatDebuff = true,      -- grey out the icon in the debuff phase
	buffBorder   = { 1, 0.49, 0.04, 1 },   -- DH orange while buff on YOU
	debuffBorder = { 0.45, 0.45, 0.45, 1 },-- grey while only debuff remains
	buffText   = { 1, 1, 1, 1 },
	debuffText = { 0.8, 0.8, 0.8, 1 },
}

local function GetCfg() return ns.ModuleCfg('fieryBrand', DEFAULTS) end
ns.FBGetCfg = GetCfg

local function BarFont()
	local lib = ns.LSM()
	local font = lib and lib:Fetch('font', 'Expressway', true)
	-- the addon-wide font wins when one is set; Expressway stays the fallback,
	-- so 'Default (per module)' leaves this exactly as it was
	return ns.GetFont(font or _G.STANDARD_TEXT_FONT)
end

-- Engine duration formatter: whole seconds rounded up, plain '7' (no ' s').
-- Same NumericRuleFormatter pattern as the WoG bar / CDM D&D timer.
local fbDurFmt
local function DurationTextOptions()
	if fbDurFmt == nil then
		fbDurFmt = 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 fbDurFmt = 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 fbDurFmt then return { formatter = fbDurFmt, textFormatter = fbDurFmt } 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 IconTexture()
	local get = _G.C_Spell and _G.C_Spell.GetSpellTexture
	if get then
		local tex = get(204021)
		if not (issecretvalue and issecretvalue(tex)) and tex then return tex end
	end
	return FB_ICON_FALLBACK
end

----------------------------------------------------------------------
-- Holder (ours): position anchor + preview surface. All REAL visuals live
-- on the aura buttons; the holder itself renders nothing outside preview.
----------------------------------------------------------------------
local holder, previewTex, previewText, previewBorder
local preview
local ApplySettings -- fwd

local function EnsureHolder()
	if holder then return end
	holder = CreateFrame('Frame', 'KiraUIFieryBrand', UIParent)
	holder:SetFrameStrata('MEDIUM')
	holder:SetClampedToScreen(true)
	holder:SetMovable(true)
	holder:EnableMouse(false)
	holder:RegisterForDrag('RightButton')
	holder:SetSize(44, 44)
	holder:Show() -- always shown; it draws nothing (buttons show themselves)

	-- pinned while it is riding another frame
	ns.MakeDraggable(holder, GetCfg, 'x', 'y',
		function() if ns.FBApply then ns.FBApply() end end,
		function(cfg) return not cfg.lock and (cfg.attachFrame or '') == '' end)
end

-- Dynamic anchoring: 'attached' / 'fallback' (anchor hidden) / 'free'.
local anchorMode
local function PositionHolder(force)
	if not holder then return end
	local cfg = GetCfg()
	local target
	if (cfg.attachFrame or '') ~= '' then
		local t = _G[cfg.attachFrame]
		if type(t) == 'table' and t.GetObjectType and t.GetWidth then target = t end
	end

	local mode = 'free'
	if target then
		mode = 'attached'
		if cfg.attachFallback then
			-- pcall: visibility reads on OTHER ADDONS' frames only (allowed;
			-- aura buttons are never touched)
			local ok, shown = pcall(target.IsVisible, target)
			if not ok or not shown then mode = 'fallback' end
		end
	end
	if not force and mode == anchorMode then return end
	anchorMode = mode

	local x, y = cfg.x or 0, cfg.y or 0
	holder:ClearAllPoints()
	if mode == 'attached' then
		local side = cfg.attachSide or 'RIGHT'
		if side == 'LEFT' then
			holder:SetPoint('RIGHT', target, 'LEFT', x, y)
		elseif side == 'TOP' then
			holder:SetPoint('BOTTOM', target, 'TOP', x, y)
		elseif side == 'BOTTOM' then
			holder:SetPoint('TOP', target, 'BOTTOM', x, y)
		else
			holder:SetPoint('LEFT', target, 'RIGHT', x, y)
		end
	elseif mode == 'fallback' then
		-- Fallback means the anchor frame is HIDDEN, so the icon parks at its
		-- own configured spot. It used to anchor to `target` here - the very
		-- frame that is not visible - which inherits that frame's position
		-- rather than escaping it, so the icon could land anywhere, including
		-- off-screen. UIParent is the whole point of a fallback.
		holder:SetPoint('CENTER', UIParent, 'CENTER', x, y)
	else
		holder:SetPoint('CENTER', UIParent, 'CENTER', x, y)
	end
end

----------------------------------------------------------------------
-- Engine layers: two containers, debuff UNDER buff at the same spot.
-- Created at FILE LOAD (12.1 rule: never create containers in combat).
----------------------------------------------------------------------
local buffContainer, debuffContainer
-- style refs captured inside initializeFrame so ApplySettings can restyle
local layers = { buff = {}, debuff = {} }

-- EVERY ref here (frame, border, icon, dur, cd) is an ENGINE AuraContainer
-- region captured inside initializeFrame. Outside that callback the engine
-- treats them as forbidden objects, so from tainted code each write throws
--   "Attempt to access forbidden object from code tainted by an AddOn"
-- Guarding one call at a time just moves the error to the next line, so the
-- whole block runs under a single pcall: either the engine lets us restyle the
-- icon or it keeps its own look, and either way nothing lands in the error frame.
local function StyleLayer(refs, isDebuff)
	if not refs then return end
	if refs.frame and refs.frame.IsForbidden and refs.frame:IsForbidden() then return end
	local cfg = GetCfg()
	local w, h = cfg.iconW or 44, cfg.iconH or 44
	pcall(function()
		if refs.frame then refs.frame:SetSize(w, h) end
		if refs.border then
			local c = isDebuff and cfg.debuffBorder or cfg.buffBorder
			refs.border:SetColorTexture(c[1] or 1, c[2] or 1, c[3] or 1, c[4] or 1)
		end
		if refs.icon then
			refs.icon:SetDesaturated(isDebuff and cfg.desatDebuff or false)
		end
		if refs.dur then
			local ds = cfg.durSize or 0
			if ds <= 0 then ds = math.max(10, Round(math.min(w, h) * 0.36)) end
			refs.dur:SetFont(BarFont(), ds, 'OUTLINE')
			local c = isDebuff and cfg.debuffText or cfg.buffText
			refs.dur:SetTextColor(c[1] or 1, c[2] or 1, c[3] or 1, c[4] or 1)
		end
	end)
end

if IS_121 then
	EnsureHolder()

	local function MakeLayer(containerName, slotName, filter, unit, isDebuff, level)
		-- sub-holder fixes the z-order: buff layer above debuff layer
		local sub = CreateFrame('Frame', nil, holder)
		sub:SetAllPoints(holder)
		sub:SetFrameLevel(holder:GetFrameLevel() + level)

		local container = CreateFrame('AuraContainer', containerName, sub, 'CustomAuraContainerTemplate')
		container:SetPoint('CENTER', holder, 'CENTER', 0, 0)
		container:SetSize(1, 1) -- engine-forced anyway; SetPoint is what matters

		local refs = isDebuff and layers.debuff or layers.buff
		local slot = container:AddAuraSlot(slotName, filter, {
			candidateFilters = {
				includeSpellIDs = FB_IDS,
			},
			initializeFrame = function(auraFrame)
				refs.frame = auraFrame
				auraFrame:SetSize(GetCfg().iconW or 44, GetCfg().iconH or 44)

				-- No tooltip on this icon. The aura button is a normal engine
				-- frame, so hovering it pops Blizzard's aura tooltip - which
				-- tells you nothing the icon and its timer do not already, and
				-- gets in the way of whatever is behind it. Taking the mouse
				-- off the frame removes the tooltip and the mouse-blocking in
				-- one go; the holder still handles right-drag in preview.
				pcall(auraFrame.EnableMouse, auraFrame, false)
				if auraFrame.SetMouseClickEnabled then
					pcall(auraFrame.SetMouseClickEnabled, auraFrame, false)
				end
				if auraFrame.SetMouseMotionEnabled then
					pcall(auraFrame.SetMouseMotionEnabled, auraFrame, false)
				end

				-- 1px black-ish frame behind the icon; phase-colored
				local border = auraFrame:CreateTexture(nil, 'BACKGROUND')
				border:SetAllPoints()
				refs.border = border

				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)
				refs.icon = icon

				-- radial swipe, same recipe as the Externals buttons: always on,
				-- default look, reverse (icon stays bright, dark swipe grows as
				-- it expires). The engine writes the REAL expiration into this
				-- Cooldown internally (we can never read it back). Created ON
				-- the button so it passes the child-object validation.
				local cd = CreateFrame('Cooldown', nil, auraFrame, 'CooldownFrameTemplate')
				cd:SetAllPoints(icon)
				cd:SetDrawEdge(false)
				cd:SetDrawBling(false)
				cd:SetReverse(true)
				cd:SetHideCountdownNumbers(true) -- the engine duration TEXT does the numbers
				cd.noCooldownCount = true -- keep OmniCC-likes off it
				auraFrame:SetDurationCooldown(cd)
				refs.cd = cd

				-- overlay frame ABOVE the cooldown so the number draws over the
				-- swipe; the fontstring is born on it. The old trick (register
				-- as a direct child, then SetParent onto the overlay) dies on
				-- the 2026-07 PTR build: aura buttons now carry the
				-- 'ChangeParent' forbidden aspect, so their descendants can
				-- never be reparented by addon code. Registration accepts a
				-- fontstring on a child FRAME as long as it is anchored to the
				-- button - same validation rule as SetAuraBorder.
				local lvl = CreateFrame('Frame', nil, auraFrame)
				lvl:SetAllPoints()
				lvl:SetFrameLevel(cd:GetFrameLevel() + 5)
				local dur = lvl:CreateFontString(nil, 'OVERLAY', 'NumberFontNormal')
				dur:SetPoint('CENTER', auraFrame, 'CENTER', 0, 0)
				auraFrame:SetDurationText(dur, DurationTextOptions())
				refs.dur = dur

				StyleLayer(refs, isDebuff)
			end,
		})
		slot:SetPoint('CENTER', holder, 'CENTER', 0, 0)
		container:SetUnit(unit)
		container:SetEnabled(false) -- flipped on by ApplySettings when enabled
		return container
	end

	-- big level gap: the debuff layer's countdown overlay rides ~6 levels
	-- above its own button, so the buff layer starts 15 higher - EVERYTHING
	-- the buff draws (icon, border, swipe, number) covers EVERYTHING the
	-- debuff draws. With identical size/position the opaque buff icon fully
	-- hides the debuff layer while your buff is active; the engine reveals
	-- it automatically the moment the buff button hides.
	buffContainer   = MakeLayer('KiraUIFBBuffContainer',   'KiraUIFBBuffSlot',   'HELPFUL',        'player', false, 16)
	debuffContainer = MakeLayer('KiraUIFBDebuffContainer', 'KiraUIFBDebuffSlot', 'HARMFUL|PLAYER', 'target', true,  1)
end

----------------------------------------------------------------------
-- Debuff container gating: candidateFilters only apply on hostile+HARMFUL,
-- so on a FRIENDLY target the spell filter would be ignored and the slot
-- could show a random debuff. Force the layer off unless the target is
-- attackable. UnitCanAttack is not an aura API; guard secrecy anyway.
----------------------------------------------------------------------
local function TargetIsHostile()
	local can = _G.UnitCanAttack('player', 'target')
	if issecretvalue and issecretvalue(can) then return true end -- restricted: keep on
	return can and true or false
end

-- Readability gate. When a unit is disconnected, out of visible range or
-- phased, the engine's identity gate degrades: its aura filter flags have not
-- streamed, so filter RESULTS for that unit are untrustworthy and the slot can
-- show something that is not really there. Hide the layer instead.
--
-- UnitCanAssist is deliberately absent from the original five checks: this
-- container tracks a debuff on a HOSTILE target, where CanAssist is always
-- false and would keep the layer permanently off.
local function IsReadable(unit)
	return unit ~= nil
		and _G.UnitIsConnected(unit) == true
		and _G.UnitIsVisible(unit) == true
		and _G.UnitPhaseReason(unit) == nil
end

local function UpdateDebuffLayer()
	if not debuffContainer then return end
	local cfg = GetCfg()
	-- off-class never enables the layer (see the class gate at the top)
	local on = cfg.enable and not ns.FBDisabled and IsDemonHunter()
		and TargetIsHostile() and IsReadable('target')
	if not pcall(debuffContainer.SetEnabled, debuffContainer, on) then return end
	if on then
		-- SetUnit('target') does NOT rebuild when the token string is
		-- unchanged -> force a refresh now and once more next frame
		debuffContainer:UpdateAllAuras()
		_G.C_Timer.After(0, function()
			if debuffContainer then debuffContainer:UpdateAllAuras() end
		end)
	end
end

----------------------------------------------------------------------
-- Apply / preview / diagnostics
----------------------------------------------------------------------
ApplySettings = function()
	EnsureHolder()
	local cfg = GetCfg()
	holder:SetSize(cfg.iconW or 44, cfg.iconH or 44)
	holder:SetFrameStrata(cfg.strata or 'MEDIUM')
	PositionHolder(true)
	StyleLayer(layers.buff, false)
	StyleLayer(layers.debuff, true)
end

ns.FBApply = function()
	-- off-class is handled exactly like the version gate: switch the containers
	-- off and touch nothing else. No styling pass means no writes to engine
	-- regions we are not allowed to touch.
	if ns.FBDisabled or not IsDemonHunter() then
		pcall(function()
			if buffContainer then buffContainer:SetEnabled(false) end
			if debuffContainer then debuffContainer:SetEnabled(false) end
		end)
		if ns.FBSyncFollowTicker then ns.FBSyncFollowTicker() end
		return
	end
	ApplySettings()
	local on = GetCfg().enable and true or false
	if buffContainer then buffContainer:SetEnabled(on) end
	UpdateDebuffLayer()
	-- start/stop the attach-fallback follower to match the new settings
	if ns.FBSyncFollowTicker then ns.FBSyncFollowTicker() end
end

ns.FBIsPreview = function() return preview and true or false end
ns.FBSetPreview = function(state)
	if ns.FBDisabled or not IsDemonHunter() then return end
	preview = state and true or false
	EnsureHolder()
	ApplySettings()
	holder:EnableMouse(preview)
	if preview and not previewTex then
		previewBorder = holder:CreateTexture(nil, 'BACKGROUND')
		previewBorder:SetAllPoints()
		previewTex = holder:CreateTexture(nil, 'ARTWORK')
		previewTex:SetPoint('TOPLEFT', 1, -1)
		previewTex:SetPoint('BOTTOMRIGHT', -1, 1)
		previewTex:SetTexCoord(0.08, 0.92, 0.08, 0.92)
		previewTex:SetTexture(IconTexture())
		previewText = holder:CreateFontString(nil, 'OVERLAY')
		previewText:SetPoint('CENTER')
	end
	if previewTex then
		if preview then
			local cfg = GetCfg()
			-- Re-set every time, not just at creation. GetSpellTexture returns
			-- nil for an uncached spell, and the old code only ever asked once -
			-- so one unlucky first call left an invisible icon permanently.
			previewTex:SetTexture(IconTexture())
			local c = cfg.buffBorder or DEFAULTS.buffBorder
			previewBorder:SetColorTexture(c[1] or 1, c[2] or 0.5, c[3] or 0, c[4] or 1)
			local ds = cfg.durSize or 0
			if ds <= 0 then ds = math.max(10, Round(math.min(cfg.iconW or 44, cfg.iconH or 44) * 0.36)) end
			previewText:SetFont(BarFont(), ds, 'OUTLINE')
			local tc = cfg.buffText or DEFAULTS.buffText
			previewText:SetTextColor(tc[1] or 1, tc[2] or 1, tc[3] or 1, tc[4] or 1)
			previewText:SetText('8')
			previewBorder:Show() previewTex:Show() previewText:Show()
		else
			previewBorder:Hide() previewTex:Hide() previewText:Hide()
		end
	end
end

ns.FBTest = function()
	local function p(...) print('|cffff7d0aKiraUI-FieryBrand|r', ...) end
	p('--- Fiery Brand diagnostics (best run OUT of combat with brand active) ---')
	if not GetPlayerAuraBySpellID then p('C_UnitAuras.GetPlayerAuraBySpellID missing') return end
	for id in pairs(FB_IDS) do
		local aura = GetPlayerAuraBySpellID(id)
		p('player buff', id, '=', aura and 'FOUND' or 'nothing')
		if GetUnitAuraBySpellID then
			local d = GetUnitAuraBySpellID('target', id, 'HARMFUL')
			p('target debuff', id, '=', d and 'FOUND' or 'nothing')
		end
	end
	-- Known 12.1 risk (working-rules 5.1): AddAuraSlot+initializeFrame can silently fail.
	-- HasAuraSlot tells us whether the engine actually accepted the slots.
	if buffContainer and buffContainer.HasAuraSlot then
		p('engine accepted buff slot =', buffContainer:HasAuraSlot('KiraUIFBBuffSlot') and 'YES' or 'NO (silent slot failure - report this)')
	end
	if debuffContainer and debuffContainer.HasAuraSlot then
		p('engine accepted debuff slot =', debuffContainer:HasAuraSlot('KiraUIFBDebuffSlot') and 'YES' or 'NO (silent slot failure - report this)')
	end
	local cfg = GetCfg()
	p('enable =', cfg.enable and 'on' or 'off',
		'| 12.1+ =', IS_121 and 'yes' or 'NO (module disabled)',
		'| containers =', (buffContainer and debuffContainer) and 'built' or 'MISSING',
		'| in combat =', _G.InCombatLockdown() and 'yes' or 'no')

	-- Every gate that can make the icon invisible, named individually. "Nothing
	-- appears" has half a dozen possible causes and they are indistinguishable
	-- from the outside.
	local okC, _, cls = pcall(_G.UnitClass, 'player')
	p('UnitClass =', okC and tostring(cls) or 'CALL FAILED',
		'| secret =', (issecretvalue and okC and issecretvalue(cls)) and 'yes' or 'no',
		'| IsDemonHunter() =', IsDemonHunter() and 'yes' or 'NO (module refuses to draw)')
	p('holder =', holder and 'built' or 'MISSING',
		'| shown =', (holder and holder:IsShown()) and 'yes' or 'no',
		'| size =', holder and (Round(holder:GetWidth() or 0) .. 'x' .. Round(holder:GetHeight() or 0)) or '-',
		'| alpha =', holder and string.format('%.2f', holder:GetAlpha() or 0) or '-')
	local px, py = holder and holder:GetCenter()
	p('holder centre =', px and (Round(px) .. ', ' .. Round(py)) or 'no position',
		'| preview =', preview and 'ON' or 'off',
		'| previewTex =', previewTex and (previewTex:IsShown() and 'shown' or 'hidden') or 'not created')
	p('attachFrame =', (cfg.attachFrame or '') ~= '' and cfg.attachFrame or 'none (free placement)',
		'| that frame exists =', ((cfg.attachFrame or '') == '' or _G[cfg.attachFrame]) and 'yes' or 'NO - icon has nothing to anchor to')
	p('If a FOUND id differs from what the slots track, tell Kira/Claude which id showed FOUND.')
end

----------------------------------------------------------------------
-- Events
----------------------------------------------------------------------
local evt = CreateFrame('Frame')
evt:RegisterEvent('PLAYER_ENTERING_WORLD')
evt:RegisterEvent('PLAYER_TARGET_CHANGED')
-- readability can change WITHOUT the target changing: they phase out, or the
-- connection drops. Both re-drive the same gate.
evt:RegisterUnitEvent('UNIT_PHASE', 'target')
evt:RegisterUnitEvent('UNIT_CONNECTION', 'target')
evt:SetScript('OnEvent', function(_, event)
	if ns.FBDisabled or not IsDemonHunter() then return end
	if event == 'PLAYER_TARGET_CHANGED'
		or event == 'UNIT_PHASE' or event == 'UNIT_CONNECTION' then
		if GetCfg().enable then UpdateDebuffLayer() end
	else -- PLAYER_ENTERING_WORLD
		ns.FBApply()
		-- re-anchor once other addons have built their frames
		_G.C_Timer.After(2, function() if ns.FBApply then ns.FBApply() end end)
	end
end)

-- attach-fallback follower (cheap: repositions only when the mode changes).
-- Only runs while the module is on AND actually configured to follow another
-- frame; otherwise the ticker is cancelled rather than waking 4x/second to
-- fall straight back out. SyncFollowTicker is driven from ns.FBApply, which
-- the options page calls on every relevant toggle.
local followTicker
local function SyncFollowTicker()
	local cfg = GetCfg()
	local want = (not ns.FBDisabled) and IsDemonHunter() and cfg.enable
		and (cfg.attachFrame or '') ~= '' and cfg.attachFallback and true or false
	if want and not followTicker then
		followTicker = _G.C_Timer.NewTicker(0.25, function() PositionHolder(false) end)
	elseif not want and followTicker then
		followTicker:Cancel()
		followTicker = nil
	end
end
ns.FBSyncFollowTicker = SyncFollowTicker

_G.SLASH_KIRAVDH1 = '/kiravdh'
_G.SlashCmdList.KIRAVDH = function()
	if ns.FBTest then ns.FBTest() end
end
