-- TotemBar - ui.lua
-- The totem bar frame: 4 element buttons plus a Totemic Recall button.
-- Left-click an element button to cast its chosen totem; right-click
-- clears the slot. Hovering an element button pops an upward flyout of
-- the element's other known totems: left-click one to cast it once,
-- right-click one to set it as the slot's new default. Each element
-- button also carries an OmniCC-style remaining-duration timer text (hybrid
-- source: pfUI libtotem's GetTotemInfo when present, else TotemBar's
-- own cast-tracking - see core/cast.lua). No per-button text labels;
-- hover the button for a tooltip naming the element/totem. Each element
-- button also carries a native action-button-style radial cooldown
-- swipe (CooldownFrameTemplate) reflecting the chosen totem's spellbook
-- cooldown, IN ADDITION to the duration timer text. The Recall button's
-- icon pulses whenever any element totem is currently out of range.
-- WoW-API-only file; not offline-tested, only syntax-checked (see
-- tools/luatests notes in the repo).

TotemBar = TotemBar or {}

-- Defensive fallback in case DEFAULT_CHAT_FRAME is ever unset this early
-- in the loading sequence (guards against a later/leaner client build).
local ChatOut = DEFAULT_CHAT_FRAME or ChatFrame1

local BUTTON_SIZE = 36
-- 10 (was 4): the 48px round ring frames overhang their 36px buttons by
-- 6px per side; adjacent opaque ring bands (screen radius ~22.9px) need
-- button centers >= ~45.8px apart to not overlap. 36+10 = 46 clears it,
-- for the bar row and the flyout column alike. Now a live-adjustable
-- UPVALUE (not a constant): the options panel's "Button spacing" slider
-- (range 10-30px, TotemBarDB.buttonGap) reassigns this in place via
-- TotemBar.SetButtonGap below - every closure that reads BUTTON_GAP
-- (element/flyout/assign-panel positioning, ApplyBarLayout) sees the new
-- value on its next read. 10px is still the floor: below that, adjacent
-- ring bands start to overlap per the geometry above.
local BUTTON_GAP = TotemBar.DEFAULT_BUTTON_GAP or 10

-- Bar-layout grid (feature: selectable bar arrangement, TotemBarDB.barLayout,
-- see ApplyBarLayout below). Maps each saved layout string to its column
-- count; row count then falls out of math.ceil(buttonCount / cols). Cycle
-- order for the options panel's cycle button lives in TotemBar.BAR_LAYOUTS.
local BAR_LAYOUT_COLS = { ["1x6"] = 6, ["2x3"] = 3, ["3x2"] = 2 }
TotemBar.BAR_LAYOUTS = { "1x6", "2x3", "3x2" }

-- Rows use the SAME pitch as columns (user request: tight grid). The timer
-- text below a row slightly overlaps the next row's ring overhang; it draws
-- on the OVERLAY layer above it and stays readable.

-- Timer-text OnUpdate throttle: refresh at most this often (seconds),
-- not every frame, to keep the shared/guild-addon perf budget sane.
-- 0.1s (was 0.2): the same tick also refreshes the ripple wave INPUTS (the
-- wave animation itself runs every frame in UpdateWaves, driven off these).
-- Still allocation-free per tick.
local TIMER_UPDATE_INTERVAL = 0.1
local timerElapsed = 0

-- Destroyed-totem liveness poll throttle (see TotemBar.GUID_LIVENESS_POLL_INTERVAL,
-- core/cast.lua): a SLOWER gate layered on top of the 0.1s tick above, so
-- UnitExists/UnitHealth/UnitIsDeadOrGhost are only actually called once
-- every ~0.5s even though UpdateTimerDisplays itself runs 10x/sec. GetTime()-based
-- rather than an arg1 accumulator since UpdateTimerDisplays has no frame
-- delta of its own to accumulate.
local lastGuidLivenessCheck = nil

-- Fallback icon for an unresolved/unknown totem name (flyout icons,
-- ResolveTotemIcon, the pending-assignment panel). Element buttons' own
-- empty-slot state uses the custom sheet's per-element glyph instead
-- (see SetElementIcon/ELEMENT_EMPTY_CELL above), not this constant.
local EMPTY_ICON = "Interface\\Icons\\INV_Misc_QuestionMark"

-- Totemic Recall: exact icon is verified at button-creation time via a
-- spellbook scan (GetSpellTexture); this is only the last-resort
-- fallback if that scan fails to resolve a texture.
local RECALL_SPELL_NAME = "Totemic Recall"
local RECALL_ICON_FALLBACK = "Interface\\Icons\\Spell_Nature_AstralRecal"

-- Pulse UI - floating round icons + ripple pulse.
-- Ring flipbook: 8x8 grid texture (64 cells). Cells 1..62 = duration-arc
-- fill frames (0=empty .. 61=full), cell 63 = wave ring (ripple), cell 64 =
-- the decorative frame band - always shown, hides the icon's square
-- corners so it reads as a floating round icon. Texture generated by
-- tools/gen_ring_textures.js; path WITHOUT extension (client resolves
-- .blp/.tga).
local RING_TEXTURE = "Interface\\AddOns\\TotemBar\\textures\\ring_round"
local RING_FRAMES = 62
local RING_GRID_COLS = 8
-- Cell 64 = the permanent decorative frame band (now baked-in shadow look,
-- see the art contract in the Rev 6 texture generator) - always shown,
-- hides the icon's square corners so it reads as a floating round icon.
-- (Rev 5's user-selectable ring-skin picker, which swapped this cell for a
-- separate ring_tracks.tga, was reverted per user feedback - see
-- ApplyRoundFrame below.)
local RING_WAVE_CELL = 63
local RING_TRACK_CELL = 64
-- Built once at load (core/pulse.lua loads before ui.lua, see .toc); the
-- per-tick path only indexes this table.
local allRingCoords = TotemBar.buildRingTexCoords(64, RING_GRID_COLS)

-- Screen px the ring/wave textures are drawn at, centered on the 36px
-- button - the ring extends past the button's own edges so the decorative
-- frame band's opaque interior fully covers the icon's square corners (see
-- the texture generator's geometry rationale comment).
local RING_TEX_SIZE = 48
local WAVE_DURATION = 0.8      -- ripple lifetime, seconds
local WAVE_MAX_SCALE = 1.9     -- wave grows from RING_TEX_SIZE to *WAVE_MAX_SCALE
local WAVE_BASE_ALPHA = 0.85   -- wave alpha at spawn, fades to 0 by WAVE_DURATION

-- Circular pulse countdown (Rev 6): the linear pulse bar (Rev 1) was
-- replaced with a thin white inner arc drawn ON the band itself, since a
-- round readout fits the round slots better than a straight bar (user
-- feedback). Same flipbook mechanics as the duration ring (ringFill) above,
-- just a separate all-white texture so it reads distinctly from the
-- element-colored duration arc. Independently toggleable
-- (TotemBarDB.showPulseBars, key name kept from the old linear bar - no
-- migration needed) from the ripple wave (TotemBarDB.showPulseWaves).
local PULSEARC_TEXTURE = "Interface\\AddOns\\TotemBar\\textures\\ring_pulsearc"

-- Pulse UI Rev 3: anticipation glow + spawn ripple + round hover/pushed
-- click states, all drawn from one FX sheet (tools/gen_ring_textures.js):
-- 2x2 grid of 128px cells. Cell 1 = annular anticipation halo, cell 2 =
-- round hover ring, cell 3 = pushed press disc, cell 4 = spawn flash
-- (reserved). Path WITHOUT extension, same convention as RING_TEXTURE.
local FX_TEXTURE = "Interface\\AddOns\\TotemBar\\textures\\ring_fx"
local fxCoords = TotemBar.buildRingTexCoords(4, 2)
local FX_GLOW_CELL = 1     -- annular anticipation halo (drawn at 64px)
local FX_HOVER_CELL = 2    -- round hover ring (drawn at RING_TEX_SIZE)
local FX_PUSHED_CELL = 3   -- press disc (drawn at RING_TEX_SIZE)
local FX_FLASH_CELL = 4    -- reserved (spawn flash disc)
local GLOW_TEX_SIZE = 64
local GLOW_RAMP_START = 0.78   -- glow builds over the last 22% before a pulse
local GLOW_MAX_ALPHA = 0.7
local SPAWN_WAVE_DURATION = 0.6

-- Custom icon sheet (tools/gen_icons.js): 4x4 grid of 128px cells.
local ICONS_TEXTURE = "Interface\\AddOns\\TotemBar\\textures\\icons"
local iconCoords = TotemBar.buildRingTexCoords(16, 4)
local ELEMENT_EMPTY_CELL = { Fire = 1, Earth = 2, Water = 3, Air = 4 }
-- cell 5 reserved (custom recall icon, reverted per user feedback)
local ICON_DROPSET_CELL = 6
local ICON_CROP = 0.08 -- standard action-button crop for real spell icons

-- UI chrome sheet + panel skin (tools/gen_ui_textures.js).
local UI_TEXTURE = "Interface\\AddOns\\TotemBar\\textures\\ui"
local uiCoords = TotemBar.buildRingTexCoords(4, 2)
local UI_SHADOW_CELL = 1
local UI_EMBLEM_CELL = 2
local UI_DIVIDER_CELL = 3
local PANEL_BACKDROP = {
    bgFile = "Interface\\AddOns\\TotemBar\\textures\\panel_bg",
    edgeFile = "Interface\\AddOns\\TotemBar\\textures\\panel_border",
    tile = true, tileSize = 128, edgeSize = 16,
    insets = { left = 5, right = 5, top = 5, bottom = 5 },
}
TotemBar.PANEL_BACKDROP = PANEL_BACKDROP  -- shared with options.lua / bind.lua
TotemBar.UI_TEXTURE = UI_TEXTURE
TotemBar.uiCoords = uiCoords
TotemBar.UI_EMBLEM_CELL = UI_EMBLEM_CELL
TotemBar.UI_DIVIDER_CELL = UI_DIVIDER_CELL

local ELEMENT_COLORS = {
    Fire  = { 1.00, 0.45, 0.10 },
    Earth = { 0.40, 0.85, 0.30 },
    Water = { 0.25, 0.60, 1.00 },
    Air   = { 0.70, 0.55, 1.00 },
}

local elementButtons = {}         -- element -> button frame
local recallButton = nil          -- Totemic Recall button frame (for the auto-recall indicator refresh)
local dropSetButton = nil         -- DropSet button frame (for RefreshPulseUI's ring-skin re-apply)

-- True when ANY element's totem is currently out-of-range (the
-- buff-presence red tint, see UpdateTimerDisplays). Recomputed once per
-- UpdateTimerDisplays pass; drives the Recall button's icon pulse
-- (OnRecallUpdate below) as a "go recall + redeploy" visual prompt.
local anyOutOfRange = false

-- Hover flyout: hovering an element button pops a column of icon
-- buttons UPWARD for the OTHER known totems of that element (all known
-- for the element minus the currently-chosen default). Left-click one to
-- cast it ONCE without changing the slot's default; right-click one to
-- make it the slot's new default. Shares one frame + a pool of icon
-- buttons.
-- Computed from the static totem map, not hard-coded: Air has SEVEN totems,
-- and a hard-coded 6 silently dropped the last one (Tranquil Air Totem)
-- whenever the Air slot was empty -- the flyout then lists ALL known totems of
-- the element, not "all minus the chosen default".
local MAX_FLYOUT_ICONS = TotemBar.maxTotemsPerElement()   -- most totems any single element has
local FLYOUT_PAD = 4               -- inner padding inside the flyout frame
local FLYOUT_GAP = 2               -- gap between element button top and flyout bottom
local FLYOUT_HIDE_INTERVAL = 0.1   -- throttle for the mouse-leave hide check

local flyoutIcons = {}         -- pooled flyout icon buttons (created once)
local flyoutFrame = nil        -- lazily created shared flyout frame
local flyoutElement = nil      -- element currently shown in the flyout
local flyoutOwnerButton = nil  -- element button the flyout is anchored to
local flyoutElapsed = 0        -- throttle accumulator for the hide check

-- Forward declarations so functions defined later can be referenced by
-- closures created earlier in the file (standard Lua forward-decl idiom:
-- `local foo` then later `foo = function() ... end` / `function foo()`).
local RefreshButton
local RefreshCooldown
local EnsureFlyoutFrame
local ShowFlyout
local RefreshFlyoutCooldowns
local RefreshFlyoutMana
local HideFlyout
local OnFlyoutUpdate
local CreateElementButton
local CreateRecallButton
local CreateDropSetButton
local RefreshRecallIndicator
local OnRecallUpdate
local UpdateTimerDisplays
local UpdateWaves
local OnBarUpdate
local OnDragStart
local OnDragStop
local EnsureAssignFrame
local assignFrame        -- lazily created pending-suggestion panel

-- Finds the spellbook index of a known spell by exact name, or nil.
-- Cache-backed accessor from core/spellindex.lua (loaded earlier in the
-- TOC) -- used to be its own fresh linear GetSpellName() scan here.
local FindSpellIndexByName = TotemBar.findSpellIndex

-- Fills a tooltip with a totem spell, resolved to the HIGHEST known rank.
-- A first-name-match slot (FindSpellIndexByName) is RANK 1, so SetSpell on
-- it showed rank-1 mana/damage/duration - misleading, since the name-based
-- CAST always uses the highest rank. Also appends the rank to the name
-- line, which 1.12's SetSpell omits entirely.
local function SetSpellTooltip(tip, name)
    local idx = (TotemBar.findHighestRankSlot and TotemBar.findHighestRankSlot(name))
        or FindSpellIndexByName(name)
    if not idx then
        tip:SetText(name)
        return
    end
    tip:SetSpell(idx, BOOKTYPE_SPELL)
    local _, rank = GetSpellName(idx, BOOKTYPE_SPELL)
    if rank and rank ~= "" then
        tip:AppendText(" |cffa8a8a8(" .. rank .. ")|r")
    end
end

-- Resolves the icon texture path for whatever totem is currently chosen
-- for `element`, or nil when the slot is empty/unresolved. Callers route
-- the result through SetElementIcon, which draws the sheet's per-element
-- glyph for nil instead of a spellbook texture.
local function GetElementIcon(element)
    local db = TotemBarDB
    local name = db and db.chosen and db.chosen[element]
    if not name then
        return nil
    end
    local idx = FindSpellIndexByName(name)
    if not idx then
        return nil
    end
    return GetSpellTexture(idx, BOOKTYPE_SPELL)
end

-- Resolves (iconTexture, known) for an arbitrary totem name: the spellbook
-- texture when the player knows it, else the empty/question-mark icon and
-- known=false (the pending panel greys unknown totems).
local function ResolveTotemIcon(name)
    if not name then
        return EMPTY_ICON, false
    end
    local idx = FindSpellIndexByName(name)
    if not idx then
        return EMPTY_ICON, false
    end
    local texture = GetSpellTexture(idx, BOOKTYPE_SPELL)
    return texture or EMPTY_ICON, true
end

-- Resolves the Totemic Recall icon by scanning the spellbook for the
-- real spell (so it matches whatever texture TWoW actually ships),
-- falling back to a hardcoded texture if the scan can't find it.
local function GetRecallIcon()
    local idx = FindSpellIndexByName(RECALL_SPELL_NAME)
    if idx then
        local texture = GetSpellTexture(idx, BOOKTYPE_SPELL)
        if texture then
            return texture
        end
    end
    return RECALL_ICON_FALLBACK
end

-- Sets an element button's icon: real spell icon (standard crop) when a
-- totem is chosen/resolved, element glyph from the sheet when empty.
-- SetTexCoord persists across SetTexture, so both branches must set it.
local function SetElementIcon(btn, texturePath)
    if texturePath then
        btn.icon:SetTexture(texturePath)
        btn.icon:SetTexCoord(ICON_CROP, 1 - ICON_CROP, ICON_CROP, 1 - ICON_CROP)
    else
        local c = iconCoords[ELEMENT_EMPTY_CELL[btn.element] or 1]
        btn.icon:SetTexture(ICONS_TEXTURE)
        btn.icon:SetTexCoord(c.l, c.r, c.t, c.b)
    end
end

RefreshButton = function(element)
    local btn = elementButtons[element]
    if not btn then
        return
    end
    SetElementIcon(btn, GetElementIcon(element))
    RefreshCooldown(element)
end

-- Refreshes element `element`'s native cooldown swipe (the same radial
-- CooldownFrameTemplate widget standard action buttons use) to match its
-- currently-chosen totem's spellbook cooldown. Clears the swipe (start=0)
-- when the slot is empty or unresolved. Called from RefreshButton (i.e.
-- on selection changes and once at bar-build time) and from the
-- SPELL_UPDATE_COOLDOWN event handler below - NEVER from the per-tick
-- timer OnUpdate, since re-calling CooldownFrame_SetTimer every tick
-- would restart the swipe's animation instead of letting it play.
RefreshCooldown = function(element)
    local btn = elementButtons[element]
    if not btn then
        return
    end
    local db = TotemBarDB
    local totemName = db and db.chosen and db.chosen[element]
    local idx = totemName and FindSpellIndexByName(totemName)
    if not idx then
        CooldownFrame_SetTimer(btn.cd, 0, 0, 0)
        return
    end
    local start, duration, enable = GetSpellCooldown(idx, BOOKTYPE_SPELL)
    CooldownFrame_SetTimer(btn.cd, start, duration, enable)
end

-- Glue for core/assign.lua's pending-assignment logic: it stays WoW-API-
-- light and reaches the spellbook / bar refresh through these slots.
-- isTotemKnown resolves a totem name against the live spellbook; RefreshAll
-- re-skins every element button (used after a pending assignment is applied).
TotemBar.isTotemKnown = function(name)
    return FindSpellIndexByName(name) ~= nil
end

TotemBar.RefreshAll = function()
    local elements = TotemBar.TOTEM_ELEMENTS
    for i = 1, table.getn(elements) do
        RefreshButton(elements[i])
    end
end

-- Bind-mode key overlays: a small top-right FontString on each bindable
-- button/flyout icon, ALWAYS shown (action-bar-hotkey style) whenever a key
-- is bound, hidden only when nothing is bound. Independent of bind mode.
-- Declared here, BEFORE EnsureFlyoutFrame/CreateElementButton/
-- CreateRecallButton/CreateDropSetButton (all of which call
-- registerBindOverlay from their function bodies): Lua resolves a `local`
-- as an upvalue only for code appearing lexically after its declaration, so
-- this block must sit above every factory that references it, not just
-- above CreateElementButton (EnsureFlyoutFrame is defined earlier in the
-- file than the element/recall/dropset button factories).
local bindOverlayTargets = {}   -- { frame = button, action = fn()->command }

local function ensureBindOverlay(frame)
    if frame.bindKeyText then
        return frame.bindKeyText
    end
    local fs = frame:CreateFontString(nil, "OVERLAY")
    fs:SetFont("Fonts\\FRIZQT__.TTF", 10, "OUTLINE")
    fs:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -1, -1)
    fs:SetTextColor(1, 0.9, 0.2)
    fs:Hide()
    frame.bindKeyText = fs
    return fs
end

-- action is a function returning the binding command for this frame right now
-- (flyout icons change totem), or nil.
local function registerBindOverlay(frame, actionFn)
    ensureBindOverlay(frame)
    tinsert(bindOverlayTargets, { frame = frame, action = actionFn })
end

TotemBar.refreshBindOverlays = function()
    for i = 1, table.getn(bindOverlayTargets) do
        local t = bindOverlayTargets[i]
        local fs = t.frame.bindKeyText
        local cmd = t.action()
        local key = cmd and GetBindingKey(cmd) or nil
        if key then
            fs:SetText(TotemBar.shortenKey(key))
            fs:Show()
        else
            fs:Hide()
        end
    end
end

-- Replaces the square Blizzard hover/pushed art with round FX-sheet states,
-- sized to the ring frame so they hug the round button. Declared here,
-- BEFORE EnsureFlyoutFrame (same ordering constraint as registerBindOverlay
-- above): the pooled flyout icons' creation loop lives inside
-- EnsureFlyoutFrame and calls this, so it must already be a visible upvalue
-- at that point in the file, not just before CreateElementButton/
-- CreateRecallButton/CreateDropSetButton further down.
local function ApplyRoundClickStates(btn, size)
    btn:SetHighlightTexture(FX_TEXTURE, "ADD")
    local h = btn:GetHighlightTexture()
    if h then
        local c = fxCoords[FX_HOVER_CELL]
        h:SetTexCoord(c.l, c.r, c.t, c.b)
        h:ClearAllPoints()
        h:SetPoint("CENTER", btn, "CENTER", 0, 0)
        h:SetWidth(size)
        h:SetHeight(size)
    end
    btn:SetPushedTexture(FX_TEXTURE)
    local p = btn:GetPushedTexture()
    if p then
        local c = fxCoords[FX_PUSHED_CELL]
        p:SetTexCoord(c.l, c.r, c.t, c.b)
        p:ClearAllPoints()
        p:SetPoint("CENTER", btn, "CENTER", 0, 0)
        p:SetWidth(size)
        p:SetHeight(size)
    end
end

-- Repositions every pooled flyout icon per the current BUTTON_GAP (mirrors
-- the bottom-up stacking math used when the icons are first built, below).
-- The icon pool is built lazily ONCE and its anchors are baked in at that
-- point - they don't reflow themselves - so this must be called again
-- whenever BUTTON_GAP changes live (TotemBar.SetButtonGap), not just at
-- build time. `frame` lets the build-time call pass the not-yet-assigned
-- local `f` directly; later calls omit it and fall back to the flyoutFrame
-- upvalue (guarded: a no-op if the flyout has never been built yet).
local function layoutFlyoutIcons(frame)
    local f = frame or flyoutFrame
    if not f then
        return
    end
    for i = 1, MAX_FLYOUT_ICONS do
        local ico = flyoutIcons[i]
        if ico then
            ico:ClearAllPoints()
            ico:SetPoint("BOTTOM", f, "BOTTOM", 0, FLYOUT_PAD + (i - 1) * (BUTTON_SIZE + BUTTON_GAP))
        end
    end
end

-- Lazily builds the single shared flyout frame plus its pool of icon
-- buttons (TotemBarFlyoutIcon1..MAX_FLYOUT_ICONS), stacked bottom-up so
-- ShowFlyout can just Show the first N. DIALOG strata so it draws above
-- the bar backdrop. Same anti-bevel icon treatment as the element
-- buttons (opaque black backdrop under an inset, cropped ARTWORK icon).
EnsureFlyoutFrame = function()
    if flyoutFrame then
        return flyoutFrame
    end

    -- Parent to the bar (not UIParent) so the flyout inherits the bar's
    -- scale (UI-size slider) and hides with it. Still DIALOG strata below,
    -- so it draws above the bar backdrop regardless of parent.
    local f = CreateFrame("Frame", "TotemBarFlyout", TotemBarFrame)
    f:SetFrameStrata("DIALOG")
    f:SetWidth(BUTTON_SIZE + FLYOUT_PAD * 2)
    f:SetHeight(BUTTON_SIZE + FLYOUT_PAD * 2)
    f:SetBackdrop({
        bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        tile = true, tileSize = 16, edgeSize = 16,
        insets = { left = 4, right = 4, top = 4, bottom = 4 },
    })
    -- Floating icons: fully transparent flyout backdrop/border (the flyout
    -- frame itself is now just an invisible hit-test/layout area).
    f:SetBackdropColor(0, 0, 0, 0)
    f:SetBackdropBorderColor(0, 0, 0, 0)
    f:EnableMouse(true)         -- so gaps/padding still count as "over the flyout"
    f:SetClampedToScreen(true)
    f:Hide()

    for i = 1, MAX_FLYOUT_ICONS do
        local ico = CreateFrame("Button", "TotemBarFlyoutIcon" .. i, f)
        ico:SetWidth(BUTTON_SIZE)
        ico:SetHeight(BUTTON_SIZE)
        -- Bottom-up: icon 1 just inside the bottom padding, each next
        -- one a full button+gap higher (so nearest the element button
        -- is first). Anchor applied below via layoutFlyoutIcons (once the
        -- whole pool exists), not here - the same helper re-applies it
        -- live when BUTTON_GAP changes.

        ico:SetBackdrop({
            bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
            edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
            tile = true, tileSize = 16, edgeSize = 12,
            insets = { left = 3, right = 3, top = 3, bottom = 3 },
        })
        ico:SetBackdropColor(0, 0, 0, 0)
        ico:SetBackdropBorderColor(0, 0, 0, 0)

        local icon = ico:CreateTexture("TotemBarFlyoutIcon" .. i .. "Icon", "ARTWORK")
        icon:SetPoint("TOPLEFT", ico, "TOPLEFT", 3, -3)
        icon:SetPoint("BOTTOMRIGHT", ico, "BOTTOMRIGHT", -3, 3)
        icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
        ico.icon = icon

        -- Grounding shadow ellipse (Rev 4 UI chrome), same as the element
        -- buttons' - BACKGROUND layer, no glow on flyout icons so ordering
        -- relative to other regions doesn't matter here.
        local shadow = ico:CreateTexture("TotemBarFlyoutIcon" .. i .. "Shadow", "BACKGROUND")
        shadow:SetTexture(UI_TEXTURE)
        local sc = uiCoords[UI_SHADOW_CELL]
        shadow:SetTexCoord(sc.l, sc.r, sc.t, sc.b)
        shadow:SetPoint("CENTER", ico, "CENTER", 0, -15)
        shadow:SetWidth(58)
        shadow:SetHeight(20)
        shadow:SetAlpha(0.7)  -- softened, see the element buttons' plate note
        ico.shadow = shadow

        -- Floating round frame, same decorative band as the element
        -- buttons (no fill/tint - just the permanent frame). Flyout icons
        -- are BUTTON_SIZE (36px) too, so the proportional ring size
        -- S*(48/36) is just RING_TEX_SIZE (48px) again.
        local ringTrack = ico:CreateTexture("TotemBarFlyoutIcon" .. i .. "RingTrack", "OVERLAY")
        ringTrack:SetTexture(RING_TEXTURE)
        local rtc = allRingCoords[RING_TRACK_CELL]
        ringTrack:SetTexCoord(rtc.l, rtc.r, rtc.t, rtc.b)
        ringTrack:SetPoint("CENTER", ico, "CENTER", 0, 0)
        ringTrack:SetWidth(RING_TEX_SIZE)
        ringTrack:SetHeight(RING_TEX_SIZE)
        ico.ringTrack = ringTrack

        -- Native cooldown swipe over the flyout icon, so the player can
        -- see which alternative totems are on cooldown while choosing.
        -- Same "Model" (NOT "Cooldown") frame-type caveat as the element
        -- buttons: vanilla 1.12 has no dedicated Cooldown widget type -
        -- CooldownFrameTemplate is a Model template here (see the element
        -- button's cd note above). Driven once at flyout-open by
        -- ShowFlyout (the widget animates itself from start+duration), so
        -- no per-frame refresh for this transient popup.
        local cd = CreateFrame("Model", "TotemBarFlyoutIcon" .. i .. "Cooldown", ico, "CooldownFrameTemplate")
        cd:SetAllPoints(ico.icon)
        cd:SetFrameLevel(ico:GetFrameLevel())
        ico.cd = cd

        -- Round hover/pushed states (Rev 3), same FX sheet as the bar
        -- buttons; flyout icons are BUTTON_SIZE, sized like the bar's ring.
        ApplyRoundClickStates(ico, RING_TEX_SIZE)
        ico:RegisterForClicks("LeftButtonUp", "RightButtonUp")

        -- Left-click: cast once for the element the flyout is currently
        -- showing. recordCast() drives THAT element's timer to the
        -- actually-cast totem, without touching the slot's chosen
        -- default. Right-click: make this totem the slot's new chosen
        -- default and re-populate the flyout in place (so the
        -- newly-chosen totem drops out of the "others" list and the
        -- previously-chosen one appears), keeping the flyout open.
        ico:SetScript("OnClick", function()
            if not (this.totemName and flyoutElement) then
                return
            end
            if arg1 == "RightButton" then
                local element = flyoutElement
                local owner = flyoutOwnerButton
                TotemBarDB.chosen[element] = this.totemName
                RefreshButton(element)
                if owner then
                    ShowFlyout(owner, element)
                end
            else
                CastSpellByName(this.totemName)
                TotemBar.recordCast(flyoutElement, this.totemName)
                -- Immediate cooldown feedback: don't wait for
                -- SPELL_UPDATE_COOLDOWN. Refreshes the bar button's swipe
                -- (in case the cast totem is also this element's chosen
                -- default) and the flyout icon's own swipe.
                RefreshCooldown(flyoutElement)
                RefreshFlyoutCooldowns()
            end
        end)

        ico:SetScript("OnEnter", function()
            if this.totemName then
                GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
                SetSpellTooltip(GameTooltip, this.totemName)
                GameTooltip:AddLine("Left-click: cast  /  Right-click: set default", 1, 1, 1)
                GameTooltip:Show()
            end
        end)
        ico:SetScript("OnLeave", function()
            GameTooltip:Hide()
        end)

        ico:Hide()
        registerBindOverlay(ico, function()
            if ico.totemName then
                return "TOTEMBAR_TOTEM_" .. TotemBar.bindingSuffix(ico.totemName)
            end
            return nil
        end)
        flyoutIcons[i] = ico
    end

    -- Anchors every pooled icon per BUTTON_GAP now that the whole pool
    -- exists (flyoutFrame isn't assigned yet at this point in the function,
    -- hence passing `f` explicitly - see layoutFlyoutIcons's own comment).
    layoutFlyoutIcons(f)

    -- OnUpdate only fires while the frame is shown, so this hide-check
    -- naturally stops running once the flyout is hidden.
    f:SetScript("OnUpdate", OnFlyoutUpdate)

    flyoutFrame = f
    return f
end

-- Populates and shows the flyout above `button` for `element`, listing
-- every known totem of that element EXCEPT the currently-chosen default.
-- Shows nothing if there are no "others".
ShowFlyout = function(button, element)
    local f = EnsureFlyoutFrame()

    local db = TotemBarDB
    local spellNames = TotemBar.scanSpellbook()
    local known = TotemBar.knownTotems(spellNames, element)
    local chosen = db and db.chosen and db.chosen[element]

    local count = 0
    for i = 1, table.getn(known) do
        local totemName = known[i]
        if totemName ~= chosen and count < MAX_FLYOUT_ICONS then
            count = count + 1
            local ico = flyoutIcons[count]
            ico.totemName = totemName
            local idx = FindSpellIndexByName(totemName)
            local texture = idx and GetSpellTexture(idx, BOOKTYPE_SPELL)
            ico.icon:SetTexture(texture or EMPTY_ICON)
            -- Drive the swipe once here at flyout-open so the player can
            -- see which alternative totems are on cooldown; the widget
            -- animates itself from start+duration, no per-tick refresh.
            if idx then
                local start, duration, enable = GetSpellCooldown(idx, BOOKTYPE_SPELL)
                CooldownFrame_SetTimer(ico.cd, start, duration, enable)
            else
                CooldownFrame_SetTimer(ico.cd, 0, 0, 0)
            end
            ico:Show()
        end
    end

    for j = count + 1, MAX_FLYOUT_ICONS do
        flyoutIcons[j]:Hide()
        flyoutIcons[j].totemName = nil
        -- Clear the swipe on unused pool icons so a stale cooldown from a
        -- previous open doesn't linger if this slot is reused later.
        CooldownFrame_SetTimer(flyoutIcons[j].cd, 0, 0, 0)
    end

    if count == 0 then
        -- No other known totems for this element; don't show an empty box.
        flyoutElement = nil
        flyoutOwnerButton = nil
        f:Hide()
        return
    end

    flyoutElement = element
    flyoutOwnerButton = button
    flyoutElapsed = 0

    f:SetHeight(count * (BUTTON_SIZE + BUTTON_GAP) - BUTTON_GAP + FLYOUT_PAD * 2)
    f:ClearAllPoints()
    f:SetPoint("BOTTOM", button, "TOP", 0, FLYOUT_GAP)
    f:Show()
    -- Freshly-populated flyout icons need their overlays re-driven right
    -- away (rather than waiting for the next UPDATE_BINDINGS/toggle) so
    -- keys show immediately while bind mode is already on.
    if TotemBar.refreshBindOverlays then
        TotemBar.refreshBindOverlays()
    end
    RefreshFlyoutMana()
end

-- Re-drives the cooldown swipe on every CURRENTLY-SHOWN pooled flyout
-- icon, re-resolving each one's spellbook index fresh (so a just-cast
-- totem's swipe reflects the cooldown that cast just started). No-op if
-- the flyout isn't open. Doesn't touch icon texture/totemName/layout -
-- only the swipe - so it's safe to call from anywhere without disturbing
-- ShowFlyout's own population pass (which sets the initial swipe state
-- itself, including clearing unused pool icons; left as-is here).
RefreshFlyoutCooldowns = function()
    if not (flyoutFrame and flyoutFrame:IsShown()) then
        return
    end
    for i = 1, MAX_FLYOUT_ICONS do
        local ico = flyoutIcons[i]
        if ico:IsShown() and ico.totemName then
            local idx = FindSpellIndexByName(ico.totemName)
            if idx then
                local start, duration, enable = GetSpellCooldown(idx, BOOKTYPE_SPELL)
                CooldownFrame_SetTimer(ico.cd, start, duration, enable)
            else
                CooldownFrame_SetTimer(ico.cd, 0, 0, 0)
            end
        end
    end
end

-- Dims every shown flyout icon the player cannot currently afford, on the same
-- rule as the bar buttons (TotemBar.iconTintFor / notEnoughMana). Flyout icons
-- have no range state of their own -- they are alternatives that aren't out --
-- so only the mana half applies. Cheap: the cost lookup is cached, and the
-- flyout only exists while it is hovered.
RefreshFlyoutMana = function()
    if not (flyoutFrame and flyoutFrame:IsShown()) then
        return
    end
    local playerMana = (type(UnitMana) == "function") and UnitMana("player") or nil
    for i = 1, MAX_FLYOUT_ICONS do
        local ico = flyoutIcons[i]
        if ico:IsShown() and ico.totemName then
            -- No Clearcasting exemption (2026-08-28): Elemental Focus does not
            -- cover totems, see core/manacost.lua.
            local oom = TotemBar.notEnoughMana(TotemBar.getTotemManaCost(ico.totemName), playerMana)
            local r, g, b, key = TotemBar.iconTintFor(false, oom)
            if key ~= ico.tintKey then
                ico.icon:SetVertexColor(r, g, b)
                ico.tintKey = key
            end
        end
    end
end

HideFlyout = function()
    if flyoutFrame then
        flyoutFrame:Hide()
    end
    flyoutElement = nil
    flyoutOwnerButton = nil
end

-- Throttled mouse-leave check: hide the flyout once the cursor is over
-- NEITHER the owning element button NOR the flyout itself. This keeps
-- it open while the mouse travels from the button up onto the flyout.
-- No per-frame allocation - just the accumulator and two MouseIsOver
-- geometry checks, gated to run ~every FLYOUT_HIDE_INTERVAL seconds.
OnFlyoutUpdate = function()
    flyoutElapsed = flyoutElapsed + arg1
    if flyoutElapsed < FLYOUT_HIDE_INTERVAL then
        return
    end
    flyoutElapsed = 0
    RefreshFlyoutMana()
    if flyoutOwnerButton and (MouseIsOver(flyoutFrame) or MouseIsOver(flyoutOwnerButton)) then
        return
    end
    HideFlyout()
end

-- Applies the floating round-icon frame to one button: permanent
-- decorative-frame texture (ringTrack) + duration-arc texture/tint
-- (ringFill, where present) + wave tint (pulseWave, where present), and
-- blanks the button's own square backdrop/border - the frame ring now IS
-- the visual frame, always (no ring-option gate: even with the duration
-- arc hidden, the permanent ring frame stays visible and the button must
-- never show a black box behind it). Used for element buttons (which have
-- ringFill/pulseWave) and the Recall/DropSet buttons (which don't, guarded
-- below) alike.
local function ApplyRoundFrame(btn)
    btn.ringTrack:SetTexture(RING_TEXTURE)
    local trc = allRingCoords[RING_TRACK_CELL]
    btn.ringTrack:SetTexCoord(trc.l, trc.r, trc.t, trc.b)

    if btn.ringFill then
        btn.ringFill:SetTexture(RING_TEXTURE)
        -- No element-color assignment here anymore: the duration ring is
        -- now a time-remaining traffic light (green->yellow->orange->red),
        -- recolored every tick in UpdateTimerDisplays via TotemBar.timeColor.
        btn.ringLastIdx = nil   -- force a fill-frame re-apply on the next tick
    end

    if btn.pulseArc then
        local c = ELEMENT_COLORS[btn.element]
        if c then
            btn.pulseArc:SetVertexColor(c[1], c[2], c[3], 0.95)
        end
    end

    if btn.pulseWave then
        local c = ELEMENT_COLORS[btn.element]
        if c then
            btn.pulseWave:SetVertexColor(c[1], c[2], c[3])
        end
    end

    if btn.glow then
        local c = ELEMENT_COLORS[btn.element]
        if c then
            btn.glow:SetVertexColor(c[1], c[2], c[3])
        end
    end

    btn:SetBackdropColor(0, 0, 0, 0)
    btn:SetBackdropBorderColor(0, 0, 0, 0)
end

-- The timer text hangs below the button (the pulse arc and ripple wave are
-- both centered ON the button, so neither competes for this anchor).
local function ApplyTimerAnchor(btn)
    btn.timerText:ClearAllPoints()
    btn.timerText:SetPoint("TOP", btn, "BOTTOM", 0, -1)
end

-- Deterministic-stacking child frame for regions that must render above
-- ALL of a button's own regions regardless of draw layer (2026-07-16,
-- HIGHLIGHT-hover regression fix). Background: f3644c5 moved
-- ringFill/pulseWave/pulseArc (and the Recall button's autoIndicator) to
-- the HIGHLIGHT layer to beat ringTrack's OVERLAY layer - but 1.12 only
-- renders a Button's HIGHLIGHT draw layer while the mouse is hovering it
-- (in-game confirmed: every fill ring/indicator went invisible except
-- under the cursor). Layers were never going to work for "always-on-top,
-- always visible" content on a Button. The fix used here instead (the same
-- pattern used elsewhere for row-highlight panel frames): WoW's cross-frame
-- draw order is primarily (strata,
-- frame level) - a CHILD FRAME at a higher FrameLevel than its parent
-- draws every one of its own regions above ALL of the parent's regions,
-- no matter which of the 5 draw layers either side uses. So a small,
-- mouse-transparent child frame pinned over the button's own bounds,
-- one level higher, gives deterministic top-of-stack rendering without
-- spending any of the parent's own 5-layer budget.
local function CreateRingOverlay(btn, name)
    local overlay = CreateFrame("Frame", name, btn)
    overlay:EnableMouse(false)          -- pure visual layer, must not steal btn's hover/click
    overlay:SetAllPoints(btn)           -- deckungsgleich with btn; CENTER anchors below resolve identically
    overlay:SetFrameLevel(btn:GetFrameLevel() + 1)
    return overlay
end

CreateElementButton = function(element, index)
    local name = "TotemBarButton" .. element
    local btn = CreateFrame("Button", name, TotemBarFrame)
    btn:SetWidth(BUTTON_SIZE)
    btn:SetHeight(BUTTON_SIZE)
    btn:SetPoint("LEFT", TotemBarFrame, "LEFT", (index - 1) * (BUTTON_SIZE + BUTTON_GAP) + BUTTON_GAP, 0)

    -- Backdrop template kept (needed for SetBackdropColor/BorderColor
    -- below) but floating: ApplyRoundFrame (called later in this function)
    -- blanks both to fully transparent - the decorative ring frame is the
    -- only visible frame around the icon now.
    btn:SetBackdrop({
        bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        tile = true, tileSize = 16, edgeSize = 12,
        insets = { left = 3, right = 3, top = 3, bottom = 3 },
    })

    local icon = btn:CreateTexture(name .. "Icon", "ARTWORK")
    -- Inset a few px from the button's own edges (the floating decorative
    -- ring frame, drawn OVERLAY on top, covers this margin and the icon's
    -- square corners - see ApplyRoundFrame/the texture generator's
    -- geometry rationale).
    icon:SetPoint("TOPLEFT", btn, "TOPLEFT", 3, -3)
    icon:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", -3, 3)
    btn.icon = icon
    -- Set ahead of SetElementIcon below (which reads btn.element to pick
    -- the empty-slot glyph cell); reassigned harmlessly again further
    -- down alongside the rest of the button's setup, same repeat-write
    -- pattern as the element-tint lookup ahead of ApplyRoundFrame below.
    btn.element = element
    -- SetElementIcon sets both texture and texcoord (SetTexCoord persists
    -- across SetTexture, so both the real-icon crop and the sheet-glyph
    -- coords always get (re)applied together - see its own comment).
    SetElementIcon(btn, GetElementIcon(element))

    -- Native action-button-style radial cooldown swipe (the same
    -- CooldownFrameTemplate widget the default action bars use),
    -- covering the icon area. This is IN ADDITION to the OmniCC-style
    -- duration timer text below; RefreshCooldown() drives it from
    -- events, see there for why it's not driven off the per-tick timer.
    --
    -- Frame type is "Model", NOT "Cooldown": vanilla 1.12 has no
    -- dedicated Cooldown widget type (that was added in TBC/2.0) -
    -- CooldownFrameTemplate is a Model-type template in 1.12 FrameXML
    -- that renders Interface\Cooldown\UI-Cooldown-Indicator.mdx (same
    -- approach pfUI's own libtotem/action bars use). CreateFrame with
    -- type "Cooldown" would error on this client.
    local cd = CreateFrame("Model", name .. "Cooldown", btn, "CooldownFrameTemplate")
    cd:SetAllPoints(icon)
    -- Match the button's own frame level instead of the default child
    -- bump (parent level + 1): WoW's cross-frame draw order is primarily
    -- (strata, level) - at the default +1 level the ENTIRE swipe frame
    -- would draw above ALL of btn's own regions regardless of draw
    -- layer, including the OVERLAY timer text below. At the SAME level,
    -- ordering falls back to per-region draw layer, letting the OVERLAY
    -- timer text render on top of the swipe as intended. Still flagged
    -- for an in-game visual check (see file header note).
    cd:SetFrameLevel(btn:GetFrameLevel())
    btn.cd = cd

    -- OmniCC-style remaining-duration text, anchored BELOW the button
    -- (centered under the icon) so it doesn't overlap the native
    -- cooldown swipe (btn.cd) rendered on top of the icon. Parented to
    -- btn (a Button, like the element buttons themselves), not to the
    -- bar's backdrop Frame - plain 1.12 Frames don't clip child regions
    -- (no SetClipsChildren in this client), so text hanging below the
    -- bar's backdrop still renders fully; flagged for an in-game visual
    -- check regardless (see file header note). Hidden by default;
    -- UpdateTimerDisplays() shows/updates it.
    --
    -- Left parented to btn (NOT moved to ringOverlay), 2026-07-16
    -- HIGHLIGHT-hover regression fix: geometry check (btn height 36,
    -- ring RING_TEX_SIZE 48 centered on btn -> ring overhangs 6px past
    -- every edge) puts ring:GetBottom() at btn:GetBottom()-6, while this
    -- FontString's SetPoint("TOP", btn, "BOTTOM", 0, -1) puts its OWN top
    -- at only btn:GetBottom()-1 - i.e. a ~5px vertical band where the two
    -- regions' bounding boxes technically overlap, directly under the
    -- button's center. In practice that band is the ring's transparent
    -- annulus hole (the band's opaque pixels hug its outer 48px edge, not
    -- its center) plus the font's own ascender padding above the glyphs,
    -- so no visible ink collision - stays on btn/OVERLAY, unchanged.
    local timerText = btn:CreateFontString(name .. "Timer", "OVERLAY")
    timerText:SetFont("Fonts\\FRIZQT__.TTF", 14, "OUTLINE")
    timerText:SetPoint("TOP", btn, "BOTTOM", 0, -1)
    timerText:SetJustifyH("CENTER")
    timerText:SetText("")
    timerText:Hide()
    btn.timerText = timerText
    btn.timerVisible = false       -- cached shown-state, avoid redundant Show/Hide
    btn.timerLastText = nil        -- cached last string, avoid redundant SetText
    btn.timerLastLow = nil         -- cached last <=5s tint state, avoid redundant SetTextColor
    btn.tintRed = false            -- last out-of-range verdict (read by the Recall button's pulse)
    btn.tintKey = 0                -- cached composed tint (range + out-of-mana), avoid redundant SetVertexColor

    -- Set here (ahead of the later btn.element = element below) because
    -- ApplyRoundFrame, called at the end of this block, needs it for the
    -- element-tint color lookup; the later assignment is then a harmless
    -- no-op repeat of the same value.
    btn.element = element

    -- Grounding shadow ellipse (Rev 4 UI chrome). BACKGROUND layer - the
    -- anticipation glow below used to also be BACKGROUND on the (wrong)
    -- assumption that same-layer draw order follows creation order in this
    -- client; the 2026-07-16 ring-occlusion investigation live-confirmed
    -- that's false (ringTrack was randomly hiding ringFill despite being
    -- created first) - 1.12 discards SetDrawLayer's sublevel argument, so
    -- same-MAIN-layer siblings really do have undefined relative order.
    -- glow now sits on its own BORDER layer instead (below), which is the
    -- real fix: no assumption needed when the two don't share a layer.
    local shadow = btn:CreateTexture(name .. "Shadow", "BACKGROUND")
    shadow:SetTexture(UI_TEXTURE)
    local sc = uiCoords[UI_SHADOW_CELL]
    shadow:SetTexCoord(sc.l, sc.r, sc.t, sc.b)
    shadow:SetPoint("CENTER", btn, "CENTER", 0, -15)
    shadow:SetWidth(58)
    shadow:SetHeight(20)
    -- Softened after preview review: full-strength plate read boxy on light
    -- ground; flatter + more transparent grounds without drawing attention.
    shadow:SetAlpha(0.7)
    btn.shadow = shadow

    -- Pulse UI Rev 2: floating round frame (permanent decorative band +
    -- duration-arc fill) + ripple wave. Sized to RING_TEX_SIZE (48px)
    -- centered on the 36px button, NOT SetAllPoints - the ring extends
    -- past the button's own edges (see the geometry rationale in the
    -- texture generator).
    --
    -- Frame-level stacking (2026-07-16, HIGHLIGHT-hover regression fix -
    -- see CreateRingOverlay's own comment for the full story): ringTrack,
    -- ringFill, pulseWave and pulseArc are all centered on the same point
    -- at the same RING_TEX_SIZE, i.e. all four spatially overlap. A prior
    -- fix moved the three dynamic ones to HIGHLIGHT to beat ringTrack's
    -- OVERLAY - but 1.12 only paints a Button's HIGHLIGHT layer on hover,
    -- so that made them invisible almost always. ringTrack is the one
    -- PERMANENT member of this quartet (always shown, see ringTrack:Show()
    -- below) so it stays right where it was: parented to btn, OVERLAY
    -- layer. ringFill/pulseWave/pulseArc move to a dedicated child frame
    -- (ringOverlay) one FrameLevel above btn, which puts all three above
    -- ringTrack unconditionally, regardless of draw layer. Within that
    -- child frame the three still need to not collide with EACH OTHER
    -- (1.12 discards SetDrawLayer's sublevel argument, so same-main-layer
    -- siblings have undefined relative order) - given 3 items and a full
    -- 5-layer budget to itself now, each gets its own layer:
    --   ringFill  = ARTWORK   (base: the colored duration-remaining ring)
    --   pulseWave = OVERLAY   (the brief per-pulse ripple must read above
    --                          the fill it pulses over)
    --   pulseArc  = HIGHLIGHT (the thin white countdown-to-next-pulse
    --                          readout; per its own file comment it must
    --                          "read distinctly" from ringFill, so it gets
    --                          the topmost layer to stay legible even
    --                          while a wave is mid-flash)
    -- All three are still spatially anchored to the SAME center point as
    -- before (now via ringOverlay, which is deckungsgleich with btn -
    -- SetAllPoints - so a CENTER anchor resolves to the identical pixel
    -- as anchoring to btn directly).
    local ringTrack = btn:CreateTexture(name .. "RingTrack", "OVERLAY")
    ringTrack:SetPoint("CENTER", btn, "CENTER", 0, 0)
    ringTrack:SetWidth(RING_TEX_SIZE)
    ringTrack:SetHeight(RING_TEX_SIZE)
    ringTrack:Show()   -- the frame is permanent, unlike ringFill/pulseWave below
    btn.ringTrack = ringTrack

    local ringOverlay = CreateRingOverlay(btn, name .. "RingOverlay")
    btn.ringOverlay = ringOverlay

    local ringFill = ringOverlay:CreateTexture(name .. "RingFill", "ARTWORK")
    ringFill:SetPoint("CENTER", ringOverlay, "CENTER", 0, 0)
    ringFill:SetWidth(RING_TEX_SIZE)
    ringFill:SetHeight(RING_TEX_SIZE)
    ringFill:Hide()
    btn.ringFill = ringFill
    btn.ringLastIdx = nil     -- cached flipbook frame, avoid redundant SetTexCoord
    btn.ringVisible = false   -- cached shown-state, avoid redundant Show/Hide

    -- Ripple wave: expanding, fading ring in the element color, one wave
    -- per pulse (replaces the old pulse progress bar). Animated every
    -- frame by UpdateWaves below (allocation-free), inputs refreshed by
    -- the 0.1s timer tick in UpdateTimerDisplays.
    local pulseWave = ringOverlay:CreateTexture(name .. "PulseWave", "OVERLAY")
    pulseWave:SetTexture(RING_TEXTURE)
    local wc = allRingCoords[RING_WAVE_CELL]
    pulseWave:SetTexCoord(wc.l, wc.r, wc.t, wc.b)
    pulseWave:SetPoint("CENTER", ringOverlay, "CENTER", 0, 0)
    pulseWave:SetWidth(RING_TEX_SIZE)
    pulseWave:SetHeight(RING_TEX_SIZE)
    pulseWave:Hide()
    btn.pulseWave = pulseWave
    btn.waveVisible = false
    -- Per-frame wave animation inputs, refreshed by the 0.1s tick; nil
    -- interval means "no wave for this slot right now".
    btn.waveInterval = nil
    btn.waveOrigin = nil      -- phase origin (pulseAnchor or start)
    btn.waveOneshotAt = nil   -- detonation time for oneshot waves

    -- Circular pulse countdown: element-colored inner arc on the band,
    -- fills clockwise toward the next pulse and resets (user feedback: the
    -- wave alone doesn't show WHEN; a circular bar fits the round slots).
    -- Colored below by ApplyRoundFrame (called at the end of this function)
    -- via ELEMENT_COLORS - no fixed color set here.
    local pulseArc = ringOverlay:CreateTexture(name .. "PulseArc", "HIGHLIGHT")
    pulseArc:SetTexture(PULSEARC_TEXTURE)
    pulseArc:SetPoint("CENTER", ringOverlay, "CENTER", 0, 0)
    pulseArc:SetWidth(RING_TEX_SIZE)
    pulseArc:SetHeight(RING_TEX_SIZE)
    pulseArc:Hide()
    btn.pulseArc = pulseArc
    btn.pulseArcLastIdx = nil
    btn.pulseVisible = false

    -- Anticipation glow: annular halo just outside the ring band, fading in
    -- over the last stretch before a pulse. BORDER layer (moved from
    -- BACKGROUND, 2026-07-16 - see the shadow comment above): stays below
    -- ARTWORK (icon) as intended, but no longer shares a layer with shadow.
    local glow = btn:CreateTexture(name .. "Glow", "BORDER")
    glow:SetTexture(FX_TEXTURE)
    local gc = fxCoords[FX_GLOW_CELL]
    glow:SetTexCoord(gc.l, gc.r, gc.t, gc.b)
    glow:SetPoint("CENTER", btn, "CENTER", 0, 0)
    glow:SetWidth(GLOW_TEX_SIZE)
    glow:SetHeight(GLOW_TEX_SIZE)
    glow:Hide()
    btn.glow = glow
    btn.glowVisible = false
    btn.spawnAt = nil          -- one-shot spawn ripple origin (set by the tick)
    btn.lastStartSeen = nil    -- cast-change detector
    btn.waveOneshotDelay = nil -- arming window length for oneshot glow

    ApplyRoundFrame(btn)
    ApplyTimerAnchor(btn)

    -- No normal texture: UI-Quickslot2's bevel bleeds through
    -- transparent icon art (see backdrop above). Round hover/pushed FX
    -- states (Rev 3) only appear on click/hover, so they don't bleed.
    ApplyRoundClickStates(btn, RING_TEX_SIZE)

    btn:RegisterForClicks("LeftButtonUp", "RightButtonUp")
    btn.element = element

    -- Left-click casts the slot's chosen totem. Right-click clears the
    -- slot (sets no default); picking a new default happens via the
    -- hover flyout's right-click instead (see EnsureFlyoutFrame above).
    btn:SetScript("OnClick", function()
        local clickedElement = this.element
        if arg1 == "RightButton" then
            TotemBarDB.chosen[clickedElement] = nil
            RefreshButton(clickedElement)
        else
            local db = TotemBarDB
            local totemName = db and db.chosen and db.chosen[clickedElement]
            if totemName then
                CastSpellByName(totemName)
                TotemBar.recordCast(clickedElement, totemName)
            else
                ChatOut:AddMessage("TotemBar: no totem chosen for " .. clickedElement .. " (hover for known totems, right-click one to set it as default)")
            end
        end
    end)

    btn:SetScript("OnEnter", function()
        local db = TotemBarDB
        local totemName = db and db.chosen and db.chosen[this.element]
        GameTooltip:SetOwner(this, "ANCHOR_TOP")
        if totemName then
            SetSpellTooltip(GameTooltip, totemName)
            GameTooltip:AddLine("Left-click: cast  /  Right-click: clear", 1, 1, 1)
        else
            GameTooltip:SetText(this.element .. " (empty)")
            GameTooltip:AddLine("Hover for known totems, right-click one to set default", 1, 1, 1)
        end
        GameTooltip:Show()
        -- Pop the "cast one of the others" flyout above this button.
        -- The flyout hides itself via its own throttled mouse-leave
        -- check (OnFlyoutUpdate), so no OnLeave handling is needed here.
        ShowFlyout(this, this.element)
    end)
    btn:SetScript("OnLeave", function()
        GameTooltip:Hide()
    end)

    elementButtons[element] = btn
    registerBindOverlay(btn, function() return "TOTEMBAR_CAST_" .. string.upper(element) end)
    return btn
end

-- Refreshes the Recall button's small "A" auto-recall indicator to
-- match TotemBarDB.autoRecall: shown (greenish) when on, hidden when
-- off. Called once at button creation and again after every right-click
-- toggle (see CreateRecallButton below).
RefreshRecallIndicator = function()
    if not recallButton or not recallButton.autoIndicator then
        return
    end
    if TotemBarDB and TotemBarDB.autoRecall then
        recallButton.autoIndicator:SetTextColor(0.3, 1, 0.3)
        recallButton.autoIndicator:Show()
    else
        recallButton.autoIndicator:Hide()
    end
end

-- Public alias so options.lua can refresh the "A" auto-recall indicator
-- after the auto-recall checkbox is toggled.
TotemBar.RefreshRecallIndicator = RefreshRecallIndicator

-- Pulses the Recall button's icon alpha while anyOutOfRange is true (set
-- by UpdateTimerDisplays) - a "go recall + redeploy" visual prompt.
-- Time-based (GetTime()), so the pulse stays smooth regardless of frame
-- rate; no table/string allocation, just float math + SetAlpha. Once
-- anyOutOfRange goes false, resets to full alpha exactly once (cached
-- via iconPulsing) instead of calling SetAlpha every single frame.
OnRecallUpdate = function()
    if anyOutOfRange then
        this.icon:SetAlpha(0.35 + 0.65 * math.abs(math.sin(GetTime() * 3)))
        this.iconPulsing = true
    elseif this.iconPulsing then
        this.icon:SetAlpha(1)
        this.iconPulsing = false
    end
end

-- Totemic Recall: left-click casts it immediately (no dropdown, no
-- per-element timer - recall has no duration of its own, it just clears
-- totems). Right-click instead toggles TotemBarDB.autoRecall, the flag
-- that decides whether TotemBar.recallAndCastAll() (the Totems macro's
-- entry point, core/cast.lua) prepends a recall before redeploying. A
-- small "A" FontString overlay reflects the current flag state.
CreateRecallButton = function(index)
    local name = "TotemBarButtonRecall"
    local btn = CreateFrame("Button", name, TotemBarFrame)
    btn:SetWidth(BUTTON_SIZE)
    btn:SetHeight(BUTTON_SIZE)
    btn:SetPoint("LEFT", TotemBarFrame, "LEFT", (index - 1) * (BUTTON_SIZE + BUTTON_GAP) + BUTTON_GAP, 0)

    -- Backdrop template kept (needed for SetBackdropColor/BorderColor
    -- below) but floating: ApplyRoundFrame blanks both to fully transparent
    -- - the decorative ring frame (below) is the only visible frame now.
    btn:SetBackdrop({
        bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        tile = true, tileSize = 16, edgeSize = 12,
        insets = { left = 3, right = 3, top = 3, bottom = 3 },
    })

    local icon = btn:CreateTexture(name .. "Icon", "ARTWORK")
    icon:SetPoint("TOPLEFT", btn, "TOPLEFT", 3, -3)
    icon:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", -3, 3)
    icon:SetTexture(GetRecallIcon())
    icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
    btn.icon = icon
    btn.iconPulsing = false   -- cached: whether the icon's alpha is currently != 1 (avoids redundant SetAlpha calls once the pulse stops)

    -- Auto-recall indicator: small "A" in the icon's top-left corner. Sits
    -- inside the area ringTrack (below) is documented to cover ("covers
    -- the icon's square corners" - see the element button's icon comment)
    -- - i.e. a REAL, code-confirmed spatial collision with ringTrack, not
    -- a borderline one. A same-layer HIGHLIGHT promotion (2026-07-16
    -- ring-occlusion follow-up) tried to win that collision the same way
    -- ringFill's did, and broke exactly the same way once confirmed
    -- in-game: 1.12 only paints a Button's HIGHLIGHT layer on hover, so
    -- the "A" went invisible except under the cursor. Fixed the same way
    -- as the element buttons' ring stack (see CreateRingOverlay): its own
    -- small child frame one FrameLevel above btn, so it renders above
    -- ringTrack deterministically regardless of layer. Doesn't share
    -- CreateElementButton's ringOverlay (different button, different
    -- frame tree) - a second, button-local overlay, OVERLAY layer is
    -- fine since it's the only region on it (no sibling to collide with).
    -- RefreshRecallIndicator() (called below, and after every toggle)
    -- shows/hides and colors it based on TotemBarDB.autoRecall.
    local autoOverlay = CreateRingOverlay(btn, name .. "AutoOverlay")
    btn.autoOverlay = autoOverlay
    local autoIndicator = autoOverlay:CreateFontString(name .. "Auto", "OVERLAY")
    autoIndicator:SetFont("Fonts\\FRIZQT__.TTF", 10, "OUTLINE")
    autoIndicator:SetPoint("TOPLEFT", icon, "TOPLEFT", 1, -1)
    autoIndicator:SetJustifyH("LEFT")
    autoIndicator:SetText("A")
    autoIndicator:Hide()
    btn.autoIndicator = autoIndicator

    -- Grounding shadow ellipse (Rev 4 UI chrome). No glow on this button,
    -- so creation order relative to other regions doesn't matter here.
    local shadow = btn:CreateTexture(name .. "Shadow", "BACKGROUND")
    shadow:SetTexture(UI_TEXTURE)
    local sc = uiCoords[UI_SHADOW_CELL]
    shadow:SetTexCoord(sc.l, sc.r, sc.t, sc.b)
    shadow:SetPoint("CENTER", btn, "CENTER", 0, -15)
    shadow:SetWidth(58)
    shadow:SetHeight(20)
    -- Softened after preview review: full-strength plate read boxy on light
    -- ground; flatter + more transparent grounds without drawing attention.
    shadow:SetAlpha(0.7)
    btn.shadow = shadow

    -- Same floating round frame as the element buttons (permanent
    -- decorative band, no fill/tint - no ringFill/pulseWave on this
    -- button, guarded inside ApplyRoundFrame).
    local ringTrack = btn:CreateTexture(name .. "RingTrack", "OVERLAY")
    ringTrack:SetPoint("CENTER", btn, "CENTER", 0, 0)
    ringTrack:SetWidth(RING_TEX_SIZE)
    ringTrack:SetHeight(RING_TEX_SIZE)
    btn.ringTrack = ringTrack
    ApplyRoundFrame(btn)

    -- No normal texture (UI-Quickslot2 bevel bleeds through transparent
    -- icon art). Round hover/pushed FX states (Rev 3) only show on
    -- click/hover, no bleed.
    ApplyRoundClickStates(btn, RING_TEX_SIZE)

    btn:RegisterForClicks("LeftButtonUp", "RightButtonUp")

    -- "Go recall + redeploy" visual prompt: pulses the icon's alpha
    -- while anyOutOfRange is true (set by UpdateTimerDisplays). Only
    -- touches btn.icon's alpha, never btn.autoIndicator, so the "A"
    -- auto-recall flag text stays steady/visible throughout.
    btn:SetScript("OnUpdate", OnRecallUpdate)

    btn:SetScript("OnClick", function()
        if arg1 == "RightButton" then
            TotemBarDB.autoRecall = not TotemBarDB.autoRecall
            if TotemBarDB.autoRecall then
                ChatOut:AddMessage("TotemBar: auto-recall before setting ON")
            else
                ChatOut:AddMessage("TotemBar: auto-recall before setting OFF")
            end
            RefreshRecallIndicator()
        else
            -- Shared with the TOTEMBAR_RECALL keybind (bind.lua) so the two manual
            -- paths cannot drift apart: TotemBar.manualRecall does the gate, the
            -- cooldown check, the queue-safe cast, the refund snapshot and the
            -- own-tracking wipe (see core/cast.lua). Only the chat feedback is ours.
            local action = TotemBar.manualRecall()
            if action == "none-out" then
                -- Confidently nothing out (cast something this session, all expired):
                -- don't waste Totemic Recall's 6s cooldown on a no-op cast. After a
                -- /reload the session flag is clear and 1.12 can't re-detect a pre-reload
                -- totem, so the gate fails open there. Pressing again also overrides it,
                -- for a totem we cannot see (e.g. dropped from the action bar).
                ChatOut:AddMessage("TotemBar: no totems out - not recalling (saves the 6s cooldown). Press again to recall anyway.")
            elseif action == "cooldown" then
                ChatOut:AddMessage("TotemBar: Totemic Recall isn't ready yet - totem timers kept.")
            end
        end
    end)

    btn:SetScript("OnEnter", function()
        GameTooltip:SetOwner(this, "ANCHOR_TOP")
        GameTooltip:SetText(RECALL_SPELL_NAME)
        GameTooltip:AddLine("Left-click: recall now", 1, 1, 1)
        local state = "OFF"
        if TotemBarDB and TotemBarDB.autoRecall then
            state = "ON"
        end
        GameTooltip:AddLine("Auto Recall Toggle (right-click): " .. state, 1, 1, 1)
        local activeCost = TotemBar.sumActiveCost(TotemBar.activeTotems, TotemBar.TOTEM_ELEMENTS, GetTime(), TotemBar.getTotemManaCost, TotemBar.remaining)
        local pct = (TotemBarDB and TotemBarDB.recallRefundPct) or 0.25
        local refund = TotemBar.refundAmount(pct, activeCost)
        if refund > 0 then
            GameTooltip:AddLine("Refund: ~" .. refund .. " mana", 0.6, 0.6, 1)
        end
        GameTooltip:Show()
    end)
    btn:SetScript("OnLeave", function()
        GameTooltip:Hide()
    end)

    recallButton = btn
    RefreshRecallIndicator()
    registerBindOverlay(btn, function() return "TOTEMBAR_RECALL" end)

    return btn
end

-- The "drop set" button: one click casts all four chosen totems
-- (TotemBar.recallAndCastAll, with its 2s double-press guard). Placed after
-- the Recall button.
CreateDropSetButton = function(index)
    local name = "TotemBarButtonDropSet"
    local btn = CreateFrame("Button", name, TotemBarFrame)
    btn:SetWidth(BUTTON_SIZE)
    btn:SetHeight(BUTTON_SIZE)
    btn:SetPoint("LEFT", TotemBarFrame, "LEFT", (index - 1) * (BUTTON_SIZE + BUTTON_GAP) + BUTTON_GAP, 0)
    btn:SetBackdrop({
        bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        tile = true, tileSize = 16, edgeSize = 12,
        insets = { left = 3, right = 3, top = 3, bottom = 3 },
    })

    local icon = btn:CreateTexture(name .. "Icon", "ARTWORK")
    icon:SetPoint("TOPLEFT", btn, "TOPLEFT", 3, -3)
    icon:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", -3, 3)
    icon:SetTexture(ICONS_TEXTURE)
    local dc = iconCoords[ICON_DROPSET_CELL]
    icon:SetTexCoord(dc.l, dc.r, dc.t, dc.b)
    btn.icon = icon

    -- Grounding shadow ellipse (Rev 4 UI chrome). No glow on this button,
    -- so creation order relative to other regions doesn't matter here.
    local shadow = btn:CreateTexture(name .. "Shadow", "BACKGROUND")
    shadow:SetTexture(UI_TEXTURE)
    local sc = uiCoords[UI_SHADOW_CELL]
    shadow:SetTexCoord(sc.l, sc.r, sc.t, sc.b)
    shadow:SetPoint("CENTER", btn, "CENTER", 0, -15)
    shadow:SetWidth(58)
    shadow:SetHeight(20)
    -- Softened after preview review: full-strength plate read boxy on light
    -- ground; flatter + more transparent grounds without drawing attention.
    shadow:SetAlpha(0.7)
    btn.shadow = shadow

    -- Same floating round frame as the element buttons (permanent
    -- decorative band, no fill/tint - no ringFill/pulseWave on this
    -- button, guarded inside ApplyRoundFrame).
    local ringTrack = btn:CreateTexture(name .. "RingTrack", "OVERLAY")
    ringTrack:SetPoint("CENTER", btn, "CENTER", 0, 0)
    ringTrack:SetWidth(RING_TEX_SIZE)
    ringTrack:SetHeight(RING_TEX_SIZE)
    btn.ringTrack = ringTrack
    ApplyRoundFrame(btn)

    -- Round hover/pushed FX states (Rev 3), same sheet as the other
    -- bar buttons.
    ApplyRoundClickStates(btn, RING_TEX_SIZE)
    btn:RegisterForClicks("LeftButtonUp")
    btn:SetScript("OnClick", function()
        TotemBar.recallAndCastAll()
    end)
    btn:SetScript("OnEnter", function()
        GameTooltip:SetOwner(this, "ANCHOR_TOP")
        GameTooltip:SetText("Drop all totems")
        GameTooltip:AddLine("Left-click: cast your whole set", 1, 1, 1)
        local cost = TotemBar.sumChosenCost(TotemBarDB.chosen, TotemBar.TOTEM_ELEMENTS, TotemBar.getTotemManaCost)
        if cost and cost > 0 then
            GameTooltip:AddLine("Mana: " .. cost, 0.6, 0.6, 1)
        end
        GameTooltip:Show()
    end)
    btn:SetScript("OnLeave", function() GameTooltip:Hide() end)

    registerBindOverlay(btn, function() return "TOTEMBAR_DROPSET" end)
    dropSetButton = btn
    return btn
end

-- Refreshes every element button's remaining-duration timer text.
-- HYBRID source: prefers pfUI libtotem's GetTotemInfo(slot) when
-- present and reporting the slot active (also catches totems cast
-- outside TotemBar); otherwise falls back to TotemBar's own
-- cast-tracking (TotemBar.activeTotems). Called at most ~5x/sec by
-- OnBarUpdate below, never per-frame.
UpdateTimerDisplays = function()
    local now = GetTime()
    local hasGTI = (type(GetTotemInfo) == "function")
    local elements = TotemBar.TOTEM_ELEMENTS
    local activeTotems = TotemBar.activeTotems
    local outOfRangeFound = false     -- OR-accumulator across this pass; written to anyOutOfRange at the end

    -- Destroyed-totem liveness poll gate for this pass (see local
    -- lastGuidLivenessCheck above and TotemBar.GUID_LIVENESS_POLL_INTERVAL,
    -- core/cast.lua) -- computed ONCE per call, not per element, so all four
    -- slots share the same ~0.5s cadence.
    local doLivenessPoll = TotemBar.shouldPollLiveness(lastGuidLivenessCheck, now,
        TotemBar.GUID_LIVENESS_POLL_INTERVAL)
    if doLivenessPoll then
        lastGuidLivenessCheck = now
    end

    -- Mana input for the out-of-mana dim, read ONCE per pass rather than per
    -- button. (The Clearcasting buff walk that used to sit here is gone with the
    -- exemption itself -- Elemental Focus does not cover totems, see
    -- core/manacost.lua -- so this pass no longer scans buffs at all.)
    local playerMana = (type(UnitMana) == "function") and UnitMana("player") or nil

    for i = 1, table.getn(elements) do
        local element = elements[i]
        local btn = elementButtons[element]
        if btn then
            -- Own-tracking: compute remaining, evicting the record the
            -- moment it expires (per-element table, no growth).
            local ownRecord = activeTotems[element]
            local ownRemaining = nil
            if ownRecord then
                ownRemaining = TotemBar.remaining(ownRecord.start, ownRecord.duration, now)
                if not ownRemaining or ownRemaining <= 0 then
                    activeTotems[element] = nil
                    ownRemaining = nil
                    ownRecord = nil     -- expired: not "active" for the range-tint check below either
                end
            end

            -- Destroyed-totem liveness poll: only runs once a GUID was
            -- latched (core/cast.lua's UNIT_CASTEVENT/UNIT_MODEL_CHANGED
            -- hooks) AND this pass is due (doLivenessPoll, ~0.5s cadence).
            -- On a client without SuperWoW no record ever gets a guid, so
            -- this is a no-op there -- today's behaviour, unchanged. A
            -- destroyed verdict clears the record the SAME way natural
            -- expiry does above, so the countdown text, duration ring,
            -- pulse animation and out-of-range tint below all disappear in
            -- one step -- they already derive from ownRecord's presence.
            if ownRecord and ownRecord.guid and doLivenessPoll then
                local exists, guid = nil, nil
                if type(UnitExists) == "function" then
                    exists, guid = UnitExists(ownRecord.guid)
                end
                local health, deadOrGhost = nil, nil
                if exists then
                    if type(UnitHealth) == "function" then
                        health = UnitHealth(ownRecord.guid)
                    end
                    if type(UnitIsDeadOrGhost) == "function" then
                        deadOrGhost = UnitIsDeadOrGhost(ownRecord.guid)
                    end
                end
                if TotemBar.totemDestroyed(exists, health, deadOrGhost) then
                    -- Tombstones the element (see TotemBar.evictDestroyedTotem /
                    -- TotemBar.destroyedTombstone, core/cast.lua) so
                    -- resolveRemaining below stops trusting GTI for it until
                    -- the evicted record's own natural expiry -- otherwise
                    -- pfUI's libtotem (itself just another blind duration
                    -- timer) keeps reporting the SAME destroyed totem active
                    -- and the countdown/ring/pulse reappear from the GTI
                    -- branch a moment after being cleared here (adversarial-
                    -- review finding #1). Mutates TotemBar.activeTotems, the
                    -- SAME table `activeTotems` above already points to, so
                    -- ownRecord/ownRemaining are nil'd out explicitly to
                    -- match rather than re-reading it.
                    TotemBar.evictDestroyedTotem(element)
                    ownRemaining = nil
                    ownRecord = nil
                end
            end

            -- Spawn ripple: a NEW own cast (start changed) fires one ripple.
            -- The freshness check keeps restored/old records from replaying.
            -- Gated on showPulseWaves (the ripple's own toggle), not
            -- showPulseBars.
            if TotemBarDB.showPulseWaves then
                local startNow = ownRecord and ownRecord.start
                if startNow and startNow ~= btn.lastStartSeen then
                    btn.lastStartSeen = startNow
                    if now - startNow < 1.5 then
                        btn.spawnAt = startNow
                    end
                elseif not ownRecord then
                    btn.lastStartSeen = nil
                end
            end

            -- pfUI libtotem, when present: Fire=1, Earth=2, Water=3,
            -- Air=4 - i.e. exactly TotemBar.TOTEM_ELEMENTS' own order.
            local gtiActive, gtiRemaining, gtiName, gtiStart, gtiDuration
            if hasGTI then
                local active, tname, start, duration = GetTotemInfo(i)
                gtiActive = active
                gtiName = tname
                gtiStart = start
                -- Mastery parity: libtotem reports FLAT book durations, own
                -- tracking stores the Totemic Mastery-inflated one. Without
                -- scaling GTI by the same factor the timer flips source at the
                -- book duration and the countdown jumps back UP instead of
                -- running out (see TotemBar.gtiDurationWithMastery).
                gtiDuration = TotemBar.gtiDurationWithMastery(duration, tname,
                    TotemBar.hasTotemicMastery and TotemBar.hasTotemicMastery())
                if start and gtiDuration then
                    gtiRemaining = TotemBar.remaining(start, gtiDuration, now)
                end
                -- Latch: did libtotem ever report THIS record's totem active?
                -- Only then may a later "slot inactive" veto the range tint
                -- below (TotemBar.rangeTintActive).
                if ownRecord and gtiActive and gtiName == ownRecord.totemName then
                    ownRecord.gtiTracked = true
                end
            end

            -- Destroyed-totem tombstone (adversarial-review finding #1, see
            -- TotemBar.evictDestroyedTotem above and TotemBar.tombstoneActive/
            -- resolveRemaining, core/cast.lua): while active, resolveRemaining
            -- below must not trust GTI for this element even though hasGTI/
            -- gtiActive above were computed with no knowledge of the eviction.
            -- Swept once expired so the table never holds a stale entry past
            -- its own usefulness (bounded to 4 keys regardless, but tidy).
            local tombstoned = TotemBar.tombstoneActive(TotemBar.destroyedTombstone[element], now)
            if not tombstoned and TotemBar.destroyedTombstone[element] then
                TotemBar.destroyedTombstone[element] = nil
            end

            local remainingVal = TotemBar.resolveRemaining(gtiActive, gtiRemaining, ownRemaining, tombstoned)

            if remainingVal and TotemBarDB.showTimerText then
                if not btn.timerVisible then
                    btn.timerText:Show()
                    btn.timerVisible = true
                end
                local text = TotemBar.formatRemaining(remainingVal)
                if text ~= btn.timerLastText then
                    btn.timerText:SetText(text)
                    btn.timerLastText = text
                end
                local isLow = remainingVal <= 5
                if isLow ~= btn.timerLastLow then
                    if isLow then
                        btn.timerText:SetTextColor(1, 0.15, 0.15, 1)
                    else
                        btn.timerText:SetTextColor(1, 1, 1, 1)
                    end
                    btn.timerLastLow = isLow
                end
            elseif btn.timerVisible then
                btn.timerText:Hide()
                btn.timerVisible = false
                btn.timerLastText = nil
                btn.timerLastLow = nil
            end

            -- Duration ring: flipbook frame from remaining/total, tinted as
            -- a time-remaining traffic light (green->yellow->orange->red -
            -- user feedback: element color didn't convey urgency). Gated on
            -- showDurationRing for the ARC only (ringFill) - ringTrack (the
            -- permanent decorative frame band) is never hidden here.
            if TotemBarDB.showDurationRing and remainingVal then
                local totalDur = TotemBar.resolveDuration(gtiActive, gtiRemaining, gtiDuration,
                    ownRemaining, ownRecord and ownRecord.duration)
                local idx = TotemBar.ringFrameIndex(remainingVal, totalDur, RING_FRAMES)
                if idx ~= btn.ringLastIdx then
                    local rc = allRingCoords[idx + 1]
                    btn.ringFill:SetTexCoord(rc.l, rc.r, rc.t, rc.b)
                    btn.ringLastIdx = idx
                end
                local tr, tg, tb
                if totalDur and totalDur > 0 then
                    tr, tg, tb = TotemBar.timeColor(remainingVal / totalDur)
                else
                    tr, tg, tb = 1, 1, 1
                end
                btn.ringFill:SetVertexColor(tr, tg, tb)
                if not btn.ringVisible then
                    btn.ringFill:Show()
                    btn.ringVisible = true
                end
            elseif btn.ringVisible then
                btn.ringFill:Hide()
                btn.ringVisible = false
                btn.ringLastIdx = nil
            end

            -- Ripple wave + pulse arc inputs: only for totems with
            -- PULSE_DATA. Phase origin: own record start (anchored by real
            -- gain events where available) or libtotem's start for casts
            -- made outside TotemBar. Both the wave's animation AND the
            -- pulse arc's flipbook frame are driven every FRAME in
            -- UpdateWaves below (moved off this 0.1s tick - a 10Hz flipbook
            -- update read as choppy, see rev7 report) - this block just
            -- refreshes what they should animate toward. showPulseWaves and
            -- showPulseBars are independent display toggles, but the arc
            -- reads the SAME waveInterval/waveOrigin/waveOneshotAt/
            -- waveOneshotDelay fields as the wave, so they're populated
            -- whenever EITHER toggle is on.
            local waveSet = false
            if remainingVal then
                local totemName = (ownRecord and ownRecord.totemName) or gtiName
                local pd = totemName and TotemBar.PULSE_DATA[totemName]
                if pd then
                    local startAt = (ownRecord and ownRecord.start) or gtiStart
                    if startAt then
                        if TotemBarDB.showPulseWaves or TotemBarDB.showPulseBars then
                            if pd.ptype == "oneshot" then
                                btn.waveInterval = nil
                                btn.waveOneshotAt = startAt + pd.delay
                                btn.waveOrigin = nil
                                btn.waveOneshotDelay = pd.delay
                            else
                                btn.waveInterval = pd.interval
                                btn.waveOrigin = (ownRecord and ownRecord.pulseAnchor) or startAt
                                btn.waveOneshotAt = nil
                                btn.waveOneshotDelay = nil
                            end
                            waveSet = true
                        end
                    end
                end
            end
            if not waveSet then
                btn.waveInterval = nil
                btn.waveOneshotAt = nil
                btn.waveOrigin = nil
                btn.waveOneshotDelay = nil
                if btn.waveVisible then
                    btn.pulseWave:Hide()
                    btn.waveVisible = false
                end
            end
            -- Pulse arc hide-on-expiry: UpdateWaves (every frame) recomputes
            -- arcRatio from the fields above and will already hide it within
            -- the next frame once waveInterval/waveOneshotAt go nil, but that
            -- can be up to one frame stale right after this tick - not worth
            -- a duplicate hide-path here (see rev7 report).

            -- Out-of-range tint: buff-presence based. A totem's party
            -- buff uses the SAME icon texture as the totem spell itself
            -- (verified in-game), so "am I in range?" == "do I have a
            -- buff whose texture matches this totem's icon?".
            --
            -- ACTIVE requires an own-tracking record (ownRecord, already
            -- nil'd above once its stored duration expires). pfUI's libtotem
            -- may veto it - that is what keeps a totem someone/something
            -- DESTROYED (burned, killed) before its timer ran out from
            -- flashing red - but ONLY for a slot libtotem actually tracked
            -- (gtiTracked, latched above). Its single cast queue records at
            -- most one totem per multi-drop, so vetoing on a merely MISSING
            -- GTI record silenced the tint and the Recall pulse for most of
            -- a freshly dropped set. See TotemBar.rangeTintActive.
            local rangeActive = TotemBar.rangeTintActive(ownRecord ~= nil, hasGTI,
                ownRecord and ownRecord.gtiTracked, gtiActive)

            local rangeRed = false
            if rangeActive then
                if TotemBar.hasBuffWithIcon(ownRecord.icon) then
                    -- In range: remember it (self-learning - marks this
                    -- as a buff totem so a later drop-off can be told
                    -- apart from a totem that simply never grants one).
                    ownRecord.everHadBuff = true
                elseif ownRecord.everHadBuff then
                    -- Had the buff earlier from this cast, don't have it
                    -- now: wandered out of the totem's range. (A non-buff
                    -- totem - Searing/Magma/Grounding - never sets
                    -- everHadBuff, so it can never turn red here.)
                    rangeRed = true
                end
            end

            -- Out-of-mana dim for the totem THIS slot would cast (the chosen
            -- one, not the record's - the record is a totem already standing,
            -- which costs nothing to keep). Composed with the range tint in one
            -- place: two independent SetVertexColor call sites would race, and
            -- whichever ran last would win (see TotemBar.iconTintFor).
            local chosenName = TotemBarDB.chosen and TotemBarDB.chosen[element]
            local oom = false
            if chosenName then
                oom = TotemBar.notEnoughMana(TotemBar.getTotemManaCost(chosenName), playerMana)
            end

            local tr2, tg2, tb2, tintKey = TotemBar.iconTintFor(rangeRed, oom)
            if tintKey ~= btn.tintKey then
                btn.icon:SetVertexColor(tr2, tg2, tb2)
                btn.tintKey = tintKey
            end
            btn.tintRed = rangeRed

            if btn.tintRed then
                outOfRangeFound = true
            end
        end
    end

    anyOutOfRange = outOfRangeFound
end

-- Re-applies every Pulse-UI setting to the live buttons (options panel
-- setters call this; cheap, not on any hot path).
function TotemBar.RefreshPulseUI()
    local elements = TotemBar.TOTEM_ELEMENTS
    for i = 1, table.getn(elements) do
        local btn = elementButtons[elements[i]]
        if btn then
            ApplyRoundFrame(btn)
            ApplyTimerAnchor(btn)
            if not TotemBarDB.showDurationRing and btn.ringVisible then
                btn.ringFill:Hide()
                btn.ringVisible = false
                btn.ringLastIdx = nil
            end
            if not TotemBarDB.showPulseWaves then
                btn.waveInterval = nil
                btn.waveOneshotAt = nil
                btn.waveOrigin = nil
                btn.waveOneshotDelay = nil
                btn.spawnAt = nil
                if btn.waveVisible then
                    btn.pulseWave:Hide()
                    btn.waveVisible = false
                end
                if btn.glow then
                    btn.glow:Hide()
                    btn.glowVisible = false
                end
            end
            if not TotemBarDB.showPulseBars and btn.pulseVisible then
                btn.pulseArc:Hide()
                btn.pulseVisible = false
                btn.pulseArcLastIdx = nil
            end
            if not TotemBarDB.showTimerText and btn.timerVisible then
                btn.timerText:Hide()
                btn.timerVisible = false
                btn.timerLastText = nil
                btn.timerLastLow = nil
            end
        end
    end

    -- Recall/DropSet share ApplyRoundFrame with the element buttons
    -- (harmless re-apply of their static ring-frame texture; kept for
    -- symmetry with the element-button refresh above). Flyout icons don't
    -- go through ApplyRoundFrame (see EnsureFlyoutFrame) but their ring
    -- frame texture is likewise static now, set once at creation - no
    -- per-refresh work needed for them.
    if recallButton then
        ApplyRoundFrame(recallButton)
    end
    if dropSetButton then
        ApplyRoundFrame(dropSetButton)
    end
end

-- Dev aid, one line of live WoW-object state for a single ring texture
-- (ringFill, ringTrack, the icon, or timerText), called by
-- TotemBar.DumpRingRenderState below. Every getter is pcall-wrapped
-- individually ("?" on failure/absence) - this must never itself error,
-- not even on a stale/nil texture object.
-- GetTexCoord's return arity is UNCONFIRMED on this 1.12 client (4-value
-- left/right/top/bottom vs the 8-value corner form retail uses) - its
-- result is captured via a table constructor (`{ pcall(...) }`) instead
-- of assuming a fixed number of return values, so the dump itself is the
-- in-game measurement that settles the question.
-- rect=[...]/layer= added alongside (2026-07-16, occlusion follow-up): the
-- screen-space GetLeft/Right/Top/Bottom quad and GetDrawLayer, so a stale
-- anchor (ringFill not actually over the button) or a same-layer sibling
-- collision (1.12 discards SetDrawLayer sublevels - same main layer means
-- undefined draw order between siblings) show up directly in the dump
-- instead of needing to be inferred from shown/alpha/texCoord alone.
local function DumpRingTextureLine(element, label, tex)
    if not tex then
        return "  " .. element .. " " .. label .. ": (nil texture object)\n"
    end
    local shownOk, shown = pcall(tex.IsShown, tex)
    local texOk, texPath = pcall(tex.GetTexture, tex)
    local alphaOk, alpha = pcall(tex.GetAlpha, tex)
    local wOk, w = pcall(tex.GetWidth, tex)
    local hOk, h = pcall(tex.GetHeight, tex)
    local leftOk, left = pcall(tex.GetLeft, tex)
    local rightOk, right = pcall(tex.GetRight, tex)
    local topOk, top = pcall(tex.GetTop, tex)
    local bottomOk, bottom = pcall(tex.GetBottom, tex)
    local layerOk, layer = pcall(tex.GetDrawLayer, tex)

    local parentShown, parentAlpha = "?", "?"
    local parentOk, parent = pcall(tex.GetParent, tex)
    if parentOk and parent then
        local pShownOk, pShown = pcall(parent.IsShown, parent)
        local pAlphaOk, pAlpha = pcall(parent.GetAlpha, parent)
        if pShownOk then parentShown = tostring(pShown) end
        if pAlphaOk then parentAlpha = tostring(pAlpha) end
    end

    local coordParts = { pcall(tex.GetTexCoord, tex) }
    local coordStr = "?"
    if coordParts[1] then
        local n = table.getn(coordParts)
        local s = ""
        for ci = 2, n do
            if s ~= "" then s = s .. "," end
            s = s .. tostring(coordParts[ci])
        end
        coordStr = "[" .. s .. "]"
    end

    return "  " .. element .. " " .. label .. ": shown=" .. (shownOk and tostring(shown) or "?")
        .. " texture='" .. (texOk and tostring(texPath) or "?") .. "'"
        .. " alpha=" .. (alphaOk and tostring(alpha) or "?")
        .. " texCoord=" .. coordStr
        .. " w=" .. (wOk and tostring(w) or "?")
        .. " h=" .. (hOk and tostring(h) or "?")
        .. " parentShown=" .. parentShown
        .. " parentAlpha=" .. parentAlpha
        .. " rect=[" .. (leftOk and tostring(left) or "?") .. "," .. (topOk and tostring(top) or "?")
        .. "," .. (rightOk and tostring(right) or "?") .. "," .. (bottomOk and tostring(bottom) or "?") .. "]"
        .. " layer=" .. (layerOk and tostring(layer) or "?") .. "\n"
end

-- Dev aid, one line per region returned by a button's own :GetRegions()
-- (2026-07-16, occlusion follow-up). GetRegions() is Blizzard's own
-- z-order-relevant enumeration of the regions directly parented to a
-- frame - within a shared main draw layer (see the sublevel-discard note
-- above) it's the best available proxy for actual sibling paint order, so
-- walking it WITH the index is how we tell "ringTrack drawn 2nd, after
-- ringFill" apart from "ringTrack drawn 1st, before ringFill" from outside
-- the client. Deliberately only ever called on a single small element
-- button (~8 regions), never on a big container frame - a wide frame can
-- carry hundreds of regions and walking all of them was the cause of an
-- earlier crash, hence the hard cap below regardless of how many the
-- client reports.
local REGION_WALK_CAP = 24
local function DumpRegionLine(idx, region)
    if not region then
        return "  [" .. idx .. "] (nil region)\n"
    end
    local typeOk, objType = pcall(region.GetObjectType, region)
    local nameOk, name = pcall(region.GetName, region)
    local layerOk, layer = pcall(region.GetDrawLayer, region)
    local shownOk, shown = pcall(region.IsShown, region)
    local leftOk, left = pcall(region.GetLeft, region)
    local topOk, top = pcall(region.GetTop, region)

    -- Texture and FontString expose different "what is this" getters
    -- (GetTexture vs GetText); try both, pcall-guarded, and use whichever
    -- succeeds - a region of the other kind simply fails the wrong one.
    local content = "?"
    local texOk, texPath = pcall(region.GetTexture, region)
    if texOk and texPath ~= nil then
        content = "texture='" .. tostring(texPath) .. "'"
    else
        local textOk, text = pcall(region.GetText, region)
        if textOk and text ~= nil then
            content = "text='" .. tostring(text) .. "'"
        end
    end

    return "  [" .. idx .. "] name=" .. (nameOk and tostring(name) or "?")
        .. " type=" .. (typeOk and tostring(objType) or "?")
        .. " layer=" .. (layerOk and tostring(layer) or "?")
        .. " shown=" .. (shownOk and tostring(shown) or "?")
        .. " " .. content
        .. " left=" .. (leftOk and tostring(left) or "?")
        .. " top=" .. (topOk and tostring(top) or "?") .. "\n"
end

-- Dev aid, called from TotemBar.DumpTimerState (core/cast.lua)'s /tb tdump
-- so the export stays a single file/single command. Purpose: the pure
-- ring math (resolveRemaining/resolveDuration/ringFrameIndex) was already
-- proven correct offline for the Searing-ring report (text shows, no
-- ring), so this section captures what DumpTimerState's own-tracking/GTI
-- dump can't: the RENDER side. Per element button: (a) which button
-- instance is bound to the element, (b) the cached ring state this same
-- render path reads/writes (btn.ringVisible/btn.ringLastIdx - the exact
-- fields UpdateTimerDisplays above uses, not a guess), (c) a same-tick
-- recompute of resolveDuration/ringFrameIndex/timeColor with the SAME
-- inputs the render path would use right now, (d) the countdown
-- FontString's own live state as a control group (it's the one the player
-- actually sees), and (e) the LIVE WoW state of both ring textures (ringFill, the
-- gated arc in question, and ringTrack, the always-on decorative band -
-- if ringTrack is ALSO dark then the whole assembly, not just the fill,
-- is hidden/mispositioned upstream of this addon's own Show/Hide calls).
function TotemBar.DumpRingRenderState()
    local now = GetTime()
    local hasGTI = (type(GetTotemInfo) == "function")
    local elements = TotemBar.TOTEM_ELEMENTS
    local activeTotems = TotemBar.activeTotems
    local out = "\n-- render state (per-element ring + text, live WoW objects) --\n"

    for i = 1, table.getn(elements) do
        local element = elements[i]
        local btn = elementButtons[element]
        if not btn then
            out = out .. element .. ": (no button)\n"
        else
            local btnName = "?"
            local okName, nameVal = pcall(btn.GetName, btn)
            if okName and nameVal then
                btnName = nameVal
            end

            -- Same recompute inputs/precedence as UpdateTimerDisplays
            -- above, same tick (i.e. what the render path would compute
            -- if it ran again right now).
            local ownRecord = activeTotems[element]
            local ownRemaining = nil
            if ownRecord then
                ownRemaining = TotemBar.remaining(ownRecord.start, ownRecord.duration, now)
            end
            local gtiActive, gtiRemaining, gtiDuration
            if hasGTI then
                local ok, active, tname, start, duration = pcall(GetTotemInfo, i)
                if ok then
                    -- Same mastery scaling UpdateTimerDisplays applies, so this
                    -- dump keeps reporting what the render path would compute.
                    gtiActive = active
                    gtiDuration = TotemBar.gtiDurationWithMastery(duration, tname,
                        TotemBar.hasTotemicMastery and TotemBar.hasTotemicMastery())
                    if start and gtiDuration then
                        gtiRemaining = TotemBar.remaining(start, gtiDuration, now)
                    end
                end
            end
            -- Same tombstone read UpdateTimerDisplays' live pass does (see
            -- its own comment) -- read-only here, the live pass owns
            -- sweeping expired entries.
            local tombstoned = TotemBar.tombstoneActive(TotemBar.destroyedTombstone[element], now)
            local remainingVal = TotemBar.resolveRemaining(gtiActive, gtiRemaining, ownRemaining, tombstoned)
            local totalDur = TotemBar.resolveDuration(gtiActive, gtiRemaining, gtiDuration,
                ownRemaining, ownRecord and ownRecord.duration)
            local idxStr = "n/a"
            if remainingVal then
                idxStr = tostring(TotemBar.ringFrameIndex(remainingVal, totalDur, RING_FRAMES))
            end
            local colorStr = "n/a"
            if totalDur and totalDur > 0 and remainingVal then
                local okC, cr, cg, cb = pcall(TotemBar.timeColor, remainingVal / totalDur)
                if okC then
                    colorStr = "(" .. tostring(cr) .. "," .. tostring(cg) .. "," .. tostring(cb) .. ")"
                end
            end

            out = out .. element .. ": btn=" .. btnName
                .. " ringVisible=" .. tostring(btn.ringVisible)
                .. " ringLastIdx=" .. tostring(btn.ringLastIdx)
                .. " | recompute: remainingVal=" .. tostring(remainingVal)
                .. " totalDur=" .. tostring(totalDur)
                .. " ringIdx=" .. idxStr
                .. " timeColor=" .. colorStr
                .. " tombstoned=" .. tostring(tombstoned) .. "\n"

            -- Control group: the countdown text the player actually sees, so a
            -- mismatch (text shown, ring both cached+live hidden) pins the
            -- bug to the ring specifically, not a shared render gate.
            -- Guarded on btn.timerText itself (not just pcall on its
            -- methods): indexing .IsShown/.GetText on a nil timerText
            -- would error while pcall's OWN arguments are being evaluated,
            -- i.e. outside pcall's protection - shouldn't happen (created
            -- unconditionally in CreateElementButton) but this dump must
            -- never be the thing that errors.
            local tShown, tText = "?", "?"
            local tLeft, tTop, tRight, tBottom, tLayer = "?", "?", "?", "?", "?"
            if btn.timerText then
                local tShownOk, tShownVal = pcall(btn.timerText.IsShown, btn.timerText)
                local tTextOk, tTextVal = pcall(btn.timerText.GetText, btn.timerText)
                local tLeftOk, tLeftVal = pcall(btn.timerText.GetLeft, btn.timerText)
                local tRightOk, tRightVal = pcall(btn.timerText.GetRight, btn.timerText)
                local tTopOk, tTopVal = pcall(btn.timerText.GetTop, btn.timerText)
                local tBottomOk, tBottomVal = pcall(btn.timerText.GetBottom, btn.timerText)
                local tLayerOk, tLayerVal = pcall(btn.timerText.GetDrawLayer, btn.timerText)
                if tShownOk then tShown = tostring(tShownVal) end
                if tTextOk then tText = tostring(tTextVal) end
                if tLeftOk then tLeft = tostring(tLeftVal) end
                if tRightOk then tRight = tostring(tRightVal) end
                if tTopOk then tTop = tostring(tTopVal) end
                if tBottomOk then tBottom = tostring(tBottomVal) end
                if tLayerOk then tLayer = tostring(tLayerVal) end
            end
            out = out .. "  " .. element .. " timerText: shown=" .. tShown
                .. " text='" .. tText .. "'"
                .. " rect=[" .. tLeft .. "," .. tTop .. "," .. tRight .. "," .. tBottom .. "]"
                .. " layer=" .. tLayer .. "\n"

            out = out .. DumpRingTextureLine(element, "ringFill", btn.ringFill)
            out = out .. DumpRingTextureLine(element, "ringTrack", btn.ringTrack)
            out = out .. DumpRingTextureLine(element, "icon", btn.icon)

            -- Region walk (2026-07-16, occlusion follow-up): btn is a
            -- small element button (icon/shadow/ringTrack/glow/timerText,
            -- ~5 regions now that ringFill/pulseWave/pulseArc moved off
            -- btn - see below) - safe to enumerate in full, unlike a big
            -- container frame. Index order is GetRegions()'s own return
            -- order, our best proxy for actual same-layer sibling paint
            -- order (see DumpRegionLine above).
            local regionParts = { pcall(btn.GetRegions, btn) }
            if regionParts[1] then
                local total = table.getn(regionParts) - 1
                local capped = total
                if capped > REGION_WALK_CAP then capped = REGION_WALK_CAP end
                out = out .. "  " .. element .. " regions (count=" .. total .. ", showing " .. capped .. "):\n"
                for ri = 1, capped do
                    out = out .. DumpRegionLine(ri, regionParts[ri + 1])
                end
            else
                out = out .. "  " .. element .. " regions: (GetRegions failed)\n"
            end

            -- Second region walk (2026-07-16, ring-overlay fix): ringFill/
            -- pulseWave/pulseArc now live on btn.ringOverlay, a separate
            -- higher-FrameLevel child frame (see CreateRingOverlay) - they
            -- no longer show up in btn:GetRegions() above at all, so the
            -- dump would silently go blind on exactly the textures this
            -- tool exists to inspect without this second walk. Same small
            -- frame (~3 regions), same cap.
            if btn.ringOverlay then
                local overlayParts = { pcall(btn.ringOverlay.GetRegions, btn.ringOverlay) }
                if overlayParts[1] then
                    local oTotal = table.getn(overlayParts) - 1
                    local oCapped = oTotal
                    if oCapped > REGION_WALK_CAP then oCapped = REGION_WALK_CAP end
                    out = out .. "  " .. element .. " ringOverlay regions (count=" .. oTotal .. ", showing " .. oCapped .. "):\n"
                    for ri = 1, oCapped do
                        out = out .. DumpRegionLine(ri, overlayParts[ri + 1])
                    end
                else
                    out = out .. "  " .. element .. " ringOverlay regions: (GetRegions failed)\n"
                end
            else
                out = out .. "  " .. element .. " ringOverlay: (nil)\n"
            end
        end
    end

    return out
end

-- Ripple animation + pulse-arc flipbook: expanding/fading ring in the
-- element color (one wave per pulse) and the circular countdown arc, both
-- driven every FRAME (pure float math + Set calls, no allocation, same
-- class as OnRecallUpdate - the arc joined the wave here in rev7, was a
-- 0.1s-tick flipbook update before and read as choppy). Inputs are
-- refreshed by the 0.1s tick in UpdateTimerDisplays above. Wave/glow are
-- gated on showPulseWaves, the arc on showPulseBars - independent toggles
-- that happen to share the same waveInterval/waveOrigin/waveOneshotAt/
-- waveOneshotDelay fields (see the tick's comment).
UpdateWaves = function()
    local now = GetTime()
    local elements = TotemBar.TOTEM_ELEMENTS
    for i = 1, table.getn(elements) do
        local btn = elementButtons[elements[i]]
        if btn then
            if TotemBarDB.showPulseWaves then
                local frac = nil
                if btn.spawnAt then
                    frac = TotemBar.oneshotWaveFrac(btn.spawnAt, 0, now, SPAWN_WAVE_DURATION)
                    if not frac then
                        btn.spawnAt = nil
                    end
                end
                if not frac then
                    if btn.waveInterval then
                        local ratio = TotemBar.pulseRatio(btn.waveOrigin, nil, btn.waveInterval, now)
                        frac = TotemBar.waveFrac(ratio, btn.waveInterval, WAVE_DURATION)
                    elseif btn.waveOneshotAt then
                        frac = TotemBar.oneshotWaveFrac(btn.waveOneshotAt, 0, now, WAVE_DURATION)
                    end
                end
                if frac then
                    local size = RING_TEX_SIZE * (1 + (WAVE_MAX_SCALE - 1) * frac)
                    btn.pulseWave:SetWidth(size)
                    btn.pulseWave:SetHeight(size)
                    btn.pulseWave:SetAlpha(WAVE_BASE_ALPHA * (1 - frac))
                    if not btn.waveVisible then
                        btn.pulseWave:Show()
                        btn.waveVisible = true
                    end
                elseif btn.waveVisible then
                    btn.pulseWave:Hide()
                    btn.waveVisible = false
                end

                -- Anticipation glow: ramps up as the next pulse (or the
                -- oneshot detonation) approaches; hidden otherwise. Tied to
                -- the wave's own toggle (showPulseWaves), unchanged.
                local ga = nil
                if btn.waveInterval then
                    local gratio = TotemBar.pulseRatio(btn.waveOrigin, nil, btn.waveInterval, now)
                    ga = TotemBar.glowRamp(gratio, GLOW_RAMP_START)
                elseif btn.waveOneshotAt and btn.waveOneshotDelay and now < btn.waveOneshotAt then
                    ga = TotemBar.glowRamp(1 - ((btn.waveOneshotAt - now) / btn.waveOneshotDelay), GLOW_RAMP_START)
                end
                if ga then
                    btn.glow:SetAlpha(GLOW_MAX_ALPHA * ga)
                    if not btn.glowVisible then
                        btn.glow:Show()
                        btn.glowVisible = true
                    end
                elseif btn.glowVisible then
                    btn.glow:Hide()
                    btn.glowVisible = false
                end
            elseif btn.waveVisible or btn.glowVisible then
                -- Toggled off (RefreshPulseUI normally already clears these
                -- immediately; this is the per-frame safety net).
                if btn.waveVisible then
                    btn.pulseWave:Hide()
                    btn.waveVisible = false
                end
                if btn.glowVisible then
                    btn.glow:Hide()
                    btn.glowVisible = false
                end
            end

            -- Pulse arc: continuous countdown readout toward the next
            -- pulse/detonation (own toggle: showPulseBars, independent of
            -- the wave/glow above). A duplicate pulseRatio()/oneshotRatio()
            -- call vs. the wave block above is cheap and keeps this block
            -- self-contained (see rev7 report).
            local arcRatio = nil
            if TotemBarDB.showPulseBars then
                if btn.waveInterval then
                    arcRatio = TotemBar.pulseRatio(btn.waveOrigin, nil, btn.waveInterval, now)
                elseif btn.waveOneshotAt and btn.waveOneshotDelay then
                    arcRatio = TotemBar.oneshotRatio(btn.waveOneshotAt - btn.waveOneshotDelay, btn.waveOneshotDelay, now)
                    if arcRatio and arcRatio >= 1 then arcRatio = nil end
                end
            end
            if arcRatio then
                local idx = math.floor(arcRatio * (RING_FRAMES - 1) + 0.5)
                if idx ~= btn.pulseArcLastIdx then
                    local rc = allRingCoords[idx + 1]
                    btn.pulseArc:SetTexCoord(rc.l, rc.r, rc.t, rc.b)
                    btn.pulseArcLastIdx = idx
                end
                if not btn.pulseVisible then
                    btn.pulseArc:Show()
                    btn.pulseVisible = true
                end
            elseif btn.pulseVisible then
                btn.pulseArc:Hide()
                btn.pulseVisible = false
                btn.pulseArcLastIdx = nil
            end
        end
    end
end

OnBarUpdate = function()
    UpdateWaves()
    timerElapsed = timerElapsed + arg1
    if timerElapsed < TIMER_UPDATE_INTERVAL then
        return
    end
    timerElapsed = 0
    UpdateTimerDisplays()
end

OnDragStart = function()
    if not TotemBarDB.locked then
        this:StartMoving()
    end
end

OnDragStop = function()
    this:StopMovingOrSizing()
    local point, _, relPoint, x, y = this:GetPoint()
    TotemBarDB.point = point
    TotemBarDB.relPoint = relPoint
    TotemBarDB.x = x
    TotemBarDB.y = y
end

-- (Re)applies the current BUTTON_GAP to the pending-assignment panel: frame
-- width (4 icons + gaps + the fixed accept-button margin) and each icon's
-- horizontal anchor - both baked in once when the panel is first built, so
-- (like layoutFlyoutIcons above) this must be called again from
-- TotemBar.SetButtonGap when the spacing slider changes live, not just at
-- build time. `frame` lets the build-time call pass the not-yet-assigned
-- local `f` directly; later calls omit it and fall back to the assignFrame
-- upvalue (guarded: a no-op if the panel has never been built yet).
local function layoutAssignIcons(frame)
    local f = frame or assignFrame
    if not f then
        return
    end
    f:SetWidth(4 * (BUTTON_SIZE + BUTTON_GAP) + BUTTON_GAP + 40)
    for i = 1, 4 do
        local ico = f.icons and f.icons[i]
        if ico then
            ico:ClearAllPoints()
            ico:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT",
                BUTTON_GAP + (i - 1) * (BUTTON_SIZE + BUTTON_GAP), 6)
        end
    end
end

-- Lazily builds the pending-suggestion panel: a heading label, a row of up
-- to 4 element-ordered totem icons, an Accept button, and a close "X".
-- Built once, reused; event-driven show/hide (no OnUpdate, no per-frame
-- allocation). Anchored above the bar.
EnsureAssignFrame = function()
    if assignFrame then
        return assignFrame
    end

    -- Parent to the bar so the pending panel inherits the bar's scale
    -- (UI-size slider), like the flyout. Still DIALOG strata below. Width
    -- is set below by layoutAssignIcons (once f.icons exists), not here.
    local f = CreateFrame("Frame", "TotemBarAssignFrame", TotemBarFrame)
    f:SetFrameStrata("DIALOG")
    f:SetHeight(BUTTON_SIZE + 34)
    -- Rev 4 UI chrome: shared panel skin (bespoke border/bg) instead of the
    -- generic tooltip textures. The bg texture carries the color now, so
    -- the tint just needs to be near-opaque white.
    f:SetBackdrop(PANEL_BACKDROP)
    f:SetBackdropColor(1, 1, 1, 0.97)
    f:SetClampedToScreen(true)
    f:ClearAllPoints()
    f:SetPoint("BOTTOM", TotemBarFrame, "TOP", 0, 6)

    local heading = f:CreateFontString("TotemBarAssignHeading", "OVERLAY")
    heading:SetFont("Fonts\\FRIZQT__.TTF", 11, "OUTLINE")
    heading:SetPoint("TOP", f, "TOP", 0, -6)
    heading:SetText("Assigned set")
    f.heading = heading

    f.icons = {}
    for i = 1, 4 do
        local ico = f:CreateTexture("TotemBarAssignIcon" .. i, "ARTWORK")
        ico:SetWidth(BUTTON_SIZE)
        ico:SetHeight(BUTTON_SIZE)
        -- Anchor applied below via layoutAssignIcons (once the whole row
        -- exists), not here - the same helper re-applies it live when
        -- BUTTON_GAP changes.
        ico:SetTexCoord(0.08, 0.92, 0.08, 0.92)
        f.icons[i] = ico
    end

    -- Sets the frame width and every icon's anchor per BUTTON_GAP now that
    -- the whole row exists (assignFrame isn't assigned yet at this point in
    -- the function, hence passing `f` explicitly - see layoutAssignIcons's
    -- own comment).
    layoutAssignIcons(f)

    local accept = CreateFrame("Button", "TotemBarAssignAccept", f, "UIPanelButtonTemplate")
    accept:SetWidth(28)
    accept:SetHeight(BUTTON_SIZE)
    accept:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -6, 6)
    accept:SetText("OK")
    accept:SetScript("OnClick", function()
        TotemBar.ApplyPending()
    end)
    accept:SetScript("OnEnter", function()
        GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
        GameTooltip:SetText("Apply assigned set")
        GameTooltip:AddLine("Sets these as your chosen totems (no cast).", 1, 1, 1)
        GameTooltip:Show()
    end)
    accept:SetScript("OnLeave", function() GameTooltip:Hide() end)
    f.accept = accept

    local close = CreateFrame("Button", "TotemBarAssignClose", f, "UIPanelCloseButton")
    close:SetWidth(22)
    close:SetHeight(22)
    close:SetPoint("TOPRIGHT", f, "TOPRIGHT", -2, -2)
    close:SetScript("OnClick", function()
        TotemBar.ClearAssignment()
    end)
    f.close = close

    f:Hide()
    assignFrame = f
    return f
end

-- Populates and shows the panel from TotemBar.pending. Unknown totems are
-- greyed (desaturated + dimmed). Called by ReceiveAssignment via the hook.
TotemBar.ShowAssignPanel = function()
    local p = TotemBar.pending
    if not p then
        return
    end
    local f = EnsureAssignFrame()
    f.heading:SetText(p.label or "Assigned set")

    local elements = TotemBar.TOTEM_ELEMENTS
    for i = 1, 4 do
        local element = elements[i]
        local name = element and p.set[element]
        local ico = f.icons[i]
        if name then
            local tex, known = ResolveTotemIcon(name)
            ico:SetTexture(tex)
            if known then
                ico:SetVertexColor(1, 1, 1)
                ico:SetAlpha(1)
            else
                ico:SetVertexColor(0.5, 0.5, 0.5)
                ico:SetAlpha(0.5)
            end
            ico:Show()
        else
            ico:Hide()
        end
    end
    f:Show()
end

-- Hides the panel (called by ClearAssignment / ApplyPending via the hook).
TotemBar.HideAssignPanel = function()
    if assignFrame then
        assignFrame:Hide()
    end
end

-- Repositions the bar buttons (up to six) into the grid configured by
-- TotemBarDB.barLayout and resizes the bar frame to match. Buttons are
-- ordered: the four element buttons (TOTEM_ELEMENTS order), then Recall,
-- then DropSet. The DropSet button is optional (TotemBarDB.showDropSetButton,
-- options panel "Show drop-all button"): when off it is left out of the grid
-- AND hidden, which drops the count to 5 - it is always the LAST slot in
-- every layout table, so slots 1..5 stay exactly where they were and the
-- 2x2 element block never breaks. Called once at the end of BuildUI (after
-- every button exists), from options.lua's layout cycle button, and from
-- that checkbox's setter. No-op before BuildUI has run (TotemBarFrame
-- doesn't exist yet).
TotemBar.ApplyBarLayout = function()
    if not TotemBarFrame then
        return
    end

    -- nil-safe read: an old SavedVariables file (or a call before
    -- ensureDefaults) means "show", matching the default.
    local showDrop = TotemBarDB.showDropSetButton
    if showDrop == nil then
        showDrop = true
    end

    local buttons = {}
    local elements = TotemBar.TOTEM_ELEMENTS
    for i = 1, table.getn(elements) do
        tinsert(buttons, elementButtons[elements[i]])
    end
    tinsert(buttons, recallButton)
    if showDrop then
        tinsert(buttons, dropSetButton)
    end

    -- The button frame itself always exists (BuildUI creates it
    -- unconditionally, so the toggle needs no UI rebuild and the bind
    -- overlay stays registered) - only its visibility follows the option.
    -- The TOTEMBAR_DROPSET keybinding keeps working either way: it is bound
    -- to the cast function, not to this frame.
    if dropSetButton then
        if showDrop then
            dropSetButton:Show()
        else
            dropSetButton:Hide()
        end
    end

    local count = table.getn(buttons)
    local layout = TotemBarDB.barLayout
    -- Explicit per-button {col, row} slots (core/optionslogic.lua): the 2x3
    -- layout keeps the four elements as a 2x2 block in the first two columns
    -- with Recall/DropSet as the third column, which no row-major fill order
    -- produces - hence a positions table per layout instead of index math.
    local positions = TotemBar.BAR_LAYOUT_POSITIONS[layout] or TotemBar.BAR_LAYOUT_POSITIONS["1x6"]
    local cols = BAR_LAYOUT_COLS[layout] or 6
    local extra = 0

    for i = 1, count do
        local btn = buttons[i]
        local pos = positions[i]
        if btn and pos then
            btn:ClearAllPoints()
            btn:SetPoint("TOPLEFT", TotemBarFrame, "TOPLEFT",
                BUTTON_GAP + pos[1] * (BUTTON_SIZE + BUTTON_GAP),
                -(BUTTON_GAP + pos[2] * (BUTTON_SIZE + BUTTON_GAP + extra)))
        end
    end

    local width, height = TotemBar.barDimensions(count, cols, BUTTON_SIZE, BUTTON_GAP, extra)
    TotemBarFrame:SetWidth(width)
    TotemBarFrame:SetHeight(height)
end

function TotemBar.BuildUI()
    if TotemBarFrame then
        return
    end

    local numElements = table.getn(TotemBar.TOTEM_ELEMENTS)

    -- Placeholder size: TotemBar.ApplyBarLayout() (called below, once every
    -- button exists) positions each button per the configured grid and
    -- resizes the frame for real - this initial size is never seen on screen.
    local frame = CreateFrame("Frame", "TotemBarFrame", UIParent)
    frame:SetWidth(BUTTON_SIZE + BUTTON_GAP * 2)
    frame:SetHeight(BUTTON_SIZE + BUTTON_GAP * 2)
    frame:SetBackdrop({
        bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        tile = true, tileSize = 16, edgeSize = 16,
        insets = { left = 4, right = 4, top = 4, bottom = 4 },
    })
    -- Floating icons: the bar's own backdrop is fully transparent (an
    -- invisible drag/hit-test area only - size/anchors unchanged).
    frame:SetBackdropColor(0, 0, 0, 0)
    frame:SetBackdropBorderColor(0, 0, 0, 0)
    frame:SetScale(TotemBarDB.scale or 1.0)

    frame:SetMovable(true)
    frame:EnableMouse(true)
    frame:RegisterForDrag("LeftButton")
    frame:SetScript("OnDragStart", OnDragStart)
    frame:SetScript("OnDragStop", OnDragStop)

    frame:ClearAllPoints()
    frame:SetPoint(TotemBarDB.point, UIParent, TotemBarDB.relPoint, TotemBarDB.x, TotemBarDB.y)

    for i = 1, numElements do
        local element = TotemBar.TOTEM_ELEMENTS[i]
        CreateElementButton(element, i)
        RefreshCooldown(element)   -- initial swipe state for whatever's already chosen
    end
    CreateRecallButton(numElements + 1)
    CreateDropSetButton(numElements + 2)

    -- Positions every button per TotemBarDB.barLayout and gives the frame
    -- its real size (see ApplyBarLayout above and the placeholder-size
    -- note on the CreateFrame call).
    TotemBar.ApplyBarLayout()

    frame:SetScript("OnUpdate", OnBarUpdate)

    frame:Show()
    if TotemBarDB.hidden then
        frame:Hide()
    end

    -- Show any already-bound key labels right after login (overlays are
    -- always visible now, not gated on bind mode - see refreshBindOverlays).
    TotemBar.refreshBindOverlays()
end

-- Scales the bar while keeping its TOP-LEFT corner visually fixed, so the
-- bar grows/shrinks toward the bottom-right instead of drifting away from
-- where the player put it. SetScale scales the anchor offset too, so a naive
-- SetScale moves the frame; here we capture the top-left's ABSOLUTE screen
-- position (local coord * effective scale = pixels), apply the new scale,
-- then re-anchor TOPLEFT so those pixels are preserved. Persists the new
-- scale + anchor so the next login reproduces it.
function TotemBar.SetBarScale(newScale)
    local f = TotemBarFrame
    if not f then
        return
    end
    if not newScale or newScale <= 0 then
        newScale = 1
    end
    local before = f:GetEffectiveScale()
    local left = f:GetLeft()
    local top = f:GetTop()
    f:SetScale(newScale)
    if left and top and before then
        local leftPx = left * before
        local topPx = top * before
        local after = f:GetEffectiveScale()
        f:ClearAllPoints()
        f:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", leftPx / after, topPx / after)
    end
    if TotemBarDB then
        TotemBarDB.scale = newScale
        local p, _, rp, x, y = f:GetPoint()
        if p then
            TotemBarDB.point = p
            TotemBarDB.relPoint = rp
            TotemBarDB.x = x
            TotemBarDB.y = y
        end
    end
end

-- Live-applies a new button gap (px, range 10-30): reassigns the shared
-- BUTTON_GAP upvalue (every closure that reads it - element/flyout/assign-
-- panel positioning, ApplyBarLayout - sees the new value on its next read,
-- no re-declaration needed) and persists it. Two icon pools bake their
-- BUTTON_GAP-dependent anchors in once at build time instead of recomputing
-- them on every read (unlike ApplyBarLayout, which already re-reads
-- BUTTON_GAP fresh every call and just needs re-running): the flyout icon
-- pool and the pending-assignment panel, both re-laid-out here via their
-- own helpers (layoutFlyoutIcons/layoutAssignIcons above), each a no-op if
-- that particular frame was never built yet. Driven by the options panel's
-- "Button spacing" slider.
function TotemBar.SetButtonGap(newGap)
    if not newGap then
        newGap = TotemBar.DEFAULT_BUTTON_GAP or 10
    end
    newGap = TotemBar.clampValue(newGap, 10, 30)
    BUTTON_GAP = newGap
    if TotemBarDB then
        TotemBarDB.buttonGap = newGap
    end
    layoutFlyoutIcons()
    layoutAssignIcons()
    if TotemBar.ApplyBarLayout then
        TotemBar.ApplyBarLayout()
    end
end

-- Shows/hides the bar and persists the choice (TotemBarDB.hidden). Driven
-- by the options panel's "Show bar" checkbox, the minimap right-click, and
-- the bare /tb command.
function TotemBar.ToggleBar()
    if not TotemBarFrame then
        return
    end
    if TotemBarFrame:IsShown() then
        TotemBarFrame:Hide()
        TotemBarDB.hidden = true
    else
        TotemBarFrame:Show()
        TotemBarDB.hidden = false
    end
end

-- Resets the bar's saved anchor to centered defaults and re-anchors it.
-- Does NOT change scale. Driven by the options panel's "Reset position".
function TotemBar.ResetPosition()
    TotemBarDB.point = "CENTER"
    TotemBarDB.relPoint = "CENTER"
    TotemBarDB.x = 0
    TotemBarDB.y = 0
    if TotemBarFrame then
        TotemBarFrame:ClearAllPoints()
        TotemBarFrame:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
    end
end

-- "/tb scan" dev-aid: one-shot print of every spellbook entry that looks
-- like a totem, so the static element map in core/totemdata.lua can be
-- checked against the live TurtleWoW client. Also writes the list to
-- <WoW>\imports\totembar_scan.txt via SuperWoW's ExportFile (when
-- present) so it can be read off-client without pasting from chat.
function TotemBar.PrintScan()
    local names = TotemBar.scanSpellbook()
    local count = 0
    local dump = ""
    ChatOut:AddMessage("TotemBar: scanning spellbook for totems...")
    for i = 1, table.getn(names) do
        if string.find(names[i], "Totem", 1, true) then
            ChatOut:AddMessage("  " .. names[i])
            dump = dump .. names[i] .. "\n"
            count = count + 1
        end
    end
    ChatOut:AddMessage("TotemBar: " .. count .. " totem spell(s) found.")
    -- ExportFile appends .txt itself; pass the name WITHOUT extension.
    if ExportFile then
        ExportFile("totembar_scan", "totems=" .. count .. "\n" .. dump)
    end
end

local function HandleSlashCommand(msg)
    local cmd = string.lower(msg or "")
    if cmd == "" then
        TotemBar.ToggleBar()
    elseif cmd == "lock" then
        TotemBarDB.locked = not TotemBarDB.locked
        if TotemBarDB.locked then
            ChatOut:AddMessage("TotemBar: bar locked.")
        else
            ChatOut:AddMessage("TotemBar: bar unlocked (drag to move).")
        end
    elseif cmd == "scan" then
        TotemBar.PrintScan()
    elseif cmd == "assign" then
        -- Dev aid: inject a sample assignment to exercise the pending panel
        -- + accept/decline flow end-to-end without any transport.
        local sample = {
            Fire  = "Searing Totem",
            Earth = "Strength of Earth Totem",
            Water = "Mana Spring Totem",
            Air   = "Grace of Air Totem",
        }
        local ok, reason = TotemBar.ReceiveAssignment(sample, "TEST assignment")
        if ok then
            ChatOut:AddMessage("TotemBar: injected TEST assignment (click OK on the panel to apply).")
        else
            ChatOut:AddMessage("TotemBar: assign failed - " .. tostring(reason))
        end
    elseif cmd == "options" or cmd == "opt" then
        TotemBar.ToggleOptions()
    elseif cmd == "bind" then
        if TotemBar.ToggleBindMode then TotemBar.ToggleBindMode() end
    elseif cmd == "manadump" then
        if TotemBar.dumpManaScan then TotemBar.dumpManaScan() end
    elseif cmd == "tdump" then
        if TotemBar.DumpTimerState then TotemBar.DumpTimerState() end
    elseif string.find(cmd, "^pulsecal") then
        local _, _, sub = string.find(cmd, "^pulsecal%s*(%a*)")
        if TotemBar.PulseCal then TotemBar.PulseCal(sub or "") end
    elseif string.find(cmd, "^tprobe") then
        local _, _, sub = string.find(cmd, "^tprobe%s*(%a*)")
        if TotemBar.TProbe then TotemBar.TProbe(sub or "") end
    else
        ChatOut:AddMessage("TotemBar: unknown command '" .. msg .. "'. Usage: /tb, /tb lock, /tb scan, /tb assign, /tb options, /tb bind, /tb manadump, /tb tdump, /tb pulsecal, /tb tprobe")
    end
end

-- SPELL_UPDATE_COOLDOWN fires whenever any spell's cooldown starts or
-- ends (the standard signal the default action bars key off of) - this
-- is the event-driven trigger for RefreshCooldown, kept separate from
-- the throttled per-tick timer OnUpdate (see RefreshCooldown's comment
-- for why: SetTimer-on-every-tick would restart the swipe animation).
local eventFrame = CreateFrame("Frame", "TotemBarEventFrame", UIParent)
eventFrame:RegisterEvent("ADDON_LOADED")
eventFrame:RegisterEvent("SPELL_UPDATE_COOLDOWN")
eventFrame:RegisterEvent("SPELLS_CHANGED")
eventFrame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS")
eventFrame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_CREATURE_BUFFS")
eventFrame:SetScript("OnEvent", function()
    if event == "ADDON_LOADED" and arg1 == "TotemBar" then
        TotemBar.ensureDefaults()
        -- BUTTON_GAP was initialized above (file scope, load time) to the
        -- file-load default, before TotemBarDB was even readable - it must
        -- be overwritten by the SAVED value now, BEFORE BuildUI runs, or
        -- the very first layout bakes in the default instead of whatever
        -- the player set via the options panel's "Button spacing" slider.
        BUTTON_GAP = TotemBar.clampValue(TotemBarDB.buttonGap or TotemBar.DEFAULT_BUTTON_GAP or 10, 10, 30)
        TotemBar.BuildUI()
        eventFrame:UnregisterEvent("ADDON_LOADED")
    elseif event == "SPELLS_CHANGED" then
        -- BuildUI resolves each slot's icon off the live spellbook at
        -- ADDON_LOADED time, but the spellbook (GetSpellName/
        -- GetSpellTexture) isn't reliably populated that early after a
        -- login/reload - so a SAVED totem's icon lookup falls through to
        -- the sheet's empty-slot glyph even though its name is stored
        -- fine. SPELLS_CHANGED fires once the spellbook is ready (and
        -- whenever it later changes), so re-resolve every icon here.
        local elements = TotemBar.TOTEM_ELEMENTS
        for i = 1, table.getn(elements) do
            RefreshButton(elements[i])
        end
        if recallButton and recallButton.icon then
            -- Re-set BOTH texture and crop texcoord: SetTexCoord persists
            -- across SetTexture, so a stale non-crop coord state must not
            -- survive a texture re-resolve.
            recallButton.icon:SetTexture(GetRecallIcon())
            recallButton.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
        end
    elseif event == "SPELL_UPDATE_COOLDOWN" then
        local elements = TotemBar.TOTEM_ELEMENTS
        for i = 1, table.getn(elements) do
            RefreshCooldown(elements[i])
        end
        RefreshFlyoutCooldowns()
    elseif event == "CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS"
        or event == "CHAT_MSG_SPELL_PERIODIC_CREATURE_BUFFS" then
        -- Re-anchor pulse phase on REAL observed periodic gains (spec 5.5).
        local totemName = TotemBar.parseSelfGain(arg1)
        if totemName then
            local element = TotemBar.elementOf(totemName)
            local rec = element and TotemBar.activeTotems[element]
            if rec and rec.totemName == totemName then
                -- These lines carry no caster, so another shaman's
                -- identically-named totem is indistinguishable from ours.
                -- Accept at most one anchor per pulse interval so a foreign
                -- tick train can't drag our phase off-beat.
                local pd = TotemBar.pulseInfo(totemName)
                local nowGain = GetTime()
                if TotemBar.shouldAnchorPulse(rec.pulseAnchor, pd and pd.interval, nowGain) then
                    rec.pulseAnchor = nowGain
                end
            end
        end
    end
end)

SLASH_TOTEMBAR1 = "/tb"
SlashCmdList = SlashCmdList or {}
SlashCmdList["TOTEMBAR"] = HandleSlashCommand
