---@diagnostic disable: undefined-field, undefined-global
---@class Private
local Private = select(2, ...)

-- Guild Bank Tab Organizer (WW1-BANK-ORGANIZE / v2.4 column-scoped): a paced
-- split/spread drain that places each user-configured item into every guild-bank
-- COLUMN assigned to it in the CURRENTLY-OPEN tab, filling each column's 7 slots up to
-- that column's configured stackSize (stackSize=1 reproduces the retired flat
-- "atomize to 1-per-slot" behavior as a special case). Modeled on RosterArrange.lua's
-- paced drain shape: ONE mutating action per settled state, a fresh re-snapshot every
-- tick, self-reschedule via C_Timer.After, an unconditional safety deadline, and
-- supersede-via-generation. Entirely local: only mutates the presser's own open guild
-- bank view, sends no chat/addon-message/network payload.
--
-- Guild-bank writes are async server round-trips: QueryGuildBankTab is a request, and
-- GetGuildBankItemInfo reads a cache the server updates on its own schedule. So the
-- drain does NOT fire on a blind fixed interval -- after each split+place it waits on
-- a LANDING-CONFIRMATION gate (tab no longer locked AND the destination slot's count
-- reached the expected post-op count) before issuing the next. This makes it
-- latency-tolerant: under a lag spike it waits more ticks instead of stacking a second
-- operation onto an unsettled first one (the overlap that used to strand the split-off
-- item on the cursor). A bounded settle timeout re-syncs to reality if confirmation
-- never arrives.
--
-- Ships in every packaged build (no battletag/roster data), so it must not assume
-- Private.opProgress or any other RosterArrange.lua-seeded state exists (that file is
-- stripped from packaged builds) -- see the defensive init below.
--
-- Private.* deps: Private:GetBankTabConfig (Migrations.lua, Phase 31) for the
-- per-tab/per-column {itemID, stackSize} config, Private.BANK_COLS_PER_TAB (14), and
-- the shared Private.opProgress surface. Never reads/writes any roster/battletag data.

-- Defensive init (RosterArrange.lua normally seeds this, but is stripped from
-- packaged builds).
Private.opProgress = Private.opProgress or {}
Private.bankGen = Private.bankGen or 0

local BANK_PACE = 0.4     -- seconds between paced ticks (API-throttle-friendly)
local BANK_SETTLE = 6     -- seconds; max wait for one op to land before re-syncing
local BANK_DEADLINE = 180 -- seconds; unconditional safety stop

-- nil while idle, else { gen, tab, startTime, targets } plus the confirmation-gate
-- fields set per-op by BankOrganizeTick: waiting, expectSlot, expectCount, waitStart.
local bankDrain
local BankOrganizeTick -- forward-declared; self-reschedules via C_Timer.After

-- Event-free, synchronous bank-open probe. Retail does have guild-bank events
-- (GUILDBANKBAGSLOTS_CHANGED, GUILDBANK_ITEM_LOCK_CHANGED), but this drain
-- deliberately does not hinge on them -- it reads the client cache directly and
-- paces via the landing-confirmation gate instead. GuildBankFrame is
-- Blizzard_GuildBankUI's load-on-demand frame -- nil before the bank is ever
-- opened this session.
local function IsBankOpen()
    return GuildBankFrame ~= nil and GuildBankFrame:IsShown()
        and (GetNumGuildBankTabs() or 0) > 0
        and (GetCurrentGuildBankTab() or 0) >= 1
end

local function GetMaxSlots()
    return MAX_GUILDBANK_SLOTS_PER_TAB or 98
end

-- Resolves a guild bank item link to its numeric itemID. Links carry random/bonus-id
-- suffixes, so slots are always compared by itemID, never raw link string equality.
local function ResolveItemID(link)
    if not link then return nil end
    local id = GetItemInfoInstant(link)
    if id then return id end
    local idStr = link:match("item:(%d+)")
    return idStr and tonumber(idStr) or nil
end

-- Sibling of Private.NotifyProgressChanged (RosterArrange.lua) -- a SEPARATE callback
-- slot, never reused, so binding this here can never clobber the Roster screen's own
-- live progress-bar binding.
local function NotifyBankProgress()
    if Private.NotifyBankProgressChanged then Private.NotifyBankProgressChanged() end
end

-- Cursor-safety detection (corrected, Phase 35 Task 2): the engine only ever puts real
-- items on the cursor via SplitGuildBankItem, so a bare "is the cursor holding
-- anything" check is sufficient here -- a bare non-nil GetCursorInfo() return is the
-- reliable signal (in-game confirmed this same day, UI/Screens/BankScreen.lua, commit
-- 7475447: the tab-count-based cursor global does NOT reliably detect a guild-bank-
-- sourced cursor item).
local function IsCursorHoldingItem()
    return GetCursorInfo() ~= nil
end

local function EndBankDrain()
    bankDrain = nil
    Private.opProgress.bankOrganize = nil
    NotifyBankProgress()
end

-- Public surface for the UI (mirrors RosterArrange.lua's IsOperationRunning /
-- GetOperationProgress / CancelOperations shape).
function Private:IsBankOrganizeRunning()
    return bankDrain ~= nil
end

function Private:GetBankOrganizeProgress()
    if bankDrain and Private.opProgress.bankOrganize then
        local p = Private.opProgress.bankOrganize
        return {
            running = true,
            label = string.format("Organizing %d/%d", p.done, p.total),
            done = p.done,
            total = p.total,
        }
    end
    return { running = false }
end

-- The drain is idempotent/re-runnable by construction (a fresh re-snapshot every
-- tick) -- cancelling only needs to stop scheduling further ticks; there is no
-- half-applied state to unwind.
function Private:CancelBankOrganize()
    bankDrain = nil
    Private.bankGen = (Private.bankGen or 0) + 1
    Private.opProgress.bankOrganize = nil
    NotifyBankProgress()
end

--[[ ---------------------------------------------------------------------------------
Pure targeting layer (zero WoW-API calls, offline hand-traceable per project
convention -- .claude/CLAUDE.md: "offline unit-style checks for the parser/generator
where feasible"). Everything below operates on already-taken data (tabConfig or the
tick's slotInfo snapshot); nothing here reads or writes the live game state.
----------------------------------------------------------------------------------]]

-- Slot<->column geometry, verified against Blizzard_GuildBankUI.lua/.xml (14 visual
-- columns x 7 rows, paired sub-columns -- NOT naive row-major; ARCHITECTURE.md
-- Anti-Pattern 1). Copied verbatim from 32-RESEARCH.md -- do not re-derive/simplify.
local function SlotsForColumn(col)
    local outerGroup = math.ceil(col / 2)          -- Blizzard's 1..7 pair index
    local base = (outerGroup - 1) * 14 + (col % 2 == 1 and 0 or 7)
    local slots = {}
    for row = 1, 7 do slots[row] = base + row end
    return slots
end
-- SlotsForColumn(1)  -> {1,2,3,4,5,6,7}
-- SlotsForColumn(2)  -> {8,9,10,11,12,13,14}
-- SlotsForColumn(3)  -> {15,16,17,18,19,20,21}
-- SlotsForColumn(14) -> {92,93,94,95,96,97,98}

local function ColumnForSlot(slot)
    local outerGroup = math.ceil(slot / 14)
    local idx14 = ((slot - 1) % 14) + 1
    return idx14 <= 7 and (2 * outerGroup - 1) or (2 * outerGroup)
end

-- Config -> targets translation. Input: WildThingsSavedVars.bankorganize.tabs[tabIndex]
-- (or nil). Output: { [itemID] = { {column=N, slots={...7 slot numbers...},
-- stackSize=K}, ... } }, derived/in-memory only -- never persisted. Iterates
-- col = 1..BANK_COLS_PER_TAB EXPLICITLY (not pairs() over the sparse config) so each
-- itemID's entry list comes out column-ascending "for free" -- PickDestinationSlot's
-- fill order depends on this ordering.
function Private:BuildBankOrganizeTargets(tabConfig)
    local targets = {}
    if not tabConfig or type(tabConfig.columns) ~= "table" then
        return targets
    end
    for col = 1, Private.BANK_COLS_PER_TAB do
        local cfg = tabConfig.columns[col]
        if cfg and cfg.itemID then
            targets[cfg.itemID] = targets[cfg.itemID] or {}
            local list = targets[cfg.itemID]
            list[#list + 1] = {
                column = col,
                slots = SlotsForColumn(col),
                stackSize = cfg.stackSize or 1,
            }
        end
    end
    return targets
end

-- Builds the set of "this item's own configured slots" (union of every target entry's
-- slots) plus a column->entry lookup, so PickSourceStack can find a given own-slot's
-- stackSize cap (via ColumnForSlot) without a second linear scan of entries.
local function BuildOwnSlotIndex(entries)
    local ownSlots, entryByColumn = {}, {}
    for _, entry in ipairs(entries) do
        entryByColumn[entry.column] = entry
        for _, s in ipairs(entry.slots) do
            ownSlots[s] = true
        end
    end
    return ownSlots, entryByColumn
end

-- (b) Picking the SOURCE stack and split count. Loose stock (outside this item's own
-- configured slots, whole stack drawable) is preferred first, then Surplus (an
-- own-slot already at/over its own stackSize cap contributes only its excess) -- never
-- drains a still-deficient sibling target slot (that would just move the deficit
-- around). Lowest slot number wins ties within each tier.
local function PickSourceStack(targets, itemID, slotInfo, excludeSlot)
    local entries = targets[itemID]
    if not entries then return nil end
    local ownSlots, entryByColumn = BuildOwnSlotIndex(entries)

    local looseSlot, surplusSlot, surplusAvailable
    for slot = 1, #slotInfo do
        local info = slotInfo[slot]
        if slot ~= excludeSlot and info and info.itemID == itemID and info.count > 0 then
            if not ownSlots[slot] then
                looseSlot = slot
                break -- loose always wins; ascending scan already gives lowest slot
            elseif not surplusSlot then
                local entry = entryByColumn[ColumnForSlot(slot)]
                if entry and info.count > entry.stackSize then
                    surplusSlot = slot
                    surplusAvailable = info.count - entry.stackSize
                end
            end
        end
    end

    if looseSlot then
        return { sourceSlot = looseSlot, available = slotInfo[looseSlot].count }
    end
    if surplusSlot then
        return { sourceSlot = surplusSlot, available = surplusAvailable }
    end
    return nil -- no source available right now for this itemID
end

-- (a) Picking the DESTINATION slot. A COUPLED scan with PickSourceStack -- a candidate
-- destination is only selected if a source exists for it right now; otherwise the scan
-- continues to the next candidate rather than stalling on an unfillable slot.
local function PickDestinationSlot(targets, slotInfo)
    local itemIDs = {}
    for itemID in pairs(targets) do itemIDs[#itemIDs + 1] = itemID end
    table.sort(itemIDs) -- stable, arbitrary-but-deterministic item-to-item order

    for _, itemID in ipairs(itemIDs) do
        for _, entry in ipairs(targets[itemID]) do -- ascending column order
            for _, slot in ipairs(entry.slots) do  -- top-to-bottom within the column
                local info = slotInfo[slot]
                local need, destCurrentCount
                if info then
                    if not info.itemID then
                        need, destCurrentCount = entry.stackSize, 0 -- empty -> eligible
                    elseif info.itemID == itemID and info.count < entry.stackSize then
                        need, destCurrentCount = entry.stackSize - info.count, info.count
                    end
                    -- itemID at/over cap, or a DIFFERENT item occupying the slot: skip,
                    -- never evicted -- documented known limitation.
                end
                if need then
                    local source = PickSourceStack(targets, itemID, slotInfo, slot)
                    if source then
                        return {
                            itemID = itemID,
                            destSlot = slot,
                            need = need,
                            destCurrentCount = destCurrentCount,
                            sourceSlot = source.sourceSlot,
                            splitCount = math.min(need, source.available),
                        }
                    end
                    -- no supply for THIS destination right now -- do not stall, keep scanning
                end
            end
        end
    end
    return nil -- converged: no eligible (destination, source) pair anywhere in the tab
end

-- (c) Native max-stack lookup for the consolidation ceiling (OVERFLOW-01): the ITEM'S
-- true max stack size, not the configured per-column stackSize (a display-neatness
-- preference, not the game's hard cap). C_Item.GetItemInfo returns nil for an item not
-- yet in the client's item cache -- callers MUST treat nil as "skip this item this
-- tick," never arithmetic against a nil cap; the call itself re-triggers the async
-- fetch, so a later tick's fresh call typically resolves within 1-2 ticks.
local function GetNativeMaxStack(itemID)
    local _, _, _, _, _, _, _, itemStackCount = C_Item.GetItemInfo(itemID)
    return itemStackCount
end

-- (c.1) Consolidation move selection (OVERFLOW-01 default disposition): packs the
-- loose surplus onto the LEFTMOST configured column's first row (entries[1].slots[1]
-- -- entries is column-ascending per BuildBankOrganizeTargets), up to the item's
-- native max stack, before advancing to slots[2], [3]... Reuses PickSourceStack
-- verbatim for the source half; only the destination-selection half is new. Returns
-- nil once the whole leftmost column is at native max (or no source remains) -- the
-- tick then converges and the leftover surplus is reported, never an error.
local function ResolveConsolidateMove(targets, slotInfo, itemID)
    local entries = targets[itemID]
    local leftmost = entries[1] -- column-ascending invariant, BuildBankOrganizeTargets
    local nativeMax = GetNativeMaxStack(itemID)
    if not nativeMax then return nil end -- uncached this tick; skip, retry next tick

    for _, slot in ipairs(leftmost.slots) do -- row 1, 2, 3... within the leftmost column only
        local info = slotInfo[slot]
        if not info or not info.itemID or info.itemID == itemID then
            local current = (info and info.itemID == itemID) and info.count or 0
            local room = nativeMax - current
            if room > 0 then
                local source = PickSourceStack(targets, itemID, slotInfo, slot)
                if source then
                    return {
                        kind = "bank",
                        overflow = true,
                        itemID = itemID,
                        destSlot = slot,
                        destCurrentCount = current,
                        column = leftmost.column,
                        sourceSlot = source.sourceSlot,
                        splitCount = math.min(room, source.available),
                    }
                end
            end
        end
    end
    return nil -- leftmost column fully at native max (or no source left): stop, report
end

local NUM_BAG_SLOTS = NUM_BAG_SLOTS or 4 -- legacy global, still current; Enum.BagIndex.Backpack == 0

-- (c.1b) Free/mergeable character-bag slot finder (OVERFLOW-02 bag-spill): the ONE
-- necessarily-impure new helper -- unlike the pure bank-side targeting math above, bag
-- contents can change between ticks (the player can loot manually), so this is a live
-- read, kept isolated here rather than interleaved with it. Scans bag IDs
-- 0..NUM_BAG_SLOTS (character bags only, RESEARCH Assumption A3 -- deliberately never
-- the reagent/bank/warband bag), skipping any bag whose bagFamily ~= 0 (profession/
-- reagent bags, RESEARCH Pitfall 2 / Assumption A2). Within each eligible bag, prefers
-- a MERGE target (an existing same-item slot under nativeMax) over a fresh empty slot.
-- Returns bag, slot, currentCount, or the string "bags_full" when no bag yields either.
local function FindFreeBagSlot(itemID, nativeMax)
    for bag = 0, NUM_BAG_SLOTS do
        local numFree, bagFamily = C_Container.GetContainerNumFreeSlots(bag)
        if bagFamily == 0 then -- planner-discipline-allow: bagFamily == 0
            local numSlotsFn = C_Container.GetContainerNumSlots or GetContainerNumSlots -- Assumption A4
            local numSlots = (numSlotsFn and numSlotsFn(bag)) or 0
            for slot = 1, numSlots do
                local info = C_Container.GetContainerItemInfo(bag, slot)
                if info and info.itemID == itemID and info.stackCount < nativeMax then
                    return bag, slot, info.stackCount
                end
            end
            if numFree and numFree > 0 then
                for slot = 1, numSlots do
                    if not C_Container.GetContainerItemInfo(bag, slot) then
                        return bag, slot, 0
                    end
                end
            end
        end
    end
    return "bags_full"
end

-- (c.1c) Bag-spill move selection (OVERFLOW-02 opt-in disposition): same source
-- picking as ResolveConsolidateMove (PickSourceStack, reused verbatim), but the
-- destination is a character-bag slot (FindFreeBagSlot) instead of a guild-bank slot.
-- Returns a kind="bag" move, the "bags_full" sentinel (propagated so the caller can
-- report the un-moved remainder), or nil (uncached item or no source available -- skip
-- this tick, never error).
local function ResolveBagSpillMove(targets, slotInfo, itemID)
    local nativeMax = GetNativeMaxStack(itemID)
    if not nativeMax then return nil end
    local source = PickSourceStack(targets, itemID, slotInfo, nil)
    if not source then return nil end

    local bag, bagSlot, destCurrentCount = FindFreeBagSlot(itemID, nativeMax)
    if bag == "bags_full" then return "bags_full" end

    local room = nativeMax - destCurrentCount
    return {
        kind = "bag",
        overflow = true,
        itemID = itemID,
        destBag = bag,
        destBagSlot = bagSlot,
        destCurrentCount = destCurrentCount,
        column = nil,
        sourceSlot = source.sourceSlot,
        splitCount = math.min(room, source.available),
    }
end

-- (c.2) Overflow surplus predicate, computed per-itemID: is every one of this item's
-- target entries' slots at-or-over its own stackSize cap (destFull -- no destination
-- remains eligible via PickDestinationSlot) AND does slotInfo still show loose stock
-- of this itemID sitting outside its own configured slots (looseRemainder)? Also
-- reports a THIRD value, ownSlotExcess: the sum, over every configured slot, of
-- max(0, ownCount - entry.stackSize) -- how much this item's OWN slots already carry
-- above their configured cap (e.g. surplus consolidation packed into slot 1 above
-- stackSize). This is CONTEXT-locked to differ by disposition: the SPILL disposition
-- adds ownSlotExcess to looseRemainder to form its capacity-bounded surplus (own-slot
-- excess is real, withdrawable stock), while the CONSOLIDATION disposition continues
-- to use looseRemainder alone (it intentionally packs above stackSize, so its own
-- excess is not "surplus" to itself). ResolveOverflowMove and the tick's convergence
-- readout both consume this single predicate; neither recomputes a parallel
-- definition of "surplus" (folded in from the retired PrintOverflowSummaryIfAny --
-- same walk, same semantics).
local function ComputeOverflowInfo(entries, slotInfo, itemID)
    local ownSlots = {}
    local destFull = true
    local ownSlotExcess = 0
    for _, entry in ipairs(entries) do
        for _, slot in ipairs(entry.slots) do
            ownSlots[slot] = true
            local info = slotInfo[slot]
            local blockedByOther = info and info.itemID and info.itemID ~= itemID
            local ownCount = (info and info.itemID == itemID) and info.count or 0
            if not blockedByOther and ownCount < entry.stackSize then
                destFull = false
            end
            if ownCount > entry.stackSize then
                ownSlotExcess = ownSlotExcess + (ownCount - entry.stackSize)
            end
        end
    end

    local looseRemainder = 0
    if destFull then
        for slot = 1, #slotInfo do
            local info = slotInfo[slot]
            if info and info.itemID == itemID and not ownSlots[slot] then
                looseRemainder = looseRemainder + info.count
            end
        end
    end

    return destFull, looseRemainder, ownSlotExcess
end

-- (c.3) Overflow-resolution dispatch: the fallback move-source BankOrganizeTick calls
-- once PickDestinationSlot returns nil (the spread itself has converged). For each
-- itemID (sorted, same ordering idiom as PickDestinationSlot), only a candidate that
-- is destFull AND has a loose remainder outside its own configured slots gets a move
-- resolved for it. Returns the first non-nil move found, tagged move.overflow = true
-- by ResolveConsolidateMove so the tick's per-item accumulator can track it; returns
-- nil once every candidate is exhausted (converged).
local function ResolveOverflowMove(targets, slotInfo, spillToBags)
    local itemIDs = {}
    for itemID in pairs(targets) do itemIDs[#itemIDs + 1] = itemID end
    table.sort(itemIDs)

    for _, itemID in ipairs(itemIDs) do
        local entries = targets[itemID]
        local destFull, looseRemainder, ownSlotExcess = ComputeOverflowInfo(entries, slotInfo, itemID)
        -- Capacity-bounded surplus (G-35-3 fix): the spill disposition also counts
        -- own-slot over-cap excess as withdrawable surplus (consolidation may have
        -- already packed it above stackSize, leaving looseRemainder=0); consolidation
        -- keeps its loose-outside-only definition, so this branch is byte-behaviour-
        -- identical to before this fix when spillToBags is false.
        local surplus = spillToBags and (looseRemainder + ownSlotExcess) or looseRemainder
        if destFull and surplus > 0 then
            local move
            if spillToBags then
                move = ResolveBagSpillMove(targets, slotInfo, itemID)
                if move == "bags_full" then
                    -- No bag move available for THIS item right now -- keep scanning
                    -- other items rather than treating it as a move. The convergence
                    -- readout still reports this item's un-moved remainder (the tick's
                    -- convergence branch recomputes ComputeOverflowInfo fresh, so the
                    -- fact reaches PrintOverflowResult without this resolver mutating
                    -- any accumulator itself).
                    move = nil
                end
            else
                move = ResolveConsolidateMove(targets, slotInfo, itemID)
            end
            if move then return move end
        end
    end
    return nil -- converged: no candidate yields a move
end

-- Read-only slotInfo scan (no gate/pacing/mutation) -- the same shape the paced tick's
-- own scan loop uses, but invoked once. Reused by OrganizeCurrentBankTab's
-- progress-total estimate and PreviewBankOrganize's snapshot; the tick's own scan loop
-- stays separate because it additionally builds emptySlots/anyLocked for the harness.
local function BuildSlotInfo(tab, maxSlots)
    local slotInfo = {}
    for slot = 1, maxSlots do
        local texture, itemCount, locked = GetGuildBankItemInfo(tab, slot)
        if not texture then
            slotInfo[slot] = { itemID = nil, count = 0, locked = false }
        elseif locked then
            slotInfo[slot] = { itemID = nil, count = itemCount or 0, locked = true }
        else
            slotInfo[slot] = {
                itemID = ResolveItemID(GetGuildBankItemLink(tab, slot)),
                count = itemCount or 1,
                locked = false,
            }
        end
    end
    return slotInfo
end

-- Sums this item's on-hand quantity across the WHOLE tab -- inside AND outside its
-- configured columns -- the same pool the real engine would eventually draw from.
-- Read-only: only inspects the already-taken slotInfo snapshot.
local function SumItemCount(slotInfo, itemID)
    local total = 0
    for slot = 1, #slotInfo do
        local info = slotInfo[slot]
        if info and info.itemID == itemID then
            total = total + info.count
        end
    end
    return total
end

-- Achievable per-entry placement capacity from the actual snapshot: a slot only
-- counts toward this item's capacity if it's empty or already holds this itemID
-- under stackSize -- a slot permanently blocked by a different item (never evicted)
-- contributes zero, matching what the real engine can actually place.
local function EntryCapacity(entry, itemID, slotInfo)
    local cap = 0
    for _, slot in ipairs(entry.slots) do
        local info = slotInfo[slot]
        if not info or not info.itemID or info.itemID == itemID then
            cap = cap + entry.stackSize
        end
    end
    return cap
end

-- Chat-prints PreviewBankOrganize's plan, one line per assigned item, e.g.
-- "Item 12345: 850 on hand -> col 3: 200, col 7: 200 (remainder 50 -> consolidate
-- onto column 3)" or "... (remainder 50 -> spill to bags)". Best-effort disposition
-- reflection (35-CONTEXT.md) -- reads WildThingsSavedVars.bankorganize.spill_to_bags
-- to word the remainder line only; the preview itself stays mutation-free.
local function PrintPreviewSummary(plan)
    local itemIDs = {}
    for itemID in pairs(plan) do itemIDs[#itemIDs + 1] = itemID end
    table.sort(itemIDs)

    local spillToBags = WildThingsSavedVars.bankorganize.spill_to_bags

    for _, itemID in ipairs(itemIDs) do
        local itemPlan = plan[itemID]
        local parts = {}
        for _, col in ipairs(itemPlan.columns) do
            parts[#parts + 1] = ("col %d: %d"):format(col.column, col.allocated)
        end
        local line = ("|cff99ff00Wild Things:|r Item %d: %d on hand -> %s")
            :format(itemID, itemPlan.totalOnHand, table.concat(parts, ", "))
        if itemPlan.remainder > 0 then
            if spillToBags then
                line = line .. (" (remainder %d -> spill to bags)"):format(itemPlan.remainder)
            else
                line = line .. (" (remainder %d -> consolidate onto column %d)")
                    :format(itemPlan.remainder, itemPlan.leftmostColumn)
            end
        end
        print(line)
    end
end

-- (d) OVERFLOW-03 readout: one |cff99ff00Wild Things:|r line per item that actually
-- had >=1 consolidated/spilled this run (`results` is built by the tick's
-- convergence branch from the per-run bankDrain.overflowMoved accumulator, sorted
-- itemID iteration same as PrintPreviewSummary). Silent when results is empty -- no
-- regression to the Phase 32 "silent unless there is real overflow" contract.
-- spillToBags picks the wording; the spill disposition's actual dispatch lands in
-- Plan 02.
local function PrintOverflowResult(results, spillToBags)
    local totalMoved = 0
    for _, r in ipairs(results) do
        local line
        if spillToBags then
            line = ("|cff99ff00Wild Things:|r Item %d: moved %d to your bags")
                :format(r.itemID, r.moved)
            if r.unmoved > 0 then
                line = line .. (" (%d could not fit)"):format(r.unmoved)
            end
        else
            line = ("|cff99ff00Wild Things:|r Item %d: consolidated %d onto column %d")
                :format(r.itemID, r.moved, r.column)
            if r.unmoved > 0 then
                line = line .. (" (%d left as-is)"):format(r.unmoved)
            end
        end
        print(line)
        totalMoved = totalMoved + r.moved
    end
    if spillToBags and #results > 1 then
        print(("|cff99ff00Wild Things:|r Moved %d items to your bags total"):format(totalMoved))
    end
end

-- One paced drain tick: bails if superseded/stopped, enforces the safety deadline,
-- re-checks the bank is still open (also doubles as the combat guard -- combat
-- auto-closes the guild bank), guards against a held cursor item, then takes ONE
-- fresh snapshot of the current tab and issues at most one split+place.
BankOrganizeTick = function(gen)
    if not bankDrain or bankDrain.gen ~= gen then return end -- superseded or cancelled

    if GetTime() - bankDrain.startTime > BANK_DEADLINE then
        print("|cff99ff00Wild Things:|r Guild Bank organize timed out — press Organize again.")
        EndBankDrain()
        return
    end

    if not IsBankOpen() then
        print("|cff99ff00Wild Things:|r Guild bank closed — press Organize again to continue.")
        EndBankDrain()
        return
    end

    local tab = bankDrain.tab
    local maxSlots = GetMaxSlots()

    -- One fresh snapshot for the whole tick. QueryGuildBankTab is an async request;
    -- GetGuildBankItemInfo reads the client cache the server keeps current on its own.
    -- We never ACT on this read until the confirmation gate below says the last op has
    -- landed, so a stale read only makes us wait longer -- never act early.
    QueryGuildBankTab(tab)

    local emptySlots = {}
    local slotInfo = {}
    local anyLocked = false

    for slot = 1, maxSlots do
        local texture, itemCount, locked = GetGuildBankItemInfo(tab, slot)
        if not texture then
            emptySlots[#emptySlots + 1] = slot
            slotInfo[slot] = { itemID = nil, count = 0, locked = false }
        elseif locked then
            anyLocked = true
            slotInfo[slot] = { itemID = nil, count = itemCount or 0, locked = true }
        else
            local itemID = ResolveItemID(GetGuildBankItemLink(tab, slot))
            slotInfo[slot] = { itemID = itemID, count = itemCount or 1, locked = false }
        end
    end

    -- Cursor-safety: a held item means a prior placement was dropped by the server.
    -- Re-place it into the first empty slot (or clear it if the tab is full) -- but
    -- only while the tab is NOT mid-lock, since placing into a settling tab is what
    -- stranded it in the first place. Re-arm and return either way.
    if IsCursorHoldingItem() then
        if not anyLocked then
            if emptySlots[1] then
                PickupGuildBankItem(tab, emptySlots[1])
            else
                ClearCursor()
            end
        end
        C_Timer.After(BANK_PACE, function() BankOrganizeTick(gen) end)
        return
    end

    -- Landing-confirmation gate: after issuing a split+place we must not issue the
    -- next one until the last one has actually landed on the server. "Landed" is a
    -- PER-SLOT check (the destination slot's count reached the expected post-op
    -- count) -- NOT the old tab-wide empty-slot-count delta, which cannot detect a
    -- merge into an already-partial target stack (32-RESEARCH.md Pitfall 1). Under a
    -- latency spike the cache still shows the pre-op state (or the slot still
    -- locked), so we simply wait more ticks instead of overlapping ops.
    if bankDrain.waiting then
        local landed
        if bankDrain.expectBag then
            -- Bag-destination landing gate (OVERFLOW-02): confirms against the LOCAL
            -- character-bag cache, not the guild-bank slotInfo snapshot -- the
            -- destination here is purely client-side inventory state.
            local bagInfo = C_Container.GetContainerItemInfo(bankDrain.expectBag, bankDrain.expectBagSlot)
            landed = bagInfo and bagInfo.itemID == bankDrain.expectItemID
                and bagInfo.stackCount >= bankDrain.expectCount
        else
            landed = slotInfo[bankDrain.expectSlot]
                and slotInfo[bankDrain.expectSlot].count >= bankDrain.expectCount
        end
        if not anyLocked and landed then
            bankDrain.waiting = false -- confirmed landed; fall through to next action
            -- OVERFLOW-03 accounting (WR-02): only now, with landing actually
            -- confirmed, tally the pending op into the per-item accumulator that
            -- PrintOverflowResult reads at convergence.
            if bankDrain.pendingOverflow then
                local pending = bankDrain.pendingOverflow
                local acc = bankDrain.overflowMoved[pending.itemID] or { moved = 0, column = pending.column }
                acc.moved = acc.moved + pending.splitCount
                acc.column = pending.column
                bankDrain.overflowMoved[pending.itemID] = acc
                bankDrain.pendingOverflow = nil
            end
        elseif GetTime() - bankDrain.waitStart < BANK_SETTLE then
            C_Timer.After(BANK_PACE, function() BankOrganizeTick(gen) end)
            return -- keep waiting for the server to settle
        else
            bankDrain.waiting = false -- settle timed out; re-sync to reality below
            bankDrain.pendingOverflow = nil -- never confirmed landed; don't count it as moved
        end
    end

    -- The server is still settling a prior action -- do not act this tick.
    if anyLocked then
        C_Timer.After(BANK_PACE, function() BankOrganizeTick(gen) end)
        return
    end

    local spillToBags = WildThingsSavedVars.bankorganize.spill_to_bags
    local move = PickDestinationSlot(bankDrain.targets, slotInfo)
    if not move then
        -- The spread itself has converged; fall back to overflow resolution (the
        -- third move-source) before treating the tab as fully converged. Never a
        -- synchronous burst -- at most one move, reschedules through the same tick.
        move = ResolveOverflowMove(bankDrain.targets, slotInfo, spillToBags)
    end

    if not move then
        -- Converged: no eligible (destination, source) pair anywhere in the tab, and
        -- no overflow move remains either. Covers "fully organized", "under-supply,
        -- nothing left to fill" (silent), and "overflow fully consolidated/spilled"
        -- (readout printed below) alike.
        local results = {}
        local itemIDs = {}
        for itemID in pairs(bankDrain.targets) do itemIDs[#itemIDs + 1] = itemID end
        table.sort(itemIDs)
        for _, itemID in ipairs(itemIDs) do
            local acc = bankDrain.overflowMoved[itemID]
            local moved = acc and acc.moved or 0
            local entries = bankDrain.targets[itemID]
            local destFull, looseRemainder, ownSlotExcess = ComputeOverflowInfo(entries, slotInfo, itemID)
            -- Capacity-bounded spill surplus (G-35-3 fix): mirrors ResolveOverflowMove's
            -- gate so the readout's inclusion/unmoved reflect the same predicate that
            -- decided whether a move was even attempted. Consolidation mode keeps
            -- looseRemainder alone (byte-identical to before this fix).
            local spillSurplus = looseRemainder + ownSlotExcess
            -- Include an item that moved >=1 (consolidation or spill), OR -- spill mode
            -- only -- an item still destFull-with-surplus that never got a single move
            -- in (bags full from the very first attempt, or the surplus sat entirely in
            -- own-slot excess): its unmoved count must still reach the readout per
            -- OVERFLOW-02's could-not-fit reporting requirement.
            if moved > 0 or (spillToBags and destFull and spillSurplus > 0) then
                results[#results + 1] = {
                    itemID = itemID,
                    moved = moved,
                    unmoved = spillToBags and spillSurplus or looseRemainder,
                    column = acc and acc.column or nil,
                }
            end
        end
        PrintOverflowResult(results, spillToBags)

        local p = Private.opProgress.bankOrganize
        if p then p.done = p.total end
        NotifyBankProgress()
        print("|cff99ff00Wild Things:|r Guild Bank organized.")
        EndBankDrain()
        return
    end

    -- Split move.splitCount off move.sourceSlot onto the cursor and place it into
    -- move.destSlot, then arm the confirmation gate: the next tick must see
    -- move.destSlot's count reach move.destCurrentCount + move.splitCount (and the tab
    -- unlocked) before we act again. This may be a PARTIAL fill (if the source didn't
    -- have enough to fully satisfy move.need) -- the same destination naturally
    -- re-selects on a later tick, continuing the top-up from whatever source becomes
    -- available next. A consolidation move (move.overflow) lands exactly the same way
    -- -- kind="bank" either way -- and additionally tallies the per-item accumulator
    -- the OVERFLOW-03 readout reads at convergence.
    bankDrain.waiting = true
    bankDrain.waitStart = GetTime()
    SplitGuildBankItem(tab, move.sourceSlot, move.splitCount)

    if move.kind == "bag" then
        if not IsCursorHoldingItem() then
            -- Split silently no-op'd (stale/raced source slot -- another officer
            -- withdrew/moved the stack between this tick's snapshot and now): do NOT
            -- touch the destination bag slot with an empty cursor, or
            -- PickupContainerItem would pick up whatever the player's own bag slot
            -- holds instead of placing anything. Bail this tick; a fresh snapshot
            -- re-resolves the move next tick.
            bankDrain.waiting = false
            C_Timer.After(BANK_PACE, function() BankOrganizeTick(gen) end)
            return
        end
        bankDrain.expectBag = move.destBag
        bankDrain.expectBagSlot = move.destBagSlot
        bankDrain.expectItemID = move.itemID
        bankDrain.expectCount = move.destCurrentCount + move.splitCount
        bankDrain.expectSlot = nil
        C_Container.PickupContainerItem(move.destBag, move.destBagSlot)
    else
        if not IsCursorHoldingItem() then
            -- Split silently no-op'd (same stale-cache precondition as the bag branch
            -- above): do NOT touch the destination guild-bank slot with an empty
            -- cursor, or PickupGuildBankItem would pick that slot's contents up onto
            -- the cursor instead of placing anything, displacing an already-correctly-
            -- placed stack out of its column. Bail this tick; a fresh snapshot
            -- re-resolves the move next tick.
            bankDrain.waiting = false
            C_Timer.After(BANK_PACE, function() BankOrganizeTick(gen) end)
            return
        end
        bankDrain.expectSlot = move.destSlot
        bankDrain.expectCount = move.destCurrentCount + move.splitCount
        bankDrain.expectBag = nil
        bankDrain.expectBagSlot = nil
        PickupGuildBankItem(tab, move.destSlot)
    end

    -- Record the pending move's identity now; it's only tallied into
    -- bankDrain.overflowMoved once the landing-confirmation gate above verifies the
    -- op actually took effect (WR-02) -- a settle-timeout re-sync discards this
    -- without counting it as moved.
    if move.overflow then
        bankDrain.pendingOverflow = { itemID = move.itemID, splitCount = move.splitCount, column = move.column }
    else
        bankDrain.pendingOverflow = nil
    end

    local p = Private.opProgress.bankOrganize
    if p then
        if move.overflow then
            -- WR-03: the initial total estimate only scans configured destination
            -- slots, so it never anticipates overflow ops -- grow the total as each
            -- one is dispatched so the bar doesn't read 100% while overflow moves
            -- (consolidation/spill) are still in flight.
            p.total = p.total + 1
        end
        p.done = math.min(p.done + 1, p.total)
        NotifyBankProgress()
    end

    C_Timer.After(BANK_PACE, function() BankOrganizeTick(gen) end)
end

-- Public entry point: reads the per-tab/per-column config LIVE at press-time (the
-- seam Phase 34's wrong-tab guard extends), then spreads every configured item across
-- its assigned columns, up to each column's stack size, paced over real time.
function Private:OrganizeCurrentBankTab()
    if not IsBankOpen() then
        print("|cff99ff00Wild Things:|r Open the guild bank first, then press Organize.")
        return
    end

    local tab = GetCurrentGuildBankTab()
    local tabConfig = Private:GetBankTabConfig(tab)
    if not tabConfig then
        print("|cff99ff00Wild Things:|r No bank config for this tab.")
        return
    end

    local targets = Private:BuildBankOrganizeTargets(tabConfig)
    local maxSlots = GetMaxSlots()

    -- Progress `total` approximation (32-RESEARCH.md "Progress total recomputation"):
    -- the count of currently-eligible destination slots at drain start, treating each
    -- as "1 op" best-case. Fragmented supply may need more than 1 op per slot, in
    -- which case p.done hits p.total before the drain actually finishes -- a harmless,
    -- already-guarded (math.min(done+1, total) below) progress-bar imprecision, not a
    -- correctness issue.
    QueryGuildBankTab(tab)
    local slotInfo = BuildSlotInfo(tab, maxSlots)
    local total = 0
    for _, entries in pairs(targets) do
        for _, entry in ipairs(entries) do
            for _, slot in ipairs(entry.slots) do
                local info = slotInfo[slot]
                if info and (not info.itemID or info.count < entry.stackSize) then
                    total = total + 1
                end
            end
        end
    end

    -- Supersede any running bank drain.
    Private.bankGen = (Private.bankGen or 0) + 1
    local gen = Private.bankGen
    bankDrain = { gen = gen, tab = tab, startTime = GetTime(), targets = targets, overflowMoved = {} }
    Private.opProgress.bankOrganize = { done = 0, total = total }
    NotifyBankProgress()

    BankOrganizeTick(gen)
end

-- ORGANIZE-04: mutation-free spread preview. Reports how each assigned item's current
-- on-hand quantity would spread across its configured columns, WITHOUT touching the
-- bank -- never calls SplitGuildBankItem/PickupGuildBankItem/ClearCursor, and takes
-- exactly ONE read-only snapshot (BuildSlotInfo, the same idiom the paced tick uses).
-- The allocation math is the FINAL-DISTRIBUTION form (deliberately simpler than the
-- tick's loose/surplus source-tier reasoning) -- it only reports how much lands in
-- each column, which is fully determined by the pool total and each entry's capacity,
-- in column order, independent of exactly which physical slot the stock currently
-- sits in.
function Private:PreviewBankOrganize()
    if not IsBankOpen() then
        print("|cff99ff00Wild Things:|r Open the guild bank first, then press Preview.")
        return nil
    end

    local tab = GetCurrentGuildBankTab()
    local tabConfig = Private:GetBankTabConfig(tab)
    if not tabConfig then
        print("|cff99ff00Wild Things:|r No bank config for this tab.")
        return nil
    end

    local targets = Private:BuildBankOrganizeTargets(tabConfig)
    QueryGuildBankTab(tab)
    local slotInfo = BuildSlotInfo(tab, GetMaxSlots())

    local itemIDs = {}
    for itemID in pairs(targets) do itemIDs[#itemIDs + 1] = itemID end
    table.sort(itemIDs)

    local plan = {}
    for _, itemID in ipairs(itemIDs) do
        -- Pool = everything currently on-hand for this item anywhere in the tab
        -- (inside AND outside its configured columns) -- the same pool the real
        -- engine would eventually draw from.
        local pool = SumItemCount(slotInfo, itemID)
        local itemPlan = {
            totalOnHand = pool,
            columns = {},
            leftmostColumn = targets[itemID][1].column, -- column-ascending invariant
        }
        for _, entry in ipairs(targets[itemID]) do -- ascending column order
            local allocated = math.min(pool, EntryCapacity(entry, itemID, slotInfo))
            pool = pool - allocated
            itemPlan.columns[#itemPlan.columns + 1] =
                { column = entry.column, stackSize = entry.stackSize, allocated = allocated }
        end
        itemPlan.remainder = pool -- > 0 means this item's supply exceeds capacity
        plan[itemID] = itemPlan
    end

    PrintPreviewSummary(plan)
    return plan
end
