---@diagnostic disable: undefined-field, undefined-global
local _, namespace = ...
local Private = namespace
local WTUI = namespace.WildThingsUI
local Theme = namespace.Theme

-- READY-05 cooldown window popup (rank-0-2 viewer-gated, opt-in
-- auto-open). A standalone popup listing (class-icon, sender, spell-
-- icon, spellName, mm:ss) rows for every WT-Blackhand sender's
-- enabled cooldowns that are CURRENTLY ACTIVE (live > 0) in the raid
-- (rank 0-2 AND rank 3+ -- sender eligibility is cross-rank since
-- 2026-07-26), sorted by LIVE remaining descending, ticking down at
-- 1 Hz via the shared countdown driver registered by the orchestrator
-- (UI/GlowDriver.lua -- no new OnUpdate frame). Footer: a numeric
-- offset EditBox + a "/pull XX" button that LIVE-UPDATES its label to
-- show the actual /pull seconds (= ceil(max(live_remaining) + offset))
-- and, when clicked, EXECUTES the /pull slash command with the
-- displayed value via RunSlashCommand below -- dispatching to whichever
-- addon owns the "/pull" token (BigWigs, DBM, EXBoss, ...). That is NOT
-- the same thing as putting the text "/pull 10" into chat: doing the
-- latter with C_ChatInfo.SendChatMessage was the original defect (fixed
-- 2026-07-29), because SendChatMessage transmits literal characters and
-- never parses commands -- the raid saw a "/pull 10" chat line and no
-- pull timer ever started. The fixed path sends no chat message at all;
-- the owning addon does its own announcing. Guarded by
-- C_ChatInfo.InChatMessagingLockdown; that guard and the "no addon owns
-- /pull" case both report on the bottom status bar (built in by the
-- chrome primitive).
--
-- Built on top of namespace:CreateChromeWindow -- the SAME chrome
-- primitive the Summoning window (SummonScreen.lua:1800) and the main
-- shell (Shell.lua:169) use. Same
-- titlebar (logo + two-tone title + X close IconButton), same status
-- bar, same drag, save-position, raise-on-click, ESC-close, off-screen
-- clamp. Per user feedback 2026-07-26 ("Es nutzt aber immer noch nicht
-- das neue 'Summoning' window template") -- the popup is now on the
-- canonical template, not the older CreatePopupWindow primitive.
--
-- This file is the visible chrome ONLY. The orchestrator
-- (Private:OpenReadyCheckCooldownPopup) and the wire side live in
-- ReadyCheckCooldownComm.lua -- the orchestrator calls this builder
-- once (lazy), then drives the open path. The 1 Hz countdown driver
-- is also started by the orchestrator (NOT at build time) so it
-- restarts on every open, not only the first (W8 fix).

local ONE_SECOND = 1.0
local POPUP_WIDTH = 330 -- -25% from the original 440 per user feedback 2026-07-26
local POPUP_BODY_BASE_HEIGHT = 80 -- chrome window's content area minimum; rows grow upward into this
local POPUP_FOOTER_GAP = 8 -- gap between the row list and the EditBox + Button (matches the side margins per user feedback 2026-07-26)
local POPUP_FOOTER_BOTTOM_PAD = 4 -- tighter bottom margin below EditBox/Button (per user feedback 2026-07-26)
local POPUP_PADDING = 8 -- side + top inner margins
local ROW_HEIGHT = 20
local ICON_SIZE = 16
local SENDER_COL_WIDTH = 100
local SPELL_COL_WIDTH = 130
local TIMER_COL_WIDTH = 50

-- Module-local popup cache. The orchestrator (Task 1's
-- Private:OpenReadyCheckCooldownPopup) writes this on first open;
-- this file does NOT register the countdown driver at build time --
-- the driver restart is the orchestrator's job so a popup that was
-- hidden then re-shown after a previous driver owner reference has
-- gone stale still ticks (W8).
local popup

-- Private:ComputeLiveRemaining wrapper. The comm file owns the
-- implementation; this is just a convenience for the per-row render
-- so the popup never re-derives the subtraction.
local function LiveRemaining(entry)
    if Private.ComputeLiveRemaining then
        return Private:ComputeLiveRemaining(entry)
    end
    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

-- Format seconds as mm:ss (T-r3x-10 sender-stamped remaining is never
-- trusted directly; we use the live-computed value everywhere).
local function FormatMmSs(seconds)
    seconds = math.max(0, math.floor(seconds or 0))
    local m = math.floor(seconds / 60)
    local s = seconds % 60
    return string.format("%d:%02d", m, s)
end

-- GC stale remote entries. Mirrors the receive-time GC in
-- ReadyCheckCooldownComm.lua (drop any sender whose lastSeen is
-- older than 30s).
local function GcStaleEntries()
    if Private.GcStaleReadyCheckCooldownEntries then
        Private:GcStaleReadyCheckCooldownEntries()
    end
end

-- 260730-osw / OSW-01: tolerant SavedVars readers for the popup's
-- own auto-close setting. Both must read WildThingsSavedVars INSIDE
-- the function body -- this file loads from UI/UI.xml (TOC slot #2,
-- before Migrations.lua runs), so a file-scope capture would bind
-- nil forever. Pattern mirrors WildThings.lua:211-214 -- nil-check
-- every level, then `~= false` so a user's stored `false` survives
-- a defaults merge rather than getting re-defaulted back on.
local function CloseBeforePullEnabled()
    local s = WildThingsSavedVars
    if type(s) ~= "table" then return false end
    local rc = s.readycheck
    if type(rc) ~= "table" then return false end
    local cw = rc.cooldownwindow
    if type(cw) ~= "table" then return false end
    return cw.close_before_pull ~= false
end

local function CloseBeforePullSeconds()
    local s = WildThingsSavedVars
    if type(s) ~= "table" then return 5 end
    local rc = s.readycheck
    if type(rc) ~= "table" then return 5 end
    local cw = rc.cooldownwindow
    if type(cw) ~= "table" then return 5 end
    local n = tonumber(cw.close_before_pull_seconds)
    if type(n) ~= "number" then return 5 end
    -- Clamp into the inclusive range 1..30 so a hand-edited SavedVars
    -- file cannot produce a negative or absurd delay. The slider's
    -- own min/max also clamps, but the reader is the last line of
    -- defence.
    if n < 1 then n = 1 end
    if n > 30 then n = 30 end
    return n
end

-- Collect one flat list of { sender, senderClass, spellID, spellName,
-- liveRemaining } rows for the render pass. Walks both the remote
-- coalescing map and the explicit "player" local-read key.
--
-- User feedback 2026-07-26: only spells that are CURRENTLY ON COOLDOWN
-- (live > 0) belong in the list. GC runs FIRST so already-expired
-- senders don't keep a row alive.
--
-- The FADE_DELAY grace that used to sit here is gone (2026-07-30, user
-- feedback: "Spells bleiben bei 0:00 noch fuer ~1s laenger sichtbar als
-- erwartet"). It stamped the first tick on which `live <= 0` was observed
-- and hid the row only on a LATER tick -- and because the driver ticks at
-- 1 Hz, a 0.5s grace always rounded up to a full extra second on screen.
--
-- Removing it costs nothing, because neither reason it was added holds:
--   * "so the 0-tick reads as 0:00 instead of a hard cut" -- FormatMmSs
--     floors, so a row with 0.7s left ALREADY renders as "0:00". The last
--     tick before expiry shows 0:00 on its own, grace or no grace.
--   * "so a transient underflow doesn't flicker" -- cannot happen.
--     LiveRemaining clamps at 0, `remaining` is fixed and elapsed only
--     grows, so live falls monotonically and never returns above 0. Only a
--     fresh broadcast raises it, and that is a real value, not a flicker.
local function CollectRows()
    GcStaleEntries()
    local map = Private.readycheckCooldownBySender
    if type(map) ~= "table" then return {} end
    local rows = {}
    for key, entry in pairs(map) do
        if type(entry) == "table" and type(entry.entries) == "table" then
            for _, e in ipairs(entry.entries) do
                local live = LiveRemaining(e)
                if live > 0 then
                    table.insert(rows, {
                        sender = entry.senderName or key,
                        senderClass = entry.senderClass,
                        spellID = e.spellID,
                        spellName = e.name or ("Spell " .. tostring(e.spellID)),
                        liveRemaining = live,
                        specID = e.specID,
                    })
                end
            end
        end
    end
    table.sort(rows, function(a, b)
        if a.liveRemaining == b.liveRemaining then
            return tostring(a.sender) < tostring(b.sender)
        end
        return a.liveRemaining > b.liveRemaining
    end)
    return rows
end

-- Module-level scratch state for the build-once popup: we cache the
-- row frames and rebuild their text in place on every refresh -- a
-- full re-parent/Show cycle per tick would re-trigger OnShow/OnHide
-- hooks and re-anchor the window for every tick.
local rowFrames = {}

-- Create a single row frame child of `parent`, anchored to TOP, with
-- five visual columns:
--   [class icon] [sender (class color)] [spell icon] [spellName] [mm:ss]
local function CreateRowFrame(parent, yOffset)
    local row = CreateFrame("Frame", nil, parent)
    row:SetHeight(ROW_HEIGHT)
    row:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, -yOffset)
    row:SetPoint("TOPRIGHT", parent, "TOPRIGHT", 0, -yOffset)

    -- Spec icon (leftmost). Set per-row via GetSpecIconTexture(specID).
    -- Replaces the class icon per user feedback 2026-07-26 ("class
    -- Icon kann weg") -- the class color of the sender name is
    -- already conveyed by the text, but the SPEC (e.g. Frost vs
    -- Fire for a Mage) is unique information worth a 16px atlas.
    row.specIcon = row:CreateTexture(nil, "ARTWORK")
    row.specIcon:SetSize(ICON_SIZE, ICON_SIZE)
    row.specIcon:SetPoint("LEFT", row, "LEFT", 4, 0)

    -- Sender name (class-colored text).
    row.sender = row:CreateFontString(nil, "ARTWORK")
    row.sender:SetFontObject(WildThingsFont13)
    row.sender:SetJustifyH("LEFT")
    row.sender:SetPoint("LEFT", row.specIcon, "RIGHT", 4, 0)
    row.sender:SetWidth(SENDER_COL_WIDTH)

    -- Spell icon.
    row.spellIcon = row:CreateTexture(nil, "ARTWORK")
    row.spellIcon:SetSize(ICON_SIZE, ICON_SIZE)
    row.spellIcon:SetPoint("LEFT", row.sender, "RIGHT", 6, 0)

    -- Spell name (dim text).
    row.spell = row:CreateFontString(nil, "ARTWORK")
    row.spell:SetFontObject(WildThingsFont13)
    row.spell:SetJustifyH("LEFT")
    row.spell:SetPoint("LEFT", row.spellIcon, "RIGHT", 4, 0)
    row.spell:SetWidth(SPELL_COL_WIDTH)

    -- mm:ss (right-aligned, tabular).
    row.timer = row:CreateFontString(nil, "ARTWORK")
    row.timer:SetFontObject(WildThingsFontNum13)
    row.timer:SetJustifyH("RIGHT")
    row.timer:SetPoint("RIGHT", row, "RIGHT", -8, 0)
    row.timer:SetWidth(TIMER_COL_WIDTH)

    return row
end

-- Resolve a spec ID to its spec icon texture path. Returns nil when
-- the spec is unknown (older API on some clients, or an unmigrated
-- payload from an addon that predates the specID broadcast). The
-- caller hides the spec icon slot when this returns nil.
--
-- Modern API: C_SpecializationInfo.GetSpecializationInfo(specID)
-- nominally returns a TABLE {id, name, description, icon, role, ...},
-- but on some retail WoW builds it returns a plain NUMBER (the
-- specID itself) -- `info.icon` then throws "attempt to index a
-- number value". The legacy global GetSpecializationInfoForSpecID
-- specID returns multi-values (name, description, icon, role, ...).
-- Handle all three forms defensively: try the modern API first,
-- branch on the return type; fall back to the global multi-value
-- unpack if the modern API returns a number or nothing.
local function GetSpecIconTexture(specID)
    if type(specID) ~= "number" then return nil end
    if C_SpecializationInfo and C_SpecializationInfo.GetSpecializationInfo then
        local info = C_SpecializationInfo.GetSpecializationInfo(specID)
        if type(info) == "table" and info.icon then
            return info.icon
        end
    end
    if GetSpecializationInfoForSpecID then
        local _, _, icon = GetSpecializationInfoForSpecID(specID)
        if icon then return icon end
    end
    return nil
end

-- Resolve a spell ID to its texture path. Returns the "?" fallback when
-- the spell isn't known.
local function GetSpellIconTexture(spellID)
    if C_Spell and C_Spell.GetSpellInfo then
        local info = C_Spell.GetSpellInfo(spellID)
        if info and info.originalIconID then
            return info.originalIconID
        end
    end
    return "Interface\\Icons\\INV_MISC_QuestionMark"
end

-- Pull-button status message shown in the chrome window's bottom status
-- bar. nil/false clears it (empty string = hidden). Takes the message
-- instead of the old boolean because there are now two distinct
-- rejections to report: the chat-messaging lockdown, and "nothing on
-- this client owns /pull".
local function SetPullStatus(p, message)
    p:SetStatusLeft(message or "")
end

-- 260730-prp (PRP-08): the local RunSlashCommand that used to live here
-- has been moved to ReadyCheckCooldownComm.lua as `Private.RunSlashCommand`
-- (DOT, two real parameters) so the windowless keybind path can reach it
-- without the popup ever being built. The popup's two call sites (p.pull
-- and p.abort OnClick closures) call `Private.RunSlashCommand` directly
-- inside the click closure -- NOT through a file-scope local capture, on
-- purpose: this file loads from UI/UI.xml (WildThings.toc:21) and
-- ReadyCheckCooldownComm.lua loads at :195, so a file-scope local would
-- bind nil forever and fail at runtime as a nil call, never at build.

-- namespace:CreateReadyCheckCooldownPopup()
-- The popup builder, called by Private:OpenReadyCheckCooldownPopup
-- (ReadyCheckCooldownComm.lua) on first open. Returns a Frame.
function namespace:CreateReadyCheckCooldownPopup()
    -- D-13: capability-gated build. Non-eligible viewers never get
    -- any chrome (UI/Widgets/Shell.lua:240-246 pattern) -- the
    -- orchestrator's identical gate (ReadyCheckCooldownComm.lua) is
    -- defense in depth, this one is the visible one.
    if not Private:ViewerHasCapability("readycheck.cooldownwindow") then
        return CreateFrame("Frame")
    end

    -- Base chrome: same primitive as the Summoning window and the
    -- main shell. Titlebar has logo + "Wild Things" + "Cooldowns" +
    -- X close IconButton; status bar is free for our combat-lockdown
    -- message; drag, save-position, raise-on-click, ESC-close, off-
    -- screen clamp all come for free from the primitive.
    -- Forward-declared: the onClose closure below is built *inside* this
    -- statement, and a local's scope only starts after it -- `local p = ...`
    -- would bind the (nil) global p and error on every X click.
    local p
    p = namespace:CreateChromeWindow("WildThingsCooldownPopup", {
        width = POPUP_WIDTH,
        height = 280, -- grows dynamically with row count (see RenderRows)
        resizable = false,
        titleAccent = "Wild Things",
        titleDim = "Cooldowns",
        statusLeft = "",
        statusRight = "",
        onClose = function() p:Hide() end,
    })

    -- ============================================================
    -- Body: rows region inside the chrome window's contentHost,
    -- anchored TOP-LEFT/TOP-RIGHT with the footer sitting just above
    -- the status bar at BOTTOM. Content host is auto-inset by
    -- H_TITLEBAR + H_STATUSBAR by the primitive (ChromeWindow.lua:
    -- 226-230), so we use it directly. POPUP_PADDING inset on all
    -- four sides per user feedback 2026-07-26 ("mach im Fenster noch
    -- ein paar pixel margin").
    -- ============================================================
    local contentHost = p.contentHost

    local body = CreateFrame("Frame", nil, contentHost)
    body:SetPoint("TOPLEFT", contentHost, "TOPLEFT", POPUP_PADDING, -POPUP_PADDING)
    body:SetPoint("TOPRIGHT", contentHost, "TOPRIGHT", -POPUP_PADDING, -POPUP_PADDING)
    body:SetHeight(POPUP_BODY_BASE_HEIGHT)

    local MAX_ROWS = 160
    for i = 1, MAX_ROWS do
        rowFrames[i] = CreateRowFrame(body, 0)
        rowFrames[i]:Hide()
    end

    -- 260730-osw / OSW-04: empty-state block. Mirrors the shipped
    -- `BuildConfirmationBlock` pattern at UI/Screens/SummonScreen.lua:1909-1955
    -- (rounded accent-wash circle, check icon tinted accent, bold
    -- accent-coloured headline -- recognised as "the same design"
    -- anywhere in the addon). Built ONCE, hidden by default, shown
    -- by RenderRows when nRows == 0. Declared lexically above
    -- RenderRows (declared below) so RenderRows captures it as a
    -- real upvalue rather than a nil global.
    --
    -- Anchored to all four corners of `body` so it automatically
    -- tracks the dynamic body height that RenderRows sets, and so
    -- it has a real rectangle -- `CENTER` anchoring inside a
    -- one-point frame resolves to nil (project memory
    -- `wow-one-point-no-size-means-no-rect`).
    local readyBlock = CreateFrame("Frame", nil, body)
    readyBlock:SetPoint("TOPLEFT", body, "TOPLEFT", 0, 0)
    readyBlock:SetPoint("BOTTOMRIGHT", body, "BOTTOMRIGHT", 0, 0)

    local readyCircle = CreateFrame("Frame", nil, readyBlock)
    readyCircle:SetSize(32, 32)
    -- Positive y offset lifts the circle so the circle-plus-headline
    -- pair reads as vertically centred rather than the circle alone.
    readyCircle:SetPoint("CENTER", readyBlock, "CENTER", 0, 14)
    -- Radius 16 is half of 32 -- the equal numbers make it a full circle.
    WTUI:AddRoundedFill(readyCircle, 16, Theme.ACCENT_A13)

    local readyIcon = namespace:CreateIcon(readyCircle, "check", 18)
    readyIcon:SetPoint("CENTER", readyCircle, "CENTER", 0, 0)
    readyIcon:SetTint(Theme.ACCENT)
    -- CreateIcon defaults to idleAlpha 0.7; the explicit SetAlpha(1) is
    -- required so the checkmark renders at full strength.
    readyIcon:SetAlpha(1)

    local readyText = readyBlock:CreateFontString(nil, "ARTWORK")
    readyText:SetFontObject(WildThingsFontBold16)
    readyText:SetTextColor(Theme.ACCENT.r, Theme.ACCENT.g, Theme.ACCENT.b, Theme.ACCENT.a)
    readyText:SetJustifyH("CENTER")
    readyText:SetText("Everything is ready")
    -- Single-point anchor: a second horizontal point pins the vertical
    -- centre and thus the height, which is a documented project trap
    -- (`wow-fontstring-wrap-needs-explicit-width`). The string is short
    -- and does not wrap, so no explicit SetWidth is needed either.
    readyText:SetPoint("TOP", readyCircle, "BOTTOM", 0, -6)

    readyBlock:Hide()

    -- Footer: offset EditBox (left) + /pull button (right). Both
    -- BOTTOM-anchored to contentHost so they sit just above the
    -- chrome status bar. POPUP_PADDING inset on both sides.
    local onOffsetChanged
    p.offset = namespace:CreateEditBox(contentHost, "Offset (s)", function(value)
        if onOffsetChanged then onOffsetChanged(value) end
    end)
    p.offset:SetPoint("BOTTOMLEFT", contentHost, "BOTTOMLEFT", POPUP_PADDING, POPUP_FOOTER_BOTTOM_PAD)
    p.offset:SetWidth(120)

    -- W9 fix: input grammar is ^[0-9]{1,3}$ (digits-only, 0-999, 0
    -- allowed for "pull now"). We hook the underlying OnTextSet path
    -- via the EditBox's currentValue: the EditBox's OnValueChanged
    -- callback already gets the typed text, we reject anything that
    -- doesn't match.
    onOffsetChanged = function(value)
        local s = tostring(value or "")
        if s:match("^[0-9]+$") and #s >= 1 and #s <= 3 then
            local n = tonumber(s)
            if n and n >= 0 and n <= 999 then
                p.offset.currentValue = n
                p.offset:SetWarning(false)
                -- 260730-prp (PRP-04): persist the offset on the valid
                -- branch ONLY -- a half-typed value must never become
                -- the value a windowless keypress uses. The EditBox is
                -- the writer for the offset; the windowless keybind
                -- reads it back at call time via Private:Get.
                Private:SetCooldownPullOffsetSeconds(n)
                if p.pull and p.pull.Enable then p.pull:Enable() end
                return
            end
        end
        p.offset.currentValue = nil
        p.offset:SetWarning(true)
        if p.pull and p.pull.Disable then p.pull:Disable() end
    end

    -- 260730-prp (PRP-04): seed the EditBox from the persisted value,
    -- not the hardcoded "10". Read at popup BUILD time -- this is
    -- deliberately the lazy-build path (the popup is built on first
    -- open), so by then Migrations has run and the leaf is populated.
    -- `namespace:CreateEditBox`'s `SetText` fires `OnTextSet` ->
    -- `OnValueChanged` (UI/Widgets/EditBox.lua:206-218), so this seed
    -- re-writes the same value straight back to SavedVars via the
    -- onOffsetChanged valid branch above -- idempotent and intentional,
    -- not an accidental double write. Two consequences are the point:
    -- the offset now survives a /reload, and the number the window
    -- shows is provably the number the keybind will use.
    local initialOffset = Private:GetCooldownPullOffsetSeconds()
    p.offset:SetText(tostring(initialOffset))
    p.offset.currentValue = initialOffset
    if p.pull and p.pull.Enable then p.pull:Enable() end

    -- Compute the displayed /pull value: ceil(max(live remaining) + offset),
    -- floored at 0. 260730-prp (PRP-03): now delegates to the popup-
    -- independent `Private:ComputeCooldownPullSeconds(offsetOverride)` in
    -- ReadyCheckCooldownComm.lua so the window and the keybind pass through
    -- ONE arithmetic site. The override preserves today's behaviour byte
    -- for byte -- onOffsetChanged sets `currentValue` to nil on invalid
    -- input and the old body already fell back to offset 0, so passing
    -- the same nil-fallback to the shared function reproduces it.
    -- Deliberately NOT nil-guarded like LiveRemaining (:69-76) guards
    -- Private.ComputeLiveRemaining: a tolerant fallback here would return
    -- a plausible-looking but WRONG pull number; an outright error is the
    -- honest failure and is visible immediately.
    local function ComputePullXX()
        local offset = p.offset and p.offset.currentValue
        if type(offset) ~= "number" then offset = 0 end
        return Private:ComputeCooldownPullSeconds(offset)
    end

    -- 260730-osw / OSW-01 + OSW-02: pull-state machine. Declared BEFORE
    -- the p.pull / p.abort button constructors below because both
    -- click handlers are closures created at those constructor calls;
    -- a helper declared below them would be captured as a nil global
    -- (Lua closures capture upvalues at construction time). The pair
    -- of timers is the single source of truth for "is one of OUR pulls
    -- running?" -- the popup never tries to read BigWigs' own state.
    local pullCloseTimer = nil
    local pullResetTimer = nil

    local function CancelPullTimers()
        if pullCloseTimer and pullCloseTimer.Cancel then
            pullCloseTimer:Cancel()
        end
        if pullResetTimer and pullResetTimer.Cancel then
            pullResetTimer:Cancel()
        end
        pullCloseTimer = nil
        pullResetTimer = nil
    end

    -- `p.abort` is constructed AFTER this function is DECLARED, but the
    -- helper is only ever CALLED after the popup is fully built -- by
    -- which time `p.abort` exists. Guard so an early call from a
    -- build-time path is a no-op rather than a hard error.
    local function ShowPullMode()
        if p.abort then p.abort:Hide() end
        if p.pull then p.pull:Show() end
    end

    local function ShowAbortMode()
        if p.pull then p.pull:Hide() end
        if p.abort then p.abort:Show() end
    end

    local function ResetPullState()
        CancelPullTimers()
        ShowPullMode()
    end

    -- Arm both timers for a `/pull XX` we just dispatched. Always arm
    -- pullResetTimer so Abort restores `/pull` after the countdown
    -- actually lands; only arm pullCloseTimer when the user enabled the
    -- auto-close (OSW-01). The math.max(0, ...) clamp is INTENTIONAL:
    -- a `/pull 3` with a 5-second setting yields a delay of 0 -- the
    -- window closes immediately. That is the stated behaviour of
    -- this feature and not a defect.
    local function ArmPullTimers(xx)
        CancelPullTimers()

        pullResetTimer = C_Timer.NewTimer(xx, function()
            pullResetTimer = nil
            Private.safecall(ResetPullState)
        end)

        if CloseBeforePullEnabled() then
            local delay = math.max(0, xx - CloseBeforePullSeconds())
            pullCloseTimer = C_Timer.NewTimer(delay, function()
                pullCloseTimer = nil
                Private.safecall(function()
                    if p and p.IsShown and p:IsShown() then
                        p:Hide()
                    end
                end)
            end)
        end
    end

    -- 260730-prp (PRP-07): windowless-keybind sync hook. WTAPI:
    -- PullFromKeybind calls this on the already-built popup so the lead
    -- sees the same Abort mode + close/reset timers a button click would
    -- have armed. Declared AFTER ArmPullTimers (which it CALLS) and
    -- BEFORE the p.pull constructor (which builds the click closure).
    -- Lua closures capture upvalues at construction time -- declaring
    -- this above ArmPullTimers would capture a nil global that fails
    -- only at runtime. The body's upvalues (SetPullStatus, ShowAbortMode,
    -- ArmPullTimers) are populated by the time this method is CALLED
    -- (which is well after the popup is built); the declaration position
    -- only matters for what the closure captures if it itself were a
    -- closure, which it is not.
    --
    -- COLON declaration (`function p:OnExternalPull(xx)`): Task 1 calls
    -- it as `popup:OnExternalPull(xx)`. A dot declaration here would land
    -- the popup in `xx` and leave the real `xx` nil, arming a zero-second
    -- timer. This is the same defect class that shipped in the CountdownTick
    -- declaration (:762-771) and is the reason a probe row guards the form.
    --
    -- Body mirrors exactly what the button OnClick does after a
    -- successful dispatch (:608-610): clear status, show Abort, arm
    -- timers. Nothing else -- the dispatch itself already happened in
    -- the comm file, and re-dispatching here would double-fire the pull.
    --
    -- Two consequences of running this while the window is HIDDEN, both
    -- correct and neither a defect: the close timer will Hide() an
    -- already-hidden window (its body is guarded by `p:IsShown()`), and
    -- the Abort/Pull button state is set up for the next open, so a lead
    -- who opens the window mid-pull sees Abort exactly as if they had
    -- clicked the button.
    function p:OnExternalPull(xx)
        SetPullStatus(p, nil)
        ShowAbortMode()
        ArmPullTimers(xx)
    end

    -- /pull XX button. OnClick:
    --   1. If C_ChatInfo.InChatMessagingLockdown: set status bar
    --      text and return (no dispatch, button stays clickable).
    --   2. EXECUTE "/pull <CURRENT displayed value>" via RunSlashCommand,
    --      i.e. hand it to whichever addon owns the /pull token. Nothing
    --      is written to chat -- the provider announces the pull itself.
    --   3. If no addon owns /pull, say so on the status bar. The old
    --      code path silently broadcast the literal text in this case;
    --      RunSlashCommand structurally cannot.
    -- The button label is refreshed by RenderRows every tick to show the
    -- live max+offset, so the click handler reads the same value.
    --
    -- The lockdown guard is KEPT even though BigWigs re-checks lockdown
    -- itself: BigWigs prints its rejection to the chat frame, while this
    -- reports in the popup the user just clicked in, and a provider that
    -- does NOT self-check still must not start a pull during lockdown.
    p.pull = namespace:CreateButton(contentHost, "/pull 10", function()
        if C_ChatInfo and C_ChatInfo.InChatMessagingLockdown and C_ChatInfo.InChatMessagingLockdown() then
            SetPullStatus(p, "Cannot start /pull in combat")
            return
        end

        -- 260730-osw / OSW-01+OSW-02: compute the value ONCE so the
        -- dispatch and the close-arm agree on the same number -- the
        -- live value can change between two calls and the close
        -- timer would then be armed against a different value than the
        -- one actually pulled. The lockdown and unowned-token rejections
        -- above leave the button mode untouched: a rejected click started
        -- no pull, so the UI must NOT show Abort.
        local xx = ComputePullXX()

        -- 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).
        -- 260730-prp: references `Private.RunSlashCommand` via the
        -- closure (NOT a file-scope local capture) -- see pointer at
        -- the deletion site above; this file loads before the comm file
        -- so a file-scope capture would bind nil here.
        if not Private.RunSlashCommand("/pull", ("%d"):format(xx)) then
            SetPullStatus(p, "No addon provides /pull (BigWigs/DBM)")
            return
        end

        SetPullStatus(p, nil)
        ShowAbortMode()
        ArmPullTimers(xx)
    end, { variant = "primary" })
    p.pull:SetPoint("BOTTOMRIGHT", contentHost, "BOTTOMRIGHT", -POPUP_PADDING, POPUP_FOOTER_BOTTOM_PAD)

    -- 260730-osw / OSW-02: Abort button. A SECOND button, not a
    -- recolour of p.pull: UI/Widgets/Button.lua bakes variantDef into
    -- the hover closures at construction (lines 17-45) and creates the
    -- `variantWash` texture only when the variant is `danger` at build
    -- time (lines 123-129). A `primary` button can never become red
    -- at runtime -- so the two variants sit at the same footer anchor
    -- and we Show/Hide exactly one.
    --
    -- `/pull 0` cancels BigWigs' countdown (verified in
    -- BigWigs_Plugins/Pull.lua:509-531 -- DoCountdown(0) reaches
    -- Blizzard's real cancel via the loader-captured original
    -- C_PartyInfo.DoCountdown). For DBM / EXBoss the abort is not
    -- verified -- non-BigWigs providers may silently no-op, but the
    -- button state still returns to `/pull`, which is the honest
    -- fallback.
    p.abort = namespace:CreateButton(contentHost, "Abort", function()
        if C_ChatInfo and C_ChatInfo.InChatMessagingLockdown and C_ChatInfo.InChatMessagingLockdown() then
            SetPullStatus(p, "Cannot abort /pull in combat")
            return
        end

        -- String "0", not number 0: provider handlers do `input == ""`
        -- and `tonumber(input)` on the trimmed string.
        if not Private.RunSlashCommand("/pull", "0") then
            SetPullStatus(p, "No addon provides /pull (BigWigs/DBM)")
            return
        end

        SetPullStatus(p, nil)
        ResetPullState()
    end, { variant = "danger" })
    p.abort:SetPoint("BOTTOMRIGHT", contentHost, "BOTTOMRIGHT", -POPUP_PADDING, POPUP_FOOTER_BOTTOM_PAD)
    p.abort:Hide()

    -- ============================================================
    -- Refresh fn: re-render rows from the current coalescing map.
    -- Called by the 1 Hz StartGlow driver (registered from the
    -- orchestrator, not here -- W8 fix). Also resizes the chrome
    -- window so it shrinks/grows with the row count (dynamic height).
    -- ============================================================
    local function RenderRows()
        local rows = CollectRows()
        local nRows = math.min(#rows, #rowFrames)
        local i = 1
        for _, r in ipairs(rows) do
            if i > #rowFrames then break end
            local frame = rowFrames[i]
            frame:Show()

            -- Class icon (leftmost): per-class atlas strings -- each
            -- one is a single 32x32 icon, no TexCoord math, no distortion.
            local classKey = r.senderClass and r.senderClass:upper()
            -- Spec icon (leftmost). Resolved via GetSpecIconTexture(specID)
            -- which uses C_SpecializationInfo.GetSpecializationInfo. Hides
            -- the slot if the specID is nil/unknown.
            local specTex = GetSpecIconTexture(r.specID)
            if specTex then
                frame.specIcon:SetTexture(specTex)
                frame.specIcon:Show()
            else
                frame.specIcon:Hide()
            end

            -- Sender: class color via Theme.GetClassColor, white fallback.
            local classColor = Theme.WT_WHITE
            if r.senderClass and Theme.GetClassColor then
                classColor = Theme:GetClassColor(r.senderClass) or Theme.WT_WHITE
            end
            frame.sender:SetTextColor(classColor.r, classColor.g, classColor.b, classColor.a)
            frame.sender:SetText(tostring(r.sender or "?"))

            -- Spell icon (between sender and spell name).
            frame.spellIcon:SetTexture(GetSpellIconTexture(r.spellID))
            frame.spellIcon:Show()

            -- Spell name (dim text).
            frame.spell:SetTextColor(Theme.TEXT.r, Theme.TEXT.g, Theme.TEXT.b, Theme.TEXT.a)
            frame.spell:SetText(tostring(r.spellName or "?"))

            -- mm:ss (right-aligned, tabular).
            frame.timer:SetTextColor(Theme.TEXT_DIM.r, Theme.TEXT_DIM.g, Theme.TEXT_DIM.b, Theme.TEXT_DIM.a)
            frame.timer:SetText(FormatMmSs(r.liveRemaining))

            -- Stack the next row below this one.
            frame:ClearAllPoints()
            frame:SetPoint("TOPLEFT", body, "TOPLEFT", 0, -((i - 1) * ROW_HEIGHT))
            frame:SetPoint("TOPRIGHT", body, "TOPRIGHT", 0, -((i - 1) * ROW_HEIGHT))

            i = i + 1
        end
        for j = i, #rowFrames do
            rowFrames[j]:Hide()
        end

        -- 260730-osw / OSW-04: show the empty-state block when there
        -- are no rows to render. Deliberately placed AFTER the row-hide
        -- loop above: showing the block while a previous pass's row
        -- frames were still drawn would render text over text. This
        -- exact ordering mistake shipped once in SummonScreen.lua and
        -- is recorded as a phase defect -- the order here is the fix.
        if nRows == 0 then
            readyBlock:Show()
        else
            readyBlock:Hide()
        end

        -- Dynamic body height: nRows * ROW_HEIGHT (with a min of
        -- POPUP_BODY_BASE_HEIGHT so an empty list still shows a
        -- sensible body that doesn't collapse to 0).
        local bodyHeight = math.max(POPUP_BODY_BASE_HEIGHT, nRows * ROW_HEIGHT)
        body:SetHeight(bodyHeight)

        -- Dynamic chrome window height: titlebar (H_TITLEBAR) +
        -- status bar (H_STATUSBAR) + top padding + body + footer
        -- gap + footer height + bottom padding. The footer sits
        -- POPUP_FOOTER_BOTTOM_PAD (=4) above the contentHost bottom,
        -- the body sits POPUP_PADDING (=8) below the contentHost top,
        -- and POPUP_FOOTER_GAP (=8) of breathing room sits between
        -- the body bottom and the footer top. The chrome window grows
        -- downward by calling SetHeight -- the contentHost auto-
        -- resizes via its 4-point anchor, and the titlebar/statusbar
        -- frames stay pinned at their respective edges.
        local footerH = math.max(p.offset:GetHeight() or 0, p.pull:GetHeight() or 0)
        local newHeight = (Theme.H_TITLEBAR or 48)
            + (Theme.H_STATUSBAR or 24)
            + POPUP_PADDING
            + bodyHeight
            + POPUP_FOOTER_GAP
            + footerH
            + POPUP_FOOTER_BOTTOM_PAD
        p:SetHeight(newHeight)

        -- Live /pull XX button label refresh (per user feedback 2026-07-26).
        if p.pull and p.pull.SetText then
            p.pull:SetText(("/pull %d"):format(ComputePullXX()))
        end
    end

    -- Public Refresh(): the orchestrator calls this once at open
    -- time to seed the rows from the post-Open state.
    function p:Refresh()
        RenderRows()
    end

    -- CountdownTick: the 1 Hz body registered with the GlowDriver by
    -- the orchestrator. We accumulate dt and re-render only when the
    -- accumulator reaches ONE_SECOND.
    --
    -- BUG-FIX 2026-07-29: declared with a DOT, never a colon. The driver
    -- calls updateFn(owner, dt) with exactly two args (GlowDriver.lua:46),
    -- so a `function p:CountdownTick(_owner, dt)` shifts every parameter
    -- by the implicit self: dt lands in _owner and dt itself is nil. The
    -- accumulator then adds `(dt or 0)` == 0 forever, never reaches
    -- ONE_SECOND, and RenderRows is never called -- the popup freezes on
    -- the single open-time render from p:Refresh(). That is why 0-remaining
    -- rows never disappeared, the countdown never ticked, the 30s GC never
    -- ran and the /pull label never updated. Every other updateFn in this
    -- codebase is a plain two-parameter function -- see WTUI:BorderPulse.
    local accum = 0
    function p.CountdownTick(_owner, dt)
        accum = accum + (dt or 0)
        if accum < ONE_SECOND then return end
        accum = 0
        RenderRows()
    end

    -- W8 teardown: HookScript (NOT SetScript) so the OnHide handler
    -- chain from CreateChromeWindow stays intact -- our StopGlow
    -- stacks ON TOP of the primitive's OnHide.
    --
    -- 260730-osw / OSW-01: cancel the close timer when the window
    -- closes (so a future Show() doesn't inherit a stale handler),
    -- but deliberately leave pullResetTimer running -- the pull itself
    -- keeps going after the window closes, and that timer is what
    -- restores the `/pull` label for the next time the window opens.
    -- Explicitly NOT dispatching /pull 0 on close: closing the window
    -- must never cancel a live pull.
    p:HookScript("OnHide", function()
        if WTUI and WTUI.StopGlow then
            WTUI:StopGlow(p, "countdown")
        end
        SetPullStatus(p, nil)
        if pullCloseTimer and pullCloseTimer.Cancel then
            pullCloseTimer:Cancel()
        end
        pullCloseTimer = nil
    end)

    -- 260730-osw / OSW-03: parentless event frame following the
    -- UI/WindowRaiser.lua:7 precedent. NO PLAYER_REGEN_ENABLED handler
    -- -- the requirement is explicitly that the window does NOT come
    -- back by itself when combat ends.
    --
    -- Hide() in combat is legal for THIS window: it is built from
    -- namespace:CreateChromeWindow out of plain frames and textures,
    -- no SecureGroupHeaderTemplate descendant anywhere. The Summoning
    -- window's deferral machinery (SummonScreen.lua:650-684) exists
    -- only because that window hosts a secure-template descendant
    -- and its `Hide()` under lockdown throws ADDON_ACTION_BLOCKED.
    -- This window has no such restriction.
    --
    -- The one pre-existing path that CAN still re-open the window
    -- after combat is Core.lua:194-204 -- a READY_CHECK that arrived
    -- *during* combat sets pendingCooldownCheck and runs the check
    -- at combat end. That is a genuinely deferred NEW ready check,
    -- not a resurrection of the window this feature closed, and it
    -- is deliberately left alone.
    p.combatWatcher = CreateFrame("Frame")
    p.combatWatcher:RegisterEvent("PLAYER_REGEN_DISABLED")
    p.combatWatcher:SetScript("OnEvent", function(_, event)
        if event ~= "PLAYER_REGEN_DISABLED" then return end
        Private.safecall(function()
            if p and p.IsShown and p:IsShown() then
                p:Hide()
            end
            CancelPullTimers()
        end)
    end)

    return p
end