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

	The settings window shell: one movable panel, a category rail down the left,
	a scrolling content area on the right.

	Panels register themselves rather than being hardcoded here:

	    Boom.Options:Register("appearance", {
	        label = "Appearance",     -- Boom.L key
	        order = 20,
	        build = function(content, column) ... end,
	    })

	`build` runs once, the first time its category is opened, and receives a
	column helper that stacks widgets with consistent spacing so every page looks
	like it was laid out by the same hand.
----------------------------------------------------------------------------]]

local ADDON_NAME, Boom = ...

local Options = Boom:NewModule("Options")

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

local ipairs, pairs, tsort = ipairs, pairs, table.sort

local WINDOW_WIDTH  = 860
-- 620 of content plus the 22 the emblem header added over a plain title bar, so
-- the scroll area is exactly as tall as it was before the header grew.
local WINDOW_HEIGHT = 642
local RAIL_WIDTH    = 172

--------------------------------------------------------------------------------
-- Category registry
--------------------------------------------------------------------------------

local categories = {}
local ordered = {}
local dirtyOrder = true

function Options:Register(key, def)
	def.key = key
	def.order = def.order or 100
	categories[key] = def
	ordered[#ordered + 1] = def
	dirtyOrder = true
end

local function sortedCategories()
	if dirtyOrder then
		tsort(ordered, function(a, b)
			if a.order == b.order then return a.key < b.key end
			return a.order < b.order
		end)
		dirtyOrder = false
	end
	return ordered
end

--------------------------------------------------------------------------------
-- Column helper
--
-- Panels never position anything themselves. They append to a column, which
-- keeps a running cursor and hands back the same left margin, the same gap
-- between controls and the same extra breathing room under a heading on every
-- page in the addon.
--------------------------------------------------------------------------------

local Column = {}
Column.__index = Column

local GAP_DEFAULT = 8
local GAP_HEADING = 16

function Options:CreateColumn(parent, opts)
	opts = opts or {}
	return setmetatable({
		parent  = parent,
		x       = opts.x or 0,
		y       = opts.y or 0,
		width   = opts.width,
		widgets = {},
	}, Column)
end

--- Append a widget. `gap` overrides the default spacing above it.
function Column:Add(widget, gap)
	if not widget then return widget end

	self.y = self.y - (gap or (self.count and GAP_DEFAULT or 0))
	self.count = (self.count or 0) + 1

	widget:ClearAllPoints()
	widget:SetPoint("TOPLEFT", self.parent, "TOPLEFT", self.x, self.y)
	if self.width and widget.SetWidth and not widget.boomFixedWidth then
		widget:SetWidth(self.width)
	end

	self.y = self.y - (widget:GetHeight() or 20)
	self.widgets[#self.widgets + 1] = widget
	return widget
end

--- A section title. Slightly more air above it than between plain controls,
--- which is what makes a long page scan as groups.
function Column:Heading(text)
	local heading = Widgets:CreateHeading(self.parent, text)
	return self:Add(heading, self.count and GAP_HEADING or 0)
end

--- Two widgets side by side, for settings that belong together.
function Column:AddPair(left, right, gap)
	if not left then return end

	self.y = self.y - (gap or (self.count and GAP_DEFAULT or 0))
	self.count = (self.count or 0) + 1

	local half = ((self.width or 400) - 12) / 2

	left:ClearAllPoints()
	left:SetPoint("TOPLEFT", self.parent, "TOPLEFT", self.x, self.y)
	if left.SetWidth and not left.boomFixedWidth then left:SetWidth(half) end
	-- Must be recorded, or Column:Refresh walks straight past every paired
	-- control and they keep showing the previous profile's values.
	self.widgets[#self.widgets + 1] = left

	local height = left:GetHeight() or 20

	if right then
		right:ClearAllPoints()
		right:SetPoint("TOPLEFT", self.parent, "TOPLEFT", self.x + half + 12, self.y)
		if right.SetWidth and not right.boomFixedWidth then right:SetWidth(half) end
		self.widgets[#self.widgets + 1] = right
		height = math.max(height, right:GetHeight() or 20)
	end

	self.y = self.y - height
end

function Column:Spacer(height)
	self.y = self.y - (height or 10)
end

--- Total height consumed, for sizing the scroll content.
function Column:GetHeight()
	return math.abs(self.y) + 16
end

--- Register a callback to run whenever the page re-reads its values. Panels use
--- this for controls whose enabled state depends on another control, which
--- otherwise would be computed once at build time and then stay wrong forever
--- after a profile switch.
function Column:OnRefresh(func)
	self.refreshHooks = self.refreshHooks or {}
	self.refreshHooks[#self.refreshHooks + 1] = func
end

function Column:Refresh()
	for _, widget in ipairs(self.widgets) do
		if widget.Refresh then widget:Refresh() end
	end

	if self.refreshHooks then
		for _, func in ipairs(self.refreshHooks) do
			local ok, err = pcall(func)
			if not ok then Boom:Debug("options refresh hook failed:", err) end
		end
	end
end

--------------------------------------------------------------------------------
-- Window
--------------------------------------------------------------------------------

local window

local function buildWindow()
	if window then return window end

	window = Skin:CreatePanel(UIParent, {
		name = "BoomOptionsFrame",
		title = "Boom  |cff667788" .. Boom.VERSION .. "|r",
		icon = Boom.Media.LOGO,
		width = WINDOW_WIDTH,
		height = WINDOW_HEIGHT,
		closeButton = true,
	})
	window:SetPoint("CENTER")
	window:SetFrameStrata("HIGH")
	window:SetToplevel(true)
	window:Hide()

	-- Escape closes it, like every other panel in the game.
	tinsert(UISpecialFrames, "BoomOptionsFrame")

	------------------------------------------------------------------ left rail
	-- Anchored under whatever height the title bar actually came out at: a panel
	-- carrying an emblem gets a taller header, and a hardcoded offset here would
	-- slide the whole rail up underneath it.
	local railTop = -(window.titleBarHeight + 1)

	local rail = CreateFrame("Frame", nil, window)
	rail:SetPoint("TOPLEFT", window, "TOPLEFT", 1, railTop)
	rail:SetPoint("BOTTOMLEFT", window, "BOTTOMLEFT", 1, 1)
	rail:SetWidth(RAIL_WIDTH)
	Skin:CreateBackdrop(rail, { color = Skin.colors.bgDeep, borderSize = 0 })

	local edge = Skin:CreateSeparator(rail, false)
	edge:SetPoint("TOPRIGHT", rail, "TOPRIGHT", 0, 0)
	edge:SetPoint("BOTTOMRIGHT", rail, "BOTTOMRIGHT", 0, 0)

	local tabs = Widgets:CreateTabList(rail, {
		width = RAIL_WIDTH - 12,
		onSelect = function(key) Options:Select(key) end,
	})
	tabs:SetPoint("TOPLEFT", rail, "TOPLEFT", 6, -10)
	window.tabs = tabs

	--------------------------------------------------------------- content area
	local body = CreateFrame("Frame", nil, window)
	body:SetPoint("TOPLEFT", rail, "TOPRIGHT", 14, -10)
	body:SetPoint("BOTTOMRIGHT", window, "BOTTOMRIGHT", -12, 40)
	window.body = body

	local scroll = Widgets:CreateScrollFrame(body)
	scroll:SetAllPoints(body)
	-- Panels are sized explicitly from their column, so the automatic
	-- measurement pass would only fight us.
	scroll.autoSize = false
	window.scroll = scroll
	window.content = scroll.content

	------------------------------------------------------------------- footer
	local footer = CreateFrame("Frame", nil, window)
	footer:SetPoint("BOTTOMLEFT", window, "BOTTOMLEFT", RAIL_WIDTH + 12, 1)
	footer:SetPoint("BOTTOMRIGHT", window, "BOTTOMRIGHT", -1, 1)
	footer:SetHeight(38)

	local line = Skin:CreateSeparator(footer)
	line:SetPoint("TOPLEFT")
	line:SetPoint("TOPRIGHT")

	local lockButton = Widgets:CreateButton(footer, {
		label = L["Lock Frames"],
		width = 150,
		onClick = function()
			Boom.Frames:SetLocked(not Boom.profile.general.lockFrames)
			Options:RefreshFooter()
		end,
	})
	lockButton:SetPoint("LEFT", footer, "LEFT", 4, 0)
	window.lockButton = lockButton

	local testButton = Widgets:CreateButton(footer, {
		label = L["Test Mode"],
		width = 150,
		onClick = function()
			Boom.Config:Set("general.testMode", not Boom.profile.general.testMode)
			Options:RefreshFooter()
		end,
	})
	testButton:SetPoint("LEFT", lockButton, "RIGHT", 8, 0)
	window.testButton = testButton

	local setupButton = Widgets:CreateButton(footer, {
		label = L["Use Recommended Setup"],
		width = 210,
		primary = true,
		onClick = function() Boom.Presets:ShowWizard() end,
	})
	setupButton:SetPoint("RIGHT", footer, "RIGHT", -10, 0)
	window.setupButton = setupButton

	return window
end

--- The three footer buttons. Every label is re-resolved here rather than kept
--- from build time, so this doubles as the footer's language refresh.
function Options:RefreshFooter()
	if not window then return end

	local locked = Boom.profile.general.lockFrames
	if window.lockButton.SetLabel then
		window.lockButton:SetLabel(locked and L["Unlock Frames"] or L["Lock Frames"])
	end

	local testing = Boom.profile.general.testMode
	if window.testButton.SetLabel then
		window.testButton:SetLabel(testing and L["Test Mode"] .. " *" or L["Test Mode"])
	end

	if window.setupButton and window.setupButton.SetLabel then
		window.setupButton:SetLabel(L["Use Recommended Setup"])
	end
end

--------------------------------------------------------------------------------
-- Category switching
--------------------------------------------------------------------------------

--------------------------------------------------------------------------------
-- Built pages, keyed by the language they were built in
--
-- A page resolves every label, every tooltip and every dropdown entry through L
-- while it is being built, and then holds those strings forever. Switching
-- language therefore cannot be fixed by refreshing values - the page has to be
-- built again.
--
-- WoW frames can never be destroyed, so "build it again" has to mean something
-- bounded. Keying the cache by locale is what makes it so: each page is built at
-- most once per language, switching back and forth reuses what is already there,
-- and with two languages the ceiling is two copies of the pages the user has
-- actually opened. The alternative that was here before - keep the pages and
-- tell the user to /reload - is why half the settings window stayed in the old
-- language.
--------------------------------------------------------------------------------

local panelsByLocale = {}
local panels                       -- the active locale's table

local function panelsFor(locale)
	local store = panelsByLocale[locale or "enUS"]
	if not store then
		store = {}
		panelsByLocale[locale or "enUS"] = store
	end
	return store
end

--- Hide every page of every language. Cheap - it is a handful of frames - and it
--- is the only way to be sure the previous language's copy is off screen.
local function hideAllPanels(except)
	for _, store in pairs(panelsByLocale) do
		for _, panel in pairs(store) do
			if panel ~= except then panel:Hide() end
		end
	end
end

--------------------------------------------------------------------------------
-- Build failures
--
-- A settings page is ordinary insecure UI, but it is built lazily and only
-- once, so a Lua error partway through used to leave a page that looked almost
-- right and was wired to nothing. The controls above the failure point were
-- real and clickable; the functions they called had not been assigned yet.
-- Clicking them produced "attempt to call a nil value" and nothing else, on
-- every click, until the interface was reloaded.
--
-- So a page either builds completely or it is replaced by a page that says so.
--------------------------------------------------------------------------------

local hiddenParent
local hiddenHolder, buildFailurePage

--- Somewhere to park the wreckage of a failed build. Frames cannot be
--- destroyed in this client, and a hidden parent is the cheapest way to make a
--- subtree stop rendering and stop taking clicks.
function hiddenHolder()
	if not hiddenParent then
		hiddenParent = CreateFrame("Frame", nil, UIParent)
		hiddenParent:Hide()
	end
	return hiddenParent
end

function buildFailurePage(key, err)
	local page = CreateFrame("Frame", nil, window.content)
	page:SetPoint("TOPLEFT", window.content, "TOPLEFT", 0, 0)
	page:SetPoint("TOPRIGHT", window.content, "TOPRIGHT", 0, 0)
	page:SetHeight(140)

	local title = Widgets:CreateHeading(page, L["This page could not be built"])
	title:SetPoint("TOPLEFT", page, "TOPLEFT", 4, -4)

	local body = page:CreateFontString(nil, "OVERLAY")
	Util.SetFont(body, Boom.Media:Font("Boom Default"), 12, "NONE")
	body:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -10)
	body:SetPoint("RIGHT", page, "RIGHT", -12, 0)
	body:SetJustifyH("LEFT")
	body:SetWordWrap(true)
	body:SetTextColor(Util.Unpack(Skin.colors.textDim))
	body:SetText(L["Every other page still works. Type /boom errors to see the details, or /reload to try again."])

	local detail = page:CreateFontString(nil, "OVERLAY")
	Util.SetFont(detail, Boom.Media:Font("Boom Default"), 11, "NONE")
	detail:SetPoint("TOPLEFT", body, "BOTTOMLEFT", 0, -10)
	detail:SetPoint("RIGHT", page, "RIGHT", -12, 0)
	detail:SetJustifyH("LEFT")
	detail:SetWordWrap(true)
	detail:SetTextColor(Util.Unpack(Skin.colors.bad))
	detail:SetText(tostring(err))

	-- No column, so Options:RefreshAll walks straight past it.
	return page
end

function Options:Select(key)
	buildWindow()

	local def = categories[key]
	if not def then return end

	panels = panelsFor(Boom.activeLocale)
	hideAllPanels(panels[key])

	local panel = panels[key]
	if not panel then
		panel = CreateFrame("Frame", nil, window.content)
		panel:SetPoint("TOPLEFT", window.content, "TOPLEFT", 0, 0)
		panel:SetPoint("TOPRIGHT", window.content, "TOPRIGHT", 0, 0)
		panel:SetHeight(1)

		local column = Options:CreateColumn(panel, {
			x = 4,
			y = -4,
			width = WINDOW_WIDTH - RAIL_WIDTH - 60,
		})
		panel.column = column

		local ok, err = true, nil
		if def.build then
			ok, err = pcall(def.build, panel, column)
		end

		if ok then
			-- Only a page that finished building gets cached. A half-built one
			-- used to be cached anyway, which is how a panel that threw halfway
			-- through left live buttons wired to functions that were never
			-- assigned: every click then failed with "attempt to call a nil
			-- value", forever, because the build was never retried.
			panels[key] = panel
			panel:SetHeight(column:GetHeight())
		else
			-- Throw the wreckage away. WoW frames cannot be destroyed, so the
			-- children are parked off-screen behind a hidden frame instead of
			-- being left clickable underneath the message below.
			panel:Hide()
			panel:SetParent(hiddenHolder())

			panel = buildFailurePage(key, err)
			panels[key] = panel

			Boom:ReportError(key .. " settings page", err)
		end
	else
		-- Settings can change from elsewhere (slash commands, the wizard).
		if panel.column then panel.column:Refresh() end
	end

	panel:Show()
	window.scroll:SetContentHeight(panel:GetHeight())
	window.scroll:ScrollTo(0)

	self.currentKey = key
end

--------------------------------------------------------------------------------
-- Show / hide
--------------------------------------------------------------------------------

local function populateTabs()
	local entries = {}
	for _, def in ipairs(sortedCategories()) do
		entries[#entries + 1] = { key = def.key, label = L[def.label] or def.label, icon = def.icon }
	end
	window.tabs:SetEntries(entries)
end

function Options:Open(key)
	buildWindow()
	populateTabs()
	self:RefreshFooter()

	window:Show()

	local target = key or self.currentKey or (sortedCategories()[1] and sortedCategories()[1].key)
	if target then
		-- silent: SetSelected would otherwise fire onSelect and build the panel
		-- once, then Select would do it again.
		window.tabs:SetSelected(target, true)
		self:Select(target)
	end
end

function Options:Close()
	if window then window:Hide() end
end

function Options:Toggle(key)
	if window and window:IsShown() then
		self:Close()
	else
		self:Open(key)
	end
end

function Options:IsShown()
	return window and window:IsShown() or false
end

function Options:GetWindow()
	return window
end

--- Force every built panel to re-read its values, after a profile switch or a
--- preset being applied.
---
--- Only the current language's pages: the others are off screen and will refresh
--- themselves the next time they are selected.
function Options:RefreshAll()
	for _, panel in pairs(panelsFor(Boom.activeLocale)) do
		if panel.column then panel.column:Refresh() end
	end
	self:RefreshFooter()
end

--------------------------------------------------------------------------------
-- Blizzard settings integration
--------------------------------------------------------------------------------

local blizzardHolder

local function registerWithBlizzard()
	if not Settings or not Settings.RegisterCanvasLayoutCategory then return end

	local holder = CreateFrame("Frame")
	holder.name = "Boom"
	blizzardHolder = holder

	local title = holder:CreateFontString(nil, "ARTWORK", "GameFontNormalLarge")
	title:SetPoint("TOPLEFT", 16, -16)
	title:SetText("Boom " .. Boom.VERSION)

	local blurb = holder:CreateFontString(nil, "ARTWORK", "GameFontHighlight")
	blurb:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -8)
	blurb:SetText(L["Modern Healing & Support Raid Frames"])
	holder.blurb = blurb

	local open = CreateFrame("Button", nil, holder, "UIPanelButtonTemplate")
	open:SetSize(200, 26)
	open:SetPoint("TOPLEFT", blurb, "BOTTOMLEFT", 0, -20)
	open:SetText(L["Settings"])
	holder.openButton = open
	open:SetScript("OnClick", function()
		-- Deliberately NOT HideUIPanel(SettingsPanel): that routes through the
		-- UIParent panel manager from our insecure click handler and is a known
		-- way to taint it. Blizzard's own settings frame closes itself when the
		-- player presses Escape, and leaving it open behind our window is
		-- harmless.
		Options:Open()
	end)

	local category = Settings.RegisterCanvasLayoutCategory(holder, "Boom")
	category.ID = "Boom"
	Settings.RegisterAddOnCategory(category)
	Options.blizzardCategory = category
end

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

--- Re-render everything the settings UI shows in the player's language.
---
--- The pages themselves are not translated in place - they are swapped for the
--- copy built in the new language, which Select does via the per-locale cache
--- above. What is left is the chrome around them: the tab rail, the footer, and
--- Blizzard's own settings entry.
function Options:Relocalize()
	if blizzardHolder then
		if blizzardHolder.blurb then
			blizzardHolder.blurb:SetText(L["Modern Healing & Support Raid Frames"])
		end
		if blizzardHolder.openButton then
			blizzardHolder.openButton:SetText(L["Settings"])
		end
	end

	if not window then return end

	self:RefreshFooter()

	if window:IsShown() then
		populateTabs()

		local key = self.currentKey
		if key then
			window.tabs:SetSelected(key, true)
			-- Re-selecting is what actually swaps the page: Select reads the
			-- cache for the language that is active now, and builds the page in
			-- that language if this is the first time it has been asked for.
			self:Select(key)
		end
	else
		-- Closed. Nothing is on screen to correct, and the next Open() will
		-- rebuild the rail and select into the right cache anyway.
		hideAllPanels()
	end
end

function Options:OnEnable()
	registerWithBlizzard()

	self:RegisterMessage("BOOM_PROFILE_CHANGED", function() Options:RefreshAll() end)
	self:RegisterMessage("BOOM_BINDINGS_CHANGED", function() Options:RefreshAll() end)
	self:RegisterMessage("BOOM_LOCALE_CHANGED", function() Options:Relocalize() end)
end
