---@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, sends /pull <displayed value> in raid chat
-- guarded by C_ChatInfo.InChatMessagingLockdown. The "Cannot send
-- /pull in combat" message shows 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), the main
-- shell (Shell.lua:169), and the /wt chrometest consumer 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 FADE_DELAY = 0.5 -- entries with liveRemaining <= 0 stay visible briefly so the 0-tick reads as "0:00" not a hard cut
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

-- 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. Entries with live <= 0 are kept for
-- FADE_DELAY seconds (so a transient clock-drift or single-frame
-- underflow doesn't cause a flicker), then flagged stale and skipped.
-- GC runs FIRST so already-expired senders don't keep a row alive.
--
-- WR-02 fix (2026-07-26): stamp the FIRST tick on which `live <= 0`
-- was observed, and only after FADE_DELAY elapsed from THAT tick hide
-- the row. The stamp lives on `entry.fadeStartedAt[fadeKey]` so it
-- survives a fresh broadcast overwrite (which clears the whole entry).
local function CollectRows()
    GcStaleEntries()
    local map = Private.readycheckCooldownBySender
    if type(map) ~= "table" then return {} end
    local now = GetTime()
    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)
                local rowStale = false
                if live <= 0 then
                    local fadeKey = key .. ":" .. tostring(e.spellID)
                    entry.fadeStartedAt = entry.fadeStartedAt or {}
                    if entry.fadeStartedAt[fadeKey] == nil then
                        entry.fadeStartedAt[fadeKey] = now
                    end
                    local expiresAt = entry.fadeStartedAt[fadeKey] + FADE_DELAY
                    if now >= expiresAt then
                        rowStale = true
                    end
                end
                if not rowStale 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

-- "Cannot send /pull in combat" status message shown in the chrome
-- window's bottom status bar. Empty string = hidden.
local function SetPullBlocked(p, blocked)
    if blocked then
        p:SetStatusLeft("Cannot send /pull in combat")
    else
        p:SetStatusLeft("")
    end
end

-- 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.
    local 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

    -- 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)
                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

    p.offset:SetText("10")
    p.offset.currentValue = 10
    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. Single source of truth for both the live button
    -- label and the click-time send.
    local function ComputePullXX()
        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 = LiveRemaining(e)
                        if live > maxRemaining then maxRemaining = live end
                    end
                end
            end
        end
        local offset = p.offset and p.offset.currentValue
        if type(offset) ~= "number" then offset = 0 end
        local xx = math.ceil(maxRemaining + offset)
        if xx < 0 then xx = 0 end
        return xx
    end

    -- /pull XX button. OnClick:
    --   1. If C_ChatInfo.InChatMessagingLockdown: set status bar
    --      text and return (no send, button stays clickable).
    --   2. Send the CURRENT displayed value via C_ChatInfo.SendChatMessage.
    -- The button label is refreshed by RenderRows every tick to show the
    -- live max+offset, so the click handler reads the same value.
    p.pull = namespace:CreateButton(contentHost, "/pull 10", function()
        if C_ChatInfo and C_ChatInfo.InChatMessagingLockdown and C_ChatInfo.InChatMessagingLockdown() then
            SetPullBlocked(p, true)
            return
        end

        SetPullBlocked(p, false)

        -- Modern API only (Interface 120001/120000 always exposes
        -- C_ChatInfo.SendChatMessage). The legacy global fallback used
        -- by ReadyCheck.lua:140-144 is a Deprecated call (flagged by
        -- check.ps1 -Level Warning), and adding a second instance
        -- would grow the 46/16 baseline -- the modern API alone is
        -- sufficient on every supported client build.
        C_ChatInfo.SendChatMessage(("/pull %d"):format(ComputePullXX()), "RAID")
    end, { variant = "primary" })
    p.pull:SetPoint("BOTTOMRIGHT", contentHost, "BOTTOMRIGHT", -POPUP_PADDING, POPUP_FOOTER_BOTTOM_PAD)

    -- ============================================================
    -- 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

        -- 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.
    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.
    p:HookScript("OnHide", function()
        if WTUI and WTUI.StopGlow then
            WTUI:StopGlow(p, "countdown")
        end
        SetPullBlocked(p, false)
    end)

    return p
end