local addonName, ns = ...

-------------------------------------------------------------------------------
-- THE SECRET-VALUE POLICY: FAIL LOUD, NEVER MASK
-------------------------------------------------------------------------------
-- EXACTLY ONE GUARD, at the identification boundary. Everywhere else, let it
-- throw. Both halves of that were learned the hard way in one evening.
--
-- The guard is in NormalizeUnitName, and it is there because a secret name is
-- an EXPECTED answer: the sweep walks ~90 unit tokens a tick and most of them
-- are not identifiable players. Skipping those is handling, not masking. It
-- deliberately uses issecretvalue rather than UnitIsPlayer, because
-- UnitIsPlayer covers one cause and cannot cover compound tokens at all.
--
-- Everywhere else there is no guard, because a secret anywhere else means we
-- asked a source we should never have asked -- a fact about the code, knowable
-- while writing it. Guarding that cannot rescue a correct program; it only
-- converts a wrong one from a traceback into a feature that silently does
-- nothing, and stale-but-plausible output is far harder to diagnose than a
-- crash naming the line. Two bugs cost a live battleground each precisely
-- because nothing threw.
--
-- The test for "should this be guarded": is a secret here a NORMAL outcome, or
-- does it mean we made a mistake? Guard the first. Never guard the second.
--
-- Prefer a non-secret SOURCE over either (the scoreboard over UnitClass).
--
-- Two secrecy triggers, and they are not interchangeable:
--   IDENTITY-GATED -- secret only when that unit's identity is secret, so a safe
--     token makes the call safe: UnitClass, UnitClassBase, UnitRace, UnitSex,
--     UnitSexBase, UnitPhaseReason, UnitGroupRolesAssigned(Enum),
--     UnitIsRaidOfficer, UnitInRaid, UnitIsPVP, UnitIsGroupLeader,
--     UnitIsGroupAssistant, UnitLeadsAnyGroup, UnitGetAvailableRoles,
--     UnitIsOwnerOrControllerOfUnit, GetInspectSpecialization.
--   AURA-GATED -- secret whenever auras are secret, which covers combat,
--     encounters, M+ and THE WHOLE PVP MATCH, on every token including "player":
--     UnitIsCharmed, UnitIsPossessed. There is no safe token for these; do not
--     call them here at all.
--
-- Caching a secret is still fine -- store it, pass it back to SetValue/SetText,
-- never read or compare it, and keep a PLAIN BOOLEAN beside it to branch on
-- (see entry.hasHealth).
-------------------------------------------------------------------------------

-------------------------------------------------------------------------------
-- Local refs
-------------------------------------------------------------------------------
local InCombatLockdown        = InCombatLockdown
local UnitExists              = UnitExists
local UnitName                = UnitName
local UnitClass               = UnitClass
local UnitRace                = UnitRace
local UnitIsUnit              = UnitIsUnit
local UnitHealth              = UnitHealth
local UnitHealthMax           = UnitHealthMax
local UnitGUID                = UnitGUID
local UnitIsDeadOrGhost       = UnitIsDeadOrGhost
local UnitPower               = UnitPower
local UnitPowerMax            = UnitPowerMax
local UnitPowerType           = UnitPowerType
local issecretvalue           = issecretvalue
local UnitFactionGroup        = UnitFactionGroup
local IsActiveBattlefieldArena = IsActiveBattlefieldArena
local IsInRaid                = IsInRaid
local IsInGroup               = IsInGroup
local IsControlKeyDown        = IsControlKeyDown
local GetInstanceInfo         = GetInstanceInfo
local GetNumBattlefieldScores = GetNumBattlefieldScores
local GetBattlefieldArenaFaction = GetBattlefieldArenaFaction
local RegisterStateDriver     = RegisterStateDriver

-------------------------------------------------------------------------------
-- Forward declarations
-------------------------------------------------------------------------------
-- Every top-level local function, declared here before anything is defined.
--
-- Lua resolves a name to a local only if the local is already in scope at the
-- point of use; otherwise it silently binds a GLOBAL, which is nil at call time.
-- In a file this long that meant a helper placed near the code it was ABOUT,
-- rather than after everything it CALLED, captured nil -- and it happened three
-- times in one day. Declaring them all up front removes the category: order of
-- definition no longer matters, only order of execution.
--
-- Definitions below use `function Name(` (assignment to the local), never
-- `local function Name(` (which would shadow this with a fresh local scoped from
-- that line down, reintroducing the same trap for anything above it).
--
-- Verify with: luac5.1 -l -p Core.lua | grep -c GETGLOBAL   -- should not name
-- any of these; and grep SETGLOBAL -- should be empty.
--
-- Chunk-local count after this block is ~176 of Lua's 200. Do not add locals
-- to this file casually; move state to ns.* or a table instead.
local NormalizeUnitName, GetMyTeamFaction, ApplyHeading, HideIntroPanel
local ShowIntroPanel, HidePanelRevealBars, ApplyObjectiveGlowColor, StackRulesHere
local ClearFlagPossession
local ObjectiveStackCount, ApplyObjectiveStackText, RefreshPreviews
local GetBarSpec, GetBarStatus, DeadRemaining, ApplyBarNameText
local RefreshSpecText, ApplyClassArtVisibility, DriveMist, RenderBarSuppression
local UpdateBarGlow, CreatePingDockArt, ApplyPingScale, AttachPingPress
local PingDockUpdate, StartPingDock, CardDockUpdate, StartCardDock
local CreateBar, CreateFrameSet, ApplyBarGeometry, ApplyRosterSizing
local ApplySizing, ApplyPosition, ResetBar, SetEnemyPingAttributes, SetEnemyFocusAttributes
local SetFriendlyPingAttributes, UpdateEnemyFrameSet, UpdateFriendlyFrameSet, GetDeadAutoClearDuration
local MarkEntryDead, MarkEntryAlive, CheckUnit, ScanTeamTargeting
local RunEnemyTokenSweep, RenderTargetGlow, RenderFriendlyTargetGlow, RenderEnemyHealth
local TrackEnemyObjectives, TrackFriendlyObjectives, RenderEnemyOverlay, RenderFriendlyObjective
local ShowActive, OnZoneChanged, RepositionWarnings, IsInPvPQueue
local BuildUnitToFrameMap, EnsureRaidObjectiveIcon, HideAllRaidObjectiveIcons, UpdateRaidObjectiveIcons
local VerifyTargetOrMarkStealth
local LeaveMatch
local UpdateFlagClock
local ObjectiveFromChatText
local RestoreFlagClock


-------------------------------------------------------------------------------
-- Constants
-------------------------------------------------------------------------------
local MAX_NORMAL    = 15
local MAX_EPIC      = 40
local POWER_BAR_H   = 4

local FACTION_INDEX = { Horde = 0, Alliance = 1 }
local FACTION_NAMES = { [0] = "Horde", [1] = "Alliance" }

-- Accent gold, matching Palette.accent in the options panel.
ns.HEADING_RULE_COLOR = { 0.85, 0.66, 0.22 }
ns.HEADING_RULE_GAP   = 4      -- px between the text and each rule
ns.HEADING_CLEARANCE  = 9      -- px between the lower rule and the first bar

-- Glow plate, matching Palette.platformRest / platformRestDark in the options
-- panel. A trapezoid with a vertical gradient: bright along the edge nearest
-- the heading, fading to nothing away from it — that falloff is the "fade in"
-- the hairlines alone do not give.
ns.HEADING_PLATE_COLOR      = { 0.52, 0.40, 0.16 }
ns.HEADING_PLATE_DARK       = { 0.20, 0.15, 0.07 }
ns.HEADING_PLATE_ALPHA      = 0.26
ns.HEADING_PLATE_HEIGHT     = 16
ns.HEADING_PLATE_NARROW     = 0.60   -- far edge width, as a fraction of the near edge

-- A fading double hairline spanning the container's width, the same device the
-- options panel uses for its section headers.
--
-- Built as two HALVES because a gradient can only ramp one way: a single
-- texture would fade in from the left and stop dead on the right. Returns the
-- four textures so the caller can reposition and show/hide them as a set.
function ns.MakeHeadingRule(container)
    local a = ns.HEADING_RULE_COLOR
    local out = {}
    for _, spec in ipairs({ { 0, 0.55 }, { -3, 0.20 } }) do
        for _, side in ipairs({ "left", "right" }) do
            local t = container:CreateTexture(nil, "OVERLAY")
            t:SetHeight(1)
            t:SetColorTexture(1, 1, 1, 1)
            if side == "left" then
                t:SetGradient("HORIZONTAL",
                    CreateColor(a[1], a[2], a[3], 0), CreateColor(a[1], a[2], a[3], spec[2]))
            else
                t:SetGradient("HORIZONTAL",
                    CreateColor(a[1], a[2], a[3], spec[2]), CreateColor(a[1], a[2], a[3], 0))
            end
            t._side, t._dy = side, spec[1]
            out[#out + 1] = t
        end
    end
    return out
end

-- The glow plate. ONE plate, under the lower rule only — the same arrangement
-- DecorateTextHeader uses for the enable-frames header, which is the reference
-- for this whole band.
--
-- SetGradient("VERTICAL", a, b) puts `a` at the BOTTOM and `b` at the top, so
-- bright-then-dark glows from the bottom edge upward toward the rule. An
-- earlier version had this inverted and lit the wrong edge.
--
-- The trapezoid comes from SetVertexOffset pulling the far edge inward, so the
-- plate tapers as it fades rather than fading as a rectangle.
function ns.MakeHeadingPlate(container)
    -- ARTWORK, not OVERLAY: the plate rises BEHIND the heading text (see the
    -- placement note in LayOutHeadingBand), so it has to sit under both the
    -- text and the rules, which are both OVERLAY.
    local plate = container:CreateTexture(nil, "ARTWORK", nil, -1)
    plate:SetTexture("Interface\\Buttons\\WHITE8x8")
    plate:SetHeight(ns.HEADING_PLATE_HEIGHT)
    -- Gold, not faction-tinted. Red/blue hazes were tried and did not work --
    -- the band already names the faction in words, and colouring the plate as
    -- well competed with the count sitting on it for no added information.
    local c, d = ns.HEADING_PLATE_COLOR, ns.HEADING_PLATE_DARK
    plate:SetGradient("VERTICAL",
        CreateColor(c[1], c[2], c[3], ns.HEADING_PLATE_ALPHA),
        CreateColor(d[1], d[2], d[3], 0))
    container.headingPlate = plate
    return plate
end

-- Places a rule set at `y` above the container's top edge.
function ns.PlaceHeadingRule(container, rule, y, xShift)
    xShift = xShift or 0
    for _, t in ipairs(rule) do
        t:ClearAllPoints()
        if t._side == "left" then
            t:SetPoint("BOTTOMLEFT",  container, "TOPLEFT", xShift, y + t._dy)
            t:SetPoint("BOTTOMRIGHT", container, "TOP",     xShift, y + t._dy)
        else
            t:SetPoint("BOTTOMLEFT",  container, "TOP",      xShift, y + t._dy)
            t:SetPoint("BOTTOMRIGHT", container, "TOPRIGHT", xShift, y + t._dy)
        end
    end
end


-- BG intro panel art (256x512 portrait TGAs, RGBA)
local FACTION_PANELS = {
    -- THE ORIGINAL FADED CRESTS, kept after trying the solid Backdrops pair.
    -- These are deliberately washed out, which suits a waiting screen: they read
    -- as a watermark the bars will replace rather than as a picture competing for
    -- attention. The solid versions are baked and available
    -- (IntroHorde/IntroAlliance, from Textures/bake_intro.py) if that judgement
    -- ever reverses -- swapping is these two lines.
    --
    -- Their problem is legibility against a bright world, which is what
    -- INTRO_SHADE_ALPHA below addresses: darken the ground rather than the art.
    [0] = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\horde_stylized_foreground_256x512_rgba.tga",
    [1] = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\alliance_stylized_foreground_256x512_rgba.tga",
}
-- PANEL_ART_W/H are gone: they sized the old tall portrait panel, which was
-- 256x512 drawn at 2/3. The banner takes its size from the bar-width setting
-- instead, so nothing needs to know the source texture's dimensions.
local INTRO_PANEL_ALPHA = 1.0  -- panel artwork opacity

-- Waiting portrait. It does NOT replace the heading -- the heading stays and
-- reads "Horde · 0" -- it fills the space BELOW it where the cards will appear,
-- so the column is occupied by the faction rather than empty until players load.
--
-- ASPECT is width/height of the artwork, and the portrait is drawn at the card
-- width, so height follows. Art at a different ratio stretches. 0.52 is the
-- shape the source art is drawn to (roughly 1:2).
ns.INTRO_PORTRAIT_ASPECT = 0.52

-- Portrait width as a fraction of the bar width. The faded art was drawn to fill
-- the column, but at full width it crowds the space the bars are about to take;
-- 0.72 keeps it clearly a badge sitting in the column. The solid Backdrops
-- crests wanted about half, being far denser. Kept as a constant so the two can
-- be swapped without rediscovering that.
ns.INTRO_PORTRAIT_SCALE = 0.72

-- Shade alpha while waiting. It was raised to 0.88 to make the faded crests
-- legible against a bright world; with INTRO_SHOW_ART off that reason is gone and
-- 0.88 over a 428px empty panel is a heavy slab for one line of text.
--
-- 0.50 is deliberately BELOW the 0.60 the bars use. The same alpha reads heavier
-- here because the area is large and uninterrupted -- behind bars the cards break
-- it up, so it never presents as a single block. Matching the numbers would not
-- match the weight.
--
-- Raise it back toward 0.88 if the artwork is ever turned on.
-- HidePanelRevealBars already restores 0.60, so this only needs setting.
ns.INTRO_SHADE_ALPHA = 0.50
-- The bars' resting shade. Named so the intro's restore and the creation-time
-- default cannot drift apart -- they were the same literal in two places.
ns.SHADE_ALPHA       = 0.60

-- The waiting text sits in the SHADE BAND under the heading, not over the art.
-- Over the crest it competed with a busy, high-contrast image for legibility;
-- the shade is flat and dark and is already there to be read against.
ns.INTRO_TEXT_SCALE = 0.88   -- caption size, as a multiple of the heading size

-- The caption breathes rather than blinks. A SINE, not a timer: it spends most
-- of its time near the extremes and eases through them, where a scheduled
-- fade-out/fade-in has hard corners at the turnaround and reads as flashing.
--
-- RATE is cycles per second, so 0.28 is a ~3.6s round trip -- slow enough to be
-- noticed rather than watched.
--
-- MIN is the floor. 0.55 was too shallow to register as movement at all; 0.30
-- gives a real dip while still never reaching zero, because text that fully
-- disappears reads as a fault rather than an effect. Below about 0.15 it starts
-- to look like a rendering problem rather than a pulse.
ns.INTRO_TEXT_PULSE_RATE = 0.28
ns.INTRO_TEXT_PULSE_MIN  = 0.30
ns.INTRO_TEXT_TOP = 4      -- below the container top
ns.INTRO_TEXT_GAP = 8      -- between the text and the crest
ns.INTRO_PAD_BOTTOM = 10   -- below the crest, before the container ends

-- ARTWORK OFF for pre-release. The crest never sat right in this space -- see the
-- container-sizing note in ShowIntroPanel for the part that was actually broken
-- -- and a plain shaded panel with a centred caption is honest and finished,
-- where a not-quite-right illustration is neither.
--
-- The panel keeps its full height regardless, so the column still reserves the
-- space the bars are about to fill rather than collapsing to a strip. Set true to
-- bring the crest back; everything it needs is still wired.
ns.INTRO_SHOW_ART = false

-- How many players a frame set needs before the portrait gives way to bars, and
-- how long that has to hold first.
--
-- 2 rather than 1 because a scoreboard's first scan can arrive partial; swapping
-- on a single name risks the portrait leaving and returning. It is NOT a
-- ghost-bar defence any more -- that was blank scoreboard rows, filtered at the
-- scan since 2026-08-17 -- so fs.active is trustworthy and 1 would also work.
--
-- The hold is the part that matters for feel. Without it the portrait vanishes
-- the instant one name lands, which reads as a glitch rather than a transition.
ns.INTRO_MIN_PLAYERS = 2
ns.INTRO_HOLD        = 1.0

-- Forward-declared upvalues consumed by helper closures below.
-- (Real assignments live in the State block further down.)
local playerFactionIndex = 0

-- Forward-declared: the frame set update functions call this, but it lives
-- with the rest of the spec display code further down.

-- Canonical name form, shared by both sides of the pairing.
--
-- The two sources disagree on shape: C_PvP.GetScoreInfo hands back a single
-- "Name" or "Name-Realm" string, while UnitName returns name and realm as two
-- values with realm empty for same-realm players. Everything is normalised to
-- the scoreboard's form so the two can be compared directly.
--
-- THE ONE PLACE A SECRET IS AN EXPECTED ANSWER, not a mistake -- so this is
-- the one guard the fail-loud policy keeps. Restored 2026-08-16 after being
-- removed and proving necessary within a single battleground.
--
-- A secret name here does NOT mean we asked something we should not have. It
-- means this token is not one we can identify, which for a sweep that walks 90
-- tokens a tick is a routine, correct outcome. Skipping it is handling, not
-- masking.
--
-- IT MUST BE issecretvalue, NOT UnitIsPlayer. UnitIsPlayer only covers one
-- cause (non-player units) and CANNOT cover compound tokens at all: raid(i)
-- target answers NIL, which is "unknown", so it passes any `~= false` test and
-- arrives here with a secret name regardless. issecretvalue covers every cause
-- -- pets, NPCs, taint, match phase, anything Blizzard adds later.
--
-- ORDER IS LOAD-BEARING. This runs before EVERY other test, because `not name`,
-- `name == UNKNOWNOBJECT` and `realm ~= ""` are all reads that throw on a
-- secret. A guard placed second is not a guard.
function NormalizeUnitName(unit)
    local name, realm = UnitName(unit)
    if issecretvalue and issecretvalue(name) then return nil end
    if not name then return nil end
    -- Blizzard's own raid UI treats this placeholder as "not resolved yet",
    -- not as identity. Letting it through would key a tracker entry under
    -- "Unknown" and pair every unresolved unit to the same bar.
    if name == UNKNOWNOBJECT then return nil end
    if issecretvalue and issecretvalue(realm) then return name end
    if realm and realm ~= "" then
        return name .. "-" .. realm
    end
    return name
end

-- Player's team faction in the current BG. Cross-faction queues can place the
-- player on the opposite-race team, so UnitFactionGroup (racial) is wrong for
-- enemy/friendly classification. Falls back to racial faction outside BGs
-- (test mode, login) where GetBattlefieldArenaFaction returns nil.
function GetMyTeamFaction()
    return GetBattlefieldArenaFaction() or playerFactionIndex
end

-- Single centered line: icon + faction name + Players (N).
-- Builds the heading string (faction icon markup + name + player count).
-- Shared with the settings previews so both render an identical line —
-- previews previously hand-rolled a plain string and lost the faction icon.
-- Faction name, then the count in accent gold.
--
-- The crest markup was dropped: it inlined Interface\\PVPFrame\\PVP-Currency-*,
-- which is a CURRENCY emblem drawn to read at 16px in a ledger, not a piece of
-- heraldry — the one borrowed asset left on a card where everything else is
-- purpose-drawn. Inline |T..|t also cannot be sized, tinted or positioned
-- independently of the text, so any real crest has to be its own texture.
--
-- "Players" went with it. The bars are obviously players; the payload is the
-- faction and the number.
ns.HEADING_COUNT_COLOR = "ffd9a838"      -- Palette.accent as hex

-- Positions the text and both rules for a heading band. Shared by live frames
-- and the settings previews so the two cannot drift — the previews build their
-- own FontString, and this is the only thing that decides where the band sits.
--
-- Driven from the FONT SIZE, not measured: GetStringHeight is avoidable here
-- and the size is already known.
-- Shows or hides both rule sets. Separate from LayOutHeadingBand because the
-- previews and the live frames hide the heading through different paths.
function ns.ShowHeadingRules(container, shown)
    for _, key in ipairs({ "headingRuleTop", "headingRuleBottom" }) do
        local r = container[key]
        if r then
            for _, t in ipairs(r) do t:SetShown(shown) end
        end
    end
    if container.headingPlate then container.headingPlate:SetShown(shown) end
end

function ns.LayOutHeadingBand(container, heading, fontSize, xShift)
    -- xShift recentres the whole band -- text, rules and plate together --
    -- over the card-plus-art COMPOSITION when the class art hangs outside
    -- the card (same halving the centre text and stealth reveal use). The
    -- band stays container-width; it slides, it does not stretch.
    xShift = xShift or 0
    local lower = ns.HEADING_CLEARANCE
    local base  = lower + ns.HEADING_RULE_GAP + 1
    local upper = base + fontSize + ns.HEADING_RULE_GAP

    heading:ClearAllPoints()
    heading:SetPoint("BOTTOM", container, "TOP", xShift, base)
    if container.headingRuleBottom then
        ns.PlaceHeadingRule(container, container.headingRuleBottom, lower, xShift)
    end
    if container.headingRuleTop then
        ns.PlaceHeadingRule(container, container.headingRuleTop, upper, xShift)
    end

    -- The glow plate, mirroring AddHeaderPlatform's DEFAULT case exactly
    -- (placement "below", flipped = false), because that is the arrangement
    -- DecorateTextHeader uses and the one this band is copying:
    --
    --   * its BOTTOM edge sits ON the lower rule and it rises from there,
    --     so the glow reads as coming off the rule rather than hanging under
    --     it. In the options panel the platform's bottom lands exactly on the
    --     "below" hairline (top at anchor bottom +12, height 16 -> bottom at
    --     -4, and the hairline is at -4). Same relationship here.
    --   * bright at the BOTTOM, transparent at the top. SetGradient("VERTICAL")
    --     puts its FIRST colour at the bottom.
    --   * the TOP edge is the one pulled in, so it narrows as it fades.
    --
    -- Getting the taper and the gradient on opposite ends is what broke this
    -- twice: the bright end lands on the narrow tip and the wide end is fully
    -- transparent, which renders as almost nothing.
    local plate = container.headingPlate
    local w = container:GetWidth()
    if plate and w and w > 0 then
        local inset = (w - w * ns.HEADING_PLATE_NARROW) / 2
        plate:ClearAllPoints()
        plate:SetPoint("BOTTOMLEFT",  container, "TOPLEFT",  xShift, lower)
        plate:SetPoint("BOTTOMRIGHT", container, "TOPRIGHT", xShift, lower)
        if plate.SetVertexOffset then
            plate:SetVertexOffset(1,  inset, 0)   -- UPPER_LEFT pulled in
            plate:SetVertexOffset(3, -inset, 0)   -- UPPER_RIGHT pulled in
        end
    end
end

function ns.BuildFactionHeading(factionIndex, count, fontSize)
    local factionName = FACTION_NAMES[factionIndex] or "Players"
    -- "Alliance · 8". The separator is a middle dot rather than brackets or a
    -- dash: it reads as a divider without adding weight, and it keeps the count
    -- as the only thing carrying colour.
    return ("%s · |c%s%d|r"):format(factionName, ns.HEADING_COUNT_COLOR, count or 0)
end

-- Takes the frame set rather than a bare container so that the hide toggle and
-- the font size are READ here instead of being passed in. They used to be
-- trailing parameters, two of the three callers omitted hideFaction, and a nil
-- there reads as "show" -- so the one caller that passed it correctly had its
-- work undone by the next call in the same pass.
function ApplyHeading(fs, factionIndex, count)
    local container = fs.container
    if fs.g.hideFaction and fs.g.hideFaction() then
        container.heading:Hide()
        ns.ShowHeadingRules(container, false)
        return
    end
    container.heading:Show()
    ns.ShowHeadingRules(container, true)

    local fontSize = (fs.g.headingSize and fs.g.headingSize()) or 12
    container.heading:SetFont(ns.LOCALE_FONT, fontSize, "")

    -- The heading sits HEADING_RULE_GAP + 1 above the container, so the upper
    -- rule clears the text by the same gap. Driven from the font size rather
    -- than measured: GetStringHeight on a live FontString is avoidable here and
    -- the size is already known.
    -- Recentre over the composition when the class art hangs outside the
    -- card. Any bar serves as the height reference -- the set shares one --
    -- and RevealArtOffset already answers style, Hide Class Art and resizes.
    local refBar = fs.bars and fs.bars[1]
    local xShift = refBar and ns.RevealArtOffset
                   and -(ns.RevealArtOffset(refBar) / 2) or 0
    ns.LayOutHeadingBand(container, container.heading, fontSize, xShift)
    container.heading:SetText(ns.BuildFactionHeading(factionIndex, count, fontSize))
end


-- The panel is parented to UIParent rather than to the container, so that it
-- can be positioned independently of it. The consequence is that hiding the
-- container does NOT cascade to the panel, so every path that takes a frame
-- set off screen has to hide it explicitly.
function HideIntroPanel(fs)
    if not fs or not fs.container then return end
    if fs.container.introPanel     then fs.container.introPanel:Hide()     end
    if fs.container.introPanelText then fs.container.introPanelText:Hide() end
end

-- Show the static "Waiting for players" panel until scoreboard data arrives.
function ShowIntroPanel(fs)
    fs._introWasUp = true   -- arms the load-in cascade for the reveal
    if not fs or not fs.container then return end
    local container = fs.container
    if not container.introPanel then return end

    -- A disabled frame set has no players to wait for. Its update function
    -- returns early, leaving fs.active at 0, which is indistinguishable from
    -- "still filling" to the caller — so the check belongs here rather than
    -- there.
    if fs.g and fs.g.enable and not fs.g.enable() then
        HideIntroPanel(fs)
        return
    end

    local myTeam = GetMyTeamFaction()
    local factionIndex = fs.isEnemy and (1 - myTeam) or myTeam
    local tex = FACTION_PANELS[factionIndex]
    if not tex then return end

    -- Width from the same setting the bars use, so the portrait and the column
    -- cannot disagree about how wide the frame set is; height from the art's own
    -- aspect so it never stretches.
    local w = (fs.g.barWidth and fs.g.barWidth()) or 200
    local pw = w * ns.INTRO_PORTRAIT_SCALE
    container.introPanel:SetSize(pw, pw / ns.INTRO_PORTRAIT_ASPECT)
    container.introPanelTex:SetTexture(tex)
    container.introPanelTex:SetShown(ns.INTRO_SHOW_ART)
    container.introPanel:SetAlpha(INTRO_PANEL_ALPHA)
    if container.shade then container.shade:SetAlpha(ns.INTRO_SHADE_ALPHA) end
    container.introPanel:Show()

    -- AND THE CONTAINER. This is why the portrait never appeared on zone-in:
    -- fs.container:Show() is only called by the frame-set updates, and both do it
    -- behind `if active > 0`. At match start active is 0 -- which is precisely
    -- when the portrait is wanted -- so the panel was being shown inside a hidden
    -- parent and could not draw. (The old /bft intro preview masked this by
    -- showing the container itself.)
    --
    -- Safe here: the enable() check above has already returned for a disabled
    -- frame set, so this cannot resurrect one the user turned off.
    container:Show()

    if container.introPanelText then
        -- Sized DOWN from the heading, not up. At 1.6x it was set as a title in
        -- its own right and overran the column -- "Waiting for enemy players" is
        -- 25 characters against a 275px bar, so it has to be small to sit inside
        -- the shaded band at all. Subordinate to the heading is also the correct
        -- hierarchy: the heading is the fact, this is the caption.
        local fontSize = (fs.g.headingSize and fs.g.headingSize() or 12)
                         * ns.INTRO_TEXT_SCALE
        container.introPanelText:SetFont(ns.LOCALE_FONT, fontSize, "")
        -- Bounded to the column so it WRAPS. Unbounded it renders as one
        -- line and runs straight out of the shade -- fine for the old
        -- 25-character caption, not for "Waiting For Enemies To Join
        -- Scoreboard". The panel height below measures GetStringHeight, which
        -- already accounts for however many lines that comes to.
        container.introPanelText:SetWidth(w - 8)
        container.introPanelText:SetJustifyH("CENTER")

        -- Centred on the COMPOSITION, not on the container. With the class art
        -- hanging outside the card the bars that replace this panel sit half an
        -- art-width to the left of it, so a caption centred on the container
        -- alone visibly jumped sideways at the handoff. Same halving the
        -- heading band, the centre text and the stealth reveal take.
        local introShift = -(ns.ArtOutsideWidth(
            (fs.g.barHeight and fs.g.barHeight()) or 0,
            fs.g.hideClassArt and fs.g.hideClassArt()) / 2)
        -- Names the faction being waited on. "Waiting for players" was
        -- ambiguous with two frame sets on screen, each waiting on a different
        -- side; this says which column is which before anyone has loaded in.
        -- Generic, NOT faction-named: the heading directly above already reads
        -- "Horde - 0", so naming the faction here says it twice. This says which
        -- COLUMN is waiting, which is the part the heading does not cover.
        container.introPanelText:SetText(
            fs.isEnemy and "Waiting For Enemies To Join Scoreboard"
                       or "Waiting For Allies To Join Scoreboard")
        container.introPanelText:SetAlpha(1)
        container.introPanelText:Show()

        -- With the art off the caption is the only thing in the panel, so it goes
        -- in the middle of it rather than hanging at the top under the heading.
        container.introPanelText:ClearAllPoints()
        if ns.INTRO_SHOW_ART then
            container.introPanelText:SetPoint("TOP", container, "TOP", introShift, -ns.INTRO_TEXT_TOP)
            container.introPanel:ClearAllPoints()
            container.introPanel:SetPoint("TOP", container.introPanelText, "BOTTOM",
                                          0, -ns.INTRO_TEXT_GAP)
        else
            container.introPanelText:SetPoint("CENTER", container, "CENTER", introShift, 0)
        end

        -- THE CONTAINER BECOMES THE PANEL, and this is what stops the whole thing
        -- looking arbitrary. ApplySizing gives an empty frame set a height of one
        -- BAR -- 35px -- because that is all it can know about a column with no
        -- bars in it. The shade is anchored to the container, so it was a 63px
        -- strip with a 380px crest hanging out of the bottom of it into bare
        -- world: nothing contained anything, and the eye had no group to read.
        --
        -- Sizing the container to caption + gap + crest + pad makes the shade
        -- wrap all of it, so it reads as one panel rather than three unrelated
        -- objects. No teardown needed -- UpdateEnemyFrameSet calls ApplySizing
        -- when it populates, which restores bar-based sizing on the way out.
        -- Height is computed as if the crest were present whether or not it is
        -- drawn, so turning the art off does not collapse the column back to a
        -- strip -- the panel is reserving the space the bars will occupy.
        local capH = (container.introPanelText:GetStringHeight() or fontSize) + 2
        container:SetSize(w, ns.INTRO_TEXT_TOP + capH + ns.INTRO_TEXT_GAP
                             + (pw / ns.INTRO_PORTRAIT_ASPECT) + ns.INTRO_PAD_BOTTOM)
    end

    -- Bars hidden; HEADING AND SHADE STAY. The heading still reads "Horde · 0",
    -- which is information -- it says the frame set is alive and the count is
    -- genuinely zero, not that the addon has not loaded. The column shade stays
    -- because the portrait sits in the column and benefits from the same
    -- grounding the cards get.
    for i = 1, fs.maxBars do fs.bars[i]:SetAlpha(0) end
end

-- Hide the static panel and reveal bars/heading/backdrop at full opacity.
function HidePanelRevealBars(fs)
    if not fs or not fs.container then return end
    local container = fs.container
    if container.introPanel     then container.introPanel:Hide()     end
    if container.introPanelText then container.introPanelText:Hide() end
    -- Shade and heading are no longer dimmed while waiting, so there is nothing
    -- to restore -- they are set here anyway, cheaply, because this also runs on
    -- the path back from test mode and a stale alpha from any earlier build
    -- would otherwise persist until reload.
    if container.shade          then container.shade:SetAlpha(ns.SHADE_ALPHA) end
    if container.heading        then container.heading:SetAlpha(1)   end
    for i = 1, fs.maxBars do fs.bars[i]:SetAlpha(1) end

    -- Snap the container to bar height NOW. ApplyRosterSizing deliberately
    -- leaves the container alone while the intro is up (so it stops collapsing
    -- the panel), which means that once the intro hides nothing resizes it
    -- until the next roster change -- and with a short enemy list that can be a
    -- long time. Until then the shade sat at the intro's full height with the
    -- bars occupying only the top of it.
    ApplyRosterSizing(fs)

    -- The load-in cascade plays exactly once per waiting-panel handoff:
    -- ShowIntroPanel arms it, the first reveal after spends it. A roster
    -- refresh that calls this again mid-match finds it disarmed.
    if fs._introWasUp then
        fs._introWasUp = nil
        ns.StartBarLoadin(fs)
    end
end

-- Sole owner of "portrait or bars?", called from both tickers.
--
-- DECOUPLED PER FRAME SET, deliberately. The two populate from different sources
-- with different latencies -- friendly from party/raid tokens, available almost
-- at once, enemy from C_PvP.GetScoreInfo, which lags -- so a shared trigger would
-- park a portrait over a friendly column that already holds real data. The
-- friendly set can also be disabled entirely, which would strand a coupled
-- enemy portrait.
--
-- The portrait is allowed to come BACK if a roster empties mid-match. Deliberate
-- for now: it is honest about the state, and the case barely arises before the
-- match ends.
function ns.UpdateIntroPanel(fs)
    if not fs or not fs.container or not fs.container.introPanel then return end
    local ready = (fs.active or 0) >= ns.INTRO_MIN_PLAYERS
    local revealed = false
    if ready then
        -- Stamp when the threshold was first met, so the hold measures from
        -- there rather than from whenever this happens to be called.
        fs.introReadyAt = fs.introReadyAt or GetTime()
        if fs.container.introPanel:IsShown()
           and (GetTime() - fs.introReadyAt) >= ns.INTRO_HOLD then
            HidePanelRevealBars(fs)
            revealed = true
        end
    else
        fs.introReadyAt = nil
        -- Re-show if EITHER the panel or its container is hidden. /bft state
        -- caught the exact failure: container shown=false, h=422, panel
        -- shown=true. ShowActive hides every container first and then calls
        -- this; on the second ShowActive of a match (PVP_MATCH_ACTIVE, the
        -- zone-change event, the watchdog) the panel was still "shown" from the
        -- first pass, so this skipped, and the container it had just hidden
        -- stayed hidden with a shown panel inside it. A panel inside a hidden
        -- parent is not shown, whatever IsShown says.
        if not fs.container.introPanel:IsShown() or not fs.container:IsShown() then
            ShowIntroPanel(fs)
        end
    end

    -- CONTINUOUS bar suppression while the panel is up. ShowIntroPanel zeroes
    -- the bar alphas once, but the roster keeps populating during the wait
    -- and the hold -- new assignments arrive at full alpha and the range
    -- ticker re-asserts alpha 1 -- so real bars were surfacing under the
    -- panel, and the reveal cascade then "removed" them to replay its
    -- entrance. This runs LAST in the ticker, so it gets the frame's final
    -- word for as long as the panel is showing.
    if not revealed and fs.container.introPanel:IsShown() and fs.container:IsShown() then
        for i = 1, fs.maxBars do fs.bars[i]:SetAlpha(0) end
    end
end


-- Per-BG arena-slot → classification mapping. Blizzard assigns flag/orb
-- carriers to specific arena slots; we infer "who's carrying what" from the
-- known assignment pattern. Classification IDs match Enum.PvPUnitClassification.
-- `single = N` means SINGLE-OBJECTIVE: this battleground only ever has one
-- carrier, so occupying ANY arena slot is itself the statement "this player has
-- the objective". No slot-order guessing, and the classification is a DISPLAY
-- CHOICE rather than a value recovered from the API -- there is only one thing
-- it could be.
--
-- That distinction matters because UnitPvpClassification takes a
-- UnitTokenPvPRestrictedForAddOns, the same restricted family as UnitHealth and
-- UnitPower, so its return is expected to be secret for enemies -- unusable,
-- because choosing an icon means indexing a table with it. Inferring from the
-- slot yields a PLAIN number we are allowed to use. That is why this map exists
-- at all; it is the workaround, not a shortcut.
--
-- Eye of the Storm and Deephaul Ravine were `{ [1] = 1, [2] = 0 }` -- a
-- byte-for-byte copy of the Warsong row, present since at least the June 13
-- build, so not rebuild damage. It claimed two carriers holding opposing FACTION
-- flags, which is wrong twice over for a one-objective map and is what put a
-- Horde flag on a Horde player.
--
-- Warsong, Twin Peaks and Kotmogu keep explicit per-slot rows: they genuinely
-- have two or four simultaneous carriers of different objectives.
--
-- KOTMOGU'S ORDER IS CONFIRMED CORRECT AS WRITTEN (user, 2026-08-17):
-- blue / purple / green / orange. BlitzBrain has {7,8,9,10} for the same
-- battleground and is simply WRONG -- do not "reconcile" the two, and do not
-- treat that addon as a reference for this. Its own comment disagrees with its
-- own table as well, so it is unreliable on this point twice over.
-- NOT an Enum.PvPUnitClassification member, and deliberately out of its range.
-- We never read a classification from the API -- it is restricted for enemies --
-- so these numbers are only ever OUR index into OBJECTIVE_ICONS. A
-- value outside 0-10 makes that obvious and cannot collide if the enum grows.
-- Deephaul Ravine's objective is a crystal, not a flag, and gets its own art.
local OBJECTIVE_CRYSTAL = 100

-- Declared here, ahead of everything that walks it -- UpdateFlagClock,
-- TrackEnemyObjectives, TrackFriendlyObjectives -- so none of them capture a global.
-- It is a table, so it cannot live in the forward-declaration block of
-- functions at the top of the file.
local ARENA_UNITS = { "arena1", "arena2", "arena3", "arena4", "arena5" }

local OBJECTIVE_SLOTS = {
    [2106] = { [1] = 1, [2] = 0 },  -- Warsong Gulch    -- 2 carriers, faction flags
    [726]  = { [1] = 1, [2] = 0 },  -- Twin Peaks       -- 2 carriers, faction flags
    [566]  = { single = 2 },        -- Eye of the Storm -- 1 carrier, neutral flag
    [2656] = { single = OBJECTIVE_CRYSTAL },  -- Deephaul Ravine -- 1 carrier, its own crystal
    [998]  = { [1] = 7, [2] = 10, [3] = 8, [4] = 9 },  -- Temple of Kotmogu (blue/purple/green/orange)
}

-- Expand `single` into every arena slot once, at load. The scans then keep one
-- loop shape and pay nothing at runtime for the distinction -- the table
-- declares intent, this normalises it. 5 rather than #ARENA_UNITS because that
-- table is declared further down the file.
for _, m in pairs(OBJECTIVE_SLOTS) do
    local c = m.single
    if c then
        m.single = nil
        for i = 1, 5 do m[i] = c end
    end
end

-- Pre-baked circular versions of the Blizzard objective icons (candlelight
-- ring border, square border cropped) — see Textures/Obj*.tga
-- Purpose-drawn low-detail redraws, all stored at 64px. WoW builds no mipmaps
-- for addon TGAs, so a texture drawn far smaller than it is stored gets
-- point-sampled and hard edges alias — at the default bar height of 35 these
-- draw at ~32px, and in Epic BGs at ~17px. 64 keeps that reduction near 2x and
-- moves the filtering offline. See Textures/bake_cut.py (OUT_SIZE).
--
-- The finding behind them: at the ~40px these render at, fine detail resolves
-- as NOISE rather than as detail and the silhouette dissolves into it. Fewer,
-- bolder shapes survive the size. This is NOT a resolution or filtering
-- problem — the jaggedness was pixel snapping, handled at draw time now, and
-- fixing it revealed that the art itself was the remaining issue.
local OBJECTIVE_ICONS = {
    [0]  = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\FlagHorde4.tga",
    [1]  = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\FlagAlliance4.tga",
    [2]  = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\FlagNeutral4.tga",
    [7]  = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\OrbBlue.tga",
    [8]  = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\OrbGreen.tga",
    [9]  = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\OrbOrange.tga",
    [10] = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\OrbPurple.tga",
    -- Deephaul Ravine crystal. Index 100 is OURS, not an enum member -- see
    -- OBJECTIVE_CRYSTAL. Baked from SimpleTextureCircleIcons/Crystal.png,
    -- registered in bake_cut.py under FIT (so it fills the disc like its
    -- neighbours) and OUT_SIZE 64.
    [100] = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\CrystalDeephaul.tga",
}

-- Animated objective art: 16 frames in a 4x4 grid, driven by native FlipBook
-- animations. Only these two classifications have animated sheets; every other
-- one falls through to the static texture in OBJECTIVE_ICONS.
-- Empty by design, for now. These held the previous artwork, and leaving them
-- registered would animate two classifications in the old style while the rest
-- used the new static set. ApplyObjectiveIcon falls back to the plain texture
-- when a classification isn't listed here, so re-adding sheets later is the
-- only change needed. Sheets are 4x4 grids of 64px cells at 256x256.
local OBJECTIVE_FLIPBOOKS = {}

-- Carrier glow tint per classification: orbs glow their own colour, flags
-- glow black — additive black is invisible, so flags switch the halo to a
-- BLEND shadow. Matches the ring colours baked into the Obj*.tga icons.
local OBJECTIVE_GLOW = {
    -- Flags glow again. These were { 0, 0, 0, blend = "BLEND" } -- a suppressed
    -- halo, which suited the old centred EMBLEM art and does not suit banners.
    -- The halo is PingHalo.tga drawn at ns.ICON_GLOW_SCALE (1.5x the icon), so
    -- what shows is the overhang: a ring around the icon rather than a wash over
    -- it. Same trick as the card glow in Libs/Glow.lua -- a backdrop sized past
    -- the thing in front of it.
    --
    -- Colours are the banner FIELD, taken as the dominant hue by AREA across lit
    -- chromatic pixels (Horde 5deg, Alliance 225deg, Neutral 75deg). Sampling the
    -- brightest pixels instead returned gold for all three -- the filigree and
    -- pole hardware are shared, so brightness finds what they have in common
    -- rather than what tells them apart.
    --
    -- Lifted toward white to match the orbs' treatment: a fully saturated hue
    -- reads as a coloured shape behind the icon, a lifted one reads as light.
    [0]  = { 1.00, 0.36, 0.30 },   -- Horde banner, red
    [1]  = { 0.28, 0.46, 1.00 },   -- Alliance banner, blue
    [2]  = { 0.82, 1.00, 0.29 },   -- Neutral banner, green
    [7]  = { 0.30, 0.80, 1.00 },
    [8]  = { 0.40, 1.00, 0.45 },
    [9]  = { 1.00, 0.62, 0.18 },
    [10] = { 0.72, 0.45, 1.00 },
    -- Deephaul crystal, coloured like the orbs rather than suppressed like the
    -- flags -- it is a glowing gem, so it earns a halo. Sampled from the art
    -- itself (brightest 3% of pixels, normalised) rather than picked by eye.
    [100] = { 1.00, 0.28, 0.34 },
}

-- nil classification = dead/unknown: red alarm pulse
function ApplyObjectiveGlowColor(glow, classification)
    local c = classification and OBJECTIVE_GLOW[classification]
    if c then
        glow:SetBlendMode(c.blend or "ADD")
        glow:SetVertexColor(c[1], c[2], c[3], 1)
    else
        glow:SetBlendMode("ADD")
        glow:SetVertexColor(1, 0.2, 0.2, 1)
    end
end

-- Applies a carrier icon: the animated flipbook sheet where one exists for
-- that classification, otherwise the static texture. Sole owner of the icon,
-- its animation and its glow, so live bars, test mode and the settings
-- previews all render carriers identically.
--
-- Live previously used the static set only, which meant the animated orb and
-- banner existed in test mode and nowhere else. Only two of the seven
-- classifications have flipbook art; the rest fall through to static and stop
-- any animation left running from a previous occupant of the bar.
function ns.ApplyObjectiveIcon(bar, classification)
    local flip = OBJECTIVE_FLIPBOOKS[classification]
    if flip then
        bar.pvpIcon:SetTexture(flip.tex)
        if not bar.pvpIconFlip then
            bar.pvpIconFlip = bar.pvpIcon:CreateAnimationGroup()
            bar.pvpIconFlip:SetLooping("REPEAT")
            local fb = bar.pvpIconFlip:CreateAnimation("FlipBook")
            fb:SetFlipBookRows(4)
            fb:SetFlipBookColumns(4)
            fb:SetFlipBookFrames(16)
            bar.pvpIconFlipFB = fb
        end
        bar.pvpIconFlipFB:SetDuration(flip.duration)
        if not bar.pvpIconFlip:IsPlaying() then bar.pvpIconFlip:Play() end
    else
        if bar.pvpIconFlip then
            bar.pvpIconFlip:Stop()
            bar.pvpIcon:SetTexCoord(0, 1, 0, 1)  -- flipbook leaves a subframe crop
        end
        bar.pvpIcon:SetTexture(OBJECTIVE_ICONS[classification])
    end

    -- Shadow takes the same texture as whatever branch above ran. On a
    -- flipbook it holds frame 0 rather than animating, since the animation
    -- drives only the icon's own coordinates — acceptable for a shadow, and
    -- worth knowing if sheets come back.
    -- SHOW THE HALO. It was created Hidden and nothing ever showed it, so the
    -- bars have never had a carrier glow -- only the raid-frame path did, via
    -- its own copy at ApplyObjectiveGlowColor(f.glow, ...). OBJECTIVE_GLOW
    -- was therefore only half-consulted, which is why editing it changed nothing
    -- on the bars.
    --
    -- No matching Hide is needed: pvpIconFrame is the glow's parent and the
    -- no-carrier paths hide that, which takes the whole slot with it.
    if bar.pvpIconGlow then
        ApplyObjectiveGlowColor(bar.pvpIconGlow, classification)
        bar.pvpIconGlow:Show()
        if bar.pvpIconGlow.animGroup and not bar.pvpIconGlow.animGroup:IsPlaying() then
            bar.pvpIconGlow.animGroup:Play()
        end
    end

    bar.pvpIconShadow:SetTexture(bar.pvpIcon:GetTexture())
    bar.pvpIconShadow:SetTexCoord(bar.pvpIcon:GetTexCoord())
    bar.pvpIconShadow:Show()

    ns.SizeCarrierIcon(bar)
    bar.pvpIconFrame:Show()
end

-- Stack growth model per BG mode. stacks = start + floor((now - pickup)/interval) * step
--
-- Carrier stacks. Kotmogu's orbs and the two flag maps (Warsong Gulch, Twin
-- Peaks) are all modelled below. Two maps in OBJECTIVE_SLOTS still have no entry
-- here, so their carriers show an icon with no number:
--
--   * Eye of the Storm (566)   -- BOTH have carrier stacks. EotS matters most,
--   * Deephaul Ravine (2656)      it is in the Blitz rotation.
--
-- TODO, and deliberately NOT started: these are the user's own logic to write.
-- Recorded here so the shape is not lost --
--
--   * Both use the ORB model, not the flag one: a per-carrier clock anchored to
--     pickup, exactly as Kotmogu works. Do not reach for the shared flag clock;
--     that model does not apply to either map.
--   * They add ONE thing on top: a reset driven by a "returned to centre" chat
--     message, for the objective going back to the middle without a capture.
--     That is additive and specific to these two maps. KOTMOGU IS NOT AFFECTED
--     -- an orb resets on drop, which is complete, and it will never need chat
--     logic of any kind.
--   * The exact message wording and the timings are NOT yet confirmed. Nothing
--     should be guessed into the config -- a wrong interval shows a confident
--     wrong number, which is worse than the blank icon shown now.
--
-- Missing until then for a reason worth recording: the reference this config's
-- flag model came from covers neither map, and that absence was briefly
-- mistaken for evidence the maps have no stacking mechanic. They do.
--
-- Stacks are INFERRED FROM TIME, never read from the aura. That is deliberate:
-- a carrier is usually across the map with no nameplate, and the only token
-- that can name them is raid(i)target, which Blizzard blocks for most unit
-- APIs. Reading the real debuff would work for the one carrier standing next to
-- you and fail for every other, which is worse than a consistent estimate.
--
-- The two modes differ in what the clock is anchored to, which is why they are
-- not one shape: an orb clock is PER CARRIER and starts at pickup, a flag clock
-- is ONE PER MATCH and starts when both teams hold. See the mode notes on the
-- table below, and the known gaps in the flag model further down.
-- Keyed by INSTANCE id, from select(8, GetInstanceInfo()) -- the same lookup
-- this table has always used.
--
-- Note there are two id spaces for the same map and they do not interchange:
-- Kotmogu is 998 as an instance id and 417 as a UI map id. Other addons in this
-- space key off C_Map.GetBestMapForUnit, so their numbers cannot be pasted in
-- here; the WSG and Twin Peaks values below are instance ids.
local STACK_RULES = {
    -- ORBS: one clock per carrier. Value is a damage-taken percentage that
    -- starts on pickup and climbs while held.
    [998] = {  -- Temple of Kotmogu
        mode  = "orb",
        blitz = { interval = 15, start = 30, step = 30 },
        rated = { interval = 15, start = 30, step = 30 },
    },
    -- FLAGS: ONE SHARED clock for the whole match, not per carrier, so both
    -- carriers show the same raw count. It starts when BOTH teams hold a flag
    -- and climbs with no cap. Drops do NOT stop it -- otherwise a team could
    -- juggle the flag to reset the pressure, which is the exact stalemate the
    -- mechanic exists to break.
    -- Ids taken from OBJECTIVE_SLOTS above, which keys off the same
    -- select(8, GetInstanceInfo()) and is already proven by live carrier
    -- detection. (2106, not 489 -- that is the pre-BfA Warsong Gulch id.)
    -- clockStartsWhen = "all": the shared clock starts when EVERY classification in the
    -- battleground's carrier map is held at once -- for these two, both flags.
    -- Read straight off the arena slots (see UpdateFlagClock), not from chat:
    -- the game's own state, in any client language, and it works for a late
    -- join because it sees what is held NOW rather than waiting for messages
    -- that already went past.
    -- NO homeAfterUncarried here, deliberately. These two reset on CHAT: the
    -- game announces every way a flag gets home -- "was returned to its base by
    -- X" for a manual return and "has returned to its base!" for the ground
    -- timeout -- so a no-carrier timer would only ever duplicate a line that
    -- already arrived. On a non-English client it would do worse: catch passive
    -- returns and miss manual ones, giving a clock that is right sometimes.
    -- Locale support is ALL OR NOTHING per map. EotS is all (no chat at all);
    -- these are English-only until their strings are gathered, and the guard in
    -- StackRulesHere says so by drawing nothing rather than a maybe.
    -- (Ground timeout measured at 15s in normal mode, kept in the notes.)
    [2106] = { mode = "flag", clockStartsWhen = "all", rated = { interval = 30 }, blitz = { interval = 15 } }, -- Warsong Gulch
    [726]  = { mode = "flag", clockStartsWhen = "all", rated = { interval = 30 }, blitz = { interval = 15 } }, -- Twin Peaks

    -- Eye of the Storm: ORB model despite being a flag, because "flag" mode's
    -- clock only starts when BOTH teams hold one (see UpdateFlagClock) and
    -- EotS has a single neutral flag -- that condition can never be true, so the
    -- clock would never start. Orb mode anchors to pickup, which is what one
    -- carrier wants.
    --
    -- start 0 / step 1: no number at pickup, 1 after the first interval, then 2,
    -- 3... Kotmogu differs because its value is a damage PERCENTAGE (30/60/90),
    -- not a count.
    -- Eye of the Storm. SHARED clock like the flag maps, because the stacks
    -- SURVIVE A DROP -- an orb-model clock anchored to pickupTime would reset
    -- every time the flag changed hands, which is wrong here.
    --
    -- But it cannot use the flag maps' start condition either: that waits for
    -- BOTH teams to hold one (clockStartsWhen = "all" in UpdateFlagClock), and EotS has a single neutral
    -- flag. clockStartsWhen = "any" instead -- the clock begins at the FIRST pickup
    -- we see and runs until a reset, which needs no chat message at all, since
    -- the carrier scan already tells us when someone is holding it.
    [566] = {
        mode    = "flag",
        clockStartsWhen = "any",
        -- homeAfterUncarried: seconds with NO carrier before the stacks reset.
        -- A dropped EotS flag returns to centre after 20s (measured), and a
        -- RETURN is what clears the count -- a drop alone does not. So an absence
        -- longer than the return timeout IS a return, and it needs no chat at
        -- all: the carrier scan reads no text, so EotS works on any client.
        --
        -- PER TIER rather than shared, because the interval DOES differ 30/15
        -- between rated and Blitz -- so the timeout might have too. It does not:
        -- both measured at 20s (Blitz verified by clock, 47 -> 07). Kept split
        -- anyway, since the two being equal today is a fact about the game rather
        -- than a guarantee, and merging them would hide a future divergence.
        --
        -- The chat reset stays as the faster path where the strings are known.
        -- Whichever fires first wins; this is the floor, not the only mechanism.
        rated   = { interval = 30, homeAfterUncarried = 20 },
        blitz   = { interval = 15, homeAfterUncarried = 20 },
    },

    -- Deephaul Ravine has NO entry on purpose: it has carrier icons but no
    -- stacks, so StackRulesHere returns nil and no number is drawn.
}

-- Flag status row for WSG / Twin Peaks. This is the data behind the objective
-- icons at the top of the screen, so it is server-driven and stays correct
-- while dead or out of render range -- unlike the arena tokens.

-- Which stack model this map uses, and its timings. Returns the tier table and
-- the mode; nil when the map has no stacking objective.
-- `rated` is the DEFAULT tier, not the rated-only one. C_PvP.IsSoloRBG is true
-- only in Solo Blitz, so everything else -- regular unrated BGs included --
-- takes that branch. The 30s flag interval there is confirmed correct for
-- REGULAR battlegrounds (user, 2026-08-17), which is the case the key name
-- makes look unhandled. Renaming it would orphan nothing, but the comment is
-- cheaper than touching every row.

local IS_ENGLISH_CLIENT = (GetLocale() or ""):sub(1, 2) == "en"

function StackRulesHere()
    local instanceID = select(8, GetInstanceInfo())
    local bgConfig = instanceID and STACK_RULES[instanceID]
    if not bgConfig then return nil end
    local tier = (C_PvP.IsSoloRBG and C_PvP.IsSoloRBG()) and bgConfig.blitz or bgConfig.rated
    -- Locale guard, per map, all or nothing. A flag map whose tier has no
    -- homeAfterUncarried resets on CHAT, so outside an English client it would
    -- start correctly (possession comes from the arena scan) and then never
    -- reset on a manual return -- a clock that is right until it is not. Draw
    -- nothing instead. EotS has a timeout and needs no chat, so it passes and
    -- works everywhere. Lift per map as strings are gathered.
    if bgConfig.mode == "flag" and not IS_ENGLISH_CLIENT
       and not (tier and tier.homeAfterUncarried) then
        return nil
    end
    return tier, bgConfig.mode, bgConfig.clockStartsWhen, instanceID
end

-- =============================================================================
-- SHARED FLAG CLOCK -- possession model
-- =============================================================================
-- Built from live observation (2026-08-17), which overturned two earlier
-- theories:
--
--   * The game tracks POSSESSION, not carrying. A flag is "out" from the moment
--     it is picked up until it is HOME -- returned by a player, returned by
--     itself after the ground timeout, or placed after a capture. Being dropped
--     changes nothing: the flag is still out. Observed directly: pick up, drop,
--     enemy picks up theirs -> clock started. So "both flags HELD" (the arena
--     slots this tick) was the wrong start condition; "both flags OUT" is right.
--
--   * Reset is IMMEDIATE when both flags are home, with no neutral text --
--     observed as a 3s gap between the second return and an enemy re-pickup in
--     which the stacks were already gone. The "few seconds late" that had been
--     in the notes was only ever the CAPTURE case, where the game resets on the
--     later "placed at their bases" line, not on the capture itself.
--
-- The model that falls out is one boolean per flag, keyed by
-- Enum.PvPUnitClassification (FlagCarrierAlliance = 1, FlagCarrierHorde = 0,
-- FlagCarrierNeutral = 2 for EotS):
--
--   SET  out[flag]   by the arena scan seeing a carrier for it. Locale-free,
--                    and it handles a late join for free -- whatever is being
--                    carried when you arrive is out. Chat "picked up" also
--                    sets it, harmlessly, as a belt-and-braces.
--   CLEAR out[flag]  by chat "returned" (covers both "was returned to its base
--                    by X" and "has returned to its base!" -- the game announces
--                    the ground timeout too, so chat is complete on its own), by
--                    "placed at their bases" / "has been reset" / "wins" for all
--                    flags at once; or, on a map with homeAfterUncarried, by no
--                    carrier for that long. That is EotS's ONLY reset mechanism
--                    (it has no return line), not a floor for the flag maps --
--                    a partial timeout there would make a non-English client
--                    right after passive returns and wrong after manual ones,
--                    and locale support is all or nothing per map.
--   START            when every mapped flag is out ("all"; WSG/TP) or any is
--                    ("any"; EotS).
--   RESET            when NO mapped flag is out.
--
-- Same rules for all three flag maps. EotS is just the one-flag case with no
-- return message: out via scan, home via the 20s timeout.
--
-- Chat identifies WHICH flag by the faction name in the text -- "The Alliance
-- Flag was picked up by X!", "The Horde Flag has returned to its base!" -- and
-- never by channel. The colour follows the flag, not the actor, and parsing the
-- name removes the whole question. Dropped and captured lines are deliberately
-- ignored: a drop changes nothing, and a capture is always followed by the
-- placement line, which is when the game actually resets.
--
-- The old widget-based late-join seed (ReadFlagHolders / SeedFlagHolders on
-- UPDATE_UI_WIDGET) is gone: it could not tell a dropped flag from one at base,
-- and the scan does the job without that ambiguity.
local flagOutOfBase       = {}    -- [classification] = true while that flag is out of base
local flagCarrierLastSeenAt  = {}    -- [classification] = GetTime() a carrier was last seen
local flagClockStartedAt        -- GetTime() the clock started; nil = idle

-- English fragments, matched as substrings. The verb is the only part that is
-- language-bound; the flag is identified from the faction name, which the
-- FACTION_* globals give localised where the message includes it.
local FLAG_CHAT_FRAGMENTS = {
    pickup   = "picked up",              -- "The Horde Flag was picked up by X!"
    returned = "returned",               -- "was returned to its base by X" / "has returned to its base!"
    placed   = "placed at their bases",  -- neutral, a few seconds after a capture
    reset    = "has been reset",
    wins     = "wins",
}

local OBJECTIVE_ALLIANCE_FLAG = (Enum and Enum.PvPUnitClassification and Enum.PvPUnitClassification.FlagCarrierAlliance) or 1
local OBJECTIVE_HORDE_FLAG    = (Enum and Enum.PvPUnitClassification and Enum.PvPUnitClassification.FlagCarrierHorde)    or 0

function ObjectiveFromChatText(msg)
    if msg:find(FACTION_ALLIANCE or "Alliance", 1, true) or msg:find("Alliance", 1, true) then
        return OBJECTIVE_ALLIANCE_FLAG
    end
    if msg:find(FACTION_HORDE or "Horde", 1, true) or msg:find("Horde", 1, true) then
        return OBJECTIVE_HORDE_FLAG
    end
    return nil
end

function ClearFlagPossession(reason)
    if ns.debugFlagTrace and flagClockStartedAt then
        print(("|cffff9933BFT flag:|r RESET after %.0fs -- %s")
            :format(GetTime() - flagClockStartedAt, reason or "?"))
    end
    flagClockStartedAt = nil
    wipe(flagOutOfBase)
    wipe(flagCarrierLastSeenAt)
    if ns.db and ns.db.flagClock then ns.db.flagClock.startedAt = nil end
end

-- THE CLOCK SURVIVES /reload. Every reload wipes Lua state, so without this the
-- clock restarted at reload time while the game's kept counting -- "game says
-- 7, addon says 2" after a reload ninety seconds earlier, with no reset ever
-- having fired. The user reloads constantly during development, so this was
-- being hit routinely and read as a logic fault.
--
-- Two facts make it simple: SavedVariables are written on /reload, and
-- GetTime() is client uptime, continuous across a reload. So the raw timestamp
-- and the two possession tables can be persisted as-is. The live locals are
-- pointed AT the saved tables, so every per-tick write persists for free with
-- no copying. Keyed by instanceID so a stale record from a previous match on
-- the same map is not adopted; guarded against a client RESTART, where
-- GetTime() starts over and any saved startedAt is in the "future".
function RestoreFlagClock(instanceID)
    if not ns.db then return end
    local saved = ns.db.flagClock
    if not (saved and saved.instanceID == instanceID) then
        saved = { instanceID = instanceID, out = {}, lastSeen = {} }
        ns.db.flagClock = saved
    end
    saved.out      = saved.out or {}
    saved.lastSeen = saved.lastSeen or {}
    flagOutOfBase         = saved.out
    flagCarrierLastSeenAt = saved.lastSeen
    if saved.startedAt and saved.startedAt <= GetTime() then
        flagClockStartedAt = saved.startedAt
        if ns.debugFlagTrace then
            print(("|cffff9933BFT flag:|r clock RESTORED across reload, %.0fs old")
                :format(GetTime() - saved.startedAt))
        end
    else
        saved.startedAt = nil
        flagClockStartedAt = nil
    end
end

-- Sole owner of the clock: one walk of the arena slots per tick, no names read.
function UpdateFlagClock()
    local stackCfg, mode, clockStartsWhen, instanceID = StackRulesHere()
    if mode ~= "flag" or not stackCfg then return end
    local carrierMap = OBJECTIVE_SLOTS[instanceID]
    if not carrierMap then return end
    local now = GetTime()

    -- A carrier means the flag is out. Nothing here ever clears `out` on an
    -- empty slot -- that is precisely the dropped case, and dropped is still out.
    for arenaIdx, cls in pairs(carrierMap) do
        if UnitExists(ARENA_UNITS[arenaIdx]) then
            flagOutOfBase[cls] = true
            flagCarrierLastSeenAt[cls] = now
        end
    end

    -- Ground timeout: no carrier for that flag for this long means it went home
    -- by itself. Locale-free floor for the chat "returned" line.
    local timeout = stackCfg.homeAfterUncarried
    if timeout then
        for _, cls in pairs(carrierMap) do
            if flagOutOfBase[cls] and flagCarrierLastSeenAt[cls] and (now - flagCarrierLastSeenAt[cls]) >= timeout then
                flagOutOfBase[cls] = nil
                if ns.debugFlagTrace then
                    print(("|cffff9933BFT flag:|r objective %s uncarried %.0fs -> home (timeout)"):format(tostring(cls), timeout))
                end
            end
        end
    end

    local anyOut, allOut = false, true
    for _, cls in pairs(carrierMap) do
        if flagOutOfBase[cls] then anyOut = true else allOut = false end
    end

    if not flagClockStartedAt then
        if (clockStartsWhen == "any" and anyOut) or (clockStartsWhen == "all" and allOut) then
            flagClockStartedAt = now
            if ns.db and ns.db.flagClock then ns.db.flagClock.startedAt = now end
            if ns.debugFlagTrace then print("|cffff9933BFT flag:|r clock STARTED") end
        end
    elseif not anyOut then
        ClearFlagPossession("no objective out of base")
    end
end

-- Chat: makes resets FASTER where the strings match; never required.
function ns.OnFlagChat(event, msg)
    local _, mode = StackRulesHere()
    if mode ~= "flag" or type(msg) ~= "string" then return end

    -- Match-wide: every flag goes home. Channel is irrelevant.
    if msg:find(FLAG_CHAT_FRAGMENTS.placed, 1, true)
       or msg:find(FLAG_CHAT_FRAGMENTS.reset, 1, true)
       or msg:find(FLAG_CHAT_FRAGMENTS.wins, 1, true) then
        ClearFlagPossession("chat: " .. msg)
        return
    end

    -- Per-flag: which flag does the line name?
    local cls = ObjectiveFromChatText(msg)
    if not cls then return end
    if msg:find(FLAG_CHAT_FRAGMENTS.returned, 1, true) then
        flagOutOfBase[cls] = nil          -- home; UpdateFlagClock resets if that was the last one
        if ns.debugFlagTrace then print("|cffff9933BFT flag:|r objective " .. tostring(cls) .. " home (chat: " .. msg .. ")") end
    elseif msg:find(FLAG_CHAT_FRAGMENTS.pickup, 1, true) then
        flagOutOfBase[cls] = true         -- redundant with the scan, harmless
    end
    -- Dropped and captured lines fall through: neither changes possession, and
    -- the capture's real reset is the placement line above.
end

-- Sole owner of the stack NUMBER, for every surface that shows one -- the bars
-- and the raid-frame objective icons. Returns nil when there is nothing to
-- show, which every caller treats as hide.
--
-- The two models differ in where the clock lives: orbs keep one per carrier
-- (pickupTime), flags share a single match-wide clock, so a flag carrier's own
-- pickup time is irrelevant and both carriers read the same number.
function ObjectiveStackCount(pickupTime)
    local stackCfg, mode = StackRulesHere()
    if not stackCfg then return nil end
    if mode == "flag" then
        -- Purely a READ of the shared clock. Starting and resetting it is
        -- UpdateFlagClock's job alone; this used to also start it for
        -- clockStartsWhen = "any", which put a second start mechanism inside a
        -- render helper.
        if not flagClockStartedAt then return nil end
        return math.floor((GetTime() - flagClockStartedAt) / stackCfg.interval)
    end
    if not pickupTime then return nil end
    -- ZERO IS SHOWN, deliberately. It was briefly suppressed on the reasoning
    -- that there is nothing to report before the first stack lands; the user's
    -- call is that a visible "0" reads better -- it says the clock is RUNNING and
    -- the carrier is being tracked, where a blank icon is indistinguishable from
    -- the feature not working.
    --
    -- Battlegrounds with no stack mechanic at all still show nothing, because
    -- they have no STACK_RULES entry and this function returns nil at
    -- the top. Deephaul Ravine is the case: carrier icon, no number.
    return stackCfg.start + math.floor((GetTime() - pickupTime) / stackCfg.interval) * stackCfg.step
end

function ApplyObjectiveStackText(stackText, classification, pickupTime, fontSize)
    local stacks = ObjectiveStackCount(pickupTime)
    if stacks then
        stackText:SetFont(ns.LOCALE_FONT, fontSize or 16, "OUTLINE")
        stackText:SetText(stacks)
        stackText:Show()
    else
        stackText:Hide()
    end
end

-- Merged enemy overlay: carrier > dead priority (carrier dropping = arena
-- slot reassigns the same tick, so co-existence is rare).
-- Dead marker on the objective icon slot. Mirrors the live branch so previews
-- and test mode show what a real corpse looks like rather than their own idea
-- of one. The respawn cooldown is the caller's business — only the enemy path
-- knows the timings — so this clears it and lets that caller set it after.
-- Sole owner of the respawn countdown, shared by live, test mode and previews.
-- `remaining` is seconds, or nil when it can't be known — see DeadRemaining.
--
-- Death itself is carried by the bar (the spatter in FX.dead), so there is no
-- longer any dead artwork in the objective icon slot. Carrier already took
-- priority over dead in both renderers, meaning a dead flag carrier used to
-- lose its carrier icon; nothing competes for that space now, and the timer
-- steps aside to the left when the icon is present.
function ns.SetDeadTimer(bar, remaining, fontSize)
    local t = bar.deadTimer
    if not t then return end
    if not remaining or remaining <= 0 then
        t:Hide()
        bar.deadTimerBack:Hide()
        return
    end

    -- Size comes in from the caller. It shares the Objective Text Size setting
    -- with the carrier stack count — both are numbers drawn on a marker in the
    -- right-hand slot, so one control for the pair beats two that would almost
    -- always be set to the same value.
    local size = math.max(8, fontSize or ns.DEAD_TIMER_FALLBACK)
    t:SetFont(ns.LOCALE_FONT, size, "")
    t:SetText(math.ceil(remaining))
    -- White: the marker and the name are both red now, so the number is the
    -- one element that separates itself from them rather than joining in.
    local c = ns.DEAD_TIMER_COLOR
    t:SetTextColor(c[1], c[2], c[3], 1)

    local d = size * ns.DEAD_TIMER_BACKING
    bar.deadTimerBack:SetSize(d, d)
    bar.deadTimerBack:SetAlpha(ns.DEAD_TIMER_BACK_A)
    bar.deadTimerBack:ClearAllPoints()
    bar.deadTimerBack:SetPoint("CENTER", t, "CENTER", 0, 0)
    bar.deadTimerBack:Show()
    -- Sits ON the marker rather than beside it: the right slot holds one thing
    -- at a time, and stacking the count over the gravestone reads as a label
    -- on that marker instead of two competing objects sharing an edge. Matches
    -- how the carrier stack count already overlays its own icon.
    t:ClearAllPoints()
    t:SetJustifyH("CENTER")
    if bar.stateArt and bar.stateArt:IsShown() then
        t:SetPoint("CENTER", bar.stateArt, "CENTER", 0, ns.ICON_NUMBER_Y)
    elseif bar.pvpIconFrame:IsShown() then
        t:SetPoint("CENTER", bar.pvpIconFrame, "CENTER", 0, ns.ICON_NUMBER_Y)
    else
        ns.AnchorToSlot(t, bar, ns.ART_SLOT)
    end
    t:Show()
end

-- Lays the diamonds out from the TOP-RIGHT, inset past the objective icon
-- slot so a carrier icon and the diamonds can coexist. Rows fill left-to-right
-- then downward, growing away from the corner.
-- Range probe spells, longest first.
--
-- Stored as IDs, USED as names. Both halves matter:
--
--   * The table holds IDs because a hardcoded English name would not match on
--     a non-English client, and because GetSpellInfo keys off the ID to give us
--     the live name and range.
--
--   * C_Spell.IsSpellInRange must be called with the NAME. Verified in game on
--     12.1: with a hostile target selected,
--         C_Spell.IsSpellInRange(585, "target")       -> nil
--         C_Spell.IsSpellInRange("Smite", "target")   -> false
--     Despite the parameter being documented as a spellIdentifier, a raw ID
--     silently returns nil rather than an answer -- indistinguishable from
--     "no idea" unless you know to look. Do not "simplify" this back to the ID.
--
-- Passing the name also buys override handling: name lookup resolves through
-- whatever currently overrides the spell, so a talented replacement still
-- answers. The yardage is read live too, so talents that extend range are
-- reflected -- a Holy Priest with the Smite range talents reads 46, not 40.
--
-- Ordered candidates rather than one pick per class. "Baseline and unlearnable"
-- is not something that can be guaranteed across patches -- retail keeps moving
-- abilities into class trees -- so the first entry is the preferred all-spec
-- choice and the rest are insurance. The resolver takes the first that is
-- actually in the spellbook, so a spec that lost the preferred spell still gets
-- a working check instead of silently getting nothing.
--
-- Every ID is lifted from LibRangeCheck-3.0's HarmSpells tables, which are
-- patch-maintained; spec restrictions are noted where they apply.
local RANGE_SPELLS = {
    DEATHKNIGHT = { 47541, 49576 },          -- Death Coil (Unholy) 40, Death Grip 30
    DEMONHUNTER = { 185123, 204021, 183752 },-- Throw Glaive (Havoc) 30, Fiery Brand (Veng) 30, Consume Magic 20
    DRUID       = { 5176, 8921, 6795 },      -- Wrath 40, Moonfire 40, Growl 30
    EVOKER      = { 362969 },                -- Azure Strike 25
    HUNTER      = { 75, 466930 },            -- Auto Shot 40, Black Arrow 40
    MAGE        = { 116, 133, 44425, 118 },  -- Frostbolt 40, Fireball 40, Arcane Barrage 40, Polymorph 30
    MONK        = { 117952, 115546 },        -- Crackling Jade Lightning 40, Provoke 30
    PALADIN     = { 20473, 20271, 62124 },   -- Holy Shock (Holy) 40, Judgement 30, Hand of Reckoning 30
    PRIEST      = { 585, 589, 8092 },        -- Smite 40, Shadow Word: Pain 40, Mind Blast 40
    ROGUE       = { 185565, 36554, 185763 }, -- Poisoned Knife (Assa) 30, Shadowstep 25, Pistol Shot (Outlaw) 20
    SHAMAN      = { 188196, 8042, 370 },     -- Lightning Bolt 40, Earth Shock 40, Purge 30
    WARLOCK     = { 686, 5782 },             -- Shadow Bolt (Demo/Affli) 40, Fear 30
    WARRIOR     = { 355 },                   -- Taunt 30
}

-- Resolved probe for the player's current spec. Nil when nothing resolves.
ns.rangeSpellID, ns.rangeSpellName, ns.rangeSpellYards = nil, nil, nil

-- Re-resolves the probe. Must run on spec and talent changes, not once at
-- load: the spellbook is only correct for the ACTIVE spec, and a candidate that
-- resolved for one spec can vanish in another.
--
-- A spell with a MINIMUM range is rejected outright. Charge is 8-25 yards and
-- reports out of range inside 8, which would read as "far away" for someone
-- stood on top of you -- the worst possible failure for this feature.
-- LibRangeCheck composes two checkers to handle those; not worth it here when
-- every class has a clean alternative.
function ns.ResolveRangeSpell()
    ns.rangeSpellID, ns.rangeSpellName, ns.rangeSpellYards = nil, nil, nil
    local _, class = UnitClass("player")
    local list = class and RANGE_SPELLS[class]
    if not list then return end
    for _, id in ipairs(list) do
        if IsSpellKnown(id) or IsPlayerSpell(id) then
            local info = C_Spell and C_Spell.GetSpellInfo(id)
            local maxR = info and info.maxRange
            local minR = info and info.minRange or 0
            if maxR and maxR > 0 and minR == 0 then
                ns.rangeSpellID    = id
                ns.rangeSpellName  = info.name
                ns.rangeSpellYards = maxR
                return
            end
        end
    end
end

-- The predicate the bars will use. NAME, not ID -- see the table comment.
--
-- Fails OPEN: no resolved probe, a non-attackable unit, or a nil answer all
-- report in range. Fading is the visible state, so an unanswerable question
-- must never produce one -- a bar dimmed because we could not tell is worse
-- than one that never dims at all.
function ns.IsUnitInRange(unit)
    if UnitCanAttack("player", unit) then
        local name = ns.rangeSpellName
        if not name then return true end
        return C_Spell.IsSpellInRange(name, unit) ~= false
    end

    -- FRIENDLY: UnitInRange, not a helpful-spell probe.
    --
    -- Matching the enemy side with helpful spells was the plan, until the
    -- tables showed Demon Hunter, Hunter and Warrior have NO usable helpful
    -- spell at all, and Death Knight only Raise Ally -- a battle res, which
    -- answers nil on a living ally. Nine classes would have worked and four
    -- would have silently done nothing.
    --
    -- UnitInRange is purpose-built for group members, which is all a friendly
    -- bar ever holds. Its range is Blizzard's own and does NOT track your
    -- longest heal, so the two panes can disagree on the same character --
    -- an accepted trade for a check that behaves the same on all thirteen.
    --
    -- The second return says whether the question could be answered at all;
    -- false there means unanswerable, which fails open like everything else.
    local inRange, checked = UnitInRange(unit)
    if not checked then return true end
    return inRange and true or false
end

-- Every Range Check readout in the panel, so one resolve updates all of them.
-- Started as a single closure owned by the Battlegrounds enemy pane; four panes
-- need it now, and a second closure would have silently replaced the first.
ns.rangeLabels = {}

function ns.RegisterRangeLabel(fontString)
    ns.rangeLabels[#ns.rangeLabels + 1] = fontString
    if ns.RefreshRangeLabel then ns.RefreshRangeLabel() end
end

function ns.RefreshRangeLabel()
    local text = ns.RangeSpellLabel()
    -- Muted when nothing resolved, so an unusable spec reads as inactive
    -- rather than as a value.
    local c = ns.rangeSpellID and ns.Palette.textLabel or ns.Palette.textMuted
    for _, fs in ipairs(ns.rangeLabels) do
        fs:SetText(text)
        fs:SetTextColor(c[1], c[2], c[3])
    end
end

-- Label for the options panel, beside the Range Check toggle.
function ns.RangeSpellLabel()
    if not ns.rangeSpellID then return "Distance unavailable for this spec" end
    return ("Distance %d (%s)"):format(ns.rangeSpellYards, ns.rangeSpellName)
end

-- How hard the CARD resists the fade, as an exponent on the opacity.
--
-- 0.5 (square root) keeps a 0.15 fade sitting on a ~0.39 card, so the bar reads
-- as faint rather than as a dark hole. Raise toward 1.0 to make the card fade
-- with everything else; lower to keep it more solid.
ns.RANGE_BACKDROP_POW = 0.5

-- Out-of-range fade.
--
-- Fading bar:SetAlpha() alone looked wrong, and the reason is container.shade:
-- a soft dark plate on the CONTAINER, behind every bar, which is not a child of
-- any of them. Fading a bar just uncovers it, so the card darkens on its way
-- out instead of thinning.
--
-- The shade is shared, so it cannot be faded per bar. The fix works the other
-- way round -- keep the card's OWN background more opaque than its contents, so
-- it goes on masking the shade while everything on it fades.
--
-- That is only possible because of the frame nesting: healthBar is a child of
-- visual, so their alphas MULTIPLY. Child alpha can never exceed its parent's,
-- which is why this cannot be done by boosting a child of `bar` instead.
--
--   visual    -> o^POW              (the card, fading slowly)
--   healthBar -> o / o^POW          (everything on it, so o^POW * that = o)
--
-- Both endpoints stay honest: at opacity 1 nothing fades, at 0 the whole bar
-- goes, so a 0.00 slider still means invisible rather than a ghost card.
function ns.ApplyRangeFade(bar, outOfRange, opacity)
    if bar._loadingIn then return end   -- the load-in cascade owns this alpha
    local visual, health = bar.visual, bar.healthBar
    if not outOfRange then
        bar:SetAlpha(1)
        if visual then visual:SetAlpha(1) end
        if health then health:SetAlpha(1) end
        return
    end
    local o    = math.max(0, math.min(1, opacity or 1))
    local back = o ^ ns.RANGE_BACKDROP_POW
    bar:SetAlpha(1)
    if visual then visual:SetAlpha(back) end
    if health then health:SetAlpha(back > 0 and (o / back) or 0) end
end

-- Which demo bars read as out of range. 2 and 6 are chosen because every other
-- slot is already carrying a demo: 4 has the objective icon, 5 the stealth
-- reveal, 7 the Stealth status.
function ns.MockOutOfRange(index)
    return index == 2 or index == 6
end

-- Distance from the card's left edge to an echo's CENTRE at phase f, where 0 is
-- newly born and furthest out and 1 is arrived.
--
-- The middle term puts the largest echo's VISIBLE edge on REVEAL_OUTSET at
-- arrival, rather than its centre. It carries the aspect because the glyph is
-- padded inside a square texture -- measuring the texture instead left the
-- arriving echo floating ~6px short of where the number said it would be.
--
-- Shared with the name anchor below so the two cannot disagree about where the
-- flight path is -- they did, and the name sat 28px further out than anything
-- ever reached.
function ns.EchoCentre(size, f)
    return ns.REVEAL_OUTSET
           + size * ns.REVEAL_ECHO_TO * ns.REVEAL_CHEVRON_ASPECT / 2
           + ns.REVEAL_ECHO_TRAVEL * size * (1 - f)
end

-- How far left the VISIBLE glyph reaches at phase f -- visible, because the
-- glyph is padded inside a square texture and the margin is not part of what
-- the eye sees the name butting up against.
function ns.EchoReach(size, f)
    local scale = ns.REVEAL_ECHO_FROM + (ns.REVEAL_ECHO_TO - ns.REVEAL_ECHO_FROM) * f
    return ns.EchoCentre(size, f) + size * scale * ns.REVEAL_CHEVRON_ASPECT / 2
end

function ns.HideStealthReveal(bar)
    if bar.revealMarch then bar.revealMarch:Hide() end
    if bar.revealChevrons then
        for _, ch in ipairs(bar.revealChevrons) do
            ch:SetAlpha(1)              -- the driver owns this; hand it back
            ch:Hide()
        end
    end
    if bar.revealName then bar.revealName:Hide() end
end

-- Draws the reveal at a given opacity. Split from the live path so the test
-- bars and the settings previews drive the exact same geometry with literals
-- instead of re-deriving it -- the team-targeting grid drifted that way once.
function ns.DrawStealthReveal(bar, who, classToken, alpha)
    local chevrons, txt = bar.revealChevrons, bar.revealName
    if not (chevrons and chevrons[1] and txt and who) then return false end

    local h    = bar._fxBarH or 32
    local size = math.max(6, h * ns.REVEAL_SIZE)
    local cc = RAID_CLASS_COLORS[classToken] or { r = 0.8, g = 0.8, b = 0.8 }

    -- OUTSIDE the card's left edge: [name] [indicator] | bar.
    --
    -- Three earlier attempts are worth not repeating. The top-right corner
    -- alongside the team-targeting grid had no room -- a readable diamond plus
    -- a name does not fit in a 40px card, stacked or side by side. Taking the
    -- centre text fit fine but meant owning the name/spec slot, and every
    -- layout pass in the addon wants to put that back, so it needed a claim
    -- honoured inside PlaceBarLabel and it still fought test mode. Sitting
    -- INSIDE the left edge then landed on top of the class art.
    --
    -- Off the card entirely, none of that applies: it overlaps nothing, owns
    -- nothing, and needs no claim on any slot. The left side is free -- the
    -- class art starts AT the edge and runs inward, and the ping button is the
    -- only thing that lives outside the card, on the right. Nothing clips
    -- children, so drawing beyond the edge is fine.
    --
    -- Anchored off `bar` rather than `bar.healthBar`, matching the ping
    -- button, which is the addon's existing convention for anything that sits
    -- outside the card rather than on it.
    -- Only the hue is set here. Size and position belong to the driver, which
    -- moves each echo every frame -- setting them here as well would fight it
    -- and snap the echoes back to a fixed spot on every render tick.
    for _, ch in ipairs(chevrons) do
        ch:SetVertexColor(cc.r, cc.g, cc.b)
        ch:Show()
    end

    -- Name runs further left from the indicator, so the pair grows away from
    -- the card and its length never pushes anything toward the bar. Class
    -- colour does the separating work: it is the only coloured text in play,
    -- since the centre text is white or state-tinted.
    txt:SetFont(ns.LOCALE_FONT,
        math.max(8, (bar._textSize or 12) * ns.REVEAL_NAME_SCALE),
        ns.REVEAL_NAME_FLAGS)
    txt:SetShadowColor(0, 0, 0, 0.9)
    txt:SetShadowOffset(1, -1)
    txt:SetText(who)
    txt:SetTextColor(cc.r, cc.g, cc.b, alpha)
    txt:SetJustifyH("RIGHT")
    txt:ClearAllPoints()
    -- Anchored to the CARD at the far end of the flight path, not to a chevron.
    -- The chevrons move every frame, so hanging the name off one would make it
    -- jitter in sympathy.
    --
    -- Both terms of EchoReach are linear in f, so the furthest point is always
    -- at an endpoint -- no need to sample the flight. The previous version
    -- assumed the widest echo sat at the far end, when it is the NEAREST echo
    -- that is widest, and put the name 28px beyond anything on screen.
    local reach = math.max(ns.EchoReach(size, 0), ns.EchoReach(size, 1))
                  + ns.REVEAL_GAP + ns.RevealArtOffset(bar)
    txt:SetPoint("RIGHT", bar, "LEFT", -reach, 0)
    txt:Show()

    -- Base size and the overall fade are handed to the driver rather than
    -- applied here: it derives every echo's size, position and alpha from them
    -- each frame, so the 4s fade-out and the flight compose instead of fighting
    -- over the same properties.
    local march = bar.revealMarch
    if march then
        march.baseAlpha = alpha
        march.baseSize  = size
        if not march:IsShown() then
            march.t = 0
            march:Show()
        end
    end
    return true
end

-- Live path. Returns true while the reveal is running, in which case it owns
-- the team-targeting slot for this pass.
function ns.RenderStealthReveal(bar, entry, want)
    local at   = entry and entry.revealAt
    local life = ns.REVEAL_HOLD + ns.REVEAL_FADE
    -- Expiry is checked even when the option is off, so toggling it back on
    -- mid-match cannot resurrect a reveal from minutes ago.
    if at and (GetTime() - at) >= life then
        entry.revealAt, entry.revealBy, entry.revealClass = nil, nil, nil
        at = nil
    end
    if not (want and at) then
        ns.HideStealthReveal(bar)
        return false
    end

    local age   = GetTime() - at
    local alpha = (age <= ns.REVEAL_HOLD) and 1
                  or (1 - (age - ns.REVEAL_HOLD) / ns.REVEAL_FADE)
    return ns.DrawStealthReveal(bar, entry.revealBy, entry.revealClass, alpha)
end

function ns.RenderTeamTargeting(bar, classes, total)
    local dmds = bar.teamDiamonds
    if not dmds then return end
    if not classes or #classes == 0 then
        for i = 1, ns.TEAM_TARGET_MAX do dmds[i]:Hide() end
        if bar.teamOverflow then bar.teamOverflow:Hide() end
        return
    end

    local h   = bar._fxBarH or 32
    local gap = h * ns.TEAM_TARGET_GAP
    -- Shrink to fit rather than overflow: the block must sit inside the card
    -- at every bar height, and three rows is the constraint, not the width.
    local rows = ns.TEAM_TARGET_ROWS
    local fits = (h - 2 * ns.TEAM_TARGET_MARGIN - (rows - 1) * gap) / rows
    local size = math.max(3, math.min(h * ns.TEAM_TARGET_SIZE, fits))
    -- The objective icon owns the right slot; start clear of it so a flag
    -- carrier being focused shows both.
    local right = (h * ns.ICON_SCALE) + ns.TEAM_TARGET_INSET

    local shown = math.min(#classes, ns.TEAM_TARGET_MAX)
    for i = 1, ns.TEAM_TARGET_MAX do
        local dmd = dmds[i]
        if i <= shown then
            local col = (i - 1) % ns.TEAM_TARGET_COLS
            local row = math.floor((i - 1) / ns.TEAM_TARGET_COLS)
            dmd:SetSize(size, size)
            dmd:ClearAllPoints()
            dmd:SetPoint("TOPRIGHT", bar.healthBar, "TOPRIGHT",
                -(right + col * (size + gap)),
                -(ns.TEAM_TARGET_MARGIN + row * (size + gap)))
            local cc = RAID_CLASS_COLORS[classes[i]] or { r = 0.8, g = 0.8, b = 0.8 }
            dmd:SetVertexColor(cc.r, cc.g, cc.b, 1)
            dmd:Show()
        else
            dmd:Hide()
        end
    end

    -- Past ten the list stops being readable, so the surplus becomes a number.
    local extra = (total or shown) - shown
    if bar.teamOverflow then
        if extra > 0 then
            bar.teamOverflow:SetFont(ns.LOCALE_FONT, math.max(7, size * 0.85), "")
            bar.teamOverflow:SetText("+" .. extra)
            bar.teamOverflow:ClearAllPoints()
            bar.teamOverflow:SetPoint("TOPRIGHT", bar.healthBar, "TOPRIGHT",
                -(right + ns.TEAM_TARGET_COLS * (size + gap)), -ns.TEAM_TARGET_MARGIN)
            bar.teamOverflow:Show()
        else
            bar.teamOverflow:Hide()
        end
    end
end


-------------------------------------------------------------------------------
-- ENGINE
-------------------------------------------------------------------------------

-------------------------------------------------------------------------------
-- Live state
-------------------------------------------------------------------------------
-- Volatile per-tick state, keyed by fullName. Wiped and rebuilt by the token
-- sweep each pass; anything that must outlive a tick (cached health, dead
-- timers, stealth guesses) is stamped onto the entry rather than kept here.
ns.tracker = ns.tracker or {}

-- Reverse lookup fullName -> bar, rebuilt on each out-of-combat roster pass.
local barByName = {}

-- Scoreboard-derived, refreshed every scan. Specs are keyed by SHORT name
-- because that is what the scoreboard hands back for same-realm players;
-- friendlyClasses is keyed both ways so a compound-token lookup (which yields
-- Name-Realm) and a scoreboard lookup (which may not) both resolve.
ns.enemySpecs      = ns.enemySpecs      or {}
ns.friendlySpecs   = ns.friendlySpecs   or {}
ns.friendlyClasses = ns.friendlyClasses or {}

-- Objective carriers on our own side, and when they picked up. Separate from
-- the enemy path because friendly carriers come from group tokens rather than
-- arena slots.
ns.friendlyObjectives       = ns.friendlyObjectives       or {}
ns.friendlyObjectivePickups = ns.friendlyObjectivePickups or {}   -- name -> { pickupTime, classification }

-- Peek key state: held = swap each bar's centre text to the other source.
-- Kept on ns rather than as a file local because the previews read it too.
ns.peekActive = false

local frameSets     = {}
-- Match-lifecycle flags. Declared HERE, ahead of ShowActive/LeaveMatch/the
-- watchdog/the event handler, all of which write them -- a write to a not-yet-
-- declared local is a silent global (caught by the SETGLOBAL check).
--
-- matchOver: set by PVP_MATCH_COMPLETE, cleared by the events that mean a NEW
-- instance. Without it the zone watchdog undoes LeaveMatch a second after the
-- match ends -- instanceType is still "pvp" while the scoreboard is up and
-- activeEnemy is nil, so it re-runs OnZoneChanged and brings the ticker back to
-- sweep a finished match.
-- leaveMatchPending: LeaveMatch owed from a match that ended in combat, paid on
-- PLAYER_REGEN_ENABLED, superseded by ShowActive.
local matchOver         = false
local leaveMatchPending = false
local activeEnemy    = nil
local activeFriendly = nil

-- Arena slots. In a battleground these are the objective carriers, and unlike
-- raid(i)target they are SIMPLE tokens, so they answer UnitHealth.

-- STEALTH_CLASSES / STEALTH_RACES ARE GONE. They listed the classes and races
-- that can stealth (Rogue, Druid, Mage, Hunter, Night Elf), and gated the
-- inference in VerifyTargetOrMarkStealth, along with a 100-call
-- C_CreatureInfo.GetRaceInfo loop at load to get the localised race names.
--
-- The gate was only ever needed because "we could not target them" was assumed
-- to be ambiguous. It is not: inside a battleground /targetexact reaches ANY
-- enemy in the instance regardless of range, so a failed target is a real
-- statement about targetability rather than about distance. Restricting it by
-- class could therefore only cause false NEGATIVES.
--
-- And it was blind to exactly the case that matters most: a Mage mass-invising
-- the whole team hides Warriors and Paladins too, and the class gate said those
-- cannot stealth. Any future invisibility effect is now covered for free,
-- because nothing is enumerated any more.

-- Ping dock tuning. The button nearest the cursor magnifies to its full zoom;
-- neighbours follow with a sharpened-cosine falloff on vertical distance.
local PING_DOCK_SPEED     = 45   -- exponential approach rate (higher = snappier)
local PING_GLOW_ALPHA     = 0.85 -- peak alpha of the active-item halo
local PING_GLOW_SCALE     = 1.7  -- halo diameter, relative to the zoomed icon
local PING_GLOW_COLOR     = { 1.00, 0.92, 0.72 }  -- candlelight, matching the settings UI

-------------------------------------------------------------------------------
-- GETTERS — keyed by prefix, avoids string dispatch in hot paths
-------------------------------------------------------------------------------
-- Every frame set exposes the same field names, so render code never branches
-- on which set it is looking at. The friendly sets fold "Use Enemy Bar
-- Settings" in here rather than at every call site.
local G = {
    normalEnemy = {
        enable       = ns.GetNormalEnemyEnable,
        barWidth     = ns.GetNormalEnemyBarWidth,
        barHeight    = ns.GetNormalEnemyBarHeight,
        barSpacing   = ns.GetNormalEnemyBarSpacing,
        textSize     = ns.GetNormalEnemyTextSize,
        headingSize  = ns.GetNormalEnemyHeadingTextSize,
        orbStackSize = ns.GetNormalEnemyOrbStackTextSize,
        powerEnabled = ns.GetNormalEnemyPowerBarEnabled,
        hideClassArt = ns.GetNormalEnemyHideClassArt,
        hideFaction  = ns.GetNormalEnemyHideFactionInfo,
        textDisplay  = ns.GetNormalEnemyTextDisplayMode,
        teamTargeting = ns.GetNormalEnemyTeamTargeting,
        stealthReveal = ns.GetNormalEnemyStealthReveal,
        rangeCheck   = ns.GetNormalEnemyRangeCheck,
        rangeOpacity = ns.GetNormalEnemyRangeOpacity,
        frameLocked  = ns.GetNormalEnemyFrameLocked,
        pingEnabled  = ns.GetNormalEnemyPingEnabled,
        pingLeft     = ns.GetNormalEnemyPingLeftClick,
        pingRight    = ns.GetNormalEnemyPingRightClick,
        pingZoom     = ns.GetNormalEnemyPingZoomAmount,
        framePos     = ns.GetNormalEnemyFramePosition,
    },
    normalFriendly = {
        enable       = ns.GetNormalFriendlyEnable,
        barWidth     = function() if ns.GetNormalFriendlyUseEnemySettings() then return ns.GetNormalEnemyBarWidth()          end return ns.GetNormalFriendlyBarWidth()          end,
        barHeight    = function() if ns.GetNormalFriendlyUseEnemySettings() then return ns.GetNormalEnemyBarHeight()         end return ns.GetNormalFriendlyBarHeight()         end,
        barSpacing   = function() if ns.GetNormalFriendlyUseEnemySettings() then return ns.GetNormalEnemyBarSpacing()        end return ns.GetNormalFriendlyBarSpacing()        end,
        textSize     = function() if ns.GetNormalFriendlyUseEnemySettings() then return ns.GetNormalEnemyTextSize()          end return ns.GetNormalFriendlyTextSize()          end,
        headingSize  = function() if ns.GetNormalFriendlyUseEnemySettings() then return ns.GetNormalEnemyHeadingTextSize()   end return ns.GetNormalFriendlyHeadingTextSize()   end,
        orbStackSize = function() if ns.GetNormalFriendlyUseEnemySettings() then return ns.GetNormalEnemyOrbStackTextSize()  end return ns.GetNormalFriendlyOrbStackTextSize()  end,
        powerEnabled = function() if ns.GetNormalFriendlyUseEnemySettings() then return ns.GetNormalEnemyPowerBarEnabled()   end return ns.GetNormalFriendlyPowerBarEnabled()   end,
        hideClassArt = function() if ns.GetNormalFriendlyUseEnemySettings() then return ns.GetNormalEnemyHideClassArt()      end return ns.GetNormalFriendlyHideClassArt()      end,
        hideFaction  = function() if ns.GetNormalFriendlyUseEnemySettings() then return ns.GetNormalEnemyHideFactionInfo()   end return ns.GetNormalFriendlyHideFactionInfo()   end,
        textDisplay  = function() if ns.GetNormalFriendlyUseEnemySettings() then return ns.GetNormalEnemyTextDisplayMode()   end return ns.GetNormalFriendlyTextDisplayMode()   end,
        -- Enemy-only feature: the grid answers "who on our side is on this
        -- enemy", which is meaningless on a friendly card.
        teamTargeting = function() return false end,
        -- Friendly bars can never carry a stealth flag: VerifyTargetOrMarkStealth
        -- only ever writes entries keyed by ns.playerKeys, which is scoreboard
        -- ENEMIES.
        stealthReveal = function() return false end,
        rangeCheck   = function() if ns.GetNormalFriendlyUseEnemySettings() then return ns.GetNormalEnemyRangeCheck()   end return ns.GetNormalFriendlyRangeCheck()   end,
        rangeOpacity = function() if ns.GetNormalFriendlyUseEnemySettings() then return ns.GetNormalEnemyRangeOpacity() end return ns.GetNormalFriendlyRangeOpacity() end,
        frameLocked  = ns.GetNormalFriendlyFrameLocked,
        pingEnabled  = ns.GetNormalFriendlyPingEnabled,
        pingLeft     = ns.GetNormalFriendlyPingLeftClick,
        pingRight    = ns.GetNormalFriendlyPingRightClick,
        pingZoom     = ns.GetNormalFriendlyPingZoomAmount,
        framePos     = ns.GetNormalFriendlyFramePosition,
    },
    epicEnemy = {
        enable       = ns.GetEpicEnemyEnable,
        barWidth     = ns.GetEpicEnemyBarWidth,
        barHeight    = ns.GetEpicEnemyBarHeight,
        barSpacing   = ns.GetEpicEnemyBarSpacing,
        textSize     = ns.GetEpicEnemyTextSize,
        headingSize  = ns.GetEpicEnemyHeadingTextSize,
        orbStackSize = ns.GetEpicEnemyOrbStackTextSize,
        powerEnabled = ns.GetEpicEnemyPowerBarEnabled,
        hideClassArt = ns.GetEpicEnemyHideClassArt,
        hideFaction  = ns.GetEpicEnemyHideFactionInfo,
        textDisplay  = ns.GetEpicEnemyTextDisplayMode,
        -- Forty players means a dozen can share one target and the grid stops
        -- meaning anything.
        teamTargeting = function() return false end,
        stealthReveal = function() return false end,
        rangeCheck   = ns.GetEpicEnemyRangeCheck,
        rangeOpacity = ns.GetEpicEnemyRangeOpacity,
        frameLocked  = ns.GetEpicEnemyFrameLocked,
        pingEnabled  = ns.GetEpicEnemyPingEnabled,
        pingLeft     = ns.GetEpicEnemyPingLeftClick,
        pingRight    = ns.GetEpicEnemyPingRightClick,
        pingZoom     = ns.GetEpicEnemyPingZoomAmount,
        framePos     = ns.GetEpicEnemyFramePosition,
    },
    epicFriendly = {
        enable       = ns.GetEpicFriendlyEnable,
        barWidth     = function() if ns.GetEpicFriendlyUseEnemySettings() then return ns.GetEpicEnemyBarWidth()          end return ns.GetEpicFriendlyBarWidth()          end,
        barHeight    = function() if ns.GetEpicFriendlyUseEnemySettings() then return ns.GetEpicEnemyBarHeight()         end return ns.GetEpicFriendlyBarHeight()         end,
        barSpacing   = function() if ns.GetEpicFriendlyUseEnemySettings() then return ns.GetEpicEnemyBarSpacing()        end return ns.GetEpicFriendlyBarSpacing()        end,
        textSize     = function() if ns.GetEpicFriendlyUseEnemySettings() then return ns.GetEpicEnemyTextSize()          end return ns.GetEpicFriendlyTextSize()          end,
        headingSize  = function() if ns.GetEpicFriendlyUseEnemySettings() then return ns.GetEpicEnemyHeadingTextSize()   end return ns.GetEpicFriendlyHeadingTextSize()   end,
        orbStackSize = function() if ns.GetEpicFriendlyUseEnemySettings() then return ns.GetEpicEnemyOrbStackTextSize()  end return ns.GetEpicFriendlyOrbStackTextSize()  end,
        powerEnabled = function() if ns.GetEpicFriendlyUseEnemySettings() then return ns.GetEpicEnemyPowerBarEnabled()   end return ns.GetEpicFriendlyPowerBarEnabled()   end,
        hideClassArt = function() if ns.GetEpicFriendlyUseEnemySettings() then return ns.GetEpicEnemyHideClassArt()      end return ns.GetEpicFriendlyHideClassArt()      end,
        hideFaction  = function() if ns.GetEpicFriendlyUseEnemySettings() then return ns.GetEpicEnemyHideFactionInfo()   end return ns.GetEpicFriendlyHideFactionInfo()   end,
        textDisplay  = function() if ns.GetEpicFriendlyUseEnemySettings() then return ns.GetEpicEnemyTextDisplayMode()   end return ns.GetEpicFriendlyTextDisplayMode()   end,
        teamTargeting = function() return false end,
        stealthReveal = function() return false end,
        rangeCheck   = function() if ns.GetEpicFriendlyUseEnemySettings() then return ns.GetEpicEnemyRangeCheck()   end return ns.GetEpicFriendlyRangeCheck()   end,
        rangeOpacity = function() if ns.GetEpicFriendlyUseEnemySettings() then return ns.GetEpicEnemyRangeOpacity() end return ns.GetEpicFriendlyRangeOpacity() end,
        frameLocked  = ns.GetEpicFriendlyFrameLocked,
        pingEnabled  = ns.GetEpicFriendlyPingEnabled,
        pingLeft     = ns.GetEpicFriendlyPingLeftClick,
        pingRight    = ns.GetEpicFriendlyPingRightClick,
        pingZoom     = ns.GetEpicFriendlyPingZoomAmount,
        framePos     = ns.GetEpicFriendlyFramePosition,
    },
}
ns.G = G

-------------------------------------------------------------------------------
-- Bar geometry constants
-------------------------------------------------------------------------------
-- Objective icon size, as a fraction of card height.
ns.ICON_SCALE = 0.90

-- BOTH SHADOW ALPHAS ARE 0, AND THE REBUILD HAD SILENTLY PUT THEM BACK.
--
-- ICON_SHADOW_ALPHA existed and was 0 before the data loss. The rebuild dropped
-- the constant and left pvpIconShadow hardcoded at 0.6, which restores the exact
-- bug the notes describe as costing most of a session: a black copy of the icon
-- offset +2/-2 is invisible behind a SQUARE icon and unmistakable behind a ROUND
-- one, where it reads as a second off-centre circle. The objective icons are all
-- round now.
--
-- classArtShadow is the same construction on the class art, and worse there. The
-- redrawn set DISSOLVES at its edges by design -- the whole purpose of the
-- horizontal profiles in bake_classart.py -- so an offset copy underneath has
-- nothing to hide behind and reads as a ghosted duplicate rather than as depth.
-- The art also already carries a baked SCRIM to block the health fill, which is
-- what a runtime shadow would have been for.
--
-- Never offset anything behind art with soft or curved edges. If depth is wanted,
-- a CENTRED darkening is the only thing that works.
ns.ICON_SHADOW_ALPHA      = 0
ns.CLASS_ART_SHADOW_ALPHA = 0

-- Class art. 2.37 is wider than the source 2:1 on purpose -- the art is meant
-- to run past its own frame and bleed off the left edge. Capped at 45% of the
-- bar so it never reaches the centre text on a narrow bar.
local CLASS_ART_SCALE  = 1.0
local CLASS_ART_ASPECT = 2.37
-- How far the REDRAWN class art sits in from the card's left edge, in px. Zero
-- put its baked left-edge fade flush against the rounded corner; a few px lets
-- the fade breathe inside the card. Legacy (Blizzard) icons are square with a
-- hard edge and take no inset -- an inset there just reads as shoved right.
local CLASS_ART_INSET  = 7
local CLASS_ICON_GAP   = 4   -- legacy Blizzard icons sit OUTSIDE the card, this far left of it

-- June exception to the shadow rule above: the SHARP set was designed AROUND
-- its cast shadow -- a black silhouette at +2/-2 selling "popped off the
-- card" -- and shipped that way for months. The rule stands for the polygon
-- set (dissolving edges, baked scrim); sharp gets June's numbers back.
local CLASS_ART_SHARP_SHADOW = 0.45  -- June's shadow alpha
-- How far left the ENTIRE stealth-reveal assembly (chevron flight path and
-- name together) shifts to clear the class art. Polygon lives inside the
-- card, so nothing to clear; legacy and sharp occupy one card-height plus
-- the gap to its left (sharp's visible left edge is pinned to the legacy
-- position, so one number serves both). Height read live from the bar so a
-- card resize never strands the reveal at a stale offset.
-- How much room the class art occupies OUTSIDE the card, for a card of
-- height `h`. Everything that has to stay centred on the whole composition
-- rather than on the bare card -- the stealth reveal, the centre text, the
-- heading band -- is derived from this one number, so they cannot disagree.
--
-- Takes a HEIGHT rather than a bar because the settings previews lay their
-- heading out BEFORE their bars, and so have the height in hand but no
-- laid-out bar to ask. Asking an unlaid bar returns nil and silently
-- collapses the offset to the icon gap -- which is exactly the first-render
-- bug that put the test-mode reveal on top of the frames.
function ns.ArtOutsideWidth(h, hideArt)
    local style = ns.GetClassArtStyle and ns.GetClassArtStyle() or "polygon"
    if style == "polygon2" then return 0 end  -- the inside composition
    if hideArt then return 0 end
    return (h or 0) + CLASS_ICON_GAP
end

-- bar._fxBarH is the CARD height, not the health bar's -- the outside art is
-- sized on the card, so a power strip must not move any of this with it.
function ns.RevealArtOffset(bar)
    return ns.ArtOutsideWidth(bar._fxBarH, bar._hideClassArt)
end

local CLASS_ART_POLY_POP   = 1.08  -- outside-polygon overhang past the card edges
local CLASS_ART_POLY_NUDGE = 3     -- BANDAID: outside-polygon shifted left until the
                                   -- art is redrawn for the outside slot

-- Outside-polygon backdrop: the polygon art's baked scrim was tuned to block
-- the health FILL; hung outside the card there is nothing behind it and the
-- art reads thin. Per the shadow rule above, the fix is a CENTRED darkening:
-- the silhouette drawn at zero offset becomes a backdrop that follows the
-- art's own alpha exactly, thickening it without adding an edge.
local CLASS_ART_POLY_BACKDROP = 0.65

local function ClassArtShadowAlpha()
    local style = ns.GetClassArtStyle and ns.GetClassArtStyle()
    if style == "sharp"   then return CLASS_ART_SHARP_SHADOW end
    if style == "polygon" then return CLASS_ART_POLY_BACKDROP end
    return ns.CLASS_ART_SHADOW_ALPHA
end

-- The halo behind a carrier icon, relative to the icon. Preserves the ratio
-- the fixed sizes used to give (48 around 32).
--
-- Worth revisiting: the halo is a CIRCLE, which suited round icons. The new
-- banners are not round, so it now rings a rectangle. Either drop it for
-- carriers or swap PingHalo for a softer bloom.
ns.ICON_GLOW_SCALE = 1.50
-- The death marker's halo: same PingHalo ring the objective icons wear.
-- White, additive like theirs -- black was tried first (a shadow ring, drawn
-- BLEND) and disappeared against the dead card's already-dark trough.
ns.DEAD_GLOW_ALPHA = 0.65

-- The state watermark and the objective icon share the RIGHT-hand slot: it
-- holds one thing at a time, which is why a carrier icon suppresses the death
-- marker rather than the two stacking.
ns.ART_SLOT  = "right"

-- Team targeting: coloured diamonds, one per teammate on this enemy.
ns.TEAM_TARGET_COLS   = 3
ns.TEAM_TARGET_ROWS   = 3
ns.TEAM_TARGET_MAX    = 9          -- COLS * ROWS
ns.TEAM_TARGET_SIZE   = 0.28       -- of bar height, preferred
ns.TEAM_TARGET_GAP    = 0.03       -- of bar height, between diamonds
ns.TEAM_TARGET_MARGIN = 2          -- px clear of the card's top edge
ns.TEAM_TARGET_INSET  = 13         -- px clear of the objective icon slot

-- Stealth reveal badge, drawn OUTSIDE the card's left edge.
ns.REVEAL_HOLD       = 5.0         -- seconds at full opacity
ns.REVEAL_FADE       = 1.0         -- seconds fading out after that (6s total)

-- How long before the SAME enemy can trigger another reveal. Must be at least
-- HOLD + FADE or a re-stealth can overwrite a reveal still on screen; the
-- surplus above that is the quiet window that swallows combat vanishes, where
-- the stealth is a global rather than an attempt to hide.
--
-- Too high and a genuine re-stealth minutes into a fight goes unreported, so
-- this is deliberately only a few seconds past the reveal's own lifetime.
-- MATCHED TO THE REVEAL'S OWN LENGTH (REVEAL_HOLD + REVEAL_FADE), and it should
-- stay matched if either changes. It measures from revealArmedAt, stamped when a
-- reveal STARTS, so an equal value means the next one becomes possible exactly as
-- the previous finishes: layering is impossible, and nothing is suppressed beyond
-- that. Longer only adds dead time where a genuine second find is dropped;
-- shorter lets two overlap, which is the whole thing this prevents.
ns.REVEAL_REARM      = 6.0
-- How long a stealth detection stays OWED a reveal. The latch below is not
-- cleared by us re-finding the enemy ourselves, so it needs a bound of its
-- own: without one, a rogue who stealths, is never touched for two minutes
-- and is then incidentally targeted by a team mate would produce a reveal
-- attributed to an event nobody remembers. Stealth openers land in seconds;
-- this is generous and still bounded.
ns.REVEAL_OWED_WINDOW = 30
ns.REVEAL_SIZE       = 0.50        -- chevron, fraction of bar height
ns.REVEAL_GAP        = 2           -- px between the name and the newest chevron
ns.REVEAL_OUTSET     = 2           -- px the arriving echo stops short of the card

-- Chevron march. A row of "> > >" pointing at the card with a travelling alpha
-- wave running along them toward it, replacing the pulsing diamond and its
-- sonar ring. A pulse says "something here"; a directed wave says "over there",
-- which is what this indicator is actually for.
--
-- STAGGER is a fraction of a cycle, so the crest is a real travelling wave
-- rather than three lights blinking together. At 0 they pulse in unison; above
-- roughly 1/COUNT the wave laps itself and reads as noise.
-- Each chevron is one ECHO IN FLIGHT, not a fixed position in a row. It is born
-- small at the far end, then grows and fades as it travels into the card, and
-- the next is emitted behind it. Three of them at evenly spread phases make the
-- stream continuous.
--
-- This is the sonar ring's behaviour with a chevron shape. A row of chevrons
-- cross-fading in place was tried first and read as three separate arrows
-- blinking, not as one thing expanding -- the movement is what made the ring
-- work, and holding position loses it however the alpha is ramped.
ns.REVEAL_CHEVRON_COUNT   = 3      -- echoes in flight at once
ns.REVEAL_ECHO_TIME       = 1.10   -- seconds for one echo, birth to arrival
ns.REVEAL_ECHO_FROM       = 0.62   -- scale at birth, furthest from the card
ns.REVEAL_ECHO_TO         = 2.10   -- scale on arrival
ns.REVEAL_ECHO_TRAVEL     = 2.70   -- distance covered, as a fraction of base size

-- TRAVEL is the ONLY thing that separates the echoes now that they are centred,
-- and it fights the growth: a later echo is wider, so it eats the gap the
-- travel opened. Below about 2.0 they overlap outright; 2.70 puts a clear gap
-- between the middle and near echoes and just separates the far pair. Raising
-- ECHO_TO without raising this closes them up again.

-- The glyph is 0.662 as wide as it is tall and sits padded inside a SQUARE
-- texture, so a single number still sizes it and the proportions hold at any
-- scale. Worth knowing when reading the numbers above: a scale of 2.10 on a
-- 16px base is 34px TALL but only ~22px wide on screen. The aspect is also what
-- the name anchor measures against, so it is a constant rather than a figure
-- buried in the baker.
ns.REVEAL_CHEVRON_ASPECT  = 0.662  -- visible width / height of the baked glyph

-- Name label.
--
-- WoW has NO bold flag, and Settings.lua deliberately forbids hardcoding a
-- Latin face -- glyph coverage differs by region, so a bold Latin font renders
-- as boxes the moment the client is not English. That leaves OUTLINE as the
-- only weight lever there is, which is why it is back after being removed: it
-- is not decoration here, it is the bolding.
--
-- Set FLAGS to "" for no weight, or "THICKOUTLINE" for more. The shadow stays
-- either way; at "" it is what keeps the name legible over the world.
ns.REVEAL_NAME_SCALE      = 1.70   -- multiple of the bar's text size
ns.REVEAL_NAME_FLAGS      = "OUTLINE"

-- Respawn countdown.
ns.DEAD_TIMER_FALLBACK  = 26        -- seconds, when the API will not say
-- White. SetDeadTimer's own comment spells out why: the marker and the name
-- are both red, so the number is the one element that separates itself from
-- them rather than joining in.
ns.DEAD_TIMER_COLOR     = { 1.00, 1.00, 1.00 }
ns.DEAD_TIMER_BACKING   = 2.20      -- backing diameter, as a multiple of the font size
ns.DEAD_TIMER_BACK_A    = 0.85
ns.DEAD_TIMER_BACK_TEX  = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\ObjTimerBacking.tga"

-- The carrier stack count gets the same dark pool the respawn countdown has.
-- That number was the only one of the two WITHOUT a backing -- a bare FontString
-- with "OUTLINE" -- and an outline is a one-pixel rim competing with filigree at
-- its own scale, which is exactly why an outline was rejected for the status
-- word. It is the reason the timer has always looked clearer than the stacks.
--
-- Reusing ObjTimerBacking rather than ArtStatusPool2 is a deliberate call: the
-- radial pool is built for two digits and its dense core leaves the outer
-- characters of a three-digit orb count on the falloff. Accepted -- most of the
-- number being legible is enough, and it keeps the two numbers identical.
-- Vertical offset for EVERY number drawn on an icon -- the respawn countdown
-- and the carrier stack count -- so they move together and stay centred on X.
-- Negative is down. Tried at -2 on the "optical centre sits above geometric"
-- theory; it did not work out in practice and is back at true centre. Left as
-- a constant because both numbers share it deliberately -- two numbers in the
-- same slot at different heights would read as misaligned -- and because the
-- next art may want it again.
ns.ICON_NUMBER_Y = 0

ns.STACK_BACK_TEX   = ns.DEAD_TIMER_BACK_TEX
ns.STACK_BACK_SCALE = 2.20      -- diameter, as a multiple of the font size
ns.STACK_BACK_A     = 0.85

-------------------------------------------------------------------------------
-- Status / label vocabulary
-------------------------------------------------------------------------------
-- How each status announces itself:
--   "label"  replaces the centre text outright
--   "onart"  stamps a word across the class art, leaving the centre alone
--   "marker" hangs a small marker off the live label
--   "silent" announces nothing in text; the state art carries it alone
--
-- Both current statuses are "onart", which is why the centre slot is always
-- the name/spec pair and nothing else competes for it.
-- DEAD IS "silent" AS OF 2026-08-17, ON TRIAL. The spirit-healer marker reads as
-- death on its own well enough that the stamped word was saying it twice -- the
-- same reasoning that removed the stealth MARKER once the stealth word landed,
-- applied in the opposite direction. The word is the redundant half here because
-- the icon also carries the respawn countdown.
--
-- Only the TEXT goes. The marker itself is STATE_ART via ApplyBarStateArt, a
-- separate path, so the spirit icon and its countdown are untouched.
--
-- Flip back to "onart" to restore it; STATUS_LABELS still holds "DEAD".
ns.STATUS_DISPLAY = { ["Stealth"] = "onart", ["Dead"] = "silent" }
ns.STATUS_LABELS  = { ["Stealth"] = "STEALTH", ["Dead"] = "DEAD" }
ns.STATUS_COLORS  = { ["Stealth"] = { 1, 1, 1 }, ["Dead"] = { 1, 1, 1 } }

ns.STATUS_FONT_SCALE = 1.0
ns.STATUS_FONT_FLAGS = ""

-- The stamped word, as a fraction of bar height. "STEALTH" is seven characters
-- against "DEAD"'s four, so at a shared size it overruns the art; it gets its
-- own multiplier rather than a shared one.
ns.ART_STATUS_SCALE = { default = 1.00, ["Stealth"] = 0.79 }
ns.ART_STATUS_COLOR = { default = { 1.00, 1.00, 1.00 } }
ns.ART_STATUS_X     = -0.20        -- offset from art centre, as a fraction of art width

-- The dark pool the word sits on. Sized off the TEXT size, so it tracks the
-- word rather than the bar.
ns.ART_STATUS_BACK_A   = 1.00      -- pool opacity
ns.ART_STATUS_BACK_PAD = 2.00      -- width beyond the word, as a multiple of text size
ns.ART_STATUS_BACK_H   = 2.45      -- pool height, as a multiple of text size

-- A glow behind the pool, using the SAME nine-slice halo as the enemy card
-- highlight (BarGlow.tga) so the two read as one visual language. Layered
-- glow -> black pool -> word, so the halo is only ever visible as the overhang
-- past the pool's edge -- the same backdrop trick the card glow and the carrier
-- icon halo both use.
--
-- PAD is how far it reaches past the pool; 10 matches the baked boundary inset
-- in the texture, which is what Libs/Glow.lua uses and why its edge lands where
-- the art expects. MARGIN is the slice margin: inset + corner radius + AA.
-- ITS OWN TEXTURE, not the cards'. BarGlow.tga carries a 10px falloff band in a
-- 64px file, sized for the enemy CARDS, and nine-slice draws corners UNSTRETCHED
-- -- so those 10px stay 10px on screen however small the glow is drawn. Behind a
-- 35px card that is a halo; behind this ~29px pool it was most of the element,
-- which is why shrinking the drawn size never made it subtle. The band can only
-- change in the file.
--
-- NARROW IS NOT SUBTLE -- learned by trying it. At a 3px band the falloff was
-- crisp enough to read as a DRAWN BORDER, and a hard thin line is more
-- conspicuous than a soft wide one; "smaller" came out "more prominent". The
-- gradient is what makes it read as light rather than as an outline, so the band
-- wants to be reasonably wide and the ALPHA low, not the reverse.
--
-- StatusGlow.tga is therefore a 6px band at 0.40 alpha, hollow like BarGlow
-- since the pool sits in front and a filled centre would only lighten the black
-- it is meant to be edging. Rebake with Textures/bake_statusglow.py; BAND is the
-- knob and it IS the on-screen thickness.
--
-- MARGIN must exceed the band or the corner slices through the falloff -- 7
-- against the 6px band, giving a 14px minimum draw, far under the 24px this
-- reaches at the smallest text size.
--
-- TO TURN IT OFF: set the fourth value of ART_STATUS_GLOW_COLOR to 0. The black
-- pool alone was the state before any of this and remains a legitimate answer.
ns.ART_STATUS_GLOW_TEX    = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\StatusGlow.tga"
-- The pool's VISIBLE black is far smaller than the rectangle it is drawn in:
-- ArtStatusPool2 measures 68% of its width and 52% of its height at alpha 0.90,
-- the rest being dissolve. Sizing the glow from GetWidth/GetHeight therefore rang
-- the texture BOUNDS rather than the black, which is why it read as oversized at
-- every pad. These trace the core instead. Lower them to tighten.
ns.ART_STATUS_GLOW_CORE_W = 0.76
ns.ART_STATUS_GLOW_CORE_H = 0.60
ns.ART_STATUS_GLOW_PAD    = 2
ns.ART_STATUS_GLOW_MARGIN = 7
ns.ART_STATUS_GLOW_COLOR  = { 0.90, 0.88, 0.72, 0.40 }   -- candlelight; ALPHA 0 DISABLES

-- "Show Name and Spec": two lines, the spec smaller and warmer so the pair
-- reads as primary + secondary rather than as two equal values.
ns.BOTH_SPEC_SIZE_DELTA = 3
ns.BOTH_NAME_COLOR      = { 1.00, 1.00, 1.00 }
ns.BOTH_SPEC_COLOR      = { 1.00, 0.92, 0.72 }

-- Class art treatment per state. Stealth carries MORE colour than it used to,
-- not less: the mist and the stamped word carry the state, so draining the art
-- as well left the card unreadable.
local ART_STATE = {
    normal  = { alpha = 0.90, desat = false },
    stealth = { alpha = 0.90, desat = false },  -- full presence: stealth art is not faded, the fog and trough carry the state
    dead    = { alpha = 0.55, desat = true  },  -- the GREYSCALE carries death; only a slight fade on top
}

-- Stealth mist. The art is greyscale; all colour comes from MIST_COLOR here,
-- so retinting never needs new art.
ns.MIST_COLOR      = { 0.62, 0.68, 0.78 }  -- cold grey-blue, reads as fog not as a buff
ns.MIST_FADE_TIME  = 0.35                  -- seconds to ramp in/out of stealth
ns.MIST_TILE_SCALE = 0.85                  -- tile height as a fraction of card height

-- How much of the card's aspect the fog is allowed to inherit. 0 = tiles land
-- perfectly square; 1 = the tile spans the card and the fog smears by the full
-- aspect ratio. 0.45 on a 275x35 card gives 1 + 6.86*0.45 = 4.09, so features
-- come out about 4x wider than tall.
--
-- Deliberately NOT 0. The tiles themselves are isotropic (measured: gradient
-- ratios 0.96 and 1.15), so all elongation is this term, and some of it is
-- wanted -- drifting fog reads better slightly drawn out along the direction it
-- travels, and fully square tiles put ~9 repeats across a wide card, which
-- reads as texture rather than atmosphere.
ns.MIST_STRETCH = 0.45

-- Per-layer drift, in tiles per second. Opposing signs and non-harmonic rates
-- keep the two layers from ever settling into a visible repeating pattern.
-- du/dv are UV drift per second. Both layers scaled by the same 1.3 on
-- 2026-08-19 ("a little faster"): scaling them TOGETHER keeps the ratio between
-- the layers, which is what gives the interference its swirl -- speeding one
-- alone would change the character, not just the pace.
ns.MIST_LAYERS = {
    -- Alphas at the ceiling since the fog now sits over a live class-coloured
    -- fill (2026-08-19) instead of the old empty tinted substrate -- it needs
    -- more presence to read against real content. Denser than this means
    -- rebaking the tiles (bake_mist.py), not turning these knobs.
    { alpha = 1.00, scale = 1.00, du =  0.065, dv =  0.017 },
    { alpha = 0.88, scale = 1.45, du = -0.040, dv = -0.027 },
}

-- Slow overall breathing on top of the drift, so the fog swells and thins
-- rather than sliding past at constant density.
ns.MIST_PULSE_RATE  = 0.22   -- cycles per second
ns.MIST_PULSE_DEPTH = 0.18   -- +/- fraction of base alpha

-- The death marker: a wraith with lit eyes, drawn at full colour and exempt
-- from the dead art treatment (that desaturates the CLASS art, not this). It
-- reads as "waiting to come back" rather than "gone", which is what the respawn
-- timer sitting on top of it is actually counting. Baked from
-- SimpleTextureCircleIcons/Vanish.png -- named for a stealth ability at the
-- source, but it is the ghost. Replaced CutSpirit2 on 2026-08-19; that file is
-- still on disk if the spirit-healer read is ever wanted back.
ns.DEAD_STATE_TEX = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\DeadWraith2.tga"

-- The lost-health region is the class colour at a fraction of its value, not a
-- neutral grey -- that is what keeps an empty card still reading as that class.
-- Suppressed states drop the same colour further rather than switching to a
-- different one.
ns.TROUGH_VALUE      = 0.16   -- multiplier on the class colour, alive
ns.TROUGH_VALUE_DEAD = 0.08   -- and suppressed-but-still-tinted (stealth)
ns.TROUGH_ALPHA      = 0.60

-- Dead trough, as an ABSOLUTE colour rather than a multiplier -- a dead card
-- shows no class colour at all. Tuned by eye through {0.130,0.045,0.045} ->
-- {0.098,0.048,0.050} -> {0.086,0.056,0.058} before landing here, walking the
-- warmth out of it: the earlier reddish values read as "damaged" rather than
-- as "gone". Slightly blue rather than pure grey so it sits with the card's
-- cool furniture instead of looking like a desaturation bug.
ns.DEAD_TROUGH_PRESENCE = 0.55  -- dead trough = greyscaled class trough at this fraction

-- Stealth substrate. The fog is a fixed cool grey drawn BLEND over the card, so
-- whatever hue is underneath tints it: over reds and purples it read as fog,
-- over the greens and yellows it just muddied them. Rather than make the fog's
-- blend adapt to thirteen different substrates, the card underneath is forced
-- to ONE colour while stealthed, so the fog always has the same thing to sit on.
--
-- Warlock purple, because that is the class the effect happened to be tuned
-- against and it reads as "hidden" without going neutral. Kept as its own table
-- rather than read from RAID_CLASS_COLORS so it can be nudged without waiting
-- on Blizzard and without dragging actual Warlock cards with it.
--
-- Class identity is not lost: the class art still sits on top at 0.46 alpha and
-- the STEALTH watermark names the state outright.
-- Warlock purple, by choice: rogue yellow (the class that nominally owns
-- stealth) was tried 2026-08-19 and reverted on review -- the purple sits
-- better under the fog. Drives the trough override only; the art stays
-- neutral (STEALTH_TINT_ART below).
ns.STEALTH_TINT = { r = 0.58, g = 0.51, b = 0.79 }

-- Whether the class art is tinted to match. The fog draws ABOVE the art (mist
-- at ARTWORK 5/6, art at -1), and the art is the strongest colour under it --
-- far more than the trough, which is already down at 0.08 of its value. So this
-- is the half that actually unifies the substrate; the trough on its own barely
-- moves the result.
--
-- Set false to keep each class's own art colours and take only the trough
-- change. The art is desaturated and re-tinted rather than dimmed: dimming is
-- what made stealth unreadable before, and this keeps the luminance while
-- removing the hue that was fighting the fog.
-- The TROUGH flattens to STEALTH_TINT on stealth (rogue yellow -- one shared
-- hue so "stealthed" reads before "which class"), but the class ART stays
-- neutral: its own colours, untinted. Tinting the art too was the old
-- warlock-purple look; the icon keeping its identity is the 2026-08-19 call.
ns.STEALTH_TINT_ART = false

-- How solid the health FILL stays under the fog. 0 = the fill is not drawn
-- at all during stealth (walked down from 0.55 through 0.15 in live tuning);
-- the VALUE underneath stays honest -- this is alpha, not SetValue(0), so
-- nothing downstream can mistake a stealther for a corpse and the fill
-- reappears at its true last-known level the instant stealth clears.
ns.STEALTH_FILL_ALPHA = 0

-- Trough ramp: the empty end of the bar darkens toward its right edge.
-- Width is a fraction of the bar so it reads the same at every Bar Width
-- setting rather than being a fixed slice (20px was a fifth of a narrow bar
-- and a twentieth of a wide one). Falloff is how far the colour travels: 0
-- would be pure black, 1 would be no ramp at all -- so 0.7 is a good deal
-- subtler than the original ramp-to-black.
ns.TROUGH_RAMP_FRACTION = 0.05
ns.TROUGH_RAMP_FALLOFF  = 0.7

-- The power bar used to square off the card's bottom corners: it is a separate
-- StatusBar underneath the masked health layers and never got a mask of its
-- own. It now has one -- bar.roundMaskP, from BarPowerMask.tga, cut to land on
-- the same corner radius as the card so the two silhouettes agree.

-------------------------------------------------------------------------------
-- Colour helpers
-------------------------------------------------------------------------------
local POWER_COLORS = {
    MANA        = { 0.31, 0.45, 0.85 },
    RAGE        = { 0.78, 0.25, 0.25 },
    ENERGY      = { 0.90, 0.86, 0.36 },
    FOCUS       = { 0.85, 0.55, 0.28 },
    RUNIC_POWER = { 0.35, 0.65, 0.85 },
    LUNAR_POWER = { 0.30, 0.52, 0.90 },
    MAELSTROM   = { 0.20, 0.60, 1.00 },
    INSANITY    = { 0.55, 0.30, 0.80 },
    FURY        = { 0.79, 0.26, 0.99 },
    PAIN        = { 1.00, 0.61, 0.00 },
    ESSENCE     = { 0.20, 0.80, 0.80 },
}
local POWER_FALLBACK = { 0.31, 0.45, 0.85 }

-- Power colour from a power token. Falls back to mana blue rather than nil so
-- callers never have to branch.
function ns.GetPowerColor(powerToken)
    local c = powerToken and POWER_COLORS[powerToken]
    if c then return c[1], c[2], c[3] end
    return POWER_FALLBACK[1], POWER_FALLBACK[2], POWER_FALLBACK[3]
end

-- Power colour for a CLASS, used where no live power token is available --
-- test mode and the settings previews, which have no unit to read.
local CLASS_PRIMARY_POWER = {
    WARRIOR = "RAGE",  PALADIN = "MANA",   HUNTER = "FOCUS",  ROGUE  = "ENERGY",
    PRIEST  = "MANA",  DEATHKNIGHT = "RUNIC_POWER", SHAMAN = "MANA", MAGE = "MANA",
    WARLOCK = "MANA",  MONK = "ENERGY",    DRUID  = "MANA",
    DEMONHUNTER = "FURY", EVOKER = "MANA",
}
function ns.GetClassPowerColor(classToken)
    return ns.GetPowerColor(CLASS_PRIMARY_POWER[classToken])
end

-- Spec font size, honouring Show Both. Shared so ApplyBarLayout, ComposeLabel
-- and the previews cannot disagree about how big the secondary line is.
function ns.SpecFontSize(bar, textSize)
    if bar._activeLabel == "both" then
        return math.max(6, (textSize or 12) - ns.BOTH_SPEC_SIZE_DELTA)
    end
    return textSize or 12
end

-------------------------------------------------------------------------------
-- Centre text, status stamps and layout
-------------------------------------------------------------------------------
-- Peek: hold the key to swap each bar's centre text to the other source. The
-- previews register a refresher here so the key reaches them too.
local previewRefreshers = {}

function ns.RegisterPreviewRefresh(fn)
    if type(fn) == "function" then previewRefreshers[#previewRefreshers + 1] = fn end
end

function RefreshPreviews()
    for _, fn in ipairs(previewRefreshers) do fn() end
end

-- Exposed for settings that change how a bar DRAWS rather than what it says --
-- the class-artwork toggle -- which have to reach the preview panes to be
-- judged at all. The peek key uses the local directly.
ns.RefreshPreviews = RefreshPreviews

-- Which spec the scoreboard last reported for this bar's occupant. Specs are
-- keyed by SHORT name because that is the form the scoreboard hands back.
function GetBarSpec(bar)
    local name = bar.assignedName
    if not name then return nil end
    local spec = bar._isEnemy and ns.enemySpecs[name] or ns.friendlySpecs[name]
    -- talentSpec is still a secret in 12.1, so it can be displayed but never
    -- inspected. Handing a secret to SetText is fine; comparing it is not.
    return spec
end

-- Dead and stealth are the only two states, and both are inferred rather than
-- read: dead from a paired token, stealth from a probe that did not take.
function GetBarStatus(bar)
    if bar._testStealth then return "Stealth" end
    if bar._testDead    then return "Dead"    end
    local entry = bar.fullName and ns.tracker[bar.fullName]
    if entry then
        if entry.isDead    then return "Dead"    end
        if entry.isStealth then return "Stealth" end
    end
    return nil
end

-- Seconds left on a respawn, or nil when it cannot be known.
function DeadRemaining(startTime)
    if not startTime then return nil end
    local duration = GetDeadAutoClearDuration()
    if not duration then return nil end
    return duration - (GetTime() - startTime)
end

-- Shows or hides one centre-slot FontString and parks it dead centre.
function ns.PlaceLabelSlot(fontString, shown, anchorTo, dx)
    fontString:ClearAllPoints()
    if not shown then
        fontString:Hide()
        return
    end
    fontString:SetJustifyH("CENTER")
    fontString:SetPoint("CENTER", anchorTo, "CENTER", dx or 0, 0)
    fontString:Show()
end

-- A small word hung off whichever label is live.
function ns.SetStateMarker(bar, text)
    local m = bar.stateMarker
    if not m then return end
    if not text then
        m:Hide()
        bar._stateArtSlot = nil
        return
    end
    m:SetFont(ns.LOCALE_FONT, ns.BarStatusSize(bar._fxBarH, 0.8), "OUTLINE")
    m:SetText(text)
    m:Show()
    bar._stateArtSlot = "label"
end

-- The status word stamped across the class art. Sized as a fraction of bar
-- height rather than of the text setting, so it cannot be shrunk out of
-- legibility by turning the name size down.
function ns.SetArtStatus(bar, status)
    local scale = ns.ART_STATUS_SCALE[status] or ns.ART_STATUS_SCALE.default
    local size  = ns.BarStatusSize(bar._fxBarH, scale)
    bar._artStatusSize = size
    if not bar.statusText:SetFont(ns.STATUS_FONT, size, ns.STATUS_FONT_FLAGS) then
        bar.statusText:SetFont(ns.LOCALE_FONT, size, ns.STATUS_FONT_FLAGS)
    end
    local c = ns.ART_STATUS_COLOR[status] or ns.ART_STATUS_COLOR.default
    bar.statusText:SetText(ns.STATUS_LABELS[status] or status)
    bar.statusText:SetTextColor(c[1], c[2], c[3], 1)
end

-- Composes what the centre of the bar says. Sole owner of that decision, so
-- live bars, test mode and the previews cannot disagree about it.
function ns.ComposeLabel(bar, mode, name, spec, status, peek)
    if peek == nil then peek = ns.peekActive end

    -- Resolve how this status announces itself before anything else. Only
    -- "label" replaces the centre outright; every other mode falls through to
    -- the normal name/spec path, so the player's identity survives the state.
    local display = status and (ns.STATUS_DISPLAY[status] or "label") or nil
    local tint, artStatus
    if display == "marker" then
        ns.SetStateMarker(bar, ns.STATUS_LABELS[status] or status)
        status = nil
    else
        ns.SetStateMarker(bar, nil)
        if display == "tint" then
            tint = ns.STATUS_COLORS[status]
        elseif display == "onart" then
            ns.SetArtStatus(bar, status)
            artStatus = true
        end
        -- "silent" needs no branch of its own -- it deliberately does none of the
        -- above and then falls into the status = nil below. Named here only so
        -- the mode is a decision rather than something that works by accident:
        -- an unrecognised mode reaches this same point, and the two should not be
        -- indistinguishable.
        if display ~= "label" then status = nil end
    end
    -- Read by PlaceBarLabel and by ApplyBarLayout's width rule. Always set,
    -- never left to persist: a bar going from dead to alive would otherwise
    -- keep the stamp.
    bar._artStatus = artStatus

    local tr = tint and tint[1] or 1
    local tg = tint and tint[2] or 1
    local tb = tint and tint[3] or 1

    -- Peek swaps the source; that is the whole point of holding the key.
    local source = (mode == "spec") and "spec" or "name"
    if peek then
        source = (source == "spec") and "name" or "spec"
    elseif status then
        -- Status replaces the primary rather than sitting beside it.
        local c = ns.STATUS_COLORS[status]
        local size = ns.BarStatusSize(bar._fxBarH, ns.STATUS_FONT_SCALE)
        if not bar.statusText:SetFont(ns.STATUS_FONT, size, ns.STATUS_FONT_FLAGS) then
            bar.statusText:SetFont(ns.LOCALE_FONT, size, ns.STATUS_FONT_FLAGS)
        end
        bar.statusText:SetText(ns.STATUS_LABELS[status] or status)
        bar.statusText:SetTextColor(c and c[1] or 1, c and c[2] or 1, c and c[3] or 1, 1)
        bar._activeLabel = "status"
        return
    end

    -- Both sources at once. Peek is deliberately ignored: there is nothing to
    -- reveal when everything is already on screen.
    if mode == "both" then
        local size = bar._textSize or 12
        bar._activeLabel = "both"          -- set first: SpecFontSize reads it
        bar._bothSpecSize = ns.SpecFontSize(bar, size)
        bar.nameText:SetFont(ns.LOCALE_FONT, size, "")
        bar.specText:SetFont(ns.LOCALE_FONT, bar._bothSpecSize, "")

        local nc, sc = ns.BOTH_NAME_COLOR, ns.BOTH_SPEC_COLOR
        bar.nameText:SetText(name)
        bar.nameText:SetTextColor(nc[1] * tr, nc[2] * tg, nc[3] * tb, 1)
        bar.specText:SetText(spec)
        bar.specText:SetTextColor(sc[1] * tr, sc[2] * tg, sc[3] * tb, 1)
        return
    end

    if source == "spec" then
        bar.specText:SetText(spec)
        bar.specText:SetTextColor(tr, tg, tb, 1)
        bar._activeLabel = "spec"
    else
        bar.nameText:SetText(name)
        bar.nameText:SetTextColor(tr, tg, tb, 1)
        bar._activeLabel = "name"
    end
end

-- Hangs a FontString off a named slot on the bar. "label" follows whichever
-- centre text is live, so anything anchored this way re-hangs by itself when
-- Text Display changes or the peek key is held.
function ns.AnchorToSlot(fontString, bar, slot)
    if not (fontString and bar and bar.healthBar) then return end
    fontString:ClearAllPoints()
    if slot == "right" then
        -- The right-hand slot, same place the objective icon and the state
        -- watermark occupy. Centred on it rather than beside it, so a number
        -- drawn here reads as a label ON the marker.
        local d = math.max(1, (bar._fxBarH or 32) * ns.ICON_SCALE)
        fontString:SetPoint("CENTER", bar.healthBar, "RIGHT", -(d / 2) - 3, 0)
    else
        local target = (bar._activeLabel == "spec") and bar.specText or bar.nameText
        if not target then return end
        fontString:SetPoint("LEFT", target, "RIGHT", 4, 0)
    end
end

-- Places whatever ComposeLabel decided. Sole owner of showing/hiding the
-- centre FontStrings -- ComposeLabel only writes text.
function ns.PlaceBarLabel(bar, anchorTo)
    -- With the class art hanging OUTSIDE the card (legacy, sharp), the
    -- composition's optical centre moves left; the centre text follows by
    -- half the art footprint so it stays centred on the WHOLE, not the card.
    local artShift = -(ns.RevealArtOffset and ns.RevealArtOffset(bar) or 0) / 2
    if bar._activeLabel == "both" then
        -- Equal gaps above, between and below, so the pair sits optically
        -- centred at any bar height.
        local h   = bar._fxBarH or 32
        local h1  = bar._textSize or 12
        local h2  = bar._bothSpecSize or math.max(6, h1 - ns.BOTH_SPEC_SIZE_DELTA)
        local gap = math.max(0, (h - h1 - h2) / 3)
        local rows = {
            { bar.nameText, gap,            h1 },
            { bar.specText, gap + h1 + gap, h2 },
        }
        for _, row in ipairs(rows) do
            local fs, top, hh = row[1], row[2], row[3]
            fs:ClearAllPoints()
            fs:SetJustifyH("CENTER")
            fs:SetPoint("CENTER", anchorTo, "CENTER", artShift, h / 2 - (top + hh / 2))
            fs:Show()
        end
    else
        ns.PlaceLabelSlot(bar.nameText, bar._activeLabel == "name", anchorTo, artShift)
        ns.PlaceLabelSlot(bar.specText, bar._activeLabel == "spec", anchorTo, artShift)
    end

    if bar._artStatus and bar.classArt then
        -- Anchored to the BAR's left side, at the spot the word occupies when
        -- the inset (polygon2) art is active -- computed from that art's
        -- geometry rather than from whatever art is actually shown, so the
        -- word sits identically across all four styles. Anchoring to the art
        -- itself put the word OUTSIDE the card for the outside styles.
        local h  = bar._fxBarH or 32
        local w2 = math.min((h - 2) * CLASS_ART_SCALE * CLASS_ART_ASPECT,
                            (bar._fxBarW or 300) * 0.45)
        local cx = CLASS_ART_INSET + w2 / 2 + w2 * ns.ART_STATUS_X + 5
        bar.statusText:ClearAllPoints()
        bar.statusText:SetJustifyH("CENTER")
        bar.statusText:SetPoint("CENTER", bar.healthBar, "LEFT", cx, 0)
        bar.statusText:Show()

        -- Sized from the rendered word. GetStringWidth is safe here where it
        -- is not for the name: this string is ours, out of ns.STATUS_LABELS,
        -- never a secret from the API.
        if bar.artStatusBack then
            local sz = bar._artStatusSize or 12
            local w  = bar.statusText:GetStringWidth() or 0
            bar.artStatusBack:SetSize(math.max(1, w + sz * ns.ART_STATUS_BACK_PAD),
                                      math.max(1, sz * ns.ART_STATUS_BACK_H))
            bar.artStatusBack:SetAlpha(ns.ART_STATUS_BACK_A)
            bar.artStatusBack:ClearAllPoints()
            bar.artStatusBack:SetPoint("CENTER", bar.statusText, "CENTER", 0, 0)
            bar.artStatusBack:Show()

            if bar.artStatusGlow then
                local pad = ns.ART_STATUS_GLOW_PAD
                local c   = ns.ART_STATUS_GLOW_COLOR
                bar.artStatusGlow:SetSize(
                    bar.artStatusBack:GetWidth()  * ns.ART_STATUS_GLOW_CORE_W + pad * 2,
                    bar.artStatusBack:GetHeight() * ns.ART_STATUS_GLOW_CORE_H + pad * 2)
                bar.artStatusGlow:ClearAllPoints()
                bar.artStatusGlow:SetPoint("CENTER", bar.artStatusBack, "CENTER", 0, 0)
                bar.artStatusGlow:SetVertexColor(c[1], c[2], c[3], c[4])
                bar.artStatusGlow:Show()
            end
        end
    else
        if bar.artStatusBack then bar.artStatusBack:Hide() end
        if bar.artStatusGlow then bar.artStatusGlow:Hide() end
        ns.PlaceLabelSlot(bar.statusText, bar._activeLabel == "status", anchorTo, artShift)
    end

    -- The marker rides whichever label is live, so it re-hangs whenever the
    -- labels are re-placed -- switching Text Display, or holding the peek key.
    if bar._stateArtSlot == "label" and bar.stateMarker then
        ns.AnchorToSlot(bar.stateMarker, bar, ns.ART_SLOT)
    end
end

function ns.AnchorBarText(bar)
    ns.PlaceBarLabel(bar, bar.healthBar)
end

-- Writes the centre text for one bar from live data.
function ApplyBarNameText(bar, mode)
    local name = bar.assignedName
    if not name then return end
    ns.ComposeLabel(bar, mode, name, GetBarSpec(bar), GetBarStatus(bar))
end

-- Per-tick refresh: specs arrive from the scoreboard scan after bars are
-- built, and bars only rebuild on roster change, so the label is rewritten
-- each tick. Cheap -- a few SetText calls per active bar.
function RefreshSpecText(fs)
    if not fs or (fs.active or 0) == 0 then return end
    local mode = fs.g.textDisplay and fs.g.textDisplay() or "name"
    for i = 1, fs.active do
        local bar = fs.bars[i]
        if bar and bar.assignedName then
            local was, wasArt = bar._activeLabel, bar._artStatus
            ApplyBarNameText(bar, mode)
            -- Re-anchor when the active element changed OR when the stamp
            -- appeared/cleared -- the stamp does not change _activeLabel, so
            -- keying on that alone would leave it unplaced and hidden.
            if bar._activeLabel ~= was or bar._artStatus ~= wasArt then
                ns.AnchorBarText(bar)
            end
        end
    end
end

-- Peek key handler, driven by the binding in Bindings.xml (which fires on both
-- press and release). Refreshes immediately rather than waiting for the next
-- ticker pass, so holding the key feels instant instead of up to 100ms late.
function ns.SetPeek(down)
    down = down and true or false
    if ns.peekActive == down then return end
    ns.peekActive = down
    RefreshPreviews()
    if ns.testModeActive then
        if ns.RefreshTestBars then ns.RefreshTestBars() end
        return
    end
    RefreshSpecText(activeEnemy)
    RefreshSpecText(activeFriendly)
end

-------------------------------------------------------------------------------
-- Class art
-------------------------------------------------------------------------------
-- Single owner of class-art visibility. The art has two independent reasons to
-- be hidden -- the Hide Class Art option, and having no art for that class --
-- and letting each caller decide produced ordering bugs where one turned it
-- back on after the other had turned it off.
function ApplyClassArtVisibility(bar)
    for _, tex in ipairs({ bar.classArt, bar.classArtShadow, bar.classArtColor }) do
        if tex then tex:SetShown(tex._wantShown and not bar._hideClassArt or false) end
    end
end

-- Paints the class art and the trough for one bar's state. Shared by live
-- bars, test mode and the previews so all three render a state identically.
function ns.ApplyBarClassVisual(bar, classToken, suppressed, isStealth)
    local art = classToken and ns.GetClassArt(classToken)
    local state = suppressed and (isStealth and ART_STATE.stealth or ART_STATE.dead)
                  or ART_STATE.normal

    local cx0, cx1, cy0, cy1 = ns.GetClassArtTexCoord()
    -- Stealth flattens the art to one hue so the fog above it has a constant
    -- substrate. Both the art and its shadow, or the shadow keeps the class
    -- colour and fringes the tinted art with it.
    local tintArt = suppressed and isStealth and ns.STEALTH_TINT_ART
    for _, tex in ipairs({ bar.classArt, bar.classArtShadow }) do
        if tex then
            tex._wantShown = (art ~= nil)
            if art then
                tex:SetTexture(art)
                tex:SetTexCoord(cx0, cx1, cy0, cy1)
                if tintArt then
                    tex:SetDesaturated(true)
                    tex:SetVertexColor(ns.STEALTH_TINT.r, ns.STEALTH_TINT.g, ns.STEALTH_TINT.b)
                else
                    tex:SetDesaturated(state.desat)
                    -- Always reset: a bar leaving stealth would otherwise keep
                    -- the tint for the rest of its life.
                    tex:SetVertexColor(1, 1, 1)
                end
            end
        end
    end
    if bar.classArt then bar.classArt:SetAlpha(state.alpha) end
    if bar.classArtShadow then
        -- The loop above retextured the shadow along with the art and left it
        -- white; it is a black SILHOUETTE (June's construction), so re-assert
        -- black here. Alpha is per-style: 0 except for sharp.
        bar.classArtShadow:SetVertexColor(0, 0, 0)
        bar.classArtShadow:SetAlpha(ClassArtShadowAlpha())
    end
    if bar.classArtColor then bar.classArtColor._wantShown = false end
    ApplyClassArtVisibility(bar)

    -- Trough and its ghost end-cap. The cap gradients the SAME colour down to
    -- black over the last 20px, so the lost-health region ends by darkening
    -- rather than by stopping.
    -- Stealth overrides the class colour outright -- see ns.STEALTH_TINT
    -- (rogue yellow): the trough is what the fog blends against, and one
    -- shared hue is what makes "stealthed" read before "which class". At the
    -- NORMAL trough value though, not dead-dim -- a stealther is alive.
    local cc = RAID_CLASS_COLORS[classToken] or { r = 0.5, g = 0.5, b = 0.5 }
    if suppressed and isStealth then cc = ns.STEALTH_TINT end
    local v  = (suppressed and not isStealth) and ns.TROUGH_VALUE_DEAD or ns.TROUGH_VALUE

    -- The fill ghosts a step under the fog; both ways so leaving stealth
    -- restores it. Applied here because this runs for live, test and preview
    -- bars alike -- one writer, three consumers.
    local fillTex = bar.healthBar and bar.healthBar.GetStatusBarTexture
                    and bar.healthBar:GetStatusBarTexture()
    if fillTex then
        fillTex:SetAlpha((suppressed and isStealth) and ns.STEALTH_FILL_ALPHA or 1)
    end
    local a  = ns.TROUGH_ALPHA

    -- Dead GREYSCALES the class trough rather than dropping to an absolute
    -- near-black (2026-08-19, same language as the art: drained of colour,
    -- only slightly faded). The luminance keeps each class's trough at its
    -- own brightness -- identity survives as tone -- while the missing hue is
    -- what says dead. DEAD_TROUGH_PRESENCE is the slight fade on top.
    --
    -- Stealth keeps a colour outright, because its fog needs something to
    -- blend against. Dead has no fog.
    local tr, tg, tb
    if suppressed and not isStealth then
        -- ns.TROUGH_VALUE, not v: the greyscale is of the ALIVE trough's
        -- brightness, with PRESENCE as the only fade -- v here is the old
        -- dead-dim value and using it would fade twice
        local lum = (0.30 * cc.r + 0.59 * cc.g + 0.11 * cc.b)
                    * ns.TROUGH_VALUE * ns.DEAD_TROUGH_PRESENCE
        tr, tg, tb = lum, lum, lum
    else
        tr, tg, tb = cc.r * v, cc.g * v, cc.b * v
    end

    if bar.healthBg then
        bar.healthBg:SetColorTexture(tr, tg, tb, a)
    end
    if bar.healthBgCap then
        local f = ns.TROUGH_RAMP_FALLOFF
        bar.healthBgCap:SetGradient("HORIZONTAL",
            CreateColor(tr,     tg,     tb,     a),
            CreateColor(tr * f, tg * f, tb * f, a))
        bar.healthBgCap:Show()
    end
    if bar.healthBar then bar.healthBar:SetStatusBarColor(cc.r, cc.g, cc.b) end
end

-- Objective icon size, derived from bar height so it tracks the slider.
function ns.SizeCarrierIcon(bar)
    local d = math.max(1, (bar._fxBarH or 32) * ns.ICON_SCALE)
    if bar.pvpIconFrame then bar.pvpIconFrame:SetSize(d, d) end
    if bar.pvpIcon then bar.pvpIcon:SetSize(d, d) end
    -- The halo is what makes the ring: it is drawn LARGER than the icon, so only
    -- the overhang shows. It was pinned at 48px from creation, so at any other
    -- bar height the ring was the wrong width relative to the icon it rings.
    -- Textures are not clipped by their parent frame, so exceeding pvpIconFrame
    -- is fine and is the point.
    if bar.pvpIconGlow then
        local gd = d * (ns.ICON_GLOW_SCALE or 1.5)
        bar.pvpIconGlow:SetSize(gd, gd)
    end
    if bar.pvpIconShadow then
        bar.pvpIconShadow:SetSize(d, d)
        bar.pvpIconShadow:ClearAllPoints()
        bar.pvpIconShadow:SetPoint("CENTER", bar.pvpIconFrame, "CENTER", 1, -1)
    end
end

-------------------------------------------------------------------------------
-- Layout
-------------------------------------------------------------------------------
-- Sole owner of a bar's geometry. Called from ApplySizing for live bars and
-- directly by the previews, so the two cannot drift.
function ns.ApplyBarLayout(bar, barWidth, barHeight, textSize, powerShown, hideClassArt)
    bar._fxBarW, bar._fxBarH = barWidth, barHeight
    bar._textSize      = textSize
    bar._hideClassArt  = hideClassArt and true or false

    bar:SetSize(barWidth, barHeight)
    if bar.visual then
        bar.visual:SetSize(barWidth, barHeight)
        bar.visual:ClearAllPoints()
        bar.visual:SetPoint("CENTER", bar, "CENTER", 0, 0)
    end

    -- Fill texture is a global setting, applied here so live bars, test mode
    -- and the previews all pick up a change without a rebuild. Only re-set
    -- when it actually changes: SetStatusBarTexture hands back a NEW texture
    -- object, and the masks are attached to the old one.
    local fill = ns.GetBarFillTexture()
    if bar.healthBar and bar._fillTex ~= fill then
        bar.healthBar:SetStatusBarTexture(fill)
        bar._fillTex = fill
        bar._roundActive, bar._featherActive = nil, nil
    end

    -- Edge treatment. Built lazily against the CURRENT fill texture, because
    -- SetStatusBarTexture hands back a new object and a mask attached to the
    -- old one is simply lost.
    local fillTex = bar.healthBar and bar.healthBar:GetStatusBarTexture()
    if fillTex then
        -- Feathered fill end: an 8px strip on the right of the FILL, so the
        -- colour melts to transparent instead of stopping on a hard edge.
        -- CLAMPTOWHITE, not additive -- outside the strip the mask must read
        -- as fully opaque or the whole fill disappears.
        if not bar.edgeFeather then
            local feather = bar.healthBar:CreateMaskTexture()
            feather:SetTexture("Interface\\AddOns\\BlitzFramesTargets\\Textures\\BarEdgeFeather.tga",
                "CLAMPTOWHITE", "CLAMPTOWHITE")
            feather:SetWidth(8)
            feather:SetPoint("TOPRIGHT",    fillTex, "TOPRIGHT",    0, 0)
            feather:SetPoint("BOTTOMRIGHT", fillTex, "BOTTOMRIGHT", 0, 0)
            bar.edgeFeather = feather
        end
        if not bar._featherActive then
            fillTex:AddMaskTexture(bar.edgeFeather)
            bar._featherActive = true
        end

        -- The death drain's ghost StatusBar: created and kept sized here,
        -- where the health bar's plain geometry is in hand
        ns.EnsureDrainGhost(bar)


        -- Rounded silhouette: one mask per FRAME, both sized to bar.visual.
        -- Masks silently fail on textures belonging to a different frame,
        -- which is why there are two identical ones rather than one shared.
        if not bar.roundMaskV then
            local function MakeRoundMask(parent)
                local m = parent:CreateMaskTexture()
                m:SetTexture("Interface\\AddOns\\BlitzFramesTargets\\Textures\\BarRoundMask.tga",
                    "CLAMPTOBLACKADDITIVE", "CLAMPTOBLACKADDITIVE")
                m:SetAllPoints(bar.visual)
                return m
            end
            bar.roundMaskV = MakeRoundMask(bar.visual)
            bar.roundMaskH = MakeRoundMask(bar.healthBar)
        end
        if not bar._roundActive then
            bar.visualBg:AddMaskTexture(bar.roundMaskV)
            bar.visualSheen:AddMaskTexture(bar.roundMaskV)
            bar.healthBg:AddMaskTexture(bar.roundMaskH)
            bar.healthBgCap:AddMaskTexture(bar.roundMaskH)
            fillTex:AddMaskTexture(bar.roundMaskH)
            bar._roundActive = true
        end
    end

    -- OVERLAY, not a split (2026-08-20). The health bar always fills the card
    -- and the power strip draws ON TOP of its bottom edge, so toggling Power
    -- Bars cannot change the shape of anything -- the health bar, the class
    -- art, the reveal offsets and the heading all stay put. It used to give
    -- the strip its own 4px band (healthH = barHeight - POWER_BAR_H), which
    -- meant every one of those moved when the option flipped.
    --
    -- It also simplifies the corners: the health bar is now exactly
    -- bar.visual, so roundMaskH (SetAllPoints(bar.visual)) rounds all four of
    -- its corners, and BarPowerMask keeps rounding the strip's own.
    if bar.healthBar then
        bar.healthBar:ClearAllPoints()
        bar.healthBar:SetPoint("TOPLEFT", bar.visual, "TOPLEFT", 0, 0)
        bar.healthBar:SetSize(barWidth, barHeight)
    end
    if bar.powerBar then
        bar.powerBar:ClearAllPoints()
        -- Full width, flush to the card's bottom edge. A horizontal inset was
        -- tried and read worse: the strip is only 4px tall, so pulling its ends
        -- in made it look like a detached sliver rather than part of the card.
        -- Full width also lines its rounded corners up with the card's own,
        -- which BarPowerMask is cut to match exactly.
        bar.powerBar:SetPoint("BOTTOMLEFT", bar.visual, "BOTTOMLEFT", 0, 0)
        bar.powerBar:SetSize(barWidth, POWER_BAR_H)
        bar.powerBar:SetShown(powerShown and true or false)
    end

    -- Lost-health region: a flat class-tinted trough, then a ghost end-cap
    -- over the last 20px where the COLOUR itself slides to black. A value
    -- ramp, not an opacity fade -- one material changing shade, rather than
    -- two layers crossfading. The 20px is fixed, not proportional: it is an
    -- edge treatment, so it should not grow with the bar.
    local rampW = math.max(2, barWidth * ns.TROUGH_RAMP_FRACTION)
    if bar.healthBg then
        bar.healthBg:ClearAllPoints()
        bar.healthBg:SetPoint("TOPLEFT",     bar.healthBar, "TOPLEFT",      0, 0)
        bar.healthBg:SetPoint("BOTTOMRIGHT", bar.healthBar, "BOTTOMRIGHT", -rampW, 0)
    end
    if bar.healthBgCap then
        bar.healthBgCap:ClearAllPoints()
        bar.healthBgCap:SetPoint("TOPRIGHT",   bar.healthBar, "TOPRIGHT",    0, 0)
        bar.healthBgCap:SetPoint("BOTTOMLEFT", bar.healthBar, "BOTTOMRIGHT", -rampW, 0)
        bar.healthBgCap:Show()
    end

    -- Class art: landscape, anchored to the left edge and bleeding off it. The
    -- aspect comes from the artwork set rather than the constant, so the square
    -- Blizzard icons stay square when the comparison toggle is on.
    --
    -- Placement splits by WHERE the style lives, not which style it is.
    -- Polygon 2 is a bleed designed to sit INSIDE the card, inset from its
    -- left edge. Legacy, sharp and polygon are not part of the card at all:
    -- they hang OUTSIDE it, off the left edge, so the card keeps its width
    -- for the bar and text. Width comes from each set's own aspect.
    local artStyle = ns.GetClassArtStyle and ns.GetClassArtStyle() or "polygon"
    local artOutside = artStyle ~= "polygon2"

    -- WHAT THE ART IS A FEATURE OF, which is what decides its height and its
    -- anchor. OUTSIDE art belongs to the CARD: it sizes on barHeight and
    -- hangs off bar.visual, so switching Power Bars on cannot touch it. It
    -- used to size and anchor on the HEALTH BAR, which surrenders POWER_BAR_H
    -- to the strip -- so enabling power bars shrank the icon AND slid it
    -- upward, since the health bar's centre is no longer the card's centre.
    --
    -- The INSIDE bleed is a feature of the health region: it is masked to it
    -- (roundMaskA is SetAllPoints(healthBar)) and fills it edge to edge, so
    -- it stays anchored there. Since the power strip became an overlay the
    -- two heights are identical anyway -- the distinction is kept because it
    -- is the anchor that carries the meaning, not the number.
    local artAnchor = artOutside and bar.visual or bar.healthBar
    local artH      = barHeight

    -- Width is derived SEPARATELY from the height for the inside set, because
    -- it is already correct and deriving it from the filled height would drag
    -- it ~5px wider. The redrawn art keeps the inset basis it was tuned
    -- against; the outside sets take their width from the card height, so
    -- Blizzard's icons stay genuinely square.
    local artBase
    if artOutside then
        -- Polygon runs slightly proud of the card -- its bleed reads better
        -- with a little overhang. (June's full 1.12 pop on sharp read as
        -- misalignment.)
        if artStyle == "polygon" then
            artH = artH * CLASS_ART_POLY_POP
        end
        artBase = artH
    else
        artBase = (barHeight - 2) * CLASS_ART_SCALE
    end
    local artW = math.min(artBase * ns.GetClassArtAspect(), barWidth * 0.45)
    local artPoint, artRel, artX
    if artOutside then
        artPoint, artRel = "RIGHT", "LEFT"
        if artStyle == "legacy" then
            artX = -CLASS_ICON_GAP
        else
            -- Sharp and outside-polygon both fade to transparent on their
            -- right side, so edge-based anchoring always reads adrift.
            -- Instead the art's LEFT edge lands exactly where the legacy
            -- icon's left edge sits (one icon-height plus the gap, left of
            -- the card); the fade's spill over the card follows from that,
            -- June-style.
            artX = artW - (barHeight + CLASS_ICON_GAP)
            if artStyle == "polygon" then
                -- Bandaid until the polygon art is redrawn for the outside
                -- slot: nudge it left so its content clears the card edge.
                artX = artX - CLASS_ART_POLY_NUDGE
            end
        end
    else
        artPoint, artRel, artX = "LEFT", "LEFT", CLASS_ART_INSET
    end
    if bar.classArt then
        bar.classArt:SetSize(artW, artH)
        bar.classArt:ClearAllPoints()
        bar.classArt:SetPoint(artPoint, artAnchor, artRel, artX, 0)
    end
    if bar.classArtShadow then
        bar.classArtShadow:SetSize(artW, artH)
        bar.classArtShadow:ClearAllPoints()
        -- Sharp casts June's offset shadow; polygon's silhouette is CENTRED,
        -- a backdrop rather than a shadow (see CLASS_ART_POLY_BACKDROP)
        if artStyle == "polygon" then
            bar.classArtShadow:SetPoint(artPoint, artAnchor, artRel, artX, 0)
        else
            bar.classArtShadow:SetPoint(artPoint, artAnchor, artRel, artX + 2, -2)
        end
    end
    if bar.classArtColor then
        bar.classArtColor:SetSize(artW, artH)
        bar.classArtColor:ClearAllPoints()
        bar.classArtColor:SetPoint(artPoint, artAnchor, artRel, artX, 0)
    end
    -- The card's round mask rides the art only while the art is INSIDE the
    -- card. A mask anchored to the card renders everything outside it as
    -- transparent, so outside art (legacy, sharp) would simply vanish.
    -- Toggled here rather than at creation because the setting can flip live.
    if bar.roundMaskA and bar._artMasked ~= (not artOutside) then
        bar._artMasked = not artOutside
        for _, tex in ipairs({ bar.classArt, bar.classArtShadow }) do
            if artOutside then
                tex:RemoveMaskTexture(bar.roundMaskA)
            else
                tex:AddMaskTexture(bar.roundMaskA)
            end
        end
    end
    ApplyClassArtVisibility(bar)

    -- Centre text. statusText is deliberately excluded: SetArtStatus owns its
    -- font at a scaled size, and re-applying the plain name font here would
    -- undo that on every layout pass.
    if bar.nameText then bar.nameText:SetFont(ns.LOCALE_FONT, textSize, "") end
    if bar.specText then bar.specText:SetFont(ns.LOCALE_FONT, ns.SpecFontSize(bar, textSize), "") end

    ns.SizeCarrierIcon(bar)
    ns.PlaceBarLabel(bar, bar.healthBar)
end

-- Sole owner of the state DECORATION -- the stealth mist and the death marker.
-- Split out so test mode and live cannot disagree: test mode never called the
-- suppression pass, so it was getting the class tint and none of this.
--
-- `hasCarrier` suppresses the death marker: the objective slot holds one thing
-- at a time, and a carrier icon outranks a corpse.
function ns.ApplyBarStateArt(bar, status, hasCarrier)
    -- Requested, not applied: the driver ramps toward this over MIST_FADE_TIME
    -- so stealth does not pop in and out.
    bar._mistWant = (status == "Stealth") and 1 or 0

    if bar.stateArt then
        -- The drain plays FIRST; the wraith enters when it lands. Both at
        -- once read as two unrelated effects fighting over the moment.
        if status == "Dead" and not hasCarrier
           and not (ns.IsDeathDraining and ns.IsDeathDraining(bar)) then
            local d = math.max(1, (bar._fxBarH or 32) * ns.ICON_SCALE)
            bar.stateArt:SetTexture(ns.DEAD_STATE_TEX)
            bar.stateArt:SetSize(d, d)
            bar.stateArt:ClearAllPoints()
            bar.stateArt:SetPoint("RIGHT", bar.healthBar, "RIGHT", -3, 0)
            bar.stateArt:SetVertexColor(1, 1, 1, 1)
            bar.stateArt:Show()
            if bar.stateArtGlow then
                local gd = d * ns.ICON_GLOW_SCALE
                bar.stateArtGlow:SetSize(gd, gd)
                bar.stateArtGlow:ClearAllPoints()
                bar.stateArtGlow:SetPoint("CENTER", bar.stateArt, "CENTER")
                bar.stateArtGlow:Show()
                if bar.stateArtGlow.animGroup and not bar.stateArtGlow.animGroup:IsPlaying() then
                    bar.stateArtGlow.animGroup:Play()
                end
            end
        else
            bar.stateArt:Hide()
            if bar.stateArtGlow then
                bar.stateArtGlow:Hide()
                if bar.stateArtGlow.animGroup and bar.stateArtGlow.animGroup:IsPlaying() then
                    bar.stateArtGlow.animGroup:Stop()
                end
            end
        end
    end
end

-- Every bar with mist, live or preview. Registered at creation rather than
-- walked from frameSets, because the settings previews build their bars the
-- same way but are not part of any frame set.
ns.mistBars = ns.mistBars or {}

-- Drives the mist for every bar from one OnUpdate rather than one each. The
-- tiles are REPEAT-wrapped, so both the drift and the tiling are texcoords.
local mistDriver = CreateFrame("Frame")
local mistT = 0

function DriveMist(bar, dt)
    local layers = bar.fxLayers
    if not layers or not layers[1] then return end

    -- Ramp toward the requested state.
    local want = bar._mistWant or 0
    local cur  = bar._mistFade or 0
    if cur ~= want then
        local step = dt / ns.MIST_FADE_TIME
        cur = (want > cur) and math.min(want, cur + step) or math.max(want, cur - step)
        bar._mistFade = cur
    end
    if cur <= 0 then
        for _, m in ipairs(layers) do m:Hide() end
        return
    end

    -- One breath applied to both layers, so they swell together.
    local pulse = 1 + math.sin(mistT * ns.MIST_PULSE_RATE * 2 * math.pi) * ns.MIST_PULSE_DEPTH
    for _, m in ipairs(layers) do
        local L = m._layer
        -- Tile height is a fraction of card height, so the fog keeps its scale
        -- as the bar height slider moves; the horizontal repeat follows from
        -- the aspect so tiles stay square rather than smearing.
        local rep  = 1 / (ns.MIST_TILE_SCALE * L.scale)
        -- ASPECT TERM, PARTIALLY APPLIED. The tile is square and the card is not
        -- (275x35, nearly 8:1), so mapping the same UV span to both axes smears
        -- the fog by the full aspect. `repU, repV = rep, rep` did exactly that
        -- and is what the rebuild left behind.
        --
        -- Correcting it fully would be wrong too: ns.MIST_STRETCH blends between
        -- square (0) and fully smeared (1), and 0.45 is the value Core.lua
        -- carried before the loss. repV covers the height in `rep` repeats, so a
        -- tile is H * MIST_TILE_SCALE * L.scale tall; the width is that times the
        -- stretch factor, which needs rep * aspect / stretch repeats across.
        local w = bar._fxBarW or 300
        local h = bar._fxBarH or 32
        local aspect  = w / math.max(1, h)
        local stretch = 1 + (aspect - 1) * ns.MIST_STRETCH
        local repU, repV = rep * aspect / stretch, rep
        local u = (mistT * L.du) % 1
        local v = (mistT * L.dv) % 1
        m:SetTexCoord(u, u + repU, v, v + repV)
        m:SetAlpha(L.alpha * pulse * cur)
        m:Show()
    end
end

mistDriver:SetScript("OnUpdate", function(_, elapsed)
    mistT = mistT + elapsed
    for i = 1, #ns.mistBars do
        DriveMist(ns.mistBars[i], elapsed)
    end
end)

-- BAR LOAD-IN CASCADE. When the waiting panel hands off to real bars, each
-- bar swipes in from the side while the stagger walks down the column and
-- everything fades up -- one diagonal wave instead of a blink. Animates
-- bar.visual ONLY (position offset + alpha), which is the non-secure child
-- the hover scaling already moves in combat, so this is lockdown-safe.
-- Stepping ratio: the stagger sits near half the flight, so each row reads
-- as its own entrance -- a staircase -- rather than smearing into one wave
-- (0.06 against 0.30 did exactly that). Motion is purely horizontal; the
-- vertical dimension is carried by the top-down ORDER of the fades alone.
ns.LOADIN_TIME    = 0.20   -- one bar's flight
ns.LOADIN_STAGGER = 0.09   -- per-row delay
ns.LOADIN_SLIDE_X = -40    -- horizontal slide-in distance
ns.LOADIN_SLIDE_Y = 0

local loadinActive = {}
local loadinDriver = CreateFrame("Frame")

local function FinishLoadin(fs)
    for i = 1, fs.maxBars do
        local bar = fs.bars[i]
        if bar then
            if bar.visual then
                -- ClearAllPoints first, exactly as ApplyBarLayout anchors it,
                -- so the rest position can never be a leftover point
                bar.visual:ClearAllPoints()
                bar.visual:SetPoint("CENTER", bar, "CENTER", 0, 0)
                bar.visual:SetAlpha(1)
            end
            bar._loadingIn = nil
        end
    end
    loadinActive[fs] = nil
end
ns.CancelBarLoadin = FinishLoadin   -- LeaveMatch mid-flight must not strand offsets

local function DriveLoadin(_, elapsed)
    for fs in pairs(loadinActive) do
        fs._loadinT = fs._loadinT + elapsed
        local t = fs._loadinT
        local done = true
        for i = 1, fs.active or 0 do
            local bar = fs.bars[i]
            local v   = bar and bar.visual
            if v then
                local p = (t - (i - 1) * ns.LOADIN_STAGGER) / ns.LOADIN_TIME
                if p < 1 then done = false end
                p = math.min(math.max(p, 0), 1)
                local e = 1 - (1 - p) * (1 - p)  -- ease-out: arrives, never slams
                v:SetPoint("CENTER", bar, "CENTER",
                    ns.LOADIN_SLIDE_X * (1 - e), ns.LOADIN_SLIDE_Y * (1 - e))
                v:SetAlpha(e)
            end
        end
        if done then FinishLoadin(fs) end
    end
    if not next(loadinActive) then loadinDriver:SetScript("OnUpdate", nil) end
end

function ns.StartBarLoadin(fs)
    if not fs or (fs.active or 0) == 0 then return end
    fs._loadinT = 0
    for i = 1, fs.active do
        local bar = fs.bars[i]
        if bar and bar.visual then
            bar.visual:SetAlpha(0)
            -- ApplyRangeFade yields while a bar is flying; the flag clears in
            -- FinishLoadin and the next range tick reasserts the true fade
            bar._loadingIn = true
        end
    end
    loadinActive[fs] = true
    loadinDriver:SetScript("OnUpdate", DriveLoadin)
end

-- DEATH DRAIN. When a bar first goes dead, a red copy of the health fill
-- HOLDS at the last-known level for a beat, then slingshots down to zero --
-- the real fill snaps instantly (rule 1), the ghost is what the eye follows.
--
-- SECRET-SAFE BY CONSTRUCTION, second design. The first captured the fill
-- texture's rendered width at the death frame -- and GetWidth on a
-- secret-driven fill returns a SECRET number, which threw on the first live
-- death exactly as the fail-loud policy intends. This version reads nothing:
--
--   * The ghost is a second StatusBar. RenderEnemyHealth FEEDS it the same
--     cached secret values it gives the real bar (SetValue accepts secrets;
--     feeding is legal, reading never happens). It stays hidden, so this
--     costs two native calls per bar per tick.
--   * On death the real bar zeroes and the ghost simply STOPS BEING FED --
--     it keeps drawing the last-known amount on its own.
--   * The sweep animates the ghost's FRAME WIDTH -- our own plain number --
--     from full to zero. Its fill is (secret fraction) x (frame width), so
--     the drawn bar sweeps to nothing without the fraction ever existing in
--     Lua. Every drain completes in the same wall-clock time regardless of
--     how much health it started with, which is the slingshot read anyway.
ns.DEATH_DRAIN_ENABLED = true
ns.DEATH_DRAIN_HOLD  = 0.35   -- seconds the ghost lingers at the old level
-- Re-arm window. The dead flag can FLICKER around a corpse (a token reads
-- them momentarily alive mid-transition), and that one alive tick feeds the
-- ghost the respawn seed -- FULL -- so the re-fired drain flashed the whole
-- bar red. A real death -> respawn -> death cycle takes far longer than
-- this, so a re-trigger inside the window is always the flicker.
ns.DEATH_DRAIN_REARM = 3.0
ns.DEATH_DRAIN_TIME  = 0.28   -- the catch-up sweep, fast and accelerating
-- Tail and leading-edge tints: dim at the tail, hot at the receding edge,
-- which is what points the motion.
ns.DEATH_DRAIN_TAIL = { 0.55, 0.06, 0.04, 0.25 }
ns.DEATH_DRAIN_EDGE = { 1.00, 0.28, 0.16, 0.85 }

local deathDrainActive = {}
local deathDrainDriver = CreateFrame("Frame")

-- Created (and resized) from ApplyBarLayout's fill block, where the health
-- bar's plain geometry is in hand. Hidden except while draining.
function ns.EnsureDrainGhost(bar)
    local hb = bar.healthBar
    local g = bar.drainGhost
    if not g then
        g = CreateFrame("StatusBar", nil, hb)
        g:SetFrameLevel(hb:GetFrameLevel() + 1)
        g:Hide()
        bar.drainGhost = g
    end
    g:SetStatusBarTexture(ns.GetBarFillTexture and ns.GetBarFillTexture()
                          or "Interface\\Buttons\\WHITE8x8")
    local tex = g:GetStatusBarTexture()
    tex:SetSnapToPixelGrid(false)
    tex:SetTexelSnappingBias(0)
    -- Its own round mask: masks silently fail across frames, and this ghost
    -- is its own StatusBar -- the card's masks cannot reach it
    if not g.roundMask then
        local m = g:CreateMaskTexture()
        m:SetTexture("Interface\\AddOns\\BlitzFramesTargets\\Textures\\BarRoundMask.tga",
            "CLAMPTOBLACKADDITIVE", "CLAMPTOBLACKADDITIVE")
        m:SetAllPoints(bar.visual)
        g.roundMask = m
    end
    if not g._masked then
        tex:AddMaskTexture(g.roundMask)
        g._masked = true
    end
    local tc, ec = ns.DEATH_DRAIN_TAIL, ns.DEATH_DRAIN_EDGE
    tex:SetGradient("HORIZONTAL",
        CreateColor(tc[1], tc[2], tc[3], tc[4]),
        CreateColor(ec[1], ec[2], ec[3], ec[4]))
    g:ClearAllPoints()
    g:SetPoint("TOPLEFT", hb, "TOPLEFT", 0, 0)
    g:SetPoint("BOTTOMLEFT", hb, "BOTTOMLEFT", 0, 0)
    g:SetWidth(hb:GetWidth())   -- OUR frame's width: plain, laid out by us
end

local function StopDrain(bar, handOff)
    if bar.drainGhost then
        bar.drainGhost:Hide()
        if bar.healthBar then bar.drainGhost:SetWidth(bar.healthBar:GetWidth()) end
    end
    deathDrainActive[bar] = nil
    -- The wraith waits for the drain (ApplyBarStateArt skips it while
    -- draining). Live bars re-apply on the next suppression tick; test mode
    -- has no tick, so hand it the marker here.
    if handOff and bar._testDead then ns.ApplyBarStateArt(bar, "Dead", nil) end
end

local function DriveDeathDrain(_, elapsed)
    local any = false
    for bar in pairs(deathDrainActive) do
        local g = bar.drainGhost
        local tt = (bar._drainClock or 0) + elapsed
        bar._drainClock = tt
        -- Hold phase: the ghost just sits at the old level
        local t = math.max(0, tt - ns.DEATH_DRAIN_HOLD) / ns.DEATH_DRAIN_TIME
        if t >= 1 or not g then
            StopDrain(bar, true)
        else
            -- slingshot: cubic ease-in, slow out of the hold and
            -- accelerating hard into zero
            local e = (1 - t)
            local w = (bar._drainW or 0) * e * e * e
            if w < 1 then
                StopDrain(bar, true)
            else
                g:SetWidth(w)
                g:SetAlpha(1 - t * t)   -- thins out as it empties
                any = true
            end
        end
    end
    if not any then deathDrainDriver:SetScript("OnUpdate", nil) end
end

function ns.StartDeathDrain(bar)
    if not ns.DEATH_DRAIN_ENABLED then return end
    local g = bar.drainGhost
    if not g or not bar.healthBar then return end
    local now = GetTime()
    if now - (bar._lastDrainAt or -math.huge) < ns.DEATH_DRAIN_REARM then return end
    bar._lastDrainAt = now
    bar._drainW = bar.healthBar:GetWidth()
    bar._drainClock = 0
    g:SetWidth(bar._drainW)
    g:SetAlpha(1)
    g:Show()
    deathDrainActive[bar] = true
    deathDrainDriver:SetScript("OnUpdate", DriveDeathDrain)
end

function ns.IsDeathDraining(bar) return deathDrainActive[bar] and true or false end

function ns.StopDeathDrain(bar) StopDrain(bar, false) end

-- Suppressed-bar effects. Stealth paints the whole card with drifting mist;
-- dead simply drains. Derived here rather than by each caller so a bar going
-- straight from Dead to Stealth stays suppressed the whole time.
function RenderBarSuppression(fs)
    if not fs or (fs.active or 0) == 0 then return end
    for i = 1, fs.active do
        local bar = fs.bars[i]
        local entry = bar and bar.fullName and ns.tracker[bar.fullName]
        local status = bar and GetBarStatus(bar)
        local suppressed = (status == "Dead" or status == "Stealth")
        if bar then
            ns.ApplyBarClassVisual(bar, bar._classToken, suppressed, status == "Stealth")
            -- No health write here. RenderEnemyHealth is the SOLE owner of the
            -- health bar's value and already zeroes dead/stealth every tick,
            -- before this runs. A second writer here was one of four hands on the
            -- same value, which is what made a stuck-empty bar impossible to
            -- pin: any of them could have been the last to touch it.

            ns.ApplyBarStateArt(bar, status, entry and entry.objective)
        end
    end
end

-- Forward declaration: the bar click handlers install this as a PostClick
-- hook, and it is defined further down with the rest of the token sweep.

local SETPOS = {
    normalEnemy    = ns.SetNormalEnemyFramePosition,
    normalFriendly = ns.SetNormalFriendlyFramePosition,
    epicEnemy      = ns.SetEpicEnemyFramePosition,
    epicFriendly   = ns.SetEpicFriendlyFramePosition,
}

-- Bar glow has two state sources (hover, target). Show if either is active.

function UpdateBarGlow(bar)
    if bar._isHover or bar._isTarget then
        ns.ShowGlow(bar)
    else
        ns.HideGlow(bar)
    end
end

-- Hover and press feedback: scale, highlight and glow. NONE of it is protected
-- -- only the targeting click is -- so it belongs to the visual half rather
-- than the interactive one, and the settings previews get the same feel as live
-- bars from the same code instead of going inert.
--
-- Live bars are secure Buttons and preview bars are plain Frames; both accept
-- these scripts, and HookScript is safe on a frame with nothing hooked yet.
ns.CARD_HOVER_SCALE = 1.03   -- flat step when the dock is not driving

-- Press feedback as a SUBTRACTED delta, not a multiplier.
--
-- The original set the pressed scale absolutely: 1.03 hovered, 0.99 pressed, so
-- the click moved the card by a full 0.04. Multiplying instead gives
-- 1.03 * 0.99 = 1.0197 -- a 0.01 movement, a quarter of the travel, from the
-- very same two numbers. The press read as almost nothing.
--
-- A delta also survives the dock: pressing from a magnified 1.05 lands on 1.01
-- and still moves by 0.04, where a multiplier would have scaled the feedback
-- with the zoom and made it weakest on the bar under the cursor.
ns.CARD_PRESS_DROP  = 0.04   -- scale subtracted while held

-- Sole owner of bar.visual's scale. THREE things want to set it -- hover, the
-- press feedback and the card dock -- and left to themselves they fight: the
-- dock eases a bar up while OnLeave slams it back to 1.0 on the same frame.
--
-- They compose here instead. The dock's eased zoom wins when it is running,
-- hover's flat step applies when it is not, and the press multiplies either.
function ns.ApplyBarScale(bar)
    if not bar.visual then return end
    local z = bar._dockZoom or (bar._isHover and ns.CARD_HOVER_SCALE or 1)
    if bar._pressed then z = z - ns.CARD_PRESS_DROP end
    bar.visual:SetScale(math.max(0.5, z))
end

function ns.AttachBarHover(bar)
    bar:EnableMouse(true)
    bar:HookScript("OnEnter", function(self)
        self._isHover = true
        ns.ApplyBarScale(self)
        self.highlight:Show()
        UpdateBarGlow(self)
    end)
    bar:HookScript("OnLeave", function(self)
        self._isHover = false
        -- Clear the press here too. Releasing the button after the cursor has
        -- left the frame does not deliver OnMouseUp to it, so _pressed would
        -- stay true and hold the card at 1 - CARD_PRESS_DROP -- stuck SMALLER,
        -- and only fixable by clicking it again to supply the missing up.
        self._pressed = nil
        ns.ApplyBarScale(self)
        self.highlight:Hide()
        UpdateBarGlow(self)
    end)
    bar:HookScript("OnMouseDown", function(self)
        self._pressed = true
        ns.ApplyBarScale(self)
    end)
    bar:HookScript("OnMouseUp", function(self)
        -- Cleared rather than set back to a hover constant: the pointer is
        -- still over the bar, and ApplyBarScale already knows what that means.
        self._pressed = nil
        ns.ApplyBarScale(self)
    end)
end

-- Round swipe for cooldowns shown over the circular objective icons

local CIRCLE_MASK = "Interface\\CharacterFrame\\TempPortraitAlphaMask"

-- Modern-look prototype: selectable bar fill texture (GPT-generated set)

local BAR_FILL_TEXTURES = {
    smooth = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\BarFillSmooth.tga",
    smoke  = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\BarFillSmoke.tga",
    glass  = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\BarFillGlass.tga",
}

function ns.GetBarFillTexture()
    return BAR_FILL_TEXTURES[ns.GetBarFillStyle()] or BAR_FILL_TEXTURES.smoke
end

-- Modern-look prototype: GPT-modernized class art for the left-edge bleed
-- (2.2:1 crops, subject left-weighted on near-black). Classes without custom
-- art yet fall back to the square Blizzard icon — see ns.GetClassArt callers.

local CLASS_ART = {
    WARRIOR     = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\ClassArtWarriorV10.tga",
    MAGE        = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\ClassArtMageV10.tga",
    PALADIN     = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\ClassArtPaladinV10.tga",
    HUNTER      = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\ClassArtHunterV10.tga",
    ROGUE       = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\ClassArtRogueV10.tga",
    PRIEST      = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\ClassArtPriestV10.tga",
    DEATHKNIGHT = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\ClassArtDeathKnightV10.tga",
    SHAMAN      = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\ClassArtShamanV10.tga",
    WARLOCK     = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\ClassArtWarlockV10.tga",
    MONK        = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\ClassArtMonkV10.tga",
    DRUID       = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\ClassArtDruidV10.tga",
    DEMONHUNTER = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\ClassArtDemonHunterV10.tga",
    EVOKER      = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\ClassArtEvokerV10.tga",
}

-- The previous commissioned set ("sharp"): the June-era bakes from the
-- archive, left corners rounded to the card radius (bake_sharp_corners.py) -- dissolve baked into the alpha (luminance-weighted), no scrim,
-- full-bleed texcoord, never masked. June drew them INSIDE the card at
-- height x 2.25; the same art now hangs OUTSIDE it, legacy-style.
local CLASS_ART_SHARP = {
    WARRIOR     = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\ClassArtWarriorSharp3.tga",
    MAGE        = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\ClassArtMageSharp3.tga",
    PALADIN     = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\ClassArtPaladinSharp3.tga",
    HUNTER      = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\ClassArtHunterSharp3.tga",
    ROGUE       = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\ClassArtRogueSharp3.tga",
    PRIEST      = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\ClassArtPriestSharp3.tga",
    DEATHKNIGHT = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\ClassArtDeathKnightSharp3.tga",
    SHAMAN      = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\ClassArtShamanSharp3.tga",
    WARLOCK     = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\ClassArtWarlockSharp3.tga",
    MONK        = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\ClassArtMonkSharp3.tga",
    DRUID       = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\ClassArtDruidSharp3.tga",
    DEMONHUNTER = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\ClassArtDemonHunterSharp3.tga",
    EVOKER      = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\ClassArtEvokerSharp3.tga",
}
local CLASS_ART_SHARP_ASPECT = 2.25  -- June's width basis (artW = height * 2.25)

local CLASS_ICON = {
    WARRIOR     = "Interface\\Icons\\ClassIcon_Warrior",
    PALADIN     = "Interface\\Icons\\ClassIcon_Paladin",
    HUNTER      = "Interface\\Icons\\ClassIcon_Hunter",
    ROGUE       = "Interface\\Icons\\ClassIcon_Rogue",
    PRIEST      = "Interface\\Icons\\ClassIcon_Priest",
    DEATHKNIGHT = "Interface\\Icons\\ClassIcon_DeathKnight",
    SHAMAN      = "Interface\\Icons\\ClassIcon_Shaman",
    MAGE        = "Interface\\Icons\\ClassIcon_Mage",
    WARLOCK     = "Interface\\Icons\\ClassIcon_Warlock",
    MONK        = "Interface\\Icons\\ClassIcon_Monk",
    DRUID       = "Interface\\Icons\\ClassIcon_Druid",
    DEMONHUNTER = "Interface\\Icons\\ClassIcon_DemonHunter",
    EVOKER      = "Interface\\Icons\\ClassIcon_Evoker",
}

-- Blizzard's square icons carry roughly 7% of dead border baked into the file.
-- Every Blizzard frame that uses them trims it; without the trim the crest sits
-- inside a visible box that reads as a second, misaligned edge against the card.
local CLASS_ICON_TRIM = 0.07

-- Which artwork set a bar draws. The toggle is deliberately shared rather than
-- per-frame-set: it exists to judge the redrawn art against Blizzard's, and
-- having to flip it in four places to compare would defeat the point.
--
-- Declared AFTER both tables on purpose. A `local function` above them would
-- capture nil globals instead of the upvalues -- the same use-before-declaration
-- trap that cost four bugs during the rebuild.
function ns.GetClassArt(classToken)
    if not classToken then return nil end
    local style = ns.GetClassArtStyle and ns.GetClassArtStyle() or "polygon"
    if style == "legacy" then return CLASS_ICON[classToken] end
    if style == "sharp"  then return CLASS_ART_SHARP[classToken] end
    return CLASS_ART[classToken]  -- polygon and polygon2 share the art
end

-- Width:height of the art slot. Follows the artwork rather than being a fixed
-- constant, because the two sets are not the same shape -- the redrawn art is a
-- 2.37:1 left-weighted bleed, Blizzard's icons are square. Stretching a 1:1
-- crest across the wide slot distorts it past recognition.
function ns.GetClassArtAspect()
    local style = ns.GetClassArtStyle and ns.GetClassArtStyle() or "polygon"
    if style == "legacy" then return 1.0 end
    if style == "sharp"  then return CLASS_ART_SHARP_ASPECT end
    return CLASS_ART_ASPECT  -- polygon and polygon2 share the aspect
end

-- Texture coords for the current set: trimmed for Blizzard's icons, full-bleed
-- for the redrawn art, which is already cropped to its slot.
function ns.GetClassArtTexCoord()
    if ns.GetClassArtStyle and ns.GetClassArtStyle() == "legacy" then
        return CLASS_ICON_TRIM, 1 - CLASS_ICON_TRIM, CLASS_ICON_TRIM, 1 - CLASS_ICON_TRIM
    end
    return 0, 1, 0, 1
end

-- Role icons (Blizzard atlases). Friendly bars only — UnitGroupRolesAssigned
-- doesn't return useful data for enemies. Healer-only for now; re-add TANK
-- and DAMAGER entries to surface those roles.


function CreatePingDockArt(btn)
    -- Active-item halo ring, driven by the ping dock (BACKGROUND sits behind
    -- every sibling icon, so the halo never covers neighbouring artwork).
    -- TGA with explicit extension — extensionless paths only resolve BLP/TGA,
    -- which is how the original RadialGlow.png reference silently failed.
    local glow = btn:CreateTexture(nil, "BACKGROUND")
glow:SetSnapToPixelGrid(false)
glow:SetTexelSnappingBias(0)
    glow:SetTexture("Interface\\AddOns\\BlitzFramesTargets\\Textures\\PingHalo.tga")
    glow:SetBlendMode("ADD")
    glow:SetVertexColor(PING_GLOW_COLOR[1], PING_GLOW_COLOR[2], PING_GLOW_COLOR[3])
    glow:SetPoint("CENTER", btn, "CENTER")
    glow:SetAlpha(0)
    btn.glow = glow

    -- Pre-baked lit variant of the icon, crossfaded in on hover. OVERLAY keeps
    -- it above every sibling's ARTWORK icon while active; SetAllPoints(icon)
    -- makes it track the icon's dock zoom for free.
    local hover = btn:CreateTexture(nil, "OVERLAY")
    hover:SetSnapToPixelGrid(false)
    hover:SetTexelSnappingBias(0)
    hover:SetTexture("Interface\\AddOns\\BlitzFramesTargets\\Textures\\AssistPingV2_hover.tga")
    hover:SetAllPoints(btn.icon)
    hover:SetAlpha(0)
    btn.hoverIcon = hover
end

ns.PING_PRESS_SCALE = 0.88   -- press feedback on the ping icon

-- Sole owner of the ping icon's drawn size, for exactly the reason
-- ns.ApplyBarScale exists on the cards: the dock and the press feedback both
-- want it, and whichever ran last would win.
--
-- The icon is re-anchored to CENTRE on first use and stays that way. It starts
-- as SetAllPoints(btn), which cannot express a size change at all -- the anchors
-- pin all four edges, so SetSize is ignored until they are dropped.
function ApplyPingScale(btn)
    local icon = btn.icon
    if not icon then return end
    if not btn._dockAnchored then
        icon:ClearAllPoints()
        icon:SetPoint("CENTER", btn, "CENTER")
        btn._dockAnchored = true
    end
    -- A MULTIPLIER here, unlike the cards' subtracted delta, and deliberately.
    -- A ping icon's zoom range runs to 3x, so a fixed delta would be a huge
    -- press at rest and an invisible one when magnified. Cards move between
    -- 1.00 and 1.05, where the constant delta is what keeps the click honest.
    local s = (btn._dockZoom or 1) * (btn._pressScale or 1)
    icon:SetSize(btn:GetWidth() * s, btn:GetHeight() * s)
    if btn.glow then
        local gd = btn:GetWidth() * s * PING_GLOW_SCALE
        btn.glow:SetSize(gd, gd)
    end
end

-- Hover and press feedback for a ping button. Shared by the live buttons and
-- the previews' plain-frame stand-ins, so the two cannot drift.
function AttachPingPress(btn)
    btn:HookScript("OnMouseDown", function(self)
        self._pressScale = ns.PING_PRESS_SCALE
        ApplyPingScale(self)
    end)
    btn:HookScript("OnMouseUp", function(self)
        self._pressScale = nil
        ApplyPingScale(self)
    end)
end

function PingDockUpdate(fs, elapsed)
    local k = 1 - math.exp(-PING_DOCK_SPEED * elapsed)

    -- Effect is live while the cursor is over (or just beside) any ping button
    local live = false
    for i = 1, fs.maxBars do
        local btn = fs.bars[i].pingBtn
        if btn:IsShown() and btn:IsMouseOver(12, -12, -12, 12) then
            live = true
            break
        end
    end

    local zoomOverride = fs.getZoom and fs.getZoom()
    local settled = not live
    for i = 1, fs.maxBars do
        local btn  = fs.bars[i].pingBtn
        local icon = btn.icon

        -- Plain mouseover: only the hovered button magnifies. The rolling
        -- proximity falloff (macOS-dock style) was built, shipped, and
        -- REVERTED -- it looked good and played badly, moving buttons the
        -- muscle memory expected to stay put. The eased approach stays; it is
        -- the neighbours moving that was the problem, not the animation.
        local strength = (live and btn:IsShown() and btn:IsMouseOver()) and 1 or 0

        local zoomTarget = 1 + ((zoomOverride or btn.zoomAmount or 1) - 1) * strength

        local z = btn._dockZoom or 1
        z = z + (zoomTarget - z) * k
        btn._dockZoom = z

        -- Glow marks the active item: full strength while the cursor is on the
        -- button itself, easing out the moment it leaves (binary target, not
        -- strength-derived — derived glow proved too subtle to read as a state)
        local glowTarget = (live and btn:IsShown() and btn:IsMouseOver()) and 1 or 0
        local gs = btn._dockGlow or 0
        gs = gs + (glowTarget - gs) * k
        btn._dockGlow = gs

        if strength > 0 or math.abs(z - zoomTarget) > 0.003 or math.abs(gs - glowTarget) > 0.003 then
            settled = false
        end

        if z > 1.001 or btn._dockAnchored then
            ApplyPingScale(btn)
        end

        -- Magnified items draw over their relaxed neighbours (sublevel sorts
        -- ARTWORK textures across the equal-frame-level sibling buttons)
        icon:SetDrawLayer("ARTWORK", math.floor(strength * 7 + 0.5))

        -- Size is ApplyPingScale's; only the alphas are the dock's.
        btn.glow:SetAlpha(gs * PING_GLOW_ALPHA)
        btn.hoverIcon:SetAlpha(gs)
    end

    if settled then
        for i = 1, fs.maxBars do
            local btn = fs.bars[i].pingBtn
            if btn._dockAnchored then
                btn.icon:ClearAllPoints()
                btn.icon:SetAllPoints(btn)
                btn._dockAnchored = nil
            end
            btn.icon:SetDrawLayer("ARTWORK", 0)
            btn.glow:SetAlpha(0)
            btn.hoverIcon:SetAlpha(0)
            btn._dockZoom, btn._dockGlow = nil, nil
        end
        fs.pingDockDriver:SetScript("OnUpdate", nil)
        fs.pingDockActive = nil
    end
end

function StartPingDock(fs)
    if fs.pingDockActive then return end
    fs.pingDockActive = true
    fs.pingDockDriver = fs.pingDockDriver or CreateFrame("Frame")
    fs.pingDockDriver:SetScript("OnUpdate", function(_, elapsed)
        PingDockUpdate(fs, elapsed)
    end)
end

-- Wire the dock onto an arbitrary bar set — used by the settings previews so
-- the effect can be felt while tuning (live frame sets wire up in CreateBar).
-- set = { bars = t, maxBars = n, getZoom = fn } where bars[i].pingBtn has .icon

function ns.AttachPingDock(set)
    for i = 1, set.maxBars do
        local btn = set.bars[i].pingBtn
        if not btn.glow then CreatePingDockArt(btn) end
        btn:EnableMouse(true)
        btn:SetScript("OnEnter", function() StartPingDock(set) end)
        AttachPingPress(btn)
    end
end

-------------------------------------------------------------------------------
-- Card dock
-------------------------------------------------------------------------------
-- The same magnification as the ping dock, on the cards themselves.
--
-- The ping dock's trick does NOT transfer. It scales a texture inside a
-- fixed-size button, so nothing reflows and nothing protected is touched. A
-- card IS the button, and bars are SecureUnitButtons -- their size and position
-- are untouchable in combat, which is exactly when this has to work.
--
-- What CAN move is bar.visual, an ordinary child frame. The hover feedback has
-- been scaling it in combat since the beginning, which is the proof this is
-- allowed. So the cards magnify and the frame set never reflows.
--
-- The consequence, and the reason this is not a true dock: a magnified card
-- OVERLAPS its neighbours rather than displacing them. Displacement means
-- repositioning secure buttons, which is not available in combat at any price.
-- Keep CARD_DOCK_ZOOM modest for that reason -- the effect has to read as
-- emphasis, not as cards colliding.
--
-- The hit area stays the unscaled button, which sounds worse than it is: the
-- magnification is centred on each bar's own centre, so centres never move, and
-- the cursor driving the effect is by definition near the centre of the bar it
-- is magnifying. Only the outer edge of a swollen card is dead to clicks.
ns.CARD_DOCK_ZOOM = 1.09          -- magnification of the hovered card

local CARD_DOCK_SPEED     = 45    -- exponential approach rate (higher = snappier)

function CardDockUpdate(set, elapsed)
    local k = 1 - math.exp(-CARD_DOCK_SPEED * elapsed)
    -- EVERY bar, not just the active ones -- this is why a card sometimes stayed
    -- magnified. set.active shrinks when the roster does, so a bar zoomed at
    -- index 5 when active was 8 falls outside the new bound, never gets eased,
    -- and keeps its scale. `settled` then reports true because nothing is
    -- hovered, the driver unregisters, and the card stays big for good.
    --
    -- The old expression could never reach maxBars anyway: 0 is TRUTHY in Lua,
    -- so `set.active or set.maxBars` returned 0 whenever the set was empty, and
    -- an empty set is exactly when a leftover zoom most needs clearing.
    local n = set.maxBars or set.active or 0

    local live = false
    for i = 1, n do
        local bar = set.bars[i]
        if bar and bar:IsShown() and bar:IsMouseOver(6, -6, -6, 6) then
            live = true
            break
        end
    end

    local zoom = (set.getCardZoom and set.getCardZoom()) or ns.CARD_DOCK_ZOOM
    local settled = not live

    for i = 1, n do
        local bar = set.bars[i]
        if bar and bar.visual and not bar:IsShown() then
            -- Hidden bars are SNAPPED, not eased. Nothing can see the
            -- transition, and easing them would hold `settled` false and keep
            -- the driver running forever.
            --
            -- This is also where a lost OnLeave gets cleaned up. Hiding a frame
            -- while the cursor is over it never fires OnLeave, so _isHover stays
            -- true -- and since ApplyBarScale falls back to CARD_HOVER_SCALE on
            -- _isHover alone, the bar would come back pre-zoomed with no dock
            -- state to explain it.
            --
            -- Guarded so an idle hidden bar costs a field read rather than a
            -- SetScale every frame -- the loop now covers maxBars, which is 40
            -- bars mostly sitting hidden.
            if bar._dockZoom or bar._isHover or bar._pressed then
                bar._dockZoom, bar._isHover, bar._pressed = nil, false, nil
                ns.ApplyBarScale(bar)
            end
        elseif bar and bar.visual then
            -- Nothing can be held down if no button is down. Cheap self-heal for
            -- any path that loses the mouse-up, so the driver cannot settle with
            -- a card left shrunk. OnLeave covers the common case; this covers the
            -- ones nobody has thought of.
            if bar._pressed and not IsMouseButtonDown() then
                bar._pressed = nil
            end

            -- Plain mouseover, same revert as the ping dock: only the
            -- hovered card magnifies; neighbours hold their size.
            local strength = (live and bar:IsShown() and bar:IsMouseOver()) and 1 or 0

            local target = 1 + (zoom - 1) * strength
            local z = bar._dockZoom or 1
            z = z + (target - z) * k
            if math.abs(z - target) > 0.002 then settled = false end

            -- Released to nil once it is back at rest, rather than pinned at
            -- exactly 1.0 forever -- that hands the scale back to hover, which
            -- otherwise never gets to apply its own step again.
            bar._dockZoom = (z > 1.001) and z or nil
            ns.ApplyBarScale(bar)
        end
    end
    return settled
end

function StartCardDock(set)
    if set.cardDockActive then return end
    set.cardDockActive = true
    set.cardDockDriver = set.cardDockDriver or CreateFrame("Frame")
    set.cardDockDriver:SetScript("OnUpdate", function(self, elapsed)
        -- Stops itself once everything has eased back, so an idle frame set
        -- costs nothing. Re-armed by the next OnEnter.
        if CardDockUpdate(set, elapsed) then
            self:SetScript("OnUpdate", nil)
            set.cardDockActive = false
        end
    end)
end
ns.StartCardDock = StartCardDock

-- Wire the dock onto an arbitrary bar set, mirroring ns.AttachPingDock so the
-- settings previews can feel it too. set = { bars = t, maxBars = n }.
function ns.AttachCardDock(set)
    for i = 1, (set.maxBars or 0) do
        local bar = set.bars[i]
        if bar then bar:HookScript("OnEnter", function() StartCardDock(set) end) end
    end
end

-------------------------------------------------------------------------------
-- Bar visuals
-------------------------------------------------------------------------------
-- Sole owner of every VISUAL widget on a bar. Split out of CreateBar so the
-- settings previews can build a bar that looks identical without inheriting
-- any of the secure/interactive machinery -- the click handlers, drag, and
-- targeting attributes stay in CreateBar, which previews neither need nor
-- should have.
function ns.CreateBarVisual(bar)
    local visual = CreateFrame("Frame", nil, bar)
    visual:SetPoint("CENTER", bar, "CENTER", 0, 0)
    bar.visual = visual

    -- Background: same fill texture as health bar so banding/dip lines up
    local bg = visual:CreateTexture(nil, "BACKGROUND")
    bg:SetTexture("Interface\\RaidFrame\\Raid-Bar-Hp-Fill")
    bg:SetGradient("VERTICAL",
        CreateColor(0.03, 0.03, 0.04, 1.0),
        CreateColor(0.24, 0.24, 0.26, 0.88))
    bg:SetAllPoints(visual)
    bar.visualBg = bg

    -- Glassy sheen: bright highlight fading down from the top edge
    local sheen = visual:CreateTexture(nil, "BACKGROUND", nil, 1)
    sheen:SetColorTexture(1, 1, 1, 1)
    sheen:SetGradient("VERTICAL",
        CreateColor(0.85, 0.92, 1.0, 0.06),
        CreateColor(0.85, 0.92, 1.0, 0.0))
    sheen:SetAllPoints(visual)
    bar.visualSheen = sheen

    -- Class icon (square on left side)
    local classIcon = visual:CreateTexture(nil, "ARTWORK")
classIcon:SetSnapToPixelGrid(false)
classIcon:SetTexelSnappingBias(0)
    bar.classIcon = classIcon



    -- Sync indicator: shown when no key is stored for this player
    local syncText = visual:CreateFontString(nil, "OVERLAY")
    syncText:SetFont(ns.LOCALE_FONT, 10, "")
    syncText:SetTextColor(1, 0.82, 0, 1)
    syncText:SetText("click to sync")
    syncText:Hide()
    bar.syncText = syncText

    -- Health bar — StatusBar passes secret values to C++ natively
    local healthBar = CreateFrame("StatusBar", nil, visual)
    healthBar:SetStatusBarTexture("Interface\\RaidFrame\\Raid-Bar-Hp-Fill")
    healthBar:EnableMouse(false)
    bar.healthBar = healthBar

    -- Health bar background
    local healthBg = healthBar:CreateTexture(nil, "BACKGROUND")
    healthBg:SetColorTexture(0.1, 0.1, 0.1, 0.3)
    healthBg:SetAllPoints(healthBar)
    bar.healthBg = healthBg

    -- Role icon (friendly bars only): inner-left of the health bar. Parented
    -- to healthBar so it renders above the fill, mirroring BlitzBrain's spec
    -- icon outer-left + role icon inner-left layout (here classIcon is the
    -- outer-left; role icon is the inner-left).
    -- Power bar — thin strip at bottom of health bar, shown conditionally
    local powerBar = CreateFrame("StatusBar", nil, healthBar)
    -- ABOVE the art layer, deliberately. Both are children of healthBar, so
    -- they share a frame level by default and the later-created one (artLayer)
    -- would win -- which, now that the strip OVERLAYS the health bar instead
    -- of taking its own band, would bury it under the class art and the
    -- stealth fog. Power is data: it stays legible through both.
    powerBar:SetFrameLevel(healthBar:GetFrameLevel() + 4)
    powerBar:SetStatusBarTexture("Interface\\RaidFrame\\Raid-Bar-Hp-Fill")
    powerBar:SetStatusBarColor(0.2, 0.4, 0.9)
    powerBar:EnableMouse(false)
    powerBar:Hide()
    bar.powerBar = powerBar

    -- Power bar background
    local powerBg = powerBar:CreateTexture(nil, "BACKGROUND")
    powerBg:SetColorTexture(0, 0, 0, 0.5)
    powerBg:SetAllPoints(powerBar)

    -- Its own round mask, on powerBar. Masks fail silently across frames, so
    -- the health layer's mask cannot be borrowed -- and BarRoundMask would be
    -- the wrong file anyway: it is cut for a 256x64 card, and stretched onto a
    -- 4px strip its vertical radius collapses to a fifth of a pixel. That is
    -- what has been squaring off the bottom corners. BarPowerMask is the same
    -- 3px radius baked at the strip's aspect, so both silhouettes land on the
    -- same 2.3 x 1.5px corner on screen.
    --
    -- Attached once: unlike the health bar, powerBar's status texture is never
    -- re-set, so there is no new texture object to re-mask later.
    local roundMaskP = powerBar:CreateMaskTexture()
    roundMaskP:SetTexture("Interface\\AddOns\\BlitzFramesTargets\\Textures\\BarPowerMask.tga",
        "CLAMPTOBLACKADDITIVE", "CLAMPTOBLACKADDITIVE")
    roundMaskP:SetAllPoints(powerBar)
    bar.roundMaskP = roundMaskP
    powerBg:AddMaskTexture(roundMaskP)
    local powerFill = powerBar:GetStatusBarTexture()
    if powerFill then powerFill:AddMaskTexture(roundMaskP) end

    -- Name text (parented to healthBar so it sits above the bar fill)
    local nameText = healthBar:CreateFontString(nil, "OVERLAY")
    nameText:SetFont(ns.LOCALE_FONT, 12, "")
    nameText:SetShadowColor(0, 0, 0, 0.3)
    nameText:SetShadowOffset(1, -1)
    nameText:SetJustifyH("CENTER")
    nameText:SetWordWrap(false)
    nameText:SetTextColor(1, 1, 1, 1)
    bar.nameText = nameText

    -- Spec text. Shares the centre slot with the name -- Text Display picks
    -- which one is live, and the peek key swaps them.
    --
    -- On healthBar, NOT on visual: healthBar is a child of visual, so anything
    -- on the parent is drawn under the fill. That is where this used to sit,
    -- back when it was a popout that slid out from the class icon rather than
    -- a centre label.
    local specText = healthBar:CreateFontString(nil, "OVERLAY")
    specText:SetFont(ns.LOCALE_FONT, 12, "")
    specText:SetShadowColor(0, 0, 0, 0.3)
    specText:SetShadowOffset(1, -1)
    specText:SetJustifyH("CENTER")
    specText:SetWordWrap(false)
    specText:SetTextColor(1, 1, 1, 1)
    bar.specText = specText



    -- PvP icon (objective carrier and dead/ghost overlay share this slot)
    local pvpIconFrame = CreateFrame("Frame", nil, healthBar)
    pvpIconFrame:SetSize(32, 32)
    pvpIconFrame:SetPoint("RIGHT", healthBar, "RIGHT", -3, 0)
    pvpIconFrame:Hide()
    bar.pvpIconFrame = pvpIconFrame

    local pvpIconGlow = pvpIconFrame:CreateTexture(nil, "BACKGROUND")
pvpIconGlow:SetSnapToPixelGrid(false)
pvpIconGlow:SetTexelSnappingBias(0)
    pvpIconGlow:SetSize(48, 48)
    pvpIconGlow:SetPoint("CENTER")
    pvpIconGlow:SetTexture("Interface\\AddOns\\BlitzFramesTargets\\Textures\\PingHalo.tga")
    pvpIconGlow:SetBlendMode("ADD")
    pvpIconGlow:SetVertexColor(1.00, 0.92, 0.72, 1) -- candlelight, matches the ping halo
    pvpIconGlow:Hide()
    bar.pvpIconGlow = pvpIconGlow

    pvpIconGlow.animGroup = pvpIconGlow:CreateAnimationGroup()
    pvpIconGlow.animGroup:SetLooping("REPEAT")
    local fadeOut = pvpIconGlow.animGroup:CreateAnimation("Alpha")
    fadeOut:SetFromAlpha(0.75); fadeOut:SetToAlpha(0.40); fadeOut:SetDuration(0.4); fadeOut:SetOrder(1)
    local fadeIn  = pvpIconGlow.animGroup:CreateAnimation("Alpha")
    fadeIn:SetFromAlpha(0.40);  fadeIn:SetToAlpha(0.75);  fadeIn:SetDuration(0.4);  fadeIn:SetOrder(2)

    local pvpIcon = pvpIconFrame:CreateTexture(nil, "ARTWORK")
    -- Always as a pair. The icon is sized from bar height (0.9 x 40 = 36px, and
    -- fractional at most slider positions), so letting it snap to the pixel
    -- grid stair-steps the disc's rim. Worse on the orbs than the flags
    -- because the orb subject fills ~96% of its canvas -- its hard circular
    -- edge sits right at the texture boundary, where snapping bites hardest.
    pvpIcon:SetSnapToPixelGrid(false)
    pvpIcon:SetTexelSnappingBias(0)
    pvpIcon:SetAllPoints(pvpIconFrame) -- tracks frame size (death state resizes it)
    bar.pvpIcon = pvpIcon

    local pvpIconCooldown = CreateFrame("Cooldown", nil, pvpIconFrame, "CooldownFrameTemplate")
    pvpIconCooldown:SetAllPoints()
    pvpIconCooldown:SetSwipeTexture(CIRCLE_MASK) -- round swipe to match the circular icon
    pvpIconCooldown:SetDrawSwipe(true)
    pvpIconCooldown:SetReverse(true)
    pvpIconCooldown:SetSwipeColor(0, 0, 0, 0.6)
    pvpIconCooldown:SetDrawEdge(false)
    pvpIconCooldown:SetHideCountdownNumbers(false)
    bar.pvpIconCooldown = pvpIconCooldown

    -- Carrier stack count (Temple of Kotmogu damage stacks)
    local pvpIconStackText = pvpIconFrame:CreateFontString(nil, "OVERLAY")
    pvpIconStackText:SetFont(ns.LOCALE_FONT, 16, "OUTLINE")
    pvpIconStackText:SetPoint("CENTER", pvpIconFrame, "CENTER", 0, ns.ICON_NUMBER_Y)
    pvpIconStackText:SetTextColor(1, 1, 1, 1)
    pvpIconStackText:Hide()
    bar.pvpIconStackText = pvpIconStackText

    -- Sublevel -1 puts it under the FontString, which sits at OVERLAY 0.
    local pvpIconStackBack = pvpIconFrame:CreateTexture(nil, "OVERLAY", nil, -1)
    pvpIconStackBack:SetSnapToPixelGrid(false)
    pvpIconStackBack:SetTexelSnappingBias(0)
    pvpIconStackBack:SetTexture(ns.STACK_BACK_TEX)
    pvpIconStackBack:SetVertexColor(0, 0, 0, 1)
    pvpIconStackBack:SetPoint("CENTER", pvpIconStackText, "CENTER", 0, 0)
    pvpIconStackBack:Hide()
    bar.pvpIconStackBack = pvpIconStackBack

    -- The backing FOLLOWS the text rather than being shown by each caller.
    -- There are around twenty Show/Hide sites across Core.lua, both Battlegrounds
    -- tabs and test mode; hooking here means all of them get it, including any
    -- added later, and none of them can forget. Size is read from the font at
    -- show time so it tracks the Objective Text Size setting for free.
    hooksecurefunc(pvpIconStackText, "Show", function(self)
        local _, size = self:GetFont()
        local d = (size or 16) * ns.STACK_BACK_SCALE
        pvpIconStackBack:SetSize(d, d)
        pvpIconStackBack:SetAlpha(ns.STACK_BACK_A)
        pvpIconStackBack:Show()
    end)
    hooksecurefunc(pvpIconStackText, "Hide", function()
        pvpIconStackBack:Hide()
    end)

    -- ------------------------------------------------------------------
    -- Art layer: everything drawn ON the card sits here, above the health
    -- fill, so the fill can move without re-stacking any of it.
    -- ------------------------------------------------------------------
    -- Hover highlight. On visual, and DELIBERATELY BEHIND the card content.
    --
    -- healthBar is a child FRAME of visual, so it draws above visual's layers
    -- whatever the draw layer says -- the trough, fill, art and mist all cover
    -- this. That is the point: only the sliver that the card does not cover
    -- shows, so hover reads as a lit BORDER around the card rather than as a
    -- wash over the whole frame. A full-frame tint was tried and rejected.
    --
    -- Do NOT "fix" this by moving it to artLayer or masking it. It was moved
    -- there once on the theory that it was buried by accident; it is not, and
    -- the result was exactly the overlay wash this design avoids.
    local highlight = visual:CreateTexture(nil, "HIGHLIGHT")
    highlight:SetAllPoints(visual)
    highlight:SetColorTexture(1.0, 0.95, 0.75, 0.2)
    highlight:Hide()
    bar.highlight = highlight

    local artLayer = CreateFrame("Frame", nil, healthBar)
    artLayer:SetAllPoints(healthBar)
    bar.artLayer = artLayer

    -- Class art watermark. Bleeds off the LEFT edge and dissolves to the
    -- right; the alpha is baked into the texture, not applied here.
    local classArtShadow = artLayer:CreateTexture(nil, "ARTWORK", nil, -2)
    classArtShadow:SetSnapToPixelGrid(false)
    classArtShadow:SetTexelSnappingBias(0)
    classArtShadow:SetVertexColor(0, 0, 0)  -- alpha via SetAlpha (per-style), not vertex
    classArtShadow:SetAlpha(0)
    classArtShadow:Hide()
    bar.classArtShadow = classArtShadow

    local classArt = artLayer:CreateTexture(nil, "ARTWORK", nil, -1)
    classArt:SetSnapToPixelGrid(false)
    classArt:SetTexelSnappingBias(0)
    classArt:Hide()
    bar.classArt = classArt

    -- A tinted copy stacked over the art. SetDesaturated is boolean, so a
    -- partial hue shift needs a second coloured texture rather than a value.
    local classArtColor = artLayer:CreateTexture(nil, "ARTWORK", nil, 0)
    classArtColor:SetSnapToPixelGrid(false)
    classArtColor:SetTexelSnappingBias(0)
    classArtColor:SetBlendMode("ADD")
    classArtColor:Hide()
    bar.classArtColor = classArtColor

    -- State art (the marker that rides the live label).
    local stateArtGlow = artLayer:CreateTexture(nil, "OVERLAY", nil, -2)
    stateArtGlow:SetTexture("Interface\\AddOns\\BlitzFramesTargets\\Textures\\PingHalo.tga")
    stateArtGlow:SetBlendMode("ADD")
    stateArtGlow:SetVertexColor(1, 1, 1, ns.DEAD_GLOW_ALPHA)
    stateArtGlow:Hide()
    bar.stateArtGlow = stateArtGlow

    -- Same breathing pulse as the objective-icon halos, same numbers
    stateArtGlow.animGroup = stateArtGlow:CreateAnimationGroup()
    stateArtGlow.animGroup:SetLooping("REPEAT")
    local sgOut = stateArtGlow.animGroup:CreateAnimation("Alpha")
    sgOut:SetFromAlpha(0.75); sgOut:SetToAlpha(0.40); sgOut:SetDuration(0.4); sgOut:SetOrder(1)
    local sgIn  = stateArtGlow.animGroup:CreateAnimation("Alpha")
    sgIn:SetFromAlpha(0.40);  sgIn:SetToAlpha(0.75);  sgIn:SetDuration(0.4);  sgIn:SetOrder(2)

    local stateArt = artLayer:CreateTexture(nil, "OVERLAY", nil, -1)
    stateArt:SetSnapToPixelGrid(false)
    stateArt:SetTexelSnappingBias(0)
    stateArt:Hide()
    bar.stateArt = stateArt

    local stateArtColor = artLayer:CreateTexture(nil, "OVERLAY", nil, 0)
stateArtColor:SetSnapToPixelGrid(false)
stateArtColor:SetTexelSnappingBias(0)
    stateArtColor:SetBlendMode("ADD")
    stateArtColor:Hide()
    bar.stateArtColor = stateArtColor

    local stateMarker = artLayer:CreateFontString(nil, "OVERLAY")
    stateMarker:SetFont(ns.LOCALE_FONT, 10, "OUTLINE")
    stateMarker:Hide()
    bar.stateMarker = stateMarker

    -- Status word stamped across the class art, on its own dark pool.
    -- ArtStatusPool2 is a capsule with a flat core and a long dissolve --
    -- enough to sit glyphs on, soft enough not to read as a box.
    -- Sublevel -3, one BELOW the pool's -2, so the halo is behind the black and
    -- shows only where the pool does not cover it.
    local artStatusGlow = artLayer:CreateTexture(nil, "OVERLAY", nil, -3)
    artStatusGlow:SetSnapToPixelGrid(false)
    artStatusGlow:SetTexelSnappingBias(0)
    artStatusGlow:SetTexture(ns.ART_STATUS_GLOW_TEX)
    artStatusGlow:SetTextureSliceMargins(ns.ART_STATUS_GLOW_MARGIN, ns.ART_STATUS_GLOW_MARGIN,
                                         ns.ART_STATUS_GLOW_MARGIN, ns.ART_STATUS_GLOW_MARGIN)
    artStatusGlow:SetTextureSliceMode(Enum.UITextureSliceMode.Stretched)
    artStatusGlow:SetBlendMode("ADD")
    artStatusGlow:Hide()
    bar.artStatusGlow = artStatusGlow

    local artStatusBack = artLayer:CreateTexture(nil, "OVERLAY", nil, -2)
    artStatusBack:SetSnapToPixelGrid(false)
    artStatusBack:SetTexelSnappingBias(0)
    artStatusBack:SetTexture("Interface\\AddOns\\BlitzFramesTargets\\Textures\\ArtStatusPool2.tga")
    artStatusBack:SetVertexColor(0, 0, 0, 1)
    artStatusBack:Hide()
    bar.artStatusBack = artStatusBack

    local statusText = artLayer:CreateFontString(nil, "OVERLAY")
    statusText:SetFont(ns.LOCALE_FONT, 12, ns.STATUS_FONT_FLAGS)
    statusText:Hide()
    bar.statusText = statusText

    -- Health trough cap: the fill ramps out over the last few pixels, so the
    -- backing needs a matching gradient or the seam shows.
    local healthBgCap = healthBar:CreateTexture(nil, "BACKGROUND")
    healthBgCap:SetTexture("Interface\\Buttons\\WHITE8x8")
    healthBgCap:Hide()
    bar.healthBgCap = healthBgCap

    -- Respawn countdown.
    local deadTimerBack = artLayer:CreateTexture(nil, "OVERLAY", nil, -1)
deadTimerBack:SetSnapToPixelGrid(false)
deadTimerBack:SetTexelSnappingBias(0)
    deadTimerBack:SetTexture(ns.DEAD_TIMER_BACK_TEX)
    -- Tinted black: the art is a light disc, and the number sits ON it.
    deadTimerBack:SetVertexColor(0, 0, 0, 1)
    deadTimerBack:Hide()
    bar.deadTimerBack = deadTimerBack

    local deadTimer = artLayer:CreateFontString(nil, "OVERLAY")
    deadTimer:SetFont(ns.LOCALE_FONT, 12, "OUTLINE")
    deadTimer:Hide()
    bar.deadTimer = deadTimer

    -- Objective icon shadow, under the icon frame.
    if bar.pvpIconFrame then
        local pvpIconShadow = bar.pvpIconFrame:CreateTexture(nil, "BACKGROUND", nil, -2)
        pvpIconShadow:SetSnapToPixelGrid(false)
        pvpIconShadow:SetTexelSnappingBias(0)
        pvpIconShadow:SetVertexColor(0, 0, 0, ns.ICON_SHADOW_ALPHA)
        pvpIconShadow:Hide()
        bar.pvpIconShadow = pvpIconShadow
    end

    -- Team-targeting diamonds. Created once and shown/hidden per pass rather
    -- than built on demand: this updates at 10Hz and churning textures would
    -- be the expensive part.
    bar.teamDiamonds = {}
    for i = 1, ns.TEAM_TARGET_MAX do
        local dmd = artLayer:CreateTexture(nil, "OVERLAY", nil, -3)
        dmd:SetSnapToPixelGrid(false)
        dmd:SetTexelSnappingBias(0)
        dmd:SetTexture("Interface\\AddOns\\BlitzFramesTargets\\Textures\\TeamTargetDiamond2.tga")
        dmd:Hide()
        bar.teamDiamonds[i] = dmd
    end

    local teamOverflow = artLayer:CreateFontString(nil, "OVERLAY")
    teamOverflow:SetFont(ns.LOCALE_FONT, 10, "")
    teamOverflow:SetTextColor(1, 1, 1, 0.9)
    teamOverflow:Hide()
    bar.teamOverflow = teamOverflow

    -- Stealth reveal badge. Drawn OUTSIDE the card's left edge, so it overlaps
    -- nothing and needs no claim on any slot.
    -- Stealth reveal indicator: a row of chevrons pointing AT the card, with a
    -- travelling alpha wave running along them toward it. The direction is the
    -- whole point -- the wave carries the eye to the bar, which the pulsing
    -- diamond this replaces only did indirectly.
    -- Parented to BAR, not artLayer. artLayer descends from bar.visual, and the
    -- hover and click feedback scale bar.visual -- so on artLayer the whole
    -- reveal bounced in and out every time the card was moused or clicked.
    -- Nothing here overlaps the card anyway; it all sits off the left edge,
    -- which is the same reason the ping button hangs off bar rather than on it.
    bar.revealChevrons = {}
    for _ = 1, ns.REVEAL_CHEVRON_COUNT do
        local ch = bar:CreateTexture(nil, "OVERLAY", nil, -2)
        ch:SetSnapToPixelGrid(false)
        ch:SetTexelSnappingBias(0)
        ch:SetTexture("Interface\\AddOns\\BlitzFramesTargets\\Textures\\RevealChevron.tga")
        ch:Hide()
        bar.revealChevrons[#bar.revealChevrons + 1] = ch
    end

    -- Driven by an OnUpdate rather than an AnimationGroup, for the same reason
    -- the sonar ping it replaces was: animations on a TEXTURE do not cleanly
    -- revert on Stop(), so every Play/Stop cycle compounds on the last. This
    -- recomputes from a stored base every frame and never reads back.
    local march = CreateFrame("Frame", nil, bar)
    march:SetSize(1, 1)
    march:SetPoint("CENTER")
    march:Hide()
    march:SetScript("OnUpdate", function(self, elapsed)
        local period = ns.REVEAL_ECHO_TIME
        self.t = (self.t or 0) + elapsed
        if self.t >= period then self.t = self.t % period end

        local base  = self.baseAlpha or 1
        local size  = self.baseSize or 16
        local chevs = bar.revealChevrons
        local n     = #chevs
        local from, to = ns.REVEAL_ECHO_FROM, ns.REVEAL_ECHO_TO

        for i, ch in ipairs(chevs) do
            -- Every echo runs the SAME animation at a different phase, so the
            -- stream needs no per-echo state and cannot drift out of step.
            local f  = (self.t / period + (i - 1) / n) % 1
            local sz = size * (from + (to - from) * f)

            -- Size, position and alpha are all recomputed from the stored base
            -- every frame and never read back, so re-entering the draw path on
            -- every render tick cannot accumulate. That is the same discipline
            -- the ring driver needed and for the same reason.
            ch:SetSize(sz, sz)
            ch:ClearAllPoints()
            -- Anchored by CENTRE, not by the right edge. Anchoring the edge
            -- makes an echo grow leftward only, and that growth very nearly
            -- cancels its travel -- every echo then shares a left edge and they
            -- nest inside one another instead of separating. Centring makes the
            -- growth symmetric, so travel alone sets the spacing.
            ch:SetPoint("CENTER", bar, "LEFT",
                -(ns.EchoCentre(size, f) + ns.RevealArtOffset(bar)), 0)
            ch:SetAlpha(base * (1 - f))
        end
    end)
    bar.revealMarch = march

    local revealName = bar:CreateFontString(nil, "OVERLAY")
    revealName:SetFont(ns.LOCALE_FONT, 10, "OUTLINE")
    revealName:SetJustifyH("RIGHT")
    revealName:Hide()
    bar.revealName = revealName

    -- Suppression overlay: two mist layers at different scales, drifting on
    -- both axes in opposite directions.
    --
    -- Its own round mask, on artLayer. Masks silently fail across frames, so
    -- the health-layer mask cannot be reused here -- and without one the fog
    -- spills past the card's rounded corners.
    local roundMaskA = artLayer:CreateMaskTexture()
    roundMaskA:SetTexture("Interface\\AddOns\\BlitzFramesTargets\\Textures\\BarRoundMask.tga",
        "CLAMPTOBLACKADDITIVE", "CLAMPTOBLACKADDITIVE")
    roundMaskA:SetAllPoints(healthBar)
    bar.roundMaskA = roundMaskA

    -- The redrawn class art rides the same mask so its bleed respects the
    -- card's rounded corners. Legacy Blizzard icons hang OUTSIDE the card and
    -- must NOT be masked (they would vanish); ApplyBarGeometry adds/removes
    -- the mask as the setting flips. Added here rather than at creation
    -- because the mask does not exist yet at that point.
    if classArt then classArt:AddMaskTexture(roundMaskA) end
    if classArtShadow then classArtShadow:AddMaskTexture(roundMaskA) end
    bar._artMasked = true  -- ApplyBarGeometry drops it when the legacy icon sits outside the card

    bar.fxLayers = {}
    ns.mistBars[#ns.mistBars + 1] = bar
    for i, L in ipairs(ns.MIST_LAYERS) do
        -- ARTWORK 5 and 6: above the class art, below the state watermark at 7.
        -- The watermark is the subject and the fog is atmosphere, so running
        -- the mist over the top washes it out -- the opposite of the point.
        local m = artLayer:CreateTexture(nil, "ARTWORK", nil, 4 + i)
        -- REPEAT wrapping is what makes UV scrolling work at all: without it
        -- coordinates outside 0..1 clamp instead of tiling, and the fog smears
        -- into streaks at the edges.
        m:SetTexture("Interface\\AddOns\\BlitzFramesTargets\\Textures\\MistTile"
            .. (i == 1 and "A" or "B") .. ".tga", "REPEAT", "REPEAT")
        m:SetBlendMode("BLEND")
        m:SetAllPoints(healthBar)
        m:AddMaskTexture(roundMaskA)
        -- The art is baked near-white so this lands predictably; all of the
        -- colour is here, which is why retinting never needs new art.
        m:SetVertexColor(ns.MIST_COLOR[1], ns.MIST_COLOR[2], ns.MIST_COLOR[3], 1)
        m:SetAlpha(0)
        m:Hide()
        m._layer = L
        bar.fxLayers[i] = m
    end

    -- Non-secure feedback, so it comes with the visual half. This is what keeps
    -- the previews responsive without giving them a secure button.
    ns.AttachBarHover(bar)
    return bar
end

function CreateBar(index, container, globalName, isEnemy)
    local bar = CreateFrame("Button", globalName, container,
        "SecureUnitButtonTemplate, SecureHandlerStateTemplate, SecureHandlerEnterLeaveTemplate")
    bar:SetID(index)
    bar:RegisterForClicks("LeftButtonDown", "RightButtonDown")

    -- Card dock, resolved at event time the same way the ping button does it --
    -- the frame set does not exist yet while its bars are being built.
    bar:HookScript("OnEnter", function()
        local fs = frameSets[container._prefix]
        if fs then StartCardDock(fs) end
    end)
    -- LEFT CLICK TARGETS, RIGHT CLICK FOCUSES.
    --
    -- The two sides get there differently because only one of them has a unit
    -- token. A friendly bar carries `unit`, so focus is a plain secure action.
    -- An enemy in a battleground has no token at all -- the whole premise of
    -- this addon -- so focus is reached the way targeting already is: hijack
    -- the target for one macro line and hand it straight back. Same shape as
    -- the ping macros, built in SetEnemyFocusAttributes.
    if isEnemy then
        bar:SetAttribute("type1", "macro")
        bar:SetAttribute("type2", "macro")
        -- The macro needs a different form depending on whether the player
        -- has a target to restore, and the swap has to happen in COMBAT --
        -- so it happens in the restricted environment, driven by a state
        -- driver, exactly as the ping button does it. Registered once here
        -- rather than per assignment: only the two variants change per
        -- occupant, and RegisterStateDriver is combat-locked.
        bar:SetAttribute("_onstate-targetexists", [[
            local has = newstate == "hastarget"
            self:SetAttribute("macrotext2",
                self:GetAttribute(has and "focusmacro-hastarget" or "focusmacro-notarget"))
        ]])
        RegisterStateDriver(bar, "targetexists", "[@target, exists] hastarget; notarget")
    else
        bar:SetAttribute("*type1", "target")
        bar:SetAttribute("*type2", "focus")
    end
    bar:EnableMouseWheel(true)
    -- Same gate as the container's, read off the BAR (every bar carries its
    -- own copy of the attribute) -- the clicks still route to the container,
    -- which owns the cycling snippet.
    bar:SetAttribute("_onenter", ([[
        if self:GetAttribute("wheelCycle") then
            self:SetBindingClick(true, "MOUSEWHEELUP",   "%s", "Button4")
            self:SetBindingClick(true, "MOUSEWHEELDOWN", "%s", "Button5")
        end
    ]]):format(container:GetName(), container:GetName()))

    -- Visual wrapper: plain Frame centered on bar; SetScale here scales from center with no drift
    ns.CreateBarVisual(bar)

    -- Ping button (outside bar to the right)
    local pingBtn = CreateFrame("Button", globalName .. "Ping", bar,
        "SecureActionButtonTemplate, SecureHandlerStateTemplate")
    pingBtn:RegisterForClicks("LeftButtonDown", "RightButtonDown")

    local pingIcon = pingBtn:CreateTexture(nil, "ARTWORK")
pingIcon:SetSnapToPixelGrid(false)
pingIcon:SetTexelSnappingBias(0)
    pingIcon:SetAllPoints(pingBtn)
    pingIcon:SetTexture("Interface\\AddOns\\BlitzFramesTargets\\Textures\\AssistPingV2.tga")
    pingBtn.icon = pingIcon
    CreatePingDockArt(pingBtn)

    -- The dock driver eases everything back to rest on its own; no OnLeave needed
    pingBtn:SetScript("OnEnter", function(self)
        local fs = frameSets[container._prefix]
        if fs then StartPingDock(fs) end
    end)
    AttachPingPress(pingBtn)

    pingBtn:Hide()
    bar.pingBtn = pingBtn

    -- Ping receiver — lets the native ping keybind target this bar
    bar:SetAttribute("ping-receiver", true)
    Mixin(bar, PingableType_UnitFrameMixin)

    function bar:GetTargetPingGUID()
        return self.assignedGUID
    end

    function bar:GetContextualPingType()
        return isEnemy and Enum.PingSubjectType.Attack or Enum.PingSubjectType.Assist
    end

    -- Ctrl+drag: move the parent container from individual bars
    bar:RegisterForDrag("LeftButton")
    bar:SetScript("OnDragStart", function(self)
        if InCombatLockdown() then return end
        if not IsControlKeyDown() then return end
        container:StartMoving()
        container._isMoving = true
    end)
    bar:SetScript("OnDragStop", function()
        -- OnDragStop fires for EVERY drag gesture, including the ones OnDragStart
        -- refused -- no ctrl held, frame locked, in combat. StopMovingOrSizing is
        -- protected on this secure container, so calling it unconditionally
        -- triggered ADDON_ACTION_BLOCKED on any stray left-drag during combat.
        -- Only stop what we actually started.
        if not container._isMoving then return end
        container._isMoving = nil
        container:StopMovingOrSizing()
        local x, y = container:GetLeft(), container:GetTop()
        if x and y then SETPOS[container._prefix]({ x = x, y = y }) end
    end)

    -- Hover, press and glow feedback come from ns.AttachBarHover, which
    -- CreateBarVisual already applied -- it is unprotected, so it lives with the
    -- visuals and the previews share it.

    -- Stealth inference. This used to be a 50ms timer watching for
    -- PLAYER_TARGET_CHANGED, because names were unreadable and "no event
    -- fired" was the only available signal. 12.1 made UnitName non-secret
    -- inside a PvP match, so PostClick can simply ask whether we landed on
    -- who we asked for.
    -- Each button proves itself against the token IT set. Reading the target
    -- after a right click was the bug: the focus macro restores the target, so
    -- the check saw someone else and marked every right-clicked enemy as
    -- stealthed. (That misfire predates right click doing anything -- an empty
    -- macro still fires PostClick -- but making the button useful would have
    -- turned a rare wrong answer into one per focus.)
    bar:HookScript("PostClick", function(self, button)
        VerifyTargetOrMarkStealth(self.fullName,
            button == "RightButton" and "focus" or "target")
    end)

    bar.assignedName = nil
    bar.assignedGUID = nil
    bar.fullName     = nil
    bar._unit        = nil
    bar._isEnemy     = isEnemy
    bar:Hide()
    return bar
end

-- HOVER CYCLE FRAMES. Scrolling over a frame set cycles your target through
-- it -- enemy frames cycle enemies (/targetexact), friendly frames cycle
-- friendlies (/target unit). It works in combat because the binding is
-- installed from inside a secure _onenter snippet and dropped again on
-- _onleave, so the wheel is only ever taken while the cursor is on the frames.
--
-- OPT-IN, default off. Taking the wheel means taking it from whatever the
-- player has there -- camera zoom for most, a bound spell for some -- and
-- while that is only for as long as they hover the frames, it is still not a
-- trade to make on their behalf. The snippets read `wheelCycle` off the frame
-- they run on, so the decision is made HERE, out of combat, and the
-- restricted environment only ever reads it.
--
-- SetAttribute on a secure frame is combat-locked, hence the deferral: a
-- player who flips the option mid-fight gets it when they leave combat.
local wheelRefreshPending = false
function ns.RefreshWheelCycle()
    if InCombatLockdown() then
        wheelRefreshPending = true
        return
    end
    wheelRefreshPending = false
    -- `or nil` because attributes are cleared by nil, not by false
    local allowed = (ns.GetWheelCycleEnabled and ns.GetWheelCycleEnabled()) or nil
    for _, fs in pairs(frameSets) do
        if fs.container then fs.container:SetAttribute("wheelCycle", allowed) end
        for i = 1, fs.maxBars do
            local b = fs.bars[i]
            if b then b:SetAttribute("wheelCycle", allowed) end
        end
    end
end

function ns.PayWheelCycleDebt()
    if wheelRefreshPending then ns.RefreshWheelCycle() end
end

function CreateFrameSet(cfg)
    local g    = G[cfg.prefix]
    local bars = {}

    local container = CreateFrame("Button", cfg.globalName, UIParent,
        "SecureActionButtonTemplate, SecureHandlerEnterLeaveTemplate")
    container:SetFrameStrata("LOW")
    container:SetMovable(true)
    container:SetClampedToScreen(true)
    container:EnableMouse(true)
    -- Store prefix so bar OnDragStop can reach SETPOS without a separate upvalue chain
    container._prefix = cfg.prefix

    -- Scroll wheel targeting
    container:RegisterForClicks("Button4Down", "Button5Down")
    container:SetAttribute("type4", "macro")
    container:SetAttribute("type5", "macro")
    container:EnableMouseWheel(true)

    -- Gated on `wheelCycle`, which ns.RefreshWheelCycle sets out of combat.
    -- Without the gate this stole the wheel from anyone who had bound it.
    container:SetAttribute("_onenter", [[
        if self:GetAttribute("wheelCycle") then
            self:SetBindingClick(true, "MOUSEWHEELUP",   self:GetName(), "Button4")
            self:SetBindingClick(true, "MOUSEWHEELDOWN", self:GetName(), "Button5")
        end
    ]])
    container:SetAttribute("_onleave", [[
        self:ClearBindings()
    ]])

    SecureHandlerWrapScript(container, "OnClick", container, [[
        local max = self:GetAttribute("maxUnits")
        if not max or max == 0 then return end
        if button ~= "Button4" and button ~= "Button5" then return end

        local idx = self:GetAttribute("playerIndex") or 1
        if button == "Button4" then
            idx = idx - 1
            if idx < 1 then idx = max end
        else
            idx = idx + 1
            if idx > max then idx = 1 end
        end

        local cmd = self:GetAttribute("targetCmd"..idx)
        if cmd then self:SetAttribute("macrotext", cmd) end
        self:SetAttribute("playerIndex", idx)
    ]])

    -- Post-scroll stealth detection (enemy only — friendly bars use raidN tokens
    -- that always succeed, no failed-click signal). After the secure macro runs
    -- we read the new playerIndex, look up the expected fullName, and compare
    -- target's class/race to the stored values. Mismatch = click failed, so
    -- mark stealth on stealth-capable entries.
    -- Mousewheel scroll targets the bar under the cursor via a secure
    -- attribute, so the same stealth question applies as for a direct click:
    -- did we land on who we asked for? Routed through the shared verifier
    -- rather than re-deriving it, so both paths agree.
    if cfg.isEnemy then
        container:HookScript("OnClick", function(self, button)
            if button ~= "Button4" and button ~= "Button5" then return end
            local fs = frameSets[self._prefix]
            if not fs then return end
            local idx = self:GetAttribute("playerIndex")
            VerifyTargetOrMarkStealth(idx and fs.names and fs.names[idx])
        end)
    end

    -- Backdrop: a soft shadow that fades out on every edge rather than a hard
    -- panel -- darkens the world peeking between the cards without reading as
    -- a box. container.bg is kept (hidden) so older layout code referencing it
    -- stays valid; showing it puts a flat black rectangle behind the column,
    -- and its hard top and bottom edges are what made the end cards look
    -- square-cornered.
    local bg = container:CreateTexture(nil, "BACKGROUND")
    bg:SetColorTexture(0, 0, 0, 0.35)
    bg:SetAllPoints(container)
    bg:Hide()
    container.bg = bg

    -- Heading label — single centered line above container
    local heading = container:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    heading:SetPoint("BOTTOM", container, "TOP", 0, 7)
    heading:SetJustifyH("CENTER")
    heading:SetText("")
    heading:SetTextColor(1, 1, 1, 1)
    container.heading = heading

    -- Gold double rules above and below the faction heading, plus the glow
    -- plate under the lower rule. Built through the same helpers the previews
    -- use so the two cannot drift.
    container.headingRuleBottom = ns.MakeHeadingRule(container)
    container.headingRuleTop    = ns.MakeHeadingRule(container)
    container.headingPlate      = ns.MakeHeadingPlate(container)

    -- The column shade behind the cards. Darkens the world showing between
    -- them without reading as a panel.
    local shade = container:CreateTexture(nil, "BACKGROUND")
    shade:SetTexture("Interface\\AddOns\\BlitzFramesTargets\\Textures\\ColumnShade.tga")
    shade:SetVertexColor(0, 0, 0, 1)
    shade:SetPoint("TOPLEFT",     container, "TOPLEFT",     -14,  14)
    shade:SetPoint("BOTTOMRIGHT", container, "BOTTOMRIGHT",  14, -14)
    shade:SetAlpha(ns.SHADE_ALPHA)
    container.shade = shade

    -- Waiting portrait. Anchored to the TOP of the container and growing down,
    -- so it occupies the space the cards will fill -- the heading sits above it
    -- untouched, exactly where it always does.
    --
    -- Parented to the container, not UIParent. The old panel anchored itself to
    -- UIParent using the saved frame position, which meant it only landed
    -- correctly if that position had been saved, and it did not follow the
    -- container when the frames were dragged. Anchoring to the container is
    -- both simpler and always right.
    local introPanel = CreateFrame("Frame", nil, container)
    introPanel:SetPoint("TOP", container, "TOP", 0, 0)
    introPanel:SetFrameStrata("MEDIUM")
    introPanel:Hide()

    -- Faction artwork, filling the banner.
    local introPanelTex = introPanel:CreateTexture(nil, "ARTWORK")
    introPanelTex:SetAllPoints(introPanel)
    introPanelTex:SetSnapToPixelGrid(false)
    introPanelTex:SetTexelSnappingBias(0)

    -- Text sits OVER the artwork now, not above it. Shadowed rather than
    -- outlined: the art behind it is busy, and an outline on top of detail reads
    -- as a second edge.
    -- Parented to the CONTAINER, not introPanel: it now sits above the crest, and
    -- anchoring a frame to its own child is the sort of loop the layout engine
    -- should never be asked to resolve.
    local introPanelText = container:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    introPanelText:SetTextColor(1, 1, 1, 0.92)
    introPanelText:SetShadowColor(0, 0, 0, 0.9)
    introPanelText:SetShadowOffset(1, -1)
    introPanelText:SetPoint("TOP", container, "TOP", 0, -ns.INTRO_TEXT_TOP)
    introPanelText:Hide()

    -- Driven off introPanel's own OnUpdate, which by default runs only while the
    -- frame is VISIBLE -- so this costs nothing for the whole match after the
    -- bars take over, and needs no registry or teardown of its own.
    introPanel:SetScript("OnUpdate", function()
        local t = GetTime() * ns.INTRO_TEXT_PULSE_RATE * 2 * math.pi
        local lo = ns.INTRO_TEXT_PULSE_MIN
        introPanelText:SetAlpha(lo + (1 - lo) * (0.5 + 0.5 * math.sin(t)))
    end)

    container.introPanelText = introPanelText
    container.introPanel     = introPanel
    container.introPanelTex  = introPanelTex

    -- Bar pool
    for i = 1, cfg.maxBars do
        bars[i] = CreateBar(i, container, cfg.globalName .. "Bar" .. i, cfg.isEnemy)
    end

    -- Ctrl+drag from the container itself (heading/gap area)
    container:RegisterForDrag("LeftButton")
    container:SetScript("OnDragStart", function(self)
        if InCombatLockdown() then return end
        if g.frameLocked() then return end
        if not IsControlKeyDown() then return end
        self:StartMoving()
        self._isMoving = true
    end)
    container:SetScript("OnDragStop", function(self)
        -- See the bar-level pair above: this fires even when OnDragStart bailed,
        -- and StopMovingOrSizing is protected on a secure frame.
        if not self._isMoving then return end
        self._isMoving = nil
        self:StopMovingOrSizing()
        local x, y = self:GetLeft(), self:GetTop()
        if x and y then SETPOS[cfg.prefix]({ x = x, y = y }) end
    end)

    container:Hide()

    return {
        prefix      = cfg.prefix,
        globalName  = cfg.globalName,
        maxBars     = cfg.maxBars,
        isEnemy     = cfg.isEnemy,
        container   = container,
        bars        = bars,
        active      = 0,
        scoreCount  = 0,
        names       = {},
        g           = g,
    }
end

-- The four frame sets, built once at load. Normal and Epic are separate sets
-- rather than one resized set: their bar counts differ by 25, and the secure
-- attributes have to exist before combat either way.
frameSets.normalEnemy    = CreateFrameSet({ prefix = "normalEnemy",    globalName = "BFTNormalEnemyFrame",    maxBars = MAX_NORMAL, isEnemy = true  })
frameSets.normalFriendly = CreateFrameSet({ prefix = "normalFriendly", globalName = "BFTNormalFriendlyFrame", maxBars = MAX_NORMAL, isEnemy = false })
frameSets.epicEnemy      = CreateFrameSet({ prefix = "epicEnemy",      globalName = "BFTEpicEnemyFrame",      maxBars = MAX_EPIC,   isEnemy = true  })
frameSets.epicFriendly   = CreateFrameSet({ prefix = "epicFriendly",   globalName = "BFTEpicFriendlyFrame",   maxBars = MAX_EPIC,   isEnemy = false })

-- Dragging is combat-locked, so it is unregistered on the way in and restored
-- on the way out rather than being checked at drag time.
local dragToggle = CreateFrame("Frame")
dragToggle:RegisterEvent("PLAYER_REGEN_DISABLED")
dragToggle:RegisterEvent("PLAYER_REGEN_ENABLED")
dragToggle:SetScript("OnEvent", function(_, event)
    local inCombat = event == "PLAYER_REGEN_DISABLED"
    for _, fs in pairs(frameSets) do
        -- A drag that began out of combat and was still held when combat
        -- started cannot be stopped -- StopMovingOrSizing is protected on these
        -- secure containers and gets blocked. StartMoving glues the frame to the
        -- cursor until something stops it, so without this the frame follows the
        -- mouse for the rest of the fight. Pay it the moment it becomes legal.
        if not inCombat and fs.container._isMoving then
            fs.container._isMoving = nil
            fs.container:StopMovingOrSizing()
            local x, y = fs.container:GetLeft(), fs.container:GetTop()
            if x and y and SETPOS[fs.prefix] then SETPOS[fs.prefix]({ x = x, y = y }) end
        end
        for i = 1, fs.maxBars do
            if inCombat then
                fs.bars[i]:RegisterForDrag()
            else
                fs.bars[i]:RegisterForDrag("LeftButton")
            end
        end
    end
end)

-- Places the bars within their container and hands every per-bar decision to
-- ns.ApplyBarLayout.
--
-- This used to do the whole layout itself, which is why live and test bars
-- looked nothing like the previews: the previews call ns.ApplyBarLayout and
-- this did not, so only one of the two paths ever got the class art, the
-- selectable fill texture, the masks or the derived icon size. Everything that
-- is genuinely per-BAR belongs there; what stays here is what is genuinely
-- per-SET -- stacking order, container size, and the heading.
-- SPLIT IN TWO, because the two halves change for different reasons and at
-- very different rates -- and conflating them was the addon's one real CPU spike.
--
-- Per-bar GEOMETRY (position, ApplyBarLayout, ping button) depends only on the
-- settings and the bar's index. Bar 7 is laid out identically whether it holds a
-- player or not. So it wants redoing when SETTINGS change -- a slider moving,
-- test mode toggling, a rebuild -- which is rare.
--
-- Container SIZE and HEADING depend on the roster, which during a load-in
-- changes on nearly every 10Hz tick as players trickle in. Before this split,
-- every one of those ticks relaid out ALL maxBars: 15 x 36 geometry calls in a
-- normal BG, 40 x 36 = 1,440 in an epic, and a 40-player trickle came to
-- ~57,600 SetPoint/SetSize calls over four seconds. The engine re-resolves
-- anchors on each, so that is a visible hitch at exactly the moment the match
-- starts.
--
-- ApplySizing keeps its name and does BOTH, so every existing caller is still
-- correct. The roster path in UpdateEnemyFrameSet / UpdateFriendlyFrameSet
-- calls ApplyRosterSizing alone. The geometry half is also memoised on the
-- settings tuple, so a redundant call from a rebuild costs a table lookup.
function ApplyBarGeometry(fs)
    local g            = fs.g
    local barWidth     = g.barWidth()
    local barHeight    = g.barHeight()
    local barSpacing   = g.barSpacing()
    local textSize     = g.textSize()
    local powerShown   = g.powerEnabled and g.powerEnabled() or false
    local hideClassArt = g.hideClassArt and g.hideClassArt() or false

    -- Memo. Everything the loop below reads is in this key; if none of it moved,
    -- every bar is already exactly where and how it should be.
    --
    -- The last two are SHARED settings that ApplyBarLayout reads for itself
    -- rather than taking as arguments -- the fill texture and the class-art
    -- toggle. Leaving them out would memoise away a toggle of "Use Blizzard
    -- Class Icons": RebuildBars would run, the key would match, nothing would
    -- redraw. If ApplyBarLayout ever grows another such read, it goes here too.
    local key = table.concat({ barWidth, barHeight, barSpacing, textSize,
                               tostring(powerShown), tostring(hideClassArt),
                               tostring(ns.GetBarFillTexture and ns.GetBarFillTexture()),
                               tostring(ns.GetClassArtStyle and ns.GetClassArtStyle()) }, "|")
    -- On a memo hit, only bars that ResetBar has stripped need the loop -- and
    -- usually there are none, so this is a cheap scan of a boolean per bar.
    -- On a miss, everything is relaid out regardless.
    local hit = (fs._geomKey == key)
    fs._geomKey = key

    for i = 1, fs.maxBars do
        local bar = fs.bars[i]
        if not hit or bar._needsGeometry then
            bar._needsGeometry = nil
            bar:ClearAllPoints()
            bar:SetPoint("CENTER", fs.container, "TOPLEFT",
                barWidth / 2,
                -((i - 1) * (barHeight + barSpacing) + barHeight / 2))

            ns.ApplyBarLayout(bar, barWidth, barHeight, textSize, powerShown, hideClassArt)

            -- Ping button lives OUTSIDE the card, immediately right of it.
            bar.pingBtn:ClearAllPoints()
            bar.pingBtn:SetSize(barHeight - 2, barHeight - 2)
            bar.pingBtn:SetPoint("LEFT", bar, "RIGHT", 1, 0)
            bar.pingBtn.icon:ClearAllPoints()
            bar.pingBtn.icon:SetAllPoints(bar.pingBtn)
        end
    end
end

-- The roster half: container height from the active count, plus the heading.
-- Cheap -- one SetSize and one heading refresh -- so it is fine at 10Hz.
function ApplyRosterSizing(fs)
    local g          = fs.g
    local barWidth   = g.barWidth()
    local barHeight  = g.barHeight()
    local barSpacing = g.barSpacing()

    -- While the intro panel is up, ShowIntroPanel owns the container's height --
    -- it sized it to caption + crest + pad. Collapsing it to one bar here, which
    -- happens on any roster change below the intro threshold (one enemy in, say),
    -- leaves the caption floating in a 35px strip and reads as the intro not
    -- showing at all.
    local introUp = fs.container.introPanel and fs.container.introPanel:IsShown()
    if not introUp then
        local active = fs.active
        local totalH = active > 0 and (active * barHeight + (active - 1) * barSpacing) or barHeight
        fs.container:SetSize(barWidth, totalH)
    end

    local factionIndex
    if ns.testModeActive then
        factionIndex = fs.isEnemy and 0 or 1
    else
        local myTeam = GetMyTeamFaction()
        factionIndex = fs.isEnemy and (1 - myTeam) or myTeam
    end
    ApplyHeading(fs, factionIndex, fs.scoreCount > 0 and fs.scoreCount or active)
end

function ApplySizing(fs)
    ApplyBarGeometry(fs)
    ApplyRosterSizing(fs)
end

function ApplyPosition(fs)
    local pos = fs.g.framePos()
    if pos then
        fs.container:ClearAllPoints()
        fs.container:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", pos.x, pos.y)
        if fs.container.introPanel then
            fs.container.introPanel:ClearAllPoints()
            fs.container.introPanel:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", pos.x, pos.y)
        end
    end
end

function ns.ApplyPositionToAll()
    for _, fs in pairs(frameSets) do
        ApplyPosition(fs)
    end
end

function ResetBar(bar)
    ns.StopDeathDrain(bar)
    bar._renderWasDead = nil
    bar._lastDrainAt = nil
    bar:SetAttribute("unit", nil)
    bar._unit        = nil
    bar.assignedName = nil
    bar.assignedGUID = nil
    bar._isTarget    = false
    bar._isHover     = false
    -- TEST-MODE STATUS OVERRIDES. GetBarStatus checks these BEFORE the tracker,
    -- so a bar that was the stealth or dead demo in test mode reports that status
    -- forever once it is reused by a live battleground -- and nothing can shift
    -- it, because pairing, targeting and death all write to ns.tracker, which
    -- never gets read while these are set. Recycling a bar must clear them.
    bar._testStealth = nil
    bar._testDead    = nil
    -- This reset strips the round masks and the fill texture below and relies on
    -- the next ApplyBarLayout to put them back. ApplyBarGeometry is memoised on
    -- the settings, so a bar reset here and reassigned later would otherwise
    -- come back with no rounded corners. Flag it; ApplyBarGeometry checks.
    bar._needsGeometry = true
    -- Per-player state that assignment overwrites but recycling did not clear.
    -- Not a live bug -- both are set again before they are read -- but the same
    -- class as _dockZoom and _testStealth were before they bit.
    bar._classToken  = nil
    bar._deadSince   = nil
    -- The stamped word and its backing. ComposeLabel hides these every tick when
    -- there is no status, so they have never been seen stuck -- but a recycled
    -- bar does not get a ComposeLabel pass until it is reassigned, and this is
    -- the third kind of bar state today that outlived the thing that set it.
    if bar.statusText     then bar.statusText:Hide()     end
    if bar.artStatusBack  then bar.artStatusBack:Hide()  end
    if bar.artStatusGlow  then bar.artStatusGlow:Hide()  end
    bar._artStatus = nil
    -- Zoom state travels with the bar unless it is cleared here. A recycled bar
    -- kept _dockZoom and _pressed, and nothing re-applied the scale, so the next
    -- player to land on this bar inherited the previous one's magnification.
    bar._dockZoom    = nil
    bar._pressed     = nil
    ns.ApplyBarScale(bar)
    ns.HideGlow(bar)
    if bar.pvpIconFrame then bar.pvpIconFrame:Hide(); bar.pvpIconFrame:SetSize(32, 32) end
    if bar.pvpIconGlow then
        bar.pvpIconGlow:Hide()
        if bar.pvpIconGlow.animGroup and bar.pvpIconGlow.animGroup:IsPlaying() then
            bar.pvpIconGlow.animGroup:Stop()
        end
    end
    if bar.pvpIconCooldown then bar.pvpIconCooldown:Clear() end
    if bar.pvpIconStackText then bar.pvpIconStackText:Hide() end
    if bar.pvpIconFlip then
        bar.pvpIconFlip:Stop()
        bar.pvpIcon:SetTexCoord(0, 1, 0, 1) -- flipbook leaves a subframe crop behind
    end
    if bar.classArt then
        bar.classArt:Hide()
        -- Desaturation and vertex colour reset as a PAIR -- the stealth tint
        -- sets both, so clearing one and not the other leaves a recycled bar
        -- greyscale or purple.
        bar.classArt:SetDesaturated(false)
        bar.classArt:SetVertexColor(1, 1, 1)
        if bar.classArtShadow then
            bar.classArtShadow:Hide()
            bar.classArtShadow:SetDesaturated(false)
            bar.classArtShadow:SetVertexColor(1, 1, 1)
        end
        bar.healthBar:SetStatusBarTexture("Interface\\RaidFrame\\Raid-Bar-Hp-Fill")
        -- Invalidate the fill cache: ApplyBarLayout skips re-setting when it
        -- believes the texture is already correct, and this just changed it.
        bar._fillTex = nil
        bar._roundActive, bar._featherActive = nil, nil
    end
    bar.nameText:SetTextColor(1, 1, 1, 1)
    if bar._featherActive then
        bar.healthBar:GetStatusBarTexture():RemoveMaskTexture(bar.edgeFeather)
        bar._featherActive = nil
    end
    if bar.healthBgCap then bar.healthBgCap:Hide() end
    if bar.healthBg then
        bar.healthBg:ClearAllPoints()
        bar.healthBg:SetAllPoints(bar.healthBar)
        bar.healthBg:SetColorTexture(0.1, 0.1, 0.1, 0.3)
    end
    if bar._roundActive then
        bar.visualBg:RemoveMaskTexture(bar.roundMaskV)
        bar.visualSheen:RemoveMaskTexture(bar.roundMaskV)
        bar.healthBg:RemoveMaskTexture(bar.roundMaskH)
        bar.healthBgCap:RemoveMaskTexture(bar.roundMaskH)
        bar.healthBar:GetStatusBarTexture():RemoveMaskTexture(bar.roundMaskH)
        bar._roundActive = nil
    end
    bar.nameText:SetText("")
    bar.classIcon:Hide()
    bar.syncText:Hide()
    bar.healthBar:SetValue(0)
    bar.powerBar:SetValue(0)
    bar.visual:SetScale(1.0)
    bar.highlight:Hide()
    bar.pingBtn:Hide()
    bar:Hide()
end

-- Right-click focus for an enemy bar. Mirrors SetEnemyPingAttributes: the
-- target is hijacked for one line and restored, with two variants because
-- restoring differs depending on whether there was a target to begin with.
--
-- /focus [@target,exists] rather than a bare /focus target, and the guard is
-- load-bearing: /targetexact FAILS against a stealthed enemy -- that failure is
-- how this addon detects stealth at all -- and an unguarded /focus would then
-- fire with no target and CLEAR whatever focus the player already had.
function SetEnemyFocusAttributes(bar, fullName)
    local fhas = "/cleartarget\n/targetexact " .. fullName
                 .. "\n/focus [@target,exists]\n/targetlasttarget"
    local fno  = "/targetexact " .. fullName
                 .. "\n/focus [@target,exists]\n/cleartarget"
    bar:SetAttribute("focusmacro-hastarget", fhas)
    bar:SetAttribute("focusmacro-notarget",  fno)
    bar:SetAttribute("macrotext2", UnitExists("target") and fhas or fno)
end

function SetEnemyPingAttributes(bar, fullName, g)
    local pingBtn   = bar.pingBtn
    local leftType  = g.pingLeft()
    local rightType = g.pingRight()

    local m1has = "/cleartarget\n/targetexact " .. fullName .. "\n/ping [@target] " .. leftType  .. "\n/targetlasttarget"
    local m1no  = "/targetexact " .. fullName .. "\n/ping [@target] " .. leftType  .. "\n/cleartarget"
    local m2has = "/cleartarget\n/targetexact " .. fullName .. "\n/ping [@target] " .. rightType .. "\n/targetlasttarget"
    local m2no  = "/targetexact " .. fullName .. "\n/ping [@target] " .. rightType .. "\n/cleartarget"

    pingBtn:SetAttribute("type1", "macro")
    pingBtn:SetAttribute("type2", "macro")
    pingBtn:SetAttribute("pingmacro1-hastarget", m1has)
    pingBtn:SetAttribute("pingmacro1-notarget",  m1no)
    pingBtn:SetAttribute("pingmacro2-hastarget", m2has)
    pingBtn:SetAttribute("pingmacro2-notarget",  m2no)
    pingBtn:SetAttribute("macrotext1", UnitExists("target") and m1has or m1no)
    pingBtn:SetAttribute("macrotext2", UnitExists("target") and m2has or m2no)
    pingBtn:SetAttribute("_onstate-targetexists", [[
        local has = newstate == "hastarget"
        self:SetAttribute("macrotext1", self:GetAttribute(has and "pingmacro1-hastarget" or "pingmacro1-notarget"))
        self:SetAttribute("macrotext2", self:GetAttribute(has and "pingmacro2-hastarget" or "pingmacro2-notarget"))
    ]])
    RegisterStateDriver(pingBtn, "targetexists", "[@target, exists] hastarget; notarget")
end

function SetFriendlyPingAttributes(bar, unit, g)
    local pingBtn   = bar.pingBtn
    local leftType  = g.pingLeft()
    local rightType = g.pingRight()

    local m1has = "/cleartarget\n/target " .. unit .. "\n/ping [@target] " .. leftType  .. "\n/targetlasttarget"
    local m1no  = "/target " .. unit .. "\n/ping [@target] " .. leftType  .. "\n/cleartarget"
    local m2has = "/cleartarget\n/target " .. unit .. "\n/ping [@target] " .. rightType .. "\n/targetlasttarget"
    local m2no  = "/target " .. unit .. "\n/ping [@target] " .. rightType .. "\n/cleartarget"

    pingBtn:SetAttribute("type1", "macro")
    pingBtn:SetAttribute("type2", "macro")
    pingBtn:SetAttribute("pingmacro1-hastarget", m1has)
    pingBtn:SetAttribute("pingmacro1-notarget",  m1no)
    pingBtn:SetAttribute("pingmacro2-hastarget", m2has)
    pingBtn:SetAttribute("pingmacro2-notarget",  m2no)
    pingBtn:SetAttribute("macrotext1", UnitExists("target") and m1has or m1no)
    pingBtn:SetAttribute("macrotext2", UnitExists("target") and m2has or m2no)
    pingBtn:SetAttribute("_onstate-targetexists", [[
        local has = newstate == "hastarget"
        self:SetAttribute("macrotext1", self:GetAttribute(has and "pingmacro1-hastarget" or "pingmacro1-notarget"))
        self:SetAttribute("macrotext2", self:GetAttribute(has and "pingmacro2-hastarget" or "pingmacro2-notarget"))
    ]])
    RegisterStateDriver(pingBtn, "targetexists", "[@target, exists] hastarget; notarget")
end

-- POOLED SCAN TABLES. UpdateEnemyFrameSet runs at 10Hz and used to build
-- `entries`, one record per scoreboard row, and `present` fresh on every tick --
-- before it ever reached the change guard. In a 40-man epic that is ~42 tables
-- a tick, 420 a second, nearly all discarded unchanged. The row records are
-- reused by index and wiped in place; anything past this tick's count is left
-- allocated but unread. The scan itself must still run every tick -- it
-- refreshes enemySpecs and friendlyClasses -- only the allocation was waste.
local scanEntries = {}
local scanSpare   = {}   -- record tables trimmed off the tail, for reuse
local scanPresent = {}

function UpdateEnemyFrameSet(fs)
    local g = fs.g
    if not g.enable() then
        fs.container:Hide()
        return
    end

    local count = GetNumBattlefieldScores()
    if count == 0 then return end

    local myTeam = GetMyTeamFaction()

    local entries = scanEntries
    local nEntries = 0
    for i = 1, count do
        local info = C_PvP.GetScoreInfo(i)
        -- `info.name ~= ""` IS THE GHOST BAR FIX. The scoreboard hands back rows
        -- with EMPTY STRINGS for name, classToken and raceName -- seen live with
        -- 20 rows of which 17 were blanks. An empty string is TRUTHY in Lua, so
        -- `if info.name` waved every one of them through, and each built a bar
        -- with no name, no class token, and RAID_CLASS_COLORS[""] falling back to
        -- grey. That is the "grey bar with nothing on it" exactly, and the
        -- heading disagreed with it because fs.scoreCount counted the blanks.
        if info and info.name and info.name ~= "" then
            local name  = info.name
            local short = name:match("^([^%-]+)") or name
            if info.faction ~= myTeam then
                ns.enemySpecs[short] = info.talentSpec
                local classToken = info.classToken
                -- Same truthiness trap: "" would pass and then key the sort and
                -- the colour lookup on an empty string.
                if classToken and classToken ~= "" then
                    nEntries = nEntries + 1
                    local e = entries[nEntries]
                    if not e then
                        e = table.remove(scanSpare) or {}
                        entries[nEntries] = e
                    end
                    e.name       = short
                    e.fullName   = name
                    e.classToken = classToken
                        -- raceName is not collected any more: its only readers
                        -- were the class:race fingerprint and the stealth race
                        -- gate, both gone. guid stays -- it feeds
                        -- bar.assignedGUID, which the ping path reads.
                    e.guid       = info.guid
                end
            else
                ns.friendlySpecs[short] = info.talentSpec
                -- Team targeting and the stealth reveal both colour by the
                -- TEAMMATE's class, looked up by name.
                if info.classToken and info.classToken ~= "" then
                    ns.friendlyClasses[name]  = info.classToken
                    ns.friendlyClasses[short] = info.classToken
                end
            end
        end
    end

    -- Treat a transient zero-enemy scoreboard return as noise once we've
    -- already populated. The scoreboard occasionally returns nothing-but-
    -- friendlies for a tick during waiting-room flux; without this guard
    -- the heading flickers to "(0)" and every bar is reset/hidden, then
    -- repopulated on the next tick.
    -- Trim the pool to this tick's count. Rows past nEntries are stale records
    -- from a larger earlier scan; nilling them keeps #entries, ipairs and
    -- table.sort honest with no changes downstream. The record TABLES are kept
    -- in a side pool so they are not reallocated when the count grows again.
    for i = nEntries + 1, #entries do
        scanSpare[#scanSpare + 1] = entries[i]
        entries[i] = nil
    end

    if #entries == 0 and (fs.active or 0) > 0 then return end

    -- Role assignment was removed entirely, so the sort is class bucket then
    -- name -- stable, and independent of anything we have to infer.
    table.sort(entries, function(a, b)
        if a.classToken ~= b.classToken then return a.classToken < b.classToken end
        return a.name < b.name
    end)

    fs.scoreCount = #entries

    local inCombat = InCombatLockdown()

    -- Always diff — run whether in combat or not
    local present = scanPresent
    wipe(present)
    for _, e in ipairs(entries) do present[e.fullName] = true end

    -- layoutDirty forces a full re-run regardless of the diff, and it is the
    -- reason the GHOST BAR is fixed rather than merely less likely.
    --
    -- This function runs in two halves: everything above is combat-safe and
    -- runs unconditionally, while bar:Show(), the stale-bar reset and
    -- `fs.active` are all gated on `not inCombat` further down. So a scan that
    -- lands mid-combat populates the bars and writes fs.names[i] but never
    -- shows/hides anything and never advances fs.active.
    --
    -- The diff below is then a TRAP: on the next tick #entries matches the
    -- stale fs.active and every fs.names[i] matches too -- because the half
    -- that ran already wrote them -- so `changed` is false, this returns at the
    -- guard below, and the layout stays wedged until the roster happens to
    -- change again. Meanwhile the heading keeps updating from fs.scoreCount,
    -- which is what puts "Horde - 1" over a bar the departed-player pass above
    -- has greyed out and nothing is left to reset.
    local changed = fs.layoutDirty or (#entries ~= fs.active)
    if not changed then
        for i, e in ipairs(entries) do
            if fs.names[i] ~= e.fullName then changed = true; break end
        end
    end

    -- The "Player Left" branch that used to live here is gone. It keyed on
    -- SCOREBOARD ABSENCE, which is not the same as leaving -- a transient gap
    -- greyed a live player's bar and zeroed their health -- and the notes
    -- record it as removed on 2026-08-15. It had survived the rebuild, and was
    -- one of four separate writers of the health bar's value.

    -- Update heading (always safe)
    local factionIndex = fs.isEnemy and (1 - myTeam) or myTeam
    ApplyHeading(fs, factionIndex, fs.scoreCount)

    if not changed then return end

    -- CLAMPED TO THE POOL. fs.bars only has maxBars entries, so an entry count
    -- above it indexed nil and threw -- which is what the blank rows above did,
    -- inflating a 15-bar set to 17. The blanks are filtered now, but the clamp
    -- stays: a real overflow should drop the surplus bars, not kill the ticker
    -- and everything sequenced after it.
    local active = math.min(#entries, fs.maxBars)

    for i = 1, active do
        local e   = entries[i]
        local bar = fs.bars[i]
        local cc  = RAID_CLASS_COLORS[e.classToken] or { r = 0.8, g = 0.8, b = 0.8 }
        -- Visual updates — all safe in combat. Colour only: the VALUE is
        -- RenderEnemyHealth's alone, and it runs later this same tick.
        bar.healthBar:SetStatusBarColor(cc.r, cc.g, cc.b)

        local iconPath = CLASS_ICON[e.classToken]
        if iconPath then
            bar.classIcon:SetTexture(iconPath)
            bar.classIcon:Show()
        else
            bar.classIcon:Hide()
        end

        -- SCOREBOARD MEMBERSHIP SET -- the value is `true`, not a record.
        --
        -- It used to cache class/race/guid, for two consumers that are both gone:
        -- the class:race fingerprint pairing (retired when 12.1 made UnitClass
        -- and UnitRace secret) and the stealth class gate (removed once
        -- /targetexact turned out to be a true targetability test). Nothing has
        -- read a field since, and every surviving use is a truthiness test --
        -- "is this name an enemy in THIS match" -- which is the only question
        -- this table has left to answer.
        ns.playerKeys[e.fullName] = true
        -- The sync prompt is gone with honor-based pairing: names pair on
        -- sight, so there is never an entry waiting on data you have to go
        -- and fetch by clicking it.
        if bar.syncText then bar.syncText:Hide() end

        -- Lua fields — safe in combat
        bar.fullName     = e.fullName
        barByName[e.fullName] = bar
        bar.nameText:SetText(e.name)
        bar.assignedName = e.name
        bar.assignedGUID = e.guid
        -- Stamped so the per-tick suppression pass can repaint the card
        -- without re-reading the roster.
        bar._classToken  = e.classToken
        fs.names[i]      = e.fullName

        -- Test-mode overrides shadow ns.tracker inside GetBarStatus, and clearing
        -- them in ResetBar is NOT sufficient: ResetBar only runs on bars past
        -- `active`, so a bar handed to a real player never sees it. Assignment is
        -- the one point every live bar passes through, whatever route it took.
        bar._testStealth = nil
        bar._testDead    = nil

        -- Secure operations — combat locked
        if not inCombat then
            bar:SetAttribute("macrotext1", "/targetexact " .. e.fullName)
            SetEnemyFocusAttributes(bar, e.fullName)
            if g.pingEnabled() then
                SetEnemyPingAttributes(bar, e.fullName, g)
                bar.pingBtn.zoomAmount = g.pingZoom()
                bar.pingBtn:Show()
            else
                bar.pingBtn:Hide()
            end
            bar:Show()
        end
    end

    if not inCombat then
        for i = active + 1, fs.maxBars do
            local staleBar = fs.bars[i]
            if staleBar.fullName then
                barByName[staleBar.fullName] = nil
                staleBar.fullName = nil
            end
            ResetBar(staleBar)
            fs.names[i] = nil
        end

        fs.active = active
        fs.container:SetAttribute("maxUnits", active)
        fs.container:SetAttribute("playerIndex", 1)
        for i = 1, active do
            fs.container:SetAttribute("targetCmd"..i, "/targetexact " .. entries[i].fullName)
        end
        for i = active + 1, fs.maxBars do
            fs.container:SetAttribute("targetCmd"..i, nil)
        end
        -- Geometry is memoised on the settings, so on a load-in this is a key
        -- compare plus one boolean per bar -- it only does real work for bars
        -- ResetBar has stripped, which need their masks back before reuse.
        ApplyBarGeometry(fs)
        ApplyRosterSizing(fs)
        if active > 0 then fs.container:Show() end
        fs.layoutDirty = nil
    else
        -- The secure half could not run. Record the debt so the next call
        -- rebuilds unconditionally instead of being talked out of it by a diff
        -- against state this call already half-wrote.
        fs.layoutDirty = true
    end
end

function UpdateFriendlyFrameSet(fs)
    if InCombatLockdown() then return end

    local g = fs.g
    if not g.enable() then
        fs.container:Hide()
        return
    end

    local entries = {}
    local prefix  = IsInRaid() and "raid" or (IsInGroup() and "party" or nil)
    if not prefix then return end

    local maxUnit = prefix == "raid" and 40 or 4
    for i = 1, maxUnit do
        local unit = prefix .. i
        if UnitExists(unit) then
            local name = UnitName(unit)
            if name then
                -- Scoreboard FIRST, because C_PvP.GetScoreInfo hands back a plain
                -- string while UnitClass is identity-gated. That is source
                -- selection, not a guard -- it is the "do not ask a secret
                -- source" half, and it stays.
                --
                -- The UnitClass fallback covers the window before the first scan
                -- lands and is deliberately NOT wrapped in issecretvalue. A group
                -- member answers plainly today; if that ever changes, the `or`
                -- default below and the sort's `<` and `~=` should throw and say
                -- so, rather than silently painting the whole friendly column
                -- WARRIOR-red and looking like an art bug.
                local classToken = ns.friendlyClasses[name]
                if not classToken then
                    local _, uc = UnitClass(unit)
                    classToken = uc
                end
                table.insert(entries, {
                    unit       = unit,
                    name       = name:match("^([^%-]+)") or name,
                    classToken = classToken or "WARRIOR",
                })
            end
        end
    end

    -- Role assignment was removed, so the sort is class bucket then name.
    table.sort(entries, function(a, b)
        if a.classToken ~= b.classToken then return a.classToken < b.classToken end
        return a.name < b.name
    end)

    local active = math.min(#entries, fs.maxBars)

    for i = 1, active do
        local e   = entries[i]
        local bar = fs.bars[i]
        -- Friendly bars need this too: RenderBarSuppression runs on the friendly
        -- set as well, so a stale test override shows a live teammate as dead or
        -- stealthed in exactly the same way.
        bar._testStealth = nil
        bar._testDead    = nil
        local cc  = RAID_CLASS_COLORS[e.classToken] or { r = 0.8, g = 0.8, b = 0.8 }

        bar:SetAttribute("unit", e.unit)
        bar._unit        = e.unit
        bar.assignedName = e.name
        bar.assignedGUID = UnitGUID(e.unit)

        bar.healthBar:SetStatusBarColor(cc.r, cc.g, cc.b)
        bar.healthBar:SetMinMaxValues(0, UnitHealthMax(e.unit))
        bar.healthBar:SetValue(UnitHealth(e.unit))

        bar._classToken = e.classToken
        bar.classIcon:SetTexture(CLASS_ICON[e.classToken] or "Interface\\Icons\\ClassIcon_Warrior")
        bar.classIcon:Show()

        bar.syncText:Hide()

        bar.nameText:SetText(e.name)

        if g.pingEnabled() then
            SetFriendlyPingAttributes(bar, e.unit, g)
            bar.pingBtn.zoomAmount = g.pingZoom()
            bar.pingBtn:Show()
        else
            bar.pingBtn:Hide()
        end

        bar:Show()
        fs.names[i] = e.unit
    end

    for i = active + 1, fs.maxBars do
        ResetBar(fs.bars[i])
        fs.names[i] = nil
    end

    fs.active = active
    fs.container:SetAttribute("maxUnits", active)
    fs.container:SetAttribute("playerIndex", 1)
    for i = 1, active do
        fs.container:SetAttribute("targetCmd"..i, "/target " .. entries[i].unit)
    end
    for i = active + 1, fs.maxBars do
        fs.container:SetAttribute("targetCmd"..i, nil)
    end
    ApplyBarGeometry(fs)    -- memoised: cheap unless a bar was reset
    ApplyRosterSizing(fs)
    if active > 0 then fs.container:Show() end
end

function GetDeadAutoClearDuration()
    if C_PvP.IsSoloRBG and C_PvP.IsSoloRBG() then return 17 end
    if C_PvP.IsRatedBattleground and C_PvP.IsRatedBattleground() then return 25 end
    return nil
end

function MarkEntryDead(entry)
    if entry.isDead then return end
    entry.isDead = true
    entry.isStealth = false
    entry.revealOwedAt = nil   -- a corpse is not hiding; a fresh stealth re-arms it
    -- A DEATH IS INFORMATION: they will come back at full. So record 100% as the
    -- last known health rather than merely dropping the stale reading.
    --
    -- Dropping it alone is not wrong -- RenderEnemyHealth falls through to a full
    -- bar when hasHealth is false -- but it says "we have never seen them", which
    -- is a different claim, and it only looks right because the fallback happens
    -- to mean full. This states it positively.
    --
    -- It matters most for the case that has no other answer: in a REGULAR
    -- battleground there is no respawn duration to expire the dead flag, so a
    -- player can be re-found by a COMPOUND token -- raid(i)target, which proves
    -- they are alive but cannot report health. With nothing cached the bar would
    -- be relying on a fallback to look right; with this it is reporting the last
    -- thing we actually knew.
    --
    -- Plain 1/1 rather than a secret: SetMinMaxValues and SetValue take either.
    entry.lastHealthMax, entry.lastHealth, entry.hasHealth = 1, 1, true
    -- Power gets no such treatment. Respawn power is class-dependent -- mana full,
    -- rage empty, energy full -- and unknowable from here, so it stays cleared and
    -- renders empty until a real token reports it.
    entry.lastPower,  entry.lastPowerMax,  entry.hasPower  = nil, nil, nil
    entry.deadTime = GetTime()
    entry.deadDuration = nil
    local expiry = GetDeadAutoClearDuration()
    if expiry then
        entry.deadDuration = expiry
        entry.deadExpiry = (entry.deadExpiry or 0) + 1
        local thisExpiry = entry.deadExpiry
        C_Timer.After(expiry, function()
            if entry.deadExpiry == thisExpiry and entry.isDead then
                entry.isDead = false
                entry.deadTime = nil
                entry.deadDuration = nil
                entry.deadExpiry = nil
            end
        end)
    end
end

function MarkEntryAlive(entry)
    if not entry.isDead then return end
    entry.isDead = false
    entry.deadTime = nil
    entry.deadDuration = nil
    entry.deadExpiry = nil
end

-- Unit tokens, built once at load.
--
-- The sweeps below run at 10Hz over 40 units each, and "raid" .. i .. "target"
-- allocates two strings per iteration. The tokens never change, so none of that
-- allocation has to happen. Same reasoning as ARENA_UNITS.
local NAMEPLATE_UNITS = {}
local GROUP_UNITS     = { raid = {}, party = {} }   -- "raid7"
local GROUP_TARGETS   = { raid = {}, party = {} }   -- "raid7target"
local MATE_OF_TARGET  = {}                          -- "raid7target" -> "raid7"
for i = 1, 40 do
    NAMEPLATE_UNITS[i] = "nameplate" .. i
    for _, pre in ipairs({ "raid", "party" }) do
        local unit   = pre .. i
        local target = unit .. "target"
        GROUP_UNITS[pre][i]    = unit
        GROUP_TARGETS[pre][i]  = target
        MATE_OF_TARGET[target] = unit
    end
end

-- Stealth inference, shared by both ways of targeting from a bar (a direct
-- click and a mousewheel scroll). Runs AFTER the secure action, so it can
-- simply ask whether we landed on who we asked for.
-- `unit` is which token carries the proof, defaulting to "target".
--
-- Left click targets them, so the target is the evidence. Right click FOCUSES
-- them and hands the target straight back, so the focus is the evidence -- and
-- that makes right click a stealth probe that never moves your target, which
-- the left-click one cannot be.
--
-- One case is deliberately unprovable: if the focus was ALREADY this player
-- from an earlier click, a failed attempt leaves it unchanged and looks like
-- success. That errs toward NOT claiming stealth, which is the safe direction,
-- and the next probe of either kind corrects it.
function VerifyTargetOrMarkStealth(fullName, unit)
    unit = unit or "target"
    if not fullName then return end
    if UnitIsDeadOrGhost("player") then return end

    -- Scoreboard presence is the only roster test left. It still matters: it
    -- means they are in this match, so someone who left cannot be flagged.
    if not (ns.playerKeys and ns.playerKeys[fullName]) then return end

    local entry = ns.tracker[fullName]
    if entry and entry.isDead then return end

    -- UnitIsPlayer first: you can be targeting a pet or totem, and UnitName is
    -- only exempt from secrecy for player units.
    if UnitExists(unit) and UnitIsPlayer(unit) ~= false then
        local landedName = NormalizeUnitName(unit)
        -- A nil name here means secret, not absent -- we genuinely cannot tell,
        -- so infer nothing rather than guessing stealth.
        if landedName == nil then return end
        if landedName == fullName then return end   -- landed on them
    end

    -- The token is empty or holds someone else: the action did not take.
    if not entry then
        entry = {}
        ns.tracker[fullName] = entry
    end
    entry.isStealth = true
    -- SEPARATE FROM isStealth ON PURPOSE. isStealth is a RENDER state -- it
    -- drives the fog, the held health and the trough tint -- and any
    -- successful pair clears it, including our own nameplate or target. That
    -- clearing is correct for rendering and was wrong for the reveal: if our
    -- own nameplate happened to spot them first, the flag was gone before any
    -- team mate could be credited and the reveal never fired at all.
    --
    -- This latch is the OTHER question: is a reveal still owed for this
    -- stealth? It survives us re-finding them and is spent only when a team
    -- mate actually targets them (or on death, or on expiry).
    entry.revealOwedAt = GetTime()
end

-- isCompound flags raid(i)target tokens, which Blizzard blocks for
-- UnitHealth/UnitPower; the renderer skips those.
function CheckUnit(unit, isCompound)
    if not UnitExists(unit) then return nil end

    -- THE UnitName EXEMPTION IS PER-UNIT, NOT PER-MATCH. UnitName uses the
    -- SecretWhenUnitNameIdentityRestricted predicate, which exempts PLAYER units
    -- in PvP -- so it is plain for a player and SECRET for a pet, totem, guard
    -- or any other NPC. A battleground is full of those, and every token here
    -- lands on them constantly: mouseover, target, and above all raid(i)target,
    -- which follows a teammate onto whatever they are hitting.
    --
    -- This check has to come BEFORE the name is read. Filtering through
    -- playerKeys below does not help -- that runs after NormalizeUnitName, and
    -- the name lookup is the thing that throws.
    --
    -- `== false` rather than `not`, deliberately: compound tokens can return
    -- NIL from UnitIsPlayer while UnitName still resolves their player endpoint
    -- fine, so only an explicit false is a rejection.
    if UnitIsPlayer(unit) == false then return nil end

    -- 12.1 made UnitName non-secret inside a PvP match while making UnitClass
    -- and UnitRace secret, which retired the old class:race fingerprinting
    -- entirely. playerKeys holds only scoreboard enemies, so the lookup also
    -- filters out friendlies and any player who is not in this match.
    local fullName = NormalizeUnitName(unit)
    if not fullName then return nil end
    if not ns.playerKeys[fullName] then return nil end

    local entry = ns.tracker[fullName]
    if not entry then
        entry = {}
        ns.tracker[fullName] = entry
    end

    -- Dead/alive detection runs every match (including compound tokens) so any
    -- pair contributes, regardless of whether this token wins the pair slot.
    if UnitIsDeadOrGhost(unit) then
        MarkEntryDead(entry)
    else
        MarkEntryAlive(entry)
    end

    if entry.pairedUnit then return nil end

    -- Stealth EXIT edge, with attribution, taken before the flag is cleared.
    --
    -- Only a COMPOUND token can attribute: raid(i)target names the teammate who
    -- has them. Every other token in this sweep is one of OUR OWN views --
    -- target, focus, mouseover, nameplate, arena slot -- which is the "found
    -- them yourself" case. And because nameplates sweep BEFORE the compound
    -- pass, seeing them yourself takes the pair first and returns above, so no
    -- reveal fires. That is the "unless it is the user themselves" rule, for
    -- free, out of the existing sweep order.
    -- FIRST TEAM MATE TO TARGET THEM WINS, regardless of what found them in
    -- between. The gate is the latch rather than isStealth (see
    -- VerifyTargetOrMarkStealth); "excluding you" still comes free from
    -- isCompound, because our own target, mouseover and nameplate tokens are
    -- all SIMPLE -- only raid(i)target is compound, and that is by definition
    -- somebody else's target.
    if isCompound and entry.revealOwedAt
       and (GetTime() - entry.revealOwedAt) < ns.REVEAL_OWED_WINDOW then
        -- Re-arm guard, per enemy. Without it a Vanish-garrote-reappear inside
        -- the 4s window overwrites the running reveal: Bob's reveal is 2s in,
        -- the rogue vanishes and pops, Daniel touches them first, and the card
        -- restarts with Daniel's name. The first finder -- the one who actually
        -- broke the stealth -- loses the credit, and the same happens with
        -- Shadowmeld or Invisibility used mid-fight rather than to hide.
        --
        -- Keyed on the last reveal rather than on how long they stayed hidden.
        -- Stealth duration would look like the better test and is not:
        -- entry.isStealth records when WE noticed they were missing, not when
        -- they stealthed, so a late detection would suppress a perfectly good
        -- first reveal. Keying on the previous reveal can only ever suppress a
        -- SECOND one, which is exactly the case worth losing.
        --
        -- revealArmedAt is separate from revealAt because the renderer clears
        -- revealAt the moment the reveal expires -- it could not answer "did one
        -- just run" a second later.
        local last = entry.revealArmedAt
        if not (last and (GetTime() - last) < ns.REVEAL_REARM) then
            local mate = MATE_OF_TARGET[unit]
            local mateName = mate and NormalizeUnitName(mate)
            if mateName then
                -- Realm stripped for display: the card is narrow and the realm
                -- is never the point.
                entry.revealBy      = mateName:match("^[^-]+") or mateName
                entry.revealClass   = ns.friendlyClasses[mateName] or "WARRIOR"
                entry.revealAt      = GetTime()
                entry.revealArmedAt = entry.revealAt
                entry.revealOwedAt  = nil   -- spent: one reveal per stealth

            end
        end
    end

    entry.pairedUnit = unit
    entry.isCompound = isCompound or false
    entry.isStealth  = false            -- a successful pair clears the guess
    if unit == "target" then
        entry.isTarget = true
        -- TARGETING THEM YOURSELF SPENDS THE REVEAL. You found them; being
        -- told a moment later that someone else also has them is noise. Only
        -- "target" does this, deliberately -- a nameplate sighting or a
        -- mouseover is not you finding them, and those must NOT disarm it or
        -- we are back to the bug this latch was built to fix.
        entry.revealOwedAt = nil
    end

    -- Health/power passthrough inline. Compound tokens block UnitHealth and
    -- UnitPower so we skip them.
    --
    -- Cached so the bar can keep showing this reading once the token goes away.
    -- Caching a secret is no less safe than passing one straight through: it
    -- stays secret in the table and is only ever handed back to SetValue, never
    -- read or compared. `hasHealth` is a plain boolean precisely because IT is
    -- the thing that gets branched on.
    --
    -- NEVER CACHE WHILE DEAD. This was the health bug, found by /bft watch:
    -- MarkEntryDead above seeds the cache 1/1 (rule 2 -- they respawn full),
    -- and then this block, SAME CALL, overwrote it with UnitHealth of the
    -- corpse -- a secret 0. Token goes away, they respawn unseen, a compound
    -- token or the rated timer clears isDead, and the render draws the cache:
    -- secret 0, every alpha 1.00, masks intact. An empty bar over a class-tinted
    -- trough, which is exactly "full but faded". The rule was in the notes --
    -- "invalidate on death and never cache while dead" -- and the rebuild kept
    -- the first half and lost the second.
    if not isCompound and not entry.isDead then
        entry.lastHealthMax = UnitHealthMax(unit)
        entry.lastHealth    = UnitHealth(unit, true)
        entry.hasHealth     = true

        -- PLAIN UnitPower(unit). The working build read it with no extra
        -- arguments; the rebuild invented `UnitPower(unit, nil, true)`, and an
        -- explicit nil powerType plus the unmodified flag is not what was ever
        -- tested. UnitHealth(unit, true) below IS original -- health and power
        -- genuinely differ here, so do not "make them consistent".
        local powerMax = UnitPowerMax(unit)
        local power    = UnitPower(unit)
        local _, powerToken = UnitPowerType(unit)
        entry.lastPowerMax = powerMax
        entry.lastPower    = power
        entry.powerToken   = powerToken
        entry.hasPower     = true
    end

    return fullName
end

-- Who on our team is targeting each enemy. Rides raid(i)target and compares
-- NAMES -- UnitIsUnit is what 12.1 restricts, while UnitName resolves a
-- compound token's PLAYER endpoint fine.
--
-- This was briefly disabled on the theory that compound tokens always return
-- secret names. They do not. The secrecy follows the UNIT, not the token shape:
-- UnitName is exempt for player units in PvP, so raid1target is plain when the
-- teammate is on a player and secret when they are on a pet or totem -- which
-- in a battleground is most of the time. Hence NormalizeUnitName is called only
-- after UnitIsPlayer clears the endpoint, exactly as CheckUnit does it.
function ScanTeamTargeting()
    local want = activeEnemy and activeEnemy.g and activeEnemy.g.teamTargeting
                 and activeEnemy.g.teamTargeting()
    for _, entry in pairs(ns.tracker) do
        if entry.targetedBy then wipe(entry.targetedBy) end
        entry.targetedByCount = 0
    end
    if not want then return end

    local prefix = IsInRaid() and "raid" or (IsInGroup() and "party" or nil)
    if not prefix then return end

    local targets, mates = GROUP_TARGETS[prefix], GROUP_UNITS[prefix]
    for i = 1, 40 do
        -- Same rule as CheckUnit: a teammate on a pet or totem gives a secret
        -- name, so the endpoint has to clear UnitIsPlayer before it is read.
        local tUnit = targets[i]
        local tName = UnitIsPlayer(tUnit) ~= false and NormalizeUnitName(tUnit) or nil
        -- playerKeys is scoreboard ENEMIES only, so this filters out teammates
        -- targeting each other in one lookup.
        if tName and ns.playerKeys[tName] then
            local entry = ns.tracker[tName]
            if entry then
                local mate = NormalizeUnitName(mates[i])
                if mate then
                    entry.targetedBy = entry.targetedBy or {}
                    -- Capped at the display maximum: past that the list is
                    -- collected for nothing, and the count is shown instead.
                    local n = #entry.targetedBy
                    if n < ns.TEAM_TARGET_MAX then
                        entry.targetedBy[n + 1] = ns.friendlyClasses[mate] or "WARRIOR"
                    end
                    entry.targetedByCount = (entry.targetedByCount or 0) + 1
                end
            end
        end
    end
end

-- One full per-tick sweep: wipe volatile state, then scan tokens in priority
-- order. Returns the fullName the current target paired to, or nil.
function RunEnemyTokenSweep()
    for _, entry in pairs(ns.tracker) do
        entry.pairedUnit = nil
        entry.isCompound = false
        entry.isTarget   = false
    end

    local currentTargetFullName = CheckUnit("target")
    CheckUnit("focus")

    -- Both simple tokens, so both answer UnitHealth. Cheap, and they cover a
    -- player being inspected right now -- hovered, or picked up by soft
    -- targeting -- which is exactly when their health is being looked at.
    --
    -- "softenemy" simply does not exist when soft targeting is off; CheckUnit
    -- already bails on UnitExists, so no version guard is needed.
    CheckUnit("mouseover")
    CheckUnit("softenemy")

    for i = 1, 40 do CheckUnit(NAMEPLATE_UNITS[i]) end

    -- Arena slots, which in a battleground are the objective carriers. These
    -- are SIMPLE tokens, so unlike raid(i)target they answer UnitHealth -- and
    -- a carrier is exactly the player most likely to be across the map with no
    -- nameplate, i.e. the case the compound pass can only ever name.
    --
    -- Swept after nameplates so a nameplate still wins the pair when both
    -- exist (first-write-wins), and before the compound pass so a carrier
    -- prefers a token that can supply health over one that cannot.
    for i = 1, #ARENA_UNITS do CheckUnit(ARENA_UNITS[i]) end

    -- Compound pass, restored. It was briefly removed on the belief that
    -- UnitName always returns a secret for raid(i)target; it does not -- the
    -- secrecy follows the UNIT at the far end, and CheckUnit now rejects
    -- non-players before reading a name, which is what this pass was missing.
    --
    -- Worth remembering WHY it presented as a health bug: this loop sits at the
    -- END of the sweep, so a throw here aborted the whole enemy ticker OnUpdate
    -- and took TrackEnemyObjectives, RenderTargetGlow, RenderEnemyHealth,
    -- RenderBarSuppression, RenderEnemyOverlay and RefreshSpecText with it --
    -- on exactly the ticks a teammate was hitting a pet. Health rendered only
    -- on the ticks that did not throw.
    for i = 1, 40 do CheckUnit(GROUP_TARGETS.raid[i], true) end

    ScanTeamTargeting()

    return currentTargetFullName
end

function RenderTargetGlow(fs, targetFullName)
    if not fs or fs.active == 0 then return end
    for i = 1, fs.active do
        local bar = fs.bars[i]
        local shouldGlow = (targetFullName ~= nil and bar.fullName == targetFullName) or false
        if bar._isTarget ~= shouldGlow then
            bar._isTarget = shouldGlow
            UpdateBarGlow(bar)
        end
    end
end

function RenderFriendlyTargetGlow(fs)
    if not fs or fs.active == 0 then return end
    for i = 1, fs.active do
        local bar  = fs.bars[i]
        local unit = bar._unit
        local shouldGlow = (unit and UnitIsUnit("target", unit)) and true or false
        if bar._isTarget ~= shouldGlow then
            bar._isTarget = shouldGlow
            UpdateBarGlow(bar)
        end
    end
end

-- Friendly role icons: per-tick refresh from UnitGroupRolesAssigned. Cheap
-- local read, no API request. NONE / unassigned roles hide the icon.

-- SOLE OWNER of the enemy health and power bar VALUES. Nothing else writes them
-- on a live bar (ResetBar zeroes a recycled one; test mode drives its own).
--
-- THE HEALTH MODEL -- four rules, in priority order. The cache
-- (entry.lastHealth / lastHealthMax / hasHealth) is the mechanism; these are
-- the contract it implements. Written down because a rebuild once lost a rule
-- and the code kept running without it.
--
--   1. DEAD -> drawn EMPTY. An overlay of state; the number underneath is
--      irrelevant while the flag is set. Stealth USED to share this rule;
--      since 2026-08-19 it holds last-known instead (rules 2-4), so the card
--      reads as a misty version of the normal bar -- the mist and the label
--      carry the state, the values stay honest to the last reading.
--   2. DEAD -> ALIVE, by any proof (compound token, simple token, rated respawn
--      timer) -> last-known becomes 100%. They respawned full. Mechanism:
--      MarkEntryDead seeds the cache 1/1, so the instant isDead clears, the
--      cache IS full.
--   3. OTHERWISE -> LAST KNOWN. A live simple token updates it (CheckUnit,
--      simple tokens only); a compound token or no token HOLDS it. Never snap
--      to full on losing sight. Compound goes through this branch like anything
--      else without a live reading -- a live 40% who becomes compound-paired
--      stays at 40%.
--   4. NEVER HAD A READING -> 100%. Only a fresh entry -- match start, or a
--      player we have had no token on. Not a revert: it is what "last known"
--      is before a first reading. Once any reading exists, rule 3 owns the
--      value until rule 2 rewrites it.
--
-- Every branch writes the whole min/max/value triple from ONE source, because
-- the sources differ in kind -- live readings are secrets, the seed and the
-- default are plain 1/1 -- and a plain max with a secret value is a real way to
-- draw nothing.
function RenderEnemyHealth(fs)
    if not fs or fs.active == 0 then return end
    for i = 1, fs.active do
        local bar   = fs.bars[i]
        local hb    = bar.healthBar
        local entry = bar.fullName and ns.tracker[bar.fullName]
        local isDead = entry and entry.isDead

        -- Death-drain edge detection: the frame a bar turns dead, capture the
        -- fill's rendered width BEFORE rule 1 zeroes it, and sweep a red
        -- ghost down from there. Cleared on any non-dead frame so a respawn
        -- mid-drain cancels cleanly.
        local wasDead = bar._renderWasDead
        bar._renderWasDead = isDead and true or nil
        if isDead and not wasDead then
            ns.StartDeathDrain(bar)
        elseif not isDead and wasDead then
            ns.StopDeathDrain(bar)
        end

        -- The drain ghost eats the same values as the real bar on every
        -- LIVE branch, so at the moment of death it is already holding the
        -- last-known amount -- fed, never read (see DEATH DRAIN).
        local ghost = bar.drainGhost
        if isDead then                                  -- rule 1
            hb:SetMinMaxValues(0, 1)
            hb:SetValue(0)
        elseif entry and entry.hasHealth then           -- rules 2 and 3
            hb:SetMinMaxValues(0, entry.lastHealthMax)
            hb:SetValue(entry.lastHealth)
            if ghost then
                ghost:SetMinMaxValues(0, entry.lastHealthMax)
                ghost:SetValue(entry.lastHealth)
            end
        else                                            -- rule 4
            hb:SetMinMaxValues(0, 1)
            hb:SetValue(1)
            if ghost then
                ghost:SetMinMaxValues(0, 1)
                ghost:SetValue(1)
            end
        end


        local pb = bar.powerBar
        if pb:IsShown() then
            if entry and entry.hasPower and not isDead then
                pb:SetMinMaxValues(0, entry.lastPowerMax)
                pb:SetValue(entry.lastPower)
                pb:SetStatusBarColor(ns.GetPowerColor(entry.powerToken))
            else
                pb:SetMinMaxValues(0, 1)
                pb:SetValue(0)
            end
        end
    end
end

-- Carrier inference: walk arena slots for the current BG, mark matching
-- enemy tracker entries with objective + objectivePickedUpAt. Cleanup
-- pass clears pickup state for entries that lost classification.

function TrackEnemyObjectives()
    -- ONE walk to clear, ONE walk to clean up, with the arena scan between --
    -- down from three. The old shape cleared in a first pass, then ran the
    -- identical cleanup loop in BOTH the no-map early-return and at the end, so
    -- every tick paid for it twice on top of the clear.
    --
    -- The clear cannot be merged into the cleanup: the cleanup keys on
    -- objective being nil, so the arena scan has to run in between to
    -- re-set it for anyone still carrying. But the two cleanups collapse into
    -- one that runs after the (possibly empty) scan, which is the same result on
    -- every path.
    for _, entry in pairs(ns.tracker) do entry.objective = nil end

    local bgName = select(8, GetInstanceInfo())
    local carrierMap = bgName and OBJECTIVE_SLOTS[bgName]

    if carrierMap then
        for arenaIdx, classification in pairs(carrierMap) do
            local unit = ARENA_UNITS[arenaIdx]
            -- UnitIsCharmed is gone from this test and stays gone: it follows
            -- AURA secrecy, which covers the whole PvP match on every token
            -- including "player", so it returns a secret boolean and cannot be
            -- branched on. UnitIsPlayer is not on any secret list and is load
            -- bearing here -- UnitName is only exempt from secrecy for PLAYER
            -- units, and this runs before the name is read. `~= false` because
            -- compound tokens can answer nil.
            if UnitExists(unit) and UnitIsPlayer(unit) ~= false
                and UnitIsEnemy("player", unit) then
                local fullName = NormalizeUnitName(unit)
                if fullName and not ns.playerKeys[fullName] then fullName = nil end
                if fullName then
                    local entry = ns.tracker[fullName]
                    if not entry then
                        entry = {}
                        ns.tracker[fullName] = entry
                    end
                    entry.objective = classification
                    -- Pickup time anchors the orb-model stack clock; a change of
                    -- classification on the same player is a new pickup.
                    if not entry.objectivePickedUpAt or entry.objectivePrevious ~= classification then
                        -- Across a /reload the tracker is empty, so this reads
                        -- as a fresh pickup and orb stacks restart at 30 while
                        -- the game shows 120. Adopt the persisted time if it is
                        -- this player holding this same objective.
                        local kept = ns.db and ns.db.orbPickups and ns.db.orbPickups[fullName]
                        if kept and kept.objective == classification and kept.at <= GetTime() then
                            entry.objectivePickedUpAt = kept.at
                        else
                            entry.objectivePickedUpAt = GetTime()
                        end
                        entry.objectivePrevious = classification
                        if ns.db then
                            ns.db.orbPickups = ns.db.orbPickups or {}
                            ns.db.orbPickups[fullName] = { at = entry.objectivePickedUpAt, objective = classification }
                        end
                    end
                end
            end
        end
    end

    -- Anyone who had a pickup but is no longer classified has dropped it.
    for fullName, entry in pairs(ns.tracker) do
        if entry.objectivePickedUpAt and not entry.objective then
            entry.objectivePickedUpAt = nil
            entry.objectivePrevious = nil
            if ns.db and ns.db.orbPickups then ns.db.orbPickups[fullName] = nil end
        end
    end
end

-- Friendly carrier inference: same arena scan, but for friendly faction.
-- Names from arena tokens are not secret in PvP, so name-keyed lookup works.

function TrackFriendlyObjectives()
    wipe(ns.friendlyObjectives)
    local bgName = select(8, GetInstanceInfo())
    local carrierMap = bgName and OBJECTIVE_SLOTS[bgName]
    -- Charm check dropped for the same reason as TrackEnemyObjectives: UnitIsCharmed
    -- follows AURA secrecy, which is on for the entire PvP match on every token.
    if not carrierMap then
        wipe(ns.friendlyObjectivePickups)
        return
    end

    for arenaIdx, classification in pairs(carrierMap) do
        local unit = ARENA_UNITS[arenaIdx]
        -- UnitIsCharmed stays removed (aura-gated, always secret in a match);
        -- UnitIsPlayer is back, because UnitName is exempt only for player
        -- units and this side has no playerKeys filter to fall back on.
        if UnitExists(unit) and UnitIsPlayer(unit) ~= false
            and UnitIsFriend("player", unit) then
            local name = UnitName(unit)
            if name then
                ns.friendlyObjectives[name] = classification
            end
        end
    end

    -- Sticky pickup tracking
    for name in pairs(ns.friendlyObjectivePickups) do
        if not ns.friendlyObjectives[name] then
            ns.friendlyObjectivePickups[name] = nil
            if ns.db and ns.db.orbPickups then ns.db.orbPickups[name] = nil end
        end
    end
    local now = GetTime()
    for name, classification in pairs(ns.friendlyObjectives) do
        local rec = ns.friendlyObjectivePickups[name]
        if not rec or rec.classification ~= classification then
            local kept = ns.db and ns.db.orbPickups and ns.db.orbPickups[name]
            local at = (kept and kept.objective == classification and kept.at <= now) and kept.at or now
            ns.friendlyObjectivePickups[name] = { pickupTime = at, classification = classification }
            if ns.db then
                ns.db.orbPickups = ns.db.orbPickups or {}
                ns.db.orbPickups[name] = { at = at, objective = classification }
            end
        end
    end
end

function RenderEnemyOverlay(fs)
    if not fs or fs.active == 0 then return end
    local fontSize = fs.g.orbStackSize and fs.g.orbStackSize() or 16
    for i = 1, fs.active do
        local bar   = fs.bars[i]
        local entry = bar.fullName and ns.tracker[bar.fullName]

        -- Out-of-range fade. NO SIMPLE TOKEN MEANS OUT OF RANGE -- this used to
        -- fail open and it was the wrong default.
        --
        -- UnitInRange can only be asked of a token we own: target, focus,
        -- mouseover, softenemy, nameplate, arena slot. A compound raid(i)target
        -- pair cannot answer it. But "we cannot measure it" is not the same as
        -- "assume they are close": having no view of a player at all is itself
        -- strong evidence they are far away, because a nameplate exists for
        -- anyone within nameplate distance. Failing open meant a player nobody
        -- could see rendered identically to one standing next to you.
        --
        -- Carriers are not a counter-example. arena1-5 are simple tokens that
        -- exist across the whole map, so a flag carrier gets a REAL range check
        -- and dims on its result rather than on this default.
        --
        -- NOTE THIS DISAGREES WITH ns.IsUnitInRange ON PURPOSE. That one fails
        -- OPEN -- no probe spell returns true -- because there the missing thing
        -- is the INSTRUMENT, and a player standing next to you would be dimmed
        -- for a fact about your spec. Here the missing thing is any VIEW of the
        -- player at all, which is evidence about them rather than about us. Same
        -- words ("we cannot tell"), different situations, so different defaults.
        --
        -- CAVEAT WORTH KNOWING: "no simple token" is in practice "no nameplate",
        -- so this leans on nameplateShowEnemies being on. With enemy nameplates
        -- off the premise collapses and everything out of target/mouseover reads
        -- as out of range. If that ever matters, gate this default on the CVar
        -- rather than weakening it.
        local rangeUnit = entry and not entry.isCompound and entry.pairedUnit
        ns.ApplyRangeFade(bar,
            (fs.g.rangeCheck and fs.g.rangeCheck())
                and (not rangeUnit or not ns.IsUnitInRange(rangeUnit))
                or false,
            fs.g.rangeOpacity and fs.g.rangeOpacity())

        -- A running reveal owns the top-right slot: the grid stands down for
        -- its lifetime rather than drawing underneath it, then takes over as
        -- the reveal fades -- the finder is still targeting them, so the
        -- diamond it leaves behind is the same one the grid would draw.
        local revealing = ns.RenderStealthReveal(bar, entry,
            fs.g.stealthReveal and fs.g.stealthReveal())
        if revealing then
            ns.RenderTeamTargeting(bar, nil, nil)
        else
            -- Who on our team is on this enemy. ScanTeamTargeting already
            -- cleared these when the option is off, so no branch is needed.
            ns.RenderTeamTargeting(bar, entry and entry.targetedBy, entry and entry.targetedByCount)
        end

        local carrierIcon = entry and entry.objective and OBJECTIVE_ICONS[entry.objective]
        if carrierIcon then
            ns.ApplyObjectiveIcon(bar, entry.objective)
            ApplyObjectiveStackText(bar.pvpIconStackText, entry.objective, entry.objectivePickedUpAt, fontSize)
        else
            -- Dead no longer claims this slot; the spatter carries the state
            -- and the countdown is its own FontString, so the only question
            -- left here is whether there is a carrier icon to show.
            bar.pvpIconFrame:Hide()
            bar.pvpIconStackText:Hide()
            bar.pvpIconShadow:Hide()
        end

        -- Countdown runs regardless of the icon, so a dead carrier shows both.
        if entry and entry.isDead and entry.deadTime and entry.deadDuration then
            ns.SetDeadTimer(bar, entry.deadDuration - (GetTime() - entry.deadTime), fontSize)
        else
            ns.SetDeadTimer(bar, nil, fontSize)
        end
    end
end

-- Friendly overlay: carrier > dead priority. Friendly units have a stable
-- bar._unit (raidN) so dead detection is per-tick without sticky state.
function RenderFriendlyObjective(fs)
    if not fs or fs.active == 0 then return end
    local fontSize = fs.g.orbStackSize and fs.g.orbStackSize() or 16
    for i = 1, fs.active do
        local bar  = fs.bars[i]
        local name = bar.assignedName
        local unit = bar._unit
        local classification = name and ns.friendlyObjectives[name]
        local carrierIcon = classification and OBJECTIVE_ICONS[classification]
        -- Friendly death has no tracker entry to hang a timestamp on, so the
        -- moment of death is stamped on the bar the first tick it is seen.
        local isDead = unit and UnitExists(unit) and UnitIsDeadOrGhost(unit)
        if isDead then
            if not bar._deadSince then bar._deadSince = GetTime() end
        else
            bar._deadSince = nil
        end
        ns.SetDeadTimer(bar, isDead and DeadRemaining(bar._deadSince) or nil, fontSize)

        if carrierIcon then
            ns.ApplyObjectiveIcon(bar, classification)
            local rec = ns.friendlyObjectivePickups[name]
            ApplyObjectiveStackText(bar.pvpIconStackText, classification, rec and rec.pickupTime, fontSize)
        else
            bar.pvpIconFrame:Hide()
            bar.pvpIconStackText:Hide()
            bar.pvpIconShadow:Hide()
        end
    end
end

-------------------------------------------------------------------------------
-- Friendly health ticker — polls at 10Hz, avoids UNIT_HEALTH spam at 40 units
-------------------------------------------------------------------------------
local healthTickElapsed = 0
local HEALTH_TICK_RATE  = 0.1

local healthTicker = CreateFrame("Frame")
healthTicker:SetScript("OnUpdate", function(_, elapsed)
    if not activeFriendly then return end
    healthTickElapsed = healthTickElapsed + elapsed
    if healthTickElapsed < HEALTH_TICK_RATE then return end
    healthTickElapsed = 0

    -- Refresh roster (no-op in combat). Catches late joiners that GROUP_ROSTER_UPDATE
    -- missed because we were combat-locked when it fired.
    UpdateFriendlyFrameSet(activeFriendly)
    if activeFriendly.active == 0 then return end

    for i = 1, activeFriendly.active do
        local bar  = activeFriendly.bars[i]
        local unit = bar._unit

        -- Friendly bars always hold a real party/raid token, so unlike the
        -- enemy side there is no compound case to skip.
        local g = activeFriendly.g
        ns.ApplyRangeFade(bar,
            g.rangeCheck and g.rangeCheck()
                and unit and UnitExists(unit) and not ns.IsUnitInRange(unit),
            g.rangeOpacity and g.rangeOpacity())

        if unit and UnitExists(unit) then
            bar.healthBar:SetMinMaxValues(0, UnitHealthMax(unit))
            bar.healthBar:SetValue(UnitHealth(unit))
            if bar.powerBar:IsShown() then
                bar.powerBar:SetMinMaxValues(0, UnitPowerMax(unit))
                bar.powerBar:SetValue(UnitPower(unit))
                local _, powerToken = UnitPowerType(unit)
                bar.powerBar:SetStatusBarColor(ns.GetPowerColor(powerToken))
            end
        end
    end

    TrackFriendlyObjectives()
    RenderFriendlyTargetGlow(activeFriendly)
    RenderFriendlyObjective(activeFriendly)
    RenderBarSuppression(activeFriendly)
    RefreshSpecText(activeFriendly)

    -- Driven by STATE, not by a one-shot at zone change. ShowActive fires once on
    -- entering the battleground, so if the scoreboard already had players at that
    -- instant the portrait never appeared at all -- and nothing showed it later,
    -- because this path only ever hid it.
    ns.UpdateIntroPanel(activeFriendly)
end)

-------------------------------------------------------------------------------
-- Enemy ticker — polls scoreboard at 10 Hz
-------------------------------------------------------------------------------
local enemyTickElapsed = 0
local ENEMY_TICK_RATE  = 0.1

local enemyTicker = CreateFrame("Frame")
enemyTicker:SetScript("OnUpdate", function(_, elapsed)
    if not activeEnemy then return end
    enemyTickElapsed = enemyTickElapsed + elapsed
    if enemyTickElapsed < ENEMY_TICK_RATE then return end
    enemyTickElapsed = 0

    RequestBattlefieldScoreData()
    UpdateEnemyFrameSet(activeEnemy)

    -- FIRST, not last. This decision depends on nothing below it, and sitting at
    -- the bottom of the ticker meant any throw in the sweep or the render passes
    -- starved it -- the same failure that hid the health bug. Nothing about
    -- "portrait or bars" should be contingent on carrier scanning succeeding.
    ns.UpdateIntroPanel(activeEnemy)

    local currentTargetFullName = RunEnemyTokenSweep()
    ns.DebugWatchTick()
    TrackEnemyObjectives()
    UpdateFlagClock()
    RenderTargetGlow(activeEnemy, currentTargetFullName)
    RenderEnemyHealth(activeEnemy)
    RenderBarSuppression(activeEnemy)
    RenderEnemyOverlay(activeEnemy)


    RefreshSpecText(activeEnemy)

    -- Driven by STATE, not by a one-shot at zone change. ShowActive fires once on
    -- entering the battleground, so if the scoreboard already had players at that
    -- instant the portrait never appeared at all -- and nothing showed it later,
    -- because this path only ever hid it.
    ns.UpdateIntroPanel(activeEnemy)
end)

-------------------------------------------------------------------------------
-- Zone detection
-------------------------------------------------------------------------------
function ShowActive(enemyFS, friendlyFS)
    RestoreFlagClock(select(8, GetInstanceInfo()))
    for _, fs in pairs(frameSets) do
        fs.container:Hide()
        -- Take the intro panel down with it. A hidden container with a "shown"
        -- panel inside is a lie that UpdateIntroPanel used to believe.
        if fs.container.introPanel then fs.container.introPanel:Hide() end
    end
    leaveMatchPending = nil   -- a new match supersedes any deferred teardown
    activeEnemy    = enemyFS
    activeFriendly = friendlyFS

    if enemyFS then
        ApplyPosition(enemyFS)
        ApplySizing(enemyFS)
        UpdateEnemyFrameSet(enemyFS)
        ns.UpdateIntroPanel(enemyFS)
    end
    if friendlyFS then
        ApplyPosition(friendlyFS)
        ApplySizing(friendlyFS)
        UpdateFriendlyFrameSet(friendlyFS)
        ns.UpdateIntroPanel(friendlyFS)
    end
end

-- /bft reveal -- fire a reveal on the first live enemy bar, for eyeballing the
-- animation without waiting on the real trigger.
--
-- The real one is deliberately narrow and that is easy to mistake for a bug: it
-- needs the enemy flagged stealth (a click whose /targetexact failed), a
-- TEAMMATE targeting them (the compound pair, which supplies the revealer's
-- name), and NO simple token of our own on them -- because nameplates sweep
-- before the compound pass and first-write-wins, so seeing them yourself claims
-- the pair and suppresses the reveal by design. In a real match that means it
-- only fires when a teammate finds them somewhere you cannot see.
--
-- This stamps the same three fields the live path sets, so it exercises the
-- actual render rather than a mock of it.
function ns.DebugFireReveal(name, class)
    local fs = activeEnemy
    if not fs or (fs.active or 0) == 0 then
        print("|cffff0000BlitzFramesTargets:|r no live enemy bars to fire a reveal on.")
        return
    end
    local bar = fs.bars[1]
    local entry = bar and bar.fullName and ns.tracker[bar.fullName]
    if not entry then
        print("|cffff0000BlitzFramesTargets:|r bar 1 has no tracker entry yet.")
        return
    end
    entry.revealBy      = name or "Testmate"
    entry.revealClass   = class or "ROGUE"
    entry.revealAt      = GetTime()
    entry.revealArmedAt = entry.revealAt
    print(("|cff33ff99BlitzFramesTargets:|r reveal fired on %s (by %s, %s)")
        :format(tostring(bar.assignedName or "bar 1"), entry.revealBy, entry.revealClass))
end

-- /bft state -- dump what the intro decision is actually seeing. Added because
-- "no intro" had three plausible causes and guessing between them was costing
-- more than measuring would.
-- /bft watch <name> -- print one line per enemy tick for that player, until
-- /bft watch with no name. For the "full but faded after respawn" report: it
-- shows whether isDead is clearing, what token (if any) paired them, and what
-- the health branch is about to draw -- the three facts that decide whether the
-- fault is in the sweep, the tracker or the render.
function ns.DebugWatch(name)
    ns.debugWatchName = (name and name ~= "") and name:lower() or nil
    print(("|cff33ff99BlitzFramesTargets:|r watch %s")
        :format(ns.debugWatchName and ("'" .. ns.debugWatchName .. "'") or "off"))
end

function ns.DebugWatchTick()
    local want = ns.debugWatchName
    if not want or not activeEnemy then return end
    for fullName, entry in pairs(ns.tracker) do
        if fullName:lower():find(want, 1, true) then
            local bar = barByName[fullName]
            local branch = (entry.isDead or entry.isStealth) and "SUPPRESSED->0"
                        or (entry.hasHealth and "CACHE") or "FALLBACK->full"
            -- The WIDGET side too, so a "full but faded" bar can be attributed to
            -- value, alpha, texture or mask in one line rather than guessed at.
            -- Values may be secrets; issecretvalue is a report here, not a guard.
            local widget = "no bar"
            if bar and bar.healthBar then
                local hb  = bar.healthBar
                local v   = hb:GetValue()
                local _, mx = hb:GetMinMaxValues()
                local tex = hb:GetStatusBarTexture()
                local function show(x) return issecretvalue(x) and "<secret>" or tostring(x) end
                widget = ("val=%s max=%s hbA=%.2f visA=%.2f barA=%.2f fillA=%s fillShown=%s masks=%s")
                    :format(show(v), show(mx),
                        hb:GetAlpha(), bar.visual and bar.visual:GetAlpha() or -1, bar:GetAlpha(),
                        tex and ("%.2f"):format(tex:GetAlpha()) or "nil",
                        tex and tostring(tex:IsShown()) or "nil",
                        tex and tostring(tex:GetNumMaskTextures()) or "nil")
            end
            print(("[%s] dead=%s stealth=%s paired=%s compound=%s hasHealth=%s branch=%s | %s")
                :format(fullName:match("^[^-]+"),
                    tostring(entry.isDead), tostring(entry.isStealth),
                    tostring(entry.pairedUnit), tostring(entry.isCompound),
                    tostring(entry.hasHealth), branch, widget))
        end
    end
end

-- /bft flag -- toggle a trace of the shared flag clock (every start, every
-- reset with its reason, every objective going home and why) and dump the
-- current state. For "game says 7, addon says 2": the reset lines say whether
-- it was chat, timeout, or no reset at all and a late start.
function ns.DebugFlag()
    ns.debugFlagTrace = not ns.debugFlagTrace
    local rules, mode, startsWhen, instanceID = StackRulesHere()
    local slots = instanceID and OBJECTIVE_SLOTS[instanceID]
    print(("|cffff9933BFT flag:|r trace %s | mode=%s startsWhen=%s clock=%s")
        :format(ns.debugFlagTrace and "ON" or "off", tostring(mode), tostring(startsWhen),
            flagClockStartedAt and ("%.0fs"):format(GetTime() - flagClockStartedAt) or "idle"))
    if slots then
        for idx, cls in pairs(slots) do
            local u = ARENA_UNITS[idx]
            print(("  slot %s (%s) exists=%s out=%s lastSeen=%s")
                :format(u, tostring(cls), tostring(UnitExists(u)), tostring(flagOutOfBase[cls]),
                    flagCarrierLastSeenAt[cls] and ("%.0fs ago"):format(GetTime() - flagCarrierLastSeenAt[cls]) or "-"))
        end
    end
end

function ns.DebugState()
    local function dump(label, fs)
        if not fs then print(("  %s: <nil>"):format(label)) return end
        local c, ip = fs.container, fs.container and fs.container.introPanel
        print(("  %s  active=%s scoreCount=%s readyAt=%s | container shown=%s h=%.0f | panel shown=%s | enabled=%s")
            :format(label,
                tostring(fs.active), tostring(fs.scoreCount), tostring(fs.introReadyAt),
                c and tostring(c:IsShown()) or "?", c and c:GetHeight() or -1,
                ip and tostring(ip:IsShown()) or "?",
                fs.g and fs.g.enable and tostring(fs.g.enable()) or "?"))
    end
    local _, itype = GetInstanceInfo()
    print(("|cff33ff99BFT state|r  instance=%s  MIN_PLAYERS=%s HOLD=%s")
        :format(tostring(itype), tostring(ns.INTRO_MIN_PLAYERS), tostring(ns.INTRO_HOLD)))
    dump("enemy   ", activeEnemy)
    dump("friendly", activeFriendly)
end

-- THE ONE PLACE PER-MATCH STATE IS TORN DOWN. Anything a battleground sets that
-- should not survive into the next one is cleared here, and only here.
--
-- Two sites used to share this job and each knew about part of it: the leave-
-- instance branch of OnZoneChanged hid the frames and wiped the tracker but
-- not the roster counts (so a second battleground opened thinking it was full),
-- and PVP_MATCH_COMPLETE wiped the scoreboard caches but did not clear
-- activeEnemy (so the ticker kept sweeping a finished match). Neither reset the
-- flag-stack clock at all. Every one of those was found as a separate bug.
--
-- Called from both events. Idempotent, so double-firing is harmless.
--
-- What is deliberately NOT here: bar geometry (settings-driven, memoised, and
-- correct across matches), and the frame sets' enable/position/size settings.
function LeaveMatch()
    -- MUST NOT be called in combat: fs.container is a secure frame and Hide()
    -- on one is protected during lockdown. Callers check InCombatLockdown and
    -- defer to PLAYER_REGEN_ENABLED -- see the PVP_MATCH_COMPLETE handler.
    for _, fs in pairs(frameSets) do
        fs.container:Hide()
        if fs.container.introPanel then fs.container.introPanel:Hide() end
        fs._introWasUp = nil
        ns.CancelBarLoadin(fs)
        -- Test-mode status overrides. Plain field writes, so unlike ResetBar this
        -- is safe in or out of combat -- no SetAttribute involved.
        for i = 1, fs.maxBars do
            local b = fs.bars[i]
            if b then b._testStealth, b._testDead = nil, nil end
        end
        -- Roster state. fs.names is diffed against the next roster, so a stale
        -- name that happened to recur would suppress the rebuild.
        fs.active       = 0
        fs.scoreCount   = 0
        fs.introReadyAt = nil
        if fs.names then wipe(fs.names) end
    end
    activeEnemy    = nil
    activeFriendly = nil
    wipe(barByName)
    wipe(ns.tracker)

    -- Scoreboard-derived caches. These were PVP_MATCH_COMPLETE's half.
    wipe(ns.playerKeys)
    wipe(ns.enemySpecs)
    wipe(ns.friendlySpecs)
    wipe(ns.friendlyClasses)
    wipe(ns.friendlyObjectives)
    wipe(ns.friendlyObjectivePickups)

    -- Flag-stack clock and possession. A clock started in one Warsong must not
    -- still be running when the next one loads -- and the persisted record goes
    -- too, or a reload during the NEXT match on the same map would adopt it.
    ClearFlagPossession("match left")
    if ns.db then ns.db.flagClock = nil; ns.db.orbPickups = nil end
end

-- SOLE ANSWER to "are we in a battleground". The instance type alone stopped
-- being enough in 12.1: a Training Ground practice ARENA reports instanceType
-- "pvp", so every gate that trusted it brought the frames up inside practice
-- arenas -- reported live, with real enemy bars populating from the arena
-- scoreboard. Measured in one: instance=pvp, id=1505,
-- IsActiveBattlefieldArena()=true, GetNumArenaOpponentSpecs()=0.
--
-- IsActiveBattlefieldArena is the discriminator that works. The opponent-spec
-- count was the other candidate and does NOT -- it returned 0 in that same
-- arena. Unit tokens are no use either: arena1-5 exist in BATTLEGROUNDS too,
-- which is what OBJECTIVE_SLOTS carrier detection is built on.
local function InBattleground()
    local _, instanceType = GetInstanceInfo()
    return instanceType == "pvp" and not IsActiveBattlefieldArena()
end
ns.InBattleground = InBattleground

function OnZoneChanged()
    if not InBattleground() then
        LeaveMatch()
        return
    end

    -- Test mode leaves bars Show()'d with stale fs.active; clear it before
    -- the real BG pipeline takes over, otherwise test bars bleed through.
    if ns.testModeActive then
        ns.testModeActive = false
        for _, fs in pairs(frameSets) do
            for i = 1, fs.maxBars do ResetBar(fs.bars[i]) end
            fs.active = 0
        end
    end

    if ns.IsEpicBattleground() then
        ShowActive(frameSets.epicEnemy, frameSets.epicFriendly)
    else
        ShowActive(frameSets.normalEnemy, frameSets.normalFriendly)
    end
end

-- ZONE DETECTION IS NOT A ONE-SHOT. Same lesson as the intro panel: drive it
-- from STATE, because the event fires at a moment the state may not be ready.
--
-- GetInstanceInfo is not reliably populated at PLAYER_ENTERING_WORLD. Joining a
-- battleground LATE is the reliable way to see it -- instanceType still reads as
-- the zone you came from, OnZoneChanged takes the teardown branch, and
-- activeEnemy is set to nil. The enemy ticker then returns at `if not
-- activeEnemy` on every tick FOREVER, because PLAYER_ENTERING_WORLD and
-- ZONE_CHANGED_NEW_AREA are the only triggers and both have already fired.
-- Nothing ever asks again. That is the "no bars on a late join" bug.
--
-- The second failure is quieter: IsEpicBattleground reads maxPlayers from the
-- same unpopulated call, so a nil there hands an epic battleground the NORMAL
-- frame set -- right pipeline, wrong bar count and wrong settings prefix. This
-- checks the active set against what the zone actually wants, so it corrects
-- that too rather than only the nil case.
--
-- 1s is deliberately slow. It is a safety net, not a mechanism: the events
-- still do the work whenever they can, and this only notices when they could
-- not.
local ZONE_WATCHDOG_RATE = 1.0
local zoneWatchdogElapsed = 0
local zoneWatchdog = CreateFrame("Frame")
zoneWatchdog:SetScript("OnUpdate", function(_, elapsed)
    zoneWatchdogElapsed = zoneWatchdogElapsed + elapsed
    if zoneWatchdogElapsed < ZONE_WATCHDOG_RATE then return end
    zoneWatchdogElapsed = 0

    if matchOver then return end
    if not InBattleground() then
        -- HEALS BOTH WAYS NOW. This used to be a bare `return`, so the watchdog
        -- could only ever fix "should be running and is not" -- teardown was
        -- left entirely to the events. That is a one-way valve: if the frames
        -- were ever brought up wrongly, nothing re-checked, and the waiting
        -- panel re-showed itself forever because UpdateIntroPanel sees an
        -- arena scoreboard that never reaches INTRO_MIN_PLAYERS.
        --
        -- What got it up there was a practice arena reporting instanceType
        -- "pvp" (see InBattleground). That gate is fixed, but the valve stays
        -- two-way: teardown that depends on a single event firing correctly is
        -- exactly what left the panel stranded in an arena.
        --
        -- Test mode is unaffected: it never sets activeEnemy/activeFriendly,
        -- so this cannot tear it down.
        if activeEnemy or activeFriendly then
            if not InCombatLockdown() then LeaveMatch() end
        end
        return
    end

    -- Only ever heals toward "should be running and is not", or "running as the
    -- wrong pair". Teardown stays with the events, so this cannot fight test
    -- mode or the exit path.
    local want = ns.IsEpicBattleground() and frameSets.epicEnemy or frameSets.normalEnemy
    if activeEnemy ~= want then
        OnZoneChanged()
    end
end)

-------------------------------------------------------------------------------
-- ns.RebuildBars — called by settings UI after any setting change
-------------------------------------------------------------------------------
function ns.RebuildBars()
    if InCombatLockdown() then return end
    for _, fs in pairs(frameSets) do
        ApplySizing(fs)
        ApplyPosition(fs)
    end
    if activeEnemy    then UpdateEnemyFrameSet(activeEnemy)       end
    if activeFriendly then UpdateFriendlyFrameSet(activeFriendly) end
end

-------------------------------------------------------------------------------
-- Test mode
-------------------------------------------------------------------------------
function ns.CanAccessTestMode()
    local _, instanceType = GetInstanceInfo()
    return instanceType ~= "pvp" and instanceType ~= "arena"
end

function ns.ToggleTestMode()
    if ns.testModeActive then
        ns.testModeActive = false
        -- Same pair the options panel uses: test mode is a thing that opens and
        -- closes, so it should sound like one.
        ns.PlayUISound("PanelClose")
        ns.RefreshTestBars()
    else
        -- Sound goes AFTER the access check, or a right-click in an active
        -- match plays an open sound and then nothing opens.
        if not ns.CanAccessTestMode() then return end
        ns.testModeActive = true
        ns.PlayUISound("PanelOpen")
        ns.RefreshTestBars()
    end
end

local TEST_CLASSES = {
    "WARRIOR", "PALADIN", "HUNTER", "ROGUE", "PRIEST",
    "DEATHKNIGHT", "SHAMAN", "MAGE", "WARLOCK", "MONK",
    "DRUID", "DEMONHUNTER", "EVOKER",
}
local TEST_NAMES = {
    "Brindleclaw", "Sunbreaker", "Ashveil",  "Ironhaven", "Coldmere",
    "Dustwhisper", "Emberpeak",  "Stonefury", "Voidmaw",   "Grimthorn",
    "Sparklefix",  "Dreadholm",  "Mirecleft", "Frostbane", "Shadowlace",
}
local TEST_SPECS = {
    "Arms", "Retribution", "Beast Mastery", "Assassination", "Discipline",
    "Frost", "Elemental", "Fire", "Affliction", "Windwalker",
    "Balance", "Havoc", "Devastation",
}

-- Mock team-targeting data, shared by test mode and the settings previews so
-- the two cannot drift. Deterministic per bar index rather than random: a
-- preview that reshuffles on every repaint is impossible to judge a layout
-- with. Bar 6 deliberately overflows so the "+N" path is visible without
-- waiting for eleven people to focus one enemy.
-- One demo reveal, on the third bar, so the preview shows the reveal and the
-- grid side by side rather than only ever one of them.
--
-- Bar 5 is a Priest in both the settings preview and the test frames, which is
-- the palest bar in either set -- so an orange finder reads hard against it,
-- and it stays obvious that the diamond carries the FINDER's class rather than
-- the target's. (Bar 3 is itself a Druid, so orange there would have demoed
-- exactly the thing the reveal is not saying.)
function ns.MockStealthReveal(index)
    if index ~= 5 then return nil end
    return "Bob", "DRUID"
end

function ns.MockTeamTargeting(index)
    -- 3, 6 and 9 exercise one, two and all three rows; 13 overflows so the
    -- "+N" path is visible without waiting for ten people to focus one enemy.
    local counts = { [2] = 1, [3] = 3, [4] = 6, [5] = 9, [6] = 13 }
    local n = counts[index]
    if not n then return nil, nil end
    local classes = {}
    for k = 1, math.min(n, ns.TEAM_TARGET_MAX) do
        classes[k] = TEST_CLASSES[((index + k - 2) % #TEST_CLASSES) + 1]
    end
    return classes, n
end

function ns.RefreshTestBars()
    if not ns.testModeActive then
        for _, fs in pairs(frameSets) do
            for i = 1, fs.maxBars do ResetBar(fs.bars[i]) end
            fs.active = 0
            fs.container:Hide()
        end
        OnZoneChanged()
        return
    end

    local function Populate(fs, count)
        local g = fs.g

        -- GEOMETRY BEFORE RENDERING. ApplySizing runs at the END of this
        -- function (it owns the heading, see the note down there), which left
        -- the first test-mode press after a /reload rendering into bars that
        -- had never been laid out: bar._fxBarH was still nil, so
        -- ns.RevealArtOffset returned just the icon gap instead of a whole
        -- card height, and the stealth reveal drew squashed against the
        -- frames. Every later press looked right because the previous run had
        -- left the geometry behind. ApplyBarGeometry is memoised, so on every
        -- press after the first this is a key comparison and nothing else.
        ApplyBarGeometry(fs)

        -- Scatter mock objective carriers across random bars so carrier styling
        -- can be eyeballed against the class-coloured backdrops: 2 orbs, 2 flags
        -- and the Deephaul crystal.
        --
        -- The crystal is included so index 100 is actually exercised somewhere.
        -- It is the only classification the addon INVENTS rather than mirrors
        -- from Enum.PvPUnitClassification, so it is also the one most likely to
        -- be missed when the icon or glow tables change.
        local testCarriers = {}
        local testStacks   = {}   -- orb damage stacks, mocked
        if count >= 5 then
            local slots = {}
            for i = 1, count do slots[i] = i end
            for i = count, 2, -1 do
                local j = math.random(i)
                slots[i], slots[j] = slots[j], slots[i]
            end
            -- Purple orb and horde flag always included — they carry the
            -- flipbook animation demo; the other two stay static to compare
            testCarriers[slots[1]] = 10
            testCarriers[slots[2]] = ({ 7, 8, 9 })[math.random(3)]
            testCarriers[slots[3]] = 0
            testCarriers[slots[4]] = ({ 1, 2 })[math.random(2)]
            testCarriers[slots[5]] = 100   -- Deephaul crystal

            -- Mock stacks. Live reads a pickup time and counts up; there is no
            -- match here to read one from, and StackRulesHere returns nil
            -- outside the configured battlegrounds, so the numbers are supplied
            -- directly — same as the settings previews do.
            --
            -- THE TWO MODELS PRODUCE DIFFERENT-LOOKING NUMBERS, and both need
            -- showing or the readability of one gets judged from the other:
            --   orb  -- a damage percentage, start 30 and +30 per interval, so
            --          two and three digits (30..180)
            --   flag -- a shared match clock, counting 1, 2, 3... from when both
            --          teams hold, so usually ONE digit
            -- The flags previously had no mock stack at all, which is why the
            -- narrow numbers were never eyeballed until they turned up live.
            testStacks[slots[1]] = 30 * math.random(1, 6)
            testStacks[slots[2]] = 30 * math.random(1, 6)
            testStacks[slots[3]] = math.random(1, 9)
            testStacks[slots[4]] = math.random(1, 9)
            testStacks[slots[5]] = math.random(1, 9)

        end
        -- One non-carrier bar demos the death state, and on enemy sets a
        -- second demos stealth. Stealth is enemy-only in live (friendly units
        -- pair by unit token and are never lost), so friendly test frames
        -- deliberately don't show one.
        local testDeath
        local testStealth
        if count >= 5 then
            local taken = {}
            for idx in pairs(testCarriers) do taken[idx] = true end
            repeat testDeath = math.random(count) until not taken[testDeath]
            taken[testDeath] = true
            if fs.isEnemy and count >= 6 then
                repeat testStealth = math.random(count) until not taken[testStealth]
            end
        end

        for i = 1, count do
            local bar        = fs.bars[i]
            local classToken = TEST_CLASSES[((i - 1) % #TEST_CLASSES) + 1]


            -- Dead and stealth are both "suppressed": emptied fill, drained
            -- art and a dimmed trough, matching live where RenderEnemyHealth
            -- empties the bar for either. Stealth then adds the drifting mist
            -- on top; dead gets the ghost marker instead.
            local baseName     = TEST_NAMES[i] or ("Player" .. i)
            local isDead       = (i == testDeath)
            local isStealth    = (i == testStealth)
            local isSuppressed = isDead or isStealth

            -- Drain preview: fires on the transition only. The ghost was fed
            -- this bar's value on the previous refresh, same as live.
            if isDead and not bar._testDead then
                ns.StartDeathDrain(bar)
            elseif not isDead and bar._testDead then
                ns.StopDeathDrain(bar)
            end

            ns.ApplyBarClassVisual(bar, classToken, isSuppressed, isStealth)
            ns.ApplyBarStateArt(bar, isStealth and "Stealth" or (isDead and "Dead" or nil),
                                testCarriers[i] ~= nil)
            bar.healthBar:SetMinMaxValues(0, 1)
            -- Dead empties; stealth holds a live value under the fog, same as
            -- RenderEnemyHealth rule 1 since the 2026-08-19 change
            local hpFrac = math.random(30, 100) / 100
            bar.healthBar:SetValue(isDead and 0 or hpFrac)
            if bar.drainGhost and not isDead then
                bar.drainGhost:SetMinMaxValues(0, 1)
                bar.drainGhost:SetValue(hpFrac)
            end

            local spec = TEST_SPECS[((i - 1) % #TEST_SPECS) + 1]
            bar.assignedName     = baseName
            bar.assignedFullName = baseName
            if fs.isEnemy then
                ns.enemySpecs[bar.assignedFullName] = spec
            else
                ns.friendlySpecs[bar.assignedFullName] = spec
            end

            -- Dead demo bar carries the Dead status; every other bar previews
            -- the real name/spec composition. Colours are owned by the shared
            -- composer, so nothing is tinted here.
            bar._testDead    = isDead or nil
            bar._testStealth = isStealth or nil
            ApplyBarNameText(bar, g.textDisplay and g.textDisplay() or "name")
            ns.AnchorBarText(bar)
            -- statusText excluded: ComposeLabel just set it in the status face
            -- at a scaled size, and re-applying the plain name font here would
            -- undo that on every test-mode refresh.
            local textSize = g.textSize()
            bar.nameText:SetFont(ns.LOCALE_FONT, textSize, "")
            -- Smaller on Show Both, where it is the secondary line.
            bar.specText:SetFont(ns.LOCALE_FONT, ns.SpecFontSize(bar, textSize), "")

            ns.ApplyRangeFade(bar,
                g.rangeCheck and g.rangeCheck() and ns.MockOutOfRange(i),
                g.rangeOpacity and g.rangeOpacity())

            -- Same renderer live bars use, fed the shared mock. Runs AFTER
            -- AnchorBarText above, because the reveal takes the centre slot
            -- and anything that places the labels would re-show them over it.
            local who, cls = ns.MockStealthReveal(i)
            local revealing = g.stealthReveal and g.stealthReveal()
                              and ns.DrawStealthReveal(bar, who, cls, 1)
            if revealing then
                ns.RenderTeamTargeting(bar, nil, nil)
            else
                ns.HideStealthReveal(bar)
                if g.teamTargeting and g.teamTargeting() then
                    ns.RenderTeamTargeting(bar, ns.MockTeamTargeting(i))
                else
                    ns.RenderTeamTargeting(bar, nil, nil)
                end
            end

            if g.pingEnabled() then
                bar.pingBtn.zoomAmount = g.pingZoom()
                bar.pingBtn:Show()
            else
                bar.pingBtn:Hide()
            end

            -- Power bar: no live unit to read, so a mock value stands in and
            -- the tint comes from the class we assigned this bar rather than
            -- a real power token. Suppressed bars read empty to match the live
            -- dead/stealth/unpaired override.
            if g.powerEnabled() then
                bar.powerBar:SetMinMaxValues(0, 1)
                bar.powerBar:SetValue(isSuppressed and 0 or (math.random(20, 100) / 100))
                bar.powerBar:SetStatusBarColor(ns.GetClassPowerColor(classToken))
                bar.powerBar:Show()
            else
                bar.powerBar:Hide()
            end

            local classification = testCarriers[i]
            if classification then
                ns.ApplyObjectiveIcon(bar, classification)
                -- Orbs carry stacks; flags don't, so only the orb slots get a
                -- number. Font size follows the Orb Stack Text Size setting so
                -- the slider can be judged here too.
                if testStacks[i] then
                    bar.pvpIconStackText:SetFont(ns.LOCALE_FONT,
                        (g.orbStackSize and g.orbStackSize()) or 16, "OUTLINE")
                    bar.pvpIconStackText:SetText(testStacks[i])
                    bar.pvpIconStackText:Show()
                else
                    bar.pvpIconStackText:Hide()
                end
                bar.pvpIconFrame:Show()
            else
                if bar.pvpIconFlip then
                    bar.pvpIconFlip:Stop()
                    bar.pvpIcon:SetTexCoord(0, 1, 0, 1)
                end
                bar.pvpIconFrame:Hide()
                bar.pvpIconShadow:Hide()
            end

            -- Test mode has no real death to time, so the dead demo bar counts
            -- down from the Solo Blitz respawn and loops. Live reads the real
            -- clock; this only has to prove the widget is wired and styled.
            if isDead then
                ns.SetDeadTimer(bar, 17 - (GetTime() % 17), (g.orbStackSize and g.orbStackSize()) or nil)
            else
                ns.SetDeadTimer(bar, nil, nil)
            end

            bar:Show()
            fs.names[i] = "test" .. i
        end
        for i = count + 1, fs.maxBars do
            ResetBar(fs.bars[i])
            fs.names[i] = nil
        end
        fs.active = count
        fs.scoreCount = count
        -- The heading is ApplySizing's job: it derives the same test-mode
        -- faction index (enemy = Horde, friendly = Alliance) and the same count
        -- from fs. Setting it here as well is what made the bug visible -- this
        -- call honoured Hide Faction and the ApplySizing call put it back.
        ApplySizing(fs)
        ApplyPosition(fs)
        -- SetShown, not a bare Show: a disabled frame set arrives here with
        -- count 0, and only skipping the Show leaves a container that was
        -- already visible sitting there with an empty "(0)" heading and no
        -- bars under it. The live paths hide on the enable check instead.
        fs.container:SetShown(count > 0)
    end

    local count = math.min(13, MAX_NORMAL) -- one bar per class so the full art set is visible
    Populate(frameSets.normalEnemy,    frameSets.normalEnemy.g.enable()    and count or 0)
    Populate(frameSets.normalFriendly, frameSets.normalFriendly.g.enable() and count or 0)

end

-------------------------------------------------------------------------------
-- Events
-------------------------------------------------------------------------------
local eventFrame = CreateFrame("Frame")
eventFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
eventFrame:RegisterEvent("ZONE_CHANGED_NEW_AREA")
-- Fires once the match itself is live, by which point GetInstanceInfo is
-- definitely populated. Gives a late join its bars immediately instead of
-- waiting up to a second for the zone watchdog to notice.
eventFrame:RegisterEvent("PVP_MATCH_ACTIVE")
eventFrame:RegisterEvent("PLAYER_TARGET_CHANGED")
eventFrame:RegisterEvent("PVP_MATCH_COMPLETE")
-- The range probe is only valid for the ACTIVE spec, so it re-resolves on both
-- the spec swap and the spellbook rebuild that follows a talent change.
eventFrame:RegisterEvent("CHAT_MSG_BG_SYSTEM_NEUTRAL")
eventFrame:RegisterEvent("CHAT_MSG_BG_SYSTEM_ALLIANCE")
eventFrame:RegisterEvent("CHAT_MSG_BG_SYSTEM_HORDE")
eventFrame:RegisterEvent("SPELLS_CHANGED")
eventFrame:RegisterEvent("PLAYER_SPECIALIZATION_CHANGED")
-- Leaving combat is the moment the secure half of UpdateEnemyFrameSet becomes
-- legal again. Without this the debt recorded in fs.layoutDirty is only ever
-- paid on the next roster change, so a bar orphaned mid-fight sits there for
-- however long that takes -- which in a BG can be the rest of the match.
eventFrame:RegisterEvent("PLAYER_REGEN_ENABLED")

eventFrame:SetScript("OnEvent", function(_, event, arg1)
    if event == "PLAYER_REGEN_ENABLED" then
        -- A match that ended in combat deferred its teardown to here.
        if leaveMatchPending then
            leaveMatchPending = nil
            LeaveMatch()
        end
        -- ...as does a Hover Cycle Frames toggle that landed mid-fight.
        ns.PayWheelCycleDebt()
        if activeEnemy and activeEnemy.layoutDirty then
            UpdateEnemyFrameSet(activeEnemy)
        end
        if activeFriendly then UpdateFriendlyFrameSet(activeFriendly) end
        return
    end
    if event == "CHAT_MSG_BG_SYSTEM_NEUTRAL"
        or event == "CHAT_MSG_BG_SYSTEM_ALLIANCE"
        or event == "CHAT_MSG_BG_SYSTEM_HORDE" then
        ns.OnFlagChat(event, arg1)
        return

    elseif event == "SPELLS_CHANGED" or event == "PLAYER_SPECIALIZATION_CHANGED" then
        ns.ResolveRangeSpell()
        if ns.RefreshRangeLabel then ns.RefreshRangeLabel() end
        return

    elseif event == "PLAYER_ENTERING_WORLD" then
        matchOver = false
        playerFactionIndex = FACTION_INDEX[UnitFactionGroup("player")] or 0
        ns.RefreshWheelCycle()   -- push the saved setting into the secure attributes
        ns.ResolveRangeSpell()
        if ns.RefreshRangeLabel then ns.RefreshRangeLabel() end
        OnZoneChanged()

    elseif event == "ZONE_CHANGED_NEW_AREA" or event == "PVP_MATCH_ACTIVE" then
        matchOver = false
        OnZoneChanged()

    elseif event == "PLAYER_TARGET_CHANGED" then

        -- NO SWEEP HERE. This event fires SYNCHRONOUSLY inside TargetUnit while
        -- the secure macro on a bar is still executing, and in that restricted
        -- context UnitName("target") returns secrets for both name and realm --
        -- "attempt to compare local 'realm' (a secret string value, while
        -- execution tainted by 'BlitzFramesTargets')". The same call is plain
        -- when it runs from our own OnUpdate, so the token is fine and the
        -- CONTEXT is the mistake.
        --
        -- Nothing needs to replace it: the enemy ticker already runs the sweep
        -- and RenderTargetGlow unconditionally every 100ms, so the glow still
        -- follows the target -- just from a clean context, at most one tick
        -- later. No dirty flag, because there is nothing the ticker was not
        -- already going to do.
        if activeFriendly then
            RenderFriendlyTargetGlow(activeFriendly)
        end

    elseif event == "PVP_MATCH_COMPLETE" then
        matchOver = true
        -- The match routinely ends mid-fight, and LeaveMatch hides secure
        -- frames, which is protected in combat lockdown -- ADDON_ACTION_BLOCKED
        -- once per game. Defer rather than skip, so the ticker still stops
        -- sweeping the finished match once combat drops.
        if InCombatLockdown() then
            leaveMatchPending = true
        else
            LeaveMatch()
        end
    end
end)

-- ============================================================
-- PvP Drink / Flask of Honor Low Stock Warnings
-- ============================================================
local DRINK_ITEMS = {
    260260, -- Springrunner Sparkling
    260261, -- Bloom Nectar
    260262, -- Fairbreeze Feast
    260263, -- Silvermoon Soiree Spread
    260264, -- Quel'Danas Rations
}
local DRINK_THRESHOLD = 20

local FLASK_SPELL_ID = 1239355
local FLASK_ITEM_ID = 241334
local FLASK_WARNING_THRESHOLD = 1500 -- 25 minutes

local drinkWarningFrame = CreateFrame("Frame", nil, UIParent, "BackdropTemplate")
drinkWarningFrame:SetSize(64, 64)
drinkWarningFrame:SetPoint("TOP", UIParent, "TOP", 0, -30)
drinkWarningFrame:SetFrameStrata("FULLSCREEN_DIALOG")
drinkWarningFrame:SetBackdrop({
    bgFile = "Interface\\Buttons\\WHITE8x8",
    edgeFile = "Interface\\Buttons\\WHITE8x8",
    edgeSize = 2,
})
drinkWarningFrame:SetBackdropColor(0, 0, 0, 0.6)
drinkWarningFrame:SetBackdropBorderColor(0.6, 0.5, 0.1, 1)
drinkWarningFrame:Hide()

drinkWarningFrame.glow = drinkWarningFrame:CreateTexture(nil, "BACKGROUND")
drinkWarningFrame.glow:SetPoint("CENTER")
drinkWarningFrame.glow:SetSize(96, 96)
drinkWarningFrame.glow:SetTexture("Interface\\SpellActivationOverlay\\IconAlert")
drinkWarningFrame.glow:SetTexCoord(0.00781250, 0.50781250, 0.27734375, 0.52734375)
drinkWarningFrame.glow:SetVertexColor(0.6, 0.5, 0.1, 0.6)

drinkWarningFrame.icon = drinkWarningFrame:CreateTexture(nil, "ARTWORK")
drinkWarningFrame.icon:SetPoint("TOPLEFT", 2, -2)
drinkWarningFrame.icon:SetPoint("BOTTOMRIGHT", -2, 2)
drinkWarningFrame.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)

drinkWarningFrame.countText = drinkWarningFrame:CreateFontString(nil, "OVERLAY")
drinkWarningFrame.countText:SetFont(ns.LOCALE_FONT, 22, "OUTLINE")
drinkWarningFrame.countText:SetPoint("TOP", drinkWarningFrame, "BOTTOM", 0, -4)
drinkWarningFrame.countText:SetTextColor(0.6, 0.5, 0.1, 1)

drinkWarningFrame.remainingText = drinkWarningFrame:CreateFontString(nil, "OVERLAY")
drinkWarningFrame.remainingText:SetFont(ns.LOCALE_FONT, 12, "OUTLINE")
drinkWarningFrame.remainingText:SetPoint("TOP", drinkWarningFrame.countText, "BOTTOM", 0, -2)
drinkWarningFrame.remainingText:SetTextColor(0.6, 0.5, 0.1, 1)
drinkWarningFrame.remainingText:SetText("Remaining")

local flaskWarningFrame = CreateFrame("Button", "BlitzFramesTargetsFlaskWarning", UIParent, "SecureActionButtonTemplate, BackdropTemplate")
flaskWarningFrame:SetSize(64, 64)
flaskWarningFrame:SetPoint("TOP", UIParent, "TOP", 0, -30)
flaskWarningFrame:SetFrameStrata("FULLSCREEN_DIALOG")
flaskWarningFrame:SetBackdrop({
    bgFile = "Interface\\Buttons\\WHITE8x8",
    edgeFile = "Interface\\Buttons\\WHITE8x8",
    edgeSize = 2,
})
flaskWarningFrame:SetBackdropColor(0, 0, 0, 0.6)
flaskWarningFrame:SetBackdropBorderColor(0.6, 0.5, 0.1, 1)
flaskWarningFrame:RegisterForClicks("AnyDown", "AnyUp")
flaskWarningFrame:SetAttribute("type", "item")
flaskWarningFrame:SetAttribute("item", "item:" .. FLASK_ITEM_ID)
flaskWarningFrame:Hide()

flaskWarningFrame.glow = flaskWarningFrame:CreateTexture(nil, "BACKGROUND")
flaskWarningFrame.glow:SetPoint("CENTER")
flaskWarningFrame.glow:SetSize(96, 96)
flaskWarningFrame.glow:SetTexture("Interface\\SpellActivationOverlay\\IconAlert")
flaskWarningFrame.glow:SetTexCoord(0.00781250, 0.50781250, 0.27734375, 0.52734375)
flaskWarningFrame.glow:SetVertexColor(0.6, 0.5, 0.1, 0.6)

flaskWarningFrame.icon = flaskWarningFrame:CreateTexture(nil, "ARTWORK")
flaskWarningFrame.icon:SetPoint("TOPLEFT", 2, -2)
flaskWarningFrame.icon:SetPoint("BOTTOMRIGHT", -2, 2)
flaskWarningFrame.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)

flaskWarningFrame.topText = flaskWarningFrame:CreateFontString(nil, "OVERLAY")
flaskWarningFrame.topText:SetFont(ns.LOCALE_FONT, 12, "OUTLINE")
flaskWarningFrame.topText:SetPoint("BOTTOM", flaskWarningFrame, "TOP", 0, 4)
flaskWarningFrame.topText:SetTextColor(0.6, 0.5, 0.1, 1)
flaskWarningFrame.topText:SetText("")

flaskWarningFrame.countText = flaskWarningFrame:CreateFontString(nil, "OVERLAY")
flaskWarningFrame.countText:SetFont(ns.LOCALE_FONT, 22, "OUTLINE")
flaskWarningFrame.countText:SetPoint("TOP", flaskWarningFrame, "BOTTOM", 0, -4)
flaskWarningFrame.countText:SetTextColor(0.6, 0.5, 0.1, 1)

flaskWarningFrame.remainingText = flaskWarningFrame:CreateFontString(nil, "OVERLAY")
flaskWarningFrame.remainingText:SetFont(ns.LOCALE_FONT, 12, "OUTLINE")
flaskWarningFrame.remainingText:SetPoint("TOP", flaskWarningFrame.countText, "BOTTOM", 0, -2)
flaskWarningFrame.remainingText:SetTextColor(0.6, 0.5, 0.1, 1)
flaskWarningFrame.remainingText:SetText("Remaining")

function RepositionWarnings()
    if InCombatLockdown() then return end
    local drinkShown = drinkWarningFrame:IsShown()
    local flaskShown = flaskWarningFrame:IsShown()
    drinkWarningFrame:ClearAllPoints()
    flaskWarningFrame:ClearAllPoints()
    if drinkShown and flaskShown then
        drinkWarningFrame:SetPoint("TOP", UIParent, "TOP", -44, -30)
        flaskWarningFrame:SetPoint("TOP", UIParent, "TOP", 44, -30)
    elseif drinkShown then
        drinkWarningFrame:SetPoint("TOP", UIParent, "TOP", 0, -30)
    elseif flaskShown then
        flaskWarningFrame:SetPoint("TOP", UIParent, "TOP", 0, -30)
    end
end

flaskWarningFrame:HookScript("OnClick", function()
    C_Timer.After(0.5, function()
        ns.UpdateFlaskWarning()
    end)
end)

function IsInPvPQueue()
    for i = 1, GetMaxBattlefieldID() do
        local status = GetBattlefieldStatus(i)
        if status ~= "none" then
            return true
        end
    end
    return false
end

function ns.UpdateDrinkWarning()
    if not ns.GetDrinkWarningEnabled() then
        drinkWarningFrame:Hide()
        RepositionWarnings()
        return
    end
    local _, instanceType = GetInstanceInfo()
    if instanceType == "pvp" or instanceType == "arena" or instanceType == "party" or instanceType == "raid" then
        drinkWarningFrame:Hide()
        RepositionWarnings()
        return
    end
    if not IsInPvPQueue() then
        drinkWarningFrame:Hide()
        RepositionWarnings()
        return
    end
    local _, healer = GetPVPRoles()
    if not healer then
        drinkWarningFrame:Hide()
        RepositionWarnings()
        return
    end
    local bestID, bestCount = nil, 0
    for _, itemID in ipairs(DRINK_ITEMS) do
        local c = C_Item.GetItemCount(itemID)
        if c > bestCount then
            bestCount = c
            bestID = itemID
        end
    end
    if bestCount < DRINK_THRESHOLD then
        drinkWarningFrame.icon:SetTexture(C_Item.GetItemIconByID(bestID or DRINK_ITEMS[1]))
        drinkWarningFrame.countText:SetText(bestCount)
        drinkWarningFrame:Show()
    else
        drinkWarningFrame:Hide()
    end
    RepositionWarnings()
end

function ns.UpdateFlaskWarning()
    if InCombatLockdown() then return end
    if not ns.GetFlaskWarningEnabled() then
        flaskWarningFrame:Hide()
        RepositionWarnings()
        return
    end
    local _, instanceType = GetInstanceInfo()
    if instanceType == "pvp" or instanceType == "arena" or instanceType == "party" or instanceType == "raid" then
        flaskWarningFrame:Hide()
        RepositionWarnings()
        return
    end
    if not IsInPvPQueue() then
        flaskWarningFrame:Hide()
        RepositionWarnings()
        return
    end

    local aura = C_UnitAuras.GetPlayerAuraBySpellID(FLASK_SPELL_ID)
    if not aura then
        local tex = C_Spell.GetSpellTexture(FLASK_SPELL_ID)
        flaskWarningFrame.icon:SetTexture(tex or 136243)
        flaskWarningFrame.topText:SetText("Aura Missing")
        flaskWarningFrame.topText:Show()
        local bagCount = C_Item.GetItemCount(FLASK_ITEM_ID)
        flaskWarningFrame.countText:SetText(bagCount)
        flaskWarningFrame.countText:SetFont(ns.LOCALE_FONT, 22, "OUTLINE")
        flaskWarningFrame.remainingText:SetText("Remaining")
        flaskWarningFrame:Show()
    elseif aura.expirationTime then
        local remaining = aura.expirationTime - GetTime()
        if remaining < FLASK_WARNING_THRESHOLD then
            flaskWarningFrame.icon:SetTexture(aura.icon or 136243)
            flaskWarningFrame.topText:SetText("")
            flaskWarningFrame.topText:Hide()
            local mins = math.ceil(remaining / 60)
            flaskWarningFrame.countText:SetText(mins .. "m")
            flaskWarningFrame.countText:SetFont(ns.LOCALE_FONT, 22, "OUTLINE")
            flaskWarningFrame.remainingText:SetText("Remaining")
            flaskWarningFrame:Show()
        else
            flaskWarningFrame:Hide()
        end
    else
        flaskWarningFrame:Hide()
    end
    RepositionWarnings()
end

local warningEventFrame = CreateFrame("Frame")
warningEventFrame:RegisterEvent("BAG_UPDATE")
warningEventFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
warningEventFrame:RegisterUnitEvent("UNIT_AURA", "player")
warningEventFrame:RegisterEvent("UPDATE_BATTLEFIELD_STATUS")
warningEventFrame:SetScript("OnEvent", function()
    C_Timer.After(0.5, function()
        ns.UpdateDrinkWarning()
        ns.UpdateFlaskWarning()
    end)
end)

-- ============================================================
-- Raid Frame Objective Icons (carrier flag/orb on Blizzard raid frames)
-- ============================================================
-- Reuses the friendlyObjectives / friendlyObjectivePickups data populated by
-- TrackFriendlyObjectives above. Renders a non-secure child icon on each
-- CompactUnitFrame; mirrors BlitzBrain's taint-free hooking pattern.

local raidObjectiveCache = {}

function BuildUnitToFrameMap()
    local map = {}
    for g = 1, 8 do
        for m = 1, 5 do
            local frame = _G["CompactRaidGroup" .. g .. "Member" .. m]
            if frame and frame:IsVisible() and frame.displayedUnit then
                map[frame.displayedUnit] = frame
            end
        end
    end
    for i = 1, 40 do
        local frame = _G["CompactRaidFrame" .. i]
        if frame and frame:IsVisible() and frame.displayedUnit then
            map[frame.displayedUnit] = frame
        end
    end
    for i = 1, 5 do
        local frame = _G["CompactPartyFrameMember" .. i]
        if frame and frame:IsVisible() and frame.displayedUnit then
            map[frame.displayedUnit] = frame
        end
    end
    return map
end

function EnsureRaidObjectiveIcon(compactFrame)
    if raidObjectiveCache[compactFrame] then return raidObjectiveCache[compactFrame] end

    local f = CreateFrame("Frame", nil, compactFrame)
    f:SetFrameLevel(compactFrame:GetFrameLevel() + 10)

    f.glow = f:CreateTexture(nil, "BACKGROUND")
    f.glow:SetPoint("CENTER")
    f.glow:SetTexture("Interface\\AddOns\\BlitzFramesTargets\\Textures\\PingHalo.tga")
    f.glow:SetBlendMode("ADD")
    f.glow:SetVertexColor(1.00, 0.92, 0.72, 1) -- candlelight, matches the ping halo

    f.icon = f:CreateTexture(nil, "ARTWORK")
    f.icon:SetPoint("CENTER")

    f.glow.animGroup = f.glow:CreateAnimationGroup()
    f.glow.animGroup:SetLooping("REPEAT")
    local fadeOut = f.glow.animGroup:CreateAnimation("Alpha")
    fadeOut:SetFromAlpha(0.75); fadeOut:SetToAlpha(0.40); fadeOut:SetDuration(0.6); fadeOut:SetOrder(1)
    local fadeIn = f.glow.animGroup:CreateAnimation("Alpha")
    fadeIn:SetFromAlpha(0.40); fadeIn:SetToAlpha(0.75); fadeIn:SetDuration(0.6); fadeIn:SetOrder(2)

    local stackOverlay = CreateFrame("Frame", nil, f)
    stackOverlay:SetAllPoints()
    stackOverlay:SetFrameLevel(f:GetFrameLevel() + 2)
    f.stackText = stackOverlay:CreateFontString(nil, "OVERLAY")
    f.stackText:SetFont(ns.LOCALE_FONT, 12, "OUTLINE")
    f.stackText:SetPoint("CENTER", f, "CENTER", 0, 0)
    f.stackText:SetTextColor(1, 1, 1, 1)
    f.stackText:SetJustifyH("CENTER")
    f.stackText:Hide()

    f:EnableMouse(false)
    f:Hide()
    raidObjectiveCache[compactFrame] = f
    return f
end

function HideAllRaidObjectiveIcons()
    for _, f in pairs(raidObjectiveCache) do
        if f.glow.animGroup:IsPlaying() then f.glow.animGroup:Stop() end
        f:Hide()
        if f.stackText then f.stackText:Hide() end
    end
end

function UpdateRaidObjectiveIcons()
    if not ns.GetRaidObjectiveEnabled() then
        HideAllRaidObjectiveIcons()
        return
    end
    -- not InBattleground(), NOT instanceType == "arena": a Training Ground
    -- practice arena reports "pvp", so the old test let objective icons run
    -- there. Outside a battleground there are no objectives to show anyway.
    if not InBattleground() then
        HideAllRaidObjectiveIcons()
        return
    end

    -- Friendly bars' ticker drives TrackFriendlyObjectives when active. If the
    -- friendly bars feature is disabled, scan ourselves so raid frame icons
    -- still update.
    if not activeFriendly then
        TrackFriendlyObjectives()
    end

    local map = BuildUnitToFrameMap()
    local iconSize = ns.GetRaidObjectiveIconSize()
    local offX = ns.GetRaidObjectiveOffsetX()
    local offY = ns.GetRaidObjectiveOffsetY()
    local anchor = ns.GetRaidObjectiveAnchor() or "RIGHT"
    local stackFontSize = ns.GetRaidObjectiveStackTextSize()
    local now = GetTime()

    local seen = {}

    for unit, frame in pairs(map) do
        -- UnitIsPlayer restored here too. The old note said it "earns nothing"
        -- because friendlyObjectives only holds real players -- but that check
        -- happens AFTER UnitName, and UnitName is exempt from secrecy only for
        -- player units. A raid frame showing a pet is enough to throw.
        if UnitIsPlayer(unit) ~= false and UnitIsFriend("player", unit) then
            local name = UnitName(unit)
            local classification = name and ns.friendlyObjectives[name]
            local texture = classification and OBJECTIVE_ICONS[classification]
            if texture then
                local f = EnsureRaidObjectiveIcon(frame)
                seen[f] = true
                f:SetSize(iconSize, iconSize)
                f.icon:SetSize(iconSize, iconSize)
                f.glow:SetSize(iconSize * 1.5, iconSize * 1.5)
                f.icon:SetTexture(texture)
                ApplyObjectiveGlowColor(f.glow, classification)
                f:ClearAllPoints()
                f:SetPoint(anchor, frame, anchor, offX, offY)
                f:Show()
                if not f.glow.animGroup:IsPlaying() then
                    f.glow.animGroup:Play()
                end
                if f.stackText then
                    local rec = ns.friendlyObjectivePickups[name]
                    local stacks = ObjectiveStackCount(rec and rec.pickupTime)
                    if stacks then
                        f.stackText:SetFont(ns.LOCALE_FONT, stackFontSize, "OUTLINE")
                        f.stackText:SetText(stacks)
                        f.stackText:Show()
                    else
                        f.stackText:Hide()
                    end
                end
            end
        end
    end

    for _, f in pairs(raidObjectiveCache) do
        if not seen[f] then
            if f.glow.animGroup:IsPlaying() then f.glow.animGroup:Stop() end
            f:Hide()
            if f.stackText then f.stackText:Hide() end
        end
    end
end
ns.UpdateRaidObjectiveIcons = UpdateRaidObjectiveIcons

local raidObjElapsed = 0
local RAID_OBJ_TICK = 0.1
local raidObjTicker = CreateFrame("Frame")
raidObjTicker:SetScript("OnUpdate", function(_, elapsed)
    raidObjElapsed = raidObjElapsed + elapsed
    if raidObjElapsed < RAID_OBJ_TICK then return end
    raidObjElapsed = 0
    UpdateRaidObjectiveIcons()
end)

