local _, ns = ...

------------------------------------------------------------
-- Ready Check Window
------------------------------------------------------------

local POST_FINISH_HIDE_DELAY = 3
local WINDOW_WIDTH           = 680

local ROW_HEIGHT_FULL    = 26
local ROW_HEIGHT_COMPACT = 20
local HEADER_HEIGHT      = 38
local FOOTER_HEIGHT      = 44

local PAD                = 12
local STATUS_W           = 20
local NAME_W             = 150
local ICON_SIZE          = 22
local COL_W              = 46   -- column cell width (icon centered inside)

local COMPACT_COLS       = 4

------------------------------------------------------------
-- Check definitions — order drives the icon column order
------------------------------------------------------------
local function SpellIcon(spellID, fallback)
    local info = C_Spell and C_Spell.GetSpellInfo and C_Spell.GetSpellInfo(spellID)
    if info and info.iconID then return info.iconID end
    return fallback or 134400
end

-- Extensible spell-ID pools. Primary detection path for food/flask is
-- the pool; name-pattern fallback covers any that slip through. Add IDs
-- whenever a new consumable drops; localisation stays intact because
-- spellID lookup is locale-independent.
-- Current-patch food buff spell IDs (Midnight S1 tiered foods). The
-- name-pattern fallback ("Well Fed") still catches anything we miss.
local FOOD_SPELL_IDS = {
    382145, 382150, 382146, 382149, 382246,
    396092, 382247,
    382152, 382153, 382157, 382154, 382155, 382156,
    382230, 382231, 382232, 382234, 382235, 382236,
}

-- Current-patch Midnight S1 phial IDs.
local FLASK_SPELL_IDS = {
    1236763, 1239355, 1235057, 1239755, 1236767,
    1235111, 1235110, 1235108,
}

-- Evoker "Blessing of the Bronze" grants a class-specific sub-aura per
-- raider; match any of them as "movement speed raid buff present".
local BRONZE_SPELL_IDS = {
    381732, 381741, 381746, 381748, 381749, 381750,
    381751, 381752, 381753, 381754, 381756, 381757, 381758,
}

local function SpellIDSet(list)
    local set = {}
    for _, id in ipairs(list) do set[id] = true end
    return set
end
local foodSet   = SpellIDSet(FOOD_SPELL_IDS)
local flaskSet  = SpellIDSet(FLASK_SPELL_IDS)
local bronzeSet = SpellIDSet(BRONZE_SPELL_IDS)

-- Icon fallbacks. Flask/phial uses 134873 (a reliable alchemy bottle) —
-- the 1417734 FileDataID was showing as a blank green square on some clients.
-- Food iconID is overridden per-player (uses their Well Fed aura's actual
-- icon, or a generic food icon when still channel-eating).
-- Use texture paths instead of numeric FileDataIDs. Paths like
-- "Interface\\Icons\\<name>" are stable across patches; FileDataIDs
-- occasionally get renumbered or stop resolving (causing green squares).
local ICON_FOOD_GENERIC   = "Interface\\Icons\\INV_Misc_Food_145"
local ICON_FLASK_FALLBACK = "Interface\\Icons\\INV_Alchemy_EndlessFlask_01"
local ICON_DURABILITY     = "Interface\\Icons\\INV_Hammer_17"

local ICON_VANTUS = "Interface\\Icons\\INV_Inscription_80_VantusRune_Demons"

-- Column order and per-column detection. Left to right.
local CHECKS_META = {
    { key = "food",   header = "Food",   label = "Food",                              iconID = ICON_FOOD_GENERIC },
    { key = "flask",  header = "Flask",  label = "Flask / Phial",                     iconID = ICON_FLASK_FALLBACK },
    { key = "vantus", header = "Vantus", label = "Vantus Rune",                       iconID = ICON_VANTUS },
    { key = "int",    header = "Int",    label = "Intellect (Arcane Intellect)",      spellID = 1459  },
    { key = "ap",     header = "AP",     label = "Attack Power (Battle Shout)",       spellID = 6673  },
    { key = "vers",   header = "Vers",   label = "Versatility (Mark of the Wild)",    spellID = 1126  },
    { key = "stam",   header = "Stam",   label = "Stamina (Power Word: Fortitude)",   spellID = 21562 },
    { key = "haste",  header = "Haste",  label = "Haste / Crit (Skyfury)",            spellID = 462854 },
    { key = "move",   header = "Move",   label = "Movement (Blessing of the Bronze)", iconID = 4622448 },
    { key = "dur",    header = "Dur",    label = "Durability",                        iconID = ICON_DURABILITY },
}

local iconsResolved = false
local function ResolveCheckIcons()
    if iconsResolved then return end
    for _, c in ipairs(CHECKS_META) do
        if c.spellID then c.iconID = SpellIcon(c.spellID, c.iconID) end
    end
    iconsResolved = true
end

------------------------------------------------------------
-- SavedVariables
------------------------------------------------------------
local function DB()
    YippYappHelperDB = YippYappHelperDB or {}
    YippYappHelperDB.readyCheck = YippYappHelperDB.readyCheck or {
        enabled   = true,
        collapsed = false,
        -- TOPLEFT anchor is filled in lazily on first show, centered on
        -- screen. SetHeight only affects the bottom edge afterwards, so
        -- the header buttons stay put when collapsing/expanding.
        anchorLeft = nil, anchorTop = nil,
    }
    return YippYappHelperDB.readyCheck
end

------------------------------------------------------------
-- Buff detection
------------------------------------------------------------
local function UnitHasAuraBySpellID(unit, spellID)
    if not unit or not UnitExists(unit) then return false end
    local i = 1
    while true do
        local aura = C_UnitAuras.GetAuraDataByIndex(unit, i, "HELPFUL")
        if not aura then return false end
        if aura.spellId == spellID then return true end
        i = i + 1
    end
end

local function UnitHasAuraByName(unit, name)
    if not unit or not UnitExists(unit) then return false end
    return C_UnitAuras.GetAuraDataBySpellName(unit, name, "HELPFUL") ~= nil
end

-- Returns the matching aura table (or nil). Lets callers show details in tooltip.
local function FindAura(unit, predicate)
    if not unit or not UnitExists(unit) then return nil end
    local i = 1
    while true do
        local aura = C_UnitAuras.GetAuraDataByIndex(unit, i, "HELPFUL")
        if not aura then return nil end
        if predicate(aura) then return aura end
        i = i + 1
    end
end

-- Finds the "Well Fed" aura (post-eating buff). Returns the aura table so
-- callers can use its actual food-item icon instead of a generic one.
local function FindWellFed(unit)
    return FindAura(unit, function(a)
        if a.spellId and foodSet[a.spellId] then return true end
        return a.name == "Well Fed"
    end)
end

-- Returns true if the unit is currently channelling a food/drink aura.
-- The channel aura is literally named "Food" or "Drink" in English; most
-- food items cast one of a handful of well-known channel spells.
local EATING_AURA_NAMES = { Food = true, Drink = true, Refreshment = true }
local function IsEating(unit)
    return FindAura(unit, function(a)
        return a.name and EATING_AURA_NAMES[a.name]
    end) ~= nil
end

-- Food column state. Returns one of:
--   { state = "wellfed", aura = <auraTable> }  — show aura.icon, steady
--   { state = "eating",  aura = nil }          — show generic food icon, pulsing
--   { state = "missing" }                      — empty square
local function EvalFood(unit)
    local wf = FindWellFed(unit)
    if wf then return { state = "wellfed", aura = wf } end
    if IsEating(unit) then return { state = "eating" } end
    return { state = "missing" }
end

local function FindFlask(unit)
    return FindAura(unit, function(a)
        if a.spellId and flaskSet[a.spellId] then return true end
        if not a.name then return false end
        return a.name:find("^Phial of ") or a.name:find("^Flask of ")
    end)
end

local function FindVantus(unit)
    return FindAura(unit, function(a)
        return a.name and a.name:find("^Vantus Rune:")
    end)
end

local function FindAuraBySpellID(unit, spellID)
    return FindAura(unit, function(a) return a.spellId == spellID end)
end

-- Blessing of the Bronze grants a per-class spellID; any of them counts.
local function FindBronze(unit)
    return FindAura(unit, function(a) return a.spellId and bronzeSet[a.spellId] end)
end

------------------------------------------------------------
-- Durability
-- GetInventoryItemDurability only reports the player's own gear, so we
-- share each raider's % via a lightweight addon comm on ready-check.
------------------------------------------------------------
local DURABILITY_PREFIX = "YYHDUR"
C_ChatInfo.RegisterAddonMessagePrefix(DURABILITY_PREFIX)

local durabilityCache = {}  -- [shortName] = { pct = <0..100>, ts = GetTime() }
local DURABILITY_TTL = 600  -- 10 min

local function ComputeOwnDurability()
    local current, max = 0, 0
    for slot = 1, 18 do
        local cur, m = GetInventoryItemDurability(slot)
        if cur and m and m > 0 then
            current = current + cur
            max     = max + m
        end
    end
    if max == 0 then return nil end
    return math.floor(current / max * 100 + 0.5)
end

local function BroadcastOwnDurability()
    local pct = ComputeOwnDurability()
    if not pct then return end
    local shortName = Ambiguate(UnitName("player"), "short")
    durabilityCache[shortName] = { pct = pct, ts = GetTime() }
    if not IsInGroup() then return end
    local channel = IsInRaid() and "RAID" or "PARTY"
    -- Random 0-1.5s stagger so 30 raiders don't all flood CHAT_MSG_ADDON
    -- in the same tick (Blizzard throttles/drops bursts).
    C_Timer.After(math.random() * 1.5, function()
        C_ChatInfo.SendAddonMessage(DURABILITY_PREFIX, tostring(pct), channel)
    end)
end

local function GetDurabilityFor(unit)
    if UnitIsUnit(unit, "player") then
        return ComputeOwnDurability()
    end
    local raw = UnitName(unit)
    if not raw then return nil end
    -- Cache key matches the format used on receive (Ambiguate short).
    local short = Ambiguate(raw, "short")
    local entry = durabilityCache[short] or durabilityCache[raw]
    if entry and (GetTime() - entry.ts) < DURABILITY_TTL then
        return entry.pct
    end
    return nil
end


------------------------------------------------------------
-- Window shell
------------------------------------------------------------
local frame = CreateFrame("Frame", "YippYappReadyCheck", UIParent, "BackdropTemplate")
frame:SetSize(WINDOW_WIDTH, 260)
frame:SetScale(0.95)
frame:SetFrameStrata("FULLSCREEN_DIALOG")
frame:SetToplevel(true)
frame:SetClampedToScreen(true)
frame:SetMovable(true)
frame:EnableMouse(true)
frame:RegisterForDrag("LeftButton")
frame:SetScript("OnDragStart", frame.StartMoving)
frame:SetScript("OnDragStop", function(self)
    self:StopMovingOrSizing()
    local db = DB()
    -- Save the TOPLEFT anchor (relative to UIParent's TOPLEFT) so resizing
    -- only changes the bottom edge, never the top/header.
    local left = self:GetLeft()
    local top  = self:GetTop()
    local parentTop  = UIParent:GetTop()
    local parentLeft = UIParent:GetLeft() or 0
    db.anchorLeft = left - parentLeft
    db.anchorTop  = top - parentTop   -- negative: offset down from parent top
end)
frame:SetBackdrop({
    bgFile   = "Interface\\Buttons\\WHITE8x8",
    edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
    edgeSize = 12,
    insets   = { left = 3, right = 3, top = 3, bottom = 3 },
})
frame:SetBackdropColor(0.06, 0.06, 0.06, 0.72)
frame:SetBackdropBorderColor(0.3, 0.3, 0.3, 0.85)
frame:Hide()

-- Header
local title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
title:SetPoint("TOPLEFT", PAD, -10)
title:SetText("|cff00aaffReady Check|r")

local countdown = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
countdown:SetPoint("LEFT", title, "RIGHT", 10, 0)

local summary = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
summary:SetPoint("TOPRIGHT", -64, -14)
summary:SetJustifyH("RIGHT")

-- Custom-styled header buttons matching the rest of the addon (BackdropTemplate
-- + tooltip border, cyan hover). ASCII "+" / "−" / "×" render reliably in
-- FRIZQT__.TTF, unlike the Unicode ▲/▼ triangles.
local function MakeHeaderButton(parent, glyph, size, onClick)
    local b = CreateFrame("Button", nil, parent, "BackdropTemplate")
    b:SetSize(size, size)
    b:SetBackdrop({
        bgFile   = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Buttons\\WHITE8x8",
        edgeSize = 1,
        insets   = { left = 0, right = 0, top = 0, bottom = 0 },
    })
    b:SetBackdropColor(0.10, 0.10, 0.10, 0.9)
    b:SetBackdropBorderColor(0.35, 0.35, 0.35, 1)

    local fs = b:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    fs:SetPoint("CENTER", 0, 0)
    fs:SetText(glyph)
    fs:SetTextColor(0.85, 0.85, 0.85)
    b._label = fs

    b:SetScript("OnEnter", function(self)
        self:SetBackdropBorderColor(0.0, 0.8, 1.0, 1)
        self._label:SetTextColor(1, 1, 1)
    end)
    b:SetScript("OnLeave", function(self)
        self:SetBackdropBorderColor(0.35, 0.35, 0.35, 1)
        self._label:SetTextColor(0.85, 0.85, 0.85)
    end)
    b:SetScript("OnClick", onClick)
    return b
end

-- Collapse / expand toggle
local collapseBtn = MakeHeaderButton(frame, "−", 20, nil)
collapseBtn:SetPoint("TOPRIGHT", -34, -10)

local function UpdateCollapseTexture()
    collapseBtn._label:SetText(DB().collapsed and "+" or "−")
end
UpdateCollapseTexture()

-- Close button (custom, replacing Blizzard UIPanelCloseButton).
-- Anchored a couple extra pixels in from the right so its 1px border
-- doesn't get clipped by the frame's own edge.
local closeBtn = MakeHeaderButton(frame, "×", 20, function() frame:Hide() end)
closeBtn:SetPoint("TOPRIGHT", -12, -10)

-- Column header (shown in full view only)
local headerRow = CreateFrame("Frame", nil, frame)
headerRow:SetHeight(20)
headerRow:SetPoint("TOPLEFT",  PAD, -HEADER_HEIGHT)
headerRow:SetPoint("TOPRIGHT", -PAD, -HEADER_HEIGHT)

local headerLabels = {}
for i, meta in ipairs(CHECKS_META) do
    local fs = headerRow:CreateFontString(nil, "ARTWORK", "GameFontNormalSmall")
    fs:SetText(meta.header)
    fs:SetTextColor(0.75, 0.75, 0.75)
    fs:SetJustifyH("CENTER")
    headerLabels[i] = fs
end

-- Vertical column dividers (drawn behind the roster rows).
local columnDividerFrame = CreateFrame("Frame", nil, frame)
columnDividerFrame:SetPoint("TOPLEFT",  frame, "TOPLEFT",  0, -(HEADER_HEIGHT + 22))
columnDividerFrame:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", 0, FOOTER_HEIGHT)
columnDividerFrame:SetFrameLevel(frame:GetFrameLevel())

local columnDividers = {}
for i = 1, #CHECKS_META + 1 do
    local line = columnDividerFrame:CreateTexture(nil, "ARTWORK")
    line:SetWidth(1)
    line:SetColorTexture(0.22, 0.22, 0.22, 0.9)
    columnDividers[i] = line
end

-- Horizontal line closing the grid below the last row.
local bottomDivider = frame:CreateTexture(nil, "ARTWORK")
bottomDivider:SetHeight(1)
bottomDivider:SetColorTexture(0.25, 0.25, 0.25, 0.6)
bottomDivider:Hide()

local divider = frame:CreateTexture(nil, "ARTWORK")
divider:SetHeight(1)
divider:SetPoint("TOPLEFT",  8, -(HEADER_HEIGHT + 22))
divider:SetPoint("TOPRIGHT", -8, -(HEADER_HEIGHT + 22))
divider:SetColorTexture(0.25, 0.25, 0.25, 0.6)

-- Footer: Ready / Not Ready buttons
-- Black button with muted colored text + matching thin border. Matches
-- the addon's other call-to-action buttons (Raid Tools, Release Blocker, etc).
local function MakeFooterButton(label, textColor, accent, onClick)
    local b = CreateFrame("Button", nil, frame, "BackdropTemplate")
    b:SetSize(130, 26)
    b:SetBackdrop({
        bgFile   = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        edgeSize = 8,
        insets   = { left = 2, right = 2, top = 2, bottom = 2 },
    })
    b:SetBackdropColor(0.08, 0.08, 0.08, 0.95)
    b:SetBackdropBorderColor(accent[1], accent[2], accent[3], 0.55)

    local fs = b:CreateFontString(nil, "OVERLAY")
    fs:SetFont("Fonts\\FRIZQT__.TTF", 13, "OUTLINE")
    fs:SetPoint("CENTER")
    fs:SetText(label)
    fs:SetTextColor(textColor[1], textColor[2], textColor[3])
    b._label = fs

    b:SetScript("OnEnter", function(self)
        self:SetBackdropBorderColor(accent[1], accent[2], accent[3], 1)
        self:SetBackdropColor(0.14, 0.14, 0.14, 0.98)
    end)
    b:SetScript("OnLeave", function(self)
        self:SetBackdropBorderColor(accent[1], accent[2], accent[3], 0.55)
        self:SetBackdropColor(0.08, 0.08, 0.08, 0.95)
    end)
    b:SetScript("OnClick", onClick)
    return b
end

-- Whether the Ready / Not Ready buttons are currently visible. Hidden once
-- the local player has responded so the footer collapses out of the way.
local footerVisible = true

-- Forward declaration; Render is defined further below.
local Render

local function HideBlizzardPopup()
    -- Try every variant Blizzard has shipped.
    if StaticPopup_Hide then StaticPopup_Hide("READY_CHECK") end
    if ReadyCheckFrame and ReadyCheckFrame:IsShown() then ReadyCheckFrame:Hide() end
    if ReadyCheckListenerFrame and ReadyCheckListenerFrame.frame
       and ReadyCheckListenerFrame.frame:IsShown() then
        ReadyCheckListenerFrame.frame:Hide()
    end
end

local function SetFooterVisible(v)
    footerVisible = v and true or false
end

local function Respond(ready)
    -- Send the answer FIRST, before we touch any UI state. A previous issue
    -- saw users press Ready but no answer went through — likely because
    -- hiding the Blizzard popup or re-rendering interfered with the click
    -- path. Keep this step minimal and do it before anything else.
    if ConfirmReadyCheck then ConfirmReadyCheck(ready) end

    -- Optimistically mirror our own answer in the local state map so our
    -- row immediately shows the result without waiting for the round-trip
    -- READY_CHECK_CONFIRM event. Even if the event is delayed or debounced,
    -- the UI doesn't look stuck on "pending".
    local myName = UnitName("player")
    if myName then
        readyStatus[Ambiguate(myName, "short")] = ready and "ready" or "notready"
    end

    HideBlizzardPopup()
    SetFooterVisible(false)
    if frame:IsShown() and Render then Render() end
end

-- Muted green / muted red — not bright, match the addon palette.
local READY_TEXT_COLOR    = { 0.42, 0.82, 0.45 }
local NOTREADY_TEXT_COLOR = { 0.88, 0.45, 0.45 }
local READY_ACCENT        = { 0.25, 0.55, 0.30 }
local NOTREADY_ACCENT     = { 0.60, 0.25, 0.25 }

local readyBtn    = MakeFooterButton("Ready",     READY_TEXT_COLOR,    READY_ACCENT,    function() Respond(true)  end)
local notReadyBtn = MakeFooterButton("Not Ready", NOTREADY_TEXT_COLOR, NOTREADY_ACCENT, function() Respond(false) end)
-- Center the two buttons together with a small gap.
readyBtn:SetPoint("BOTTOM", frame, "BOTTOM", -75, 10)
notReadyBtn:SetPoint("BOTTOM", frame, "BOTTOM", 75, 10)

------------------------------------------------------------
-- Status icon helper
-- Blizzard's standard ready-check textures: show no border/bg.
------------------------------------------------------------
local READY_TEX    = "Interface\\RaidFrame\\ReadyCheck-Ready"
local NOTREADY_TEX = "Interface\\RaidFrame\\ReadyCheck-NotReady"
local WAITING_TEX  = "Interface\\RaidFrame\\ReadyCheck-Waiting"

-- Authoritative ready-state map fed from event arguments. Keyed by name so
-- it survives unitID churn across READY_CHECK_CONFIRM ticks. Reset on each
-- fresh READY_CHECK. Values: "ready" | "notready" | nil (pending).
local readyStatus = {}

local function GetReadyFor(unit)
    local name = UnitName(unit)
    return name and readyStatus[name] or nil
end

local function ApplyStatusTexture(texture, unit)
    local status = GetReadyCheckStatus(unit)
    if status == "ready" then
        texture:SetTexture(READY_TEX); return true
    elseif status == "notready" then
        texture:SetTexture(NOTREADY_TEX); return false
    else
        texture:SetTexture(WAITING_TEX); return nil
    end
end

------------------------------------------------------------
-- Full-view row pool (status + name + icon strip)
------------------------------------------------------------
local fullPool, activeFull = {}, {}

local function MakeFullRow()
    local row = CreateFrame("Frame", nil, frame)
    row:SetHeight(ROW_HEIGHT_FULL)

    row.status = row:CreateTexture(nil, "ARTWORK")
    row.status:SetSize(STATUS_W, STATUS_W)
    row.status:SetPoint("LEFT", PAD, 0)

    row.name = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    row.name:SetPoint("LEFT", row.status, "RIGHT", 8, 0)
    row.name:SetWidth(NAME_W)
    row.name:SetJustifyH("LEFT")
    row.name:SetWordWrap(false)

    row.icons = {}
    for i = 1, #CHECKS_META do
        local btn = CreateFrame("Frame", nil, row, "BackdropTemplate")
        btn:SetSize(ICON_SIZE, ICON_SIZE)
        btn:EnableMouse(true)

        -- Empty-slot look: dark fill + subtle border, always present.
        btn:SetBackdrop({
            bgFile   = "Interface\\Buttons\\WHITE8x8",
            edgeFile = "Interface\\Buttons\\WHITE8x8",
            edgeSize = 1,
            insets   = { left = 0, right = 0, top = 0, bottom = 0 },
        })
        btn:SetBackdropColor(0.10, 0.10, 0.10, 0.9)
        btn:SetBackdropBorderColor(0.32, 0.32, 0.32, 1)

        local tex = btn:CreateTexture(nil, "ARTWORK")
        tex:SetPoint("TOPLEFT", 1, -1)
        tex:SetPoint("BOTTOMRIGHT", -1, 1)
        tex:SetTexCoord(0.08, 0.92, 0.08, 0.92)
        btn.tex = tex

        -- Pulse animation for "actively eating" state
        btn.pulse = btn:CreateAnimationGroup()
        btn.pulse:SetLooping("REPEAT")
        local p1 = btn.pulse:CreateAnimation("Alpha")
        p1:SetFromAlpha(1); p1:SetToAlpha(0.35); p1:SetDuration(0.6); p1:SetOrder(1)
        local p2 = btn.pulse:CreateAnimation("Alpha")
        p2:SetFromAlpha(0.35); p2:SetToAlpha(1); p2:SetDuration(0.6); p2:SetOrder(2)

        -- Text overlay for numeric values (durability %)
        local textFs = btn:CreateFontString(nil, "OVERLAY")
        textFs:SetFont("Fonts\\FRIZQT__.TTF", 10, "OUTLINE")
        textFs:SetPoint("CENTER", 0, 0)
        textFs:Hide()
        btn.textFs = textFs

        btn:SetScript("OnEnter", function(self)
            GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
            -- Tooltip must sit above our FULLSCREEN_DIALOG frame.
            GameTooltip:SetFrameStrata("TOOLTIP")
            GameTooltip:AddLine(CHECKS_META[i].label, 1, 1, 1)
            if self._present == true then
                GameTooltip:AddLine("|cff00ff00present|r")
                if self._aura and self._aura.name then
                    GameTooltip:AddLine(self._aura.name, 0.8, 0.8, 0.8)
                    local exp = self._aura.expirationTime or 0
                    if exp > 0 then
                        local remaining = math.max(0, math.floor(exp - GetTime()))
                        if remaining > 60 then
                            GameTooltip:AddLine(
                                string.format("|cffaaaaaa%dm %ds remaining|r",
                                    math.floor(remaining / 60), remaining % 60))
                        elseif remaining > 0 then
                            GameTooltip:AddLine(string.format("|cffaaaaaa%ds remaining|r", remaining))
                        end
                    end
                end
            elseif self._present == false then
                GameTooltip:AddLine("|cffff4040missing|r")
            else
                GameTooltip:AddLine("|cff888888n/a|r (out of range or not applicable)")
            end
            GameTooltip:Show()
        end)
        btn:SetScript("OnLeave", function() GameTooltip:Hide() end)
        -- Make sure the icon frame sits above the row (for reliable mouse hits)
        btn:SetFrameLevel(row:GetFrameLevel() + 2)

        row.icons[i] = btn
    end
    return row
end

local function AcquireFullRow()
    local row = table.remove(fullPool) or MakeFullRow()
    table.insert(activeFull, row)
    row:Show()
    return row
end

local function ReleaseFullRows()
    for _, r in ipairs(activeFull) do r:Hide() end
    for _, r in ipairs(activeFull) do table.insert(fullPool, r) end
    wipe(activeFull)
end

-- opts: { present=bool|nil, iconID, aura, pulse=bool, text=string|nil, textColor={r,g,b} }
local function ApplyIconState(iconFrame, opts)
    local present = opts.present
    iconFrame._present = present
    iconFrame._aura    = opts.aura
    iconFrame.textFs:Hide()
    iconFrame.pulse:Stop()

    if opts.text then
        -- Text-only mode (durability %): no icon, no backdrop/border.
        iconFrame.tex:Hide()
        iconFrame.textFs:SetText(opts.text)
        local c = opts.textColor or { 1, 1, 1 }
        iconFrame.textFs:SetTextColor(c[1], c[2], c[3])
        iconFrame.textFs:Show()
        iconFrame:SetBackdropColor(0, 0, 0, 0)
        iconFrame:SetBackdropBorderColor(0, 0, 0, 0)
        iconFrame:SetAlpha(1)
        return
    end
    -- Restore normal backdrop for icon-mode buttons (in case this frame was
    -- previously used in text mode on an earlier render).
    iconFrame:SetBackdropColor(0.10, 0.10, 0.10, 0.9)

    if present == true then
        iconFrame.tex:SetTexture(opts.iconID)
        iconFrame.tex:SetDesaturated(false)
        iconFrame.tex:SetVertexColor(1, 1, 1, 1)
        iconFrame.tex:Show()
        iconFrame:SetBackdropBorderColor(0.32, 0.32, 0.32, 1)
        iconFrame:SetAlpha(1)
        if opts.pulse then iconFrame.pulse:Play() end
    elseif present == false then
        iconFrame.tex:Hide()
        iconFrame:SetBackdropBorderColor(0.45, 0.45, 0.45, 1)
        iconFrame:SetAlpha(1)
    else
        iconFrame.tex:SetTexture(opts.iconID)
        iconFrame.tex:SetDesaturated(true)
        iconFrame.tex:SetVertexColor(0.5, 0.5, 0.5, 0.4)
        iconFrame.tex:Show()
        iconFrame:SetBackdropBorderColor(0.2, 0.2, 0.2, 0.6)
        iconFrame:SetAlpha(1)
    end
end

------------------------------------------------------------
-- Compact-view row pool (grid cells with just status + name)
------------------------------------------------------------
local compactPool, activeCompact = {}, {}

local function MakeCompactRow()
    local row = CreateFrame("Frame", nil, frame)
    row:SetHeight(ROW_HEIGHT_COMPACT)
    row.cells = {}
    for i = 1, COMPACT_COLS do
        local cell = CreateFrame("Frame", nil, row)
        cell:SetHeight(ROW_HEIGHT_COMPACT)
        cell.status = cell:CreateTexture(nil, "ARTWORK")
        cell.status:SetSize(STATUS_W - 4, STATUS_W - 4)
        cell.status:SetPoint("LEFT", 4, 0)
        cell.name = cell:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        cell.name:SetPoint("LEFT", cell.status, "RIGHT", 4, 0)
        cell.name:SetJustifyH("LEFT")
        cell.name:SetWordWrap(false)
        row.cells[i] = cell
    end
    return row
end

local function AcquireCompactRow()
    local row = table.remove(compactPool) or MakeCompactRow()
    table.insert(activeCompact, row)
    row:Show()
    -- Reset all cells (hidden until used)
    for _, cell in ipairs(row.cells) do
        cell:Hide()
        cell.status:SetTexture(nil)
        cell.name:SetText("")
    end
    return row
end

local function ReleaseCompactRows()
    for _, r in ipairs(activeCompact) do r:Hide() end
    for _, r in ipairs(activeCompact) do table.insert(compactPool, r) end
    wipe(activeCompact)
end

------------------------------------------------------------
-- Layout helpers
------------------------------------------------------------
------------------------------------------------------------
-- Test roster (for /yyhrc preview)
-- 24 fake raiders across classes with varied ready / buff / durability
-- states so every visual state in the window is exercised.
------------------------------------------------------------
local TEST_ROSTER = {
    { name = "Bloodfang",   class = "WARRIOR",     ready = "ready",    food = "wellfed", flask = true,  vantus = true,  int = true,  ap = true,  vers = true,  stam = true,  haste = true,  move = true,  dur = 100 },
    { name = "Shadowblade", class = "ROGUE",       ready = "ready",    food = "wellfed", flask = true,  vantus = true,  int = true,  ap = true,  vers = true,  stam = true,  haste = true,  move = true,  dur = 95 },
    { name = "Lightshield", class = "PALADIN",     ready = "ready",    food = "wellfed", flask = true,  vantus = true,  int = true,  ap = true,  vers = true,  stam = true,  haste = true,  move = true,  dur = 82 },
    { name = "Frostwhisper", class = "MAGE",       ready = "ready",    food = "eating",  flask = true,  vantus = true,  int = true,  ap = true,  vers = true,  stam = true,  haste = true,  move = true,  dur = 88 },
    { name = "Nightbloom",  class = "DRUID",       ready = "ready",    food = "wellfed", flask = true,  vantus = true,  int = true,  ap = true,  vers = true,  stam = true,  haste = true,  move = true,  dur = 76 },
    { name = "Stormcaller", class = "SHAMAN",      ready = "ready",    food = "wellfed", flask = true,  vantus = true,  int = true,  ap = true,  vers = true,  stam = true,  haste = true,  move = true,  dur = 91 },
    { name = "Voidspeaker", class = "WARLOCK",     ready = "notready", food = "wellfed", flask = true,  vantus = true,  int = true,  ap = true,  vers = true,  stam = true,  haste = true,  move = true,  dur = 67 },
    { name = "Holyflame",   class = "PRIEST",      ready = "ready",    food = "wellfed", flask = true,  vantus = true,  int = true,  ap = true,  vers = true,  stam = true,  haste = true,  move = true,  dur = 100 },
    { name = "Shadowhunt",  class = "HUNTER",      ready = "ready",    food = "missing", flask = true,  vantus = true,  int = true,  ap = true,  vers = true,  stam = true,  haste = true,  move = true,  dur = 85 },
    { name = "Frostgrip",   class = "DEATHKNIGHT", ready = "ready",    food = "wellfed", flask = false, vantus = false, int = true,  ap = true,  vers = true,  stam = true,  haste = true,  move = true,  dur = 58 },
    { name = "Chiblast",    class = "MONK",        ready = "pending",  food = "eating",  flask = true,  vantus = true,  int = true,  ap = true,  vers = true,  stam = true,  haste = true,  move = true,  dur = 72 },
    { name = "Felreaver",   class = "DEMONHUNTER", ready = "ready",    food = "wellfed", flask = true,  vantus = true,  int = true,  ap = true,  vers = true,  stam = true,  haste = true,  move = true,  dur = 89 },
    { name = "Bronzewing",  class = "EVOKER",      ready = "ready",    food = "wellfed", flask = true,  vantus = true,  int = true,  ap = true,  vers = true,  stam = true,  haste = true,  move = true,  dur = 93 },
    { name = "Axewield",    class = "WARRIOR",     ready = "ready",    food = "wellfed", flask = true,  vantus = true,  int = true,  ap = true,  vers = true,  stam = true,  haste = true,  move = false, dur = 100 },
    { name = "Daggerfall",  class = "ROGUE",       ready = "pending",  food = "missing", flask = false, vantus = false, int = true,  ap = true,  vers = false, stam = true,  haste = true,  move = true,  dur = 34 },
    { name = "Sunforge",    class = "PALADIN",     ready = "ready",    food = "wellfed", flask = true,  vantus = true,  int = true,  ap = true,  vers = true,  stam = true,  haste = true,  move = true,  dur = 88 },
    { name = "Arcaneweave", class = "MAGE",        ready = "ready",    food = "wellfed", flask = true,  vantus = true,  int = true,  ap = true,  vers = true,  stam = true,  haste = false, move = true,  dur = 81 },
    { name = "Wildroot",    class = "DRUID",       ready = "ready",    food = "wellfed", flask = true,  vantus = true,  int = true,  ap = true,  vers = true,  stam = true,  haste = true,  move = true,  dur = 96 },
    { name = "Thunderfist", class = "SHAMAN",      ready = "notready", food = "missing", flask = false, vantus = false, int = true,  ap = true,  vers = true,  stam = false, haste = true,  move = true,  dur = 45 },
    { name = "Soulrend",    class = "WARLOCK",     ready = "ready",    food = "wellfed", flask = true,  vantus = true,  int = true,  ap = true,  vers = true,  stam = true,  haste = true,  move = true,  dur = 73 },
    { name = "Mindweave",   class = "PRIEST",      ready = "ready",    food = "wellfed", flask = true,  vantus = true,  int = true,  ap = true,  vers = true,  stam = true,  haste = true,  move = true,  dur = 100 },
    { name = "Trueshot",    class = "HUNTER",      ready = "ready",    food = "wellfed", flask = true,  vantus = true,  int = true,  ap = true,  vers = true,  stam = true,  haste = true,  move = true,  dur = 84 },
    { name = "Deathchill",  class = "DEATHKNIGHT", ready = "pending",  food = "eating",  flask = true,  vantus = true,  int = true,  ap = true,  vers = true,  stam = true,  haste = true,  move = true,  dur = 62 },
    { name = "Stormglide",  class = "EVOKER",      ready = "ready",    food = "wellfed", flask = true,  vantus = true,  int = true,  ap = false, vers = true,  stam = true,  haste = true,  move = true,  dur = 97 },
}

local usingTestRoster = false

-- Single-pass scan of a unit's HELPFUL auras. Previously each GatherFromUnit
-- call did 8+ independent FindAura scans (flask, vantus, int, ap, vers, stam,
-- haste, move, wellfed, eating) which at 30 raiders = ~3,600 aura index
-- lookups per render. This batches them into one iteration of the aura list.
local EATING_NAMES = EATING_AURA_NAMES
local function ScanUnitAuras(unit)
    local out = {
        flask = nil, vantus = nil,
        int = nil, ap = nil, vers = nil, stam = nil, haste = nil, move = nil,
        wellfed = nil, eating = false,
    }
    if not unit or not UnitExists(unit) then return out end
    local i = 1
    while true do
        local a = C_UnitAuras.GetAuraDataByIndex(unit, i, "HELPFUL")
        if not a then break end
        local sid = a.spellId
        local nm  = a.name

        if sid == 1459 then out.int = a
        elseif sid == 6673 then out.ap = a
        elseif sid == 1126 then out.vers = a
        elseif sid == 21562 then out.stam = a
        elseif sid == 462854 then out.haste = a
        end

        if sid and flaskSet[sid] then
            out.flask = out.flask or a
        elseif nm and not out.flask and (nm:find("^Phial of ") or nm:find("^Flask of ")) then
            out.flask = a
        end

        if sid and bronzeSet[sid] then out.move = a end

        if nm and not out.vantus and nm:find("^Vantus Rune:") then
            out.vantus = a
        end

        if not out.wellfed then
            -- Midnight "hearty" foods use buff names other than the literal
            -- "Well Fed" (e.g. "Hearty Well Fed", "Hearty Feast"). Match the
            -- spellID whitelist first, then any name starting with "Well Fed"
            -- or containing "Hearty".
            if sid and foodSet[sid] then
                out.wellfed = a
            elseif nm and (nm == "Well Fed"
                    or nm:find("^Well Fed")
                    or nm:find("^Hearty")) then
                out.wellfed = a
            end
        end

        if not out.eating and nm and EATING_NAMES[nm] then out.eating = true end

        i = i + 1
    end
    return out
end

local function GatherFromUnit(unit)
    if not UnitExists(unit) then return nil end
    local _, class = UnitClass(unit)
    local name = UnitName(unit) or "?"
    local ready = readyStatus[name]
    if not ready then
        local blizz = GetReadyCheckStatus(unit)
        if blizz == "ready" or blizz == "notready" then ready = blizz end
    end

    local a = ScanUnitAuras(unit)
    local foodState
    if a.wellfed then foodState = { state = "wellfed", aura = a.wellfed }
    elseif a.eating then foodState = { state = "eating" }
    else foodState = { state = "missing" } end

    return {
        unit  = unit,
        name  = name,
        class = class,
        ready = ready,
        food  = foodState,
        aura  = {
            flask  = a.flask,
            vantus = a.vantus,
            int    = a.int,
            ap     = a.ap,
            vers   = a.vers,
            stam   = a.stam,
            haste  = a.haste,
            move   = a.move,
        },
        dur = GetDurabilityFor(unit),
        test = false,
    }
end

-- Rotating sample icons for the preview so test rows visually vary.
local SAMPLE_FOOD_ICONS = {
    "Interface\\Icons\\INV_Misc_Food_145",
    "Interface\\Icons\\INV_Misc_Food_162_fish",
    "Interface\\Icons\\INV_Misc_Food_11",
    "Interface\\Icons\\INV_Misc_Food_74",
    "Interface\\Icons\\INV_Misc_Food_76",
}
local SAMPLE_FLASK_ICONS = {
    "Interface\\Icons\\INV_Alchemy_EndlessFlask_01",
    "Interface\\Icons\\INV_Alchemy_EndlessFlask_04",
    "Interface\\Icons\\INV_Potion_62",
    "Interface\\Icons\\INV_Potion_79",
    "Interface\\Icons\\INV_Alchemy_Flask_02",
}

local function pickSample(list, i)
    return list[((i - 1) % #list) + 1]
end

local function GatherFromTest(entry, idx)
    local foodState
    if entry.food == "wellfed" then
        foodState = { state = "wellfed", aura = { icon = pickSample(SAMPLE_FOOD_ICONS, idx) } }
    elseif entry.food == "eating" then
        foodState = { state = "eating" }
    else
        foodState = { state = "missing" }
    end
    return {
        name  = entry.name,
        class = entry.class,
        ready = entry.ready == "pending" and nil or entry.ready,
        food  = foodState,
        aura  = {
            flask  = entry.flask  and { icon = pickSample(SAMPLE_FLASK_ICONS, idx) } or nil,
            vantus = entry.vantus and {} or nil,
            int    = entry.int    and {} or nil,
            ap     = entry.ap     and {} or nil,
            vers   = entry.vers   and {} or nil,
            stam   = entry.stam   and {} or nil,
            haste  = entry.haste  and {} or nil,
            move   = entry.move   and {} or nil,
        },
        dur  = entry.dur,
        test = true,
    }
end

-- Forward declaration so GatherRoster can reference it (body defined below).
local IterateRaidUnits

local function GatherRoster()
    if usingTestRoster then
        local list = {}
        for i, e in ipairs(TEST_ROSTER) do list[#list+1] = GatherFromTest(e, i) end
        return list
    end
    local list = {}
    for _, u in ipairs(IterateRaidUnits()) do
        local d = GatherFromUnit(u)
        if d then list[#list+1] = d end
    end
    return list
end

IterateRaidUnits = function()
    local list = {}
    if IsInRaid() then
        for i = 1, GetNumGroupMembers() do list[#list+1] = "raid" .. i end
    elseif IsInGroup() then
        list[#list+1] = "player"
        for i = 1, GetNumGroupMembers() - 1 do list[#list+1] = "party" .. i end
    else
        list[#list+1] = "player"
    end
    return list
end

-- Icon X = center of the column (to center the icon inside the column cell).
local function ComputeIconXPositions()
    local xs = {}
    local colStart = PAD + STATUS_W + 8 + NAME_W + 10
    for i = 1, #CHECKS_META do
        local cellCenter = colStart + (i - 1) * COL_W + COL_W / 2
        xs[i] = cellCenter - ICON_SIZE / 2
    end
    return xs
end

-- Where each column boundary (left edge of column) sits in frame-relative x.
local function ComputeColumnBoundaries()
    local xs = {}
    local colStart = PAD + STATUS_W + 8 + NAME_W + 10
    for i = 0, #CHECKS_META do
        xs[i + 1] = colStart + i * COL_W
    end
    return xs
end

local ICON_XS = nil  -- resolved on first render

local function ApplyHeaderRow(show)
    if show then
        headerRow:Show()
        divider:Show()
        local boundaries = ComputeColumnBoundaries()
        for i, fs in ipairs(headerLabels) do
            local cellCenter = boundaries[i] + COL_W / 2
            fs:ClearAllPoints()
            fs:SetPoint("CENTER", headerRow, "LEFT", cellCenter - PAD, 0)
            fs:Show()
        end
    else
        headerRow:Hide()
        divider:Hide()
        columnDividerFrame:Hide()
        for _, line in ipairs(columnDividers) do line:Hide() end
        bottomDivider:Hide()
    end
end

-- Called after rows are laid out so we know the bottom y of the grid.
-- Positions vertical column dividers and the closing horizontal line so
-- they meet exactly at the last crossing, not extending past it.
local function PositionGridLines(gridBottomY)
    local boundaries = ComputeColumnBoundaries()
    local gridTop    = -(HEADER_HEIGHT + 22)  -- under the header divider
    local gridLeft   = boundaries[1]
    local gridRight  = boundaries[#boundaries]

    columnDividerFrame:Show()

    -- Verticals: run from grid top to the gridBottomY only (no overshoot).
    for i, line in ipairs(columnDividers) do
        line:ClearAllPoints()
        local x = boundaries[i]
        if not x then line:Hide()
        else
            line:SetPoint("TOPLEFT",    frame, "TOPLEFT", x, gridTop)
            line:SetPoint("BOTTOMLEFT", frame, "TOPLEFT", x, gridBottomY)
            line:Show()
        end
    end

    -- Closing horizontal line: from leftmost vertical to rightmost vertical.
    bottomDivider:ClearAllPoints()
    bottomDivider:SetPoint("TOPLEFT",  frame, "TOPLEFT", gridLeft,  gridBottomY)
    bottomDivider:SetPoint("TOPRIGHT", frame, "TOPLEFT", gridRight, gridBottomY)
    bottomDivider:Show()
end

------------------------------------------------------------
-- Build: FULL view
------------------------------------------------------------
local function BuildFullView()
    ResolveCheckIcons()
    ICON_XS = ComputeIconXPositions()
    ReleaseCompactRows()
    ReleaseFullRows()
    ApplyHeaderRow(true)

    local y = -(HEADER_HEIGHT + 26)
    local readyCount, totalCount, missingCount = 0, 0, 0

    for _, p in ipairs(GatherRoster()) do
        totalCount = totalCount + 1
        local row = AcquireFullRow()
        row:ClearAllPoints()
        row:SetPoint("TOPLEFT",  frame, "TOPLEFT",  0, y)
        row:SetPoint("TOPRIGHT", frame, "TOPRIGHT", 0, y)

        -- Status icon
        if p.ready == "ready" then
            row.status:SetTexture(READY_TEX);    readyCount = readyCount + 1
        elseif p.ready == "notready" then
            row.status:SetTexture(NOTREADY_TEX)
        else
            row.status:SetTexture(WAITING_TEX)
        end

        local col = p.class and RAID_CLASS_COLORS[p.class]
        row.name:SetText(col and col:WrapTextInColorCode(p.name) or p.name)

        local rowMissing = false
        for i, c in ipairs(CHECKS_META) do
            local btn = row.icons[i]
            btn:ClearAllPoints()
            btn:SetPoint("LEFT", row, "LEFT", ICON_XS[i], 0)
            btn:Show()

            if c.key == "food" then
                local fs = p.food
                if fs.state == "wellfed" then
                    ApplyIconState(btn, {
                        present = true,
                        iconID  = (fs.aura and fs.aura.icon) or c.iconID,
                        aura    = fs.aura,
                    })
                elseif fs.state == "eating" then
                    ApplyIconState(btn, { present = true, iconID = c.iconID, pulse = true })
                else
                    ApplyIconState(btn, { present = false, iconID = c.iconID })
                    rowMissing = true
                end
            elseif c.key == "dur" then
                if p.dur then
                    local r, g, b = 0.3, 1, 0.3
                    if p.dur < 70 then r, g, b = 1, 0.8, 0.2 end
                    if p.dur < 40 then r, g, b = 1, 0.3, 0.3 end
                    ApplyIconState(btn, { text = p.dur .. "%", textColor = { r, g, b } })
                    if p.dur < 40 then rowMissing = true end
                else
                    ApplyIconState(btn, { present = nil, iconID = c.iconID })
                end
            else
                local a = p.aura[c.key]
                local present = a ~= nil
                -- For flask, prefer the actual aura's icon (each phial has its
                -- own icon texture). For fixed raid buffs (Int/AP/Stam/etc.)
                -- the spell-based iconID is stable and preferred for uniformity.
                local iconID = c.iconID
                if present and c.key == "flask" and a.icon then
                    iconID = a.icon
                end
                ApplyIconState(btn, { present = present, iconID = iconID, aura = a })
                if not present then rowMissing = true end
            end
        end
        if rowMissing then missingCount = missingCount + 1 end

        y = y - ROW_HEIGHT_FULL
    end

    summary:SetText(string.format(
        "|cffffffff%d|r/%d ready  |cff888888·|r  |cffff6060%d|r missing",
        readyCount, totalCount, missingCount))

    PositionGridLines(y)

    frame:SetHeight(-y + (footerVisible and FOOTER_HEIGHT or 8) + 6)
end

------------------------------------------------------------
-- Build: COMPACT view
------------------------------------------------------------
local function BuildCompactView()
    ReleaseFullRows()
    ReleaseCompactRows()
    ApplyHeaderRow(false)

    local roster = GatherRoster()
    local totalCount, readyCount = #roster, 0
    local colW = math.floor((WINDOW_WIDTH - 2 * PAD) / COMPACT_COLS)
    local y = -(HEADER_HEIGHT + 6)

    local currentRow
    for idx, p in ipairs(roster) do
        local col = (idx - 1) % COMPACT_COLS
        if col == 0 then
            currentRow = AcquireCompactRow()
            currentRow:ClearAllPoints()
            currentRow:SetPoint("TOPLEFT",  frame, "TOPLEFT",  PAD, y)
            currentRow:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -PAD, y)
            y = y - ROW_HEIGHT_COMPACT
        end

        local cell = currentRow.cells[col + 1]
        cell:Show()
        cell:SetWidth(colW)
        cell:ClearAllPoints()
        cell:SetPoint("LEFT", currentRow, "LEFT", col * colW, 0)
        cell.name:SetWidth(colW - STATUS_W - 12)

        if p.ready == "ready" then
            cell.status:SetTexture(READY_TEX); readyCount = readyCount + 1
        elseif p.ready == "notready" then
            cell.status:SetTexture(NOTREADY_TEX)
        else
            cell.status:SetTexture(WAITING_TEX)
        end

        local col2 = p.class and RAID_CLASS_COLORS[p.class]
        cell.name:SetText(col2 and col2:WrapTextInColorCode(p.name) or p.name)
    end

    summary:SetText(string.format("|cffffffff%d|r/%d ready", readyCount, totalCount))
    bottomDivider:Hide()
    frame:SetHeight(-y + (footerVisible and FOOTER_HEIGHT or 8) + 10)
end

------------------------------------------------------------
-- Render dispatch
------------------------------------------------------------
Render = function()
    UpdateCollapseTexture()
    -- Toggle footer button visibility per state.
    if footerVisible then
        readyBtn:Show()
        notReadyBtn:Show()
    else
        readyBtn:Hide()
        notReadyBtn:Hide()
    end
    if DB().collapsed then
        BuildCompactView()
    else
        BuildFullView()
    end
end

collapseBtn:SetScript("OnClick", function()
    DB().collapsed = not DB().collapsed
    Render()
end)

------------------------------------------------------------
-- Countdown
------------------------------------------------------------
local endTime = 0
local ticker
local function StartCountdown(duration)
    endTime = GetTime() + (duration or 30)
    if ticker then ticker:Cancel() end
    local function tick()
        local remaining = math.max(0, math.floor(endTime - GetTime() + 0.5))
        countdown:SetText(string.format("|cffcccc00%ds|r", remaining))
        if remaining <= 0 and ticker then ticker:Cancel(); ticker = nil end
    end
    tick()
    ticker = C_Timer.NewTicker(0.25, tick)
end

local function StopCountdown()
    if ticker then ticker:Cancel(); ticker = nil end
    countdown:SetText("")
end

------------------------------------------------------------
-- Fade-out animation on finish
------------------------------------------------------------
local fadeGroup = frame:CreateAnimationGroup()
local fade = fadeGroup:CreateAnimation("Alpha")
fade:SetFromAlpha(1)
fade:SetToAlpha(0)
fade:SetDuration(1.2)
fadeGroup:SetScript("OnFinished", function()
    frame:Hide()
    frame:SetAlpha(1)  -- reset for next show
end)

local function ShowFrameInstant()
    fadeGroup:Stop()
    frame:SetAlpha(1)
    frame:Show()
end

------------------------------------------------------------
-- Events
------------------------------------------------------------
-- UNIT_AURA is high-frequency; register only while a ready check is active
-- or the frame is visible, and unregister again afterward.
local events = CreateFrame("Frame")
events:RegisterEvent("READY_CHECK")
events:RegisterEvent("READY_CHECK_CONFIRM")
events:RegisterEvent("READY_CHECK_FINISHED")
events:RegisterEvent("CHAT_MSG_ADDON")

local function EnableAuraTracking(on)
    if on then events:RegisterEvent("UNIT_AURA")
    else events:UnregisterEvent("UNIT_AURA") end
end

local refreshThrottle
local function QueueRefresh(delay)
    if refreshThrottle then return end
    refreshThrottle = C_Timer.NewTimer(delay or 0.1, function()
        refreshThrottle = nil
        if frame:IsShown() then Render() end
    end)
end

local function ShowFrameAtSavedPos()
    local db = DB()

    -- First-ever show: center the (expanded) window horizontally and place
    -- it a bit above vertical center, then cache the TOPLEFT corner.
    if not db.anchorLeft or not db.anchorTop then
        local scale = UIParent:GetEffectiveScale()
        local screenW = UIParent:GetWidth()
        local screenH = UIParent:GetHeight()
        -- Assume ~600px height for an expanded 24-player view (actual height
        -- is recomputed by Render). Off-center calculation just needs to be
        -- close enough; the user can drag to fine-tune.
        local approxH = 600
        db.anchorLeft = math.floor((screenW - WINDOW_WIDTH) / 2)
        db.anchorTop  = -math.floor((screenH - approxH) / 2)
    end

    frame:ClearAllPoints()
    frame:SetPoint("TOPLEFT", UIParent, "TOPLEFT", db.anchorLeft, db.anchorTop)
    ShowFrameInstant()
end

-- Only refresh on UNIT_AURA events for actual group members, to avoid
-- a burst of no-op refreshes from unrelated units (pets, target, etc).
local function UnitIsInGroup(unit)
    if not unit then return false end
    if unit == "player" then return true end
    if unit:match("^raid%d+$") then return true end
    if unit:match("^party%d+$") then return true end
    return false
end

events:SetScript("OnEvent", function(_, event, arg1, arg2, arg3, arg4)
    if event == "CHAT_MSG_ADDON" then
        if arg1 ~= DURABILITY_PREFIX then return end
        local pct = tonumber(arg2)
        if not pct then return end
        local sender = arg4 and Ambiguate(arg4, "short") or nil
        if not sender then return end
        durabilityCache[sender] = { pct = pct, ts = GetTime() }
        -- 30 durability messages arrive in a burst during the staggered
        -- broadcast — coalesce into one render at the end of the window.
        if frame:IsShown() then QueueRefresh(0.6) end
        return
    end

    if not DB().enabled then return end
    if event == "READY_CHECK" then
        usingTestRoster = false
        footerVisible = true
        EnableAuraTracking(true)
        -- Reset our event-captured status map. arg1 is the initiator's
        -- name — they're implicitly "ready" since they started the check.
        wipe(readyStatus)
        if arg1 then readyStatus[Ambiguate(arg1, "short")] = "ready" end
        BroadcastOwnDurability()
        ShowFrameAtSavedPos()
        Render()
        StartCountdown(arg2 or 30)
        HideBlizzardPopup()
    elseif event == "READY_CHECK_CONFIRM" then
        -- arg1 = unit, arg2 = ready (boolean). Record it in our map so
        -- we don't depend on GetReadyCheckStatus (which can be stale
        -- for a tick after the event fires).
        local name = arg1 and UnitName(arg1)
        if name then
            readyStatus[name] = arg2 and "ready" or "notready"
        end
        -- Was calling Render() directly — at 30 players that's 30 full
        -- renders in the first second. Throttle to coalesce bursts.
        if frame:IsShown() then QueueRefresh(0.15) end
        HideBlizzardPopup()
        -- Early-finish: count unique responders directly from readyStatus
        -- (cheap — no per-confirm GetRaidRosterInfo storm).
        local num = GetNumGroupMembers()
        if num > 0 then
            local answered = 0
            for _ in pairs(readyStatus) do answered = answered + 1 end
            if answered >= num then
                StopCountdown()
                C_Timer.After(POST_FINISH_HIDE_DELAY, function()
                    if frame:IsShown() then fadeGroup:Play() end
                    EnableAuraTracking(false)
                end)
            end
        end
    elseif event == "READY_CHECK_FINISHED" then
        StopCountdown()
        if frame:IsShown() then Render() end
        C_Timer.After(POST_FINISH_HIDE_DELAY, function()
            if frame:IsShown() then fadeGroup:Play() end
            EnableAuraTracking(false)
        end)
    elseif event == "UNIT_AURA" then
        -- 0.5s debounce — aura ticks in a 30-man raid can fire dozens of
        -- times per second, and rendering only every 100ms still burns cpu.
        if frame:IsShown() and UnitIsInGroup(arg1) then QueueRefresh(0.5) end
    end
end)

------------------------------------------------------------
-- Public API + slash command
------------------------------------------------------------
local function Preview(withTestRoster)
    DB().enabled = true
    usingTestRoster = withTestRoster and true or false
    footerVisible = true
    if not usingTestRoster then BroadcastOwnDurability() end
    ShowFrameAtSavedPos()
    Render()
    StartCountdown(30)
end

-- Expose spell-ID pools so other files / users can extend at runtime
-- if a new patch adds consumables we haven't catalogued.
local function addIDsTo(set, list, listRef)
    for _, id in ipairs(list) do
        if not set[id] then set[id] = true; table.insert(listRef, id) end
    end
end

ns.ReadyCheck = {
    IsEnabled  = function() return DB().enabled end,
    SetEnabled = function(v) DB().enabled = v and true or false end,
    Preview    = Preview,
    Hide       = function() frame:Hide() end,
    AddFoodSpellIDs   = function(list) addIDsTo(foodSet,  list, FOOD_SPELL_IDS)  end,
    AddFlaskSpellIDs  = function(list) addIDsTo(flaskSet, list, FLASK_SPELL_IDS) end,
    AddBronzeSpellIDs = function(list) addIDsTo(bronzeSet, list, BRONZE_SPELL_IDS) end,
}

SLASH_YYHREADYCHECK1 = "/yyhrc"
SlashCmdList.YYHREADYCHECK = function(msg)
    if msg == "hide" then
        frame:Hide()
    elseif msg == "live" then
        Preview(false)  -- real current group
    else
        Preview(true)   -- 24 test characters (default)
    end
end
