local _, ns = ...

------------------------------------------------------------
-- Class colors
------------------------------------------------------------
local CLASS_COLORS = {
    WARRIOR     = { 0.78, 0.61, 0.43 },
    PALADIN     = { 0.96, 0.55, 0.73 },
    HUNTER      = { 0.67, 0.83, 0.45 },
    ROGUE       = { 1.00, 0.96, 0.41 },
    PRIEST      = { 1.00, 1.00, 1.00 },
    DEATHKNIGHT = { 0.77, 0.12, 0.23 },
    SHAMAN      = { 0.00, 0.44, 0.87 },
    MAGE        = { 0.25, 0.78, 0.92 },
    WARLOCK     = { 0.53, 0.53, 0.93 },
    MONK        = { 0.00, 1.00, 0.60 },
    DRUID       = { 1.00, 0.49, 0.04 },
    DEMONHUNTER = { 0.64, 0.19, 0.79 },
    EVOKER      = { 0.20, 0.58, 0.50 },
}

local TIER_COUNT_COLORS = {
    [0] = { 1.0, 0.3, 0.3 },
    [1] = { 1.0, 0.6, 0.3 },
    [2] = { 1.0, 1.0, 0.3 },
    [3] = { 0.7, 1.0, 0.3 },
    [4] = { 0.3, 1.0, 0.3 },
    [5] = { 0.3, 1.0, 0.8 },
}

-- Raid buff definitions. spellID drives the icon lookup via
-- C_Spell.GetSpellInfo; fallbackIcon is a stable texture path used when
-- the spell-info call returns nil (can happen early in session or if
-- Blizzard renumbers a spellID mid-patch).
local RAID_BUFFS = {
    -- Stat buffs
    { file = "MAGE",        buff = "Arcane Intellect",       desc = "5% Intellect",
      spellID = 1459,   fallbackIcon = "Interface\\Icons\\Spell_Holy_MagicalSentry" },
    { file = "WARRIOR",     buff = "Battle Shout",           desc = "5% Attack Power",
      spellID = 6673,   fallbackIcon = "Interface\\Icons\\Ability_Warrior_BattleShout" },
    { file = "PRIEST",      buff = "Power Word: Fortitude",  desc = "5% Stamina",
      spellID = 21562,  fallbackIcon = "Interface\\Icons\\Spell_Holy_WordFortitude" },
    { file = "DRUID",       buff = "Mark of the Wild",       desc = "3% Versatility",
      spellID = 1126,   fallbackIcon = "Interface\\Icons\\Spell_Nature_Regeneration" },
    { file = "SHAMAN",      buff = "Skyfury",                desc = "Crit + Haste",
      spellID = 462854, fallbackIcon = "Interface\\Icons\\Spell_Nature_WindFury" },
    -- Utility / movement
    { file = "EVOKER",      buff = "Blessing of the Bronze", desc = "Movement speed",
      spellID = 364342, fallbackIcon = 4622448 },
    -- Debuffs applied to the boss
    { file = "MONK",        buff = "Mystic Touch",           desc = "5% Phys Dmg Taken",
      spellID = 113746, fallbackIcon = "Interface\\Icons\\Ability_Monk_MysticTouch" },
    { file = "DEMONHUNTER", buff = "Chaos Brand",            desc = "5% Magic Dmg Taken",
      spellID = 1490,   fallbackIcon = "Interface\\Icons\\Ability_Warlock_Eradication" },
}

-- Icon widget cache for the Raid Buffs strip. Created lazily on first
-- overview refresh, reused on subsequent refreshes.
local buffIconFrames = {}
local function EnsureBuffIconFrame(idx, parent, size)
    local f = buffIconFrames[idx]
    if f then return f end
    f = CreateFrame("Frame", nil, parent, "BackdropTemplate")
    f:SetSize(size, size)
    f:SetBackdrop({
        bgFile   = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Buttons\\WHITE8x8",
        edgeSize = 1,
        insets   = { left = 0, right = 0, top = 0, bottom = 0 },
    })
    f:SetBackdropColor(0.08, 0.08, 0.08, 0.9)
    f.tex = f:CreateTexture(nil, "ARTWORK")
    f.tex:SetPoint("TOPLEFT", 1, -1)
    f.tex:SetPoint("BOTTOMRIGHT", -1, 1)
    f.tex:SetTexCoord(0.08, 0.92, 0.08, 0.92)
    f:EnableMouse(true)
    f:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
        GameTooltip:SetFrameStrata("TOOLTIP")
        GameTooltip:AddLine(self._buffName or "", 1, 1, 1)
        if self._desc then GameTooltip:AddLine(self._desc, 0.75, 0.75, 0.75, true) end
        if self._present then
            GameTooltip:AddLine("|cff00ff00Present|r")
        else
            GameTooltip:AddLine("|cffff4444Missing|r")
        end
        GameTooltip:Show()
    end)
    f:SetScript("OnLeave", function() GameTooltip:Hide() end)
    buffIconFrames[idx] = f
    return f
end

-- Role prefixes
local ROLE_PREFIX = { TANK = "|cff8888ffT|r", HEALER = "|cff00ff00H|r", DAMAGER = "|cffff4444D|r" }

------------------------------------------------------------
-- Raid Inspect Window
------------------------------------------------------------
local RAID_WIDTH = 400
local RAID_HEIGHT = 500
local ROW_HEIGHT = 20

local raidFrame = CreateFrame("Frame", "YippYappRaidFrame", UIParent, "BackdropTemplate")
raidFrame:SetSize(RAID_WIDTH, RAID_HEIGHT)
raidFrame:SetPoint("CENTER", 200, 0)
raidFrame:SetMovable(true)
raidFrame:EnableMouse(true)
raidFrame:RegisterForDrag("LeftButton")
raidFrame:SetScript("OnDragStart", raidFrame.StartMoving)
raidFrame:SetScript("OnDragStop", raidFrame.StopMovingOrSizing)
raidFrame:SetClampedToScreen(true)
raidFrame:SetFrameStrata("HIGH")
raidFrame:SetBackdrop({
    bgFile = "Interface\\Buttons\\WHITE8x8",
    edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
    edgeSize = 16,
    insets = { left = 4, right = 4, top = 4, bottom = 4 },
})
raidFrame:SetBackdropColor(0.08, 0.08, 0.08, 0.95)
raidFrame:SetBackdropBorderColor(0.6, 0.6, 0.6, 1)
ns.SmoothFrame(raidFrame)
raidFrame:Hide()
ns.RaidFrame = raidFrame

-- ESC to close
raidFrame:SetScript("OnShow", function()
    if raidFrame.inAppMode then return end
    tinsert(UISpecialFrames, "YippYappRaidFrame")
end)
raidFrame:SetScript("OnHide", function()
    for i = #UISpecialFrames, 1, -1 do
        if UISpecialFrames[i] == "YippYappRaidFrame" then
            table.remove(UISpecialFrames, i)
            break
        end
    end
end)

-- Close button
local close = CreateFrame("Button", nil, raidFrame, "UIPanelCloseButton")
close:SetPoint("TOPRIGHT", -2, -2)

-- Release Blocker toggle (persistent, top-right of header)
local rbBtn = CreateFrame("Button", nil, raidFrame, "BackdropTemplate")
rbBtn:SetSize(130, 22)
rbBtn:SetPoint("TOPRIGHT", raidFrame, "TOPRIGHT", -32, -18)
rbBtn:SetBackdrop({
    bgFile   = "Interface\\Buttons\\WHITE8x8",
    edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
    edgeSize = 8,
    insets   = { left = 2, right = 2, top = 2, bottom = 2 },
})
ns.SmoothFrame(rbBtn)

local rbBtnFs = rbBtn:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
rbBtnFs:SetPoint("CENTER")

local function UpdateReleaseBlockerBtn()
    local enabled = ns:IsReleaseBlockerEnabled()
    if enabled then
        rbBtn:SetBackdropColor(0.0, 0.2, 0.0, 0.8)
        rbBtn:SetBackdropBorderColor(0.0, 0.5, 0.0, 0.6)
        rbBtnFs:SetText("|cff00ff00Release Block: ON|r")
    else
        rbBtn:SetBackdropColor(0.15, 0.05, 0.05, 0.8)
        rbBtn:SetBackdropBorderColor(0.4, 0.15, 0.15, 0.6)
        rbBtnFs:SetText("|cffff4444Release Block: OFF|r")
    end
end

rbBtn:SetScript("OnClick", function()
    local newState = ns:ToggleReleaseBlocker()
    ns:BroadcastReleaseBlockerState(newState)
    UpdateReleaseBlockerBtn()
    -- Keep the Settings-panel checkbox in sync with this toggle.
    if ns.NotifySettingChanged then ns.NotifySettingChanged("YYH_RELEASE_BLOCKER") end
end)
rbBtn:SetScript("OnEnter", function(self)
    self:SetBackdropBorderColor(0.6, 0.6, 0.6, 0.8)
    GameTooltip:SetOwner(self, "ANCHOR_BOTTOM")
    GameTooltip:AddLine("Release Spirit Blocker", 1, 1, 1)
    GameTooltip:AddLine("Hides the Release button when you die in a guild raid.", 0.7, 0.7, 0.7, true)
    GameTooltip:AddLine("Hold CTRL for 1s to override.", 0.7, 0.7, 0.7)
    if IsInRaid() then
        local myIdx = UnitInRaid("player")
        if myIdx then
            local _, rank = GetRaidRosterInfo(myIdx + 1)
            if rank and rank >= 1 then
                GameTooltip:AddLine(" ")
                GameTooltip:AddLine("As leader/assist, toggling broadcasts to all YippYapp users in raid.", 0, 0.8, 0, true)
            end
        end
    end
    GameTooltip:Show()
end)
rbBtn:SetScript("OnLeave", function()
    UpdateReleaseBlockerBtn()
    GameTooltip:Hide()
end)

UpdateReleaseBlockerBtn()

-- Expose so the Settings panel (and any other callers) can refresh the
-- in-raid-frame button when the flag changes from elsewhere.
ns.RefreshReleaseBlockerButton = UpdateReleaseBlockerBtn

-- Scan button next to the release blocker
local scanBtn = CreateFrame("Button", nil, raidFrame, "BackdropTemplate")
scanBtn:SetSize(110, 22)
scanBtn:SetPoint("RIGHT", rbBtn, "LEFT", -6, 0)
scanBtn:SetBackdrop({
    bgFile   = "Interface\\Buttons\\WHITE8x8",
    edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
    edgeSize = 8,
    insets   = { left = 2, right = 2, top = 2, bottom = 2 },
})
scanBtn:SetBackdropColor(0.08, 0.08, 0.08, 0.8)
scanBtn:SetBackdropBorderColor(0.35, 0.35, 0.35, 0.6)
if ns.SmoothFrame then ns.SmoothFrame(scanBtn) end
local scanBtnFs = scanBtn:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
scanBtnFs:SetPoint("CENTER")
scanBtnFs:SetText("Scan Addons")
scanBtn:SetScript("OnEnter", function(self)
    self:SetBackdropBorderColor(0.0, 0.8, 1.0, 1)
    GameTooltip:SetOwner(self, "ANCHOR_BOTTOM")
    GameTooltip:SetText("YippYapp Addon Scan")
    GameTooltip:AddLine("Pings the group to see who has YippYappHelper installed.", 1, 1, 1, true)
    GameTooltip:Show()
end)
scanBtn:SetScript("OnLeave", function(self)
    self:SetBackdropBorderColor(0.35, 0.35, 0.35, 0.6)
    GameTooltip:Hide()
end)
scanBtn:SetScript("OnClick", function()
    if ns.AddonScanner and ns.AddonScanner.Open then ns.AddonScanner:Open() end
end)

function ns:SetRaidAppMode(enabled, contentWidth, contentHeight)
    if enabled then
        raidFrame:SetMovable(false)
        raidFrame:EnableMouse(false)
        raidFrame:SetBackdrop(nil)
        close:Hide()
        local dw, dh = ns:GetAppFrameSize()
        raidFrame:SetSize(contentWidth or dw, contentHeight or (dh - 34))
    else
        raidFrame:SetMovable(true)
        raidFrame:EnableMouse(true)
        raidFrame:SetBackdrop({
            bgFile = "Interface\\Buttons\\WHITE8x8",
            edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
            edgeSize = 16,
            insets = { left = 4, right = 4, top = 4, bottom = 4 },
        })
        raidFrame:SetBackdropColor(0.08, 0.08, 0.08, 0.95)
        raidFrame:SetBackdropBorderColor(0.6, 0.6, 0.6, 1)
        close:Show()
        raidFrame:SetSize(RAID_WIDTH, RAID_HEIGHT)
    end
end

------------------------------------------------------------
-- Tab system: Overview (default) | Tier Tracker
------------------------------------------------------------
local currentTab = "overview"
local rescanBtn  -- forward declare

local overviewContainer = CreateFrame("Frame", nil, raidFrame)
overviewContainer:SetPoint("TOPLEFT", 0, -44)
overviewContainer:SetPoint("BOTTOMRIGHT", 0, 0)

local tierContainer = CreateFrame("Frame", nil, raidFrame)
tierContainer:SetPoint("TOPLEFT", 0, -44)
tierContainer:SetPoint("BOTTOMRIGHT", 0, 0)
tierContainer:Hide()

local perfContainer = CreateFrame("Frame", nil, raidFrame)
perfContainer:SetPoint("TOPLEFT", 0, -44)
perfContainer:SetPoint("BOTTOMRIGHT", 0, 0)
perfContainer:Hide()

local luraContainer = CreateFrame("Frame", nil, raidFrame)
luraContainer:SetPoint("TOPLEFT", 0, -44)
luraContainer:SetPoint("BOTTOMRIGHT", 0, 0)
luraContainer:Hide()

-- Reuse overviewContainer for groups content (merged)
local groupsContainer = overviewContainer

local overviewTab = ns.CreateUnderlineTab(raidFrame, "Overview", { 1.0, 1.0, 0.3 })
overviewTab:SetSize(110, 24)
overviewTab:SetPoint("TOPLEFT", 8, -18)

local luraTab = ns.CreateUnderlineTab(raidFrame, "L'ura", { 0.85, 0.55, 1.0 })
luraTab:SetSize(80, 24)
luraTab:SetPoint("LEFT", overviewTab, "RIGHT", 6, 0)

local function UpdateTabStyles()
    ns.SetTabInactive(overviewTab)
    ns.SetTabInactive(luraTab)
    overviewContainer:Hide()
    luraContainer:Hide()
    if currentTab == "overview" then
        ns.SetTabActive(overviewTab)
        overviewContainer:Show()
    elseif currentTab == "lura" then
        ns.SetTabActive(luraTab)
        luraContainer:Show()
        -- Build on first show so addon load time stays cheap.
        if ns.Lura and ns.Lura.UI then
            ns.Lura.UI:BuildInto(luraContainer)
            ns.Lura.UI:Refresh()
        end
    end
end

overviewTab:SetScript("OnClick", function()
    currentTab = "overview"
    UpdateTabStyles()
    ns:RefreshRaidOverview()
end)

luraTab:SetScript("OnClick", function()
    currentTab = "lura"
    UpdateTabStyles()
end)

-- Apply initial tab styling
UpdateTabStyles()

function ns.SetRaidTab(tabName)
    currentTab = tabName
    UpdateTabStyles()
    if tabName == "overview" then
        ns:RefreshRaidOverview()
    end
end

------------------------------------------------------------
-- Tier Tracker tab content
------------------------------------------------------------
rescanBtn = CreateFrame("Button", nil, raidFrame, "BackdropTemplate")
rescanBtn:SetSize(60, 18)
rescanBtn:SetPoint("TOPRIGHT", -36, -44)
rescanBtn:SetBackdrop({
    bgFile = "Interface\\Buttons\\WHITE8x8",
    edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
    edgeSize = 10,
    insets = { left = 2, right = 2, top = 2, bottom = 2 },
})
rescanBtn:SetBackdropColor(0.15, 0.15, 0.15, 1)
rescanBtn:SetBackdropBorderColor(0.5, 0.5, 0.5, 0.8)
rescanBtn:SetFrameLevel(raidFrame:GetFrameLevel() + 10)
rescanBtn:Hide()

local rescanText = rescanBtn:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
rescanText:SetAllPoints()
rescanText:SetText("|cff00ff00Rescan|r")

rescanBtn:SetScript("OnClick", function() ns:ScanRaid() end)
rescanBtn:SetScript("OnEnter", function(self)
    self:SetBackdropBorderColor(0.3, 1.0, 0.3, 0.8)
    GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
    GameTooltip:SetText("Rescan raid for tier pieces")
    GameTooltip:Show()
end)
rescanBtn:SetScript("OnLeave", function(self)
    self:SetBackdropBorderColor(0.5, 0.5, 0.5, 0.8)
    GameTooltip_Hide()
end)

local summaryText = tierContainer:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
summaryText:SetPoint("TOPLEFT", 14, -6)
summaryText:SetWidth(RAID_WIDTH - 110)
summaryText:SetJustifyH("LEFT")

local divider = tierContainer:CreateTexture(nil, "ARTWORK")
divider:SetHeight(1)
divider:SetPoint("TOPLEFT", 12, -22)
divider:SetPoint("TOPRIGHT", -12, -22)
divider:SetColorTexture(0.5, 0.5, 0.5, 0.6)

local headerName = tierContainer:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
headerName:SetPoint("TOPLEFT", 14, -26)
headerName:SetText("|cffaaaaaaPlayer|r")

local headerTier = tierContainer:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
headerTier:SetPoint("TOPLEFT", 138, -26)
headerTier:SetText("|cffaaaaaaTier|r")

local headerPieces = tierContainer:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
headerPieces:SetPoint("TOPLEFT", 212, -26)
headerPieces:SetText("|cffaaaaaaPieces|r")

local scrollFrame = CreateFrame("ScrollFrame", nil, tierContainer, "UIPanelScrollFrameTemplate")
scrollFrame:SetPoint("TOPLEFT", 12, -40)
scrollFrame:SetPoint("BOTTOMRIGHT", -28, 12)

local scrollContent = CreateFrame("Frame", nil, scrollFrame)
scrollContent:SetSize(RAID_WIDTH - 44, 1)
scrollFrame:SetScrollChild(scrollContent)

ns.RaidRows = {}

------------------------------------------------------------
-- Helper: gather raid roster data
------------------------------------------------------------
local function GetRaidRoster()
    local roster = {}
    local n = GetNumGroupMembers()
    if n == 0 then return roster end

    if IsInRaid() then
        for i = 1, n do
            local name, rank, subgroup, level, class, fileName, zone, online, isDead, role, isML, combatRole = GetRaidRosterInfo(i)
            if name then
                local assignedRole = UnitGroupRolesAssigned("raid" .. i) or "NONE"
                table.insert(roster, {
                    name = name:match("^([^%-]+)") or name,
                    fullName = name,
                    class = fileName,
                    group = subgroup or 1,
                    role = assignedRole,
                    online = online,
                    index = i,
                })
            end
        end
    else
        -- Party mode
        local units = { "player" }
        for i = 1, n - 1 do
            table.insert(units, "party" .. i)
        end
        for i, unit in ipairs(units) do
            if UnitExists(unit) then
                local name = UnitName(unit)
                local _, fileName = UnitClass(unit)
                local assignedRole = UnitGroupRolesAssigned(unit) or "NONE"
                if name then
                    table.insert(roster, {
                        name = name,
                        fullName = name,
                        class = fileName,
                        group = 1,
                        role = assignedRole,
                        online = UnitIsConnected(unit),
                        index = i,
                    })
                end
            end
        end
    end
    return roster
end

------------------------------------------------------------
-- Shared section layout helpers (consistent across all tabs)
------------------------------------------------------------
local SECTION_PAD = 14
local SECTION_LINE_H = 20
local SECTION_HEADER_COLOR = "ffaaaaaa"

local function MakeSectionHeader(parent, y, text)
    local fs = parent:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    fs:SetPoint("TOPLEFT", SECTION_PAD, y)
    fs:SetText("|c" .. SECTION_HEADER_COLOR .. text .. "|r")
    return fs, y - SECTION_LINE_H
end

local function MakeDivider(parent, y)
    local div = parent:CreateTexture(nil, "ARTWORK")
    div:SetHeight(1)
    div:SetPoint("TOPLEFT", SECTION_PAD - 2, y + 2)
    div:SetPoint("RIGHT", parent, "RIGHT", -SECTION_PAD, 0)
    div:SetColorTexture(0.3, 0.3, 0.3, 0.5)
    return y - 8
end

------------------------------------------------------------
-- Consumable / buff checking
-- Note: In 12.0+, aura name fields on other players are secret/tainted strings.
-- We use spellId-based detection instead of name matching.
------------------------------------------------------------

-- Spell IDs sourced from the shared registry in Core/Data.lua. These were
-- previously duplicated locally and had drifted — the flask IDs here were
-- still Dragonflight-era values while ReadyCheck.lua already carried the
-- current Midnight S1 pool. Registering both files against the same list
-- guarantees they agree on what counts as a consumable.
local function AsIDSet(list)
    local set = {}
    for _, id in ipairs(list) do set[id] = true end
    return set
end
local FLASK_SPELL_IDS = AsIDSet(ns.CONSUMABLE_SPELL_IDS.FLASK)
local FOOD_SPELL_IDS  = AsIDSet(ns.CONSUMABLE_SPELL_IDS.FOOD)

local RUNE_SPELL_IDS = {
    [453247] = true, -- Void-Touched Augment Rune
    [393438] = true, -- Crystallized Augment Rune
}

local function HasBuff(unit, checkFunc)
    for i = 1, 40 do
        local data = C_UnitAuras and C_UnitAuras.GetBuffDataByIndex(unit, i)
        if not data then break end
        local ok, result = pcall(checkFunc, data)
        if ok and result then return true end
    end
    return false
end

local function HasFlask(unit)
    return HasBuff(unit, function(data)
        -- Check by spell ID first (reliable, works with tainted names)
        if data.spellId and FLASK_SPELL_IDS[data.spellId] then return true end
        -- Fallback: long duration buff (>30min) from self that isn't harmful
        if data.duration and data.duration >= 1800 and data.isFromPlayerOrPlayerPet and not data.isHarmful then
            -- Try name match with pcall (works for own buffs, tainted for others)
            local ok, name = pcall(tostring, data.name)
            if ok and name then
                if name:find("Flask") or name:find("Phial") or name:find("Tempered") then return true end
            end
        end
        return false
    end)
end

local function HasFood(unit)
    return HasBuff(unit, function(data)
        if data.spellId and FOOD_SPELL_IDS[data.spellId] then return true end
        -- Well Fed has many spell IDs across expansions; try name as fallback
        local ok, name = pcall(tostring, data.name)
        if ok and name and name:find("Well Fed") then return true end
        return false
    end)
end

local function HasAugmentRune(unit)
    return HasBuff(unit, function(data)
        if data.spellId and RUNE_SPELL_IDS[data.spellId] then return true end
        local ok, name = pcall(tostring, data.name)
        if ok and name and (name:find("Augment") or name:find("Crystallized")) then return true end
        return false
    end)
end

local function GetUnitForRosterIndex(index, isRaid)
    if isRaid then
        return "raid" .. index
    else
        if index == 1 then return "player" end
        return "party" .. (index - 1)
    end
end

-- Helper: format a player name with class color
local function ColorName(p)
    local cc = CLASS_COLORS[p.class] or { 0.7, 0.7, 0.7 }
    local hex = string.format("ff%02x%02x%02x", cc[1] * 255, cc[2] * 255, cc[3] * 255)
    return "|c" .. hex .. p.name .. "|r"
end

-- Helper: find a player by name in roster
local function FindPlayer(roster, name)
    for _, p in ipairs(roster) do
        if p.name == name then return p end
    end
    return nil
end

------------------------------------------------------------
-- Overview tab content
------------------------------------------------------------
local ovPool = {}
local ovPoolIdx = 0

local function OvAcquire(parent, font)
    ovPoolIdx = ovPoolIdx + 1
    local fs = ovPool[ovPoolIdx]
    if not fs then
        fs = parent:CreateFontString(nil, "OVERLAY", font or "GameFontNormal")
        ovPool[ovPoolIdx] = fs
    else
        fs:SetFontObject(font or "GameFontNormal")
        fs:SetTextColor(1, 1, 1)
    end
    fs:ClearAllPoints()
    fs:SetWordWrap(false)
    fs:SetWidth(raidFrame:GetWidth() - 28)
    fs:SetJustifyH("LEFT")
    fs:Show()
    return fs
end

function ns:RefreshRaidOverview()
    for i = 1, ovPoolIdx do ovPool[i]:Hide() end
    ovPoolIdx = 0

    local roster = GetRaidRoster()

    if #roster == 0 then
        local fs = OvAcquire(overviewContainer, "GameFontNormal")
        fs:SetPoint("TOPLEFT", SECTION_PAD, -8)
        fs:SetText("|cff888888Not in a group|r")
        return
    end

    local tanks, healers, dps = 0, 0, 0
    local classCounts = {}
    for _, p in ipairs(roster) do
        if p.role == "TANK" then tanks = tanks + 1
        elseif p.role == "HEALER" then healers = healers + 1
        else dps = dps + 1 end
        classCounts[p.class] = (classCounts[p.class] or 0) + 1
    end

    -- ── Two-column layout ──
    -- Left: Groups + Splits + Swaps
    -- Right: Composition + Buffs + Classes + PI
    local fw = raidFrame:GetWidth() - 28
    local rightX = math.floor(fw * 0.55) + SECTION_PAD  -- right column starts at ~55%
    local rightW = fw - rightX + SECTION_PAD

    -- ── RIGHT COLUMN ──
    local ry = -8

    -- Composition (large, prominent)
    local compFs = OvAcquire(overviewContainer, "GameFontNormalLarge")
    compFs:SetPoint("TOPLEFT", rightX, ry)
    compFs:SetWidth(rightW)
    compFs:SetText("|cff8888ff" .. tanks .. " Tank|r   " ..
        "|cff00ff00" .. healers .. " Heal|r   " ..
        "|cffff4444" .. dps .. " DPS|r")
    ns.ApplyTextShadow(compFs)
    ry = ry - 22

    local totalFs = OvAcquire(overviewContainer, "GameFontNormalSmall")
    totalFs:SetPoint("TOPLEFT", rightX, ry)
    totalFs:SetTextColor(unpack(ns.COLORS.TEXT_TERTIARY))
    totalFs:SetText(#roster .. " players in group")
    ry = ry - 20

    -- Raid Buffs icon strip. Icons with a green border = present in raid;
    -- desaturated + red border = missing. Hover any icon for name + status.
    local buffHdr = OvAcquire(overviewContainer, "GameFontNormal")
    buffHdr:SetPoint("TOPLEFT", rightX, ry)
    buffHdr:SetTextColor(unpack(ns.COLORS.TEXT_HEADER))
    buffHdr:SetText("Raid Buffs")
    ns.ApplyTextShadow(buffHdr)
    ry = ry - SECTION_LINE_H

    local BUFF_ICON_SIZE = 34
    local BUFF_ICON_GAP  = 5
    -- Fit as many icons per row as the right column can hold; wrap if
    -- the list grows beyond what fits in one row. Current 6 buffs fit
    -- easily in one row on any normal raid-frame width.
    local iconsPerRow = math.max(1,
        math.floor((rightW - 4) / (BUFF_ICON_SIZE + BUFF_ICON_GAP)))
    local rowsUsed = 0
    for i, rb in ipairs(RAID_BUFFS) do
        local icon = EnsureBuffIconFrame(i, overviewContainer, BUFF_ICON_SIZE)
        local col = (i - 1) % iconsPerRow
        local row = math.floor((i - 1) / iconsPerRow)
        rowsUsed = math.max(rowsUsed, row + 1)
        icon:ClearAllPoints()
        icon:SetPoint("TOPLEFT", overviewContainer, "TOPLEFT",
            rightX + 4 + col * (BUFF_ICON_SIZE + BUFF_ICON_GAP),
            ry - row * (BUFF_ICON_SIZE + BUFF_ICON_GAP))

        -- Resolve icon: prefer spellID lookup, fall back to static path.
        local iconID = rb.fallbackIcon or 134400
        if rb.spellID and C_Spell and C_Spell.GetSpellInfo then
            local info = C_Spell.GetSpellInfo(rb.spellID)
            if info and info.iconID then iconID = info.iconID end
        end
        icon.tex:SetTexture(iconID)

        local have = classCounts[rb.file] and classCounts[rb.file] > 0
        if have then
            icon.tex:SetDesaturated(false)
            icon.tex:SetVertexColor(1, 1, 1, 1)
            icon:SetBackdropBorderColor(0.0, 0.85, 0.35, 1)
            icon:SetAlpha(1)
        else
            -- Keep the icon visible so the raid leader can still see
            -- *which* buffs are missing, but heavily desaturate it so
            -- the eye reads "off" at a glance: full greyscale + dim
            -- vertex color + low overall alpha + dull red border.
            icon.tex:SetDesaturated(true)
            icon.tex:SetVertexColor(0.35, 0.35, 0.35, 1)
            icon:SetBackdropBorderColor(0.45, 0.12, 0.12, 0.9)
            icon:SetAlpha(0.55)
        end

        icon._buffName = rb.buff
        icon._desc     = rb.desc
        icon._present  = have
        icon:Show()
    end
    -- Hide any surplus icons from a previous render with more buffs.
    for i = #RAID_BUFFS + 1, #buffIconFrames do
        buffIconFrames[i]:Hide()
    end

    ry = ry - rowsUsed * (BUFF_ICON_SIZE + BUFF_ICON_GAP) - 4

    -- Consumables
    local isRaid = IsInRaid()
    local flaskCount, foodCount, runeCount = 0, 0, 0
    local missingFlask, missingFood = {}, {}

    for _, p in ipairs(roster) do
        local unit = GetUnitForRosterIndex(p.index, isRaid)
        if UnitExists(unit) and UnitIsConnected(unit) then
            if HasFlask(unit) then
                flaskCount = flaskCount + 1
            else
                table.insert(missingFlask, p)
            end
            if HasFood(unit) then
                foodCount = foodCount + 1
            else
                table.insert(missingFood, p)
            end
            if HasAugmentRune(unit) then
                runeCount = runeCount + 1
            end
        end
    end

    local consHdr = OvAcquire(overviewContainer, "GameFontNormal")
    consHdr:SetPoint("TOPLEFT", rightX, ry)
    consHdr:SetWidth(rightW)
    consHdr:SetTextColor(unpack(ns.COLORS.TEXT_HEADER))
    consHdr:SetText("Consumables")
    ns.ApplyTextShadow(consHdr)
    ry = ry - SECTION_LINE_H

    local total = #roster
    local flaskColor = flaskCount == total and "ff00ff00" or (flaskCount >= total * 0.8 and "ffffff00" or "ffff4444")
    local foodColor = foodCount == total and "ff00ff00" or (foodCount >= total * 0.8 and "ffffff00" or "ffff4444")
    local runeColor = runeCount == total and "ff00ff00" or "ff888888"

    -- Flask line
    local flaskFs = OvAcquire(overviewContainer, "GameFontNormal")
    flaskFs:SetPoint("TOPLEFT", rightX + 4, ry)
    flaskFs:SetWidth(rightW)
    local flaskNames = ""
    if #missingFlask > 0 and #missingFlask <= 4 then
        local names = {}
        for _, p in ipairs(missingFlask) do table.insert(names, ColorName(p)) end
        flaskNames = "  |cff555555-|r " .. table.concat(names, " ")
    elseif #missingFlask > 4 then
        flaskNames = "  |cffff4444" .. #missingFlask .. " missing|r"
    end
    flaskFs:SetText("|c" .. flaskColor .. flaskCount .. "/" .. total .. "|r  Flask" .. flaskNames)
    ry = ry - 17

    -- Food line
    local foodFs = OvAcquire(overviewContainer, "GameFontNormal")
    foodFs:SetPoint("TOPLEFT", rightX + 4, ry)
    foodFs:SetWidth(rightW)
    local foodNames = ""
    if #missingFood > 0 and #missingFood <= 4 then
        local names = {}
        for _, p in ipairs(missingFood) do table.insert(names, ColorName(p)) end
        foodNames = "  |cff555555-|r " .. table.concat(names, " ")
    elseif #missingFood > 4 then
        foodNames = "  |cffff4444" .. #missingFood .. " missing|r"
    end
    foodFs:SetText("|c" .. foodColor .. foodCount .. "/" .. total .. "|r  Food" .. foodNames)
    ry = ry - 17

    -- Rune line
    local runeFs = OvAcquire(overviewContainer, "GameFontNormal")
    runeFs:SetPoint("TOPLEFT", rightX + 4, ry)
    runeFs:SetWidth(rightW)
    runeFs:SetText("|c" .. runeColor .. runeCount .. "/" .. total .. "|r  Augment Rune")

    -- Update header release blocker button state
    UpdateReleaseBlockerBtn()

    -- ── LEFT COLUMN: groups/splits (starts from top, full left side) ──
    ns:RefreshRaidGroups(roster, -8)
end

------------------------------------------------------------
-- Groups tab content
------------------------------------------------------------
local grpPool = {}
local grpPoolIdx = 0
-- Separate pools for Frame-template (badges) and Button-template
-- (Swap All / Refresh / Auto) frames. Keeping them apart prevents a
-- slot first created as a Button from being handed out as a "badge"
-- later (or vice versa) — SetScript("OnClick") on a plain Frame is a
-- silent no-op, which was causing button clicks to intermittently
-- stop working after the suggestion count changed between refreshes.
local swapFramePools = { ["Frame"] = {}, ["Button"] = {} }
local swapFramePoolIdx = { ["Frame"] = 0, ["Button"] = 0 }

local function AcquireSwapFrame(parent, template)
    template = template or "Frame"
    local pool = swapFramePools[template] or swapFramePools["Frame"]
    local idx = (swapFramePoolIdx[template] or 0) + 1
    swapFramePoolIdx[template] = idx

    local f = pool[idx]
    if not f then
        f = CreateFrame(template, nil, parent, "BackdropTemplate")
        pool[idx] = f
    else
        f:SetParent(parent)
    end
    f:ClearAllPoints()
    -- Clear old children font string text so stale labels don't leak
    -- across refreshes. (Anchor is re-applied by each caller.)
    for _, region in pairs({ f:GetRegions() }) do
        if region:IsObjectType("FontString") then
            region:SetText("")
        end
    end
    f:Show()
    return f
end

local function GrpAcquire(parent, font)
    grpPoolIdx = grpPoolIdx + 1
    local fs = grpPool[grpPoolIdx]
    if not fs then
        fs = parent:CreateFontString(nil, "OVERLAY", font or "GameFontNormal")
        grpPool[grpPoolIdx] = fs
    else
        fs:SetFontObject(font or "GameFontNormal")
        fs:SetTextColor(1, 1, 1)
    end
    fs:ClearAllPoints()
    -- Reset width / justify on every acquisition. Previously a slot
    -- that a caller had SetWidth-constrained (e.g. a split-balance
    -- cell at 40px) kept that width on reuse, which truncated later
    -- uses like the "Split Balance" / "Suggested Swaps" headers into
    -- "split..." / "sugg...". SetWidth(0) returns the font string to
    -- auto-sizing; SetJustifyH("LEFT") is our default alignment.
    fs:SetWidth(0)
    fs:SetJustifyH("LEFT")
    fs:SetWordWrap(false)
    fs:Show()
    return fs
end

-- PI target assignments (persisted, keyed by priest name)
-- Restore from saved variables
ns.PIAssignments = (YippYappHelperDB and YippYappHelperDB.piAssignments) or ns.PIAssignments or {}

local function SavePIAssignments()
    YippYappHelperDB = YippYappHelperDB or {}
    YippYappHelperDB.piAssignments = ns.PIAssignments
end

-- Raid split configuration (persisted). mode = "odds" | "halves".
-- Odds maps split A→1,3,5 and B→2,4,6; halves maps split A→first half
-- of groups and B→second half (same as NSRT's `odds` parameter).
local function GetRaidSplitConfig()
    YippYappHelperDB = YippYappHelperDB or {}
    YippYappHelperDB.raidSplit = YippYappHelperDB.raidSplit or { mode = "odds" }
    local cfg = YippYappHelperDB.raidSplit
    if cfg.mode ~= "odds" and cfg.mode ~= "halves" then cfg.mode = "odds" end
    return cfg
end

-- Resolve the maximum subgroup count for the current raid.
-- Mythic raid (difficulty 16) is strict 20-man → always 4 groups.
-- Everything else: use the highest subgroup with at least one
-- player, floored at 6 (the standard flex cap) and ceilinged at 8
-- (the game's subgroup cap). This way, if players have drifted into
-- groups 7-8 (common after a fresh invite before anyone compacts
-- groups), the balancer + compaction still sees them.
local function DetectMaxGroup()
    local _, _, instDiff = GetInstanceInfo()
    local selected = GetRaidDifficultyID and GetRaidDifficultyID()
    local diff = (instDiff and instDiff > 0) and instDiff or selected
    if diff == 16 then return 4 end

    local highest = 6
    if IsInRaid() then
        for i = 1, GetNumGroupMembers() do
            local _, _, subgroup = GetRaidRosterInfo(i)
            if subgroup and subgroup > highest then highest = subgroup end
        end
    end
    return math.min(highest, 8)
end

-- PI picker popup
local piPicker = CreateFrame("Frame", "YippYappPIPicker", UIParent, "BackdropTemplate")
piPicker:SetSize(180, 200)
piPicker:SetFrameStrata("DIALOG")
piPicker:SetClampedToScreen(true)
piPicker:SetBackdrop({
    bgFile   = "Interface\\Buttons\\WHITE8x8",
    edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
    edgeSize = 12,
    insets   = { left = 3, right = 3, top = 3, bottom = 3 },
})
piPicker:SetBackdropColor(0.06, 0.06, 0.06, 0.97)
piPicker:SetBackdropBorderColor(0.5, 0.5, 0.5, 0.8)
piPicker:EnableMouse(true)
piPicker:Hide()

piPicker:SetScript("OnShow", function()
    tinsert(UISpecialFrames, "YippYappPIPicker")
end)
piPicker:SetScript("OnHide", function()
    for i = #UISpecialFrames, 1, -1 do
        if UISpecialFrames[i] == "YippYappPIPicker" then
            table.remove(UISpecialFrames, i)
            break
        end
    end
end)

local piPickerTitle = piPicker:CreateFontString(nil, "OVERLAY", "GameFontNormal")
piPickerTitle:SetPoint("TOPLEFT", 10, -8)

local piPickerScroll = CreateFrame("ScrollFrame", nil, piPicker, "UIPanelScrollFrameTemplate")
piPickerScroll:SetPoint("TOPLEFT", 6, -28)
piPickerScroll:SetPoint("BOTTOMRIGHT", -24, 6)

local piPickerContent = CreateFrame("Frame", nil, piPickerScroll)
piPickerContent:SetSize(148, 1)
piPickerScroll:SetScrollChild(piPickerContent)

local piPickerRows = {}
local piPickerRowsUsed = 0
local piPickerPriestName = nil
local piPickerDivider = nil

local function AcquirePIPickerRow()
    piPickerRowsUsed = piPickerRowsUsed + 1
    local row = piPickerRows[piPickerRowsUsed]
    if not row then
        row = CreateFrame("Button", nil, piPickerContent)
        row:SetSize(148, 20)
        row._fs = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        row._fs:SetPoint("LEFT", 6, 0)
        row._hl = row:CreateTexture(nil, "HIGHLIGHT")
        row._hl:SetAllPoints()
        row._hl:SetColorTexture(1, 1, 1, 0.05)
        piPickerRows[piPickerRowsUsed] = row
    end
    row:ClearAllPoints()
    -- Hide the optional active-row background
    if row._bg then row._bg:Hide() end
    row:Show()
    return row
end

local function ShowPIPicker(anchorBtn, priestName, roster)
    piPickerPriestName = priestName

    -- Hide old rows
    for i = 1, piPickerRowsUsed do piPickerRows[i]:Hide() end
    piPickerRowsUsed = 0

    -- Build a score lookup for the current ST ranking so we can
    -- decorate each candidate row with its raw sim score + a
    -- "(best)" tag on the top-ranked one. Falls back gracefully if
    -- the PI module hasn't loaded or the player hasn't scanned.
    local scoresByName = {}
    local topName = nil
    if ns.PI and ns.PI.RankTargets then
        local ranked = ns.PI:RankTargets("ST")
        for i, r in ipairs(ranked) do
            scoresByName[r.name] = { rank = i, score = r.score }
            if i == 1 then topName = r.name end
        end
    end

    piPickerTitle:SetText("|cffaaaaaaPick PI target|r")

    -- "Clear" option at top
    local yOff = 0
    local clearBtn = AcquirePIPickerRow()
    clearBtn:SetPoint("TOPLEFT", 0, -yOff)
    clearBtn._fs:SetText("|cff888888Clear assignment|r")
    clearBtn:SetScript("OnClick", function()
        ns.PIAssignments[priestName] = nil
        SavePIAssignments()
        piPicker:Hide()
        ns:RefreshRaidGroups()
    end)
    yOff = yOff + 22

    -- Divider (create once, reuse)
    if not piPickerDivider then
        piPickerDivider = piPickerContent:CreateTexture(nil, "ARTWORK")
        piPickerDivider:SetHeight(1)
        piPickerDivider:SetPoint("RIGHT", piPickerContent, "RIGHT", 0, 0)
        piPickerDivider:SetColorTexture(0.3, 0.3, 0.3, 0.5)
    end
    piPickerDivider:ClearAllPoints()
    piPickerDivider:SetPoint("TOPLEFT", 0, -yOff)
    piPickerDivider:SetPoint("RIGHT", piPickerContent, "RIGHT", 0, 0)
    piPickerDivider:Show()
    yOff = yOff + 4

    -- List all DPS and tanks
    local targets = {}
    for _, p in ipairs(roster) do
        if p.role == "DAMAGER" or p.role == "TANK" then
            table.insert(targets, p)
        end
    end
    -- Sort by PI score desc (when available) so the best pick is at
    -- the top of the picker. Ties / unscored fall back to name asc,
    -- preserving the old behavior when no scan has run yet.
    table.sort(targets, function(a, b)
        local sa = scoresByName[a.name] and scoresByName[a.name].score or nil
        local sb = scoresByName[b.name] and scoresByName[b.name].score or nil
        if sa and sb and sa ~= sb then return sa > sb end
        if sa and not sb then return true end
        if sb and not sa then return false end
        return a.name < b.name
    end)

    local currentTarget = ns.PIAssignments[priestName]

    for _, p in ipairs(targets) do
        local row = AcquirePIPickerRow()
        row:SetPoint("TOPLEFT", 0, -yOff)

        local roleTag = ROLE_PREFIX[p.role] or ""
        local nameText = roleTag .. " " .. ColorName(p) .. " |cff555555G" .. p.group .. "|r"
        -- Append score + best-pick tag when PI scoring data is available.
        local scoreInfo = scoresByName[p.name]
        if scoreInfo then
            local scoreK = math.floor((scoreInfo.score or 0) / 1000 + 0.5)
            if p.name == topName then
                nameText = nameText .. "  |cffffd100" .. scoreK .. "k (best)|r"
            else
                nameText = nameText .. "  |cff888888" .. scoreK .. "k|r"
            end
        end
        if p.name == currentTarget then
            nameText = nameText .. "  |cff00ff00<|r"
        end
        row._fs:SetText(nameText)

        if p.name == currentTarget then
            if not row._bg then
                row._bg = row:CreateTexture(nil, "BACKGROUND")
                row._bg:SetAllPoints()
                row._bg:SetColorTexture(0, 1, 0, 0.06)
            end
            row._bg:Show()
        end

        local targetName = p.name
        row:SetScript("OnClick", function()
            ns.PIAssignments[priestName] = targetName
            SavePIAssignments()
            piPicker:Hide()
            ns:RefreshRaidGroups()
        end)

        yOff = yOff + 20
    end

    piPickerContent:SetHeight(math.max(yOff, 1))

    -- Size the popup to fit (max 300px tall)
    local popupH = math.min(yOff + 36, 300)
    piPicker:SetHeight(popupH)

    -- Anchor to the button
    piPicker:ClearAllPoints()
    piPicker:SetPoint("TOPLEFT", anchorBtn, "TOPRIGHT", 4, 0)
    piPicker:Show()
end

-- PI assignment buttons (created on demand)
local piBtnPool = {}
local piBtnPoolIdx = 0

local function AcquirePIButton(parent)
    piBtnPoolIdx = piBtnPoolIdx + 1
    local btn = piBtnPool[piBtnPoolIdx]
    if not btn then
        btn = CreateFrame("Button", nil, parent, "BackdropTemplate")
        btn:SetSize(200, 20)
        btn:SetBackdrop({
            bgFile = "Interface\\Buttons\\WHITE8x8",
            edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
            edgeSize = 8,
            insets = { left = 2, right = 2, top = 2, bottom = 2 },
        })
        btn.fs = btn:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        btn.fs:SetPoint("LEFT", 8, 0)
        btn.targetFs = btn:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        btn.targetFs:SetPoint("RIGHT", -8, 0)
        btn:SetScript("OnEnter", function(self)
            self:SetBackdropBorderColor(0.6, 0.6, 0.6, 0.8)
        end)
        btn:SetScript("OnLeave", function(self)
            self:SetBackdropBorderColor(0.3, 0.3, 0.3, 0.5)
        end)
        piBtnPool[piBtnPoolIdx] = btn
    end
    btn:SetBackdropColor(0.1, 0.1, 0.1, 0.8)
    btn:SetBackdropBorderColor(0.3, 0.3, 0.3, 0.5)
    btn:ClearAllPoints()
    btn:Show()
    return btn
end

function ns:RefreshRaidGroups(roster, startY)
    for i = 1, grpPoolIdx do grpPool[i]:Hide() end
    grpPoolIdx = 0
    for i = 1, piBtnPoolIdx do piBtnPool[i]:Hide() end
    piBtnPoolIdx = 0
    for template, pool in pairs(swapFramePools) do
        local used = swapFramePoolIdx[template] or 0
        for i = 1, used do pool[i]:Hide() end
        swapFramePoolIdx[template] = 0
    end

    -- If called standalone (from SetRaidTab), fetch roster ourselves
    if not roster then
        roster = GetRaidRoster()
    end
    local baseY = startY or -8

    if #roster == 0 then
        return
    end

    -- Sort roster into groups
    local groups = {}
    for i = 1, 8 do groups[i] = {} end
    for _, p in ipairs(roster) do
        local g = p.group or 1
        if g >= 1 and g <= 8 then
            table.insert(groups[g], p)
        end
    end

    local maxGroup = 0
    for g = 1, 8 do
        if #groups[g] > 0 then maxGroup = g end
    end

    -- ── Group display — left 55% of the frame ──
    -- Two rows, columns scale to cover every populated group up to 8.
    -- Top row = odd groups (split A under odds mode), bottom = even (B).
    -- Under halves mode the rows still alphabetize odd/even cleanly
    -- which keeps the visual stable when the user flips split mode.
    local fw = math.floor((raidFrame:GetWidth() - 28) * 0.55)
    local memberLineH = 18
    local groupBlockH = 22 + 5 * memberLineH + 10

    local grpStartY = MakeDivider(groupsContainer, baseY)

    -- Build rowOrder dynamically so groups 7-8 are visible if populated.
    local rowOrder = { {}, {} }
    for g = 1, math.max(6, maxGroup) do
        local row = (g % 2 == 1) and 1 or 2
        table.insert(rowOrder[row], g)
    end
    local colCount = math.max(3, math.max(#rowOrder[1], #rowOrder[2]))
    local colW = math.floor(fw / colCount)

    for row, groupNums in ipairs(rowOrder) do
        for col, g in ipairs(groupNums) do
            if g <= maxGroup and #groups[g] > 0 then
                local x = SECTION_PAD + (col - 1) * colW
                local cellY = grpStartY - (row - 1) * groupBlockH

                local hdr = GrpAcquire(groupsContainer, "GameFontNormal")
                hdr:SetPoint("TOPLEFT", groupsContainer, "TOPLEFT", x, cellY)
                hdr:SetText("|c" .. SECTION_HEADER_COLOR .. "Group " .. g .. "|r")

                for j, p in ipairs(groups[g]) do
                    local mfs = GrpAcquire(groupsContainer, "GameFontNormal")
                    mfs:SetPoint("TOPLEFT", groupsContainer, "TOPLEFT", x + 8, cellY - 4 - j * memberLineH)
                    local roleTag = ROLE_PREFIX[p.role] or " "
                    mfs:SetText(roleTag .. " " .. ColorName(p))
                end
            end
        end
    end

    -- ── Analysis section (always reserve 2 rows for groups 1-6) ──
    local analysisY = grpStartY - 2 * groupBlockH - 4

    analysisY = MakeDivider(groupsContainer, analysisY)

    -- Run the multi-criteria balancer (see Features/Raid/RaidSwapLogic.lua).
    -- maxGroup is auto-detected from difficulty (mythic = 4, else 6) and
    -- splitMode is read from savedvars.
    local splitCfg  = GetRaidSplitConfig()
    local maxGroup  = DetectMaxGroup()

    -- Plan stickiness: the full ordered plan is cached on ns so that
    -- applying one row doesn't re-rank the rest. The cache is scoped
    -- to (mode, maxGroup); switching split mode or raid difficulty
    -- flushes it so stale orientations can't linger.
    if ns._raidSwap and (ns._raidSwap.mode ~= splitCfg.mode
                      or ns._raidSwap.maxGroup ~= maxGroup) then
        ns._raidSwap = nil
    end

    local splitA, splitB, allPriests, suggestions
    if ns._raidSwap and ns._raidSwap.plan and #ns._raidSwap.plan > 0 then
        -- Cache warm: reconcile against current roster (drop entries
        -- whose players left or whose swap/move is already satisfied)
        -- and skip the balancer so its fresh ranking doesn't override
        -- the remaining steps.
        suggestions = ns.RaidSwap:Reconcile(ns._raidSwap.plan, roster,
            maxGroup, splitCfg.mode)
    end

    if not suggestions or #suggestions == 0 then
        local swapResult = ns.RaidSwap:Suggest(roster, {
            maxGroup      = maxGroup,
            splitMode     = splitCfg.mode,
            piAssignments = ns.PIAssignments,
        })
        splitA     = swapResult.splitA
        splitB     = swapResult.splitB
        allPriests = swapResult.allPriests
        suggestions = swapResult.suggestions
        ns._raidSwap = {
            mode     = splitCfg.mode,
            maxGroup = maxGroup,
            plan     = ns.RaidSwap:SerializePlan(suggestions),
        }
    else
        -- Still need fresh split buckets for the balance table — the
        -- cached plan is actionable, but splitA/B reflect live state.
        local split = ns.RaidSwap:BuildSplit(roster, maxGroup, splitCfg.mode)
        splitA, splitB, allPriests = split.A, split.B, split.allPriests
        -- Re-serialize the reconciled list so the cache matches the
        -- rows the user actually sees (single-row clicks pop by name).
        ns._raidSwap.plan = ns.RaidSwap:SerializePlan(suggestions)
    end

    -- Compute the group lists each side owns under the current split
    -- mode, so the A/B row labels can show real group numbers
    -- ("A 1,3,5" / "A 1-2") instead of a generic "odd/even".
    local function GroupList(sideA)
        local list = {}
        for g = 1, maxGroup do
            local isA
            if splitCfg.mode == "halves" then
                isA = g <= math.floor(maxGroup / 2)
            else
                isA = g % 2 == 1
            end
            if isA == sideA then table.insert(list, tostring(g)) end
        end
        return table.concat(list,
            splitCfg.mode == "halves" and "-" or ",")
    end
    local aGroups = GroupList(true)   -- e.g. "1,3,5" or "1-2"
    local bGroups = GroupList(false)  -- e.g. "2,4,6" or "3-4"
    -- Collapse "1-2-3" → "1-3" for the halves variant (contiguous range).
    if splitCfg.mode == "halves" then
        local first, last = aGroups:match("^(%d+)%-.*%-(%d+)$")
        if first and last then aGroups = first .. "-" .. last end
        first, last = bGroups:match("^(%d+)%-.*%-(%d+)$")
        if first and last then bGroups = first .. "-" .. last end
    end

    -- ── Split Balance mini-table ──
    -- Three rows (header + A + B) with one cell per stat. A cell turns red
    -- when |A - B| >= 2 so the eye lands on the imbalance immediately.
    -- Column widths are derived from the left-column frame width (`fw`) so
    -- wider raid frames get roomier columns instead of clipped headers.
    local balHdr = GrpAcquire(groupsContainer, "GameFontNormal")
    balHdr:SetPoint("TOPLEFT", groupsContainer, "TOPLEFT", SECTION_PAD, analysisY)
    balHdr:SetText("|c" .. SECTION_HEADER_COLOR .. "Split Balance|r" ..
        "  |cff888888(" .. maxGroup .. " groups · " ..
        (splitCfg.mode == "halves" and "halves" or "odd/even") .. ")|r")
    ns.ApplyTextShadow(balHdr)

    -- Mode toggle button — small pill button on the right of the Split
    -- Balance header. Click cycles odds ↔ halves and re-renders.
    local modeBtn = AcquireSwapFrame(groupsContainer, "Button")
    modeBtn:SetSize(90, 18)
    modeBtn:SetPoint("TOPLEFT", groupsContainer, "TOPLEFT", SECTION_PAD + fw - 100, analysisY + 1)
    modeBtn:SetBackdrop({
        bgFile   = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        edgeSize = 8, insets = { left = 2, right = 2, top = 2, bottom = 2 },
    })
    modeBtn:SetBackdropColor(0.08, 0.08, 0.08, 0.9)
    modeBtn:SetBackdropBorderColor(0.3, 0.5, 0.7, 0.6)
    ns.SmoothFrame(modeBtn)
    if not modeBtn._fs then
        modeBtn._fs = modeBtn:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        modeBtn._fs:SetPoint("CENTER")
    end
    modeBtn._fs:SetText(splitCfg.mode == "halves"
        and "|cff9ad4ffHalves|r"
        or  "|cff9ad4ffOdd/Even|r")
    modeBtn:SetScript("OnClick", function()
        splitCfg.mode = (splitCfg.mode == "odds") and "halves" or "odds"
        ns:RefreshRaidOverview()
    end)
    modeBtn:SetScript("OnEnter", function(self)
        self:SetBackdropBorderColor(0.3, 0.8, 1.0, 1)
        GameTooltip:SetOwner(self, "ANCHOR_LEFT")
        GameTooltip:AddLine("Split mode", 1, 1, 1)
        GameTooltip:AddLine("|cffffffffOdd/Even|r: A = groups 1,3,5...  B = 2,4,6...", 0.8, 0.8, 0.8, true)
        GameTooltip:AddLine("|cffffffffHalves|r: A = first half of groups, B = second half", 0.8, 0.8, 0.8, true)
        GameTooltip:AddLine(" ", 1, 1, 1)
        GameTooltip:AddLine("Click to toggle", 0.9, 0.9, 0.9)
        GameTooltip:Show()
    end)
    modeBtn:SetScript("OnLeave", function(self)
        self:SetBackdropBorderColor(0.3, 0.5, 0.7, 0.6)
        GameTooltip:Hide()
    end)

    analysisY = analysisY - 18

    local BAL_COL_KEYS = {
        { key = "total",   header = "Total" },
        { key = "tanks",   header = "Tank"  },
        { key = "healers", header = "Heal"  },
        { key = "dps",     header = "DPS"   },
        { key = "melee",   header = "Mele"  },
        { key = "ranged",  header = "Rang"  },
        { key = "lust",    header = "Lust"  },
        { key = "brez",    header = "Brez"  },
    }

    -- Space the columns across the full left panel. Label column fits
    -- "A (odd)" / "B (even)"; remaining width splits evenly across the
    -- 8 stat columns. The cell width scales with frame size so wider
    -- raid frames get roomier columns, but never below a readable floor.
    local BAL_LABEL_X = SECTION_PAD + 4
    local BAL_LABEL_W = 60
    local BAL_STATS_X = SECTION_PAD + BAL_LABEL_W + 8
    local BAL_CELL_W = math.max(38, math.floor((fw - BAL_LABEL_W - 16) / #BAL_COL_KEYS))

    local function SplitStats(side)
        return {
            total   = #side.all,
            tanks   = #side.tanks,
            healers = #side.healers,
            dps     = #side.dps,
            melee   = #side.melee,
            ranged  = #side.ranged,
            lust    = #side.lust,
            brez    = #side.brez,
        }
    end
    local statsA = SplitStats(splitA)
    local statsB = SplitStats(splitB)

    local function BalCell(y, colIdx, text, color)
        local cell = GrpAcquire(groupsContainer, "GameFontNormalSmall")
        cell:SetPoint("TOPLEFT", groupsContainer, "TOPLEFT",
            BAL_STATS_X + (colIdx - 1) * BAL_CELL_W, y)
        cell:SetWidth(BAL_CELL_W)
        cell:SetJustifyH("CENTER")
        cell:SetText(text)
        if color then cell:SetTextColor(unpack(color)) end
    end

    -- Header row
    for i, col in ipairs(BAL_COL_KEYS) do
        BalCell(analysisY, i, col.header, { 0.55, 0.55, 0.55 })
    end
    analysisY = analysisY - 16

    -- A row
    local aLbl = GrpAcquire(groupsContainer, "GameFontNormalSmall")
    aLbl:SetPoint("TOPLEFT", groupsContainer, "TOPLEFT", BAL_LABEL_X, analysisY)
    aLbl:SetWidth(BAL_LABEL_W)
    aLbl:SetJustifyH("LEFT")
    aLbl:SetText("|cffffffffA (" .. aGroups .. ")|r")
    for i, col in ipairs(BAL_COL_KEYS) do
        local a, b = statsA[col.key], statsB[col.key]
        local bad = math.abs(a - b) >= 2
        BalCell(analysisY, i, (bad and "|cffff4444" or "|cffffffff") .. a .. "|r")
    end
    analysisY = analysisY - 16

    -- B row
    local bLbl = GrpAcquire(groupsContainer, "GameFontNormalSmall")
    bLbl:SetPoint("TOPLEFT", groupsContainer, "TOPLEFT", BAL_LABEL_X, analysisY)
    bLbl:SetWidth(BAL_LABEL_W)
    bLbl:SetJustifyH("LEFT")
    bLbl:SetText("|cffffffffB (" .. bGroups .. ")|r")
    for i, col in ipairs(BAL_COL_KEYS) do
        local a, b = statsA[col.key], statsB[col.key]
        local bad = math.abs(a - b) >= 2
        BalCell(analysisY, i, (bad and "|cffff4444" or "|cffffffff") .. b .. "|r")
    end
    analysisY = analysisY - 22  -- row height + gap before next section

    -- ── Left: Suggested Swaps (visual cards with Apply button) ──
    -- Helper: check if we can move raid members
    local function CanMoveRaidMembers()
        if InCombatLockdown() then return false end
        if not IsInRaid() then return false end
        local _, rank = GetRaidRosterInfo(1) -- check our rank
        -- Actually check the player's own rank
        for i = 1, GetNumGroupMembers() do
            local name, r = GetRaidRosterInfo(i)
            if name and UnitIsUnit("raid" .. i, "player") then
                return r >= 1 -- 1 = assistant, 2 = leader
            end
        end
        return false
    end

    local canMove = CanMoveRaidMembers()

    -- Filter out malformed suggestions. Swap ops need both players;
    -- move ops need a player + a targetGroup.
    local actionable = {}
    for _, s in ipairs(suggestions) do
        if s.op == "move" and s.p1 and s.targetGroup then
            table.insert(actionable, s)
        elseif (s.op == nil or s.op == "swap") and s.p1 and s.p2 then
            s.op = "swap"
            table.insert(actionable, s)
        end
    end

    if #actionable > 0 then
        local sHdr = GrpAcquire(groupsContainer, "GameFontNormal")
        sHdr:SetPoint("TOPLEFT", groupsContainer, "TOPLEFT", SECTION_PAD, analysisY)
        sHdr:SetText("|cffffffaaSuggested Changes|r  |cff888888(click any row to apply)|r")
        ns.ApplyTextShadow(sHdr)

        -- "Swap All" button (always rendered so users can see the action
        -- exists; disabled + hint tooltip when they lack assist/leader).
        local swapAllBtn = AcquireSwapFrame(groupsContainer, "Button")
        swapAllBtn:SetSize(80, 20)
        swapAllBtn:SetPoint("TOPLEFT", groupsContainer, "TOPLEFT", SECTION_PAD + fw - 90, analysisY + 1)
        swapAllBtn:SetBackdrop({
            bgFile = "Interface\\Buttons\\WHITE8x8",
            edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
            edgeSize = 8,
            insets = { left = 2, right = 2, top = 2, bottom = 2 },
        })
        ns.SmoothFrame(swapAllBtn)
        if not swapAllBtn._fs then
            swapAllBtn._fs = swapAllBtn:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            swapAllBtn._fs:SetPoint("CENTER")
        end
        -- Capture the exact dispatch info per suggestion so closures
        -- don't hold live references to roster tables that could
        -- mutate between refreshes.
        local applyQueue = {}
        for _, s in ipairs(actionable) do
            if s.op == "move" then
                table.insert(applyQueue, { op = "move", idx = s.p1.index, targetGroup = s.targetGroup })
            else
                table.insert(applyQueue, { op = "swap", idx1 = s.p1.index, idx2 = s.p2.index })
            end
        end
        if canMove then
            swapAllBtn:SetBackdropColor(0.0, 0.32, 0.12, 1)
            swapAllBtn:SetBackdropBorderColor(0.0, 0.85, 0.25, 0.7)
            swapAllBtn._fs:SetText("|cffffffffApply All (" .. #actionable .. ")|r")
            swapAllBtn:SetAlpha(1)
            swapAllBtn:EnableMouse(true)
            swapAllBtn:SetScript("OnClick", function()
                if InCombatLockdown() then
                    print("|cff00ff00YippYapp|r: Can't change groups during combat")
                    return
                end
                for _, a in ipairs(applyQueue) do
                    if a.op == "swap" then
                        SwapRaidSubgroup(a.idx1, a.idx2)
                    else
                        SetRaidSubgroup(a.idx, a.targetGroup)
                    end
                end
                -- Entire plan spent; clear the cache so the next
                -- refresh produces a fresh pass for any residual
                -- imbalance (Apply-All isn't a perfect close — a
                -- move that would've over-filled a destination can
                -- silently fail in the game API).
                ns._raidSwap = nil
                C_Timer.After(0.5, function() ns:RefreshRaidOverview() end)
            end)
            swapAllBtn:SetScript("OnEnter", function(self)
                self:SetBackdropColor(0.05, 0.42, 0.18, 1)
                GameTooltip:SetOwner(self, "ANCHOR_LEFT")
                GameTooltip:AddLine("Apply all " .. #actionable .. " changes", 1, 1, 1)
                GameTooltip:Show()
            end)
            swapAllBtn:SetScript("OnLeave", function(self)
                self:SetBackdropColor(0.0, 0.32, 0.12, 1)
                GameTooltip:Hide()
            end)
        else
            swapAllBtn:SetBackdropColor(0.15, 0.15, 0.15, 0.9)
            swapAllBtn:SetBackdropBorderColor(0.4, 0.4, 0.4, 0.6)
            swapAllBtn._fs:SetText("|cff888888Apply All|r")
            swapAllBtn:SetAlpha(0.75)
            swapAllBtn:EnableMouse(true)
            swapAllBtn:SetScript("OnClick", nil)
            swapAllBtn:SetScript("OnEnter", function(self)
                GameTooltip:SetOwner(self, "ANCHOR_LEFT")
                GameTooltip:AddLine("Raid changes disabled", 1, 0.7, 0.7)
                if InCombatLockdown() then
                    GameTooltip:AddLine("In combat", 0.9, 0.9, 0.9)
                elseif not IsInRaid() then
                    GameTooltip:AddLine("Not in a raid group", 0.9, 0.9, 0.9)
                else
                    GameTooltip:AddLine("You need assist or leader rank to move players", 0.9, 0.9, 0.9, true)
                end
                GameTooltip:Show()
            end)
            swapAllBtn:SetScript("OnLeave", function() GameTooltip:Hide() end)
        end

        analysisY = analysisY - SECTION_LINE_H

        -- Shared badge styling for the two name pills. Badges come from the
        -- swap-frame pool which is also used for CENTER-anchored buttons
        -- (Swap All / Refresh / Auto). Their _fs font string survives
        -- between pool uses with whatever anchor the last caller set, so
        -- we always clear + re-anchor here.
        local function StyleBadge(badge, player, anchor)
            badge:SetHeight(20)
            badge:ClearAllPoints()
            if anchor.rel then
                badge:SetPoint("LEFT", anchor.rel, "RIGHT", anchor.dx, 0)
            else
                badge:SetPoint("LEFT", anchor.parent, "LEFT", anchor.x, 0)
            end
            badge:SetBackdrop({
                bgFile   = "Interface\\Buttons\\WHITE8x8",
                edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
                edgeSize = 8, insets = { left = 2, right = 2, top = 2, bottom = 2 },
            })
            badge:SetBackdropColor(0.08, 0.08, 0.08, 0.8)
            local cc = CLASS_COLORS[player.class] or { 0.5, 0.5, 0.5 }
            badge:SetBackdropBorderColor(cc[1] * 0.5, cc[2] * 0.5, cc[3] * 0.5, 0.7)
            if not badge._fs then
                badge._fs = badge:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            end
            badge._fs:ClearAllPoints()
            badge._fs:SetPoint("LEFT", 6, 0)
            badge._fs:SetJustifyH("LEFT")
            badge._fs:SetText(ColorName(player) .. " |cff444444G" .. player.group .. "|r")
            badge:SetWidth(badge._fs:GetStringWidth() + 16)
        end

        for _, s in ipairs(actionable) do
            -- Clickable row. Swap ops render [p1] <> [p2], move ops
            -- render [p1] -> Gn. OnClick dispatches the correct call.
            local rowBtn = AcquireSwapFrame(groupsContainer, "Button")
            rowBtn:SetSize(fw - SECTION_PAD * 2, 24)
            rowBtn:SetPoint("TOPLEFT", groupsContainer, "TOPLEFT", SECTION_PAD, analysisY)
            rowBtn:SetBackdrop({
                bgFile   = "Interface\\Buttons\\WHITE8x8",
                edgeFile = "Interface\\Buttons\\WHITE8x8",
                edgeSize = 1,
                insets   = { left = 0, right = 0, top = 0, bottom = 0 },
            })
            rowBtn:SetBackdropColor(0.05, 0.05, 0.05, 0.0)
            rowBtn:SetBackdropBorderColor(0, 0, 0, 0)

            local isMove  = s.op == "move"
            local p1Name  = s.p1.name
            local p1Grp   = s.p1.group
            local p1Idx   = s.p1.index
            local p2Name  = s.p2 and s.p2.name
            local p2Grp   = s.p2 and s.p2.group
            local p2Idx   = s.p2 and s.p2.index
            local tgtGrp  = s.targetGroup

            rowBtn:SetScript("OnEnter", function(self)
                self:SetBackdropColor(0.10, 0.28, 0.14, 0.5)
                self:SetBackdropBorderColor(0.0, 0.7, 0.25, 0.5)
                if canMove then
                    GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
                    if isMove then
                        GameTooltip:AddLine("Click to move", 1, 1, 1)
                        GameTooltip:AddLine(p1Name .. " (G" .. p1Grp .. ")  ->  Group " .. tgtGrp,
                            0.75, 0.75, 0.75)
                    else
                        GameTooltip:AddLine("Click to swap", 1, 1, 1)
                        GameTooltip:AddLine(p1Name .. " (G" .. p1Grp .. ")  <->  " .. p2Name .. " (G" .. p2Grp .. ")",
                            0.75, 0.75, 0.75)
                    end
                    GameTooltip:Show()
                end
            end)
            rowBtn:SetScript("OnLeave", function(self)
                self:SetBackdropColor(0.05, 0.05, 0.05, 0.0)
                self:SetBackdropBorderColor(0, 0, 0, 0)
                GameTooltip:Hide()
            end)
            if canMove then
                rowBtn:EnableMouse(true)
                if isMove then
                    rowBtn:SetScript("OnClick", function()
                        if InCombatLockdown() then
                            print("|cff00ff00YippYapp|r: Can't change groups during combat")
                            return
                        end
                        SetRaidSubgroup(p1Idx, tgtGrp)
                        if ns._raidSwap and ns._raidSwap.plan then
                            ns.RaidSwap:RemoveFromPlan(ns._raidSwap.plan,
                                "move", p1Name, nil, tgtGrp)
                        end
                        C_Timer.After(0.5, function() ns:RefreshRaidOverview() end)
                    end)
                else
                    rowBtn:SetScript("OnClick", function()
                        if InCombatLockdown() then
                            print("|cff00ff00YippYapp|r: Can't swap during combat")
                            return
                        end
                        SwapRaidSubgroup(p1Idx, p2Idx)
                        if ns._raidSwap and ns._raidSwap.plan then
                            ns.RaidSwap:RemoveFromPlan(ns._raidSwap.plan,
                                "swap", p1Name, p2Name, nil)
                        end
                        C_Timer.After(0.5, function() ns:RefreshRaidOverview() end)
                    end)
                end
            else
                rowBtn:EnableMouse(false)
                rowBtn:SetScript("OnClick", nil)
            end

            local badge1 = AcquireSwapFrame(groupsContainer)
            StyleBadge(badge1, s.p1, { parent = rowBtn, x = 8 })
            badge1:SetFrameLevel(rowBtn:GetFrameLevel() + 1)

            -- Second badge for swaps; group-number pill for moves.
            local arrowFs = GrpAcquire(groupsContainer, "GameFontNormalSmall")
            arrowFs:SetPoint("LEFT", badge1, "RIGHT", 6, 0)
            arrowFs:SetTextColor(unpack(ns.COLORS.TEXT_TERTIARY))
            arrowFs:SetText(isMove and "->" or "<>")

            local lastAnchor
            if isMove then
                -- Small "G{n}" target pill.
                local tgtFs = GrpAcquire(groupsContainer, "GameFontNormalSmall")
                tgtFs:SetPoint("LEFT", arrowFs, "RIGHT", 6, 0)
                tgtFs:SetTextColor(unpack(ns.COLORS.TEXT_PRIMARY))
                tgtFs:SetText("|cffffffffGroup " .. tgtGrp .. "|r")
                lastAnchor = tgtFs
            else
                local badge2 = AcquireSwapFrame(groupsContainer)
                StyleBadge(badge2, s.p2, { rel = arrowFs, dx = 6 })
                badge2:SetFrameLevel(rowBtn:GetFrameLevel() + 1)
                lastAnchor = badge2
            end

            local reasonFs = GrpAcquire(groupsContainer, "GameFontNormalSmall")
            reasonFs:SetPoint("LEFT", lastAnchor, "RIGHT", 10, 0)
            reasonFs:SetTextColor(unpack(ns.COLORS.TEXT_TERTIARY))
            reasonFs:SetText("|cffaaaaaa" .. s.reason .. "|r")

            analysisY = analysisY - 26  -- row height (24) + small gap
        end
    else
        local okFs = GrpAcquire(groupsContainer, "GameFontNormal")
        okFs:SetPoint("TOPLEFT", groupsContainer, "TOPLEFT", SECTION_PAD, analysisY)
        okFs:SetText("|cff00ff00Splits look balanced|r")
        analysisY = analysisY - SECTION_LINE_H
    end

    -- ── Right: PI Assignments (independent Y, anchored to right column) ──
    local fullW = raidFrame:GetWidth() - 28
    local piX = math.floor(fullW * 0.55) + SECTION_PAD
    local piW = fullW - piX + SECTION_PAD
    local piY = grpStartY - 2 * groupBlockH - 4
    piY = piY - 8  -- align with divider

    local piHdr = GrpAcquire(groupsContainer, "GameFontNormal")
    piHdr:SetPoint("TOPLEFT", groupsContainer, "TOPLEFT", piX, piY)
    piHdr:SetText("|c" .. SECTION_HEADER_COLOR .. "PI Targets|r" ..
        (#allPriests == 0 and "  |cffff4444No priests|r" or ""))
    ns.ApplyTextShadow(piHdr)

    -- Refresh splits button (re-runs swap suggestions after PI changes)
    local refreshBtn = AcquireSwapFrame(groupsContainer, "Button")
    refreshBtn:SetSize(60, 18)
    refreshBtn:SetPoint("TOPLEFT", groupsContainer, "TOPLEFT", piX + piW - 68, piY + 1)
    refreshBtn:SetBackdrop({
        bgFile   = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        edgeSize = 8,
        insets   = { left = 2, right = 2, top = 2, bottom = 2 },
    })
    refreshBtn:SetBackdropColor(0.1, 0.1, 0.1, 0.8)
    refreshBtn:SetBackdropBorderColor(0.3, 0.3, 0.3, 0.5)
    ns.SmoothFrame(refreshBtn)
    if not refreshBtn._fs then
        refreshBtn._fs = refreshBtn:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        refreshBtn._fs:SetPoint("CENTER")
    end
    refreshBtn._fs:SetText("|cffaaaaaaRefresh|r")
    refreshBtn:SetScript("OnClick", function()
        -- Explicit "replan from scratch" escape hatch. Flushes the
        -- sticky plan so PI changes or manual roster moves produce a
        -- fresh ranking instead of being reconciled into the old one.
        ns._raidSwap = nil
        ns:RefreshRaidOverview()
    end)
    refreshBtn:SetScript("OnEnter", function(self)
        self:SetBackdropColor(0.15, 0.15, 0.15, 0.9)
        self:SetBackdropBorderColor(0.5, 0.5, 0.5, 0.7)
        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
        GameTooltip:AddLine("Refresh swap suggestions", 1, 1, 1)
        GameTooltip:AddLine("Updates splits based on PI assignments", 0.7, 0.7, 0.7)
        GameTooltip:Show()
    end)
    refreshBtn:SetScript("OnLeave", function(self)
        self:SetBackdropColor(0.1, 0.1, 0.1, 0.8)
        self:SetBackdropBorderColor(0.3, 0.3, 0.3, 0.5)
        GameTooltip:Hide()
    end)

    piY = piY - SECTION_LINE_H + 2

    -- Build DPS target list for PI selection
    local dpsTargets = {}
    for _, p in ipairs(roster) do
        if p.role == "DAMAGER" or p.role == "TANK" then
            table.insert(dpsTargets, p)
        end
    end
    table.sort(dpsTargets, function(a, b) return a.name < b.name end)

    for _, priest in ipairs(allPriests) do
        local btn = AcquirePIButton(groupsContainer)
        btn:SetWidth(math.min(piW - 8, 300))
        btn:SetPoint("TOPLEFT", groupsContainer, "TOPLEFT", piX + 4, piY)

        btn.fs:SetText(ColorName(priest) .. " |cff555555G" .. priest.group .. "|r")

        local currentTarget = ns.PIAssignments[priest.name]
        if currentTarget then
            local tp = FindPlayer(roster, currentTarget)
            if tp then
                btn.targetFs:SetText("-> " .. ColorName(tp))
            else
                btn.targetFs:SetText("-> |cff888888" .. currentTarget .. "|r")
            end
        else
            btn.targetFs:SetText("|cff555555Click to assign|r")
        end

        local priestName = priest.name
        btn:SetScript("OnClick", function(self)
            if piPicker:IsShown() and piPickerPriestName == priestName then
                piPicker:Hide()
            else
                ShowPIPicker(self, priestName, roster)
            end
        end)

        piY = piY - 24
    end
end

------------------------------------------------------------
-- Performance tab (C_DamageMeter API)
------------------------------------------------------------
local perfPool = {}
local perfPoolIdx = 0

local function PerfAcquire(font)
    perfPoolIdx = perfPoolIdx + 1
    local fs = perfPool[perfPoolIdx]
    if not fs then
        fs = perfContainer:CreateFontString(nil, "OVERLAY", font or "GameFontNormal")
        perfPool[perfPoolIdx] = fs
    else
        fs:SetFontObject(font or "GameFontNormal")
        fs:SetTextColor(1, 1, 1)
    end
    fs:ClearAllPoints()
    fs:SetWordWrap(false)
    fs:SetWidth(raidFrame:GetWidth() - 28)
    fs:SetJustifyH("LEFT")
    fs:Show()
    return fs
end

local function FormatDPS(amount)
    if amount >= 1000000 then
        return string.format("%.1fM", amount / 1000000)
    elseif amount >= 1000 then
        return string.format("%.1fK", amount / 1000)
    end
    return tostring(math.floor(amount))
end

-- C_DamageMeter returns "secret/tainted" numbers that throw on arithmetic.
-- Laundering via tostring→tonumber produces a fresh untainted number.
local function Launder(n)
    if type(n) == "number" then return tonumber(tostring(n)) end
    return n
end

local function GetCombatData(sessionType, meterType)
    if not C_DamageMeter or not C_DamageMeter.GetCombatSessionFromType then
        return nil
    end
    local ok, session = pcall(C_DamageMeter.GetCombatSessionFromType, sessionType, meterType)
    if not ok or not session then return nil end

    -- Build a laundered shallow copy so we can freely do arithmetic downstream.
    local clean = {
        durationSeconds = Launder(session.durationSeconds),
        combatSources = {},
    }
    if session.combatSources then
        for _, src in ipairs(session.combatSources) do
            table.insert(clean.combatSources, {
                name            = src.name,
                classFilename   = src.classFilename,
                amountPerSecond = Launder(src.amountPerSecond),
                totalAmount     = Launder(src.totalAmount),
                maxAmount       = Launder(src.maxAmount),
            })
        end
    end
    return clean
end

function ns:RefreshRaidPerformance()
    for i = 1, perfPoolIdx do perfPool[i]:Hide() end
    perfPoolIdx = 0

    local y = -8

    -- Try Overall first, fallback to Current
    local SESSION_OVERALL = 0
    local SESSION_CURRENT = 1
    local TYPE_DPS = 1
    local TYPE_DAMAGE_TAKEN = 7
    local TYPE_AVOIDABLE = 8
    local TYPE_DEATHS = 9
    local TYPE_INTERRUPTS = 5

    local dpsSession = GetCombatData(SESSION_CURRENT, TYPE_DPS)
        or GetCombatData(SESSION_OVERALL, TYPE_DPS)

    if not dpsSession or not dpsSession.combatSources or #dpsSession.combatSources == 0 then
        local fs = PerfAcquire("GameFontNormal")
        fs:SetPoint("TOPLEFT", SECTION_PAD, y)
        fs:SetText("|cff888888No combat data available|r")
        local fs2 = PerfAcquire("GameFontNormalSmall")
        fs2:SetPoint("TOPLEFT", SECTION_PAD, y - SECTION_LINE_H)
        fs2:SetTextColor(unpack(ns.COLORS.TEXT_SECONDARY))
        fs2:SetText("Data appears after combat (requires Blizzard Damage Meter enabled)")
        return
    end

    local fw = raidFrame:GetWidth() - 28
    local leftW = math.floor(fw * 0.5)
    local rightX = leftW + SECTION_PAD

    -- ── LEFT: DPS Rankings + Split DPS ──
    local hdr = PerfAcquire("GameFontNormal")
    hdr:SetPoint("TOPLEFT", SECTION_PAD, y)
    ns.ApplyTextShadow(hdr)

    local duration = dpsSession.durationSeconds
    local durStr = duration and string.format(" |cff555555(%dm %ds)|r", math.floor(duration / 60), duration % 60) or ""
    hdr:SetText("|c" .. SECTION_HEADER_COLOR .. "DPS|r" .. durStr)
    y = y - SECTION_LINE_H

    -- Sort by DPS descending
    local dpsList = {}
    for _, src in ipairs(dpsSession.combatSources) do
        if src.name and src.classFilename then
            table.insert(dpsList, src)
        end
    end
    table.sort(dpsList, function(a, b) return a.amountPerSecond > b.amountPerSecond end)

    -- Calculate split DPS from ALL players (not just top 10)
    local roster = GetRaidRoster()
    local splitDPS = { A = 0, B = 0 }
    local splitCount = { A = 0, B = 0 }
    local playerGroups = {}
    for _, p in ipairs(roster) do
        playerGroups[p.name] = p.group
    end

    for _, src in ipairs(dpsList) do
        local name = src.name:match("^([^%-]+)") or src.name
        local grp = playerGroups[name]
        if grp then
            local split = (grp % 2 == 1) and "A" or "B"
            splitDPS[split] = splitDPS[split] + src.amountPerSecond
            splitCount[split] = splitCount[split] + 1
        end
    end

    -- Show top 10 DPS
    local maxDPS = dpsList[1] and dpsList[1].amountPerSecond or 1
    for i = 1, math.min(#dpsList, 10) do
        local src = dpsList[i]
        local cc = CLASS_COLORS[src.classFilename] or { 0.7, 0.7, 0.7 }
        local hex = string.format("ff%02x%02x%02x", cc[1] * 255, cc[2] * 255, cc[3] * 255)

        local fs = PerfAcquire("GameFontNormalSmall")
        fs:SetPoint("TOPLEFT", SECTION_PAD + 4, y)
        fs:SetWidth(leftW - 8)
        local name = src.name:match("^([^%-]+)") or src.name
        fs:SetText(string.format("|c%s%s|r  %s", hex, name, FormatDPS(src.amountPerSecond)))
        y = y - 14
    end

    if #dpsList > 10 then
        local mfs = PerfAcquire("GameFontNormalSmall")
        mfs:SetPoint("TOPLEFT", SECTION_PAD + 4, y)
        mfs:SetTextColor(unpack(ns.COLORS.TEXT_TERTIARY))
        mfs:SetText("+" .. (#dpsList - 10) .. " more")
        y = y - 14
    end

    -- Split DPS comparison (average per player)
    y = y - 4
    local avgA = splitCount.A > 0 and (splitDPS.A / splitCount.A) or 0
    local avgB = splitCount.B > 0 and (splitDPS.B / splitCount.B) or 0
    local avgDiff = math.abs(avgA - avgB)
    local avgTotal = avgA + avgB
    local imbalanced = avgTotal > 0 and (avgDiff / avgTotal) > 0.15

    local splitFs = PerfAcquire("GameFontNormal")
    splitFs:SetPoint("TOPLEFT", SECTION_PAD, y)
    ns.ApplyTextShadow(splitFs)
    local splitColor = imbalanced and "|cffff4444" or "|cff00ff00"
    splitFs:SetText("|c" .. SECTION_HEADER_COLOR .. "Avg DPS per player|r")
    y = y - SECTION_LINE_H

    local splitDetail = PerfAcquire("GameFontNormal")
    splitDetail:SetPoint("TOPLEFT", SECTION_PAD + 4, y)
    splitDetail:SetText(
        "A (1,3,5): " .. FormatDPS(avgA) .. " |cff555555(" .. splitCount.A .. " players)|r   " ..
        "B (2,4,6): " .. FormatDPS(avgB) .. " |cff555555(" .. splitCount.B .. " players)|r" ..
        (imbalanced and ("  " .. splitColor .. math.floor(avgDiff / avgTotal * 100) .. "% diff|r") or ""))

    -- ── RIGHT COLUMN: Avoidable damage, Deaths, Interrupts ──
    local ry = -8

    -- Avoidable Damage
    local avoidSession = GetCombatData(SESSION_CURRENT, TYPE_AVOIDABLE)
        or GetCombatData(SESSION_OVERALL, TYPE_AVOIDABLE)
    if avoidSession and avoidSession.combatSources and #avoidSession.combatSources > 0 then
        local ahdr = PerfAcquire("GameFontNormal")
        ahdr:SetPoint("TOPLEFT", rightX, ry)
        ahdr:SetText("|c" .. SECTION_HEADER_COLOR .. "Avoidable Damage Taken|r")
        ns.ApplyTextShadow(ahdr)
        ry = ry - SECTION_LINE_H

        local avoidList = {}
        for _, src in ipairs(avoidSession.combatSources) do
            if src.name and src.totalAmount > 0 then
                table.insert(avoidList, src)
            end
        end
        table.sort(avoidList, function(a, b) return a.totalAmount > b.totalAmount end)

        for i = 1, math.min(#avoidList, 5) do
            local src = avoidList[i]
            local cc = CLASS_COLORS[src.classFilename] or { 0.7, 0.7, 0.7 }
            local hex = string.format("ff%02x%02x%02x", cc[1] * 255, cc[2] * 255, cc[3] * 255)
            local name = src.name:match("^([^%-]+)") or src.name
            local afs = PerfAcquire("GameFontNormalSmall")
            afs:SetPoint("TOPLEFT", rightX + 4, ry)
            afs:SetWidth(fw - leftW - 8)
            afs:SetText(string.format("|cffff4444%s|r  |c%s%s|r", FormatDPS(src.totalAmount), hex, name))
            ry = ry - 14
        end
        ry = ry - 4
    end

    -- Deaths
    local deathSession = GetCombatData(SESSION_CURRENT, TYPE_DEATHS)
        or GetCombatData(SESSION_OVERALL, TYPE_DEATHS)
    if deathSession and deathSession.combatSources and #deathSession.combatSources > 0 then
        local dhdr = PerfAcquire("GameFontNormal")
        dhdr:SetPoint("TOPLEFT", rightX, ry)
        dhdr:SetText("|c" .. SECTION_HEADER_COLOR .. "Deaths|r")
        ns.ApplyTextShadow(dhdr)
        ry = ry - SECTION_LINE_H

        local deathList = {}
        for _, src in ipairs(deathSession.combatSources) do
            if src.name and src.totalAmount > 0 then
                table.insert(deathList, src)
            end
        end
        table.sort(deathList, function(a, b) return a.totalAmount > b.totalAmount end)

        for i = 1, math.min(#deathList, 5) do
            local src = deathList[i]
            local cc = CLASS_COLORS[src.classFilename] or { 0.7, 0.7, 0.7 }
            local hex = string.format("ff%02x%02x%02x", cc[1] * 255, cc[2] * 255, cc[3] * 255)
            local name = src.name:match("^([^%-]+)") or src.name
            local dfs = PerfAcquire("GameFontNormalSmall")
            dfs:SetPoint("TOPLEFT", rightX + 4, ry)
            dfs:SetWidth(fw - leftW - 8)
            dfs:SetText(string.format("|cffff4444%dx|r  |c%s%s|r", src.totalAmount, hex, name))
            ry = ry - 14
        end
        ry = ry - 4
    end

    -- Interrupts
    local intSession = GetCombatData(SESSION_CURRENT, TYPE_INTERRUPTS)
        or GetCombatData(SESSION_OVERALL, TYPE_INTERRUPTS)
    if intSession and intSession.combatSources and #intSession.combatSources > 0 then
        local ihdr = PerfAcquire("GameFontNormal")
        ihdr:SetPoint("TOPLEFT", rightX, ry)
        ihdr:SetText("|c" .. SECTION_HEADER_COLOR .. "Interrupts|r")
        ns.ApplyTextShadow(ihdr)
        ry = ry - SECTION_LINE_H

        local intList = {}
        for _, src in ipairs(intSession.combatSources) do
            if src.name and src.totalAmount > 0 then
                table.insert(intList, src)
            end
        end
        table.sort(intList, function(a, b) return a.totalAmount > b.totalAmount end)

        for i = 1, math.min(#intList, 5) do
            local src = intList[i]
            local cc = CLASS_COLORS[src.classFilename] or { 0.7, 0.7, 0.7 }
            local hex = string.format("ff%02x%02x%02x", cc[1] * 255, cc[2] * 255, cc[3] * 255)
            local name = src.name:match("^([^%-]+)") or src.name
            local ifs = PerfAcquire("GameFontNormalSmall")
            ifs:SetPoint("TOPLEFT", rightX + 4, ry)
            ifs:SetWidth(fw - leftW - 8)
            ifs:SetText(string.format("|cff00aaff%d|r  |c%s%s|r", src.totalAmount, hex, name))
            ry = ry - 14
        end
    end
end

------------------------------------------------------------
-- Refresh tier display
------------------------------------------------------------
local tierRowPool = {}
local tierRowPoolIdx = 0

local function AcquireTierRow()
    tierRowPoolIdx = tierRowPoolIdx + 1
    local row = tierRowPool[tierRowPoolIdx]
    if not row then
        row = CreateFrame("Frame", nil, scrollContent)
        row:SetSize(RAID_WIDTH - 44, ROW_HEIGHT)
        row._bg = row:CreateTexture(nil, "BACKGROUND")
        row._bg:SetAllPoints()
        row._nameStr = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        row._nameStr:SetPoint("LEFT", 2, 0)
        row._nameStr:SetWidth(120)
        row._nameStr:SetJustifyH("LEFT")
        row._nameStr:SetWordWrap(false)
        row._tierStr = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        row._tierStr:SetPoint("LEFT", 126, 0)
        row._tierStr:SetWidth(70)
        row._tierStr:SetJustifyH("CENTER")
        row._piecesStr = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        row._piecesStr:SetPoint("LEFT", row, "LEFT", 200, 0)
        row._piecesStr:SetWidth(140)
        row._piecesStr:SetJustifyH("LEFT")
        row._piecesStr:SetWordWrap(false)
        row._piecesStr:SetTextColor(0.6, 0.6, 0.6)
        tierRowPool[tierRowPoolIdx] = row
    end
    row:ClearAllPoints()
    row:Show()
    return row
end

function ns:RefreshRaidDisplay()
    for i = 1, tierRowPoolIdx do tierRowPool[i]:Hide() end
    tierRowPoolIdx = 0
    -- Also hide legacy rows if any
    for _, row in ipairs(ns.RaidRows) do row:Hide() end
    wipe(ns.RaidRows)

    local sorted = ns:GetSortedTierData()
    local summary = ns:GetTierSummary()

    summaryText:SetText(string.format(
        "%d players scanned | 2-set: |cff00ff00%d|r | 4-set: |cff00ff00%d|r | No tier: |cffff4444%d|r",
        summary.total, summary.with2set, summary.with4set, summary.counts[0] or 0
    ))

    local yOffset = 0
    for i, data in ipairs(sorted) do
        local row = AcquireTierRow()
        row:SetPoint("TOPLEFT", 0, -yOffset)

        if i % 2 == 0 then
            row._bg:SetColorTexture(1, 1, 1, 0.03)
            row._bg:Show()
        else
            row._bg:Hide()
        end

        local cc = CLASS_COLORS[data.class] or { 1, 1, 1 }
        row._nameStr:SetTextColor(cc[1], cc[2], cc[3])

        local displayName = data.name
        if data.tierCount >= 4 then
            displayName = displayName .. "  |cff888888PASS|r"
        end
        row._nameStr:SetText(displayName)

        local tc = TIER_COUNT_COLORS[data.tierCount] or { 1, 1, 1 }
        row._tierStr:SetTextColor(tc[1], tc[2], tc[3])

        local tierLabel = data.tierCount .. "/5"
        if data.tierCount >= 4 then
            tierLabel = tierLabel .. " (4-set)"
        elseif data.tierCount >= 2 then
            tierLabel = tierLabel .. " (2-set)"
        end
        row._tierStr:SetText(tierLabel)

        if data.tierCount >= 4 then
            row._piecesStr:SetText("max")
        elseif #data.tierSlots > 0 then
            row._piecesStr:SetText(table.concat(data.tierSlots, ", "))
        else
            row._piecesStr:SetText("--")
        end

        yOffset = yOffset + ROW_HEIGHT
    end

    scrollContent:SetHeight(math.max(yOffset, 1))
end

------------------------------------------------------------
-- Auto-refresh on roster changes
------------------------------------------------------------
local rosterEventFrame = CreateFrame("Frame")
rosterEventFrame:RegisterEvent("GROUP_ROSTER_UPDATE")
rosterEventFrame:SetScript("OnEvent", function()
    -- Only refresh if the raid frame is visible and on the overview tab
    if raidFrame:IsShown() and currentTab == "overview" then
        ns:RefreshRaidOverview()
    end
end)
