-- FPSMonitor.lua
-- A lightweight FPS monitoring addon for World of Warcraft.
-- Provides a text-based panel and minimap button to display frame rate
-- statistics, frame time jitter and addon memory usage. Graphing functionality
-- has been removed to prioritize performance and simplicity.

-- The addon name is provided as the first return from ... when this file is loaded.
local addonName = ...

-- Compatibility shims for Retail's namespaced AddOn API.  When running on
-- older clients the globals are used directly.  Avoid resolving through
-- C_AddOns if unavailable to prevent nil function errors on legacy clients.
local GetNumAddOns        = (C_AddOns and C_AddOns.GetNumAddOns)        or GetNumAddOns
local IsAddOnLoaded       = (C_AddOns and C_AddOns.IsAddOnLoaded)       or IsAddOnLoaded
local GetAddOnMemoryUsage = (C_AddOns and C_AddOns.GetAddOnMemoryUsage) or GetAddOnMemoryUsage
local GetAddOnCPUUsage    = (C_AddOns and C_AddOns.GetAddOnCPUUsage)    or GetAddOnCPUUsage
local GetAddOnInfo        = (C_AddOns and C_AddOns.GetAddOnInfo)        or GetAddOnInfo

-- Local references to commonly used API functions and math routines.  Caching
-- these values avoids global lookups on every use and yields a small
-- performance benefit.
local GetFramerate    = GetFramerate
local GetTime         = GetTime
local CreateFrame     = CreateFrame
local GameTooltip     = GameTooltip
local GameTooltip_Hide = GameTooltip_Hide
local string_format   = string.format
-- Provide a fallback implementation for wipe on very old clients where the
-- global wipe function may not exist.  If wipe is nil, define our own
-- simple table clearing function.  This avoids errors on legacy versions.
local wipe = wipe or function(t) for k in pairs(t) do t[k] = nil end end
local math_floor      = math.floor
local math_max        = math.max
local math_min        = math.min
local math_sqrt       = math.sqrt
local math_pi         = math.pi
local TAU             = math_pi * 2
-- Cache additional trigonometric functions used for minimap positioning to
-- avoid repeated global lookups.  On some old clients these may not be
-- present; fall back to math.atan when math.atan2 is unavailable.
local math_cos  = math.cos
local math_sin  = math.sin
local math_atan = math.atan
local math_atan2 = math.atan2

-- Colorize network latency for quick visual feedback.  Latencies below
-- roughly 100ms are considered good and shown in green, moderate values in
-- yellow and high latencies in red.  If the latency value is nil the
-- string "N/A" is returned.  This function is used by UpdateDisplay().
local function ColorizeLatency(lat)
    if not lat or lat <= 0 then return "N/A" end
    if lat >= 300 then
        return string_format("|cffff0000%.0f ms|r", lat)
    elseif lat >= 100 then
        return string_format("|cffffff00%.0f ms|r", lat)
    else
        return string_format("|cff00ff00%.0f ms|r", lat)
    end
end

-- Preallocate a table for display values.  Reusing this table avoids
-- allocating a new table on every display update, reducing garbage
-- generation and memory usage.  Its size must be at least the number of
-- entries in STAT_LABELS.  This table is used in UpdateDisplay().
local valuesList = {}

-- Forward declarations for functions defined later.  Declaring them here
-- allows mutual recursion and references before definition.
local UpdateDisplay
local CreateDisplayFrame
local CreateMinimapButton
local CreateOptionsPanel
local CalculateStats
local PrintStats

-- SavedVariables tables declared in the TOC file. They may not exist on
-- the first run so we create them if needed when the addon loads.
FPSMonitorDB = FPSMonitorDB or {}
FPSPerCharDB = FPSPerCharDB or {}
FPSPerCharDB.overall = FPSPerCharDB.overall or { min = math.huge, max = 0 }

-- Default configuration values. These are merged into the saved variables on
-- load so that new options are automatically added without wiping user data.
local defaultConfig = {
    pos = {
        point = "CENTER",
        relativePoint = "CENTER",
        x = 0,
        y = 0,
    },
    minimap = {
        hide = false,
        angle = 0, -- radians around the minimap for the button position
    },
    updateInterval = 0.5,       -- seconds between display updates
    sampleInterval = 60,        -- seconds of history retained for statistics
    memoryUpdateInterval = 1,   -- seconds between addon memory usage checks
    fpsThresholds = {
        yellow = 60,            -- FPS below this threshold displays yellow
        red    = 30,            -- FPS below this threshold displays red
    },
    captureDelay = 5,           -- seconds to wait after loading screens before capturing
    scale = 1.0,                -- UI scale for the stats panel
}

-- Utility function: deep copy a table.  This is used when replacing
-- corrupted saved variable tables with defaults so we don't end up
-- referencing the defaults directly.  Non-table values are returned
-- unchanged.
local function CopyTable(tbl)
    if type(tbl) ~= "table" then return tbl end
    local copy = {}
    for k, v in pairs(tbl) do
        if type(v) == "table" then
            copy[k] = CopyTable(v)
        else
            copy[k] = v
        end
    end
    return copy
end

-- Utility function: merge default values into the saved configuration table.
-- Any missing keys in `dest` will be filled from `src` recursively.  Existing
-- values are preserved.  Only table keys present in the defaults are
-- considered; extraneous keys in the destination are left untouched.
local function DeepCopyDefaults(src, dest)
    if type(src) ~= "table" then return end
    for k, v in pairs(src) do
        if type(v) == "table" then
            dest[k] = dest[k] or {}
            DeepCopyDefaults(v, dest[k])
        elseif dest[k] == nil then
            dest[k] = v
        end
    end
end

-- Normalize an angle in radians into the range [0, 2π).  Used for minimap
-- button positioning.  Negative values are wrapped into the positive range.
local function NormalizeAngle(angle)
    angle = angle % TAU
    if angle < 0 then angle = angle + TAU end
    return angle
end

-- Validate the saved variables. If any expected value has the wrong type
-- replace it with a sensible default. This guards against corrupted saved
-- variables or manual user edits that could otherwise cause errors.
local function ValidateConfig()
    if type(FPSMonitorDB) ~= "table" then
        FPSMonitorDB = {}
    end
    DeepCopyDefaults(defaultConfig, FPSMonitorDB)
    -- Validate position
    if type(FPSMonitorDB.pos) ~= "table" then
        FPSMonitorDB.pos = CopyTable(defaultConfig.pos)
    else
        local pos = FPSMonitorDB.pos
        if type(pos.point) ~= "string" then pos.point = defaultConfig.pos.point end
        if type(pos.relativePoint) ~= "string" then pos.relativePoint = defaultConfig.pos.relativePoint end
        if type(pos.x) ~= "number" then pos.x = defaultConfig.pos.x end
        if type(pos.y) ~= "number" then pos.y = defaultConfig.pos.y end
    end
    -- Validate minimap settings
    if type(FPSMonitorDB.minimap) ~= "table" then
        FPSMonitorDB.minimap = CopyTable(defaultConfig.minimap)
    else
        local mm = FPSMonitorDB.minimap
        if type(mm.hide) ~= "boolean" then mm.hide = defaultConfig.minimap.hide end
        if type(mm.angle) ~= "number" then
            mm.angle = defaultConfig.minimap.angle
        else
            mm.angle = NormalizeAngle(mm.angle)
        end
    end
    -- Validate numeric intervals
    if type(FPSMonitorDB.updateInterval) ~= "number" or FPSMonitorDB.updateInterval <= 0 then
        FPSMonitorDB.updateInterval = defaultConfig.updateInterval
    end
    if type(FPSMonitorDB.sampleInterval) ~= "number" or FPSMonitorDB.sampleInterval <= 0 then
        FPSMonitorDB.sampleInterval = defaultConfig.sampleInterval
    end
    if type(FPSMonitorDB.memoryUpdateInterval) ~= "number" or FPSMonitorDB.memoryUpdateInterval <= 0 then
        FPSMonitorDB.memoryUpdateInterval = defaultConfig.memoryUpdateInterval
    end
    if type(FPSMonitorDB.captureDelay) ~= "number" or FPSMonitorDB.captureDelay < 0 then
        FPSMonitorDB.captureDelay = defaultConfig.captureDelay
    end
    -- Validate scale (must be positive)
    if type(FPSMonitorDB.scale) ~= "number" or FPSMonitorDB.scale <= 0 then
        FPSMonitorDB.scale = defaultConfig.scale
    end
    -- Validate FPS thresholds
    if type(FPSMonitorDB.fpsThresholds) ~= "table" then
        FPSMonitorDB.fpsThresholds = CopyTable(defaultConfig.fpsThresholds)
    else
        local ft = FPSMonitorDB.fpsThresholds
        if type(ft.yellow) ~= "number" then ft.yellow = defaultConfig.fpsThresholds.yellow end
        if type(ft.red) ~= "number" then ft.red = defaultConfig.fpsThresholds.red end
    end
end

-- Apply fonts safely.  If the custom font fails to load, fall back to
-- STANDARD_TEXT_FONT.  Use standard addon path delimiters with backslashes.
local FONT_PATH = "Interface\\AddOns\\FPSMonitor\\Pepsi.ttf"
local function SetFontSafe(fs, size, flags)
    if fs and fs.SetFont then
        if not fs:SetFont(FONT_PATH, size, flags) then
            fs:SetFont(STANDARD_TEXT_FONT, size, flags)
        end
    end
end

-- Labels used for the statistic display.  Keeping this table at file scope
-- allows reusing it whenever the display is (re)initialized.
-- Labels used for the statistic display.  The order here determines the
-- arrangement of rows within the on‑screen panel.  Additional metrics such as
-- network latency can be added by appending to this table.  When modifying
-- this list be sure to update UpdateDisplay() accordingly.
local STAT_LABELS = {
    "Current FPS",
    "Average FPS",
    "Min FPS",
    "Max FPS",
    "1% Low",
    "0.1% Low",
    "Frame Time",
    "Jitter",
    "AddOn Memory",
    "Latency Home",
    "Latency World",
}

-- History of recent frame times.  We keep samples for the last `sampleInterval`
-- seconds using a simple ring buffer to avoid expensive table.remove calls.
-- Instead of storing each sample as a table (dt, fps) we maintain two
-- parallel arrays (dtHistory and fpsHistory).  This avoids per-sample table
-- allocations and reduces memory churn.  The indices wrap around via
-- historyStart and historyCount.
local dtHistory = {}
local fpsHistory = {}
local historyStart = 1 -- first valid index in the history buffer
local historyCount = 0 -- number of samples currently stored
local historyTime = 0  -- total time represented by the samples
-- Running sums allow average and jitter calculations without scanning
local sumFPS = 0
local sumDT = 0
local sumDTSquared = 0
local sessionMinFPS = math.huge
local sessionMaxFPS = 0
local capturing = false
local captureStartTime
local displayFrame
local minimapButton
local optionsPanel
local updateFrame -- frame used for periodic updates; declared early so slash commands can reference it safely
local lastMemorySampleTime = 0

-- These runtime variables are loaded from the saved configuration in
-- ValidateConfig().  Default values are defined in `defaultConfig` above.
local sampleInterval       = defaultConfig.sampleInterval
local updateInterval       = defaultConfig.updateInterval
local memoryUpdateInterval = defaultConfig.memoryUpdateInterval
local captureDelay         = defaultConfig.captureDelay

-- Insert a value into a sorted array while keeping its size limited. Used by
-- CalculateStats to efficiently maintain lists of the lowest FPS samples.
local function InsertSorted(list, value, maxSize)
    local i = #list
    while i > 0 and value < list[i] do
        list[i + 1] = list[i]
        i = i - 1
    end
    list[i + 1] = value
    if #list > maxSize then
        list[#list] = nil
    end
end

-- Clear all collected statistics.  Resets the frame history and running sums,
-- as well as the session min/max values.  Does not modify per-character
-- overall records.
local function ResetStats()
    -- Clear the ring buffers for dt and fps.  Assigning new empty tables
    -- releases references to old data so that Lua's garbage collector can
    -- reclaim memory.  Avoid per-sample table allocations by using these
    -- arrays instead of frameHistory.
    dtHistory = {}
    fpsHistory = {}
    historyStart = 1
    historyCount = 0
    historyTime = 0
    sumFPS = 0
    sumDT = 0
    sumDTSquared = 0
    sessionMinFPS = math.huge
    sessionMaxFPS = 0
end

-- Add a new frame sample and maintain running statistics.  Uses the OnUpdate
-- elapsed time as the frame duration.  Sampling is skipped if capturing is
-- disabled to avoid collecting data during loading screens.
local function AddSample(dt)
    -- Only collect samples when capturing is enabled
    if not capturing then return end
    -- Ignore extremely small frames and clamp excessively long ones so the time
    -- axis remains consistent even when the client is unfocused.
    if dt <= 0 or dt < 0.001 then return end
    dt = math_min(dt, 1)

    -- Use the API provided FPS value rather than 1/dt.  The OnUpdate elapsed
    -- time can be clamped by the client (typically to ~3ms), which caused the
    -- maximum FPS to incorrectly report ~333.3.  GetFramerate() provides a more
    -- accurate instantaneous value for this purpose.
    local fps = GetFramerate()
    if not fps or fps <= 0 then return end

    -- Update addon memory usage at a throttled interval
    local now = GetTime()
    local memUsage = updateFrame and updateFrame.currentMemory or 0
    if now - lastMemorySampleTime >= memoryUpdateInterval then
        if UpdateAddOnMemoryUsage and GetAddOnMemoryUsage then
            UpdateAddOnMemoryUsage()
        end
        memUsage = GetAddOnMemoryUsage(addonName) / 1024
        if updateFrame then updateFrame.currentMemory = memUsage end
        lastMemorySampleTime = now
    end

    -- Update running sums used for quick statistics calculation
    sumFPS = sumFPS + fps
    sumDT = sumDT + dt
    sumDTSquared = sumDTSquared + dt * dt

    -- Add sample at the end of the buffer.  Use parallel arrays to avoid
    -- allocating a new table for each sample.  The index is computed as
    -- historyStart + historyCount since the buffer is unbounded; we perform
    -- occasional reindexing to avoid large numeric keys.
    local index = historyStart + historyCount
    dtHistory[index]  = dt
    fpsHistory[index] = fps
    historyCount = historyCount + 1
    historyTime = historyTime + dt

    -- Trim old samples outside the sliding window and update sums.  Use
    -- dtHistory and fpsHistory arrays.  When removing a sample we need to
    -- subtract its fps, dt and dt^2 contributions from the running sums.
    while historyCount > 0 and historyTime > sampleInterval do
        local oldDT  = dtHistory[historyStart]
        local oldFPS = fpsHistory[historyStart]
        historyTime = historyTime - oldDT
        sumFPS = sumFPS - oldFPS
        sumDT  = sumDT - oldDT
        sumDTSquared = sumDTSquared - oldDT * oldDT
        dtHistory[historyStart]  = nil
        fpsHistory[historyStart] = nil
        historyStart = historyStart + 1
        historyCount = historyCount - 1
    end

    -- Reindex the buffer occasionally to avoid huge numeric indices.  Copy
    -- the remaining samples down to the start of the arrays.  This is
    -- performed infrequently (after 1000 samples have been removed) to
    -- amortize the cost.
    if historyStart > 1000 then
        for i = historyStart, historyStart + historyCount - 1 do
            local newIndex = i - historyStart + 1
            dtHistory[newIndex]  = dtHistory[i]
            fpsHistory[newIndex] = fpsHistory[i]
            dtHistory[i]  = nil
            fpsHistory[i] = nil
        end
        historyStart = 1
    end

    -- Update session min/max only when we are actively capturing data
    if fps < sessionMinFPS then sessionMinFPS = fps end
    if fps > sessionMaxFPS then sessionMaxFPS = fps end
end

-- Reusable tables for percentile calculations.  Preallocating these tables
-- avoids per-call allocations and reduces garbage generation.  The lists
-- will be wiped before each use in CalculateStats.
local low1List, low01List = {}, {}

-- Compute various statistics from the history buffer.  The `currentFPS`
-- parameter should be the instantaneous FPS returned from GetFramerate(),
-- and `elapsed` is the frame time in seconds (from OnUpdate).  Returns a
-- table containing current FPS, average FPS, min/max FPS, 1% and 0.1%
-- percentile FPS, frame time in milliseconds and frame time jitter.
function CalculateStats(currentFPS, elapsed)
    local count = historyCount
    if count == 0 then
        return {
            current    = currentFPS,
            average    = currentFPS,
            min        = currentFPS,
            max        = currentFPS,
            low1       = currentFPS,
            low01      = currentFPS,
            frameTime  = elapsed * 1000,
            jitter     = 0,
        }
    end

    -- Calculate average FPS using the sum of delta times
    local avgFPS = sumDT > 0 and (count / sumDT) or currentFPS

    -- Maintain sorted lists of the lowest 1% and 0.1% FPS values.  Use the
    -- fpsHistory array for these computations.  Preallocated lists avoid
    -- per-call allocations.
    wipe(low1List)
    wipe(low01List)
    local low1Size  = math_max(1, math_floor(count * 0.01 + 0.5))
    local low01Size = math_max(1, math_floor(count * 0.001 + 0.5))
    for i = historyStart, historyStart + historyCount - 1 do
        local fpsSample = fpsHistory[i]
        InsertSorted(low1List, fpsSample, low1Size)
        InsertSorted(low01List, fpsSample, low01Size)
    end
    local low1  = low1List[low1Size]  or low1List[#low1List]
    local low01 = low01List[low01Size] or low01List[#low01List]

    -- Calculate jitter as the standard deviation of frame times
    local meanDT = sumDT / count
    local variance = (sumDTSquared / count) - meanDT * meanDT
    if variance < 0 then variance = 0 end

    return {
        current   = currentFPS,
        average   = avgFPS,
        min       = sessionMinFPS,
        max       = sessionMaxFPS,
        low1      = low1,
        low01     = low01,
        frameTime = elapsed * 1000,
        jitter    = math_sqrt(variance) * 1000,
    }
end

-- Colorize the FPS value based on thresholds.  FPS below the red threshold
-- displays in red; below the yellow threshold displays in yellow; otherwise
-- displays in green.  Thresholds are configurable via the options panel.
local function ColorizeFPS(fps)
    local thresholds = FPSMonitorDB.fpsThresholds or defaultConfig.fpsThresholds
    local red    = thresholds.red or defaultConfig.fpsThresholds.red
    local yellow = thresholds.yellow or defaultConfig.fpsThresholds.yellow
    if fps < red then
        return string_format("|cffff0000%.1f|r", fps)
    elseif fps < yellow then
        return string_format("|cffffff00%.1f|r", fps)
    else
        return string_format("|cff00ff00%.1f|r", fps)
    end
end

-- Colorize memory usage (in megabytes) for quick visual feedback.  High
-- memory usage is shown in red, moderate usage in yellow and low usage in
-- green.  If memory is nil the string "N/A" is returned.
local function ColorizeMemory(mem)
    if not mem then return "N/A" end
    if mem >= 50 then -- 50 MB+
        return string_format("|cffff0000%.2f MB|r", mem)
    elseif mem >= 20 then -- 20 MB+
        return string_format("|cffffff00%.2f MB|r", mem)
    else
        return string_format("|cff00ff00%.2f MB|r", mem)
    end
end

-- Update the on-screen display text with the latest statistics.  Avoid
-- updating text if the display is hidden.  Memory usage may be nil if
-- AddSample hasn't run yet; ColorizeMemory handles nil gracefully.
function UpdateDisplay(stats, memory)
    if not displayFrame or not displayFrame:IsShown() then return end
    -- Fetch network statistics each update.  GetNetStats returns download,
    -- upload, home latency and world latency.  Not all clients return two
    -- latency values (very old clients may only return one).  Use nil when
    -- unavailable to allow ColorizeLatency to produce "N/A".
    local _, _, lagHome, lagWorld
    if GetNetStats then
        local down, up, home, world = GetNetStats()
        lagHome, lagWorld = home, world
    end
    -- Populate the valuesList with formatted strings.  Reuse the same table to
    -- avoid per-update allocations.
    valuesList[1]  = ColorizeFPS(stats.current)
    valuesList[2]  = string_format("%.1f", stats.average)
    valuesList[3]  = string_format("%.1f", stats.min)
    valuesList[4]  = string_format("%.1f", stats.max)
    valuesList[5]  = string_format("%.1f", stats.low1)
    valuesList[6]  = string_format("%.1f", stats.low01)
    valuesList[7]  = string_format("%.2f ms", stats.frameTime)
    valuesList[8]  = string_format("%.2f ms", stats.jitter)
    valuesList[9]  = ColorizeMemory(memory)
    valuesList[10] = ColorizeLatency(lagHome)
    valuesList[11] = ColorizeLatency(lagWorld)
    local numValues = #STAT_LABELS
    -- Update each value string.  Hide any extra font strings beyond our
    -- current list of statistics.
    for i = 1, numValues do
        local fs = displayFrame.values[i]
        if fs then
            fs:SetText(valuesList[i] or "")
            fs:Show()
        end
    end
    for i = numValues + 1, #displayFrame.values do
        local fs = displayFrame.values[i]
        if fs then fs:Hide() end
    end
end

-- Print summarized statistics to the chat frame.  Intended for the
-- `/fpsmon stats` slash command.
function PrintStats(stats, memory)
    print(string_format(
        "FPSMonitor: %.1f avg %.1f min %.1f max %.1f 1%% %.1f 0.1%% %.1f frame %.2f ms jitter %.2f ms",
        stats.current, stats.average, stats.min, stats.max, stats.low1,
        stats.low01, stats.frameTime, stats.jitter))
    if memory then
        print("Memory: " .. ColorizeMemory(memory))
    end
    -- Print network latency when available
    if GetNetStats then
        local _, _, home, world = GetNetStats()
        if home or world then
            local lh = home and string_format("%.0f ms", home) or "N/A"
            local lw = world and string_format("%.0f ms", world) or "N/A"
            print(string_format("Latency: Home %s  World %s", lh, lw))
        end
    end
    if FPSPerCharDB.overall then
        print(string_format("Overall min %.1f  Overall max %.1f", FPSPerCharDB.overall.min, FPSPerCharDB.overall.max))
    end
end

-- Initialize or refresh the label/value pairs on the display frame.  Called
-- whenever the display frame is created or when its contents need to be
-- updated.
local function InitializeLabels()
    if not displayFrame then return end
    -- Ensure the tables exist
    displayFrame.labels = displayFrame.labels or {}
    displayFrame.values = displayFrame.values or {}
    local rowHeight = 16
    -- Start below the title and some padding
    local yOffset = -36
    -- Create or update rows for each statistic
    for i, labelText in ipairs(STAT_LABELS) do
        local label = displayFrame.labels[i]
        if not label then
            label = displayFrame:CreateFontString(nil, "ARTWORK")
            displayFrame.labels[i] = label
        end
        label:ClearAllPoints()
        label:SetPoint("TOPLEFT", displayFrame, "TOPLEFT", 10, yOffset)
        SetFontSafe(label, 12)
        label:SetText(labelText .. ":")
        label:Show()

        local value = displayFrame.values[i]
        if not value then
            value = displayFrame:CreateFontString(nil, "ARTWORK")
            displayFrame.values[i] = value
        end
        value:ClearAllPoints()
        value:SetPoint("TOPRIGHT", displayFrame, "TOPRIGHT", -10, yOffset)
        value:SetJustifyH("RIGHT")
        SetFontSafe(value, 12, "OUTLINE")
        value:SetText("--")
        value:Show()

        yOffset = yOffset - rowHeight
    end
    -- Hide any leftover rows from previous configurations
    for i = #STAT_LABELS + 1, #displayFrame.labels do
        local lbl = displayFrame.labels[i]
        local val = displayFrame.values[i]
        if lbl then lbl:Hide() end
        if val then val:Hide() end
    end
    -- Adjust frame height dynamically based on the number of rows.  Include
    -- additional padding for the title at the top and the reset button
    -- area at the bottom.  Each row is rowHeight pixels tall.
    local height = 70 + (#STAT_LABELS * rowHeight)
    displayFrame:SetHeight(height)
end

-- Create the movable display frame.  This panel shows the FPS statistics and
-- provides a reset button for session statistics.  The frame position is
-- persisted between sessions via FPSMonitorDB.pos.
local function CreateDisplayFrame()
    if displayFrame then return end
    displayFrame = CreateFrame("Frame", "FPSMonitorDisplay", UIParent, "BackdropTemplate")
    -- Set an initial size.  The height will be adjusted dynamically in
    -- InitializeLabels() based on the number of displayed statistics.
    displayFrame:SetSize(240, 280)
    local pos = FPSMonitorDB.pos or defaultConfig.pos
    displayFrame:SetPoint(pos.point, UIParent, pos.relativePoint, pos.x, pos.y)
    displayFrame:SetBackdrop({ bgFile = "Interface/Tooltips/UI-Tooltip-Background" })
    displayFrame:SetBackdropColor(0, 0, 0, 0.6)

    -- Apply saved scale to the frame.  Setting the scale before positioning
    -- ensures the position anchors correctly relative to UIParent.
    local scale = FPSMonitorDB.scale or defaultConfig.scale
    displayFrame:SetScale(scale)
    displayFrame:EnableMouse(true)
    displayFrame:SetMovable(true)
    displayFrame:SetClampedToScreen(true)
    displayFrame:RegisterForDrag("LeftButton")
    displayFrame:SetScript("OnDragStart", displayFrame.StartMoving)
    displayFrame:SetScript("OnDragStop", function(self)
        self:StopMovingOrSizing()
        local point, _, relativePoint, x, y = self:GetPoint()
        FPSMonitorDB.pos = { point = point, relativePoint = relativePoint, x = x, y = y }
    end)
    -- Standard close button
    local close = CreateFrame("Button", nil, displayFrame, "UIPanelCloseButton")
    close:SetPoint("TOPRIGHT", -4, -4)

    -- Reset button clears the current session statistics
    local reset = CreateFrame("Button", nil, displayFrame, "UIPanelButtonTemplate")
    reset:SetSize(60, 20)
    reset:SetPoint("BOTTOMRIGHT", displayFrame, "BOTTOMRIGHT", -8, 8)
    reset:SetText("Reset")
    reset:SetScript("OnClick", function()
        ResetStats()
        print("FPSMonitor: session statistics reset")
    end)

    if UISpecialFrames then
        table.insert(UISpecialFrames, "FPSMonitorDisplay")
    end

    -- Title
    displayFrame.title = displayFrame:CreateFontString(nil, "ARTWORK")
    displayFrame.title:SetPoint("TOP", 0, -10)
    SetFontSafe(displayFrame.title, 14, "OUTLINE")
    displayFrame.title:SetText("FPS Monitor")

    displayFrame.labels = {}
    displayFrame.values = {}

    -- Create the rows.  This call will also adjust the frame height based on
    -- the number of statistics and add padding for the reset button.
    InitializeLabels()

    -- Populate with initial values so text is visible immediately
    local fps = GetFramerate() or 0
    local stats = CalculateStats(fps, 0)
    UpdateDisplay(stats, updateFrame and updateFrame.currentMemory)

    displayFrame:SetScript("OnShow", function()
        -- Refresh label layout on show in case the configuration changed or
        -- additional rows were added.  This ensures no stale FontStrings remain
        InitializeLabels()
        local f = GetFramerate() or 0
        local s = CalculateStats(f, 0)
        UpdateDisplay(s, updateFrame and updateFrame.currentMemory)
    end)
end

-- Position the minimap button around the minimap based on stored angle
local function UpdateMinimapButtonPosition(angle)
    if not minimapButton or not Minimap then return end
    angle = NormalizeAngle(angle or FPSMonitorDB.minimap.angle or 0)
    local radius = (Minimap:GetWidth() / 2) + 5
    -- Use cached trig functions for better performance on legacy clients
    local x = radius * math_cos(angle)
    local y = radius * math_sin(angle)
    minimapButton:SetPoint("CENTER", Minimap, "CENTER", x, y)
end

-- Create a simple minimap button.  The button toggles the display on left
-- click and opens the configuration panel on right click.  Position and
-- visibility are persisted via FPSMonitorDB.minimap.
local function CreateMinimapButton()
    if minimapButton or not Minimap then return end
    local ok, button = pcall(CreateFrame, "Button", "FPSMonitorMinimapButton", Minimap)
    if not ok or not button then return end
    minimapButton = button
    minimapButton:SetSize(32, 32)
    minimapButton:SetFrameStrata("MEDIUM")
    minimapButton:SetFrameLevel(8)
    -- Attempt to use a PNG icon first; fall back to TGA if missing.  If both
    -- fail the button will show a blank icon but remain functional.
    minimapButton:SetNormalTexture("Interface/AddOns/FPSMonitor/FPS1.png")
    local tex = minimapButton:GetNormalTexture()
    if not tex or tex:GetTexture() == "" then
        minimapButton:SetNormalTexture("Interface/AddOns/FPSMonitor/FPS1.tga")
    end
    minimapButton:SetHighlightTexture("Interface/Minimap/UI-Minimap-ZoomButton-Highlight")
    UpdateMinimapButtonPosition(FPSMonitorDB.minimap.angle)
    minimapButton:RegisterForDrag("LeftButton")
    minimapButton:SetScript("OnDragStart", function(self)
        self:SetScript("OnUpdate", function()
            local mx, my = Minimap:GetCenter()
            local px, py = GetCursorPosition()
            local scale = Minimap:GetEffectiveScale()
            px, py = px / scale, py / scale
            local angle = math_atan2 and math_atan2(py - my, px - mx) or math_atan(py - my, px - mx)
            angle = NormalizeAngle(angle)
            FPSMonitorDB.minimap.angle = angle
            UpdateMinimapButtonPosition(angle)
        end)
    end)
    minimapButton:SetScript("OnDragStop", function(self)
        self:SetScript("OnUpdate", nil)
        FPSMonitorDB.minimap.angle = NormalizeAngle(FPSMonitorDB.minimap.angle)
        UpdateMinimapButtonPosition(FPSMonitorDB.minimap.angle)
    end)
    minimapButton:RegisterForClicks("LeftButtonUp", "RightButtonUp")
    minimapButton:SetScript("OnClick", function(_, btn)
        if btn == "RightButton" then
            if not optionsPanel then CreateOptionsPanel() end
            if Settings and Settings.OpenToCategory and optionsPanel and optionsPanel.category then
                Settings.OpenToCategory(optionsPanel.category)
            elseif optionsPanel then
                optionsPanel:Show()
            end
            return
        end
        if displayFrame and displayFrame:IsShown() then
            displayFrame:Hide()
        else
            pcall(CreateDisplayFrame)
            if displayFrame then displayFrame:Show() end
        end
    end)
    minimapButton:SetScript("OnEnter", function(self)
        local fps = GetFramerate() or 0
        local stats = CalculateStats(fps, 0)
        GameTooltip:SetOwner(self, "ANCHOR_BOTTOMLEFT")
        GameTooltip:SetText("FPS Monitor")
        GameTooltip:AddLine(string_format("Current: %.1f", stats.current))
        GameTooltip:AddLine(string_format("Average: %.1f", stats.average))
        GameTooltip:AddLine(string_format("1%% Low: %.1f", stats.low1))
        GameTooltip:AddLine(string_format("0.1%% Low: %.1f", stats.low01))
        if updateFrame and updateFrame.currentMemory then
            GameTooltip:AddLine("Memory: " .. ColorizeMemory(updateFrame.currentMemory))
        end
        GameTooltip:Show()
    end)
    minimapButton:SetScript("OnLeave", GameTooltip_Hide)
    if FPSMonitorDB.minimap.hide then
        minimapButton:Hide()
    end
end

-- Configuration options panel.  Uses the new Settings API on Retail clients
-- when available, otherwise falls back to the legacy Interface Options
-- system.  The panel provides controls for the minimap button visibility,
-- update interval, sample window length and FPS color thresholds.
local function CreateOptionsPanel()
    if optionsPanel then return end
    -- Ensure the Retail Settings API is loaded if present
    if not Settings and C_AddOns and C_AddOns.LoadAddOn then
        pcall(C_AddOns.LoadAddOn, "Blizzard_Settings")
    end
    optionsPanel = CreateFrame("Frame", "FPSMonitorOptions", InterfaceOptionsFramePanelContainer or UIParent)
    optionsPanel.name = "FPS Monitor"
    optionsPanel.general = CreateFrame("Frame", nil, optionsPanel)
    optionsPanel.general:SetAllPoints(optionsPanel)

    local title = optionsPanel.general:CreateFontString(nil, "ARTWORK", "GameFontNormalLarge")
    title:SetPoint("TOPLEFT", 16, -16)
    title:SetText("FPS Monitor")

    -- Show minimap button checkbox
    local minimapCheck = CreateFrame("CheckButton", nil, optionsPanel.general, "UICheckButtonTemplate")
    minimapCheck:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -8)
    if minimapCheck.Text then minimapCheck.Text:SetText("Show minimap button") end
    minimapCheck:SetChecked(not FPSMonitorDB.minimap.hide)
    minimapCheck:SetScript("OnClick", function(self)
        local show = self:GetChecked()
        FPSMonitorDB.minimap.hide = not show
        if show then
            if not minimapButton then CreateMinimapButton() end
            if minimapButton then minimapButton:Show() end
        elseif minimapButton then
            minimapButton:Hide()
        end
    end)

    -- Update interval slider
    local updateSlider = CreateFrame("Slider", nil, optionsPanel.general, "OptionsSliderTemplate")
    updateSlider:SetPoint("TOPLEFT", minimapCheck, "BOTTOMLEFT", 0, -30)
    updateSlider:SetMinMaxValues(0.1, 2)
    updateSlider:SetValueStep(0.1)
    updateSlider:SetObeyStepOnDrag(true)
    updateSlider:SetWidth(200)
    updateSlider:SetValue(FPSMonitorDB.updateInterval)
    _G[updateSlider:GetName() .. "Low"]:SetText("0.1")
    _G[updateSlider:GetName() .. "High"]:SetText("2.0")
    local updateText = updateSlider:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall")
    updateText:SetPoint("TOP", updateSlider, "BOTTOM", 0, -2)
    updateSlider.text = updateText
    updateSlider:SetScript("OnValueChanged", function(self, value)
        value = math.floor(value * 10 + 0.5) / 10
        self.text:SetText("Update interval: " .. value .. "s")
        FPSMonitorDB.updateInterval = value
        updateInterval = value
    end)
    updateSlider:SetValue(FPSMonitorDB.updateInterval)
    updateSlider.text:SetText("Update interval: " .. FPSMonitorDB.updateInterval .. "s")

    -- Sample window slider
    local sampleSlider = CreateFrame("Slider", nil, optionsPanel.general, "OptionsSliderTemplate")
    sampleSlider:SetPoint("TOPLEFT", updateSlider, "BOTTOMLEFT", 0, -40)
    sampleSlider:SetMinMaxValues(10, 120)
    sampleSlider:SetValueStep(10)
    sampleSlider:SetObeyStepOnDrag(true)
    sampleSlider:SetWidth(200)
    sampleSlider:SetValue(FPSMonitorDB.sampleInterval)
    _G[sampleSlider:GetName() .. "Low"]:SetText("10")
    _G[sampleSlider:GetName() .. "High"]:SetText("120")
    local sampleText = sampleSlider:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall")
    sampleText:SetPoint("TOP", sampleSlider, "BOTTOM", 0, -2)
    sampleSlider.text = sampleText
    sampleSlider:SetScript("OnValueChanged", function(self, value)
        value = math.floor(value + 0.5)
        self.text:SetText("Sample window: " .. value .. "s")
        FPSMonitorDB.sampleInterval = value
        sampleInterval = value
        -- Reset statistics to apply the new window length
        ResetStats()
    end)
    sampleSlider:SetValue(FPSMonitorDB.sampleInterval)
    sampleSlider.text:SetText("Sample window: " .. FPSMonitorDB.sampleInterval .. "s")

    -- Red threshold slider
    local redSlider = CreateFrame("Slider", nil, optionsPanel.general, "OptionsSliderTemplate")
    redSlider:SetPoint("TOPLEFT", sampleSlider, "BOTTOMLEFT", 0, -40)
    redSlider:SetMinMaxValues(5, 120)
    redSlider:SetValueStep(5)
    redSlider:SetObeyStepOnDrag(true)
    redSlider:SetWidth(200)
    redSlider:SetValue(FPSMonitorDB.fpsThresholds.red or defaultConfig.fpsThresholds.red)
    _G[redSlider:GetName() .. "Low"]:SetText("5")
    _G[redSlider:GetName() .. "High"]:SetText("120")
    local redText = redSlider:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall")
    redText:SetPoint("TOP", redSlider, "BOTTOM", 0, -2)
    redSlider.text = redText
    redSlider:SetScript("OnValueChanged", function(self, value)
        value = math.floor(value + 0.5)
        self.text:SetText("Red threshold: " .. value .. " FPS")
        FPSMonitorDB.fpsThresholds.red = value
    end)
    redSlider:SetValue(FPSMonitorDB.fpsThresholds.red or defaultConfig.fpsThresholds.red)
    redSlider.text:SetText("Red threshold: " .. (FPSMonitorDB.fpsThresholds.red or defaultConfig.fpsThresholds.red) .. " FPS")

    -- Yellow threshold slider
    local yellowSlider = CreateFrame("Slider", nil, optionsPanel.general, "OptionsSliderTemplate")
    yellowSlider:SetPoint("TOPLEFT", redSlider, "BOTTOMLEFT", 0, -40)
    yellowSlider:SetMinMaxValues(10, 240)
    yellowSlider:SetValueStep(5)
    yellowSlider:SetObeyStepOnDrag(true)
    yellowSlider:SetWidth(200)
    yellowSlider:SetValue(FPSMonitorDB.fpsThresholds.yellow or defaultConfig.fpsThresholds.yellow)
    _G[yellowSlider:GetName() .. "Low"]:SetText("10")
    _G[yellowSlider:GetName() .. "High"]:SetText("240")
    local yellowText = yellowSlider:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall")
    yellowText:SetPoint("TOP", yellowSlider, "BOTTOM", 0, -2)
    yellowSlider.text = yellowText
    yellowSlider:SetScript("OnValueChanged", function(self, value)
        value = math.floor(value + 0.5)
        self.text:SetText("Yellow threshold: " .. value .. " FPS")
        FPSMonitorDB.fpsThresholds.yellow = value
    end)
    yellowSlider:SetValue(FPSMonitorDB.fpsThresholds.yellow or defaultConfig.fpsThresholds.yellow)
    yellowSlider.text:SetText("Yellow threshold: " .. (FPSMonitorDB.fpsThresholds.yellow or defaultConfig.fpsThresholds.yellow) .. " FPS")

    -- Scale slider
    local scaleSlider = CreateFrame("Slider", nil, optionsPanel.general, "OptionsSliderTemplate")
    scaleSlider:SetPoint("TOPLEFT", yellowSlider, "BOTTOMLEFT", 0, -40)
    scaleSlider:SetMinMaxValues(0.5, 2.0)
    scaleSlider:SetValueStep(0.05)
    scaleSlider:SetObeyStepOnDrag(true)
    scaleSlider:SetWidth(200)
    local initialScale = FPSMonitorDB.scale or defaultConfig.scale
    scaleSlider:SetValue(initialScale)
    _G[scaleSlider:GetName() .. "Low"]:SetText("0.5")
    _G[scaleSlider:GetName() .. "High"]:SetText("2.0")
    local scaleText = scaleSlider:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall")
    scaleText:SetPoint("TOP", scaleSlider, "BOTTOM", 0, -2)
    scaleSlider.text = scaleText
    scaleSlider:SetScript("OnValueChanged", function(self, value)
        -- Round value to two decimals to prevent jittery updates
        value = math.floor(value * 100 + 0.5) / 100
        self.text:SetText("Panel scale: " .. string_format("%.2f", value))
        FPSMonitorDB.scale = value
        if displayFrame then
            displayFrame:SetScale(value)
        end
    end)
    scaleSlider:SetValue(initialScale)
    scaleSlider.text:SetText("Panel scale: " .. string_format("%.2f", initialScale))

    -- Register the options panel with the Settings system where available
    if Settings and Settings.RegisterCanvasLayoutCategory and Settings.RegisterAddOnCategory then
        local category, layout = Settings.RegisterCanvasLayoutCategory(optionsPanel.general, optionsPanel.name)
        optionsPanel.category = category
        Settings.RegisterAddOnCategory(category)
    elseif InterfaceOptions_AddCategory then
        InterfaceOptions_AddCategory(optionsPanel)
    end
end

-- Update overall per-character statistics based on the current session.
local function UpdateCharacterStats()
    local overall = FPSPerCharDB.overall
    if sessionMinFPS < overall.min then overall.min = sessionMinFPS end
    if sessionMaxFPS > overall.max then overall.max = sessionMaxFPS end
end

-- Reset the position of the display frame to the default.  Does not affect
-- the minimap button.  Called when resetting all statistics.
local function ResetFramePositions()
    FPSMonitorDB.pos = CopyTable(defaultConfig.pos)
    if displayFrame then
        displayFrame:ClearAllPoints()
        local pos = FPSMonitorDB.pos
        displayFrame:SetPoint(pos.point, UIParent, pos.relativePoint, pos.x, pos.y)
    end
end

-- Slash command handler for /fpsmon and /fpsmonitor.  Supports a handful of
-- commands to control the addon without opening the settings panel.  The
-- default behaviour toggles the stats display.
SLASH_FPSMON1 = "/fpsmon"
SLASH_FPSMON2 = "/fpsmonitor"
SlashCmdList["FPSMON"] = function(msg)
    msg = msg and msg:lower() or ""
    if msg == "reset" then
        ResetStats()
        ResetFramePositions()
        print("FPSMonitor: session statistics reset")
        return
    elseif msg == "resetall" then
        ResetStats()
        FPSPerCharDB.overall = { min = math.huge, max = 0 }
        ResetFramePositions()
        print("FPSMonitor: all statistics reset")
        return
    end
    -- Minimap commands: show/hide/toggle
    local miniCmd, arg = msg:match("^minimap%s*(%S*)")
    if miniCmd then
        if miniCmd == "show" then
            FPSMonitorDB.minimap.hide = false
        elseif miniCmd == "hide" then
            FPSMonitorDB.minimap.hide = true
        else
            -- toggle when no explicit argument provided
            FPSMonitorDB.minimap.hide = not FPSMonitorDB.minimap.hide
        end
        if FPSMonitorDB.minimap.hide then
            if minimapButton then minimapButton:Hide() end
            print("FPSMonitor: minimap button hidden")
        else
            if not minimapButton then CreateMinimapButton() end
            if minimapButton then minimapButton:Show() end
            print("FPSMonitor: minimap button shown")
        end
        return
    end
    if msg == "stats" then
        local fps = GetFramerate() or 0
        local stats = CalculateStats(fps, 0)
        local mem = updateFrame and updateFrame.currentMemory
        PrintStats(stats, mem)
        return
    elseif msg == "config" then
        if not optionsPanel then
            CreateOptionsPanel()
        end
        if Settings and Settings.OpenToCategory and optionsPanel and optionsPanel.category then
            Settings.OpenToCategory(optionsPanel.category)
        elseif optionsPanel then
            optionsPanel:Show()
        else
            print("FPSMonitor: configuration panel unavailable")
        end
        return
    elseif msg == "help" then
        print("/fpsmon - toggle display")
        print("/fpsmon minimap [show|hide|toggle] - control minimap button")
        print("/fpsmon stats - print current statistics to chat")
        print("/fpsmon config - open configuration panel")
        print("/fpsmon reset - reset session statistics and reposition display")
        print("/fpsmon resetall - reset all statistics and reposition display")
        return
    end
    -- Default: toggle the display frame
    if displayFrame and displayFrame:IsShown() then
        displayFrame:Hide()
    else
        pcall(CreateDisplayFrame)
        if displayFrame then displayFrame:Show() end
    end
end

-- Create the update frame and set up the OnUpdate handler.  The update frame
-- also dispatches events during the addon lifecycle.
updateFrame = CreateFrame("Frame")
updateFrame:SetScript("OnUpdate", function(self, elapsed)
    -- Handle delayed start after loading screens
    if captureStartTime and GetTime() >= captureStartTime then
        capturing = true
        captureStartTime = nil
    end
    -- Collect a sample each frame while capturing
    if capturing then
        AddSample(elapsed)
    end
    -- Throttled display updates
    self.t = (self.t or 0) + elapsed
    if self.t >= updateInterval then
        self.t = 0
        local currentFPS = GetFramerate() or 0
        local stats = CalculateStats(currentFPS, elapsed)
        UpdateDisplay(stats, updateFrame.currentMemory)
    end
end)

-- Event handler.  Handles addon loading, player login, entering and leaving
-- the world, and logout events to manage state and persistent statistics.
local function OnEvent(_, event, arg1)
    if event == "ADDON_LOADED" and arg1 == addonName then
        ValidateConfig()
        captureDelay         = FPSMonitorDB.captureDelay or defaultConfig.captureDelay
        sampleInterval       = FPSMonitorDB.sampleInterval or defaultConfig.sampleInterval
        updateInterval       = FPSMonitorDB.updateInterval or defaultConfig.updateInterval
        memoryUpdateInterval = FPSMonitorDB.memoryUpdateInterval or defaultConfig.memoryUpdateInterval
        -- Precompute the first memory sample
        if UpdateAddOnMemoryUsage and GetAddOnMemoryUsage then
            UpdateAddOnMemoryUsage()
            updateFrame.currentMemory = GetAddOnMemoryUsage(addonName) / 1024
        end
        -- Create options panel ahead of time to register with Settings
        pcall(CreateOptionsPanel)
        updateFrame:UnregisterEvent("ADDON_LOADED")
    elseif event == "PLAYER_LOGIN" then
        if not FPSMonitorDB.minimap.hide then
            pcall(CreateMinimapButton)
        end
    elseif event == "PLAYER_ENTERING_WORLD" then
        -- Delay sample collection for a short period to avoid skewing
        -- statistics with extremely high FPS values while assets load.
        captureStartTime = GetTime() + captureDelay
        capturing = false
        sessionMinFPS = math.huge
        sessionMaxFPS = 0
    elseif event == "PLAYER_LEAVING_WORLD" then
        capturing = false
        captureStartTime = nil
        UpdateCharacterStats()
    elseif event == "PLAYER_LOGOUT" then
        UpdateCharacterStats()
    end
end
updateFrame:RegisterEvent("ADDON_LOADED")
updateFrame:RegisterEvent("PLAYER_LOGIN")
updateFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
updateFrame:RegisterEvent("PLAYER_LEAVING_WORLD")
updateFrame:RegisterEvent("PLAYER_LOGOUT")
updateFrame:SetScript("OnEvent", OnEvent)