---@diagnostic disable: undefined-field, undefined-global
--- SUMMON-06: Summoning Window -- renders the classified summon session
-- data from SummonComm.lua in a standalone chrome window (titlebar +
-- banner + two-scrollable-section + footer). Reached via /wt summons
-- and the Shell nav row (which calls Private:ShowSummonWindow through
-- the shared private table). Ships in every build (UI/UI.xml entry is
-- above the do-not-package fence).
--
-- Phase 56: the Summoning UI is no longer a tab inside the main Shell
-- window -- it is its own standalone chrome window, opened lazily on
-- the first Private:ShowSummonWindow() call. The window is hidden at
-- the end of construction (SUMWIN-09 -- no auto-reopen after /reload).
-- The Summons screen registration in WildThings.lua is deleted; the
-- /wt summonsolo dev affordance feeds CreateSummonHeader's showSolo
-- flag so SUMDESIGN-12/13/14 and SUMWIN-08 are single-client exercisable.
--
-- Phase 56 (D-08): ONE SecureGroupHeaderTemplate replaces the previous
-- two-header design. The header is parented once at construction to
-- screenRoot and never reparented (Pitfall 2's whole attack surface
-- gone). Children are routed into the Needs / Inside section content
-- frames by SetPoint alone -- StyleRowContent's existing anchoring
-- proves this is safe (a child can be positioned inside a frame other
-- than its parent). The variant switcher (compact / standard / wide)
-- and the variant module-local are gone with it; only the standard
-- two-section split remains, and GetVariantLayout() now returns the
-- single standard metrics.
--
-- SUMMON-F1 (Phase 52): SecureGroupHeaderTemplate ersetzt Phase 44's
-- SecureUnitButtonTemplate. Der eine Header erzeugt bis zu 40
-- Kind-Buttons. Der Header managed Unit-Zuordnungen autark — bei
-- Roster-Shuffles (auch im Combat) bleiben die Bindings korrekt, weil
-- der Client Kinder bei Gruppen-Änderungen neu attributiert. Ein
-- Refresh-Loop außerhalb des Secure-Context steuert Sichtbarkeit,
-- Position und dynamisches Styling (Name, Klasse, Status-Icon,
-- Countdown, Tier-Tag).
--
-- SUMMON-11: Right-click context menu on player rows -- whisper (always available),
-- raid ping (officer-gated via UnitIsGroupLeader/UnitIsGroupAssistant), and an
-- 8-target raid marker picker (also officer-gated). Phase 56 (SUMDESIGN-13):
-- replaced the previous Blizzard UIDropDownMenu with a custom Frame popup
-- parented to UIParent so the Wild Things visual contract (DD_BG fill, 1px
-- strong border, 26px items, class-coloured header + 1px rule) is met and
-- Show/Hide are never protected mutations. Marker picker is a sibling popup
-- to the right of the menu; its 8 buttons read the row's unit attribute
-- LIVE so a re-arrange between right-click and click still acts on the
-- current row slot. Officer status is re-evaluated at open time.
-- In combat: right-click is a guarded no-op (no taint error, accepted degradation).
local _, namespace = ...
local Private = namespace
local WTUI = namespace.WildThingsUI
local Theme = namespace.Theme

---------------------------------------------------------------------------
-- Module-locals: layout constants
---------------------------------------------------------------------------

-- FOOTER_HEIGHT retired with the footer strip in plan 57-03 -- the banner,
-- portal strip and no-addon line are now the body's own geometry and
-- measure themselves. No constant survives from the old fixed-height strip.

-- Phase 57 (SUMDESIGN-05/06): the no-portal banner's pulse uses the same
-- shared glow driver as the rest of the addon. The id literal is a module
-- local so StartBannerPulse and StopBannerPulse register/stop the same key,
-- and the harness row A37 plan adds in 57-05 can grep for it.
local NO_PORTAL_BANNER_PULSE_ID = "noPortalPulse"

-- Body geometry (Design 1a body padding "12px 14px 10px"). These literals
-- are not Theme tokens because Design 1a introduces its own scale; the
-- UI-SPEC pins the exact pixel values (57-UI-SPEC "Spacing Scale" rows).
local BODY_HORIZONTAL_PAD = 14
local BODY_TOP_PAD = 12
local BODY_BOTTOM_PAD = 10
local BLOCK_GAP = 10
local SECTION_HEADER_BOX_GAP = 4
local SECTION_GAP = 12

-- Phase 57 (SUMDESIGN-15): these outer height bounds are locked by the phase
-- context. The measured arithmetic between them is deliberately best-effort;
-- the clamp is what prevents small pixel drift from producing a broken window.
local WINDOW_H_MIN = 220
local WINDOW_H_MAX = 620

-- Banner dimensions
local BANNER_PADDING_TOP = 9
local BANNER_PADDING_BOTTOM = 9
local BANNER_PADDING_LEFT = 11
local BANNER_PADDING_RIGHT = 11
local BANNER_LABEL_CHIP_GAP = 6
local CHIP_HEIGHT = 22
local CHIP_GAP = 6
local CHIP_PAD_X = 12

-- Portal strip
local PORTAL_STRIP_HEIGHT = 26
local PORTAL_STRIP_DOT_SIZE = 6
local PORTAL_STRIP_DOT_RADIUS = 3 -- = size/2 for a true circle
local PORTAL_STRIP_PAD_X = 11

-- No-addon line
local NO_ADDON_LINE_BOTTOM_PAD = 10
local NO_ADDON_LINE_ICON_GAP = 8
-- The line's minimum height: the 12 px info icon plus a little breathing room, so
-- a single-line message never renders shorter than its own icon.
local NO_ADDON_LINE_MIN_HEIGHT = 14

-- Half of the 6 px meta dot (layout.metaDotSize). AddRoundedFill needs a radius of
-- half the side length to render a circle; 0 renders a square.
local META_DOT_RADIUS = 3

-- Pin and auto-close state (SUMMON-12). Pinned flag is module-local, deliberately
-- never written to SavedVars per the CONTEXT locked decision -- resets on /reload.
local pinned = false
local autoCloseTicker = nil  -- C_Timer.NewTicker handle for 1s polling loop (Task 2)
local autoCloseTimer = nil   -- C_Timer.After handle for 5s grace countdown (Task 2)

-- SUMMON-01 (D-16): set when a ticker tick is skipped because the player is
-- in combat lockdown; cleared and force-rebuilt on the first out-of-combat
-- tick after that, so the display catches up within about a second of
-- combat ending. See .planning/v2.7-MILESTONE-AUDIT.md §1.
local pendingRebuild = false

-- Phase 56 (SUMWIN-08): set when the first secure-header build is requested
-- during combat; the existing 1s ticker creates it after combat ends.
local pendingFirstBuild = false

-- Phase 56 (SUMWIN-02): set when a close is requested while InCombatLockdown()
-- is true; picked up on the next out-of-combat ticker tick and the window
-- is hidden then. Mirrors the pendingRebuild pattern -- one chokepoint, one
-- flag, one pickup site, never a per-call-site inline guard.
local pendingClose = false

-- Phase 56 (SUMWIN-07, code review CR-02): the mirror of pendingClose. Show()
-- on the window is the SAME class of protected mutation as Hide() once the
-- secure header is a descendant -- the restriction is symmetric, and only the
-- close half was originally guarded.
--
-- It applies to a window that is ALREADY on screen when combat starts: the
-- refresh and the re-Show are deferred to the first out-of-combat tick.
-- A window that is NOT on screen is handled one level up by CR-A instead --
-- see DeferOpenUntilCombatEnds below. That is a change from SUMWIN-08 as
-- originally written, which demanded the window appear immediately.
local pendingShow = false

-- Phase 56 UAT (CR-A): a request to OPEN the window during combat does not open
-- it. The user asked for a chat notice plus an automatic open once combat ends,
-- rather than a window appearing mid-pull with a caption explaining that its
-- list is not there yet.
--
-- This needs its own event frame rather than the 1s ticker: the ticker is armed
-- inside BuildSummonWindow, and the whole point here is that nothing gets built
-- while in combat, so there is no ticker to pick the request up.
local deferredOpenRequested = false
local combatDeferFrame = nil

-- Phase 56 (SUMWIN-01/02): the chrome window handle.
local summonWindow = nil

-- CR-A: record an open request made during combat and arm the combat-end frame
-- that fulfils it. The frame is created once and then just sits there -- it holds
-- no state beyond the flag, so re-arming is a cheap early return.
--
-- The notice prints only on the FIRST deferred request of a fight. Repeated
-- /wt summons presses (or a seal landing on top of one) must not spam the chat
-- frame with the same sentence.
--
-- Private:ShowSummonWindow is resolved at event time, not here, so the forward
-- reference to its definition further down this file is safe -- it is a table
-- field lookup, not the upvalue trap documented under FORWARD DECLARATIONS.
local function DeferOpenUntilCombatEnds()
    if not combatDeferFrame then
        combatDeferFrame = CreateFrame("Frame")
        combatDeferFrame:RegisterEvent("PLAYER_REGEN_ENABLED")
        combatDeferFrame:SetScript("OnEvent", function()
            if not deferredOpenRequested then return end
            deferredOpenRequested = false
            Private.safecall(function()
                if Private.ShowSummonWindow then Private:ShowSummonWindow() end
            end)
        end)
    end

    if not deferredOpenRequested then
        deferredOpenRequested = true
        print("|cff99ff00Wild Things:|r Summoning window opens once combat ends.")
    end
end

-- Phase 56 (SUMDESIGN-12): event frame for PLAYER_TARGET_CHANGED. Created
-- once at window build time; the ring refresh below is its sole consumer.
-- Wrapped in Private.safecall per house convention (feature code never calls
-- pcall directly per CLAUDE.md "Errors are contained by Private.safecall").
local targetChangedFrame = nil

-- The right-click context menu (SUMMON-11 / SUMDESIGN-13) was REMOVED at the
-- user's request during Phase 56 UAT. Rows are left-click-to-target only. Gone
-- with it: the menu Frame, its dismiss catcher, the 8-marker picker, the whisper
-- and ping actions, the menuContext* state, and the row's type2 secure attribute.

---------------------------------------------------------------------------
-- FORWARD DECLARATIONS -- load-bearing, do not fold them into the
-- definitions further down this file.
---------------------------------------------------------------------------
-- Each of these four is CALLED earlier in the file than it is DEFINED. In
-- Lua a name referenced before any `local` of that name is in scope compiles
-- to a read of a GLOBAL, and that binding is fixed at compile time -- a later
-- `local function` of the same name creates a local and never sets the
-- global, so the earlier call site reads nil forever.
--
-- This is not theoretical. Phase 56's code review found the whole (since
-- removed) right-click menu dead this way, and a repo-wide scan then caught
-- CreateSummonHeader in the same state -- which would have made the SUMWIN-08
-- deferred first build throw instead of populating. SummonComm's SealSession
-- was found in the same state during UAT, which kept every list empty.
--
-- check.ps1 cannot see any of it: every file here opens with
-- `---@diagnostic disable: undefined-global`, which suppresses exactly this
-- check. Only reading, or a use-before-declaration scan, finds it.
--
-- Their definitions below are written `function Name(...)`, NOT
-- `local function Name(...)` -- the `local` keyword there would shadow these
-- declarations and silently re-break every earlier call site.
local ResolveChildKey
local CreateSummonHeader

local lastNeedsSummonCount = 0

local STATUS_NORMAL = "normal"
local STATUS_CLOSING = "closing"
local STATUS_WAITING_FOR_COMBAT = "waiting-for-combat"
local STATUS_DEFERRED_FIRST_BUILD = "deferred-first-build"

-- Phase 56 UAT: the status bar no longer carries a standing caption. The
-- "Click a name to target" hint and the "<n> to summon" count were removed --
-- the hint is self-evident from using the window once, and the count already
-- lives in the "Needs Summon (n)" section header. The bar now speaks only when
-- it has something to say: the auto-close countdown, the waiting-for-combat
-- state, and the deferred-first-build notice. STATUS_NORMAL blanks it.
--
-- needsSummonCount is still a parameter: every caller has it to hand and the
-- state machine reads better with the count present than with call sites
-- rewritten to drop it.
local function UpdateSummonStatus(needsSummonCount, state)
    if not summonWindow then return end

    local left = ""
    if state == STATUS_CLOSING then
        left = "Closing in 5s"
    elseif state == STATUS_WAITING_FOR_COMBAT then
        left = "Closing · waiting for combat to end"
    elseif state == STATUS_DEFERRED_FIRST_BUILD then
        left = "List appears once combat ends"
    end

    summonWindow:SetStatusLeft(left)
    summonWindow:SetStatusRight("")
end

-- Phase 56 (SUMWIN-01/02): the chrome window handle. Created lazily on the
-- first Private:ShowSummonWindow() call so SUMWIN-09 (no auto-reopen after
-- /reload) is enforced by construction -- a frame defaults to shown, and
-- the builder hides the window at its end. Position persists (CHROME-06);
-- visibility does not.
-- Phase 56 (SUMDESIGN verification dev affordance): default false; flipped
-- by /wt summonsolo -> Private:ToggleSummonSoloRows() -> forces a rebuild.
-- Feeds CreateSummonHeader's showSolo attribute so the Summon window is
-- single-client exercisable for SUMDESIGN-12/13/14 and SUMWIN-08 -- the
-- SecureGroupHeaderTemplate creates no children when showSolo is false.
-- Default off means no shipped behaviour change.
local summonSoloRows = false

-- SUMMON-07: Live summon-pending state per canonical key, polled from C_IncomingSummon
-- on the 1s ticker. Maps key -> { pending (boolean), remainingSec (number), startedAt (number) }.
-- RemainingSec is the ceil-rounded remaining time in seconds, updated every 1s tick.
-- Entries for players no longer in "Needs Summon" are cleaned up automatically.
local liveSummonStates = {}

-- SUMMON-07: the summon offer window. C_IncomingSummon exposes only a status
-- enum (none/pending/accepted/declined) and NO remaining time, so the countdown
-- is measured locally from the first tick on which we observed PENDING against
-- this window. Two minutes is the client's own summon-acceptance timeout.
local SUMMON_OFFER_WINDOW = 120

-- The pending value of the summon status enum. Enum.SummonStatus is the modern
-- source; the SUMMON_STATUS_* FrameXML globals are the older one. Both are
-- resolved defensively and fall back to the documented value, because comparing
-- against nil would read every pending summon as not-pending.
local SUMMON_PENDING = (Enum and Enum.SummonStatus and Enum.SummonStatus.Pending)
    or SUMMON_STATUS_PENDING or 1

-- SUMMON-13: targeting claims, refreshed from SummonComm on the 1s ticker.
-- Maps canonical key -> number of raiders currently targeting that player.
local targetedBy = {}

-- SUMDESIGN-10/11 (D-09): per-key dwell-state cache. Each entry carries the
-- CLASSIFICATION-TIER status (Needs Summon / Inside — Away / Offline /
-- In Combat) and the GetTime() stamp the player entered it. The transient
-- meta states (summoning / targeted / pending) deliberately do not reset
-- this -- only a genuine entry.status change can, because "how long has this
-- player needed a summon" is what the classifier answers and what the UI
-- survives a Rescan to keep answering (D-09). A Rescan zeroes session and
-- targetClaims in SummonComm, but never touches this table -- the keys are
-- canonical player keys, not session-row ids.
-- Maps key -> { status = <string>, enteredAt = <number> }.
local dwellStates = {}

-- SUMDESIGN-10/11: TTL for dwellStates entries. Far longer than any realistic
-- pull-window wait or the 5s auto-close grace; it never fires under normal
-- use. Its only job is to bound growth for a key that is never seen again
-- (a raider permanently removed from the roster) so dwellStates cannot leak.
local DWELL_TTL = 900 -- seconds (15 min)

-- SUMDESIGN-10/11: rewrite the cache entry only on a genuine classification
-- change for that key. An identical repeat call on every ticker pass must
-- leave enteredAt alone -- otherwise a Rescan that re-emits the same status
-- would restart the clock, which is exactly the failure D-09 forbids.
local function TouchDwellState(key, status)
    local existing = dwellStates[key]
    if not existing or existing.status ~= status then
        dwellStates[key] = { status = status, enteredAt = GetTime() }
    end
end

-- SUMDESIGN-10/11: floor of elapsed time since the cached enteredAt; 0 when
-- no entry exists yet -- so a first-sighted row renders 0:00 rather than
-- blank, exactly per the partial-data contract.
local function GetDwellSeconds(key)
    local s = dwellStates[key]
    if not s then return 0 end
    return math.floor(GetTime() - s.enteredAt)
end

-- SUMDESIGN-10/11: drop entries older than DWELL_TTL. Called from the always-
-- runs side of the 1s ticker's InCombatLockdown() guard, alongside the
-- read-only pollers; it performs no protected write. Deliberately NOT called
-- at any session boundary -- a Rescan IS a new session, and clearing here
-- would defeat the requirement the cache exists to satisfy.
local function PruneDwellStates()
    local now = GetTime()
    for key, entry in pairs(dwellStates) do
        if now - entry.enteredAt > DWELL_TTL then
            dwellStates[key] = nil
        end
    end
end

-- SUMDESIGN-10: minute + zero-padded second formatter in the repo's
-- established "%d:%02d" idiom (ReadyCheckCooldownPopup.lua:73-78). THIS ONE
-- COUNTS UP (GetDwellSeconds returns elapsed-since-enteredAt); the two
-- other mm:ss values in this window -- the C_IncomingSummon accept window
-- and the portal countdown -- both count down from a future deadline. Never
-- conflate them.
local function mmss(t)
    if t < 0 then t = 0 end
    local m = math.floor(t / 60)
    local s = t % 60
    return string.format("%d:%02d", m, s)
end

-- SUMDESIGN-07/08/09: resolve a row's meta state in the UI-SPEC's fixed
-- priority order. First match wins. Read top-to-bottom:
--   1. offline -- a fact about the player themselves, beats anything
--      transient layered on top (a player cannot be "being summoned" while
--      genuinely offline).
--   2. combat  -- same rationale; placement-affecting player fact.
--   3. summoning -- a live summon offer on this specific player, the most
--      actionable signal for this row.
--   4. targeted -- a SUMMON_TARGET claim against this player; outranks the
--      mere "pending portal" raid-wide fact.
--   5. pending  -- an active portal exists; raid-wide, lowest among the
--      "something is happening" tiers -- only renders when nothing more
--      specific applies.
--   6. default  -- the Waiting baseline.
-- The target-count read comes from the existing targetedBy module-local;
-- NEVER reinstated local target polling -- UnitTarget() does not exist.
-- Returns a table with: id, label, color, dotPulses, dotAlpha, nameAlpha,
-- showsBar.

-- SUMMON-14: Is-summoning default -- always "Waiting" per seed gap G2 (no usable
-- signal exists in the WoW API for detecting whether a specific player is actively
-- being summoned at a summoning stone).
--
-- ⚠ DECLARED HERE, ABOVE ITS ONLY READER, ON PURPOSE. It previously sat ~50 lines
-- BELOW ResolveMetaState, so the read inside that function compiled as a GLOBAL
-- lookup and evaluated to nil at runtime -- a `local` declared later in a file is
-- not in scope earlier in it. The default "Waiting" label, the most frequent meta
-- state of all, rendered blank on every row. `check.ps1` cannot see this: every
-- file's `---@diagnostic disable: undefined-global` header suppresses exactly that
-- check. This is the same forward-reference bug class the repo already shipped once
-- as `SealSession` in SummonComm.lua. Do not move it back down.
local SUMMON_TIER_WAITING = "Waiting"

local function ResolveMetaState(key, entry)
    if entry.status == "Offline" then
        return {
            id = "offline",
            label = "Offline",
            color = Theme.DANGER,
            dotPulses = false,
            dotAlpha = 1,
            nameAlpha = 0.5,
            showsBar = false,
        }
    end
    if entry.status == "In Combat" then
        return {
            id = "combat",
            label = "In combat",
            color = Theme.DANGER,
            dotPulses = false,
            dotAlpha = 1,
            nameAlpha = 0.65,
            showsBar = false,
        }
    end
    local liveState = liveSummonStates[key]
    if liveState and liveState.pending then
        return {
            id = "summoning",
            label = "Summoning",
            color = Theme.ACCENT,
            dotPulses = true,
            dotAlpha = 1,
            nameAlpha = 1,
            showsBar = true,
        }
    end
    local claims = targetedBy[key]
    if claims and claims > 0 then
        local label = claims > 1 and ("Targeted (" .. claims .. ")") or "Targeted"
        return {
            id = "targeted",
            label = label,
            color = Theme.INFO,
            dotPulses = false,
            dotAlpha = 1,
            nameAlpha = 1,
            showsBar = false,
        }
    end
    -- Guarded cross-file call. Mirrors the defensive pattern UpdateFooterInfo
    -- uses (SummonComm.lua is loaded after this file per WildThings.toc, so
    -- the accessor exists by the time the ticker fires -- the type check is
    -- for the do-not-package path).
    if type(Private.GetActivePortalStatus) == "function" then
        local portal = Private:GetActivePortalStatus()
        if portal then
            local remaining = math.max(0, 300 - math.floor(GetTime() - portal.placedAt))
            return {
                id = "pending",
                label = "Portal open · " .. mmss(remaining),
                color = Theme.WARN,
                dotPulses = true,
                dotAlpha = 1,
                nameAlpha = 1,
                showsBar = false,
            }
        end
    end
    return {
        id = "waiting",
        label = SUMMON_TIER_WAITING,
        color = Theme.TEXT_DIM,
        dotPulses = false,
        dotAlpha = 0.35,
        nameAlpha = 1,
        showsBar = false,
    }
end

-- SUMDESIGN-07: dot alpha pulse, 1-second true period (NOT Theme.GLOW_SPEED
-- = 6 rad/s, which is tuned for the accent grid-slot glows). Registered on
-- WTUI:StartGlow keyed by the dot frame itself (its table identity), so per-
-- row registration is collision-free even under row pooling -- the SAME dot
-- frame can carry many players' renderings across passes, so the stop call
-- for non-pulsing rows must be UNCONDITIONAL on every pass (not transition-
-- triggered), see ApplyRowAnatomy.
-- The elapsed accumulator lives on the DOT FRAME, not in this closure. It has
-- to: ApplyRowAnatomy calls this on EVERY pass for a still-pulsing row, and
-- WTUI:StartGlow is idempotent by (owner, id) -- it REPLACES _glowFns[idx] with
-- whatever function it is handed (GlowDriver.lua:76-80) rather than rejecting
-- the re-registration. With a closure-local accumulator each pass therefore
-- installed a fresh one starting at 0, so the sine restarted from phase 0 on
-- every body rebuild and the dot never completed a smooth cycle. Keying the
-- accumulator to the frame makes re-registration phase-preserving.
local function StartDotPulse(dot)
    dot._pulseElapsed = dot._pulseElapsed or 0
    WTUI:StartGlow(dot, "metaPulse", function(owner, dt)
        dot._pulseElapsed = dot._pulseElapsed + dt
        -- 2*pi / 1.0 = a true 1-second period per the design.
        local alpha = 0.4 + 0.6 * (0.5 + 0.5 * math.sin(dot._pulseElapsed * (2 * math.pi / 1.0)))
        dot:SetAlpha(alpha)
    end)
end

-- SUMDESIGN-07: pair of StartDotPulse. WTUI:StopGlow is documented to be a
-- safe no-op for an unregistered owner+id (UI/GlowDriver.lua:99-102), which
-- is what makes the unconditional call below safe -- a row that has never
-- pulsed in a session has no registration to tear down.
local function StopDotPulse(dot)
    WTUI:StopGlow(dot, "metaPulse")
    -- Clear the phase accumulator too, so a pooled row that later renders a
    -- pulsing player again starts its cycle at 0 rather than resuming the
    -- previous player's phase. Paired with StartDotPulse's `or 0` init.
    dot._pulseElapsed = nil
    dot:SetAlpha(1)
end

-- SUMMON-09: Footer UI references for portal indicator and warlock chips.
-- Updated by UpdateFooterInfo on every 1s ticker poll.
-- Phase 57 (SUMDESIGN-05/06): the footer strip is GONE. The two
-- mutually-exclusive top-of-body blocks (noPortalBanner / portalStrip) plus
-- the noAddonLine below the scroll frame replace it. All three are
-- constructed once at window build time and driven from UpdateFooterInfo
-- every pass. The chip pool relocates into noPortalBanner (chips live
-- INSIDE the banner, per SUMDESIGN-05) so the pool is no longer a global.
local noPortalBanner = nil
local noPortalBannerLabel = nil
local noPortalBannerChipPool = {}  -- { button, label } pool, banner-local
local portalStrip = nil
local portalStripLabel = nil
local portalStripDot = nil
local noAddonLine = nil
local noAddonLineLabel = nil
local noAddonLineIcon = nil
local confirmBlock = nil

-- Phase 57 (SUMDESIGN-04): no-addon cohort -- keys whose ClassifyAll status
-- is "No Addon". Collected by RebuildBody in the same walk that already
-- tallies the outstanding count, then passed to UpdateNoAddonLine. Lives
-- here so UpdateFooterInfo and UpdateNoAddonLine can both read it without
-- re-deriving; populated every pass, never read between passes.
local noAddonCohort = {}

-- SUMMON-15: Frame references set during CreateSummonScreen so RebuildBody can
-- tear down and rebuild the body sections on variant switch.
local needsScrollFrame = nil
local screenRoot = nil

-- SUMMON-F1 (Phase 52) / Phase 56 D-08: ONE SecureGroupHeaderTemplate,
-- parented once to screenRoot, never reparented. Each child button is
-- routed into the Needs or Inside section by SetPoint alone (in
-- BuildSecureRows), so the runtime SetParent surface of the previous
-- two-header design is gone -- Pitfall 2's whole attack surface
-- eliminated at the source. Collapsing the variant switcher and the
-- singleList branch in BuildSecureRows (plan 56-01 task 2) was the
-- necessary precondition for this -- a second header was the only reason
-- the singleList branch existed.
local summonHeader = nil

-- SUMMON-15: GETVARIANTLAYOUT() keeps its return shape so every existing
-- caller and Phase 57's rewrite are unaffected. Phase 56 task 2 deletes
-- the singleList branch; the function still returns rowHeight, padX and
-- showLocation -- StyleRowContent reads showLocation (UI-SPEC carries it
-- forward to the wide variant even though 56-01 no longer renders it).
-- Phase 57 adds the new D-08 fields (banner config etc.) to the table
-- and ShapeRowContent reads them.

-- Phase 57 (SUMDESIGN-16, D-11): STATUS_ICON -- REMOVED. The per-row
-- classification icon column is GONE from Design 1a's row anatomy
-- (UI-SPEC "Row Anatomy"); row state is now communicated by the meta group's
-- colour dot and label, not by a per-status icon. The remaining icon
-- obligation (banner, confirmation block, no-addon footer line) is handled
-- by namespace:CreateIcon with on-disk-confirmed stems in plans 57-03 and
-- 57-04. The two hand-built ".tga" paths this table indexed into are
-- deleted with it -- D-11's bug class disappears from this file because
-- the code that could ever mis-build a path is gone.

---------------------------------------------------------------------------
-- SUMMON-15: GetVariantLayout() -- the single layout (variant switcher removed)
---------------------------------------------------------------------------

-- Phase 56 (D-08): the variant switcher is GONE, so the layout is fixed at
-- the standard metrics. The function and its return shape are kept so every
-- existing caller and Phase 57's rewrite (which adds new D-08 fields) are
-- unaffected. singleList is no longer returned -- the branch it gated in
-- BuildSecureRows is also gone.
--
-- Phase 57: row anatomy rewritten to 32 px (Design 1a), with new metric
-- fields for the dwell column width, cast-bar thickness, meta dot size and
-- dot/label gap. showLocation is KEPT here even though D-02 deletes its last
-- consumer in StyleRowContent this plan -- check-chrome.ps1 row A27 asserts
-- that rowHeight / padX / showLocation are all still returned, and removing
-- the field to tidy dead data would fail TEARDOWN-01's own guard. The values
-- are phase constants, not per-pass data; this stays a single table literal
-- with no conditional branch.
local function GetVariantLayout()
    return {
        rowHeight = 32,
        padX = 10,
        showLocation = false,
        dwellWidth = 34,
        castBarHeight = 2,
        metaDotSize = 6,
        dotLabelGap = 4,
    }
end

---------------------------------------------------------------------------
-- DisplayKey(key) -- strip realm suffix for display text
---------------------------------------------------------------------------

-- Mirrors RaidTimelineScreen.lua:388-398. Takes a canonical "Name-Realm" key
-- and returns "Name" (title-cased). Non-string keys and single-token keys pass
-- through unchanged.
local function DisplayKey(key)
    if type(key) ~= "string" then
        return key
    end
    local name, realm = key:match("^(.-)%-(.+)$")
    if not name then
        return (key:gsub("^[a-z]", string.upper))
    end
    name = (name:gsub("^[a-z]", string.upper))
    realm = (realm:gsub("^[a-z]", string.upper))
    return name .. "-" .. realm
end

-- Phase 57 (T-57-01): SanitizeDisplayText -- strip WoW's text-escape
-- introducer (|) from any string this file renders in a FontString.
--
-- The addon comm channel is spoofable and the trust boundary is guild rank,
-- not cryptography (Transmission.lua); `portal.casterName` arrives over the
-- PORTAL message and was rendered verbatim in the previous build. A crafted
-- name containing escape sequences could forge a clickable hyperlink
-- (|H<...>|h[...]|h) inside this window or swallow the rest of the line
-- (|n, |T, |c<...>). Names sourced from UnitFullName via ResolveChildKey
-- are not attacker-controlled, but routing every rendered name through one
-- helper is cheaper than tracking which of them is which -- and the
-- classified table is keyed by canonical keys that can originate from a
-- SUMMON_POS reply.
--
-- Non-strings are returned unchanged (so a nil-arg call site cannot
-- nil-error here) and nil maps to "" so the call site never has to
-- nil-guard separately.
local function SanitizeDisplayText(s)
    if s == nil then return "" end
    if type(s) ~= "string" then return s end
    -- WoW's escape introducer is the vertical bar. Strip every occurrence
    -- so a sequence like |Hitem:123|h[link]|h becomes Hitem:123h[link]h --
    -- visually noisy but harmless, and crucially no longer interpreted as
    -- an escape. Players see the leading text, never a clickable link.
    local out = s:gsub("|", "")
    return out
end

---------------------------------------------------------------------------
-- Phase 56: SafeCloseSummonWindow() -- the combat-guarded close chokepoint
---------------------------------------------------------------------------

-- Mirrors the SafeRebuildBody() guard-and-defer pattern. Once a
-- SecureGroupHeaderTemplate header is a descendant of the Summon window,
-- Hide() on the window itself is a protected mutation blocked by
-- InCombatLockdown() when triggered from insecure addon code -- and the
-- titlebar X is exactly that case. Every close path routes through this
-- function: titlebar X (via opts.onClose in CreateChromeWindow), the
-- auto-close grace timer in CheckAutoClose below, and any future
-- programmatic close. Per-call-site inline guards are forbidden (CR-02
-- defect class Phase 52 caught twice).
--
-- A close attempted in combat is deferred-and-retried: pendingClose is set
-- here and picked up by the 1s ticker in StartAutoCloseTicker (below), past
-- that callback's own InCombatLockdown() early-return -- so the pickup does
-- the bare Hide() rather than re-entering this function, which would only
-- re-ask a question already answered. That pickup is this chokepoint's own
-- deferred continuation, not a third close path.
--
-- Returns true when the window was actually hidden, false when combat
-- deferred it. CheckAutoClose needs that distinction to show the
-- waiting-for-combat caption instead of a countdown that looks stuck.
--
-- Nil-guard summonWindow so the very first call, before any builder has
-- run, is a safe no-op.
local function SafeCloseSummonWindow()
    if InCombatLockdown() then
        -- Mutually exclusive with pendingShow -- see ShowSummonWindow.
        pendingClose = true
        pendingShow = false
        return false
    end
    if summonWindow then
        summonWindow:Hide()
    end
    return true
end

---------------------------------------------------------------------------
-- CheckAutoClose() -- auto-close polling for the summoning window (SUMMON-12)
---------------------------------------------------------------------------

-- Called by the C_Timer.NewTicker every ~1s. When the window is not pinned and
-- every classified entry has a status other than "Needs Summon", a 5-second grace
-- timer starts. If a "Needs Summon" entry re-appears during the grace period (e.g.
-- after a Rescan), the grace timer is cancelled. When the grace timer fires and
-- the window is still unpinned, the standalone Summon window is closed via the
-- SafeCloseSummonWindow chokepoint (Phase 56).
--
-- Caveat: the 1s ticker interval means there is at most a 1-second delay between
-- the session data satisfying the "everyone inside" condition and the grace timer
-- starting. This is inherent in the polling design and is the "already-satisfied
-- on first open" behaviour described in the CONTEXT -- the window closes as soon
-- as the ticker observes the all-inside condition plus the 5s grace period.
local function CheckAutoClose()
    if pinned then return end  -- never close when pinned

    local classified = Private:GetSummonSessionResults()
    if not classified then return end  -- no session data, do nothing

    -- Check if ANY entry still has status "Needs Summon"
    for _, entry in pairs(classified) do
        if entry.status == "Needs Summon" then
            -- Still someone outside -- cancel any in-progress grace countdown
            if autoCloseTimer then
                autoCloseTimer:Cancel()
                autoCloseTimer = nil
            end
            UpdateSummonStatus(lastNeedsSummonCount, STATUS_NORMAL)
            return
        end
    end

    -- Everyone inside -- start 5s grace ONLY if not already counting
    if not autoCloseTimer then
        UpdateSummonStatus(0, STATUS_CLOSING)
        autoCloseTimer = C_Timer.NewTimer(5, function()
            autoCloseTimer = nil
            if not pinned then
                local closed = SafeCloseSummonWindow()
                if not closed then
                    UpdateSummonStatus(0, STATUS_WAITING_FOR_COMBAT)
                end
            end
        end)
    end
end

---------------------------------------------------------------------------
-- SUMMON-07: PollLiveSummonStates() -- C_IncomingSummon polling
---------------------------------------------------------------------------

-- Polls C_IncomingSummon.HasIncomingSummon / IncomingSummonStatus for every
-- "Needs Summon" player that has a unit attribute. Updates the module-local
-- liveSummonStates table. Returns true if any state changed since the last
-- poll (including cleanup of stale entries), false otherwise.
local function PollLiveSummonStates()
    local classified = Private:GetSummonSessionResults()
    if not classified then
        if next(liveSummonStates) then
            liveSummonStates = {}
            return true
        end
        return false
    end

    local changed = false
    local seen = {}

    for key, entry in pairs(classified) do
        if entry.status == "Needs Summon" and entry.unit then
            seen[key] = true
            -- HasIncomingSummon, NOT HasPendingSummon -- the latter does not
            -- exist and threw "attempt to call a nil value" once per tick.
            -- IncomingSummonStatus returns a status NUMBER (none/pending/
            -- accepted/declined), not a table: an accepted or declined summon
            -- is no longer something the raid is waiting on, so only PENDING
            -- counts as pending here.
            local prev = liveSummonStates[key]
            local hasPending = C_IncomingSummon.HasIncomingSummon(entry.unit)
                and C_IncomingSummon.IncomingSummonStatus(entry.unit) == SUMMON_PENDING

            -- The API carries no remaining time (SUMMON_OFFER_WINDOW above),
            -- so the countdown runs from the first tick that saw this pending.
            local remaining = 0
            local startedAt = nil
            if hasPending then
                startedAt = (prev and prev.pending and prev.startedAt) or GetTime()
                remaining = math.max(0, math.ceil(SUMMON_OFFER_WINDOW - (GetTime() - startedAt)))
            end

            if not prev or prev.pending ~= hasPending or prev.remainingSec ~= remaining then
                changed = true
                liveSummonStates[key] = { pending = hasPending, remainingSec = remaining, startedAt = startedAt }
            end
        end
    end

    -- Clean up entries no longer in "Needs Summon"
    for key in pairs(liveSummonStates) do
        if not seen[key] then
            liveSummonStates[key] = nil
            changed = true
        end
    end

    return changed
end

---------------------------------------------------------------------------
-- Phase 57 (SUMDESIGN-05/06): StartBannerPulse / StopBannerPulse
---------------------------------------------------------------------------

-- The banner's border pulses amber via the shared glow driver. The id
-- literal is a module-local so start and stop register/stop the same key
-- (the driver's O(1) swap-remove + 0-live self-hide is the load-bearing
-- DoS bound -- GlowDriver.lua:99-121). The closure is bespoke rather than
-- WTUI:BorderPulse() because that convenience helper hardcodes
-- Theme.GLOW_COLOR (accent green) and the banner needs WARN. The
-- GLOW_ALPHA_MIN/MAX envelope is reused because the minimum (0.35) already
-- coincides with Theme.WARN_A35, the banner's static border colour, so the
-- pulse's resting floor matches the design's non-animated reference
-- rather than approximating it.
local function StartBannerPulse(banner)
    local elapsedTotal = 0
    WTUI:StartGlow(banner, NO_PORTAL_BANNER_PULSE_ID, function(owner, dt)
        elapsedTotal = elapsedTotal + dt
        local range = (Theme.GLOW_ALPHA_MAX - Theme.GLOW_ALPHA_MIN) / 2
        local mid = Theme.GLOW_ALPHA_MIN + range
        local alpha = mid + range * math.sin(elapsedTotal * Theme.GLOW_SPEED)
        local c = Theme.WARN
        banner:SetBorderColor(c.r, c.g, c.b, alpha)
    end)
end

-- Stopping the pulse restores the static WARN_A35 border so the banner
-- looks correct on its last frame before Hide(). StopGlow is a safe
-- no-op when the pair was never registered (GlowDriver.lua:99-102), so
-- calling this unconditionally on every portal-up pass is safe.
local function StopBannerPulse(banner)
    WTUI:StopGlow(banner, NO_PORTAL_BANNER_PULSE_ID)
    if banner and banner.SetBorderColor then
        banner:SetBorderColor(Theme.WARN_A35.r, Theme.WARN_A35.g, Theme.WARN_A35.b, Theme.WARN_A35.a)
    end
end

---------------------------------------------------------------------------
-- Phase 57 (SUMDESIGN-05): BuildBannerBlock(parent) -- create-once
---------------------------------------------------------------------------

-- The no-portal banner. Created exactly once at window build time. Its
-- body geometry (label + chip row) is set up here, but the chip pool
-- itself is filled per pass inside UpdateFooterInfo -- those writes are
-- SecureUnitButtonTemplate SetAttribute calls and must stay behind the
-- combat gate. The banner frame (border, fill, icon, label) is plain
-- insecure Lua and needs no guard.
--
-- Initial height is the label-only height (9 + 13 + 9 = 31); UpdateFooterInfo
-- re-sizes the banner every pass to include the chip rows it built, and
-- re-anchors needsScrollFrame's TOP below the (now larger) banner.
local function BuildBannerBlock(parent)
    local banner = CreateFrame("Frame", nil, parent)
    banner:SetPoint("TOPLEFT", parent, "TOPLEFT", BODY_HORIZONTAL_PAD, -BODY_TOP_PAD)
    banner:SetPoint("TOPRIGHT", parent, "TOPRIGHT", -BODY_HORIZONTAL_PAD, -BODY_TOP_PAD)

    -- WARN_A10 fill (the design's rgba(255,184,77,.1)) and a 1 px WARN_A35
    -- border at RADIUS_MD. Border is shown immediately so the pulse has
    -- something visible to drive; the static colour is set, then
    -- StartBannerPulse takes over the per-frame colour.
    WTUI:AddRoundedFill(banner, Theme.RADIUS_MD, Theme.WARN_A10)
    WTUI:AddRoundedBorder(banner, Theme.RADIUS_MD, 1)
    banner:SetBorderColor(Theme.WARN_A35.r, Theme.WARN_A35.g, Theme.WARN_A35.b, Theme.WARN_A35.a)
    banner:ShowBorder()

    -- Icon: triangle-alert via the always-routed CreateIcon path (D-11).
    -- No hand-built texture path; the path is always Theme.GetIconPath.
    local icon = namespace:CreateIcon(banner, "triangle-alert", Theme.ICON_MD)
    icon:SetPoint("TOPLEFT", banner, "TOPLEFT", BANNER_PADDING_LEFT, -BANNER_PADDING_TOP)
    icon:SetVertexColor(Theme.WT_WHITE.r, Theme.WT_WHITE.g, Theme.WT_WHITE.b, Theme.WT_WHITE.a)

    -- Label: fixed literal "No portal up", WildThingsFont13, white.
    local label = banner:CreateFontString(nil, "ARTWORK")
    label:SetFontObject(WildThingsFont13)
    label:SetText("No portal up")
    label:SetTextColor(Theme.WT_WHITE.r, Theme.WT_WHITE.g, Theme.WT_WHITE.b, Theme.WT_WHITE.a)
    label:SetJustifyH("LEFT")
    label:SetWordWrap(false)
    label:SetPoint("LEFT", icon, "RIGHT", Theme.GAP, 0)
    label:SetPoint("RIGHT", banner, "RIGHT", -BANNER_PADDING_RIGHT, 0)
    noPortalBannerLabel = label

    -- Initial banner height: padding-top + label + padding-bottom. The
    -- chip row(s) below are not present at this point; UpdateFooterInfo
    -- adds them per pass and re-sizes the banner to fit.
    local labelH = label:GetStringHeight() or 13
    banner:SetHeight(BANNER_PADDING_TOP + labelH + BANNER_PADDING_BOTTOM)

    return banner
end

---------------------------------------------------------------------------
-- Phase 57 (SUMDESIGN-06): BuildPortalStrip(parent) -- create-once
---------------------------------------------------------------------------

-- The portal-up strip. Created exactly once; never has chips or animated
-- content, so no per-pass geometry is needed. Single 26 px line; the label
-- is right-anchored with word wrap disabled so a long realm-qualified
-- caster name truncates with an ellipsis rather than growing the strip.
local function BuildPortalStrip(parent)
    local strip = CreateFrame("Frame", nil, parent)
    strip:SetHeight(PORTAL_STRIP_HEIGHT)
    strip:SetPoint("TOPLEFT", parent, "TOPLEFT", BODY_HORIZONTAL_PAD, -BODY_TOP_PAD)
    strip:SetPoint("TOPRIGHT", parent, "TOPRIGHT", -BODY_HORIZONTAL_PAD, -BODY_TOP_PAD)

    -- ACCENT_A08 fill, ACCENT_A20 1 px border at RADIUS_MD. Static
    -- colours, no pulse -- the strip is informational, not a warning.
    WTUI:AddRoundedFill(strip, Theme.RADIUS_MD, Theme.ACCENT_A08)
    WTUI:AddRoundedBorder(strip, Theme.RADIUS_MD, 1)
    strip:SetBorderColor(Theme.ACCENT_A20.r, Theme.ACCENT_A20.g, Theme.ACCENT_A20.b, Theme.ACCENT_A20.a)
    strip:ShowBorder()

    -- 6 px static accent dot -- a plain Frame with AddRoundedFill at
    -- radius 3 (size/2) for a true circle, NOT a Lucide glyph (the dot is
    -- too small to read a glyph on, matching the meta-state dot pattern
    -- in BuildRowAnatomy/ApplyRowAnatomy).
    local dot = CreateFrame("Frame", nil, strip)
    dot:SetSize(PORTAL_STRIP_DOT_SIZE, PORTAL_STRIP_DOT_SIZE)
    WTUI:AddRoundedFill(dot, PORTAL_STRIP_DOT_RADIUS, Theme.ACCENT)
    dot:SetPoint("LEFT", strip, "LEFT", PORTAL_STRIP_PAD_X, 0)
    portalStripDot = dot

    -- Label: "Portal up — placed by {name}". Em dash is U+2014 (NEVER two
    -- ASCII hyphens, see the Wave-1 bug class documented in this plan's
    -- preamble). The {name} slot is rewritten every pass by
    -- UpdateFooterInfo; both DisplayKey (realm-strip) and SanitizeDisplayText
    -- (escape-strip) run over the rendered name segment.
    local label = strip:CreateFontString(nil, "ARTWORK")
    label:SetFontObject(WildThingsFont13)
    label:SetText("Portal up — placed by ")
    label:SetTextColor(Theme.ACCENT.r, Theme.ACCENT.g, Theme.ACCENT.b, Theme.ACCENT.a)
    label:SetJustifyH("RIGHT")
    label:SetWordWrap(false)
    label:SetPoint("LEFT", dot, "RIGHT", Theme.GAP, 0)
    label:SetPoint("RIGHT", strip, "RIGHT", -PORTAL_STRIP_PAD_X, 0)
    portalStripLabel = label

    -- Initial hidden state -- UpdateFooterInfo shows it on a portal-up tick.
    strip:Hide()
    return strip
end

---------------------------------------------------------------------------
-- Phase 57 (SUMDESIGN-04): BuildNoAddonLine(parent) -- create-once
---------------------------------------------------------------------------

-- The single dimmed no-addon footer line. Created exactly once at the
-- body's bottom. NEVER has a mouse script, EnableMouse, or
-- RegisterForClicks call (D-12 + this plan's "Structural" decision --
-- the milestone's own Out-of-Scope row says "Whisper action on the
-- no-addon footer line"). Word wrap is ENABLED so a long list wraps
-- inside the body's scroll frame rather than truncating and silently
-- hiding a raider (UI-SPEC E5 overflow).
local function BuildNoAddonLine(parent)
    local line = CreateFrame("Frame", nil, parent)
    line:SetPoint("BOTTOMLEFT", parent, "BOTTOMLEFT", BODY_HORIZONTAL_PAD, BODY_BOTTOM_PAD)
    line:SetPoint("BOTTOMRIGHT", parent, "BOTTOMRIGHT", -BODY_HORIZONTAL_PAD, BODY_BOTTOM_PAD)

    -- Icon: info at 12 px via CreateIcon. The factory's default idleAlpha
    -- is 0.7; the contract here is 0.4. SetAlpha is called explicitly
    -- after creation so a future default change does not silently drift
    -- the icon to the wrong opacity (Q-icon in 57-RESEARCH.md).
    local icon = namespace:CreateIcon(line, "info", 12)
    icon:SetPoint("LEFT", line, "LEFT", 0, 0)
    icon:SetAlpha(0.4)
    noAddonLineIcon = icon

    -- Label: invariant "No position reported (no addon): " followed by the
    -- sorted comma-joined name list. TEXT_DIM, WildThingsFont13, word wrap
    -- ENABLED. No mouse handler, no EnableMouse.
    local label = line:CreateFontString(nil, "ARTWORK")
    label:SetFontObject(WildThingsFont13)
    label:SetTextColor(Theme.TEXT_DIM.r, Theme.TEXT_DIM.g, Theme.TEXT_DIM.b, Theme.TEXT_DIM.a)
    label:SetJustifyH("LEFT")
    label:SetWordWrap(true)
    label:SetPoint("LEFT", icon, "RIGHT", NO_ADDON_LINE_ICON_GAP, 0)
    label:SetPoint("RIGHT", line, "RIGHT", 0, 0)
    label:SetPoint("TOP", line, "TOP", 0, 0)
    label:SetPoint("BOTTOM", line, "BOTTOM", 0, 0)
    noAddonLineLabel = label

    -- Initial hidden state. The line is only shown when the cohort
    -- RebuildBody collects on this pass is non-empty.
    line:Hide()
    return line
end

---------------------------------------------------------------------------
-- Phase 57 (SUMDESIGN-04): UpdateNoAddonLine(entries)
---------------------------------------------------------------------------

-- The label is invariant ("No position reported (no addon): "), so there
-- is no singular/plural copy branch to get wrong. With zero entries the
-- container is hidden ENTIRELY -- no blank line, no bare icon, no space
-- reserved. With one entry the line is a single sentence. With many
-- entries the names are comma-joined and word-wrap absorbs the rest.
--
-- A raider with no resolvable display name is omitted rather than rendered
-- as an empty slot (UI-SPEC E5 partial). The caller (RebuildBody) is
-- expected to pass the cohort sorted by canonical key -- the same
-- convention the existing warlock-chip sort uses, per 57-PATTERNS.md.
local function UpdateNoAddonLine(entries)
    if not noAddonLine or not noAddonLineLabel then return end

    if not entries or #entries == 0 then
        noAddonLine:Hide()
        -- Scroll frame's bottom can fall to screenRoot's bottom padding now --
        -- the no-addon line is not on screen, so no gap above it exists.
        --
        -- screenRoot, NOT root: `root` is a local inside BuildSummonWindow ~1500
        -- lines below, so reading it here compiled as a nil global and this whole
        -- branch never ran. screenRoot is the module-local that BuildSummonWindow
        -- assigns from it. check.ps1 cannot see this class of mistake.
        if needsScrollFrame and screenRoot then
            needsScrollFrame:SetPoint("BOTTOMRIGHT", screenRoot, "BOTTOMRIGHT",
                -BODY_HORIZONTAL_PAD, NO_ADDON_LINE_BOTTOM_PAD)
        end
        return
    end

    local names = {}
    for _, key in ipairs(entries) do
        -- SanitizeDisplayText is the comm-channel safety: an attacker-
        -- controlled name containing a |H link would otherwise forge a
        -- clickable hyperlink inside the raid lead's window. DisplayKey
        -- strips the realm suffix first, so a "Name-Realm" canonical key
        -- renders as "Name". Both run unconditionally -- the cost is
        -- trivial and tracking which of the two cohorts is attacker-
        -- controlled is exactly the kind of bookkeeping a single helper
        -- exists to avoid.
        local display = SanitizeDisplayText(DisplayKey(key))
        if display and display ~= "" then
            table.insert(names, display)
        end
    end

    noAddonLineLabel:SetText("No position reported (no addon): " .. table.concat(names, ", "))

    -- Give the line an explicit height, measured from the wrapped label.
    --
    -- Without this the sizing is CIRCULAR and resolves to nothing: the line is
    -- anchored only LEFT/RIGHT so it has no height of its own, while the label is
    -- anchored TOP and BOTTOM to the line, so it takes its height FROM the line.
    -- Neither side determines it. The visible consequences are that
    -- MeasureContentHeight reads noAddonLine:GetHeight() as ~0 and under-sizes the
    -- whole window, and a message long enough to wrap has nowhere to wrap into.
    -- BuildBannerBlock already sets its own height for exactly this reason; this
    -- block was the one that did not.
    local labelH = noAddonLineLabel:GetStringHeight() or 0
    noAddonLine:SetHeight(math.max(NO_ADDON_LINE_MIN_HEIGHT, labelH))

    noAddonLine:Show()

    -- Scroll frame's bottom hangs above the line with BLOCK_GAP. The
    -- line's height is self-determined by its label wrap; anchoring to
    -- the line's TOPRIGHT lets the scroll frame follow it however tall
    -- the wrap makes it.
    -- screenRoot, NOT root (see above). Note the guard only needs the scroll
    -- frame -- the anchor target is noAddonLine -- but it is kept symmetric with
    -- the branch above rather than diverging for no reason.
    if needsScrollFrame and screenRoot then
        needsScrollFrame:SetPoint("BOTTOMRIGHT", noAddonLine, "TOPRIGHT", 0, BLOCK_GAP)
    end
end

---------------------------------------------------------------------------
-- SUMMON-09: UpdateFooterInfo() -- the two mutually-exclusive top-of-body
-- blocks and the no-addon footer line
---------------------------------------------------------------------------

-- Reads Private:GetActivePortalStatus() (SUMMON-08) and the classified
-- session data. With a portal active, shows the green "Portal up — placed
-- by {name}" strip and hides the banner. With no portal, shows the
-- amber banner with clickable warlock chips inside it. The two blocks
-- branch off a SINGLE read of the portal accessor so a portal expiring
-- mid-pass cannot produce a tick with both visible.
--
-- The chip SetAttribute calls stay inside this function (and therefore
-- behind the SafeRebuildBody chokepoint that calls it, plus the ticker's
-- post-guard section). The banner's own build function does no
-- SetAttribute at all -- chips are built here, in the gated path.
local function UpdateFooterInfo()
    -- The two label locals are guarded alongside their frames deliberately.
    -- Today the guarantee is transitive -- BuildBannerBlock assigns both
    -- noPortalBanner and noPortalBannerLabel, BuildPortalStrip assigns both
    -- portalStrip and portalStripLabel -- so checking the frames alone would
    -- be sufficient. But it is sufficient only by coincidence of who assigns
    -- what, and the analyser cannot see the invariant either (it reported
    -- three need-check-nil warnings against the label call sites below).
    -- Naming them here makes the precondition explicit, so a future refactor
    -- that splits either builder fails closed instead of nil-indexing at
    -- runtime, and keeps this file out of the checker's findings list.
    if not noPortalBanner or not portalStrip or not noAddonLine then return end
    if not noPortalBannerLabel or not portalStripLabel then return end

    -- Defensive: GetActivePortalStatus lives in SummonComm.lua (loaded after
    -- this file per WildThings.toc), so it exists by the time the ticker
    -- fires. Guarded with type check per the codebase's do-not-package
    -- defensive pattern. ONE read of the accessor per pass -- both blocks
    -- branch off this local, so a portal expiring between reads cannot
    -- produce a tick with both visible.
    local portal = nil
    if type(Private.GetActivePortalStatus) == "function" then
        portal = Private:GetActivePortalStatus()
    end

    ---------------------------------------------------------------------
    -- Portal-up branch: hide the banner, show the strip.
    ---------------------------------------------------------------------
    if portal then
        if noPortalBanner:IsShown() then
            noPortalBanner:Hide()
            StopBannerPulse(noPortalBanner)
        end
        -- The strip label is rewritten with the (sanitised) caster name.
        -- The fixed prefix is set in BuildPortalStrip; we only overwrite
        -- the variable segment. Em dash is U+2014; do not normalise to "--".
        local name = SanitizeDisplayText(DisplayKey(portal.casterName))
        portalStripLabel:SetText("Portal up — placed by " .. (name or ""))
        portalStrip:Show()

        -- The scroll frame's top re-anchors to the strip's bottom (strip
        -- is a single 26 px line, height does not change).
        if needsScrollFrame then
            needsScrollFrame:SetPoint("TOPLEFT", portalStrip, "BOTTOMLEFT", 0, -BLOCK_GAP)
            needsScrollFrame:SetPoint("TOPRIGHT", portalStrip, "BOTTOMRIGHT", 0, -BLOCK_GAP)
        end

        -- Hide all banner chips from a previous pass that had warlocks --
        -- the pool is banner-local, but a pool entry that was visible
        -- last pass must not remain on screen under the strip.
        for _, chip in ipairs(noPortalBannerChipPool) do
            chip.button:Hide()
        end

        -- No-addon line uses its own visibility from the cohort collected
        -- in RebuildBody; it is independent of the portal state.
        UpdateNoAddonLine(noAddonCohort)
        return
    end

    ---------------------------------------------------------------------
    -- No-portal branch: hide the strip, show the banner, build chips.
    ---------------------------------------------------------------------
    if portalStrip:IsShown() then
        portalStrip:Hide()
    end
    if not noPortalBanner:IsShown() then
        noPortalBanner:Show()
        StartBannerPulse(noPortalBanner)
    end

    -- Scan classified for "Inside — Present" warlocks (eligibility is
    -- UNCHANGED from the previous footer implementation, per the plan:
    -- "Chip eligibility stays as it is"). Sorted by canonical key -- the
    -- same convention the no-addon cohort uses and the no-addon label
    -- code below copies, so the two list displays share their order
    -- rule by construction.
    local classified = Private:GetSummonSessionResults()
    local warlocks = {}
    if classified then
        for key, entry in pairs(classified) do
            if entry.status == "Inside — Present" and entry.class == "WARLOCK" then
                table.insert(warlocks, { key = key, entry = entry })
            end
        end
    end
    table.sort(warlocks, function(a, b) return a.key < b.key end)

    -- Build chips. Banner-local pool, lazy creation, SecureUnitButtonTemplate
    -- buttons. The two SetAttribute calls per chip stay in this function
    -- (which is called from SafeRebuildBody / the ticker's post-guard
    -- section) -- so they remain behind exactly one combat chokepoint.
    -- A chip's entry must have a unit token; a warlock without one is
    -- skipped entirely (UI-SPEC E3 partial) -- a chip whose click cannot
    -- resolve must not be offered.
    local usableWarlocks = {}
    for _, wl in ipairs(warlocks) do
        if wl.entry.unit then
            table.insert(usableWarlocks, wl)
        end
    end

    -- Banner's usable width for chip layout. The banner is already padded
    -- by BANNER_PADDING_LEFT/RIGHT, so we don't pad again.
    local containerW = noPortalBanner:GetWidth() - BANNER_PADDING_LEFT - BANNER_PADDING_RIGHT

    -- Wrap cursor: curX is in the banner's own coordinate frame (x increases
    -- right), curY is measured down from BANNER_PADDING_TOP. Chips wrap
    -- when the next chip would push past the banner's right interior edge.
    local curX = BANNER_PADDING_LEFT
    local curY = BANNER_PADDING_TOP + (noPortalBannerLabel:GetStringHeight() or 13) + BANNER_LABEL_CHIP_GAP
    local numRows = 0
    local rowCount = 0  -- chips placed on the current row, drives wrap check
    local warlockColor = Theme.GetClassColor("WARLOCK")

    for i, wl in ipairs(usableWarlocks) do
        local chip = noPortalBannerChipPool[i]
        if not chip then
            -- Create a new chip + label pair. The button is a
            -- SecureUnitButtonTemplate; SetAttribute below stays behind the
            -- combat gate. WTUI:AddRoundedBorder at RADIUS_PILL gives it a
            -- capsule outline (Pill.lua's pill is a different shape for
            -- spells/items; we copy only the two-line border idiom, not
            -- the widget itself -- see 57-PATTERNS.md).
            local btn = CreateFrame("Button", nil, noPortalBanner, "SecureUnitButtonTemplate")
            btn:SetSize(1, CHIP_HEIGHT)
            btn:RegisterForClicks("LeftButtonUp")
            WTUI:AddHoverHighlight(btn)
            WTUI:AddRoundedBorder(btn, Theme.RADIUS_PILL, 1)

            local lbl = btn:CreateFontString(nil, "ARTWORK")
            lbl:SetFontObject(WildThingsFont12)
            lbl:SetJustifyH("CENTER")
            lbl:SetPoint("CENTER", btn, "CENTER", 0, 0)

            chip = { button = btn, label = lbl }
            noPortalBannerChipPool[i] = chip
        end

        -- Sized to its name. The label's measured width drives the
        -- button's width (plus CHIP_PAD_X on each side), which drives
        -- the wrap arithmetic below.
        local name = SanitizeDisplayText(DisplayKey(wl.key))
        chip.label:SetText(name or "")
        local labelW = chip.label:GetStringWidth() or 0
        local chipW = labelW + (2 * CHIP_PAD_X)

        -- Wrap check: if this chip would cross the right edge AND we
        -- already placed at least one chip on the current row, drop
        -- to the next row. (An oversized single chip that itself
        -- exceeds the banner is still placed on the current row --
        -- clamping it to the banner would lose the name.)
        if rowCount > 0 and (curX + chipW) > (BANNER_PADDING_LEFT + containerW) then
            curX = BANNER_PADDING_LEFT
            curY = curY + CHIP_HEIGHT + CHIP_GAP
            rowCount = 0
        end

        -- Position + size. The chip's parent is the banner itself, so
        -- all coordinates are relative to the banner's TOPLEFT.
        chip.button:ClearAllPoints()
        chip.button:SetSize(chipW, CHIP_HEIGHT)
        chip.button:SetPoint("TOPLEFT", noPortalBanner, "TOPLEFT", curX, -curY)
        chip.button:SetAttribute("unit", wl.entry.unit)
        chip.button:SetAttribute("type1", "target")
        chip.button:Show()

        chip.label:SetTextColor(warlockColor.r, warlockColor.g, warlockColor.b, warlockColor.a)
        chip.label:Show()

        -- Border colour: accent when this warlock is the current target,
        -- default border-strong otherwise. EVERY chip is written on
        -- EVERY pass (the row ring's self-correcting reset pattern --
        -- RefreshTargetRing SummonScreen.lua around :1248) so a chip
        -- that was accented last pass but is not THIS pass reverts.
        --
        -- Token comparison, not a key comparison, for the same reason
        -- RefreshTargetRing uses one: UnitFullName("target") is a secret
        -- value under Midnight and canonicalizing it raises. Every chip in
        -- usableWarlocks has a unit attribute by construction (the filter
        -- above), and UnitIsUnit is falsy for a token that no longer exists.
        if UnitIsUnit(wl.entry.unit, "target") then
            chip.button:SetBorderColor(Theme.ACCENT_A70.r, Theme.ACCENT_A70.g, Theme.ACCENT_A70.b, Theme.ACCENT_A70.a)
        else
            chip.button:SetBorderColor(Theme.BORDER_STRONG.r, Theme.BORDER_STRONG.g, Theme.BORDER_STRONG.b, Theme.BORDER_STRONG.a)
        end

        curX = curX + chipW + CHIP_GAP
        rowCount = rowCount + 1
        if rowCount == 1 then numRows = numRows + 1 end
    end

    -- Hide excess chips from a previous pass that had more warlocks.
    -- Unconditional -- the pool survives a shrink.
    for i = #usableWarlocks + 1, #noPortalBannerChipPool do
        noPortalBannerChipPool[i].button:Hide()
    end

    -- Resize the banner to fit the chip row(s). The label is always
    -- there; chip row height is numRows * CHIP_HEIGHT plus one inter-row
    -- gap for every additional row, or zero when no warlocks.
    local labelH = noPortalBannerLabel:GetStringHeight() or 13
    local chipsH = (numRows > 0)
        and (numRows * CHIP_HEIGHT + (numRows - 1) * CHIP_GAP)
        or 0
    local labelToChipsGap = (numRows > 0) and BANNER_LABEL_CHIP_GAP or 0
    noPortalBanner:SetHeight(
        BANNER_PADDING_TOP
        + labelH
        + labelToChipsGap
        + chipsH
        + BANNER_PADDING_BOTTOM
    )

    -- Scroll frame's top re-anchors to the (now grown) banner's bottom.
    if needsScrollFrame then
        needsScrollFrame:SetPoint("TOPLEFT", noPortalBanner, "BOTTOMLEFT", 0, -BLOCK_GAP)
        needsScrollFrame:SetPoint("TOPRIGHT", noPortalBanner, "BOTTOMRIGHT", 0, -BLOCK_GAP)
    end

    -- No-addon line + scroll frame's bottom re-anchored independently.
    UpdateNoAddonLine(noAddonCohort)
end

---------------------------------------------------------------------------
-- SUMMON-13: targeting claims come from SummonComm, not from local polling
---------------------------------------------------------------------------

-- PollTargetedBy() is GONE. It iterated the raid reading UnitGUID("raidNtarget"),
-- which fails precisely where this feature matters: a unit's target only resolves
-- while that unit is in unit-update range, and people at a summoning stone usually
-- are not. It also depended on observing other units, which Midnight 12.x keeps
-- closing off. Each client now reports its OWN target over SUMMON_TARGET and
-- Private:GetSummonTargetCounts() returns targetKey -> claim count.

---------------------------------------------------------------------------
-- SUMMON-F1: SecureRowInitializer -- initialConfigFunction string
---------------------------------------------------------------------------

-- Executed by the SecureGroupHeaderTemplate for each child button during the
-- secure configuration pass. Minimal: only sets secure attributes — no frame
-- methods like RegisterForClicks (not available in RestrictedExecution).
-- Click registration and visual styling happen outside the secure context in
-- StyleSecureChild() and the BuildSecureRows() refresh loop.
-- Using a string (not a function reference) keeps it self-contained with no
-- addon upvalue capture that would taint the secure environment.
local SecureRowInitializer = [[
    self:SetAttribute("type1", "target")
]]

---------------------------------------------------------------------------
-- Phase 57 (SUMDESIGN-07/08/10/16): BuildRowAnatomy(row) -- shared create-once
---------------------------------------------------------------------------

-- One-time creation of the row's five renderable regions: name, meta dot,
-- meta label, dwell label, cast bar. Idempotent via the row._anatomyBuilt
-- flag -- the same idempotency shape StyleSecureChild's _styled gate already
-- uses for the secure path, so the two paths stay structurally parallel.
--
-- Called from BOTH the secure-children path (StyleSecureChild) and the
-- pooled non-interactive path (GetNonInteractiveRow), so both row kinds
-- end up with the same five regions at the same destinations, regardless
-- of which path an Offline / In Combat row actually takes at runtime.
-- Rows are plain Frames or secure Buttons -- both are valid parents for
-- CreateFontString/CreateTexture/CreateFrame under the metaDot.
local function BuildRowAnatomy(row)
    if row._anatomyBuilt then return end
    row._anatomyBuilt = true

    -- Name: bold 14px, left-justified, word wrap disabled (the dual-anchor
    -- + no-wrap idiom this repo already uses for ellipsis truncation;
    -- RaidTimelineScreen.lua:600 / 1255 / 1441, GroupGrid.lua:369).
    local nameLabel = row:CreateFontString(nil, "ARTWORK")
    nameLabel:SetFontObject(WildThingsFontBold14)
    nameLabel:SetJustifyH("LEFT")
    nameLabel:SetWordWrap(false)
    row.nameLabel = nameLabel

    -- Meta dot: a small plain Frame with a circular fill via the toolkit's
    -- AddRoundedFill primitive at half the size for a true circle. Fill
    -- only -- a 6px dot cannot hold a legible Lucide glyph and the design
    -- calls it a "dot" rather than an icon (UI-SPEC row anatomy). The dot
    -- is sized and coloured per pass by ApplyRowAnatomy.
    local metaDot = CreateFrame("Frame", nil, row)
    row.metaDot = metaDot
    -- Radius is HALF the dot size, which is what makes it a circle. A radius of 0
    -- renders a square, and at 6 px a square dot reads as a different glyph
    -- entirely next to the round dots the rest of the UI uses (see MakeStatusDot,
    -- UI/Screens/RaidTimelineScreen.lua:126, the design's circular status pill).
    -- The size is the fixed layout.metaDotSize, so baking the radius here is safe.
    WTUI:AddRoundedFill(metaDot, META_DOT_RADIUS, nil) -- re-tinted per pass

    -- Meta label: 12px, right-justified. Colour and text written per pass.
    local metaLabel = row:CreateFontString(nil, "ARTWORK")
    metaLabel:SetFontObject(WildThingsFont12)
    metaLabel:SetJustifyH("RIGHT")
    row.metaLabel = metaLabel

    -- Dwell label: mono 12px, right-justified, fixed width. SetWidth is the
    -- row's only horizontal sizing -- nothing else may compete with the
    -- name's dual anchor for the row's remaining width. Colour is constant
    -- across all states (design: dwell has no per-state treatment).
    local dwellLabel = row:CreateFontString(nil, "ARTWORK")
    dwellLabel:SetFontObject(WildThingsFontNum12)
    dwellLabel:SetJustifyH("RIGHT")
    dwellLabel:SetWordWrap(false)
    row.dwellLabel = dwellLabel

    -- Cast bar: plain Texture, 2px tall, pinned to the row's bottom edge,
    -- coloured Theme.ACCENT. Always hidden at creation; only the resolved
    -- meta state's `showsBar` flag reveals it (see ApplyRowAnatomy). The
    -- fraction is driven by the one real signal -- liveSummonStates[key]
    -- -- and shown only in the summoning state. Hide() is preferred over
    -- SetWidth(0) for the non-summoning states per UI-SPEC's "hide rather
    -- than zero-width" guidance.
    local castBar = row:CreateTexture(nil, "ARTWORK")
    castBar:SetColorTexture(Theme.ACCENT.r, Theme.ACCENT.g, Theme.ACCENT.b, Theme.ACCENT.a)
    castBar:SetHeight(2)
    castBar:Hide()
    row.castBar = castBar
end

---------------------------------------------------------------------------
-- Phase 57 (SUMDESIGN-07/08/09/10): ApplyRowAnatomy(row, key, entry, layout, rowIndex)
---------------------------------------------------------------------------

-- Single per-pass row renderer. Idempotent creation (BuildRowAnatomy) runs
-- once per row; this function runs on every refresh pass for every row,
-- regardless of which code path the row belongs to. The two paths (the
-- secure-header children loop and the non-interactive pooled builder) each
-- position the row at the right vertical cursor before calling here, and
-- this function owns everything from row height down to the pulse and cast
-- bar -- so any visual drift between the two paths is impossible by
-- construction.
--
-- Every write here is a texture, FontString, alpha, colour, SetWidth, or
-- SetPoint call on an already-created region -- ordinary insecure Lua, the
-- alpha-only target-ring precedent row A31 already enforces for
-- RefreshTargetRing. No new combat guard is introduced; the existing
-- SafeRebuildBody / pendingRebuild chokepoint already gates the secure
-- children's Show()/Hide()/rebuild cycle, and the non-interactive path's
-- WritePos/Show() are also confined to that one chokepoint.
local function ApplyRowAnatomy(row, key, entry, layout, rowIndex)
    if not row.nameLabel then BuildRowAnatomy(row) end

    -- 1. Height + alternating stripe. ROW_ODD / ROW_EVEN are the existing
    -- pair, unchanged. The stripe advances per rowIndex (the caller
    -- passes rowIndex + 1 so the first row is index 1, not 0, matching the
    -- pre-existing convention).
    row:SetHeight(layout.rowHeight)
    local stripe = (rowIndex % 2 == 1) and Theme.ROW_ODD or Theme.ROW_EVEN
    if row.bg then
        row.bg:SetColorTexture(stripe.r, stripe.g, stripe.b, stripe.a)
    end

    -- 2. Dwell clock state -- the only place the current classification for
    -- a key is known on this pass. TouchDwellState is the only function
    -- allowed to rewrite dwellStates[key].enteredAt, and it ignores an
    -- identical repeat call (see Task 1's note on that function).
    TouchDwellState(key, entry.status)

    -- 3. Resolve the meta state in fixed priority order (offline > combat
    -- > summoning > targeted > portal-pending > Waiting). ResolveMetaState
    -- returns one table consumed below.
    local state = ResolveMetaState(key, entry)

    -- 4. Anchor right-to-left from the row's right edge to the name's
    -- left edge. Dwell is the rightmost fixed-width element; meta label
    -- reads left of dwell; meta dot reads left of meta label; the name
    -- dual-anchors from the row's LEFT+padX to the dot's LEFT - Theme.GAP,
    -- which is what produces the ellipsis truncation when the name
    -- overflows. Visual reading order stays dot-then-label-then-dwell.
    local dwellLabel = row.dwellLabel
    if dwellLabel then
        dwellLabel:ClearAllPoints()
        dwellLabel:SetWidth(layout.dwellWidth)
        dwellLabel:SetPoint("RIGHT", row, "RIGHT", -layout.padX, 0)
    end

    local metaLabel = row.metaLabel
    if metaLabel then
        metaLabel:ClearAllPoints()
        metaLabel:SetPoint("RIGHT", dwellLabel or row, "LEFT", -Theme.GAP, 0)
    end

    local metaDot = row.metaDot
    if metaDot then
        metaDot:ClearAllPoints()
        metaDot:SetSize(layout.metaDotSize, layout.metaDotSize)
        metaDot:SetPoint("RIGHT", metaLabel or row, "LEFT", -layout.dotLabelGap, 0)
        -- The dot's fill is a single Texture placed via AddRoundedFill in
        -- BuildRowAnatomy. Re-tint its colour and alpha per pass.
        -- Color the fill via SetVertexColor on each corner + edge texture
        -- AddRoundedFill stashed. The roundFill container is what is
        -- colour-tinted.
        if metaDot.roundedFill then
            -- Color every region of the fill with the meta state's color
            -- token (alpha-aware: dot alpha is separate).
            for _, tex in pairs(metaDot.roundedFill) do
                tex:SetVertexColor(state.color.r, state.color.g, state.color.b, state.dotAlpha)
            end
        end
        metaDot:SetAlpha(state.dotAlpha)
        metaDot:Show()
    end

    local nameLabel = row.nameLabel
    if nameLabel then
        nameLabel:ClearAllPoints()
        nameLabel:SetPoint("LEFT", row, "LEFT", layout.padX, 0)
        nameLabel:SetPoint("RIGHT", metaDot or row, "LEFT", -Theme.GAP, 0)
        nameLabel:SetText(DisplayKey(key))
        local classColor = Theme.GetClassColor(entry.class)
        nameLabel:SetTextColor(classColor.r, classColor.g, classColor.b, classColor.a)
        -- Class colour stays at full strength in the rendered colour; the
        -- per-state name dimming is the label's ALPHA, applied last so it
        -- does not get re-tinted by SetTextColor above.
        nameLabel:SetAlpha(state.nameAlpha)
    end

    -- 5. Meta label's text and colour.
    if metaLabel then
        metaLabel:SetText(state.label)
        metaLabel:SetTextColor(state.color.r, state.color.g, state.color.b, state.color.a)
        metaLabel:Show()
    end

    -- 6. Dwell time -- constant colour (no per-state treatment).
    if dwellLabel then
        dwellLabel:SetText(mmss(GetDwellSeconds(key)))
        dwellLabel:SetTextColor(Theme.TEXT_DIM.r, Theme.TEXT_DIM.g, Theme.TEXT_DIM.b, Theme.TEXT_DIM.a)
        dwellLabel:Show()
    end

    -- 7. Pulse handling. UNCONDITIONAL STOP on every pass when the resolved
    -- state does not pulse. Rows are pooled and reused; the dot frame's
    -- table identity is the glow driver's lookup key, so a row that
    -- rendered a pulsing player last pass and is now rendering a static
    -- one WOULD inherit player A's leftover registration and pulse
    -- forever, unless the stop runs unconditionally here. A transition-
    -- triggered stop cannot see that, because from this row's
    -- perspective no transition happened -- the player changed, not the
    -- state.
    if metaDot then
        if state.dotPulses then
            StartDotPulse(metaDot)
        else
            StopDotPulse(metaDot)
        end
    end

    -- 8. Cast bar. The width formula lives in the UI-SPEC's reference
    -- implementation: row width * (remaining / SUMMON_OFFER_WINDOW).
    -- hide() rather than SetWidth(0) for every non-summoning state, which
    -- sidesteps the zero-width-texture question entirely.
    local castBar = row.castBar
    if castBar then
        castBar:ClearAllPoints()
        castBar:SetPoint("BOTTOMLEFT", row, "BOTTOMLEFT", 0, 0)
        castBar:SetHeight(layout.castBarHeight)
        if state.showsBar then
            local live = liveSummonStates[key]
            local remaining = (live and live.pending) and live.remainingSec or 0
            local pct = math.min(1, math.max(0, remaining / SUMMON_OFFER_WINDOW))
            -- The small positive floor mirrors Slider.lua's idiom (avoid a
            -- degenerate zero-width region in the live-summoning render --
            -- here the texture is at the row's bottom edge, so any
            -- non-zero width shows a visible cast edge).
            castBar:SetWidth(math.max(0.0001, row:GetWidth() * pct))
            castBar:Show()
        else
            castBar:Hide()
        end
    end
end

---------------------------------------------------------------------------
-- SUMMON-F1: StyleSecureChild(button, layout) -- one-time visual setup
---------------------------------------------------------------------------

-- Called once per child button on the first refresh pass after creation.
-- Idempotent via button._styled flag. Creates all textures, FontStrings and
-- applies the hover highlight from outside the secure context.
-- Dynamic values (text, colors, position) are set in the refresh loop, not here.
--
-- Phase 57 (SUMDESIGN-16): the per-row classification icon, the tier tag,
-- the right-edge status FontString AND the legacy nameLabel created here
-- are all GONE -- Design 1a's row anatomy has no icon column at all
-- (CONTEXT.md "Row Anatomy"), and the new name/meta/dwell/cast-bar fields
-- are created for the FIRST time via BuildRowAnatomy below, identically
-- to the non-interactive path. The icon obligation (SUMDESIGN-16) relocates
-- to the banner, confirmation block and no-addon footer line, each via
-- Theme.GetIconPath / namespace:CreateIcon. button.icon, button.tierLabel,
-- button.statusLabel and the .tga texture paths are all deleted.
local function StyleSecureChild(button, layout)
    if button._styled then return end
    button._styled = true

    -- Left-click targets (type1="target"); right-click is deliberately not
    -- registered. RegisterForClicks must run outside the restricted execution
    -- environment — it is NOT available in the initialConfigFunction string
    -- (RestrictedExecution.lua sandbox).
    button:RegisterForClicks("LeftButtonUp")

    -- Background texture (color set per refresh for alternating stripes)
    local bg = button:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints(button)
    button.bg = bg

    -- Hover highlight
    WTUI:AddHoverHighlight(button)

    -- Phase 57: shared row anatomy. Same call as GetNonInteractiveRow uses;
    -- the two paths converge here so the five renderable regions
    -- (nameLabel, metaDot, metaLabel, dwellLabel, castBar) are created
    -- exactly once for every row, identically, regardless of which path
    -- the row took to get here.
    BuildRowAnatomy(button)

    -- Phase 56 (SUMDESIGN-12): the target ring. Built with the toolkit's
    -- AddRoundedStroke primitive so its corners match every other stroke in the
    -- UI -- the previous four hairline edge textures met at square corners,
    -- which read as foreign next to the rounded buttons and panels.
    --
    -- The combat-safety property is unchanged and is what this design is for:
    -- the refresh (RefreshTargetRing below) writes ONLY alpha, never SetPoint,
    -- SetAttribute, Show, Hide or SetSize. Alpha is not a protected mutation, so
    -- the ring keeps following PLAYER_TARGET_CHANGED inside combat lockdown.
    -- The stroke is built once here, out of combat, and starts fully faded.
    local ringAccent = Theme.ACCENT_A70
    local ring = WTUI:AddRoundedStroke(button, Theme.RADIUS_SM, 1)
    ring:SetStrokeColor(ringAccent.r, ringAccent.g, ringAccent.b, ringAccent.a)
    ring:SetAlpha(0)

    button.ring = ring
end

---------------------------------------------------------------------------
-- SUMMON-F1: StyleRowContent(button, key, entry, layout, rowIndex)
---------------------------------------------------------------------------

-- Phase 57: positioning + delegation only. The button is positioned in the
-- content frame at the section's vertical cursor, then ApplyRowAnatomy
-- (above) takes over for everything from row height and stripe down to
-- the per-row meta state's dot pulse and cast bar -- one renderer for
-- both the secure-children path (this one) and the non-interactive pooled
-- path (BuildNonInteractiveRows, below). Any visual drift between the
-- two paths is impossible by construction.
--
-- The legacy per-row icon, tier tag and right-edge status FontString, plus
-- the layout.showLocation consumer branch, are all gone -- deleted, not
-- repaired (Design 1a's row anatomy has no icon column; the wide-variant
-- location column was dropped at D-02 in plan 56-05's commit and the
-- layout.showLocation field is kept only because check-chrome.ps1's A27
-- asserts it).
local function StyleRowContent(button, contentFrame, y, rowIndex, key, entry, layout)
    button:SetPoint("TOPLEFT", contentFrame, "TOPLEFT", 0, y)
    button:SetPoint("TOPRIGHT", contentFrame, "TOPRIGHT", 0, y)
    ApplyRowAnatomy(button, key, entry, layout, rowIndex)
end

---------------------------------------------------------------------------
-- SUMMON-F1: BuildNonInteractiveRows(contentFrame, y, entries, layout)
---------------------------------------------------------------------------

-- Phase 57: shared with StyleRowContent above via ApplyRowAnatomy. The two
-- paths produce pixel-identical rows. Which path an Offline / In Combat
-- row actually takes at runtime is still an open question (RaidSnapshot
-- assigns a unit token to nearly every roster slot), and the answer must
-- not change what the user sees -- hence ONE renderer for both.
--
-- Rows come from a per-content-frame POOL and are reused. This used to
-- CreateFrame + CreateTexture + two CreateFontString per row on every pass:
-- the 1s ticker rebuilds whenever live state changes, ClearContentFrame can
-- only HIDE the old frames, and a WoW frame can never be destroyed -- so a
-- raid with a few offline players leaked frames for as long as the window
-- stayed open. Pool shape mirrors warlockChipPool above; the pool hangs off
-- the content frame itself, like GetSectionHeader's cached FontString, so it
-- cannot be mismatched to the wrong parent.
local function GetNonInteractiveRow(contentFrame, index)
    contentFrame.nonInteractiveRows = contentFrame.nonInteractiveRows or {}
    local pool = contentFrame.nonInteractiveRows
    local row = pool[index]
    if row then return row end

    row = CreateFrame("Frame", nil, contentFrame)

    row.bg = row:CreateTexture(nil, "BACKGROUND")
    row.bg:SetAllPoints(row)

    -- Phase 57: shared row anatomy, same call as StyleSecureChild uses.
    -- Creates nameLabel (WildThingsFontBold14), metaDot, metaLabel,
    -- dwellLabel, castBar in one pass; all anchoring/text writes run
    -- per pass inside ApplyRowAnatomy below.
    BuildRowAnatomy(row)

    pool[index] = row
    return row
end

local function BuildNonInteractiveRows(contentFrame, y, rowIndex, entries, layout)
    for i, item in ipairs(entries) do
        local key = item.key
        local entry = item.entry

        local row = GetNonInteractiveRow(contentFrame, i)
        row:ClearAllPoints()
        row:SetPoint("TOPLEFT", contentFrame, "TOPLEFT", 0, y)
        row:SetPoint("TOPRIGHT", contentFrame, "TOPRIGHT", 0, y)

        -- Everything else -- height, stripe, name/meta/dwell/cast-bar --
        -- is owned by ApplyRowAnatomy, the same function the secure path
        -- delegates to. No icon, no hand-built texture path, no
        -- SetTexture / SetVertexColor / SetColorTexture / SetText here.
        ApplyRowAnatomy(row, key, entry, layout, rowIndex)

        -- ClearContentFrame hid every pooled row before this pass; only the
        -- ones this pass actually filled come back.
        row:Show()

        rowIndex = rowIndex + 1
        y = y - layout.rowHeight
    end
    return y, rowIndex
end

---------------------------------------------------------------------------
-- SUMMON-F1: ClearContentFrame(cf) -- hide non-header children
---------------------------------------------------------------------------

-- Hides all direct children of the content frame EXCEPT the one
-- SecureGroupHeaderTemplate (which we manage via its own child
-- show/hide). Section-header wrappers and non-interactive row frames
-- are hidden here; header children are managed by BuildSecureRows.
-- Phase 56 (D-08): one header, not two -- the only exempted frame is summonHeader.
local function ClearContentFrame(cf)
    local children = { cf:GetChildren() }
    for _, child in ipairs(children) do
        -- Don't hide the one SecureGroupHeaderTemplate -- its children are
        -- managed individually by BuildSecureRows.
        if child ~= summonHeader then
            child:Hide()
        end
    end
end

---------------------------------------------------------------------------
-- SUMMON-F1: BuildSecureRows(classified, layout) -- header refresh loop
---------------------------------------------------------------------------

-- Resolves the CURRENT canonical key of whoever the header auto-bound this
-- child's unit attribute to right now. Header children are matched against
-- classified data by this live-derived key, NEVER by comparing the live unit
-- token against a unit token cached in classified. RaidSnapshot.lua documents
-- the STALENESS CONTRACT this avoids: raidIndex/unit are snapshot-time only,
-- and SetRaidSubgroup/SwapRaidSubgroup re-enumerate them as a side effect. A
-- roster shuffle between summon-session-seal and this refresh would otherwise
-- match a live secure-frame attribute against a stale cached token, showing
-- the wrong player's name/status on a row whose button now targets someone
-- else entirely (52-CONTEXT.md D-05 -- the intended design this implements).
function ResolveChildKey(child)
    local unit = child:GetAttribute("unit")
    if not unit or not UnitExists(unit) then return nil end
    local name, realm = UnitFullName(unit)
    if not name then return nil end
    if not realm or realm == "" then
        realm = Private:GetSelfRealm()
    end
    return Private:CanonicalKey(name, realm)
end

-- Phase 56 (SUMDESIGN-12): refresh the per-row ring alpha. The only writes
-- this function performs on a row are four `texture:SetAlpha(0-or-1)` calls.
-- It does NOT call SetPoint, SetAttribute, Show, Hide or SetSize on the
-- button or its ring -- texture alpha is not a protected mutation, and that
-- is precisely why this stays correct under PLAYER_TARGET_CHANGED and the
-- 1s ticker safety net during combat with NO guard at all.
--
-- Every non-matching row's ring goes to alpha 0 on the same pass. The
-- single-row-exclusivity property is self-correcting: there is no
-- "remember which row was previously ringed" state to get out of sync.
-- Children without a ring or without a unit attribute are skipped -- they
-- have nothing to update and nothing to match.
--
-- Matching is UnitIsUnit(childUnit, "target"), NOT a name comparison. Under
-- Midnight (12.x) UnitFullName("target") returns a SECRET STRING to tainted
-- execution, and canonicalizing it threw "attempt to index local 's' (a secret
-- string value)" out of RosterKeys.lua on every right-click target change --
-- 34 errors per targeting spree. UnitIsUnit compares two tokens the addon
-- already holds, leaks no identity, and stays readable under taint, so this
-- keeps working instead of degrading to "no row ever ringed".
local function RefreshTargetRing()
    if not summonHeader then return end
    local children = { summonHeader:GetChildren() }
    for _, child in ipairs(children) do
        if child.ring then
            local unit = child:GetAttribute("unit")
            -- One SetAlpha on the stroke container, not a loop over four edge
            -- textures. Frame alpha is not a protected mutation either.
            child.ring:SetAlpha((unit and UnitIsUnit(unit, "target")) and 1 or 0)
        end
    end
end

-- Phase 56 (SUMDESIGN-12): small event frame registered once at window
-- build time. PLAYER_TARGET_CHANGED fires whenever the player's target
-- changes (left-click, Tab-cycle, target macro, /target, etc.). Driving the
-- ring from this event keeps it visually in lockstep with "who am I
-- targeting right now"; the 1s ticker below is a safety net for the
-- InCombatLockdown window where PLAYER_TARGET_CHANGED still fires but the
-- child's unit token may be mid-rebind. Per-feature error containment per
-- CLAUDE.md: feature code does not call pcall directly.
local function BuildTargetChangedFrame()
    if targetChangedFrame then return end
    targetChangedFrame = CreateFrame("Frame")
    targetChangedFrame:RegisterEvent("PLAYER_TARGET_CHANGED")
    targetChangedFrame:SetScript("OnEvent", function()
        Private.safecall(RefreshTargetRing)
    end)
end

-- Phase 57 (SUMDESIGN-01 / SUMDESIGN-02 / SUMDESIGN-03 / SUMDESIGN-04 /
-- SUMDESIGN-09): the single-header refresh loop renders TWO counted sections
-- ("Not in instance" and "Too far away") from one shared status->section
-- lookup consulted by BOTH the header-children path and the non-interactive
-- path. The one SecureGroupHeaderTemplate (summonHeader) is parented once at
-- construction time to screenRoot; this function NEVER reparents it. Children
-- are routed into the two section content frames by SetPoint alone
-- (StyleRowContent's existing anchoring proves this is safe -- a child can be
-- positioned inside a frame other than its parent). Hide any header child that
-- resolves to no classified entry.
--
-- Phase 57 carries forward Phase 56's one-section-to-zero-and-back-to-two
-- trajectory: CR-C removed the "Inside" section because it was always empty
-- (the tier-5 predicate was structurally unreachable). This plan restores two
-- sections by REPAIRING tier-5 -- but the membership here is different: both
-- sections list people who NEED a summon, not people who are already inside.

-- Phase 57: shared section membership lookup (SUMDESIGN-01 / SUMDESIGN-09). The
-- same table is consulted by the header-children loop below AND by the
-- non-interactive bucket built here, so the two render paths cannot diverge on
-- which statuses appear. Absence is deliberate:
--   - "Inside — Present"  -> the already-inside-alive cohort; excluded by
--                             SUMDESIGN-02 (Phase 56 CR-C) and stays excluded.
--   - "In Instance — Dead"-> the dead-inside cohort; excluded by SUMDESIGN-03
--                             / D-07. A player who dies inside appears the
--                             moment their status changes to one that needs a
--                             summon.
--   - "No Addon"           -> rerouted to the dimmed footer line in plan 57-03
--                             (SUMDESIGN-04 / D-12) -- not a row on either path.
local SECTION_FOR_STATUS = {
    ["Needs Summon"] = "needs",
    ["Offline"]      = "needs",
    ["In Combat"]    = "needs",
    ["Inside — Away"] = "far",
}

-- Top-to-bottom render order. The order is load-bearing: the lead's eye reads
-- top-down and "Not in instance" is the larger, more common cohort.
local SECTION_ORDER = { "needs", "far" }

-- Header label copy. Both are fixed literals from the UI-SPEC copy contract;
-- no player-supplied text reaches a section header so it cannot overflow.
local SECTION_LABEL = {
    ["needs"] = "Not in instance",
    ["far"]   = "Too far away",
}

-- CreateSummonSectionHeader(parent, title): local wrapper around
-- namespace:CreateSectionHeader. The shared widget's own behaviour (uppercase
-- label, trailing 1px BORDER_STRONG hairline filling to the header's right edge)
-- is untouched -- six other screens depend on it. The wrapper adds a right-aligned
-- count FontString (WildThingsFontNum13, mono) and re-anchors the shared
-- widget's RIGHT to stop short of the count, so the shared widget's hairline
-- fills the label-to-count gap instead of running past it. The wrapper's height
-- is taken from the returned shared header's measured height, and a SetCount(n)
-- method writes a bare numeral with no plural noun (the UI-SPEC contract).
--
-- Re-anchoring what a WTUI factory returns is the caller's prerogative and is
-- what keeps the shared widget's six other consumers untouched.
local function CreateSummonSectionHeader(parent, title)
    local wrapper = CreateFrame("Frame", nil, parent)
    wrapper:SetPoint("LEFT", parent, "LEFT", 0, 0)
    wrapper:SetPoint("RIGHT", parent, "RIGHT", 0, 0)

    local count = wrapper:CreateFontString(nil, "ARTWORK")
    count:SetFontObject(WildThingsFontNum13)
    count:SetTextColor(Theme.TEXT_SECTION.r, Theme.TEXT_SECTION.g, Theme.TEXT_SECTION.b, Theme.TEXT_SECTION.a)
    count:SetJustifyH("RIGHT")
    count:SetPoint("RIGHT", wrapper, "RIGHT", 0, 0)

    local header = namespace:CreateSectionHeader(wrapper, title)
    header:ClearAllPoints()
    header:SetPoint("LEFT", wrapper, "LEFT", 0, 0)
    header:SetPoint("RIGHT", count, "LEFT", -Theme.GAP, 0)

    wrapper:SetHeight(header:GetHeight())

    function wrapper:SetCount(n)
        count:SetText(tostring(n))
    end

    return wrapper
end

-- GetSectionUI(cf, section): lazily creates and caches one section-header
-- wrapper and one bordered box per section on the content frame itself. Both
-- must live in the cache-miss branch: WoW frames cannot be destroyed, so
-- creating either on every ticker rebuild would leak permanently.
local function GetSectionUI(cf, section)
    cf.summonSections = cf.summonSections or {}
    local cached = cf.summonSections[section]
    if cached then return cached.wrapper, cached.box end

    local wrapper = CreateSummonSectionHeader(cf, SECTION_LABEL[section])
    local box = CreateFrame("Frame", nil, cf)
    WTUI:AddRoundedBorder(box, Theme.RADIUS_MD, 1)
    box:SetBorderColor(Theme.BORDER.r, Theme.BORDER.g, Theme.BORDER.b, Theme.BORDER.a)
    box:ShowBorder()

    -- An empty section's box must be hidden, not merely emptied: the content
    -- clear only hides direct children, and a shown rowless box still draws.
    cached = { wrapper = wrapper, box = box }
    cf.summonSections[section] = cached
    return wrapper, box
end

local function BuildConfirmationBlock(parent)
    if confirmBlock then return confirmBlock end

    local block = CreateFrame("Frame", nil, parent)
    block:SetPoint("LEFT", parent, "LEFT", 0, 0)
    block:SetPoint("RIGHT", parent, "RIGHT", 0, 0)

    local circle = CreateFrame("Frame", nil, block)
    circle:SetSize(44, 44)
    circle:SetPoint("TOP", block, "TOP", 0, -34)
    WTUI:AddRoundedFill(circle, 22, Theme.ACCENT_A13)

    local icon = namespace:CreateIcon(circle, "check", 20)
    icon:SetPoint("CENTER", circle, "CENTER", 0, 0)
    icon:SetTint(Theme.ACCENT)

    local title = block:CreateFontString(nil, "ARTWORK")
    title:SetFontObject(WildThingsFontBold16)
    title:SetTextColor(Theme.ACCENT.r, Theme.ACCENT.g, Theme.ACCENT.b, Theme.ACCENT.a)
    title:SetText("Everyone is in")
    title:SetPoint("TOP", circle, "BOTTOM", 0, -8)

    local body = block:CreateFontString(nil, "ARTWORK")
    body:SetFontObject(WildThingsFont13)
    body:SetTextColor(Theme.TEXT_DIM.r, Theme.TEXT_DIM.g, Theme.TEXT_DIM.b, Theme.TEXT_DIM.a)
    body:SetText("Everyone made it in. This window will close automatically.")
    body:SetWidth(260)
    body:SetWordWrap(true)
    body:SetSpacing(6)
    body:SetJustifyH("CENTER")
    body:SetPoint("TOP", title, "BOTTOM", 0, -8)

    local bodyHeight = body:GetStringHeight()
    block:SetHeight(34 + 44 + 8 + title:GetStringHeight() + 8 + bodyHeight + 30)
    block:Hide()
    confirmBlock = block
    return block
end

local function UpdateConfirmationBlock(shown)
    if not confirmBlock then return end
    if shown then
        confirmBlock:Show()
    else
        confirmBlock:Hide()
    end
end

local function BuildSecureRows(classified, layout)
    if not needsScrollFrame or not screenRoot then return end
    if not summonHeader then return end  -- header not yet created (first-open race)

    -- Collect and sort all classified entries
    local allEntries = {}
    if classified then
        for key, entry in pairs(classified) do
            table.insert(allEntries, { key = key, entry = entry })
        end
    end
    table.sort(allEntries, function(a, b)
        -- Inside — Away first (less common, more urgent), then by key.
        -- Status-priority is informational; the two render paths route by
        -- SECTION_FOR_STATUS below, not by this sort.
        if a.entry.status == "Inside — Away" and b.entry.status ~= "Inside — Away" then
            return true
        elseif a.entry.status ~= "Inside — Away" and b.entry.status == "Inside — Away" then
            return false
        end
        return a.key < b.key
    end)

    -- Split into interactive (has unit) and non-interactive (no unit).
    -- Q5: Offline / In Combat / No Addon almost always have a live unit token
    -- (RaidSnapshot sets unit for every enumerated roster slot), so they land
    -- in `interactive` -- not in `nonInteractive` as the old comment assumed.
    local interactive = {}
    local nonInteractive = {}
    for _, item in ipairs(allEntries) do
        if item.entry.unit then
            table.insert(interactive, item)
        else
            table.insert(nonInteractive, item)
        end
    end

    -- keyToEntry: RAW classified entry (not the {key=,entry=} sort wrapper) for
    -- each interactive item, keyed by canonical key. Storing the wrapper here
    -- would leave every keyToEntry[key].status/.class access below reading a
    -- field that only exists one level deeper, at keyToEntry[key].entry.status.
    -- Header children are matched against this by ResolveChildKey's live
    -- identity resolution, never by a cached unit-token lookup.
    local keyToEntry = {}
    for _, item in ipairs(interactive) do
        keyToEntry[item.key] = item.entry
    end

    -- Build the per-section buckets for BOTH paths in one pass over each list.
    -- An entry whose status is not in SECTION_FOR_STATUS renders on neither
    -- path: "Inside — Present" / "In Instance — Dead" stay excluded, and
    -- "No Addon" is the dimmed footer line (plan 57-03), not a row.
    local buckets = {
        ["needs"] = { interactive = {}, nonInteractive = {} },
        ["far"]   = { interactive = {}, nonInteractive = {} },
    }
    local function bucketInsert(item)
        local section = SECTION_FOR_STATUS[item.entry.status]
        if not section then return end
        if item.entry.unit then
            table.insert(buckets[section].interactive, item)
        else
            table.insert(buckets[section].nonInteractive, item)
        end
    end
    for _, item in ipairs(interactive) do bucketInsert(item) end
    for _, item in ipairs(nonInteractive) do bucketInsert(item) end

    -- Layout is owned by the builder alone. Phase 56 removed the previous
    -- re-anchoring of both scroll frames on every rebuild.
    needsScrollFrame:Show()

    local cfNeeds = needsScrollFrame.contentFrame
    ClearContentFrame(cfNeeds)

    -- The single header is parented once at construction to screenRoot. It
    -- is NEVER reparented here. Children are routed by SetPoint alone
    -- (StyleRowContent's existing anchoring), so the second section costs
    -- no new secure surface (D-08).

    -- Pre-resolve every header child's section membership ONCE so the
    -- per-section child loop below only has to compare strings, and so a
    -- child that belongs to the OTHER section is not hidden by a section it
    -- does not belong to. This is the one place this restructure can
    -- silently hide a row that should show: one pass over the children
    -- happens per section, and the per-pass decision must consult a result
    -- that was computed BEFORE the section loop started.
    local childrenSections = {}
    local headerChildren = { summonHeader:GetChildren() }
    for _, child in ipairs(headerChildren) do
        local key = ResolveChildKey(child)
        if key and keyToEntry[key] then
            childrenSections[child] = SECTION_FOR_STATUS[keyToEntry[key].status]
        else
            childrenSections[child] = nil
        end
    end

    -- Counts are computed before any section layout. The confirmation branch
    -- consumes these exact counts too, so one pass can never show confirmation
    -- and rows from two independent tallies.
    local sectionCounts = {}
    local totalCount = 0
    for _, section in ipairs(SECTION_ORDER) do
        local sectionBucket = buckets[section]
        local count = #sectionBucket.interactive + #sectionBucket.nonInteractive
        sectionCounts[section] = count
        totalCount = totalCount + count
    end

    local y = 0
    local rowIndex = 0

    if totalCount == 0 then
        -- Hide every header child FIRST. This branch returns before the
        -- per-section child loop that normally owns show/hide, so without this
        -- the secure children shown by the PREVIOUS pass stay visible and draw
        -- straight over the confirmation block. That is not a corner case: it is
        -- the ordinary end of every session -- the last player arrives, this
        -- pass flips to totalCount == 0, and the rows from the pass before are
        -- still on screen underneath "Everyone is in" until SUMWIN-06's
        -- auto-close hides the whole window a few seconds later.
        --
        -- child:Hide() is a protected mutation on a secure child, which is fine
        -- here: BuildSecureRows is only ever reached through the combat-gated
        -- SafeRebuildBody or the ticker's post-guard section, and the
        -- per-section loop below performs exactly the same call.
        for _, child in ipairs(headerChildren) do
            child:Hide()
        end

        -- SUMDESIGN-15: confirmation replaces the sections outright; it is not
        -- an empty rendering of the two boxes Phase 56 left able to fall empty.
        UpdateConfirmationBlock(true)
        for _, section in ipairs(SECTION_ORDER) do
            local wrapper, box = GetSectionUI(cfNeeds, section)
            wrapper:Hide()
            box:Hide()
        end
        if confirmBlock then
            confirmBlock:ClearAllPoints()
            confirmBlock:SetPoint("TOPLEFT", cfNeeds, "TOPLEFT", 0, 0)
            confirmBlock:SetPoint("TOPRIGHT", cfNeeds, "TOPRIGHT", 0, 0)
            cfNeeds:SetHeight(confirmBlock:GetHeight())
        else
            cfNeeds:SetHeight(0)
        end
        needsScrollFrame:FullUpdate()
        return
    end

    UpdateConfirmationBlock(false)

    -- Thread y / rowIndex across only rendered sections, so zebra striping has
    -- no holes and the 12 px gap exists only between two visible sections.
    local renderedSections = 0
    for _, section in ipairs(SECTION_ORDER) do
        local sectionBucket = buckets[section]
        local total = sectionCounts[section]
        if total > 0 then
            if renderedSections > 0 then
                y = y - SECTION_GAP
            end

            local sectionUI, sectionBox = GetSectionUI(cfNeeds, section)
            sectionUI:ClearAllPoints()
            sectionUI:SetPoint("TOPLEFT", cfNeeds, "TOPLEFT", 0, y)
            sectionUI:SetPoint("TOPRIGHT", cfNeeds, "TOPRIGHT", 0, y)
            sectionUI:SetCount(total)
            sectionUI:Show()

            y = y - sectionUI:GetHeight() - SECTION_HEADER_BOX_GAP
            local boxTop = y
            sectionBox:ClearAllPoints()
            sectionBox:SetPoint("TOPLEFT", cfNeeds, "TOPLEFT", 0, y)
            sectionBox:SetPoint("TOPRIGHT", cfNeeds, "TOPRIGHT", 0, y)

            for _, child in ipairs(headerChildren) do
                local childSection = childrenSections[child]
                if childSection == section then
                    local key = ResolveChildKey(child)
                    StyleSecureChild(child, layout)
                    StyleRowContent(child, cfNeeds, y, rowIndex + 1, key, keyToEntry[key], layout)
                    child:Show()
                    rowIndex = rowIndex + 1
                    y = y - layout.rowHeight
                elseif childSection == nil then
                    child:Hide()
                end
            end

            if #sectionBucket.nonInteractive > 0 then
                y, rowIndex = BuildNonInteractiveRows(cfNeeds, y, rowIndex + 1, sectionBucket.nonInteractive, layout)
            end

            sectionBox:SetHeight(boxTop - y)
            sectionBox:Show()
            renderedSections = renderedSections + 1
        end
    end

    cfNeeds:SetHeight(math.abs(y))
    needsScrollFrame:FullUpdate()
end

---------------------------------------------------------------------------
-- SUMMON-15: RebuildBody() -- tear down and rebuild body on variant switch
---------------------------------------------------------------------------

-- Called from variant button OnClick handlers and the ticker refresh path.
-- Reads the current classified data, gets the active layout, and rebuilds
-- the body sections via BuildSecureRows (SUMMON-F1).
local function RebuildBody()
    local classified = Private:GetSummonSessionResults()
    if not classified then return end

    local layout = GetVariantLayout()
    BuildSecureRows(classified, layout)

    -- Recompute the outstanding count in the same pass that rendered rows.
    -- SUMWIN-05 / ROADMAP success criterion 3: the count must come from the
    -- exact same data the list rendered from, never a separate tally.
    local n = 0
    for _, entry in pairs(classified) do
        if entry.status == "Needs Summon" then
            n = n + 1
        end
    end
    lastNeedsSummonCount = n

    -- Phase 57 (SUMDESIGN-04): collect the no-addon cohort in the same
    -- walk that already tallies the outstanding count. SECTION_FOR_STATUS
    -- has no "No Addon" key (plan 57-01) so BuildSecureRows never produces
    -- it; the cohort must be derived here, directly from the classified
    -- table. Sorted by canonical key -- the same convention the warlock-
    -- chip sort and the no-addon label code use, so the no-addon line and
    -- the chip row stay ordered by the same rule across the body.
    noAddonCohort = {}
    for key, entry in pairs(classified) do
        if entry.status == "No Addon" then
            table.insert(noAddonCohort, key)
        end
    end
    table.sort(noAddonCohort)
    UpdateSummonStatus(n, STATUS_NORMAL)
end

local function MeasureContentHeight()
    local contentHeight = 0

    -- Banner and portal strip are irrelevant in the confirmation state.
    if not (confirmBlock and confirmBlock:IsShown()) then
        if noPortalBanner and noPortalBanner:IsShown() then
            contentHeight = contentHeight + noPortalBanner:GetHeight()
        elseif portalStrip and portalStrip:IsShown() then
            contentHeight = contentHeight + portalStrip:GetHeight()
        end
        if contentHeight > 0 then
            contentHeight = contentHeight + BLOCK_GAP
        end
    end

    if confirmBlock and confirmBlock:IsShown() then
        contentHeight = contentHeight + confirmBlock:GetHeight()
    elseif needsScrollFrame and needsScrollFrame.contentFrame then
        contentHeight = contentHeight + needsScrollFrame.contentFrame:GetHeight()
    end

    if noAddonLine and noAddonLine:IsShown() then
        contentHeight = contentHeight + BLOCK_GAP + noAddonLine:GetHeight()
    end

    return contentHeight
end

local function ApplyComputedHeight()
    if not summonWindow then return end

    local height = Theme.H_TITLEBAR + BODY_TOP_PAD + MeasureContentHeight()
        + BODY_BOTTOM_PAD + Theme.H_STATUSBAR
    height = math.max(WINDOW_H_MIN, math.min(WINDOW_H_MAX, height))
    if summonWindow:GetHeight() == height then return end

    -- One height write is sufficient: body root, content host and scroll frame
    -- are purely edge-anchored and none writes its own height. Nothing restores
    -- over this value either: this window is non-resizable, so no saved size is
    -- ever written for its name. This helper is invoked only as SafeRebuildBody's
    -- third post-guard step, so the protected write has exactly one chokepoint.
    summonWindow:SetHeight(height)
end

---------------------------------------------------------------------------
-- SUMMON-F1: SafeRebuildBody() -- combat-guarded rebuild/footer/height
---------------------------------------------------------------------------

-- BuildSecureRows (via RebuildBody) performs Show()/Hide()/SetParent() on the
-- SecureGroupHeaderTemplate frames and their secure children, and
-- UpdateFooterInfo's warlock chips call SetAttribute() on SecureUnitButtonTemplate
-- buttons. ApplyComputedHeight is treated as the same protected mutation class:
-- Phase 56 UAT observed SetFrameLevel blocked on this exact window solely because
-- it hosts the secure header. All three writes therefore share this one gate and
-- the existing pendingRebuild deferral; no sibling resize chokepoint exists.
-- Every call site outside the ticker must route through this wrapper.
local function SafeRebuildBody()
    if InCombatLockdown() then
        pendingRebuild = true
        return
    end
    RebuildBody()
    UpdateFooterInfo()
    if confirmBlock and confirmBlock:IsShown() then
        -- Confirmation replaces the portal/banner surface too; UpdateFooterInfo
        -- owns populated-state mutual exclusion, then this final state branch
        -- removes both before the measured-height write.
        if noPortalBanner then
            noPortalBanner:Hide()
            StopBannerPulse(noPortalBanner)
        end
        if portalStrip then portalStrip:Hide() end
    end
    ApplyComputedHeight()
end

---------------------------------------------------------------------------
-- SUMMON-01: StartAutoCloseTicker -- idempotent ticker arming (D-15/D-16)
---------------------------------------------------------------------------

-- Arms the 1s polling loop (SUMMON-12 auto-close + SUMMON-07 live summon
-- states). Idempotent: returns immediately when autoCloseTicker is already
-- set, so it is safe to call unconditionally from CreateSummonScreen's build
-- AND from the OnShow restart hook below without ever double-arming the
-- ticker. The OnHide hook cancels this ticker and clears the handle when the
-- window closes, which is what lets a later OnShow safely restart it.
-- Wrapped in Private.safecall per the codebase's error-containment
-- convention (Utility.lua:15) -- a nil access in the callback must never
-- crash the addon. See .planning/v2.7-MILESTONE-AUDIT.md §1.
--
-- SUMMON-01 (D-16): SetAttribute calls on SecureUnitButtonTemplate children
-- (warlock chips in UpdateFooterInfo) are blocked during combat lockdown.
-- CheckAutoClose and both pollers perform no protected writes (verified by
-- reading them), so they keep running every tick even in combat; only
-- UpdateFooterInfo and the rebuild -- the two protected-write call sites --
-- are skipped while in combat lockdown. A skipped tick sets pendingRebuild
-- so the first out-of-combat tick force-rebuilds regardless of whether the
-- live-state poll itself reported a change.
local function StartAutoCloseTicker()
    if autoCloseTicker then return end
    autoCloseTicker = C_Timer.NewTicker(1, function()
        Private.safecall(function()
            CheckAutoClose()
            local liveChanged = PollLiveSummonStates()
            -- SUMMON-13: claims arrive over SUMMON_TARGET; this just reads the
            -- accumulated counts (and lets the accessor prune expired ones).
            -- Guarded like every cross-file call in this repo.
            targetedBy = Private.GetSummonTargetCounts and Private:GetSummonTargetCounts() or {}

            -- Phase 56 (SUMDESIGN-12): ring refresh runs on the always-runs
            -- side of the combat guard, alongside the read-only pollers.
            -- Texture alpha writes are not protected
            -- mutations, so this stays correct in combat. The
            -- PLAYER_TARGET_CHANGED event handler in BuildTargetChangedFrame
            -- is the primary driver; this 1s tick is the safety net so the
            -- ring catches up if the event fired during a state where the
            -- header had no children to refresh (e.g. first build race).
            RefreshTargetRing()

            -- SUMDESIGN-11 (D-09): TTL-prune the dwell cache on the always-
            -- runs side of the combat guard. Pure table mutation, no frame
            -- writes; deliberately NOT placed at a session boundary -- a
            -- Rescan IS a new session, and clearing there would defeat the
            -- cache's whole purpose.
            PruneDwellStates()

            -- SUMLIVE-07: ClassifyAll performs no frame mutations, so keep its
            -- guarded cross-file entry point above the combat guard. Classification
            -- stays live during combat while only rendering defers through the
            -- existing pendingRebuild flag; moving this below would freeze the data.
            if Private.ReclassifySummonSession then
                liveChanged = Private:ReclassifySummonSession() or liveChanged
            end

            if InCombatLockdown() then
                pendingRebuild = true
                return
            end

            -- Phase 56 (SUMWIN-08): deferred first header build. Clear the
            -- flag BEFORE creating so a tick landing exactly on the
            -- combat-end boundary creates the header once, not twice.
            if pendingFirstBuild then
                pendingFirstBuild = false
                summonHeader = CreateSummonHeader(screenRoot, summonSoloRows)
                C_Timer.After(0, function()
                    if InCombatLockdown() then
                        pendingRebuild = true
                        return
                    end
                    SafeRebuildBody()
                end)
            end

            if pendingRebuild or liveChanged then
                pendingRebuild = false
                SafeRebuildBody()
            else
                UpdateFooterInfo()
            end

            -- Phase 56 (SUMWIN-02): deferred close pickup. A close requested
            -- during combat hides the window now. Ordered after rebuild so
            -- a window that was asked to close during combat does not
            -- spend its last tick rendering rows it is about to hide -- but
            -- we do not early-return because the pollers above must keep
            -- running.
            -- Phase 56 (CR-02): deferred open pickup, the mirror of the close
            -- pickup below. Ordered before it only for readability -- the two
            -- flags are mutually exclusive at their set sites, so pickup order
            -- carries no meaning.
            if pendingShow then
                pendingShow = false
                if summonWindow then
                    summonWindow:Show()
                    SafeRebuildBody()
                end
            end

            if pendingClose then
                pendingClose = false
                if summonWindow then
                    summonWindow:Hide()
                end
            end
        end)
    end)
end

---------------------------------------------------------------------------
-- SUMMON-F1: CreateSummonHeader(parent, layout) -- create a SecureGroupHeader
---------------------------------------------------------------------------

-- Creates one SecureGroupHeaderTemplate that auto-manages child buttons for
-- all raid members (raid1..raid40). The header uses SecureRowInitializer (a
-- string attribute) as its initialConfigFunction to set secure click attributes
-- on each child. Returns the header frame.
--
-- Phase 56: showSolo is now a parameter, defaulting to false. The
-- /wt summonsolo dev affordance passes true so SUMDESIGN-12/13/14 and
-- SUMWIN-08 (combat first-open) are single-client exercisable: the
-- SecureGroupHeaderTemplate creates NO children when showSolo is false
-- and the player is solo. Default off means no shipped behaviour change.
function CreateSummonHeader(parent, showSolo)
    local hdr = CreateFrame("Frame", nil, parent, "SecureGroupHeaderTemplate")
    hdr:SetAttribute("template", "SecureUnitButtonTemplate")
    hdr:SetAttribute("templateType", "Button")
    hdr:SetAttribute("initialConfigFunction", SecureRowInitializer)
    hdr:SetAttribute("groupFilter", "1,2,3,4,5,6,7,8")
    hdr:SetAttribute("showRaid", true)
    hdr:SetAttribute("showParty", true)
    hdr:SetAttribute("showPlayer", true)
    hdr:SetAttribute("showSolo", showSolo and true or false)
    hdr:SetAttribute("point", "TOP")
    hdr:SetAttribute("xOffset", 0)
    hdr:SetAttribute("yOffset", 0)
    hdr:SetAttribute("sortMethod", "INDEX")
    hdr:SetAttribute("maxUnits", 40)
    hdr:SetAttribute("unitsPerColumn", 1)
    hdr:SetAttribute("maxColumns", 1)
    hdr:Show()  -- trigger child button creation
    return hdr
end

---------------------------------------------------------------------------
-- Phase 56: BuildSummonWindow() -- the standalone-window builder
---------------------------------------------------------------------------

-- Replaces the previous Private:CreateSummonScreen(contentHost) screen builder.
-- The Summoning UI is no longer a tab inside the main Shell window; it is its
-- own standalone chrome window, built once on the first Private:ShowSummonWindow()
-- call, then hidden at the end of construction (SUMWIN-09 -- no auto-reopen
-- after /reload). Position persists (CHROME-06); visibility does not.
--
-- The body content is the same as before (banner, two scroll-frame sections,
-- secure headers, footer info) -- only the chrome and the lifecycle hooks
-- move. Collapsing the two headers to one is plan 56-02's task and stays
-- separate from this rewrite so any build-or-runtime regression here has
-- a single change site.
local function BuildSummonWindow()
    -- Titlebar handlers. Defined before the construction call so they can be
    -- passed as onClick in the buttons array. They look up the pin button
    -- handle via summonWindow.titlebarButtons[1] at click time, not at
    -- definition, so the chicken-and-egg with CreateChromeWindow is avoided.
    -- SetActive is the documented persistent-state mechanism on IconButton
    -- (IconButton.lua:40-47) -- a tinted glyph would be erased by the
    -- button's own OnLeave -> SetIconState("idle") on the next mouse-out.
    local function TogglePin()
        pinned = not pinned
        if pinned and autoCloseTimer then
            autoCloseTimer:Cancel()
            autoCloseTimer = nil
        end
        if not pinned and summonWindow and summonWindow.titlebarButtons and summonWindow.titlebarButtons[1] then
            summonWindow.titlebarButtons[1]:SetActive(false)
        elseif summonWindow and summonWindow.titlebarButtons and summonWindow.titlebarButtons[1] then
            summonWindow.titlebarButtons[1]:SetActive(true)
        end
        if not pinned then
            -- On unpin, let the next tick re-evaluate the auto-close decision.
            -- On pin, restore normal captions and the outstanding count.
            UpdateSummonStatus(lastNeedsSummonCount, STATUS_NORMAL)
        end
    end

    local function RescanClick()
        Private:RescanSummonSession()
    end

    -- Construct the chrome window. The opts table is the one the UI-SPEC
    -- specifies verbatim: width=420, height=440 (NOT the 744 fallback),
    -- resizable=false, two-tone title, pin+rescan titlebar buttons (close
    -- is built by the primitive, never duplicated here), and onClose routed
    -- through the SafeCloseSummonWindow chokepoint. CreateShell passes no
    -- onClose, so it keeps today's bare window:Hide() via the fallback.
    summonWindow = namespace:CreateChromeWindow("WildThingsSummonWindow", {
        width = 420,
        height = 440,
        resizable = false,
        titleAccent = "Wild Things",
        titleDim = "Summons",
        buttons = {
            { stem = "pin",        tooltip = "Pin",    onClick = TogglePin },
            { stem = "refresh-cw", tooltip = "Rescan", onClick = RescanClick },
        },
        onClose = SafeCloseSummonWindow,
    })

    -- UI-SPEC gap closure (SUMWIN-03): CreateChromeWindow builds window.closeBtn
    -- with no tooltip. The Summon window attaches one at this call site, NOT
    -- in the primitive -- doing it in the primitive would add an unrequested
    -- tooltip to the main Shell and /wt chrometest.
    WTUI:AddTooltip(summonWindow.closeBtn, "Close")

    -- Body root: parented to summonWindow.contentHost, anchored to all four
    -- of its edges. The contentHost is already inset by H_TITLEBAR/H_STATUSBAR
    -- by the primitive (ChromeWindow.lua:218-222), so the body fills exactly
    -- the inner content area.
    local contentHost = summonWindow.contentHost
    local root = CreateFrame("Frame", nil, contentHost)
    root:SetPoint("TOPLEFT", contentHost, "TOPLEFT", 0, 0)
    root:SetPoint("TOPRIGHT", contentHost, "TOPRIGHT", 0, 0)
    root:SetPoint("BOTTOMRIGHT", contentHost, "BOTTOMRIGHT", 0, 0)

    -- Store root for RebuildBody (SUMMON-15)
    screenRoot = root

    -- Phase 56: lifecycle hooks moved off screenRoot onto summonWindow.
    -- OnHide cancels the auto-close ticker and grace timer (Pitfall: stale
    -- C_Timer callbacks firing after a window close would nil-access a
    -- destroyed frame). HookScript is additive and does not clobber any
    -- future handler the primitive may install.
    summonWindow:HookScript("OnHide", function()
        if autoCloseTicker then
            autoCloseTicker:Cancel()
            autoCloseTicker = nil
        end
        if autoCloseTimer then
            autoCloseTimer:Cancel()
            autoCloseTimer = nil
        end
        -- Nav open-indicator sync (Shell.lua): every close path (titlebar X,
        -- auto-close ticker, SafeCloseSummonWindow) routes through Hide(), so this
        -- one hook is the single place that needs to announce "closed".
        if Private.FireCallback then
            Private:FireCallback("WT_SUMMON_WINDOW_VISIBILITY", false)
        end
    end)

    -- SUMMON-01 (D-15) + Phase 56 (SUMWIN-09): OnShow restarts the ticker.
    -- The starter function is idempotent (no-op when the handle is already
    -- set), so this hook is deliberately a no-op on the very first build --
    -- a frame defaults to shown and we Hide() at the end of construction,
    -- so OnShow does not fire there (Pitfall 12). The unconditional
    -- StartAutoCloseTicker() at the end of construction is what arms the
    -- ticker for the first session.
    summonWindow:HookScript("OnShow", function()
        StartAutoCloseTicker()
        -- Nav open-indicator sync (Shell.lua): mirrors the OnHide announcement above.
        if Private.FireCallback then
            Private:FireCallback("WT_SUMMON_WINDOW_VISIBILITY", true)
        end
    end)

    -- SUMMON-01 (D-05): registered ONCE at build time -- the window is
    -- cached and never destroyed, so this permanent registration needs no
    -- unsubscribe bookkeeping. Guards on summonWindow:IsShown() so an
    -- invisible window does no rebuild work; a seal firing before this
    -- window has ever been built is harmless (CallbackHandler's Fire is a
    -- no-op with no subscribers, and the first build already reads session
    -- results fresh above). See .planning/v2.7-MILESTONE-AUDIT.md §1.
    if Private.RegisterCallback then
        Private.RegisterCallback("SummonScreenSealRefresh", "WT_SUMMON_SESSION_SEALED", function()
            if not summonWindow or not summonWindow:IsShown() then return end
            SafeRebuildBody()
        end)
    end

    ---------------------------------------------------------------------------
    -- Data
    ---------------------------------------------------------------------------
    local classified = Private:GetSummonSessionResults()
    local needsSummonCount = 0
    if classified then
        for _, entry in pairs(classified) do
            if entry.status == "Needs Summon" then
                needsSummonCount = needsSummonCount + 1
            end
        end
    end
    lastNeedsSummonCount = needsSummonCount

    -- The banner ("Summoning · <n> players") was REMOVED during Phase 56 UAT.
    -- The window's own titlebar already names it, and the per-section header
    -- carries the only count that matters ("Needs Summon (n)"), so the banner
    -- was a second title plus a redundant number, costing list height.
    --
    -- Phase 56 (D-08): the variant switcher (CreateVariantButton,
    -- UpdateVariantButtons, variantButtons, the variant module-local) is
    -- GONE with it.
    --
    -- Phase 57 (SUMDESIGN-05/06/04): the body's vertical geometry is now
    -- bannerOrStrip at top, scroll frame in the middle, noAddonLine at
    -- bottom. The three are created below BEFORE the scroll frame, so the
    -- scroll frame's initial anchor can target the banner's bottom (no
    -- chips on the first build, so banner height is the label-only
    -- 31 px). UpdateFooterInfo re-anchors the scroll frame's top on
    -- every pass to follow the banner's growth when warlock chips wrap.

    ---------------------------------------------------------------------------
    -- BODY: banner/strip, scroll frame, no-addon line (top to bottom)
    ---------------------------------------------------------------------------

    -- Build the three blocks. banner and strip are mutually exclusive at
    -- runtime; both are pre-created so UpdateFooterInfo never creates
    -- frames mid-pass (CreateFrame is not free, and the lifetime is the
    -- window's). Initial state matches what UpdateFooterInfo's first
    -- pass would produce for a session with no portal and no chips:
    -- banner shown, strip hidden, noAddonLine hidden.
    noPortalBanner = BuildBannerBlock(root)
    portalStrip = BuildPortalStrip(root)
    noAddonLine = BuildNoAddonLine(root)
    StartBannerPulse(noPortalBanner)

    -- The scroll frame. Re-anchored per pass by UpdateFooterInfo /
    -- UpdateNoAddonLine as the banner height and the noAddonLine
    -- visibility change. Initial anchor here assumes the no-portal,
    -- no-chips state (banner height 31 px).
    needsScrollFrame = namespace:CreateScrollFrame(root)
    needsScrollFrame:ClearAllPoints()
    needsScrollFrame:SetPoint("TOPLEFT", noPortalBanner, "BOTTOMLEFT", 0, -BLOCK_GAP)
    needsScrollFrame:SetPoint("TOPRIGHT", noPortalBanner, "BOTTOMRIGHT", 0, -BLOCK_GAP)
    needsScrollFrame:SetPoint("BOTTOMRIGHT", root, "BOTTOMRIGHT",
        -BODY_HORIZONTAL_PAD, NO_ADDON_LINE_BOTTOM_PAD)
    BuildConfirmationBlock(needsScrollFrame.contentFrame)

    -- Phase 56 (D-08): ONE SecureGroupHeaderTemplate, parented to screenRoot
    -- (NOT to a scroll-frame content frame). It is created exactly once, in
    -- the builder, and never reparented or torn down for the window's whole
    -- lifetime. Children are routed into cfNeeds or cfInside by SetPoint
    -- alone (StyleRowContent's existing anchoring). showSolo is fed by the
    -- /wt summonsolo dev affordance (summonSoloRows module-local), default
    -- false. Collapsing to one header is the plan 56-01 task 2 deliverable;
    -- the runtime reparent surface (Pitfall 2) is gone with it.
    --
    -- The in-combat branch below is now DEFENSIVE ONLY. CR-A means
    -- Private:ShowSummonWindow never reaches this builder during combat, and
    -- ToggleSummonSoloRows refuses in combat outright, so no live path builds
    -- under lockdown any more. It is kept because the failure it prevents is a
    -- hard error, not a cosmetic one: CreateSummonHeader's SetAttribute calls
    -- and hdr:Show() are protected mutations. If a future caller does build in
    -- combat, the chrome still appears and pendingFirstBuild hands the secure
    -- half to the 1s ticker, exactly as SUMWIN-08 originally required.
    if InCombatLockdown() then
        pendingFirstBuild = true
    else
        summonHeader = CreateSummonHeader(root, summonSoloRows)
        -- Initial body population through the single combat-gated chokepoint.
        -- Deferred to next frame: SecureGroupHeaderTemplate processes children
        -- asynchronously after Show(), so GetChildren() is empty synchronously.
        -- The C_Timer.After(0) fires on the next frame when children are ready.
        -- Guarded: opening /wt summons for the first time while in combat must not
        -- attempt the protected Show()/Hide()/SetParent() mutations in BuildSecureRows;
        -- defer to pendingRebuild, which the ticker (armed below) picks up out of combat.
        C_Timer.After(0, function()
            if InCombatLockdown() then
                pendingRebuild = true
                return
            end
            SafeRebuildBody()
        end)
    end

    ---------------------------------------------------------------------------
    -- FOOTER (SUMMON-06: timestamp + Rescan button)
    ---------------------------------------------------------------------------
    -- Phase 57 (SUMDESIGN-04/05/06): the FOOTER_HEIGHT / footerBar /
    -- footerInfoFrame / footerPortalLabel quartet is GONE. The body's
    -- bottom region is now the noAddonLine (created above before the
    -- scroll frame), which UpdateNoAddonLine shows/hides per pass. The
    -- banner and strip replace the portal label and the warlock-chip
    -- strip that used to live in the footer -- they live at the TOP of
    -- the body now, not the bottom, and are mutually exclusive.

    -- Start the 1s polling loop (SUMMON-12 auto-close + SUMMON-07 live summon
    -- states). Unconditional here, NOT moved into the OnShow hook below --
    -- CreateFrame defaults a frame to shown, so OnShow does not fire on this
    -- very first build; this call is what makes the very first open of every
    -- session poll live state at all (SUMMON-01 D-15). See
    -- .planning/v2.7-MILESTONE-AUDIT.md §1.
    StartAutoCloseTicker()

    -- First pass routes through the same chokepoint as every later rebuild, so
    -- banner/strip, sections/confirmation, footer and measured height are all
    -- derived from one classified pass. Under lockdown the existing flag carries
    -- the complete operation to the first out-of-combat tick.
    SafeRebuildBody()

    -- Phase 56 (SUMDESIGN-12): arm the PLAYER_TARGET_CHANGED frame. Built
    -- once and reused for the window's lifetime -- the frame is just an
    -- event dispatcher and holds no state, so a /reload followed by a fresh
    -- BuildSummonWindow just rebuilds it. RefreshTargetRing is its sole
    -- consumer; the 1s ticker above is a safety net.
    BuildTargetChangedFrame()

    -- SUMWIN-09: the window defaults to shown; explicitly hide it at the end
    -- of construction so /reload does not reopen it. Position persistence
    -- (CHROME-06) and visibility are deliberately decoupled -- the position
    -- we just set on /reload is restored; the visibility is not.
    summonWindow:Hide()

    -- Status bar initial state. If the first build was deferred by combat,
    -- show the deferred-list caption; otherwise show the normal state with
    -- the count we computed during the same pass that initialized the body.
    if pendingFirstBuild then
        UpdateSummonStatus(needsSummonCount, STATUS_DEFERRED_FIRST_BUILD)
    else
        UpdateSummonStatus(needsSummonCount, STATUS_NORMAL)
    end

    return summonWindow
end

---------------------------------------------------------------------------
-- SUMMON-01: Private:RefreshSummonScreen() -- exported refresh entry point
---------------------------------------------------------------------------

-- Refreshes the already-built Summoning Window in place: rebuilds the body
-- rows and the footer info from the latest session data. Required because
-- Shell:ShowScreen(id) early-returns when the id is already the active
-- screen (Shell.lua:335), so a re-trigger on an already-open Summons tab
-- would otherwise show stale rows (ROADMAP criterion 3).
-- Callers MUST guard: if Private.RefreshSummonScreen then ... end -- same
-- fence-safety shape as every other cross-file call in this repo.
-- See .planning/v2.7-MILESTONE-AUDIT.md §1.
function Private:RefreshSummonScreen()
    if not screenRoot then return end
    SafeRebuildBody()
end

---------------------------------------------------------------------------
-- Phase 56: Private:ShowSummonWindow() -- the new public entry point
---------------------------------------------------------------------------

-- Builds summonWindow lazily on the first call (SUMWIN-01) and shows it.
-- A second call must show and refresh in place, NEVER rebuild -- the
-- lazy build is the SUMWIN-09 enforcement, and a rebuild on every call
-- would defeat the deliberate "only one chrome primitive allocation per
-- session" design. SafeRebuildBody performs the refresh and is itself
-- combat-guarded (pendingRebuild pickup handles mid-pull deferral), so
-- a /wt summons landing inside combat does no secure-template writes
-- immediately; the ticker catches up on the next out-of-combat tick.
function Private:ShowSummonWindow()
    -- CR-A: an OPEN request during combat is answered with a chat notice and an
    -- automatic open on PLAYER_REGEN_ENABLED -- nothing is built and nothing
    -- appears. Conditional on the window not already being up, so a seal
    -- arriving mid-fight still refreshes a window the player has open (that path
    -- continues below and defers through pendingShow instead).
    if InCombatLockdown() and not (summonWindow and summonWindow:IsShown()) then
        DeferOpenUntilCombatEnds()
        return
    end

    if not summonWindow then
        BuildSummonWindow()
    end
    if not summonWindow then return end

    -- Guard-and-defer, mirroring SafeCloseSummonWindow. Reached only for a
    -- window that is already on screen (the CR-A branch above owns the closed
    -- case), so the re-Show and the refresh wait for the first out-of-combat
    -- tick. Still conditional on summonHeader: with no secure descendant there
    -- is nothing protected to defer.
    if summonHeader and InCombatLockdown() then
        -- The two deferred visibility flags are mutually exclusive: whichever
        -- the player asked for LAST must win. Relying on pickup order instead
        -- would apply close-then-show as a hide, which is the opposite of the
        -- last request.
        pendingShow = true
        pendingClose = false
        return
    end

    summonWindow:Show()
    SafeRebuildBody()
end

---------------------------------------------------------------------------
-- Private:CloseSummonWindow() -- nav-row toggle close entry point
---------------------------------------------------------------------------

-- Routes through the same combat-guarded chokepoint as the titlebar X and the
-- auto-close ticker (SafeCloseSummonWindow), so a toggle-close from the Shell
-- nav row behaves identically to every other close path: deferred-and-retried
-- under InCombatLockdown(), immediate otherwise. Nil-safe via
-- SafeCloseSummonWindow's own guard when no window has ever been built.
function Private:CloseSummonWindow()
    SafeCloseSummonWindow()
end

---------------------------------------------------------------------------
-- Phase 56: Private:IsSummonWindowShown() -- consumed by plan 56-03
---------------------------------------------------------------------------

-- Returns whether the standalone Summon window exists and is currently
-- shown. Plan 56-03 (ApplyAutoOpenMatrix conversion) reads this to decide
-- "in-place refresh vs open" -- the old activeScreenID == "summons" check
-- stops being valid the moment this phase deletes the screen registration,
-- so the new check is "is the standalone window up?". Declared here in
-- plan 56-01 to keep the export surface in one commit.
function Private:IsSummonWindowShown()
    return summonWindow ~= nil and summonWindow:IsShown()
end

---------------------------------------------------------------------------
-- Phase 56: Private:ToggleSummonSoloRows() -- dev affordance for /wt summonsolo
---------------------------------------------------------------------------

-- Flips the summonSoloRows module-local (default false) and forces a rebuild.
-- Feeds CreateSummonHeader's showSolo attribute so the Summon window is
-- single-client exercisable for SUMDESIGN-12/13/14 and SUMWIN-08 -- the
-- SecureGroupHeaderTemplate creates NO children when showSolo is false and
-- the player is solo (which is always true on a single-client test). The
-- toggle is printed so a tester can confirm the state. Default off means
-- no shipped behaviour change.
function Private:ToggleSummonSoloRows()
    -- Code review WR-02: this tears the window down and rebuilds the secure
    -- header, which is a protected mutation. It cannot route through
    -- SafeCloseSummonWindow -- a deferred close would leave summonWindow nil
    -- while the rebuild had already run. So the whole toggle refuses in
    -- combat rather than deferring half of itself. Dev affordance, so a
    -- refusal with a reason is the right cost.
    if InCombatLockdown() then
        print("|cffff5555Wild Things:|r summonsolo needs you out of combat "
            .. "-- it rebuilds the secure header.")
        return
    end

    summonSoloRows = not summonSoloRows
    if summonWindow then
        -- Force a rebuild -- the solo rows flag only takes effect on a
        -- fresh header, so toggling on a live window is meaningless
        -- without re-creating the headers. Tear down and rebuild.
        local wasShown = summonWindow:IsShown()
        summonWindow:Hide()
        summonWindow = nil
        BuildSummonWindow()
        if wasShown and summonWindow then
            summonWindow:Show()
            SafeRebuildBody()
        end
    end
    print("|cff99ff00Wild Things:|r summonSoloRows = " .. tostring(summonSoloRows))
end

---------------------------------------------------------------------------
-- SUMMON-F1: Private:SummonHeaderAudit(mode) -- dev command for header inspection
---------------------------------------------------------------------------

-- Prints a structured dump of the ONE SecureGroupHeaderTemplate state to the
-- chat frame. Useful for verifying the header migration in-game without
-- needing a live raid. Available in dev builds only (behind @do-not-package@
-- fence, reached via DevToolsScreen). Modes:
--   nil / "summary" – header counts, child totals (default)
--   "children"     – summary + first 10 child unit attributes and unit names
--   "classified"   – shows how many classified entries matched header children
--
-- Phase 56 (D-08): the audit reports ONE header (summonHeader) instead of
-- the previous two. Dev-tools output wording is free; the important
-- constraint is that it does not throw on a nil second header.
function Private:SummonHeaderAudit(mode)
    mode = mode or "summary"
    local P = "|cff99ff00Wild Things:|r "

    -- Header existence (one header, not two)
    local headerExists = summonHeader and true or false
    print(P .. "--- Summoning Header Audit (" .. mode .. ") ---")
    print(P .. "Window built: " .. (summonWindow and "yes" or "no"))
    print(P .. "Screen built: " .. (screenRoot and "yes" or "no"))
    print(P .. "Header: " .. (headerExists and "present" or "MISSING"))

    if not headerExists then
        print(P .. "No header exists -- open the Summoning window first (/wt summons)")
        return
    end

    -- Child counts
    local function auditHeader(name, hdr)
        if not hdr then
            print(P .. string.format("  %s: nil (not created)", name))
            return
        end
        local children = { hdr:GetChildren() }
        local total = #children
        local showing = 0
        local hidden = 0
        local styled = 0
        for _, child in ipairs(children) do
            if child:IsShown() then
                showing = showing + 1
            else
                hidden = hidden + 1
            end
            if child._styled then
                styled = styled + 1
            end
        end
        print(P .. string.format("  %s: %d children (%d showing, %d hidden, %d styled)",
            name, total, showing, hidden, styled))
        return children
    end

    local headerChildren = auditHeader("Summon header", summonHeader)

    if mode == "children" then
        local function dumpChildren(name, children)
            if not children then return end
            local limit = math.min(10, #children)
            for i = 1, limit do
                local child = children[i]
                local unit = child:GetAttribute("unit") or "nil"
                local unitName = unit ~= "nil" and UnitName(unit) or "n/a"
                local shown = child:IsShown() and "shown" or "hidden"
                local st = child._styled and "styled" or "raw"
                print(P .. string.format("    #%d unit=%s name=%s [%s/%s]", i, unit, unitName, shown, st))
            end
        end
        dumpChildren("Summon header", headerChildren)
    end

    if mode == "classified" then
        local classified = Private:GetSummonSessionResults()
        if not classified then
            print(P .. "  No session data -- run /wt summonstest first")
            return
        end
        local totalClassified = 0
        local withUnit = 0
        for _, entry in pairs(classified) do
            totalClassified = totalClassified + 1
            if entry.unit then withUnit = withUnit + 1 end
        end
        print(P .. string.format("  Classified: %d total, %d with unit, %d non-interactive",
            totalClassified, withUnit, totalClassified - withUnit))

        -- Match check: count how many classified entries with units match a header child
        local unitSet = {}
        for _, entry in pairs(classified) do
            if entry.unit then unitSet[entry.unit] = true end
        end

        local function countMatches(name, children)
            if not children then return 0 end
            local matches = 0
            for _, child in ipairs(children) do
                local unit = child:GetAttribute("unit")
                if unit and unitSet[unit] then
                    matches = matches + 1
                end
            end
            print(P .. string.format("  %s: %d children match classified units", name, matches))
            return matches
        end

        local matches = countMatches("Summon header", headerChildren)
        print(P .. string.format("  Matched: %d total of %d classified", matches, withUnit))
    end

    print(P .. "--- End Summoning Header Audit ---")
end
