---@diagnostic disable: undefined-field, undefined-global
-- INSPECT-04..09, INSPECT-12: Raid Inspect page -- Claude Design variant 1a,
-- Shell-hosted three-column no-scroll layout (column 1: Tanks over Healers;
-- column 2: Melee; column 3: Ranged) with per-cell severity colouring, spec
-- icons, item/currency column-head icons, no-data/offline states, a
-- data-driven header, request-update button, and live crest refresh.
-- Registered via RegisterScreen("inspect", ...) in WildThings.lua and reached
-- through /wt inspect. Ships in every build (UI/UI.xml entry is above the
-- do-not-package fence).
--
-- Reads the inspect round model from InspectComm.lua via Private:GetInspectRound()
-- and Private:GetInspectPullID(). Thresholds are read live from
-- WildThingsSavedVars.inspect at every build, so a settings change applies to the
-- next open with no reload (INSPECT-10).
--
-- INSPECT-12: CURRENCY_DISPLAY_UPDATE event handler updates the viewer's own crest
-- count live without a full page rebuild. A module-local key->rowFrame lookup table
-- enables pinpoint cell updates.
local _, namespace = ...
local Private = namespace
local WTUI = namespace.WildThingsUI
local Theme = namespace.Theme

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

-- Task 2 (design-1a §1.2-1.4): three columns with two 10px gaps; Tanks/Healers
-- stacked with an 8px gap; a 26px group header band; Theme.H_ROW (36) rows.
local COL_GAP = 10
local GROUP_GAP = 8
local GROUP_HEADER_HEIGHT = 26
local ROW_HEIGHT = Theme.H_ROW
local PLAYER_ROW_PAD = 10
local PLAYER_ROW_GAP = 8
local MARK_SIZE = 16
local NUM_CELL_CREST = 40
local NUM_CELL_POT = 36
local NUM_CELL_FLASK = 36
-- Bound to PLAYER_ROW_GAP (declared above) rather than the literal 8: the
-- header's icon-head block and the numeric row's cell chain must always move
-- together, never re-tune one side without the other.
local NUM_CELL_GAP = PLAYER_ROW_GAP
-- 128px = 40 + 36 + 36 + 2*8 -- the header's icon-head block mirrors the row's
-- full numeric span INCLUDING the two inter-cell gaps between crest/pot/flask,
-- not just the three cell widths. Both chains hang off the same right edge
-- with the same 10px pad, so a block that omitted the gaps (as this one used
-- to) drifts every head right of its column, cumulatively, right to left.
local GROUP_HEAD_BLOCK_WIDTH = NUM_CELL_CREST + NUM_CELL_POT + NUM_CELL_FLASK + 2 * NUM_CELL_GAP

-- Column-head icons (Task 2).
--
-- The crest head resolves live from the currency, which always has its icon
-- available. The potion and flask heads are hand-picked texture fileIDs: going
-- through a representative item id resolved the wrong art for the flask and
-- nothing at all for the potion, because GetItemIconByID answers from the
-- client's item cache and a consumable the player has never seen is simply not
-- in it. A fileID needs no cache, no async retry and no representative-item
-- decision, so it cannot drift when the tracked item lists change.
local CREST_CURRENCY_ID = 3347       -- Myth Dawncrest
local COMBAT_POTION_ICON_FILE = 7548911
local FLASK_ICON_FILE = 7548903

-- INSPECT-04: static class->bucket table for DAMAGER role. TANK and HEALER are
-- determined by combatRole from SnapshotRaidStatus; only DAMAGER needs the
-- melee/ranged split. Imperfect by the API's limitation -- a Shaman or Druid spec
-- swap between Enhancement/Elemental or Feral/Balance is not detected without a
-- re-report (the specID in the report would disambiguate, but maintaining a
-- specID->melee/ranged table is unwieldy and drifts with every patch). The
-- CONTEXT accepts a hand-maintained static table; this is it.
local RANGED_CLASSES = {
    HUNTER = true,
    MAGE = true,
    WARLOCK = true,
    PRIEST = true,
    EVOKER = true,
}

-- INSPECT-12: key->rowFrame lookup table for live crest refresh. Populated during
-- BuildBody when rows are created, cleared on rebuild. The CURRENCY_DISPLAY_UPDATE
-- handler reads this to find the viewer's own cell.
local crestRowLookup = {}

-- INSPECT-12: viewer's own canonical key, set once at screen build time so the
-- CURRENCY_DISPLAY_UPDATE handler doesn't recompute it on every event.
local viewerKey = nil

-- Task 1: header widget refs, populated once at CreateInspectScreen build time.
-- RefreshHeader is module-local (mirroring BuildBody/BuildColumn) so it can be
-- called both from BuildBody's top (keeping header and body in sync) and from
-- the Request-update button's post-refresh rebuild.
local headerRefs = {}

-- Task 3: set once CreateInspectScreen has run, so Private:SetInspectFixture
-- can trigger a rebuild without needing the screen to be currently shown.
local builtRoot = nil
local builtBodyFrame = nil

-- Task 3: solo fixture harness state and a forward declaration of its builder
-- -- BuildInspectFixture is defined further down (near BuildBody), but
-- RefreshHeader (defined next) needs to see it too, or the header would show
-- live data while the body shows fixture data, breaking the very invariant
-- Task 1 exists to establish ("the header and body can never disagree").
local inspectFixtureEnabled = false
local BuildInspectFixture

---------------------------------------------------------------------------
-- FormatLastPullLine(round, pullID) -- the design-1a header line (Task 1)
---------------------------------------------------------------------------

-- Byte-identical no-data hint to the one INSPECT-07 shipped (design-1a header
-- block, "before the first pull ... falls back to the existing no-data hint").
-- Covers all three CONTEXT-locked fallback cases in one branch: no pull ever
-- (pullID nil/0), no round, and a round restored from an older SavedVars with
-- no encounterName.
local NO_DATA_HINT = "No data \226\128\148 next pull will populate."

local function FormatLastPullLine(round, pullID)
    if not pullID or pullID == 0 or not round or type(round.encounterName) ~= "string" then
        return NO_DATA_HINT
    end

    local line = "Last pull \226\128\148 " .. round.encounterName

    -- Explicit true/false tests -- a truthiness test cannot distinguish false
    -- (wipe) from nil (unknown), which is exactly the tri-state InspectComm.lua's
    -- ENCOUNTER_END handler exists to preserve.
    local segment = ""
    if round.success == true then
        segment = "kill"
    elseif round.success == false then
        segment = "wipe"
    end

    if type(round.timestamp) == "number" then
        -- tostring() narrows date()'s annotated string|osdate return: the union
        -- only ever yields osdate for a "*t" format, never for this one.
        local timeStr = tostring(date("%H:%M:%S", round.timestamp))
        if segment ~= "" then
            segment = segment .. " " .. timeStr
        else
            segment = timeStr
        end
    end

    if segment ~= "" then
        line = line .. " \194\183 " .. segment
    end

    return line
end

---------------------------------------------------------------------------
-- CountReported(round, raidStatus) -- reported/total for the header counter
---------------------------------------------------------------------------

-- Total is the snapshot's key count (a map, not a list -- InspectComm.lua:505-508
-- idiom). Reported counts snapshot keys that also have a round.reports entry, so
-- a raider who left the raid after reporting can't push the numerator above the
-- denominator.
local function CountReported(round, raidStatus)
    local total = 0
    local reported = 0
    if raidStatus then
        for key in pairs(raidStatus) do
            total = total + 1
            if round and round.reports and round.reports[key] then
                reported = reported + 1
            end
        end
    end
    return reported, total
end

---------------------------------------------------------------------------
-- RefreshHeader() -- re-renders the SectionHeader line + reported counter
---------------------------------------------------------------------------

-- Re-reads the round, pull id and raid snapshot through the same
-- existence-guarded calls BuildBody uses, then sets the SectionHeader text and
-- the counter values. Called from the top of BuildBody so the header and body
-- can never disagree, and from the Request-update button's post-refresh
-- rebuild.
local function RefreshHeader()
    if not headerRefs.sectionHeader then return end

    -- Task 3: prefer the fixture over live data, exactly like BuildBody's two
    -- read sites -- otherwise the header would show live state while the
    -- body shows fixture state.
    local round, raidStatus, pid
    if inspectFixtureEnabled and BuildInspectFixture then
        raidStatus, round = BuildInspectFixture()
        pid = round.pullID
    else
        round = Private.GetInspectRound and Private:GetInspectRound() or nil
        raidStatus = Private.SnapshotRaidStatus and Private:SnapshotRaidStatus() or nil
        pid = Private.GetInspectPullID and Private:GetInspectPullID() or 0
    end

    headerRefs.sectionHeader.label:SetText(FormatLastPullLine(round, pid):upper())

    local reported, total = CountReported(round, raidStatus)
    if total == 0 then
        -- Solo, or a five-man party where GetRaidRosterInfo returns nil for
        -- every index (raidStatus stays empty) -- never read "0/0 reported".
        headerRefs.reportedFS:Hide()
        headerRefs.totalFS:Hide()
        headerRefs.reportedWordFS:Hide()
    else
        headerRefs.reportedFS:SetText(tostring(reported))
        headerRefs.totalFS:SetText("/" .. tostring(total))
        headerRefs.reportedFS:Show()
        headerRefs.totalFS:Show()
        headerRefs.reportedWordFS:Show()
    end
end

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

-- Mirrors SummonScreen.lua:119-130. 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

---------------------------------------------------------------------------
-- GetSeverityScore(entry, thresholds) -- weighted severity (Task 2, design §2)
---------------------------------------------------------------------------

-- Higher = worse. missing (offline or nodata) scores -1, so any present
-- player (0 or more) outranks it in the DESCENDING severity sort. Weights are
-- design-1a's renderVals verbatim, mapped onto the repo's three independent
-- threshold knobs (RESEARCH P5): pot==0/flask==0 are the heaviest signal,
-- below-threshold is a lighter signal, crest lightest of all.
local function GetSeverityScore(entry, thresholds)
    if not entry or entry.missing then return -1 end
    local crest = tonumber(entry.crest) or 0
    local combatPotions = tonumber(entry.combatPotions) or 0
    local flasks = tonumber(entry.flasks) or 0
    local score = 0
    if combatPotions == 0 then score = score + 100 end
    if flasks == 0 then score = score + 100 end
    if combatPotions < thresholds.combatPotionThreshold then score = score + 20 end
    if flasks < thresholds.flaskThreshold then score = score + 20 end
    if crest < thresholds.crestThreshold then score = score + 10 end
    return score
end

---------------------------------------------------------------------------
-- GetRoleBucket(entry) -- "TANK" | "HEALER" | "MELEE" | "RANGED"
---------------------------------------------------------------------------

-- Uses combatRole from SnapshotRaidStatus for TANK/HEALER; for DAMAGER,
-- splits by the RANGED_CLASSES static table. A nil/missing role or class
-- falls through to "MELEE" (defensive -- every player lands in a column).
local function GetRoleBucket(entry)
    if not entry then return "MELEE" end
    if entry.role == "TANK" then return "TANK" end
    if entry.role == "HEALER" then return "HEALER" end
    if entry.role == "DAMAGER" then
        if entry.class and RANGED_CLASSES[entry.class] then
            return "RANGED"
        end
        return "MELEE"
    end
    return "MELEE"  -- nil/missing role falls to melee
end

---------------------------------------------------------------------------
-- FormatCell(value, entry, threshold) -- per-cell text + colour (Task 2)
---------------------------------------------------------------------------

-- Evaluated INDEPENDENTLY per cell -- the fix for today's single combined
-- flag colouring all three cells identically. missing renders "?" (never the
-- em dash) coloured NEUTRAL (offline) or WARN (nodata); otherwise 0 is
-- DANGER, below the cell's OWN threshold is WARN, else TEXT. A genuine
-- reported zero still renders "0" -- the nil/zero distinction INSPECT-06
-- established is preserved by entry.missing, not by the value itself.
local function FormatCell(value, entry, threshold)
    if entry.missing then
        local color = entry.offline and Theme.NEUTRAL or Theme.WARN
        return "?", color
    end
    local n = tonumber(value) or 0
    local color
    if n == 0 then
        color = Theme.DANGER
    elseif n < threshold then
        color = Theme.WARN
    else
        color = Theme.TEXT
    end
    return tostring(n), color
end

---------------------------------------------------------------------------
-- ClearContentFrame(contentFrame) -- hide all children
---------------------------------------------------------------------------

local function ClearContentFrame(cf)
    local children = { cf:GetChildren() }
    for _, child in ipairs(children) do
        child:Hide()
    end
end

---------------------------------------------------------------------------
-- SortRows(rows, thresholds) -- present < nodata < offline, then severity
-- descending, then pot+flask ascending, then name (Task 2, design §2)
---------------------------------------------------------------------------

local function SortRows(rows, thresholds)
    table.sort(rows, function(a, b)
        local rankA = a.entry.offline and 2 or (a.entry.nodata and 1 or 0)
        local rankB = b.entry.offline and 2 or (b.entry.nodata and 1 or 0)
        if rankA ~= rankB then return rankA < rankB end

        local sevA = GetSeverityScore(a.entry, thresholds)
        local sevB = GetSeverityScore(b.entry, thresholds)
        if sevA ~= sevB then return sevA > sevB end

        local sumA = (tonumber(a.entry.combatPotions) or 0) + (tonumber(a.entry.flasks) or 0)
        local sumB = (tonumber(b.entry.combatPotions) or 0) + (tonumber(b.entry.flasks) or 0)
        if sumA ~= sumB then return sumA < sumB end

        return a.key < b.key
    end)
end

---------------------------------------------------------------------------
-- GetClassIDByFile / GetSpecIconFile -- spec-icon resolution (Task 2)
---------------------------------------------------------------------------

-- Memoized reverse map, classFile ("MAGE") -> numeric classID. Built once on
-- first call.
local classIDByFile = nil
local function GetClassIDByFile(classFile)
    if not classIDByFile then
        classIDByFile = {}
        local n = (GetNumClasses and GetNumClasses()) or 13
        for i = 1, n do
            local info = C_CreatureInfo and C_CreatureInfo.GetClassInfo
                and C_CreatureInfo.GetClassInfo(i)
            if info and info.classFile then
                classIDByFile[info.classFile] = info.classID or i
            end
        end
    end
    return classIDByFile[classFile]
end

-- The report's spec value is a 1-4 class-scoped index, never a global spec
-- ID (InspectComm.lua's corrected comment on GetSpecialization()). Returns
-- the icon fileID, or nil when resolution fails at any hop.
local function GetSpecIconFile(specIndex, classFile)
    if type(specIndex) ~= "number" or specIndex < 1 then return nil end
    if type(classFile) ~= "string" then return nil end
    local classID = GetClassIDByFile(classFile)
    if not classID then return nil end
    if not GetSpecializationInfoForClassID then return nil end
    -- Icon is return #4 -- positions 1-3 are id, name, description.
    -- ReadyCheckCooldownPopup.lua:223 takes position 3 for the sibling call
    -- and is wrong; that is a real defect in a different file, not fixed here.
    local _, _, _, icon = GetSpecializationInfoForClassID(classID, specIndex)
    return icon
end

---------------------------------------------------------------------------
-- Column-head icon resolution (Task 2) -- guarded, degrades to a text label
---------------------------------------------------------------------------

local function ResolveCrestIcon()
    local info = C_CurrencyInfo and C_CurrencyInfo.GetCurrencyInfo
        and C_CurrencyInfo.GetCurrencyInfo(CREST_CURRENCY_ID)
    return info and info.iconFileID
end

-- Direct fileIDs: nothing to resolve, nothing to fail. Kept as functions so
-- BuildGroupHeadCell's resolve-or-degrade contract stays uniform across all
-- three heads -- the crest one genuinely can return nil.
local function ResolvePotionIcon()
    return COMBAT_POTION_ICON_FILE
end

local function ResolveFlaskIcon()
    return FLASK_ICON_FILE
end

-- Builds one 16x16 column-head cell inside `slot`: the resolved icon centred,
-- or the one-character text fallback when resolution fails (never a blank
-- square). Wrapped in an invisible Button overlay so it can carry a tooltip --
-- the icon Texture itself cannot take mouse input (InspectScreen.lua's
-- existing crest-tooltip idiom).
local function BuildGroupHeadCell(slot, resolveFn, fallbackChar, tooltipText)
    local fileID = resolveFn()

    -- Templateless Button, so it starts with no normal/highlight/pushed texture
    -- at all -- the Set*Texture(nil) trio the older crest-tooltip idiom below
    -- carries would be a no-op here, and the analyser flags nil against those
    -- parameters. Omitted deliberately, not forgotten.
    local tipBtn = CreateFrame("Button", nil, slot)
    tipBtn:SetAllPoints(slot)

    if type(fileID) == "number" then
        local icon = namespace:CreateIcon(tipBtn, fileID, 16)
        icon:SetPoint("CENTER", tipBtn, "CENTER", 0, 0)
        icon:ClearTint()
        icon:SetAlpha(1)
    else
        local label = tipBtn:CreateFontString(nil, "ARTWORK")
        label:SetFontObject(WildThingsFont11)
        label:SetText(fallbackChar)
        label:SetTextColor(Theme.TEXT_SECTION.r, Theme.TEXT_SECTION.g, Theme.TEXT_SECTION.b, Theme.TEXT_SECTION.a)
        label:SetPoint("CENTER", tipBtn, "CENTER", 0, 0)
    end

    WTUI:AddTooltip(tipBtn, tooltipText)
end

---------------------------------------------------------------------------
-- BuildSpecMark(markSlot, entry) -- spec icon, class-atlas, then class disc
---------------------------------------------------------------------------

-- Fallback chain, never blank (CONTEXT "Icons -- LOCKED"): the reported spec
-- icon, else the class atlas (probed via C_Texture.GetAtlasInfo before
-- SetAtlas -- SetAtlas silently draws nothing for an unknown name), else the
-- class-coloured disc.
local function BuildSpecMark(markSlot, entry)
    local specIcon = markSlot:CreateTexture(nil, "ARTWORK")
    specIcon:SetAllPoints(markSlot)

    local iconFile = GetSpecIconFile(entry.specID, entry.class)
    local resolved = false

    if type(iconFile) == "number" then
        specIcon:SetTexture(iconFile)
        -- Stock spec icons carry a baked-in grey border that reads as a frame
        -- around every row's mark at this size -- trim it.
        specIcon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
        resolved = true
    else
        local atlas = type(entry.class) == "string" and ("classicon-" .. entry.class) or nil
        if atlas and C_Texture and C_Texture.GetAtlasInfo and C_Texture.GetAtlasInfo(atlas) then
            specIcon:SetAtlas(atlas)
            specIcon:SetSize(MARK_SIZE, MARK_SIZE)  -- after SetAtlas, per the ordering rule
            resolved = true
        end
    end

    if resolved then
        -- SetVertexColor(1,1,1,1), not ClearTint(): ClearTint is a WTUI convenience
        -- defined on the objects namespace:CreateIcon returns (UI/Widgets/Icon.lua),
        -- and specIcon is a bare markSlot:CreateTexture -- calling it here was a nil
        -- call. BuildGroupHeadCell above may use ClearTint because its icon DOES come
        -- from CreateIcon; this one does not.
        specIcon:SetVertexColor(1, 1, 1, 1)
        specIcon:SetAlpha(1)
        if entry.offline then
            specIcon:SetDesaturated(true)
            specIcon:SetVertexColor(0.55, 0.55, 0.55)  -- must follow the reset-to-white above
        end
        return
    end

    -- Last resort: the class-coloured disc. Never a blank square.
    specIcon:Hide()
    local dot = CreateFrame("Frame", nil, markSlot)
    dot:SetSize(MARK_SIZE / 2, MARK_SIZE / 2)
    dot:SetPoint("CENTER", markSlot, "CENTER", 0, 0)
    WTUI:AddRoundedFill(dot, MARK_SIZE / 4)
    local color = Theme.GetClassColor(entry.class)
    dot:SetRoundedFillColor(color.r, color.g, color.b, color.a)
    if entry.offline then
        dot:SetAlpha(0.55)
    end
end

---------------------------------------------------------------------------
-- BuildPlayerRow(row, key, entry, thresholds) -- one 36px player row
---------------------------------------------------------------------------

local function BuildPlayerRow(row, key, entry, thresholds)
    local markSlot = CreateFrame("Frame", nil, row)
    markSlot:SetSize(MARK_SIZE, MARK_SIZE)
    markSlot:SetPoint("LEFT", row, "LEFT", PLAYER_ROW_PAD, 0)

    -- Numeric cells, chained right-to-left from the row's RIGHT edge.
    local flaskText, flaskColor = FormatCell(entry.flasks, entry, thresholds.flaskThreshold)
    local flaskLabel = row:CreateFontString(nil, "ARTWORK")
    flaskLabel:SetFontObject(WildThingsFontNum13)
    flaskLabel:SetText(flaskText)
    flaskLabel:SetTextColor(flaskColor.r, flaskColor.g, flaskColor.b, flaskColor.a)
    flaskLabel:SetJustifyH("CENTER")
    flaskLabel:SetWidth(NUM_CELL_FLASK)
    flaskLabel:SetPoint("RIGHT", row, "RIGHT", -PLAYER_ROW_PAD, 0)

    local potText, potColor = FormatCell(entry.combatPotions, entry, thresholds.combatPotionThreshold)
    local potLabel = row:CreateFontString(nil, "ARTWORK")
    potLabel:SetFontObject(WildThingsFontNum13)
    potLabel:SetText(potText)
    potLabel:SetTextColor(potColor.r, potColor.g, potColor.b, potColor.a)
    potLabel:SetJustifyH("CENTER")
    potLabel:SetWidth(NUM_CELL_POT)
    potLabel:SetPoint("RIGHT", flaskLabel, "LEFT", -PLAYER_ROW_GAP, 0)

    local crestText, crestColor = FormatCell(entry.crest, entry, thresholds.crestThreshold)
    local crestLabel = row:CreateFontString(nil, "ARTWORK")
    crestLabel:SetFontObject(WildThingsFontNum13)
    crestLabel:SetText(crestText)
    crestLabel:SetTextColor(crestColor.r, crestColor.g, crestColor.b, crestColor.a)
    crestLabel:SetJustifyH("CENTER")
    crestLabel:SetWidth(NUM_CELL_CREST)
    crestLabel:SetPoint("RIGHT", potLabel, "LEFT", -PLAYER_ROW_GAP, 0)

    -- Name -- anchored to the fixed-width SLOT's right edge, not the icon
    -- itself, which is what keeps every name's left edge aligned regardless
    -- of which mark is showing (the repo's two-anchor ellipsis idiom).
    local nameColor = entry.offline and Theme.TEXT_DIM or Theme.GetClassColor(entry.class)
    local nameLabel = row:CreateFontString(nil, "ARTWORK")
    nameLabel:SetFontObject(WildThingsFontBold13)
    nameLabel:SetText(DisplayKey(key))
    nameLabel:SetTextColor(nameColor.r, nameColor.g, nameColor.b, nameColor.a)
    nameLabel:SetPoint("LEFT", markSlot, "RIGHT", PLAYER_ROW_GAP, 0)
    nameLabel:SetPoint("RIGHT", crestLabel, "LEFT", -PLAYER_ROW_GAP, 0)
    nameLabel:SetJustifyH("LEFT")
    nameLabel:SetWordWrap(false)

    -- INSPECT-F2: crest-cap tooltip via invisible button overlay. Shows
    -- "earned X/Y" when maxQuantity > 0 (capped currency). No tooltip when
    -- uncapped or data is missing.
    if not entry.missing and entry.maxQuantity and entry.maxQuantity > 0 then
        local tooltipText = "earned " .. tostring(entry.totalEarned or 0)
            .. "/" .. tostring(entry.maxQuantity)
        local crestTooltipBtn = CreateFrame("Button", nil, row)
        crestTooltipBtn:SetAllPoints(crestLabel)
        crestTooltipBtn:SetNormalTexture(nil)
        crestTooltipBtn:SetHighlightTexture(nil)
        crestTooltipBtn:SetPushedTexture(nil)
        WTUI:AddTooltip(crestTooltipBtn, tooltipText)
    end

    -- Spec icon / fallback chain -- never blank.
    BuildSpecMark(markSlot, entry)

    -- INSPECT-12: store row reference for live crest refresh. Keyed by
    -- canonical key so CURRENCY_DISPLAY_UPDATE can find the viewer's own row.
    crestRowLookup[key] = {
        row = row,
        crestLabel = crestLabel,
        nameLabel = nameLabel,
    }
end

---------------------------------------------------------------------------
-- BuildGroupBox(parent, groupLabel, roleIconStem, rows, thresholds,
--                drawColumnHeads, width) -- one design-1a group box
---------------------------------------------------------------------------

-- Mirrors RaidTimelineScreen.lua's CreateBottomPanel: a rounded SURFACE_1 fill
-- with a 1px BORDER stroke, a transparent header band closed by a hairline
-- (design-1a's group header has no fill). Children anchored 1px inside the
-- panel -- the caller inset contract WTUI:AddRoundedBorder requires.
local function BuildGroupBox(parent, groupLabel, roleIconStem, rows, thresholds, drawColumnHeads, width)
    local box = CreateFrame("Frame", nil, parent)
    box:SetWidth(width)
    WTUI:AddRoundedFill(box, Theme.RADIUS_MD, Theme.SURFACE_1)
    WTUI:AddRoundedBorder(box, Theme.RADIUS_MD)
    box:SetBorderColor(Theme.BORDER.r, Theme.BORDER.g, Theme.BORDER.b, Theme.BORDER.a)

    local header = CreateFrame("Frame", nil, box)
    header:SetPoint("TOPLEFT", box, "TOPLEFT", 1, -1)
    header:SetPoint("TOPRIGHT", box, "TOPRIGHT", -1, -1)
    header:SetHeight(GROUP_HEADER_HEIGHT)

    local hairline = header:CreateTexture(nil, "ARTWORK")
    hairline:SetHeight(1)
    hairline:SetColorTexture(Theme.BORDER.r, Theme.BORDER.g, Theme.BORDER.b, Theme.BORDER.a)
    hairline:SetSnapToPixelGrid(false)
    hairline:SetTexelSnappingBias(0)
    hairline:SetPoint("BOTTOMLEFT", header, "BOTTOMLEFT", 0, 0)
    hairline:SetPoint("BOTTOMRIGHT", header, "BOTTOMRIGHT", 0, 0)

    local roleIcon = namespace:CreateIcon(header, roleIconStem, 12)
    roleIcon:SetPoint("LEFT", header, "LEFT", 10, 0)
    roleIcon:SetAlpha(0.5)

    -- Reserved 112px block at the header's right edge, aligning the heads with
    -- the three numeric row cells below them. Always present so the group label
    -- stops at the same x whether or not `drawColumnHeads` fills it.
    local headBlock = CreateFrame("Frame", nil, header)
    headBlock:SetSize(GROUP_HEAD_BLOCK_WIDTH, GROUP_HEADER_HEIGHT)
    headBlock:SetPoint("RIGHT", header, "RIGHT", -10, 0)

    local label = header:CreateFontString(nil, "ARTWORK")
    label:SetFontObject(WildThingsFont13)
    label:SetText(groupLabel:upper())
    label:SetTextColor(Theme.TEXT_SECTION.r, Theme.TEXT_SECTION.g, Theme.TEXT_SECTION.b, Theme.TEXT_SECTION.a)
    label:SetPoint("LEFT", roleIcon, "RIGHT", 8, 0)
    label:SetPoint("RIGHT", headBlock, "LEFT", -8, 0)
    label:SetJustifyH("LEFT")
    label:SetWordWrap(false)

    if drawColumnHeads then
        local crestSlot = CreateFrame("Frame", nil, headBlock)
        crestSlot:SetSize(NUM_CELL_CREST, GROUP_HEADER_HEIGHT)
        crestSlot:SetPoint("LEFT", headBlock, "LEFT", 0, 0)
        BuildGroupHeadCell(crestSlot, ResolveCrestIcon, "C", "Myth Dawncrest")

        local potSlot = CreateFrame("Frame", nil, headBlock)
        potSlot:SetSize(NUM_CELL_POT, GROUP_HEADER_HEIGHT)
        potSlot:SetPoint("LEFT", crestSlot, "RIGHT", NUM_CELL_GAP, 0)
        BuildGroupHeadCell(potSlot, ResolvePotionIcon, "P", "Combat potions")

        local flaskSlot = CreateFrame("Frame", nil, headBlock)
        flaskSlot:SetSize(NUM_CELL_FLASK, GROUP_HEADER_HEIGHT)
        flaskSlot:SetPoint("LEFT", potSlot, "RIGHT", NUM_CELL_GAP, 0)
        BuildGroupHeadCell(flaskSlot, ResolveFlaskIcon, "F", "Flasks")
    end

    -- Rows, or a single dim placeholder when the group has no players --
    -- keeps the empty-group case working rather than collapsing to nothing.
    local rowCount = #rows
    if rowCount == 0 then
        local placeholder = CreateFrame("Frame", nil, box)
        placeholder:SetHeight(ROW_HEIGHT)
        placeholder:SetPoint("TOPLEFT", box, "TOPLEFT", 1, -GROUP_HEADER_HEIGHT - 1)
        placeholder:SetPoint("TOPRIGHT", box, "TOPRIGHT", -1, -GROUP_HEADER_HEIGHT - 1)
        local dim = placeholder:CreateFontString(nil, "ARTWORK")
        dim:SetFontObject(WildThingsFont13)
        dim:SetText("\226\128\148")
        dim:SetTextColor(Theme.TEXT_DISABLED.r, Theme.TEXT_DISABLED.g, Theme.TEXT_DISABLED.b, Theme.TEXT_DISABLED.a)
        dim:SetPoint("CENTER", placeholder, "CENTER", 0, 0)
        box:SetHeight(GROUP_HEADER_HEIGHT + ROW_HEIGHT + 2)
        return box
    end

    local y = -GROUP_HEADER_HEIGHT
    for rowIndex, item in ipairs(rows) do
        local row = CreateFrame("Frame", nil, box)
        row:SetHeight(ROW_HEIGHT)
        row:SetPoint("TOPLEFT", box, "TOPLEFT", 1, y - 1)
        row:SetPoint("TOPRIGHT", box, "TOPRIGHT", -1, y - 1)

        -- Alternating stripe, applied after sorting, by final row index.
        local stripe = (rowIndex % 2 == 1) and Theme.ROW_ODD or Theme.ROW_EVEN
        local bg = row:CreateTexture(nil, "BACKGROUND")
        bg:SetAllPoints(row)
        bg:SetColorTexture(stripe.r, stripe.g, stripe.b, stripe.a)

        BuildPlayerRow(row, item.key, item.entry, thresholds)

        y = y - ROW_HEIGHT
    end

    box:SetHeight(GROUP_HEADER_HEIGHT + rowCount * ROW_HEIGHT + 2)
    return box
end

---------------------------------------------------------------------------
-- BuildBody(root, bodyFrame) -- rebuild all four columns
---------------------------------------------------------------------------

-- Reads the inspect round model, classifies players into four buckets,
-- and populates the four column frames. Returns nothing -- the column
-- frames are children of bodyFrame and are anchored/populated in place.
local function BuildBody(root, bodyFrame)
    -- Task 1: keep the header and body from ever disagreeing.
    RefreshHeader()

    -- Clear the crest lookup table on every rebuild (INSPECT-12).
    crestRowLookup = {}

    -- Read thresholds live from SavedVars at every build (INSPECT-10), so a
    -- settings change applies to the next shell open with no reload.
    local thresholds = {
        crestThreshold = (WildThingsSavedVars.inspect and WildThingsSavedVars.inspect.crestThreshold) or 5,
        combatPotionThreshold = (WildThingsSavedVars.inspect and WildThingsSavedVars.inspect.combatPotionThreshold) or 2,
        flaskThreshold = (WildThingsSavedVars.inspect and WildThingsSavedVars.inspect.flaskThreshold) or 1,
    }

    -- Store viewer's own key for live refresh (INSPECT-12).
    if Private.GetSelfKey then
        viewerKey = Private:GetSelfKey()
    else
        viewerKey = nil
    end

    -- Task 3: solo fixture harness -- built once per BuildBody call when
    -- enabled, then substituted at both of the two existing read sites below
    -- (never a forked second code path through the rest of BuildBody).
    local fixtureRaidStatus, fixtureRound
    if inspectFixtureEnabled then
        fixtureRaidStatus, fixtureRound = BuildInspectFixture()
    end

    local round = nil
    if inspectFixtureEnabled then
        round = fixtureRound
    elseif Private.GetInspectRound then
        round = Private:GetInspectRound()
    end

    -- Collect the full raid roster from SnapshotRaidStatus so we can show
    -- never-reported players as "--" (INSPECT-06) rather than omitting them
    -- entirely. Guarded: SnapshotRaidStatus is on Private (shipped tier).
    local raidStatus = nil
    if inspectFixtureEnabled then
        raidStatus = fixtureRaidStatus
    elseif Private.SnapshotRaidStatus then
        raidStatus = Private:SnapshotRaidStatus()
    end

    -- Build a merged player list: every player in the raid, with their report
    -- if available (nil report = never-reported player, INSPECT-06). offline
    -- is entry.online == false; nodata is present with no matching report;
    -- missing is either -- both computed independently per CONTEXT "Cell
    -- colour + state logic -- LOCKED".
    local bucket = { TANK = {}, HEALER = {}, MELEE = {}, RANGED = {} }

    if raidStatus then
        for key, rs in pairs(raidStatus) do
            local report = round and round.reports and round.reports[key]
            local entry = nil
            if report then
                entry = {
                    crest = report.crest,
                    combatPotions = report.combatPotions,
                    flasks = report.flasks,
                    totalEarned = report.totalEarned,
                    maxQuantity = report.maxQuantity,
                    class = report.class or rs.class,
                    role = report.role or rs.role,
                    unit = report.unit or rs.unit,
                    specID = report.specID,
                    online = rs.online,
                    nodata = false,
                }
            else
                -- Never-reported player: use raid snapshot data for class/role
                -- but leave counts nil -- FormatCell renders "?" for missing.
                entry = {
                    crest = nil,
                    combatPotions = nil,
                    flasks = nil,
                    totalEarned = nil,
                    maxQuantity = nil,
                    class = rs.class,
                    role = rs.role,
                    unit = rs.unit,
                    specID = nil,
                    online = rs.online,
                    nodata = true,
                }
            end
            entry.offline = (entry.online == false)
            entry.missing = entry.offline or entry.nodata

            local roleBucket = GetRoleBucket(entry)
            table.insert(bucket[roleBucket], { key = key, entry = entry })
        end
    end

    for _, role in ipairs({ "TANK", "HEALER", "MELEE", "RANGED" }) do
        SortRows(bucket[role], thresholds)
    end

    -- Column metrics: three columns, two 10px gaps (design-1a §1.2). The
    -- shell window is user-resizable and its size persisted, so never
    -- hard-code a width.
    local bodyWidth = bodyFrame:GetWidth()
    if not bodyWidth or bodyWidth < 1 then bodyWidth = 400 end  -- defensive fallback
    local colWidth = math.floor((bodyWidth - 2 * COL_GAP) / 3)

    -- All previously-built group boxes are recreated on every rebuild -- an
    -- accepted, bounded frame count (T-3pd-04); row pooling is deliberately
    -- not adopted (RESEARCH: no per-wipe repaint seam to justify it here).
    ClearContentFrame(bodyFrame)

    -- Column 1: Tanks stacked over Healers, 8px gap.
    local tanksBox = BuildGroupBox(bodyFrame, "Tanks", "shield", bucket.TANK, thresholds, true, colWidth)
    tanksBox:SetPoint("TOPLEFT", bodyFrame, "TOPLEFT", 0, 0)

    -- Healers draws its own icon column heads too. Design 1a has the box under
    -- Tanks reserve an empty 112px spacer instead, on the reasoning that the
    -- heads read as one-per-visual-column -- but in the built page the two
    -- boxes are separately bordered, so the bare Healers header just looked
    -- unfinished next to Melee and Ranged. User call, overriding the design.
    local healersBox = BuildGroupBox(bodyFrame, "Healers", "heart", bucket.HEALER, thresholds, true, colWidth)
    healersBox:SetPoint("TOPLEFT", tanksBox, "BOTTOMLEFT", 0, -GROUP_GAP)

    -- Column 2: Melee.
    local meleeBox = BuildGroupBox(bodyFrame, "Melee", "swords", bucket.MELEE, thresholds, true, colWidth)
    meleeBox:SetPoint("TOPLEFT", bodyFrame, "TOPLEFT", colWidth + COL_GAP, 0)

    -- Column 3: Ranged.
    local rangedBox = BuildGroupBox(bodyFrame, "Ranged", "swords", bucket.RANGED, thresholds, true, colWidth)
    rangedBox:SetPoint("TOPLEFT", bodyFrame, "TOPLEFT", 2 * (colWidth + COL_GAP), 0)
end

---------------------------------------------------------------------------
-- Solo fixture harness (Task 3, RESEARCH A1/A4) -- /wt inspecttest fixture
---------------------------------------------------------------------------

-- Off by default; toggled via Private:SetInspectFixture / '/wt inspecttest
-- fixture'. Renders synthetic local data only -- sends nothing, crosses no
-- trust boundary (T-3pd-03), and changes no shipped behaviour when off.
-- (inspectFixtureEnabled itself is declared earlier, alongside this
-- function's forward declaration, so RefreshHeader can see both.)

-- Returns (raidStatus, round), shaped exactly like Private:SnapshotRaidStatus()
-- and Private:GetInspectRound() so BuildBody's two read sites can substitute
-- them without a second code path.
--
-- Each synthetic player's name encodes the spec its icon SHOULD show (per the
-- plan's classFile/specIndex table -- this table IS RESEARCH assumption A1
-- written out). If a rendered icon disagrees with the name beside it, that
-- mismatch is the finding: record it, do not "correct" this table to make the
-- page look right. Also covers: all four role buckets (>=2 Melee, >=2
-- Ranged), one offline player, one never-reported (nodata) player, a 0 /
-- below-threshold / healthy value for each of crest/pot/flask, one
-- spec-index-0 report (class-icon fallback), and one bogus class string
-- (class-disc fallback).
function BuildInspectFixture()
    local raidStatus = {}
    local reports = {}

    -- Named fields, not a positional array: two of these entries (the
    -- offline warlock, the never-reported evoker) leave several fields nil,
    -- and a nil in the MIDDLE of a positional array makes #def / unpack(def)
    -- undefined behaviour in Lua (the length operator can stop at the first
    -- hole). Named keys sidestep that entirely -- an absent field is simply
    -- nil, never ambiguous.
    local defs = {
        { key = "protectionwarrior-blackhand", class = "WARRIOR", role = "TANK", specIndex = 3, crest = 8, pot = 3, flask = 2, online = true, hasReport = true },
        { key = "holypaladin-blackhand", class = "PALADIN", role = "HEALER", specIndex = 1, crest = 0, pot = 1, flask = 0, online = true, hasReport = true },
        { key = "restorationdruid-blackhand", class = "DRUID", role = "HEALER", specIndex = 4, crest = 1, pot = 0, flask = 1, online = true, hasReport = true },
        { key = "subtletyrogue-blackhand", class = "ROGUE", role = "DAMAGER", specIndex = 3, crest = 3, pot = 1, flask = 0, online = true, hasReport = true },
        { key = "frostdk-blackhand", class = "DEATHKNIGHT", role = "DAMAGER", specIndex = 2, crest = 9, pot = 4, flask = 3, online = true, hasReport = true },
        { key = "windwalkermonk-blackhand", class = "MONK", role = "DAMAGER", specIndex = 3, crest = 7, pot = 1, flask = 2, online = true, hasReport = true },
        { key = "freshalt-blackhand", class = "SHAMAN", role = "DAMAGER", specIndex = 0, crest = 1, pot = 0, flask = 1, online = true, hasReport = true },
        { key = "bogusclass-blackhand", class = "NOTACLASS", role = "DAMAGER", specIndex = 1, crest = 6, pot = 3, flask = 2, online = true, hasReport = true },
        { key = "shadowpriest-blackhand", class = "PRIEST", role = "DAMAGER", specIndex = 3, crest = 4, pot = 2, flask = 1, online = true, hasReport = true },
        { key = "frostmage-blackhand", class = "MAGE", role = "DAMAGER", specIndex = 3, crest = 6, pot = 0, flask = 0, online = true, hasReport = true },
        { key = "beastmasteryhunter-blackhand", class = "HUNTER", role = "DAMAGER", specIndex = 1, crest = 5, pot = 2, flask = 1, online = true, hasReport = true },
        { key = "destructionwarlock-blackhand", class = "WARLOCK", role = "DAMAGER", specIndex = 3, online = false, hasReport = false },
        { key = "devastationevoker-blackhand", class = "EVOKER", role = "DAMAGER", online = true, hasReport = false },
    }

    for i, def in ipairs(defs) do
        raidStatus[def.key] = {
            inRaid = true,
            online = def.online,
            class = def.class,
            role = def.role,
            unit = "raid" .. i,
        }
        if def.hasReport then
            reports[def.key] = {
                crest = def.crest,
                combatPotions = def.pot,
                flasks = def.flask,
                specID = def.specIndex,
                class = def.class,
                role = def.role,
                unit = "raid" .. i,
            }
        end
    end

    local round = {
        pullID = 9999,
        encounterName = "Fixture Encounter",
        success = false,
        timestamp = time(),
        reports = reports,
    }

    return raidStatus, round
end

-- Private:SetInspectFixture(enabled): sets the flag, prints the new state in
-- the repo's usual coloured print style, and triggers a body rebuild if the
-- screen has already been built. Accepts a nil argument as a toggle.
function Private:SetInspectFixture(enabled)
    if enabled == nil then
        inspectFixtureEnabled = not inspectFixtureEnabled
    else
        inspectFixtureEnabled = enabled and true or false
    end

    print("|cff99ff00Wild Things:|r inspect fixture " .. (inspectFixtureEnabled and "ENABLED" or "disabled"))

    if builtRoot and builtBodyFrame then
        Private.safecall(function()
            BuildBody(builtRoot, builtBodyFrame)
        end)
    end
end

---------------------------------------------------------------------------
-- Private:CreateInspectScreen(contentHost) -- the screen builder (INSPECT-04)
---------------------------------------------------------------------------

function Private:CreateInspectScreen(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)

    -- INSPECT-12: register CURRENCY_DISPLAY_UPDATE for live crest refresh.
    -- The event fires whenever the player's currency quantities change
    -- (earning, spending, or transferring crests). The handler updates only
    -- the viewer's own crest cell without a full page rebuild.
    root:SetScript("OnEvent", function(self, event, ...)
        if event == "CURRENCY_DISPLAY_UPDATE" then
            if not viewerKey then return end
            local rowData = crestRowLookup[viewerKey]
            if not rowData or not rowData.crestLabel then return end

            -- Re-read the viewer's own crest count dynamically.
            local info = C_CurrencyInfo.GetCurrencyInfo(CREST_CURRENCY_ID)
            local crestCount = (info and info.quantity) or 0

            -- Update the crest label text in place -- bare number, matching
            -- the rows now that the letter prefixes are gone (Task 2).
            rowData.crestLabel:SetText(tostring(crestCount))

            -- Same three-way rule the rows use (Task 2): 0 is DANGER, below
            -- the live crestThreshold is WARN, otherwise TEXT. Previously
            -- this handler only knew WARN/TEXT_DIM, which would have
            -- disagreed with every other row on the page after this change.
            local crestThreshold = (WildThingsSavedVars.inspect and WildThingsSavedVars.inspect.crestThreshold) or 5
            local color
            if crestCount == 0 then
                color = Theme.DANGER
            elseif crestCount < crestThreshold then
                color = Theme.WARN
            else
                color = Theme.TEXT
            end
            rowData.crestLabel:SetTextColor(color.r, color.g, color.b, color.a)
        end
    end)
    root:RegisterEvent("CURRENCY_DISPLAY_UPDATE")

    -- INSPECT-12: clean up the ticker and clear the lookup table when the
    -- screen hides. The CURRENCY_DISPLAY_UPDATE event registration stays --
    -- the OnEvent handler guards on viewerKey/crestRowLookup being nil after
    -- hide, so it's a harmless no-op when the screen is not visible.
    root:HookScript("OnHide", function()
        crestRowLookup = {}
        viewerKey = nil
    end)

    -- Task 1: single bodyFrame upvalue, declared here (above the header
    -- block) so it exists before the Request-update button's OnClick closure
    -- captures it below. The creation line further down ASSIGNS this local;
    -- it must not re-declare it (a shadowing re-declaration is the exact bug
    -- class this repo has shipped three times).
    local bodyFrame

    ---------------------------------------------------------------------------
    -- HEADER: design-1a header block (SectionHeader, button row, counter)
    ---------------------------------------------------------------------------

    local headerHost = CreateFrame("Frame", nil, root)
    headerHost:SetPoint("TOPLEFT", root, "TOPLEFT", Theme.PAD_WINDOW, -Theme.PAD_WINDOW)
    headerHost:SetPoint("TOPRIGHT", root, "TOPRIGHT", -Theme.PAD_WINDOW, -Theme.PAD_WINDOW)

    -- namespace:CreateSectionHeader self-anchors LEFT/RIGHT to its parent
    -- (SectionHeader.lua:16-17), so only the vertical position is set here.
    -- RefreshHeader overwrites the label as soon as round data is available,
    -- but the widget measures its own height ONCE at construction from that
    -- label (SectionHeader.lua:36) -- seeding it with "" makes it 0 tall for
    -- the rest of its life. Seed the no-data hint instead: same font, same
    -- single line, so the measured height is the one every later line needs.
    local sectionHeader = namespace:CreateSectionHeader(headerHost, NO_DATA_HINT)
    sectionHeader:SetPoint("TOP", headerHost, "TOP", 0, 0)
    headerRefs.sectionHeader = sectionHeader

    local buttonRow = CreateFrame("Frame", nil, headerHost)
    buttonRow:SetHeight(Theme.H_BTN)
    buttonRow:SetPoint("TOPLEFT", sectionHeader, "BOTTOMLEFT", 0, -Theme.GAP)
    buttonRow:SetPoint("TOPRIGHT", sectionHeader, "BOTTOMRIGHT", 0, -Theme.GAP)

    -- headerHost is sized explicitly. Anchoring its BOTTOM to buttonRow would
    -- be circular -- buttonRow hangs off sectionHeader, which self-anchors to
    -- headerHost -- and the layout engine rejects that outright with "Cannot
    -- anchor to a region dependent on it". Both rows are fixed-height, so the
    -- sum is exact and nothing is lost by not auto-sizing.
    local sectionHeaderHeight = sectionHeader:GetHeight()
    if not sectionHeaderHeight or sectionHeaderHeight < 1 then
        -- Defensive: GetStringHeight can read 0 before the font file resolves.
        sectionHeaderHeight = 16
    end
    headerHost:SetHeight(sectionHeaderHeight + Theme.GAP + Theme.H_BTN)

    -- Request update button (INSPECT-09): broadcasts INSPECT_REQ to request
    -- fresh reports from every client. Rank-gated (GATE-02): built
    -- CONDITIONALLY (never build-then-hide) so a non-officer never has the
    -- control in the frame tree at all. This is a cosmetic, local-only UI
    -- gate -- the INSPECT_REQ comm handler and Private:RequestInspectRefresh
    -- stay deliberately unprivileged (any raider may request), per the locked
    -- decision.
    if Private:ViewerHasCapability("inspect.refresh") then
        local refreshButton = namespace:CreateButton(buttonRow, "Request update", function()
            if Private.RequestInspectRefresh then
                Private:RequestInspectRefresh()
            end
            -- Rebuild the body after a short delay to let replies arrive.
            -- C_Timer.After(2, ...) mirroring the SummonScreen's 3s collect
            -- window. On a single client (no other raiders), the INSPECT_REQ
            -- handler self-populates immediately, so 2s is plenty.
            C_Timer.After(2, function()
                Private.safecall(function()
                    BuildBody(root, bodyFrame)
                end)
            end)
        end, { variant = "primary", icon = "refresh-cw" })
        refreshButton:SetPoint("LEFT", buttonRow, "LEFT", 0, 0)
        WTUI:AddTooltip(refreshButton, "Request fresh inspect reports from all raiders")
    end

    -- Reported counter: anchored to the button ROW's own RIGHT edge, never to
    -- the button -- on a non-officer's client the button does not exist.
    -- Three FontStrings chained right-to-left: the word "reported" in the
    -- caption font, "/total" in the numeric font dimmed, the reported count
    -- in the numeric font accented.
    local reportedWordFS = buttonRow:CreateFontString(nil, "ARTWORK")
    reportedWordFS:SetFontObject(WildThingsFont11)
    reportedWordFS:SetText("reported")
    reportedWordFS:SetTextColor(Theme.TEXT_SECTION.r, Theme.TEXT_SECTION.g, Theme.TEXT_SECTION.b, Theme.TEXT_SECTION.a)
    reportedWordFS:SetPoint("RIGHT", buttonRow, "RIGHT", 0, 0)
    reportedWordFS:SetJustifyH("RIGHT")

    local totalFS = buttonRow:CreateFontString(nil, "ARTWORK")
    totalFS:SetFontObject(WildThingsFontNum16)
    totalFS:SetTextColor(Theme.TEXT_DIM.r, Theme.TEXT_DIM.g, Theme.TEXT_DIM.b, Theme.TEXT_DIM.a)
    totalFS:SetPoint("RIGHT", reportedWordFS, "LEFT", -4, 0)
    totalFS:SetJustifyH("RIGHT")

    local reportedFS = buttonRow:CreateFontString(nil, "ARTWORK")
    reportedFS:SetFontObject(WildThingsFontNum16)
    reportedFS:SetTextColor(Theme.ACCENT.r, Theme.ACCENT.g, Theme.ACCENT.b, Theme.ACCENT.a)
    reportedFS:SetPoint("RIGHT", totalFS, "LEFT", 0, 0)
    reportedFS:SetJustifyH("RIGHT")

    headerRefs.reportedFS = reportedFS
    headerRefs.totalFS = totalFS
    headerRefs.reportedWordFS = reportedWordFS

    ---------------------------------------------------------------------------
    -- BODY: four role columns (no scroll -- INSPECT-04)
    ---------------------------------------------------------------------------

    -- Body frame occupies the remaining space below the header. Anchored to
    -- the header host's BOTTOM with a live gap -- the header block's height
    -- now varies with its own content instead of a fixed HEADER_HEIGHT.
    bodyFrame = CreateFrame("Frame", nil, root)
    bodyFrame:SetPoint("TOPLEFT", headerHost, "BOTTOMLEFT", 0, -Theme.GAP)
    bodyFrame:SetPoint("TOPRIGHT", headerHost, "BOTTOMRIGHT", 0, -Theme.GAP)
    bodyFrame:SetPoint("BOTTOM", root, "BOTTOM", 0, Theme.PAD_WINDOW)

    -- Bind body width for column measurement. A frame created under a visible
    -- parent may already be shown, so the very first open might not fire
    -- OnShow at all -- this deferred build guarantees a first population.
    C_Timer.After(0, function()
        BuildBody(root, bodyFrame)
    end)

    -- Task 3 (RESEARCH P1): Shell:ShowScreen builds a screen once and caches
    -- the frame, thereafter only Show()/Hide()-ing it -- so without this hook
    -- every open after the first shows first-open data (and the live-crest
    -- handler stays a permanent no-op after the OnHide clears it). The
    -- deferral is not optional: OnShow fires during ShowScreen's own Show()
    -- call, before the frame has a resolved width, and BuildBody measures
    -- bodyFrame:GetWidth().
    root:HookScript("OnShow", function()
        C_Timer.After(0, function()
            Private.safecall(function()
                BuildBody(root, bodyFrame)
            end)
        end)
    end)

    builtRoot = root
    builtBodyFrame = bodyFrame

    return root
end
