--ui/fps_monitor.lua, a real-time FPS chart embedded in the right column of the main /fps panel.
--two charts of pre-created vertical-bar textures: a 200ms-average chart that reacts quickly
--(so toggling an optimization shows its FPS effect within a fraction of a second) and a
--2-second-average chart for a smoother, longer view. the bar textures are created once and
--only resized on each redraw -- no frame churn. the driving OnUpdate lives on a child frame
--that shows/hides with the main panel, so sampling and drawing only happen while /fps is open.

local _ = nil
local addonName, private = ...

local detailsFramework = DetailsFramework
--pixel-perfect size/point helpers (round to whole physical pixels), so the thin chart bars stay crisp
--and evenly spaced at any UI scale instead of blurring across pixel boundaries.
local DFPixelUtil = DFPixelUtil

private.UI = private.UI or {}

--APPEARANCE
--the embedded chart (right column of the /fps panel) and the detached window each read their OWN
--style table below, so restyling one never touches the other. edit whichever you want. bar count =
--chartWidth / targetBarSlot (a wider chart shows more bars => more time). colors are pre-allocated
--tables (the frequent redraw reads them without building anything).

--appearance + geometry for the EMBEDDED chart. independent of DETACHED_STYLE.
local EMBEDDED_STYLE = {
	chartWidth = 365,        --width (px) of each chart
	primaryHeight = 50,      --height (px) of the 200ms (fast) chart
	secondaryHeight = 50,    --height (px) of the 2s (smooth) chart
	barGap = 0.5,            --gap (px) between bars; each bar draws (slot - barGap) wide
	targetBarSlot = 1.5,     --target slot width (px); bar count = chartWidth / this. lower = more/thinner bars
	minScaleFps = 60,        --the bar scale never compresses below this, so low framerates aren't magnified
	fpsFontSize = 26,        --font size of the big current-FPS number
	barColor = {0.27, 0.75, 0.4, 0.9},
	chartBgColor = {0.1, 0.1, 0.1, 0.5},   --dim backdrop drawn behind each chart's bars
	--the big number's framerate thresholds and the color it uses at/above each.
	goodFps = 60,
	okFps = 30,
	colorGood = {0.31, 0.85, 0.4},
	colorOk = {0.95, 0.82, 0.27},
	colorBad = {0.9, 0.32, 0.32},
}

--appearance + geometry for the DETACHED window. independent of EMBEDDED_STYLE -- change bar colors,
--chart sizes, the big-number font, the window frame, etc. here without touching the in-options chart.
local DETACHED_STYLE = {
	chartWidth = 365,
	primaryHeight = 50,
	secondaryHeight = 50,
	barGap = 0.5,
	targetBarSlot = 1.5,
	minScaleFps = 60,
	fpsFontSize = 20,
	barColor = {0.27, 0.75, 0.4, 0.9},
	chartBgColor = {0.1, 0.1, 0.1, 0.5},
	goodFps = 60,
	okFps = 30,
	colorGood = {0.31, 0.85, 0.4},
	colorOk = {0.95, 0.82, 0.27},
	colorBad = {0.9, 0.32, 0.32},
	--the free-floating window frame (only the detached monitor has one). panelWidth = 9px left margin
	--(charts aligned under the title text, see OpenDetached) + chartWidth 365 + 4px right margin so the
	--chart clears the right wall. panelHeight fits the title bar, big number, and both stacked charts.
	panelWidth = 378,
	panelHeight = 180,
	bodyColor = {0.1, 0.1, 0.1, 1},
	titleBarColor = {0.2, 0.2, 0.2, 1},
	borderColor = {0.2, 0.2, 0.2, 1},
	roundness = 3,
	--the options popup (opened from the Options button in the main /fps panel). the Scale slider resizes
	--the whole window between scaleMin/scaleMax; the Alpha slider sets its opacity between alphaMin/
	--alphaMax. scale/alpha are the initial values.
	scale = 1,
	scaleMin = 0.65,
	scaleMax = 2,
	alpha = 1,
	alphaMin = 0.1,
	alphaMax = 1,
}

---@class FPSPP_FPSMonitor
local Monitor = {}
private.UI.FPSMonitor = Monitor

--returns the threshold color table for a framerate under the given style (no allocation).
---@return number[]
local fpsColor = function(style, fps)
	if fps >= style.goodFps then
		return style.colorGood
	elseif fps >= style.okFps then
		return style.colorOk
	end

	return style.colorBad
end

--scales an fps value to a bar height within chartHeight, clamped to a visible sliver.
---@return number
local barHeight = function(fps, scaleMax, chartHeight)
	local height = (fps / scaleMax) * chartHeight
	if height < 1 then
		return 1
	elseif height > chartHeight then
		return chartHeight
	end

	return height
end

--returns the largest value in a history list, floored at the style's minScaleFps.
---@return number
local historyMax = function(style, history)
	local maximum = style.minScaleFps
	for index, value in ipairs(history) do
		if value > maximum then
			maximum = value
		end
	end

	return maximum
end

--formats a duration in seconds as a short human span: "28s", "4m 40s", "5m".
---@param seconds number
---@return string
local formatSpan = function(seconds)
	seconds = math.floor(seconds + 0.5)
	if seconds < 60 then
		return seconds .. "s"
	end

	local minutes = math.floor(seconds / 60)
	local remainder = seconds % 60
	if remainder == 0 then
		return minutes .. "m"
	end

	return minutes .. "m " .. remainder .. "s"
end

--builds one chart area: a frame holding barCount bottom-anchored bar textures. the textures
--are created here once and only resized later. interval is the seconds each bar covers, used
--for the top-right time-span label. returns the frame (with .Bars).
local buildChart = function(parent, style, height, barCount, interval)
	local chart = CreateFrame("Frame", nil, parent)
	chart:SetSize(style.chartWidth, height)

	--dim backdrop behind the bars (bars are on the "artwork" layer, so they draw on top).
	local background = chart:CreateTexture(nil, "background")
	background:SetAllPoints(chart)
	background:SetColorTexture(style.chartBgColor[1], style.chartBgColor[2], style.chartBgColor[3], style.chartBgColor[4])

	--top-right caption: the total time the chart spans (bar count x each bar's interval). static,
	--so it is built once here rather than on every redraw.
	local span = detailsFramework:CreateLabel(chart, formatSpan(barCount * interval), 12, "gray")
	span:SetPoint("bottomright", chart, "topright", 0, 2)

	--top-center readout of the lowest/highest FPS currently shown in the chart, refreshed each redraw
	--(see renderChart). same size/color as the "200ms average"/"2-second average" captions.
	chart.RangeLabel = detailsFramework:CreateLabel(chart, "", 12, "gray")
	chart.RangeLabel:SetPoint("bottom", chart, "top", 0, 2)

	local slotWidth = style.chartWidth / barCount
	chart.Bars = {}
	for i = 1, barCount do
		local bar = chart:CreateTexture(nil, "artwork")
		bar:SetColorTexture(style.barColor[1], style.barColor[2], style.barColor[3], style.barColor[4])
		--pixel-snap the width and slot position so the bars stay crisp and evenly spaced; the height is
		--refreshed each redraw in renderChart. minWidth 1 keeps a bar from rounding away to nothing.
		DFPixelUtil.SetSize(bar, slotWidth - style.barGap, 1, 1)
		DFPixelUtil.SetPoint(bar, "bottomleft", chart, "bottomleft", (i - 1) * slotWidth, 0)
		chart.Bars[i] = bar
	end

	return chart
end

--redraws one chart: right-aligns the history (newest bar on the right) and resizes each bar
--texture; bars with no data yet are flattened.
local renderChart = function(style, chart, history, chartHeight)
	local scaleMax = historyMax(style, history)
	local count = #history
	local barCount = #chart.Bars
	for i = 1, barCount do
		--map bar i to a history index so the newest value lands on the rightmost bar.
		local dataIndex = count - (barCount - i)
		if dataIndex >= 1 then
			DFPixelUtil.SetHeight(chart.Bars[i], barHeight(history[dataIndex], scaleMax, chartHeight), 1)
		else
			DFPixelUtil.SetHeight(chart.Bars[i], 1, 1)
		end
	end

	--refresh the min/max readout from the actual FPS values currently in the chart.
	if count > 0 then
		local minValue, maxValue = history[1], history[1]
		for i = 2, count do
			local value = history[i]
			if value < minValue then
				minValue = value
			elseif value > maxValue then
				maxValue = value
			end
		end
		chart.RangeLabel.text = string.format("min: %d  max: %d", minValue, maxValue)
	else
		chart.RangeLabel.text = ""
	end
end

--builds the FPS number and both charts onto `parent`, bound to `sampler` and drawn with `style`,
--and returns a "group" table with a :Render() method plus references to the widgets. shared by the
--embedded chart (main /fps panel) and the detached window; each caller passes its own style, so the
--two are styled independently. the caller anchors the group and drives its :Render(); a group owns
--no OnUpdate of its own.
---@param parent table the frame the widgets are created on
---@param sampler FPSPP_FPSSampler the sampler this group reads from
---@param style table the appearance table (EMBEDDED_STYLE or DETACHED_STYLE)
---@return table
local buildMonitorGroup = function(parent, sampler, style)
	--derive the bar count from the chart width so widening the chart shows more bars and more time,
	--then size both histories to match before reading the capacities back.
	local barCount = math.floor(style.chartWidth / style.targetBarSlot)
	sampler:SetCapacities(barCount, barCount)
	local primaryCapacity, secondaryCapacity = sampler:GetCapacities()
	local primaryInterval, secondaryInterval = sampler:GetIntervals()

	local group = {sampler = sampler, style = style}

	--secondary chart pinned to the bottom-right; the primary and the big number stack above it.
	group.SecondaryChart = buildChart(parent, style, style.secondaryHeight, secondaryCapacity, secondaryInterval)
	group.SecondaryChart:SetPoint("bottomright", parent, "bottomright", -5, 5)
	local secondaryCaption = detailsFramework:CreateLabel(parent, private.locale["MONITOR_SECONDARY"], 12, "gray")
	secondaryCaption:SetPoint("bottomleft", group.SecondaryChart, "topleft", 0, 2)

	--primary chart
	group.PrimaryChart = buildChart(parent, style, style.primaryHeight, primaryCapacity, primaryInterval)
	group.PrimaryChart:SetPoint("bottomleft", group.SecondaryChart, "topleft", 0, 30)
	local primaryCaption = detailsFramework:CreateLabel(parent, private.locale["MONITOR_PRIMARY"], 12, "gray")
	primaryCaption:SetPoint("bottomleft", group.PrimaryChart, "topleft", 0, 2)

	--big current-FPS number, recolored by threshold on each redraw (~5 times per second).
	group.FPSLabel = detailsFramework:CreateLabel(parent, "-- FPS", style.fpsFontSize, "white")
	group.FPSLabel:SetPoint("bottom", group.PrimaryChart, "top", 0, 20)

	--redraws both charts and the big number from this group's sampler. ~5 times a second.
	function group:Render()
		renderChart(self.style, self.PrimaryChart, self.sampler:GetPrimaryHistory(), self.style.primaryHeight)
		renderChart(self.style, self.SecondaryChart, self.sampler:GetSecondaryHistory(), self.style.secondaryHeight)

		local current = self.sampler:GetCurrent()
		self.FPSLabel.text = string.format("%d FPS", current)
		self.FPSLabel.color = fpsColor(self.style, current)
	end

	return group
end

--attaches a "driver" child frame to `host` whose OnUpdate samples and redraws `group` only while
--`host` is shown, so sampling and drawing cost nothing when the window is closed. OnShow resets the
--sampler so each time the window opens the chart starts fresh.
local attachDriver = function(host, group)
	local sampler = group.sampler
	local driver = CreateFrame("Frame", nil, host)
	driver:SetScript("OnShow", function()
		sampler:Reset()
		group:Render()
	end)
	driver:SetScript("OnUpdate", function(_, elapsed)
		if sampler:Update(elapsed) then
			group:Render()
		end
	end)
	return driver
end

--scales the detached window IN PLACE. a bare SetScale drifts the window toward a screen edge (the
--"invisible wall"): its anchor offset to UIParent is measured in the frame's own coordinate space,
--which shrinks/grows with the scale, so the anchored point slides. we read the on-screen center,
--apply the scale, then re-anchor by that center so only the size changes. GetCenter is nil before the
--frame is laid out (e.g. first open, pre-show) -- fall back to a plain SetScale then.
local applyDetachedScale = function(panel, scale)
	local centerX, centerY = panel:GetCenter()
	if not centerX then
		panel:SetScale(scale)
		return
	end

	local oldEffective = panel:GetEffectiveScale()
	panel:SetScale(scale)
	local newEffective = panel:GetEffectiveScale()
	--GetCenter is in the frame's effective-scale units; convert to screen pixels with the old scale,
	--then back into the new scale's units so the center lands on the same physical spot.
	panel:ClearAllPoints()
	panel:SetPoint("center", UIParent, "bottomleft", centerX * oldEffective / newEffective, centerY * oldEffective / newEffective)
end

--builds the "Options" button (stacked just above the Detach button) and the Scale popup it toggles.
--the popup holds the Scale slider that sets the DETACHED window's scale, stored on the Monitor so it
--applies whether or not the window is currently open (and live while it is). everything here lives in
--the main /fps options panel, which is never scaled -- so dragging the slider can't rescale it under
--the cursor and make its value jump (the reason the in-window cogwheel was removed).
---@param panel table the main /fps options panel (popup parent)
---@param chart table the primary chart frame the buttons attach to
---@param anchorButton table the Detach button; the Options button stacks above it
---@param buttonTemplate table the DF button template shared with the Detach button
local buildScaleOptions = function(panel, chart, anchorButton, buttonTemplate)
	local style = DETACHED_STYLE

	--forward-declared so the Options button's click handler (built first, so the popup can anchor
	--above it) can reference it.
	local popup

	--"Options" button, stacked just above the Detach button; toggles the popup.
	local optionsButton = detailsFramework:CreateButton(chart, function() popup:SetShown(not popup:IsShown()) end, 60, 18, private.locale["MONITOR_OPTIONS"], nil, nil, nil, nil, nil, nil, buttonTemplate)
	optionsButton:SetPoint("bottomright", anchorButton, "topright", 0, 2)
	optionsButton.tooltip = private.locale["MONITOR_OPTIONS_DESC"]

	--the popup, anchored above the Options button. hidden until clicked; high frame level so it draws
	--above the panel's other widgets. anchor to optionsButton.widget (the real frame) -- CreateButton
	--returns a DF wrapper table, which the rounded panel's real SetPoint rejects as a relative frame.
	popup = detailsFramework:CreateRoundedPanel(panel, "FPSPlusPlusDetachedMonitorOptions1", {width = 180, height = 84})
	popup:SetPoint("bottomright", optionsButton.widget, "topright", 0, 2)
	popup:SetColor(0.08, 0.08, 0.08, 1)
	popup:SetRoundness(3)
	popup:SetFrameLevel(panel:GetFrameLevel() + 50)
	popup:Hide()

	--builds one labeled slider row at vertical offset y: caption (left) + live value (right) + slider.
	--onChanged(value) applies the setting; the row keeps the readout in sync.
	local buildRow = function(y, labelText, minValue, maxValue, current, valueFormat, onChanged)
		local label = detailsFramework:CreateLabel(popup, labelText, 11, "white")
		label:SetPoint("topleft", popup, "topleft", 8, y)
		local valueLabel = detailsFramework:CreateLabel(popup, string.format(valueFormat, current), 11, "gray")
		valueLabel:SetPoint("topright", popup, "topright", -8, y)
		local slider = detailsFramework:CreateSlider(popup, 160, 16, minValue, maxValue, 0.05, current, true)
		slider:SetPoint("topleft", popup, "topleft", 8, y - 16)
		slider:SetValue(current)
		slider:SetHook("OnValueChanged", function(_, _, value)
			onChanged(value)
			valueLabel.text = string.format(valueFormat, value)
		end)
	end

	--Scale: resizes the detached window in place (applyDetachedScale, so it doesn't drift into a wall).
	buildRow(-8, private.locale["MONITOR_SCALE"], style.scaleMin, style.scaleMax, Monitor.detachedScale, "%.2fx", function(value)
		Monitor.detachedScale = value
		if Monitor.DetachedPanel then
			applyDetachedScale(Monitor.DetachedPanel, value)
		end
	end)

	--Alpha: the detached window's opacity.
	buildRow(-46, private.locale["MONITOR_ALPHA"], style.alphaMin, style.alphaMax, Monitor.detachedAlpha, "%.2f", function(value)
		Monitor.detachedAlpha = value
		if Monitor.DetachedPanel then
			Monitor.DetachedPanel:SetAlpha(value)
		end
	end)
end

--builds the FPS number and the two charts onto the right column of the given panel (the main
--/fps panel), plus the driver, the "Detach" button and the "Options" button. called once from
--OnInit, after the main panel exists.
---@param parent table the main /fps options panel
function Monitor:Initialize(parent)
	self.group = buildMonitorGroup(parent, private.Core.FPSSampler, EMBEDDED_STYLE)
	attachDriver(parent, self.group)

	--the scale and opacity applied to the detached window. adjustable from the Options popup below even
	--while the window is closed; applied on open and live while it is open (see OpenDetached).
	self.detachedScale = DETACHED_STYLE.scale
	self.detachedAlpha = DETACHED_STYLE.alpha

	--"Detach" button in the top-right of the primary (200ms) chart. it lifts a copy of both charts
	--into a free-floating window (see OpenDetached), sat above the chart's own captions so it does
	--not collide with the time-span label.
	local _, _, _, _, buttonTemplate = private.UI.Widgets.GetOptionTemplates()
	local detachButton = detailsFramework:CreateButton(self.group.PrimaryChart, function() Monitor:OpenDetached() end, 60, 18, private.locale["MONITOR_DETACH"], nil, nil, nil, nil, nil, nil, buttonTemplate)
	detachButton:SetPoint("bottomright", self.group.PrimaryChart, "topright", 0, 16)
	detachButton.tooltip = private.locale["MONITOR_DETACH_DESC"]

	--"Options" button (above Detach) + the Scale popup it toggles. built in this unscaled panel so the
	--scale slider never rescales under the cursor -- which is why the detached window has no cogwheel.
	buildScaleOptions(parent, self.group.PrimaryChart, detachButton, buttonTemplate)
end

--builds the detached FPS-monitor window on first use and shows it: a free-floating, draggable
--rounded panel parented to UIParent with its own sampler and driver, so it keeps running after the
--options panel closes. it stays up until the player closes it (the title-bar close button) or
--reloads. built lazily -- most sessions never detach, so the window and its second sampler cost
--nothing unless asked for. styled entirely from DETACHED_STYLE (independent of the embedded chart).
function Monitor:OpenDetached()
	if not self.DetachedPanel then
		local style = DETACHED_STYLE
		local panel = detailsFramework:CreateRoundedPanel(UIParent, "FPSPlusPlusDetachedMonitor1", {
			width = style.panelWidth,
			height = style.panelHeight,
			use_titlebar = true,
			title = private.locale["MONITOR_TITLE"],
		})
		panel:SetPoint("center", UIParent, "center", 350, 0)
		--draw above the rest of the UI (and its own charts/popup, which are children in this strata).
		panel:SetFrameStrata("TOOLTIP")
		panel:SetColor(style.bodyColor[1], style.bodyColor[2], style.bodyColor[3], style.bodyColor[4])
		panel:SetTitleBarColor(style.titleBarColor[1], style.titleBarColor[2], style.titleBarColor[3], style.titleBarColor[4])
		panel:SetBorderCornerColor(style.borderColor[1], style.borderColor[2], style.borderColor[3], style.borderColor[4])
		panel:SetRoundness(style.roundness)
		--CreateTitleBar builds the title fontstring but never fills it from the 'title' option, so
		--push the configured title in ourselves (same quirk the options panel works around).
		panel.TitleBar.Text:SetText(panel.options.title)
		--strip the title bar's own rounded-frame background but keep the bar (and its title text +
		--close button): the text and close button are separate children, so they survive. move the
		--title from centered to the top-left. the panel's OnSizeChanged re-shows the fill on resize,
		--so re-disable through a hook (the window never resizes, but this keeps it gone regardless).
		panel.TitleBar:DisableRoundedCorners()
		panel.TitleBar:HookScript("OnSizeChanged", function(titleBar) titleBar:DisableRoundedCorners() end)
		panel.TitleBar.Text:ClearAllPoints()
		panel.TitleBar.Text:SetPoint("left", panel.TitleBar, "left", 2, 0)
		--drag by the title bar; the title bar's close button (built by CreateTitleBar) hides the panel.
		private.UI.SetupPanelDrag(panel)

		--its own sampler so the detached window samples independently of the embedded chart -- both
		--can run at once (options panel open while detached) without double-counting frames.
		panel.group = buildMonitorGroup(panel, private.Core.FPSSampler.New(), style)
		attachDriver(panel, panel.group)

		--align the charts' left edge with the title text: buildMonitorGroup pins the charts to the
		--bottom-right (for the embedded chart's column layout); the detached window instead anchors
		--them under the title. the title bar is 14px narrower than the panel (7px inset each side) and
		--its text sits 2px in from that, so the title reads at panel-left + 9 -- match it. the primary
		--chart, captions, range/span labels and FPS number all follow the secondary chart, so this
		--single re-anchor shifts the whole stack.
		panel.group.SecondaryChart:ClearAllPoints()
		panel.group.SecondaryChart:SetPoint("bottomleft", panel, "bottomleft", 9, 5)

		--apply the current scale + opacity (set from the Options popup in the main /fps panel).
		applyDetachedScale(panel, self.detachedScale)
		panel:SetAlpha(self.detachedAlpha)

		self.DetachedPanel = panel
	end

	--re-center if it has no anchor yet (first show), then show; the driver's OnShow resets its sampler.
	if self.DetachedPanel:GetNumPoints() == 0 then
		self.DetachedPanel:SetPoint("center", UIParent, "center", 0, 0)
	end
	self.DetachedPanel:Show()
end
