---@diagnostic disable: undefined-field, undefined-global
local _, namespace = ...

local Theme = namespace.Theme

-- Private and namespace are the SAME shared table (select(2, ...) everywhere else in the
-- codebase) -- this alias makes the engine calls below read as Private:ParseRosterExport /
-- Private:ArrangeForEncounter / Private:InviteMissingFromTokens, matching the codebase's
-- existing engine-call convention (Roster.lua, RosterArrange.lua).
local Private = namespace
local WTUI = namespace.WildThingsUI

-- Role -> role-icon stem (screens.jsx ROLE_ICON). Screen-local, mirrors Shell.lua's NavItem
-- precedent (a single-consumer composite kept in the file that uses it).
local ROLE_ICON = { TANK = "shield", HEALER = "heart", DAMAGER = "swords" }

-- Status -> label/color/tooltip (screens.jsx STATUS_META). "bench" overrides in/missing
-- whenever a row isn't on the selected encounter's invitelist (12-CONTEXT Area 2).
local STATUS_META = {
    ["in"] = { label = "In raid", color = Theme.OK, tip = "Player is in the raid group." },
    invited = { label = "Invited…", color = Theme.WARN, tip = "Invite sent — waiting for accept." },
    missing = { label = "Missing", color = Theme.DANGER, tip = "On the setup but not in the raid." },
    bench = { label = "Bench", color = Theme.NEUTRAL, tip = "Sitting out for this encounter." },
    offline = { label = "Offline", color = Theme.NEUTRAL, tip = "Player is offline." },
    notfound = { label = "Not found", color = Theme.DANGER, tip = "No character by that name — check the name/realm." },
    declined = { label = "Declined", color = Theme.DANGER, tip = "Player declined the invite." },
    ingroup = { label = "In group", color = Theme.WARN, tip = "Player is already in another group." },
}

-- Token (a raw invitelist entry, "Name" or "Name-Realm") -> canonical key, reusing
-- Private:CanonicalKey verbatim (same shape as Roster.lua:59-60 / RosterArrange.lua's own
-- CanonicalizeLiveName). A single shared helper avoids re-deriving this 2-line pattern in
-- 4 different call sites below.
local function TokenToKey(token)
    local name, realm = token:match("^(.-)%-(.+)$")
    return Private:CanonicalKey(name or token, realm or GetNormalizedRealmName())
end

-- Union of every parsed encounter's invitelist, CanonicalKey-deduped (Roster.lua:53-66
-- shape) -- one row per canonical player across the WHOLE roster, not just the selected
-- encounter. Keeps the first-seen display token per key.
local function BuildUnionRows()
    local seenKeys, rows = {}, {}
    for _, enc in ipairs(Private.parsedRosterExport) do
        for _, token in ipairs(enc.invitelist) do
            local key = TokenToKey(token)
            if not seenKeys[key] then
                seenKeys[key] = true
                rows[#rows + 1] = { key = key, displayName = token }
            end
        end
    end
    return rows
end

local function BuildUnionTokens()
    local tokens = {}
    for _, row in ipairs(BuildUnionRows()) do
        tokens[#tokens + 1] = row.displayName
    end
    return tokens
end

-- Canonical-key set of one encounter's invitelist -- the active/bench partition for that
-- chip (12-CONTEXT Area 2: "active" if on this set, otherwise "bench" for this chip).
local function BuildInvitelistKeys(enc)
    local keys = {}
    if not enc then return keys end
    for _, token in ipairs(enc.invitelist) do
        keys[TokenToKey(token)] = true
    end
    return keys
end

-- Groups 1-4 seed (GROUP-01, plan 14-02): WYSIWYG staged positions -- exactly what
-- "Invite & Arrange" would produce, read via the read-only Private:BuildTargetPositions
-- accessor (14-01) rather than re-deriving the ceil/mod position math in this file.
local function SeedGroupsOneToFour(slots40, enc)
    for _, pos in ipairs(Private:BuildTargetPositions(enc.invitelist)) do
        -- Bound check (WR-03): an invitelist longer than 20 tokens (paste error, or two
        -- boss sections accidentally merged) produces pos.group > 4. Without this guard
        -- the overflow would bleed into slots 21-40, the same index range
        -- SeedGroupsFiveToEight uses for LIVE subgroup 5-8 members -- silently corrupting
        -- that display. Drop the overflow entry rather than clamp/wrap it.
        if pos.group <= 4 then
            slots40[(pos.group - 1) * 5 + pos.slot] = pos.key
        end
    end
end

-- Groups 5-8 seed (GROUP-01, plan 14-02): buckets non-invitelist, currently-in-raid
-- members by their LIVE subgroup (5-8 only -- a live subgroup outside 5-8 has no group-
-- view slot to seed into and is simply skipped, e.g. a non-invitelist member still sitting
-- in subgroup 1-4). Within each bucket, table.sort on the canonical key gives a
-- deterministic tie-break (14-RESEARCH.md Open Question 1 / Assumption A1) -- raw pairs()
-- iteration of statusByKey has no stable order and must never drive slot placement.
-- dualVis (optional out-param, CR-01 review fix): every key actually placed into
-- slots40 by this seed is also recorded into dualVis[key] = true, so a caller can
-- later recover a displaced "dual visibility" occupant onto the bench (see
-- BuildBenchList below) even though they are not a BuildUnionRows() member.
local function SeedGroupsFiveToEight(slots40, invitelistKeys, liveStatus, dualVis)
    -- Every non-invitelist, currently-in-raid member. Those already sitting in a LIVE
    -- subgroup 5-8 seed into the matching group (WYSIWYG for their real subgroup); everyone
    -- else -- live subgroup 1-4 (e.g. the logged-in raid LEAD, G-14-1) or an overflowing
    -- 5-8 bucket -- goes to the fill pool so they stay visible instead of vanishing.
    local buckets = {}
    local overflow = {}
    for key, entry in pairs(liveStatus) do
        if entry.inRaid and not invitelistKeys[key] then
            local sg = entry.subgroup
            if sg and sg >= 5 and sg <= 8 then
                local bucket = buckets[sg]
                if not bucket then
                    bucket = {}
                    buckets[sg] = bucket
                end
                bucket[#bucket + 1] = key
            else
                overflow[#overflow + 1] = key
            end
        end
    end

    for g = 5, 8 do
        local bucket = buckets[g]
        if bucket then
            table.sort(bucket)
            for s = 1, math.min(#bucket, 5) do
                local key = bucket[s]
                slots40[(g - 1) * 5 + s] = key
                if dualVis then dualVis[key] = true end
            end
            for s = 6, #bucket do -- >5 in one live subgroup: send the rest to the fill pool
                overflow[#overflow + 1] = bucket[s]
            end
        end
    end

    -- Fill pool: first free slot scanning groups 8 -> 5 (matches the legacy FindBenchGroup
    -- bench convention and the "push to Group 8" expectation). Sort keeps placement stable.
    local fillOrder = {}
    for g = 8, 5, -1 do
        for s = 1, 5 do fillOrder[#fillOrder + 1] = (g - 1) * 5 + s end
    end
    table.sort(overflow)
    local fi = 1
    for _, key in ipairs(overflow) do
        while fi <= #fillOrder and slots40[fillOrder[fi]] do fi = fi + 1 end
        if fi > #fillOrder then break end -- 5-8 full: drop the rest rather than error
        slots40[fillOrder[fi]] = key
        if dualVis then dualVis[key] = true end
        fi = fi + 1
    end
end

-- Bench list (GROUP-01, plan 14-02): every roster-union member NOT staged in groups 1-4
-- (slots40 indices 1-20). A non-invitelist in-raid member intentionally appears BOTH here
-- AND parked in groups 5-8 -- dual visibility is a locked CONTEXT decision, not a bug; do
-- not dedupe against groups 5-8 occupancy. Returns canonical keys only -- grid:SetLayout's
-- bench parameter resolves display names internally via its own BuildDisplayNameByKey.
--
-- dualVis (optional, CR-01 review fix): the set of non-invitelist "dual visibility" keys
-- originally seeded into this encounter's groups 5-8 (SeedGroupsFiveToEight's dualVis
-- out-param). Such a key is never a BuildUnionRows() member, so if a bench-drop displaces
-- it from slots40 it would otherwise vanish from the staged view entirely (GROUP-03
-- requires a displaced occupant to return to the bench for ALL 40 slots, not just the
-- invitelist ones). Recover it onto the bench here if it's no longer staged anywhere.
local function BuildBenchList(slots40, dualVis)
    local stagedOneToFour = {}
    for i = 1, 20 do
        if slots40[i] then
            stagedOneToFour[slots40[i]] = true
        end
    end
    local bench = {}
    local benchKeys = {}
    for _, row in ipairs(BuildUnionRows()) do
        if not stagedOneToFour[row.key] then
            bench[#bench + 1] = row.key
            benchKeys[row.key] = true
        end
    end
    if dualVis then
        local stagedAnywhere = {}
        for i = 1, 40 do
            if slots40[i] then stagedAnywhere[slots40[i]] = true end
        end
        for key in pairs(dualVis) do
            if not stagedAnywhere[key] and not benchKeys[key] then
                bench[#bench + 1] = key
            end
        end
    end
    return bench
end

-- Paste absorber (IMPORT-01): WoW delivers a pasted string as a synthetic OnChar
-- burst (not one paste event), and EditBox has an internal byte cap (SetMaxBytes)
-- that silently drops overflow with zero UI feedback -- this buffers every OnChar
-- and treats the buffer, not the (possibly truncated) box text, as the source of
-- truth. HookScript (not SetScript) is required on
-- OnChar/OnTextChanged because namespace:CreateEditBox already wires its own OnTextChanged/
-- OnTextSet handlers (drives currentValue/OnValueChanged) -- a second SetScript would
-- silently clobber that wiring.
-- The two caps are deliberately different numbers for two different jobs, and
-- conflating them (one `CAP` for both) is what made this reject real exports:
-- RosterData.lua's own boss export is ~20.5 KB, so a single 8192 cap failed the
-- `#s > CAP` test on the one paste the feature exists to accept.
--   BOX_CAP    -- SetMaxBytes, i.e. what the EditBox itself holds and RENDERS.
--                 Kept small on purpose: a multiline box asked to lay out 20 KB
--                 is slow, and it never needs to -- an oversized paste is meant
--                 to truncate here so the `#s > #boxText` branch below fires and
--                 swaps the visible text for the short "captured" placeholder.
--                 The buffer, not the box, is the source of truth.
--   ACCEPT_CAP -- the actual rejection threshold for the buffered paste. Sized
--                 with ~3x headroom over today's export so a growing roster does
--                 not silently hit it again.
local IMPORT_BOX_CAP = 8192
local IMPORT_ACCEPT_CAP = 65536

function Private:AttachImportPasteAbsorber(editBox, onRetry)
    editBox:SetMaxLetters(0)
    editBox:SetMaxBytes(IMPORT_BOX_CAP)
    editBox:SetMultiLine(true)

    local buf, bufN, lastN = {}, 0, 0
    local captured
    local settingText = false

    local function Finalize()
        editBox:SetScript("OnUpdate", nil)
        local s = strtrim(table.concat(buf, "", 1, bufN))
        wipe(buf)
        bufN, lastN = 0, 0

        -- WR-01 (21-REVIEW): decide oversized-paste rejection off the buffered length `s`
        -- itself, not a comparison against the box's own (possibly SetMaxBytes-truncated)
        -- text -- any paste that actually exceeds the cap always has #s > #boxText (buffer
        -- is longer than the truncated box), so the old comparison made this branch
        -- unreachable in the one case it exists to catch. ACCEPT_CAP, never BOX_CAP: the
        -- box truncating is the NORMAL path for a large export, not a rejection reason.
        if #s > IMPORT_ACCEPT_CAP then
            captured = nil
            editBox:SetMaxBytes(0)
            settingText = true
            editBox:SetText("")
            settingText = false
            if onRetry then onRetry() end
            return
        end

        -- Swap the visible text for the short placeholder whenever what was captured
        -- cannot be shown honestly in a one-line box. Two independent reasons:
        --   #s > #boxText -- BOX_CAP truncated the paste, so the box is showing a
        --                    fragment while the buffer holds the whole thing.
        --   embedded "\n" -- the box is one line tall (IMPORT_BOX_HEIGHT), so a
        --                    multi-line paste that fit under BOX_CAP would otherwise
        --                    display only its first line and read as a partial import.
        -- Every real export hits at least one of these, so the placeholder is the normal
        -- outcome of a paste. What it deliberately does NOT catch is ordinary typing:
        -- Finalize() runs ~2 frames after the last OnChar, so an unconditional
        -- placeholder would replace each typed character the moment the user paused,
        -- making the box impossible to type into at all.
        local boxText = editBox:GetText() or ""
        if #s > #boxText or s:find("\n", 1, true) then
            captured = s
            settingText = true
            editBox:SetText(("[ Import string captured (%d characters) ]"):format(#s))
            editBox:SetCursorPosition(0)
            settingText = false
        else
            captured = nil
        end
    end

    editBox:HookScript("OnChar", function(_, c)
        bufN = bufN + 1
        buf[bufN] = c
        if bufN == 1 then
            lastN = 0
            editBox:SetScript("OnUpdate", function()
                if bufN == lastN then
                    Finalize()
                else
                    lastN = bufN
                end
            end)
        end
    end)

    editBox:HookScript("OnTextChanged", function(_, userInput)
        if userInput and not settingText and captured then
            captured = nil -- manual edit after a capture starts over
        end
    end)

    return {
        GetText = function()
            return captured or strtrim(editBox:GetText() or "")
        end,
        -- WR-02 (21-REVIEW): `captured` is only cleared by a new Finalize() run or a
        -- user-typed OnTextChanged -- a programmatic SetText("") (e.g. on popup reopen)
        -- does NOT clear it, so a stale (possibly already-rejected) capture could be
        -- resubmitted from a visually-empty box without a fresh paste. Call sites that
        -- reset the visible text on reopen must also call this.
        -- CR-01 (21-REVIEW iter2): also clear the in-flight OnChar buffer (buf/bufN/lastN)
        -- and cancel any pending Finalize() OnUpdate poll -- otherwise a popup reopen mid-
        -- paste can leave a stale buffer that Finalize() later resurfaces or concatenates
        -- with a fresh paste into the same buf table.
        Reset = function()
            captured = nil
            editBox:SetScript("OnUpdate", nil)
            wipe(buf)
            bufN, lastN = 0, 0
        end,
    }
end

-- IMPORT-02: optional first-line version header (WTRoster:N). A header-less paste is
-- always the pre-existing legacy format and is fully accepted -- Private:ParseRosterExport's
-- line loop only reacts to ^EncounterID:/^invitelist: lines, so any other line (including
-- this header) is already silently skipped there; this check runs BEFORE the parse, at the
-- import call site only (RosterParser.lua itself stays unmodified -- it has 3 other call
-- sites that must not gain a new error-return contract).
local SUPPORTED_ROSTER_IMPORT_VERSION = 1

local function ValidateImportHeader(text)
    local headerVersion = text:match("^WTRoster:(%d+)")
    if not headerVersion then
        return true -- headerless legacy paste, fully accepted
    end
    headerVersion = tonumber(headerVersion)
    if headerVersion > SUPPORTED_ROSTER_IMPORT_VERSION then
        return false, "This export is newer than your addon — update WildThings."
    end
    -- headerVersion <= SUPPORTED_ROSTER_IMPORT_VERSION: no retired versions exist yet to
    -- reject (CONTEXT.md's "recognized-but-retired" branch has nothing to compare against
    -- until a future phase actually retires a version).
    return true
end

-- Private:CaptureRosterImport(text, onApplied) -- the screen-independent half of an import,
-- lifted out of CreateRosterScreen's HandleImportCapture closure so the Dashboard's
-- own Import button runs the SAME code path rather than a second implementation that
-- could drift. Everything here is state the whole addon reads (SavedVars,
-- Private.rosterExport/parsedRosterExport); everything left behind in the closure is
-- RosterScreen's own view state (staged-layout caches, chips, rows, glow), which only
-- means anything if that screen was ever built.
-- Returns true, encounterCount on success or false, reason on any rejection. Every
-- step stays guard-claused so a malformed/garbage/empty paste degrades to an
-- actionable chat message, never a raw Lua error or a silent empty-roster write
-- (T-21-10/T-21-13). The chat messages live here, at the single point of truth, so
-- both call sites report identically.
-- WT_ROSTER_CHANGED fires with reason "import" exactly as before -- WildThings.lua's
-- SummonWindowAutoOpen subscriber fail-closed-ignores that reason, and RosterScreen's
-- own subscriber re-seeds the Group View grid.
-- `onApplied` runs after the persist but deliberately BEFORE that broadcast, and the
-- ordering is load-bearing, not stylistic: RosterScreen's onApplied is what wipes
-- stagedByEncounter, and its RosterScreenGridRefresh subscriber re-seeds the grid from
-- exactly that table. Fire first and the grid re-seeds from the PREVIOUS import's staged
-- layout -- the stale-layout bug CR-01 (21-REVIEW) fixed. Keep the call here, above the
-- FireCallback line.
function Private:CaptureRosterImport(text, onApplied)
    if type(text) ~= "string" or strtrim(text) == "" then
        print("|cffff5555Wild Things:|r Nothing to import — paste an export first.")
        return false, "empty"
    end

    local ok, headerMessage = ValidateImportHeader(text)
    if not ok then
        print("|cffff5555Wild Things:|r " .. headerMessage)
        return false, "header"
    end

    -- T-21-11: text is handed ONLY to the pure pattern-matching RosterParser --
    -- never to loadstring or any dynamic code-evaluation/string-to-chunk loader.
    local encounters = Private:ParseRosterExport(text)
    if #encounters == 0 then
        print("|cffff5555Wild Things:|r Unrecognized format — nothing parseable in the pasted text.")
        return false, "unparseable" -- do not write SavedVars, do not overwrite Private.rosterExport
    end

    WildThingsSavedVars.roster.import_export = text
    WildThingsSavedVars.roster.import_captured_at = time()
    Private.rosterExport = text
    Private.parsedRosterExport = encounters

    if onApplied then onApplied(#encounters) end

    if Private.FireCallback then Private:FireCallback("WT_ROSTER_CHANGED", "import") end

    print(("|cff99ff00Wild Things:|r Import captured — %d encounter(s) parsed."):format(#encounters))
    return true, #encounters
end

-- Private:OpenRosterImportPopup(parent, onCaptured) -- the shared paste popup, built
-- once on first use and reused by every call site (the Roster screen's Import button
-- and the Dashboard's). ONE popup instance, because two would mean two absorbers
-- buffering the same paste; `onCaptured` is re-pointed on each open instead, which is
-- what lets the Roster screen attach its in-place repaint while the Dashboard attaches
-- nothing. onCaptured runs only after a SUCCESSFUL capture, and is handed straight to
-- CaptureRosterImport as its `onApplied` so it lands inside that function's documented
-- persist-then-refresh-then-broadcast order rather than after the whole thing.
local sharedImportPopup
local sharedImportOnCaptured

-- Popup geometry, named rather than inlined because the same numbers appear in four
-- places (popup width, wrap width, box width, SetAdditionalHeight) and drifted apart the
-- last time they were literals. IMPORT_POPUP_PAD is the popup's own 16px SPACING doubled
-- (PopupWindow.lua), i.e. the left+right inset a full-width child has to subtract.
-- Deliberately ONE line tall: nothing multi-line is ever on display here. The absorber
-- swaps a successful paste for its short "[ Import string captured (N characters) ]"
-- placeholder the same frame it finalizes, so a tall box would show a single line of
-- text above a block of dead space. Width is set to fit that placeholder, not the export.
-- The box still IS multiline (SetMultiLine below) -- that is what lets the pasted
-- newlines reach OnChar at all; it is only its rendered HEIGHT that is one line.
local IMPORT_POPUP_WIDTH = 420
local IMPORT_POPUP_PAD = 32
local IMPORT_BOX_HEIGHT = Theme.H_INPUT

function Private:OpenRosterImportPopup(parent, onCaptured)
    if not sharedImportPopup then
        sharedImportPopup = namespace:CreatePopupWindowWithButton()
        sharedImportPopup:SetWidth(IMPORT_POPUP_WIDTH)
        sharedImportPopup.text:SetWidth(IMPORT_POPUP_WIDTH - IMPORT_POPUP_PAD)
        sharedImportPopup:SetText("Paste your raid roster export below, then click Import.")
        sharedImportPopup:SetButtonText("Import")

        -- CreateEditBox has no placeholder-text feature (RESEARCH Pitfall 5) -- the
        -- title param IS the "paste here" instruction, rendered above the box.
        sharedImportPopup.editBox = namespace:CreateEditBox(sharedImportPopup,
            "Paste your raid roster export here", function() end)
        sharedImportPopup.editBox:SetMultiLine(true)
        sharedImportPopup.editBox:SetWidth(IMPORT_POPUP_WIDTH - IMPORT_POPUP_PAD)
        sharedImportPopup.editBox:SetHeight(IMPORT_BOX_HEIGHT)
        sharedImportPopup.editBox:SetPoint("TOP", sharedImportPopup.text, "BOTTOM", 0, -Theme.GAP_LG - 16)

        sharedImportPopup.absorber = Private:AttachImportPasteAbsorber(sharedImportPopup.editBox, function()
            print("|cffff5555Wild Things:|r Pasted text is too large — please try a smaller export.")
        end)

        sharedImportPopup:SetAdditionalHeight(
            IMPORT_BOX_HEIGHT + Theme.GAP_LG + 16 + sharedImportPopup.button:GetHeight() + 10)

        -- The callback is read through the sharedImportOnCaptured upvalue at click time,
        -- not captured here -- this closure is built once, but which screen wants the
        -- post-import refresh changes with every open.
        sharedImportPopup:SetButtonOnClick(function()
            Private:CaptureRosterImport(sharedImportPopup.absorber.GetText(), function(count)
                if sharedImportOnCaptured then sharedImportOnCaptured(count) end
            end)
        end)
    end

    sharedImportOnCaptured = onCaptured
    sharedImportPopup.editBox:SetText("")
    -- WR-02 (21-REVIEW): SetText("") alone does NOT clear the absorber's `captured`
    -- string, so a stale capture could be resubmitted from a visually-empty box.
    if sharedImportPopup.absorber then sharedImportPopup.absorber.Reset() end
    sharedImportPopup:Pop(parent)
end

-- namespace:CreateRosterScreen(contentHost)
-- Roster screen (ROSTER-01/02/05/06, DATA-02): encounter chips, Invite All, Re-invite
-- missing (N), and a pooled row per rostered player with role icon + class-colored name +
-- status badge. Pure view over the unchanged invite/arrange engine -- every invite/arrange/
-- parse touchpoint below calls Private:ParseRosterExport/Private:ArrangeForEncounter/
-- Private:InviteMissingFromTokens verbatim, never reimplemented. The live GROUP_ROSTER_UPDATE
-- refresh and per-row Invite/Swap buttons are implemented below in this file.
function namespace:CreateRosterScreen(contentHost)
    local root = CreateFrame("Frame", nil, contentHost)
    root:SetPoint("TOPLEFT", contentHost, "TOPLEFT")
    root:SetPoint("BOTTOMRIGHT", contentHost, "BOTTOMRIGHT")

    -- Live refresh (ROSTER-02/03): a dedicated event frame, registered only while this
    -- screen is shown (OnShow) and unregistered on OnHide -- mirrors RosterArrange.lua's
    -- own rosterEventFrame idiom (CreateFrame + SetScript OnEvent + RegisterEvent). No
    -- polling of any kind -- GROUP_ROSTER_UPDATE is the only trigger for a repaint.
    local rosterScreenEventFrame = CreateFrame("Frame")

    if not Private.parsedRosterExport then
        Private.parsedRosterExport = Private:ParseRosterExport(Private.rosterExport)
    end

    local selectedIndex = 1
    local selectedEnc = Private.parsedRosterExport[1]
    local chipButtons = {}
    local searchFilter = ""
    -- Client-local optimistic "invited" status -- there is no native pending-invite API
    -- (12-RESEARCH Pitfall 2). Each entry stores the GetTime() of the invite (was a bare
    -- `true`); cleared in RepaintRows the moment the live snapshot shows the key in raid.
    -- A declined/ignored/invalid invite that never joins reverts to "missing" once
    -- INVITE_PENDING_TTL elapses (see MarkInvited / DeriveStatus) so the Invite button
    -- returns and the row can be retried -- supersedes 12-CONTEXT Area 3's "no timed
    -- simulation" decision at the user's request (stuck-on-"invited" rows could not retry).
    local INVITE_PENDING_TTL = 30 -- seconds
    local invitedFlags = {}
    local rowPool = {}
    local reinviteBtn

    -- Forward-declared: assigned once the row list is built further down this function.
    -- SelectChip below calls it guarded (chip clicks cannot actually fire before the full
    -- builder finishes running, but the guard documents the dependency explicitly).
    local RepaintRows

    -- Forward-declared (IMPORT-01/02): the Import popup's button (below) calls this via
    -- the upvalue; the real body is assigned further down, after rosterRows/RepaintRows
    -- exist, since a successful capture must rebuild the chip row and repaint the roster
    -- rows in place (21-RESEARCH.md Pitfall 2 -- never Shell.lua RegisterScreen/ShowScreen).
    local HandleImportCapture

    -- Records an optimistic invite (timestamped) and schedules a one-shot repaint at TTL
    -- expiry, so an invite that is never accepted reverts the row to "missing" (restoring
    -- the Invite button) without waiting for another GROUP_ROSTER_UPDATE.
    local function MarkInvited(key)
        if Private.inviteFailures then Private.inviteFailures[key] = nil end -- fresh attempt clears any prior failure
        invitedFlags[key] = GetTime()
        C_Timer.After(INVITE_PENDING_TTL + 0.1, function()
            if RepaintRows then RepaintRows(Private:SnapshotRaidStatus()) end
        end)
    end

    -- Group grid (GROUP-01/GROUP-05, plan 14-02): forward-declared like RepaintRows above
    -- -- instantiated further down (after the scrollFrame it shares content-area geometry
    -- with), but SelectChip below must be able to close over the assigned values as
    -- upvalues, not stray globals. groupGrid stays nil on a packaged build where
    -- GroupGrid.lua is stripped (pkgmeta.yaml); every call site below is nil-guarded.
    local groupGrid
    local groupGridVisible = false
    local stagedByEncounter = {}
    -- Per-encounter set of non-invitelist "dual visibility" keys seeded into groups 5-8
    -- (CR-01 review fix) -- lets BuildBenchList recover a displaced groups-5-8 occupant
    -- onto the bench instead of silently discarding them.
    local dualVisibilityByEncounter = {}
    -- Open-snapshot cache (DRAGUX-01, plan 22-04): captured ONLY at Group View OPEN
    -- (SetGroupViewVisible(true)) and at an encounter SWITCH while the view is already
    -- visible (SelectChip below) -- never inside OpenGroupView itself, since that function
    -- is also re-invoked by arrange/import-driven refreshes (the WT_ROSTER_CHANGED
    -- subscriber, Task 3) and must not move the Reset baseline forward on every such
    -- refresh. Always a fresh copy (groupGrid:GetLayout()), never a live reference to
    -- stagedByEncounter -- ResetGroups below restores from this deterministically.
    local openSnapshotByEncounter = {}
    local OpenGroupView
    -- Forward-declared like OpenGroupView above: assigned inside the `if groupGrid then`
    -- block (Task 2), but the far-below GROUP_ROSTER_UPDATE dispatch closure -- outside
    -- that block's scope -- must also be able to call it on every live raid-membership
    -- change while Group View is open.
    local RefreshSetGroupsEnabled
    -- Forward-declared (DRAGUX-03, 22-05 Task 2): RefreshSetGroupsEnabled's closure
    -- (defined before setGroupsBtn itself further down this same `if groupGrid then`
    -- block) must close over this as an upvalue, not a stray global -- assigned at
    -- setGroupsBtn's creation site below.
    local setGroupsExplain

    -- Progress bar + Stop-button state (STOP-01/PROG-01/02). progressBar/RefreshOpButtons/
    -- UpdateProgressUI are forward-declared here (assigned further down, after listViewBtn/
    -- searchBox exist) so the trigger handlers below can already close over them.
    -- runningTrigger is the opKey of the trigger that started the CURRENTLY running
    -- operation (or nil) -- only that trigger's button shows "Stop"; the others stay normal
    -- (overlapping presses are already safe by engine design, see RefreshOpButtons below).
    local progressBar
    local RefreshOpButtons
    local UpdateProgressUI
    local runningTrigger
    local triggerButtons = {} -- opKey -> { src = <button>, twin = <stopButton> }

    -- Creates a cached danger "Stop" twin that exactly overlays srcBtn (Show/Hide-swapped,
    -- never both visible). Button.lua bakes the variant/hover closures at creation with no
    -- runtime variant-swap API, so a separate cached twin is the established idiom -- the
    -- same one the row Swap in / Swap out buttons above already use.
    local function AttachStopTwin(srcBtn, opKey)
        local twin = namespace:CreateButton(srcBtn:GetParent(), "Stop", function()
            Private:CancelOperations()
            if RefreshOpButtons then RefreshOpButtons() end
            if RepaintRows then RepaintRows(Private:SnapshotRaidStatus()) end
        end, { variant = "danger", icon = "x" })
        twin:SetFixedWidth(true)
        twin:ClearAllPoints()
        twin:SetAllPoints(srcBtn)
        twin:Hide()
        triggerButtons[opKey] = { src = srcBtn, twin = twin }
    end

    -- Disabled-control self-explanation overlay (DRAGUX-03, 22-RESEARCH Pattern 3): a
    -- transparent hit-frame, SetAllPoints over btn at a higher frame level, whose
    -- OnEnter/OnLeave show/hide the shared WIDGET tooltip (WTUI:ShowWidgetTooltip, NOT
    -- the existing AddTooltip badge mechanism) via reasonFn -- a zero-arg function
    -- re-evaluated at hover-show time so it always reflects the CURRENT disabled reason.
    -- The hit-frame's mouse-enable is toggled ONLY by the caller (RefreshOpButtons /
    -- RefreshSetGroupsEnabled below), at the EXACT SAME call site that flips
    -- btn:Enable()/btn:Disable() (Pitfall 8) -- never a separate poller -- so a
    -- just-re-enabled button's overlay is disabled at the same tick and never swallows
    -- a click meant for it. Starts mouse-disabled (every op button starts enabled).
    local function AddDisabledExplanation(btn, reasonFn)
        local hit = CreateFrame("Frame", nil, btn)
        hit:SetAllPoints(btn)
        hit:SetFrameLevel(btn:GetFrameLevel() + 1)
        hit:EnableMouse(false)
        hit:SetScript("OnEnter", function()
            WTUI:ShowWidgetTooltip(btn, reasonFn)
        end)
        hit:SetScript("OnLeave", function()
            WTUI:HideWidgetTooltip()
        end)
        return hit
    end

    -- reasonFn (DRAGUX-03): mirrors Private:ArrangeForEncounter's own lead/assist guard
    -- (RosterArrange.lua ~1548) so the hover text explains exactly the condition that
    -- guard would otherwise silently print on click.
    local function InviteArrangeDisabledReason()
        if IsInRaid() and not UnitIsGroupLeader("player") and not UnitIsGroupAssistant("player") then
            return "Arrange requires raid lead or assist"
        end
        return nil
    end

    -- Private:InviteMissingFromTokens has no lead/assist or raid-membership restriction
    -- -- Invite All is never natively disabled today. The overlay is still attached for
    -- structural symmetry with the other two op buttons (plan's explicit listing); its
    -- reasonFn simply never surfaces since the hit-frame is only mouse-enabled while the
    -- button IS disabled (never, for this button).
    local function InviteAllDisabledReason()
        return nil
    end

    -- EJ dungeonEncounterID → boss portrait fileID cache, built lazily on first use.
    -- Creature portraits from EJ_GetCreatureInfo are wider than tall; a center-crop
    -- texCoord is applied at render time (see SetLeadingIcon) to produce a 1:1 square.
    -- nil until first RebuildChips; memoized for the session.
    local bossIconCache

    local function BuildBossIconCache()
        if bossIconCache then
            return bossIconCache
        end
        bossIconCache = {}

        if not EJ_GetInstanceByIndex or not EJ_GetEncounterInfoByIndex then
            return bossIconCache
        end

        local savedTier = EJ_GetCurrentTier and EJ_GetCurrentTier() or nil
        local savedInstance = EJ_GetCurrentInstance and EJ_GetCurrentInstance() or nil

        local numTiers = EJ_GetNumTiers and EJ_GetNumTiers() or 0
        if numTiers > 0 and EJ_SelectTier then
            EJ_SelectTier(numTiers)
        end

        local i = 1
        while true do
            local instanceID = EJ_GetInstanceByIndex(i, true)
            if not instanceID then break end

            if EJ_SelectInstance then
                EJ_SelectInstance(instanceID)
            end

            local j = 1
            while true do
                local _, _, journalEncounterID, _, _, _, dungeonEncounterID =
                    EJ_GetEncounterInfoByIndex(j, instanceID)
                if not journalEncounterID then break end

                if dungeonEncounterID and EJ_GetCreatureInfo then
                    local _, _, _, _, iconImage = EJ_GetCreatureInfo(1, journalEncounterID)
                    if type(iconImage) == "number" then
                        bossIconCache[dungeonEncounterID] = iconImage
                    end
                end
                j = j + 1
            end
            i = i + 1
        end

        if savedTier and EJ_SelectTier then EJ_SelectTier(savedTier) end
        if savedInstance and EJ_SelectInstance then EJ_SelectInstance(savedInstance) end

        return bossIconCache
    end

    -- "Encounter" section label.
    local sectionHeader = namespace:CreateSectionHeader(root, "Encounter")
    sectionHeader:SetPoint("TOPLEFT", root, "TOPLEFT", Theme.PAD_WINDOW, -Theme.PAD_WINDOW)
    sectionHeader:SetPoint("TOPRIGHT", root, "TOPRIGHT", -Theme.PAD_WINDOW, -Theme.PAD_WINDOW)

    -- Encounter-chip click handler: PURE VIEW ONLY (P2 fix) -- selects the chip and
    -- repaints, nothing else. No saved-var write and no arrange here; browsing chips
    -- must never mutate the live raid. Inviting/arranging now lives on the "Invite &
    -- Arrange" primary button below.
    local function SelectChip(i, enc)
        for idx, chip in ipairs(chipButtons) do
            chip:SetSelected(idx == i)
        end
        selectedIndex = i
        selectedEnc = enc

        if RepaintRows then
            RepaintRows(Private:SnapshotRaidStatus())
        end
        -- Keep the staged group grid in sync when the raid lead switches bosses while
        -- Group View is open (14-02 Task 1.5): seed-if-absent for the new encounter, else
        -- reuse its already-staged layout. Never runs while the grid is hidden.
        if groupGrid and groupGridVisible then
            OpenGroupView()
            -- DRAGUX-01 (22-04): a boss switch while Group View is open establishes a NEW
            -- open-snapshot baseline for the newly-selected encounter (see the cache's
            -- declaration comment above for why this is NOT done inside OpenGroupView).
            if selectedEnc then
                openSnapshotByEncounter[selectedEnc.id] = groupGrid:GetLayout()
            end
        end
    end

    -- Encounter chips: left-to-right, wrapping within the header's available width.
    local availWidth = contentHost:GetWidth()
    if not availWidth or availWidth <= 0 then
        availWidth = 760 -- fallback for the (rare) case the shell hasn't sized contentHost yet
    end
    local wrapRight = availWidth - Theme.PAD_WINDOW
    local chipStartY = -Theme.PAD_WINDOW - sectionHeader:GetHeight() - Theme.GAP
    local chipY = chipStartY

    -- Rebuilds the encounter-chip row from Private.parsedRosterExport, reusing the same
    -- wrap geometry as the initial construction-time layout (IMPORT-01 in-place refresh --
    -- 21-RESEARCH.md Pitfall 2, never Shell.lua RegisterScreen/ShowScreen). Hides + wipes
    -- the existing chip buttons and re-runs the same left-to-right wrapping loop against
    -- the new array. Note: the action row below the chips keeps its ORIGINAL Y (not
    -- re-flowed on a later rebuild) -- an accepted implementation-detail tradeoff
    -- (21-RESEARCH.md Code Examples), since re-anchoring the whole screen on every import
    -- would risk destabilizing unrelated geometry for a rare (once-per-session) action.
    local function RebuildChips()
        for _, chip in ipairs(chipButtons) do
            chip:Hide()
        end
        wipe(chipButtons)
        local iconLookup = BuildBossIconCache()
        local chipX = Theme.PAD_WINDOW
        chipY = chipStartY
        for i, enc in ipairs(Private.parsedRosterExport) do
            local bossIcon = iconLookup[enc.id]
            local chipOpts = { selected = (i == selectedIndex) }
            if bossIcon then
                chipOpts.icon = bossIcon
                chipOpts.iconSize = 22
            end
            local chip = namespace:CreateButton(root, i .. ". " .. enc.name, function() SelectChip(i, enc) end,
                chipOpts)
            local chipWidth = chip:GetWidth()
            if chipX > Theme.PAD_WINDOW and (chipX + chipWidth) > wrapRight then
                chipX = Theme.PAD_WINDOW
                chipY = chipY - Theme.H_BTN - Theme.GAP
            end
            chip:SetPoint("TOPLEFT", root, "TOPLEFT", chipX, chipY)
            chipX = chipX + chipWidth + Theme.GAP
            chipButtons[i] = chip
        end
    end

    RebuildChips()

    local actionRowY = chipY - Theme.H_BTN - Theme.GAP

    -- "Invite & Arrange" (P2 fix): the screen's SOLE primary action, first in the
    -- action row. Owns the write + arrange + optimistic-flag + repaint that used to
    -- live on SelectChip (Roster.lua:83-85 lineage) -- browsing chips no longer
    -- arranges, only pressing this button does. No icon: no suitable Lucide stem
    -- exists (no play/go/zap/arrow-right), and reusing "users"/"arrow-up-down" would
    -- duplicate a sibling button in this same row -- the primary accent fill alone is
    -- the affordance.
    local function InviteAndArrange()
        if not selectedEnc then return end

        WildThingsSavedVars.assignment.current_encounter_id = selectedEnc.id
        Private:ArrangeForEncounter(selectedEnc)

        -- Optimistic invited flags for the selected encounter's currently-missing
        -- rows, reusing ReinviteMissing's exact scoping logic below.
        local liveStatus = Private:SnapshotRaidStatus()
        local invitelistKeys = BuildInvitelistKeys(selectedEnc)
        for _, row in ipairs(BuildUnionRows()) do
            if invitelistKeys[row.key] then
                local entry = liveStatus[row.key]
                if not (entry and entry.inRaid) then
                    MarkInvited(row.key)
                end
            end
        end

        runningTrigger = "invitearrange"
        if RefreshOpButtons then RefreshOpButtons() end
        if RepaintRows then
            RepaintRows(Private:SnapshotRaidStatus())
        end
    end

    local inviteArrangeBtn = namespace:CreateButton(root, "Invite & Arrange", InviteAndArrange,
        { variant = "primary" })
    inviteArrangeBtn:SetPoint("TOPLEFT", root, "TOPLEFT", Theme.PAD_WINDOW, actionRowY)
    AttachStopTwin(inviteArrangeBtn, "invitearrange")
    local inviteArrangeExplain = AddDisabledExplanation(inviteArrangeBtn, InviteArrangeDisabledReason)

    -- "Invite All": union of every parsed encounter's invitelist, invited verbatim via the
    -- engine (Roster.lua:44-69 shape). Demoted to default variant (P2 fix) -- "Invite &
    -- Arrange" above is now the screen's sole primary action.
    local inviteAllBtn = namespace:CreateButton(root, "Invite All", function()
        Private:InviteMissingFromTokens(BuildUnionTokens())
        for _, row in ipairs(BuildUnionRows()) do
            MarkInvited(row.key)
        end
        runningTrigger = "inviteall"
        if RefreshOpButtons then RefreshOpButtons() end
        if RepaintRows then
            RepaintRows(Private:SnapshotRaidStatus())
        end
    end, { icon = "users" })
    inviteAllBtn:SetPoint("LEFT", inviteArrangeBtn, "RIGHT", Theme.GAP, 0)
    AttachStopTwin(inviteAllBtn, "inviteall")
    local inviteAllExplain = AddDisabledExplanation(inviteAllBtn, InviteAllDisabledReason)

    -- "Re-invite missing (N)": scoped to the CURRENTLY SELECTED encounter's invitelist so
    -- the count matches the row list's own "missing" bucket -- a player benched for this
    -- boss is not "missing" and must not be re-invited by this button (12-CONTEXT Area 2's
    -- bench-overrides-missing rule, and the design kit's own scoping of missCount/inviteAll
    -- to the currently-selected boss's active roster).
    local function ReinviteMissing()
        local liveStatus = Private:SnapshotRaidStatus()
        local invitelistKeys = BuildInvitelistKeys(selectedEnc)
        local missing = {}
        for _, row in ipairs(BuildUnionRows()) do
            if invitelistKeys[row.key] then
                local entry = liveStatus[row.key]
                if not (entry and entry.inRaid) then
                    missing[#missing + 1] = row.displayName
                    MarkInvited(row.key)
                end
            end
        end
        Private:InviteMissingFromTokens(missing)
        runningTrigger = "reinvite"
        if RefreshOpButtons then RefreshOpButtons() end
        if RepaintRows then
            RepaintRows(Private:SnapshotRaidStatus())
        end
    end

    reinviteBtn = namespace:CreateButton(root, "Re-invite missing (0)", ReinviteMissing, { icon = "refresh-cw" })
    reinviteBtn:SetPoint("LEFT", inviteAllBtn, "RIGHT", Theme.GAP, 0)
    AttachStopTwin(reinviteBtn, "reinvite")

    -- Import (IMPORT-01): opens the shared paste popup defined at file scope above.
    -- HandleImportCapture is the forward-declared upvalue -- its real body is assigned
    -- further down (after rosterRows/RepaintRows exist) and now carries only this
    -- screen's own refresh; the parse/persist half lives in Private:CaptureRosterImport,
    -- which the popup calls before invoking this callback.
    local function OpenImportPopup()
        Private:OpenRosterImportPopup(root, function()
            if HandleImportCapture then
                HandleImportCapture()
            end
        end)
    end

    local importBtn = namespace:CreateButton(root, "Import", OpenImportPopup, { icon = "download" })
    importBtn:SetPoint("LEFT", reinviteBtn, "RIGHT", Theme.GAP, 0)

    -- Glow-until-acted-on (DRAGUX-02): glow importBtn via the central driver while NO
    -- roster export is present -- reusing Private.parsedRosterExport's existing
    -- presence signal (empty means neither a captured SavedVars import nor the static
    -- RosterData.lua fallback yielded any parseable encounter) rather than a new flag.
    -- Show/HideBorder around the glow, same as GroupGrid's slots. The comment that used to
    -- sit here claimed buttons show a static border that only needed recoloring -- that
    -- stopped being true when Button.lua started calling SetBorderShown(false), which left
    -- this glow silently painting a hidden texture.
    local function RefreshImportGlow()
        local hasExport = Private.parsedRosterExport and #Private.parsedRosterExport > 0
        if hasExport then
            WTUI:StopGlow(importBtn, "importPending")
            importBtn:HideBorder()
        else
            importBtn:ShowBorder()
            WTUI:StartGlow(importBtn, "importPending", WTUI:BorderPulse())
        end
    end
    RefreshImportGlow()

    -- Icon-only search box (ROSTER-06): case-insensitive substring filter on the raw
    -- display-name token, applied inside RepaintRows below. EditBox has no placeholder-text
    -- feature (12-RESEARCH Pitfall 4) -- the leading "search" icon alone is the affordance.
    local searchBox = namespace:CreateEditBox(root, "", function(text)
        searchFilter = (text or ""):lower()
        -- DRAGUX-04: the same box also drives the grid's fuzzy filter live while Group
        -- View is visible -- List view keeps its existing plain-find RepaintRows path
        -- below, untouched. groupGrid is nil-guarded (packaged-build safety, same as
        -- every other groupGrid call site in this file).
        if groupGrid and groupGridVisible then
            groupGrid:ApplySearchFilter(text)
        end
        if RepaintRows then
            RepaintRows(Private:SnapshotRaidStatus())
        end
    end)
    searchBox:SetLeadingIcon("search")
    -- CreateEditBox sets only a height, never a width -- without an explicit width this
    -- single-anchor frame collapses to 0px (invisible) and drags the List/Group view-toggle
    -- icons (anchored to its LEFT edge) to the window's far edge. 150px per the design mock.
    searchBox:SetWidth(150)
    searchBox:SetPoint("TOPRIGHT", root, "TOPRIGHT", -Theme.PAD_WINDOW, actionRowY)

    -- Row list: pooled rows in a scroll container below the header/action row.
    local scrollFrame = namespace:CreateScrollFrame(root)
    scrollFrame:SetPoint("TOPLEFT", root, "TOPLEFT", Theme.PAD_WINDOW, actionRowY - Theme.H_BTN - Theme.GAP_LG)
    scrollFrame:SetPoint("BOTTOMRIGHT", root, "BOTTOMRIGHT", -Theme.PAD_WINDOW, Theme.PAD_WINDOW)

    -- Post-drop repaint callback (GROUP-02/03, plan 15-02): reads the same staged table a
    -- drop just mutated in place (currentSlots40 IS stagedByEncounter[selectedEnc.id] by
    -- reference), recomputes the bench, and re-runs the existing full-repaint entry point
    -- (grid:SetLayout) -- no second incremental render path. Defensive nil guards: a no-op
    -- if this ever fires before selectedEnc/a seed exists. Writes nothing to
    -- WildThingsSavedVars and calls no live-raid mutation API.
    local function OnGroupLayoutChanged()
        if not selectedEnc then return end
        local slots40 = stagedByEncounter[selectedEnc.id]
        if not slots40 then return end
        -- APPLY-02 leader-slot-1 UX (16-RESEARCH.md Pattern 4): re-snap a displaced leader
        -- to slot 1 of their group in place (same table object) BEFORE the repaint, so the
        -- staged grid always shows an achievable state -- reuses the engine's own
        -- leader-correction (Private:NormalizeLeaderInSlots), not a duplicated local rule.
        Private:NormalizeLeaderInSlots(slots40)
        groupGrid:SetLayout(slots40, BuildBenchList(slots40, dualVisibilityByEncounter[selectedEnc.id]))
    end

    -- Group grid host (GROUP-01, plan 14-02): instantiated ONCE, guarded on
    -- namespace.CreateGroupGrid existing (packaged-build safety -- GroupGrid.lua is
    -- excluded from the release ZIP per pkgmeta.yaml, same as this whole dev-only file).
    -- Re-anchored over the SAME content area as scrollFrame above (identical PAD offsets)
    -- so toggling list<->group swaps content in place; hidden by default (list view first).
    groupGrid = namespace.CreateGroupGrid and namespace:CreateGroupGrid(root, OnGroupLayoutChanged) or nil
    local listViewBtn
    local groupsViewBtn
    local setGroupsBtn
    local resetGroupsBtn
    if groupGrid then
        groupGrid.root:ClearAllPoints()
        groupGrid.root:SetPoint("TOPLEFT", root, "TOPLEFT", Theme.PAD_WINDOW, actionRowY - Theme.H_BTN - Theme.GAP_LG)
        groupGrid.root:SetPoint("BOTTOMRIGHT", root, "BOTTOMRIGHT", -Theme.PAD_WINDOW, Theme.PAD_WINDOW)
        groupGrid.root:Hide()

        -- Simple screen-local staged-vs-live compare (DRAGUX-02, Open Question 3): for
        -- every OCCUPIED staged slot, its group (ceil(i/5)) must match that key's LIVE
        -- subgroup -- a one-directional check (staged -> live only), deliberately NOT
        -- RosterArrange's own all-mismatches verifier function (no coupling into that
        -- engine's internals). Good enough for an "arrange pending" glow heuristic;
        -- SetGroups' own apply path is the source of truth for the actual reorder.
        local function StagedDiffersFromLive(slots40, liveStatus)
            for i = 1, 40 do
                local key = slots40[i]
                if key then
                    local wantGroup = math.ceil(i / 5)
                    local entry = liveStatus[key]
                    if not entry or not entry.inRaid or entry.subgroup ~= wantGroup then
                        return true
                    end
                end
            end
            return false
        end

        -- Glow-until-acted-on (DRAGUX-02): the border is shown only for the duration of the
        -- pulse, same pattern as importBtn's RefreshImportGlow above.
        local function RefreshSetGroupsGlow(active)
            if active then
                setGroupsBtn:ShowBorder()
                WTUI:StartGlow(setGroupsBtn, "pendingApply", WTUI:BorderPulse())
            else
                WTUI:StopGlow(setGroupsBtn, "pendingApply")
                setGroupsBtn:HideBorder()
            end
        end

        -- reasonFn (DRAGUX-03): mirrors RefreshSetGroupsEnabled's own Enable/Disable
        -- condition below -- not-in-raid and no-staged-layout are its two real disabled
        -- states; lead/assist is NOT one of them (that check must stay a pure click-time
        -- backstop per WR-02 -- disabling the button natively for it would make the
        -- backstop unreachable).
        local function SetGroupsDisabledReason()
            if not IsInRaid() then
                return "You must be in a raid to set groups"
            end
            if not (selectedEnc and stagedByEncounter[selectedEnc.id]) then
                return "No staged layout to set — open Group View first"
            end
            return nil
        end

        -- Disabled-state recompute (APPLY-02): Enabled iff in a raid AND a staged layout
        -- exists for the selected encounter; Disabled otherwise (not-in-raid / no-staged-
        -- layout edge, mirrored by SetGroups' own click-time no-op guards below).
        -- Recomputed at the end of OpenGroupView (staged layout guaranteed present after
        -- the seed-if-absent step) and on every live GROUP_ROSTER_UPDATE while Group View
        -- is visible, since raid membership can change independently of a staged edit.
        RefreshSetGroupsEnabled = function()
            if not setGroupsBtn then return end
            if IsInRaid() and selectedEnc and stagedByEncounter[selectedEnc.id] then
                setGroupsBtn:Enable()
            else
                setGroupsBtn:Disable()
            end
            if setGroupsExplain then
                setGroupsExplain:EnableMouse(not setGroupsBtn:IsEnabled())
            end

            -- DRAGUX-02: glow while Group View is visible, a staged layout exists, AND it
            -- differs from the live raid -- piggybacked on this EXISTING recompute cadence
            -- (OpenGroupView + every live GROUP_ROSTER_UPDATE while visible, Pitfall 7),
            -- never a new poll loop.
            -- IsInRaid() mirrors the Enable condition above on purpose: out of a raid the
            -- live snapshot is empty, so StagedDiffersFromLive is trivially true and the
            -- button would sit there DISABLED and pulsing for a click it refuses to accept.
            local slots40 = selectedEnc and stagedByEncounter[selectedEnc.id]
            local shouldGlow = IsInRaid() and groupGridVisible and slots40 ~= nil
                and StagedDiffersFromLive(slots40, Private:SnapshotRaidStatus())
            RefreshSetGroupsGlow(shouldGlow)
        end

        -- Seed-once-per-encounter + full repaint (GROUP-01): reuses stagedByEncounter[id]
        -- on re-open/re-switch instead of re-seeding -- called from the toggle-on handler
        -- and from SelectChip (above) while Group View is already visible. NEVER called
        -- from the GROUP_ROSTER_UPDATE path (that is grid:UpdateStatus's job, Task 2).
        OpenGroupView = function()
            if not selectedEnc then return end
            local slots40 = stagedByEncounter[selectedEnc.id]
            if not slots40 then
                slots40 = {}
                local dualVis = {}
                SeedGroupsOneToFour(slots40, selectedEnc)
                SeedGroupsFiveToEight(slots40, BuildInvitelistKeys(selectedEnc), Private:SnapshotRaidStatus(), dualVis)
                stagedByEncounter[selectedEnc.id] = slots40
                dualVisibilityByEncounter[selectedEnc.id] = dualVis
            end
            groupGrid:SetLayout(slots40, BuildBenchList(slots40, dualVisibilityByEncounter[selectedEnc.id]))
            groupGrid:UpdateStatus(Private:SnapshotRaidStatus())
            RefreshSetGroupsEnabled()
        end

        -- "Set groups" (APPLY-02): the raid lead's one-click apply of the staged Group
        -- View layout. The ONLY reorder path here is the shared engine
        -- (Private:ApplyTargetPositions via Private:BuildTargetPositionsFromGroupSlots) --
        -- the exact same paced, combat-guarded drain "Invite & Arrange" uses. No raw
        -- SetRaidSubgroup/SwapRaidSubgroup call and no Safe* primitive call in this file.
        local function SetGroups()
            if not selectedEnc then return end
            if not stagedByEncounter[selectedEnc.id] then return end

            -- No early InCombatLockdown gate here (WR-02): persist pending_arrange
            -- unconditionally and let the shared engine's ProcessStep pend/auto-
            -- resume transparently via ARRANGE-05/06, exactly like the boss-button
            -- path (Private:ArrangeForEncounter) -- rather than hard-returning and
            -- relying on the raid lead to remember to press again after combat.

            if IsInRaid() and not UnitIsGroupLeader("player") and not UnitIsGroupAssistant("player") then
                print(("|cff99ff00Wild Things:|r %s"):format(
                    "You must be raid leader or assistant to set groups."))
                return
            end

            -- ARRANGE-06: persist the full staged layout (the only re-derivable
            -- reference for this path -- there is no smaller source, 18-RESEARCH
            -- Pitfall 2) before the shared engine apply, so a combat pend or a
            -- mid-arrange /reload can re-resolve this exact "Set groups" request.
            local layout = groupGrid:GetLayout()
            WildThingsSavedVars.roster.pending_arrange = { kind = "slots40", slots40 = layout, timestamp = time() }
            Private:ApplyTargetPositions(Private:BuildTargetPositionsFromGroupSlots(layout))
            runningTrigger = "setgroups"
            if RefreshOpButtons then RefreshOpButtons() end

            -- DRAGUX-02: pressing Set-groups is itself an "acted on" signal -- stop the
            -- pending-apply glow immediately rather than waiting for the live raid to
            -- visibly converge via the next GROUP_ROSTER_UPDATE-driven
            -- RefreshSetGroupsEnabled recompute (the async paced apply can take a moment).
            RefreshSetGroupsGlow(false)
        end

        -- "Reset" (DRAGUX-01, plan 22-04): discard manual (drag) edits and revert to the
        -- open-snapshot captured when Group View was opened (or last switched to this
        -- encounter) -- deterministic, stable revert-to-open-snapshot semantics, not a
        -- re-seed-from-invitelist. Restores a FRESH COPY of the snapshot into
        -- stagedByEncounter (never the snapshot table itself -- a later drag mutates
        -- stagedByEncounter[id] in place, which must never also corrupt the baseline
        -- copy) and repaints via the existing SetLayout entry point. Falls back to the
        -- legacy re-seed-from-invitelist path only if no snapshot exists yet (defensive --
        -- should not happen once the open/switch capture sites above are wired).
        local function ResetGroups()
            if not selectedEnc then return end
            local snapshot = openSnapshotByEncounter[selectedEnc.id]
            if snapshot then
                local slots40 = {}
                for i = 1, 40 do
                    slots40[i] = snapshot[i]
                end
                stagedByEncounter[selectedEnc.id] = slots40
                OnGroupLayoutChanged()
            else
                stagedByEncounter[selectedEnc.id] = nil
                dualVisibilityByEncounter[selectedEnc.id] = nil
                OpenGroupView() -- cache now nil -> re-seeds from invitelist/live raid + repaints
            end
        end

        local function SetGroupViewVisible(visible)
            groupGridVisible = visible
            if visible then
                scrollFrame:Hide()
                -- DRAGUX-04: searchBox stays visible in Group View now (re-anchored to its
                -- own fixed slot above) -- no more Hide() here.
                groupGrid.root:Show()
                setGroupsBtn:Show()
                resetGroupsBtn:Show()
                -- WR-01 (16-REVIEW.md): correct the enabled state immediately on show,
                -- don't rely on OpenGroupView's tail call or the next GROUP_ROSTER_UPDATE
                -- tick -- OpenGroupView early-returns before reaching RefreshSetGroupsEnabled
                -- when selectedEnc is nil, which would otherwise leave the button in its
                -- native-enabled default for the interim.
                RefreshSetGroupsEnabled()
                listViewBtn:SetActive(false)
                groupsViewBtn:SetActive(true)
                OpenGroupView()
                -- DRAGUX-01 (22-04): Group View just became visible -- establish the
                -- open-snapshot baseline Reset reverts to (cache declaration comment above).
                if selectedEnc then
                    openSnapshotByEncounter[selectedEnc.id] = groupGrid:GetLayout()
                end
                -- DRAGUX-04: re-apply whatever the box already contains against the
                -- freshly (re-)opened grid, so a query typed before switching views
                -- immediately highlights/recedes instead of waiting for the next keystroke.
                groupGrid:ApplySearchFilter(searchFilter)
            else
                groupGrid.root:Hide()
                scrollFrame:Show()
                setGroupsBtn:Hide()
                resetGroupsBtn:Hide()
                listViewBtn:SetActive(true)
                groupsViewBtn:SetActive(false)
                -- DRAGUX-02 (T-22-02): stop the pending-apply glow while Group View is
                -- hidden -- shouldGlow's own groupGridVisible gate only re-evaluates on
                -- the NEXT RefreshSetGroupsEnabled call, which would otherwise leave a
                -- lingering pulse animating a hidden button until then.
                RefreshSetGroupsGlow(false)
                -- DRAGUX-04: clear the grid's search highlight on leaving Group View so a
                -- stale receded/highlighted state doesn't persist to the next open.
                groupGrid:ApplySearchFilter("")
                if RepaintRows then
                    RepaintRows(Private:SnapshotRaidStatus())
                end
            end
        end

        -- Single SavedVars write site (VIEW-02): the only place
        -- WildThingsSavedVars.roster.view_mode is written. The restore call below only reads.
        local function SetViewMode(mode)
            WildThingsSavedVars.roster.view_mode = mode
            SetGroupViewVisible(mode == "groups")
        end

        -- "Set groups" anchors to root's top-right corner; the search box no longer shares
        -- this anchor point (DRAGUX-04 re-anchor below) and stays visible alongside it in
        -- Group View. Hidden at creation. Created before the view-toggle icons so those
        -- can anchor to this cluster (below).
        setGroupsBtn = namespace:CreateButton(root, "Set groups", SetGroups, { variant = "primary" })
        setGroupsBtn:SetPoint("TOPRIGHT", root, "TOPRIGHT", -Theme.PAD_WINDOW, actionRowY)
        setGroupsBtn:Hide()
        AttachStopTwin(setGroupsBtn, "setgroups")
        setGroupsExplain = AddDisabledExplanation(setGroupsBtn, SetGroupsDisabledReason)

        -- "Reset" sits just left of "Set groups", shown/hidden with it (Group View only).
        resetGroupsBtn = namespace:CreateButton(root, "Reset", ResetGroups, { icon = "refresh-cw" })
        resetGroupsBtn:SetPoint("RIGHT", setGroupsBtn, "LEFT", -Theme.GAP, 0)
        resetGroupsBtn:Hide()

        -- View-toggle icons anchor ONCE to the always-present Reset/Set-groups cluster (never
        -- to the search box), so they hold the exact same position in both views instead of
        -- shifting when the view changes. The group buttons keep their anchors and 28px size
        -- while hidden, so this reference stays put in List view too.
        groupsViewBtn = namespace:CreateIconButton(root, "users-round", function()
            SetViewMode("groups")
        end)
        groupsViewBtn:SetPoint("RIGHT", resetGroupsBtn, "LEFT", -Theme.GAP, 0)

        listViewBtn = namespace:CreateIconButton(root, "menu", function()
            SetViewMode("list")
        end)
        listViewBtn:SetPoint("RIGHT", groupsViewBtn, "LEFT", -Theme.GAP, 0)

        -- DRAGUX-04: the search box gets its OWN fixed-width slot immediately LEFT of the
        -- List/Group view-toggle icons, instead of stretching rightward into the
        -- Reset/Set-groups cluster's space (which those buttons only Hide/Show within,
        -- never vacate) -- the old anchor made the box and that cluster mutually
        -- exclusive by construction. listViewBtn/groupsViewBtn/resetGroupsBtn/
        -- setGroupsBtn all keep FIXED anchors regardless of which view is active, so
        -- this single re-anchor (done once, at construction, not toggled by
        -- SetGroupViewVisible) keeps the search box visible and non-overlapping in BOTH
        -- views.
        searchBox:ClearAllPoints()
        searchBox:SetPoint("TOPRIGHT", listViewBtn, "TOPLEFT", -Theme.GAP, 0)

        -- Restore (VIEW-02): once, at screen construction, mirroring this file's own
        -- "Rule 1 fix" precedent (explicit once-at-end call, not an OnShow hook -- this
        -- screen is built once and OnShow only fires on later tab-switches). Read-only.
        SetGroupViewVisible(WildThingsSavedVars.roster.view_mode == "groups")
    end

    -- Persistent combat gate (DRAGUX-01, plan 22-04): a single frame, created once here --
    -- this screen (CreateRosterScreen) builds only once per session, so this is
    -- effectively addon-lifetime for the life of the screen -- registered for
    -- PLAYER_REGEN_DISABLED/ENABLED. Mirrors RosterArrange.lua:1208-1211's
    -- combatResumeFrame / UI/WindowRaiser.lua:7,51-52's raiserFrame persistent-
    -- registration idiom. Private.gridCombatLocked is the exact flag 22-03's slot payload
    -- getter reads to block new drags (GroupGrid.lua's WTUI:MakeDraggable getPayload).
    -- Scoped to the DRAG gesture and grid-affordance refresh ONLY -- SetGroups()/
    -- ResetGroups() above gain NO InCombatLockdown gate (Pitfall 6): "Set groups" must
    -- keep persisting pending_arrange and letting the shared engine pend/auto-resume via
    -- ARRANGE-05/06, exactly as its existing WR-02 comment documents.
    Private.gridCombatLocked = false
    local combatGateFrame = CreateFrame("Frame")
    combatGateFrame:RegisterEvent("PLAYER_REGEN_DISABLED")
    combatGateFrame:RegisterEvent("PLAYER_REGEN_ENABLED")
    combatGateFrame:SetScript("OnEvent", function(_, event)
        Private.safecall(function()
        Private.gridCombatLocked = (event == "PLAYER_REGEN_DISABLED")
        if Private.gridCombatLocked then
            WTUI:CancelActiveDrag() -- force-cancel any in-flight drag the instant combat starts
            -- CR-01 (22-REVIEW.md): the mouse is very likely still resting on the slot
            -- it was dragged over when combat interrupts the drag -- neither that
            -- slot's OnLeave nor OnHide fires in that case, so its "dropTarget" glow
            -- would otherwise leak forever and keep GlowDriver's OnUpdate alive.
            if groupGrid then groupGrid:ClearDropTargetGlow() end
        end
        -- Refresh the grid affordances so the drag-lock/unlock takes effect immediately
        -- rather than waiting for the next unrelated GROUP_ROSTER_UPDATE tick; a cheap
        -- no-op when Group View isn't built or the button doesn't exist yet.
        if RefreshSetGroupsEnabled then RefreshSetGroupsEnabled() end
        end)
    end)

    -- WT_ROSTER_CHANGED subscriber (HYGIENE-04, plan 22-04): DOT syntax with a distinct
    -- string identity ("RosterScreenGridRefresh") -- NEVER colon syntax on Private (Pitfall
    -- 2 / Init.lua's own CallbackHandler comment: colon syntax makes self == Private and a
    -- later subscriber would silently overwrite this one). Refreshes the GRID ONLY, gated
    -- on groupGridVisible -- list-view RepaintRows/RebuildChips keep their existing inline
    -- call sites, untouched by this phase.
    Private.RegisterCallback("RosterScreenGridRefresh", "WT_ROSTER_CHANGED", function()
        if groupGrid and groupGridVisible and OpenGroupView then
            OpenGroupView()
        end
    end)

    -- Only the button that started the current op shows "Stop"; the others stay normal and
    -- are NOT disabled -- overlapping presses are already safe by engine design
    -- (InviteMissingFromTokens merges its queue, ApplyTargetPositions supersedes any
    -- in-flight arrange via arrangeGen) -- the last-clicked trigger simply owns the Stop.
    RefreshOpButtons = function()
        local running = Private:IsOperationRunning()
        if not running then runningTrigger = nil end
        for key, pair in pairs(triggerButtons) do
            if key == "setgroups" and not groupGridVisible then
                -- "Set groups" and its Stop twin live only in Group View; never surface
                -- either in List View, even while an op is running (the else branch below
                -- would otherwise Show() the src button on any unrelated running op).
                pair.src:Hide()
                pair.twin:Hide()
            elseif running and runningTrigger == key then
                pair.src:Hide()
                pair.twin:Show()
            else
                pair.twin:Hide()
                pair.src:Show()
            end
        end

        -- DRAGUX-03: Invite & Arrange requires raid lead or assist while in a raid --
        -- native-disable it here (the SAME site that already flips the Stop-twin state
        -- above) so the click-time backstop inside Private:ArrangeForEncounter is joined
        -- by a proactive hover explanation. Toggle the overlay's mouse-enable at this
        -- exact site too (Pitfall 8) -- never a separate poller.
        if IsInRaid() and not UnitIsGroupLeader("player") and not UnitIsGroupAssistant("player") then
            inviteArrangeBtn:Disable()
        else
            inviteArrangeBtn:Enable()
        end
        if inviteArrangeExplain then
            inviteArrangeExplain:EnableMouse(not inviteArrangeBtn:IsEnabled())
        end
        if inviteAllExplain then
            inviteAllExplain:EnableMouse(not inviteAllBtn:IsEnabled())
        end
    end

    -- Progress bar (PROG-01/02): mounted into the action row's middle gap, between the
    -- left action cluster (now ending at importBtn, IMPORT-01) and the right toggle/search
    -- cluster. TOPLEFT+RIGHT (not BOTTOMRIGHT) keeps the widget's own H_BTN height and
    -- yields a flexible bar spanning that gap, present in both List and Group views without
    -- touching the scrollFrame / groupGrid geometry.
    progressBar = namespace:CreateProgressBar(root)
    progressBar:SetPoint("TOPLEFT", importBtn, "TOPRIGHT", Theme.GAP, 0)
    -- searchBox now always sits immediately left of the toggle-icon cluster (DRAGUX-04
    -- re-anchor above) -- or, when groupGrid is nil, is the rightmost element itself --
    -- so it is always the correct right-edge reference for the progress bar's middle-gap
    -- fill, regardless of whether Group View exists in this build.
    progressBar:SetPoint("RIGHT", searchBox, "LEFT", -Theme.GAP, 0)
    progressBar:Hide()

    UpdateProgressUI = function()
        local prog = Private:GetOperationProgress()
        if prog.running then
            progressBar:SetValue(prog.total and prog.total > 0 and prog.done / prog.total or 0, prog.label)
            progressBar:Show()
        else
            progressBar:Hide()
        end
        RefreshOpButtons()
    end

    -- StatusBadge: a small pill (dot + label) with a tooltip. Screen-local per 12-CONTEXT
    -- Area 2 (not promoted to UI/Widgets/StatusBadge.lua).
    local function MakeStatusBadge(parent)
        local badge = CreateFrame("Frame", nil, parent)
        badge:SetHeight(18)

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

        badge.dot = badge:CreateTexture(nil, "ARTWORK")
        badge.dot:SetSize(6, 6)
        badge.dot:SetPoint("LEFT", badge, "LEFT", 7, 0)

        badge.label = badge:CreateFontString(nil, "ARTWORK")
        badge.label:SetFontObject(WildThingsFont13)
        badge.label:SetPoint("LEFT", badge.dot, "RIGHT", 5, 0)

        function badge:SetStatus(status)
            local meta = STATUS_META[status] or STATUS_META.missing
            local c = meta.color
            badge.dot:SetColorTexture(c.r, c.g, c.b, c.a)
            badge.bg:SetColorTexture(c.r, c.g, c.b, 0.13)
            badge.label:SetText(meta.label)
            badge.label:SetTextColor(c.r, c.g, c.b, c.a)
            badge:SetWidth(badge.label:GetStringWidth() + 7 + 5 + 8)
            WTUI:AddTooltip(badge, meta.label, meta.tip)
        end

        return badge
    end

    -- DATA-02 dual source: in-raid rows read straight from the live SnapshotRaidStatus
    -- sweep; not-in-raid rows fall back to RosterAlts.charInfo. Never a hard nil-index --
    -- a row with neither source still renders (Theme.GetClassColor defaults to white).
    local function ResolveClassRole(key, liveStatus)
        local entry = liveStatus[key]
        if entry and entry.inRaid then
            return entry.class, entry.role
        end
        local fallback = Private.RosterAlts and Private.RosterAlts.charInfo and Private.RosterAlts.charInfo[key]
        if fallback then
            return fallback.class, fallback.role
        end
        return nil, nil
    end

    -- Status precedence (12-CONTEXT Area 2): bench overrides in/missing; otherwise live
    -- in-raid presence; otherwise the client-local optimistic "invited" flag (populated by
    -- the invite flows in this file); otherwise missing.
    local function DeriveStatus(key, liveStatus, invitelistKeys)
        if not invitelistKeys[key] then
            return "bench"
        end
        local entry = liveStatus[key]
        if entry and entry.inRaid then
            if Private.inviteFailures then Private.inviteFailures[key] = nil end -- joined: drop any stale failure
            return entry.online and "in" or "offline"
        end
        -- A definite invite failure reported by the engine (RosterArrange) outranks the
        -- optimistic "invited" state -- show the real reason instead of a stuck "Invited…".
        local fail = Private.inviteFailures and Private.inviteFailures[key]
        if fail then
            return fail
        end
        if invitedFlags[key] then
            if (GetTime() - invitedFlags[key]) < INVITE_PENDING_TTL then
                return "invited"
            end
            invitedFlags[key] = nil -- pending invite expired without a join: allow retry
        end
        return "missing"
    end

    -- Create-once row: role icon + class-colored name + StatusBadge + a right-aligned
    -- action slot holding the per-row Invite/Swap buttons.
    local function MakeRow(parent)
        local row = CreateFrame("Frame", nil, parent)
        row:SetHeight(Theme.H_ROW)

        row.icon = namespace:CreateIcon(row, "swords", Theme.ICON_SM)
        row.icon:SetPoint("LEFT", row, "LEFT", Theme.GAP, 0)

        row.name = row:CreateFontString(nil, "ARTWORK")
        row.name:SetFontObject(WildThingsFont15)
        row.name:SetWidth(150)
        row.name:SetJustifyH("LEFT")
        row.name:SetPoint("LEFT", row.icon, "RIGHT", Theme.GAP, 0)

        row.badge = MakeStatusBadge(row)
        row.badge:SetPoint("LEFT", row.name, "RIGHT", Theme.GAP_LG, 0)

        row.actionSlot = CreateFrame("Frame", nil, row)
        row.actionSlot:SetPoint("RIGHT", row, "RIGHT", -Theme.GAP, 0)
        row.actionSlot:SetSize(1, Theme.H_ROW)

        -- Right-click: Lead/Assist promote menu. Engine-gated (RosterArrange
        -- ShowPlayerPromoteMenu is a silent no-op unless the local player is the raid
        -- lead and this row's player is a live raid member below leader rank).
        row:EnableMouse(true)
        row:SetScript("OnMouseUp", function(_, button)
            if button == "RightButton" then
                Private:ShowPlayerPromoteMenu(row, row.key, row.displayName)
            end
        end)

        function row:Bind(displayName, key, status, className, roleStem)
            if roleStem then
                row.icon:SetTexture(Theme.GetIconPath(roleStem))
                row.icon:Show()
            else
                row.icon:Hide()
            end

            row.name:SetText(displayName)
            row.displayName = displayName -- raw invitelist token, needed by PopulateRowAction's Invite call
            row.key = key -- canonical key, needed by the right-click promote menu
            local nameColor = (status == "offline") and Theme.TEXT_DIM or Theme.GetClassColor(className)
            row.name:SetTextColor(nameColor.r, nameColor.g, nameColor.b, nameColor.a)

            row.badge:SetStatus(status)
        end

        return row
    end

    -- Screen-local: canonical key of the bench player currently picked for swap-in, or nil
    -- (ROSTER-04). Cleared on Cancel, on a successful SwapBenchPlayer exchange, and never
    -- persisted across screen rebuilds.
    local swapping = nil

    -- Per-row action buttons (ROSTER-04): created ONCE per row and cached on the row
    -- (row.inviteBtn/swapInBtn/swapOutBtn/cancelBtn), never CreateButton'd again on repaint
    -- -- only Show/Hide and OnClick are re-pointed each call. Bench "Swap in" and active
    -- "Swap out" get their OWN cached button (rather than one shared field) because
    -- Button.lua bakes a fixed hover/variant closure at creation time with no runtime
    -- variant-swap API -- reusing a single button across the primary/default variants would
    -- either lose the correct hover styling or require duplicating Button.lua's internals.
    -- The bench swap-in/out exchange goes through Private:SwapBenchPlayer ONLY -- no raw
    -- SetRaidSubgroup/SwapRaidSubgroup call in this file.
    local function PopulateRowAction(row, key, status)
        if not row.inviteBtn then
            row.inviteBtn = namespace:CreateButton(row.actionSlot, "Invite", function() end, { icon = "plus" })
            row.inviteBtn:SetPoint("RIGHT", row.actionSlot, "RIGHT", 0, 0)
        end
        if not row.swapInBtn then
            row.swapInBtn = namespace:CreateButton(row.actionSlot, "Swap in", function() end,
                { variant = "primary", icon = "arrow-up-down" })
            row.swapInBtn:SetPoint("RIGHT", row.actionSlot, "RIGHT", 0, 0)
        end
        if not row.swapOutBtn then
            row.swapOutBtn = namespace:CreateButton(row.actionSlot, "Swap out", function() end,
                { icon = "arrow-up-down" })
            row.swapOutBtn:SetPoint("RIGHT", row.actionSlot, "RIGHT", 0, 0)
        end
        if not row.cancelBtn then
            row.cancelBtn = namespace:CreateButton(row.actionSlot, "Cancel", function() end,
                { variant = "danger", icon = "x" })
            row.cancelBtn:SetPoint("RIGHT", row.actionSlot, "RIGHT", 0, 0)
        end

        row.inviteBtn:Hide()
        row.swapInBtn:Hide()
        row.swapOutBtn:Hide()
        row.cancelBtn:Hide()

        if status == "bench" then
            if swapping == key then
                row.cancelBtn:SetScript("OnClick", function()
                    swapping = nil
                    if RepaintRows then
                        RepaintRows(Private:SnapshotRaidStatus())
                    end
                end)
                row.cancelBtn:Show()
            else
                row.swapInBtn:SetScript("OnClick", function()
                    swapping = key
                    if RepaintRows then
                        RepaintRows(Private:SnapshotRaidStatus())
                    end
                end)
                row.swapInBtn:Show()
            end
        elseif swapping ~= nil and (status == "in" or status == "offline") then
            row.swapOutBtn:SetScript("OnClick", function()
                if Private:SwapBenchPlayer(swapping, key) then
                    swapping = nil
                end
                if RepaintRows then
                    RepaintRows(Private:SnapshotRaidStatus())
                end
            end)
            row.swapOutBtn:Show()
        elseif status == "missing" or status == "notfound" or status == "declined" or status == "ingroup" then
            row.inviteBtn:SetScript("OnClick", function()
                Private:InviteMissingFromTokens({ row.displayName })
                MarkInvited(key)
                if RepaintRows then
                    RepaintRows(Private:SnapshotRaidStatus())
                end
            end)
            row.inviteBtn:Show()
        end
    end

    -- Rostered-player universe: union of every parsed encounter's invitelist, built once
    -- (the search filter and per-chip bench/active split are recomputed on top of it).
    local rosterRows = BuildUnionRows()

    -- IMPORT-01/02 post-capture refresh (called from the Import popup's button once
    -- Private:CaptureRosterImport has already validated, parsed and persisted): the real
    -- body -- assigned here (not where the button was wired) because it must reassign the
    -- rosterRows upvalue just declared above and call RepaintRows below; Lua's lexical
    -- scoping requires both locals to already be declared at this point in the source
    -- text. Takes no argument any more: the text never reaches this screen, only the
    -- fact that Private.parsedRosterExport has been replaced.
    HandleImportCapture = function()
        -- CR-01 (21-REVIEW): invalidate every staged Group View layout on re-import --
        -- stagedByEncounter/dualVisibilityByEncounter are seed-once-per-encounter-id caches
        -- and a re-import can silently reuse the previous import's stale keys under the same
        -- encounter id (e.g. a raid lead re-pasting an updated roster export mid-raid). Without
        -- this, "Set groups" could apply the OLD roster's layout to the live raid.
        -- openSnapshotByEncounter (22-04) is wiped for the same reason -- a stale
        -- open-snapshot baseline under a reused encounter id would let Reset revert to the
        -- OLD import's layout instead of the freshly re-seeded one.
        wipe(stagedByEncounter)
        wipe(dualVisibilityByEncounter)
        wipe(openSnapshotByEncounter)

        -- In-place refresh (RESEARCH Pitfall 2): reset selection BEFORE rebuilding the
        -- chips -- RebuildChips reads selectedIndex at chip-creation time to mark the
        -- selected chip, so it must run after the reset, not before. Never Shell.lua
        -- RegisterScreen/ShowScreen.
        selectedIndex = 1
        selectedEnc = Private.parsedRosterExport[1]
        RebuildChips()
        rosterRows = BuildUnionRows()
        if RepaintRows then
            RepaintRows(Private:SnapshotRaidStatus())
        end

        -- CR-01 / HYGIENE-04 (22-04): the visible Group View grid re-seeds from the now-
        -- empty caches above via the WT_ROSTER_CHANGED subscriber, never a point-to-point
        -- OpenGroupView call from here. CaptureRosterImport fires that event immediately
        -- after this function returns -- see its `onApplied` contract for why the wipes
        -- above must happen first.

        -- DRAGUX-02: presence just changed (parsedRosterExport was reassigned by
        -- CaptureRosterImport) -- recompute the import glow now rather than waiting for
        -- the next screen build.
        RefreshImportGlow()
    end

    RepaintRows = function(liveStatus)
        local invitelistKeys = BuildInvitelistKeys(selectedEnc)

        local visible = {}
        local missingCount = 0
        for _, r in ipairs(rosterRows) do
            local entry = liveStatus[r.key]
            -- Accepted invite confirmation: drop the optimistic flag the moment the live
            -- snapshot actually shows this key in raid (12-CONTEXT Area 3).
            if entry and entry.inRaid then
                invitedFlags[r.key] = nil
            end
            if invitelistKeys[r.key] then
                if not (entry and entry.inRaid) then
                    missingCount = missingCount + 1
                end
            end
            if searchFilter == "" or r.displayName:lower():find(searchFilter, 1, true) then
                visible[#visible + 1] = r
            end
        end

        if reinviteBtn then
            reinviteBtn:SetText(("Re-invite missing (%d)"):format(missingCount))
        end

        local y = 0
        for i, r in ipairs(visible) do
            local row = rowPool[i]
            if not row then
                row = MakeRow(scrollFrame.contentFrame)
                rowPool[i] = row
            end
            local status = DeriveStatus(r.key, liveStatus, invitelistKeys)
            local className, role = ResolveClassRole(r.key, liveStatus)
            row:Bind(r.displayName, r.key, status, className, role and ROLE_ICON[role])
            row:ClearAllPoints()
            row:SetPoint("TOPLEFT", scrollFrame.contentFrame, "TOPLEFT", 0, -y)
            row:SetPoint("TOPRIGHT", scrollFrame.contentFrame, "TOPRIGHT", 0, -y)
            row:Show()
            PopulateRowAction(row, r.key, status)
            y = y + Theme.H_ROW
        end

        for i = #visible + 1, #rowPool do
            rowPool[i]:Hide()
        end

        scrollFrame.contentFrame:SetWidth(scrollFrame:GetWidth() or 1)
        scrollFrame.contentFrame:SetHeight(math.max(y, 1))
        scrollFrame:FullUpdate()
    end

    -- Lets the invite engine (RosterArrange) ask this screen to repaint the moment an invite
    -- failure is recorded, so the row flips to "Not found"/"Declined"/"In group" at once
    -- instead of waiting for the next roster event or the "invited" TTL.
    Private.NotifyInviteStatusChanged = function()
        if RepaintRows then RepaintRows(Private:SnapshotRaidStatus()) end
    end

    -- Lets the engine drains (RosterArrange) push live progress to this screen's bar and
    -- Stop-button state the moment either drain ticks, converges, or is cancelled.
    Private.NotifyProgressChanged = function()
        UpdateProgressUI()
    end

    -- Wire the live event frame's handler and the root's show/hide lifecycle now that
    -- RepaintRows is assigned. OnShow registers GROUP_ROSTER_UPDATE and repaints
    -- immediately; OnHide unregisters -- so this screen only listens while visible
    -- (T-12-08). Handles the shell's later hide (close button)/show (window:Show())
    -- transitions.
    -- Named handler (Pattern 3 static wrapper, 24-RESEARCH.md): extracted so the
    -- SetScript callback below can be a wrap-once closure that forwards through
    -- the 24-01 error-containment helper, instead of allocating a fresh closure
    -- on every GROUP_ROSTER_UPDATE tick while Group View is open live.
    local function OnRosterScreenEvent(_, event)
        local liveStatus = Private:SnapshotRaidStatus()
        RepaintRows(liveStatus)
        -- GROUP-05's load-bearing repaint-split boundary: a SECOND, separate dispatch,
        -- status-only -- never routed through RepaintRows and never grid:SetLayout. This
        -- is what lets the staged 40-slot layout survive any live raid-roster change
        -- while Group View is open (14-RESEARCH.md Pitfall 1).
        if groupGrid and groupGridVisible then
            -- Leader handoff (PARTY_LEADER_CHANGED): WoW re-pins the new leader to slot 1 of
            -- their group, so re-snap the staged layout to match immediately (a deliberate
            -- position change, unlike the status-only GROUP_ROSTER_UPDATE path) rather than
            -- waiting for a manual edit -- OnGroupLayoutChanged normalizes + repaints.
            if event == "PARTY_LEADER_CHANGED" then
                OnGroupLayoutChanged()
            end
            groupGrid:UpdateStatus(liveStatus)
            -- APPLY-02: raid membership (IsInRaid) can change independently of a staged
            -- edit -- recompute setGroupsBtn's Enable/Disable on every live update, not
            -- just at OpenGroupView.
            RefreshSetGroupsEnabled()
        end
    end
    rosterScreenEventFrame:SetScript("OnEvent", function(self, event, ...)
        Private.safecall(OnRosterScreenEvent, self, event, ...)
    end)
    root:SetScript("OnShow", function()
        rosterScreenEventFrame:RegisterEvent("GROUP_ROSTER_UPDATE")
        rosterScreenEventFrame:RegisterEvent("PARTY_LEADER_CHANGED")
        RepaintRows(Private:SnapshotRaidStatus())
        RefreshImportGlow()
    end)
    root:SetScript("OnHide", function()
        rosterScreenEventFrame:UnregisterEvent("GROUP_ROSTER_UPDATE")
        rosterScreenEventFrame:UnregisterEvent("PARTY_LEADER_CHANGED")
        -- Drag-leak backstop (WR-01 review fix, 16-REVIEW.md): force-cancel any
        -- in-flight drag if this screen's own root is hidden directly while a drag is
        -- mid-gesture.
        WTUI:CancelActiveDrag()
        -- CR-01 (22-REVIEW.md): this root is an ANCESTOR of groupGrid.root, so hiding
        -- it here does NOT propagate OnHide down to groupGrid.root (WoW's frame model
        -- never cascades OnHide to descendants) -- groupGrid's own OnHide backstop
        -- never fires on this path. Without this, a hover-triggered "dropTarget" glow
        -- on a still-mouse-over slot would leak forever when the user tab-switches
        -- away from Roster mid-drag-hover.
        if groupGrid then groupGrid:ClearDropTargetGlow() end
    end)

    -- Initial registration + paint (Rule 1 fix): a freshly created frame is already in the
    -- "shown" state when its ancestor chain (contentHost/shell window) is visible, so WoW
    -- never fires OnShow for it at creation time -- OnShow only fires on an explicit
    -- hidden->shown transition. Without this, the very first roster screen open would build
    -- the screen but never register the live-refresh listener. Mirrors this file's existing
    -- pattern (12-03) of an explicit initial RepaintRows call rather than relying on OnShow
    -- for the first paint.
    rosterScreenEventFrame:RegisterEvent("GROUP_ROSTER_UPDATE")
    rosterScreenEventFrame:RegisterEvent("PARTY_LEADER_CHANGED")
    RepaintRows(Private:SnapshotRaidStatus())
    -- Bar starts hidden and the trigger buttons start in their normal (non-Stop) state.
    UpdateProgressUI()

    return root
end
