------------------------------------------------------------------------
-- HexCD: PartyCDDisplay — Party CD icon grid with independent trackers
--
-- Independent tracker bars, each movable:
--   1. PERSONAL           — Personal defensives (per-player, anchored to unit frames)
--   2. RANGED_DEFENSIVE   — Rally, VE (floating bar)
--   3. STACKED_DEFENSIVE  — AMZ, Darkness, Barrier (floating bar)
--   4. UTILITY            — Roar, Freedom, Grip (floating bar)
--   5. HEALING            — Tranq, Hymn, Convoke (floating bar)
--   6. ST_CC              — Poly, Hex, Repentance (floating bar)
--   7. AOE_CC             — Cap Totem, Ring of Frost (floating bar)
--
-- Personal defensives anchor to each player's unit frame.
-- Party-wide CD bars are floating panels showing who has what ready.
------------------------------------------------------------------------
HexCD = HexCD or {}
HexCD.PartyCDDisplay = {}

local PCD = HexCD.PartyCDDisplay
local Config = HexCD.Config
local Log = HexCD.DebugLog

------------------------------------------------------------------------
-- Constants
------------------------------------------------------------------------
local ICON_SIZE = 24
local ICON_PADDING = 2
local MAX_ICONS_PER_PLAYER = 6
local MAX_PLAYERS = 5
local UPDATE_INTERVAL = 0.25
local BAR_ROW_HEIGHT = 26
local BAR_NAME_WIDTH = 55

-- Categories that anchor per-player to unit frames
local ANCHORED_CATEGORIES = { PERSONAL = true }

-- Categories that get their own floating bar
local FLOATING_CATEGORIES = { "EXTERNAL_DEFENSIVE", "UTILITY", "HEALING", "OFFENSIVE", "CC" }
local FLOATING_TITLES = {
    EXTERNAL_DEFENSIVE = "|cFF44CC44External Defensives|r",
    UTILITY            = "|cFF88AACCUtility|r",
    HEALING            = "|cFF4488CCHealing CDs|r",
    OFFENSIVE          = "|cFFFF8844Offensive CDs|r",
    CC                 = "|cFFCC44CCCrowd Control|r",
}

------------------------------------------------------------------------
-- Unit Frame detection (Danders, Cell, ElvUI, Blizzard)
------------------------------------------------------------------------
local UF_PATTERNS = {
    { name = "Danders",      frames = "DandersFrames_Party%d",             unit = "unit", count = 4 },
    { name = "Danders-Player", frames = "DandersFrames_Player",            unit = "unit", count = 1, noIndex = true },
    { name = "Danders-Raid", frames = "DandersRaidFrame%d",                unit = "unit", count = 40 },
    { name = "Cell",         frames = "CellPartyFrameHeaderUnitButton%d",  unit = "unit", count = 5 },
    { name = "Cell-Raid",    frames = "CellRaidFrameHeader1UnitButton%d",  unit = "unit", count = 40 },
    { name = "ElvUI",        frames = "ElvUF_PartyGroup1UnitButton%d",     unit = "unit", count = 5 },
    { name = "Blizzard",     frames = "CompactPartyFrameMember%d",         unit = "unit", count = 5 },
    { name = "Blizz-Raid",   frames = "CompactRaidFrame%d",               unit = "unit", count = 40 },
}

------------------------------------------------------------------------
-- State
------------------------------------------------------------------------
local isVisible = false
local updateFrame = nil
local onUpdateThrottle = 0

-- Personal defensives: playerName → { container, icons[] }
local personalBars = {}

-- Floating bars: category → { frame, rows[], ... }
local floatingBars = {}

------------------------------------------------------------------------
-- Helpers
------------------------------------------------------------------------

local spellIconCache = {}
local function GetSpellIcon(spellID)
    if spellIconCache[spellID] then return spellIconCache[spellID] end
    local icon = nil
    pcall(function()
        if C_Spell and C_Spell.GetSpellInfo then
            local info = C_Spell.GetSpellInfo(spellID)
            if info and info.iconID then icon = info.iconID end
        end
    end)
    if not icon then
        pcall(function()
            if GetSpellTexture then icon = GetSpellTexture(spellID) end
        end)
    end
    if not icon then icon = "Interface\\Icons\\INV_Misc_QuestionMark" end
    spellIconCache[spellID] = icon
    return icon
end

local function FormatTime(sec)
    if sec <= 0 then return "" end
    if sec < 60 then return string.format("%d", sec) end
    return string.format("%d:%02d", sec / 60, sec % 60)
end

local function StripRealm(name)
    if not name then return name end
    return name:match("^([^-]+)") or name
end

local function IsSpellEnabled(spellID)
    if not Config then return true end
    local val = Config:Get("partyCDSpell_" .. spellID)
    if val == nil then return true end  -- default: enabled
    return val and true or false
end

------------------------------------------------------------------------
-- Icon creation
------------------------------------------------------------------------

local function CreateCDIcon(parent)
    local btn = CreateFrame("Frame", nil, parent)
    btn:SetSize(ICON_SIZE, ICON_SIZE)
    btn:Hide()

    local icon = btn:CreateTexture(nil, "ARTWORK")
    icon:SetAllPoints()
    icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
    btn.icon = icon

    local cd = CreateFrame("Cooldown", nil, btn, "CooldownFrameTemplate")
    cd:SetAllPoints()
    cd:SetDrawEdge(false)
    cd:SetSwipeColor(0, 0, 0, 0.65)
    cd:SetHideCountdownNumbers(true)
    btn.cooldown = cd

    local timeText = btn:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    timeText:SetPoint("CENTER", 0, 0)
    timeText:SetFont(timeText:GetFont(), 10, "OUTLINE")
    timeText:SetTextColor(1, 1, 1)
    btn.timeText = timeText

    local glow = btn:CreateTexture(nil, "OVERLAY")
    glow:SetPoint("TOPLEFT", -2, 2)
    glow:SetPoint("BOTTOMRIGHT", 2, -2)
    glow:SetColorTexture(1.0, 0.8, 0.0, 0.5)  -- bright yellow, test mode indicator
    glow:Hide()
    btn.glow = glow

    return btn
end

local function UpdateIcon(btn, spellID, state, now)
    btn.icon:SetTexture(GetSpellIcon(spellID))
    local remaining = math.max(0, state.readyTime - now)
    if remaining > 0 then
        btn.icon:SetDesaturated(true)
        btn.icon:SetAlpha(0.6)
        btn.glow:Hide()
        btn.timeText:SetText(FormatTime(remaining))
        local startTime = state.castTime or (now - state.effectiveCD + remaining)
        btn.cooldown:SetCooldown(startTime, state.effectiveCD)
    else
        btn.icon:SetDesaturated(false)
        btn.icon:SetAlpha(1.0)
        -- Show obvious glow only in test mode
        local CS = HexCD.CommSync
        if CS and CS:IsTestMode() then btn.glow:Show() else btn.glow:Hide() end
        btn.timeText:SetText("")
        btn.cooldown:Clear()
    end
    btn:Show()
end

------------------------------------------------------------------------
-- Find unit frame (Danders API first, then patterns)
------------------------------------------------------------------------

-- Extract unit ID from a frame (tries multiple access paths)
local function GetFrameUnit(f)
    local unit = nil
    pcall(function()
        unit = f.displayedUnit or f.unit
            or (f.GetAttribute and f:GetAttribute("unit"))
    end)
    return unit
end

-- Check if a frame's unit matches the target player name
local function FrameMatchesPlayer(f, playerName)
    local unit = GetFrameUnit(f)
    if not unit then return false end
    local ok, name = pcall(UnitName, unit)
    if ok and name and not (issecretvalue and issecretvalue(name)) then
        return StripRealm(name) == playerName
    end
    return false
end

--- Scan children of a container frame for one whose unit matches playerName.
local function ScanChildFrames(container, playerName)
    if not container or not container.GetChildren then return nil end
    local ok, children = pcall(container.GetChildren, container)
    if not ok or not children then return nil end
    -- GetChildren returns multiple values, not a table — wrap in table
    local list = { children }
    -- In some WoW versions GetChildren returns a table, in others varargs
    if type(list[1]) == "table" then list = list[1] end
    for _, child in ipairs(list) do
        if type(child) == "table" and FrameMatchesPlayer(child, playerName) then
            return child
        end
    end
    return nil
end

local function FindUnitFrame(playerName)
    local Log = HexCD.DebugLog

    -- 1. Danders API (addon unit frames)
    if DandersFrames_GetAllFrames then
        local ok, allFrames = pcall(DandersFrames_GetAllFrames)
        if ok and allFrames then
            for _, f in ipairs(allFrames) do
                if f and f.unit then
                    if FrameMatchesPlayer(f, playerName) then return f end
                end
            end
        end
    end

    -- 2. Named frame patterns (Cell, ElvUI, older Blizzard)
    for _, pattern in ipairs(UF_PATTERNS) do
        for i = 1, pattern.count do
            local frameName = pattern.noIndex and pattern.frames or string.format(pattern.frames, i)
            local f = _G[frameName]
            if f then
                if FrameMatchesPlayer(f, playerName) then return f end
            end
        end
    end

    -- 3. Blizzard EditMode containers (TWW/Midnight default frames)
    --    CompactPartyFrame contains member frames as children
    --    CompactRaidFrameContainer contains raid-style party frames
    local containers = {
        _G["CompactPartyFrame"],
        _G["CompactRaidFrameContainer"],
    }
    for _, container in ipairs(containers) do
        local match = ScanChildFrames(container, playerName)
        if match then return match end
    end

    -- 4. UnitID-based fallback: match player name → unitID, then scan frames by unitID
    local targetUnit = nil
    for i = 1, 4 do
        local uid = "party" .. i
        local ok, name = pcall(UnitName, uid)
        if ok and name and StripRealm(name) == playerName then
            targetUnit = uid
            break
        end
    end
    if not targetUnit then
        local ok, name = pcall(UnitName, "player")
        if ok and name and StripRealm(name) == playerName then
            targetUnit = "player"
        end
    end

    if targetUnit then
        for i = 1, 5 do
            local f = _G["CompactPartyFrameMember" .. i]
            if f and GetFrameUnit(f) == targetUnit then return f end
        end
        for i = 1, 40 do
            local f = _G["CompactRaidFrame" .. i]
            if f and GetFrameUnit(f) == targetUnit then return f end
        end
        if targetUnit == "player" and PlayerFrame and PlayerFrame:IsShown() then
            return PlayerFrame
        end
    end

    if Log then
        Log:Log("DEBUG", "FindUnitFrame: no frame found for " .. tostring(playerName))
    end
    return nil
end

--- Debug command: dump all detectable unit frames and their units.
--- Call via /hexcd debugframes
function PCD:DebugFrames()
    local Log = HexCD.DebugLog
    if not Log then return end
    Log:Log("INFO", "=== FindUnitFrame Debug Dump ===")

    -- Dump party unit IDs
    for i = 1, 4 do
        local uid = "party" .. i
        local ok, name = pcall(UnitName, uid)
        Log:Log("INFO", string.format("  %s → %s", uid, ok and tostring(name) or "nil"))
    end
    local ok, pname = pcall(UnitName, "player")
    Log:Log("INFO", string.format("  player → %s", ok and tostring(pname) or "nil"))

    -- Dump named patterns
    for _, pattern in ipairs(UF_PATTERNS) do
        for i = 1, math.min(pattern.count, 5) do
            local frameName = pattern.noIndex and pattern.frames or string.format(pattern.frames, i)
            local f = _G[frameName]
            if f then
                local unit = GetFrameUnit(f)
                local shown = f.IsShown and f:IsShown()
                Log:Log("INFO", string.format("  %s: unit=%s shown=%s", frameName, tostring(unit), tostring(shown)))
            end
        end
    end

    -- Dump Blizzard containers
    local containers = { "CompactPartyFrame", "CompactRaidFrameContainer" }
    for _, cname in ipairs(containers) do
        local c = _G[cname]
        if c then
            Log:Log("INFO", string.format("  %s: EXISTS, shown=%s", cname, tostring(c.IsShown and c:IsShown())))
            if c.GetChildren then
                local ok2, kids = pcall(c.GetChildren, c)
                if ok2 then
                    local list = type(kids) == "table" and kids or { kids }
                    for idx, kid in ipairs(list) do
                        if type(kid) == "table" then
                            local unit = GetFrameUnit(kid)
                            local kname = kid.GetName and kid:GetName() or "anon"
                            local shown = kid.IsShown and kid:IsShown()
                            Log:Log("INFO", string.format("    child[%d] %s: unit=%s shown=%s", idx, tostring(kname), tostring(unit), tostring(shown)))
                        end
                    end
                end
            end
        else
            Log:Log("INFO", string.format("  %s: NOT FOUND", cname))
        end
    end

    -- Test FindUnitFrame for each party member
    Log:Log("INFO", "--- FindUnitFrame results ---")
    local CS = HexCD.CommSync
    if CS then
        local partyCD = CS:GetPartyCD()
        for pName in pairs(partyCD) do
            if type(pName) == "string" and pName:sub(1,1) ~= "_" then
                local uf = FindUnitFrame(pName)
                local ufName = uf and (uf.GetName and uf:GetName() or "anon-frame") or "nil"
                Log:Log("INFO", string.format("  FindUnitFrame('%s') → %s", pName, ufName))
            end
        end
    end
    Log:Log("INFO", "=== End Debug Dump ===")
end

------------------------------------------------------------------------
-- PERSONAL DEFENSIVES: per-player, anchored to unit frames
------------------------------------------------------------------------

local function GetOrCreatePersonalBar(playerName)
    if personalBars[playerName] then return personalBars[playerName] end

    local container = CreateFrame("Frame", nil, UIParent)
    container:SetSize(MAX_ICONS_PER_PLAYER * (ICON_SIZE + ICON_PADDING), ICON_SIZE)
    container:SetFrameStrata("MEDIUM")
    container:SetFrameLevel(10)
    container:Hide()

    local icons = {}
    for i = 1, MAX_ICONS_PER_PLAYER do
        icons[i] = CreateCDIcon(container)
    end

    personalBars[playerName] = { container = container, icons = icons, unitFrame = nil }
    return personalBars[playerName]
end

local personalBarsDebugOnce = true
local function UpdatePersonalBars(partyCD, now)
    local used = {}
    -- Read from new hexcd_personal_* keys (fall back to old keys for compat)
    local side = Config and Config:Get("hexcd_personal_anchorSide") or Config:Get("partyCDAnchorSide") or "RIGHT"
    local ofsX = Config and Config:Get("hexcd_personal_ofsX") or Config:Get("partyCDOfsX") or 4
    local ofsY = Config and Config:Get("hexcd_personal_ofsY") or Config:Get("partyCDOfsY") or 0
    local growth = Config and Config:Get("hexcd_personal_growth") or Config:Get("partyCDGrowth") or "RIGHT"
    local iconSize = Config and Config:Get("hexcd_personal_iconSize") or Config:Get("partyCDIconSize") or ICON_SIZE
    local padding = Config and Config:Get("hexcd_personal_iconPadding") or Config:Get("partyCDIconPadding") or ICON_PADDING
    local maxPerRow = Config and Config:Get("hexcd_personal_maxIconsPerRow") or 6
    local maxRows = Config and Config:Get("hexcd_personal_maxRows") or 1
    local hideReady = Config and Config:Get("hexcd_personal_hideReady") or false
    local maxTotal = maxPerRow * maxRows

    local point, relPoint
    if side == "LEFT" then point, relPoint = "TOPRIGHT", "TOPLEFT"; ofsX = -ofsX
    elseif side == "TOP" then point, relPoint = "BOTTOMLEFT", "TOPLEFT"
    elseif side == "BOTTOM" then point, relPoint = "TOPLEFT", "BOTTOMLEFT"; ofsY = -ofsY
    else point, relPoint = "TOPLEFT", "TOPRIGHT"
    end

    for playerName, pData in pairs(partyCD) do
        if type(pData) == "table" then
            local spells = {}
            local pSpec = pData._specID
            for spellID, state in pairs(pData) do
                if type(spellID) == "number" and type(state) == "table" and IsSpellEnabled(spellID) then
                    local dbInfo = HexCD.SpellDB and HexCD.SpellDB:GetSpell(spellID)
                    -- Resolve category honouring per-spec override (e.g.
                    -- Avenging Wrath routes to HEALING on Holy Paladin).
                    local effCat = dbInfo and HexCD.SpellDB:GetCategory(spellID, pSpec)
                    if dbInfo and effCat == "PERSONAL" then
                        local remaining = math.max(0, state.readyTime - now)
                        -- hideReady: skip spells that are off cooldown
                        if not hideReady or remaining > 0 then
                            table.insert(spells, { id = spellID, state = state })
                        end
                    end
                end
            end

            -- One-time diagnostic
            if personalBarsDebugOnce then
                local totalKeys, enabledCount = 0, 0
                for k, v in pairs(pData) do
                    if type(k) == "number" then
                        totalKeys = totalKeys + 1
                        local en = IsSpellEnabled(k)
                        local dbInfo = HexCD.SpellDB and HexCD.SpellDB:GetSpell(k)
                        local cat = dbInfo and dbInfo.category or "nil"
                        if en and cat == "PERSONAL" then enabledCount = enabledCount + 1 end
                    end
                end
                local uf = FindUnitFrame(playerName)
                local ufName = uf and (uf.GetName and uf:GetName() or "frame") or "nil"
                Log:Log("DEBUG", string.format("PersonalBars: %s — %d keys, %d PERSONAL enabled, %d visible, frame=%s",
                    playerName, totalKeys, enabledCount, #spells, ufName))
            end

            if #spells > 0 then
                table.sort(spells, function(a, b)
                    local ra = math.max(0, a.state.readyTime - now)
                    local rb = math.max(0, b.state.readyTime - now)
                    if (ra > 0) ~= (rb > 0) then return ra > 0 end
                    return a.id < b.id
                end)

                local bar = GetOrCreatePersonalBar(playerName)
                used[playerName] = true

                local uf = FindUnitFrame(playerName)
                if uf then
                    bar.container:SetParent(UIParent)
                    bar.container:SetFrameStrata("MEDIUM")
                    bar.container:SetFrameLevel(10)
                    bar.container:ClearAllPoints()
                    bar.container:SetPoint(point, uf, relPoint, ofsX, ofsY)
                    bar.unitFrame = uf
                    bar.container:Show()

                    -- Multi-row layout
                    local iconIdx = 0
                    for _, spell in ipairs(spells) do
                        if iconIdx >= maxTotal then break end
                        iconIdx = iconIdx + 1
                        local btn = bar.icons[iconIdx]
                        btn:SetSize(iconSize, iconSize)
                        btn:ClearAllPoints()

                        local row = math.floor((iconIdx - 1) / maxPerRow)
                        local col = (iconIdx - 1) % maxPerRow

                        if growth == "LEFT" then
                            btn:SetPoint("TOPRIGHT", -(col * (iconSize + padding)), -(row * (iconSize + padding)))
                        elseif growth == "DOWN" then
                            btn:SetPoint("TOPLEFT", col * (iconSize + padding), -(row * (iconSize + padding)))
                        elseif growth == "UP" then
                            btn:SetPoint("BOTTOMLEFT", col * (iconSize + padding), row * (iconSize + padding))
                        else -- RIGHT
                            btn:SetPoint("TOPLEFT", col * (iconSize + padding), -(row * (iconSize + padding)))
                        end
                        UpdateIcon(btn, spell.id, spell.state, now)
                    end
                    for i = iconIdx + 1, MAX_ICONS_PER_PLAYER do bar.icons[i]:Hide() end

                    -- Size container to fit all rows/cols
                    local usedRows = math.ceil(iconIdx / maxPerRow)
                    local usedCols = math.min(iconIdx, maxPerRow)
                    bar.container:SetSize(
                        usedCols * (iconSize + padding),
                        usedRows * (iconSize + padding)
                    )
                else
                    bar.container:Hide()
                end
            end
        end
    end

    if personalBarsDebugOnce then personalBarsDebugOnce = false end

    for name, bar in pairs(personalBars) do
        if not used[name] then bar.container:Hide() end
    end
end

------------------------------------------------------------------------
-- FLOATING BARS: party-wide CDs (ranged, stacked, healing)
------------------------------------------------------------------------

local function CreateFloatingBar(category)
    local f = CreateFrame("Frame", "HexCDPartyCD_" .. category, UIParent, "BackdropTemplate")
    f:SetSize(200, 80)
    f:SetBackdrop({
        bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        edgeSize = 8,
        insets = { left = 2, right = 2, top = 2, bottom = 2 },
    })
    f:SetBackdropColor(0.05, 0.05, 0.08, 0.75)
    f:SetBackdropBorderColor(0.3, 0.3, 0.35, 0.6)
    f:SetFrameStrata("MEDIUM")

    local title = f:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    title:SetPoint("TOP", 0, -3)
    title:SetText(FLOATING_TITLES[category] or category)
    title:SetFont(title:GetFont(), 9, "OUTLINE")
    f.title = title

    -- Movable
    f:SetMovable(true)
    f:EnableMouse(true)
    f:RegisterForDrag("LeftButton")
    f:SetScript("OnDragStart", function(self) self:StartMoving() end)
    f:SetScript("OnDragStop", function(self)
        self:StopMovingOrSizing()
        local pt, _, relPt, x, y = self:GetPoint()
        if HexCDDB then
            HexCDDB["partyCD_" .. category .. "_pos"] = { point = pt, relPoint = relPt, x = x, y = y }
        end
    end)

    -- Restore position
    local saved = HexCDDB and HexCDDB["partyCD_" .. category .. "_pos"]
    if saved then
        f:ClearAllPoints()
        f:SetPoint(saved.point, UIParent, saved.relPoint, saved.x, saved.y)
    else
        local defaults = {
            EXTERNAL_DEFENSIVE = { "TOPRIGHT", -200, -100 },
            UTILITY            = { "TOPRIGHT", -200, -200 },
            HEALING            = { "TOPRIGHT", -200, -300 },
            OFFENSIVE          = { "TOPRIGHT", -200, -400 },
            CC                 = { "TOPRIGHT", -200, -500 },
        }
        local d = defaults[category] or { "CENTER", 0, 0 }
        f:SetPoint(d[1], UIParent, d[1], d[2], d[3])
    end

    -- Pre-allocate rows
    f.rows = {}
    for i = 1, MAX_PLAYERS do
        local row = CreateFrame("Frame", nil, f)
        row:SetSize(200, BAR_ROW_HEIGHT)
        row:SetPoint("TOPLEFT", f, "TOPLEFT", 4, -14 - (i - 1) * BAR_ROW_HEIGHT)
        row:Hide()

        row.nameText = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        row.nameText:SetPoint("LEFT", 0, 0)
        row.nameText:SetWidth(BAR_NAME_WIDTH)
        row.nameText:SetJustifyH("LEFT")
        row.nameText:SetWordWrap(false)

        row.icons = {}
        for j = 1, MAX_ICONS_PER_PLAYER do
            local icon = CreateCDIcon(row)
            icon:SetPoint("LEFT", BAR_NAME_WIDTH + (j - 1) * (ICON_SIZE + ICON_PADDING), 0)
            row.icons[j] = icon
        end
        f.rows[i] = row
    end

    f:Hide()
    f:EnableMouse(false)  -- start locked
    f._unlocked = false
    return f
end

local function GetFloatingBar(category)
    if not floatingBars[category] then
        floatingBars[category] = CreateFloatingBar(category)
    end
    return floatingBars[category]
end

local function UpdateFloatingBar(category, partyCD, now)
    local bar = GetFloatingBar(category)

    -- Collect all players who have spells in this category
    local players = {}
    for playerName, pData in pairs(partyCD) do
        if type(pData) == "table" then
            local spells = {}
            local pSpec = pData._specID
            for spellID, state in pairs(pData) do
                if type(spellID) == "number" and type(state) == "table" and IsSpellEnabled(spellID) then
                    local dbInfo = HexCD.SpellDB and HexCD.SpellDB:GetSpell(spellID)
                    -- Honour per-spec categoryOverride (e.g. Avenging Wrath
                    -- → OFFENSIVE for Ret/Prot, HEALING for Holy).
                    local effCat = dbInfo and HexCD.SpellDB:GetCategory(spellID, pSpec)
                    if dbInfo and effCat == category then
                        table.insert(spells, { id = spellID, state = state })
                    end
                end
            end
            if #spells > 0 then
                table.sort(spells, function(a, b) return a.id < b.id end)
                table.insert(players, { name = playerName, class = pData._class, spells = spells })
            end
        end
    end
    table.sort(players, function(a, b) return a.name < b.name end)

    if #players == 0 then
        -- Don't hide if user is positioning (unlocked mode)
        if not bar._unlocked then bar:Hide() end
        return
    end

    local rowIdx = 0
    for _, p in ipairs(players) do
        if rowIdx >= MAX_PLAYERS then break end
        rowIdx = rowIdx + 1
        local row = bar.rows[rowIdx]
        -- Fallback: resolve class from SpellDB if CommSync didn't stash one
        -- (can happen in legacy paths or when UnitClass returned a
        -- secretvalue during pre-pop). Use the first spell's class.
        local classToken = p.class
        if not classToken and p.spells[1] then
            local dbInfo = HexCD.SpellDB and HexCD.SpellDB:GetSpell(p.spells[1].id)
            classToken = dbInfo and dbInfo.class
        end
        local coloredName = (HexCD.Util and HexCD.Util.ColorNameByClass
            and HexCD.Util.ColorNameByClass(p.name, classToken)) or p.name
        row.nameText:SetText(coloredName)
        row:Show()

        local iconIdx = 0
        for _, spell in ipairs(p.spells) do
            if iconIdx >= MAX_ICONS_PER_PLAYER then break end
            iconIdx = iconIdx + 1
            UpdateIcon(row.icons[iconIdx], spell.id, spell.state, now)
        end
        for i = iconIdx + 1, MAX_ICONS_PER_PLAYER do row.icons[i]:Hide() end
    end
    for i = rowIdx + 1, MAX_PLAYERS do bar.rows[i]:Hide() end

    -- Resize
    bar:SetSize(
        8 + BAR_NAME_WIDTH + MAX_ICONS_PER_PLAYER * (ICON_SIZE + ICON_PADDING),
        18 + rowIdx * BAR_ROW_HEIGHT
    )
    -- Restore normal appearance (in case was in unlock/positioning mode)
    if bar._unlocked then
        bar:SetBackdropBorderColor(1.0, 0.8, 0.0, 0.9)
    else
        bar:SetBackdropBorderColor(0.3, 0.3, 0.35, 0.6)
    end
    if bar.title then bar.title:SetText(FLOATING_TITLES[category] or category) end
    bar:Show()
end

local function HideAllFloating()
    for _, bar in pairs(floatingBars) do bar:Hide() end
end

local function HideAllPersonal()
    for _, bar in pairs(personalBars) do bar.container:Hide() end
end

------------------------------------------------------------------------
-- Main update
------------------------------------------------------------------------

local pcdDebugOnce = true
local pcdGroupDebugOnce = true
local function UpdateDisplay()
    if not isVisible then return end
    -- Only show in group (not solo) unless test mode
    local CS = HexCD.CommSync
    if not CS then return end
    local Util = HexCD.Util
    local inGroup = Util and Util.IsInAnyGroup()
    local testing = CS.IsTestMode and CS:IsTestMode()
    if not inGroup and not testing then
        if pcdGroupDebugOnce then
            pcdGroupDebugOnce = false
            local Log = HexCD.DebugLog
            if Log then
                Log:Log("DEBUG", "PartyCDDisplay: NOT in group — hiding personal and floating bars")
            end
        end
        for _, bar in pairs(personalBars) do bar.container:Hide() end
        -- Hide floating bars too (unless user is positioning them)
        for _, bar in pairs(floatingBars) do
            if not bar._unlocked then bar:Hide() end
        end
        return
    end
    local partyCD = CS:GetPartyCD()
    local now = GetTime()

    -- One-time debug dump to diagnose personal CD visibility
    if pcdDebugOnce then
        pcdDebugOnce = false
        local Log = HexCD.DebugLog
        if Log then
            local count = 0
            for pName, pData in pairs(partyCD) do
                if type(pData) == "table" then
                    local spellCount = 0
                    for k, v in pairs(pData) do
                        if type(k) == "number" then spellCount = spellCount + 1 end
                    end
                    Log:Log("DEBUG", string.format("PartyCDDisplay: partyCD['%s'] has %d spells", pName, spellCount))
                    count = count + 1
                end
            end
            Log:Log("DEBUG", string.format("PartyCDDisplay: %d players in partyCD", count))
        end
    end

    UpdatePersonalBars(partyCD, now)
    for _, cat in ipairs(FLOATING_CATEGORIES) do
        UpdateFloatingBar(cat, partyCD, now)
    end
end

local function OnUpdate(self, elapsed)
    onUpdateThrottle = onUpdateThrottle + elapsed
    if onUpdateThrottle < UPDATE_INTERVAL then return end
    onUpdateThrottle = 0
    UpdateDisplay()
end

------------------------------------------------------------------------
-- Public API
------------------------------------------------------------------------

function PCD:Init()
    if updateFrame then return end
    updateFrame = CreateFrame("Frame", nil, UIParent)
    updateFrame:SetScript("OnUpdate", OnUpdate)
    isVisible = true
    updateFrame:Show()
    -- Pre-create all floating bar frames so lock/unlock works immediately
    for _, cat in ipairs(FLOATING_CATEGORIES) do
        GetFloatingBar(cat)
    end
    Log:Log("DEBUG", "PartyCDDisplay: initialized (always-on)")
end

function PCD:Show()
    if not updateFrame then self:Init() end
    isVisible = true
    updateFrame:Show()
    UpdateDisplay()
end

function PCD:Hide()
    isVisible = false
    if updateFrame then updateFrame:Hide() end
    HideAllPersonal()
    HideAllFloating()
end

function PCD:Toggle()
    if isVisible then self:Hide() else self:Show() end
end

function PCD:IsVisible()
    return isVisible
end

function PCD:ResetDebug()
    pcdDebugOnce = true
    pcdGroupDebugOnce = true
end

function PCD:ShowDetached()
    self:Show()
end

function PCD:Lock(category)
    if category then
        local bar = floatingBars[category]
        if bar then
            bar:EnableMouse(false)
            bar._unlocked = false
            -- Hide if no data is being displayed (no visible rows)
            local hasData = false
            if bar.rows then
                for _, row in ipairs(bar.rows) do
                    if row:IsShown() then hasData = true; break end
                end
            end
            if not hasData then bar:Hide() end
            if bar.title then bar.title:SetText(FLOATING_TITLES[category] or category) end
        end
    else
        for cat, bar in pairs(floatingBars) do self:Lock(cat) end
    end
end

function PCD:Unlock(category)
    if category then
        local bar = GetFloatingBar(category)  -- ensure bar exists
        bar:EnableMouse(true)
        bar._unlocked = true
        bar:Show()
        -- Show category label so user knows what they're positioning
        if bar.title then
            local label = FLOATING_TITLES[category] or category
            bar.title:SetText(label .. "  |cFFFFCC00DRAG TO MOVE|r")
        end
        -- Set a minimum size so the bar is visible even with no data
        if bar:GetHeight() < 30 then bar:SetHeight(30) end
        bar:SetBackdropBorderColor(1.0, 0.8, 0.0, 0.9)
    else
        for cat, bar in pairs(floatingBars) do self:Unlock(cat) end
    end
end

function PCD:IsUnlocked(category)
    if category then
        local bar = floatingBars[category]
        return bar and bar._unlocked or false
    end
    for _, bar in pairs(floatingBars) do
        if bar._unlocked then return true end
    end
    return false
end

function PCD:ToggleLock(category)
    if self:IsUnlocked(category) then self:Lock(category) else self:Unlock(category) end
end

function PCD:GetMode()
    return "attached"
end

function PCD:_testGetState()
    return {
        isVisible = isVisible,
        personalBars = personalBars,
        floatingBars = floatingBars,
    }
end
