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

	The second half of the settings window: Click Casting, Auras, Support and
	Profiles. Registration only - this file creates no module, it just hands four
	`build` functions to the Options shell and lets it decide when to run them.

	Click Casting is the panel the whole addon is judged on. The conventional
	binding page is a grid of forty cryptic cells; Boom shows the bindings you
	actually have, a handful of free slots underneath them, and one obvious
	primary button that fills the whole thing in for your specialization.
	Everything below the grid is the editor for a single binding, in the order a
	person describes one out loud: "shift, left click, cast Regrowth".

	Two conventions repeat throughout:
	  * the column helper stacks exactly one widget per row, so controls that
	    belong side by side are packed into a strip frame first and the strip is
	    what the column receives
	  * anything the user is mid-way through typing lives in a local draft table,
	    never in the profile, so a half-typed spell name can never reach a secure
	    attribute
----------------------------------------------------------------------------]]

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, UIParent = CreateFrame, UIParent
local pairs, ipairs, type = pairs, ipairs, type
local tonumber, tostring = tonumber, tostring
local tsort, wipe, strtrim = table.sort, wipe, strtrim
local format = string.format
local max = math.max

--------------------------------------------------------------------------------
-- Shared helpers
--------------------------------------------------------------------------------

--- Options text uses the client default font rather than the profile font: the
--- user may pick a condensed pixel face for raid frames, and the settings window
--- still has to be readable afterwards.
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

--- A wrapping block of explanatory text, sized to its own content.
--- Pass opts.height for lines that change at runtime: a status line that grew
--- taller after the column had already been laid out would collide with the
--- widget beneath it.
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
	-- Column:Add would otherwise stretch this to the full column width, and the
	-- callers that pass an explicit width do so precisely to keep text clear of
	-- something else (the spec portrait, for one).
	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. Positioning inside a strip is the strip's own business;
--- panels still never position anything themselves.
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

	--- The column only refreshes what it was handed, so the strip passes the
	--- call on to the controls it swallowed.
	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

--- 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 "Renew" and "139" both
--- work and a localised spell name is accepted as typed.
local function ResolveSpellInput(text)
	text = strtrim(text or "")
	if text == "" then return nil end

	local id = tonumber(text)

	if not id and C_Spell and C_Spell.GetSpellInfo then
		local ok, info = pcall(C_Spell.GetSpellInfo, text)
		if ok and type(info) == "table" then
			id = info.spellID
		end
	end

	if not id or not Util.SpellExists(id) then return nil end
	return id
end

--------------------------------------------------------------------------------
-- Click casting: the binding grid
--
-- Forty possible keys is a wall of noise, so the grid lists the bindings that
-- exist plus a few free slots. It scrolls inside a fixed height because the row
-- count changes at runtime and the column above it has already been laid out.
--------------------------------------------------------------------------------

local BINDING_ROW_H  = 24
local BINDING_HEIGHT = 200

-- Free slots offered per mouse button. This used to be a flat cap of four
-- across the whole grid, and because the outer loop is the mouse button, all
-- four always landed on button 1 - so a fresh profile offered Left Click four
-- times and gave no hint that the other four buttons existed at all. Two each
-- keeps the list short while showing every button. The Spells & Keys tab is
-- where the full forty live.
local EMPTY_SLOTS_PER_BUTTON = 2

--- Bindings first, in a stable order (grouped by mouse button, modifiers in the
--- canonical order the client itself uses), then a few free keys per button.
local function CollectBindingRows()
	local ClickCast = Boom.ClickCast
	local store = Boom.profile.clickcast.bindings

	local bound, free = {}, {}

	for _, index in ipairs(ClickCast.MOUSE_BUTTONS) do
		local offered = 0
		for _, prefix in ipairs(ClickCast.MODIFIER_COMBOS) do
			local key = prefix .. index
			local binding = store[key]
			if binding then
				bound[#bound + 1] = { key = key, binding = binding }
			elseif offered < EMPTY_SLOTS_PER_BUTTON then
				offered = offered + 1
				free[#free + 1] = { key = key }
			end
		end
	end

	for i = 1, #free do
		bound[#bound + 1] = free[i]
	end
	return bound
end

local function CreateBindingGrid(parent, opts)
	opts = opts or {}

	local grid = CreateFrame("Frame", nil, parent)
	grid:SetSize(opts.width or 600, opts.height or BINDING_HEIGHT)
	Skin:CreateBackdrop(grid, { color = Skin.colors.bgDeep, borderColor = Skin.colors.border })

	local scroll = Widgets:CreateScrollFrame(grid)
	scroll:SetPoint("TOPLEFT", 2, -2)
	scroll:SetPoint("BOTTOMRIGHT", -2, 2)

	grid.isBoomWidget = true
	grid.rows   = {}
	grid.scroll = scroll

	local function paint(row)
		if row.isSelected then
			row.highlight:SetVertexColor(Util.Unpack(Skin.colors.bgHover))
			row.highlight:Show()
		elseif row.hovered then
			local r, g, b = Util.Unpack(Skin.colors.bgHover)
			row.highlight:SetVertexColor(r, g, b, 0.5)
			row.highlight:Show()
		else
			row.highlight:Hide()
		end
	end

	local function acquire(index)
		local row = grid.rows[index]
		if row then return row end

		row = CreateFrame("Button", nil, scroll.content)
		row:SetHeight(BINDING_ROW_H)
		row:RegisterForClicks("LeftButtonUp")

		row.highlight = row:CreateTexture(nil, "BACKGROUND")
		row.highlight:SetTexture(Media.WHITE)
		row.highlight:SetAllPoints()
		row.highlight:Hide()

		row.keyText = textLine(row, 12, "LEFT")
		row.keyText:SetPoint("LEFT", 8, 0)
		row.keyText:SetWidth(150)

		row.icon = row:CreateTexture(nil, "ARTWORK")
		row.icon:SetSize(16, 16)
		row.icon:SetPoint("LEFT", row.keyText, "RIGHT", 6, 0)
		row.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)

		row.action = textLine(row, 12, "LEFT", Skin.colors.textDim)
		row.action:SetPoint("LEFT", row.icon, "RIGHT", 6, 0)
		row.action:SetPoint("RIGHT", row, "RIGHT", -34, 0)

		row.remove = Widgets:CreateButton(row, {
			label   = "x",
			width   = 20,
			height  = 18,
			tooltip = L["Remove"],
			onClick = function()
				if row.bindKey and opts.onRemove then opts.onRemove(row.bindKey) end
			end,
		})
		row.remove:SetPoint("RIGHT", -6, 0)

		row:SetScript("OnEnter", function(self)
			self.hovered = true
			paint(self)
		end)
		row:SetScript("OnLeave", function(self)
			self.hovered = false
			paint(self)
		end)
		row:SetScript("OnClick", function(self)
			grid:Select(self.bindKey)
			if opts.onPick then opts.onPick(self.bindKey, self.binding) end
		end)

		grid.rows[index] = row
		return row
	end

	function grid:Select(key)
		self.selectedKey = key
		for i = 1, #self.rows do
			local row = self.rows[i]
			row.isSelected = (row.bindKey == key)
			paint(row)
		end
	end

	function grid:Refresh()
		local ClickCast = Boom.ClickCast
		local rows = CollectBindingRows()

		for i = 1, #rows do
			local data = rows[i]
			local row = acquire(i)

			row:ClearAllPoints()
			row:SetPoint("TOPLEFT", scroll.content, "TOPLEFT", 0, -(i - 1) * BINDING_ROW_H)
			row:SetPoint("RIGHT", scroll.content, "RIGHT", 0, 0)

			row.bindKey = data.key
			row.binding = data.binding
			row.keyText:SetText(ClickCast:DescribeKey(data.key))

			local icon = (data.binding and data.binding.type == "spell")
				and Util.GetSpellIcon(data.binding.id) or nil
			row.icon:SetTexture(icon or "")
			row.icon:SetShown(icon ~= nil)

			if data.binding then
				local description = ClickCast:DescribeBinding(data.binding)
				if data.binding.ground then
					description = description .. "  |cff667788" .. L["Ground targeted"] .. "|r"
				end
				row.keyText:SetTextColor(Util.Unpack(Skin.colors.text))
				row.action:SetTextColor(Util.Unpack(Skin.colors.text))
				row.action:SetText(description)
				row.remove:Show()
			else
				-- A free slot. Dimmed, and it says what to do with it.
				row.keyText:SetTextColor(Util.Unpack(Skin.colors.textFaint))
				row.action:SetTextColor(Util.Unpack(Skin.colors.textFaint))
				row.action:SetText(L["Click to bind"])
				row.remove:Hide()
			end

			row.isSelected = (data.key == self.selectedKey)
			row.hovered = false
			paint(row)
			row:Show()
		end

		for i = #rows + 1, #self.rows do
			self.rows[i]:Hide()
		end

		scroll:SetContentHeight(#rows * BINDING_ROW_H)
	end

	return grid
end

--- "ctrl-shift-" -> "Ctrl+Shift". Derived from the prefix rather than a lookup
--- table so it stays correct if MODIFIER_COMBOS ever grows an entry.
local function ModifierLabel(prefix)
	if not prefix or prefix == "" then return L["None"] 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:gsub("%+$", ""))
end

--------------------------------------------------------------------------------
-- Panel: Click Casting
--------------------------------------------------------------------------------

Options:Register("clickcast", {
	label = "Click Casting",
	order = 50,

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

		-- The binding being edited. Nothing here touches the profile until the
		-- user commits it with Add/Save.
		local draft = {
			modifier = "",
			button   = 1,
			type     = "spell",
			spell    = "",
			macro    = "",
			ground   = false,
		}

		local grid, status, spellBox, groundCheck, saveButton, gridHint
		local modifierDD, buttonDD, actionDD
		local setStatus, syncEditor, loadBinding, applyDraft, updateGridHint

		--- Every path that changes a binding goes through here, so the grid and
		--- the hint above it can never disagree about what is bound.
		local function refreshGrid()
			grid:Refresh()
			if updateGridHint then updateGridHint() end
		end
		-- Which action type the text field is currently dressed for.
		local shownType

		------------------------------------------------------------------ header
		column:Heading(L["Click Casting"])
		column:Add(CreateParagraph(panel, L["Bind a spell to a mouse button, then cast it by clicking any raid frame. Right click opens the unit menu and middle click targets, so you can never click yourself into a corner."], { width = width }))

		column:Add(Widgets:CreateCheckBox(panel, {
			label = L["Enable Click Casting"],
			path  = "clickcast.enabled",
		}))
		column:Add(Widgets:CreateCheckBox(panel, {
			label   = L["Cast on key down"],
			path    = "clickcast.useKeyDown",
			tooltip = L["Fire bindings the moment the button goes down instead of when it is released."],
		}))
		column:Add(Widgets:CreateCheckBox(panel, {
			label   = L["Disable click casting while frames are unlocked"],
			path    = "clickcast.disableWhenUnlocked",
			tooltip = L["Unlock the frames to drag them around the screen."],
		}))

		------------------------------------------------------------ one-click setup
		local presetButton = Widgets:CreateButton(panel, {
			label   = L["Use Recommended Setup"],
			width   = 220,
			primary = true,
			tooltip = L["This binds your most used healing and support spells to the mouse buttons."],
			onClick = function()
				Boom.Presets:Apply()
				refreshGrid()
				syncEditor()
			end,
		})

		local clearButton = Widgets:CreateButton(panel, {
			label   = L["Clear All"],
			width   = 120,
			onClick = function()
				-- Config:Set addresses one key at a time and the whole map is
				-- going away, so it is wiped in place - the engine already holds
				-- a reference to this exact table.
				wipe(Boom.profile.clickcast.bindings)
				Boom:SendMessage("BOOM_BINDINGS_CHANGED")
				refreshGrid()
				syncEditor()
			end,
		})

		column:Add(CreateStrip(panel, { presetButton, clearButton }), 14)

		------------------------------------------------------------------- grid
		column:Heading(L["Bindings"])

		grid = CreateBindingGrid(panel, {
			width  = width,
			onPick = function(key, binding) loadBinding(key, binding) end,
			onRemove = function(key)
				ClickCast:ClearBinding(key)
				refreshGrid()
				syncEditor()
			end,
		})
		column:Add(grid)

		-- Fixed height: the message swaps at runtime, and a line that grew after
		-- the column had been laid out would collide with the editor below it.
		gridHint = CreateParagraph(panel, "", { width = width, size = 11, height = 28 })
		column:Add(gridHint, 4)

		--- The grid lists bound keys plus a few free ones, so a profile with no
		--- spells on it is a short list of dimmed rows and nothing else - which
		--- reads as "the panel is broken" rather than "you have not set it up".
		--- Say which it is.
		function updateGridHint()
			local spells = 0
			for _, binding in pairs(Boom.profile.clickcast.bindings) do
				if binding.type == "spell" or binding.type == "macro" then spells = spells + 1 end
			end

			if spells == 0 then
				gridHint:SetMessage(L["No spells are bound yet. Press \"Use Recommended Setup\" above, or click a row and type a spell name below."],
					Skin.colors.warn)
			else
				gridHint:SetMessage(L["Click a row to load it into the editor below."])
			end
		end

		-- Column:Refresh already calls grid:Refresh() for us (the grid is a
		-- registered widget), but it knows nothing about the hint. Without this
		-- hook the warning survives a binding made anywhere else - the wizard,
		-- the Spells & Keys tab, a profile switch - and keeps claiming the
		-- profile is empty when it is not.
		column:OnRefresh(function()
			if updateGridHint then updateGridHint() end
		end)

		----------------------------------------------------------------- editor
		local modifierValues = {}
		for _, prefix in ipairs(ClickCast.MODIFIER_COMBOS) do
			modifierValues[#modifierValues + 1] = { value = prefix, text = ModifierLabel(prefix) }
		end

		local buttonValues = {}
		for _, index in ipairs(ClickCast.MOUSE_BUTTONS) do
			buttonValues[#buttonValues + 1] = {
				value = index,
				text  = L[ClickCast.BUTTON_LABELS[index]],
			}
		end

		local actionValues = {
			{ value = "spell",  text = L["Spell"] },
			{ value = "macro",  text = L["Macro"] },
			{ value = "target", text = L["Target"] },
			{ value = "focus",  text = L["Focus"] },
			{ value = "assist", text = L["Assist"] },
			{ value = "menu",   text = L["Context Menu"] },
			{ value = "none",   text = L["None"] },
		}

		modifierDD = Widgets:CreateDropdown(panel, {
			label  = L["Modifier"],
			width  = 160,
			values = modifierValues,
			get    = function() return draft.modifier end,
			set    = function(value) draft.modifier = value end,
			onChange = function() syncEditor() end,
		})

		buttonDD = Widgets:CreateDropdown(panel, {
			label  = L["Mouse Button"],
			width  = 160,
			values = buttonValues,
			get    = function() return draft.button end,
			set    = function(value) draft.button = value end,
			onChange = function() syncEditor() end,
		})

		actionDD = Widgets:CreateDropdown(panel, {
			label  = L["Action"],
			width  = 160,
			values = actionValues,
			get    = function() return draft.type end,
			set    = function(value) draft.type = value end,
			onChange = function() syncEditor() end,
		})

		column:Add(CreateStrip(panel, { modifierDD, buttonDD, actionDD }, { align = "BOTTOM" }), 12)

		spellBox = Widgets:CreateEditBox(panel, {
			label       = L["Spell ID or name"],
			width       = 300,
			placeholder = L["Enter a spell name or ID"],
			get = function()
				return draft.type == "macro" and draft.macro or draft.spell
			end,
			set = function(value)
				if draft.type == "macro" then
					draft.macro = value
				else
					draft.spell = value
				end
			end,
			onEnter = function() applyDraft() end,
		})

		groundCheck = Widgets:CreateCheckBox(panel, {
			label   = L["Ground targeted"],
			width   = 150,
			tooltip = L["Binds through an @mouseover macro, so the reticle lands on the player you clicked instead of asking you to place it."],
			get = function() return draft.ground end,
			set = function(value) draft.ground = value end,
		})

		saveButton = Widgets:CreateButton(panel, {
			label   = L["Add"],
			width   = 100,
			primary = true,
			onClick = function() applyDraft() end,
		})

		column:Add(CreateStrip(panel, { spellBox, groundCheck, saveButton }, { align = "BOTTOM" }))

		status = CreateParagraph(panel, "", { width = width, height = 16 })
		column:Add(status, 6)

		------------------------------------------------------------- behaviour
		function setStatus(message, color)
			status:SetMessage(message or "", color)
		end

		--- Reflect the draft in the parts of the editor that are not bound to it
		--- directly: which controls apply to the chosen action type, and whether
		--- the commit button is adding or overwriting.
		---
		--- Repainting the text field re-reads its value and would throw away
		--- whatever the user is halfway through typing, so it only happens when
		--- the action type actually changed - or when the caller asks for it
		--- because it just replaced the draft wholesale.
		function syncEditor(force)
			local isSpell = draft.type == "spell"
			local isMacro = draft.type == "macro"

			if force or shownType ~= draft.type then
				shownType = draft.type

				if spellBox.label then
					spellBox.label:SetText(isMacro and L["Macro"] or L["Spell ID or name"])
				end
				spellBox:SetEnabled(isSpell or isMacro)
				groundCheck:SetEnabled(isSpell)
				groundCheck:Refresh()
			end

			local key = draft.modifier .. tostring(draft.button)
			saveButton:SetLabel(ClickCast:GetBinding(key) and L["Save"] or L["Add"])
			grid:Select(key)
		end

		function loadBinding(key, binding)
			local prefix, index = ClickCast:SplitKey(key)
			draft.modifier = prefix or ""
			draft.button   = index or 1
			draft.type     = (binding and binding.type) or "spell"
			draft.ground   = (binding and binding.ground) and true or false

			if binding and binding.type == "spell" then
				-- Show the name where the client knows one: it is what the user
				-- typed in the first place, and it survives talent overrides.
				draft.spell = tostring(Util.GetSpellName(binding.id) or binding.id or "")
				draft.macro = ""
			elseif binding and binding.type == "macro" then
				draft.macro = binding.text or ""
				draft.spell = ""
			else
				-- A free slot, or an action with no text of its own. Both fields
				-- have to be cleared: leaving the previous row's spell in the box
				-- made the editor describe a key other than the one highlighted,
				-- and Add would then bind that spell to the wrong key.
				draft.spell = ""
				draft.macro = ""
			end

			modifierDD:Refresh()
			buttonDD:Refresh()
			actionDD:Refresh()
			syncEditor(true)

			-- Clicking a row used to move a highlight and change two dropdowns
			-- further down the page. That is technically working and visually
			-- indistinguishable from nothing happening, which is exactly how it
			-- was reported. Say what was selected, and put the cursor where the
			-- next keystroke belongs.
			if binding then
				setStatus(format("%s  |cff667788%s|r",
					ClickCast:DescribeKey(key), ClickCast:DescribeBinding(binding)))
			else
				setStatus(format(L["%s selected. Type a spell name below and press Add."],
					ClickCast:DescribeKey(key)))
			end

			-- After syncEditor, never before: the edit box refuses to Refresh
			-- while it holds focus, so focusing first would strand the old text.
			if (draft.type == "spell" or draft.type == "macro") and spellBox.editBox then
				spellBox.editBox:SetFocus()
			end
		end

		function applyDraft()
			-- The edit box only writes back on Enter or focus loss, and clicking
			-- Add takes neither, so take whatever is in it right now.
			if draft.type == "spell" then
				draft.spell = spellBox:GetText()
			elseif draft.type == "macro" then
				draft.macro = spellBox:GetText()
			end

			local key = draft.modifier .. tostring(draft.button)

			if draft.type == "none" then
				ClickCast:ClearBinding(key)
				setStatus(nil)

			elseif draft.type == "spell" then
				local id = ResolveSpellInput(draft.spell)
				if not id then
					setStatus(L["Unknown spell"], Skin.colors.bad)
					return
				end

				-- A spell the character cannot cast right now is still accepted:
				-- the user may be binding for an off-spec or a talent they are
				-- about to take. It just says so.
				if Util.IsPlayerSpell(id) then
					setStatus(nil)
				else
					setStatus(L["This spell is not available to your specialization."], Skin.colors.warn)
				end

				ClickCast:SetBinding(key, {
					type   = "spell",
					id     = id,
					ground = draft.ground and true or false,
				})

			elseif draft.type == "macro" then
				local text = strtrim(draft.macro or "")
				if text == "" then
					setStatus(L["Enter macro text."], Skin.colors.bad)
					return
				end
				ClickCast:SetBinding(key, { type = "macro", text = text })

			else
				ClickCast:SetBinding(key, { type = draft.type })
			end

			refreshGrid()
			syncEditor(true)
		end

		---------------------------------------------------------------- preview
		local function previewItems()
			local out = {}
			local specID = Boom.Config:GetPlayerSpecID()
			if not specID or not Boom.Presets then return out end

			local rows = Boom.Presets:Preview(specID)
			for i = 1, #rows do
				local row = rows[i]
				out[#out + 1] = {
					key     = row.key,
					text    = row.text,
					subtext = row.keyLabel,
					icon    = row.icon,
				}
			end
			return out
		end

		column:Heading(L["Recommended Setup"])
		column:Add(CreateParagraph(panel, L["This is exactly what the recommended setup would bind for your current specialization."], { width = width }))

		-- Portrait and preview side by side. The portrait is anchored to the
		-- list rather than added to the column so it cannot disturb the layout
		-- on specs that have no artwork.
		local PORTRAIT_W = 120
		local preview = Widgets:CreateListBox(panel, {
			width     = width,
			height    = 180,
			emptyText = L["No specialization detected."],
		})

		-- Beside the list, not on top of it. Overlaying was covering the
		-- key-label column of every row, which is the part the user is reading.
		preview.boomFixedWidth = true
		local portrait = Boom.SpecArt:CreatePortrait(panel, { width = PORTRAIT_W, fade = false })
		portrait:SetPoint("TOPLEFT", preview, "TOPRIGHT", 10, 0)
		portrait:SetAlpha(0.9)

		function preview:Refresh()
			self:SetItems(previewItems())

			local hasArt = portrait:SetSpec(Boom.Config:GetPlayerSpecID())
			portrait:SetShown(hasArt)
			-- The list keeps the portrait's width when there is no artwork.
			self:SetWidth(hasArt and (width - PORTRAIT_W - 10) or width)
		end
		column:Add(preview)

		refreshGrid()
		syncEditor(true)
		preview:Refresh()
	end,
})

--------------------------------------------------------------------------------
-- Panel: Auras
--------------------------------------------------------------------------------

local AURA_LISTS = {
	{
		path  = "auras.extraImportant",
		label = "Extra Important Debuffs",
		help  = "Always shown, even when the debuff icons are set to important only.",
	},
	{
		path  = "auras.blacklist",
		label = "Blacklist",
		help  = "Never shown, whatever else would have qualified them.",
	},
	{
		path  = "auras.buffWatch",
		label = "Watched Buffs",
		help  = "Buffs on allies you want to see on the frames.",
	},
	{
		path  = "auras.extraHots",
		label = "Extra HoTs",
		help  = "Treated as one of your own heal-over-time effects.",
	},
}

--- Curated lists are stored as spellID -> true, which is fast to test but
--- unreadable as a bare column of numbers, so every row is resolved to its icon
--- and name.
local function AuraListItems(path)
	local store = Boom.Config:Get(path)
	local ids = {}

	if type(store) == "table" then
		for id, enabled in pairs(store) do
			local numeric = tonumber(id)
			if enabled and numeric then ids[#ids + 1] = numeric end
		end
	end
	tsort(ids)

	local items = {}
	for i = 1, #ids do
		local id = ids[i]
		items[#items + 1] = {
			key  = id,
			text = (Util.GetSpellName(id) or L["Unknown spell"]) .. "  |cff667788" .. id .. "|r",
			icon = Util.GetSpellIcon(id),
		}
	end
	return items
end

--- The aura engine caches its scan per unit, so a list edit is invisible until
--- the cache is dropped and the frames are marked for an aura pass.
local function InvalidateAuras()
	if Boom.Auras and Boom.Auras.InvalidateAll then
		Boom.Auras:InvalidateAll()
	end
	if Boom.UPDATE then
		Boom:MarkAllDirty(Boom.UPDATE.AURA)
	end
end

local function BuildAuraList(panel, column, width, def)
	local list, addFromInput, removeID
	local status

	column:Heading(L[def.label])
	column:Add(CreateParagraph(panel, L[def.help], { width = width, size = 11 }))

	local input = Widgets:CreateEditBox(panel, {
		width       = 300,
		placeholder = L["Enter a spell name or ID"],
		get = function() return "" end,
		set = function() end,
		onEnter = function() addFromInput() end,
	})

	local addButton = Widgets:CreateButton(panel, {
		label   = L["Add"],
		width   = 100,
		onClick = function() addFromInput() end,
	})

	column:Add(CreateStrip(panel, { input, addButton }, { align = "BOTTOM" }))

	status = CreateParagraph(panel, "", { width = width, height = 16 })
	column:Add(status, 4)

	list = Widgets:CreateListBox(panel, {
		width     = width,
		height    = 130,
		emptyText = L["None"],
	})

	-- The list box has no per-row controls of its own, so a remove button is
	-- attached to each row as it is filled. Rows are pooled, so the button is
	-- created once and reads the row's current key when it is clicked.
	local baseSetItems = list.SetItems
	function list:SetItems(items)
		baseSetItems(self, items)

		for i = 1, #self.items do
			local row = self.rows[i]
			if not row.boomRemove then
				row.boomRemove = Widgets:CreateButton(row, {
					label   = "x",
					width   = 20,
					height  = 18,
					tooltip = L["Remove"],
					onClick = function()
						if row.key then removeID(row.key) end
					end,
				})
				row.boomRemove:SetPoint("RIGHT", row, "RIGHT", -6, 0)
			end
			-- Keep the label clear of the button it would otherwise run under.
			row.text:SetPoint("RIGHT", row, "RIGHT", -32, 0)
			row.boomRemove:Show()
		end
	end

	function list:Refresh()
		self:SetItems(AuraListItems(def.path))
	end

	column:Add(list)

	function addFromInput()
		local id = ResolveSpellInput(input:GetText())
		if not id then
			status:SetMessage(L["Unknown spell"], Skin.colors.bad)
			return
		end

		Boom.Config:Set(def.path .. "." .. id, true)
		InvalidateAuras()

		input:SetText("")
		status:SetMessage(Util.GetSpellName(id) or tostring(id), Skin.colors.good)
		list:Refresh()
	end

	function removeID(id)
		Boom.Config:Set(def.path .. "." .. id, nil)
		InvalidateAuras()
		status:SetMessage("")
		list:Refresh()
	end

	list:Refresh()
end

Options:Register("auras", {
	label = "Auras",
	order = 60,

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

		column:Heading(L["Auras"])
		column:Add(CreateParagraph(panel, L["Three tiers decide what reaches a frame: boss auras are always important, anything you can personally dispel is always shown, then the curated lists below - and everything else stays hidden so the ones that matter stay readable."], { width = width }))

		column:Add(Widgets:CreateCheckBox(panel, {
			label = L["Show Boss Auras"],
			path  = "auras.showBossAuras",
		}))
		column:Add(Widgets:CreateCheckBox(panel, {
			label   = L["Only Dispellable"],
			path    = "auras.showOnlyDispellable",
			tooltip = L["Hide every debuff you cannot personally remove."],
		}))

		for i = 1, #AURA_LISTS do
			BuildAuraList(panel, column, width, AURA_LISTS[i])
		end
	end,
})

--------------------------------------------------------------------------------
-- Panel: Support
--------------------------------------------------------------------------------

--- The cooldown bar is anchored once when it is built, so moving it from here
--- has to re-anchor it by hand or the change waits for a reload.
local function RepositionCooldownBar()
	local Support = Boom.Support
	local bar = Support and Support.GetCooldownBar and Support:GetCooldownBar()
	if not bar then return end

	local cfg = Boom.profile.support.cooldownBar
	bar:ClearAllPoints()
	bar:SetPoint(cfg.point or "CENTER", UIParent, cfg.point or "CENTER", cfg.x or 0, cfg.y or 0)

	if Support.RefreshCooldownBar then Support:RefreshCooldownBar() end
end

local function TrackedItems()
	local out = {}
	local Support = Boom.Support
	if not (Support and Support.GetTrackedBuffs) then return out end

	local ids = Support:GetTrackedBuffs()
	for i = 1, #ids do
		local id = ids[i]
		out[#out + 1] = {
			key  = id,
			text = Util.GetSpellName(id) or tostring(id),
			icon = Util.GetSpellIcon(id),
		}
	end
	return out
end

Options:Register("support", {
	label = "Support",
	order = 70,

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

		column:Heading(L["Support"])
		column:Add(CreateParagraph(panel,
			L["Your support abilities are shown on the frames. DPS and tanks can use every external here."],
			{ width = width, color = Skin.colors.text }))

		column:Add(Widgets:CreateCheckBox(panel, {
			label = L["Enable Support Features"],
			path  = "support.enabled",
		}))
		column:Add(Widgets:CreateCheckBox(panel, {
			label   = L["Missing Buff Alert"],
			path    = "support.missingBuffAlert",
			tooltip = L["Flag allies who are missing the support buff you maintain."],
		}))
		column:Add(Widgets:CreateCheckBox(panel, {
			label   = L["Track Externals"],
			path    = "support.trackExternals",
			tooltip = L["Show defensive externals cast on a player by anyone in the group."],
		}))
		column:Add(Widgets:CreateCheckBox(panel, {
			label = L["Track Raid Cooldowns"],
			path  = "support.trackRaidCooldowns",
		}))

		--------------------------------------------------------- what is tracked
		column:Heading(L["Tracked Buffs"])
		column:Add(CreateParagraph(panel, L["Read from your specialization. These are the buffs Boom watches on the frames."], { width = width, size = 11 }))

		local tracked = Widgets:CreateListBox(panel, {
			width     = width,
			height    = 130,
			emptyText = L["Nothing to track for this specialization."],
		})
		function tracked:Refresh()
			self:SetItems(TrackedItems())
		end
		column:Add(tracked)
		tracked:Refresh()

		----------------------------------------------------------- cooldown bar
		column:Heading(L["Cooldown Bar"])

		column:Add(Widgets:CreateCheckBox(panel, {
			label = L["Enable"],
			path  = "support.cooldownBar.enabled",
		}))
		column:Add(Widgets:CreateSlider(panel, {
			label = L["Icon Size"],
			path  = "support.cooldownBar.iconSize",
			min = 16, max = 48, step = 1,
		}))
		column:Add(Widgets:CreateSlider(panel, {
			label = L["Max Icons"],
			path  = "support.cooldownBar.maxIcons",
			min = 4, max = 20, step = 1,
		}))
		column:Add(Widgets:CreateDropdown(panel, {
			label  = L["Growth"],
			width  = 200,
			path   = "support.cooldownBar.growth",
			values = {
				{ value = "RIGHT", text = L["Right"] },
				{ value = "LEFT",  text = L["Left"]  },
			},
			onChange = RepositionCooldownBar,
		}))
		column:Add(Widgets:CreateCheckBox(panel, {
			label = L["Show Only Ready"],
			path  = "support.cooldownBar.showOnlyReady",
		}))
		column:Add(Widgets:CreateSlider(panel, {
			label = L["Position"] .. " X",
			path  = "support.cooldownBar.x",
			min = -600, max = 600, step = 1,
			onChange = RepositionCooldownBar,
		}))
		column:Add(Widgets:CreateSlider(panel, {
			label = L["Position"] .. " Y",
			path  = "support.cooldownBar.y",
			min = -600, max = 600, step = 1,
			onChange = RepositionCooldownBar,
		}))
	end,
})

--------------------------------------------------------------------------------
-- Panel: Profiles
--------------------------------------------------------------------------------

--- Registered (and re-registered) at build time rather than at load, so the
--- dialog strings follow a language switch - the panels themselves are thrown
--- away and rebuilt on BOOM_LOCALE_CHANGED.
local function EnsurePopups()
	local dialogs = _G.StaticPopupDialogs
	if not dialogs then return false end

	dialogs["BOOM_DELETE_PROFILE"] = {
		text    = L["Delete the profile '%s'? This cannot be undone."],
		button1 = L["Delete"],
		button2 = L["Cancel"],
		OnAccept = function(dialog, data)
			local name = data or (dialog and dialog.data)
			if name and Boom.Config:DeleteProfile(name) then
				Boom.Options:RefreshAll()
			end
		end,
		timeout = 0,
		whileDead = true,
		hideOnEscape = true,
		showAlert = true,
		preferredIndex = 3,
	}

	dialogs["BOOM_RESET_PROFILE"] = {
		text    = L["Reset the profile '%s' to defaults?"],
		button1 = L["Reset"],
		button2 = L["Cancel"],
		OnAccept = function()
			Boom.Config:ResetProfile()
			Boom.Options:RefreshAll()
		end,
		timeout = 0,
		whileDead = true,
		hideOnEscape = true,
		showAlert = true,
		preferredIndex = 3,
	}

	return true
end

local function ShowPopup(key, name)
	if not EnsurePopups() or not _G.StaticPopup_Show then return false end
	_G.StaticPopup_Show(key, name, nil, name)
	return true
end

--- Grow an edit box into a copy/paste field. Import and export strings are long
--- enough that a single line would be unusable.
local function MakeMultiline(editBox, height)
	local box = editBox.editBox
	if box.SetMultiLine then box:SetMultiLine(true) end
	box:SetHeight(height)
	editBox:SetHeight(height + (editBox.label and 20 or 0))

	-- Select everything on focus so Ctrl+C is the only thing left to do.
	box:HookScript("OnEditFocusGained", function(self) self:HighlightText() end)
	return editBox
end

Options:Register("profiles", {
	label = "Profiles",
	order = 80,

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

		local status
		local createProfile
		local draft = { name = "", copyFrom = nil, exported = "", import = "" }

		EnsurePopups()

		local function profileValues()
			local out = {}
			local list = Config:GetProfileList()
			for i = 1, #list do
				out[#out + 1] = { value = list[i], text = list[i] }
			end
			return out
		end

		local function setStatus(message, color)
			if status then status:SetMessage(message or "", color) end
		end

		------------------------------------------------------------------ header
		-- The portrait sits in the panel's top-right corner rather than in the
		-- column, so it never shifts the controls when a spec has no artwork.
		-- With per-spec profiles switched on it is also the clearest possible
		-- answer to "which spec am I editing right now?".
		local portrait = Boom.SpecArt:CreatePortrait(panel, { width = 132, fade = false })
		portrait:SetPoint("TOPRIGHT", panel, "TOPRIGHT", -4, -4)
		portrait:SetAlpha(0.9)

		column:OnRefresh(function()
			portrait:SetSpec(Config:GetPlayerSpecID())
		end)

		column:Heading(L["Profiles"])
		-- Leave room for the portrait so the paragraph does not run under it.
		column:Add(CreateParagraph(panel, L["A profile holds every Boom setting. Keep one for everything, or give each specialization its own."], { width = width - 150 }))

		status = CreateParagraph(panel, "", { width = width, height = 16 })
		column:Add(status, 4)

		----------------------------------------------------------------- current
		column:Add(Widgets:CreateDropdown(panel, {
			label  = L["Current Profile"],
			width  = 280,
			values = profileValues,
			get    = function() return Config:GetCurrentProfileName() end,
			set    = function(value)
				if value ~= Config:GetCurrentProfileName() then
					Config:SwitchProfile(value)
				end
			end,
			onChange = function(value)
				setStatus(tostring(value), Skin.colors.good)
				Options:RefreshAll()
			end,
		}))

		--------------------------------------------------------------- creation
		local nameBox = Widgets:CreateEditBox(panel, {
			label       = L["New Profile"],
			width       = 280,
			placeholder = L["Name"],
			get = function() return draft.name end,
			set = function(value) draft.name = value end,
			onEnter = function() createProfile() end,
		})

		local createButton = Widgets:CreateButton(panel, {
			label   = L["Create"],
			width   = 110,
			onClick = function() createProfile() end,
		})

		column:Add(CreateStrip(panel, { nameBox, createButton }, { align = "BOTTOM" }))

		function createProfile()
			local name = strtrim(nameBox:GetText() or "")
			if name == "" then
				setStatus(L["Enter a profile name."], Skin.colors.bad)
				return
			end
			if not Config:CreateProfile(name) then
				setStatus(L["A profile with that name already exists."], Skin.colors.bad)
				return
			end

			-- Clear the field and report success BEFORE switching. Switching
			-- broadcasts BOOM_PROFILE_CHANGED to every module, and although that
			-- dispatch is now isolated per handler, the profile is already
			-- created by this point - so the user must see that regardless of
			-- what any listener does.
			draft.name = ""
			nameBox:SetText("")
			setStatus(name, Skin.colors.good)

			Config:SwitchProfile(name)
			Options:RefreshAll()
		end

		------------------------------------------------------------------- copy
		local copyDD = Widgets:CreateDropdown(panel, {
			label  = L["Copy From"],
			width  = 280,
			values = profileValues,
			get    = function() return draft.copyFrom end,
			set    = function(value) draft.copyFrom = value end,
		})

		local copyButton = Widgets:CreateButton(panel, {
			label   = L["Copy"],
			width   = 110,
			tooltip = L["Overwrite the current profile with the settings of another one."],
			onClick = function()
				if not draft.copyFrom then return end
				if Config:CopyFrom(draft.copyFrom) then
					setStatus(draft.copyFrom, Skin.colors.good)
					Options:RefreshAll()
				else
					setStatus(L["Import failed."], Skin.colors.bad)
				end
			end,
		})

		column:Add(CreateStrip(panel, { copyDD, copyButton }, { align = "BOTTOM" }))

		------------------------------------------------------- destructive pair
		local deleteButton = Widgets:CreateButton(panel, {
			label   = L["Delete Profile"],
			width   = 160,
			onClick = function()
				local name = Config:GetCurrentProfileName()
				-- Default is the fallback every character drops back to, so it
				-- is the one profile that must always exist.
				if name == "Default" then
					setStatus(L["The Default profile cannot be deleted."], Skin.colors.bad)
					return
				end
				ShowPopup("BOOM_DELETE_PROFILE", name)
			end,
		})

		local resetButton = Widgets:CreateButton(panel, {
			label   = L["Reset Profile"],
			width   = 160,
			onClick = function()
				ShowPopup("BOOM_RESET_PROFILE", Config:GetCurrentProfileName())
			end,
		})

		column:Add(CreateStrip(panel, { deleteButton, resetButton }), 12)

		---------------------------------------------------------------- per spec
		column:Add(Widgets:CreateCheckBox(panel, {
			label   = L["Per-Specialization Profiles"],
			tooltip = L["Automatically switch profile when you change specialization."],
			-- Not a profile setting: the mapping lives per character, so it goes
			-- through the Config API instead of a path.
			get = function() return Config:IsPerSpec() end,
			set = function(value) Config:SetPerSpec(value) end,
			onChange = function() Options:RefreshAll() end,
		}), 12)

		----------------------------------------------------------------- export
		column:Heading(L["Export"])

		local exportBox = MakeMultiline(Widgets:CreateEditBox(panel, {
			label       = L["Export"],
			width       = width,
			placeholder = L["Press Export, then copy the string."],
			get = function() return draft.exported end,
			set = function(value) draft.exported = value end,
		}), 74)

		local exportButton = Widgets:CreateButton(panel, {
			label   = L["Export"],
			width   = 140,
			onClick = function()
				local text = Config:ExportProfile()
				if not text then
					setStatus(L["This client cannot export profiles."], Skin.colors.bad)
					return
				end
				draft.exported = text
				exportBox:SetText(text)
				setStatus("", nil)
			end,
		})

		column:Add(CreateStrip(panel, { exportButton }))
		column:Add(exportBox)

		----------------------------------------------------------------- import
		column:Heading(L["Import"])

		local importBox = MakeMultiline(Widgets:CreateEditBox(panel, {
			label       = L["Import"],
			width       = width,
			placeholder = L["Paste a profile string here."],
			get = function() return draft.import end,
			set = function(value) draft.import = value end,
		}), 74)

		local importButton = Widgets:CreateButton(panel, {
			label   = L["Import"],
			width   = 140,
			primary = true,
			tooltip = L["Overwrites the current profile."],
			onClick = function()
				local ok = Config:ImportProfile(strtrim(importBox:GetText() or ""))
				if ok then
					draft.import = ""
					importBox:SetText("")
					setStatus(L["Profile imported."], Skin.colors.good)
					Boom:Print(L["Profile imported."])
					Options:RefreshAll()
				else
					setStatus(L["Import failed."], Skin.colors.bad)
				end
			end,
		})

		column:Add(importBox)
		column:Add(CreateStrip(panel, { importButton }))
	end,
})
