--core/unitframes_secure.lua, the secure skeleton and secure-code generation for the party/raid unit
--frames (backend). the control frames and group headers are created here at file-load time (NOT in a
--hook): secure frames must exist before PLAYER_LOGIN, and combat cannot occur before login, so this
--is safe. everything from the click button up is combat-locked, so every later change to it
--(installing the secure code strings, layout, state drivers, show/hide) is deferred out of combat via
--core/safety.lua RunWhenSafe and run inside securecallfunction so installing the attributes does not
--taint the header's secure logic.

local _ = nil
local addonName, private = ...

private.Core = private.Core or {}
private.Core.UnitFrames = private.Core.UnitFrames or {}

---@class FPSPP_UnitFrames_Secure
local Secure = {}
private.Core.UnitFrames.Secure = Secure

--constants
local BUTTON_TEMPLATE = "FPSPlusPlus_SecureUnitButtonTemplate"
local RAID_HEADER_COUNT = 8
local UNITS_PER_COLUMN = 5
local MOVER_WIDTH = 24
local MOVER_HEIGHT = 8

--every group header created below, party + raid, for bulk secure-code refresh.
Secure.headers = {}

--bridge invoked from the secure initialConfigFunction via groupHeader:CallMethod("CreateUnitFrame",
--buttonName). CallMethod passes string/number arguments only, so the button is looked up by name.
local createUnitFrameBridge = function(groupHeader, buttonName)
	private.Core.UnitFrames.Visual:CreateUnitFrame(_G[buttonName])
end

--creates one SecureGroupHeaderTemplate under a control frame and applies the static layout
--attributes shared by party and raid headers. unit assignment + visuals are wired by the secure code
--installed later in UpdateSettings (the header has none until then).
---@param control table
---@param name string
---@return table
local createGroupHeader = function(control, name)
	local groupHeader = CreateFrame("Frame", name, control, "SecureGroupHeaderTemplate")
	--list of secure click buttons this header has spawned (filled by Visual:CreateUnitFrame).
	groupHeader.ClickButtons = {}
	--the secure initialConfigFunction calls this via CallMethod to build the insecure visuals.
	groupHeader.CreateUnitFrame = createUnitFrameBridge

	groupHeader:SetAttribute("template", BUTTON_TEMPLATE)
	groupHeader:SetAttribute("templateType", "Button")
	--"TOP"/"INDEX"/"ASC" are Blizzard SecureGroupHeader attribute values (kept uppercase like events).
	groupHeader:SetAttribute("point", "TOP")
	groupHeader:SetAttribute("maxColumns", 1)
	groupHeader:SetAttribute("unitsPerColumn", UNITS_PER_COLUMN)
	groupHeader:SetAttribute("columnSpacing", 5)
	groupHeader:SetAttribute("sortMethod", "INDEX")
	groupHeader:SetAttribute("sortDir", "ASC")
	groupHeader:SetAttribute("startingIndex", 1)

	groupHeader:RegisterEvent("GROUP_ROSTER_UPDATE")
	groupHeader:RegisterEvent("UNIT_NAME_UPDATE")

	--keep the header on screen when dragged/positioned. its real size is set in UpdateSettings.
	groupHeader:SetClampedToScreen(true)

	--start hidden so the header cannot spawn buttons before the secure code is installed (it would on
	--a /reload while already grouped). the state driver shows it once the feature is enabled, which is
	--always after UpdateAllHeaders has installed initialConfigFunction.
	groupHeader:Hide()

	Secure.headers[#Secure.headers + 1] = groupHeader

	return groupHeader
end

--reads a header's on-screen position, stores it in the profile anchor the header owns
--(header.anchorProfileKey), and re-anchors it to UIParent's top-left so the position is reproducible
--and the chained raid headers follow. the header is not independently scaled, so its effective scale
--matches UIParent's and the coordinate difference is a valid UIParent-space offset. the re-anchor is a
--secure-frame change, so it is deferred out of combat via RunWhenSafe.
---@param header table
local saveHeaderPosition = function(header)
	local anchorTable = private.Core.Profile:Get().unit_frames[header.anchorProfileKey]

	local x = header:GetLeft() - UIParent:GetLeft()
	local y = header:GetTop() - UIParent:GetTop()

	anchorTable.x = x
	anchorTable.y = y

	private.Core.Safety:RunWhenSafe(function()
		header:ClearAllPoints()
		header:SetPoint("topleft", UIParent, "topleft", x, y)
	end)
end

--creates a small draggable handle just above a header's top-left corner. dragging moves the header
--(and, for raid, the whole chained block); releasing saves the position to the profile. the header is
--a secure frame, so moving it is blocked in combat -- the drag is skipped then.
---@param header table
local createMover = function(header)
	header:SetMovable(true)

	local mover = CreateFrame("Frame", "$parentMover", header)
	mover:SetSize(MOVER_WIDTH, MOVER_HEIGHT)
	--sit just above the header's top-left so the handle never covers the first unit frame.
	mover:SetPoint("bottomleft", header, "topleft", 0, 1)
	mover:SetFrameLevel(header:GetFrameLevel() + 10)
	mover:EnableMouse(true)
	mover:RegisterForDrag("LeftButton")

	local texture = mover:CreateTexture(nil, "overlay")
	texture:SetAllPoints()
	texture:SetColorTexture(0.27, 0.87, 0.27, 0.6)
	mover.texture = texture

	--brighten on hover so the handle is easy to find and grab.
	mover:SetScript("OnEnter", function(self)
		self.texture:SetColorTexture(0.27, 0.87, 0.27, 1)
	end)
	mover:SetScript("OnLeave", function(self)
		self.texture:SetColorTexture(0.27, 0.87, 0.27, 0.6)
	end)

	mover:SetScript("OnDragStart", function()
		--moving a secure (protected) frame is blocked during combat lockdown; skip the drag then.
		if InCombatLockdown() then
			return
		end

		header:StartMoving()
	end)

	mover:SetScript("OnDragStop", function()
		header:StopMovingOrSizing()
		saveHeaderPosition(header)
	end)
end

----------------------------------------
--party: one control frame + one group header, shown only while in a party.

local partyControl = CreateFrame("Frame", "FPSPlusPlus_PartyControl", UIParent, "SecureHandlerBaseTemplate")
--the state driver (registered while the feature is enabled) sets state-partystatus; show/hide the
--header from inside the restricted environment so it is combat-safe.
partyControl:WrapScript(partyControl, "OnAttributeChanged", [[
	if (name == "state-partystatus") then
		local groupHeader = self:GetFrameRef("GroupHeader1")
		if (groupHeader) then
			if (value == "true") then
				groupHeader:Show()
			else
				groupHeader:Hide()
			end
		end
	end
]])

local partyHeader = createGroupHeader(partyControl, "FPSPlusPlus_PartyHeader1")
partyHeader:SetAttribute("showPlayer", true)
partyHeader:SetAttribute("showParty", true)
--the player positions this header; its saved position lives in profile.unit_frames.anchor.
partyHeader.anchorProfileKey = "anchor"
createMover(partyHeader)
--default position; UpdateSettings repositions it from the profile anchor (out of combat).
partyHeader:SetPoint("topleft", UIParent, "topleft", 481, -522)
partyControl:SetFrameRef("GroupHeader1", partyHeader)

Secure.partyControl = partyControl
Secure.partyHeader = partyHeader

----------------------------------------
--raid: one control frame + eight group headers (one per subgroup), shown only while in a raid.

local raidControl = CreateFrame("Frame", "FPSPlusPlus_RaidControl", UIParent, "SecureHandlerBaseTemplate")
--8 is the fixed maximum number of raid subgroups; mirrored as the literal here because the secure
--snippet cannot read a Lua upvalue.
raidControl:WrapScript(raidControl, "OnAttributeChanged", [[
	if (name == "state-raidstatus") then
		for i = 1, 8 do
			local groupHeader = self:GetFrameRef("GroupHeader" .. i)
			if (groupHeader) then
				if (value == "true") then
					groupHeader:Show()
				else
					groupHeader:Hide()
				end
			end
		end
	end
]])

local previousRaidHeader
for headerId = 1, RAID_HEADER_COUNT do
	local raidHeader = createGroupHeader(raidControl, "FPSPlusPlus_RaidHeader" .. headerId)
	raidHeader:SetAttribute("showRaid", true)
	raidHeader:SetAttribute("groupFilter", tostring(headerId))
	raidControl:SetFrameRef("GroupHeader" .. headerId, raidHeader)

	--lay the eight subgroup headers out left-to-right. the first is the movable anchor (positioned from
	--profile.unit_frames.raid_anchor in UpdateSettings); the rest chain to its right and follow it.
	if headerId == 1 then
		raidHeader.anchorProfileKey = "raid_anchor"
		createMover(raidHeader)
		--default position; UpdateSettings repositions it from profile.raid_anchor (out of combat).
		raidHeader:SetPoint("topleft", UIParent, "topleft", 300, -100)
	else
		raidHeader:SetPoint("topleft", previousRaidHeader, "topright", 2, 0)
	end

	previousRaidHeader = raidHeader
end

Secure.raidControl = raidControl

----------------------------------------
--secure code generators. these return restricted-environment code strings with @placeholders that
--UpdateSettings substitutes. the nested long brackets ([=====[ ... [====[ ... [===[ ... ]===] ]====] ]=====])
--let one code string install another as an attribute value.

--run by the header when a button is first created (Blizzard SecureGroupHeaders.lua ->
--SetupUnitButtonConfiguration). builds the insecure visuals, sets the static click actions
--(left = target, right = unit menu) and the mouseover keybind snippets, and wires refreshUnitChange.
local generateInitializationCode = function()
	return [=====[
		local groupHeader = self:GetParent()
		groupHeader:CallMethod("CreateUnitFrame", self:GetName())

		self:SetAttribute("type1", "target")
		self:SetAttribute("type2", "togglemenu")

		self:SetAttribute("secureOnEnter", [====[
			self:RunFor(self, [===[@Keybinds]===])
		]====])
		self:SetAttribute("secureOnLeave", [====[
			self:RunFor(self, [===[self:ClearBindings();]===])
		]====])

		self:SetAttribute("refreshUnitChange", groupHeader:GetAttribute("refreshunitchange-func"))
	]=====]
end

--run by the header after it assigns or changes a button's unit (Blizzard SecureGroupHeaders.lua ->
--configureChildren, right after SetAttribute("unit", ...)). re-affirms layout + keybinds and calls
--back into the insecure updater with the assigned unit.
local generateRefreshUnitChangeCode = function()
	return [=====[
		self:SetAttribute("statehidden", false)

		@SetLayoutCode

		self:SetAttribute("secureOnEnter", [====[
			self:RunFor(self, [===[@Keybinds]===])
		]====])
		self:SetAttribute("secureOnLeave", [====[
			self:RunFor(self, [===[self:ClearBindings();]===])
		]====])

		local unitId = self:GetAttribute("unit")
		self:CallMethod("UpdateUnitFrameMethod", unitId)
	]=====]
end

--sizes and positions a button from the header's settings-* attributes. embedded into the
--refreshUnitChange code via the @SetLayoutCode placeholder. button names end with the 1-based index
--(unitsPerColumn is 5, so always a single digit), which the stacking math uses. buttons anchor to the
--header's TOPLEFT (not TOP) so they sit at a predictable, fully on-screen spot regardless of header
--width, and stack downward.
--note: the restricted environment's SetPoint validates point names strictly and requires UPPERCASE
--("topleft" raises "Invalid point"), unlike the insecure API -- hence the uppercase points here
--against the project's lowercase-anchor rule. this is the only SetPoint that runs inside secure code.
local generateLayoutCode = function()
	return [=====[
		local groupHeader = self:GetParent()
		local width = groupHeader:GetAttribute("settings-width")
		local height = groupHeader:GetAttribute("settings-height")
		local scale = groupHeader:GetAttribute("settings-scale")
		local vpadding = groupHeader:GetAttribute("settings-vpadding")

		self:SetWidth(width)
		self:SetHeight(height)
		self:SetScale(scale)

		local name = self:GetName()
		local buttonId = tonumber(strsub(name, -1))

		self:ClearAllPoints()
		local yPos = ((buttonId - 1) * height * -1) - (vpadding * buttonId)
		self:SetPoint("TOPLEFT", groupHeader, "TOPLEFT", 0, yPos)
	]=====]
end

--builds the Blizzard secure-attribute modifier prefix for a keybind string, matching
--SecureButton_GetModifierPrefix exactly (lowercase, prepended in shift -> ctrl -> alt order, giving
--"alt-ctrl-shift-"). baking this into the attribute name is what makes a MODIFIED keybind resolve:
--at press time those modifiers are physically down, so Blizzard prepends the same prefix when it
--looks the attribute up.
local buildModifierPrefix = function(keybind)
	local upperKeybind = string.upper(keybind)
	local prefix = ""

	if upperKeybind:find("SHIFT%-") then
		prefix = "shift-" .. prefix
	end

	if upperKeybind:find("CTRL%-") then
		prefix = "ctrl-" .. prefix
	end

	if upperKeybind:find("ALT%-") then
		prefix = "alt-" .. prefix
	end

	return prefix
end

--builds the secure snippet that installs the player's mouseover keybinds while the cursor is over a
--button (run from secureOnEnter; secureOnLeave clears them). generated out of combat on enable /
--profile change. each keyboard key is bound via SetBindingClick to a virtual button "puf<KEY>", which
--the secure handler resolves to the <modifier>type-puf<KEY> / spell- / macrotext- attributes set here
--(Blizzard SecureTemplates.lua: a custom button name X maps to attribute suffix "-X").
local generateKeybindsCode = function()
	--always clear first so a stale binding never leaks between hovers.
	local lines = {"self:ClearBindings();"}

	local keybinds = private.Core.Profile:GetValue("unit_frames.keybinds")

	for index, keybind in ipairs(keybinds) do
		local keyText = keybind.keybind
		local action = keybind.action

		--skip incomplete entries; an empty keybind string would bind nothing.
		if type(keyText) == "string" and keyText ~= "" and action then
			local buttonName = "puf" .. keyText
			local prefix = buildModifierPrefix(keyText)
			local typeAttribute = prefix .. "type-" .. buttonName

			if action == "target" or action == "focus" or action == "togglemenu" then
				lines[#lines + 1] = string.format(
					[[self:SetAttribute(%q, %q); self:SetBindingClick(true, %q, self:GetName(), %q);]],
					typeAttribute, action, keyText, buttonName)

			elseif action == "spell" and keybind.spellId then
				--bake the spell NAME into the secure string (the global GetSpellInfo is gone in 12.0).
				local spellInfo = C_Spell.GetSpellInfo(keybind.spellId)
				if spellInfo and spellInfo.name then
					lines[#lines + 1] = string.format(
						[[self:SetAttribute(%q, "spell"); self:SetAttribute(%q, %q); self:SetBindingClick(true, %q, self:GetName(), %q);]],
						typeAttribute, prefix .. "spell-" .. buttonName, spellInfo.name, keyText, buttonName)
				end

			elseif action == "macro" and keybind.macro then
				lines[#lines + 1] = string.format(
					[[self:SetAttribute(%q, "macro"); self:SetAttribute(%q, %q); self:SetBindingClick(true, %q, self:GetName(), %q);]],
					typeAttribute, prefix .. "macrotext-" .. buttonName, keybind.macro, keyText, buttonName)
			end
		end
	end

	return table.concat(lines, "\n")
end

--installs the generated secure code + layout settings on one group header, and applies the new
--keybinds to any buttons it has already created. mutates secure attributes, so it must run out of
--combat inside securecallfunction (see UpdateAllHeaders).
---@param groupHeader table
function Secure:UpdateSettings(groupHeader)
	local profile = private.Core.Profile:Get().unit_frames

	--layout settings consumed by the secure layout code (per-button sizing).
	groupHeader:SetAttribute("settings-width", profile.unit_width)
	groupHeader:SetAttribute("settings-height", profile.unit_height)
	groupHeader:SetAttribute("settings-scale", profile.unit_scale)
	groupHeader:SetAttribute("settings-vpadding", profile.unit_vertical_padding)

	--give the header a footprint: width = button width so the eight raid headers (each anchored to the
	--previous header's topright) spread into separate columns, and height = room for one column. without
	--a size the headers are 0-wide and every raid subgroup would overlap at the same x.
	groupHeader:SetSize(profile.unit_width, UNITS_PER_COLUMN * (profile.unit_height + profile.unit_vertical_padding))

	--position the anchor headers (party header, first raid header) from the profile; the other raid
	--headers chain off the first one, so they follow it automatically.
	local anchorKey = groupHeader.anchorProfileKey
	if anchorKey then
		groupHeader:ClearAllPoints()
		groupHeader:SetPoint("topleft", UIParent, "topleft", profile[anchorKey].x, profile[anchorKey].y)
	end

	--build the secure code strings and substitute the generated fragments. function-form gsub avoids
	--% being treated as a capture reference inside generated macro text.
	local keybindCode = generateKeybindsCode()

	local initializationCode = generateInitializationCode():gsub("@Keybinds", function() return keybindCode end)
	groupHeader:SetAttribute("initialConfigFunction", initializationCode)

	local refreshCode = generateRefreshUnitChangeCode()
	refreshCode = refreshCode:gsub("@SetLayoutCode", function() return generateLayoutCode() end)
	refreshCode = refreshCode:gsub("@Keybinds", function() return keybindCode end)
	groupHeader:SetAttribute("refreshunitchange-func", refreshCode)

	--refresh buttons this header already created so a keybind change takes effect immediately; the
	--enter snippet is re-read from the attribute on the next hover.
	local onEnterValue = "self:RunFor(self, [===[" .. keybindCode .. "]===])"
	for index, clickButton in ipairs(groupHeader.ClickButtons) do
		clickButton:SetAttribute("refreshUnitChange", refreshCode)
		clickButton:SetAttribute("secureOnEnter", onEnterValue)
	end
end

--regenerates and installs the secure code on every header, out of combat. each header is updated
--inside securecallfunction so installing the attributes does not taint the header's secure logic.
function Secure:UpdateAllHeaders()
	private.Core.Safety:RunWhenSafe(function()
		for index, groupHeader in ipairs(Secure.headers) do
			securecallfunction(Secure.UpdateSettings, Secure, groupHeader)
		end
	end)
end

--drives the headers' visibility through the control WrapScripts: the real party/raid condition when
--enabled, or an always-hide value when disabled. routed through RunWhenSafe so it never touches the
--secure frames in combat.
--why an always-hide driver instead of unregister + manual Hide: unregistering leaves the control's
--state-* attribute at its last value ("true"), so re-enabling set the SAME value and the WrapScript's
--OnAttributeChanged never re-fired -- the frames stayed hidden. driving "hide" on disable guarantees
--the value toggles "true" <-> "hide", so every enable/disable re-fires the WrapScript.
---@param enabled boolean
function Secure:SetStateDriversEnabled(enabled)
	private.Core.Safety:RunWhenSafe(function()
		if enabled then
			RegisterStateDriver(Secure.partyControl, "partystatus", "[group:raid] false; [group:party] true; nope")
			RegisterStateDriver(Secure.raidControl, "raidstatus", "[group:raid] true; nope")
		else
			--a macro with no condition returns its literal, so state-* becomes "hide" (never "true").
			RegisterStateDriver(Secure.partyControl, "partystatus", "hide")
			RegisterStateDriver(Secure.raidControl, "raidstatus", "hide")
		end
	end)
end
