local _, ns = ...
local SlickUI = ns.SlickUI
local Theme = SlickUI.Theme
local oUF = ns.oUF

local GroupFrames = {
	PendingUpdate = false,
	PreviewVisible = false,
	PreviewFrames = {},
	RoleAnchors = {},
	LiveFrames = setmetatable({}, { __mode = "k" }),
}
SlickUI.GroupFrames = GroupFrames

local function GetConfig()
	return SlickUI.DB.unitFrames.group
end

local function CanReadValue(value)
	if canaccessvalue and not canaccessvalue(value) then return false end
	if issecretvalue and issecretvalue(value) then return false end
	return true
end

local ResurrectionSpells = {
	DRUID = { battle = 20484, normal = 50769 }, -- Rebirth / Revive
	DEATHKNIGHT = { battle = 61999 }, -- Raise Ally
	WARLOCK = { battle = 20707 }, -- Soulstone
	PALADIN = { battle = 391054, normal = 7328 }, -- Intercession / Redemption
	PRIEST = { normal = 2006 }, -- Resurrection
	SHAMAN = { normal = 2008 }, -- Ancestral Spirit
	MONK = { normal = 115178 }, -- Resuscitate
	EVOKER = { normal = 361227 }, -- Return
}

local function IsLearnedSpell(spellID)
	if not spellID then return false end
	if SlickUI.Alerts and type(SlickUI.Alerts.IsKnownSpell) == "function" then
		return SlickUI.Alerts:IsKnownSpell(spellID)
	end
	if type(IsPlayerSpell) == "function" then
		local ok, known = pcall(IsPlayerSpell, spellID)
		return ok and CanReadValue(known) and known == true
	end
	return false
end

local function GetLearnedSpellName(spellID)
	if not IsLearnedSpell(spellID) then return nil end
	if C_Spell and type(C_Spell.GetSpellName) == "function" then
		local ok, name = pcall(C_Spell.GetSpellName, spellID)
		if ok and CanReadValue(name) and type(name) == "string" then return name end
	end
	if type(GetSpellInfo) == "function" then
		local ok, name = pcall(GetSpellInfo, spellID)
		if ok and CanReadValue(name) and type(name) == "string" then return name end
	end
end

local function GetResurrectionMacroText()
	local _, classToken = UnitClass("player")
	local spells = ResurrectionSpells[classToken]
	if not spells then return nil end
	local battleName = GetLearnedSpellName(spells.battle)
	local normalName = GetLearnedSpellName(spells.normal)
	if not battleName and not normalName then return nil end
	local clauses = {}
	if battleName then
		clauses[#clauses + 1] = normalName
			and ("[@mouseover,help,dead,combat] " .. battleName)
			or ("[@mouseover,help,dead] " .. battleName)
	end
	if normalName then
		clauses[#clauses + 1] = "[@mouseover,help,dead,nocombat] " .. normalName
	end
	return "/cast " .. table.concat(clauses, "; ") .. "\n/target [@mouseover,help,nodead]"
end

local function ApplyResurrectionClick(frame)
	if not frame or InCombatLockdown() then return end
	local macroText = GetResurrectionMacroText()
	if macroText then
		frame:SetAttribute("*type1", "macro")
		frame:SetAttribute("macrotext1", macroText)
		frame:SetAttribute("*macrotext1", macroText)
	else
		frame:SetAttribute("*type1", "target")
		frame:SetAttribute("macrotext1", nil)
		frame:SetAttribute("*macrotext1", nil)
	end
end

local DispelOutlineCurve = C_CurveUtil.CreateColorCurve()
DispelOutlineCurve:SetType(Enum.LuaCurveType.Step)
DispelOutlineCurve:AddPoint(oUF.Enum.DispelType.None, oUF:CreateColor(0, 0, 0, 0))
DispelOutlineCurve:AddPoint(oUF.Enum.DispelType.Magic, oUF:CreateColor(0.18, 0.58, 1.00, 1))
DispelOutlineCurve:AddPoint(oUF.Enum.DispelType.Curse, oUF:CreateColor(0.72, 0.26, 1.00, 1))
DispelOutlineCurve:AddPoint(oUF.Enum.DispelType.Disease, oUF:CreateColor(1.00, 0.82, 0.12, 1))
DispelOutlineCurve:AddPoint(oUF.Enum.DispelType.Poison, oUF:CreateColor(0.18, 1.00, 0.28, 1))
DispelOutlineCurve:AddPoint(oUF.Enum.DispelType.Enrage, oUF:CreateColor(1.00, 0.38, 0.12, 1))
DispelOutlineCurve:AddPoint(oUF.Enum.DispelType.Bleed, oUF:CreateColor(1.00, 0.12, 0.18, 1))

local DispelTypeIcons = {
	Magic = "Interface\\Icons\\Spell_Holy_DispelMagic",
	Curse = "Interface\\Icons\\Spell_Nature_RemoveCurse",
	Disease = "Interface\\Icons\\Spell_Nature_NullifyDisease",
	Poison = "Interface\\Icons\\Spell_Nature_NullifyPoison",
	Enrage = "Interface\\Icons\\Ability_Hunter_BeastSoothe",
	Bleed = "Interface\\Icons\\Ability_Rogue_BloodSplatter",
}

function GroupFrames:GetDispelTypeAndColor(unit, auraInstanceID)
	local color = C_UnitAuras.GetAuraDispelTypeColor(unit, auraInstanceID, DispelOutlineCurve)
	if not color then return nil, nil end
	-- The color channels may be secret numbers in combat. They can be passed
	-- directly to restricted-safe texture methods, but must never be compared or
	-- used in Lua arithmetic to reverse-engineer the dispel type.
	return nil, color
end

local function SetDispellableOutlineShown(frame, shown)
	for _, edge in ipairs(frame.SlickDispellableOutline or {}) do
		edge:SetShown(shown)
	end
	if frame.SlickDispellableMarker then
		frame.SlickDispellableMarker:SetShown(shown)
	end
end

local function SetDispellableOutlineThickness(frame, thickness)
	local edges = frame.SlickDispellableOutline
	if not edges then return end
	edges[1]:SetHeight(thickness)
	edges[2]:SetHeight(thickness)
	edges[3]:SetWidth(thickness)
	edges[4]:SetWidth(thickness)
end

local function UpdateDispellableDebuffOutline(self, event, unit)
	if event == "UNIT_AURA" and unit ~= self.unit then
		return
	end
	if not GetConfig().showDispellableDebuffs or not self.unit then
		SetDispellableOutlineShown(self, false)
		return
	end
	if select(4, GetBuildInfo()) >= 120100 then SetDispellableOutlineShown(self, false); return end
	if InCombatLockdown() then return end
	local capabilities = SlickUI.CleanseCapabilities
	if not capabilities or not capabilities:HasAny() then
		SetDispellableOutlineShown(self, false)
		return
	end

	local slots = { C_UnitAuras.GetAuraSlots(self.unit, "HARMFUL|RAID") }
	local color
	local auraIcon
	local dispelTypeIcon
	local selectedType
	for index = 2, #slots do
		local data = C_UnitAuras.GetAuraDataBySlot(self.unit, slots[index])
		if data then
			local resolvedType
			resolvedType, color = GroupFrames:GetDispelTypeAndColor(self.unit, data.auraInstanceID)
			if color then
				if CanReadValue(data.dispelName) and type(data.dispelName) == "string" then
					resolvedType = data.dispelName
				end
				if resolvedType and not capabilities:CanCleanse(resolvedType) then
					color = nil
				else
					selectedType = resolvedType
					dispelTypeIcon = DispelTypeIcons[resolvedType]
					if CanReadValue(data.icon) and (type(data.icon) == "number" or type(data.icon) == "string") then
						auraIcon = data.icon
					end
					break
				end
			end
		end
	end

	if color then
		local red, green, blue, alpha = color:GetRGBA()
		for _, edge in ipairs(self.SlickDispellableOutline) do
			edge:SetColorTexture(red, green, blue, alpha)
			edge:Show()
		end
		local marker = self.SlickDispellableMarker
		-- Prefer the stable classification icon. The original aura texture can be
		-- protected during combat, which previously produced a misleading generic
		-- magic icon for poisons such as Poison Juice.
		marker.Icon:SetTexture(capabilities:GetIcon(selectedType) or dispelTypeIcon or auraIcon or DispelTypeIcons.Magic)
		for _, edge in ipairs(marker.SlickEdges) do
			edge:SetColorTexture(red, green, blue, 1)
		end
		marker:Show()
	else
		SetDispellableOutlineShown(self, false)
	end
end

local function CreateDispellableMarker(frame, config)
	local size = math.min(28, math.max(20, config.height - 6))
	local marker = CreateFrame("Frame", nil, frame)
	marker:SetSize(size, size)
	marker:SetPoint("CENTER", frame, "CENTER", 0, 0)
	marker:SetFrameLevel(frame:GetFrameLevel() + 18)

	local background = marker:CreateTexture(nil, "BACKGROUND")
	background:SetAllPoints()
	background:SetColorTexture(0.015, 0.018, 0.025, 0.96)

	local icon = marker:CreateTexture(nil, "ARTWORK")
	icon:SetPoint("TOPLEFT", 3, -3)
	icon:SetPoint("BOTTOMRIGHT", -3, 3)
	icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
	marker.Icon = icon

	local edges = {}
	local top = marker:CreateTexture(nil, "OVERLAY", nil, 6)
	top:SetPoint("TOPLEFT")
	top:SetPoint("TOPRIGHT")
	top:SetHeight(3)
	edges[1] = top
	local bottom = marker:CreateTexture(nil, "OVERLAY", nil, 6)
	bottom:SetPoint("BOTTOMLEFT")
	bottom:SetPoint("BOTTOMRIGHT")
	bottom:SetHeight(3)
	edges[2] = bottom
	local left = marker:CreateTexture(nil, "OVERLAY", nil, 6)
	left:SetPoint("TOPLEFT")
	left:SetPoint("BOTTOMLEFT")
	left:SetWidth(3)
	edges[3] = left
	local right = marker:CreateTexture(nil, "OVERLAY", nil, 6)
	right:SetPoint("TOPRIGHT")
	right:SetPoint("BOTTOMRIGHT")
	right:SetWidth(3)
	edges[4] = right
	marker.SlickEdges = edges

	local pulse = marker:CreateAnimationGroup()
	local fade = pulse:CreateAnimation("Alpha")
	fade:SetFromAlpha(1)
	fade:SetToAlpha(0.72)
	fade:SetDuration(0.55)
	fade:SetSmoothing("IN_OUT")
	pulse:SetLooping("BOUNCE")
	pulse:Play()
	marker.Pulse = pulse
	marker:Hide()
	frame.SlickDispellableMarker = marker
	return marker
end

local function CreateDispellableDebuffOutline(frame, config)
	local outline = {}
	local top = frame:CreateTexture(nil, "OVERLAY", nil, 6)
	top:SetPoint("TOPLEFT")
	top:SetPoint("TOPRIGHT")
	outline[1] = top
	local bottom = frame:CreateTexture(nil, "OVERLAY", nil, 6)
	bottom:SetPoint("BOTTOMLEFT")
	bottom:SetPoint("BOTTOMRIGHT")
	outline[2] = bottom
	local left = frame:CreateTexture(nil, "OVERLAY", nil, 6)
	left:SetPoint("TOPLEFT")
	left:SetPoint("BOTTOMLEFT")
	outline[3] = left
	local right = frame:CreateTexture(nil, "OVERLAY", nil, 6)
	right:SetPoint("TOPRIGHT")
	right:SetPoint("BOTTOMRIGHT")
	outline[4] = right
	frame.SlickDispellableOutline = outline
	CreateDispellableMarker(frame, config)
	SetDispellableOutlineThickness(frame, math.min(6, math.max(4, math.floor(config.height / 8))))
	SetDispellableOutlineShown(frame, false)
	frame.SlickUpdateDispellableOutline = UpdateDispellableDebuffOutline
	frame:RegisterEvent("UNIT_AURA", UpdateDispellableDebuffOutline)
	frame:RegisterEvent("GROUP_ROSTER_UPDATE", UpdateDispellableDebuffOutline, true)
	frame:RegisterEvent("PLAYER_SPECIALIZATION_CHANGED", UpdateDispellableDebuffOutline, true)
	frame:RegisterEvent("SPELLS_CHANGED", UpdateDispellableDebuffOutline, true)
	frame:RegisterEvent("PLAYER_ENTERING_WORLD", UpdateDispellableDebuffOutline, true)
end

local PERSONAL_EFFECT_MAX_ICONS = 6

-- The group info strip is intentionally a healer-maintained-effect display,
-- not a second general buff row. Keep this list explicit so food, flasks,
-- world buffs, movement effects, and other temporary auras never crowd out
-- the healing information a researcher or healer needs at a glance.
local PersonalHealingEffectSpellIDs = {
	-- Druid
	[774] = true,       -- Rejuvenation
	[8936] = true,      -- Regrowth
	[33763] = true,     -- Lifebloom
	[48438] = true,     -- Wild Growth
	[102352] = true,    -- Cenarion Ward
	[155777] = true,    -- Germination
	[200389] = true,    -- Cultivation
	[207386] = true,    -- Spring Blossoms
	-- Priest
	[17] = true,        -- Power Word: Shield
	[139] = true,       -- Renew
	[33076] = true,     -- Prayer of Mending
	[41635] = true,     -- Prayer of Mending (aura)
	[194384] = true,    -- Atonement
	[33206] = true,     -- Pain Suppression
	[47788] = true,     -- Guardian Spirit
	[77489] = true,     -- Echo of Light
	-- Paladin
	[53563] = true,     -- Beacon of Light
	[6940] = true,      -- Blessing of Sacrifice
	[1022] = true,      -- Blessing of Protection
	[156910] = true,    -- Beacon of Faith
	[200025] = true,    -- Beacon of Virtue
	-- Shaman
	[974] = true,       -- Earth Shield
	[61295] = true,     -- Riptide
	[51945] = true,     -- Earthliving
	-- Monk
	[115175] = true,    -- Soothing Mist
	[116849] = true,    -- Life Cocoon
	[119611] = true,    -- Renewing Mist
	[124682] = true,    -- Enveloping Mist
	[191840] = true,    -- Essence Font
	-- Evoker
	[355941] = true,    -- Dream Breath
	[357170] = true,    -- Time Dilation
	[364343] = true,    -- Echo
	[366155] = true,    -- Reversion
	[373267] = true,    -- Lifebind
	-- Warlock
	[20707] = true,     -- Soulstone
}

local PersonalHealingEffectSpellList = {}
for spellID in pairs(PersonalHealingEffectSpellIDs) do
	PersonalHealingEffectSpellList[#PersonalHealingEffectSpellList + 1] = spellID
end
table.sort(PersonalHealingEffectSpellList)

-- Healing channels are not consistently exposed as helpful auras on their
-- recipient. Track only channels that represent a maintained healing effect;
-- ordinary casts continue to use the explicit aura list above.
local PersonalHealingChannelSpellIDs = {
	[115175] = true,    -- Soothing Mist
}

local function GetReadableUnitGUID(unit)
	if not unit or not UnitExists(unit) then return nil end
	local guid = UnitGUID(unit)
	if not CanReadValue(guid) or type(guid) ~= "string" then return nil end
	return guid
end

local function UnitMatchesCastTarget(unit, targetName)
	if not unit or not UnitExists(unit) or type(targetName) ~= "string" then return false end
	local name, realm = UnitName(unit)
	if not CanReadValue(name) or type(name) ~= "string" then return false end
	if targetName == name then return true end
	if CanReadValue(realm) and type(realm) == "string" and realm ~= "" then
		return targetName == (name .. "-" .. realm)
	end
	return false
end

local function ResolveCastTargetGUID(targetName)
	if not CanReadValue(targetName) or type(targetName) ~= "string" or targetName == "" then return nil end
	local preferred = { "mouseover", "target", "focus", "player" }
	for _, unit in ipairs(preferred) do
		if UnitMatchesCastTarget(unit, targetName) then return GetReadableUnitGUID(unit) end
	end
	local prefix = IsInRaid() and "raid" or "party"
	local count = IsInRaid() and GetNumGroupMembers() or GetNumSubgroupMembers()
	for index = 1, count do
		local unit = prefix .. index
		if UnitMatchesCastTarget(unit, targetName) then return GetReadableUnitGUID(unit) end
	end
end

function GroupFrames:RefreshPersonalHealingEffects()
	for frame in pairs(self.LiveFrames) do
		if frame.SlickUpdatePersonalHealingEffects then
			frame:SlickUpdatePersonalHealingEffects("ChannelUpdate")
		end
	end
end

local function PackAuraSlots(...)
	return { count = select("#", ...), ... }
end

local function GetPersonalEffectIconSize(config)
	return math.min(13, math.max(9, math.floor((config.height - config.powerHeight - 6) * 0.44)))
end

local function GetEffectiveFrameHeight(config)
	if not config.showPersonalHealingEffects and not config.showCastbar then return config.height end
	-- Two pixels pad the icons and two pixels separate this added row from the
	-- power bar. The configured frame height still belongs entirely to the
	-- original health-and-power portion of the frame.
	return config.height + GetPersonalEffectIconSize(config) + 4
end

local function IsTrackedPersonalHealingEffect(data)
	if not data then return false end
	local spellID = data.spellId
	if not CanReadValue(spellID) or type(spellID) ~= "number" then return false end
	return PersonalHealingEffectSpellIDs[spellID] == true
end

local function ConfigurePersonalHealingEffects(frame)
	local bar = frame and frame.SlickPersonalHealingEffects
	if not bar then return end
	local config = GetConfig()
	local iconSize = GetPersonalEffectIconSize(config)
	local available = math.max(1, math.floor((config.width - 8) / (iconSize + 1)))
	bar.IconSize = iconSize
	bar.MaxIcons = math.min(PERSONAL_EFFECT_MAX_ICONS, available)
	bar:SetHeight(iconSize + 2)
	bar:ClearAllPoints()
	bar:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 2, 2)
	bar:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -2, 2)
	for index, icon in ipairs(bar.Icons) do
		icon:SetSize(iconSize, iconSize)
		icon:ClearAllPoints()
		icon:SetPoint("LEFT", bar, "LEFT", 1 + ((index - 1) * (iconSize + 1)), 0)
	end
	frame.Power:ClearAllPoints()
	if config.showPersonalHealingEffects or config.showCastbar then
		frame.Power:SetPoint("BOTTOMLEFT", bar, "TOPLEFT", 0, 2)
		frame.Power:SetPoint("BOTTOMRIGHT", bar, "TOPRIGHT", 0, 2)
		bar:Show()
	else
		frame.Power:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 2, 2)
		frame.Power:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -2, 2)
		bar:Hide()
	end
end

local function SetGroupCastbarSwap(castbar, casting)
	local frame = castbar and (castbar.__owner or castbar:GetParent())
	local infoBar = frame and frame.SlickPersonalHealingEffects
	if not infoBar then return end
	infoBar.SlickCasting = casting == true
	if infoBar.SlickCasting then infoBar:Show() end
	for _, icon in ipairs(infoBar.Icons or {}) do
		icon:SetShown(not infoBar.SlickCasting and icon.SlickEffectVisible == true)
	end
	if not infoBar.SlickCasting and frame.SlickUpdatePersonalHealingEffects then
		frame:SlickUpdatePersonalHealingEffects("CastbarUpdate")
	end
end

local function CreateGroupCastbar(frame, infoBar)
	local castbar = CreateFrame("StatusBar", nil, infoBar)
	castbar:SetAllPoints(infoBar)
	castbar:SetStatusBarTexture(Theme.Texture)
	castbar:SetStatusBarColor(unpack(Theme.Colors.cast))
	castbar:SetFrameLevel(infoBar:GetFrameLevel() + 2)

	local icon = castbar:CreateTexture(nil, "ARTWORK")
	icon:SetSize(10, 10)
	icon:SetPoint("LEFT", castbar, "LEFT", 1, 0)
	icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
	castbar.Icon = icon

	local text = Theme:CreateText(castbar, 8)
	text:SetPoint("LEFT", icon, "RIGHT", 3, 0)
	text:SetPoint("RIGHT", castbar, "RIGHT", -29, 0)
	text:SetJustifyH("LEFT")
	castbar.Text = text

	local time = Theme:CreateText(castbar, 8)
	time:SetPoint("RIGHT", castbar, "RIGHT", -3, 0)
	castbar.Time = time

	local spark = castbar:CreateTexture(nil, "OVERLAY")
	spark:SetTexture("Interface\\CastingBar\\UI-CastingBar-Spark")
	spark:SetSize(7, 16)
	spark:SetBlendMode("ADD")
	castbar.Spark = spark

	castbar.PostCastStart = function(element)
		element:SetStatusBarColor(unpack(Theme.Colors.cast))
		SetGroupCastbarSwap(element, true)
	end
	castbar.PostCastStop = function(element) SetGroupCastbarSwap(element, false) end
	castbar.PostCastFail = function(element) SetGroupCastbarSwap(element, false) end
	castbar.PostCastInterrupted = function(element) SetGroupCastbarSwap(element, false) end
	castbar.PostCastInterruptible = function(element)
		if element.notInterruptible == true then
			element:SetStatusBarColor(unpack(Theme.Colors.castProtected))
		else
			element:SetStatusBarColor(unpack(Theme.Colors.castInterruptible))
		end
	end
	castbar:HookScript("OnHide", function(element) SetGroupCastbarSwap(element, false) end)
	frame.SlickGroupCastbar = castbar
	if GetConfig().showCastbar then
		frame.Castbar = castbar
	end
end

local function ConfigureGroupCastbar(frame)
	local castbar = frame and frame.SlickGroupCastbar
	local infoBar = frame and frame.SlickPersonalHealingEffects
	if not castbar or not infoBar then return end
	local config = GetConfig()
	local height = math.max(8, infoBar:GetHeight())
	local iconSize = math.max(8, height - 2)
	castbar.Icon:SetSize(iconSize, iconSize)
	if config.showCastbar then
		if frame.Castbar ~= castbar then
			frame.Castbar = castbar
			frame:EnableElement("Castbar")
		end
		castbar:ForceUpdate()
	else
		if frame.Castbar == castbar then
			frame:DisableElement("Castbar")
			frame.Castbar = nil
		end
		castbar:Hide()
		SetGroupCastbarSwap(castbar, false)
	end
end

local function UpdatePersonalHealingEffects(self, event, unit)
	if event == "UNIT_AURA" and unit ~= self.unit then return end
	local bar = self.SlickPersonalHealingEffects
	if not bar then return end
	if bar.SlickCasting then
		for _, icon in ipairs(bar.Icons or {}) do
			icon.SlickEffectVisible = false
			icon:Hide()
		end
		return
	end
	if not GetConfig().showPersonalHealingEffects or not self.unit then
		for _, icon in ipairs(bar.Icons or {}) do icon.SlickEffectVisible = false end
		bar:Hide()
		if GetConfig().showCastbar then bar:Show() end
		return
	end

	local visible = 0
	local shownSpellIDs = {}
	local function ShowEffect(spellID, data)
		if shownSpellIDs[spellID] or visible >= bar.MaxIcons then return end
		shownSpellIDs[spellID] = true
		visible = visible + 1
		local icon = bar.Icons[visible]
		icon.SlickEffectVisible = true
		-- Exact-ID aura queries remain available under the 12.1 restricted-aura
		-- rules, but individual AuraData fields can still be protected. The
		-- spell ID is already known, so its normal spell texture is a safe and
		-- stable fallback without inspecting protected AuraData.
		local readableData = data and (not canaccesstable or canaccesstable(data))
		if readableData then
			icon.Texture:SetTexture(data.icon)
			local applications = data.applications
			if CanReadValue(applications) and type(applications) == "number" and applications > 1 then
				icon.Count:SetText(applications)
			else
				icon.Count:SetText("")
			end
		else
			icon.Texture:SetTexture(C_Spell.GetSpellTexture(spellID))
			icon.Count:SetText("")
		end
		icon:Show()
	end

	local isPlayerUnit = self.unit == "player"
	local exactAuraQuery
	if isPlayerUnit and C_UnitAuras.GetPlayerAuraBySpellID then
		exactAuraQuery = function(_, spellID)
			return C_UnitAuras.GetPlayerAuraBySpellID(spellID)
		end
	elseif C_UnitAuras.GetUnitAuraBySpellID then
		exactAuraQuery = C_UnitAuras.GetUnitAuraBySpellID
	end

	if select(4, GetBuildInfo()) >= 120100 and exactAuraQuery then
		-- Blizzard 12.1 forbids tainted addons from enumerating aura slots. An
		-- exact query for each explicitly supported healing effect avoids that
		-- restricted path and keeps the group info strip useful in combat.
		for _, spellID in ipairs(PersonalHealingEffectSpellList) do
			local ok, data = pcall(exactAuraQuery, self.unit, spellID)
			if ok and data ~= nil then ShowEffect(spellID, data) end
			if visible >= bar.MaxIcons then break end
		end
	else
		if InCombatLockdown() then return end
	-- GetAuraSlots returns a continuation token first. That token is commonly
	-- nil, so a plain table plus the length operator can report zero entries
	-- even when Lifebloom, Prayer of Mending, or another player-cast effect is
	-- present. Preserve the exact return count so every returned aura slot is
	-- inspected reliably.
	local slots = PackAuraSlots(C_UnitAuras.GetAuraSlots(self.unit, "HELPFUL|PLAYER", 32))
	for index = 2, slots.count do
		local data = C_UnitAuras.GetAuraDataBySlot(self.unit, slots[index])
		if IsTrackedPersonalHealingEffect(data) then
			ShowEffect(data.spellId, data)
			if visible >= bar.MaxIcons then break end
		end
	end
	end

	local channel = GroupFrames.ActiveHealingChannel
	if visible < bar.MaxIcons and channel and not shownSpellIDs[channel.spellID] then
		local unitGUID = GetReadableUnitGUID(self.unit)
		if unitGUID and channel.targetGUID == unitGUID then
			visible = visible + 1
			local icon = bar.Icons[visible]
			icon.SlickEffectVisible = true
			icon.Texture:SetTexture(channel.icon)
			icon.Count:SetText("")
			icon:Show()
		end
	end

	for index = visible + 1, #bar.Icons do
		bar.Icons[index].SlickEffectVisible = false
		bar.Icons[index]:Hide()
	end
	bar:Show()
end

local function CreatePersonalHealingEffects(frame, health)
	local bar = CreateFrame("Frame", nil, frame)
	bar:SetFrameLevel(frame:GetFrameLevel() + 12)
	bar:EnableMouse(false)
	local background = bar:CreateTexture(nil, "BACKGROUND")
	background:SetAllPoints()
	background:SetColorTexture(0.015, 0.018, 0.025, 0.88)
	bar.Icons = {}
	for index = 1, PERSONAL_EFFECT_MAX_ICONS do
		local icon = CreateFrame("Frame", nil, bar)
		icon:EnableMouse(false)
		local texture = icon:CreateTexture(nil, "ARTWORK")
		texture:SetAllPoints()
		texture:SetTexCoord(0.08, 0.92, 0.08, 0.92)
		icon.Texture = texture
		local count = Theme:CreateText(icon, 8)
		count:SetPoint("BOTTOMRIGHT", 1, -1)
		count:SetJustifyH("RIGHT")
		count:SetTextColor(1, 1, 1, 1)
		icon.Count = count
		icon:Hide()
		bar.Icons[index] = icon
	end
	bar:Hide()
	frame.SlickPersonalHealingEffects = bar
	frame.SlickUpdatePersonalHealingEffects = UpdatePersonalHealingEffects
	CreateGroupCastbar(frame, bar)
	ConfigurePersonalHealingEffects(frame)
	frame:RegisterEvent("UNIT_AURA", UpdatePersonalHealingEffects)
	frame:RegisterEvent("PLAYER_ENTERING_WORLD", UpdatePersonalHealingEffects, true)
	frame:RegisterEvent("GROUP_ROSTER_UPDATE", UpdatePersonalHealingEffects, true)
end

local function RaidHasAssignedRole(role)
	if not IsInRaid() then
		return false
	end

	for index = 1, GetNumGroupMembers() do
		local unit = "raid" .. index
		if role == "TANK" then
			local assignedRole = UnitGroupRolesAssigned(unit)
			if CanReadValue(assignedRole) and assignedRole == "TANK" then
				return true
			end
		elseif role == "MAINASSIST" then
			local assigned = GetPartyAssignment and GetPartyAssignment("MAINASSIST", unit)
			if CanReadValue(assigned) and assigned == true then
				return true
			end
		end
	end

	return false
end

local function UpdateSummonIndicator(self, _, unit)
	if self.unit ~= unit then return end
	local element = self.SummonIndicator
	local status = C_IncomingSummon.IncomingSummonStatus(unit)
	if not CanReadValue(status) then
		element:Hide()
		return
	end
	if status == (Enum.SummonStatus.Pending or 1) then
		element:SetAtlas("RaidFrame-Icon-SummonPending", false)
		element:Show()
	elseif status == (Enum.SummonStatus.Accepted or 2) then
		element:SetAtlas("RaidFrame-Icon-SummonAccepted", false)
		element:Show()
	elseif status == (Enum.SummonStatus.Declined or 3) then
		element:SetAtlas("RaidFrame-Icon-SummonDeclined", false)
		element:Show()
	else
		element:Hide()
	end
end

local function UpdateResurrectIndicator(self, _, unit)
	if self.unit ~= unit then return end
	local element = self.ResurrectIndicator
	local incoming = UnitHasIncomingResurrection(unit)
	if not CanReadValue(incoming) then
		element:Hide()
		if self.SlickResurrectText then
			self.SlickResurrectText:Hide()
		end
		return
	end
	local show = incoming == true
	element:SetShown(show)
	if self.SlickResurrectText then
		self.SlickResurrectText:SetShown(show)
	end
end

local function BuildInitialConfig(config)
	local macroText = GetResurrectionMacroText()
	local frameHeight = GetEffectiveFrameHeight(config)
	if macroText then
		return string.format(
			"self:SetWidth(%d) self:SetHeight(%d) self:SetAttribute('*type1', 'macro') self:SetAttribute('macrotext1', %q) self:SetAttribute('*macrotext1', %q)",
			config.width, frameHeight, macroText, macroText
		)
	end
	return string.format("self:SetWidth(%d) self:SetHeight(%d) self:SetAttribute('*type1', 'target')", config.width, frameHeight)
end

local function GetCurrentLevelCap()
	if GetMaxLevelForPlayerExpansion then
		return GetMaxLevelForPlayerExpansion()
	elseif GetMaxPlayerLevel then
		return GetMaxPlayerLevel()
	end
end

oUF.Tags.Methods["slickui:grouplevel"] = function(unit)
	local level = UnitLevel(unit)
	if issecretvalue and issecretvalue(level) then
		return level
	end
	if type(level) ~= "number" or level <= 0 then
		return
	end

	local levelCap = GetCurrentLevelCap()
	if levelCap and level >= levelCap then
		return
	end
	return level
end
oUF.Tags.Events["slickui:grouplevel"] = "UNIT_LEVEL PLAYER_LEVEL_UP GROUP_ROSTER_UPDATE"

local function GetGroupChoiceTag(choice)
	if not choice then
		return
	elseif choice.key == "name" then
		-- Group names have a dedicated full-width row so they are never squeezed
		-- into one of the generic one-third-width display slots.
		return
	elseif choice.key == "level" then
		return "[slickui:grouplevel]"
	end
	return choice.tag
end

local function IsGroupNameRow(slotKey)
	return slotKey == "topLeft" or slotKey == "topCenter" or slotKey == "topRight"
end

local function UpdateGroupDisplayWidths(frame)
	if not frame.SlickDisplaySlots then
		return
	end
	local width = math.max(22, frame:GetWidth() / 3 - 6)
	for _, text in pairs(frame.SlickDisplaySlots) do
		text:SetWidth(width)
	end
end

local function CreateGroupDisplay(self, health)
	local display = SlickUI.DB.unitFrames.frameDisplays.group
	local fontSizes = SlickUI.DB.unitFrames.frameDisplayFontSizes.group
	self.SlickDisplaySlots = {}
	for _, slot in ipairs(SlickUI.PlayerDisplaySlots) do
		local text = Theme:CreateText(health, fontSizes[slot.key] or 9)
		local x = slot.key == "topLeft" and 31 or slot.x
		text:SetPoint(slot.point, health, slot.relativePoint, x, slot.y)
		text:SetJustifyH(slot.justify)
		text:SetWordWrap(false)
		self.SlickDisplaySlots[slot.key] = text
		local choice = SlickUI.PlayerDisplayChoiceByKey[display[slot.key] or "none"]
		local tag = not IsGroupNameRow(slot.key) and GetGroupChoiceTag(choice) or nil
		if tag then
			self:Tag(text, tag)
		else
			text:Hide()
		end
	end
	UpdateGroupDisplayWidths(self)
end

local function ApplyHealthPredictionSettings(frame)
	local health = frame and frame.Health
	if not health or not frame.SlickHealingAll then return end
	local config = GetConfig()
	local healing = frame.SlickHealingAll
	local absorb = frame.SlickDamageAbsorb
	local healAbsorb = frame.SlickHealAbsorb

	healing:SetAlpha(config.showIncomingHealing and 0.78 or 0)
	absorb:SetAlpha(config.showAbsorbShields and 0.82 or 0)
	healAbsorb:SetAlpha(config.showHealAbsorbs and 0.86 or 0)

	-- Keep shields contiguous with the visible prediction bar.  When incoming
	-- healing is hidden, shields begin immediately after current health.
	absorb:ClearAllPoints()
	absorb:SetPoint("TOP")
	absorb:SetPoint("BOTTOM")
	if config.showIncomingHealing then
		absorb:SetPoint("LEFT", healing:GetStatusBarTexture(), "RIGHT")
	else
		absorb:SetPoint("LEFT", health:GetStatusBarTexture(), "RIGHT")
	end

	health.incomingHealOverflow = config.capOverhealing and 1 or 1.05
	if health.values and health.values.SetIncomingHealOverflowPercent then
		health.values:SetIncomingHealOverflowPercent(health.incomingHealOverflow)
	end
end

local function Style(self)
	local config = GetConfig()
	-- Secure raid-header children may be instantiated by Blizzard while combat
	-- is already active. Their secure initial-config snippet owns geometry in
	-- that case; addon-side size/strata mutations must wait until combat ends.
	if not InCombatLockdown() then
		self:SetSize(config.width, GetEffectiveFrameHeight(config))
		self:SetFrameStrata("LOW")
	end
	self:RegisterForClicks("AnyUp")
	self.menu = function(frame)
		SlickUI.OpenUnitMenu(frame, IsInRaid() and "RAID" or "PARTY")
	end
	self:SetScript("OnEnter", UnitFrame_OnEnter)
	self:SetScript("OnLeave", UnitFrame_OnLeave)
	GroupFrames.LiveFrames[self] = true
	Theme:CreatePanel(self)
	CreateDispellableDebuffOutline(self, config)

	local power = CreateFrame("StatusBar", nil, self)
	power:SetStatusBarTexture(Theme.Texture)
	power:SetPoint("BOTTOMLEFT", 2, 2)
	power:SetPoint("BOTTOMRIGHT", -2, 2)
	power:SetHeight(config.powerHeight)
	power.colorPower = true
	Theme:CreateBarBackground(power)
	self.Power = power

	local health = CreateFrame("StatusBar", nil, self)
	health:SetStatusBarTexture(Theme.Texture)
	health:SetPoint("TOPLEFT", 2, -2)
	health:SetPoint("TOPRIGHT", -2, -2)
	health:SetPoint("BOTTOM", power, "TOP", 0, 2)
	health.colorClass = config.classColors
	health.colorHealth = not config.classColors
	health.smoothing = Enum.StatusBarInterpolation.Immediate
	Theme:CreateBarBackground(health)
	self.Health = health
	CreatePersonalHealingEffects(self, health)

	local healingAll = CreateFrame("StatusBar", nil, health)
	healingAll:SetStatusBarTexture(Theme.Texture)
	healingAll:SetStatusBarColor(0.30, 1.00, 0.48, 0.78)
	healingAll:SetPoint("TOP")
	healingAll:SetPoint("BOTTOM")
	healingAll:SetPoint("LEFT", health:GetStatusBarTexture(), "RIGHT")
	healingAll:SetWidth(config.width)
	healingAll:SetFrameLevel(health:GetFrameLevel() + 1)
	health.HealingAll = healingAll
	self.SlickHealingAll = healingAll

	local damageAbsorb = CreateFrame("StatusBar", nil, health)
	damageAbsorb:SetStatusBarTexture(Theme.Texture)
	damageAbsorb:SetStatusBarColor(0.24, 0.72, 1.00, 0.82)
	damageAbsorb:SetPoint("TOP")
	damageAbsorb:SetPoint("BOTTOM")
	damageAbsorb:SetPoint("LEFT", healingAll:GetStatusBarTexture(), "RIGHT")
	damageAbsorb:SetWidth(config.width)
	damageAbsorb:SetFrameLevel(health:GetFrameLevel() + 2)
	health.DamageAbsorb = damageAbsorb
	self.SlickDamageAbsorb = damageAbsorb

	local healAbsorb = CreateFrame("StatusBar", nil, health)
	healAbsorb:SetStatusBarTexture(Theme.Texture)
	healAbsorb:SetStatusBarColor(0.95, 0.12, 0.16, 0.86)
	healAbsorb:SetPoint("TOP")
	healAbsorb:SetPoint("BOTTOM")
	healAbsorb:SetPoint("RIGHT", health:GetStatusBarTexture(), "RIGHT")
	healAbsorb:SetWidth(config.width)
	healAbsorb:SetReverseFill(true)
	healAbsorb:SetFrameLevel(health:GetFrameLevel() + 3)
	health.HealAbsorb = healAbsorb
	self.SlickHealAbsorb = healAbsorb

	ApplyHealthPredictionSettings(self)

	local leader = health:CreateTexture(nil, "OVERLAY")
	leader:SetSize(12, 12)
	leader:SetPoint("TOPLEFT", self, "TOPLEFT", 3, -3)
	self.LeaderIndicator = leader

	local role = health:CreateTexture(nil, "OVERLAY")
	role:SetSize(12, 12)
	role:SetPoint("LEFT", leader, "RIGHT", 2, 0)
	self.GroupRoleIndicator = role

	local name = Theme:CreateText(health, 10)
	name:SetPoint("TOPLEFT", health, "TOPLEFT", 4, -3)
	name:SetPoint("TOPRIGHT", health, "TOPRIGHT", -4, -3)
	name:SetJustifyH("CENTER")
	name:SetWordWrap(false)
	self:Tag(name, "[name]")
	self.SlickGroupName = name

	local level = Theme:CreateText(health, 9)
	level:SetPoint("BOTTOMLEFT", health, "BOTTOMLEFT", 3, 2)
	level:SetTextColor(unpack(Theme.Colors.mutedText))
	self:Tag(level, "[slickui:grouplevel]")
	level:Hide()

	local percent = Theme:CreateText(health, 9)
	percent:SetPoint("RIGHT", -4, 0)
	percent:SetJustifyH("RIGHT")
	self:Tag(percent, "[perhp<$%]")
	percent:Hide()

	CreateGroupDisplay(self, health)

	-- Critical roster states remain visible even when the configurable display
	-- slots are changed.  The tag guards Midnight secret values before comparing
	-- dead, ghost, connection, or AFK state.
	local automaticStatus = Theme:CreateText(health, 10)
	automaticStatus:SetPoint("CENTER", health, "CENTER", 0, 0)
	automaticStatus:SetJustifyH("CENTER")
	automaticStatus:SetTextColor(1, 0.82, 0.22, 1)
	self:Tag(automaticStatus, "[slickui:playerstatus]")
	self.SlickAutomaticStatus = automaticStatus

	local readySize = math.min(30, math.max(22, config.height - 10))
	local readyLayer = CreateFrame("Frame", nil, self)
	readyLayer:SetAllPoints(self)
	readyLayer:SetFrameLevel(self:GetFrameLevel() + 50)
	readyLayer:EnableMouse(false)
	local readyCheck = readyLayer:CreateTexture(nil, "OVERLAY", nil, 7)
	readyCheck:SetSize(readySize, readySize)
	readyCheck:SetPoint("CENTER", readyLayer, "CENTER", 0, 0)
	readyCheck.useAtlasSize = false
	readyCheck.finishedTime = 8
	readyCheck.fadeTime = 1.5
	self.SlickReadyCheckLayer = readyLayer
	self.ReadyCheckIndicator = readyCheck

	local raidTarget = health:CreateTexture(nil, "OVERLAY")
	raidTarget:SetSize(14, 14)
	raidTarget:SetPoint("CENTER", self, "TOP", 0, 1)
	self.RaidTargetIndicator = raidTarget

	local summon = self:CreateTexture(nil, "OVERLAY", nil, 7)
	local summonSize = math.min(24, math.max(16, config.height - 6))
	summon:SetSize(summonSize, summonSize)
	summon:SetPoint("CENTER", self, "CENTER", 0, 0)
	summon.useAtlasSize = false
	summon.Override = UpdateSummonIndicator
	summon:Hide()
	self.SummonIndicator = summon

	local resurrect = self:CreateTexture(nil, "OVERLAY", nil, 7)
	resurrect:SetSize(summonSize, summonSize)
	resurrect:SetPoint("RIGHT", self, "RIGHT", -4, 0)
	resurrect:SetAtlas("RaidFrame-Icon-Rez", false)
	resurrect.Override = UpdateResurrectIndicator
	resurrect:Hide()
	self.ResurrectIndicator = resurrect

	local resurrectText = Theme:CreateText(self, 10, "OVERLAY")
	Theme:SetFont(resurrectText, 10, "OUTLINE")
	resurrectText:SetPoint("CENTER", self, "CENTER", -6, 0)
	resurrectText:SetText("RES PENDING")
	resurrectText:SetTextColor(0.2, 1, 0.35, 1)
	resurrectText:SetJustifyH("CENTER")
	resurrectText:SetDrawLayer("OVERLAY", 7)
	resurrectText:Hide()
	self.SlickResurrectText = resurrectText

	self.Range = {
		insideAlpha = 1,
		outsideAlpha = 0.45,
	}
	self.SlickGroupFrame = true
end

oUF:RegisterStyle("SlickUIGroup", Style)

local function GetAdaptiveScale(count)
	local config = GetConfig()
	if not config.adaptive or count <= 5 then
		return 1
	elseif count <= 10 then
		return math.max(config.minimumScale, 0.92)
	elseif count <= 20 then
		return math.max(config.minimumScale, 0.80)
	elseif count <= 30 then
		return math.max(config.minimumScale, 0.68)
	end
	return config.minimumScale
end

function GroupFrames:GetAdaptiveScale(count)
	return GetAdaptiveScale(count)
end

local PreviewColors = {
	{ 0.25, 0.78, 0.92, 1 },
	{ 0.96, 0.55, 0.73, 1 },
	{ 0.67, 0.83, 0.45, 1 },
	{ 0.78, 0.61, 0.43, 1 },
	{ 0.64, 0.19, 0.79, 1 },
	{ 1.00, 0.49, 0.04, 1 },
}

local function GetHorizontalOffset(totalWidth)
	local anchor = GetConfig().anchor
	if anchor == "LEFT" then
		return 0
	elseif anchor == "RIGHT" then
		return -totalWidth
	end
	return -totalWidth / 2
end

local function CreatePreviewFrame(parent, index)
	local frame = CreateFrame("Frame", nil, parent)
	Theme:CreatePanel(frame)

	local power = frame:CreateTexture(nil, "ARTWORK")
	power:SetColorTexture(0.12, 0.48, 0.92, 1)
	power:SetPoint("BOTTOMLEFT", 2, 2)
	power:SetPoint("BOTTOMRIGHT", -2, 2)
	frame.Power = power

	local effectBar = CreateFrame("Frame", nil, frame)
	effectBar:EnableMouse(false)
	local effectBackground = effectBar:CreateTexture(nil, "BACKGROUND")
	effectBackground:SetAllPoints()
	effectBackground:SetColorTexture(0.015, 0.018, 0.025, 0.88)
	effectBar.Icons = {}
	local previewSpells = { 774, 33763, 17 }
	for iconIndex, spellID in ipairs(previewSpells) do
		local icon = effectBar:CreateTexture(nil, "ARTWORK")
		icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
		icon:SetTexture(C_Spell.GetSpellTexture(spellID))
		effectBar.Icons[iconIndex] = icon
	end
	frame.PersonalEffectPreview = effectBar

	local health = frame:CreateTexture(nil, "ARTWORK")
	health:SetPoint("TOPLEFT", 2, -2)
	health:SetPoint("RIGHT", -2, 0)
	health:SetPoint("BOTTOM", power, "TOP", 0, 2)
	frame.Health = health

	local leader = frame:CreateTexture(nil, "OVERLAY")
	leader:SetSize(12, 12)
	leader:SetPoint("TOPLEFT", frame, "TOPLEFT", 3, -3)
	if index == 1 then
		leader:SetAtlas("UI-HUD-UnitFrame-Player-Group-LeaderIcon")
	else
		leader:Hide()
	end
	frame.Leader = leader

	local role = frame:CreateTexture(nil, "OVERLAY")
	role:SetSize(12, 12)
	role:SetPoint("LEFT", leader, "RIGHT", 2, 0)
	local roleAtlases = {
		"UI-LFG-RoleIcon-Tank-Micro-Raid",
		"UI-LFG-RoleIcon-Healer-Micro-Raid",
		"UI-LFG-RoleIcon-DPS-Micro-Raid",
	}
	role:SetAtlas(roleAtlases[((index - 1) % #roleAtlases) + 1])
	frame.Role = role

	frame.DisplaySlots = {}
	for _, slot in ipairs(SlickUI.PlayerDisplaySlots) do
		local text = Theme:CreateText(frame, 9)
		local x = slot.key == "topLeft" and 31 or slot.x
		text:SetPoint(slot.point, health, slot.relativePoint, x, slot.y)
		text:SetJustifyH(slot.justify)
		text:SetWordWrap(false)
		frame.DisplaySlots[slot.key] = text
	end

	local name = Theme:CreateText(frame, 10)
	name:SetPoint("TOPLEFT", health, "TOPLEFT", 4, -3)
	name:SetPoint("TOPRIGHT", health, "TOPRIGHT", -4, -3)
	name:SetJustifyH("CENTER")
	name:SetWordWrap(false)
	frame.NameText = name
	return frame
end

local function UpdatePreviewDisplay(frame, index)
	local display = SlickUI.DB.unitFrames.frameDisplays.group
	local fontSizes = SlickUI.DB.unitFrames.frameDisplayFontSizes.group
	local healthPercent = 100 - ((index * 7) % 43)
	local samples = {
		name = "Member " .. index,
		level = index <= 2 and tostring(86 + index) or "",
		status = index == 4 and "AFK" or "",
		healthCurrent = tostring(healthPercent),
		healthValues = healthPercent .. "/100",
		healthPercent = healthPercent .. "%",
		healthMissing = tostring(100 - healthPercent),
		powerCurrent = tostring(70 + (index % 30)),
		powerValues = (70 + (index % 30)) .. "/100",
		powerPercent = (70 + (index % 30)) .. "%",
	}
	local width = math.max(22, frame:GetWidth() / 3 - 6)
	Theme:SetFont(frame.NameText, frame:GetWidth() >= 120 and 10 or 9)
	frame.NameText:SetText(samples.name)
	for slotKey, text in pairs(frame.DisplaySlots) do
		Theme:SetFont(text, fontSizes[slotKey] or 9)
		text:SetWidth(width)
		local selected = IsGroupNameRow(slotKey) and "none" or (display[slotKey] or "none")
		local value = selected == "name" and nil or samples[selected]
		text:SetText(value or "")
		text:SetShown(selected ~= "none" and value ~= nil and value ~= "")
	end
end

function GroupFrames:ShowPreview(count)
	if InCombatLockdown() or not self.Anchor then
		SlickUI:Print("Group-frame preview is unavailable during combat.")
		return false
	end

	local config = GetConfig()
	count = math.max(1, math.min(40, tonumber(count) or config.previewCount or 5))
	config.previewCount = count

	if not self.PreviewContainer then
		self.PreviewContainer = CreateFrame("Frame", "SlickUI_GroupPreview", UIParent)
		self.PreviewContainer:SetFrameStrata("MEDIUM")
	end

	local perLine = math.min(config.membersPerRow, count)
	local rows = config.vertical and perLine or math.ceil(count / perLine)
	local columns = config.vertical and math.ceil(count / perLine) or perLine
	local totalWidth = columns * config.width + (columns - 1) * config.spacing
	local frameHeight = GetEffectiveFrameHeight(config)
	local totalHeight = rows * frameHeight + (rows - 1) * config.spacing
	local scale = GetAdaptiveScale(count)
	local container = self.PreviewContainer
	container:ClearAllPoints()
	container:SetPoint("BOTTOMLEFT", self.Anchor, "BOTTOM", GetHorizontalOffset(totalWidth * scale), 0)
	container:SetSize(totalWidth, totalHeight)
	container:SetScale(scale)

	for index = 1, 40 do
		local frame = self.PreviewFrames[index]
		if index <= count then
			if not frame then
				frame = CreatePreviewFrame(container, index)
				self.PreviewFrames[index] = frame
			end
			local column
			local row
			if config.vertical then
				column = math.floor((index - 1) / perLine)
				row = (index - 1) % perLine
			else
				column = (index - 1) % perLine
				row = math.floor((index - 1) / perLine)
			end
			frame:ClearAllPoints()
			frame:SetPoint("BOTTOMLEFT", container, "BOTTOMLEFT", column * (config.width + config.spacing), row * (frameHeight + config.spacing))
			frame:SetSize(config.width, frameHeight)
			frame.Power:SetHeight(math.min(config.powerHeight, config.height - 8))
			local effectBar = frame.PersonalEffectPreview
			frame.Power:ClearAllPoints()
			if config.showPersonalHealingEffects then
				local iconSize = GetPersonalEffectIconSize(config)
				effectBar:SetHeight(iconSize + 2)
				effectBar:ClearAllPoints()
				effectBar:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 2, 2)
				effectBar:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -2, 2)
				local sampleCount = math.min(#effectBar.Icons, 1 + ((index - 1) % 3))
				for iconIndex, icon in ipairs(effectBar.Icons) do
					icon:SetSize(iconSize, iconSize)
					icon:ClearAllPoints()
					icon:SetPoint("LEFT", effectBar, "LEFT", 1 + ((iconIndex - 1) * (iconSize + 1)), 0)
					icon:SetShown(iconIndex <= sampleCount)
				end
				effectBar:Show()
				frame.Power:SetPoint("BOTTOMLEFT", effectBar, "TOPLEFT", 0, 2)
				frame.Power:SetPoint("BOTTOMRIGHT", effectBar, "TOPRIGHT", 0, 2)
			else
				effectBar:Hide()
				frame.Power:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 2, 2)
				frame.Power:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -2, 2)
			end
			UpdatePreviewDisplay(frame, index)
			local color = config.classColors and PreviewColors[((index - 1) % #PreviewColors) + 1] or Theme.Colors.health
			frame.Health:SetColorTexture(unpack(color))
			frame:Show()
		elseif frame then
			frame:Hide()
		end
	end

	container:Show()
	self.PreviewVisible = true
	self.PreviewCount = count
	return true
end

function GroupFrames:HidePreview()
	if self.PreviewContainer then
		self.PreviewContainer:Hide()
	end
	self.PreviewVisible = false
end

function GroupFrames:TogglePreview(count)
	if self.PreviewVisible then
		self:HidePreview()
		return false
	end
	return self:ShowPreview(count)
end

function GroupFrames:RefreshPreview()
	if self.PreviewVisible then
		self:ShowPreview(self.PreviewCount or GetConfig().previewCount)
	end
end

local function UpdateChildren(header, config)
	local frameHeight = GetEffectiveFrameHeight(config)
	for index = 1, header:GetNumChildren() do
		local frame = select(index, header:GetChildren())
		ApplyResurrectionClick(frame)
		frame:SetSize(config.width, frameHeight)
		frame.Power:SetHeight(math.min(config.powerHeight, config.height - 8))
		frame.Health.colorClass = config.classColors
		frame.Health.colorHealth = not config.classColors
		ApplyHealthPredictionSettings(frame)
		SetDispellableOutlineThickness(frame, math.min(6, math.max(4, math.floor(config.height / 8))))
		if frame.SlickDispellableMarker then
			local markerSize = math.min(28, math.max(20, config.height - 6))
			frame.SlickDispellableMarker:SetSize(markerSize, markerSize)
		end
		if frame.SlickUpdateDispellableOutline then
			frame:SlickUpdateDispellableOutline("ForceUpdate")
		end
		ConfigurePersonalHealingEffects(frame)
		ConfigureGroupCastbar(frame)
		if frame.SlickGroupName then
			Theme:SetFont(frame.SlickGroupName, config.width >= 120 and 10 or 9)
		end
		if frame.SlickUpdatePersonalHealingEffects then
			frame:SlickUpdatePersonalHealingEffects("ForceUpdate")
		end
		if frame.Health.ForceUpdate then
			frame.Health:ForceUpdate()
		end
		if frame.ReadyCheckIndicator then
			local readySize = math.min(30, math.max(22, config.height - 10))
			frame.ReadyCheckIndicator:SetSize(readySize, readySize)
			if frame.ReadyCheckIndicator.ForceUpdate then
				frame.ReadyCheckIndicator:ForceUpdate()
			end
		end
		if frame.SummonIndicator then
			local summonSize = math.min(24, math.max(16, config.height - 6))
			frame.SummonIndicator:SetSize(summonSize, summonSize)
			if frame.SummonIndicator.ForceUpdate then
				frame.SummonIndicator:ForceUpdate()
			end
		end
		if frame.ResurrectIndicator then
			local resurrectSize = math.min(24, math.max(16, config.height - 6))
			frame.ResurrectIndicator:SetSize(resurrectSize, resurrectSize)
			if frame.SlickResurrectText then
				Theme:SetFont(frame.SlickResurrectText, config.width >= 120 and 10 or 9, "OUTLINE")
			end
			if frame.ResurrectIndicator.ForceUpdate then
				frame.ResurrectIndicator:ForceUpdate()
			end
		end
		UpdateGroupDisplayWidths(frame)
	end
end

function GroupFrames:ApplyDisplaySettings(changedSlot)
	if InCombatLockdown() then
		return false
	end
	local display = SlickUI.DB.unitFrames.frameDisplays.group
	local fontSizes = SlickUI.DB.unitFrames.frameDisplayFontSizes.group
	for _, header in ipairs({ self.PartyHeader, self.RaidHeader, self.MainTankHeader, self.MainAssistHeader }) do
		if header then
			for index = 1, header:GetNumChildren() do
				local frame = select(index, header:GetChildren())
				if frame.SlickDisplaySlots then
					for slotKey, text in pairs(frame.SlickDisplaySlots) do
						if not changedSlot or changedSlot == slotKey then
							Theme:SetFont(text, fontSizes[slotKey] or 9)
							frame:Untag(text)
							text:SetText("")
							local choice = SlickUI.PlayerDisplayChoiceByKey[display[slotKey] or "none"]
							local tag = not IsGroupNameRow(slotKey) and GetGroupChoiceTag(choice) or nil
							if tag then
								text:Show()
								frame:Tag(text, tag)
								if text.UpdateTag then text:UpdateTag() end
							else
								text:Hide()
							end
						end
					end
					UpdateGroupDisplayWidths(frame)
				end
			end
		end
	end
	self:RefreshPreview()
	return true
end

local function UpdateRoleHeader(header, anchor, enabled)
	if not header or not anchor then return end
	local config = GetConfig()
	local frameHeight = GetEffectiveFrameHeight(config)
	header:SetAttribute("groupFilter", nil)
	header:SetAttribute("roleFilter", header.SlickRoleFilter)
	header:SetAttribute("initial-width", config.width)
	header:SetAttribute("initial-height", frameHeight)
	header:SetAttribute("point", "BOTTOM")
	header:SetAttribute("xOffset", 0)
	header:SetAttribute("yOffset", config.spacing)
	header:SetAttribute("unitsPerColumn", 5)
	header:SetAttribute("maxColumns", 1)
	header:SetAttribute("columnAnchorPoint", "LEFT")
	header:SetAttribute("columnSpacing", config.spacing)
	header:SetAttribute("oUF-initialConfigFunction", BuildInitialConfig(config))
	header:SetScale(1)
	header:ClearAllPoints()
	header:SetPoint("BOTTOMLEFT", anchor, "BOTTOMLEFT", 0, 0)
	anchor:SetSize(config.width, 24)
	UpdateChildren(header, config)
	anchor.RoleLabel:ClearAllPoints()
	anchor.RoleLabel:SetPoint("BOTTOMLEFT", anchor, "BOTTOMLEFT", 0, frameHeight + 6)
	anchor.RoleLabel:SetShown(enabled and RaidHasAssignedRole(header.SlickRoleFilter))
end

local function UpdateHeader(header, count, isRaid)
	local config = GetConfig()
	local frameHeight = GetEffectiveFrameHeight(config)
	local perLine = isRaid and config.membersPerRow or 5
	local columns = config.vertical and math.ceil(math.max(1, count) / perLine) or math.max(1, math.min(count, perLine))
	local scale = isRaid and GetAdaptiveScale(count) or 1
	local totalWidth = (columns * config.width + (columns - 1) * config.spacing) * scale

	header:SetAttribute("initial-width", config.width)
	header:SetAttribute("initial-height", frameHeight)
	header:SetAttribute("point", config.vertical and "BOTTOM" or "LEFT")
	header:SetAttribute("xOffset", config.vertical and 0 or config.spacing)
	header:SetAttribute("yOffset", config.vertical and config.spacing or 0)
	header:SetAttribute("unitsPerColumn", perLine)
	header:SetAttribute("maxColumns", isRaid and math.ceil(40 / perLine) or 1)
	header:SetAttribute("columnAnchorPoint", config.vertical and "LEFT" or "BOTTOM")
	header:SetAttribute("columnSpacing", config.spacing)
	if isRaid then
		-- A subgroup-based header inserts structural breaks at every raid group.
		-- SlickUI uses a compact continuous dock, so roster index order must flow
		-- through the configured row length without subgroup gaps.
		header:SetAttribute("groupBy", nil)
		header:SetAttribute("groupingOrder", nil)
		header:SetAttribute("groupFilter", nil)
		header:SetAttribute("sortMethod", "INDEX")
		header:SetAttribute("sortDir", "ASC")
	end
	header:SetAttribute("oUF-initialConfigFunction", BuildInitialConfig(config))
	header:SetScale(scale)
	header:ClearAllPoints()
	header:SetPoint("BOTTOMLEFT", GroupFrames.Anchor, "BOTTOM", GetHorizontalOffset(totalWidth), 0)
	UpdateChildren(header, config)
end

function GroupFrames:ApplySettings()
	if InCombatLockdown() then
		self.PendingUpdate = true
		return false
	end

	if not self.PartyHeader or not self.RaidHeader then
		return false
	end

	local config = GetConfig()
	self.PartyHeader:SetAttribute("showSolo", config.showSolo)
	local partyVisibility = config.showSolo and "party,solo" or "party"
	self.PartyHeader:SetVisibility(config.partyEnabled and partyVisibility or "custom hide")
	self.RaidHeader:SetVisibility(config.raidEnabled and "raid" or "custom hide")
	self.MainTankHeader:SetVisibility(config.mainTankEnabled and "raid" or "custom hide")
	self.MainAssistHeader:SetVisibility(config.mainAssistEnabled and "raid" or "custom hide")
	UpdateHeader(self.PartyHeader, math.max(1, GetNumSubgroupMembers() + 1), false)
	UpdateHeader(self.RaidHeader, math.max(1, GetNumGroupMembers()), true)
	UpdateRoleHeader(self.MainTankHeader, self.RoleAnchors.mainTank, config.mainTankEnabled)
	UpdateRoleHeader(self.MainAssistHeader, self.RoleAnchors.mainAssist, config.mainAssistEnabled)
	self:RefreshPreview()
	self.PendingUpdate = false
	return true
end

local function CreateRoleHeader(name, role)
	local config = GetConfig()
	local frameHeight = GetEffectiveFrameHeight(config)
	local header = oUF:SpawnHeader(name, nil, {
		["initial-width"] = config.width,
		["initial-height"] = frameHeight,
		["oUF-initialConfigFunction"] = BuildInitialConfig(config),
		showPlayer = true,
		showParty = false,
		showRaid = true,
		roleFilter = role,
		strictFiltering = true,
		point = "BOTTOM",
		xOffset = 0,
		yOffset = config.spacing,
		unitsPerColumn = 5,
		maxColumns = 1,
		columnAnchorPoint = "LEFT",
		columnSpacing = config.spacing,
		sortMethod = "INDEX",
		sortDir = "ASC",
	})
	header.SlickRoleFilter = role
	return header
end

local function CreateRoleAnchor(key, label)
	local config = GetConfig()
	local anchor = CreateFrame("Frame", "SlickUI_" .. key .. "Dock", UIParent)
	anchor:SetSize(config.width, 24)
	anchor.SlickMoverOnly = true
	local roleLabel = Theme:CreateText(anchor, 11)
	roleLabel:SetPoint("BOTTOMLEFT", anchor, "BOTTOMLEFT", 0, GetEffectiveFrameHeight(config) + 6)
	roleLabel:SetText(label)
	roleLabel:SetTextColor(unpack(Theme.Colors.goldText))
	roleLabel:Hide()
	anchor.RoleLabel = roleLabel
	GroupFrames.RoleAnchors[key] = anchor
	SlickUI.UnitFrames[key] = anchor
	SlickUI.Movers:Register(anchor, key, label .. " Dock")
	return anchor
end

local function CreateHeader(name, isRaid)
	local config = GetConfig()
	local frameHeight = GetEffectiveFrameHeight(config)
	local attributes = {
		["initial-width"] = config.width,
		["initial-height"] = frameHeight,
		["oUF-initialConfigFunction"] = BuildInitialConfig(config),
		showPlayer = true,
		showParty = not isRaid,
		showRaid = isRaid,
		showSolo = config.showSolo,
		point = config.vertical and "BOTTOM" or "LEFT",
		xOffset = config.vertical and 0 or config.spacing,
		yOffset = config.vertical and config.spacing or 0,
		unitsPerColumn = isRaid and config.membersPerRow or 5,
		maxColumns = isRaid and math.ceil(40 / config.membersPerRow) or 1,
		columnAnchorPoint = config.vertical and "LEFT" or "BOTTOM",
		columnSpacing = config.spacing,
	}

	if isRaid then
		attributes.sortMethod = "INDEX"
		attributes.sortDir = "ASC"
	end

	return oUF:SpawnHeader(name, nil, attributes)
end

local BlizzardFrameNames = {
	"PartyFrame",
	"CompactPartyFrame",
	"CompactRaidFrameContainer",
	"CompactRaidFrameManager",
}

local function ForEachBlizzardGroupFrame(callback)
	for _, name in ipairs(BlizzardFrameNames) do
		local frame = _G[name]
		if frame then callback(frame) end
	end
end

local function SetBlizzardFrameState(frame, hidden)
	if not frame then return end
	frame:SetAlpha(hidden and 0 or 1)
	frame:EnableMouse(not hidden)
	if hidden then
		frame:Hide()
	end
end

local function InstallBlizzardFrameHooks()
	ForEachBlizzardGroupFrame(function(frame)
		if not frame.SlickUIVisibilityHooked then
			frame.SlickUIVisibilityHooked = true
			frame:HookScript("OnShow", function(self)
				if GetConfig().hideBlizzardFrames and not InCombatLockdown() then
					self:SetAlpha(0)
					self:EnableMouse(false)
					self:Hide()
				end
			end)
		end
	end)
end

function GroupFrames:ApplyBlizzardFrameVisibility()
	if InCombatLockdown() then
		self.PendingUpdate = true
		return false
	end
	InstallBlizzardFrameHooks()
	local hidden = GetConfig().hideBlizzardFrames == true
	ForEachBlizzardGroupFrame(function(frame) SetBlizzardFrameState(frame, hidden) end)
	if not hidden then
		if CompactRaidFrameManager_UpdateShown then pcall(CompactRaidFrameManager_UpdateShown) end
		if IsInGroup() then
			if IsInRaid() then
				if CompactRaidFrameManager then CompactRaidFrameManager:Show() end
				if CompactRaidFrameContainer then CompactRaidFrameContainer:Show() end
			elseif PartyFrame then
				PartyFrame:Show()
			end
		end
	end
	return true
end

local EventFrame = CreateFrame("Frame")
EventFrame:RegisterEvent("GROUP_ROSTER_UPDATE")
EventFrame:RegisterEvent("PLAYER_ROLES_ASSIGNED")
EventFrame:RegisterEvent("PARTY_LEADER_CHANGED")
EventFrame:RegisterEvent("PLAYER_REGEN_ENABLED")
EventFrame:RegisterEvent("PLAYER_REGEN_DISABLED")
EventFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
EventFrame:RegisterEvent("PLAYER_SPECIALIZATION_CHANGED")
EventFrame:RegisterEvent("SPELLS_CHANGED")
EventFrame:RegisterEvent("ADDON_LOADED")
EventFrame:RegisterEvent("UNIT_SPELLCAST_SENT")
EventFrame:RegisterEvent("UNIT_SPELLCAST_CHANNEL_START")
EventFrame:RegisterEvent("UNIT_SPELLCAST_CHANNEL_STOP")
EventFrame:RegisterEvent("UNIT_SPELLCAST_INTERRUPTED")
EventFrame:RegisterEvent("UNIT_SPELLCAST_FAILED")
EventFrame:SetScript("OnEvent", function(_, event, unit, arg2, arg3, arg4)
	if event == "UNIT_SPELLCAST_SENT" then
		local targetName, spellID = arg2, arg4
		if unit == "player" and PersonalHealingChannelSpellIDs[spellID] then
			GroupFrames.PendingHealingChannel = {
				spellID = spellID,
				targetGUID = ResolveCastTargetGUID(targetName),
			}
		end
		return
	end
	if event == "UNIT_SPELLCAST_CHANNEL_START" then
		local spellID = arg3
		if unit == "player" and PersonalHealingChannelSpellIDs[spellID] then
			local pending = GroupFrames.PendingHealingChannel
			local targetGUID = pending and pending.spellID == spellID and pending.targetGUID or nil
			local icon = C_Spell and C_Spell.GetSpellTexture and C_Spell.GetSpellTexture(spellID)
			GroupFrames.ActiveHealingChannel = {
				spellID = spellID,
				targetGUID = targetGUID,
				icon = icon or 136179,
			}
			GroupFrames:RefreshPersonalHealingEffects()
		end
		return
	end
	if event == "UNIT_SPELLCAST_CHANNEL_STOP" or event == "UNIT_SPELLCAST_INTERRUPTED" or event == "UNIT_SPELLCAST_FAILED" then
		if unit == "player" and GroupFrames.ActiveHealingChannel then
			GroupFrames.ActiveHealingChannel = nil
			GroupFrames.PendingHealingChannel = nil
			GroupFrames:RefreshPersonalHealingEffects()
		end
		return
	end
	if event == "PLAYER_REGEN_DISABLED" then
		GroupFrames:HidePreview()
		return
	end
	if event == "PLAYER_REGEN_ENABLED" or not InCombatLockdown() then
		GroupFrames:ApplySettings()
		GroupFrames:ApplyBlizzardFrameVisibility()
	else
		GroupFrames.PendingUpdate = true
	end
end)

oUF:Factory(function(self)
	self:SetActiveStyle("SlickUIGroup")

	local anchor = CreateFrame("Frame", "SlickUI_GroupDock", UIParent)
	anchor:SetSize(180, 24)
	anchor.SlickMoverOnly = true
	GroupFrames.Anchor = anchor
	SlickUI.UnitFrames.group = anchor
	SlickUI.Movers:Register(anchor, "group", "Party / Raid Dock")

	GroupFrames.PartyHeader = CreateHeader("SlickUI_PartyHeader", false)
	GroupFrames.RaidHeader = CreateHeader("SlickUI_RaidHeader", true)
	local mainTankAnchor = CreateRoleAnchor("mainTank", "MAIN TANKS")
	local mainAssistAnchor = CreateRoleAnchor("mainAssist", "MAIN ASSISTS")
	-- Raid Finder and modern premade groups identify tanks through their assigned
	-- group role.  The legacy MAINTANK assignment is normally unset there, which
	-- left this dock empty even when the raid contained active tanks.
	GroupFrames.MainTankHeader = CreateRoleHeader("SlickUI_MainTankHeader", "TANK")
	GroupFrames.MainAssistHeader = CreateRoleHeader("SlickUI_MainAssistHeader", "MAINASSIST")
	GroupFrames.MainTankHeader:SetPoint("BOTTOMLEFT", mainTankAnchor, "BOTTOMLEFT", 0, 0)
	GroupFrames.MainAssistHeader:SetPoint("BOTTOMLEFT", mainAssistAnchor, "BOTTOMLEFT", 0, 0)
	GroupFrames:ApplySettings()
	SlickUI.Movers:SetLocked(SlickUI.DB.locked)
	GroupFrames:ApplyBlizzardFrameVisibility()
end)
