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

local Theme = namespace.Theme
local BORDER_STRONG = Theme.BORDER_STRONG

-- Private and namespace are the SAME shared table (select(2, ...) everywhere else in the
-- codebase) -- this alias makes engine/data reads read as Private:CanonicalKey /
-- Private.RosterAlts / Private.parsedRosterExport, matching the codebase's existing
-- engine-call convention (Roster.lua, RosterArrange.lua, RosterScreen.lua).
local Private = namespace
local WTUI = namespace.WildThingsUI

-- Role -> role-icon stem (screens.jsx ROLE_ICON). Screen-local composite, copied verbatim
-- from RosterScreen.lua per CONTEXT (identical badges/icons across both views).
local ROLE_ICON = { TANK = "shield", HEALER = "heart", DAMAGER = "swords" }

-- Status -> label/color/tooltip (screens.jsx STATUS_META), copied verbatim from
-- RosterScreen.lua -- CONTEXT requires visually identical badges between list and grid.
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." },
    lead = { label = "Lead", color = Theme.WARN, tip = "Raid leader." },
}

-- StatusBadge factory, copied-verbatim-in-shape from RosterScreen.lua's MakeStatusBadge,
-- extended with an opts.small variant (18px height, 11px font, no tooltip) for the 28px
-- slot rows and bench chips per CONTEXT's grid slot composition.
local function MakeStatusBadge(parent, opts)
    opts = opts or {}
    local small = opts.small

    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", small and 6 or 7, 0)

    badge.label = badge:CreateFontString(nil, "ARTWORK")
    badge.label:SetFontObject(small and WildThingsFont11 or 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)
        local leftPad = small and 6 or 7
        badge:SetWidth(badge.label:GetStringWidth() + leftPad + 5 + (small and 6 or 8))
        if not small then
            WTUI:AddTooltip(badge, meta.label, meta.tip)
        end
    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 --
-- copied verbatim from RosterScreen.lua's ResolveClassRole per CONTEXT.
local function ResolveClassRole(key, liveStatus)
    local entry = liveStatus and 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

-- Maps a statusByKey entry (Private:SnapshotRaidStatus() shape) to the shared badge
-- vocabulary. This grid has NO invitedFlags of its own -- that client-local optimistic
-- "invited" state stays screen-local to RosterScreen (list view only, ROSTER-04 lineage).
-- A key not yet confirmed present in the live snapshot simply reads "missing" here; a
-- nil/absent key is guarded, never a hard index error.
local function DeriveOccupantStatus(key, statusByKey)
    if not key then return "missing" end
    local entry = statusByKey and statusByKey[key]
    if entry and entry.inRaid then
        if not entry.online then return "offline" end
        return entry.isLead and "lead" or "in"
    end
    return "missing"
end

-- BuildSlotTooltipText(slot) (DRAGUX-03): builds the hover-tooltip info string from
-- the slot's CURRENT occupant fields (slot.displayName/className/status, set every
-- slot:Bind call). Passed as a zero-arg function to WTUI:ShowWidgetTooltip, which
-- re-evaluates it at show time -- so it always reflects the latest Bind, never a
-- stale snapshot captured at hover-start. Empty slots (no key) return nil, which
-- ShowWidgetTooltip's own guard clause treats as "show nothing".
local function BuildSlotTooltipText(slot)
    if not slot.key then return nil end
    local meta = STATUS_META[slot.status] or STATUS_META.missing
    local className = slot.className or "Unknown"
    return ("%s\n%s — %s"):format(slot.displayName or slot.key, className, meta.label)
end

-- ResolveSlotAlpha/ApplySlotAlpha (DRAGUX-01/04): the single writer for whole-slot
-- SetAlpha so hover, search-recede, and (via WTUI:MakeDraggable's dimAlpha option)
-- drag-in-flight states compose through one clear precedence instead of racing
-- competing SetAlpha callers. Drag-in-flight itself is handled directly by Drag.lua's
-- dimAlpha (passed as Theme.SLOT_ALPHA_DRAG below), not by this resolver -- it is
-- transient and always wins visually while active; this resolver governs the
-- rest/hover/search-recede baseline the slot returns to once the drag ends.
-- WR-01 (review fix): drag-source-aware guard added so this resolver never clobbers
-- Drag.lua's own dimAlpha while its own gesture is in flight -- the ordinary
-- OnEnter/OnLeave hover-fade pair (also wired on every slot) is not gated on "is this
-- frame the current drag source", so without this check the very first OnLeave fired
-- by the mouse leaving the slot to carry the drag elsewhere immediately reset the
-- alpha back to rest/hover for the entire remaining duration of the drag.
local function ResolveSlotAlpha(slot)
    local drag = WTUI:GetActiveDrag()
    if drag and drag.source == slot then
        return nil -- MakeDraggable owns alpha for the duration of its own drag
    end
    if slot.searchReceded then
        return Theme.SEARCH_RECEDE_ALPHA
    end
    -- A confirmed-present occupant (in raid / lead) reads at full strength at rest, so the
    -- rest-dim only ever marks absence (empty / missing / offline) -- never a player who is
    -- actually in the raid. Hover still lifts a dimmed slot toward SLOT_ALPHA_HOVER, but a
    -- present slot is already brighter than that, so it stays put on hover.
    local present = slot.status == "in" or slot.status == "lead"
    if slot.hovered then
        return present and Theme.SLOT_ALPHA_PRESENT or Theme.SLOT_ALPHA_HOVER
    end
    return present and Theme.SLOT_ALPHA_PRESENT or Theme.SLOT_ALPHA_REST
end

local function ApplySlotAlpha(slot)
    local a = ResolveSlotAlpha(slot)
    if a then slot:SetAlpha(a) end
end

-- ApplySlotBorder (WR-01/WR-02 review fix): the single writer for a slot's border
-- Show/Hide state AND its glow-driver registration, mirroring ApplySlotAlpha's
-- precedence pattern above. Before this, drag-hover ("dropTarget") and search-match
-- ("search") were two independent, uncoordinated writers to the same border: OnLeave
-- would unconditionally hide a still-matching search highlight (WR-01), and a slot
-- that was simultaneously an active search match AND a drop-target candidate would
-- have two competing BorderPulse closures fighting over the same border color every
-- tick (WR-02). dropTarget always wins while a drag is hovering the slot; the
-- search-match highlight owns the border otherwise; neither applies, border hides.
local function ApplySlotBorder(slot)
    if slot.dragHover then
        slot:SetBorderColor(Theme.ACCENT_A70.r, Theme.ACCENT_A70.g, Theme.ACCENT_A70.b, Theme.ACCENT_A70.a)
        slot:ShowBorder()
        WTUI:StopGlow(slot, "search")
        WTUI:StartGlow(slot, "dropTarget", WTUI:BorderPulse())
    elseif slot.searchMatched then
        slot:ShowBorder()
        WTUI:StopGlow(slot, "dropTarget")
        WTUI:StartGlow(slot, "search", WTUI:BorderPulse())
    else
        slot:HideBorder()
        WTUI:StopGlow(slot, "dropTarget")
        WTUI:StopGlow(slot, "search")
    end
end

-- FuzzyScore(haystack, needle) (DRAGUX-04): pure subsequence-match scorer with a
-- substring boost and a span-rejection guard. The plain=true
-- flag on haystack:find is mandatory -- user-typed search text must never be
-- interpreted as a Lua pattern (T-22-04).
local function FuzzyScore(haystack, needle)
    local subIdx = haystack:find(needle, 1, true)
    if subIdx then
        return 10000 - subIdx -- exact substring always outranks a subsequence match
    end
    local hLen, nLen = #haystack, #needle
    local hi, ni = 1, 1
    local firstMatch, lastMatch = nil, nil
    local consecutiveRun = 0
    local score = 0
    while hi <= hLen and ni <= nLen do
        if haystack:byte(hi) == needle:byte(ni) then
            if not firstMatch then firstMatch = hi end
            lastMatch = hi
            consecutiveRun = consecutiveRun + 1
            score = score + consecutiveRun
            ni = ni + 1
        else
            consecutiveRun = 0
        end
        hi = hi + 1
    end
    if ni <= nLen then return nil end -- not every needle char found in order
    local span = lastMatch - firstMatch + 1
    if span > nLen + 8 then return nil end -- reject overly sparse matches
    return (score * 100) / span
end

-- namespace:CreateGroupGrid(parent)
-- 8x5 pooled group grid (GROUP-01): 8 group cards (4 cols x 2 rows) of 5 slot rows each,
-- plus a bench chip pool, built ONCE at construction. Pure renderer: no game-event
-- registration, no live-mutating engine call (no SwapRaidSubgroup/SetRaidSubgroup/Safe*),
-- staged occupancy only ever changes via SetLayout's input argument (GROUP-05 boundary).
-- SetLayout (positions) and UpdateStatus (status badges + offline name-dim only) are two
-- structurally separate entry points from day one -- see RESEARCH.md Pattern 3.
function namespace:CreateGroupGrid(parent, onLayoutChanged)
    local root = CreateFrame("Frame", nil, parent)
    root:SetPoint("TOPLEFT", parent, "TOPLEFT")
    root:SetPoint("BOTTOMRIGHT", parent, "BOTTOMRIGHT")

    -- Pooled frames, built exactly once below; SetLayout/UpdateStatus only ever
    -- Bind()/Show()/Hide() these, per UI/Motion.lua's CREATE-ONCE CONTRACT.
    local groupCards = {}   -- [1..8]
    local slotFrames = {}   -- [1..40]
    local benchPool = {}    -- acquire/release, mirrors RosterScreen's rowPool idiom

    -- The grid's own copy of the staged layout, set only by SetLayout -- load-bearing
    -- name for the GROUP-05 structural verify gate (never written inside UpdateStatus).
    local currentSlots40 = {}
    -- Most recent live snapshot handed to UpdateStatus, reused by SetLayout for the
    -- dual-source class/role resolve when a seed happens without a fresh UpdateStatus
    -- call first (nil-safe: ResolveClassRole/DeriveOccupantStatus both guard a nil map).
    local lastStatusByKey = {}

    -- Fuzzy search index (DRAGUX-04): [absIdx] = { slot = slotFrame, haystack = "..." },
    -- registered inside slot:Bind (NOT at MakeSlot construction, Pitfall 3) so it
    -- self-corrects on every SetLayout/drag/Reset.
    local searchIndex = {}

    -- The single slot (at most one, since the mouse can only hover one frame at a
    -- time) currently holding the drag-hover "dropTarget" border/glow, tracked so
    -- grid:ClearDropTargetGlow (CR-01 review fix) can force-clear it without a full
    -- 40-slot sweep whenever a drag is cancelled out from under a still-hovered slot
    -- (combat-gate mid-drag, or an ancestor hide that never reaches this slot's own
    -- OnLeave/OnHide -- see WR-03).
    local dragHoverSlot = nil

    -- Grid object handle, declared here (before MakeSlot below) so slot:Bind's
    -- closure can call grid:RegisterSearchEntry -- the grid:* methods are attached to
    -- this same table further down, after construction. slot:Bind is only ever
    -- CALLED later (via SetLayout), by which point every method below is attached.
    local grid = { root = root }

    -- Drag-leak backstop (review fix, previously 16-REVIEW.md's WR-01): Drag.lua's
    -- per-frame OnHide backstop only fires when the exact drag-origin frame is hidden
    -- directly -- WoW's frame model does not propagate OnHide to descendants when an
    -- ancestor hides. Force-cancel any in-flight drag whenever this grid root is
    -- hidden, which covers both the view-toggle-away path (RosterScreen's
    -- SetGroupViewVisible(false) calls root:Hide() directly) and any other ancestor
    -- hide that reaches this root frame directly. Also sweeps the drop-target glow
    -- (CR-01, 22-REVIEW.md): the mouse never physically leaving the hovered slot means
    -- neither its OnLeave nor (per WR-03) its OnHide fires on this path, so without
    -- this the "dropTarget" glow would keep GlowDriver's OnUpdate alive forever.
    root:HookScript("OnHide", function()
        WTUI:CancelActiveDrag()
        grid:ClearDropTargetGlow()
    end)

    -- HandleSlotDrop(sourceIdx, targetIdx): staged-local swap only (GROUP-02) -- in-place
    -- index swap of currentSlots40; the degenerate slot->empty move is the same swap (nil
    -- moves correctly). Defensive nil/equal-index guard; the drop resolver already excludes
    -- the source frame so sourceIdx == targetIdx is not expected in practice.
    local function HandleSlotDrop(sourceIdx, targetIdx)
        if not sourceIdx or not targetIdx or sourceIdx == targetIdx then return end
        currentSlots40[sourceIdx], currentSlots40[targetIdx] = currentSlots40[targetIdx], currentSlots40[sourceIdx]
        if onLayoutChanged then onLayoutChanged() end
    end

    -- HandleBenchDrop(benchKey, targetIdx): staged-local swap-in only (GROUP-03) -- a
    -- full 40-index scan clears any prior occurrence of benchKey first (unique-key
    -- invariant, covers the dual-visibility groups-5-8 case), then assigns targetIdx. The
    -- displaced occupant at targetIdx (if any) simply falls out of currentSlots40 --
    -- RosterScreen's BuildBenchList recomputes it back onto the bench on the next
    -- onLayoutChanged repaint; no explicit "return to bench" call is made here.
    local function HandleBenchDrop(benchKey, targetIdx)
        if not benchKey or not targetIdx then return end
        for i = 1, 40 do
            if currentSlots40[i] == benchKey then currentSlots40[i] = nil end
        end
        currentSlots40[targetIdx] = benchKey
        if onLayoutChanged then onLayoutChanged() end
    end

    local CARD_HEADER_H = 24
    local SLOT_H = 28
    local SLOT_GAP = 2
    local CARD_PAD = 4
    local CARD_GAP = 10
    local CARD_COLS = 4
    local CARD_HEIGHT = CARD_HEADER_H + CARD_PAD * 2 + 5 * SLOT_H + 4 * SLOT_GAP
    local SLOT_ICON = 12
    local CHIP_ICON = 12
    local CHIP_H = 26

    -- Static layout width, computed once at construction (mirrors RosterScreen's
    -- contentHost:GetWidth() fallback guard for a not-yet-sized ancestor).
    local availWidth = parent:GetWidth()
    if not availWidth or availWidth <= 0 then
        availWidth = 760
    end
    local gridWidth = availWidth - 2 * Theme.PAD_WINDOW
    local cardWidth = (gridWidth - (CARD_COLS - 1) * CARD_GAP) / CARD_COLS
    local slotWidth = cardWidth - 2 * CARD_PAD

    -- Canonical key -> best-known display token, built from the union of every parsed
    -- encounter's invitelist -- a plain READ of the shared Private.parsedRosterExport
    -- table (not an engine call), same union source RosterScreen's BuildUnionRows draws
    -- from. A key with no invitelist-token match (a genuine non-invitelist raid member)
    -- falls back to FallbackDisplayName below -- lossy but always safe, never a hard
    -- nil-index. Screen-local duplication of the small TokenToKey shape, matching the
    -- same "screen-local copy, not promoted" precedent already used for MakeStatusBadge/
    -- ResolveClassRole above.
    local function BuildDisplayNameByKey()
        local map = {}
        if Private.parsedRosterExport then
            for _, enc in ipairs(Private.parsedRosterExport) do
                for _, token in ipairs(enc.invitelist) do
                    local name, realm = token:match("^(.-)%-(.+)$")
                    local key = Private:CanonicalKey(name or token, realm or GetNormalizedRealmName())
                    if not map[key] then
                        map[key] = token
                    end
                end
            end
        end
        return map
    end

    local function FallbackDisplayName(key)
        if not key or key == "" then return "" end
        local name = key:match("^(.-)%-") or key
        if name == "" then return key end
        return name:sub(1, 1):upper() .. name:sub(2)
    end

    -- Create-once pooled slot (RESEARCH Pattern 2): a 28px row with role icon, class-
    -- colored truncated name, small status badge, odd/even striping, and an
    -- "Empty" fallback text. Hover-fade wired here, exactly once, never from SetLayout/
    -- UpdateStatus (CREATE-ONCE CONTRACT).
    local function MakeSlot(card, absIdx)
        local slot = CreateFrame("Frame", nil, card)
        slot:SetHeight(SLOT_H)

        slot.stripe = slot:CreateTexture(nil, "BACKGROUND")
        slot.stripe:SetAllPoints()
        local stripeColor = (absIdx % 2 == 0) and Theme.ROW_EVEN or Theme.ROW_ODD
        slot.stripe:SetColorTexture(stripeColor.r, stripeColor.g, stripeColor.b, stripeColor.a)

        slot.icon = namespace:CreateIcon(slot, "shield", SLOT_ICON, { idleAlpha = 0.45, hoverAlpha = 0.75 })
        slot.icon:SetPoint("LEFT", slot, "LEFT", Theme.GAP, 0)

        slot.badge = MakeStatusBadge(slot, { small = true })
        slot.badge:SetPoint("RIGHT", slot, "RIGHT", -Theme.GAP, 0)

        local nameWidth = slotWidth - Theme.GAP - SLOT_ICON - Theme.GAP - 54 - Theme.GAP
        if nameWidth < 30 then nameWidth = 30 end

        slot.name = slot:CreateFontString(nil, "ARTWORK")
        slot.name:SetFontObject(WildThingsFont13)
        slot.name:SetJustifyH("LEFT")
        slot.name:SetWidth(nameWidth)
        slot.name:SetWordWrap(false)
        slot.name:SetPoint("LEFT", slot.icon, "RIGHT", Theme.GAP, 0)

        slot.empty = slot:CreateFontString(nil, "ARTWORK")
        slot.empty:SetFontObject(WildThingsFont12)
        slot.empty:SetText("Empty")
        slot.empty:SetTextColor(Theme.TEXT_DISABLED.r, Theme.TEXT_DISABLED.g, Theme.TEXT_DISABLED.b,
            Theme.TEXT_DISABLED.a)
        slot.empty:SetPoint("LEFT", slot, "LEFT", Theme.GAP, 0)
        slot.empty:Hide()

        -- CREATE-ONCE: hover-fade wired here, never inside SetLayout/UpdateStatus.
        WTUI:AddHoverFade(slot, slot.stripe)

        -- Whole-card alpha ramp (DRAGUX-01): rest baseline at construction, ramped by
        -- the resolver on hover/search below. Never touches slot.stripe (owned by
        -- AddHoverFade above).
        ApplySlotAlpha(slot)

        -- Drag/drop wiring (GROUP-02/03, plan 15-02): EnableMouse is the hard prerequisite
        -- for both the drag gesture and the hover scripts below (also repairs the Phase 14
        -- hover-fade, which silently depended on it). absIdx is stored so the drop callback
        -- and drag payload can reference this slot's staged index.
        slot:EnableMouse(true)
        slot.absIdx = absIdx

        -- DRAGUX-01: an empty slot (no key) or a combat-locked grid (Private.gridCombatLocked,
        -- set by 22-04's combat frame -- falsy/nil until then) is never a drag source; the
        -- dimAlpha opt hands the drag-in-flight alpha to Drag.lua's own OnDragStart/OnDragStop
        -- so it doesn't fight the rest/hover ramp above.
        WTUI:MakeDraggable(slot, function(s)
            if Private.gridCombatLocked or not s.key then return nil end
            return { from = "slot", key = s.key, sourceIdx = s.absIdx }
        end, { dimAlpha = Theme.SLOT_ALPHA_DRAG })

        WTUI:RegisterDropTarget(slot, function(payload, targetSlot)
            -- The gesture just ended on this slot -- clear its drop-target glow now
            -- rather than waiting for OnLeave (T-22-02: no glow outlives the gesture).
            -- CR-01 (review fix): route through grid:ClearDropTargetGlow() instead of a
            -- raw StopGlow so targetSlot.dragHover and the dragHoverSlot upvalue are also
            -- reset -- a raw StopGlow left both stale, which let a later Bind/
            -- ApplySearchFilter pass resurrect the glow with no drag in flight.
            grid:ClearDropTargetGlow()
            local targetIdx = targetSlot.absIdx
            if payload.from == "slot" then
                HandleSlotDrop(payload.sourceIdx, targetIdx)
            elseif payload.from == "bench" then
                HandleBenchDrop(payload.key, targetIdx)
            end
        end)

        -- Right-click on an occupied slot: Lead/Assist promote menu (engine-gated in
        -- RosterArrange ShowPlayerPromoteMenu). Distinct button from the LeftButton
        -- drag gesture above, so no drag interference.
        slot:SetScript("OnMouseUp", function(_, button)
            if button == "RightButton" and slot.key then
                Private:ShowPlayerPromoteMenu(slot, slot.key, slot.displayName)
            end
        end)

        -- Drag-hover affordances (GROUP-04): a hidden-by-default toggle border, shown as
        -- the accent ring only while a drag is in flight (WTUI:GetActiveDrag() gate), on
        -- ANY hovered slot (every slot is a valid target). HookScript (not SetScript) so
        -- these compose additively with the existing AddHoverFade OnEnter/OnLeave above.
        WTUI:AddRoundedMask(slot, Theme.RADIUS_SM)
        slot.stripe:AddMaskTexture(slot.roundedMask)

        WTUI:AddRoundedBorder(slot, Theme.RADIUS_SM, 1)
        slot:HideBorder()
        slot.dragHover = false
        slot.searchMatched = false

        slot:HookScript("OnEnter", function()
            if WTUI:GetActiveDrag() then
                slot.dragHover = true
                dragHoverSlot = slot
                -- DRAGUX-02 / WR-01 / WR-02 (review fix): border Show/Hide + glow
                -- registration route through the single-writer ApplySlotBorder so this
                -- never clobbers (or fights over the border with) a concurrently active
                -- search-match highlight on the same slot.
                ApplySlotBorder(slot)
                if not slot.key then
                    slot.empty:SetText("Drop here")
                end
            end
        end)

        slot:HookScript("OnLeave", function()
            slot.dragHover = false
            if dragHoverSlot == slot then
                dragHoverSlot = nil
            end
            ApplySlotBorder(slot)
            if not slot.key then
                slot.empty:SetText("Empty")
            end
        end)

        -- Whole-card alpha ramp + slot hover tooltip (DRAGUX-01/03): additive, NOT
        -- gated on an active drag -- unlike the drop-target border pair above, hover
        -- feedback applies to ANY hover of ANY slot, not only while dragging. The
        -- tooltip only shows for an occupied slot (slot.key guard); an empty slot
        -- shows none. Existing MakeStatusBadge/AddTooltip status-badge tooltips are
        -- untouched -- this is a separate, additive hover tooltip on the whole slot.
        slot:HookScript("OnEnter", function()
            slot.hovered = true
            ApplySlotAlpha(slot)
            if slot.key then
                WTUI:ShowWidgetTooltip(slot, function() return BuildSlotTooltipText(slot) end)
            end
        end)

        slot:HookScript("OnLeave", function()
            slot.hovered = false
            ApplySlotAlpha(slot)
            WTUI:HideWidgetTooltip()
        end)

        -- WR-03 (review fix): a per-slot OnHide glow backstop used to live here, but
        -- slot frames in this pooled architecture are never individually Hide()'d --
        -- only their child widgets (icon/name/badge/empty) toggle inside Bind, and only
        -- ancestor frames (root, the card) are ever Hide()'d. Per WoW's frame model
        -- (see root:HookScript("OnHide", ...) above), OnHide does not propagate from an
        -- ancestor to a descendant, so that hook could never fire -- dead code removed.
        -- Glow cleanup for the ancestor-hide case is now handled explicitly by
        -- grid:ClearDropTargetGlow() (CR-01 fix, called from root's OnHide below and
        -- from RosterScreen.lua's combat-gate/SetGroupViewVisible(false) paths) plus
        -- ApplySearchFilter("")'s existing per-slot search-glow sweep on view-hide.

        -- Occupancy bind: SetLayout's only write path onto this slot. A nil key clears
        -- the slot to its "Empty" state and never errors (T-14-01).
        function slot:Bind(key, displayName, className, roleStem, status)
            slot.key = key
            slot.displayName = displayName -- menu title for the right-click promote menu
            slot.className = className -- read by BuildSlotTooltipText at hover-show time
            slot.status = status -- read by BuildSlotTooltipText at hover-show time

            -- Rebinding (SetLayout/drag/Reset) always resets the drag-affordance state
            -- to its rest/hover baseline -- any prior search-recede/search-match was
            -- computed for the PREVIOUS occupant and is stale the instant the occupant
            -- changes. The caller's subsequent ApplySearchFilter call re-derives both.
            slot.searchReceded = false
            slot.searchMatched = false
            -- CR-01 (review fix): dragHover must reset here too, mirroring OnLeave --
            -- every drop routes through Bind (HandleSlotDrop/HandleBenchDrop ->
            -- onLayoutChanged -> SetLayout -> Bind) while the mouse is still resting on
            -- the target, so neither OnLeave nor OnHide fires to clear it first. Left
            -- alone, the stale flag lets a later ApplySearchFilter pass call
            -- ApplySlotBorder and resurrect the "dropTarget" glow with no drag in flight.
            if slot.dragHover then
                slot.dragHover = false
                if dragHoverSlot == slot then dragHoverSlot = nil end
            end
            ApplySlotAlpha(slot)
            ApplySlotBorder(slot)

            if not key then
                slot.icon:Hide()
                slot.name:Hide()
                slot.badge:Hide()
                slot.empty:SetText("Empty") -- reset any lingering drag-over "Drop here" text
                slot.empty:Show()
                grid:RegisterSearchEntry(absIdx, nil)
                return
            end

            slot.empty:Hide()

            if roleStem then
                slot.icon:SetTexture(Theme.GetIconPath(roleStem))
                slot.icon:Show()
            else
                slot.icon:Hide()
            end

            slot.name:SetText(displayName or key)
            local nameColor = (status == "offline") and Theme.TEXT_DIM or Theme.GetClassColor(className)
            slot.name:SetTextColor(nameColor.r, nameColor.g, nameColor.b, nameColor.a)
            slot.name:Show()

            slot.badge:SetStatus(status)
            slot.badge:Show()

            -- DRAGUX-04: self-registering search index, keyed by absIdx and
            -- overwritten on every Bind -- self-corrects after every drag/Reset.
            grid:RegisterSearchEntry(absIdx, (displayName or key):lower())
        end

        return slot
    end

    -- Create-once group card: bordered container, 24px header (uppercase "GROUP n" +
    -- mono-num "n/5" fill count), and 5 pooled slot rows underneath.
    local function MakeGroupCard(gIdx)
        local card = CreateFrame("Frame", nil, root)
        card:SetSize(cardWidth, CARD_HEIGHT)

        card.bg = card:CreateTexture(nil, "BACKGROUND")
        card.bg:SetAllPoints()
        card.bg:SetColorTexture(0, 0, 0, 0.12)

        WTUI:AddRoundedMask(card, Theme.RADIUS_MD)
        card.bg:AddMaskTexture(card.roundedMask)

        WTUI:AddRoundedBorder(card, Theme.RADIUS_MD, 1)
        card:SetBorderColor(BORDER_STRONG.r, BORDER_STRONG.g, BORDER_STRONG.b, BORDER_STRONG.a)

        card.header = CreateFrame("Frame", nil, card)
        card.header:SetHeight(CARD_HEADER_H)
        card.header:SetPoint("TOPLEFT", card, "TOPLEFT")
        card.header:SetPoint("TOPRIGHT", card, "TOPRIGHT")

        card.header.hairline = card.header:CreateTexture(nil, "ARTWORK")
        card.header.hairline:SetHeight(1)
        card.header.hairline:SetColorTexture(BORDER_STRONG.r, BORDER_STRONG.g, BORDER_STRONG.b, BORDER_STRONG.a)
        card.header.hairline:SetPoint("BOTTOMLEFT", card.header, "BOTTOMLEFT")
        card.header.hairline:SetPoint("BOTTOMRIGHT", card.header, "BOTTOMRIGHT")

        card.title = card.header:CreateFontString(nil, "ARTWORK")
        card.title:SetFontObject(WildThingsFont11)
        card.title:SetText(("GROUP %d"):format(gIdx))
        card.title:SetTextColor(Theme.TEXT_SECTION.r, Theme.TEXT_SECTION.g, Theme.TEXT_SECTION.b, Theme.TEXT_SECTION.a)
        card.title:SetPoint("LEFT", card.header, "LEFT", Theme.GAP, 0)

        card.fillCount = card.header:CreateFontString(nil, "ARTWORK")
        card.fillCount:SetFontObject(WildThingsFontNum13)
        card.fillCount:SetText("0/5")
        card.fillCount:SetTextColor(Theme.TEXT_SECTION.r, Theme.TEXT_SECTION.g, Theme.TEXT_SECTION.b,
            Theme.TEXT_SECTION.a)
        card.fillCount:SetPoint("RIGHT", card.header, "RIGHT", -Theme.GAP, 0)

        card.slotHost = CreateFrame("Frame", nil, card)
        card.slotHost:SetPoint("TOPLEFT", card.header, "BOTTOMLEFT", CARD_PAD, -CARD_PAD)
        card.slotHost:SetPoint("TOPRIGHT", card.header, "BOTTOMRIGHT", -CARD_PAD, -CARD_PAD)
        card.slotHost:SetHeight(5 * SLOT_H + 4 * SLOT_GAP)

        for s = 1, 5 do
            local absIdx = (gIdx - 1) * 5 + s
            local slot = MakeSlot(card.slotHost, absIdx)
            slot:SetPoint("TOPLEFT", card.slotHost, "TOPLEFT", 0, -(s - 1) * (SLOT_H + SLOT_GAP))
            slot:SetPoint("TOPRIGHT", card.slotHost, "TOPRIGHT", 0, -(s - 1) * (SLOT_H + SLOT_GAP))
            slotFrames[absIdx] = slot
        end

        return card
    end

    for g = 1, 8 do
        local card = MakeGroupCard(g)
        local col = (g - 1) % CARD_COLS
        local row = math.floor((g - 1) / CARD_COLS)
        card:SetPoint("TOPLEFT", root, "TOPLEFT",
            Theme.PAD_WINDOW + col * (cardWidth + CARD_GAP),
            -Theme.PAD_WINDOW - row * (CARD_HEIGHT + CARD_GAP))
        groupCards[g] = card
    end

    local gridBottomY = -Theme.PAD_WINDOW - 2 * CARD_HEIGHT - CARD_GAP

    -- "Bench" section label with the drag-hint copy (GROUP-04, plan 15-02) -- the gesture
    -- now exists, closing Phase 14's deferred neutral bench copy.
    local benchHeader = namespace:CreateSectionHeader(root, "Bench — drag onto a slot to swap in")
    benchHeader:SetPoint("TOPLEFT", root, "TOPLEFT", Theme.PAD_WINDOW, gridBottomY - Theme.GAP_LG)
    benchHeader:SetPoint("TOPRIGHT", root, "TOPRIGHT", -Theme.PAD_WINDOW, gridBottomY - Theme.GAP_LG)

    local benchHost = CreateFrame("Frame", nil, root)
    benchHost:SetPoint("TOPLEFT", benchHeader, "BOTTOMLEFT", 0, -Theme.GAP)
    benchHost:SetPoint("TOPRIGHT", benchHeader, "BOTTOMRIGHT", 0, -Theme.GAP)
    benchHost:SetHeight(CHIP_H)

    local benchEmpty = benchHost:CreateFontString(nil, "ARTWORK")
    benchEmpty:SetFontObject(WildThingsFont13)
    benchEmpty:SetText("Bench is empty.")
    benchEmpty:SetTextColor(Theme.TEXT_DISABLED.r, Theme.TEXT_DISABLED.g, Theme.TEXT_DISABLED.b, Theme.TEXT_DISABLED.a)
    benchEmpty:SetPoint("TOPLEFT", benchHost, "TOPLEFT", 0, 0)
    benchEmpty:Hide()

    -- "N/40 slots" footer: split across two FontStrings (mono-num digits + regular-font
    -- word) -- WildThingsFontNum is a digits-only font family (UI/Font.lua), so "slots"
    -- must render in the regular font while the count uses the mono-num object.
    local benchFooterNum = root:CreateFontString(nil, "ARTWORK")
    benchFooterNum:SetFontObject(WildThingsFontNum12)
    benchFooterNum:SetTextColor(Theme.TEXT_SECTION.r, Theme.TEXT_SECTION.g, Theme.TEXT_SECTION.b, Theme.TEXT_SECTION.a)
    benchFooterNum:SetPoint("TOPLEFT", benchHost, "BOTTOMLEFT", 0, -Theme.GAP)

    local benchFooterLabel = root:CreateFontString(nil, "ARTWORK")
    benchFooterLabel:SetFontObject(WildThingsFont12)
    benchFooterLabel:SetText(" slots")
    benchFooterLabel:SetTextColor(Theme.TEXT_SECTION.r, Theme.TEXT_SECTION.g, Theme.TEXT_SECTION.b,
        Theme.TEXT_SECTION.a)
    benchFooterLabel:SetPoint("LEFT", benchFooterNum, "RIGHT", 0, 0)

    -- Create-once pooled bench chip (RESEARCH Pattern 2 / RosterScreen rowPool idiom):
    -- pill shape, role icon + class-colored name + small status badge + tooltip. Hover-
    -- fade wired here, exactly once, never from SetLayout/UpdateStatus.
    local function MakeBenchChip(parent)
        local chip = CreateFrame("Frame", nil, parent)
        chip:SetHeight(CHIP_H)

        -- Fill inset 1px so the rounded border ring below shows through as a 1px band
        -- (ROUND-BORDER-01).
        chip.bg = chip:CreateTexture(nil, "BACKGROUND")
        chip.bg:SetPoint("TOPLEFT", chip, "TOPLEFT", 1, -1)
        chip.bg:SetPoint("BOTTOMRIGHT", chip, "BOTTOMRIGHT", -1, 1)
        chip.bg:SetColorTexture(Theme.BTN_BG.r, Theme.BTN_BG.g, Theme.BTN_BG.b, Theme.BTN_BG.a)
        chip.bg:SetSnapToPixelGrid(false)
        chip.bg:SetTexelSnappingBias(0)

        WTUI:AddRoundedMask(chip, Theme.RADIUS_SM)
        chip.bg:AddMaskTexture(chip.roundedMask)

        -- Rounded border ring (ROUND-BORDER-01): follows the corner arcs instead of a
        -- square stroke.
        WTUI:AddRoundedBorder(chip, Theme.RADIUS_SM, 1)
        chip:SetBorderColor(Theme.BORDER_STRONG.r, Theme.BORDER_STRONG.g, Theme.BORDER_STRONG.b, Theme.BORDER_STRONG.a)

        chip.icon = namespace:CreateIcon(chip, "swords", CHIP_ICON, { idleAlpha = 0.45, hoverAlpha = 0.75 })
        chip.icon:SetPoint("LEFT", chip, "LEFT", 10, 0)

        chip.name = chip:CreateFontString(nil, "ARTWORK")
        chip.name:SetFontObject(WildThingsFont13)
        chip.name:SetPoint("LEFT", chip.icon, "RIGHT", Theme.GAP, 0)

        chip.badge = MakeStatusBadge(chip, { small = true })
        chip.badge:SetPoint("LEFT", chip.name, "RIGHT", Theme.GAP, 0)

        chip.hoverBg = chip:CreateTexture(nil, "OVERLAY")
        chip.hoverBg:SetAllPoints()
        -- Clip the hover wash to the rounded shape (ROUND-BORDER-01) so it doesn't poke
        -- square corners past the rounded outline.
        chip.hoverBg:AddMaskTexture(chip.roundedMask)
        -- CREATE-ONCE: hover-fade wired here, never inside SetLayout/UpdateStatus.
        WTUI:AddHoverFade(chip, chip.hoverBg)

        -- Drag wiring (GROUP-03, plan 15-02): bench chips are drag sources only -- no
        -- RegisterDropTarget call here (no bench drop-zone; the explicit "empty this slot"
        -- gesture is deferred GROUP-06).
        chip:EnableMouse(true)
        WTUI:MakeDraggable(chip, function(c)
            if not c.key then return nil end
            return { from = "bench", key = c.key }
        end)

        function chip:Bind(key, displayName, className, roleStem, status)
            chip.key = key

            if roleStem then
                chip.icon:SetTexture(Theme.GetIconPath(roleStem))
                chip.icon:Show()
            else
                chip.icon:Hide()
            end

            chip.name:SetText(displayName or key or "")
            local nameColor = (status == "offline") and Theme.TEXT_DIM or Theme.GetClassColor(className)
            chip.name:SetTextColor(nameColor.r, nameColor.g, nameColor.b, nameColor.a)

            chip.badge:SetStatus(status)

            chip:SetWidth(10 + CHIP_ICON + Theme.GAP + chip.name:GetStringWidth() + Theme.GAP
                + chip.badge:GetWidth() + 10)

            WTUI:AddTooltip(chip, displayName or key or "", key)
        end

        return chip
    end

    -- Full repaint of bench chip membership + positions (SetLayout only -- acquire/
    -- release from benchPool, RosterScreen rowPool idiom).
    local function RebuildBenchChips(bench, displayNameByKey)
        bench = bench or {}
        local x, y = 0, 0

        for i, key in ipairs(bench) do
            local chip = benchPool[i]
            if not chip then
                chip = MakeBenchChip(benchHost)
                benchPool[i] = chip
            end

            local className, role = ResolveClassRole(key, lastStatusByKey)
            local status = DeriveOccupantStatus(key, lastStatusByKey)
            local displayName = displayNameByKey[key] or FallbackDisplayName(key)
            chip:Bind(key, displayName, className, role and ROLE_ICON[role], status)

            chip:ClearAllPoints()
            local chipWidth = chip:GetWidth()
            if x > 0 and (x + chipWidth) > gridWidth then
                x = 0
                y = y + CHIP_H + Theme.GAP
            end
            chip:SetPoint("TOPLEFT", benchHost, "TOPLEFT", x, -y)
            chip:Show()
            x = x + chipWidth + Theme.GAP
        end

        for i = #bench + 1, #benchPool do
            benchPool[i]:Hide()
        end

        benchEmpty:SetShown(#bench == 0)
        benchHost:SetHeight(math.max(y + CHIP_H, CHIP_H))

        local occupiedCount = 0
        for i = 1, 40 do
            if currentSlots40[i] then occupiedCount = occupiedCount + 1 end
        end
        benchFooterNum:SetText(("%d/40"):format(occupiedCount))
        benchFooterNum:ClearAllPoints()
        benchFooterNum:SetPoint("TOPLEFT", benchHost, "BOTTOMLEFT", 0, -Theme.GAP)
    end

    -- Status-only repaint of bench chip badges/name-dim (UpdateStatus only -- never
    -- touches bench membership, position, or the "N/40 slots" footer count).
    local function RepaintBenchChipStatuses()
        for i = 1, #benchPool do
            local chip = benchPool[i]
            if chip.key then
                local status = DeriveOccupantStatus(chip.key, lastStatusByKey)
                chip.badge:SetStatus(status)
                local className = ResolveClassRole(chip.key, lastStatusByKey)
                local nameColor = (status == "offline") and Theme.TEXT_DIM or Theme.GetClassColor(className)
                chip.name:SetTextColor(nameColor.r, nameColor.g, nameColor.b, nameColor.a)
            end
        end
    end

    -- Full repaint (RESEARCH Pattern 3): position + occupancy + fill counts + bench
    -- membership. Runs only at seed/re-seed moments (view open, encounter switch) --
    -- never from a GROUP_ROSTER_UPDATE-driven call (that is UpdateStatus's job, below).
    function grid:SetLayout(slots40, bench)
        currentSlots40 = slots40 or {}
        local displayNameByKey = BuildDisplayNameByKey()

        for i = 1, 40 do
            local key = currentSlots40[i]
            if key then
                local className, role = ResolveClassRole(key, lastStatusByKey)
                local status = DeriveOccupantStatus(key, lastStatusByKey)
                local displayName = displayNameByKey[key] or FallbackDisplayName(key)
                slotFrames[i]:Bind(key, displayName, className, role and ROLE_ICON[role], status)
            else
                slotFrames[i]:Bind(nil)
            end
        end

        for g = 1, 8 do
            local count = 0
            for s = 1, 5 do
                if currentSlots40[(g - 1) * 5 + s] then count = count + 1 end
            end
            groupCards[g].fillCount:SetText(count .. "/5")
        end

        RebuildBenchChips(bench, displayNameByKey)
    end

    -- Status-only repaint (RESEARCH Pattern 3, GROUP-05's load-bearing boundary): badges
    -- + offline name-dim on OCCUPIED slots and bench chips only. Never reads/writes slot
    -- occupancy; per-group fill counts derive from staged occupancy alone and are
    -- untouched here -- driven from every GROUP_ROSTER_UPDATE while Group View is
    -- visible (RosterScreen, plan 14-02), never from SetLayout.
    function grid:UpdateStatus(statusByKey)
        lastStatusByKey = statusByKey or {}

        for i = 1, 40 do
            local key = currentSlots40[i]
            if key then
                local status = DeriveOccupantStatus(key, lastStatusByKey)
                slotFrames[i].status = status -- keep BuildSlotTooltipText's read in sync with the badge
                slotFrames[i].badge:SetStatus(status)
                local className = ResolveClassRole(key, lastStatusByKey)
                local nameColor = (status == "offline") and Theme.TEXT_DIM or Theme.GetClassColor(className)
                slotFrames[i].name:SetTextColor(nameColor.r, nameColor.g, nameColor.b, nameColor.a)
                -- Re-derive whole-slot alpha now that status may have flipped present<->absent
                -- (e.g. an invite landed): ResolveSlotAlpha reads slot.status, so without this
                -- the card stays frozen at the alpha SetLayout set before the raid confirmed.
                ApplySlotAlpha(slotFrames[i])
            end
        end

        RepaintBenchChipStatuses()
    end

    -- Read accessor (Phase 15/16 will consume this to seed drag mechanics / the
    -- "Set groups" apply step). Returns a SHALLOW COPY, never the live internal table --
    -- currentSlots40 is only ever written by SetLayout (GROUP-05's load-bearing invariant);
    -- handing out the table by reference would let a caller mutate it in place with no
    -- defensive boundary here to catch it.
    function grid:GetLayout()
        local copy = {}
        for i = 1, 40 do
            copy[i] = currentSlots40[i]
        end
        return copy
    end

    -- grid:RegisterSearchEntry(absIdx, haystack) (DRAGUX-04) -- called from slot:Bind
    -- on every SetLayout/drag/Reset (Pitfall 3: NOT registered at MakeSlot
    -- construction, which would capture key=nil forever and go stale the moment a
    -- player is dragged to a new slot). A nil haystack clears the index entry (slot
    -- bound back to empty).
    function grid:RegisterSearchEntry(absIdx, haystack)
        if haystack then
            searchIndex[absIdx] = { slot = slotFrames[absIdx], haystack = haystack }
        else
            searchIndex[absIdx] = nil
        end
    end

    -- grid:ApplySearchFilter(needle) (DRAGUX-04): scores every registered entry via
    -- FuzzyScore; matches highlight (full alpha + a pulsing border via the central
    -- glow driver), non-matches recede via whole-slot alpha through the SAME resolver
    -- Task 1's hover/drag ramp uses (Pitfall 9 -- never touches slot.name color). An
    -- empty/nil needle restores every registered slot to its normal (non-receded)
    -- alpha and stops every "search" glow. Idempotent: re-applying the same needle
    -- yields the same highlight set; a slot rebound by a drag re-registers with the
    -- new occupant's haystack on its next Bind, so a subsequent filter finds it there.
    -- Border Show/Hide + glow registration route through ApplySlotBorder (WR-01/WR-02
    -- review fix) via slot.searchMatched, so a slot that's simultaneously an active
    -- drag-hover drop-target keeps its dropTarget border/glow precedence instead of
    -- the two writers fighting over the same border.
    function grid:ApplySearchFilter(needle)
        needle = (needle and needle ~= "") and needle:lower() or nil

        for absIdx = 1, 40 do
            local entry = searchIndex[absIdx]
            if entry then
                local slot = entry.slot
                if not needle then
                    slot.searchReceded = false
                    slot.searchMatched = false
                else
                    local score = FuzzyScore(entry.haystack, needle)
                    if score then
                        slot.searchReceded = false
                        slot.searchMatched = true
                    else
                        slot.searchReceded = true
                        slot.searchMatched = false
                    end
                end
                ApplySlotBorder(slot)
                ApplySlotAlpha(slot)
            end
        end
    end

    -- grid:ClearDropTargetGlow() (CR-01 review fix): force-clears the drag-hover
    -- "dropTarget" border/glow off whichever single slot currently holds it, without
    -- a full 40-slot sweep. Needed because the mouse never physically leaving the
    -- hovered slot means neither its OnLeave nor (per WR-03) its OnHide fires when a
    -- drag is force-cancelled out from under it -- e.g. WTUI:CancelActiveDrag() being
    -- called by the combat-gate handler mid-hover, or an ancestor Hide() that never
    -- propagates to this slot. Call this from every place that currently only calls
    -- WTUI:CancelActiveDrag(). Idempotent no-op when no slot is drag-hovered.
    function grid:ClearDropTargetGlow()
        if dragHoverSlot then
            local slot = dragHoverSlot
            slot.dragHover = false
            dragHoverSlot = nil
            ApplySlotBorder(slot)
        end
    end

    return grid
end
