--[[--------------------------------------------------------------------------
	Boom - Modern Healing & Support Raid Frames
	Click/ClickCast.lua

	The binding engine: turns Boom.profile.clickcast.bindings into secure
	attributes on every unit button.

	Everything here is subject to the hard client rule that secure attributes may
	only be written outside combat. Each apply goes through Boom:RunWhenSafe with
	a per-button key, so a burst of changes mid-pull collapses into a single
	deferred apply the moment combat drops.

	Binding key grammar:  "[alt-][ctrl-][shift-]<1..5>"
	which maps directly onto Blizzard's modified-attribute lookup, e.g.
	"ctrl-shift-2"  ->  attributes  ctrl-shift-type2 / ctrl-shift-spell2.
----------------------------------------------------------------------------]]

local ADDON_NAME, Boom = ...

local ClickCast = Boom:NewModule("ClickCast")

local Util = Boom.Util
local L    = Boom.L

local pairs, type, tostring = pairs, type, tostring
local format, gsub = string.format, string.gsub

--------------------------------------------------------------------------------
-- Constants
--------------------------------------------------------------------------------

ClickCast.MOUSE_BUTTONS = { 1, 2, 3, 4, 5 }

ClickCast.BUTTON_LABELS = {
	[1] = "Left Click",
	[2] = "Right Click",
	[3] = "Middle Click",
	[4] = "Mouse Button 4",
	[5] = "Mouse Button 5",
}

-- Canonical modifier order. Blizzard builds its lookup prefix in exactly this
-- sequence, so any other ordering silently never matches.
ClickCast.MODIFIER_ORDER = { "alt", "ctrl", "shift" }

-- Every modifier combination, in canonical form, for the options UI.
ClickCast.MODIFIER_COMBOS = {
	"", "shift-", "ctrl-", "alt-",
	"ctrl-shift-", "alt-shift-", "alt-ctrl-", "alt-ctrl-shift-",
}

local ACTION_TYPES = {
	spell  = true,
	macro  = true,
	target = true,
	focus  = true,
	assist = true,
	menu   = true,
	none   = true,
}

--------------------------------------------------------------------------------
-- Key helpers
--------------------------------------------------------------------------------

--- Build a canonical binding key from modifier state and a mouse button index.
function ClickCast:NormalizeKey(alt, ctrl, shift, buttonIndex)
	local prefix = ""
	if alt   then prefix = prefix .. "alt-"   end
	if ctrl  then prefix = prefix .. "ctrl-"  end
	if shift then prefix = prefix .. "shift-" end
	return prefix .. tostring(buttonIndex)
end

--- Split "ctrl-shift-2" into "ctrl-shift-", 2.
function ClickCast:SplitKey(key)
	local prefix, index = key:match("^(.-)(%d)$")
	if not index then return nil end
	return prefix or "", tonumber(index)
end

function ClickCast:DescribeKey(key)
	local prefix, index = self:SplitKey(key)
	if not index then return key end

	local parts = ""
	if prefix:find("alt%-")   then parts = parts .. "Alt+"   end
	if prefix:find("ctrl%-")  then parts = parts .. "Ctrl+"  end
	if prefix:find("shift%-") then parts = parts .. "Shift+" end

	return parts .. L[ClickCast.BUTTON_LABELS[index] or ("Button " .. index)]
end

--- Human readable summary of a binding, for the options list.
function ClickCast:DescribeBinding(binding)
	if not binding or binding.type == "none" then return L["None"] end

	if binding.type == "spell" then
		local name = Util.GetSpellName(binding.id)
		return name or (L["Unknown spell"] .. " (" .. tostring(binding.id) .. ")")
	elseif binding.type == "macro" then
		return L["Macro"] .. ": " .. (binding.text or ""):sub(1, 40)
	elseif binding.type == "target" then
		return L["Target"]
	elseif binding.type == "focus" then
		return L["Focus"]
	elseif binding.type == "assist" then
		return L["Assist"]
	elseif binding.type == "menu" then
		return L["Context Menu"]
	end
	return binding.type
end

--------------------------------------------------------------------------------
-- Binding storage
--------------------------------------------------------------------------------

local function bindings()
	return Boom.profile.clickcast.bindings
end

function ClickCast:GetBinding(key)
	return bindings()[key]
end

function ClickCast:SetBinding(key, binding)
	if not binding or binding.type == "none" then
		return self:ClearBinding(key)
	end
	if not ACTION_TYPES[binding.type] then return false end

	bindings()[key] = binding
	self:ApplyToAll()
	Boom:SendMessage("BOOM_BINDINGS_CHANGED")
	return true
end

function ClickCast:ClearBinding(key)
	local store = bindings()
	if not store[key] then return false end

	store[key] = nil
	-- No extra bookkeeping needed: each button clears exactly the attributes it
	-- was last given before writing the new set.
	self:ApplyToAll()
	Boom:SendMessage("BOOM_BINDINGS_CHANGED")
	return true
end

function ClickCast:IterateBindings()
	return pairs(bindings())
end

--------------------------------------------------------------------------------
-- Attribute application
--------------------------------------------------------------------------------

--- Write one binding onto one button. Caller guarantees we are out of combat.
local function writeBinding(button, key, binding)
	local prefix, index = ClickCast:SplitKey(key)
	if not index then return end

	local typeAttr  = prefix .. "type"  .. index
	local spellAttr = prefix .. "spell" .. index
	local macroAttr = prefix .. "macrotext" .. index

	-- Always clear the pair first: switching a binding from spell to macro must
	-- not leave the old spell attribute behind for the handler to find.
	button:SetAttribute(spellAttr, nil)
	button:SetAttribute(macroAttr, nil)

	local kind = binding and binding.type

	if kind == "spell" then
		local name = Util.GetSpellName(binding.id)

		if binding.ground then
			-- Ground-targeted spells cannot be aimed by the "spell" action type -
			-- it would just raise the reticle. Boom's buttons carry a `unit`
			-- attribute, so they set the game's mouseover unit while hovered and
			-- @mouseover drops the effect on that player.
			if not name then
				button:SetAttribute(typeAttr, nil)
				return
			end
			button:SetAttribute(typeAttr, "macro")
			button:SetAttribute(macroAttr, "/cast [@mouseover] " .. name)
		else
			-- The secure handler runs tonumber() on this attribute and routes to
			-- CastSpellByID for a number, CastSpellByName for a string. The name
			-- is preferred because it automatically follows talent overrides that
			-- replace a spell with a different ID; the ID is the fallback when the
			-- client cannot resolve a name (unlearned or wrong ID in our database).
			button:SetAttribute(typeAttr, "spell")
			button:SetAttribute(spellAttr, name or binding.id)
		end

	elseif kind == "macro" then
		button:SetAttribute(typeAttr, "macro")
		button:SetAttribute(macroAttr, binding.text or "")

	elseif kind == "target" then
		button:SetAttribute(typeAttr, "target")

	elseif kind == "focus" then
		button:SetAttribute(typeAttr, "focus")

	elseif kind == "assist" then
		button:SetAttribute(typeAttr, "assist")

	elseif kind == "menu" then
		button:SetAttribute(typeAttr, "togglemenu")

	else
		-- The header's initialConfigFunction leaves "*type1" = target and
		-- "*type2" = togglemenu as a safety net, so nil here would silently fall
		-- through to those. An explicit empty string punches a hole in the
		-- wildcard instead, which is what the user asked for by choosing "none".
		button:SetAttribute(typeAttr, "")
	end
end

--- Clear the attributes we wrote last time, so a removed binding or a profile
--- switch never leaves a ghost action behind.
---
--- Deliberately NOT a blanket wipe of all 8 modifier combinations x 5 buttons:
--- that is 120 SetAttribute calls per button, and with the full complement of
--- pre-spawned raid buttons it turns a profile switch into a visible hitch.
--- Each button remembers what it was given instead.
local function clearWrittenAttributes(button)
	local written = button.boomWrittenKeys
	if not written then return end

	for i = 1, #written do
		local key = written[i]
		local prefix, index = ClickCast:SplitKey(key)
		if index then
			button:SetAttribute(prefix .. "type" .. index, nil)
			button:SetAttribute(prefix .. "spell" .. index, nil)
			button:SetAttribute(prefix .. "macrotext" .. index, nil)
		end
	end

	wipe(written)
end

local function rememberWritten(button, key)
	local written = button.boomWrittenKeys
	if not written then
		written = {}
		button.boomWrittenKeys = written
	end
	written[#written + 1] = key
end

function ClickCast:ApplyToButton(button)
	if not button or not button.boomReady then return end
	-- Test-mode preview frames look like unit buttons but must never cast.
	if button.boomPreview then return end

	Boom:RunWhenSafe(function()
		if not button:IsObjectType("Button") then return end

		clearWrittenAttributes(button)

		local cc = Boom.profile.clickcast
		if not cc.enabled then
			self:RegisterClicks(button)
			return
		end

		-- While the frames are unlocked for dragging, clicks must move the panel
		-- rather than cast, or positioning becomes a minefield.
		if cc.disableWhenUnlocked and not Boom.profile.general.lockFrames then
			self:RegisterClicks(button)
			return
		end

		for key, binding in pairs(cc.bindings) do
			writeBinding(button, key, binding)
			rememberWritten(button, key)
		end

		self:RegisterClicks(button)
	end, "clickcast-" .. tostring(button:GetName() or button))
end

function ClickCast:ApplyToAll()
	if not Boom.Frames then return end
	for _, button in Boom.Frames:IterateButtons() do
		self:ApplyToButton(button)
	end
end

--------------------------------------------------------------------------------
-- Click registration
--------------------------------------------------------------------------------

function ClickCast:UseKeyDown()
	return Boom.profile.clickcast.useKeyDown and true or false
end

--- Retail requires BOTH edges to be registered. If only "AnyUp" is registered
--- while the ActionButtonUseKeyDown CVar is on, the template's edge gate means
--- nothing ever fires at all. Registering both is safe - the gate still lets
--- exactly one of them through - and the `useOnKeyDown` attribute then pins
--- which edge acts, independently of the user's action bar preference.
---
--- Both calls are protected operations on a header child, so each one is a taint
--- event. The header's creation snippet already applies them to every button it
--- makes, which leaves this doing nothing on the common path - hence the guard:
--- only write when the answer would actually differ.
function ClickCast:RegisterClicks(button)
	local wanted = self:UseKeyDown()

	if button:GetAttribute("useOnKeyDown") ~= wanted then
		button:SetAttribute("useOnKeyDown", wanted)
	end

	if not button.boomClicksRegistered then
		button.boomClicksRegistered = true
		button:RegisterForClicks("AnyDown", "AnyUp")
	end
end

--------------------------------------------------------------------------------
-- Diagnostics
--------------------------------------------------------------------------------

--- Spell IDs configured in bindings that the client cannot resolve. Surfaced by
--- /boom spells so a wrong ID is visible rather than mysteriously inert.
function ClickCast:GetBrokenBindings()
	local broken = {}
	for key, binding in pairs(bindings()) do
		if binding.type == "spell" then
			if not Util.GetSpellName(binding.id) then
				broken[#broken + 1] = { key = key, id = binding.id }
			end
		end
	end
	return broken
end

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

--- Register a button with the community click-cast table, so Clique and other
--- addons that watch it stay compatible.
local function registerExternally(button)
	if _G.ClickCastFrames then
		_G.ClickCastFrames[button] = true
	end
end

function ClickCast:OnEnable()
	-- UI/Header.lua loads first, so every pre-spawned button was created and
	-- announced before this handler existed. Catch up on them explicitly rather
	-- than relying on a later event to paper over the gap.
	if Boom.Frames then
		for _, button in Boom.Frames:IterateButtons() do
			registerExternally(button)
			ClickCast:ApplyToButton(button)
		end
	end

	self:RegisterMessage("BOOM_BUTTON_CREATED", function(_, _, button)
		-- The secure "togglemenu" action opens the unit popup itself in 12.0.7,
		-- so nothing needs wiring up for right click.
		registerExternally(button)
		ClickCast:ApplyToButton(button)
	end)

	self:RegisterMessage("BOOM_PROFILE_CHANGED", function() ClickCast:ApplyToAll() end)
	self:RegisterMessage("BOOM_BINDINGS_CHANGED", function() ClickCast:ApplyToAll() end)

	self:RegisterMessage("BOOM_CONFIG_CHANGED", function(_, _, path)
		if path == "general.lockFrames"
			or path == "clickcast.enabled"
			or path == "clickcast.useKeyDown"
			or path == "clickcast.disableWhenUnlocked" then
			ClickCast:ApplyToAll()
		end
	end)

	-- Spell names are localised and only known once the spell data is cached.
	self:RegisterEvent("PLAYER_ENTERING_WORLD", function()
		Boom:After(1, function() ClickCast:ApplyToAll() end)
	end)
end
