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

-- READY-05 cooldown window: rank-0-2 viewer-gated popup with cross-rank
-- broadcast. ANY guildmate in the raid (rank 0-2 AND rank 3+) broadcasts
-- their own per-spec enabled cooldown list as a new WT_MSG sub-event
-- ("READYCHECK_CD"); rank 0-2 viewers coalesce by sender into
-- Private.readycheckCooldownBySender and (when their own auto_popup toggle
-- is ON) auto-open a popup listing (sender, spellName, mm:ss) rows that
-- live-countdown via a receiver-stamped receivedAt timestamp. Rank 3+
-- viewers still receive the broadcast on the wire but their handler
-- returns early, so they neither store data nor open the popup -- the
-- UI capability gate (ViewerHasCapability inside the popup builder and
-- around the SettingsScreen toggle) hides the chrome too. The toggle
-- steuert nur ob [Auto-Popup], per CONTEXT.md.
--
-- The wire half here is intentionally thin: it only knows how to broadcast
-- and how to receive; the visible chrome (popup window, settings toggle)
-- lives in UI/Widgets/PopupWindows/ReadyCheckCooldownPopup.lua and the
-- Settings screen. Two independent gates keep the broadcast leg and the
-- popup-open leg separate:
--   Gate 1 (Private:ShouldBroadcastReadyCheckCooldown): in-raid + READY-05
--                                                    (no rank check)
--   Gate 2 (Private:ShouldShowReadyCheckCooldownPopup): in-raid + READY-05
--                                                    + viewer rank 0-2
--                                                    + auto_popup toggle
-- A rank-0-2 sender with the toggle OFF still broadcasts whenever the
-- READY-05 gate passes -- the toggle only suppresses the local popup.
-- A rank-3+ sender with the toggle ON-or-OFF broadcasts whenever the
-- READY-05 gate passes; their receiver-side handler returns early on
-- ViewerHasCapability so they neither store data nor open the popup.
--
-- Private.* deps: Private:Broadcast (Transmission.lua), Private:RegisterCommEvent
-- (Init.lua), Private:ViewerHasCapability (Access.lua), Private:CanonicalizeLiveName
-- (RosterKeys.lua), Private:SnapshotRaidStatus (RaidSnapshot.lua),
-- Private:ShouldRunReadyCheckCooldown (ReadyCheck.lua), Private:CollectOwnCooldownStatuses
-- (this file, defined below).

-- ============================================================
-- Sender-side cooldown read (Task 1 step (3)(a))
--
-- Mirrors ReadyCheck.lua's local GetSpellRemaining (charges-aware) and
-- GetCooldownStatuses (per-spec enabled-set iteration), but DROPS the
-- 30s threshold filter and the {onCD, name, remaining} wrapper -- the
-- receiver's window is for visual countdown of every enabled spell,
-- including short timers, so the sender must report the full list.
-- ============================================================

-- Charges-aware remaining read. Identical to ReadyCheck.lua's
-- GetSpellRemaining; inlined here so this file's load order does not
-- require ReadyCheck.lua to expose a public helper. requiredCharges
-- defaults to 1 (today's "any charge is ready" behaviour); a higher
-- value lets the sender's own broadcast reflect a multi-charge
-- "required-ready" threshold, matching the chat/decline path in
-- ReadyCheck.lua so sender and receiver never disagree.
local function GetSpellRemaining(spellID, requiredCharges)
    requiredCharges = tonumber(requiredCharges) or 1
    local chargeInfo = C_Spell.GetSpellCharges(spellID)
    if chargeInfo then
        if chargeInfo.currentCharges >= requiredCharges then return 0 end
        if chargeInfo.isActive and chargeInfo.cooldownDuration > 0 then
            -- [ASSUMED -- not in-game verified] same multi-cycle formula
            -- as ReadyCheck.lua's GetSpellRemaining: time to next single
            -- charge PLUS (requiredCharges - currentCharges - 1) full
            -- recharge cycles for the charges still missing. Compare
            -- against the in-game charge-recharge bars during UAT.
            local timeToNext = math.max(0, (chargeInfo.cooldownStartTime + chargeInfo.cooldownDuration) - GetTime())
            local extraCycles = (requiredCharges - chargeInfo.currentCharges - 1) * chargeInfo.cooldownDuration
            return timeToNext + extraCycles
        end
        return 0
    end

    local cd = C_Spell.GetSpellCooldown(spellID)
    if cd and cd.isEnabled and cd.isActive and cd.duration > 0 then
        return math.max(0, (cd.startTime + cd.duration) - GetTime())
    end
    return 0
end

-- Trinket slot resolver + collector: verbatim duplicate of ReadyCheck.lua's
-- ResolveTrinketSlot/GetTrinketCooldownEntries, inlined here for the same
-- load-order-independence reason GetSpellRemaining above is duplicated
-- (this file must not depend on ReadyCheck.lua having loaded). Reads
-- readycheck.cooldown.per_spec_items[specID] -- the trinket sibling of
-- per_spec, kept as a SEPARATE store so its itemID keys never reach
-- C_Spell.GetSpellCharges/GetSpellCooldown above. Named globals
-- (INVSLOT_TRINKET1/2), never bare 13/14 literals. A stored itemID not
-- currently in slot 13/14, or with no real on-use spell (C_Item.GetItemSpell
-- second return not numeric), contributes nothing. Below the 1.5s shared-
-- cooldown floor the trinket is ready and still reported with remaining = 0.
local function ResolveTrinketSlot(itemID)
    if GetInventoryItemID("player", INVSLOT_TRINKET1) == itemID then
        return INVSLOT_TRINKET1
    end
    if GetInventoryItemID("player", INVSLOT_TRINKET2) == itemID then
        return INVSLOT_TRINKET2
    end
    return nil
end

local function GetTrinketCooldownEntries(specID)
    local entries = {}

    local rc = WildThingsSavedVars.readycheck
    local perItems = rc and rc.cooldown and rc.cooldown.per_spec_items and rc.cooldown.per_spec_items[specID]
    if not perItems then return entries end

    for itemID, isEnabled in pairs(perItems) do
        if isEnabled then
            local slot = ResolveTrinketSlot(itemID)
            if slot then
                local spellName, spellID = C_Item.GetItemSpell(itemID)
                if type(spellID) == "number" then
                    local start, duration, enable = GetInventoryItemCooldown("player", slot)
                    local remaining = 0
                    if enable == 1 and duration and duration > 1.5 then
                        remaining = math.max(0, (start + duration) - GetTime())
                    end
                    local name = C_Item.GetItemNameByID(itemID) or spellName or ("item:" .. tostring(itemID))
                    table.insert(entries, { itemID = itemID, spellID = spellID, name = name, remaining = remaining })
                end
            end
        end
    end

    return entries
end

-- Returns an array of {spellID, name, remaining, specID} for every
-- enabled per-spec spell, with NO threshold filter. The popup's
-- receiver-side display sorts by live remaining desc; a 0-remaining
-- enabled spell still belongs in the list (the popup fades it after
-- a brief grace window).
--
-- specID here is the sender's CURRENT specialization ID -- stamped
-- on every per-spell entry so the receiver can render a spec icon
-- next to each row (per user feedback 2026-07-26 "Spec Icon fehlt"
-- -- the spec atlas distinguishes e.g. Frost vs Fire for a Mage, which
-- the class icon alone cannot). A single specID is shared across
-- all entries in the payload (every entry comes from the same spec)
-- -- but the per-entry shape lets a future enhancement broadcast
-- cross-spec entries (e.g. a Mage talent that switches to Arcane)
-- without a payload-shape change.
function Private:CollectOwnCooldownStatuses()
    local statuses = {}

    local specIndex = GetSpecialization()
    local currentSpecID = nil
    if specIndex then
        -- BUG-FIX 2026-07-26 + 2026-07-27:
        --   The modern C_SpecializationInfo.GetSpecializationInfo
        --   nominally returns a TABLE {id, name, description, ...},
        --   but on some retail WoW builds it returns a plain NUMBER
        --   (the specID) directly -- `local info = C_SpecializationInfo.GetSpecializationInfo(specIndex)`
        --   then leaves info as a number, and `info.id` throws
        --   "attempt to index a number value". The legacy global
        --   GetSpecializationInfo specIndex returns multi-values
        --   (id, name, description, icon, role, classFile), never a
        --   table. Handle all three forms with one defensive lookup:
        --   try C_SpecializationInfo first, branch on the return
        --   TYPE; fall back to select(1, ...) on the global.
        if C_SpecializationInfo and C_SpecializationInfo.GetSpecializationInfo then
            local info = C_SpecializationInfo.GetSpecializationInfo(specIndex)
            if type(info) == "table" and info.id then
                currentSpecID = info.id
            elseif type(info) == "number" then
                currentSpecID = info
            end
        end
        if not currentSpecID then
            currentSpecID = select(1, GetSpecializationInfo(specIndex))
        end
    end
    if not currentSpecID then return statuses end

    local rc = WildThingsSavedVars.readycheck
    local perSpec = rc and rc.cooldown and rc.cooldown.per_spec and rc.cooldown.per_spec[currentSpecID]
    if perSpec then
        for spellID, isEnabled in pairs(perSpec) do
            if isEnabled then
                -- Per-spell "how many charges count as ready" threshold
                -- (PTW-04): ABSENT defaults to 1 (today's "any charge is
                -- ready" behaviour). Mirrors the same defensive three-form
                -- lookup used for `perSpec` directly above. requiredCharges
                -- is a purely local computation input -- NOT added to the
                -- wire payload below; the receiver renders whatever
                -- `remaining` it receives.
                local requiredCharges = rc.cooldown.per_spec_required_charges
                    and rc.cooldown.per_spec_required_charges[currentSpecID]
                    and rc.cooldown.per_spec_required_charges[currentSpecID][spellID]
                    or 1
                local remaining = GetSpellRemaining(spellID, requiredCharges)
                local name = C_Spell.GetSpellName(spellID) or ("Spell " .. tostring(spellID))
                table.insert(statuses, {
                    spellID = spellID,
                    name = name,
                    remaining = remaining,
                    specID = currentSpecID,
                })
            end
        end
    end

    -- Trinket pass: appends one entry per currently-equipped, enabled
    -- on-use trinket. The entry deliberately carries the trinket's real
    -- use-spell ID (spellID), NOT its itemID -- the receiver's per-entry
    -- validation hard-gates on `type(e.spellID) == "number"` (below), so a
    -- bare itemID field would either fail that gate or be stripped by the
    -- receiver's field whitelist. Sending spellID/name/remaining/specID --
    -- exactly the four keys the spell path above emits, same shape -- makes
    -- a trinket row render, tooltip and icon correctly through the
    -- *existing* popup code on a client running the currently shipped
    -- build, with zero receiver changes. No third result state and no
    -- unequipped-marker field is sent (see <orientation> "Out of scope").
    for _, entry in ipairs(GetTrinketCooldownEntries(currentSpecID)) do
        table.insert(statuses, {
            spellID = entry.spellID,
            name = entry.name,
            remaining = entry.remaining,
            specID = currentSpecID,
        })
    end

    return statuses
end

-- ============================================================
-- Two independent gates (Task 1 steps (3)(b) and (3)(c))
-- ============================================================

-- GATE 1: broadcast eligibility -- INDEPENDENT of auto_popup AND of the
-- sender's own guild rank. Any guildmate in the raid who passes the
-- READY-05 gate broadcasts, full stop -- rank-3+ senders are explicitly
-- allowed (revised 2026-07-26 after user clarification: only the
-- VIEWER's rank gates the popup, not the sender's). The local
-- auto_popup toggle controls ONLY whether the local popup auto-opens
-- (GATE 2), not whether the local sender broadcasts. Sender-side guild
-- trust (same guild + same realm) is enforced on the RECEIVER side, not
-- here -- this gate has no GetGuildInfo lookup because anyone who is
-- "in the raid" and on WT-Blackhand passes the implicit guild check via
-- the broadcast's own UnitInRaid delivery path.
function Private:ShouldBroadcastReadyCheckCooldown()
    if not IsInRaid() then return false end
    if not Private:ShouldRunReadyCheckCooldown() then return false end
    return true
end

-- GATE 2: popup-open eligibility -- ADDS the viewer's rank (must be 0-2
-- in WT-Blackhand) AND the local auto_popup toggle on top of GATE 1.
-- A rank-3+ viewer who has the toggle ON still receives broadcasts on
-- the wire (the broadcast leg is in GATE 1) but the receiver handler
-- returns early on ViewerHasCapability so they neither store data nor
-- open the popup. A rank-0-2 viewer with the toggle OFF broadcasts but
-- does NOT auto-open the popup on READY_CHECK. The toggle is exclusively
-- a local display preference; the viewer-rank gate is the locked
-- CONTEXT.md "Nur Rank 0-2 sieht Toggle & Fenster" decision.
function Private:ShouldShowReadyCheckCooldownPopup()
    if not Private:ShouldBroadcastReadyCheckCooldown() then return false end
    if not Private:ViewerHasCapability("readycheck.cooldownwindow") then return false end
    local rc = WildThingsSavedVars.readycheck
    if not (rc and rc.cooldownwindow) then return false end
    if rc.cooldownwindow.auto_popup ~= true then return false end
    return true
end

-- ============================================================
-- Sender broadcast (Task 1 step (3)(d))
-- ============================================================

function Private:BroadcastOwnReadyCheckCooldowns()
    if not Private:ShouldBroadcastReadyCheckCooldown() then return end

    local payload = Private:CollectOwnCooldownStatuses()
    if #payload == 0 then return end

    Private:Broadcast("READYCHECK_CD", "RAID", payload)
end

-- ============================================================
-- Local-read path for the popup (Task 1 step (3)(f), per
-- RESOLVED Open Question 2 in RESEARCH.md)
--
-- The local player never appears in the wire payload because
-- Private:Broadcast over Transmission.lua:79 self-skips. The popup
-- must still show the local player's cooldowns, so this function
-- writes them into the same coalescing map under the explicit
-- "player" key. Re-fetched on every popup open so a freshly-used
-- cooldown shows up at 0 immediately, not stale at the previous
-- broadcast's value.
-- ============================================================

function Private:CollectAndStoreLocalPlayerCooldowns()
    local payload = Private:CollectOwnCooldownStatuses()
    -- BUG-FIX 2026-07-26 (countdown not ticking): stamp `receivedAt`
    -- on every per-spell entry so the popup's LiveRemaining formula
    -- has a non-nil timestamp to subtract from. The sender-level
    -- `receivedAt` set below is for the 30s GC, not the countdown.
    local now = GetTime()
    for _, e in ipairs(payload) do
        if type(e) == "table" then
            e.receivedAt = now
        end
    end
    Private.readycheckCooldownBySender = Private.readycheckCooldownBySender or {}
    Private.readycheckCooldownBySender["player"] = {
        senderName = UnitName("player"),
        senderClass = select(2, UnitClass("player")),
        entries = payload,
        lastSeen = now,
        receivedAt = now, -- freshly read; per-spell receivedAt == now so the live formula is a no-op on first tick, then decrements
    }
end

-- ============================================================
-- Coalescing map (Task 1 step (3)(g))
--
-- Receivers write under Private:CanonicalizeLiveName(sender) --
-- the cross-realm / same-realm canonical key from RosterSnapshot.lua
-- so two clients on the same connected realm share a key. Each
-- receiver-written entry is stamped with receivedAt = GetTime()
-- at parse time so the popup's live countdown subtracts wall-clock
-- elapsed (B3 fix). lastSeen drives the 30s GC: a sender whose
-- lastSeen is older than 30s is dropped, both at receive time and
-- at render time, so a sender who left the raid freezes no row.
-- ============================================================

Private.readycheckCooldownBySender = {}

-- Live-remaining helper (Task 1 step (3)(i)). Used by every display,
-- sort, expiry, and /pull XX arithmetic site in the popup. Local
-- entries (under "player") have receivedAt == GetTime() at the
-- moment of write and use remaining directly -- but the popup calls
-- this unconditionally on every row (cheap, and a no-op when
-- receivedAt == now).
function Private:ComputeLiveRemaining(entry)
    if type(entry) ~= "table" then return 0 end
    local receivedAt = entry.receivedAt or GetTime()
    return math.max(0, (entry.remaining or 0) - (GetTime() - receivedAt))
end

-- 30s GC for the coalescing map. Called at receive time and at render
-- time (the popup's refresh fn walks the map).
--
-- BUG-FIX 2026-07-30: age alone is NOT sufficient to drop a sender.
-- There is no periodic re-broadcast -- a client transmits once per
-- READY_CHECK (Core.lua -> RunCooldownCheck -> BroadcastAndShowReadyCheck
-- CooldownWindow) -- so EVERY lastSeen ages past this window 30s later,
-- whether or not the cooldowns it carries are still running. Dropping on
-- age alone therefore emptied the window mid-pull-prep: a 3-minute
-- cooldown vanished 30s in, with 2:30 still on it, which is precisely the
-- number the window exists to show. This stayed invisible until the 1 Hz
-- tick was repaired -- before that the GC only ever ran at open time.
--
-- So a sender is reaped only when it is stale AND none of its entries is
-- still counting. A running cooldown stays valid no matter how old the
-- broadcast is, because the remaining time is derived by subtraction from
-- the per-entry receivedAt stamp, not read from a fresh wire value.
--
-- The GC's original purpose is preserved: a sender who left the raid stops
-- broadcasting, their cooldowns count down to 0, the popup's own
-- `live > 0` filter takes the row off screen, and the next pass here reaps
-- the entry. Nothing frozen stays visible.
local SENDER_GC_SECONDS = 30
function Private:GcStaleReadyCheckCooldownEntries()
    local map = Private.readycheckCooldownBySender
    if type(map) ~= "table" then return end
    local now = GetTime()
    for key, entry in pairs(map) do
        if type(entry) == "table" and entry.lastSeen and (now - entry.lastSeen) > SENDER_GC_SECONDS then
            local stillCounting = false
            if type(entry.entries) == "table" then
                for _, e in ipairs(entry.entries) do
                    if Private:ComputeLiveRemaining(e) > 0 then
                        stillCounting = true
                        break
                    end
                end
            end
            if not stillCounting then
                map[key] = nil
            end
        end
    end
end

-- ============================================================
-- Receiver handler (Task 1 step (3)(h))
--
-- Registers a "READYCHECK_CD" handler via Private:RegisterCommEvent.
-- Sender comes from the AceComm arg (Transmission.lua:80, "sender
-- comes from the wire" rule), NEVER from the payload -- a self-
-- asserted senderName field is a self-spoof vector. The receiver's
-- two trust gates (revised 2026-07-26 after user clarification that
-- Rank 3+ senders should still be visible to Rank 0-2 viewers):
--   (a) Viewer-side rank gate (ViewerHasCapability on LOCAL viewer)
--       -- a Rank 3+ viewer's own client must not store incoming
--       broadcast data, regardless of the sender's rank. This is the
--       "nur nicht empfangen" half of the user's clarification.
--   (b) Sender-side guild gate (inline GetGuildInfo comparison) -- the
--       sender must be in OUR guild on OUR realm. This preserves the
--       non-WT-member / cross-realm-spoof protection that the original
--       SenderHasCapability provided, but WITHOUT the rank filter on
--       the sender. A Rank 3+ WT-Blackhand sender is accepted; a
--       non-WT-Blackhand sender (or a same-named-elsewhere sender) is
--       rejected on the same guild/realm grounds.
-- Self-messages are already dropped centrally by Transmission.lua:79.
--
-- Clock-drift assumption (documented inline, RESEARCH Pitfall 4):
-- broadcast flight time ~0-1s is well within a "live countdown"
-- tolerance -- receivedAt is GetTime() at the moment the receiver
-- finishes parsing the entry, NOT GetTime() at sender-broadcast time.
-- ============================================================

Private:RegisterCommEvent("READYCHECK_CD", function(sender, payload)
    -- T-01: payload must be an array
    if type(payload) ~= "table" then return end

    -- T-r3x-01 (revised): VIEWER-side rank gate. Rank 3+ viewers must
    -- not store incoming data (per user clarification 2026-07-26).
    -- The capability key is "readycheck.cooldownwindow" with maxRank=2,
    -- so this is identical to the existing UI gating in the popup
    -- builder and the SettingsScreen wrap.
    if not Private:ViewerHasCapability("readycheck.cooldownwindow") then return end

    -- T-r3x-01 (cont.): SENDER-side guild gate. Inline because the
    -- rank check in SenderHasCapability is no longer the right filter
    -- (we want any-rank-WT to be accepted). Two-line lookup against
    -- GetGuildInfo("player") is enough; no cross-realm or same-name
    -- ambiguity survives this comparison.
    local myGuild, _, _, myRealm = GetGuildInfo("player")
    if not myGuild then return end
    local senderGuild, _, _, senderRealm = GetGuildInfo(sender)
    if senderGuild ~= myGuild or senderRealm ~= myRealm then return end

    -- T-r3x-02: per-entry numeric-shape validation. Mirrors the
    -- SummonComm.lua:345-347 hardening precedent. Negative remaings
    -- are clamped to 0 (a sender-stamped -5 is treated as 0, not as a
    -- "skip this entry" signal -- the popup's live formula re-clamps
    -- anyway, this is just the wire-time guard).
    --
    -- BUG-FIX 2026-07-26 (countdown not ticking): each per-spell entry
    -- MUST be stamped with its own `receivedAt` here. The popup's
    -- LiveRemaining reads the per-entry `receivedAt` -- if it's nil it
    -- falls back to `GetTime()`, making `(now - receivedAt) == 0` and
    -- the countdown never decrements. Sender-level `receivedAt` (set a
    -- few lines below) is not consulted by LiveRemaining -- the per-
    -- spell stamp is the only thing that drives the tick.
    local entries = {}
    local now = GetTime()
    for _, e in ipairs(payload) do
        if type(e) == "table"
            and type(e.spellID) == "number"
            and type(e.name) == "string"
            and type(e.remaining) == "number" then
            table.insert(entries, {
                spellID = e.spellID,
                name = e.name,
                remaining = math.max(0, e.remaining),
                specID = (type(e.specID) == "number") and e.specID or nil,
                receivedAt = now,
            })
        end
    end

    -- T-r3x-02 (cont.): class lookup uses CanonicalizeLiveName so a
    -- malformed sender name cannot crash SnapshotRaidStatus.
    local key = Private:CanonicalizeLiveName(sender)
    local class
    local raidStatus = Private:SnapshotRaidStatus()
    if raidStatus and raidStatus[key] then class = raidStatus[key].class end

    -- Coalesce. receivedAt at the SENDER level drives the 30s GC
    -- (GatherStaleReadyCheckCooldownEntries reads entry.lastSeen which is
    -- set just below -- the per-spell receivedAt stamps above drive the
    -- countdown ticks; the two clocks happen to coincide at parse time
    -- but they are NOT the same thing semantically).
    Private.readycheckCooldownBySender = Private.readycheckCooldownBySender or {}
    Private.readycheckCooldownBySender[key] = {
        senderName = sender,
        senderClass = class,
        entries = entries,
        lastSeen = now,
        receivedAt = now,
    }

    -- GC stale entries on receive: a sender who left the raid no longer
    -- broadcasts, so their lastSeen drifts past 30s and gets dropped at
    -- the next receive -- keeps a frozen row from sticking on screen.
    Private:GcStaleReadyCheckCooldownEntries()
end)

-- ============================================================
-- Orchestrator + popup open (Task 1 steps (3)(e) and (3)(k))
--
-- The orchestrator is defined here (B2 fix, not in the popup file)
-- so Core.lua / ReadyCheck.lua have a single, capability-checks-aware
-- entry point. The popup chrome is lazy-built on first open via
-- namespace:CreateReadyCheckCooldownPopup, which is defined in
-- UI/Widgets/PopupWindows/ReadyCheckCooldownPopup.lua (Task 2) and
-- becomes available once that file has loaded (UI/UI.xml loads after
-- the toc Lua files, so by the first READY_CHECK the popup builder
-- is registered).
-- ============================================================

function Private:BroadcastAndShowReadyCheckCooldownWindow()
    -- Gate 1 (broadcast) runs first -- independent of the local
    -- auto_popup toggle AND independent of the local viewer's rank
    -- (any rank broadcasts). A rank-3+ sender with the toggle ON
    -- or OFF still broadcasts whenever the READY-05 gate passes.
    if Private:ShouldBroadcastReadyCheckCooldown() then
        Private:BroadcastOwnReadyCheckCooldowns()
    end

    -- Gate 2 (popup-open) ADDS the local viewer's rank 0-2 AND the
    -- local auto_popup toggle. Only open the popup when the viewer
    -- is rank 0-2 in WT-Blackhand, has the toggle on, and the
    -- READY-05 gate passes.
    if Private:ShouldShowReadyCheckCooldownPopup() then
        Private:OpenReadyCheckCooldownPopup()
    end
end

-- Defined here (B2 fix) so the orchestrator has a single, capability-
-- checked entry point. Body is intentionally minimal: capability-gate,
-- lazy popup build, state refresh, restart the 1 Hz driver, Show.
--
-- BUG-FIX 2026-07-26: the original code called `:Pop()` here, which is
-- the CreatePopupWindow API. CreateChromeWindow does NOT expose Pop --
-- it positions the window via RestorePosition / SetPoint("CENTER")
-- inside the primitive and returns it shown-by-default. Calling Pop
-- on the chrome window threw "attempt to call a nil value" on every
-- OpenReadyCheckCooldownPopup call (which is on every Ready Check +
-- the cooldowntest popup path). Replaced with :Show() -- the chrome
-- window is already positioned and only needs to be un-hidden.
function Private:OpenReadyCheckCooldownPopup()
    if not Private:ViewerHasCapability("readycheck.cooldownwindow") then return end

    -- Lazy build on first call. The popup file (UI/UI.xml) loads
    -- AFTER this file's TOC slot, so Private.CreateReadyCheckCooldownPopup
    -- may be nil at the very first READY_CHECK of a session -- the
    -- capability check above + this nil-guard together make that
    -- path a no-op without error. The next READY_CHECK re-evaluates.
    if not Private.readycheckCooldownPopup then
        if not Private.CreateReadyCheckCooldownPopup then return end
        Private.readycheckCooldownPopup = Private:CreateReadyCheckCooldownPopup()
        -- CreateChromeWindow returns the popup SHOWN (no Hide in the
        -- primitive); explicitly hide it here so the popup stays
        -- invisible until the first :Show() in this function fires.
        -- The orchestrator's :Show() at the end of this function is
        -- the only path that brings it on screen.
        if Private.readycheckCooldownPopup.Hide then
            Private.readycheckCooldownPopup:Hide()
        end
    end

    -- Refresh state on every open (W8 fix: must restart the 1 Hz
    -- driver unconditionally, not only at first build, so a popup
    -- that was hidden then re-shown after the previous StartGlow's
    -- owner reference has gone stale still ticks). Re-fetch the
    -- local player's cooldowns so a freshly-used cooldown shows up
    -- at 0 immediately rather than at a stale broadcast value.
    Private:CollectAndStoreLocalPlayerCooldowns()

    -- The popup exposes an optional Refresh() that re-renders rows
    -- from the current map. Call it if exposed so the very first
    -- paint after open shows the post-Open state (not the prior
    -- frame's body).
    if Private.readycheckCooldownPopup.Refresh then
        Private.readycheckCooldownPopup:Refresh()
    end

    -- Restart the 1 Hz countdown driver on EVERY open (W8 fix: must
    -- restart unconditionally, not only at first build, because the
    -- popup may have been hidden then re-shown and the previous
    -- StartGlow's owner reference can be stale -- calling StartGlow
    -- again with the same owner+id is idempotent per GlowDriver.lua:75
    -- and updates the updateFn in place).
    if Private.readycheckCooldownPopup.CountdownTick then
        WTUI:StartGlow(Private.readycheckCooldownPopup, "countdown", Private.readycheckCooldownPopup.CountdownTick)
    end

    Private.readycheckCooldownPopup:Show()
end

-- ============================================================
-- 260730-prp (PRP-01..PRP-08): windowless /pull entry points.
--
-- The /pull action's three moving parts (slash dispatch, persisted
-- offset, XX arithmetic) live in this section so the keybind can reach
-- them WITHOUT the popup ever being built. The popup still calls them
-- through `Private.*` so the window and the keybind cannot disagree
-- on the dispatch implementation or the XX number.
-- ============================================================

-- 260730-prp (PRP-08): token-walking slash dispatch. DOT (not colon)
-- declaration -- two real parameters, the file's `Private:*` methods
-- take an implicit self and this one must not. Body is moved verbatim
-- from ReadyCheckCooldownPopup.lua (formerly its local RunSlashCommand
-- there) so there is exactly ONE dispatch implementation in the repo.
-- The explanatory comment block above the body is bug-fix history:
-- it records why the token is matched instead of a handler key, and
-- why no chat API is reachable from here. Do not re-derive, paraphrase
-- or shorten.
function Private.RunSlashCommand(token, args)
    if type(SlashCmdList) ~= "table" then return false end
    local wanted = token:upper()
    -- FrameXML passes the edit box as the handler's 2nd argument, so we
    -- pass the default one for providers that index it. We never write
    -- to it, so nothing the user typed is disturbed.
    local editBox = DEFAULT_CHAT_FRAME and DEFAULT_CHAT_FRAME.editBox
    for key, handler in pairs(SlashCmdList) do
        local i = 1
        while true do
            local registered = _G["SLASH_" .. key .. i]
            if type(registered) ~= "string" then break end
            if registered:upper() == wanted then
                if type(handler) ~= "function" then return false end
                -- safecall (never a bare pcall in feature code): a
                -- third-party handler that throws surfaces via
                -- geterrorhandler instead of killing the click handler.
                -- We return true regardless -- an owner WAS dispatched;
                -- a broken provider is not the same as a missing one.
                Private.safecall(handler, args, editBox)
                return true
            end
            i = i + 1
        end
    end
    return false
end

-- 260730-prp (PRP-04): tolerant reader for the persisted offset. Read
-- INSIDE the body on purpose -- a file-scope capture of
-- WildThingsSavedVars would bind before Migrations ever ran (mutations
-- are applied at wt:OnInitialize, well after this file loads). Mirror
-- shape of the popup's existing CloseBeforePullSeconds
-- (ReadyCheckCooldownPopup.lua:113-129): nil-check every level, then
-- tonumber the leaf, then return the default at every failed step.
-- Clamp to 0..999 inclusive -- the same range the popup's EditBox
-- grammar (`^[0-9]{1,3}$`) accepts -- so a hand-edited SavedVars file
-- cannot produce a negative or absurd pull.
function Private:GetCooldownPullOffsetSeconds()
    local s = WildThingsSavedVars
    if type(s) ~= "table" then return 10 end
    local rc = s.readycheck
    if type(rc) ~= "table" then return 10 end
    local cw = rc.cooldownwindow
    if type(cw) ~= "table" then return 10 end
    local n = tonumber(cw.pull_offset_seconds)
    if type(n) ~= "number" then return 10 end
    if n < 0 then n = 0 end
    if n > 999 then n = 999 end
    return n
end

-- 260730-prp (PRP-04): writer for the persisted offset. Validate
-- tonumber(n), return early on nil, clamp 0..999, then write
-- WildThingsSavedVars.readycheck.cooldownwindow.pull_offset_seconds.
-- Create the two intermediate tables if absent (defensive) rather than
-- indexing through a possible nil.
function Private:SetCooldownPullOffsetSeconds(n)
    local num = tonumber(n)
    if type(num) ~= "number" then return end
    if num < 0 then num = 0 end
    if num > 999 then num = 999 end
    local s = WildThingsSavedVars
    if type(s) ~= "table" then return end
    if type(s.readycheck) ~= "table" then s.readycheck = {} end
    if type(s.readycheck.cooldownwindow) ~= "table" then s.readycheck.cooldownwindow = {} end
    s.readycheck.cooldownwindow.pull_offset_seconds = num
end

-- 260730-prp (PRP-03): the single arithmetic site for /pull XX.
-- ceil(max live remaining across the coalescing map) + offset, floored
-- at 0. The open window passes its live EditBox value via
-- `offsetOverride` (preserving today's offset-0-on-invalid-input
-- behaviour); the windowless keybind calls with no argument and gets
-- the persisted value. Returns math.ceil(maxRemaining + offset).
function Private:ComputeCooldownPullSeconds(offsetOverride)
    local maxRemaining = 0
    local map = Private.readycheckCooldownBySender
    if type(map) == "table" then
        for _, entry in pairs(map) do
            if type(entry) == "table" and type(entry.entries) == "table" then
                for _, e in ipairs(entry.entries) do
                    local live = Private:ComputeLiveRemaining(e)
                    if live > maxRemaining then maxRemaining = live end
                end
            end
        end
    end
    local offset
    if type(offsetOverride) == "number" then
        offset = offsetOverride
    else
        offset = Private:GetCooldownPullOffsetSeconds()
    end
    local xx = math.ceil(maxRemaining + offset)
    if xx < 0 then xx = 0 end
    return xx
end

-- 260730-prp (PRP-02): Blizzard-namespace binding-display globals.
-- These are DATA the client reads by name when the Key Bindings UI opens
-- (the client walks `BINDING_NAME_<name>` and `BINDING_HEADER_<header>`).
-- Assigned at FILE SCOPE, not inside any function -- the client reads
-- them at binding-UI paint time, not from any function call.
-- Not a breach of the two-deliberate-globals rule: the actual action
-- is routed through `WTAPI:PullFromKeybind` (WTAPI is one of the two
-- deliberate globals), so no new function global is created. These two
-- strings live in Blizzard's own namespace, alongside hundreds of other
-- BINDING_* tokens shipped by Blizzard addons.
_G["BINDING_HEADER_WILDTHINGS"] = "Wild Things"
_G["BINDING_NAME_WILDTHINGS_PULL"] = "Start /pull (max cooldown + offset)"

-- 260730-prp (PRP-01..PRP-07): windowless /pull entry point. Called by
-- Bindings.xml (the root <Binding name="WILDTHINGS_PULL"> element body).
-- COLON declaration -- Bindings.xml calls with a colon, so the
-- implicit self must match the receiver. A dot declaration paired
-- with a colon call shifts every parameter by the implicit self; that
-- exact defect shipped in this same feature's CountdownTick
-- (ReadyCheckCooldownPopup.lua:762-771) and is the reason this comment
-- exists. The whole body is wrapped in Private.safecall because a
-- binding path has no widget wrapper to contain a throw.
function WTAPI:PullFromKeybind()
    Private.safecall(function()
        -- PRP-06: capability gate FIRST, before any output. A non-eligible
        -- viewer gets no dispatch AND no chat line; silence matches the
        -- existing gate at Private:OpenReadyCheckCooldownPopup:516 and
        -- leaks nothing.
        if not Private:ViewerHasCapability("readycheck.cooldownwindow") then return end

        -- PRP-05: chat-messaging lockdown guard. With no window there is
        -- no status bar to write to, so chat is the only channel.
        -- Deliberately NOT InCombatLockdown() -- the action is not
        -- protected (no secure frame, no hardware-event requirement;
        -- BigWigs runs the identical action from a plain Button).
        if C_ChatInfo and C_ChatInfo.InChatMessagingLockdown and C_ChatInfo.InChatMessagingLockdown() then
            print("|cff99ff00Wild Things:|r Cannot start /pull in combat")
            return
        end

        -- PRP-06 (cont.) + freshness: the open path calls CollectAndStore
        -- LocalPlayerCooldowns on every open (:542) so a freshly-used
        -- cooldown reads 0 rather than a stale broadcast value. The
        -- keybind skips the open, so it must call it here or XX is
        -- computed from stale local data.
        Private:CollectAndStoreLocalPlayerCooldowns()

        -- XX MUST be computed ONCE so the dispatch and the popup sync
        -- below agree on one number.
        local xx = Private:ComputeCooldownPullSeconds()

        -- String argument, not a number: a typed dispatch hands the
        -- handler strtrim(msg) and providers depend on that (BigWigs
        -- compares input to "" and calls tonumber on it).
        if not Private.RunSlashCommand("/pull", ("%d"):format(xx)) then
            print("|cff99ff00Wild Things:|r No addon provides /pull (BigWigs/DBM)")
            return
        end

        -- PRP-07: windowless confirmation is load-bearing -- it is the
        -- user's only feedback when no window is showing.
        print(("|cff99ff00Wild Things:|r /pull %d started"):format(xx))

        -- PRP-07: if the window is ALREADY built (or gets opened later
        -- during the same pull), sync it into Abort mode with the same
        -- close/reset timers a button click would have armed. Nil-
        -- guarded so it is a silent no-op when the popup has never been
        -- built. `OnExternalPull` is defined in the popup file.
        local popup = Private.readycheckCooldownPopup
        if popup and popup.OnExternalPull then
            popup:OnExternalPull(xx)
        end
    end)
end

-- ============================================================
-- /wt cooldowntest dev command (2026-07-26)
--
-- Single-client solo-test harness for the cooldown window. Bypasses
-- the wire (Transmission.lua:85 self-skip makes single-client
-- comm loopback impossible by construction) by either injecting
-- fixture entries directly into the coalescing map OR by calling
-- the broadcast entry point so a second WoW client on the same
-- machine receives them on WT_MSG.
--
-- Modes:
--   send                       Broadcast current local cooldowns
--                              (same path RunCooldownCheck uses; the
--                              second client will see them via WT_MSG
--                              if it's in the same raid).
--   inject                     Inject a fixture set of 4 entries
--                              (3 remote-style + 1 local "player")
--                              with varying remaining times so the
--                              popup rendering, sorting, and fade
--                              behavior can be exercised without a
--                              second client.
--   popup                      Open the popup directly (uses
--                              whatever is in the coalescing map).
--   clear                      Wipe Private.readycheckCooldownBySender.
--   (no mode)                  Print current state + mode help.
--
-- Safe ungated for the same reasons as summonstest/inspecttest:
-- ReadyCheckCooldownComm.lua ships in every build, the test does
-- not cross any trust boundary (inject writes only to a local
-- Private table; send reuses the existing capability-gated broadcast
-- entry point; popup/clear are pure local UI). Wrapping it in a
-- dev-build sentinel would make it a silent no-op in exactly the
-- packaged build whose behaviour it exists to prove.
-- ============================================================

function Private:CooldownTest(mode)
    mode = tostring(mode or ""):lower()

    if mode == "" or mode == "help" then
        local n = 0
        if type(Private.readycheckCooldownBySender) == "table" then
            for _ in pairs(Private.readycheckCooldownBySender) do n = n + 1 end
        end
        print("|cff99ff00Wild Things:|r cooldowntest: " .. n .. " senders in coalescing map")
        print("|cff99ff00Wild Things:|r Modes: send | inject | popup | clear | trinkets | charges")
        return
    end

    if mode == "send" then
        Private:BroadcastOwnReadyCheckCooldowns()
        print("|cff99ff00Wild Things:|r cooldowntest: broadcast fired (RAID channel). Second client must be in the same raid to receive.")
        return
    end

    if mode == "popup" then
        Private:OpenReadyCheckCooldownPopup()
        print("|cff99ff00Wild Things:|r cooldowntest: popup opened (uses whatever is in the coalescing map)")
        return
    end

    if mode == "clear" then
        Private.readycheckCooldownBySender = {}
        print("|cff99ff00Wild Things:|r cooldowntest: coalescing map cleared")
        return
    end

    if mode == "inject" then
        local now = GetTime()
        local fakeClass = select(2, UnitClass("player")) or "WARRIOR"
        -- 4 entries: 3 remote senders + the local "player" key. Mix of
        -- classes so the class-color rendering is exercised; specIDs
        -- are real specialization IDs so the spec icon renders a real
        -- atlas. Remaining times chosen to exercise the mm:ss formatter,
        -- the sort order, and the hide boundary (60s is still ticking;
        -- 5s is short enough to expire while you watch; 0s is already
        -- done -- that row must never render at all).
        --
        -- BUG-FIX 2026-07-30: every per-spell entry carries its OWN
        -- `receivedAt`. The sender-level `receivedAt` below drives only the
        -- 30s GC -- LiveRemaining never reads it, it reads the PER-SPELL
        -- stamp and falls back to GetTime() when that is nil, which makes
        -- `(now - receivedAt)` permanently 0 and freezes the row at its
        -- literal `remaining` forever. Both production writers already stamp
        -- per entry for exactly this reason (CollectAndStoreLocalPlayerCooldowns
        -- line 281, receiver line 410); this fixture was the one writer that
        -- did not, so the synthetic rows sat static while the real raid path
        -- ticked correctly. The 0s hunter row still expired because 0 stays 0
        -- either way -- which is what made this look like a render bug.
        Private.readycheckCooldownBySender = Private.readycheckCooldownBySender or {}
        Private.readycheckCooldownBySender["testpaladin-blackhand"] = {
            senderName = "Testpaladin",
            senderClass = "PALADIN",
            entries = {
                { spellID = 31884, name = "Avenging Wrath", remaining = 60, specID = 65, receivedAt = now }, -- 65 = Retribution Paladin
            },
            lastSeen = now,
            receivedAt = now,
        }
        Private.readycheckCooldownBySender["testmage-blackhand"] = {
            senderName = "Testmage",
            senderClass = "MAGE",
            entries = {
                { spellID = 12051, name = "Evocation", remaining = 30, specID = 62, receivedAt = now }, -- 62 = Arcane Mage
                { spellID = 190319, name = "Combustion", remaining = 5, specID = 62, receivedAt = now },
            },
            lastSeen = now,
            receivedAt = now,
        }
        Private.readycheckCooldownBySender["testhunter-blackhand"] = {
            senderName = "Testhunter",
            senderClass = "HUNTER",
            entries = {
                { spellID = 19574, name = "Bestial Wrath", remaining = 0, specID = 253, receivedAt = now - 60 }, -- 253 = Beast Mastery Hunter
            },
            lastSeen = now,
            receivedAt = now - 60,
        }
        Private.readycheckCooldownBySender["player"] = {
            senderName = UnitName("player") or "Player",
            senderClass = fakeClass,
            entries = {
                { spellID = 31884, name = "Avenging Wrath", remaining = 45, specID = 102, receivedAt = now }, -- 102 = Balance Druid (current spec for this char)
            },
            lastSeen = now,
            receivedAt = now,
        }
        print("|cff99ff00Wild Things:|r cooldowntest: injected 4 senders (3 remote + local 'player') -- run /wt cooldowntest popup to view")
        return
    end

    if mode == "trinkets" then
        -- Solo, no-raid, no-second-client diagnosis of the whole trinket
        -- read path: resolves the live specID with the same defensive
        -- three-form lookup CollectOwnCooldownStatuses uses above, then
        -- prints one line per stored itemID for that spec naming the slot
        -- it occupies (or an explicit not-equipped marker), the resolved
        -- use-spell ID (or an explicit none marker) and the remaining
        -- cooldown -- so the picker/store/collector chain can be verified
        -- without a raid, a ready check, or a second client.
        local specIndex = GetSpecialization()
        local currentSpecID = nil
        if specIndex then
            if C_SpecializationInfo and C_SpecializationInfo.GetSpecializationInfo then
                local info = C_SpecializationInfo.GetSpecializationInfo(specIndex)
                if type(info) == "table" and info.id then
                    currentSpecID = info.id
                elseif type(info) == "number" then
                    currentSpecID = info
                end
            end
            if not currentSpecID then
                currentSpecID = select(1, GetSpecializationInfo(specIndex))
            end
        end

        local s13 = GetInventoryItemID("player", INVSLOT_TRINKET1)
        local s14 = GetInventoryItemID("player", INVSLOT_TRINKET2)
        print(("|cff99ff00Wild Things:|r cooldowntest: live slots -- Trinket1=%s Trinket2=%s"):format(
            tostring(s13 or "empty"), tostring(s14 or "empty")))

        if not currentSpecID then
            print("|cff99ff00Wild Things:|r cooldowntest: no live specialization resolved")
            return
        end

        local rc = WildThingsSavedVars.readycheck
        local perItems = rc and rc.cooldown and rc.cooldown.per_spec_items and rc.cooldown.per_spec_items[currentSpecID]
        if not perItems or not next(perItems) then
            print("|cff99ff00Wild Things:|r cooldowntest: no stored trinkets for spec " .. tostring(currentSpecID))
            return
        end

        for itemID, isEnabled in pairs(perItems) do
            if isEnabled then
                local itemName = C_Item.GetItemNameByID(itemID) or ("item:" .. tostring(itemID))
                local slot = ResolveTrinketSlot(itemID)
                local slotDesc = "NOT EQUIPPED"
                if slot == INVSLOT_TRINKET1 then slotDesc = "Trinket1"
                elseif slot == INVSLOT_TRINKET2 then slotDesc = "Trinket2" end

                local spellName, spellID = C_Item.GetItemSpell(itemID)
                local spellDesc = (type(spellID) == "number") and tostring(spellID) or "none"

                local remaining = 0
                if slot and type(spellID) == "number" then
                    local start, duration, enable = GetInventoryItemCooldown("player", slot)
                    if enable == 1 and duration and duration > 1.5 then
                        remaining = math.max(0, (start + duration) - GetTime())
                    end
                end

                print(("|cff99ff00Wild Things:|r cooldowntest: item %d (%s) slot=%s useSpell=%s remaining=%.1f"):format(
                    itemID, itemName, slotDesc, spellDesc, remaining))
            end
        end
        return
    end

    if mode == "charges" then
        -- Solo, no-raid, no-second-client diagnosis of the per-spell
        -- required-charges path (PTW-02/03/04): resolves the live specID
        -- with the same defensive three-form lookup CollectOwnCooldownStatuses
        -- uses above, then prints one line per enabled spellID naming the
        -- spell, its live charge state from C_Spell.GetSpellCharges, the
        -- stored required-charges value (or "1 (default)" when absent), and
        -- the result of THIS file's own GetSpellRemaining with that resolved
        -- value -- so the store/reader/charge-arithmetic chain can be
        -- verified without a raid, a ready check, or a second client.
        local specIndex = GetSpecialization()
        local currentSpecID = nil
        if specIndex then
            if C_SpecializationInfo and C_SpecializationInfo.GetSpecializationInfo then
                local info = C_SpecializationInfo.GetSpecializationInfo(specIndex)
                if type(info) == "table" and info.id then
                    currentSpecID = info.id
                elseif type(info) == "number" then
                    currentSpecID = info
                end
            end
            if not currentSpecID then
                currentSpecID = select(1, GetSpecializationInfo(specIndex))
            end
        end

        if not currentSpecID then
            print("|cff99ff00Wild Things:|r cooldowntest: no live specialization resolved")
            return
        end

        local rc = WildThingsSavedVars.readycheck
        local perSpec = rc and rc.cooldown and rc.cooldown.per_spec and rc.cooldown.per_spec[currentSpecID]
        if not perSpec or not next(perSpec) then
            print("|cff99ff00Wild Things:|r cooldowntest: no enabled spells for spec " .. tostring(currentSpecID))
            return
        end

        for spellID, isEnabled in pairs(perSpec) do
            if isEnabled then
                local spellName = C_Spell.GetSpellName(spellID) or ("Spell " .. tostring(spellID))
                local chargeInfo = C_Spell.GetSpellCharges(spellID)
                local chargeDesc = "not charge-based"
                if chargeInfo then
                    chargeDesc = ("%d/%d"):format(chargeInfo.currentCharges or 0, chargeInfo.maxCharges or 0)
                end

                local storedThreshold = rc.cooldown.per_spec_required_charges
                    and rc.cooldown.per_spec_required_charges[currentSpecID]
                    and rc.cooldown.per_spec_required_charges[currentSpecID][spellID]
                local thresholdDesc = storedThreshold and tostring(storedThreshold) or "1 (default)"

                local remaining = GetSpellRemaining(spellID, storedThreshold)

                print(("|cff99ff00Wild Things:|r cooldowntest: spell %d (%s) charges=%s required=%s remaining=%.1f"):format(
                    spellID, spellName, chargeDesc, thresholdDesc, remaining))
            end
        end
        return
    end

    print("|cff99ff00Wild Things:|r cooldowntest: unknown mode '" .. mode .. "' (try 'send' | 'inject' | 'popup' | 'clear' | 'trinkets' | 'charges')")
end
