---@diagnostic disable: undefined-field, undefined-global
---@type string
local AddonName = ...
---@class Private
local Private = select(2, ...)
local wt = Private.Ace

-- Invite + exact-order place + bench the parsed boss invitelist into the live
-- raid on the presser's own client, covering absent mains with their in-raid
-- alts (Phase 4). Entirely local: reads/writes the presser's own
-- GetRaidRosterInfo view, calls the native raid-group API directly, and reads
-- the locally-generated Private.RosterAlts map for substitution. Never sends
-- raid chat, a raid-warning broadcast, or a roster-swap addon message, and
-- never transmits any alt/battletag data off the client (see 04-CONTEXT.md).
--
-- Expected Private.* deps: Private.Ace (wt, above), Private.RosterAlts (alt
-- substitution map, generated offline), Private:CanonicalKey (03-01 name/realm
-- normalization), Private:ArrangeForEncounter/Private:ApplyTargetPositions
-- (this file's own public surface, called from RosterScreen.lua's chip/
-- "Invite & Arrange"/"Set groups" handlers -- the primary v2 UI -- and from
-- Roster.lua's dev-only fallback button OnClick closures -- deferred to user
-- interaction, never at file-load time).
--
-- Timing constraints: the hot-path event frames (rosterEventFrame) and the
-- CHAT_MSG_SYSTEM listener (inviteMsgFrame) are created lazily, on first use,
-- and registered/unregistered per drain -- not addon-lifetime. combatResumeFrame
-- is the one persistent, addon-lifetime frame in this file: created and
-- registered once at file load (mirrors UI/WindowRaiser.lua's persistent
-- PLAYER_REGEN_DISABLED registration) to catch combat-exit/reload/leader-change
-- resume triggers regardless of whether a drain is currently active.

-- Delegation to the shipped implementation in RaidSnapshot.lua (SNAP-02). This
-- file keeps no copy of the split-on-first-hyphen/own-realm-fallback logic --
-- only a local alias, so the eight existing call sites below read unchanged.
local function CanonicalizeLiveName(rawName)
    return Private:CanonicalizeLiveName(rawName)
end

-- Guarded group-mutation primitives (ARR-06). SetRaidSubgroup/SwapRaidSubgroup
-- are {{nocombat}}-tagged and throw a catchable Lua error when rejected during
-- combat lockdown -- pre-checking InCombatLockdown handles the common case,
-- pcall is defense-in-depth against the race where combat starts between the
-- check and the call (03-RESEARCH.md Pattern 1). A rejected/failed move
-- returns false and is simply left for the next button press to retry.
local function SafeSetRaidSubgroup(index, subgroup)
    if InCombatLockdown() then return false end
    return (pcall(SetRaidSubgroup, index, subgroup))
end

local function SafeSwapRaidSubgroup(indexA, indexB)
    if InCombatLockdown() then return false end
    return (pcall(SwapRaidSubgroup, indexA, indexB))
end

-- Guarded party->raid conversion (RAID-01/02/03). The solo short-circuit
-- MUST run first: UnitIsGroupLeader("player") returns false when solo, so a
-- naive not-leader guard here would wrongly block the very first invite from
-- a cold solo start. There is no party to convert yet -- the invite loop
-- below forms the party (presser becomes leader), and the event-driven invite
-- continuation (see InviteMissingFromTokens below) auto-converts the party to
-- a raid and re-invites the remaining planned members on GROUP_ROSTER_UPDATE,
-- ~1s later -- no second press required. Unlike SafeSetRaidSubgroup/
-- SafeSwapRaidSubgroup's silent-return, the non-leader and combat cases are
-- deliberately surfaced with a chat message -- they are distinct, actionable
-- failure reasons the presser needs to see (RAID-03), not a routine retry.
local function SafeConvertToRaid()
    if not IsInGroup() then return true end
    if not UnitIsGroupLeader("player") then
        print(("|cff99ff00Wild Things:|r %s"):format("You must be the group leader to convert the party to a raid."))
        return false
    end
    if InCombatLockdown() then
        print(("|cff99ff00Wild Things:|r %s"):format(
            "Can't convert to a raid while in combat — press again after combat."))
        return false
    end
    if C_PartyInfo.AllowedToDoPartyConversion and not C_PartyInfo.AllowedToDoPartyConversion(true) then
        return false
    end
    -- pcall mirrors SafeSetRaidSubgroup/SafeSwapRaidSubgroup (RAID-02 Safe* idiom):
    -- defense-in-depth if combat starts between the InCombatLockdown check above
    -- and this call (PITFALLS.md:58 -- a raw protected-call error surfacing raw
    -- to the user mid-encounter). Returns false on a swallowed error -> the press
    -- simply retries on the next button press, like the sibling wrappers.
    return (pcall(C_PartyInfo.ConvertToRaid))
end

-- Snapshots the live raid: a map from CanonicalizeLiveName(name) -> raid
-- index, and per-subgroup member counts. Re-taken between reconciliation
-- passes because SetRaidSubgroup/SwapRaidSubgroup re-enumerate raid indices
-- as a side effect (03-RESEARCH.md Pitfall 1) -- a stale snapshot cannot be
-- reused across a mutating call.
local function SnapshotRaid()
    local keyToIndex = {}
    local groupCounts = {}
    local numMembers = GetNumGroupMembers() or 0
    for i = 1, numMembers do
        local name, _, subgroup = GetRaidRosterInfo(i)
        if name then
            keyToIndex[CanonicalizeLiveName(name)] = i
            groupCounts[subgroup] = (groupCounts[subgroup] or 0) + 1
        end
    end
    return keyToIndex, groupCounts
end

-- Right-click promote menu (Lead/Assist) shared by the Roster list rows and the
-- Group View slots. Silent no-op unless the local player is the raid lead and the
-- target resolves to a live raid member below leader rank -- no empty menus. The
-- promote calls use the exact live GetRaidRosterInfo name + exactNameMatch, and the
-- target is re-resolved at click time so a roster change between opening the menu
-- and clicking cannot hit the wrong player.
function Private:ShowPlayerPromoteMenu(anchor, key, displayName)
    if not key then return end
    if not (IsInRaid() and UnitIsGroupLeader("player")) then return end

    local function ResolveLive()
        local numMembers = GetNumGroupMembers() or 0
        for i = 1, numMembers do
            local name, rank = GetRaidRosterInfo(i)
            if name and CanonicalizeLiveName(name) == key then
                return name, rank
            end
        end
    end

    local _, rank = ResolveLive()
    if not rank or rank == 2 then return end -- not in the raid, or already the lead

    local function CallOn(apiFn)
        local name = ResolveLive()
        if name then apiFn(name, true) end
    end

    MenuUtil.CreateContextMenu(anchor, function(_, root)
        root:CreateTitle(displayName or key)
        root:CreateButton("Promote to Lead", function() CallOn(C_PartyInfo.PromoteToLeader) end)
        if rank == 1 then
            root:CreateButton("Demote Assist", function() CallOn(C_PartyInfo.DemoteAssistant) end)
        else
            root:CreateButton("Promote to Assist", function() CallOn(C_PartyInfo.PromoteToAssistant) end)
        end
    end)
end

-- Bench swap-in/out (ROSTER-04): exchanges a bench player's subgroup with an
-- active row's subgroup via the file's guarded SafeSwapRaidSubgroup -- never a
-- raw SwapRaidSubgroup call (that would reintroduce the exact combat-lockdown
-- crash the Phase 6 hardening fixed). Takes canonical keys (consistent with
-- every other Private: function in this file) and resolves both to live raid
-- indices with a FRESH SnapshotRaid() -- never cached, since SetRaidSubgroup/
-- SwapRaidSubgroup re-enumerate raid indices as a side effect (03-RESEARCH.md
-- Pitfall 1), so a stale snapshot cannot be reused across a mutating call. A
-- key not currently in the raid (not yet invited) cannot be swapped -- that
-- path is the screen's own invite flow, not this function's job -- so it
-- no-ops and returns false rather than erroring.
function Private:SwapBenchPlayer(benchKey, activeKey)
    local keyToIndex = SnapshotRaid()
    local benchIndex = keyToIndex[benchKey]
    local activeIndex = keyToIndex[activeKey]
    if not benchIndex or not activeIndex then return false end
    return SafeSwapRaidSubgroup(benchIndex, activeIndex)
end

-- The presser's own CanonicalKey (Private:GetSelfKey, shipped in RosterKeys.lua,
-- KEYS-01) -- never invite/self-target the presser.

-- The raid leader's CanonicalKey. WoW pins the raid leader to slot 1 of its
-- subgroup and forbids moving it off that slot, so the arrange plan must target
-- the leader at slot 1 of its group (see ShiftLeaderInPlan) and must never bench
-- it or use it as a swap victim (see DecideMove). When already in a raid the
-- actual leader is looked up directly; before the party->raid conversion has
-- landed there is no raid leader yet, so we fall back to the presser -- who
-- drives the conversion and becomes the leader on the next paced tick.
local function GetRaidLeaderKey()
    if IsInRaid() then
        local numMembers = GetNumGroupMembers() or 0
        for i = 1, numMembers do
            if UnitIsGroupLeader("raid" .. i) then
                local name = GetRaidRosterInfo(i)
                if name then
                    return CanonicalizeLiveName(name)
                end
            end
        end
    end
    return Private:GetSelfKey()
end

-- Paced invite drain (replaces the old "invite the whole plan at once + event-driven
-- continuation" flow, which on a cold solo/party start overflowed the 5-slot party --
-- "Your party is full" spam -- and left the rest of a 20-man plan uninvited). One press
-- now paces the entire plan in two phases (see InviteDrainTick below):
--
--   Phase 1 (cold start, not yet a raid): invite the queue head, wait up to
--   INVITE_ACCEPT_WAIT for it to be accepted, then advance -- exactly ONE outstanding
--   invite at a time, so the party (max 5) never overflows and a fake/declined name simply
--   falls through to the next. The first accept makes the group a party of >=2, triggering
--   the party -> raid conversion.
--
--   Phase 2 (in a raid): the raid holds 40, so pace the rest one invite per INVITE_PACE,
--   skipping anyone already present, until the queue drains.
--
-- Combat pauses the drain and it resumes next tick; INVITE_DRAIN_DEADLINE is a safety stop.
-- inviteDrain is nil when idle, else { queue, startTime, headInviteTime }.
local INVITE_ACCEPT_WAIT = 4      -- seconds to wait for a cold-start invite before advancing
local INVITE_PACE = 1             -- seconds between paced raid invites (API-throttle-friendly)
local INVITE_DRAIN_DEADLINE = 180 -- seconds; unconditional safety stop
local inviteDrain
local InviteDrainTick             -- forward-declared; self-reschedules via C_Timer.After
local inviteMsgFrame              -- CHAT_MSG_SYSTEM listener; active only while a drain runs

-- Localized "invite failed" system-message patterns, built from the client's own global
-- strings (locale-safe -- never hardcode English). A match while a cold-start invite is
-- outstanding advances the drain instantly instead of waiting out INVITE_ACCEPT_WAIT, and
-- records WHY it failed against the invited key so the roster row can show the real reason
-- ("Not found" / "Declined" / "In group") instead of a stuck "Invited…".
local INVITE_FAIL_PATTERNS = {}
do
    local function toPattern(gs)
        if type(gs) ~= "string" then return nil end
        -- Escape Lua pattern magic, then turn %s / %1$s / %d placeholders into wildcards.
        local p = gs:gsub("([%^%$%(%)%.%[%]%*%+%-%?])", "%%%1")
        p = p:gsub("%%%d+%$s", ".-"):gsub("%%s", ".-"):gsub("%%d", ".-")
        return p
    end
    for _, spec in ipairs({
        { gs = ERR_BAD_PLAYER_NAME_S, reason = "notfound" },
        { gs = ERR_DECLINE_GROUP_S, reason = "declined" },
        { gs = ERR_ALREADY_IN_GROUP_S, reason = "ingroup" },
    }) do
        local p = toPattern(spec.gs)
        if p then INVITE_FAIL_PATTERNS[#INVITE_FAIL_PATTERNS + 1] = { pattern = p, reason = spec.reason } end
    end
end

-- Cross-module invite-failure state (read by RosterScreen's DeriveStatus): canonical key ->
-- reason ("notfound" / "declined" / "ingroup"). Set here when a failure message lands for
-- the just-invited token; cleared by the screen on re-invite or once the key joins the raid.
Private.inviteFailures = Private.inviteFailures or {}

-- Shared progress surface (PROG-01/02) for the two paced drains below. Sub-tables are nil
-- while that drain is idle; each carries { done, total } for the Roster screen's progress
-- bar. Published by the drains themselves, never computed by the UI.
Private.opProgress = Private.opProgress or { invite = nil, arrange = nil }

-- Clears the drain and stops listening for invite-failure system messages.
local function EndInviteDrain()
    inviteDrain = nil
    Private.opProgress.invite = nil
    if Private.NotifyProgressChanged then Private.NotifyProgressChanged() end
    if inviteMsgFrame then inviteMsgFrame:UnregisterEvent("CHAT_MSG_SYSTEM") end
end

-- Drops the current cold-start head and ticks immediately (system-message fast-path).
local function AdvanceInviteHead()
    local d = inviteDrain
    if not d or not d.headInviteTime or IsInRaid() then return end
    table.remove(d.queue, 1)
    d.headInviteTime = nil
    InviteDrainTick()
end

-- CHAT_MSG_SYSTEM handler: when an invite-failure line lands for the token we just invited,
-- record the reason against its key (so the roster row shows "Not found" / "Declined" / "In
-- group") and, if we are still waiting out a cold-start invite, skip straight to the next
-- token instead of stalling on INVITE_ACCEPT_WAIT. Gated so it is inert outside a drain.
local function OnInviteSystemMessage(msg)
    -- Midnight secret values: type() lets a secret string through, and msg:find
    -- below indexes it, which raises. Guard first (RosterKeys.lua's
    -- Private.HasSecretValue).
    if Private.HasSecretValue(msg) then return end
    if type(msg) ~= "string" then return end
    local d = inviteDrain
    if not d or not d.lastInviteToken then return end
    for _, f in ipairs(INVITE_FAIL_PATTERNS) do
        if msg:find(f.pattern) then
            Private.inviteFailures[CanonicalizeLiveName(d.lastInviteToken)] = f.reason
            if Private.NotifyInviteStatusChanged then Private.NotifyInviteStatusChanged() end
            if d.headInviteTime and not IsInRaid() then
                AdvanceInviteHead() -- cold-start fast-path: don't wait out the accept window
            end
            return
        end
    end
end

-- Rebuilds the pending queue, dropping the presser and anyone already in the live group --
-- by CanonicalKey, never raw string equality (mirrors the old InviteCore matching).
local function PruneInviteQueue(queue)
    local liveKeys = SnapshotRaid()
    local selfKey = Private:GetSelfKey()
    local remaining = {}
    for _, token in ipairs(queue) do
        local key = CanonicalizeLiveName(token)
        if key ~= selfKey and not liveKeys[key] then
            remaining[#remaining + 1] = token
        end
    end
    return remaining
end

-- One drain tick. Self-reschedules via C_Timer.After until the queue drains, the deadline
-- hits, or inviteDrain is cleared. See the phase description on the constants above.
InviteDrainTick = function()
    local d = inviteDrain
    if not d then return end

    -- Unconditional safety stop.
    if GetTime() - d.startTime > INVITE_DRAIN_DEADLINE then
        EndInviteDrain()
        return
    end

    -- ConvertToRaid is rejected in combat (and we avoid firing invites mid-pull) -- pause
    -- and retry on the next tick rather than surfacing a raw protected-call error.
    if InCombatLockdown() then
        C_Timer.After(INVITE_PACE, InviteDrainTick)
        return
    end

    d.queue = PruneInviteQueue(d.queue)
    if #d.queue == 0 then
        EndInviteDrain()
        return
    end

    -- Publish live invite progress (PROG-01): done rises as the queue drains and as
    -- already-present members are pruned above.
    Private.opProgress.invite = { done = math.max((d.total or 0) - #d.queue, 0), total = d.total or 0 }
    if Private.NotifyProgressChanged then Private.NotifyProgressChanged() end

    if IsInRaid() then
        -- Phase 2: the raid has room -- invite the next planned member, then pace.
        local token = table.remove(d.queue, 1)
        d.lastInviteToken = token
        C_PartyInfo.InviteUnit(token)
        d.headInviteTime = nil
        C_Timer.After(INVITE_PACE, InviteDrainTick)
        return
    end

    if (GetNumGroupMembers() or 0) >= 2 then
        -- Someone accepted -> we are a party of >=2 -> convert. ConvertToRaid is a server
        -- request (async), so just re-check next tick; once IsInRaid() flips we hit Phase 2.
        SafeConvertToRaid()
        d.headInviteTime = nil
        C_Timer.After(INVITE_PACE, InviteDrainTick)
        return
    end

    -- Phase 1 cold start: keep exactly one outstanding invite so the party never overflows.
    if not d.headInviteTime then
        d.lastInviteToken = d.queue[1]
        C_PartyInfo.InviteUnit(d.queue[1]) -- peek; stays queued until accepted or timed out
        d.headInviteTime = GetTime()
        C_Timer.After(1, InviteDrainTick)  -- poll soon so a fast accept advances quickly
        return
    end

    if GetTime() - d.headInviteTime >= INVITE_ACCEPT_WAIT then
        -- No accept within the window: treat as failed/declined and move to the next token.
        table.remove(d.queue, 1)
        d.headInviteTime = nil
        C_Timer.After(0.1, InviteDrainTick)
        return
    end

    -- Still waiting on the current cold-start invite.
    C_Timer.After(1, InviteDrainTick)
end

-- Public reusable normalized-invite entry point (ARR-01). Feeds the planned tokens into the
-- paced drain -- starting it if idle, else merging new (deduped) tokens into the running
-- queue -- so one press paces the whole plan: cold-start sequentially until the first
-- accept, convert to a raid, then pace in the rest. Safe to call repeatedly (Invite &
-- Arrange / Invite All / Re-invite / per-row Invite); already-present and duplicate keys
-- are pruned each tick.
function Private:InviteMissingFromTokens(tokens)
    if not tokens or #tokens == 0 then return end

    if inviteDrain then
        local queued = {}
        for _, t in ipairs(inviteDrain.queue) do queued[CanonicalizeLiveName(t)] = true end
        for _, t in ipairs(tokens) do
            local k = CanonicalizeLiveName(t)
            if not queued[k] then
                queued[k] = true
                inviteDrain.queue[#inviteDrain.queue + 1] = t
                -- total tracks the full planned set across merged presses (approximate,
                -- ±1 for the pruned presser -- acceptable for a progress bar, PROG-01).
                inviteDrain.total = (inviteDrain.total or 0) + 1
            end
        end
        return
    end

    local queue = {}
    for _, t in ipairs(tokens) do queue[#queue + 1] = t end
    inviteDrain = { queue = queue, startTime = GetTime(), headInviteTime = nil, total = #queue }
    if not inviteMsgFrame then
        inviteMsgFrame = CreateFrame("Frame")
        inviteMsgFrame:SetScript("OnEvent", function(_, _, msg) Private.safecall(OnInviteSystemMessage, msg) end)
    end
    inviteMsgFrame:RegisterEvent("CHAT_MSG_SYSTEM")
    InviteDrainTick()
end

-- Builds the positional target plan (ARR-02, ARR-03): entry i has the
-- CanonicalKey of invitelist token i, its target group ceil(i/5), and its
-- target slot ((i-1)%5)+1 (1..20 -> group 1..4). The name/realm split is
-- nil-safe (T-03-01) so a realm-less/malformed token cannot error. Overflow
-- guard (WR-02, 16-REVIEW.md iteration 2): an invitelist longer than 20 tokens
-- (paste error, or two boss sections accidentally merged) would otherwise
-- produce group > 4 entries; since CR-01 widened DecideMove's P1 loop to
-- groups 1-8, those entries are now actively enforced (real people moved
-- into/out of 5-8) rather than silently ignored. Mirrors RosterScreen.lua's
-- SeedGroupsOneToFour guard (WR-03) so the engine apply path never mutates
-- groups 5-8 from an oversized boss-button invitelist.
local function BuildTargetPositions(invitelist)
    local plan = {}
    for i, token in ipairs(invitelist) do
        if i > 20 then break end
        local name, realm = token:match("^(.-)%-(.+)$")
        plan[i] = {
            key = Private:CanonicalKey(name or token, realm or Private:GetSelfRealm()),
            group = math.ceil(i / 5),
            slot = ((i - 1) % 5) + 1,
        }
    end
    return plan
end

-- Pins the raid leader to the FIRST slot of its target group inside the flat
-- target plan (ARR leader constraint). WoW forces the raid leader to slot 1 of whatever
-- subgroup it occupies and will not let it be moved off that slot, so a plan that
-- targets a NON-leader at slot 1 of the leader's group can never be reached (the
-- slot-1 keeper and the leader would both want slot 1 -> permanent stall). Moving
-- the leader's entry to slot 1 of its own target-group block, shifting the entries
-- that were before it back by one slot, makes the plan ACHIEVABLE. No-op when the
-- leader is unknown, not on the invitelist, or already at slot 1.
--
-- Sparse-safe (APPLY-01, 16-RESEARCH.md Critical Finding 1): the leader's target
-- group is read from pos.group (the entry's own, already-correct group field),
-- NOT recomputed via a ceil-of-raw-array-index formula on the leader's position.
-- targetPositions from BuildTargetPositions(invitelist) is always DENSE (array
-- index == absolute position), so the old ceil-of-array-index arithmetic happened
-- to work there -- but BuildTargetPositionsFromGroupSlots(slots40)'s COMPACTED
-- output (only occupied slots emitted) desyncs array index from absolute slot
-- the moment any earlier group has fewer than 5 occupants, which could point the
-- old arithmetic at the WRONG group entirely. Keying off pos.group and collecting
-- that group's own compacted array positions (blockPositions, in existing order)
-- is correct for both dense and sparse callers.
--
-- Guard is .slot-aware, not just array-position-aware (16-VERIFICATION.md
-- gaps[0] / 16-02.T10): array-position equality (currentPos == goalPos) is NOT
-- equivalent to "already at slot 1" once a group has gaps. In a SPARSE plan,
-- array order reflects only relative occupancy order within a group, not
-- absolute slot numbers -- so the leader can be the array-first (and thus
-- currentPos == goalPos) occupant of their target group while their own .slot
-- is 2, 3, 4, or 5 (earlier absolute slots of that group are simply empty/
-- unoccupied). The guard therefore also requires targetPositions[currentPos].slot
-- == 1 before short-circuiting; when it is not, execution falls through and the
-- renumber loop below corrects .slot to 1 for the leader (the array-shift loop
-- is a natural no-op in this case since currentPos == goalPos).
--
-- Dense-neutrality: for any DENSE targetPositions (BuildTargetPositions(invitelist)
-- output, any invitelist length), blockPositions for a group is exactly the same
-- contiguous 1-5 array-position run the old ceil(p/5) arithmetic identified, and
-- blockPositions[1] always equals the old goalPos = (group-1)*5+1. Critically, a
-- dense block-first array entry ALWAYS has .slot == 1 (dense slot = ((i-1)%5)+1
-- with array index i == absolute position), so the tightened guard early-returns
-- in EXACTLY the cases the old array-position-only guard did -- this rewrite
-- stays behaviorally a provable no-op for the boss-button (dense) path.
local function ShiftLeaderInPlan(targetPositions, leaderKey)
    if not leaderKey then return end
    local currentPos, leaderGroup
    for p, pos in ipairs(targetPositions) do
        if pos.key == leaderKey then
            currentPos, leaderGroup = p, pos.group
            break
        end
    end
    if not currentPos then return end

    local blockPositions = {}
    for p, pos in ipairs(targetPositions) do
        if pos.group == leaderGroup then
            blockPositions[#blockPositions + 1] = p
        end
    end
    local goalPos = blockPositions[1]
    if currentPos == goalPos and targetPositions[currentPos].slot == 1 then return end

    local leaderEntry = targetPositions[currentPos]
    for p = currentPos, goalPos + 1, -1 do
        targetPositions[p] = targetPositions[p - 1]
    end
    targetPositions[goalPos] = leaderEntry

    for i, p in ipairs(blockPositions) do
        targetPositions[p].slot = i
        -- .group is already leaderGroup for every entry in blockPositions; left untouched.
    end
end

-- (WR-03, 18-REVIEW.md) Re-resolves the CURRENT raid leader onto an
-- already-built plan: re-runs ShiftLeaderInPlan for whoever is leader now
-- (a no-op array shuffle if unchanged) and rebuilds invitelistKeys/
-- leaderTargetGroup from the result. Used both at drain start
-- (Private:ApplyTargetPositions) and on PARTY_LEADER_CHANGED while a drain
-- is active, so DecideMove's P0/P0.5 "never bench/never swap-victim" leader
-- protection tracks the ACTUAL leader instead of a value captured once and
-- left stale for the rest of a multi-step drain (a disconnect + auto-
-- promote, or a manual /promote, mid-arrange would otherwise leave the real
-- new leader unprotected while still shielding the old one).
local function RefreshLeaderInPlan(plan)
    local leaderKey = GetRaidLeaderKey()
    ShiftLeaderInPlan(plan.targetPositions, leaderKey)

    local invitelistKeys = {}
    local leaderTargetGroup
    for _, pos in ipairs(plan.targetPositions) do
        invitelistKeys[pos.key] = true
        if pos.key == leaderKey then
            leaderTargetGroup = pos.group
        end
    end

    plan.leaderKey = leaderKey
    plan.leaderTargetGroup = leaderTargetGroup
    plan.invitelistKeys = invitelistKeys
end

-- Snapshot of the ORIGINAL planned keys (04-ALT-01), taken BEFORE
-- ResolveAltSubstitutions mutates targetPositions -- used to exclude "an alt
-- who is themselves an invitelist member" from being borrowed for a
-- different absent main's slot (CONTEXT.md LOCKED "not borrowed" rule).
local function BuildKeySet(positions)
    local set = {}
    for _, pos in ipairs(positions) do
        set[pos.key] = true
    end
    return set
end

-- For each ABSENT target position, resolves a present in-raid alt of the
-- same owner (battletag, via Private.RosterAlts) deterministically, mutating
-- pos.key in place (04-ALT-01/02/03). Absent positions with no eligible alt
-- are left untouched -- ReconcilePass's existing absent-position skip
-- already leaves that slot OPEN, no new "no match" code needed (ALT-03).
--
-- Nil-guarded field-by-field: Private.RosterAlts (the generated, gitignored,
-- #@do-not-package@ RosterAlts.lua) may be entirely absent in a fresh
-- checkout, or partially-shaped if hand-edited/corrupted -- either case must
-- be a graceful no-op, never a Lua error (T-04-01).
--
-- Takes its OWN fresh SnapshotRaid() every call -- never cached on Private
-- or the encounter table -- so a roster change between presses (a covered
-- main returns, or a different alt logs in) is re-evaluated from scratch on
-- the very next press (04-RE-02). A returning main needs NO explicit revert:
-- once the main is present again, `liveKeys[pos.key]` is truthy and this
-- function simply never enters the substitution branch for that position;
-- the previously-covering alt then falls out of the invitelistKeys set
-- rebuilt below and ReconcilePass Step 3 benches it -- stateless-per-press
-- by design, do not add an UnsubstituteAlt-style revert function.
local function ResolveAltSubstitutions(targetPositions, plannedKeys)
    local rosterAlts = Private.RosterAlts
    if not rosterAlts or not rosterAlts.charToBattletag or not rosterAlts.battletagToChars then
        return
    end

    local liveKeys = SnapshotRaid()
    local claimedAlts = {}

    for _, pos in ipairs(targetPositions) do
        if not liveKeys[pos.key] then -- planned main is ABSENT this press
            local battletag = rosterAlts.charToBattletag[pos.key]
            local chars = battletag and rosterAlts.battletagToChars[battletag]
            if chars then
                -- Generated deterministic order (Phase 1 order_sort_key) --
                -- never sort/reorder; this array order IS the tie-break.
                for _, altKey in ipairs(chars) do
                    if liveKeys[altKey]              -- present in raid    (ALT-02)
                        and not plannedKeys[altKey]   -- not another slot's own member
                        and not claimedAlts[altKey]   -- not already claimed this press
                    then
                        pos.key = altKey
                        claimedAlts[altKey] = true
                        break
                    end
                end
            end
        end
    end
end

-- Read-only preview accessor for the group-grid seeding (GROUP-01, Phase 14, WR-01 fix):
-- runs the SAME positional pipeline Private:ArrangeForEncounter uses -- build positions,
-- resolve alt substitutions, then pin the leader to slot 1 of its group -- so the staged
-- Group View is genuinely WYSIWYG with what "Invite & Arrange" would produce. Reuses the
-- existing BuildTargetPositions/BuildKeySet/ResolveAltSubstitutions/ShiftLeaderInPlan
-- locals verbatim; no duplicated position/substitution/shift math. Defined here (after
-- those locals, not right after BuildTargetPositions above) so they're all in lexical
-- scope. Never mutates live raid state: ResolveAltSubstitutions and GetRaidLeaderKey only
-- READ live state (SnapshotRaid / raid roster lookup) and reorder a fresh local
-- targetPositions table, so this stays safe to call from a pure-renderer screen. Mirrors
-- Private:SnapshotRaidStatus's promotion shape. Deliberately omits InviteMissingFromTokens
-- and the drain kickoff -- the full APPLY-01 extraction (ApplyTargetPositions /
-- BuildTargetPositionsFromGroupSlots) stays Phase 16 scope.
function Private:BuildTargetPositions(invitelist)
    local targetPositions = BuildTargetPositions(invitelist)
    local plannedKeys = BuildKeySet(targetPositions)
    ResolveAltSubstitutions(targetPositions, plannedKeys)
    ShiftLeaderInPlan(targetPositions, GetRaidLeaderKey())
    return targetPositions
end

-- Sparse-safe target-position builder for the staged group-view grid (APPLY-01,
-- 16-RESEARCH.md Pattern 2). Unlike BuildTargetPositions above (which derives
-- group/slot from a DENSE invitelist array's own index), this derives group/slot
-- from the ABSOLUTE 1..40 slot index and simply SKIPS unoccupied slots -- an empty
-- slot never shifts anything after it into the wrong group. Never compact the
-- token list first: that would be exactly the naive-compaction bug this function
-- exists to avoid.
function Private:BuildTargetPositionsFromGroupSlots(slots40)
    local plan = {}
    for i = 1, 40 do
        local key = slots40[i]
        if key then
            plan[#plan + 1] = { key = key, group = math.ceil(i / 5), slot = ((i - 1) % 5) + 1 }
        end
    end
    return plan
end

-- Leader-normalization for the staged grid (APPLY-02 UX decision, 16-RESEARCH.md
-- Pattern 4): re-derives the plan from the current staged slots, re-runs the SAME
-- (fixed, sparse-safe -- see ShiftLeaderInPlan above) leader-shift the engine uses
-- defensively at apply time, then writes the corrected group/slot back into
-- slots40 IN PLACE (same table object -- GroupGrid.lua's currentSlots40 and
-- RosterScreen's stagedByEncounter[id] stay the same reference throughout, no
-- table-identity concerns at the call site). Faithful no-op round-trip when the
-- leader is already at slot 1 of their group or absent from the slots.
function Private:NormalizeLeaderInSlots(slots40)
    local targetPositions = Private:BuildTargetPositionsFromGroupSlots(slots40)
    ShiftLeaderInPlan(targetPositions, GetRaidLeaderKey())
    for i = 1, 40 do
        slots40[i] = nil
    end
    for _, pos in ipairs(targetPositions) do
        slots40[(pos.group - 1) * 5 + pos.slot] = pos.key
    end
    return slots40
end

-- Bench target-group selection (ARR-04), reused verbatim in shape from the
-- pre-v2.0 legacy Roster.lua wt:MakeRoster moveToEnd loop (removed in the v2.0 rewrite):
-- scan groups 8, 7, 6, 5 in that order and return the first not yet at 5 members, or
-- nil when 5-8 are all full (leave the member in place, no error).
local function FindBenchGroup(groupCounts)
    local g = 8
    while g >= 5 do
        if not groupCounts[g] or groupCounts[g] < 5 then return g end
        g = g - 1
    end
    return nil
end

-- Picks a group (other than avoidGroup) with a free slot to briefly park a keeper
-- that is in its correct group but the wrong intra-group slot (ARR intra-group
-- order). SwapRaidSubgroup is a documented no-op within a single group
-- (wow_api_wiki.txt:6484-6485), so the ONLY way to reorder within a group is to
-- bounce the keeper OUT and let the next tick swap it back into the exact slot
-- cross-group. Prefers a bench group (5-8, order irrelevant) so the bounce never
-- disturbs a roster group still being built; falls back to any other roster group
-- with room. Returns nil when every other group is full (no-progress guard stops).
local function FindBounceGroup(groupCounts, avoidGroup)
    for j = 5, 8 do
        if j ~= avoidGroup and (not groupCounts[j] or groupCounts[j] < 5) then return j end
    end
    for j = 1, 4 do
        if j ~= avoidGroup and (groupCounts[j] or 0) < 5 then return j end
    end
    return nil
end

-- Detailed live-raid snapshot for ONE arrange step (ARR throttle fix). Unlike
-- SnapshotRaid it also returns per-group occupant lists (for swap-displacement)
-- and an order-signature string (CanonicalKeys concatenated in raid-index
-- order). The signature lets the drain detect "the last move produced no roster
-- change" -- because SetRaidSubgroup/SwapRaidSubgroup are server requests whose
-- effect is not visible in GetRaidRosterInfo until the server round-trip fires
-- GROUP_ROSTER_UPDATE on a later frame (each tick re-snapshots and re-finds the
-- first mismatch, so an optimistic move that did not land is simply re-attempted).
local function SnapshotDetailed()
    local keyToIndex, indexSubgroup = {}, {}
    local groupCounts, groupMembers = {}, {}
    local order = {}
    local numMembers = GetNumGroupMembers() or 0
    for i = 1, numMembers do
        local name, _, subgroup = GetRaidRosterInfo(i)
        if name and subgroup then
            local key = CanonicalizeLiveName(name)
            keyToIndex[key] = i
            indexSubgroup[i] = subgroup
            groupCounts[subgroup] = (groupCounts[subgroup] or 0) + 1
            local bucket = groupMembers[subgroup]
            if not bucket then
                bucket = {}
                groupMembers[subgroup] = bucket
            end
            bucket[#bucket + 1] = { index = i, key = key }
            order[#order + 1] = key
        end
    end
    local snap = {
        keyToIndex = keyToIndex,
        indexSubgroup = indexSubgroup,
        groupCounts = groupCounts,
        groupMembers = groupMembers,
    }
    return snap, table.concat(order, "|")
end

-- Cheap, monotonic group-membership progress metric (PROG-02): counts present-and-
-- in-target-group members against present-in-plan members. Reaches total on
-- convergence; absent positions are skipped, matching the drain's own absent-
-- position skip (LOCKED: absent positions never compacted into).
local function CountGroupsInPlace(snap, plan)
    local inPlace, total = 0, 0
    for _, pos in ipairs(plan.targetPositions) do
        local idx = snap.keyToIndex[pos.key]
        if idx then
            total = total + 1
            if snap.indexSubgroup[idx] == pos.group then
                inPlace = inPlace + 1
            end
        end
    end
    return inPlace, total
end

-- Decide the SINGLE next group mutation from one fresh snapshot, or nil when the
-- raid already matches the plan (the drain then stops -- converged). Exactly one
-- move is returned per tick because SetRaidSubgroup/SwapRaidSubgroup are server
-- requests not reflected in GetRaidRosterInfo until GROUP_ROSTER_UPDATE fires on a
-- later frame; each tick re-snapshots and re-finds the first mismatch, so an
-- optimistic move that did not land exactly is simply re-attempted next tick
-- (finalcheck-style self-correction). Priority:
--
-- P0   Bench-out: the first non-invitelist member still in a roster group (1-4) ->
--      a bench group (8 first). Never the leader. Runs first, so groups 1-4 hold
--      ONLY keepers by the time P1 runs. Members already in 5-8 are left alone
--      (no compaction); absent positions are never compacted into (CONTEXT LOCKED).
-- P0.5 Leader placement: move the leader into its target group FIRST -- SetRaid-
--      Subgroup when there is room, else swap it against the current slot-1 occupant
--      of that group (so the leader lands exactly on the slot WoW forces it to). Done
--      before P1 so the leader (which WoW pins to slot 1 and ShiftLeaderInPlan made
--      the slot-1 target of its group) never blocks another key's slot below and is
--      never used as a swap victim.
-- P1   Position fill: for each
--      roster group, walk its target slots in order and act on the FIRST slot whose
--      occupant differs from the target key:
--        * target key in a DIFFERENT group, slot occupied -> SwapRaidSubgroup the
--          occupant with the target key (cross-group exact-position exchange, case b).
--        * target key in a DIFFERENT group, slot is the group's next free slot ->
--          SetRaidSubgroup the target key straight in (case a).
--        * target key already in the RIGHT group but the wrong slot -> bounce it out
--          to a free-slot group (same-group SwapRaidSubgroup is a no-op); next tick it
--          re-enters the different-group case and lands in the exact slot (case c).
-- Desired intra-group order (present keys only, target-position order). Absent
-- keys are omitted, so the present members keep their relative order and fill
-- from slot 1 with no hole (LOCKED: absent positions skipped, no compaction).
-- Extracted (ARRANGE-01, 18-PATTERNS.md) so DecideMove's single-mismatch scan
-- and FindAllMismatches's full verification sweep share the exact same
-- present-only/no-compaction semantics from one definition.
local function BuildDesiredByGroup(targetPositions, keyToIndex)
    local desiredByGroup = {}
    for _, pos in ipairs(targetPositions) do
        if keyToIndex[pos.key] then
            local g = pos.group
            local list = desiredByGroup[g]
            if not list then
                list = {}
                desiredByGroup[g] = list
            end
            list[#list + 1] = pos.key
        end
    end
    return desiredByGroup
end

local function DecideMove(snap, plan)
    local targetPositions = plan.targetPositions
    local invitelistKeys = plan.invitelistKeys
    local leaderKey = plan.leaderKey
    local leaderTargetGroup = plan.leaderTargetGroup
    local keyToIndex = snap.keyToIndex
    local indexSubgroup = snap.indexSubgroup
    local groupCounts = snap.groupCounts
    local groupMembers = snap.groupMembers

    -- P0 Bench-out (never the leader).
    for g = 1, 4 do
        local members = groupMembers[g]
        if members then
            for _, m in ipairs(members) do
                if not invitelistKeys[m.key] and m.key ~= leaderKey then
                    local benchGroup = FindBenchGroup(groupCounts)
                    if benchGroup then
                        return { kind = "set", index = m.index, group = benchGroup }
                    end
                end
            end
        end
    end

    -- P0.5 Leader placement. Swapping against the target group's current slot-1
    -- occupant lands the leader on slot 1 (where WoW forces it anyway) with no
    -- surprise displacement; that occupant is never the leader (leader is elsewhere
    -- in this branch) and is never the final slot-1 target (the leader is).
    if leaderKey and leaderTargetGroup then
        local leaderIndex = keyToIndex[leaderKey]
        if leaderIndex and indexSubgroup[leaderIndex] ~= leaderTargetGroup then
            if (groupCounts[leaderTargetGroup] or 0) < 5 then
                return { kind = "set", index = leaderIndex, group = leaderTargetGroup }
            else
                local occupants = groupMembers[leaderTargetGroup]
                local victim = occupants and occupants[1]
                if victim and victim.key ~= leaderKey then
                    return { kind = "swap", a = victim.index, b = leaderIndex }
                end
            end
        end
    end

    -- Desired intra-group order (present keys only, target-position order). Absent
    -- keys are omitted, so the present members keep their relative order and fill
    -- from slot 1 with no hole (LOCKED: absent positions skipped, no compaction).
    local desiredByGroup = BuildDesiredByGroup(targetPositions, keyToIndex)

    -- P1 Position fill. Because P0 has emptied groups 1-4 of non-keepers, every
    -- occupant in 1-4 here is a keeper; because earlier slots of a group already
    -- match at the first mismatch, a missing occupant means slot s is exactly the
    -- group's next free slot (SetRaidSubgroup appends there). Range widened to 1-8
    -- (CR-01, 16-REVIEW.md): desiredByGroup is populated for every group present in
    -- targetPositions, including staged bench (5-8) positions from Group View drag
    -- placements ("Set groups" via BuildTargetPositionsFromGroupSlots) -- those must
    -- be enforced the same way 1-4 are. Groups 5-8 are NOT touched by P0's bench-out
    -- (scoped to 1-4 only, unchanged above), so a non-plan member already parked in
    -- 5-8 by FindBenchGroup can be the "occupant" here. WR-01 (16-REVIEW.md iteration
    -- 2) fix: swapping such a bystander straight into wantKey's current subgroup can
    -- land it back in a roster group (1-4) -- exactly what P0 exists to prevent -- so
    -- a bench-group (g >= 5) occupant that is not on the plan is routed through
    -- FindBenchGroup first and only falls through to the ordinary swap if no other
    -- bench group has room. For the boss-button path (BuildTargetPositions with
    -- #invitelist <= 20, WR-02), targetPositions never contains a group 5-8 entry, so
    -- desiredByGroup[5..8] is always nil, this whole g >= 5 branch is unreached, and
    -- these added iterations no-op -- byte-identical to the old 1-4 bound for that
    -- path (dense-input neutrality, conditional on the <= 20 precondition now
    -- enforced by BuildTargetPositions's overflow guard).
    for g = 1, 8 do
        local desired = desiredByGroup[g]
        if desired then
            local current = groupMembers[g] or {}
            for s = 1, #desired do
                local wantKey = desired[s]
                local occupant = current[s]
                if not occupant or occupant.key ~= wantKey then
                    local wantIndex = keyToIndex[wantKey]
                    if wantIndex then
                        if indexSubgroup[wantIndex] ~= g then
                            if occupant then
                                if occupant.key ~= leaderKey then
                                    if g >= 5 and not invitelistKeys[occupant.key] then
                                        -- Bystander already benched by P0: never let the
                                        -- swap below re-admit it into a roster group (1-4).
                                        -- Park it in another bench group first if one has room.
                                        local parkGroup = FindBenchGroup(groupCounts)
                                        if parkGroup and parkGroup ~= g then
                                            return { kind = "set", index = occupant.index, group = parkGroup }
                                        end
                                        -- No other bench group has room: fall through to the
                                        -- swap below (still bounded/self-correcting per WR-01).
                                    end
                                    return { kind = "swap", a = occupant.index, b = wantIndex }
                                end
                            else
                                return { kind = "set", index = wantIndex, group = g }
                            end
                        else
                            local bounce = FindBounceGroup(groupCounts, g)
                            if bounce then
                                return { kind = "set", index = wantIndex, group = bounce }
                            end

                            -- ARRANGE-02: same-subgroup deadlock (bounce == nil, every
                            -- group 1-8 is full). wantIndex already occupies g but the
                            -- wrong slot, and SwapRaidSubgroup is a documented no-op
                            -- WITHIN a single group (see FindBounceGroup's comment above),
                            -- so `occupant` -- also in g -- can never trade places with it
                            -- directly. The only move left is a swap with a member of a
                            -- DIFFERENT group: it displaces wantIndex out of g so a later
                            -- tick's ordinary cross-group case (above, `indexSubgroup[
                            -- wantIndex] ~= g`) can bring it back into (g, s) exactly,
                            -- while the swap partner is picked up and resolved by
                            -- DecideMove on a future tick like any other misplaced player.
                            -- Reinterpreted per-tick against the CURRENT snapshot (no
                            -- persistent `processed` flag -- 18-RESEARCH.md Anti-Pattern)
                            -- in the locked 4-level order; leader excluded as a victim
                            -- at EVERY level (locked). The ARRANGE-01 verification sweep
                            -- (ProcessStep) is the safety net if this per-tick translation
                            -- ever needs an extra tick
                            -- (18-RESEARCH.md Assumption A2).
                            local victimKey = occupant.key
                            local partner

                            -- Level 1: the live occupant of victimKey's OWN destined slot
                            -- in another group -- swapping it in is not wasted motion, it
                            -- advances victimKey toward its own target too.
                            for g2 = 1, 8 do
                                if g2 ~= g then
                                    local desired2 = desiredByGroup[g2]
                                    if desired2 then
                                        for s2 = 1, #desired2 do
                                            if desired2[s2] == victimKey then
                                                local occ2 = (groupMembers[g2] or {})[s2]
                                                if occ2 and occ2.key ~= leaderKey then
                                                    partner = occ2
                                                end
                                                break
                                            end
                                        end
                                    end
                                end
                                if partner then break end
                            end

                            -- Level 2: any live member of another group not yet at its
                            -- own target slot (moving it does not regress a correct player).
                            if not partner then
                                for g2 = 1, 8 do
                                    if g2 ~= g then
                                        local members2 = groupMembers[g2]
                                        local desired2 = desiredByGroup[g2]
                                        if members2 then
                                            for _, m in ipairs(members2) do
                                                if m.key ~= leaderKey then
                                                    local atTarget = false
                                                    if desired2 then
                                                        for s2 = 1, #desired2 do
                                                            if desired2[s2] == m.key then
                                                                atTarget = true
                                                                break
                                                            end
                                                        end
                                                    end
                                                    if not atTarget then
                                                        partner = m
                                                        break
                                                    end
                                                end
                                            end
                                        end
                                    end
                                    if partner then break end
                                end
                            end

                            -- Level 3: any processed (already-correct) non-leader -- last
                            -- resort; it is simply re-placed by DecideMove next tick.
                            if not partner then
                                for g2 = 1, 8 do
                                    if g2 ~= g then
                                        local members2 = groupMembers[g2]
                                        if members2 then
                                            for _, m in ipairs(members2) do
                                                if m.key ~= leaderKey then
                                                    partner = m
                                                    break
                                                end
                                            end
                                        end
                                    end
                                    if partner then break end
                                end
                            end

                            -- Level 4: any group with free space -- already tried above
                            -- (FindBounceGroup, bounce == nil here); no further rung.
                            if partner then
                                return { kind = "swap", a = partner.index, b = wantIndex }
                            end
                        end
                    end
                    break -- only the first mismatched slot of this group per tick
                end
            end
        end
    end

    return nil
end

-- Independent full group+slot verification sweep (ARRANGE-01). DecideMove
-- returns on the FIRST mismatch it finds (to decide the single next move);
-- this instead enumerates EVERY group+slot mismatch across the whole plan
-- from a fresh, server-confirmed snapshot, so drain completion can be gated
-- on an exact live-vs-plan compare rather than trusting DecideMove's own nil
-- return as sufficient proof (the P1 same-group case has a documented
-- false-convergence gap when the ARRANGE-02 ladder still cannot resolve a
-- deadlock -- see 18-RESEARCH.md Pitfall 1). Shares BuildDesiredByGroup with
-- DecideMove so the present-only/no-compaction semantics (LOCKED: absent
-- planned mains are never counted as a mismatch) are defined exactly once.
-- Pure function: issues no moves, only reports them; empty list means the
-- live raid fully matches the plan on every present group+slot.
local function FindAllMismatches(snap, plan)
    local desiredByGroup = BuildDesiredByGroup(plan.targetPositions, snap.keyToIndex)
    local mismatches = {}
    for g = 1, 8 do
        local desired = desiredByGroup[g]
        if desired then
            local current = snap.groupMembers[g] or {}
            for s = 1, #desired do
                local wantKey = desired[s]
                local occupant = current[s]
                if not occupant or occupant.key ~= wantKey then
                    mismatches[#mismatches + 1] = { group = g, slot = s, wantKey = wantKey }
                end
            end
        end
    end
    return mismatches
end

-- ARRANGE-04: reports planned mains still absent after alt substitution, once
-- per arrange run at completion (LOCKED: local print only, never chat/comms
-- -- privacy rule). ResolveAltSubstitutions leaves pos.key UNCHANGED when no
-- covering alt is found (see its comment above), so a targetPositions entry
-- whose key is still missing from the live raid at completion is exactly a
-- planned main with no available alt -- this is not re-derived logic, just a
-- membership check against the fresh completion snapshot. Iterates
-- targetPositions in order for a deterministic report; dedupe is unnecessary
-- since targetPositions holds distinct positions. Prints nothing when every
-- planned position is filled (silent success, LOCKED).
local function ReportMissingPlayers(plan, snap)
    local missing = {}
    for _, pos in ipairs(plan.targetPositions) do
        if not snap.keyToIndex[pos.key] then
            missing[#missing + 1] = pos.key
        end
    end
    if #missing == 0 then return end
    print(("|cff99ff00Wild Things:|r %s"):format(
        "Missing (no alt available): " .. table.concat(missing, ", ")))
end

-- Fallback timeout (seconds) between paced steps when NO GROUP_ROSTER_UPDATE
-- confirms the previous move (v3 event-driven pacing). SetRaidSubgroup/
-- SwapRaidSubgroup are server requests: a move that lands fires GROUP_ROSTER_UPDATE
-- (the fast path advances the next step immediately, as fast as the server
-- confirms), but a rejected/no-op move -- a "party full" race, an offline-member
-- edge, or a combat-lockdown race -- fires NO event, so this timeout advances the
-- drain anyway from a fresh, server-confirmed snapshot. ~0.8s comfortably clears an
-- offline round-trip while old Roster.lua's proven ~0.5s spacing shows the fast
-- path is safe.
local ARRANGE_FALLBACK = 0.8
-- Wall-clock bound (seconds) on a whole arrange run, measured from drain
-- start (activeDrain.startTime). This is now the PRIMARY bound on a stuck
-- run (ARRANGE-03): a non-convergent scenario aborts within ~15s instead of
-- churning to the far looser ARRANGE_MAX_STEPS x ARRANGE_FALLBACK (~64s)
-- ceiling below. The step cap is NOT removed -- it stays as a defensive
-- backstop for the (unlikely) case GetTime() is somehow unavailable/stalled
-- (18-RESEARCH.md Pitfall 3: Timeout Interplay With the Existing Step Cap).
local ARRANGE_TIMEOUT = 15
-- Hard cap on paced moves per press (defence against an unexpected non-
-- converging state); a full 20-man re-arrange needs well under this.
local ARRANGE_MAX_STEPS = 80
-- Consecutive fallback-timeout steps with an UNCHANGED order-signature before the
-- drain gives up (event-aware no-progress backstop). Set generously so a couple of
-- slow offline round-trips can never trip it -- only a genuinely stuck/rejected
-- move, which repeatedly times out without changing the roster, reaches this.
local ARRANGE_MAX_STALL = 3

-- Event-driven drain state (v3). At most one in-flight drain; a newer boss press
-- replaces it (stale callbacks bail on the generation mismatch). Fields: gen
-- (Private.arrangeGen at start), plan, stepsLeft, lastSig (prior step's order
-- signature), stall (consecutive timed-out no-change steps), token (per-armed-step
-- id for the single-advance guard), awaiting (a step is armed, waiting for its
-- trigger).
local activeDrain
-- Dedicated frame listening to GROUP_ROSTER_UPDATE ONLY while a drain is active
-- (registered at drain start, unregistered by StopDrain). Mirrors the codebase's
-- CreateFrame+OnEvent idiom (Core.lua / Versions.lua / Transmission.lua).
local rosterEventFrame

-- Interruption-safe resume (ARRANGE-05/06): WildThingsSavedVars.roster.pending_arrange
-- persists ONLY the input source of an in-flight arrange (kind="encounter" + encounterID,
-- or kind="slots40" + the staged layout) plus an epoch timestamp -- never computed moves
-- or snapshots. Cleared on TRUE completion/abort/cancel (below); deliberately preserved
-- across a combat pend so PLAYER_REGEN_ENABLED can re-resolve it fresh.
local function ClearPendingArrange()
    if WildThingsSavedVars and WildThingsSavedVars.roster then
        WildThingsSavedVars.roster.pending_arrange = nil
    end
end

-- Transient flag: set when ProcessStep pends on combat lockdown (ARRANGE-05)
-- below, cleared by the PLAYER_REGEN_ENABLED handler on combatResumeFrame.
-- Distinguishes "we are waiting to resume OUR pended arrange" from an
-- unrelated combat exit (which must be a no-op).
local pendingCombatArrange = false

-- Re-runs the FULL pipeline for a persisted pending-arrange source, NEVER
-- replaying stored/queued moves -- "re-resolve, never replay" (ARRANGE-05/06).
-- Re-checks IsInRaid() + lead/assist at resume time (a lead may have lost
-- lead status since the original press) before re-entering through the SAME
-- public entry points the original press used (Private:ArrangeForEncounter
-- for kind="encounter", Private:ApplyTargetPositions for kind="slots40") --
-- both already bump Private.arrangeGen, so there is no parallel mutation
-- path. On a failed gate or an unresolvable source, silently clears the
-- pending entry instead of resuming onto a raid the player no longer leads
-- (T-18-03-01/02).
local function ResumeArrangeFromSource(source)
    if not source then return end
    if not (IsInRaid() and (UnitIsGroupLeader("player") or UnitIsGroupAssistant("player"))) then
        ClearPendingArrange()
        return
    end

    if source.kind == "encounter" then
        -- Re-derive enc from Private.parsedRosterExport (rebuilt from the
        -- static Private.rosterExport if needed) by matching enc.id -- this
        -- re-runs alt substitution fresh rather than trusting any stale state.
        if not Private.parsedRosterExport and Private.rosterExport then
            Private.parsedRosterExport = Private:ParseRosterExport(Private.rosterExport)
        end
        local enc
        if Private.parsedRosterExport then
            for _, e in ipairs(Private.parsedRosterExport) do
                if e.id == source.encounterID then
                    enc = e
                    break
                end
            end
        end
        if enc then
            Private:ArrangeForEncounter(enc)
        else
            ClearPendingArrange()
        end
    elseif source.kind == "slots40" then
        Private:ApplyTargetPositions(Private:BuildTargetPositionsFromGroupSlots(source.slots40))
    else
        ClearPendingArrange()
    end
end

-- Freshness window for the reload-resume gate (ARRANGE-06, ~10 min): a
-- persisted pending_arrange older than this is treated as stale and cleared
-- silently rather than auto-resumed, bounding the blast radius of a
-- forgotten in-flight arrange from a much earlier session (time() epoch,
-- matches the timestamp written at the recording sites).
local PENDING_ARRANGE_STALENESS = 600

-- Dedicated persistent frame for the two interruption-safe resume triggers:
-- ARRANGE-05 combat-exit (PLAYER_REGEN_ENABLED) and ARRANGE-06 reload-resume
-- (PLAYER_ENTERING_WORLD), plus the WR-03 (18-REVIEW.md) leader-refresh
-- trigger (PARTY_LEADER_CHANGED). Registered once at file load and kept
-- registered for the addon's lifetime -- mirrors UI/WindowRaiser.lua's
-- persistent PLAYER_REGEN_DISABLED registration -- with each branch gated so
-- an unrelated combat exit, zone change, or leader change is a no-op.
local combatResumeFrame = CreateFrame("Frame")
combatResumeFrame:RegisterEvent("PLAYER_REGEN_ENABLED")
combatResumeFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
combatResumeFrame:RegisterEvent("PARTY_LEADER_CHANGED")
combatResumeFrame:SetScript("OnEvent", function(_, event)
    Private.safecall(function()
    if event == "PLAYER_REGEN_ENABLED" then
        if not pendingCombatArrange then return end
        pendingCombatArrange = false
        ResumeArrangeFromSource(WildThingsSavedVars and WildThingsSavedVars.roster and WildThingsSavedVars.roster.pending_arrange)
    elseif event == "PARTY_LEADER_CHANGED" then
        -- WR-03 (18-REVIEW.md): a plan's leaderKey/leaderTargetGroup were
        -- captured once at drain start and never refreshed, so a leadership
        -- change mid-drain (disconnect + auto-promote, manual /promote) left
        -- DecideMove's P0/P0.5 protecting the stale leader while WoW itself
        -- re-pins the NEW leader to slot 1 of their group -- a mismatch that
        -- could stall the run instead of self-correcting. Only relevant
        -- while a drain is actively ticking; a no-op otherwise.
        if activeDrain and activeDrain.plan then
            RefreshLeaderInPlan(activeDrain.plan)
        end
    elseif event == "PLAYER_ENTERING_WORLD" then
        -- Reload-resume gate (ARRANGE-06). A zone change mid-drain must not
        -- restart it -- IsOperationRunning covers both the invite and arrange
        -- drains. pendingCombatArrange also gates this: a combat-pended
        -- arrange has already called StopDrain() (activeDrain = nil), so
        -- IsOperationRunning() alone would miss it -- a mid-fight teleport
        -- would otherwise re-enter ResumeArrangeFromSource while still in
        -- combat. PLAYER_REGEN_ENABLED above is the correct resume trigger
        -- for that case.
        if Private:IsOperationRunning() or pendingCombatArrange then return end
        local entry = WildThingsSavedVars and WildThingsSavedVars.roster and WildThingsSavedVars.roster.pending_arrange
        if not entry then return end
        -- Stale, out-of-raid, or non-lead/assist: clear silently -- nothing
        -- to resume onto, no print (LOCKED privacy rule). A stale/wrong
        -- entry that slipped the freshness gate is self-correcting anyway
        -- (ResumeArrangeFromSource re-runs the idempotent pipeline).
        if not entry.timestamp or (time() - entry.timestamp) > PENDING_ARRANGE_STALENESS then
            ClearPendingArrange()
            return
        end
        if not (IsInRaid() and (UnitIsGroupLeader("player") or UnitIsGroupAssistant("player"))) then
            ClearPendingArrange()
            return
        end
        ResumeArrangeFromSource(entry)
    end
    end)
end)

-- Ends the current drain: stop listening for GROUP_ROSTER_UPDATE and clear state.
-- Called on convergence (DecideMove nil), combat lockdown, the step cap, and the
-- no-progress backstop. A supersede does NOT call this -- the newer press just
-- replaces activeDrain and reuses the still-registered frame.
local function StopDrain()
    if rosterEventFrame then
        rosterEventFrame:UnregisterEvent("GROUP_ROSTER_UPDATE")
    end
    activeDrain = nil
    Private.opProgress.arrange = nil
    if Private.NotifyProgressChanged then Private.NotifyProgressChanged() end
end

-- Forward declaration: ArmNextStep (below) schedules the fallback timeout that
-- re-enters ProcessStep, and ProcessStep arms the following step via ArmNextStep.
local ProcessStep

-- Arm the next paced step: it fires on whichever comes FIRST of (a) a
-- GROUP_ROSTER_UPDATE (OnRosterEvent, fast path) or (b) the fallback timeout below.
-- A per-step token makes only the first trigger win: ProcessStep bumps the token as
-- it arms the next step, so a late timeout for an already-advanced step -- or a late
-- event -- sees a stale token and no-ops (single-advance guard).
local function ArmNextStep()
    local d = activeDrain
    d.token = d.token + 1
    d.awaiting = true
    local gen, myToken = d.gen, d.token
    C_Timer.After(ARRANGE_FALLBACK, function()
        local dd = activeDrain
        if dd and dd.gen == gen and dd.awaiting and dd.token == myToken then
            ProcessStep(gen, true)
        end
    end)
end

-- Process ONE paced step: take a fresh (server-confirmed) snapshot, issue exactly
-- one mutation, then arm the next step. viaTimeout tells the no-progress backstop
-- whether this step was reached by the fallback timeout (a possible stall) or by a
-- GROUP_ROSTER_UPDATE (real progress). Stops on: supersede (generation mismatch),
-- the step cap, combat lockdown, the no-progress backstop, or convergence
-- (DecideMove nil). A rejected/failed move is NOT fatal (v3 removed the single-move
-- abort): it fires no event, so the fallback timeout re-evaluates next step from a
-- fresh snapshot -- which now reflects server-confirmed group counts, so DecideMove
-- can never re-pick a SetRaidSubgroup into a now-full group ("Your party is full").
function ProcessStep(gen, viaTimeout)
    local d = activeDrain
    -- Superseded by a newer press, or the drain already stopped.
    if not d or d.gen ~= gen then return end
    d.awaiting = false

    -- Wall-clock timeout (ARRANGE-03, PRIMARY bound): abort a non-convergent
    -- run within ~15s of drain start rather than letting the far looser step
    -- cap below be the only ceiling. Checked first so it fires even if the
    -- step cap would otherwise still have budget left.
    if GetTime() - d.startTime > ARRANGE_TIMEOUT then
        print(("|cff99ff00Wild Things:|r %s"):format("Arrange timed out — press again."))
        ClearPendingArrange()
        StopDrain()
        return
    end
    -- Step cap: abandonment, not a pend -- clear the persisted source.
    if d.stepsLeft <= 0 then ClearPendingArrange() StopDrain() return end
    -- Group mutation is blocked in combat (ARRANGE-05): PEND, don't abandon.
    -- StopDrain tears down the ticking (rosterEventFrame's GROUP_ROSTER_UPDATE
    -- listener) but deliberately does NOT ClearPendingArrange -- the persisted
    -- source must survive so combatResumeFrame's PLAYER_REGEN_ENABLED handler
    -- can re-resolve the FULL target layout fresh once combat ends (re-resolve,
    -- never replay) instead of leaving the raid stranded half-arranged.
    if InCombatLockdown() then
        pendingCombatArrange = true
        StopDrain()
        return
    end

    -- Chat-messaging-lockdown pause (ARRANGE-03): a transient throttle window,
    -- not a failure -- do NOT snapshot/decide/issue a move, do NOT decrement
    -- stepsLeft, do NOT update lastSig, and do NOT count a stall. Just re-arm
    -- so the next tick re-checks from a fresh snapshot once the lockdown
    -- clears. Checked before the stall-counting snapshot below so a sustained
    -- lockdown window is never misread as a no-progress stall.
    if C_ChatInfo.InChatMessagingLockdown() then
        ArmNextStep()
        return
    end

    local snap, sig = SnapshotDetailed()

    -- Event-aware no-progress backstop: only a step reached via the FALLBACK
    -- TIMEOUT whose signature is unchanged from the prior step counts as a stall (a
    -- stuck or rejected move that fired no GROUP_ROSTER_UPDATE). A step reached via
    -- the event, or with a changed signature, is real progress and resets the count
    -- -- so a run of slow offline round-trips never trips it, only a truly stuck one.
    if viaTimeout and d.lastSig ~= nil and d.lastSig == sig then
        d.stall = d.stall + 1
    else
        d.stall = 0
    end
    if d.stall >= ARRANGE_MAX_STALL then ClearPendingArrange() StopDrain() return end

    -- Publish live arrange progress (PROG-02) ahead of deciding the next move, so the
    -- bar reflects the step just snapshotted even if this step turns out to be the
    -- converging (nil-move) one.
    local inPlace, total = CountGroupsInPlace(snap, d.plan)
    Private.opProgress.arrange = { done = inPlace, total = total }
    if Private.NotifyProgressChanged then Private.NotifyProgressChanged() end

    local move = DecideMove(snap, d.plan)
    if not move then
        -- ARRANGE-01: DecideMove returning nil is not sufficient proof of
        -- completion (false-convergence gap -- see FindAllMismatches above).
        -- Re-snapshot fresh (the last move may have just landed) and run the
        -- independent full group+slot verification sweep before declaring
        -- success. Zero mismatches -> silent success (no print, LOCKED
        -- privacy rule; the progress bar clearing is the only signal). Any
        -- residual mismatch -> keep the drain alive and arm another step so
        -- the ARRANGE-02 ladder gets a further tick to act on it; re-entries
        -- are bounded by the EXISTING stall backstop / step cap (no new
        -- counter, locked) plus the ~15s wall-clock timeout landing in plan
        -- 18-02.
        local freshSnap = SnapshotDetailed()
        local mismatches = FindAllMismatches(freshSnap, d.plan)
        if #mismatches == 0 then
            -- ARRANGE-04: true completion (not a verification re-entry) --
            -- report any planned mains still absent after alt substitution,
            -- exactly once per run, using the same fresh snapshot the sweep
            -- just confirmed converged.
            ReportMissingPlayers(d.plan, freshSnap)
            ClearPendingArrange()
            StopDrain()
            if Private.FireCallback then Private:FireCallback("WT_ROSTER_CHANGED", "arrange") end
        else
            ArmNextStep()
        end
        return
    end

    d.lastSig = sig
    d.stepsLeft = d.stepsLeft - 1

    -- Issue exactly one move. A false result (a "party full" race, an offline-member
    -- edge, or a combat-lockdown race) fires no event and is left for the fallback
    -- timeout to re-evaluate from a fresh snapshot -- it never aborts the drain (v3).
    if move.kind == "set" then
        SafeSetRaidSubgroup(move.index, move.group)
    else
        SafeSwapRaidSubgroup(move.a, move.b)
    end

    ArmNextStep()
end

-- GROUP_ROSTER_UPDATE handler (active only while a drain runs). A confirmed roster
-- change means the last move landed: advance the current step immediately (fast
-- path). Only advances a step that is currently awaiting; an unrelated roster change
-- simply runs the next DecideMove, which converges/no-ops if nothing is left.
local function OnRosterEvent()
    local d = activeDrain
    if d and d.awaiting then
        ProcessStep(d.gen, false)
    end
end

-- Extracted generic apply tail (APPLY-01, 16-RESEARCH.md Pattern 1): the leader-shift,
-- plan build, and drain-kickoff statements formerly inline at the end of
-- Private:ArrangeForEncounter, now shared by both the boss-button path and the
-- upcoming "Set groups" button (Phase 16-02). Pure cut-and-wrap -- same statements,
-- same order, same variable names as before; Private:ArrangeForEncounter now ends
-- with a single call into this function. activeDrain / rosterEventFrame /
-- ARRANGE_MAX_STEPS / ArmNextStep / OnRosterEvent / GetRaidLeaderKey /
-- ShiftLeaderInPlan are all module-local upvalues already visible here, same as
-- they were inside ArrangeForEncounter -- no new upvalue plumbing needed. MUST NOT
-- re-run alt-substitution: the staged layout handed in by "Set groups" is an
-- already-resolved WYSIWYG artifact (alt-sub happens at seed time, not apply time).
function Private:ApplyTargetPositions(targetPositions)
    -- Leader constraint: pin the raid leader to slot 1 of its target group so the
    -- plan is ACHIEVABLE (WoW forces the leader to slot 1 and forbids moving it off).
    -- Done AFTER alt resolution so a leader who is himself covering an absent main's
    -- slot is pinned by his effective (post-substitution) key. Builds the EFFECTIVE
    -- (post-substitution, post-shift) bench-exclusion set and resolves the leader's
    -- final target group (its group after the shift) via the same helper used to
    -- refresh the leader mid-drain (WR-03, 18-REVIEW.md) on PARTY_LEADER_CHANGED.
    local plan = { targetPositions = targetPositions }
    RefreshLeaderInPlan(plan)

    -- Supersede any in-flight drain from a previous press (the newer boss button
    -- wins): bump the generation so stale event/timer callbacks see the mismatch
    -- and stop. Guards against overlapping drains from a double-press (Roster.lua's
    -- boss-button OnClick has no concurrency guard of its own).
    Private.arrangeGen = (Private.arrangeGen or 0) + 1

    activeDrain = {
        gen = Private.arrangeGen,
        plan = plan,
        stepsLeft = ARRANGE_MAX_STEPS,
        lastSig = nil,
        stall = 0,
        token = 0,
        awaiting = false,
        startTime = GetTime(), -- wall-clock drain start (ARRANGE-03 timeout)
    }

    -- Seed an initial arrange progress so the bar appears at once rather than waiting
    -- ~ARRANGE_FALLBACK for the first ProcessStep tick (PROG-02); refined to the
    -- present-only count on the first real tick.
    Private.opProgress.arrange = { done = 0, total = #targetPositions }
    if Private.NotifyProgressChanged then Private.NotifyProgressChanged() end

    -- Pace on GROUP_ROSTER_UPDATE, not a fixed timer: listen only while this drain
    -- is active. The invites / party->raid conversion above fire the first event
    -- (fast path); ArmNextStep's fallback timeout advances the first step if none
    -- arrives. Every subsequent step is triggered by the server confirming the
    -- previous move (fast) or the fallback timeout (rejected/no-op move).
    if not rosterEventFrame then
        rosterEventFrame = CreateFrame("Frame")
        -- Static wrapper (Pattern 3, 24-RESEARCH.md): the callback below is
        -- created ONCE here, not per-event -- it forwards to the pre-named
        -- OnRosterEvent handler via the 24-01 error-containment helper, so a
        -- GROUP_ROSTER_UPDATE burst during an arrange drain never allocates a
        -- per-event closure.
        rosterEventFrame:SetScript("OnEvent", function(self, event, ...)
            Private.safecall(OnRosterEvent, self, event, ...)
        end)
    end
    rosterEventFrame:RegisterEvent("GROUP_ROSTER_UPDATE")

    ArmNextStep()
end

-- Public progress/cancel surface (STOP-01) for the Roster screen. Defined here, right
-- before ArrangeForEncounter, because inviteDrain/activeDrain/EndInviteDrain/StopDrain/
-- Private.arrangeGen are all in lexical scope at this point.

-- True while either paced drain (invite or arrange) is running, OR an arrange is
-- combat-pended (WR-01, 18-REVIEW.md iteration 2). StopDrain clears activeDrain on
-- a combat pend, so without also consulting pendingCombatArrange here the Roster
-- screen's Stop button would silently revert to its normal (non-"Stop") state for
-- the whole combat window even though the arrange is still queued to auto-resume.
function Private:IsOperationRunning()
    return inviteDrain ~= nil or activeDrain ~= nil or pendingCombatArrange
end

-- Current progress for the Roster screen's bar: { running, label, done, total } or
-- { running = false }. Arrange takes display precedence over invite during "Invite &
-- Arrange" -- it is the later, longer-running phase, so showing it once it starts
-- avoids the bar flipping labels mid-run while invites are still trickling in. A
-- combat-pended arrange (WR-01, 18-REVIEW.md iteration 2) reports its own distinct
-- "paused" state -- checked last since a real drain's progress always takes
-- priority over the (mutually exclusive, StopDrain-cleared) pend flag.
function Private:GetOperationProgress()
    if activeDrain and Private.opProgress.arrange then
        local p = Private.opProgress.arrange
        local label = string.format("Arranging %d/%d", p.done, p.total)
        return { running = true, label = label, done = p.done, total = p.total }
    elseif inviteDrain and Private.opProgress.invite then
        local p = Private.opProgress.invite
        local label = string.format("Inviting %d/%d", p.done, p.total)
        return { running = true, label = label, done = p.done, total = p.total }
    elseif pendingCombatArrange then
        return { running = true, label = "Paused — resumes after combat" }
    end
    return { running = false }
end

-- Cancels both drains (STOP-01). The drains are idempotent/re-runnable by design (see
-- ARR-06 / ApplyTargetPositions's supersede-via-generation comment above), so cancelling
-- only needs to stop scheduling further ticks -- there is no half-applied group state to
-- unwind, and re-pressing the original button resumes cleanly from a fresh snapshot.
function Private:CancelOperations()
    EndInviteDrain()
    StopDrain()
    ClearPendingArrange()
    pendingCombatArrange = false
    Private.arrangeGen = (Private.arrangeGen or 0) + 1
    Private.opProgress.invite = nil
    Private.opProgress.arrange = nil
    if Private.NotifyProgressChanged then Private.NotifyProgressChanged() end
end

-- Invite + exact-order place + bench orchestrator, wired into the boss-button
-- OnClick (Roster.lua). Nil/empty-invitelist safe. Builds the target plan once,
-- then hands off to the event-driven drain (ProcessStep / ArmNextStep, paced on
-- GROUP_ROSTER_UPDATE) which reconciles the live raid into that plan one
-- server-confirmed group mutation at a time.
function Private:ArrangeForEncounter(enc)
    if not enc or not enc.invitelist or #enc.invitelist == 0 then return end

    -- A non-leader/assist presser can't drive SetRaidSubgroup/SwapRaidSubgroup (both pcall-
    -- guarded and silently return false), so without this check the drain would stall silently
    -- after 3 timeouts with no feedback. Gated on IsInRaid() so the solo/party cold-start path
    -- (invites form the party before a raid exists) is untouched -- SafeConvertToRaid already
    -- surfaces conversion/leader messaging for that path. In a raid, invites also require
    -- assist, so this must return before InviteMissingFromTokens below.
    if IsInRaid() and not UnitIsGroupLeader("player") and not UnitIsGroupAssistant("player") then
        print(("|cff99ff00Wild Things:|r %s"):format(
            "You must be raid leader or assistant to invite and arrange groups."))
        return
    end

    Private:InviteMissingFromTokens(enc.invitelist)

    local targetPositions = BuildTargetPositions(enc.invitelist)

    -- 04-ALT-01/02/03: resolve each absent position to a present in-raid alt
    -- BEFORE invitelistKeys is built below. This ordering is LOAD-BEARING
    -- (04-RESEARCH.md Pitfall 1): invitelistKeys is the bench-exclusion set the
    -- drain uses to decide who is "on the list" -- building it from the pre-
    -- substitution keys would make the bench step immediately bench a just-
    -- placed alt on the very next tick. Resolving first means the sets below are
    -- built from the EFFECTIVE (post-substitution) keys.
    local plannedKeys = BuildKeySet(targetPositions)
    ResolveAltSubstitutions(targetPositions, plannedKeys)

    -- ARRANGE-06: persist ONLY the re-derivable input source (encounterID) +
    -- an epoch timestamp -- never computed moves/positions -- so a combat pend
    -- or a mid-arrange /reload can re-resolve this exact request fresh. Covers
    -- both boss-button callers (Roster.lua, RosterScreen.lua) with one write.
    WildThingsSavedVars.roster.pending_arrange = { kind = "encounter", encounterID = enc.id, timestamp = time() }

    Private:ApplyTargetPositions(targetPositions)
end
