local addonName, TOAST = ...

-- Hand-written UI for the bot-generated TOAST.trends / TOAST.trends_updated
-- data (data/trends.lua). Never edit that generated file from here — this
-- module only reads TOAST.members and TOAST.trends and renders them.

local GOLD = { 0.88, 0.75, 0.36 }
local BORDER = { 1, 1, 1, 0.12 }
local SOFT_GREEN = { 0.55, 0.85, 0.55 }
local SOFT_RED   = { 0.90, 0.45, 0.45 }

-- ── Layout constants ────────────────────────────────────────────────
-- Derived (not eyeballed) so the frame height can never drift out of sync
-- with the plot/legend/footer sizes below, same convention as dashboard.lua.
local FRAME_W = 745
local HEADER_H = 100  -- == leaderboard/dashboard's banner height
local PAD = 16
local Y_AXIS_W = 56   -- reserved for y-axis value labels, left of the plot
local PLOT_HEIGHT = 200
local X_AXIS_H = 20   -- reserved for week labels, below the plot
local LEGEND_GAP = 20
-- Static two-row header above the scrollable legend: row 1 is the
-- centered benchmark toggle group (a toolbar, not tied to any column),
-- row 2 is the CHARACTER/RIO/THIS WEEK column headers, then a small gap
-- before the first character row.
local LEGEND_HEADER_TOGGLES_H = 20
local LEGEND_HEADER_ROW_GAP = 5  -- toggle row -> column-header row
local LEGEND_HEADER_COLUMNS_H = 16
local LEGEND_HEADER_GAP = 5  -- column-header row -> first character row
local LEGEND_HEADER_H = LEGEND_HEADER_TOGGLES_H + LEGEND_HEADER_ROW_GAP + LEGEND_HEADER_COLUMNS_H + LEGEND_HEADER_GAP
local LEGEND_ROW_H = 24
local LEGEND_MIN_ROWS = 2  -- floor, so a single-character crew doesn't collapse the window
local LEGEND_MAX_ROWS = 5  -- ceiling; more series than this scrolls instead of growing the window
local FOOTER_GAP = 16
local FOOTER_H = 18
local DIVIDER_MEDALLION = 32
local DIVIDER_GAP = 6

-- Legend row columns: sample -> name (flexible) -> RIO -> weekly gain,
-- RIO/gain right-aligned to the legend's own right edge so they use the
-- full legend width instead of a hardcoded column position.
local LEGEND_SAMPLE_X = 4
local LEGEND_SAMPLE_W = 20
local LEGEND_NAME_X = LEGEND_SAMPLE_X + LEGEND_SAMPLE_W + 8
local LEGEND_RIGHT_PAD = 8
local LEGEND_GAIN_COL_W = 64
local LEGEND_RIO_COL_W = 64
local LEGEND_COL_GAP = 14
local LEGEND_CONTENT_W = FRAME_W - PAD * 2 - 28

-- Benchmark placeholder group is its own header row (row 1): one
-- horizontally centered toolbar spanning the full legend width, not
-- aligned to or labeled under any table column.
local BENCHMARK_CONTROL_GAP = 18  -- shared gap between every toggle
local BENCHMARK_BOX_SIZE = 11
local BENCHMARK_LABEL_GAP = 4  -- box -> its own label

local DIVIDER_Y = -(HEADER_H + 1)
local PLOT_TOP = DIVIDER_Y - (DIVIDER_MEDALLION / 2) - 16
local PLOT_LEFT = PAD + Y_AXIS_W
local PLOT_WIDTH = FRAME_W - PLOT_LEFT - PAD
local PLOT_BOTTOM = PLOT_TOP - PLOT_HEIGHT
-- LEGEND_TOP is where the scrollable row viewport starts; the divider sits
-- LEGEND_HEADER_H higher, in the band reserved for the two-row header, at
-- the same offset above it the divider always had above the (pre-header) top.
local LEGEND_TOP = PLOT_BOTTOM - X_AXIS_H - LEGEND_GAP - LEGEND_HEADER_H
local LEGEND_DIVIDER_Y = LEGEND_TOP + LEGEND_HEADER_H + 8

-- Frame height is always this same shape: distance down to the bottom of
-- the legend viewport, plus the footer's own gap/height/padding. Called
-- with the actual (row-count-dependent) legend height every refresh —
-- never re-hardcoded elsewhere.
local function CalculateFrameHeight(legendHeight)
    return -(LEGEND_TOP - legendHeight) + FOOTER_GAP + FOOTER_H + PAD
end

local FRAME_H = CalculateFrameHeight(LEGEND_MIN_ROWS * LEGEND_ROW_H)  -- initial size; RefreshTrends corrects it immediately

local MONTH_ABBR = { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }

local DEFAULT_CLASS_COLOR = { r = 0.6, g = 0.6, b = 0.6 }

local EMPTY_COPY = {
    ["no-identity"] = { "Trends unavailable", "TOAST could not link this character to your Discord crew." },
    ["no-crew"]     = { "No RIO history yet", "Weekly tracking data will appear here after the next TOAST update." },
    ["no-data"]     = { "No RIO history yet", "Weekly tracking data will appear here after the next TOAST update." },
}

-- Module state. Everything the renderer touches across refreshes lives here
-- instead of as loose module-level locals, so no single function has to
-- close over dozens of them (see BuildDashboard's old 60-upvalue problem).
local Trends = {
    frame = nil,
    plot = nil,
    legendScroll = nil,
    legendContent = nil,
    legendDivider = nil,
    footerFS = nil,
    emptyFrame = nil,
    helperFS = nil,
    tooltip = nil,
    benchmarkTooltip = nil,
    -- Clickable placeholders (see CreateBenchmarkToggle) — toggling
    -- flips an in-memory checked flag and its visual state only; no
    -- benchmark data, rendering, or SavedVariables exist yet.
    benchmarkToggles = {},
    series = nil,               -- last CollectCrewSeries() result
    selectedSeriesIndex = nil,  -- persistent manual selection (in-memory only, no default focus)
    lineGroups = {},
    pointGroups = {},
    renderedSegments = {},  -- [seriesIndex] = { {x1=,y1=,x2=,y2=}, ... } for graph-line click hit-testing
    pools = {
        gridLines = {},
        yLabels = {},
        xLabels = {},
        seriesLines = {},
        seriesPoints = {},
        legendRows = {},
    },
}

-- ── Small shared-style helpers (local copies: these aren't exposed as
-- TOAST.* globals by dashboard.lua/welcome.lua, so duplicating the ~15-line
-- border helper here is cheaper than exporting it just for this one caller) ──

local function AddBorder(f, color)
    local top = f:CreateTexture(nil, "BORDER")
    top:SetColorTexture(color[1], color[2], color[3], color[4])
    top:SetPoint("TOPLEFT", f, "TOPLEFT", 0, 0)
    top:SetPoint("TOPRIGHT", f, "TOPRIGHT", 0, 0)
    top:SetHeight(1)

    local bottom = f:CreateTexture(nil, "BORDER")
    bottom:SetColorTexture(color[1], color[2], color[3], color[4])
    bottom:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 0, 0)
    bottom:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", 0, 0)
    bottom:SetHeight(1)

    local left = f:CreateTexture(nil, "BORDER")
    left:SetColorTexture(color[1], color[2], color[3], color[4])
    left:SetPoint("TOPLEFT", f, "TOPLEFT", 0, 0)
    left:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 0, 0)
    left:SetWidth(1)

    local right = f:CreateTexture(nil, "BORDER")
    right:SetColorTexture(color[1], color[2], color[3], color[4])
    right:SetPoint("TOPRIGHT", f, "TOPRIGHT", 0, 0)
    right:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", 0, 0)
    right:SetWidth(1)
end

-- ── Formatting / math leaf helpers ──────────────────────────────────

local function FormatNumber(n)
    n = math.floor(n + 0.5)
    local negative = n < 0
    if negative then n = -n end
    local formatted = tostring(n):reverse():gsub("(%d%d%d)", "%1,"):reverse():gsub("^,", "")
    return (negative and "-" or "") .. formatted
end

local function FormatDelta(n)
    if n > 0 then return "+" .. FormatNumber(n) end
    if n < 0 then return "-" .. FormatNumber(-n) end
    return "0"
end

local function SetDeltaColor(fs, diff)
    if diff > 0 then
        fs:SetTextColor(SOFT_GREEN[1], SOFT_GREEN[2], SOFT_GREEN[3])
    elseif diff < 0 then
        fs:SetTextColor(SOFT_RED[1], SOFT_RED[2], SOFT_RED[3])
    else
        fs:SetTextColor(1, 1, 1, 0.6)
    end
end

local function FormatWeekLabel(week)
    local _, m, d = week:match("(%d+)-(%d+)-(%d+)")
    if not m then return week end
    return tonumber(d) .. " " .. (MONTH_ABBR[tonumber(m)] or "")
end

local function ValueToY(v, axisMin, axisMax)
    local frac = (v - axisMin) / (axisMax - axisMin)
    return (frac - 1) * PLOT_HEIGHT
end

local function WeekIndexToX(weekIdx, weekCount)
    return ((weekIdx - 1) / math.max(weekCount - 1, 1)) * PLOT_WIDTH
end

local SMOOTH_STEPS = 10  -- line segments drawn per interval to approximate the curve

-- Uniform Catmull-Rom through p1->p2, using p0/p3 as tangent neighbors
-- (clamped to duplicate the nearest endpoint at the start/end of a run).
local function CatmullRomPoint(p0, p1, p2, p3, t)
    local t2 = t * t
    local t3 = t2 * t
    local x = 0.5 * ((2 * p1.x) + (-p0.x + p2.x) * t
        + (2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2
        + (-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3)
    local y = 0.5 * ((2 * p1.y) + (-p0.y + p2.y) * t
        + (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2
        + (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3)
    return x, y
end

local HIT_THRESHOLD = 9  -- pixels; graph-line click must land within this distance of a segment

-- Shortest distance from point (px,py) to the segment (x1,y1)-(x2,y2).
local function DistanceToSegment(px, py, x1, y1, x2, y2)
    local dx, dy = x2 - x1, y2 - y1
    local lenSq = dx * dx + dy * dy
    local t
    if lenSq == 0 then
        t = 0
    else
        t = ((px - x1) * dx + (py - y1) * dy) / lenSq
        t = math.max(0, math.min(1, t))
    end
    local closestX, closestY = x1 + t * dx, y1 + t * dy
    local ddx, ddy = px - closestX, py - closestY
    return math.sqrt(ddx * ddx + ddy * ddy)
end

local function MixColor(r, g, b, tr, tg, tb, amount)
    return
        r + (tr - r) * amount,
        g + (tg - g) * amount,
        b + (tb - b) * amount
end

local function VariantColor(base, variantIndex)
    if variantIndex == 1 then
        return base.r, base.g, base.b
    elseif variantIndex == 2 then
        return MixColor(base.r, base.g, base.b, 1, 1, 1, 0.16)
    elseif variantIndex == 3 then
        return MixColor(base.r, base.g, base.b, 0, 0, 0, 0.16)
    else
        return MixColor(base.r, base.g, base.b, 1, 1, 1, 0.28)
    end
end

-- ── Data selection (TOAST.members + TOAST.trends only) ──────────────

-- Returns a sorted list of { name, class_, snapshots } for every crew
-- character that has at least one trend snapshot, or nil + a reason string
-- ("no-identity" / "no-crew" / "no-data") for the empty state to use.
local function CollectCrewSeries()
    local me = TOAST_DB.me
    if not me or not me.discord_id then
        return nil, "no-identity"
    end

    local crew = {}
    for _, m in ipairs(TOAST.members) do
        if m.discord_id == me.discord_id then
            tinsert(crew, m)
        end
    end
    if #crew == 0 then
        return nil, "no-crew"
    end

    local series = {}
    for _, m in ipairs(crew) do
        local snaps = TOAST.trends and TOAST.trends[m.name]
        if snaps and #snaps > 0 then
            local sorted = {}
            for _, snap in ipairs(snaps) do tinsert(sorted, snap) end
            table.sort(sorted, function(a, b) return a.week < b.week end)

            -- A tracked week can still have no run that week, so its
            -- snapshot carries no usable rio. Drop those here so every
            -- renderer downstream can assume every remaining entry is
            -- plottable, instead of each one re-checking validity.
            local valid = {}
            for _, snap in ipairs(sorted) do
                local rio = tonumber(snap.rio)
                if rio then
                    tinsert(valid, { week = snap.week, rio = rio })
                end
            end

            if #valid > 0 then
                tinsert(series, { name = m.name, class_ = m.class_, snapshots = valid })
            end
        end
    end
    if #series == 0 then
        return nil, "no-data"
    end

    -- Sorted alphabetically before anything downstream assigns colors, so
    -- duplicate-class variant assignment stays deterministic across refreshes.
    table.sort(series, function(a, b) return a.name < b.name end)
    return series, nil
end

local function BuildSharedWeeks(series)
    local seen = {}
    local weeks = {}
    for _, s in ipairs(series) do
        for _, snap in ipairs(s.snapshots) do
            if not seen[snap.week] then
                seen[snap.week] = true
                tinsert(weeks, snap.week)
            end
        end
    end
    table.sort(weeks)

    local indexByWeek = {}
    for i, w in ipairs(weeks) do indexByWeek[w] = i end
    return weeks, indexByWeek
end

local function CalculateYAxis(series)
    local lo, hi
    for _, s in ipairs(series) do
        for _, snap in ipairs(s.snapshots) do
            if snap.rio then
                if not lo or snap.rio < lo then lo = snap.rio end
                if not hi or snap.rio > hi then hi = snap.rio end
            end
        end
    end
    if not lo then return 0, 500, 100 end
    if lo == hi then
        lo = lo - 50
        hi = hi + 50
    end

    lo = lo - 100
    hi = hi + 100
    local range = hi - lo
    local step
    if range <= 250 then step = 50
    elseif range <= 500 then step = 100
    elseif range <= 1000 then step = 200
    else step = 500 end

    local axisMin = math.max(0, math.floor(lo / step) * step)
    local axisMax = math.ceil(hi / step) * step
    return axisMin, axisMax, step
end

local function AssignSeriesColors(series)
    local classCounts = {}
    for _, s in ipairs(series) do
        local token = TOAST.CLASS_TOKEN[s.class_]
        local base = (token and RAID_CLASS_COLORS[token]) or DEFAULT_CLASS_COLOR
        local key = token or "?"
        classCounts[key] = (classCounts[key] or 0) + 1
        local r, g, b = VariantColor(base, classCounts[key])
        s.color = { r, g, b }
    end
end

-- ── Custom hover tooltip (never GameTooltip) ────────────────────────

local function EnsureTooltip()
    if Trends.tooltip then return end

    local tt = CreateFrame("Frame", "TOASTTrendsTooltip", UIParent, "BackdropTemplate")
    Mixin(tt, BackdropTemplateMixin)
    tt:SetSize(200, 90)
    tt:SetFrameStrata("TOOLTIP")
    tt:SetClampedToScreen(true)
    tt:Hide()

    local bg = tt:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints()
    bg:SetColorTexture(0.06, 0.08, 0.10, 0.97)
    AddBorder(tt, BORDER)

    local accent = tt:CreateTexture(nil, "ARTWORK")
    accent:SetPoint("TOPLEFT", tt, "TOPLEFT", 0, 0)
    accent:SetPoint("BOTTOMLEFT", tt, "BOTTOMLEFT", 0, 0)
    accent:SetWidth(2)
    tt.accent = accent

    local nameFS = tt:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    nameFS:SetPoint("TOPLEFT", tt, "TOPLEFT", 10, -8)
    tt.nameFS = nameFS

    local weekFS = tt:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    weekFS:SetPoint("TOPLEFT", nameFS, "BOTTOMLEFT", 0, -2)
    weekFS:SetTextColor(1, 1, 1, 0.5)
    tt.weekFS = weekFS

    local rioFS = tt:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    rioFS:SetPoint("TOPLEFT", weekFS, "BOTTOMLEFT", 0, -10)
    tt.rioFS = rioFS

    local weeklyFS = tt:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    weeklyFS:SetPoint("TOPLEFT", rioFS, "BOTTOMLEFT", 0, -4)
    tt.weeklyFS = weeklyFS

    Trends.tooltip = tt
end

local function HidePointTooltip()
    if Trends.tooltip then Trends.tooltip:Hide() end
end

local function ShowPointTooltip(pointBtn)
    EnsureTooltip()
    local tt = Trends.tooltip
    local series = pointBtn.seriesRef
    local idx = pointBtn.charSnapIdx
    local snap = series.snapshots[idx]
    local color = series.color

    tt.accent:SetColorTexture(color[1], color[2], color[3], 1)
    tt.nameFS:SetText(series.name)
    tt.nameFS:SetTextColor(color[1], color[2], color[3])
    tt.weekFS:SetText(string.format("Week %d \194\183 %s", pointBtn.sharedWeekIdx, FormatWeekLabel(snap.week)))
    tt.rioFS:SetText("RIO: " .. FormatNumber(snap.rio))
    tt.rioFS:SetTextColor(1, 1, 1, 1)

    if idx > 1 then
        local diff = snap.rio - series.snapshots[idx - 1].rio
        tt.weeklyFS:SetText("Weekly gain: " .. FormatDelta(diff))
        SetDeltaColor(tt.weeklyFS, diff)
    else
        tt.weeklyFS:SetText("Weekly gain: \226\128\148")
        tt.weeklyFS:SetTextColor(1, 1, 1, 0.4)
    end

    tt:ClearAllPoints()
    tt:SetPoint("BOTTOM", pointBtn, "TOP", 0, 10)
    tt:Show()
end

-- ── Legend/graph selection + hover emphasis ─────────────────────────
-- No default focus: Trends.selectedSeriesIndex starts and resets to nil
-- every window open, so the default view shows every series equally.
-- Selection is persistent (survives OnLeave) and identical whether it
-- came from clicking a legend row or clicking a graph line. Hovering a
-- legend row only previews that series and always falls back to
-- ApplySeriesVisualState() on OnLeave, never to "everyone full opacity"
-- unless that's also the current persistent state.

local HOVER_LINE_ALPHA_TARGET, HOVER_LINE_ALPHA_OTHER = 1, 0.3
local HOVER_LINE_THICK_TARGET, HOVER_LINE_THICK_OTHER = 3, 2
local HOVER_POINT_ALPHA_TARGET, HOVER_POINT_ALPHA_OTHER = 1, 0.3

local SELECT_LINE_ALPHA_TARGET, SELECT_LINE_ALPHA_OTHER = 1.0, 0.38
local SELECT_LINE_THICK_TARGET, SELECT_LINE_THICK_OTHER = 2.75, 1.5
local SELECT_POINT_ALPHA_TARGET, SELECT_POINT_ALPHA_OTHER = 1.0, 0.4
local SELECT_LEGEND_ALPHA_OTHER = 0.4

local function ApplyEmphasis(targetIndex, lineAlphaTarget, lineAlphaOther, thickTarget, thickOther, pointAlphaTarget, pointAlphaOther)
    for si, lines in pairs(Trends.lineGroups) do
        local isTarget = (si == targetIndex)
        for _, ln in ipairs(lines) do
            ln:SetAlpha(isTarget and lineAlphaTarget or lineAlphaOther)
            ln:SetThickness(isTarget and thickTarget or thickOther)
        end
    end
    for si, pts in pairs(Trends.pointGroups) do
        local isTarget = (si == targetIndex)
        local alpha = isTarget and pointAlphaTarget or pointAlphaOther
        for _, pt in ipairs(pts) do pt:SetAlpha(alpha) end
    end
end

local function ClearChartEmphasis()
    for _, lines in pairs(Trends.lineGroups) do
        for _, ln in ipairs(lines) do
            ln:SetAlpha(1)
            ln:SetThickness(2)
        end
    end
    for _, pts in pairs(Trends.pointGroups) do
        for _, pt in ipairs(pts) do pt:SetAlpha(1) end
    end
end

-- Temporary hover preview (legend row only). Never touches selectedSeriesIndex.
local function PreviewSeries(seriesIndex)
    ApplyEmphasis(seriesIndex, HOVER_LINE_ALPHA_TARGET, HOVER_LINE_ALPHA_OTHER,
        HOVER_LINE_THICK_TARGET, HOVER_LINE_THICK_OTHER, HOVER_POINT_ALPHA_TARGET, HOVER_POINT_ALPHA_OTHER)
end

-- Applies the gold beam/tint + text dimming for one legend row based on
-- the current persistent selection (not hover).
local function ApplyRowRestingVisual(row)
    local hasSelection = (Trends.selectedSeriesIndex ~= nil)
    local isSelected = (Trends.selectedSeriesIndex == row.seriesIndex)
    row.accent:SetShown(isSelected)
    if isSelected then
        row.hoverBg:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.08)
        row.nameFS:SetAlpha(1)
        row.rioFS:SetAlpha(1)
        row.weeklyFS:SetAlpha(1)
    else
        row.hoverBg:SetColorTexture(1, 1, 1, 0)
        local alpha = hasSelection and SELECT_LEGEND_ALPHA_OTHER or 1
        row.nameFS:SetAlpha(alpha)
        row.rioFS:SetAlpha(alpha)
        row.weeklyFS:SetAlpha(alpha)
    end
end

-- The persistent resting state for the whole window: the manual
-- selection's emphasis on the chart plus every legend row's beam/tint,
-- or everyone fully visible when selectedSeriesIndex is nil. Selecting
-- from the legend or the graph both end by calling this.
local function ApplySeriesVisualState()
    if Trends.selectedSeriesIndex then
        ApplyEmphasis(Trends.selectedSeriesIndex, SELECT_LINE_ALPHA_TARGET, SELECT_LINE_ALPHA_OTHER,
            SELECT_LINE_THICK_TARGET, SELECT_LINE_THICK_OTHER, SELECT_POINT_ALPHA_TARGET, SELECT_POINT_ALPHA_OTHER)
    else
        ClearChartEmphasis()
    end

    local pool = Trends.pools.legendRows
    for i in ipairs(Trends.series or {}) do
        local row = pool[i]
        if row then ApplyRowRestingVisual(row) end
    end
end

-- ── Region pools / cleanup ───────────────────────────────────────────
-- Texture/Line/FontString regions have no real "destroy", so each pool is
-- reused across refreshes (grown on demand) instead of recreated, and
-- anything left over from a smaller previous refresh is just hidden.

local function HideAll(pool)
    for _, region in ipairs(pool) do
        region:Hide()
    end
end

local function ClearChartRegions()
    HideAll(Trends.pools.gridLines)
    HideAll(Trends.pools.yLabels)
    HideAll(Trends.pools.xLabels)
    HideAll(Trends.pools.seriesLines)
    HideAll(Trends.pools.seriesPoints)
    HideAll(Trends.pools.legendRows)
    wipe(Trends.lineGroups)
    wipe(Trends.pointGroups)
    wipe(Trends.renderedSegments)
end

-- ── Rendering ─────────────────────────────────────────────────────────

local function RenderGrid(axisMin, axisMax, step)
    local linePool = Trends.pools.gridLines
    local labelPool = Trends.pools.yLabels
    local idx = 0
    local v = axisMin
    while v <= axisMax + 0.001 do
        idx = idx + 1
        local y = ValueToY(v, axisMin, axisMax)

        local line = linePool[idx]
        if not line then
            line = Trends.plot:CreateTexture(nil, "ARTWORK")
            linePool[idx] = line
        end
        line:ClearAllPoints()
        line:SetPoint("TOPLEFT", Trends.plot, "TOPLEFT", 0, y)
        line:SetSize(PLOT_WIDTH, 1)
        line:SetColorTexture(1, 1, 1, v == axisMin and 0.18 or 0.06)
        line:Show()

        local label = labelPool[idx]
        if not label then
            label = Trends.frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
            label:SetJustifyH("RIGHT")
            labelPool[idx] = label
        end
        label:ClearAllPoints()
        label:SetPoint("RIGHT", Trends.plot, "TOPLEFT", -8, y)
        label:SetWidth(Y_AXIS_W - 8)
        label:SetText(FormatNumber(v))
        label:SetTextColor(1, 1, 1, 0.45)
        label:Show()

        v = v + step
    end
    for i = idx + 1, #linePool do linePool[i]:Hide() end
    for i = idx + 1, #labelPool do labelPool[i]:Hide() end
end

local function RenderAxes(weeks)
    local pool = Trends.pools.xLabels
    local weekCount = #weeks
    local labelInterval = math.max(1, math.ceil(weekCount / 7))
    local idx = 0
    for i, week in ipairs(weeks) do
        if (i - 1) % labelInterval == 0 or i == weekCount then
            idx = idx + 1
            local x = WeekIndexToX(i, weekCount)
            local isFirst = (i == 1)
            local isLast = (i == weekCount)

            local label = pool[idx]
            if not label then
                label = Trends.frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
                pool[idx] = label
            end
            label:ClearAllPoints()
            label:SetWidth(60)
            -- Edge labels are offset inward (left-/right-aligned against
            -- the tick) so they stay inside the plot instead of centering
            -- past its border. A single-week chart has no edge to offset
            -- toward, so it falls back to centered like an interior label.
            if isFirst and not isLast then
                label:SetJustifyH("LEFT")
                label:SetPoint("TOPLEFT", Trends.plot, "TOPLEFT", x, -PLOT_HEIGHT - 6)
            elseif isLast and not isFirst then
                label:SetJustifyH("RIGHT")
                label:SetPoint("TOPRIGHT", Trends.plot, "TOPLEFT", x, -PLOT_HEIGHT - 6)
            else
                label:SetJustifyH("CENTER")
                label:SetPoint("TOP", Trends.plot, "TOPLEFT", x, -PLOT_HEIGHT - 6)
            end
            label:SetText(FormatWeekLabel(week))
            label:SetTextColor(1, 1, 1, 0.45)
            label:Show()
        end
    end
    for i = idx + 1, #pool do pool[i]:Hide() end
end

local function RenderSeries(series, weeks, indexByWeek, axisMin, axisMax)
    local weekCount = #weeks
    local linePool = Trends.pools.seriesLines
    local pointPool = Trends.pools.seriesPoints
    local lineIdx, pointIdx = 0, 0

    wipe(Trends.renderedSegments)

    local function AcquireLine()
        lineIdx = lineIdx + 1
        local line = linePool[lineIdx]
        if not line then
            line = Trends.plot:CreateLine(nil, "ARTWORK")
            linePool[lineIdx] = line
        end
        return line
    end

    for si, s in ipairs(series) do
        Trends.lineGroups[si] = {}
        Trends.pointGroups[si] = {}
        Trends.renderedSegments[si] = {}
        local color = s.color
        local snaps = s.snapshots

        -- Precompute plot coordinates once per snapshot, and split into
        -- runs wherever the shared week doesn't directly follow the
        -- previous one. A gap of one or more missing shared weeks breaks
        -- the run so the smoothed curve never bridges it.
        local pts = {}
        local runs = {}
        local currentRun
        for i, snap in ipairs(snaps) do
            local weekIdx = indexByWeek[snap.week]
            local p = { x = WeekIndexToX(weekIdx, weekCount), y = ValueToY(snap.rio, axisMin, axisMax), weekIdx = weekIdx }
            pts[i] = p
            if currentRun and weekIdx == pts[i - 1].weekIdx + 1 then
                tinsert(currentRun, p)
            else
                currentRun = { p }
                tinsert(runs, currentRun)
            end
        end

        for _, run in ipairs(runs) do
            for j = 1, #run - 1 do
                local p0 = run[j - 1] or run[j]
                local p1 = run[j]
                local p2 = run[j + 1]
                local p3 = run[j + 2] or run[j + 1]

                local sampleX, sampleY = p1.x, p1.y
                for step = 1, SMOOTH_STEPS do
                    local t = step / SMOOTH_STEPS
                    local nx, ny = CatmullRomPoint(p0, p1, p2, p3, t)
                    local line = AcquireLine()
                    line:ClearAllPoints()
                    line:SetThickness(2)
                    line:SetColorTexture(color[1], color[2], color[3], 1)
                    line:SetStartPoint("TOPLEFT", Trends.plot, sampleX, sampleY)
                    line:SetEndPoint("TOPLEFT", Trends.plot, nx, ny)
                    line:Show()
                    tinsert(Trends.lineGroups[si], line)
                    -- Same TOPLEFT-relative, downward-negative coordinate
                    -- system as the line itself, so click hit-testing can
                    -- compare directly against cursor-derived plot-local
                    -- coordinates without any extra conversion.
                    tinsert(Trends.renderedSegments[si], { x1 = sampleX, y1 = sampleY, x2 = nx, y2 = ny })
                    sampleX, sampleY = nx, ny
                end
            end
        end

        for i, snap in ipairs(snaps) do
            local p = pts[i]
            pointIdx = pointIdx + 1
            local pt = pointPool[pointIdx]
            if not pt then
                pt = CreateFrame("Button", nil, Trends.plot)
                pt:SetSize(12, 12)
                local ring = pt:CreateTexture(nil, "OVERLAY")
                ring:SetPoint("CENTER")
                ring:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.9)
                pt.ring = ring
                local dot = pt:CreateTexture(nil, "ARTWORK")
                dot:SetPoint("CENTER")
                pt.dot = dot
                pt:SetScript("OnEnter", function(self)
                    self.ring:Show()
                    ShowPointTooltip(self)
                end)
                pt:SetScript("OnLeave", function(self)
                    self.ring:Hide()
                    HidePointTooltip()
                end)
                pointPool[pointIdx] = pt
            end

            local isLatest = (i == #snaps)
            local dotSize = isLatest and 6 or 4
            pt:ClearAllPoints()
            pt:SetPoint("CENTER", Trends.plot, "TOPLEFT", p.x, p.y)
            pt.dot:SetSize(dotSize, dotSize)
            pt.dot:SetColorTexture(color[1], color[2], color[3], 1)
            pt.ring:SetSize(dotSize + 4, dotSize + 4)
            pt.ring:Hide()
            pt.seriesRef = s
            pt.charSnapIdx = i
            pt.sharedWeekIdx = p.weekIdx
            pt:Show()
            tinsert(Trends.pointGroups[si], pt)
        end
    end

    for i = lineIdx + 1, #linePool do linePool[i]:Hide() end
    for i = pointIdx + 1, #pointPool do pointPool[i]:Hide() end
end

local function RenderLegend(series)
    Trends.legendContent:SetWidth(Trends.legendScroll:GetWidth())
    local pool = Trends.pools.legendRows

    for i, s in ipairs(series) do
        local row = pool[i]
        if not row then
            row = CreateFrame("Frame", nil, Trends.legendContent)
            row:SetHeight(LEGEND_ROW_H)
            row:EnableMouse(true)

            local hoverBg = row:CreateTexture(nil, "BACKGROUND")
            hoverBg:SetAllPoints()
            hoverBg:SetColorTexture(1, 1, 1, 0)
            row.hoverBg = hoverBg

            -- Same left accent-bar + tinted-background treatment as the
            -- dashboard's My Crew "current character" row, shown only
            -- while this row is the persistently selected series.
            local accent = row:CreateTexture(nil, "ARTWORK")
            accent:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 1)
            accent:SetWidth(2)
            accent:SetPoint("TOPLEFT", row, "TOPLEFT", 0, 0)
            accent:SetPoint("BOTTOMLEFT", row, "BOTTOMLEFT", 0, 0)
            accent:Hide()
            row.accent = accent

            local sample = row:CreateTexture(nil, "ARTWORK")
            sample:SetSize(LEGEND_SAMPLE_W, 2)
            sample:SetPoint("LEFT", row, "LEFT", LEGEND_SAMPLE_X, 0)
            row.sample = sample

            -- RIO/gain are anchored to the row's own right edge so they use
            -- the full legend width instead of a hardcoded column position;
            -- name is anchored between the sample and the RIO column so it
            -- flows naturally and never overlaps either.
            local weeklyFS = row:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
            weeklyFS:SetPoint("RIGHT", row, "RIGHT", -LEGEND_RIGHT_PAD, 0)
            weeklyFS:SetWidth(LEGEND_GAIN_COL_W)
            weeklyFS:SetJustifyH("RIGHT")
            row.weeklyFS = weeklyFS

            local rioFS = row:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
            rioFS:SetPoint("RIGHT", weeklyFS, "LEFT", -LEGEND_COL_GAP, 0)
            rioFS:SetWidth(LEGEND_RIO_COL_W)
            rioFS:SetJustifyH("RIGHT")
            rioFS:SetTextColor(1, 1, 1, 0.7)
            row.rioFS = rioFS

            local nameFS = row:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
            nameFS:SetPoint("LEFT", sample, "RIGHT", 8, 0)
            nameFS:SetPoint("RIGHT", rioFS, "LEFT", -8, 0)
            nameFS:SetJustifyH("LEFT")
            row.nameFS = nameFS

            row:SetScript("OnEnter", function(self)
                self.hoverBg:SetColorTexture(1, 1, 1, 0.04)
                PreviewSeries(self.seriesIndex)
            end)
            row:SetScript("OnLeave", function()
                ApplySeriesVisualState()
            end)
            row:SetScript("OnMouseUp", function(self, button)
                if button ~= "LeftButton" then return end
                if Trends.selectedSeriesIndex == self.seriesIndex then
                    Trends.selectedSeriesIndex = nil
                else
                    Trends.selectedSeriesIndex = self.seriesIndex
                end
                ApplySeriesVisualState()
            end)

            pool[i] = row
        end

        row:ClearAllPoints()
        row:SetPoint("TOPLEFT", Trends.legendContent, "TOPLEFT", 0, -(i - 1) * LEGEND_ROW_H)
        row:SetWidth(Trends.legendContent:GetWidth())
        row.seriesIndex = i
        row.sample:SetColorTexture(s.color[1], s.color[2], s.color[3], 1)

        local hex = string.format("%02x%02x%02x",
            math.floor(s.color[1] * 255), math.floor(s.color[2] * 255), math.floor(s.color[3] * 255))
        row.nameFS:SetText(string.format("|cff%s%s|r", hex, s.name))

        local latest = s.snapshots[#s.snapshots]
        row.rioFS:SetText(latest and FormatNumber(latest.rio) or "\226\128\148")

        local previous = s.snapshots[#s.snapshots - 1]
        if latest and previous then
            local weeklyGain = latest.rio - previous.rio
            row.weeklyFS:SetText(FormatDelta(weeklyGain))
            SetDeltaColor(row.weeklyFS, weeklyGain)
        else
            row.weeklyFS:SetText("\226\128\148")
            row.weeklyFS:SetTextColor(1, 1, 1, 0.4)
        end

        ApplyRowRestingVisual(row)

        row:Show()
    end

    for i = #series + 1, #pool do pool[i]:Hide() end
    Trends.legendContent:SetHeight(math.max(#series * LEGEND_ROW_H, 1))
end

-- ── Empty state ──────────────────────────────────────────────────────

local function BuildEmptyState(parent)
    local f = CreateFrame("Frame", nil, parent)
    f:SetPoint("TOPLEFT", Trends.plot, "TOPLEFT", 0, 0)
    f:SetPoint("BOTTOMRIGHT", Trends.plot, "BOTTOMRIGHT", 0, 0)
    f:Hide()

    local titleFS = f:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    titleFS:SetPoint("CENTER", f, "CENTER", 0, 10)
    titleFS:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
    f.titleFS = titleFS

    local subFS = f:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    subFS:SetPoint("TOP", titleFS, "BOTTOM", 0, -8)
    subFS:SetWidth(360)
    subFS:SetJustifyH("CENTER")
    subFS:SetTextColor(1, 1, 1, 0.5)
    f.subFS = subFS

    Trends.emptyFrame = f
end

local function ShowEmptyState(kind)
    local copy = EMPTY_COPY[kind] or EMPTY_COPY["no-data"]
    Trends.emptyFrame.titleFS:SetText(copy[1])
    Trends.emptyFrame.subFS:SetText(copy[2])
    Trends.emptyFrame:Show()
end

local function HideEmptyState()
    Trends.emptyFrame:Hide()
end

-- ── Benchmark toggle placeholders (visually interactive) ─────────────
-- No benchmark data/rendering/SavedVariables exist yet — checked is a
-- purely in-memory, session-only flag on each toggle table, never
-- persisted and never read by RefreshTrends() or anything else.

local HOVER_BORDER_ALPHA = 0.4       -- unchecked resting (0.20) -> hover
local HOVER_LABEL_ALPHA = 0.8        -- unchecked resting (0.5) -> hover
local CHECKED_BORDER_ALPHA = 0.85
local CHECKED_HOVER_BORDER_ALPHA = 0.95  -- checked resting -> hover: brighten, don't restyle

local function SetBenchmarkToggleVisual(toggle, checked)
    toggle.checked = checked
    if checked then
        toggle.border:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], CHECKED_BORDER_ALPHA)
        toggle.fillBg:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.12)
        toggle.checkMark:Show()
        toggle.labelFS:SetAlpha(1)
    else
        toggle.border:SetColorTexture(1, 1, 1, 0.20)
        toggle.fillBg:SetColorTexture(0, 0, 0, 0.25)
        toggle.checkMark:Hide()
        toggle.labelFS:SetAlpha(0.5)
    end
end

-- Hover only ever brightens a border and/or a label on top of whatever
-- the current checked/unchecked resting state already drew — it never
-- swaps fill/checkmark, so a checked control never flashes "unchecked".
local function ApplyBenchmarkHoverVisual(toggle)
    if toggle.checked then
        toggle.border:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], CHECKED_HOVER_BORDER_ALPHA)
    else
        toggle.border:SetColorTexture(1, 1, 1, HOVER_BORDER_ALPHA)
        toggle.labelFS:SetAlpha(HOVER_LABEL_ALPHA)
    end
end

local function EnsureBenchmarkTooltip()
    if Trends.benchmarkTooltip then return end

    local tt = CreateFrame("Frame", "TOASTTrendsBenchmarkTooltip", UIParent, "BackdropTemplate")
    Mixin(tt, BackdropTemplateMixin)
    tt:SetSize(220, 50)
    tt:SetFrameStrata("TOOLTIP")
    tt:SetClampedToScreen(true)
    tt:Hide()

    local bg = tt:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints()
    bg:SetColorTexture(0.06, 0.08, 0.10, 0.97)
    AddBorder(tt, BORDER)

    local titleFS = tt:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    titleFS:SetPoint("TOPLEFT", tt, "TOPLEFT", 10, -8)
    titleFS:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
    tt.titleFS = titleFS

    local bodyFS = tt:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    bodyFS:SetPoint("TOPLEFT", titleFS, "BOTTOMLEFT", 0, -4)
    bodyFS:SetWidth(200)
    bodyFS:SetJustifyH("LEFT")
    bodyFS:SetTextColor(1, 1, 1, 0.6)
    tt.bodyFS = bodyFS

    Trends.benchmarkTooltip = tt
end

local function HideBenchmarkTooltip()
    if Trends.benchmarkTooltip then Trends.benchmarkTooltip:Hide() end
end

local function ShowBenchmarkTooltip(anchorFrame, title, body)
    EnsureBenchmarkTooltip()
    local tt = Trends.benchmarkTooltip
    tt.titleFS:SetText(title)
    tt.bodyFS:SetText(body)
    tt:ClearAllPoints()
    tt:SetPoint("BOTTOM", anchorFrame, "TOP", 0, 8)
    tt:Show()
end

-- A small square box (border + inset fill, no Blizzard checkbox texture)
-- followed by a label, both inside one mouse-enabled hit frame.
local function CreateBenchmarkToggle(parent, labelText, tooltipBody)
    local hit = CreateFrame("Frame", nil, parent)
    hit:SetHeight(LEGEND_HEADER_TOGGLES_H)
    hit:EnableMouse(true)

    local box = CreateFrame("Frame", nil, hit)
    box:SetSize(BENCHMARK_BOX_SIZE, BENCHMARK_BOX_SIZE)
    box:SetPoint("LEFT", hit, "LEFT", 0, 0)

    -- The border is just the full-size box tinted; the fill sits inset by
    -- 1px on top of it, so only a 1px ring of the border color shows.
    local border = box:CreateTexture(nil, "BACKGROUND")
    border:SetAllPoints()

    local fillBg = box:CreateTexture(nil, "BORDER")
    fillBg:SetPoint("TOPLEFT", box, "TOPLEFT", 1, -1)
    fillBg:SetPoint("BOTTOMRIGHT", box, "BOTTOMRIGHT", -1, 1)

    local checkMark = box:CreateTexture(nil, "ARTWORK")
    checkMark:SetPoint("TOPLEFT", box, "TOPLEFT", 3, -3)
    checkMark:SetPoint("BOTTOMRIGHT", box, "BOTTOMRIGHT", -3, 3)
    checkMark:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 1)
    checkMark:Hide()

    local labelFS = hit:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    labelFS:SetPoint("LEFT", box, "RIGHT", BENCHMARK_LABEL_GAP, 0)
    labelFS:SetText(labelText)

    hit:SetWidth(BENCHMARK_BOX_SIZE + BENCHMARK_LABEL_GAP + labelFS:GetStringWidth())

    local toggle = {
        checked = false,
        label = labelText,
        button = hit,
        border = border,
        fillBg = fillBg,
        checkMark = checkMark,
        labelFS = labelFS,
    }
    SetBenchmarkToggleVisual(toggle, false)

    hit:SetScript("OnEnter", function()
        ApplyBenchmarkHoverVisual(toggle)
        if tooltipBody then ShowBenchmarkTooltip(hit, labelText, tooltipBody) end
    end)
    hit:SetScript("OnLeave", function()
        SetBenchmarkToggleVisual(toggle, toggle.checked)
        HideBenchmarkTooltip()
    end)
    hit:SetScript("OnMouseUp", function(self, button)
        if button ~= "LeftButton" then return end
        toggle.checked = not toggle.checked
        SetBenchmarkToggleVisual(toggle, toggle.checked)
        -- The cursor is still over the control right after a click, so
        -- keep showing hover feedback instead of waiting for the next
        -- OnEnter (which won't fire again until the mouse actually leaves
        -- and re-enters).
        ApplyBenchmarkHoverVisual(toggle)
    end)

    return toggle
end

-- Planned meaning of each benchmark (none of this is calculated or
-- rendered yet — toggle.checked is freely clickable but still drives
-- nothing beyond its own visual state):
--   average    - average RIO of the current TOAST leaderboard
--   keyEnjoyer - current RIO of leaderboard rank 10 (the "Key Enjoyer"
--                Top 10 threshold)
--   top1       - Raider.IO EU seasonal Top 1% title cutoff
--   top01      - Raider.IO EU seasonal Top 0.1% title cutoff
local BENCHMARK_DEFS = {
    { key = "average", label = "LB AVG", tooltipBody = "Current average RIO of the TOAST leaderboard." },
    { key = "keyEnjoyer", label = "KEY ENJOYER", tooltipBody = "Current score required to enter the TOAST leaderboard Top 10 and earn the Key Enjoyer title." },
    { key = "top1", label = "1% CUTOFF", tooltipBody = "Raider.IO EU seasonal Top 1% title cutoff." },
    { key = "top01", label = "0.1% CUTOFF", tooltipBody = "Raider.IO EU seasonal Top 0.1% title cutoff." },
}

-- ── Window construction ─────────────────────────────────────────────

local function BuildChartContainer(frame)
    Trends.plot = CreateFrame("Frame", nil, frame)
    Trends.plot:SetSize(PLOT_WIDTH, PLOT_HEIGHT)
    Trends.plot:SetPoint("TOPLEFT", frame, "TOPLEFT", PLOT_LEFT, PLOT_TOP)
    AddBorder(Trends.plot, BORDER)

    -- Line regions can't take clicks themselves, so hit-test against the
    -- stored segment geometry (Trends.renderedSegments) on the plot frame
    -- itself instead.
    Trends.plot:EnableMouse(true)
    Trends.plot:SetScript("OnMouseUp", function(self, button)
        if button ~= "LeftButton" then return end
        local plotLeft, plotTop = self:GetLeft(), self:GetTop()
        if not plotLeft or not plotTop then return end

        local scale = self:GetEffectiveScale()
        local cursorX, cursorY = GetCursorPosition()
        cursorX = cursorX / scale
        cursorY = cursorY / scale

        -- Same TOPLEFT-relative, downward-negative coordinate system the
        -- stored segments use.
        local localX = cursorX - plotLeft
        local localY = cursorY - plotTop

        local bestSeriesIndex, bestDist
        for si, segments in pairs(Trends.renderedSegments) do
            for _, seg in ipairs(segments) do
                local dist = DistanceToSegment(localX, localY, seg.x1, seg.y1, seg.x2, seg.y2)
                if not bestDist or dist < bestDist then
                    bestDist = dist
                    bestSeriesIndex = si
                end
            end
        end

        if bestSeriesIndex and bestDist and bestDist <= HIT_THRESHOLD then
            if Trends.selectedSeriesIndex == bestSeriesIndex then
                Trends.selectedSeriesIndex = nil
            else
                Trends.selectedSeriesIndex = bestSeriesIndex
            end
            ApplySeriesVisualState()
        end
        -- Otherwise: clicked empty plot space, do nothing.
    end)

    BuildEmptyState(frame)

    Trends.helperFS = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    Trends.helperFS:SetPoint("BOTTOM", Trends.plot, "TOP", 0, 6)
    Trends.helperFS:SetTextColor(1, 1, 1, 0.4)
    Trends.helperFS:SetText("Weekly tracking starts here")
    Trends.helperFS:Hide()

    Trends.legendDivider = frame:CreateTexture(nil, "ARTWORK")
    Trends.legendDivider:SetColorTexture(1, 1, 1, 0.08)
    Trends.legendDivider:SetPoint("TOPLEFT", frame, "TOPLEFT", PAD, LEGEND_DIVIDER_Y)
    Trends.legendDivider:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -PAD, LEGEND_DIVIDER_Y)
    Trends.legendDivider:SetHeight(1)

    Trends.legendScroll = CreateFrame("ScrollFrame", "TOASTTrendsLegendSF", frame)
    Trends.legendScroll:SetPoint("TOPLEFT", frame, "TOPLEFT", PAD, LEGEND_TOP)
    Trends.legendScroll:SetSize(LEGEND_CONTENT_W, LEGEND_MIN_ROWS * LEGEND_ROW_H)

    Trends.legendContent = CreateFrame("Frame", nil, Trends.legendScroll)
    Trends.legendContent:SetSize(1, 1)
    Trends.legendScroll:SetScrollChild(Trends.legendContent)
    TOAST.CreateCustomScrollbar(Trends.legendScroll, Trends.legendContent)

    -- Static two-row header; sits in the LEGEND_HEADER_H band between the
    -- divider and the scroll viewport, and never scrolls with the rows.
    -- Row 1 (top) is the centered benchmark toggle toolbar; row 2 is the
    -- CHARACTER/RIO/THIS WEEK column headers, directly above the rows.
    local TOGGLE_ROW_Y = LEGEND_HEADER_GAP + LEGEND_HEADER_COLUMNS_H + LEGEND_HEADER_ROW_GAP
    local COLUMN_ROW_Y = LEGEND_HEADER_GAP

    -- Row 1: benchmark toggle group. Built from BENCHMARK_DEFS first
    -- (unpositioned) so every control's real width — box + its own
    -- label's rendered width — is known before the combined group is
    -- centered across the full legend width, rather than guessing four
    -- independent x positions.
    local toggles = {}
    for _, def in ipairs(BENCHMARK_DEFS) do
        local toggle = CreateBenchmarkToggle(frame, def.label, def.tooltipBody)
        Trends.benchmarkToggles[def.key] = toggle
        tinsert(toggles, toggle)
    end

    local totalToggleWidth = 0
    for i, toggle in ipairs(toggles) do
        totalToggleWidth = totalToggleWidth + toggle.button:GetWidth()
        if i > 1 then totalToggleWidth = totalToggleWidth + BENCHMARK_CONTROL_GAP end
    end
    local toggleGroupStartX = math.floor((LEGEND_CONTENT_W - totalToggleWidth) / 2)

    local previousControl
    for i, toggle in ipairs(toggles) do
        if i == 1 then
            toggle.button:SetPoint("BOTTOMLEFT", Trends.legendScroll, "TOPLEFT", toggleGroupStartX, TOGGLE_ROW_Y)
        else
            toggle.button:SetPoint("LEFT", previousControl, "RIGHT", BENCHMARK_CONTROL_GAP, 0)
        end
        previousControl = toggle.button
    end

    -- Row 2: table column headers, aligned to the same x positions the
    -- character rows themselves use.
    local charHeaderFS = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    charHeaderFS:SetPoint("BOTTOMLEFT", Trends.legendScroll, "TOPLEFT", LEGEND_NAME_X, COLUMN_ROW_Y)
    charHeaderFS:SetJustifyH("LEFT")
    charHeaderFS:SetText("CHARACTER")
    charHeaderFS:SetTextColor(1, 1, 1, 0.4)

    local gainHeaderFS = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    gainHeaderFS:SetPoint("BOTTOMRIGHT", Trends.legendScroll, "TOPRIGHT", -LEGEND_RIGHT_PAD, COLUMN_ROW_Y)
    gainHeaderFS:SetWidth(LEGEND_GAIN_COL_W)
    gainHeaderFS:SetJustifyH("RIGHT")
    gainHeaderFS:SetText("THIS WEEK")
    gainHeaderFS:SetTextColor(1, 1, 1, 0.4)

    local rioHeaderFS = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    rioHeaderFS:SetPoint("BOTTOMRIGHT", gainHeaderFS, "BOTTOMLEFT", -LEGEND_COL_GAP, 0)
    rioHeaderFS:SetWidth(LEGEND_RIO_COL_W)
    rioHeaderFS:SetJustifyH("RIGHT")
    rioHeaderFS:SetText("RIO")
    rioHeaderFS:SetTextColor(1, 1, 1, 0.4)

    Trends.footerFS = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    Trends.footerFS:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", PAD, 10)
    Trends.footerFS:SetTextColor(1, 1, 1, 0.3)
end

local function BuildTrendsHeader(frame)
    local header = CreateFrame("Frame", nil, frame)
    header:SetSize(FRAME_W, HEADER_H)
    header:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, 0)
    header:EnableMouse(true)
    header:RegisterForDrag("LeftButton")
    header:SetScript("OnDragStart", function() frame:StartMoving() end)
    header:SetScript("OnDragStop", function()
        frame:StopMovingOrSizing()
        TOAST.SaveWindowPosition(frame, "trends")
    end)

    local topAccentFrame = CreateFrame("Frame", nil, frame)
    topAccentFrame:SetAllPoints(frame)
    topAccentFrame:SetFrameLevel(header:GetFrameLevel() + 1)

    local topAccent = topAccentFrame:CreateTexture(nil, "ARTWORK")
    topAccent:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 1)
    topAccent:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, 0)
    topAccent:SetPoint("TOPRIGHT", frame, "TOPRIGHT", 0, 0)
    topAccent:SetHeight(2)

    local banner = header:CreateTexture(nil, "BACKGROUND")
    banner:SetTexture("Interface\\AddOns\\TOAST\\media\\banners\\zeewier.png")
    banner:SetTexCoord(0, 1, 0, 1)
    banner:SetAllPoints(header)
    banner:SetVertexColor(1, 1, 1, 0.48)

    local logo = header:CreateTexture(nil, "OVERLAY")
    logo:SetTexture("Interface\\AddOns\\TOAST\\media\\discord\\toast_128.tga")
    logo:SetSize(64, 64)
    logo:SetPoint("TOPLEFT", header, "TOPLEFT", 12, -8)

    local titleFS = header:CreateFontString(nil, "OVERLAY", "GameFontNormalHuge")
    titleFS:SetPoint("TOPLEFT", logo, "TOPRIGHT", 10, -8)
    titleFS:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
    titleFS:SetText("TOAST TRENDS")

    local subFS = header:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    subFS:SetPoint("TOPLEFT", titleFS, "BOTTOMLEFT", 0, -4)
    subFS:SetTextColor(1, 1, 1, 0.45)
    subFS:SetText("My Crew RIO Progression  \226\128\162  v" .. TOAST.VERSION)

    TOAST.CreateCloseButton(frame, function() frame:Hide() end)

    -- Same spot/behavior as the leaderboard's DASHBOARD button: left-click
    -- swaps to the dashboard, right-click opens it alongside this window.
    local dashBtn = TOAST.CreateActionButton(frame, "DASHBOARD", "gold", 90, 22)
    dashBtn:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -36, -10)
    dashBtn:SetFrameLevel(frame:GetFrameLevel() + 5)
    dashBtn:RegisterForClicks("LeftButtonUp", "RightButtonUp")
    dashBtn:SetScript("OnClick", function(self, button)
        if button == "RightButton" then
            TOAST.ShowDashboard()
        else
            frame:Hide()
            TOAST.ShowDashboard()
        end
    end)

    local dashBtnBg = select(1, dashBtn:GetRegions())
    if dashBtnBg then
        dashBtnBg:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.30)
        dashBtn:SetScript("OnEnter", function() dashBtnBg:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.42) end)
        dashBtn:SetScript("OnLeave", function() dashBtnBg:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.30) end)
    end

    -- Stacked directly below DASHBOARD, same button and behavior as the
    -- dashboard's own LEADERBOARD button.
    local lbBtn = TOAST.CreateActionButton(frame, "LEADERBOARD", "gold", 90, 22)
    lbBtn:SetPoint("TOP", dashBtn, "BOTTOM", 0, -6)
    lbBtn:SetFrameLevel(frame:GetFrameLevel() + 5)
    lbBtn:RegisterForClicks("LeftButtonUp", "RightButtonUp")
    lbBtn:SetScript("OnClick", function(self, button)
        if button == "RightButton" then
            TOAST.ShowLeaderboard()
        else
            frame:Hide()
            TOAST.commands["lb"]()
        end
    end)

    local lbBtnBg = select(1, lbBtn:GetRegions())
    if lbBtnBg then
        lbBtnBg:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.30)
        lbBtn:SetScript("OnEnter", function() lbBtnBg:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.42) end)
        lbBtn:SetScript("OnLeave", function() lbBtnBg:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.30) end)
    end

    TOAST.CreateAnchorDivider(frame, PAD, FRAME_W - PAD, DIVIDER_Y, {
        medallionSize = DIVIDER_MEDALLION,
        gap = DIVIDER_GAP,
        aboveFrame = header,
    })
end

local function BuildTrendsWindow()
    local frame = CreateFrame("Frame", "TOASTTrends", UIParent, "BackdropTemplate")
    Mixin(frame, BackdropTemplateMixin)
    frame:SetSize(FRAME_W, FRAME_H)
    TOAST.RestoreWindowPosition(frame, "trends")
    frame:SetMovable(true)
    frame:SetClampedToScreen(true)
    frame:SetFrameStrata("HIGH")
    frame:Hide()
    tinsert(UISpecialFrames, "TOASTTrends")

    local bg = frame:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints()
    bg:SetColorTexture(0.06, 0.08, 0.10, 1.0)
    AddBorder(frame, BORDER)

    Trends.frame = frame

    BuildTrendsHeader(frame)
    BuildChartContainer(frame)
end

-- ── Refresh orchestration ────────────────────────────────────────────

-- Legend viewport clamps to [LEGEND_MIN_ROWS, LEGEND_MAX_ROWS] visible rows
-- (more series scroll instead of growing the window), and the frame height
-- always follows from that same value via CalculateFrameHeight — there is
-- no second, independently hardcoded frame height anywhere.
local function UpdateDynamicLayout(seriesCount)
    local visibleRows = math.max(LEGEND_MIN_ROWS, math.min(seriesCount, LEGEND_MAX_ROWS))
    local legendHeight = visibleRows * LEGEND_ROW_H
    Trends.legendScroll:SetHeight(legendHeight)
    Trends.frame:SetHeight(CalculateFrameHeight(legendHeight))
end

local function RefreshTrends()
    ClearChartRegions()
    Trends.helperFS:Hide()

    -- No default focus: every window open starts with nothing selected
    -- and every series shown equally, regardless of which character is
    -- currently logged in.
    Trends.selectedSeriesIndex = nil

    local series, reason = CollectCrewSeries()
    Trends.series = series
    if not series then
        UpdateDynamicLayout(0)  -- compact ~2-row default, same clamp as a real crew
        Trends.plot:Hide()
        Trends.legendScroll:Hide()
        Trends.legendDivider:Hide()
        ShowEmptyState(reason)
        Trends.footerFS:SetText("")
        return
    end

    UpdateDynamicLayout(#series)

    HideEmptyState()
    Trends.plot:Show()
    Trends.legendScroll:Show()
    Trends.legendDivider:Show()

    AssignSeriesColors(series)
    local weeks, indexByWeek = BuildSharedWeeks(series)
    local axisMin, axisMax, step = CalculateYAxis(series)

    RenderGrid(axisMin, axisMax, step)
    RenderAxes(weeks)
    RenderSeries(series, weeks, indexByWeek, axisMin, axisMax)
    RenderLegend(series)
    ApplySeriesVisualState()

    if #weeks == 1 then
        Trends.helperFS:Show()
    end

    Trends.footerFS:SetText("Data updated: " .. (TOAST.trends_updated or "unknown"))
end

local function ShowTrends()
    if not Trends.frame then BuildTrendsWindow() end
    RefreshTrends()
    Trends.frame:Show()
end

local function HideTrends()
    if Trends.frame then Trends.frame:Hide() end
end

TOAST.ShowTrends = ShowTrends
TOAST.HideTrends = HideTrends

TOAST.commands["trends"] = function()
    if not Trends.frame then BuildTrendsWindow() end
    if Trends.frame:IsShown() then
        Trends.frame:Hide()
    else
        ShowTrends()
    end
end
