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

-- Create the Ace addon
Private.Ace = LibStub("AceAddon-3.0"):NewAddon("WildThings", "AceTimer-3.0")
local wt = Private.Ace

-- LDB and Minimap Icon Setup
local LDB = LibStub("LibDataBroker-1.1")
local LDBIcon = LibStub("LibDBIcon-1.0")

-- Shared design tokens (padding/gap/text colour) for the icon-audit window below.
-- Mirrors ReadyCheck.lua's binding. UI/UI.xml loads at WildThings.toc:23, well before
-- this file at :37, so Private.Theme is populated by the time this line runs.
local Theme = Private.Theme

-- Ensures the necessary addon settings is SavedVariables all exist and have sane values.
-- Delegates to Private:ApplyDefaultSettings() (Migrations.lua, MIGRATE-02) -- the
-- declarative DEFAULTS table there is the source of truth for this inventory.
-- NOTE: roster.pending_arrange is intentionally NOT defaulted (nil == absent by
-- design, ARRANGE-06 contract) -- see Migrations.lua's DEFAULTS comment.
local function EnsureSettings()
    Private:ApplyDefaultSettings()
end

-- Updates the visibility and look of the minimap icon
-- When the player's addons/auras are up to date, and there are no warnings the icon is either white or hidden
-- If they are not, the icon is red
-- This is called whenever the player's version table updates, and whenever the highest seen versions update
function Private:UpdateMinimapIcon()
    -- Create minimap icon if it doesn't exist yet
    if not Private.LDB then
        Private.LDB = LDB:NewDataObject(
            "Wild Things AddOn",
            {
                type = "data source",
                text = "Wild Things AddOn",
                icon = [[Interface\Addons\WildThings\Media\Icons\icon_big.tga]],
                OnClick = function(clickedFrame, button)
                    if button == "LeftButton" then
                        if Private.rosterShell and Private.rosterShell:IsShown() then
                            Private.rosterShell:Hide()
                        else
                            Private:OpenRosterShell()
                        end
                    end
                end,
                OnTooltipShow = function(tooltip)
                    local ICON_MOUSE_LEFT = "|A:newplayertutorial-icon-mouse-leftbutton:0:0|a"
                    tooltip:AddLine("Wild Things")
                    tooltip:AddLine(ICON_MOUSE_LEFT .. " Left-Click: Open Wild Things")
                end,
            }
        )

        LDBIcon:Register("WildThings", Private.LDB, WildThingsSavedVars.minimap)
    end

    -- Update color
    Private.LDB.icon = [[Interface\Addons\WildThings\Media\Icons\icon_big.tga]]

    -- Update visibility
    if WildThingsSavedVars.minimap.hide then
        LDBIcon:Hide("WildThings")
    else
        LDBIcon:Show("WildThings")
    end
end

-- Lazily creates the v2 shell ONCE (guarded so a repeat call never rebuilds it) and
-- registers both the Roster and Settings screen builders. Entry points: bare /wt and
-- the minimap left-click.
function Private:OpenRosterShell()
    if not Private.rosterShell then
        Private.rosterShell = Private:CreateShell("WildThingsShell")

        -- Packaged builds ship the roster UI/engine but keep battletag-bearing data and
        -- Dev Tools fenced out. Register each available screen independently.
        if Private.CreateRosterScreen then
            Private.rosterShell:RegisterScreen("roster", function(host) return Private:CreateRosterScreen(host) end, "roster.view")
        end
        if Private.CreateDevToolsScreen then
            Private.rosterShell:RegisterScreen("devtools", function(host) return Private:CreateDevToolsScreen(host) end, "devtools.view")
        end
        Private.rosterShell:RegisterScreen("settings", function(host) return Private:CreateSettingsScreen(host) end)
        -- ReadyCheckScreen builder lives in SettingsScreen.lua (ships in every build), so
        -- register it unconditionally alongside Settings.
        Private.rosterShell:RegisterScreen("readycheck", function(host) return Private:CreateReadyCheckScreen(host) end)
        -- InviteScreen.lua ships in every build (unlike RosterScreen), so this is
        -- registered unconditionally.
        Private.rosterShell:RegisterScreen("invite", function(host) return Private:CreateInviteScreen(host) end)
        -- BankScreen.lua ships in every build too (WW1-BANK-ORGANIZE), so this is also
        -- registered unconditionally.
        Private.rosterShell:RegisterScreen("bankorganize", function(host) return Private:CreateBankScreen(host) end, "bank.view")
        -- RaidTimelineScreen.lua ships in every build (before @do-not-package@ fence in
        -- UI.xml), so this is also registered unconditionally, like invite/bankorganize --
        -- unlike the guarded "roster" registration above.
        Private.rosterShell:RegisterScreen("timeline", function(host) return Private:CreateRaidTimelineScreen(host) end)
        -- Phase 56: the Summons screen registration is GONE. The Summoning UI
        -- is now its own standalone chrome window, opened by /wt summons or
        -- the nav row (Shell.lua:501) and reached via Private:ShowSummonWindow
        -- in SummonScreen.lua, not via Shell:ShowScreen("summons"). The
        -- deletion here is paired with the rewrite of Private:OpenSummonWindow
        -- below and the removal of function Private:CreateSummonScreen.
        -- Summoning stays open to all raiders (no capability gate was ever
        -- attached to this screen registration, confirmed by reading -- the
        -- bankorganize registration two lines above carries a "bank.view"
        -- argument; this one did not).
        -- InspectScreen.lua ships in every build (UI.xml entry is above the
        -- do-not-package fence), so this is registered unconditionally alongside
        -- summons/timeline/invite/bankorganize.
        Private.rosterShell:RegisterScreen("inspect", function(host) return Private:CreateInspectScreen(host) end)
    end

    -- ACCESS-04: re-evaluate nav visibility every open, not just at construction, so a rank
    -- change (guild promotion/demotion) between opens is reflected immediately.
    Private.rosterShell:RelayoutNav()

    -- CR-01/D-persist-gate (quick task 260725-5kp): triggers the lazy build of the
    -- selected screen now that every builder is registered (Shell.lua's own
    -- CreateShell-time ShowScreen("roster") call ran before any builder existed and was a
    -- no-op). The selection is no longer a hardcoded literal -- window:ResolveStartScreen
    -- owns validating the persisted screen id (registered in this build + capability held)
    -- and falling back to "timeline" otherwise. persistScreen flips to true here, AFTER
    -- RelayoutNav (so a rank-downgrade redirect during relayout is never recorded as a
    -- user navigation) and BEFORE this restore ShowScreen call (so the restore itself is
    -- recorded, self-healing a stale value on every open).
    Private.rosterShell.persistScreen = true
    Private.rosterShell:ShowScreen(Private.rosterShell:ResolveStartScreen())
    Private.rosterShell:Show()
end

-- SUMMON-01 (D-01): the shared window-open sequence -- Shell creation ->
-- navigate to the Summons tab -> show -> refresh with current data. This is
-- the WINDOW sequence only; opening the summon SESSION stays with each
-- caller (the auto-open path starts the session at T+0..T+3 and shows the
-- window later at the seal, T+6 -- see D-06). Used by both /wt summons and
-- the auto-open matrix's "closed" row, so the four-call sequence exists
-- exactly once in the source (ROADMAP criterion 2).
-- See .planning/v2.7-MILESTONE-AUDIT.md §1.
--
-- Phase 56 (SUMWIN-01/SHELL-05): the Summoning UI is now a standalone
-- chrome window, not a tab inside the main Shell. We delegate to
-- Private:ShowSummonWindow (defined in SummonScreen.lua) and stop
-- touching Private.rosterShell entirely from this path -- the main
-- window is left on whatever screen it was showing, per SHELL-05.
-- House-style existence guard: Private.ShowSummonWindow is set in
-- SummonScreen.lua's on-load path, so it is live by the time the chat
-- command runs, but the guard is the codebase's universal cross-file
-- fence and stays.
function Private:OpenSummonWindow()
    if Private.ShowSummonWindow then
        Private:ShowSummonWindow()
    end
end

function wt:OnInitialize()
    Private:RunMigrations()
    EnsureSettings()

    -- IMPORT-01 (Pitfall 4): apply a previously persisted import BEFORE any
    -- UI can open, so it wins the lazy-once-per-session RosterScreen parse
    -- race and survives /reload and relogin. No-op-safe in packaged builds
    -- (RosterData.lua/RosterParser.lua are dev-only there; this just sets an
    -- otherwise-unused Private field).
    if WildThingsSavedVars.roster.import_export and WildThingsSavedVars.roster.import_export ~= "" then
        Private.rosterExport = WildThingsSavedVars.roster.import_export
    end

    -- Initialize the WTUI toolkit's SavedVariables-backed state (frameSettings, etc.) before
    -- any widget is created. Required by UI/UI.lua's contract; without it WTUI.frameSettings is
    -- nil and the named v2 shell crashes on RestorePosition/RestoreSize (Functions.lua:238).
    Private.WildThingsUI:Initialize(WildThingsSavedVars)

    Private:InitializeTransmission()
    Private:InitializeInvite()

    -- Chat Commands (native SLASH_* registration; deliberately no AceConsole)
    SLASH_WILDTHINGS1 = "/wt"
    SlashCmdList.WILDTHINGS = function(msg) wt:ChatCommand(msg) end

    RunNextFrame(function() Private:UpdateMinimapIcon() end)
end

-- SUMMON-01 (D-06): true while an auto-open path is waiting on a session
-- this module itself started -- set when the roster-changed handler arms its
-- 3s pre-delay, cleared by the seal subscriber once it acts. Guards the seal
-- subscriber against firing for a seal caused by /wt summons or the Re-Scan
-- button, neither of which should auto-open a second time.
local pendingAutoOpen = false

-- SUMMON-01 (D-10): true when a seal landed while InCombatLockdown() was
-- true and the matrix decision was deferred rather than fired or dropped.
-- Cleared and re-applied by summonCombatFrame's PLAYER_REGEN_ENABLED
-- handler below.
local summonAutoOpenDeferred = false

-- SUMMON-01 (D-14): tolerant readers for the two auto-open settings toggles.
-- Both default to true (opt-out, not opt-in -- shipping OFF would deliver
-- the defect by default). An absent `summons` leaf and an absent key both
-- read as enabled; only an explicit boolean false disables. Comparing
-- against `~= false` (never testing truthiness) is what lets a user's
-- explicit false survive every reload -- Migrations.lua's DEFAULTS merge
-- only fills absent keys, never overwrites an existing one. Neither reader
-- is referenced from the /wt summons branch -- that command is never gated
-- (D-14).
local function AutoOpenEnabled()
    return WildThingsSavedVars.summons == nil
        or WildThingsSavedVars.summons.auto_open ~= false
end

-- SUMMON-01 (D-13): counts entries carrying the classifier's own "Needs
-- Summon" status string. A zero count suppresses the auto-open entirely --
-- otherwise the window would open only for SUMMON-12's auto-close to hide
-- it 5 seconds later, which reads as a bug. classified is the table
-- returned by Private:GetSummonSessionResults(); nil (no session data) is
-- treated as zero.
local function CountNeedsSummon(classified)
    if not classified then return 0 end
    local count = 0
    for _, entry in pairs(classified) do
        if entry.status == "Needs Summon" then
            count = count + 1
        end
    end
    return count
end

-- SUMMON-01 (D-07): the complete auto-open behaviour matrix, applied once
-- the summon session's data is confirmed ready (the seal). A seal with no
-- outstanding summons returns early below. Otherwise the standalone window's
-- own shown state is the only decision: an absent/hidden window opens, while
-- an already-open window is refreshed in place by SummonScreen.lua's seal
-- subscriber. The main Shell is deliberately not inspected or changed, which
-- satisfies SHELL-05 regardless of which screen it is showing.
--
-- Factored into one function so the seal subscriber and combat-resume handler
-- call the same decision rather than duplicating it.
local function ApplyAutoOpenMatrix()
    local classified = Private:GetSummonSessionResults()
    if CountNeedsSummon(classified) == 0 then
        -- SUMMON-01 (D-09 condition 2): a seal finding no outstanding
        -- summons returns without opening the window. The classified
        -- table is written only by SealSession() in production
        -- (SummonComm.lua:287; the assignment at :573 is the dev harness),
        -- so a Needs-Summon count cannot change between seals -- this is the
        -- only place this condition needs evaluating, not an oversight to
        -- poll elsewhere.
        return
    end

    -- Phase 56: window-state-only decision. Private:IsSummonWindowShown
    -- returns whether the standalone window exists and is up; the Shell's
    -- own state drops out of the matrix entirely.
    if Private.IsSummonWindowShown then
        if not Private:IsSummonWindowShown() then
            Private:OpenSummonWindow()
        end
        -- else: already open -- the window's own refresh path (seal
        -- subscriber in SummonScreen.lua) handles in-place updates.
    else
        -- Pre-Phase-56 fallback: the matrix had no other window-state
        -- check available, so on a misordered load fall back to opening
        -- the window. Better to open twice than never.
        Private:OpenSummonWindow()
    end
end

-- SUMMON-01 (D-10): combat-safe deferral for a seal that lands mid-pull.
-- SummonScreen.lua's rebuild touches SetAttribute/SetPoint on secure-
-- template rows, both blocked by InCombatLockdown() -- firing the matrix
-- straight into combat would throw. CreateFrame/RegisterEvent have no
-- Init.lua ordering dependency (unlike Private.RegisterCallback, D-04), so
-- this frame is safe to create here at file scope (Pitfall 4,
-- 48-RESEARCH.md). Mirrors RosterArrange.lua's combatResumeFrame
-- (PLAYER_REGEN_ENABLED persistent-frame pattern). Cheap early-return when
-- nothing is deferred -- not permanently active work.
local summonCombatFrame = CreateFrame("Frame")
summonCombatFrame:RegisterEvent("PLAYER_REGEN_ENABLED")
summonCombatFrame:SetScript("OnEvent", function()
    Private.safecall(function()
        if not summonAutoOpenDeferred then return end
        summonAutoOpenDeferred = false
        ApplyAutoOpenMatrix()
    end)
end)

function wt:OnEnable()
    Private.playerGUID = UnitGUID("player")
    Private.playerName = UnitName("player")
    Private.playerClassID = UnitClass("player")
    Private.playerSpecID, Private.playerSpecName, _, _, Private.playerRole = GetSpecializationInfo(GetSpecialization())

    -- SUMMON-01 (D-04): both new subscribers MUST register here, inside
    -- OnEnable, never at file scope. Private.RegisterCallback is created in
    -- Init.lua (TOC slot 38), which loads AFTER this file (TOC slot 37) -- a
    -- file-scope guard would see a nil Private.RegisterCallback and never
    -- register. OnEnable runs after every file has loaded (AceAddon
    -- lifecycle), so the guard here is live. DOT syntax with a distinct
    -- identity per subscriber (Init.lua:44-47) -- colon syntax on Private
    -- collapses every subscriber onto one key and silently drops all but the
    -- last. See .planning/v2.7-MILESTONE-AUDIT.md §1.
    if Private.RegisterCallback then
        -- "SummonWindowAutoOpen": fired by the two WT_ROSTER_CHANGED sites
        -- (RosterArrange.lua "arrange", RosterScreen.lua "import"). Only the
        -- arrange reason proceeds -- fail-closed equality test, so a nil,
        -- empty, "import" or unknown reason does nothing (D-11/D-12). The
        -- 3-second pre-delay is Phase 42's D-07 and stays as-is.
        Private.RegisterCallback("SummonWindowAutoOpen", "WT_ROSTER_CHANGED", function(event, reason)
            if reason ~= "arrange" then return end
            -- D-14: checked first, before any session opens or timer arms --
            -- a lead who turned this off gets no session and no SUMMON_REQ
            -- broadcast at all, not just a suppressed window.
            if not AutoOpenEnabled() then return end
            pendingAutoOpen = true
            C_Timer.NewTimer(3, function()
                -- D-17: OpenSummonSession now reports its own status. On
                -- "no_raid" there is nothing to wait for -- clear the flag and
                -- stop (no window, no message beyond what OpenSession already
                -- printed). On "throttled" KEEP pendingAutoOpen set -- a
                -- throttled call never arms a collect timer and therefore
                -- never seals, so the seal subscriber below will never fire
                -- for this attempt; show the existing (<=30s old) session
                -- data right here instead of waiting forever. On success,
                -- leave pendingAutoOpen set and let the seal subscriber below
                -- do the work once the data is fresh.
                local opened, status = Private:OpenSummonSession()
                if not opened and status == "no_raid" then
                    pendingAutoOpen = false
                elseif not opened and status == "throttled" then
                    Private:OpenSummonWindow()
                end
            end)
        end)

        -- "SummonWindowSealOpen": fired once the summon session's 3s collect
        -- window closes and the data is ready. Only acts when this module's
        -- own auto-open path is the one waiting (pendingAutoOpen) -- a seal
        -- from /wt summons or Re-Scan must not pop the window a second time.
        -- Applies the full D-07 behaviour matrix (D-13's zero-Needs-Summon
        -- suppression included) rather than opening unconditionally.
        Private.RegisterCallback("SummonWindowSealOpen", "WT_SUMMON_SESSION_SEALED", function()
            if not pendingAutoOpen then return end
            pendingAutoOpen = false
            -- D-10: a rebuild mid-pull would throw (SetAttribute/SetPoint on
            -- secure-template rows are blocked in combat) -- defer instead of
            -- firing or dropping; summonCombatFrame retries on
            -- PLAYER_REGEN_ENABLED.
            if InCombatLockdown() then
                summonAutoOpenDeferred = true
                return
            end
            ApplyAutoOpenMatrix()
        end)
    end
end

-- SNAP-03 icon audit. The in-scope Lucide stems the Summoning Window and Raid Inspect
-- page will reference. This flat table is a deliberate mirror of the canonical tuple of
-- the same name in generate_lucide_icons.py (the two tiers cannot read each other:
-- Python cannot read a Lua table at run time and the client cannot run Python), so the
-- two lists are asserted equal mechanically at plan-acceptance time. Order matches the
-- generator's. Extending the audit later is a one-line addition here plus there.
local AUDIT_STEMS = {
    "pin", "x", "check", "triangle-alert", "info",
    "user-round", "bell", "star", "search", "refresh-cw",
    "shield", "swords", "users", "settings", "heart",
}

-- Build-once scratch window (mirrors ReadyCheck.lua's overlayWindow idiom -- created on
-- first call, only re-shown afterwards, never recreated).
local iconAuditWindow

local AUDIT_COLUMNS = 5
local AUDIT_CELL = 64
local AUDIT_GLYPH = 24
local AUDIT_TITLE_HEIGHT = 28
local AUDIT_TITLE_TOP_PAD = 4

local function CreateIconAuditWindow()
    local window = Private:CreateWindow("IconAudit", true, false, false)

    -- CreateWindow's exit cross lives in window.buttonFrame, which AddButton parents to
    -- window.moverFrame -- a frame that only exists when movable=true. With movable=false
    -- that parent is nil, so the X is created parentless: window:Hide() does not hide it
    -- (it floats over the UI until /reload) and its strata is not derived from the window.
    -- Re-parented here rather than in the shared widget because this is the addon's only
    -- live AddButton caller (ReadyCheck's overlay returns before it reaches one). The
    -- GetParent() guard makes this a no-op if Window.lua ever fixes the root cause.
    if window.buttonFrame and not window.buttonFrame:GetParent() then
        window.buttonFrame:SetParent(window)
    end

    window:SetPoint("CENTER", UIParent, "CENTER")

    window.title = window:CreateFontString(nil, "ARTWORK")
    window.title:SetFontObject(WildThingsFont13)
    window.title:SetText("Icon Audit")
    window.title:SetTextColor(Theme.TEXT.r, Theme.TEXT.g, Theme.TEXT.b, Theme.TEXT.a)
    window.title:SetPoint("TOPLEFT", window, "TOPLEFT", Theme.PAD_WINDOW, -Theme.PAD_WINDOW - AUDIT_TITLE_TOP_PAD)
    window.title:SetPoint("TOPRIGHT", window, "TOPRIGHT", -Theme.PAD_WINDOW, -Theme.PAD_WINDOW - AUDIT_TITLE_TOP_PAD)

    local gridTop = Theme.PAD_WINDOW + AUDIT_TITLE_TOP_PAD + AUDIT_TITLE_HEIGHT

    for index, stem in ipairs(AUDIT_STEMS) do
        local column = (index - 1) % AUDIT_COLUMNS
        local row = math.floor((index - 1) / AUDIT_COLUMNS)

        local cell = CreateFrame("Frame", nil, window)
        cell:SetSize(AUDIT_CELL, AUDIT_CELL)
        cell:SetPoint("TOPLEFT", window, "TOPLEFT",
            Theme.PAD_WINDOW + column * AUDIT_CELL, -(gridTop + row * AUDIT_CELL))

        -- A stem whose texture is missing draws nothing at all and logs nothing -- the
        -- resulting labelled empty cell IS the audit signal, so there is deliberately no
        -- placeholder, fallback texture or "missing" glyph here to mask it.
        local glyph = Private:CreateIcon(cell, stem, AUDIT_GLYPH)
        -- Reset vertex colour to full white first, then force full opacity: the icon
        -- constructor seeds a 0.7 idle alpha, and a 70%-alpha or tinted glyph on a dark
        -- window reads as ambiguous -- which would turn the one check that exists to catch
        -- silent blanks into a source of them. Order matters (the colour reset would
        -- otherwise undo a colour applied before it).
        glyph:ClearTint()
        glyph:SetAlpha(1)
        glyph:SetPoint("TOP", cell, "TOP", 0, -Theme.GAP)

        local label = cell:CreateFontString(nil, "ARTWORK")
        label:SetFontObject(WildThingsFont13)
        label:SetText(stem)
        label:SetTextColor(Theme.TEXT.r, Theme.TEXT.g, Theme.TEXT.b, Theme.TEXT.a)
        label:SetJustifyH("CENTER")
        -- Word wrap is on by default, and triangle-alert/refresh-cw/user-round are all
        -- wider than the 64px cell in WildThingsFont13. Wrapping them to two lines
        -- overruns the window's bottom padding, since SetHeight below budgets exactly
        -- one cell per row -- so clip instead of wrapping.
        label:SetWordWrap(false)
        label:SetPoint("TOP", glyph, "BOTTOM", 0, -Theme.GAP)
        label:SetPoint("LEFT", cell, "LEFT", 0, 0)
        label:SetPoint("RIGHT", cell, "RIGHT", 0, 0)
    end

    -- The window constructor does not size itself, so do it explicitly after layout.
    window:SetWidth(Theme.PAD_WINDOW * 2 + AUDIT_COLUMNS * AUDIT_CELL)
    window:SetHeight(Theme.PAD_WINDOW * 2 + AUDIT_TITLE_TOP_PAD + AUDIT_TITLE_HEIGHT
        + math.ceil(#AUDIT_STEMS / AUDIT_COLUMNS) * AUDIT_CELL)

    return window
end

local function ShowIconAudit()
    if not iconAuditWindow then
        iconAuditWindow = CreateIconAuditWindow()
    end

    -- Self-inverse, like the rest of /wt: re-issuing the command dismisses the window
    -- rather than doing nothing.
    if iconAuditWindow:IsShown() then
        iconAuditWindow:Hide()
    else
        iconAuditWindow:Show()
    end
end

function wt:ChatCommand(cmd)
    if cmd == "icon hide" then
        WildThingsSavedVars.minimap.hide = true
        LDBIcon:Hide("WildThings")
        print("Wild Things Minimap Icon hidden.")
    elseif cmd == "icon show" then
        WildThingsSavedVars.minimap.hide = false
        LDBIcon:Show("WildThings")
        print("Wild Things Minimap Icon shown.")
    elseif cmd == "icon" then
        print("Usage: /wt icon [show|hide]")
    elseif cmd == "help" then
        print("Usage: /wt {command} [options]")
        print("Commands:")
        print("'icon [show|hide]' - Use to show/hide the minimap icon.")
        print("'export' - Dumps the current raid/party as paste-ready invitelist lines.")
        print("'bankpreview' - Previews how the open Guild Bank tab's items would spread (no mutation).")
        print("'inspect' - Opens the Raid Inspect page showing consumable/crest status.")
        print("(no command) - Opens the Wild Things window.")
    elseif cmd == "bankpreview" then
        -- BankOrganize.lua ships in every build (no do-not-package dependency), so this
        -- guard never fails in practice -- kept as the house style for chat commands
        -- whose backend may be fenced out of a packaged build.
        if Private.PreviewBankOrganize then
            Private:PreviewBankOrganize()
        end
    elseif cmd == "export" then
        -- GroupExport.lua ships in every build (no do-not-package dependency), so this
        -- guard never fails in practice -- kept as the house style for chat commands
        -- whose backend may be fenced out of a packaged build.
        if Private.ShowGroupExportPopup then
            Private:ShowGroupExportPopup()
        end
    elseif cmd and cmd:match("^sendertest%s+%S") then
        -- Dev-only, unadvertised (deliberately not listed under 'help'): exercises the
        -- authoritative Private:SenderHasCapability trust check (Phase 36 ACCESS-02)
        -- against an arbitrary sender-name string, single-client. Passing a bad/foreign
        -- name string lets the cross-realm-reject and nil-rank-reject paths be observed
        -- without a second client. Gated on the dev-build sentinel (WR-03 review fix) so
        -- this trust-boundary probe never ships in a packaged build.
        if Private.CreateRosterScreen then
            local senderArg = cmd:match("^sendertest%s+(.+)$")
            local myGuild, myRankName, myRankIndex, myRealm = GetGuildInfo("player")
            print("|cff99ff00Wild Things:|r own GetGuildInfo(player): guild=" .. tostring(myGuild)
                .. " rankName=" .. tostring(myRankName) .. " rankIndex=" .. tostring(myRankIndex)
                .. " realm=" .. tostring(myRealm))
            print("|cff99ff00Wild Things:|r SenderHasCapability(" .. tostring(senderArg) .. ", timeline.share)="
                .. tostring(Private:SenderHasCapability(senderArg, "timeline.share")))
        end
    elseif cmd == "timelinetest" then
        -- Dev-only, unadvertised (deliberately not listed under 'help'): injects a fake
        -- payload through the exact shipped ValidateTimelinePayload/ApplyTimelinePayload
        -- path (SHARE-05), enabling single-client persist/reset-rule verification with no
        -- second client and no comm round-trip. Gated on the same dev-build sentinel used
        -- throughout this file (Private.CreateRosterScreen) alongside sendertest.
        if Private.CreateRosterScreen then
            -- G-5nd-01 (Dashboard alt resolution): seven-player, two-boss fake payload
            -- carrying mains/mainRanks -- the single-client proof path for D-01..D-06, since
            -- the feature otherwise needs a raid, a second client and a regenerated
            -- RosterAlts.lua. Hoisted so players[1]/[2]'s mains cannot drift from each other.
            --
            -- idx 1 selfKey        TANK     73  mains=selfKey                  rank 1 -- viewer's main, exact-key fast path
            -- idx 2 selfalt-...    TANK     250 mains=selfKey                  rank 2 -- viewer's alt; badge owner path; tooltip main NOT in charInfo
            -- idx 3 flowmillion-.. DAMAGER  64  mains=flowmillion-blackrock    rank 1 -- real generated key (MAGE/64); tooltip class-colour + spec-icon path
            -- idx 4 flowwar-...    DAMAGER  72  mains=flowmillion-blackrock    rank 14 -- that person's alt, MELEE vs main's RANGED -- cross-column hide
            -- idx 5 benchmain-...  HEALER   65  mains=benchmain-antonidas      rank 1 -- truly benched, HEAL column -- surviving bench row
            -- idx 6 benchalt-...   DAMAGER  253 mains=benchmain-antonidas      rank 3 -- same person, RANGED column -- cross-column bench collapse
            -- idx 7 pugsy-...      DAMAGER  259 mains=pugsy-antonidas          rank 1 -- a pug: its own owner at rank 1, grouped with nobody
            --
            -- Boss A ("Alt Test Boss A", list = {1,3,7}): renders four rows -- TANK selected
            -- 1, RANGED selected 3, MELEE selected 7, HEAL benched 5. Indices 2, 4, 6 absent.
            -- Column counts 1/0/1/1. No info icon anywhere.
            -- Boss B ("Alt Test Boss B", list = {2,4}): renders four rows -- TANK selected 2
            -- with an info icon, MELEE selected 4 with an info icon, HEAL benched 5, MELEE
            -- benched 7. Indices 1, 3, 6 absent. Boss B's tile and the header count read In
            -- for the viewer even though index 1 is not in its list.
            local selfKey = Private:GetSelfKey()
            local fakePayload = {
                v = Private.TIMELINE_SCHEMA_VERSION,
                sharedAt = GetServerTime(),
                players = {
                    selfKey, "selfalt-antonidas", "flowmillion-blackrock", "flowwar-diealdor",
                    "benchmain-antonidas", "benchalt-antonidas", "pugsy-antonidas",
                },
                roles = { "TANK", "TANK", "DAMAGER", "DAMAGER", "HEALER", "DAMAGER", "DAMAGER" },
                specs = { 73, 250, 64, 72, 65, 253, 259 },
                mains = {
                    selfKey, selfKey, "flowmillion-blackrock", "flowmillion-blackrock",
                    "benchmain-antonidas", "benchmain-antonidas", "pugsy-antonidas",
                },
                mainRanks = { 1, 2, 1, 14, 1, 3, 1 },
                bosses = {
                    { id = 1, diff = 16, name = "Alt Test Boss A", list = { 1, 3, 7 } },
                    { id = 2, diff = 16, name = "Alt Test Boss B", list = { 2, 4 } },
                },
            }
            local valid = Private:ValidateTimelinePayload(fakePayload)
            if valid then
                Private:ApplyTimelinePayload(fakePayload)
            end
            print("|cff99ff00Wild Things:|r timelinetest valid=" .. tostring(valid)
                .. " applied=" .. tostring(valid))
        end
    elseif cmd == "snapshot" then
        -- Dev-only, unadvertised (deliberately not listed under 'help'): dumps the shipped
        -- Private:SnapshotRaidStatus view of the live raid (SNAP-01). Deliberately NOT
        -- wrapped in the dev-build sentinel guard the two harnesses above use (the
        -- roster-screen constructor), which is nil in packaged CurseForge/Wago builds, so
        -- copying it would make this command a silent no-op in exactly the build whose
        -- ability to resolve the snapshot it exists to prove. Do not "fix" it back. It is
        -- safe ungated -- it prints only live raid data the user already sees in their raid
        -- frames and crosses no trust boundary (unlike sendertest, which probes
        -- capability/trust). No existence guard is needed either: RaidSnapshot.lua ships in
        -- every build, and ChatCommand only ever runs on user input, long after file load.
        Private:DumpRaidSnapshot()
    elseif cmd == "wtraid" then
        -- Dev-only, unadvertised (deliberately not listed under 'help'): prints the
        -- official-WT-raid gate's verdict for the CURRENT group, the measured guild
        -- share behind it, and one line per raid member with the guild name that was
        -- actually read. Read-only -- it never writes the dashboard, never sends
        -- anything and never touches SavedVars.
        --
        -- This exists because the gate it probes (RaidSnapshot.lua, fix for debug
        -- session dashboard-kill-scope-and-reset) is otherwise only observable by
        -- killing a real boss in a real raid, and a wrong verdict is silent by
        -- construction. The per-member breakdown is the part that matters: a
        -- legitimate raid rejected for a share just under the threshold and one
        -- rejected because GetGuildInfo has not resolved for four players yet look
        -- identical in the verdict line and completely different here.
        --
        -- Ungated for the same reason /wt snapshot above is: RaidSnapshot.lua ships
        -- in every build, and the gate's behaviour in the PACKAGED build is exactly
        -- what needs proving. It prints only live raid data the user already sees in
        -- their raid frames and crosses no trust boundary.
        if Private.IsOfficialWildThingsRaid then
            local isOfficialRaid, reason = Private:IsOfficialWildThingsRaid()
            local wtCount, total = Private:GetRaidWildThingsShare()
            print(("|cff99ff00Wild Things:|r wtraid -- official=%s (%s)")
                :format(tostring(isOfficialRaid), tostring(reason)))
            print(("|cff99ff00Wild Things:|r inRaid=%s leaderIsWT=%s share=%d/%d")
                :format(tostring(IsInRaid()), tostring(Private:RaidLeaderIsWildThings()),
                    wtCount, total))
            for i = 1, (GetNumGroupMembers() or 0) do
                local unit = "raid" .. i
                if UnitExists(unit) then
                    local guildName, _, _, guildRealm = GetGuildInfo(unit)
                    print(("|cff99ff00Wild Things:|r   %s %s guild=%s realm=%s wt=%s%s")
                        :format(unit, tostring(UnitName(unit)), tostring(guildName),
                            tostring(guildRealm),
                            tostring(Private:IsWildThingsGuildInfo(guildName, guildRealm)),
                            UnitIsGroupLeader(unit) and "  <-- LEADER" or ""))
                end
            end
        else
            print("|cff99ff00Wild Things:|r wtraid -- gate unavailable (RaidSnapshot.lua did not load)")
        end
    elseif cmd == "iconaudit" then
        -- Dev-only, unadvertised (deliberately not listed under 'help'): opens a scratch
        -- window rendering every in-scope Lucide stem with its name beneath it (SNAP-03).
        -- The icon constructor fails silently on a missing stem -- nothing draws and
        -- nothing is logged -- so this is the only at-a-glance check that an asset the
        -- upcoming Summoning Window and Raid Inspect page reference actually resolves in
        -- the client. Deliberately NOT wrapped in the dev-build sentinel guard the four
        -- harnesses above use (the roster-screen constructor), which is nil in packaged
        -- CurseForge/Wago builds: the icons this window audits ship in every build, so the
        -- auditor has to run there too or it cannot audit the build that matters. Do not
        -- "fix" it back. It is safe ungated -- it renders only addon-owned texture assets
        -- and crosses no trust boundary (unlike sendertest, which probes
        -- capability/trust).
        ShowIconAudit()
    elseif cmd == "summonsolo" then
        -- Dev-only, unadvertised (deliberately not listed under 'help'): toggles
        -- the standalone Summon window's showSolo flag so SUMDESIGN-12/13/14 and
        -- SUMWIN-08 are single-client exercisable. SecureGroupHeaderTemplate
        -- creates no children when showSolo is false and the player is solo,
        -- so without this the four in-game checks above need a second player.
        -- SummonScreen.lua ships in every build, so the toggle exists in every
        -- build, but the in-game effect only matters for solo testers; default
        -- off means no shipped behaviour change. Private:ToggleSummonSoloRows
        -- prints the new state and forces a rebuild.
        if Private.ToggleSummonSoloRows then
            Private:ToggleSummonSoloRows()
        end
    elseif cmd == "restest" then
        -- Dev-only, nicht beworben (daher absichtlich nicht unter 'help' gelistet):
        -- prüft den lokalen AutoResAccept-Entscheidungspunkt mit dem eigenen Zustand.
        -- Das Feature wird in jedem Build ausgeliefert, daher bleibt der Zweig
        -- ungated; er gibt nur Text aus, sendet nichts und überschreitet keine
        -- Vertrauensgrenze.
        if Private.AutoResAcceptTest then
            Private:AutoResAcceptTest()
        end
    elseif cmd == "questtest" then
        -- Dev-only, nicht beworben (daher absichtlich nicht unter 'help' gelistet):
        -- prüft den lokalen AutoQuestAccept-Entscheidungspunkt mit dem eigenen
        -- Zustand. Das Feature wird in jedem Build ausgeliefert, daher bleibt der
        -- Zweig ungated; er gibt nur Text aus, sendet nichts und überschreitet
        -- keine Vertrauensgrenze.
        if Private.AutoQuestAcceptTest then
            Private:AutoQuestAcceptTest()
        end
    elseif cmd == "invtest" or (cmd and cmd:match("^invtest%s+%S")) then
        -- Dev-only, unadvertised (deliberately not listed under 'help'): prints the
        -- local CanIssueInvites/IsInviteEligible/ShouldAutoAcceptInvite decisions
        -- plus every input that fed them. Invite.lua ships in every build, so this
        -- branch stays ungated; it only reads local state and prints -- crossing no
        -- trust boundary and sending nothing. Optional argument targets a real
        -- friend/guildmate (e.g. `/wt invtest target`) so the friend/guild branches
        -- are reachable without a second player.
        if Private.InviteToolsTest then
            local target = cmd:match("^invtest%s+(%S+)")
            Private:InviteToolsTest(target)
        end
    elseif cmd == "queuetest" then
        -- Dev-only, nicht beworben (daher absichtlich nicht unter 'help' gelistet):
        -- druckt den Popup-Zustand, welche Rolle das LFG-Tool gerade hält und ob
        -- Blizzard den Accept-Button freigegeben hat -- der /wt restest-
        -- Präzedenzfall. Reine Ausgabe, kein Schreibpfad: das Modul selbst
        -- schreibt ebenfalls nichts mehr. Das Feature wird in jedem Build
        -- ausgeliefert, daher bleibt der Zweig ungated; er sendet nichts und
        -- überschreitet keine Vertrauensgrenze.
        if Private.AutoQueueTest then
            Private:AutoQueueTest()
        end
    elseif cmd == "summonstest" or (cmd and cmd:match("^summonstest%s+%S")) then
        -- Dev-only, unadvertised (deliberately not listed under 'help'): injects fixture
        -- SUMMON_POS reports directly into the session state, bypassing the comm pipeline
        -- entirely -- single-client comm loopback is impossible by construction
        -- (Transmission.lua:85 skips self-messages). SummonComm.lua ships in every build
        -- (no do-not-package dependency), so this stays deliberately ungated -- quoting
        -- the 'feasttest'/'snapshot'/'iconaudit' branches' own reasoning, wrapping it in
        -- Private.CreateRosterScreen would make it a silent no-op in exactly the packaged
        -- build whose behaviour it exists to prove. It is safe ungated: it crosses no
        -- trust boundary and sends nothing.
        if Private.SummonTest then
            local mode = cmd:match("^summonstest%s+(%S+)")
            Private:SummonTest(mode)
        end
    elseif cmd == "inspecttest" or (cmd and cmd:match("^inspecttest%s+%S")) then
        -- Dev-only, unadvertised (deliberately not listed under 'help'): reads
        -- live consumable/crest data via BuildInspectReport and prints it on a
        -- single client with no broadcast -- single-client comm loopback is
        -- impossible by construction (Transmission.lua:85 self-skip). The
        -- 'store' mode also writes into the round model so the persist/restore
        -- cycle is observable; 'ids' prints the per-itemID bag count for every
        -- tracked consumable (catches an itemID table that has drifted past an
        -- expansion boundary) and 'round' prints the round identity plus
        -- RoundMatches' accept/reject verdicts -- the two checks that make the
        -- cross-client half of this feature verifiable on one client.
        -- InspectComm.lua ships in every build (no
        -- do-not-package dependency), so this stays deliberately ungated --
        -- quoting the 'summonstest'/'feasttest' branches' own reasoning, wrapping
        -- it in Private.CreateRosterScreen would make it a silent no-op in exactly
        -- the packaged build whose behaviour it exists to prove. It is safe
        -- ungated: it crosses no trust boundary and sends nothing.
        if Private.InspectTest then
            local mode = cmd:match("^inspecttest%s+(%S+)")
            Private:InspectTest(mode)
        end
    elseif cmd == "wellfed" then
        -- Dev-only, unadvertised: scans the player's active Well Fed auras and
        -- prints their spell IDs so the WELL_FED_TABLE in FeastReminder.lua can
        -- be populated. Run after eating from each specific feast in-game.
        if Private.WellFedAudit then
            Private:WellFedAudit()
        end
    elseif cmd == "cooldowntest" or (cmd and cmd:match("^cooldowntest%s+%S")) then
        -- Dev-only, unadvertised (deliberately not listed under 'help'): single-
        -- client solo-test harness for the cooldown window popup. Bypasses the
        -- wire (single-client comm loopback is impossible by construction per
        -- Transmission.lua:85 self-skip) by injecting fixture entries directly
        -- into Private.readycheckCooldownBySender, OR by calling the existing
        -- broadcast entry point so a SECOND WoW client on the same machine can
        -- receive via WT_MSG (use 'send' mode for the two-client path, 'inject'
        -- for the one-client path). Modes: send | inject | popup | clear.
        -- ReadyCheckCooldownComm.lua ships in every build, so this stays
        -- deliberately ungated -- quoting the 'summonstest'/'inspecttest'/'feasttest'
        -- branches' own reasoning, wrapping it in Private.CreateRosterScreen would
        -- make it a silent no-op in exactly the packaged build whose behaviour it
        -- exists to prove. Safe ungated: inject writes only to a Private table,
        -- send reuses the capability-gated broadcast entry point, popup/clear
        -- are pure local UI.
        if Private.CooldownTest then
            local mode = cmd:match("^cooldowntest%s+(%S+)")
            Private:CooldownTest(mode)
        end
    elseif cmd == "feasttest" or (cmd and cmd:match("^feasttest%s+%S")) then
        -- Dev-only, unadvertised (deliberately not listed under 'help'): drives the
        -- shipped reminder display path directly (Private:FeastTest -> ShowReminder),
        -- the /wt timelinetest precedent -- NEVER through Private:Broadcast, which would
        -- put traffic on the live raid comm channel and spam every raider.
        -- FeastReminder.lua ships in every build (no do-not-package dependency, unlike
        -- sendertest/timelinetest's dev-build sentinel), so this stays
        -- deliberately ungated -- quoting the 'snapshot'/'iconaudit' branches' own
        -- reasoning, wrapping it in Private.CreateRosterScreen would make it a silent
        -- no-op in exactly the packaged build whose behaviour it exists to prove. It is
        -- safe ungated: it crosses no trust boundary and sends nothing.
        if Private.FeastTest then
            local mode, arg = cmd:match("^feasttest%s+(%S+)%s*(%S*)")
            Private:FeastTest(mode, arg)
        end
    elseif cmd == "breaktest" or (cmd and cmd:match("^breaktest%s+%S")) then
        -- Dev-only, unadvertised (deliberately not listed under 'help'): drives the
        -- shipped break-reminder display path directly (Private:BreakTest ->
        -- ShowBreakReminder), the /wt feasttest precedent. No comm, no broadcast --
        -- this feature has no network path at all.
        -- BreakReminder.lua ships in every build (no do-not-package dependency), so
        -- this stays deliberately ungated. It is safe ungated: it crosses no trust
        -- boundary and sends nothing.
        if Private.BreakTest then
            local durationArg = cmd:match("^breaktest%s+(%S+)")
            Private:BreakTest(durationArg)
        end
    elseif cmd == "breakbartest" or (cmd and cmd:match("^breakbartest%s+%S")) then
        -- Dev-only, unadvertised: the counterpart to breaktest above. Where breaktest
        -- drives the DISPLAY directly, this drives the DETECTION path -- it publishes a
        -- real BigWigs_StartBreak on BigWigs' message bus and lets Core.lua's handler pick
        -- it up, so the Core.lua dispatch seam stops being untestable outside a live raid.
        -- Still no network traffic: BigWigs' SendMessage is an in-process callback
        -- dispatch, not an addon-channel send, and this feature has no comm path at all.
        if Private.BreakBarTest then
            local minutesArg = cmd:match("^breakbartest%s+(%S+)")
            Private:BreakBarTest(minutesArg)
        end
    elseif cmd == "memetest" or (cmd and cmd:match("^memetest%s+%S")) then
        -- Dev-only, unadvertised (deliberately not listed under 'help'): drives the
        -- shipped meme display path directly (Private:ShowMeme), the /wt feasttest
        -- precedent -- NEVER through Private:Broadcast, which would put traffic on
        -- the live raid comm channel. This is also the only way to exercise the
        -- display and session-pool paths solo: the comm path genuinely cannot be
        -- exercised on one client, because DispatchCommEvent drops self-messages
        -- centrally (Transmission.lua:79). Modes: (none) | <number> | gif | pool.
        -- Memes.lua ships in every build (no do-not-package dependency), so this
        -- stays deliberately ungated -- quoting the 'summonstest'/'inspecttest'/
        -- 'feasttest' branches' own reasoning, wrapping it in Private.CreateRosterScreen
        -- would make it a silent no-op in exactly the packaged build whose behaviour it
        -- exists to prove. It is safe ungated: it crosses no trust boundary and sends
        -- nothing.
        if Private.MemeTest then
            local mode = cmd:match("^memetest%s+(%S+)")
            Private:MemeTest(mode)
        end
    elseif cmd == "inspect" then
        -- Opens the Raid Inspect screen (INSPECT-04) and navigates the Shell to the
        -- Inspect tab. InspectComm.lua and InspectScreen.lua both ship in every build
        -- (no do-not-package dependency), so this is deliberately ungated. No session
        -- to open (unlike summons) -- the inspect page reads the existing round model,
        -- and the Refresh button on the page handles requesting fresh data.
        Private:OpenRosterShell()
        if Private.rosterShell then
            Private.rosterShell:ShowScreen("inspect")
            Private.rosterShell:Show()
        end
    elseif cmd == "summons" then
        -- Opens a summon session manually (SUMMON-02) and navigates the Shell to the
        -- Summoning tab (SUMMON-06). SummonComm.lua ships in every build (no
        -- do-not-package dependency), so this is deliberately ungated. Order matters:
        -- OpenSession populates classified data BEFORE the shared window sequence
        -- (Private:OpenSummonWindow, D-01) creates/navigates/shows/refreshes the
        -- window. This command is never gated by either auto-open settings toggle
        -- (D-14) -- manual `/wt summons` always works.
        Private:OpenSummonSession()
        Private:OpenSummonWindow()
    elseif cmd == nil or cmd == "" or cmd:match("^%s*$") then
        Private:OpenRosterShell()
    end
end
