--[[--------------------------------------------------------------------------
	Boom - Modern Healing & Support Raid Frames
	UI/Header.lua

	Secure group headers, layout, movers, and the unit -> button registry.

	Boom does not manage unit assignment itself. Blizzard's
	SecureGroupHeaderTemplate does that, inside the secure environment, which is
	the only way a raid frame can keep working correctly across a roster change
	mid-combat. We configure it and decorate the children it produces.

	Children are styled from a secure snippet that calls back out through
	CallMethod, so a button is decorated the instant it is created rather than on
	some later polling pass.
----------------------------------------------------------------------------]]

local ADDON_NAME, Boom = ...

local Frames = Boom:NewModule("Frames")

local Util   = Boom.Util
local Skin   = Boom.Skin
local UPDATE = Boom.UPDATE

local format, tconcat = string.format, table.concat
local InCombatLockdown = InCombatLockdown

--------------------------------------------------------------------------------
-- State
--------------------------------------------------------------------------------

local headers   = {}   -- kind -> header frame
local movers    = {}   -- kind -> mover overlay
local allButtons= {}   -- array of every styled button
local unitMap   = {}   -- unit token -> array of buttons

Frames.headers = headers

local KINDS = { "party", "raid", "pet", "maintank" }

-- How many buttons to build up front per header, so nothing has to be created
-- during a pull. A 40-player raid plus its pets is the worst case.
local PRESPAWN_COUNT = { party = 5, raid = 40, pet = 40, maintank = 8 }

--------------------------------------------------------------------------------
-- Button registry
--------------------------------------------------------------------------------

function Frames:IterateButtons()
	local i = 0
	return function()
		i = i + 1
		local button = allButtons[i]
		if button then return i, button end
	end
end

function Frames:GetButtonsForUnit(unit)
	return unit and unitMap[unit] or nil
end

function Frames:ForEachButton(func)
	for i = 1, #allButtons do
		func(allButtons[i])
	end
end

--- Called by UI/UnitButton when a button's secure `unit` attribute changes.
function Frames:SetButtonUnit(button, oldUnit, newUnit)
	if oldUnit and unitMap[oldUnit] then
		local list = unitMap[oldUnit]
		for i = #list, 1, -1 do
			if list[i] == button then table.remove(list, i) end
		end
		if #list == 0 then unitMap[oldUnit] = nil end
	end

	if newUnit then
		local list = unitMap[newUnit]
		if not list then
			list = {}
			unitMap[newUnit] = list
		end
		list[#list + 1] = button
	end
end

--------------------------------------------------------------------------------
-- Child styling callback
--
-- Invoked from the header's initialConfigFunction snippet. The restricted
-- environment cannot hand us a frame reference, only strings, so we pass the
-- child's global name and look it up here.
--------------------------------------------------------------------------------

local function styleChild(header, childName)
	local child = childName and _G[childName]
	if not child then return end

	local ok, err = pcall(Boom.UnitButton.Setup, Boom.UnitButton, child)
	if not ok then
		Boom:Debug("failed to style", childName, err)
		return
	end

	allButtons[#allButtons + 1] = child
end

--- Belt and braces: catch any child the snippet callback missed.
function Frames:ScanChildren()
	for _, header in pairs(headers) do
		local count = header:GetNumChildren()
		for i = 1, count do
			local child = select(i, header:GetChildren())
			if child and child.IsObjectType and child:IsObjectType("Button") and not child.boomReady then
				local ok = pcall(Boom.UnitButton.Setup, Boom.UnitButton, child)
				if ok then allButtons[#allButtons + 1] = child end
			end
		end
	end
end

--------------------------------------------------------------------------------
-- Header creation
--------------------------------------------------------------------------------

-- Runs in the restricted environment with `self` = the freshly created child.
-- It cannot touch textures or text, so its only jobs are sizing, a safe default
-- click action, and handing the child back to insecure Lua BY NAME - frame
-- handles are scrubbed to nil when they cross that boundary.
--
-- EVERY CALL IN HERE MUST BE ON THE RESTRICTED FRAME HANDLE'S ALLOWED LIST.
-- That list is `HANDLE:<method>` in Blizzard_RestrictedAddOnEnvironment's
-- RestrictedFrames.lua - 68 methods, and nothing else exists inside a snippet.
-- SetWidth, SetHeight, SetAttribute, GetParent, GetName and CallMethod are all
-- on it. RegisterForClicks IS NOT, and neither is anything else that sounds like
-- it should be.
--
-- Getting that wrong does not fail quietly. The snippet is run through
-- CallRestrictedClosure from inside configureChildren, so an error takes down
-- the whole of SecureGroupHeader_Update: no child is positioned, none is shown,
-- and CallMethod on the last line never runs, so Boom never decorates them
-- either. The panel is simply empty. It was briefly tried here and that is
-- exactly what it did to the party frames.
local INITIAL_CONFIG = [[
	self:SetWidth(%d)
	self:SetHeight(%d)
	self:SetAttribute("*type1", "target")
	self:SetAttribute("*type2", "togglemenu")
	self:SetAttribute("useOnKeyDown", %s)
	self:GetParent():CallMethod("BoomStyleChild", self:GetName())
]]

local function createHeader(kind)
	local layout = Boom.profile.layouts[kind]

	-- The header MUST be named: Blizzard names children "<header>UnitButton<i>",
	-- and an anonymous header produces anonymous children, which breaks the
	-- CallMethod-by-name styling hook above.
	local name = "BoomHeader" .. kind:gsub("^%l", string.upper)

	local template = (kind == "pet") and "SecureGroupPetHeaderTemplate" or "SecureGroupHeaderTemplate"
	local header = CreateFrame("Frame", name, UIParent, template)

	header.boomKind = kind
	header.BoomStyleChild = styleChild

	header:SetClampedToScreen(true)

	-- SetMovable is protected too, so it waits for the safe path along with
	-- every SetAttribute; a /reload during a pull reaches PLAYER_LOGIN while
	-- still in combat.
	headers[kind] = header
	return header
end

--- Force the header to build N children up front.
--- A negative startingIndex makes numDisplayed resolve to N even with nobody in
--- the group, so every button exists before a pull rather than being created
--- during one.
---
--- The count is NOT unconditional: Blizzard clamps it to
--- unitsPerColumn * maxColumns, so asking for 40 on a 5x5 header quietly yields
--- 25. The caller works the achievable number out first.
---
--- THIS RUNS EXACTLY ONCE PER HEADER, AT LOGIN, BEFORE ANY VISIBILITY DRIVER IS
--- REGISTERED. That is not a preference, it is the fix for a real bug.
---
--- It used to run on every layout pass, and to do so it tore the visibility
--- state driver off the header, called Show() and Hide() on the header itself,
--- and then left UpdateVisibility to put the driver back. Show/Hide on a secure
--- header from addon code taints it, and re-registering the driver hands that
--- taint to the shared state-driver manager - so the next time the driver fired
--- IN COMBAT (which is exactly what happens when you join a party mid-fight),
--- Blizzard's own SecureGroupHeader_Update was refused permission to run:
---
---     ADDON_ACTION_BLOCKED: BoomHeaderPartyUnitButton1..5:Show()
---     ADDON_ACTION_BLOCKED: BoomHeaderPartyUnitButton1..5:SetPoint()
---     ADDON_ACTION_BLOCKED: BoomHeaderParty:SetWidth() / :SetHeight()
---
--- The party frames then stayed blank - no positions, no unit assignment, no
--- names - which is what "the names do not show when I join a party" was.
---
--- Doing it once, before the driver exists, means there is never a driver to
--- unregister and the header is never shown or hidden by us afterwards.
local function preSpawn(header, count)
	if InCombatLockdown() or count < 1 then return end

	local wasShown = header:IsShown()
	header:SetAlpha(0)
	header:SetAttribute("startingIndex", -(count - 1))
	header:Show()
	header:SetAttribute("startingIndex", 1)
	header:SetAlpha(1)

	if not wasShown then header:Hide() end
end

--------------------------------------------------------------------------------
-- Layout
--------------------------------------------------------------------------------

local CLASS_ORDER = "WARRIOR,DEATHKNIGHT,PALADIN,MONK,DRUID,ROGUE,HUNTER,SHAMAN,DEMONHUNTER,EVOKER,MAGE,WARLOCK,PRIEST"

local function groupFilterString(layout)
	local groups = {}
	for i = 1, 8 do
		if layout.groupFilter and layout.groupFilter[i] ~= false then
			groups[#groups + 1] = i
		end
	end
	if #groups == 0 then groups[1] = 1 end
	return tconcat(groups, ",")
end

--- Blizzard's header only rebuilds when an attribute it watches actually
--- changes value. After we resize existing children we still need a relayout,
--- so nudge it explicitly.
local function forceUpdate(header)
	if InCombatLockdown() then return end

	-- The pet header is a different template with a different update function.
	-- Driving it through SecureGroupHeader_Update assigns owner units instead of
	-- pet units, so the pet panel would show the owners again.
	local updater = header.boomKind == "pet"
		and _G.SecureGroupPetHeader_Update
		or _G.SecureGroupHeader_Update

	if type(updater) == "function" then
		pcall(updater, header)
		return
	end

	-- Fallback: toggling a watched attribute triggers the same code path.
	local current = header:GetAttribute("startingIndex") or 1
	header:SetAttribute("startingIndex", current == 1 and 2 or 1)
	header:SetAttribute("startingIndex", current)
end

--- Write an attribute only when it would actually change.
---
--- Every write to a secure header from addon code is a taint event, and
--- BOOM_LAYOUT_CHANGED fires on every tick of every slider - so dragging the
--- width slider across its range used to push roughly a thousand attribute
--- writes through four headers, all but a handful of them setting a value that
--- was already there. Reading first costs nothing and is the difference between
--- touching the header twenty times a session and twenty times a second.
local function setAttr(header, key, value)
	if header:GetAttribute(key) == value then return end
	header:SetAttribute(key, value)
end

local function applyHeaderAttributes(header, kind)
	local layout = Boom.profile.layouts[kind]
	local point, columnPoint = Util.GetGrowthAnchors(layout.growth)

	-- Every SetAttribute below would otherwise trigger a full relayout of the
	-- whole header. _ignore suppresses that cascade so twenty writes cost one
	-- rebuild at the end instead of twenty.
	header:SetAttribute("_ignore", "boom-batch")

	-- Child template. Re-applied on every layout pass rather than only at
	-- creation, so a header that was configured while combat blocked the first
	-- attempt still produces Boom-shaped buttons.
	--
	-- SecureActionButtonTemplate, deliberately NOT SecureUnitButtonTemplate:
	-- SecureUnitButton_OnClick defers to Blizzard's own C_ClickBindings system,
	-- which pre-empts our attributes entirely and will even swallow a plain
	-- target click when the user has no native binding configured.
	setAttr(header, "template", "SecureActionButtonTemplate,SecureHandlerAttributeTemplate")
	setAttr(header, "templateType", "Button")

	-- Position. Not attributes: these are ordinary frame calls, and the header
	-- is never dragged (the movers in this file are separate frames), so it
	-- deliberately does NOT get SetMovable.
	header:ClearAllPoints()
	header:SetPoint(layout.point or "CENTER", UIParent, layout.relativePoint or "CENTER",
		layout.x or 0, layout.y or 0)
	header:SetScale(layout.scale or 1)

	-- Which units.
	if kind == "raid" then
		setAttr(header, "showRaid", true)
		setAttr(header, "showParty", false)
		setAttr(header, "showSolo", false)
		setAttr(header, "showPlayer", layout.showPlayer ~= false)
		setAttr(header, "groupFilter", groupFilterString(layout))
		setAttr(header, "roleFilter", nil)
	elseif kind == "party" then
		setAttr(header, "showRaid", false)
		setAttr(header, "showParty", true)
		setAttr(header, "showSolo", layout.showSolo and true or false)
		setAttr(header, "showPlayer", layout.showPlayer ~= false)
		setAttr(header, "groupFilter", nil)
		setAttr(header, "roleFilter", nil)
	elseif kind == "maintank" then
		setAttr(header, "showRaid", true)
		setAttr(header, "showParty", false)
		setAttr(header, "showSolo", false)
		setAttr(header, "showPlayer", false)
		setAttr(header, "groupFilter", nil)
		setAttr(header, "roleFilter", "MAINTANK")
	elseif kind == "pet" then
		setAttr(header, "showRaid", true)
		setAttr(header, "showParty", true)
		setAttr(header, "showSolo", layout.showSolo and true or false)
		setAttr(header, "showPlayer", layout.showPlayer ~= false)
		setAttr(header, "groupFilter", groupFilterString(layout))
	end

	-- Geometry. The header applies the sign itself based on the anchor point,
	-- so these stay positive.
	setAttr(header, "point", point)
	setAttr(header, "columnAnchorPoint", columnPoint)
	setAttr(header, "xOffset", layout.spacingX or 3)
	setAttr(header, "yOffset", layout.spacingY or 3)
	setAttr(header, "columnSpacing", math.max(layout.spacingX or 3, layout.spacingY or 3))
	setAttr(header, "unitsPerColumn", layout.unitsPerColumn or 5)
	setAttr(header, "maxColumns", layout.maxColumns or 8)
	setAttr(header, "startingIndex", 1)

	-- Sorting.
	local mode = layout.sortMode
	if kind == "raid" and mode == "GROUP" and layout.keepGroupsTogether ~= false then
		setAttr(header, "groupBy", "GROUP")
		setAttr(header, "groupingOrder", "1,2,3,4,5,6,7,8")
		setAttr(header, "sortMethod", "INDEX")
	elseif mode == "CLASS" then
		setAttr(header, "groupBy", "CLASS")
		setAttr(header, "groupingOrder", CLASS_ORDER)
		setAttr(header, "sortMethod", "NAME")
	elseif mode == "ROLE" then
		setAttr(header, "groupBy", "ASSIGNEDROLE")
		setAttr(header, "groupingOrder", layout.roleOrder or "TANK,HEALER,DAMAGER")
		setAttr(header, "sortMethod", "NAME")
	elseif mode == "NAME" then
		setAttr(header, "groupBy", nil)
		setAttr(header, "groupingOrder", nil)
		setAttr(header, "sortMethod", "NAME")
	else
		setAttr(header, "groupBy", nil)
		setAttr(header, "groupingOrder", nil)
		setAttr(header, "sortMethod", "INDEX")
	end

	setAttr(header, "sortDir", layout.reverseOrder and "DESC" or "ASC")

	-- With no units displayed Blizzard collapses the header to minWidth x
	-- minHeight, which default to 0.1 - and the mover is anchored to the header,
	-- so unlocking while solo would leave a sliver too thin to grab. Give it the
	-- size of one unit button instead.
	setAttr(header, "minWidth", layout.width or 90)
	setAttr(header, "minHeight", layout.height or 44)

	-- Keep the creation snippet in step for children created later.
	setAttr(header, "initialConfigFunction", format(INITIAL_CONFIG,
		layout.width or 90,
		layout.height or 44,
		tostring(Boom.profile.clickcast and Boom.profile.clickcast.useKeyDown and true or false)))

	-- Clearing _ignore does not itself trigger an update; the caller runs
	-- forceUpdate() once everything is in place.
	header:SetAttribute("_ignore", nil)
end

--- Resize the children that already exist; the snippet above only covers new ones.
---
--- Guarded on the size actually changing: SetSize on a header child is a
--- protected call, and re-issuing it on forty buttons for every tick of an
--- unrelated slider is forty needless taint events per tick.
local function resizeChildren(header, layout)
	local width, height = layout.width or 90, layout.height or 44
	if header.boomChildWidth == width and header.boomChildHeight == height then return end
	header.boomChildWidth, header.boomChildHeight = width, height

	local count = header:GetNumChildren()
	for i = 1, count do
		local child = select(i, header:GetChildren())
		if child and child.SetSize then
			child:SetSize(width, height)
		end
	end
end

--- Build the full complement of buttons for every enabled panel.
---
--- Runs ONCE, on the first layout pass, before any visibility driver is
--- registered - see the note on preSpawn for why that ordering is the fix for
--- the blocked-action storm the party header used to produce.
---
--- Only for panels the user actually has switched on: pets and the main tank
--- panel are off by default, and pre-spawning them would cost eighty unit
--- buttons and their two thousand child regions for nothing. A panel switched on
--- later builds its buttons on demand instead; they are click-cast wired by
--- ClickCast:ApplyToButton off BOOM_BUTTON_CREATED, which defers itself out of
--- combat like every other secure attribute write.
local preSpawnDone = false

local function preSpawnAll()
	if preSpawnDone or InCombatLockdown() then return end
	preSpawnDone = true

	for _, kind in ipairs(KINDS) do
		local header = headers[kind]
		local layout = Boom.profile.layouts[kind]

		if header and layout.enabled then
			-- Blizzard clamps creation to unitsPerColumn * maxColumns, so ask
			-- only for what this geometry can hold.
			local capacity = (layout.unitsPerColumn or 5) * (layout.maxColumns or 1)
			local target = math.min(PRESPAWN_COUNT[kind] or 5, capacity)

			-- Isolated: pre-spawning is an optimisation, and it drives Blizzard's
			-- header code down an unusual path. If it ever fails it must not take
			-- the rest of the layout with it.
			local ok, err = pcall(preSpawn, header, target)
			if not ok then Boom:Debug("pre-spawn failed for", kind, err) end
		end
	end
end

function Frames:UpdateLayout()
	Boom:RunWhenSafe(function()
		for _, kind in ipairs(KINDS) do
			local header = headers[kind]
			local layout = Boom.profile.layouts[kind]
			if header then
				applyHeaderAttributes(header, kind)
				resizeChildren(header, layout)
			end
		end

		-- Between the attributes it needs and the drivers it must precede.
		preSpawnAll()

		for _, kind in ipairs(KINDS) do
			if headers[kind] then forceUpdate(headers[kind]) end
		end

		Frames:ScanChildren()
		Frames:UpdateVisibility()
		Frames:UpdateMovers()

		for _, button in Frames:IterateButtons() do
			-- Reported rather than allowed to escape: a single button whose
			-- indicators are in a bad state must not abort the relayout of the
			-- thirty-nine after it.
			local ok, err = pcall(Boom.UnitButton.ApplyLayout, Boom.UnitButton, button)
			if not ok then Boom:ReportError("a frame layout", err) end
		end
	end, "frames-layout")
end

--------------------------------------------------------------------------------
-- Visibility
--
-- Driven by secure state drivers so group changes mid-combat still work.
--------------------------------------------------------------------------------

local function visibilityCondition(kind, layout)
	if not layout.enabled then return "hide" end

	if kind == "raid" then
		return "[group:raid] show; hide"
	elseif kind == "party" then
		if layout.showSolo then
			return "[group:raid] hide; show"
		end
		return "[group:raid] hide; [group:party] show; hide"
	elseif kind == "maintank" then
		return "[group:raid] show; hide"
	elseif kind == "pet" then
		return "[group:raid][group:party] show; hide"
	end
	return "show"
end

-- The condition string currently registered per header kind.
--
-- Re-registering an unchanged driver is not free and not harmless: every
-- Register/UnregisterStateDriver pair writes into the shared state-driver
-- manager from addon code, and the driver's later firings inherit that. Doing it
-- on every layout pass - which is every tick of every slider - is how a header
-- ends up unable to lay itself out in combat.
local registeredCondition = {}

function Frames:UpdateVisibility()
	Boom:RunWhenSafe(function()
		for _, kind in ipairs(KINDS) do
			local header = headers[kind]
			if header then
				local condition = visibilityCondition(kind, Boom.profile.layouts[kind])
				if registeredCondition[kind] ~= condition then
					registeredCondition[kind] = condition
					UnregisterStateDriver(header, "visibility")
					RegisterStateDriver(header, "visibility", condition)
				end
			end
		end
	end, "frames-visibility")
end

--------------------------------------------------------------------------------
-- Movers
--------------------------------------------------------------------------------

local MOVER_LABEL = {
	party    = "Party",
	raid     = "Raid",
	pet      = "Pets",
	maintank = "Main Tank",
}

--- How big the panel WOULD be at its current settings.
---
--- The mover cannot take its size from the header. A secure header is hidden by
--- its state driver whenever its group type is not active - the party header
--- while you are solo or in a raid, the raid header while you are in a party -
--- and a hidden header never runs its layout, so it has no size to borrow. That
--- is why the party panel could not be dragged: the handle was a zero-size
--- sliver exactly when the user wanted to place it.
local function moverSize(kind)
	local layout = Boom.profile.layouts[kind]
	local w, h = layout.width or 90, layout.height or 44
	local sx, sy = layout.spacingX or 3, layout.spacingY or 3

	local perColumn = math.max(1, layout.unitsPerColumn or 5)
	local columns = math.max(1, layout.maxColumns or 1)

	-- Party and main tank never fill eight columns; showing the full theoretical
	-- raid block for them would just be a big misleading rectangle.
	if kind == "party" then columns = 1 end

	local point = Util.GetGrowthAnchors(layout.growth)
	local vertical = (point == "TOP" or point == "BOTTOM")

	local down, across = perColumn, columns
	if not vertical then down, across = columns, perColumn end

	return across * (w + sx) - sx, down * (h + sy) - sy
end

local function createMover(kind)
	-- Parented to UIParent and positioned on its own, NOT anchored to the
	-- header: it has to stay grabbable while the header is hidden.
	local mover = CreateFrame("Frame", nil, UIParent)
	mover:SetFrameStrata("HIGH")
	mover:SetClampedToScreen(true)
	mover:EnableMouse(true)
	mover:SetMovable(true)
	mover:RegisterForDrag("LeftButton")
	mover:Hide()

	Skin:CreateBackdrop(mover, {
		color = { 0, 0.83, 1, 0.12 },
		borderColor = { 0, 0.83, 1, 0.9 },
	})

	local label = Skin:CreateFontString(mover, "OVERLAY", 12, "OUTLINE")
	label:SetPoint("CENTER")
	label:SetText(Boom.L[MOVER_LABEL[kind]] or kind)
	mover.label = label

	-- Dragging moves OUR frame, never the secure header. Nothing protected
	-- happens during the drag, so it works in combat too; only the re-anchor
	-- afterwards is deferred, by Config:Set -> UpdateLayout -> RunWhenSafe.
	mover:SetScript("OnDragStart", function(self) self:StartMoving() end)

	mover:SetScript("OnDragStop", function(self)
		self:StopMovingOrSizing()

		local point, _, relativePoint, x, y = self:GetPoint(1)
		if not point then return end

		-- Through Config:Set, not a hand-rolled message: Set is what raises
		-- BOOM_LAYOUT_CHANGED, which re-anchors the header and drags the
		-- test-mode preview along with it.
		local prefix = "layouts." .. kind .. "."
		Boom.Config:Set(prefix .. "point", point)
		Boom.Config:Set(prefix .. "relativePoint", relativePoint)
		Boom.Config:Set(prefix .. "x", Util.Round(x, 0))
		Boom.Config:Set(prefix .. "y", Util.Round(y, 0))

		if InCombatLockdown() then
			Boom:Print(Boom.L["Saved. The frames will move when you leave combat."])
		end
	end)

	movers[kind] = mover
	return mover
end

--- Put a mover back where its profile says the panel lives, at the size that
--- panel would occupy.
local function placeMover(kind, mover)
	local layout = Boom.profile.layouts[kind]

	mover:SetSize(moverSize(kind))
	mover:ClearAllPoints()
	mover:SetPoint(layout.point or "CENTER", UIParent, layout.relativePoint or "CENTER",
		layout.x or 0, layout.y or 0)
end

function Frames:UpdateMovers()
	local unlocked = not Boom.profile.general.lockFrames

	for _, kind in ipairs(KINDS) do
		local mover = movers[kind]
		if not mover and headers[kind] then
			mover = createMover(kind)
		end

		if mover then
			-- Only offer a handle for panels that are actually in play - but
			-- offer it whether or not the header happens to be visible, so a
			-- solo player can place the party and raid panels in advance.
			if unlocked and Boom.profile.layouts[kind].enabled then
				placeMover(kind, mover)
				mover:Show()
			else
				mover:Hide()
			end
		end
	end

	if Boom.Support then Boom.Support:UpdateCooldownBarMover() end
end

function Frames:SetLocked(locked)
	Boom.Config:Set("general.lockFrames", locked and true or false)
	self:UpdateMovers()
	if Boom.ClickCast then Boom.ClickCast:ApplyToAll() end
end

--- Mover labels are written once at creation, so they need re-writing when the
--- language changes - otherwise the handles you drag the panels by keep saying
--- "Ekip" on an English UI.
function Frames:Relocalize()
	for kind, mover in pairs(movers) do
		if mover.label then
			mover.label:SetText(Boom.L[MOVER_LABEL[kind]] or kind)
		end
	end
end

--------------------------------------------------------------------------------
-- Blizzard frame suppression
--
-- This is the part of a raid frame addon that breaks the default UI if you get
-- it wrong, and the failure mode is the "blocked from an action only available
-- to the Blizzard UI" popup rather than a Lua error, so it is worth spelling
-- out exactly why the approach below is the way it is.
--
-- WHAT NOT TO DO: hook OnShow and call Hide(). That hook runs INSIDE Blizzard's
-- own secure execution every time the game shows the frame - a roster change, an
-- Edit Mode refresh, entering an instance. Hide() is a protected method on a
-- protected frame, so calling it from our tainted hook is precisely the blocked
-- action the client complains about. It also becomes a fight we re-lose every
-- time Blizzard shows the frame again.
--
-- WHAT WE DO INSTEAD: reparent the frame into a permanently hidden frame, once,
-- out of combat. Blizzard may Show() it as often as it likes; with a hidden
-- parent it never renders and IsVisible() stays false, so its own update paths
-- early-out too. No hooks, no repeated protected calls, nothing to fight.
--
-- Three further rules, each learned from a real breakage:
--   * never call CompactRaidFrameManager_SetSetting - it writes our tainted
--     value into a Blizzard-side cache that secure code reads back later, and
--     a pcall around it catches errors, not taint
--   * never UnregisterAllEvents on CompactRaidFrameContainer: CompactPartyFrame
--     is generated lazily from the container's layout pass, and Edit Mode calls
--     CompactPartyFrame:RefreshMembers() unconditionally - suppress the creation
--     and you hand Edit Mode a nil index
--   * never touch Edit Mode's own state
--
-- CompactRaidFrameManager is deliberately NOT grouped with the raid container.
-- It is the pull-out tab on the left edge, and it hosts raid markers, ready
-- check and the role poll - none of which Boom replaces. It used to share the
-- container's setting, so hiding Blizzard's raid frames silently took the tab
-- away too. Its own filter options (Everyone / Group N, sort by role) still only
-- affect Blizzard's now-invisible frames, which the option's tooltip says.
--
-- Whether the tab is shown at all outside a raid is Blizzard's decision, not
-- ours: we only ever reparent it, and never call CompactRaidFrameManager_*.
--
-- CompactPartyFrame only exists after the first group join, so this runs again
-- on roster changes. PartyFrame is the always-present container used when
-- "Use Raid-Style Party Frames" is off; omitting it leaves half of users with
-- doubled party frames.
--------------------------------------------------------------------------------

local hiddenParent

local function getHiddenParent()
	if not hiddenParent then
		hiddenParent = CreateFrame("Frame", "BoomHiddenParent", UIParent)
		hiddenParent:Hide()
	end
	return hiddenParent
end

-- Remembered so the setting can be turned back off without a reload.
local originalParent = {}

local function banish(frame)
	if not frame or InCombatLockdown() then return end

	local parent = getHiddenParent()
	if frame:GetParent() == parent then return end

	-- Edit Mode can reparent these back to UIParent on a layout change, so the
	-- original is only recorded the first time.
	if originalParent[frame] == nil then
		originalParent[frame] = frame:GetParent() or UIParent
	end

	frame:SetParent(parent)
end

local function restore(frame)
	if not frame or InCombatLockdown() then return end
	if originalParent[frame] == nil then return end

	frame:SetParent(originalParent[frame])
	originalParent[frame] = nil
end

--- Apply the Blizzard-frame setting. Reparenting is protected, so in combat
--- this queues itself and runs the moment the fight ends - a /reload mid-pull
--- used to leave Blizzard's frames on screen until the next zone change.
local function hideBlizzardFrames()
	if InCombatLockdown() then
		Boom:RunWhenSafe(hideBlizzardFrames, "frames-blizzhide")
		return
	end
	if not Boom.profile then return end

	local profile = Boom.profile.general

	local raidFrames  = { _G.CompactRaidFrameContainer }
	-- PartyFrame is CompactPartyFrame's parent, so it covers both party styles.
	local partyFrames = { _G.PartyFrame, _G.CompactPartyFrame }

	for _, frame in ipairs(raidFrames) do
		if profile.hideBlizzardRaid then banish(frame) else restore(frame) end
	end

	for _, frame in ipairs(partyFrames) do
		if profile.hideBlizzardParty then banish(frame) else restore(frame) end
	end

	-- Deliberately NOT in raidFrames. The manager is the pull-out tab, not a unit
	-- frame, and it hosts raid markers, ready check and the role poll - the parts
	-- of Blizzard's raid UI that Boom does not replace. Bundling it with the
	-- container meant that hiding the frames also took away the tab.
	if profile.hideBlizzardRaidManager then
		banish(_G.CompactRaidFrameManager)
	else
		restore(_G.CompactRaidFrameManager)
	end
end

--------------------------------------------------------------------------------
-- Test mode
--
-- A secure header can only ever show real units, so a solo player cannot see a
-- populated raid. Test mode therefore draws ordinary, non-secure preview
-- buttons using the same styling and layout maths. They are for positioning
-- only - they never cast.
--------------------------------------------------------------------------------

local preview = { buttons = {} }

local function buildPreview()
	if not preview.frame then
		preview.frame = CreateFrame("Frame", "BoomPreview", UIParent)
		preview.frame:SetSize(1, 1)
		preview.frame:Hide()
	end

	local layout = Boom.profile.layouts.raid
	local point, columnPoint = Util.GetGrowthAnchors(layout.growth)

	preview.frame:ClearAllPoints()
	preview.frame:SetPoint(layout.point or "CENTER", UIParent, layout.relativePoint or "CENTER",
		layout.x or 0, layout.y or 0)

	local perColumn = layout.unitsPerColumn or 5
	local columns   = math.min(layout.maxColumns or 8, 4)
	local total     = perColumn * columns

	-- Direction multipliers derived from the same growth mapping the header uses.
	local dx = (point == "LEFT" and 1) or (point == "RIGHT" and -1) or 0
	local dy = (point == "TOP" and -1) or (point == "BOTTOM" and 1) or 0
	local cx = (columnPoint == "LEFT" and 1) or (columnPoint == "RIGHT" and -1) or 0
	local cy = (columnPoint == "TOP" and -1) or (columnPoint == "BOTTOM" and 1) or 0

	local w, h = layout.width or 90, layout.height or 44
	local sx, sy = layout.spacingX or 3, layout.spacingY or 3

	for i = 1, total do
		local button = preview.buttons[i]
		if not button then
			button = CreateFrame("Button", "BoomPreviewButton" .. i, preview.frame)
			-- Flagged before Setup so ClickCast ignores it: a preview frame must
			-- never cast anything.
			button.boomPreview = true
			preview.buttons[i] = button
			Boom.UnitButton:Setup(button)
			button:EnableMouse(false)
		end

		-- Register against the player so real health and aura events keep the
		-- preview live; laying frames out against a frozen snapshot is much less
		-- useful than watching them react. Done here rather than at creation so
		-- re-enabling test mode re-attaches buttons that were detached on the
		-- way out, and never twice.
		if not button.boomPreviewRegistered then
			button.boomPreviewRegistered = true
			button.unit = "player"
			Frames:SetButtonUnit(button, nil, "player")
		end

		button:SetSize(w, h)

		local col = math.floor((i - 1) / perColumn)
		local row = (i - 1) % perColumn

		button:ClearAllPoints()
		button:SetPoint("TOPLEFT", preview.frame, "TOPLEFT",
			dx * (w + sx) * row + cx * (w + sx) * col,
			dy * (h + sy) * row + cy * (h + sy) * col)

		button.unit = "player"
		button.displayUnit = "player"
		button:Show()
		Boom:MarkDirty(button, UPDATE.ALL)
	end

	for i = total + 1, #preview.buttons do
		preview.buttons[i]:Hide()
	end
end

function Frames:SetTestMode(enabled)
	if enabled then
		buildPreview()
		preview.frame:Show()
		return
	end

	if not preview.frame then return end
	preview.frame:Hide()

	-- Hiding the parent does NOT stop the update pipeline: BoomUpdate tests the
	-- button's own IsShown, and the buttons are still in the unit map under
	-- "player". Detach them, or every player health tick keeps running a full
	-- indicator pass over twenty invisible frames for the rest of the session.
	for i = 1, #preview.buttons do
		local button = preview.buttons[i]
		button:Hide()
		if button.boomPreviewRegistered then
			button.boomPreviewRegistered = nil
			Frames:SetButtonUnit(button, button.unit, nil)
			button.unit, button.displayUnit = nil, nil
		end
	end
end

function Frames:IsTestMode()
	return preview.frame and preview.frame:IsShown() or false
end

--- Preview buttons deliberately sit outside the normal button registry, so the
--- relayout sweeps never reach them. Test mode exists precisely so the user can
--- watch size, spacing and colour changes land, so it has to rebuild on every
--- one of them.
function Frames:RefreshTestMode()
	if not self:IsTestMode() then return end

	buildPreview()
	for i = 1, #preview.buttons do
		local button = preview.buttons[i]
		if button:IsShown() then
			Boom.UnitButton:ApplyLayout(button)
		end
	end
end

--------------------------------------------------------------------------------
-- Lifecycle
--------------------------------------------------------------------------------

function Frames:OnEnable()
	for _, kind in ipairs(KINDS) do
		createHeader(kind)
	end

	self:UpdateLayout()
	hideBlizzardFrames()

	-- Honour a profile that was saved with test mode left on, otherwise the
	-- setting silently disagrees with the screen after a reload.
	if Boom.profile.general.testMode then
		Boom:After(0.5, function() Frames:SetTestMode(true) end)
	end

	self:RegisterMessage("BOOM_LAYOUT_CHANGED", function()
		Frames:UpdateLayout()
		Frames:RefreshTestMode()
	end)
	self:RegisterMessage("BOOM_APPEARANCE_CHANGED", function()
		Frames:RefreshTestMode()
	end)
	self:RegisterMessage("BOOM_PROFILE_CHANGED", function()
		Frames:UpdateLayout()
		Frames:SetTestMode(Boom.profile.general.testMode)
	end)

	self:RegisterMessage("BOOM_LOCALE_CHANGED", function()
		Frames:Relocalize()
		-- The status word on every frame ("Dead", "Offline", ...) is looked up
		-- live, but nothing would ask for it again until that unit's state next
		-- changed - so a corpse would keep its old-language label indefinitely.
		Boom:MarkAllDirty(UPDATE.ALL)
	end)

	self:RegisterMessage("BOOM_CONFIG_CHANGED", function(_, _, path, value)
		if path == "general.lockFrames" then
			Frames:UpdateMovers()
		elseif path == "general.testMode" then
			Frames:SetTestMode(value)
		elseif path == "general.hideBlizzardRaid"
			or path == "general.hideBlizzardParty"
			or path == "general.hideBlizzardRaidManager" then
			-- Runs for both directions now: hideBlizzardFrames restores the
			-- original parent when the setting is turned back off.
			hideBlizzardFrames()
		end
	end)

	self:RegisterEvent("GROUP_ROSTER_UPDATE", function()
		Boom:Throttle("frames-scan", 0.2, function()
			Frames:ScanChildren()
			-- CompactPartyFrame only comes into existence on the first group
			-- join, so this is where it can first be caught.
			hideBlizzardFrames()
			Boom:MarkAllDirty(UPDATE.ALL)
		end)
	end)

	self:RegisterEvent("PLAYER_ENTERING_WORLD", function()
		Boom:After(0.5, function()
			Frames:ScanChildren()
			hideBlizzardFrames()
			Boom:MarkAllDirty(UPDATE.ALL)
		end)
	end)

	-- Leaving Edit Mode re-shows the raid frames it manages.
	self:RegisterEvent("EDIT_MODE_LAYOUTS_UPDATED", function()
		Boom:Throttle("frames-blizzhide", 0.5, hideBlizzardFrames)
	end)
end
