-- KiraUI-Plugin_BuffWatch.lua
-- Track chosen buffs on YOU by spell ID (12.1+ engine-driven).
--
-- 12.1 moved aura reading behind the engine: addon Lua can no longer scan
-- aura data in restricted content, but we CAN hand Blizzard a
-- CustomAuraContainer with a candidateFilters.includeSpellIDs whitelist.
-- The engine reads the auras itself (legal even when secret) and fills the
-- widgets we provide: icon texture, cooldown swipe, countdown text, stacks.
-- includeSpellIDs takes effect for HELPFUL auras on friendly units - which
-- is exactly this module's case (buffs on the player). We control the LOOK
-- (sizes, fonts, grow direction); we can never read the actual values.
--
-- Layout is the container's own flow layout, so grow direction / spacing are
-- real engine settings (SetAuraLayoutGrowthDirection etc.), not manual anchors.
--
-- NO GLOW. Per-spell glow used to live here and has been removed outright:
-- the toggle in front of each spell, the colour picker, the glow host frame on
-- every button and the pulse fallback are all gone. Saved profiles may still
-- carry a `glowSpells` table from an older version; it is simply ignored, and
-- Migrate strips it so it does not linger in the SavedVariables file.

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

local CreateFrame = CreateFrame
local C_Timer = C_Timer
local GetTime = GetTime
local pairs, ipairs = pairs, ipairs

----------------------------------------------------------------------
-- Version gate
--
-- The AuraContainer frame type and CustomAuraContainerTemplate this module is
-- built on only exist on 12.1+; creating them on an older client hard-errors
-- and takes the whole file with it, so nothing below the settings block runs
-- there.
--
-- The PAGE still appears, the same way the Paladin and Demon Hunter ones do.
-- Hiding it entirely was the earlier behaviour and it was worse: a feature
-- that silently does not exist reads as a broken addon, and your spell list
-- and positions are still in there being kept safe for the day the client
-- updates. The settings block below therefore runs on every client - it is
-- plain tables and needs no 12.1 API - and only the display half is gated.
----------------------------------------------------------------------
local IS_121 = (select(4, _G.GetBuildInfo()) or 0) >= 120100
ns.hasBuffWatch = true
ns.BWDisabled = not IS_121

local MAX_ICONS_CAP = 32
local FALLBACK_ICON = 134400 -- question mark

local Round = ns.Round

----------------------------------------------------------------------
-- Settings
----------------------------------------------------------------------
-- Auto-filled defaults: movement/utility buffs worth watching on yourself.
local DEFAULT_SPELLS = {
	1044,   -- Blessing of Freedom
	77761,  -- Stampeding Roar (bear)
	77764,  -- Stampeding Roar (cat)
	106898, -- Stampeding Roar
	116841, -- Tiger's Lust
	262232, -- War Machine
	130,    -- Slow Fall
	375229, -- Time Spiral
	374227, -- Zephyr
	54216,  -- Master's Call
	192082, -- Wind Rush (totem)
}

local DEFAULTS = {
	enable = false,
	lock = false,
	iconW = 36,
	iconH = 36,
	-- Percent trimmed off EACH edge of the icon art. 0 shows the whole file
	-- including its built-in border; 8 matches the rest of the addon.
	zoom = 8,
	textSize = 16,
	-- Custom labels: one look for all of them. Per-spell size and offset would
	-- mean three more controls on every row of the list, which is a lot of
	-- clutter to buy something almost nobody varies per buff.
	labelSize = 14,
	labelX = 0,
	labelY = 0,
	spacing = 4,
	maxIcons = 12,
	grow = 'RIGHT',       -- RIGHT / LEFT / DOWN / UP
	x = 0,
	y = 180,
	spells = DEFAULT_SPELLS,      -- copied per-profile in GetCfg
	-- [spellID] = { text, size, x, y, color } - a label drawn on that buff's
	-- icon, e.g. "vers" on a trinket proc. Per-spell, which is why each spell
	-- now gets its own aura group (see SLOT_MAX below).
	texts = {},
	-- Attach the whole row to another addon's frame (a trinket's cooldown icon,
	-- say). Empty = free placement at x/y.
	attachFrame = '',
	attachPoint = 'CENTER',   -- CENTER overlays the frame, TOP/LEFT/... sit beside it
	attachX = 0,
	attachY = 0,
	attachFallback = true,    -- target hidden -> fall back to x/y
	strata = 'MEDIUM',        -- raise to draw OVER the frame you attach to
	-- CATEGORIES: an options-page convenience, nothing more. They never reach
	-- the engine - see BWNormalizeCategories below for what they actually do.
	-- Named "category" rather than "group" on purpose: this file already calls
	-- an engine aura group a group, and the two are unrelated.
	categories = {},          -- ordered list of category names
	spellCat = {},            -- [spellID] = category name (nil = uncategorised)
	catCollapsed = {},        -- [category name] = true -> folded on the Buffs tab
}

local function Migrate(cfg)
	-- Glow is gone entirely. Drop every field it ever used, including the
	-- per-spell glowSpells list and the colour, so an old profile does not keep
	-- carrying dead data around forever.
	cfg.glowSpells, cfg.glowColor = nil, nil
	cfg.glowSize, cfg.glowSpeed = nil, nil
	cfg.glowType, cfg.glowLines, cfg.glowFreq, cfg.glowLength, cfg.glowThickness = nil, nil, nil, nil, nil
	-- one square size became separate width/height
	if cfg.iconSize then
		cfg.iconW, cfg.iconH = cfg.iconSize, cfg.iconSize
		cfg.iconSize = nil
	end
	-- per-spell label size/offset moved to one shared setting
	if type(cfg.texts) == 'table' then
		for _, t in pairs(cfg.texts) do
			if type(t) == 'table' then t.size, t.x, t.y = nil, nil, nil end
		end
	end
end

local function GetCfg() return ns.ModuleCfg('buffWatch', DEFAULTS, Migrate) end
ns.BWGetCfg = GetCfg

-- CATEGORIES ARE A SORT, NOT A STRUCTURE.
--
-- cfg.spells stays the one ordered list the engine consumes: slot i gets
-- cfg.spells[i], and that is still the whole story downstream. A category adds
-- exactly one guarantee on top - that the spells sharing it sit in a CONTIGUOUS
-- run of that list - which is what lets the Buffs tab draw a header above each
-- run without lying. The tab promises "the order here is the order on screen";
-- scattered categories would quietly break that promise, so the invariant is
-- restored here rather than trusted to the UI.
--
-- Uncategorised spells stay at the FRONT. That way an existing bar - and every
-- bar belonging to someone who never touches this feature - keeps the exact
-- order it already had.
--
-- Membership is keyed by spell ID, like texts, so it survives
-- reordering, and a category that no longer exists silently degrades to
-- uncategorised instead of stranding its spells.
function ns.BWNormalizeCategories(cfg)
	cfg = cfg or GetCfg()
	local spells = cfg.spells
	if type(spells) ~= 'table' then return end
	local cats = cfg.categories or {}
	local of = cfg.spellCat or {}

	local rank = {}
	for i, name in ipairs(cats) do rank[name] = i end

	local buckets = { [0] = {} }        -- 0 = uncategorised, drawn first
	for i = 1, #cats do buckets[i] = {} end

	for _, id in ipairs(spells) do
		local r = rank[of[id]]
		if not r then of[id] = nil r = 0 end
		local b = buckets[r]
		b[#b + 1] = id
	end

	-- Rewritten IN PLACE: cfg.spells is handed out by reference (the options
	-- page holds onto it across a redraw), so replacing the table would leave
	-- those references pointing at the old order. Every spell lands in exactly
	-- one bucket, so the length cannot change and nothing needs trimming.
	local n = 0
	for r = 0, #cats do
		for _, id in ipairs(buckets[r]) do
			n = n + 1
			spells[n] = id
		end
	end
	return spells
end

-- Zoom -> texture coordinates: a percentage trimmed off each edge. Clamped
-- below half, since 50 would collapse the icon to nothing.
local function ZoomCoords(cfg)
	local z = (cfg.zoom or 0) / 100
	if z < 0 then z = 0 elseif z > 0.45 then z = 0.45 end
	return z, 1 - z
end

-- Everything past here builds engine widgets. Stop now on an older client;
-- ns.BWGetCfg above is all the options page needs to show its notice and keep
-- your saved spell list intact.
if not IS_121 then return end

-- ONE AURA GROUP PER WATCHED SPELL.
--
-- The engine will not tell us which spell a button is showing - that is secret.
-- The only way to know is to have put exactly one spell into the group the
-- button belongs to, which is what per-spell TEXT needs.
--
-- The groups are a fixed pool created once at load and assigned spells later,
-- rather than rebuilt when the list changes - building an AuraContainer in
-- combat is not allowed, and a list edit can happen at any time.
--
-- On-screen order is therefore YOUR list order, because groups lay out in the
-- order they were declared.
--
-- 'Max icons' now means "how many of your listed spells are tracked, counting
-- from the top" rather than a cap on simultaneous icons. With one group per
-- spell there is no global counter left to cap.
local SLOT_MAX = MAX_ICONS_CAP

-- slot index -> spell ID currently assigned (nil = starved, no button built)
local slotSpell = {}

local function SlotName(i) return 'sp' .. i end

----------------------------------------------------------------------
-- Engine container (Blizzard reads the auras, we provide the widgets)
----------------------------------------------------------------------
local holder            -- movable anchor frame (right-drag in preview)
local container
local engineButtons = {} -- every built button, for restyle on apply
local slotWidgets = {}   -- slot index -> that slot's single button record

-- Everything about a button that depends on WHICH spell its slot holds:
-- font sizes and the custom label. Called both when a button is first built
-- and on every apply, because a slot's spell changes whenever the watched
-- list is edited.
local StyleSlot

local previewActive = false
local previewButtons = {}

-- whole-seconds countdown ('12', not '12 s') - same NumericRuleFormatter
-- pattern as the CC tracker / WoG bar / CDM timer
local bwDurFmt
local function BWDurationTextOptions()
	if bwDurFmt == nil then
		bwDurFmt = 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 bwDurFmt = 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 bwDurFmt then return { formatter = bwDurFmt, textFormatter = bwDurFmt } 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

-- Everything on a button that depends on which spell its slot currently holds.
-- Wrapped in pcall for the same reason ApplyEngine is: outside initializeFrame
-- the engine treats these buttons as forbidden objects, and one unreachable
-- frame must not abort the rest.
StyleSlot = function(b)
	if not b then return end
	local cfg = GetCfg()
	local id = b.slot and slotSpell[b.slot] or nil
	pcall(function()
		b.text:SetFont(ns.GetFont(), cfg.textSize or 16, 'OUTLINE')
		b.stack:SetFont(ns.GetFont(), math.max(8, Round((cfg.textSize or 16) * 0.75)), 'OUTLINE')

		if b.icon then
			local z0, z1 = ZoomCoords(cfg)
			b.icon:SetTexCoord(z0, z1, z0, z1)
		end

		local t = id and cfg.texts and cfg.texts[id] or nil
		local label = t and t.text
		if b.custom then
			if type(label) == 'string' and label ~= '' then
				local c = (t and t.color) or { 1, 1, 1, 1 }
				b.custom:SetFont(ns.GetFont(), cfg.labelSize or 14, 'OUTLINE')
				b.custom:SetText(label)
				b.custom:SetTextColor(c[1] or 1, c[2] or 1, c[3] or 1, c[4] or 1)
				b.custom:ClearAllPoints()
				b.custom:SetPoint('BOTTOM', b.frame, 'TOP', cfg.labelX or 0, (cfg.labelY or 0) + 2)
				b.custom:Show()
			else
				b.custom:Hide()
			end
		end
	end)
end

-- Button setup for one slot. The custom-text fontstring is always built: a
-- slot's spell changes when the list is edited, so what a button needs cannot
-- be decided at build time. Whether it is visible is decided later, per apply.
local function MakeInitializer(slot)
	return function(auraFrame)
		local cfg = GetCfg()
		local w, h = cfg.iconW or 36, cfg.iconH or 36
		auraFrame:SetSize(w, h)
		-- kill the built-in hover tooltip: no mouse motion = no OnEnter.
		-- The button's forbidden aspects seal scripts / input simulation /
		-- focus queries - DISABLING mouse input is not on that list, but
		-- pcall anyway so a future sealing can't abort the whole batch.
		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)
		local z0, z1 = ZoomCoords(cfg)
		icon:SetTexCoord(z0, z1, z0, z1)
		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)
		-- countdown text above the swipe
		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(), cfg.textSize or 16, 'OUTLINE')
		auraFrame:SetDurationText(txt, BWDurationTextOptions())
		-- stack count, bottom-right corner
		local stack = lvl:CreateFontString(nil, 'OVERLAY')
		stack:SetPoint('BOTTOMRIGHT', -1, 1)
		stack:SetFont(ns.GetFont(), math.max(8, Round((cfg.textSize or 16) * 0.75)), 'OUTLINE')
		auraFrame:SetApplicationCount(stack)
		-- Custom label. Parented to the aura frame exactly as the countdown is,
		-- so it appears and disappears with the buff and needs no show/hide
		-- logic of its own. SetPoint can put it anywhere.
		--
		-- It gets its OWN frame, one level above the countdown's, rather than
		-- sharing it. Nudged down onto the icon with a negative Y offset the
		-- label lands in the cooldown swipe's territory, and sharing a frame
		-- level with the countdown was not enough to keep it on top. The extra
		-- level plus the top OVERLAY sublevel puts it above everything the
		-- button draws.
		local topLvl = CreateFrame('Frame', nil, auraFrame)
		topLvl:SetAllPoints()
		topLvl:SetFrameLevel(lvl:GetFrameLevel() + 3)
		local custom = topLvl:CreateFontString(nil, 'OVERLAY')
		custom:SetDrawLayer('OVERLAY', 7)
		custom:SetPoint('BOTTOM', auraFrame, 'TOP', 0, 2)
		custom:SetFont(ns.GetFont(), 14, 'OUTLINE')
		custom:Hide()

		local rec = { frame = auraFrame, text = txt, stack = stack, icon = icon,
		              custom = custom, slot = slot }
		engineButtons[#engineButtons + 1] = rec
		slotWidgets[slot] = rec
		StyleSlot(rec)
	end
end

local function MakeContainer()
	local cfg = GetCfg()
	local sp = cfg.spacing or 4
	local c = CreateFrame('AuraContainer', 'KiraUIBuffWatch', _G.UIParent, 'CustomAuraContainerTemplate')
	c:SetSize(1, 1)
	-- One group per slot, all starved until ReapplyFilters hands them a spell.
	-- [0] is a spell ID that can never match; an EMPTY includeSpellIDs table
	-- would read as "no filter" and let every buff through (see ReapplyFilters).
	for i = 1, SLOT_MAX do
		c:AddAuraGroup(SlotName(i), 'HELPFUL', {
			maxFrameCount = 1,
			candidateFilters = { includeSpellIDs = { [0] = true } },
			-- old + new (2026-07 PTR) key names; both validators skip unknown keys
			layout = { elementSpacingX = sp, elementSpacingY = sp, elementSpacing = sp, lineSpacing = sp },
			initializeFrame = MakeInitializer(i),
		})
	end
	c:SetEnabled(false)
	c:SetUnit('player')
	return c
end

-- 2026-07 PTR build renamed the container flow-layout API (SetAuraLayout* ->
-- SetFlowLayout*, RowWidth -> MaximumLineSize). These call whichever this
-- client has, so the module runs on both builds.
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 CRowWidth(c, w)
	local f = c.SetFlowLayoutMaximumLineSize or c.SetAuraLayoutRowWidth
	if f then f(c, w) end
end

-- Grow direction -> engine flow-layout settings. The flow layout always fits
-- at least one element per row, so rowWidth = 1 forces one icon per row =
-- vertical growth.
local function ApplyGrowth(c, cfg)
	local FD = _G.AnchorUtil.FlowDirection
	local grow = cfg.grow or 'RIGHT'
	if grow == 'LEFT' then
		CAnchor(c, 'TOPRIGHT')
		CGrowth(c, FD.Left, FD.Down)
		CRowWidth(c, nil)
	elseif grow == 'DOWN' then
		CAnchor(c, 'TOPLEFT')
		CGrowth(c, FD.Right, FD.Down)
		CRowWidth(c, 1)
	elseif grow == 'UP' then
		CAnchor(c, 'BOTTOMLEFT')
		CGrowth(c, FD.Right, FD.Up)
		CRowWidth(c, 1)
	else -- RIGHT
		CAnchor(c, 'TOPLEFT')
		CGrowth(c, FD.Right, FD.Down)
		CRowWidth(c, nil)
	end
	-- pin the container corner that matches the layout anchor to the holder,
	-- so icons grow away from the drag position
	c:ClearAllPoints()
	if grow == 'LEFT' then
		c:SetPoint('TOPRIGHT', holder, 'TOPRIGHT', 0, 0)
	elseif grow == 'UP' then
		c:SetPoint('BOTTOMLEFT', holder, 'BOTTOMLEFT', 0, 0)
	else
		c:SetPoint('TOPLEFT', holder, 'TOPLEFT', 0, 0)
	end
end

-- Placement, with an optional attach to somebody else's frame.
--
-- Modes: 'attached' (glued to the target), 'fallback' (target hidden, so use
-- the free position instead) and 'free' (no target configured). Recomputed
-- cheaply; the frame is only re-anchored when the MODE actually changes, so
-- this is safe to call from a ticker.
--
-- CENTER is the interesting attach point: it puts the row directly on top of
-- the target. With a raised strata that is how a trinket's proc buff covers
-- the trinket's own cooldown icon while it is up, and uncovers it when the
-- buff falls off - no hiding logic, the buff icon simply stops existing.
local anchorMode
local function ApplyPosition(force)
	if not holder then return end
	local cfg = GetCfg()
	holder:SetFrameStrata(cfg.strata or 'MEDIUM')

	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: a visibility read on another addon's frame could gain
			-- secret or forbidden aspects later; degrade to attached
			local ok, shown = pcall(target.IsVisible, target)
			if not ok or not shown then mode = 'fallback' end
		end
	end

	-- STANDING IN FOR AN EMPTY TRINKET ROW.
	--
	-- Riding KiraUITrinkets when no trinket is displayed, butting up against
	-- its 1px stub is not what you want - you want to BE where the row would
	-- have been. So we borrow the row's own anchor outright: its target frame,
	-- its corner, its offsets. The result is identical placement to the
	-- trinkets, rather than "next to where they aren't".
	--
	-- Only when the trinket row is genuinely empty. With even one trinket up
	-- the normal attach applies, because there is something real to sit beside.
	local subbing = false
	local sx, sy, spt
	if mode == 'attached' and target == _G.KiraUITrinkets
		and ns.TRShownCount and ns.TRShownCount() == 0 then
		local tcfg = ns.TRGetCfg and ns.TRGetCfg()
		local tname = tcfg and tcfg.attachFrame or ''
		local tt = (tname ~= '') and _G[tname] or nil
		if type(tt) == 'table' and tt.GetObjectType then
			target = tt
			spt = tcfg.attachPoint or 'TOPRIGHT'
			sx, sy = tcfg.attachX or 0, tcfg.attachY or 0
			subbing = true
		end
	end

	local sig = mode .. (subbing and '|sub' or '')
	if not force and sig == anchorMode then return end
	anchorMode = sig

	holder:ClearAllPoints()
	if subbing then
		-- match the row's own pinned corner so we land exactly where it would
		local growLeft = (GetCfg().grow or 'RIGHT') == 'LEFT'
		local tcfg = ns.TRGetCfg and ns.TRGetCfg()
		local selfPt = tcfg and tcfg.attachSelfPoint
		if not selfPt or selfPt == 'AUTO' then
			selfPt = (tcfg and (tcfg.grow or 'RIGHT') == 'LEFT') and 'TOPRIGHT' or 'TOPLEFT'
		end
		holder:SetPoint(selfPt, target, spt, sx, sy)
	elseif mode == 'attached' then
		local ax, ay = cfg.attachX or 0, cfg.attachY or 0
		local pt = cfg.attachPoint or 'CENTER'
		if pt == 'LEFT' then holder:SetPoint('RIGHT', target, 'LEFT', ax, ay)
		elseif pt == 'RIGHT' then holder:SetPoint('LEFT', target, 'RIGHT', ax, ay)
		elseif pt == 'TOP' then holder:SetPoint('BOTTOM', target, 'TOP', ax, ay)
		elseif pt == 'BOTTOM' then holder:SetPoint('TOP', target, 'BOTTOM', ax, ay)
		else holder:SetPoint('CENTER', target, 'CENTER', ax, ay) end
	else
		-- 'fallback' and 'free' land in the same place on purpose: the free
		-- position is where you want the row when the frame it rides is gone.
		holder:SetPoint('CENTER', _G.UIParent, 'CENTER', cfg.x or 0, cfg.y or 180)
	end
end

-- Follow the target's visibility. Only runs while a frame is actually attached
-- AND the fallback is wanted; otherwise the ticker is cancelled rather than
-- waking four times a second to fall straight back out.
local followTicker
local function SyncFollowTicker()
	local cfg = GetCfg()
	-- also runs when riding the trinket row: whether we stand in for it changes
	-- with what is equipped and off cooldown, which no single event announces
	local want = (cfg.attachFrame or '') ~= ''
		and (cfg.attachFallback or cfg.attachFrame == 'KiraUITrinkets') and true or false
	if want and not followTicker then
		followTicker = C_Timer.NewTicker(0.25, function() ApplyPosition(false) end)
	elseif not want and followTicker then
		followTicker:Cancel()
		followTicker = nil
	end
end

-- THE WHITELIST, PUSHED ON ITS OWN.
--
-- An EMPTY includeSpellIDs table is not "show nothing" to the engine, it is
-- "no filter" - i.e. show EVERY buff on the unit. So an empty set must never
-- be pushed; the group is emptied by max-frame-count instead, which the engine
-- does treat literally.
--
-- Split out of ApplyEngine because it needs re-running far more often than the
-- rest of the styling: the engine drops these filters when it rebuilds a
-- unit's aura data, which is what a portal or a flight path inside a key does.
-- PLAYER_ENTERING_WORLD does not fire for those - you never left the instance -
-- so nothing re-pushed them and every buff you had came flooding in.
local function ReapplyFilters()
	if not container then return end
	local cfg = GetCfg()
	-- Cheap (32 entries at most) and it makes the block invariant self-healing:
	-- a profile imported from elsewhere, or edited by hand, sorts itself out on
	-- the next apply instead of drawing a row that disagrees with the options
	-- page. Categories never get past this line.
	ns.BWNormalizeCategories(cfg)
	local maxN = math.min(cfg.maxIcons or 12, MAX_ICONS_CAP)
	for i = 1, SLOT_MAX do
		local id = (i <= maxN) and cfg.spells[i] or nil
		slotSpell[i] = id
		if id then
			pcall(container.SetAuraGroupCandidateFilters, container, SlotName(i), { includeSpellIDs = { [id] = true } })
			pcall(container.SetAuraGroupMaxFrameCount, container, SlotName(i), 1)
		else
			-- no spell for this slot: starve it rather than hand the engine an
			-- empty filter it would read as "anything goes"
			pcall(container.SetAuraGroupCandidateFilters, container, SlotName(i), { includeSpellIDs = { [0] = true } })
			pcall(container.SetAuraGroupMaxFrameCount, container, SlotName(i), 0)
		end
	end
	-- a slot's spell may have changed, so its label has to follow
	for i = 1, SLOT_MAX do
		if slotWidgets[i] then StyleSlot(slotWidgets[i]) end
	end
end

local function ApplyEngine()
	if not (container and holder) then return end
	local cfg = GetCfg()
	local w, h = cfg.iconW or 36, cfg.iconH or 36
	holder:SetSize(w, h)
	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(w, h) end)
		StyleSlot(b)
	end
	local spacing = { elementSpacingX = cfg.spacing or 4, elementSpacingY = cfg.spacing or 4, elementSpacing = cfg.spacing or 4, lineSpacing = cfg.spacing or 4 }
	ReapplyFilters()
	for i = 1, SLOT_MAX do
		pcall(container.SetAuraGroupLayout, container, SlotName(i), spacing)
	end
	ApplyGrowth(container, cfg)
	local on = (cfg.enable and not previewActive) and true or false
	container:SetEnabled(on)
	if on then pcall(container.UpdateAllAuras, container) end
end

----------------------------------------------------------------------
-- Preview: fake icons of the configured spells (their real spell textures),
-- laid out exactly like the chosen grow direction, so you can position it.
----------------------------------------------------------------------
local function SpellIcon(id)
	local tex
	if _G.C_Spell and _G.C_Spell.GetSpellTexture then
		tex = _G.C_Spell.GetSpellTexture(id)
	elseif _G.GetSpellTexture then
		tex = _G.GetSpellTexture(id)
	end
	return tex or FALLBACK_ICON
end

local function StylePreviewCD(cd, textSize)
	if not cd then return end
	for _, r in ipairs({ cd:GetRegions() }) do
		if r and r.GetFont and r.SetFont then
			local font, _, flags = r:GetFont()
			if font then r:SetFont(ns.GetFont(font), textSize, flags or 'OUTLINE') end
		end
	end
end

local function HidePreview()
	for _, b in ipairs(previewButtons) do
		b:Hide()
	end
end

local function ShowPreview()
	local cfg = GetCfg()
	local w, h = cfg.iconW or 36, cfg.iconH or 36
	local gap = cfg.spacing or 4
	local stepX, stepY = w + gap, h + gap
	local z0, z1 = ZoomCoords(cfg)
	local grow = cfg.grow or 'RIGHT'
	-- Straight list order now: one group per spell means the live layout follows
	-- your list, so the preview must not re-sort or it would lie about placement.
	local ordered = {}
	for _, id in ipairs(cfg.spells) do ordered[#ordered + 1] = id end
	local n = math.min(#ordered, cfg.maxIcons or 12)
	if n == 0 then n = 3 end -- keep the anchor visible with placeholders
	for i = 1, n do
		local b = previewButtons[i]
		if not b then
			b = CreateFrame('Frame', nil, holder)
			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:SetReverse(true)
			b.Cooldown:SetDrawEdge(false)
			b.Cooldown:SetDrawBling(false)
			-- mirrors the live button's custom label so placing the row in
			-- preview shows what the text will actually look like
			-- same treatment as the live button: its own frame above the
			-- cooldown, top OVERLAY sublevel, so the preview shows the label
			-- exactly where the real one will draw
			local ptop = CreateFrame('Frame', nil, b)
			ptop:SetAllPoints()
			ptop:SetFrameLevel(b.Cooldown:GetFrameLevel() + 8)
			b.custom = ptop:CreateFontString(nil, 'OVERLAY')
			b.custom:SetDrawLayer('OVERLAY', 7)
			previewButtons[i] = b
		end
		local id = ordered[i]
		b:SetSize(w, h)
		b.Icon:SetTexture(id and SpellIcon(id) or FALLBACK_ICON)
		b.Icon:SetTexCoord(z0, z1, z0, z1)
		local t = id and cfg.texts and cfg.texts[id] or nil
		if t and type(t.text) == 'string' and t.text ~= '' then
			local c = t.color or { 1, 1, 1, 1 }
			b.custom:SetFont(ns.GetFont(), cfg.labelSize or 14, 'OUTLINE')
			b.custom:SetText(t.text)
			b.custom:SetTextColor(c[1] or 1, c[2] or 1, c[3] or 1, c[4] or 1)
			b.custom:ClearAllPoints()
			b.custom:SetPoint('BOTTOM', b, 'TOP', cfg.labelX or 0, (cfg.labelY or 0) + 2)
			b.custom:Show()
		else
			b.custom:Hide()
		end
		b:ClearAllPoints()
		local offX, offY = (i - 1) * stepX, (i - 1) * stepY
		if grow == 'LEFT' then
			b:SetPoint('TOPRIGHT', holder, 'TOPRIGHT', -offX, 0)
		elseif grow == 'DOWN' then
			b:SetPoint('TOPLEFT', holder, 'TOPLEFT', 0, -offY)
		elseif grow == 'UP' then
			b:SetPoint('BOTTOMLEFT', holder, 'BOTTOMLEFT', 0, offY)
		else
			b:SetPoint('TOPLEFT', holder, 'TOPLEFT', offX, 0)
		end
		local duration = 8 + i * 2
		b.Cooldown:SetCooldown(GetTime() - (duration - (duration / (i + 1))), duration)
		StylePreviewCD(b.Cooldown, cfg.textSize or 16)
		b:Show()
	end
	for i = n + 1, #previewButtons do
		if previewButtons[i].custom then previewButtons[i].custom:Hide() end
		previewButtons[i]:Hide()
	end
end

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

local function Apply()
	EnsureFrames()
	-- force: settings may have changed the attach target or point, and the
	-- cheap mode check would see no change and skip the re-anchor
	ApplyPosition(true)
	SyncFollowTicker()
	holder:EnableMouse(previewActive and not GetCfg().lock or false)
	if previewActive then
		ApplyEngine() -- container off while previewing
		ShowPreview()
	else
		HidePreview()
		ApplyEngine()
	end
end
ns.BWApply = function() C_Timer.After(0, Apply) end

ns.BWIsPreview = function() return previewActive end
ns.BWSetPreview = function(state)
	previewActive = state and true or false
	Apply()
end

local relayoutPending = false

----------------------------------------------------------------------
-- Readability gate
--
-- While you are mid-portal, on a flight path or phasing, the unit's aura data
-- is in flux and the container is not to be trusted. Rather than let it draw
-- whatever it thinks it sees, the container is switched off until the unit
-- reads cleanly again - and switching it back on re-pushes the whitelist,
-- which is the part that actually fixes the flood.
----------------------------------------------------------------------
local function IsReadable(unit)
	if not unit then return false end
	local ok, connected = pcall(_G.UnitIsConnected, unit)
	if not ok or connected ~= true then return false end
	local okV, visible = pcall(_G.UnitIsVisible, unit)
	if not okV or visible ~= true then return false end
	local okP, phase = pcall(_G.UnitPhaseReason, unit)
	if not okP or phase ~= nil then return false end
	return true
end

-- HIDE THE CONTAINER OUTRIGHT while you are not in control of your character.
--
-- SetEnabled(false) stops the container updating but leaves the frame on
-- screen showing whatever it last drew. Hide() removes it, which is what is
-- actually wanted here - and it is reversible with a plain Show().
--
-- Dragonriding is the case that matters: it puts you in a VEHICLE, so
-- UNIT_ENTERED_VEHICLE / UNIT_EXITED_VEHICLE are the events that fire, not
-- any of the zone or phase ones. PLAYER_CONTROL_LOST / GAINED covers the rest
-- (taxi, cinematics, knockbacks, mind control).
-- SHOULD THE BAR BE HIDDEN RIGHT NOW?
--
-- Asked as a STATE question rather than tracked from events. Events alone are
-- fragile: miss one, get them out of order, or reload mid-flight and the bar
-- stays wrong until something else happens to fire. Querying the two conditions
-- directly cannot desync, and all three calls are combat-safe and taint-free.
--
-- Skyriding is "mounted AND flying" - mounted alone is a ground mount and the
-- bar should stay. Vehicle covers dragons proper, cannons, and any scripted
-- vehicle section.
local function ShouldSuppress()
	local ok, inVeh = pcall(function() return _G.UnitInVehicle and _G.UnitInVehicle('player') end)
	if ok and inVeh then return true end
	local okM, sky = pcall(function()
		return _G.IsMounted and _G.IsFlying and _G.IsMounted() and _G.IsFlying()
	end)
	if okM and sky then return true end
	return false
end

local suppressed
local function Suppress(on)
	if not container or not holder then return end
	on = on and true or false
	if on == suppressed then return end
	suppressed = on
	if on then
		container:Hide()
	else
		-- re-push the whitelist BEFORE showing, so the container is never
		-- briefly live without a filter
		ReapplyFilters()
		local cfg = GetCfg()
		if (cfg.enable and not previewActive) or previewActive then
			container:Show()
			pcall(container.UpdateAllAuras, container)
		end
	end
end
ns.BWSuppress = Suppress

local wasReadable
local function RefreshReadable()
	if not container then return end
	local readable = IsReadable('player')
	if readable == wasReadable then return end
	wasReadable = readable
	if readable then
		ReapplyFilters()
		local cfg = GetCfg()
		container:SetEnabled((cfg.enable and not previewActive) and true or false)
		pcall(container.UpdateAllAuras, container)
	else
		container:SetEnabled(false)
	end
end
ns.BWRefreshReadable = RefreshReadable

local evt = CreateFrame('Frame')
evt:RegisterEvent('PLAYER_ENTERING_WORLD')
evt:RegisterUnitEvent('UNIT_AURA', 'player')
-- A portal or a flight inside a key never fires PLAYER_ENTERING_WORLD - you
-- did not leave the instance - so these are the events that actually mark
-- those transitions. Each is pcall'd: an event name this client lacks would
-- raise and abort the rest of this file.
for _, e in ipairs({
	'ZONE_CHANGED', 'ZONE_CHANGED_INDOORS', 'ZONE_CHANGED_NEW_AREA',
	'PLAYER_CONTROL_LOST', 'PLAYER_CONTROL_GAINED',
	'UNIT_ENTERED_VEHICLE', 'UNIT_EXITED_VEHICLE',
	-- skyriding does not fire the vehicle events on every transition
	'PLAYER_MOUNT_DISPLAY_CHANGED',
}) do
	pcall(evt.RegisterEvent, evt, e)
end
pcall(evt.RegisterUnitEvent, evt, 'UNIT_PHASE', 'player')
pcall(evt.RegisterUnitEvent, evt, 'UNIT_CONNECTION', 'player')
pcall(evt.RegisterUnitEvent, evt, 'UNIT_FLAGS', 'player')

evt:SetScript('OnEvent', function(_, event)
	if event == 'PLAYER_ENTERING_WORLD' then
		wasReadable = nil
		suppressed = nil        -- re-evaluate after a loading screen
		C_Timer.After(1, function() Suppress(ShouldSuppress()) end)
		C_Timer.After(0.5, Apply)
		return
	end
	-- In a vehicle / not in control: hide the whole container. Out again: show
	-- it. Two events in, two events out, nothing to poll.
	-- The events are only a PROMPT to re-check; the state query decides. That
	-- way an event that never arrives, or arrives in the wrong order, cannot
	-- leave the bar stuck on or off.
	if event == 'UNIT_ENTERED_VEHICLE' or event == 'UNIT_EXITED_VEHICLE'
		or event == 'PLAYER_CONTROL_LOST' or event == 'PLAYER_CONTROL_GAINED'
		or event == 'PLAYER_MOUNT_DISPLAY_CHANGED' then
		Suppress(ShouldSuppress())
		return
	end
	if event == 'ZONE_CHANGED' or event == 'ZONE_CHANGED_INDOORS'
		or event == 'ZONE_CHANGED_NEW_AREA' or event == 'UNIT_PHASE'
		or event == 'UNIT_CONNECTION' or event == 'UNIT_FLAGS' then
		if previewActive or not container or not GetCfg().enable then return end
		wasReadable = nil     -- force the gate to re-evaluate
		RefreshReadable()
		-- and re-push regardless: the engine may have dropped the filters
		-- without the unit ever reading as unreadable
		ReapplyFilters()
		pcall(container.UpdateAllAuras, container)
		return
	end
	-- UNIT_AURA: the container refreshes its own CONTENTS, but the flow
	-- layout does not reliably re-pack when an aura drops - icons keep their
	-- old spot instead of sliding toward the anchor. UpdateAllAuras forces
	-- the full refresh INCLUDING relayout, which makes the row behave like a
	-- dynamic group (slot 1 always filled first). We never read the event
	-- payload (secret in 12.1) - this only pokes the engine.
	if relayoutPending or previewActive or not container then return end
	if not GetCfg().enable then return end
	relayoutPending = true
	C_Timer.After(0.05, function()
		relayoutPending = false
		if not previewActive and container and GetCfg().enable then
			pcall(container.UpdateAllAuras, container)
		end
	end)
end)
