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

local WTUI = namespace.WildThingsUI
local Theme = namespace.Theme

-- File-local color aliases (23-01/30-01 precedent) to keep retargeted call sites under
-- luacheck's 120-char line limit.
local SURFACE_2 = Theme.SURFACE_2
local BORDER_STRONG = Theme.BORDER_STRONG
local ACCENT_A13 = Theme.ACCENT_A13
local TEXT_DIM = Theme.TEXT_DIM
local DANGER = Theme.DANGER

-- Approximate line height of the WildThingsFont13 title label CreateEditBox renders
-- above itself (EditBox.lua:73-77) -- same fixed reserved-space constant InviteScreen.lua
-- uses (Pitfall: neither widget exposes that label's own frame height).
local TITLE_H = 16

-- WW1-BANK-ORGANIZE v2.4 shape change (BANKCFG-04): a cell with an assigned item but no
-- explicit stack-size defaults to this value. Mirrors Migrations.lua's own DEFAULT_STACK
-- (200) -- duplicated here (not exported by the accessor seam) since a cell's stack-size
-- EditBox needs a display default before the user edits it the first time.
local DEFAULT_STACK = 200

-- Grid cell sizing (Claude's discretion within the wild-things-design contract).
local MIN_CELL_WIDTH = 40
local CELL_HEIGHT = 54
local CELL_ICON_SIZE = Theme.ICON_LG
local CELL_PAD = 4
local STACK_BOX_HEIGHT = 18
local CLEAR_BTN_SIZE = 16
local CLEAR_ICON_SIZE = 12

-- Bulk-copy popup sizing (BANKCFG-05, Claude's discretion): COPY_BUTTON_MARGIN mirrors
-- ConfirmWindow.lua's own BUTTON_MARGIN=10 for visual consistency between popup types.
local COPY_BUTTON_MARGIN = 10
local COPY_ROW_GAP = 2
local COPY_ROW_PAD = 12

-- namespace:CreateBankScreen(contentHost)
-- Guild Bank Tab Organizer screen (WW1-BANK-ORGANIZE / v2.4 ASSIGN-03): a per-tab 14-cell
-- column grid (Private.BANK_COLS_PER_TAB cells) that reads/writes exclusively through the
-- Phase-31 accessors (GetBankColumnConfig/SetBankColumnConfig/ClearBankColumnConfig), plus a
-- single Organize/Stop toggle button and a live progress bar driven by BankOrganize.lua's
-- paced drain. Ships in packaged builds (no battletag data). Structure modeled on
-- InviteScreen.lua: root frame anchored to contentHost, a scrolled body.
function namespace:CreateBankScreen(contentHost)
    local root = CreateFrame("Frame", nil, contentHost)
    root:SetPoint("TOPLEFT", contentHost, "TOPLEFT", 0, 0)
    root:SetPoint("TOPRIGHT", contentHost, "TOPRIGHT", 0, 0)
    root:SetPoint("BOTTOMRIGHT", contentHost, "BOTTOMRIGHT", 0, 0)

    local scrollFrame = namespace:CreateScrollFrame(root)
    scrollFrame:SetPoint("TOPLEFT", root, "TOPLEFT", Theme.PAD_WINDOW, -Theme.PAD_WINDOW)
    scrollFrame:SetPoint("BOTTOMRIGHT", root, "BOTTOMRIGHT", -Theme.PAD_WINDOW, Theme.PAD_WINDOW)

    -- Content width must be bound before any row/cell is measured (InviteScreen.lua
    -- Pitfall 1): the grid only measures cellWidth correctly once its width is resolved.
    local viewportWidth = scrollFrame.scrollFrame:GetWidth()
    if not viewportWidth or viewportWidth < 1 then viewportWidth = 1 end
    scrollFrame.contentFrame:SetWidth(viewportWidth)

    local y = 0

    -- Forward-declared: closures below (grid cell creation/rebuild/select, the shared
    -- assign handler, organizeBtn's OnClick) all close over these before their bodies are
    -- assigned further down -- without an explicit `local` here, `function Name()` later
    -- would silently create a real global (this addon's only intentional globals are
    -- WTAPI/WildThingsSavedVars/TableContains, per .claude/CLAUDE.md).
    local CreateGridCell
    local RebuildGrid
    local SelectColumn
    local SelectTab
    local RelayoutBelowGrid
    local UpdateBankProgressUI
    local ShowCopyToPopup

    -- selectedTab defaults to the currently open guild bank tab when the bank is open,
    -- else Tab 1 (33-CONTEXT.md "Default selected tab" decision).
    local function ResolveDefaultTab()
        local tab = GetCurrentGuildBankTab() or 0
        if tab < 1 then return 1 end
        return tab
    end
    local selectedTab = ResolveDefaultTab()
    local selectedCol = 1
    local cells = {}
    local sharedAssignBox

    -- IsCursorHoldingItem(): true when the cursor holds an item from ANY source -- bags, the
    -- character bank, OR the guild bank. Gates the hover-glow OnEnter/self-terminating tick
    -- and every drop dispatch below.
    --
    -- Must use GetCursorInfo()=="item", NOT CursorHasItem(): a GUILD-BANK-sourced pickup makes
    -- CursorHasItem() return false (confirmed in-game 2026-07-23) even though GetCursorInfo()
    -- correctly reports "item", <itemID> for it -- so a CursorHasItem() gate silently swallows
    -- every guild-bank drag before it can assign (G-34-3).
    local function IsCursorHoldingItem()
        return select(1, GetCursorInfo()) == "item"
    end

    -- ReadCursorStackCount(): the quantity of the item stack currently on the cursor, or nil.
    -- C_Cursor.GetCursorItem() -> C_Item.GetStackCount resolves for BAG / character-bank
    -- pickups, but GetCursorItem() is nil for a GUILD-BANK-sourced cursor (confirmed in-game
    -- 2026-07-23) -- there is no cursor-quantity API that reaches guild-bank items. Caller
    -- falls back to the existing config / DEFAULT_STACK when this returns nil.
    local function ReadCursorStackCount()
        if not (C_Cursor and C_Cursor.GetCursorItem and C_Item and C_Item.GetStackCount) then
            return nil
        end
        local loc = C_Cursor.GetCursorItem()
        if not loc then return nil end
        local count = C_Item.GetStackCount(loc)
        if count and count >= 1 then return count end
        return nil
    end

    -- ClearCellDropGlow(cell): StopGlow + RESTORE the resting BORDER_STRONG border. Unlike
    -- GroupGrid's slots, BankScreen cells render an ALWAYS-ON border, so the stop path must
    -- recolor back to BORDER_STRONG rather than HideBorder (that would erase the cell's
    -- normal border on every hover-leave/drop).
    local function ClearCellDropGlow(cell)
        WTUI:StopGlow(cell, "dropTarget")
        cell:SetBorderColor(BORDER_STRONG.r, BORDER_STRONG.g, BORDER_STRONG.b, BORDER_STRONG.a)
    end

    -- HandleCellDrop(cell): the ONE shared drop handler for both interaction styles (drag-
    -- release via OnReceiveDrag and pickup-then-click via the extended OnMouseUp below).
    -- Config write only -- no real item placement, ever; all real item movement stays
    -- inside the gated Organize engine.
    local function HandleCellDrop(cell)
        -- GetCursorInfo(), not CursorHasItem(): see IsCursorHoldingItem above -- a guild-bank
        -- pickup reports "item" here but is invisible to CursorHasItem().
        local infoType, itemID = GetCursorInfo()
        if not infoType then return end -- empty cursor (a bare click): silent no-op

        ClearCellDropGlow(cell)

        if infoType ~= "item" or not itemID then
            ClearCursor() -- a non-item object (spell/macro): release it, write nothing
            return
        end

        -- Prefer the actual dragged quantity (read BEFORE ClearCursor below); fall back to the
        -- cell's existing stack size, then DEFAULT_STACK, when the count is unreadable
        -- (guild-bank source -- see ReadCursorStackCount).
        local existing = namespace:GetBankColumnConfig(selectedTab, cell.col)
        local stackSize = ReadCursorStackCount()
            or (existing and existing.stackSize)
            or DEFAULT_STACK
        namespace:SetBankColumnConfig(selectedTab, cell.col, itemID, stackSize)

        ClearCursor()
        RebuildGrid()
    end

    local function AddSectionHeader(title)
        local header = namespace:CreateSectionHeader(scrollFrame.contentFrame, title)
        header:ClearAllPoints()
        header:SetPoint("TOPLEFT", scrollFrame.contentFrame, "TOPLEFT", 0, y)
        header:SetPoint("TOPRIGHT", scrollFrame.contentFrame, "TOPRIGHT", 0, y)
        y = y - header:GetHeight() - Theme.GAP
        return header
    end

    local function AddDimLabel(msg)
        local label = scrollFrame.contentFrame:CreateFontString(nil, "ARTWORK")
        label:SetFontObject(WildThingsFont13)
        label:SetText(msg)
        label:SetTextColor(TEXT_DIM.r, TEXT_DIM.g, TEXT_DIM.b, TEXT_DIM.a)
        label:SetJustifyH("LEFT")
        label:SetPoint("TOPLEFT", scrollFrame.contentFrame, "TOPLEFT", 0, y)
        label:SetPoint("TOPRIGHT", scrollFrame.contentFrame, "TOPRIGHT", 0, y)
        y = y - label:GetStringHeight() - Theme.GAP
        return label
    end

    ------------------------------------------------------------------
    -- Guild Bank Columns (ASSIGN-03): a 14-cell grid mirroring the real guild bank's
    -- column layout for the selected tab.
    ------------------------------------------------------------------
    AddSectionHeader("Guild Bank Columns")
    AddDimLabel("Click a column, then shift-click a link or type an itemID below and press Assign. "
        .. "You can also drag an item from your bags or the guild bank onto a column.")

    ------------------------------------------------------------------
    -- Tab switcher (ASSIGN-03 SC2): a horizontal row of selectable tab entries. New
    -- BankScreen.lua-local code (not a call into Shell.lua) -- copies Shell.lua's
    -- CreateNavItem SetSelected/selectedOverlay/accentBar STATE PATTERN, re-anchored
    -- left-to-right instead of top-to-bottom (33-PATTERNS.md).
    ------------------------------------------------------------------
    local function ResolveTabCount()
        local count = GetNumGuildBankTabs and GetNumGuildBankTabs()
        if not count or count == 0 then count = 8 end
        return count
    end
    local tabCount = ResolveTabCount()
    local tabEntries = {}

    local function CreateTabEntry(tabIndex)
        local entry = CreateFrame("Button", nil, scrollFrame.contentFrame)
        entry:SetHeight(Theme.H_NAV)
        entry.index = tabIndex

        -- Selected wash + accent bar -- mirrors Shell.lua:CreateNavItem's state contract.
        entry.selectedOverlay = entry:CreateTexture(nil, "ARTWORK")
        entry.selectedOverlay:SetAllPoints()
        entry.selectedOverlay:SetColorTexture(
            Theme.SELECTED_OVERLAY.r, Theme.SELECTED_OVERLAY.g,
            Theme.SELECTED_OVERLAY.b, Theme.SELECTED_OVERLAY.a)
        entry.selectedOverlay:Hide()

        entry.accentBar = entry:CreateTexture(nil, "OVERLAY")
        entry.accentBar:SetHeight(2)
        entry.accentBar:SetPoint("BOTTOMLEFT", entry, "BOTTOMLEFT", 4, 0)
        entry.accentBar:SetPoint("BOTTOMRIGHT", entry, "BOTTOMRIGHT", -4, 0)
        entry.accentBar:SetColorTexture(Theme.ACCENT.r, Theme.ACCENT.g, Theme.ACCENT.b, Theme.ACCENT.a)
        entry.accentBar:Hide()

        entry.hoverOverlay = entry:CreateTexture(nil, "OVERLAY")
        entry.hoverOverlay:SetAllPoints()
        WTUI:AddHoverFade(entry, entry.hoverOverlay, {color = ACCENT_A13})

        WTUI:AddRoundedMask(entry, Theme.RADIUS_SM)
        entry.selectedOverlay:AddMaskTexture(entry.roundedMask)
        entry.hoverOverlay:AddMaskTexture(entry.roundedMask)

        -- Tab name via GetGuildBankTabInfo when it resolves (bank open), else "Tab N"
        -- (mirrors Pill.lua's "item:"..id resolution-failure fallback idiom).
        local tabName = GetGuildBankTabInfo(tabIndex)
        if not tabName or tabName == "" then tabName = "Tab " .. tabIndex end

        entry.label = entry:CreateFontString(nil, "ARTWORK")
        entry.label:SetFontObject(WildThingsFont13)
        entry.label:SetText(tabName)
        entry.label:SetTextColor(Theme.TEXT.r, Theme.TEXT.g, Theme.TEXT.b, Theme.TEXT.a)
        entry.label:SetPoint("CENTER")

        entry:SetWidth(entry.label:GetStringWidth() + Theme.GAP * 3)

        function entry:SetSelected(isSelected)
            entry.selected = isSelected and true or false
            if entry.selected then
                entry.selectedOverlay:Show()
                entry.accentBar:Show()
            else
                entry.selectedOverlay:Hide()
                entry.accentBar:Hide()
            end
        end

        entry:SetScript("OnClick", function() SelectTab(tabIndex) end)

        return entry
    end

    -- SelectTab(tabIndex): sets selectedTab, resets selectedCol to 1, repaints every
    -- entry's selected state, and rebuilds the grid from the newly selected tab's own
    -- independent GetBankColumnConfig reads (ASSIGN-03 SC2 tab isolation).
    function SelectTab(tabIndex)
        selectedTab = tabIndex
        selectedCol = 1
        for _, entry in ipairs(tabEntries) do
            entry:SetSelected(entry.index == selectedTab)
        end
        RebuildGrid()
    end

    local tabSwitcherY = y
    local prevTabEntry
    for tabIndex = 1, tabCount do
        local entry = CreateTabEntry(tabIndex)
        if prevTabEntry then
            entry:SetPoint("TOPLEFT", prevTabEntry, "TOPRIGHT", Theme.GAP, 0)
        else
            entry:SetPoint("TOPLEFT", scrollFrame.contentFrame, "TOPLEFT", 0, tabSwitcherY)
        end
        entry:SetSelected(entry.index == selectedTab)
        tabEntries[#tabEntries + 1] = entry
        prevTabEntry = entry
    end
    y = y - Theme.H_NAV - Theme.GAP

    local gridHostY = y
    local gridHost = CreateFrame("Frame", nil, scrollFrame.contentFrame)
    gridHost:SetPoint("TOPLEFT", scrollFrame.contentFrame, "TOPLEFT", 0, gridHostY)
    gridHost:SetPoint("TOPRIGHT", scrollFrame.contentFrame, "TOPRIGHT", 0, gridHostY)
    gridHost:SetHeight(1)

    -- CreateGridCell(col): builds one grid cell reading GetBankColumnConfig(selectedTab,
    -- col) -- icon + inline stack-size EditBox + hover clear (x) when configured, a dim "+"
    -- placeholder when not. Clicking the cell body selects it (SelectColumn) as the shared
    -- assign field's target. Icon-resolution mirrors Pill.lua's CreateItemPill nil-guard
    -- chain (NOT the pill widget itself -- 33-PATTERNS.md).
    function CreateGridCell(col)
        local cell = CreateFrame("Frame", nil, gridHost)
        cell:SetHeight(CELL_HEIGHT)
        cell.col = col
        cell:EnableMouse(true)
        cell:SetScript("OnReceiveDrag", function() HandleCellDrop(cell) end)

        cell.bg = cell:CreateTexture(nil, "BACKGROUND")
        cell.bg:SetPoint("TOPLEFT", cell, "TOPLEFT", 1, -1)
        cell.bg:SetPoint("BOTTOMRIGHT", cell, "BOTTOMRIGHT", -1, 1)
        cell.bg:SetColorTexture(SURFACE_2.r, SURFACE_2.g, SURFACE_2.b, SURFACE_2.a)

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

        WTUI:AddRoundedBorder(cell, Theme.RADIUS_SM, 1)
        cell:SetBorderColor(BORDER_STRONG.r, BORDER_STRONG.g, BORDER_STRONG.b, BORDER_STRONG.a)

        -- Active-column highlight (Claude's discretion: accent-alpha overlay), built on
        -- every cell so SelectColumn can toggle it regardless of configured state.
        cell.activeOverlay = cell:CreateTexture(nil, "ARTWORK")
        cell.activeOverlay:SetAllPoints()
        cell.activeOverlay:SetColorTexture(ACCENT_A13.r, ACCENT_A13.g, ACCENT_A13.b, ACCENT_A13.a)
        cell.activeOverlay:AddMaskTexture(cell.roundedMask)
        cell.activeOverlay:SetShown(col == selectedCol)

        local entry = namespace:GetBankColumnConfig(selectedTab, col)
        if entry then
            local _, _, _, _, iconID = GetItemInfoInstant(entry.itemID)
            iconID = iconID or 134400
            local itemName = GetItemInfo(entry.itemID) or ("item:" .. tostring(entry.itemID))

            cell.icon = cell:CreateTexture(nil, "ARTWORK")
            cell.icon:SetSize(CELL_ICON_SIZE, CELL_ICON_SIZE)
            cell.icon:SetPoint("TOP", cell, "TOP", 0, -CELL_PAD)
            cell.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
            cell.icon:SetTexture(iconID)
            cell.icon:SetSnapToPixelGrid(false)
            cell.icon:SetTexelSnappingBias(0)

            local sizeHost = CreateFrame("Frame", nil, cell)
            sizeHost:SetPoint("BOTTOMLEFT", cell, "BOTTOMLEFT", CELL_PAD, CELL_PAD)
            sizeHost:SetPoint("BOTTOMRIGHT", cell, "BOTTOMRIGHT", -CELL_PAD, CELL_PAD)
            sizeHost:SetHeight(STACK_BOX_HEIGHT)

            -- WR-01: the initial SetText below primes the box from already-persisted
            -- config and fires CreateEditBox's OnTextSet synchronously, AND EditBox.lua's
            -- own deferred C_Timer.After(0, Refresh) fires the same callback again one
            -- frame later (EditBox.lua:269, pre-existing, unmodified) -- both would
            -- otherwise round-trip straight back into SetBankColumnConfig with the exact
            -- value that was just read. Fixed via change-detection instead of counting
            -- callback firings: capture this cell's OWN tab/col/itemID at creation time
            -- (never the shared, mutable selectedTab/selectedCol upvalues -- a deferred
            -- callback firing after a same-frame tab switch must still target the cell it
            -- was built for), and only write when the new value differs from what's
            -- currently persisted for THIS cell and the cell is still assigned to the same
            -- item. A programmatic repaint priming the same value -> no write. A genuine
            -- user edit to a new value -> writes normally.
            local cellTab, cellCol, cellItemID = selectedTab, col, entry.itemID
            local sizeBox = namespace:CreateEditBox(sizeHost, "", function(newSize)
                local current = namespace:GetBankColumnConfig(cellTab, cellCol)
                if not current or current.itemID ~= cellItemID then return end
                if current.stackSize == newSize then return end
                namespace:SetBankColumnConfig(cellTab, cellCol, cellItemID, newSize)
            end)
            sizeBox:SetPoint("TOPLEFT", sizeHost, "TOPLEFT", 0, 0)
            sizeBox:SetPoint("BOTTOMRIGHT", sizeHost, "BOTTOMRIGHT", 0, 0)
            sizeBox:SetNumeric(true)
            sizeBox:SetMinimum(1)
            sizeBox:SetText(tostring(entry.stackSize or DEFAULT_STACK))
            -- CR-02: track this cell's inline EditBox so RebuildGrid can force-blur it
            -- before hiding/unparenting the cell (see RebuildGrid below).
            cell.sizeBox = sizeBox

            -- WR-02: the stack-size box is a separate mouse-enabled child frame covering
            -- the cell's bottom strip -- a cursor-item drop landing here would otherwise
            -- never reach the cell's own OnReceiveDrag/OnMouseUp, silently failing to
            -- assign (and leaving the cursor stuck). Forward to the SAME shared handler
            -- the cell itself uses, mirroring cell's own OnReceiveDrag (line 271, no extra
            -- guard needed -- HandleCellDrop already no-ops if the cursor holds no item)
            -- and cell's own OnMouseUp IsCursorHoldingItem() branch. Normal typed
            -- stack-size editing and click-to-focus are untouched -- these hooks only ever
            -- act when a real cursor item is involved.
            sizeBox:HookScript("OnReceiveDrag", function() HandleCellDrop(cell) end)
            sizeBox:HookScript("OnMouseUp", function()
                if IsCursorHoldingItem() then HandleCellDrop(cell) end
            end)

            cell.clearBtn = CreateFrame("Button", nil, cell)
            cell.clearBtn:SetSize(CLEAR_BTN_SIZE, CLEAR_BTN_SIZE)
            cell.clearBtn:SetPoint("TOPRIGHT", cell, "TOPRIGHT", -2, -2)

            cell.clearBtn.tex = cell.clearBtn:CreateTexture(nil, "OVERLAY")
            cell.clearBtn.tex:SetSize(CLEAR_ICON_SIZE, CLEAR_ICON_SIZE)
            cell.clearBtn.tex:SetPoint("CENTER")
            cell.clearBtn.tex:SetTexture(Theme.GetIconPath("x"))
            cell.clearBtn.tex:SetVertexColor(TEXT_DIM.r, TEXT_DIM.g, TEXT_DIM.b, TEXT_DIM.a)
            cell.clearBtn.tex:SetSnapToPixelGrid(false)
            cell.clearBtn.tex:SetTexelSnappingBias(0)

            cell.clearBtn:SetScript("OnEnter", function()
                cell.clearBtn.tex:SetVertexColor(DANGER.r, DANGER.g, DANGER.b, DANGER.a)
            end)
            cell.clearBtn:SetScript("OnLeave", function()
                cell.clearBtn.tex:SetVertexColor(TEXT_DIM.r, TEXT_DIM.g, TEXT_DIM.b, TEXT_DIM.a)
            end)
            cell.clearBtn:SetScript("OnClick", function()
                -- WR-02: the clear (x) button covers the cell's top-right corner while
                -- hovered -- a pickup-then-click drop landing here must still route to the
                -- shared drop handler (assign + ClearCursor) rather than clearing the
                -- cell's config out from under the just-dropped item. Guarded so the
                -- button's normal clear-on-click behavior is untouched when no item is
                -- on the cursor.
                if IsCursorHoldingItem() then
                    HandleCellDrop(cell)
                    return
                end
                namespace:ClearBankColumnConfig(selectedTab, col)
                RebuildGrid()
            end)
            -- WR-02: forward a drag-release drop landing on the clear button to the same
            -- shared handler (mirrors cell's own OnReceiveDrag, line 271 -- no extra guard
            -- needed since HandleCellDrop already no-ops without a cursor item).
            cell.clearBtn:HookScript("OnReceiveDrag", function() HandleCellDrop(cell) end)

            -- BANKCFG-05 "Copy to..." affordance: right-click a configured cell (planner
            -- discretion within the design contract -- cells are too narrow for a third
            -- hover icon alongside the clear (x)) opens the Task-1 popup for this column.
            -- Tooltip's dimmed second line hints at the affordance (skill's own
            -- "title + gedimmte Beschreibungszeile" tooltip convention).
            WTUI:AddTooltip(cell, itemName, "Right-click: Copy to...")
        else
            cell.placeholder = cell:CreateFontString(nil, "ARTWORK")
            cell.placeholder:SetFontObject(WildThingsFont15)
            cell.placeholder:SetText("+")
            cell.placeholder:SetTextColor(TEXT_DIM.r, TEXT_DIM.g, TEXT_DIM.b, TEXT_DIM.a)
            cell.placeholder:SetPoint("CENTER")
        end

        cell:SetScript("OnMouseUp", function(_, button)
            if button == "LeftButton" then
                if IsCursorHoldingItem() then
                    HandleCellDrop(cell)
                else
                    SelectColumn(col)
                end
            elseif button == "RightButton" and entry then
                ShowCopyToPopup(col)
            end
        end)

        -- ASSIGN-04 hover-glow: HookScript (not SetScript) so this COMPOSES with the
        -- configured-cell clear-(x) OnEnter/OnLeave above and works on empty cells too.
        cell:HookScript("OnEnter", function()
            if IsCursorHoldingItem() then
                local pulse = WTUI:BorderPulse()
                WTUI:StartGlow(cell, "dropTarget", function(owner, dt)
                    -- Self-terminating: clears the instant the cursor stops holding an
                    -- item, even if the mouse never leaves the cell.
                    if not IsCursorHoldingItem() then
                        ClearCellDropGlow(owner)
                        return
                    end
                    pulse(owner, dt)
                end)
            end
        end)
        cell:HookScript("OnLeave", function() ClearCellDropGlow(cell) end)

        return cell
    end

    -- SelectColumn(col): records the active column the shared assign field targets and
    -- repaints every cell's active-column highlight.
    function SelectColumn(col)
        selectedCol = col
        for _, cell in ipairs(cells) do
            cell.activeOverlay:SetShown(cell.col == selectedCol)
        end
    end

    -- RebuildGrid(): wipes + rebuilds all Private.BANK_COLS_PER_TAB cells for selectedTab,
    -- wrapping to two rows only when the window is too narrow for a legible single row
    -- (GroupGrid.lua's loop-index layout idiom, copied -- not referenced at runtime; it is
    -- dev-only/excluded from packaged builds), then relayouts everything below it.
    function RebuildGrid()
        for _, cell in ipairs(cells) do
            -- CR-02: force-blur a still-focused inline stack-size box before hiding and
            -- unparenting its cell -- hiding/reparenting a focused EditBox does not
            -- reliably clear its keyboard focus, which would otherwise leave the game
            -- routing keyboard input to a dead, detached frame (and stale-target
            -- CR-01's shift-click hook). ClearFocus() fires OnEditFocusLost, which nils
            -- EditBox.lua's currentlyFocused tracker when it points at this box.
            if cell.sizeBox and cell.sizeBox:HasFocus() then
                cell.sizeBox:ClearFocus()
            end
            -- WR-01: stop any in-flight drop-target glow before teardown -- mirrors the
            -- root OnHide sweep below; without this, a click on a cell's own clear (x)
            -- button (which never fires OnLeave) leaves the glow driver ticking
            -- SetBorderColor against this now-hidden, unparented, dead frame.
            ClearCellDropGlow(cell)
            cell:Hide()
            cell:SetParent(nil)
        end
        wipe(cells)

        local totalCols = namespace.BANK_COLS_PER_TAB
        local availWidth = gridHost:GetWidth()
        if not availWidth or availWidth <= 0 then availWidth = 1 end

        local colsPerRow = totalCols
        local cellWidth = math.floor(availWidth / colsPerRow)
        if cellWidth < MIN_CELL_WIDTH then
            colsPerRow = math.ceil(totalCols / 2)
            cellWidth = math.floor(availWidth / colsPerRow)
        end

        for col = 1, totalCols do
            local cell = CreateGridCell(col)
            local idx = col - 1
            local rowIdx = math.floor(idx / colsPerRow)
            local colIdx = idx % colsPerRow
            cell:SetWidth(cellWidth)
            cell:ClearAllPoints()
            cell:SetPoint("TOPLEFT", gridHost, "TOPLEFT", colIdx * cellWidth, -rowIdx * (CELL_HEIGHT + Theme.GAP))
            cells[#cells + 1] = cell
        end

        local numRows = math.ceil(totalCols / colsPerRow)
        gridHost:SetHeight(numRows * CELL_HEIGHT + (numRows - 1) * Theme.GAP)

        RelayoutBelowGrid()
    end

    ------------------------------------------------------------------
    -- Bulk-copy "Copy to..." popup (BANKCFG-05): a create-once multi-select popup
    -- composed from namespace:CreatePopupWindow (shell: Pop/SetText/SetAdditionalHeight/
    -- click-outside dismiss, reused as-is) + a vertical stack of namespace:CreateCheckButton
    -- rows (one per candidate target column) + a Confirm("Apply")/Cancel row mirroring
    -- ConfirmWindow.lua's exact layout (Confirm bottom-right, Cancel bottom-left). No
    -- CreateChecklistWindow widget exists anywhere in this codebase (33-PATTERNS.md "No
    -- Analog Found") -- this composite IS that thing, built here from the two primitives.
    -- Population (which row is hidden/labelled for a given source cell) and the real
    -- Apply fan-out are wired per-open in Task 2's ShowCopyToPopup, below.
    ------------------------------------------------------------------
    local function CreateBankCopyPopup()
        local popupWindow = namespace:CreatePopupWindow()

        -- Confirm("Apply")/Cancel row -- mirrors ConfirmWindow.lua's construction verbatim
        -- (Confirm bottom-right, Cancel bottom-left of the popup, both anchored to the
        -- popup's own BOTTOM so they stay correctly placed regardless of text/row height).
        popupWindow.confirmButton = namespace:CreateButton(popupWindow, "|cff00ff00Apply|r", function()
            popupWindow.confirmButton.OnClick()
            popupWindow:Hide()
            popupWindow.frameCover:Hide()
        end)
        popupWindow.confirmButton:SetPoint(
            "BOTTOMRIGHT", popupWindow, "BOTTOM", -0.5 * COPY_BUTTON_MARGIN, COPY_BUTTON_MARGIN)

        popupWindow.cancelButton = namespace:CreateButton(popupWindow, "|cffff0000Cancel|r", function()
            popupWindow:Hide()
            popupWindow.frameCover:Hide()
        end)
        popupWindow.cancelButton:SetPoint(
            "BOTTOMLEFT", popupWindow, "BOTTOM", 0.5 * COPY_BUTTON_MARGIN, COPY_BUTTON_MARGIN)

        function popupWindow:SetOnConfirm(OnConfirm)
            popupWindow.confirmButton.OnClick = OnConfirm
        end

        -- One CreateCheckButton row per candidate target column (1..BANK_COLS_PER_TAB of
        -- the current tab -- other-tab targets are Claude's-discretion scope per
        -- 33-CONTEXT.md and omitted to keep the list legible). Left-aligned (not centered)
        -- so relabelling a row's text later (ShowCopyToPopup) never needs repositioning.
        -- The row matching the open's source column is hidden per-open, not rebuilt.
        popupWindow.rows = {}
        local totalCols = namespace.BANK_COLS_PER_TAB
        local rowHeight = Theme.SIZE_CHECKBOX
        for col = 1, totalCols do
            local row = namespace:CreateCheckButton(popupWindow, "Col " .. col, function() end, false)
            local idxFromBottom = totalCols - col
            local yOffset = COPY_BUTTON_MARGIN + popupWindow.confirmButton:GetHeight() + COPY_ROW_GAP
                + idxFromBottom * (rowHeight + COPY_ROW_GAP)
            row:SetPoint("BOTTOMLEFT", popupWindow, "BOTTOMLEFT", COPY_ROW_PAD, yOffset)
            popupWindow.rows[col] = row
        end

        popupWindow:SetAdditionalHeight(
            popupWindow.confirmButton:GetHeight() + COPY_BUTTON_MARGIN
            + totalCols * (rowHeight + COPY_ROW_GAP) + COPY_ROW_GAP
        )

        return popupWindow
    end

    -- Built once at screen construction (create-once contract, RosterScreen.lua's
    -- importPopup precedent) with a safe no-op Apply default; ShowCopyToPopup below
    -- reassigns SetOnConfirm with the real per-column fan-out handler on every open.
    local copyPopup = CreateBankCopyPopup()
    copyPopup:SetOnConfirm(function() end)

    -- GUARD-01: create-once wrong-tab guard popup (Task 3), primed with a safe no-op
    -- default -- mirrors copyPopup's own create-once contract above.
    local wrongTabGuardPopup = namespace:CreateConfirmWindow()
    wrongTabGuardPopup:SetOnConfirm(function() end)

    -- ShowCopyToPopup(col): snapshots the source cell's CURRENT {itemID, stackSize}
    -- (T-33-05 -- the copied payload is independent of any later edit to the source),
    -- relabels/shows every OTHER column's row (hides the source column's own row,
    -- resetting every checkbox unchecked), and wires Apply to fan the snapshot out to
    -- every checked target in a single action via SetBankColumnConfig, then RebuildGrid
    -- (BANKCFG-05 SC4). Guard: a no-longer-configured source cell is a silent no-op.
    function ShowCopyToPopup(col)
        local src = namespace:GetBankColumnConfig(selectedTab, col)
        if not src then return end

        local pendingSrc = { itemID = src.itemID, stackSize = src.stackSize }
        local pendingTab = selectedTab

        local itemName = GetItemInfo(src.itemID) or ("item:" .. tostring(src.itemID))
        copyPopup:SetText("Copy " .. itemName .. " to:")

        for c, row in ipairs(copyPopup.rows) do
            if c == col then
                row:Hide()
            else
                row:SetChecked(false)
                local label = "Col " .. c
                local target = namespace:GetBankColumnConfig(selectedTab, c)
                if target then
                    label = label .. ": " .. (GetItemInfo(target.itemID) or ("item:" .. tostring(target.itemID)))
                end
                row.title:SetText(label)
                row:Show()
            end
        end

        copyPopup:SetOnConfirm(function()
            for c, row in ipairs(copyPopup.rows) do
                if row:IsShown() and row:IsChecked() then
                    namespace:SetBankColumnConfig(pendingTab, c, pendingSrc.itemID, pendingSrc.stackSize)
                end
            end
            RebuildGrid()
        end)

        copyPopup:Pop(root)
    end

    ------------------------------------------------------------------
    -- Shared assign field (ASSIGN-01): one field bound to the selected column, not 14
    -- cramped per-cell EditBoxes (33-CONTEXT.md decision).
    ------------------------------------------------------------------
    local assignLabel = scrollFrame.contentFrame:CreateFontString(nil, "ARTWORK")
    assignLabel:SetFontObject(WildThingsFont13)
    assignLabel:SetText("Shift-click a link or type an itemID for the selected column")
    assignLabel:SetTextColor(TEXT_DIM.r, TEXT_DIM.g, TEXT_DIM.b, TEXT_DIM.a)
    assignLabel:SetJustifyH("LEFT")

    local assignRowHeight = TITLE_H + Theme.H_INPUT
    local assignRow = CreateFrame("Frame", nil, scrollFrame.contentFrame)
    assignRow:SetHeight(assignRowHeight)

    -- Forward-declared: OnAssignClicked's closure (below) reads sharedAssignBox before the
    -- EditBox itself is constructed a few lines further down (mirrors this file's own
    -- former OnAddClicked/itemEditBox forward-declare idiom).
    local function OnAssignClicked()
        local text = sharedAssignBox:GetText()
        local trimmed = text and text:match("^%s*(.-)%s*$") or ""
        if trimmed == "" then return end

        local itemID = tonumber(trimmed) or GetItemInfoInstant(trimmed)
        if not itemID then
            print("|cff99ff00Wild Things:|r Could not resolve that item — shift-click a link or type a numeric itemID.")
            return
        end

        local existing = namespace:GetBankColumnConfig(selectedTab, selectedCol)
        local stackSize = existing and existing.stackSize or DEFAULT_STACK
        namespace:SetBankColumnConfig(selectedTab, selectedCol, itemID, stackSize)
        sharedAssignBox:SetText("")
        RebuildGrid()
    end

    local assignBtn = namespace:CreateButton(assignRow, "Assign", function() OnAssignClicked() end)
    assignBtn:SetPoint("BOTTOMRIGHT", assignRow, "BOTTOMRIGHT", 0, 0)

    sharedAssignBox = namespace:CreateEditBox(assignRow, "Item link or itemID", function() end)
    sharedAssignBox:SetPoint("BOTTOMLEFT", assignRow, "BOTTOMLEFT", 0, 0)
    sharedAssignBox:SetPoint("BOTTOMRIGHT", assignBtn, "BOTTOMLEFT", -Theme.GAP, 0)

    ------------------------------------------------------------------
    -- Organize
    ------------------------------------------------------------------
    local organizeHeader = namespace:CreateSectionHeader(scrollFrame.contentFrame, "Organize")

    local organizeBtn = namespace:CreateButton(scrollFrame.contentFrame, "Organize", function()
        if namespace:IsBankOrganizeRunning() then
            namespace:CancelBankOrganize() -- Stop path: never tab-gated
        else
            -- GUARD-01: read live, INSIDE the closure, every press -- never cached/hoisted.
            -- Gate on the guild bank ACTUALLY being open: GetCurrentGuildBankTab() keeps
            -- returning the last-selected tab after the bank is closed, so without this
            -- IsShown() check a closed bank produces a false-positive wrong-tab warning
            -- (mirrors BankOrganize.lua:IsBankOpen).
            local bankOpen = GuildBankFrame ~= nil and GuildBankFrame:IsShown()
            local openTab = GetCurrentGuildBankTab()
            if bankOpen and openTab and openTab >= 1 and openTab ~= selectedTab then
                wrongTabGuardPopup:SetText(
                    ("You're viewing Tab %d's config but Tab %d is open. Organize will " ..
                     "apply Tab %d's config. Continue?"):format(selectedTab, openTab, openTab))
                wrongTabGuardPopup:SetOnConfirm(function() namespace:OrganizeCurrentBankTab() end)
                wrongTabGuardPopup:Pop(root)
            else
                namespace:OrganizeCurrentBankTab() -- SC4: matching tab (or none open) -- no dialog
            end
        end
        if UpdateBankProgressUI then UpdateBankProgressUI() end
    end)
    -- Locks the button's width to "Organize"'s size so toggling to the shorter "Stop"
    -- label never jitters the layout (mirrors RosterScreen.lua's Stop-twin SetFixedWidth).
    organizeBtn:SetFixedWidth(true)

    local progressBar = namespace:CreateProgressBar(scrollFrame.contentFrame)
    progressBar:Hide()

    -- UpdateBankProgressUI (forward-referenced by organizeBtn's OnClick above): reads the
    -- engine's live progress, drives the bar + toggles the button label. Bound to
    -- Private.NotifyBankProgressChanged (a SEPARATE callback from RosterScreen's
    -- Private.NotifyProgressChanged, per BankOrganize.lua) so engine ticks repaint this
    -- screen live.
    function UpdateBankProgressUI()
        local prog = namespace:GetBankOrganizeProgress()
        if prog.running then
            progressBar:SetValue(prog.total and prog.total > 0 and prog.done / prog.total or 0, prog.label)
            progressBar:Show()
            organizeBtn:SetText("Stop")
        else
            progressBar:Hide()
            organizeBtn:SetText("Organize")
        end
    end
    namespace.NotifyBankProgressChanged = UpdateBankProgressUI

    -- Repositions the shared assign row + Organize section beneath the grid's CURRENT
    -- height, then resizes the scrollable content to fit (this file's own former
    -- RebuildPillFlow/RelayoutBelowPills variable-height rebuild idiom, 33-PATTERNS.md).
    function RelayoutBelowGrid()
        local belowY = gridHostY - gridHost:GetHeight() - Theme.GAP

        assignLabel:ClearAllPoints()
        assignLabel:SetPoint("TOPLEFT", scrollFrame.contentFrame, "TOPLEFT", 0, belowY)
        assignLabel:SetPoint("TOPRIGHT", scrollFrame.contentFrame, "TOPRIGHT", 0, belowY)
        belowY = belowY - assignLabel:GetStringHeight() - Theme.GAP

        assignRow:ClearAllPoints()
        assignRow:SetPoint("TOPLEFT", scrollFrame.contentFrame, "TOPLEFT", 0, belowY)
        assignRow:SetPoint("TOPRIGHT", scrollFrame.contentFrame, "TOPRIGHT", 0, belowY)
        belowY = belowY - assignRowHeight - Theme.GAP

        organizeHeader:ClearAllPoints()
        organizeHeader:SetPoint("TOPLEFT", scrollFrame.contentFrame, "TOPLEFT", 0, belowY)
        organizeHeader:SetPoint("TOPRIGHT", scrollFrame.contentFrame, "TOPRIGHT", 0, belowY)
        belowY = belowY - organizeHeader:GetHeight() - Theme.GAP

        organizeBtn:ClearAllPoints()
        organizeBtn:SetPoint("TOPLEFT", scrollFrame.contentFrame, "TOPLEFT", 0, belowY)

        progressBar:ClearAllPoints()
        progressBar:SetPoint("TOPLEFT", organizeBtn, "TOPRIGHT", Theme.GAP, 0)
        progressBar:SetPoint("RIGHT", scrollFrame.contentFrame, "RIGHT", 0, 0)

        belowY = belowY - Theme.H_BTN - Theme.GAP

        scrollFrame.contentFrame:SetHeight(-belowY)
        scrollFrame:FullUpdate()
    end

    RebuildGrid()
    UpdateBankProgressUI()

    -- First-open fix: at screen-build time gridHost has no resolved width yet (WoW's
    -- layout pass runs after this constructor returns), so the RebuildGrid() above lays
    -- out 14 zero-width (invisible) cells -- the "empty grid on first open after
    -- reload/login until you click a tab" symptom. When the layout pass gives gridHost
    -- its real width, OnSizeChanged fires; rebuild once at the resolved width so the
    -- pre-selected first tab's columns paint without a manual tab click. Guarded on a
    -- changed width (RebuildGrid only mutates gridHost's HEIGHT, never its width, so this
    -- never self-retriggers) and re-arms on genuine window resizes.
    local lastGridWidth
    gridHost:SetScript("OnSizeChanged", function(self, width)
        width = math.floor(width or 0)
        if width > 0 and width ~= lastGridWidth then
            lastGridWidth = width
            -- Defer to the next frame instead of rebuilding synchronously inside the
            -- layout-pass OnSizeChanged: a stack-size EditBox created mid-layout does not
            -- paint its programmatic SetText until the next interaction (the "stack-size
            -- fields empty until a tab switch" symptom). Running RebuildGrid a frame later
            -- -- exactly as a tab click does -- lets the boxes render their primed values.
            C_Timer.After(0, RebuildGrid)
        end
    end)

    -- A run started/finished while this screen was hidden must render correctly the
    -- next time it's shown (RosterScreen.lua's UpdateProgressUI precedent).
    root:SetScript("OnShow", function() UpdateBankProgressUI() end)

    -- ASSIGN-04 belt-and-suspenders: an ancestor Hide() doesn't propagate to descendants
    -- in WoW's frame model, so a cell's own OnLeave never fires if the window closes
    -- mid-hover-with-item. RebuildGrid already self-heals this on the next tab switch
    -- (cells are torn down/recreated); this sweep closes the gap for a close-then-reopen
    -- with no tab switch in between (GroupGrid.lua's own root OnHide precedent).
    root:HookScript("OnHide", function()
        for _, c in ipairs(cells) do ClearCellDropGlow(c) end
    end)

    return root
end
