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

	The four "how it looks and where it sits" pages: General, Appearance,
	Layout and Indicators.

	This file creates no module. It runs at load and registers four categories
	with Boom.Options; each `build` runs once, the first time its tab is opened.

	Two of these pages are really N pages behind one selector - Layout edits four
	panel kinds, Indicators edits twenty-odd indicators. Both use the same
	`createSwapper` helper below rather than a bespoke show/hide dance:

	  * Sub-pages are built lazily and then CACHED. Switching back therefore
	    re-reads live values through each widget's own :Refresh instead of
	    building a second copy - WoW frames can never be destroyed, so a
	    rebuild-every-switch design would leak one full page per click.
	  * The swapper owns the last slot in the parent column, which lets it fix up
	    the column cursor and the panel height after a swap. Without that the
	    scroll area would keep the height of whichever page happened to be built
	    first and clip everything taller.

	The Indicators page is deliberately data-driven: it walks the actual config
	table for the selected indicator and emits one widget per key, using the
	FIELDS descriptor table for labels and sane ranges. Adding a field to
	Core/Defaults.lua therefore makes it configurable with no edit here, and an
	unrecognised key still gets a usable control chosen from its value type.
----------------------------------------------------------------------------]]

local ADDON_NAME, Boom = ...

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

local CreateFrame = CreateFrame
local pairs, ipairs, type, select = pairs, ipairs, type, select
local tsort = table.sort
local abs, max, floor = math.abs, math.max, math.floor
local format = string.format

--------------------------------------------------------------------------------
-- Small shared helpers
--------------------------------------------------------------------------------

--- Build a dropdown value list from alternating value, text arguments.
local function choices(...)
	local out = {}
	for i = 1, select("#", ...), 2 do
		out[#out + 1] = { value = select(i, ...), text = select(i + 1, ...) }
	end
	return out
end

--- A block of wrapped explanatory text.
--- The width is fixed by the caller (and flagged with boomFixedWidth) because
--- the string has to be measured at its final width: the column would otherwise
--- resize the frame after we had already computed the height.
local function paragraph(parent, width, text)
	local frame = CreateFrame("Frame", nil, parent)
	frame.boomFixedWidth = true
	frame:SetWidth(width or 400)

	local fs = frame:CreateFontString(nil, "OVERLAY")
	Util.SetFont(fs, Media:Font("Boom Default"), 12, "NONE")
	fs:SetTextColor(Util.Unpack(Skin.colors.textDim))
	fs:SetJustifyH("LEFT")
	fs:SetJustifyV("TOP")
	fs:SetWordWrap(true)
	fs:SetWidth(width or 400)
	fs:SetPoint("TOPLEFT", 0, 0)
	fs:SetText(text or "")

	frame:SetHeight(max(14, (fs:GetStringHeight() or 14) + 2))
	frame.text = fs
	return frame
end

--------------------------------------------------------------------------------
-- Sub-page swapper
--
-- Adds a host frame as the LAST entry of `column` and swaps cached sub-pages in
-- and out of it. `builder(key, frame, subColumn)` fills a page exactly once.
--------------------------------------------------------------------------------

local function createSwapper(panel, column, opts, builder)
	opts = opts or {}

	local minHeight = opts.minHeight or 1
	local pageX     = opts.x or 0
	local pageWidth = opts.width or column.width or 400

	local host = CreateFrame("Frame", nil, panel)
	host:SetHeight(max(1, minHeight))
	column:Add(host)

	-- Distance from the top of the panel to the top of the host. The host is the
	-- last thing in the column, so the whole page height follows from this.
	local topOffset = abs(column.y) - host:GetHeight()

	local pages = {}
	local swapper = { host = host, pages = pages }

	function swapper:Resize()
		local page = pages[self.currentKey]
		local height = max(minHeight, (page and page.column:GetHeight()) or 1)

		host:SetHeight(height)
		column.y = -(topOffset + height)
		panel:SetHeight(column:GetHeight())

		local window = Options:GetWindow()
		if window and window.scroll and panel:IsShown() then
			window.scroll:SetContentHeight(panel:GetHeight())
		end
	end

	function swapper:Select(key)
		if key == nil then return end

		local previous = pages[self.currentKey]
		if previous and self.currentKey ~= key then previous.frame:Hide() end

		local page = pages[key]
		if not page then
			local frame = CreateFrame("Frame", nil, host)
			frame:SetPoint("TOPLEFT", host, "TOPLEFT", pageX, 0)
			frame:SetWidth(pageWidth)
			frame:SetHeight(1)

			page = { frame = frame, column = Options:CreateColumn(frame, { width = pageWidth }) }
			pages[key] = page

			builder(key, frame, page.column)
			frame:SetHeight(max(1, page.column:GetHeight()))
		else
			-- Cached page: the settings behind it may have moved on since it was
			-- built (slash commands, presets, a profile swap).
			page.column:Refresh()
		end

		page.frame:Show()
		self.currentKey = key
		self:Resize()
	end

	--- Column:Refresh finds this and drives the visible sub-page with it.
	function host:Refresh()
		if swapper.onRefresh then swapper.onRefresh() end
		local page = pages[swapper.currentKey]
		if page then page.column:Refresh() end
	end

	return swapper
end

--------------------------------------------------------------------------------
-- Value lists
--
-- All of these are functions, not tables, for two reasons: media lists grow when
-- LibSharedMedia registers late, and every visible string has to be re-resolved
-- through L after a language switch.
--------------------------------------------------------------------------------

local function mediaChoices(mediaType)
	return function()
		local names = Media:List(mediaType)
		local out = {}
		for i = 1, #names do
			out[i] = { value = names[i], text = names[i] }
		end
		return out
	end
end

local function localeChoices()
	local out = {}
	for _, code in ipairs(Util.SortedKeys(Boom.LOCALES)) do
		out[#out + 1] = { value = code, text = Boom.LOCALES[code] }
	end
	return out
end

local function growthChoices()
	local out = {}
	for _, key in ipairs(Util.SortedKeys(Util.GROWTH_MODES)) do
		out[#out + 1] = { value = key, text = (key:gsub("_", " ")) }
	end
	return out
end

local function pointChoices()
	return choices(
		"TOPLEFT", "TOPLEFT", "TOP", "TOP", "TOPRIGHT", "TOPRIGHT",
		"LEFT", "LEFT", "CENTER", "CENTER", "RIGHT", "RIGHT",
		"BOTTOMLEFT", "BOTTOMLEFT", "BOTTOM", "BOTTOM", "BOTTOMRIGHT", "BOTTOMRIGHT"
	)
end

local function outlineChoices()
	return choices("NONE", L["None"], "OUTLINE", "OUTLINE", "THICKOUTLINE", "THICKOUTLINE")
end

local function directionChoices()
	return choices("RIGHT", L["Right"], "LEFT", L["Left"], "UP", L["Up"], "DOWN", L["Down"])
end

local function sortChoices()
	return choices("GROUP", L["Group"], "CLASS", L["Class"], "ROLE", L["Role"],
		"NAME", L["Name"], "INDEX", L["Index"])
end

local function healthColorChoices()
	return choices("class", L["Class Color"], "static", L["Static Color"], "gradient", L["Health Gradient"])
end

local function backgroundColorChoices()
	return choices("dark", L["Dark"], "class", L["Class Color"], "custom", L["Custom"])
end

local function orientationChoices()
	return choices("HORIZONTAL", L["Horizontal"], "VERTICAL", L["Vertical"])
end

local function fillChoices()
	return choices("LEFT", L["Left"], "RIGHT", L["Right"], "TOP", L["Top"], "BOTTOM", L["Bottom"])
end

local function powerPositionChoices()
	return choices("BOTTOM", L["Bottom"], "RIGHT", L["Right"])
end

local function healthFormatChoices()
	return choices("none", L["None"], "percent", L["Percent"], "deficit", L["Deficit"],
		"current", L["Current"], "current_percent", L["Current + Percent"])
end

local function aggroModeChoices()
	return choices("border", L["Border"], "healthColor", L["Health Color"],
		"glow", L["Glow"], "off", L["None"])
end

--------------------------------------------------------------------------------
-- General
--------------------------------------------------------------------------------

Options:Register("general", {
	label = "General",
	order = 10,

	build = function(panel, column)
		column:Add(paragraph(panel, column.width,
			L["Open settings any time with /boom"] .. ".\n" ..
			L["Unlock the frames to drag them around the screen."]))

		------------------------------------------------------------------ language
		column:Heading(L["Language"])

		column:Add(Widgets:CreateDropdown(panel, {
			label  = L["Language"],
			path   = "general.locale",
			values = localeChoices,
			-- No onChange: Config:Set fires BOOM_CONFIG_CHANGED, which Bootstrap
			-- already turns into Boom:SetLocale. Calling it here as well would
			-- rebuild every options panel twice and orphan the first set.
		}))

		----------------------------------------------------------------- behaviour
		column:Heading(L["Behaviour"])

		column:AddPair(
			Widgets:CreateCheckBox(panel, {
				label = L["Lock Frames"],
				path  = "general.lockFrames",
				tooltip = L["Unlock the frames to drag them around the screen."],
				onChange = function() Options:RefreshFooter() end,
			}),
			Widgets:CreateCheckBox(panel, {
				label = L["Test Mode"],
				path  = "general.testMode",
				tooltip = L["Fills the frames with fake units so you can position everything solo."],
				onChange = function() Options:RefreshFooter() end,
			})
		)

		-- "in combat" is meaningless while tooltips are off entirely, so it
		-- follows the master switch rather than sitting there lying to the user.
		local tooltipsInCombat = Widgets:CreateCheckBox(panel, {
			label = L["Show Tooltips in Combat"],
			path  = "general.tooltipsInCombat",
		})

		local showTooltips = Widgets:CreateCheckBox(panel, {
			label = L["Show Tooltips"],
			path  = "general.showTooltips",
			onChange = function(value)
				tooltipsInCombat:SetEnabled(value and true or false)
			end,
		})

		column:Add(Widgets:CreateCheckBox(panel, {
			label   = L["Specialization Portraits"],
			path    = "general.showSpecArt",
			tooltip = L["Show the artwork for your specialization in the setup wizard and the settings window."],
		}))

		column:AddPair(showTooltips, tooltipsInCombat)

		-- Dependent enable states must be recomputed whenever the page re-reads
		-- its values, not just when it is first built: a profile switch changes
		-- the governing setting without firing any widget's onChange.
		column:OnRefresh(function()
			tooltipsInCombat:SetEnabled(Config:Get("general.showTooltips") and true or false)
		end)
		tooltipsInCombat:SetEnabled(Config:Get("general.showTooltips") and true or false)

		---------------------------------------------------------- Blizzard frames
		column:Heading(L["Blizzard Frames"])

		column:AddPair(
			Widgets:CreateCheckBox(panel, {
				label = L["Hide Blizzard Raid Frames"],
				path  = "general.hideBlizzardRaid",
			}),
			Widgets:CreateCheckBox(panel, {
				label = L["Hide Blizzard Party Frames"],
				path  = "general.hideBlizzardParty",
			})
		)

		-- Separate from the two above on purpose: the manager is the pull-out tab,
		-- not a set of unit frames, and most people who hide Blizzard's frames
		-- still want the raid markers and ready check that live on it.
		column:Add(Widgets:CreateCheckBox(panel, {
			label   = L["Hide Blizzard Raid Manager"],
			path    = "general.hideBlizzardRaidManager",
			tooltip = L["The pull-out tab on the left edge of the screen: raid markers, ready check and role poll. Its filter options only affect Blizzard's own frames, not Boom's."],
		}))

		--------------------------------------------------------------- minimap
		column:Heading(L["Advanced"])

		-- Account-wide, so it lives in Boom.global rather than the profile and
		-- cannot be bound by path.
		column:Add(Widgets:CreateCheckBox(panel, {
			label = L["Minimap Button"],
			get = function()
				return not (Boom.global and Boom.global.minimap and Boom.global.minimap.hide)
			end,
			set = function(value)
				if not (Boom.global and Boom.global.minimap) then return end
				Boom.global.minimap.hide = not value
				if Boom.Bootstrap and Boom.Bootstrap.UpdateMinimapButton then
					Boom.Bootstrap:UpdateMinimapButton()
				end
			end,
		}))
	end,
})

--------------------------------------------------------------------------------
-- Appearance
--------------------------------------------------------------------------------

Options:Register("appearance", {
	label = "Appearance",
	order = 20,

	build = function(panel, column)
		------------------------------------------------------------------- bars
		column:Heading(L["Bars"])

		column:AddPair(
			Widgets:CreateDropdown(panel, {
				label = L["Bar Texture"], path = "appearance.barTexture",
				values = mediaChoices("statusbar"),
			}),
			Widgets:CreateDropdown(panel, {
				label = L["Background Texture"], path = "appearance.bgTexture",
				values = mediaChoices("statusbar"),
			})
		)

		column:AddPair(
			Widgets:CreateDropdown(panel, {
				label = L["Bar Orientation"], path = "appearance.barOrientation",
				values = orientationChoices,
			}),
			Widgets:CreateDropdown(panel, {
				label = L["Fill Direction"], path = "appearance.barFillDirection",
				values = fillChoices,
			})
		)

		------------------------------------------------------------------- text
		column:Heading(L["Text"])

		column:AddPair(
			Widgets:CreateDropdown(panel, {
				label = L["Font"], path = "appearance.font", values = mediaChoices("font"),
			}),
			Widgets:CreateDropdown(panel, {
				label = L["Outline"], path = "appearance.fontOutline", values = outlineChoices,
			})
		)

		column:Add(Widgets:CreateSlider(panel, {
			label = L["Font Size"], path = "appearance.fontSize", min = 8, max = 20, step = 1,
		}))

		----------------------------------------------------------------- borders
		column:Heading(L["Border Size"])

		column:Add(Widgets:CreateSlider(panel, {
			label = L["Border Size"], path = "appearance.borderSize", min = 0, max = 4, step = 1,
		}))
		column:Add(Widgets:CreateColorPicker(panel, {
			label = L["Border Color"], path = "appearance.borderColor", hasAlpha = true,
		}))

		------------------------------------------------------------------ colors
		column:Heading(L["Colors"])

		-- Only meaningful in "static" mode; shown always so the user can see the
		-- colour they will get before committing to the mode.
		local staticColor = Widgets:CreateColorPicker(panel, {
			label = L["Static Color"], path = "appearance.staticHealthColor",
		})

		column:Add(Widgets:CreateDropdown(panel, {
			label = L["Health Color"], path = "appearance.healthColorMode",
			values = healthColorChoices,
			onChange = function(value) staticColor:SetEnabled(value == "static") end,
		}))
		column:Add(staticColor)

		local backgroundColor = Widgets:CreateColorPicker(panel, {
			label = L["Background Color"], path = "appearance.backgroundColor", hasAlpha = true,
		})
		local classMultiplier = Widgets:CreateSlider(panel, {
			label = L["Class Color"] .. " " .. L["Opacity"],
			path = "appearance.backgroundClassMultiplier",
			min = 0.05, max = 0.6, step = 0.01,
		})

		local function applyBackgroundMode(value)
			backgroundColor:SetEnabled(value == "custom")
			classMultiplier:SetEnabled(value == "class")
		end

		column:Add(Widgets:CreateDropdown(panel, {
			label = L["Background Color"], path = "appearance.backgroundColorMode",
			values = backgroundColorChoices,
			onChange = applyBackgroundMode,
		}))
		column:Add(backgroundColor)
		column:Add(classMultiplier)

		column:Add(Widgets:CreateColorPicker(panel, {
			label = L["Accent Color"], path = "appearance.accentColor",
		}))

		----------------------------------------------------------------- effects
		column:Heading(L["Advanced"])

		local glossAlpha = Widgets:CreateSlider(panel, {
			label = L["Gloss Overlay"] .. " " .. L["Opacity"],
			path = "appearance.gradientOverlayAlpha", min = 0, max = 1, step = 0.05,
		})
		column:Add(Widgets:CreateCheckBox(panel, {
			label = L["Gloss Overlay"], path = "appearance.gradientOverlay",
			onChange = function(value) glossAlpha:SetEnabled(value and true or false) end,
		}))
		column:Add(glossAlpha)

		local shadowAlpha = Widgets:CreateSlider(panel, {
			label = L["Drop Shadow"] .. " " .. L["Opacity"],
			path = "appearance.dropShadowAlpha", min = 0, max = 1, step = 0.05,
		})
		column:Add(Widgets:CreateCheckBox(panel, {
			label = L["Drop Shadow"], path = "appearance.dropShadow",
			onChange = function(value) shadowAlpha:SetEnabled(value and true or false) end,
		}))
		column:Add(shadowAlpha)

		-- One hook for every dependent control on this page. Without it a
		-- profile switch leaves whichever controls were greyed out at build time
		-- greyed out forever, even though the mode they depend on has changed.
		local function applyDependencies()
			staticColor:SetEnabled(Config:Get("appearance.healthColorMode") == "static")
			applyBackgroundMode(Config:Get("appearance.backgroundColorMode"))
			glossAlpha:SetEnabled(Config:Get("appearance.gradientOverlay") and true or false)
			shadowAlpha:SetEnabled(Config:Get("appearance.dropShadow") and true or false)
		end

		column:OnRefresh(applyDependencies)
		applyDependencies()

		column:AddPair(
			Widgets:CreateSlider(panel, {
				label = L["Out of Range Alpha"], path = "appearance.alphaOutOfRange",
				min = 0, max = 1, step = 0.05,
			}),
			Widgets:CreateSlider(panel, {
				label = L["Dead Alpha"], path = "appearance.alphaDead",
				min = 0, max = 1, step = 0.05,
			})
		)
		column:Add(Widgets:CreateSlider(panel, {
			label = L["Offline Alpha"], path = "appearance.alphaOffline",
			min = 0, max = 1, step = 0.05,
		}))

		--------------------------------------------------------------- power bar
		column:Heading(L["Power Bar"])

		column:Add(Widgets:CreateCheckBox(panel, {
			label = L["Enabled"], path = "powerBar.enabled",
		}))
		column:AddPair(
			Widgets:CreateSlider(panel, {
				label = L["Height"], path = "powerBar.height", min = 1, max = 12, step = 1,
			}),
			Widgets:CreateDropdown(panel, {
				label = L["Position"], path = "powerBar.position", values = powerPositionChoices,
			})
		)
		column:AddPair(
			Widgets:CreateCheckBox(panel, {
				label = L["Only Mana Users"], path = "powerBar.onlyManaUsers",
			}),
			Widgets:CreateCheckBox(panel, {
				label = L["Only Healers"], path = "powerBar.onlyGroupHealers",
			})
		)
		column:Add(Widgets:CreateCheckBox(panel, {
			label = L["Color by Power Type"], path = "powerBar.colorByType",
		}))
	end,
})

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

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

local function layoutKindChoices()
	return choices(
		"party", L["Party"],
		"raid", L["Raid"],
		"pet", L["Pets"],
		"maintank", L["Main Tank"]
	)
end

--- One layout kind's controls. Every path is "layouts.<kind>.<field>", which is
--- why a page can simply be rebuilt per kind instead of rebinding widgets.
local function buildLayoutPage(kind, frame, col)
	local base = "layouts." .. kind .. "."

	col:Add(Widgets:CreateCheckBox(frame, {
		label = L["Enabled"], path = base .. "enabled",
	}))

	col:Heading(L["Frame Size"])

	col:AddPair(
		Widgets:CreateSlider(frame, {
			label = L["Width"], path = base .. "width", min = 20, max = 200, step = 1,
		}),
		Widgets:CreateSlider(frame, {
			label = L["Height"], path = base .. "height", min = 20, max = 200, step = 1,
		})
	)
	col:AddPair(
		Widgets:CreateSlider(frame, {
			label = L["Horizontal Spacing"], path = base .. "spacingX", min = 0, max = 20, step = 1,
		}),
		Widgets:CreateSlider(frame, {
			label = L["Vertical Spacing"], path = base .. "spacingY", min = 0, max = 20, step = 1,
		})
	)
	col:Add(Widgets:CreateSlider(frame, {
		label = L["Scale"], path = base .. "scale", min = 0.5, max = 2, step = 0.05,
	}))

	col:Heading(L["Growth"])

	col:AddPair(
		Widgets:CreateDropdown(frame, {
			label = L["Growth Direction"], path = base .. "growth", values = growthChoices,
		}),
		Widgets:CreateSlider(frame, {
			label = L["Units per Column"], path = base .. "unitsPerColumn", min = 1, max = 40, step = 1,
		})
	)
	col:Add(Widgets:CreateSlider(frame, {
		label = L["Max Columns"], path = base .. "maxColumns", min = 1, max = 8, step = 1,
	}))

	col:Heading(L["Sorting"])

	col:AddPair(
		Widgets:CreateDropdown(frame, {
			label = L["Sort By"], path = base .. "sortMode", values = sortChoices,
		}),
		Widgets:CreateEditBox(frame, {
			label = L["Role Order"], path = base .. "roleOrder",
			placeholder = "TANK,HEALER,DAMAGER",
		})
	)
	col:AddPair(
		Widgets:CreateCheckBox(frame, {
			label = L["Reverse Order"], path = base .. "reverseOrder",
		}),
		Widgets:CreateCheckBox(frame, {
			label = L["Keep Groups Together"], path = base .. "keepGroupsTogether",
		})
	)
	col:AddPair(
		Widgets:CreateCheckBox(frame, {
			label = L["Show Player"], path = base .. "showPlayer",
		}),
		Widgets:CreateCheckBox(frame, {
			label = L["Show When Solo"], path = base .. "showSolo",
		})
	)

	-- Only the raid panel spreads across the eight raid groups.
	if kind == "raid" then
		col:Heading(L["Visible Raid Groups"])

		local boxes = {}
		for i = 1, 8 do
			boxes[i] = Widgets:CreateCheckBox(frame, {
				label = format("%s %d", L["Group"], i),
				path  = base .. "groupFilter." .. i,
			})
		end
		for i = 1, 8, 2 do
			col:AddPair(boxes[i], boxes[i + 1])
		end
	end
end

Options:Register("layout", {
	label = "Layout",
	order = 30,

	build = function(panel, column)
		column:Add(paragraph(panel, column.width,
			L["Unlock the frames to drag them around the screen."] .. " " ..
			L["Frames cannot be moved during combat."]))

		local swapper

		local picker = Widgets:CreateDropdown(panel, {
			label  = L["Panels"],
			values = layoutKindChoices,
			get    = function() return swapper and swapper.currentKey or LAYOUT_KINDS[1] end,
			set    = function(value)
				if swapper then swapper:Select(value) end
			end,
		})
		column:Add(picker)

		swapper = createSwapper(panel, column, { width = column.width }, buildLayoutPage)
		swapper:Select(LAYOUT_KINDS[1])
		picker:Refresh()
	end,
})

--------------------------------------------------------------------------------
-- Indicators
--
-- FIELDS maps a config key to how it should be presented. Anything not listed
-- still gets a control, chosen from the stored value's type - that is what keeps
-- this page honest when Defaults.lua grows a new indicator field.
--------------------------------------------------------------------------------

local CORE_KEYS = { enabled = true, point = true, x = true, y = true }

local FIELDS = {
	-- Always present, always first.
	enabled = { order = 1, kind = "toggle", label = "Enabled" },
	point   = { order = 2, kind = "choice", label = "Position", values = pointChoices },
	x       = { order = 3, kind = "range",  label = "X Offset", min = -60, max = 60, step = 1 },
	y       = { order = 4, kind = "range",  label = "Y Offset", min = -60, max = 60, step = 1 },

	-- Icon geometry.
	size            = { order = 10, kind = "range",  label = "Size", min = 6, max = 48, step = 1 },
	max             = { order = 11, kind = "range",  label = "Max", min = 1, max = 10, step = 1 },
	spacing         = { order = 12, kind = "range",  label = "Spacing", min = 0, max = 10, step = 1 },
	direction       = { order = 13, kind = "choice", label = "Direction", values = directionChoices },
	bossAuraScale   = { order = 14, kind = "range",  label = "Boss Aura Scale", min = 1, max = 2, step = 0.05 },

	-- Text.
	fontSize         = { order = 20, kind = "range",  label = "Font Size", min = 6, max = 24, step = 1 },
	outline          = { order = 21, kind = "choice", label = "Outline", values = outlineChoices },
	format           = { order = 22, kind = "choice", label = "Format", values = healthFormatChoices },
	maxLength        = { order = 23, kind = "range",  label = "Max Name Length", min = 1, max = 20, step = 1 },
	durationFontSize = { order = 24, kind = "range",  label = "Duration Font Size", min = 6, max = 20, step = 1 },
	countFontSize    = { order = 25, kind = "range",  label = "Stack Font Size", min = 6, max = 20, step = 1 },

	-- Colour and weight.
	color           = { order = 30, kind = "color",  label = "Color" },
	classColor      = { order = 31, kind = "toggle", label = "Class Color" },
	alpha           = { order = 32, kind = "range",  label = "Opacity", min = 0, max = 1, step = 0.05 },
	thickness       = { order = 33, kind = "range",  label = "Border Size", min = 1, max = 5, step = 1 },
	borderThickness = { order = 34, kind = "range",  label = "Border Size", min = 1, max = 5, step = 1 },
	mode            = { order = 35, kind = "choice", label = "Mode", values = aggroModeChoices },
	maxOverflow     = { order = 36, kind = "range",  label = "Max Overflow", min = 1, max = 1.5, step = 0.05 },
	expiryWarning   = { order = 37, kind = "range",  label = "Expiry Warning", min = 0, max = 15, step = 1 },

	-- Toggles.
	showDuration         = { order = 50, kind = "toggle", label = "Show Duration" },
	showStacks           = { order = 51, kind = "toggle", label = "Show Stacks" },
	showAmount           = { order = 52, kind = "toggle", label = "Show Amount" },
	showIcon             = { order = 53, kind = "toggle", label = "Show Icon" },
	showCooldown         = { order = 54, kind = "toggle", label = "Show Cooldown" },
	showCountdownFrame   = { order = 55, kind = "toggle", label = "Show Cooldown Swipe" },
	showCountdownNumbers = { order = 56, kind = "toggle", label = "Show Countdown Numbers" },
	showOverAbsorbGlow   = { order = 57, kind = "toggle", label = "Show Over-Absorb Glow" },
	glow                 = { order = 58, kind = "toggle", label = "Glow" },
	highlightBorder      = { order = 59, kind = "toggle", label = "Highlight Border" },
	hideAtFull           = { order = 60, kind = "toggle", label = "Hide at Full Health" },
	hideInVehicle        = { order = 61, kind = "toggle", label = "Hide in Vehicle" },
	hideDamager          = { order = 62, kind = "toggle", label = "Hide for Damage Dealers" },
	onlyMine             = { order = 63, kind = "toggle", label = "Only My Auras" },
	onlyImportant        = { order = 64, kind = "toggle", label = "Only Important" },
	onlyTanks            = { order = 65, kind = "toggle", label = "Only Tanks" },
}

local UNKNOWN_ORDER = 900

local function fieldOrder(key)
	local desc = FIELDS[key]
	return desc and desc.order or UNKNOWN_ORDER
end

local function fieldKind(desc, value)
	if desc and desc.kind then return desc.kind end

	local valueType = type(value)
	if valueType == "boolean" then return "toggle" end
	if valueType == "number"  then return "range" end
	if valueType == "table"   then return "color" end
	return "text"
end

--- One control for one config key of one indicator.
local function makeField(frame, id, key, value, desc, kind)
	local path  = "indicators." .. id .. "." .. key
	local label = L[(desc and desc.label) or key]

	if kind == "toggle" then
		return Widgets:CreateCheckBox(frame, { label = label, path = path })

	elseif kind == "choice" then
		return Widgets:CreateDropdown(frame, { label = label, path = path, values = desc.values })

	elseif kind == "color" then
		return Widgets:CreateColorPicker(frame, { label = label, path = path, hasAlpha = true })

	elseif kind == "range" then
		local minValue = desc and desc.min
		local maxValue = desc and desc.max
		local step     = desc and desc.step

		if not minValue then
			-- No descriptor: derive a usable range from what is stored, so an
			-- unrecognised numeric field is still adjustable.
			local current = type(value) == "number" and value or 0
			if current == floor(current) then
				minValue, maxValue, step = 0, max(20, current * 2), 1
			else
				minValue, maxValue, step = 0, max(2, current * 2), 0.05
			end
		end

		return Widgets:CreateSlider(frame, {
			label = label, path = path,
			min = minValue, max = maxValue, step = step,
		})
	end

	return Widgets:CreateEditBox(frame, { label = label, path = path })
end

--- The right-hand half of the Indicators page, for one indicator.
local function buildIndicatorPage(id, frame, col, onEnabledChanged)
	local cfg = Boom.profile.indicators and Boom.profile.indicators[id]
	if not cfg then
		col:Heading(L["None"])
		return
	end

	local def = Boom.Indicators and Boom.Indicators:Get(id)
	col:Heading(L[(def and def.label) or id])

	col:Add(Widgets:CreateCheckBox(frame, {
		label = L["Enabled"],
		path  = "indicators." .. id .. ".enabled",
		-- The list on the left carries the tick, so it has to hear about this.
		onChange = function()
			if onEnabledChanged then onEnabledChanged() end
		end,
	}))

	col:Heading(L["Position"])

	col:Add(makeField(frame, id, "point", cfg.point, FIELDS.point, "choice"))
	col:AddPair(
		makeField(frame, id, "x", cfg.x, FIELDS.x, "range"),
		makeField(frame, id, "y", cfg.y, FIELDS.y, "range")
	)

	-- Everything else this particular indicator actually stores.
	local keys = {}
	for key in pairs(cfg) do
		if not CORE_KEYS[key] then keys[#keys + 1] = key end
	end
	if #keys == 0 then return end

	tsort(keys, function(a, b)
		local orderA, orderB = fieldOrder(a), fieldOrder(b)
		if orderA == orderB then return a < b end
		return orderA < orderB
	end)

	col:Heading(L["Advanced"])

	-- Tall controls (sliders, dropdowns) and short ones (checkboxes, swatches)
	-- are paired among themselves, otherwise every mixed row leaves a ragged gap.
	local tall, short = {}, {}
	for i = 1, #keys do
		local key    = keys[i]
		local desc   = FIELDS[key]
		local kind   = fieldKind(desc, cfg[key])
		local widget = makeField(frame, id, key, cfg[key], desc, kind)

		if kind == "toggle" or kind == "color" then
			short[#short + 1] = widget
		else
			tall[#tall + 1] = widget
		end
	end

	for i = 1, #tall, 2 do
		col:AddPair(tall[i], tall[i + 1])
	end
	for i = 1, #short, 2 do
		col:AddPair(short[i], short[i + 1])
	end
end

Options:Register("indicators", {
	label = "Indicators",
	order = 40,

	build = function(panel, column)
		column:Add(paragraph(panel, column.width,
			L["Enabled indicators are shown on every unit frame."] .. " " ..
			L["Select an indicator to configure it."]))

		local LIST_WIDTH  = 210
		local LIST_GAP    = 16
		local LIST_HEIGHT = 380

		local list

		local function indicatorItems()
			local items = {}
			if not (Boom.Indicators and Boom.profile.indicators) then return items end

			for def in Boom.Indicators:IterateDefs() do
				local cfg = Boom.profile.indicators[def.id]
				if cfg then
					items[#items + 1] = {
						key     = def.id,
						text    = L[def.label] or def.id,
						-- A tick beats a word here: the row is 210px wide and the
						-- state has to be readable while scanning the whole list.
						subtext = cfg.enabled and "*" or "",
					}
				end
			end
			return items
		end

		local function refreshList()
			if list then list:SetItems(indicatorItems()) end
		end

		local swapper
		swapper = createSwapper(panel, column, {
			x         = LIST_WIDTH + LIST_GAP,
			width     = (column.width or 400) - LIST_WIDTH - LIST_GAP,
			minHeight = LIST_HEIGHT,
		}, function(id, frame, col)
			buildIndicatorPage(id, frame, col, refreshList)
		end)

		list = Widgets:CreateListBox(swapper.host, {
			width     = LIST_WIDTH,
			height    = LIST_HEIGHT,
			rowHeight = 24,
			emptyText = L["None"],
			onSelect  = function(key) swapper:Select(key) end,
		})
		list.boomFixedWidth = true
		list:SetPoint("TOPLEFT", swapper.host, "TOPLEFT", 0, 0)
		list:SetPoint("BOTTOMLEFT", swapper.host, "BOTTOMLEFT", 0, 0)

		-- Keep the ticks honest after a profile swap or a preset being applied.
		swapper.onRefresh = refreshList

		local items = indicatorItems()
		list:SetItems(items)
		if items[1] then
			list:SetSelected(items[1].key)
		end
	end,
})
