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

--[[
Migration/defaults authoring rules (MIGRATE-01/02/03):
  1. A migration `id` is a permanent forever-string. Never reuse or rename an
     id once shipped -- `WildThingsSavedVars.migrations[id]` on a live player's
     saved file is the only record that it already ran.
  2. A migration `body` must be idempotent -- it may run again (e.g. after a
     forced-error retry) and must not double-apply.
  3. A migration `body` must NOT call live game APIs (GetRaidRosterInfo, unit
     queries, C_* accessors, etc.) -- bodies transform WildThingsSavedVars
     only, nothing else.
  4. The success flag is stamped ONLY on success. A body that errors leaves
     its flag unset and is retried on the next login.
]]

-- Ordered registration list (registration order == execution order) plus an
-- id-keyed lookup for duplicate-id rejection.
local _migrations = {}
local _migrationsById = {}

--- Registers a migration spec `{ id = string, body = function }`.
--- Called at file-load time; only collects the spec, does not execute it.
function Private:RegisterMigration(spec)
    if type(spec) ~= "table" or type(spec.id) ~= "string" or spec.id == ""
       or type(spec.body) ~= "function" then
        error("RegisterMigration: spec must be {id=string, body=function}", 2)
    end
    if _migrationsById[spec.id] then
        error("RegisterMigration: duplicate migration id '" .. spec.id .. "'", 2)
    end
    _migrations[#_migrations + 1] = spec
    _migrationsById[spec.id] = spec
end

--- MIGRATE-01 entry point. Must be the FIRST call in wt:OnInitialize(),
--- strictly before the defaults pass (EnsureSettings/ApplyDefaultSettings) --
--- see Pitfall 1 in 19-RESEARCH.md: defaulting a key before migrations run
--- destroys the "key never existed vs. intentional nil" signal.
function Private:RunMigrations()
    -- Freshness must be captured BEFORE creating any sub-table, else a fresh
    -- install would immediately look "non-empty" once .migrations exists.
    local isFresh = (WildThingsSavedVars == nil) or (type(WildThingsSavedVars) == "table" and next(WildThingsSavedVars) == nil)

    if not WildThingsSavedVars then WildThingsSavedVars = {} end
    if type(WildThingsSavedVars.migrations) ~= "table" then WildThingsSavedVars.migrations = {} end
    local flags = WildThingsSavedVars.migrations

    if isFresh then
        -- Fresh-install fast path: stamp every registered migration as done
        -- WITHOUT running any body (there is no legacy data to transform).
        for _, spec in ipairs(_migrations) do
            flags[spec.id] = true
        end
    else
        for _, spec in ipairs(_migrations) do
            if not flags[spec.id] then
                local ok, err = pcall(spec.body)
                if ok then
                    flags[spec.id] = true
                else
                    print(("|cff99ff00Wild Things:|r migration '%s' failed: %s"):format(spec.id, tostring(err)))
                end
            end
        end
    end

    -- MIGRATE-03: version-gated shipped-data apply, reachable on both the
    -- fresh and normal paths. Private.RosterAlts is now populated in packaged
    -- builds too: the battletag-free half (RosterAltsData.lua, carrying
    -- exportedAt/versionNumber/charInfo) ships, and only the battletag maps in
    -- RosterAlts.lua stay behind #@do-not-package@. The nil-guard is kept
    -- because ApplyVersionedData must still tolerate a stripped/absent table,
    -- but the packaged build no longer relies on it -- it now takes the same
    -- pure-stamp path as a dev build (rosterAlts has zero numbered steps).
    Private:ApplyVersionedData("rosterAlts", Private.RosterAlts)
end

-- Seed migration: quick tasks 10/11 (commits 6f73f17/dc28177, 2026-07-20)
-- removed the `update.auto_open`/`assignment.auto_open` defaults, but players
-- who logged in before those commits still carry the stale keys in their
-- saved file. Idempotent by construction -- once the keys are gone, or for
-- anyone who never had them, the body is a no-op.
Private:RegisterMigration({
    id = "remove_stale_auto_open_keys_v1",
    body = function()
        if type(WildThingsSavedVars.update) == "table" then
            WildThingsSavedVars.update.auto_open = nil
        end
        if type(WildThingsSavedVars.assignment) == "table" then
            WildThingsSavedVars.assignment.auto_open = nil
        end
    end,
})

-- Phase 59 removed the nav-blink feature, its Settings row and its DEFAULTS
-- leaf. Players who logged in before this commit still carry the stale
-- `summons.auto_open_blink` key in their saved file; ApplyDefaultSettings
-- only fills absent keys, so a DEFAULTS-only edit would leave the stale key
-- set to true forever on every existing install. Idempotent by construction.
Private:RegisterMigration({
    id = "remove_stale_auto_open_blink_key_v1",
    body = function()
        if type(WildThingsSavedVars.summons) == "table" then
            WildThingsSavedVars.summons.auto_open_blink = nil
        end
    end,
})

-- WW1-BANK-ORGANIZE (v2.4 shape change): fixed target stack size for items
-- carried over by bankorganize_percolumn_v1 below -- the migration body
-- cannot call a live game API to read an item's real max stack (authoring
-- rule 3), so 200 is a sane consumable/reagent default the user edits
-- per-column afterward. COLS_PER_TAB (14) is the visual column count per
-- guild bank tab used for the placement math.
local DEFAULT_STACK = 200
-- Exposed via Private so Phase 32+'s BankOrganize.lua can consume the same
-- layout constant instead of redeclaring (and risking drift from) it. The
-- file-local alias below keeps existing call sites in this file unchanged.
Private.BANK_COLS_PER_TAB = 14
local COLS_PER_TAB = Private.BANK_COLS_PER_TAB

-- BANKCFG-04 (closes RESEARCH Pitfall 1): converts the old flat
-- `bankorganize.items` itemID array into the sparse per-tab/per-column
-- shape `bankorganize.tabs[tabIndex].columns[colIndex] = {itemID, stackSize}`.
-- Placement policy: one item per column, filling Tab 1 cols 1..N in the
-- list's original order; overflow spills to Tab 2 col 1.., etc. -- every
-- itemID lands in exactly one column (zero silent drops). Idempotent: a
-- re-run once `items` is already gone (or was never populated) is a clean
-- no-op via the top guard.
Private:RegisterMigration({
    id = "bankorganize_percolumn_v1",
    body = function()
        local bo = WildThingsSavedVars.bankorganize
        if type(bo) ~= "table" or type(bo.items) ~= "table" or next(bo.items) == nil then
            -- Nothing to carry over (never configured, or already migrated) --
            -- still fall through to delete a stray empty `items` key below.
            if type(bo) == "table" then bo.items = nil end
            return
        end

        -- `bo.items` may be a sparse/hole-having array (partially-corrupted
        -- SavedVars) -- `ipairs`/`#` silently truncate at the first hole,
        -- which would drop every itemID after it. Collect numeric keys via
        -- `pairs` and sort them so every itemID is placed, in original
        -- order (zero silent drops).
        local indices = {}
        for i in pairs(bo.items) do
            if type(i) == "number" then
                indices[#indices + 1] = i
            end
        end
        table.sort(indices)

        bo.tabs = bo.tabs or {}
        local n = #indices
        for placementIndex, i in ipairs(indices) do
            local itemID = bo.items[i]
            local tabIndex = math.ceil(placementIndex / COLS_PER_TAB)
            local colIndex = ((placementIndex - 1) % COLS_PER_TAB) + 1
            bo.tabs[tabIndex] = bo.tabs[tabIndex] or { columns = {} }
            bo.tabs[tabIndex].columns[colIndex] = { itemID = itemID, stackSize = DEFAULT_STACK }
        end

        bo.items = nil -- single source of truth is now `tabs`

        -- SC1 chat-print confirmation -- silent when there was nothing to migrate.
        if n <= COLS_PER_TAB then
            print(("|cff99ff00Wild Things:|r migrated %d bank item(s) -> Tab 1 cols 1..%d")
                :format(n, n) .. " -- set target stack sizes in /wt -> Guild Bank")
        else
            local spillTabs = math.ceil(n / COLS_PER_TAB)
            print(("|cff99ff00Wild Things:|r migrated %d bank item(s) across Tab 1..%d")
                :format(n, spillTabs) .. " -- set target stack sizes in /wt -> Guild Bank")
        end
    end,
})

-- Phase 50 (FEAST-F2): converts the Phase 41 single-feast SavedVars shape
-- {enabled=true, duration=15} into the Phase 50 multi-type structure with
-- per-type enable toggles, per-type duration overrides, and a global
-- defaultDuration fallback. Idempotent by construction:
--   1. If `feast` is already in the new shape (enabled is a table), the
--      migration body does nothing and the flag is stamped.
--   2. If `feast` is in the old shape (enabled is a boolean/duration is a
--      number), values are carried forward into the new shape and old keys
--      are cleaned up.
--   3. If `feast` doesn't exist at all (fresh install), the DEFAULTS pass
--      above handles it and the migration is a pure stamp -- the body is
--      still a no-op because `svFeast` is nil at entry.
Private:RegisterMigration({
    id = "feast_multitype_v1",
    body = function()
        local svFeast = WildThingsSavedVars.feast
        if type(svFeast) ~= "table" then return end

        -- Detect old shape: `enabled` is a boolean (not a sub-table).
        if type(svFeast.enabled) == "table" then return end -- already migrated

        local wasEnabled = svFeast.enabled
        local wasDuration = svFeast.duration

        -- Replace with the new multi-type structure, carrying over the old
        -- enable and duration values into the feast slot specifically.
        svFeast = {
            defaultDuration = 15,
            layout = "horizontal",
            enabled = {
                feast    = (wasEnabled ~= false), -- nil/true → true, false → false
                cauldron = true,
                soulwell = true,
                repair   = true,
            },
            duration = {
                feast    = wasDuration,   -- nil or number; nil = use defaultDuration
                cauldron = nil,
                soulwell = nil,
                repair   = nil,
            },
        }
        WildThingsSavedVars.feast = svFeast

        print(("|cff99ff00Wild Things:|r migrated feast settings to multi-type structure")
            .. " (feast enabled=" .. tostring(svFeast.enabled.feast)
            .. " duration=" .. tostring(svFeast.duration.feast) .. ")")
    end,
})

-- Phase 51 (INSPECT-F1): converts the Phase 46 single-potion-threshold SavedVars
-- shape {potionThreshold=N} into the Phase 51 split shape {combatPotionThreshold=N,
-- utilityPotionThreshold=2}. Idempotent by construction:
--   1. If `inspect` is missing or `combatPotionThreshold` already exists, the body
--      is a no-op (already migrated or fresh install handled by DEFAULTS above).
--   2. If `inspect.potionThreshold` exists, its value is carried forward into
--      `combatPotionThreshold` and the old key is deleted.
Private:RegisterMigration({
    id = "inspect_potion_split_v1",
    body = function()
        local svInspect = WildThingsSavedVars.inspect
        if type(svInspect) ~= "table" then return end
        if svInspect.combatPotionThreshold ~= nil then return end -- already migrated

        local oldValue = svInspect.potionThreshold
        svInspect.combatPotionThreshold = (type(oldValue) == "number") and oldValue or 2
        svInspect.utilityPotionThreshold = 2
        svInspect.potionThreshold = nil

        print(("|cff99ff00Wild Things:|r migrated inspect potion threshold → combatPotionThreshold=%d")
            :format(svInspect.combatPotionThreshold))
    end,
})

--- Ready Check/cooldown SavedVars reshape (Vision-Angleichung, replaces the
-- Phase-25 `cooldown.enabled`/`cooldown.auto_not_ready` pair with a string-enum
-- `trigger_mode` + the now-independent `auto_decline`/`announce_chat` pair, and
-- drops the overlay-only `overlay_pos` + the dev-only-rot `group_assignment`
-- keys now that their backend functions are also removed in ReadyCheck.lua).
-- Idempotent:
--   1. If the new keys are already present (already migrated), the body no-ops
--      and the flag is stamped.
--   2. If the old keys are present, transform forward into the new shape and
--      delete the old keys.
--   3. If neither is set (fresh install), the DEFAULTS pass handles the new
--      shape and the body is a pure no-op.
--
-- `trigger_mode` migrates UNCONDITIONALLY to "always" -- `enabled` is NOT
-- consulted. Reason: `enabled` only gated the (long-deactivated, `do return end`)
-- overlay rendering; it never gated the still-live `auto_not_ready`-driven
-- DeclineReadyCheck/PostCooldownChat behavior. Mapping `enabled == false` onto
-- `trigger_mode == "disabled"` would silently switch off a still-active
-- auto-decline for users carrying `enabled = false, auto_not_ready = true` in
-- their SavedVars, breaking the real, live, pre-migration behavior. The whole
-- trigger-mode gate now lives on `auto_decline`/`announce_chat` directly, which
-- is the path that actually decides whether RunCooldownCheck does anything.
Private:RegisterMigration({
    id = "readycheck_trigger_mode_v1",
    body = function()
        local rc = WildThingsSavedVars.readycheck
        if type(rc) ~= "table" then return end

        local cd = rc.cooldown
        if type(cd) ~= "table" then return end

        -- Already migrated (new keys present AND old keys gone) -- no-op stamp.
        if cd.trigger_mode ~= nil and cd.auto_not_ready == nil and cd.enabled == nil then
            return
        end

        -- Carry `auto_not_ready` forward into BOTH new toggles -- the old
        -- behavior bundled auto-decline + chat-announce inseparably, so the
        -- migration must reproduce that bundling until the user manually
        -- separates them in Settings. nil/true → true, false → false.
        local wasAutoNotReady = (cd.auto_not_ready ~= false)

        cd.trigger_mode = "always"
        cd.auto_decline = wasAutoNotReady
        cd.announce_chat = wasAutoNotReady

        -- Old keys: remove so the SavedVars no longer carries the legacy shape.
        cd.auto_not_ready = nil
        cd.enabled = nil

        -- Drop the overlay-only position key -- the overlay it would have
        -- positioned is removed entirely in ReadyCheck.lua (do-return-and-delete).
        rc.overlay_pos = nil

        -- Drop the deprecated "group assignment" toggle AND its backend's
        -- SavedVar -- CheckGroupAssignment is removed in ReadyCheck.lua so
        -- group_assignment would otherwise be a leftover leaf.
        rc.group_assignment = nil
    end,
})

-- MIGRATE-02: recursive fill-absent-only defaults. Never overwrites an
--- existing scalar/leaf; a leaf table is assigned by reference only when the
--- key is absent (no deep-copy -- harmless for this single-account,
--- single-session table).
local function ApplyDefaults(target, defaults)
    for k, v in pairs(defaults) do
        if target[k] == nil then
            target[k] = v
        elseif type(v) == "table" then
            if type(target[k]) == "table" then
                ApplyDefaults(target[k], v)
            end
            -- else: target[k] exists but is not a table (type mismatch,
            -- e.g. corrupted or legacy SavedVars) -- honor the
            -- fill-absent-only contract and leave the existing value
            -- untouched rather than silently discarding it.
        end
    end
end

-- Declarative defaults inventory, ported 1:1 from the former hand-written
-- EnsureSettings() body (WildThings.lua). `roster.pending_arrange` is
-- intentionally OMITTED -- it is nil-by-design (ARRANGE-06 contract, Phase
-- 18), never a defaulted key; adding an entry here would make an explicit
-- absence indistinguishable from an in-progress arrange.
local DEFAULTS = {
    minimap = { hide = false },
    update = { party_mode = false, memes = true },
    assignment = { current_encounter_id = 0 },
    -- import_export/import_captured_at: Phase 21 import persistence
    -- (IMPORT-01). import_export == "" means "no persisted import yet"
    -- (WildThings.lua's OnInitialize resolution line treats "" as absent).
    roster = { view_mode = "groups", import_export = "", import_captured_at = 0 },
    settings = { frames = {} },
    invite = {
        words = "inv invite",
        by_whisper = false,
        autopromote = false,
        promote_ranks = {},
        promote_bnets = "",
        promote_names = "",
        auto_raid_diff = false,
        raid_diff = 16,
    },
    -- Ready Check SavedVars shape (Vision-Angleichung). The `overlay_pos` and
    -- `group_assignment` fields from Phase 25 are gone -- their overlays and
    -- backend are removed entirely; legacy SavedVars carrying them are cleaned
    -- up by the readycheck_trigger_mode_v1 migration above.
    readycheck = {
        cooldown = {
            -- Vision-Angleichung: when the ready-check triggers (any in-raid
            -- READY_CHECK), should RunCooldownCheck run?
            --   "always"   -- every in-raid ready-check.
            --   "wt_only"  -- only when the raid leader is in the same Wild Things
            --                 guild on the same Blackhand realm (gated via
            --                 GetGuildInfo on the leader's unit, in ReadyCheck.lua).
            --                 Default for fresh installs -- WT's own raids are the
            --                 intended audience for the chat announce below.
            --   "disabled" -- never.
            -- The runtime tolerates an unknown/legacy value as "always"
            -- (ReadyCheck.lua:ShouldRunReadyCheckCooldown's final-return-true).
            -- NOTE: existing installs are migrated to "always" unconditionally
            -- (see readycheck_trigger_mode_v1 above) -- only FRESH installs get
            -- this "wt_only" default.
            trigger_mode = "wt_only",
            -- Auto-decline + chat-announce are independent toggles. Auto-decline
            -- default OFF -- declining the ready check is intrusive. Announce
            -- default ON -- the whole point of "wt_only" is a chat heads-up for
            -- the guild's own raids; users opt out per-axis in Settings.
            auto_decline = false,
            announce_chat = true,
            -- `per_spec[specID] = { [spellID] = true }` is the user's own
            -- opt-in spell selection, written by Settings' per-spec Cooldowns
            -- toggles (candidates sourced from cooldown_candidates.lua) --
            -- all-OFF by default, so an empty table here means nothing is
            -- watched until the user enables at least one spell.
            per_spec = {},
        },
        -- READY-05 optional popup: rank-0-2-only auto-open of the cooldown
        -- window (the popup-builder gate in ReadyCheckCooldownPopup.lua
        -- caps construction on ViewerHasCapability, so non-eligible
        -- viewers never see the row OR the popup chrome). auto_popup is
        -- opt-in (default false) per CONTEXT.md "Toggle steuert nur ob";
        -- the toggle controls ONLY whether the local popup auto-opens on
        -- READY_CHECK, NOT whether the local sender broadcasts (gates are
        -- separate in ReadyCheckCooldownComm.lua).
        cooldownwindow = { auto_popup = false },
    },
    -- WW1-BANK-ORGANIZE (v2.4 shape change): fresh-install default for the
    -- per-tab/per-column config. Existing populated `items` arrays are
    -- handled by the bankorganize_percolumn_v1 migration above, NOT by this
    -- fill-absent-only entry (Pitfall 1 -- ApplyDefaults must never be asked
    -- to carry forward an already-populated key).
    -- OVERFLOW-01/02 (Phase 35): spill_to_bags selects the surplus disposition
    -- -- false (default) consolidates onto the leftmost configured column;
    -- true spills to the presser's character bags. New leaf, no numbered
    -- migration (25-01/26-01 precedent).
    bankorganize = {
        tabs = {},
        spill_to_bags = false,
    },
    -- SHARE-03 (Phase 37): payload/sharedAt intentionally OMITTED (nil-by-design
    -- -- absence of a stored payload IS how "no timeline yet" is detected).
    -- Only `defeated` gets a fill-absent default, since an empty kill-tracking
    -- table is the correct fresh-install state. No numbered migration (brand-new
    -- key, matching the readycheck/bankorganize precedent).
    -- show_boss_model: opt-OUT (default true) for the bottom section's 3D boss
    -- model panel. ApplyDefaults is nil-checked, so a user's stored `false`
    -- survives every reload rather than being re-defaulted back on.
    timeline = {
        defeated = {},
        show_boss_model = true,
    },
    -- FEAST-F2 (Phase 50): multi-type reminder defaults. `defaultDuration` is the
    -- auto-hide fallback for any type whose per-type override is nil (default 15s).
    -- `layout` is "horizontal" or "vertical" (default horizontal -- icons side-by-side).
    -- `enabled.*` are per-type master toggles (default ALL ON).
    -- `duration.*` are per-type override durations (nil = use defaultDuration).
    -- The anchor's screen position is deliberately NOT stored here -- it lives in
    -- WildThingsSavedVars.WildThingsUI.frameSettings via WTUI:SavePosition/
    -- RestorePosition (UI/Functions.lua).
    feast = {
        defaultDuration = 15,
        layout = "horizontal",
        enabled = {
            feast    = true,
            cauldron = true,
            soulwell = true,
            repair   = true,
        },
        duration = {
            feast    = nil,
            cauldron = nil,
            soulwell = nil,
            repair   = nil,
        },
    },
    -- SHELL persistence (quick task 260725-5kp): brand-new key, no numbered
    -- migration needed -- same precedent as the timeline/bankorganize entries
    -- above. "" is the "never navigated yet" sentinel (matches
    -- roster.import_export's convention); ApplyDefaults' nil-check is what lets
    -- a stored screen id survive every reload instead of being re-defaulted.
    shell = { last_screen = "" },
    -- INSPECT-11 (Phase 46): fresh-install defaults for the Raid Inspect round
    -- model. `lastRound` is nil-by-design (no encounter yet), and `pullID`
    -- starts at 0. ApplyDefaults is nil-checked, so a stored round/pullID
    -- survives every reload. New leaf, no numbered migration needed -- same
    -- precedent as the feast/timeline/bankorganize/shell entries above.
    inspect = {
        lastRound = nil,
        pullID = 0,
        -- INSPECT-10 / INSPECT-F1: warning thresholds, user-configurable in Settings.
        -- crestThreshold: Myth Dawncrest count below this flags the player.
        -- combatPotionThreshold: combat/throughput-potion count below this flags the player.
        -- utilityPotionThreshold: utility-potion count below this (reserved, unused this phase).
        -- flaskThreshold: fleeting-flask count below this flags the player.
        crestThreshold = 5,
        combatPotionThreshold = 2,
        utilityPotionThreshold = 2,
        flaskThreshold = 1,
    },
    -- BREAK-01: fresh-install default for the break reminder. `enabled` is the master
    -- toggle (default ON). The anchor's screen position is deliberately NOT stored here
    -- -- it lives in WildThingsSavedVars.WildThingsUI.frameSettings via
    -- WTUI:SavePosition/RestorePosition (UI/Functions.lua). New leaf, no numbered
    -- migration needed -- same precedent as the feast/timeline/bankorganize entries.
    breakreminder = {
        enabled = true,
    },
    -- AUTORES-01: frische Standardwerte für das automatische Akzeptieren von
    -- Wiederbelebungen. Der Master ist standardmäßig AUS, beide Sicherheits-
    -- ausschlüsse standardmäßig AN. Neues Blatt, keine nummerierte Migration
    -- nötig -- nur Formänderungen bestehender Schlüssel brauchen eine solche
    -- (siehe den Präzedenzfall feast_multitype_v1).
    autoresaccept = {
        enabled = false,
        excludeCombat = true,
        excludeAfterlife = true,
    },
    -- SUMMON-01 (Phase 48): auto-open toggle, default true (opt-out, not
    -- opt-in -- shipping OFF would deliver the defect by default). New leaf,
    -- no numbered migration needed -- same precedent as the
    -- feast/shell/inspect entries above. See .planning/v2.7-MILESTONE-AUDIT.md §1.
    summons = {
        auto_open = true,
    },
}

--- Nil-inits WildThingsSavedVars, then fills every absent DEFAULTS key.
function Private:ApplyDefaultSettings()
    if not WildThingsSavedVars then WildThingsSavedVars = {} end
    ApplyDefaults(WildThingsSavedVars, DEFAULTS)
end

--- MIGRATE-03: generic version-gated shipped-data step applier. `versioned`
--- is expected to look like `{ versionNumber = N, [1] = fn, [2] = fn, ... }`
--- -- numbered integer keys greater than the stored version and up to
--- `versionNumber` are called, in order, then the stored version is stamped.
--- RosterAlts ships with zero numbered steps today (pure stamp -- see
--- 19-RESEARCH.md Pitfall 6); a future consumer with real per-item SavedVars
--- edits can supply numbered step functions without changing this helper.
function Private:ApplyVersionedData(key, versioned)
    if type(versioned) ~= "table" or type(versioned.versionNumber) ~= "number" then return end
    if type(WildThingsSavedVars.data_versions) ~= "table" then WildThingsSavedVars.data_versions = {} end
    local oldVersion = WildThingsSavedVars.data_versions[key] or 0
    local newVersion = versioned.versionNumber
    if newVersion <= oldVersion then return end

    local steps = {}
    for stepNum in pairs(versioned) do
        if type(stepNum) == "number" and stepNum > oldVersion and stepNum <= newVersion then
            steps[#steps + 1] = stepNum
        end
    end
    table.sort(steps)

    -- A failed step must not let the stored version advance past it -- a
    -- later step may depend on state the failed step was supposed to write.
    -- Retry-next-login semantics mirror the migration-body pcall contract.
    -- allSucceeded stays true when `steps` is empty (pure-stamp consumers,
    -- e.g. RosterAlts) or when every attempted step succeeds, so the stored
    -- version advances all the way to `newVersion` in both cases; it is only
    -- pinned to the last successful step number when a step actually fails.
    local appliedVersion = oldVersion
    local allSucceeded = true
    for _, stepNum in ipairs(steps) do
        local stepFn = versioned[stepNum]
        if type(stepFn) == "function" then
            local ok, err = pcall(stepFn)
            if ok then
                appliedVersion = stepNum
            else
                print(("|cff99ff00Wild Things:|r versioned data step '%s#%d' failed: %s"):format(key, stepNum, tostring(err)))
                allSucceeded = false
                break
            end
        else
            appliedVersion = stepNum
        end
    end
    if allSucceeded then
        appliedVersion = newVersion
    end

    WildThingsSavedVars.data_versions[key] = appliedVersion
end

--- BANKCFG-02: nil-safe chained read of a single bank-tab column entry.
--- Returns the `{ itemID, stackSize }` table for `(tab, col)`, or nil when
--- the tab/column is unconfigured. No live game-API calls (data layer only).
function Private:GetBankColumnConfig(tab, col)
    local bo = WildThingsSavedVars.bankorganize
    local t = bo and bo.tabs and bo.tabs[tab]
    return t and t.columns and t.columns[col]
end

--- BANKCFG-02: writes a single bank-tab column entry, lazily creating the
--- `tabs[tab]`/`columns` sub-tables as needed. `stackSize` is coerced to a
--- positive integer >= 1 at this write seam (floor-to-1 on non-numeric or
--- < 1 input) -- no game-API max-stack clamp belongs in the data layer.
function Private:SetBankColumnConfig(tab, col, itemID, stackSize)
    local bo = WildThingsSavedVars.bankorganize
    if type(bo) ~= "table" then return end
    if type(bo.tabs) ~= "table" then bo.tabs = {} end
    if type(bo.tabs[tab]) ~= "table" then bo.tabs[tab] = { columns = {} } end
    if type(bo.tabs[tab].columns) ~= "table" then bo.tabs[tab].columns = {} end
    local size = tonumber(stackSize)
    size = (size and size >= 1) and math.floor(size) or 1
    bo.tabs[tab].columns[col] = { itemID = itemID, stackSize = size }
end

--- BANKCFG-03 (SC3, with SC4 prune): removes exactly one column entry,
--- leaving sibling columns and every other tab untouched. Once a tab's last
--- configured column is cleared, the whole `tabs[tab]` entry is pruned so
--- the structure stays sparse (SC4) -- no empty-`columns` placeholder left
--- behind. A no-op (never errors) when the tab/column is already absent.
function Private:ClearBankColumnConfig(tab, col)
    local bo = WildThingsSavedVars.bankorganize
    local t = bo and bo.tabs and bo.tabs[tab]
    if not t or not t.columns then return end
    t.columns[col] = nil
    if next(t.columns) == nil then
        bo.tabs[tab] = nil -- SC4: prune empty tab, keep the map sparse
    end
end

--- BANKCFG-01 (SC4 reader): nil-safe chained read of a whole tab's config.
--- Returns the tab table when configured, nil when the tab has no config --
--- the "is this tab left untouched by Organize?" reader Phases 32-34 use.
function Private:GetBankTabConfig(tab)
    local bo = WildThingsSavedVars.bankorganize
    return bo and bo.tabs and bo.tabs[tab]
end

--- SHELL persistence (quick task 260725-5kp): nil-safe chained read of the
--- last-shown shell screen id. Returns nil (never "") when the chain is
--- absent, the stored value is not a string, or it is the "" sentinel --
--- callers get one unambiguous "nothing persisted" signal. No game-API calls
--- (data layer only).
function Private:GetLastShellScreen()
    local shell = WildThingsSavedVars.shell
    local id = shell and shell.last_screen
    if type(id) ~= "string" or id == "" then return nil end
    return id
end

--- SHELL persistence (quick task 260725-5kp): writes the last-shown shell
--- screen id, lazily creating `WildThingsSavedVars.shell` if absent. Rejects
--- a non-string or empty `id` up front so a corrupt/blank value can never be
--- stored. No game-API calls (data layer only).
function Private:SetLastShellScreen(id)
    if type(id) ~= "string" or id == "" then return end
    if type(WildThingsSavedVars.shell) ~= "table" then WildThingsSavedVars.shell = {} end
    WildThingsSavedVars.shell.last_screen = id
end

