--[[--------------------------------------------------------------------------
	Boom - Modern Healing & Support Raid Frames
	Options/PanelsC.lua

	The "Spells & Keys" panel: type a spell name, pick a key, assign.

	This replaced a forty-cell key matrix. The matrix built and rendered
	correctly, but an unbound cell is a dark square on a dark panel, so a fresh
	profile looked like an empty page - and reading a grid is not how anyone
	describes a binding out loud. The order here is the order people say it in:
	"Regrowth, on shift left click".

	Everything is one widget per column row. Nothing is hand-positioned, so the
	page cannot lay itself out wrongly.

	The one hard rule, inherited from PanelsB: bindings are ONLY ever written
	through ClickCast:SetBinding / :ClearBinding. Config:Set("clickcast.bindings.2")
	would store under the NUMBER 2 - Core/Config.lua runs tonumber() on the last
	path segment - while the engine looks that key up as the STRING "2". They
	would not be the same binding.
----------------------------------------------------------------------------]]

local ADDON_NAME, Boom = ...

local Options = Boom.Options
local Widgets = Boom.Widgets
local Skin    = Boom.Skin
local Media   = Boom.Media
local Util    = Boom.Util
local L       = Boom.L

local CreateFrame = CreateFrame
local ipairs, tostring, tonumber = ipairs, tostring, tonumber
local format, strtrim = string.format, strtrim
local max = math.max

--------------------------------------------------------------------------------
-- Local helpers
--
-- Duplicated from PanelsB rather than exported from it: those are file-locals
-- there, and reaching across files for a six-line font helper would couple two
-- panels that otherwise share nothing.
--------------------------------------------------------------------------------

local function textLine(parent, size, justify, color)
	local fs = parent:CreateFontString(nil, "OVERLAY")
	Util.SetFont(fs, Media:Font("Boom Default"), size or 12, "NONE")
	fs:SetTextColor(Util.Unpack(color or Skin.colors.text))
	fs:SetJustifyH(justify or "LEFT")
	return fs
end

local function CreateParagraph(parent, text, opts)
	opts = opts or {}

	local frame = CreateFrame("Frame", nil, parent)
	frame:SetSize(opts.width or 600, opts.height or 14)
	frame.isBoomWidget = true
	frame.boomFixedWidth = opts.width ~= nil

	local fs = textLine(frame, opts.size or 12, "LEFT", opts.color or Skin.colors.textDim)
	fs:SetPoint("TOPLEFT")
	fs:SetPoint("TOPRIGHT")
	fs:SetWordWrap(true)
	frame.text = fs

	function frame:SetMessage(message, color)
		fs:SetText(message or "")
		fs:SetTextColor(Util.Unpack(color or opts.color or Skin.colors.textDim))
		if not opts.height then
			self:SetHeight(max(14, (fs:GetStringHeight() or 12) + 2))
		end
	end

	function frame:Refresh() end

	frame:SetMessage(text)
	return frame
end

--- Lay several controls out in a horizontal strip and hand back one widget the
--- column can stack.
local function CreateStrip(parent, items, opts)
	opts = opts or {}

	local strip = CreateFrame("Frame", nil, parent)
	strip.isBoomWidget = true
	strip.items = items

	local gap = opts.gap or 8
	local width, height = 0, 0

	for i = 1, #items do
		local item = items[i]
		item:SetParent(strip)
		item:ClearAllPoints()
		height = max(height, item:GetHeight() or 20)
		width = width + (item:GetWidth() or 100) + gap
	end
	strip:SetSize(max(1, width - gap), max(1, height))

	-- Anchored in a second pass so a bottom-aligned strip lines the controls up
	-- on their baseline no matter which of them carry a label above the box.
	local x = 0
	for i = 1, #items do
		local item = items[i]
		if opts.align == "BOTTOM" then
			item:SetPoint("BOTTOMLEFT", strip, "BOTTOMLEFT", x, 0)
		else
			item:SetPoint("TOPLEFT", strip, "TOPLEFT", x, 0)
		end
		x = x + (item:GetWidth() or 100) + gap
	end

	function strip:Refresh()
		for i = 1, #self.items do
			local item = self.items[i]
			if item.Refresh then item:Refresh() end
		end
	end

	return strip
end

--------------------------------------------------------------------------------
-- Spell resolution
--------------------------------------------------------------------------------

--- Turn what the user typed into a spell ID. A number is taken as an ID; any
--- other text goes through the client's own lookup, so a localised spell name
--- works exactly as typed.
local function resolveSpellInput(text)
	text = strtrim(text or "")
	if text == "" then return nil, "EMPTY" end

	local id = tonumber(text)
	if not id then
		local api = C_Spell and C_Spell.GetSpellInfo
		local info = api and api(text)
		id = info and info.spellID
	end

	-- Must be a number before it goes anywhere near a binding. The name lookup
	-- is the client's, and a client that hands back anything else would other-
	-- wise put a string into binding.id, where the engine turns it into a
	-- CastSpellByName of nonsense that fails silently on every click.
	if type(id) ~= "number" then return nil, "UNKNOWN" end
	if not Util.SpellExists(id) then return nil, "UNKNOWN" end

	return id
end

--- Is this a reticle spell? A ground-targeted spell bound as a plain "spell"
--- action just raises the reticle instead of landing on the hovered player, so
--- the engine has to be told to route it through an @mouseover macro instead.
---
--- SpellDB carries the flag per entry rather than in a lookup table, so this
--- walks the current spec's list. A spell the database has never heard of is
--- assumed not to be ground-targeted, which is true of almost everything that
--- is worth binding to a friendly frame.
local function isGroundSpell(spellID)
	local SpellDB = Boom.SpellDB
	local specID  = Boom.Config and Boom.Config:GetPlayerSpecID()
	if not (SpellDB and SpellDB.GetSpells and specID) then return false end

	local list = SpellDB:GetSpells(specID)
	for _, entry in ipairs(list or {}) do
		if entry.id == spellID then return entry.ground or false end
	end

	return false
end

--------------------------------------------------------------------------------
-- Key list
--
-- Grouped button-first so the dropdown reads "Left Click, Shift+Left Click,
-- Ctrl+Left Click, ... Right Click, ...". Reaching every one of the forty keys
-- was the whole point of this page.
--------------------------------------------------------------------------------

local function allKeys()
	local ClickCast = Boom.ClickCast
	local keys = {}

	for _, button in ipairs(ClickCast.MOUSE_BUTTONS) do
		for _, prefix in ipairs(ClickCast.MODIFIER_COMBOS) do
			keys[#keys + 1] = prefix .. tostring(button)
		end
	end

	return keys
end

--------------------------------------------------------------------------------
-- Panel
--------------------------------------------------------------------------------

Options:Register("spellkeys", {
	label = "Spells & Keys",
	order = 55,

	build = function(panel, column)
		local width = column.width or 600
		local ClickCast = Boom.ClickCast

		local KEYS = allKeys()

		-- What the Assign button will act on. Never nil: an empty selection
		-- would make the button silently do nothing.
		local selectedKey = KEYS[1]

		local spellBox, keyDrop, status, bindingList

		-- Forward declaration: the spell box needs to call assign() from its
		-- onEnter, but assign() needs the key dropdown, which is built after it.
		-- CreateEditBox reads opts.onEnter at build time and never re-reads the
		-- field, so this cannot be wired up by assigning spellBox.onEnter later.
		local assign

		--- Dropdown entries, rebuilt after every change so the "already bound"
		--- annotation stays true. This is the only place that tells the user a
		--- key is taken before they overwrite it.
		local function keyValues()
			local values = {}

			for _, key in ipairs(KEYS) do
				local binding = ClickCast:GetBinding(key)
				local text = ClickCast:DescribeKey(key)

				if binding then
					local what = ClickCast:DescribeBinding(binding)
					if #what > 22 then what = what:sub(1, 22) .. "..." end
					text = text .. "   |cff667788" .. what .. "|r"
				end

				values[#values + 1] = { value = key, text = text }
			end

			return values
		end

		--- Every binding that exists, as list rows, in the same order as the
		--- dropdown so the two never disagree about what follows what.
		local function bindingItems()
			local items = {}

			for _, key in ipairs(KEYS) do
				local binding = ClickCast:GetBinding(key)
				if binding then
					items[#items + 1] = {
						key     = key,
						text    = ClickCast:DescribeKey(key),
						subtext = ClickCast:DescribeBinding(binding),
						icon    = binding.type == "spell" and Util.GetSpellIcon(binding.id) or nil,
					}
				end
			end

			return items
		end

		local function refreshAll()
			if keyDrop then
				keyDrop:SetValues(keyValues())
				keyDrop:SetValue(selectedKey)
			end
			if bindingList then
				bindingList:SetItems(bindingItems())
				bindingList:SetSelected(selectedKey, true)
			end
		end

		----------------------------------------------------------------- intro
		column:Heading(L["Spells & Keys"])
		column:Add(CreateParagraph(panel,
			L["Type a spell name, choose which mouse button and modifier should cast it, then press Assign. All 40 combinations are in the list."],
			{ width = width }))

		---------------------------------------------------------------- editor
		spellBox = Widgets:CreateEditBox(panel, {
			width       = 300,
			label       = L["Spell name or ID"],
			placeholder = L["e.g. Regrowth"],
			-- Enter assigns, so typing a name and then reaching for the mouse to
			-- confirm it is a step nobody has to take.
			onEnter     = function() if assign then assign() end end,
		})
		spellBox.boomFixedWidth = true

		keyDrop = Widgets:CreateDropdown(panel, {
			width  = 300,
			label  = L["Key"],
			values = keyValues(),
			get    = function() return selectedKey end,
			set    = function(value)
				if value then selectedKey = value end
			end,
			onChange = function()
				if bindingList then bindingList:SetSelected(selectedKey, true) end
			end,
		})
		keyDrop.boomFixedWidth = true

		column:Add(CreateStrip(panel, { spellBox, keyDrop }, { align = "BOTTOM" }), 14)

		---------------------------------------------------------------- assign
		--- Write the typed spell onto the chosen key. The engine defers the
		--- secure attribute write by itself when this is clicked in combat, so
		--- there is nothing to guard here.
		function assign()
			local id, why = resolveSpellInput(spellBox:GetText())

			if not id then
				status:SetMessage(why == "EMPTY"
					and L["Type a spell name or ID first."]
					or  L["No spell by that name or ID."], Skin.colors.bad)
				return
			end

			if not ClickCast:SetBinding(selectedKey,
				{ type = "spell", id = id, ground = isGroundSpell(id) }) then
				status:SetMessage(L["That binding could not be applied."], Skin.colors.bad)
				return
			end

			refreshAll()

			-- Binding a spell the character cannot currently cast is allowed on
			-- purpose - people set their off-spec up in advance - but saying so
			-- is the difference between "it is waiting for you" and "it broke".
			local known = Util.IsPlayerSpell and Util.IsPlayerSpell(id)
			status:SetMessage(format(L["%s bound to %s."],
				Util.GetSpellName(id) or tostring(id),
				ClickCast:DescribeKey(selectedKey)) ..
				(known and "" or ("  " .. L["(not currently castable by this specialization)"])),
				known and Skin.colors.good or Skin.colors.warn)
		end

		local assignButton = Widgets:CreateButton(panel, {
			label   = L["Assign"],
			width   = 150,
			primary = true,
			onClick = assign,
		})

		local clearButton = Widgets:CreateButton(panel, {
			label   = L["Clear This Key"],
			width   = 170,
			onClick = function()
				ClickCast:ClearBinding(selectedKey)
				refreshAll()
				status:SetMessage(format(L["%s cleared."], ClickCast:DescribeKey(selectedKey)))
			end,
		})

		column:Add(CreateStrip(panel, { assignButton, clearButton }))

		----------------------------------------------------------------- status
		-- Fixed height: this line changes at runtime, and one that grew after the
		-- column was laid out would collide with whatever sits beneath it.
		status = CreateParagraph(panel, "", { width = width, height = 16 })
		column:Add(status)

		--------------------------------------------------------------- actions
		column:Heading(L["Other actions"])
		column:Add(CreateParagraph(panel,
			L["Not every key wants a spell. These bind the selected key to a unit action instead."],
			{ width = width }))

		local ACTION_LABEL = {
			target = "Target",
			focus  = "Focus",
			assist = "Assist",
			menu   = "Context Menu",
		}

		local actionButtons = {}
		for _, action in ipairs({ "target", "focus", "assist", "menu" }) do
			actionButtons[#actionButtons + 1] = Widgets:CreateButton(panel, {
				label   = L[ACTION_LABEL[action]],
				width   = 130,
				onClick = function()
					if ClickCast:SetBinding(selectedKey, { type = action }) then
						refreshAll()
						status:SetMessage(format(L["%s bound to %s."],
							L[ACTION_LABEL[action]], ClickCast:DescribeKey(selectedKey)),
							Skin.colors.good)
					end
				end,
			})
		end
		column:Add(CreateStrip(panel, actionButtons, { gap = 6 }))

		--------------------------------------------------------------- the list
		column:Heading(L["Current bindings"])
		column:Add(CreateParagraph(panel,
			L["Click a row to select that key above."],
			{ width = width }))

		bindingList = Widgets:CreateListBox(panel, {
			width     = width,
			height    = 190,
			rowHeight = 32,
			items     = bindingItems(),
			emptyText = L["Nothing bound yet."],
			onSelect  = function(key)
				if not key then return end
				selectedKey = key
				keyDrop:SetValue(key)
				status:SetMessage(format(L["Selected: %s"], ClickCast:DescribeKey(key)))
			end,
		})
		-- Column:Add stretches to the column width anyway; saying so explicitly
		-- keeps the ListBox from being re-measured against its own content.
		bindingList.boomFixedWidth = true
		column:Add(bindingList)

		column:Add(CreateParagraph(panel,
			L["Bindings apply the moment you leave combat if you change them during a fight."],
			{ width = width }))

		---------------------------------------------------------------- refresh
		-- Runs on every profile switch, spec change and BOOM_BINDINGS_CHANGED,
		-- because Options:RefreshAll walks each built page's column.
		column:OnRefresh(refreshAll)

		refreshAll()
	end,
})
