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 }

-- Centralized so the muted gold-neutral family can be tuned in one place
-- after screenshots, without hunting through toggle/line code.
local BENCHMARK_COLORS = {
    leaderboard_avg = { 0.88, 0.75, 0.36 },
    key_enjoyer     = { 0.78, 0.67, 0.43 },
    eu_1pct         = { 0.68, 0.49, 0.29 },
    eu_0_1pct       = { 0.86, 0.83, 0.72 },
}

-- ── 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 toggle 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 (or box -> prefix icon, when a toggle has one)
-- Standard prefix-icon convention used everywhere else in the addon
-- (leaderboard/dashboard/popups): 20x20 icon, 0px gap to its label.
local BENCHMARK_PREFIX_ICON_SIZE = 20

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,
    forecastTooltip = nil,
    -- One per BENCHMARK_DEFS entry (see CreateBenchmarkToggle); checked
    -- drives which historical benchmark lines are shown and is
    -- in-memory/session-only, never persisted to SavedVariables.
    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
    weeks = nil,             -- last BuildSharedWeeks() weeks list, cached for RerenderChart
    indexByWeek = nil,
    benchmarkSeries = nil,   -- last CollectBenchmarkSeries() result, keyed by BENCHMARK_DEFS key
    forecastSeries = nil,    -- last CollectForecastSeries() result, keyed by BENCHMARK_DEFS key
    forecastBoundary = nil,  -- { line=, label= } singleton marker regions (see RenderForecastBoundary)
    minDays = nil,           -- current calendar-date domain (see DaysToX)
    maxDays = nil,
    axisMin = nil,           -- current y-axis domain, cached so RenderBand never needs a full recompute
    axisMax = nil,
    -- Persistent (survives hover, cleared/renormalized on refresh/toggle —
    -- see NormalizeFocus) vs transient (hover-only, cleared on OnLeave/
    -- refresh) range-band selection. Both are in-memory only, no
    -- SavedVariables. "Checked + ready" for either one means exactly
    -- GetVisibleForecast(def, ...) succeeds — see EffectiveBandKey.
    focusedBenchmarkKey = nil,
    hoveredBenchmarkKey = nil,
    pools = {
        gridLines = {},
        yLabels = {},
        xLabels = {},
        seriesLines = {},
        seriesPoints = {},
        legendRows = {},
        benchmarkLines = {},
        forecastLines = {},
        forecastPoints = {},
        forecastBand = {},
    },
}

-- Forward declarations: CalculateYAxis (below) needs BENCHMARK_DEFS and
-- GetVisibleForecast, and benchmark toggles (built later in the file) need
-- to trigger a chart rerender, resolve focus, and refresh the range band
-- on click/hover — but all of these are defined before the code that
-- needs them. Assigned their real values further down.
local BENCHMARK_DEFS
local RerenderChart
local GetVisibleForecast
local NormalizeFocus
local RenderBand

-- ── 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

-- ── Calendar date helpers (pure integer arithmetic, no os.date/locale) ──
-- Crew and benchmark data share one true calendar-date x-scale instead of
-- even index-spacing, so "YYYY-MM-DD" needs a comparable day count. Uses
-- the standard days-from-civil algorithm (Howard Hinnant) — deterministic,
-- locale-independent, Lua 5.1-safe (math.floor stands in for `//`).
-- Malformed input returns nil everywhere instead of erroring; both crew
-- and benchmark dates are bot-generated and must never be trusted blindly.

local DAYS_IN_MONTH = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }

local function IsLeapYear(y)
    return (y % 4 == 0 and y % 100 ~= 0) or (y % 400 == 0)
end

local function ParseISODate(s)
    if type(s) ~= "string" then return nil end
    local y, m, d = s:match("^(%d%d%d%d)-(%d%d)-(%d%d)$")
    if not y then return nil end
    y, m, d = tonumber(y), tonumber(m), tonumber(d)
    if m < 1 or m > 12 then return nil end
    local maxDay = DAYS_IN_MONTH[m]
    if m == 2 and IsLeapYear(y) then maxDay = 29 end
    if d < 1 or d > maxDay then return nil end
    return y, m, d
end

local function DateToDays(y, m, d)
    if m <= 2 then y = y - 1 end
    local era = math.floor((y >= 0 and y or y - 399) / 400)
    local yoe = y - era * 400
    local mAdj = m + (m > 2 and -3 or 9)
    local doy = math.floor((153 * mAdj + 2) / 5) + d - 1
    local doe = yoe * 365 + math.floor(yoe / 4) - math.floor(yoe / 100) + doy
    return era * 146097 + doe - 719468
end

local function DateStringToDays(s)
    local y, m, d = ParseISODate(s)
    if not y then return nil end
    return DateToDays(y, m, d)
end

-- Maps a day count to plot-local x. A degenerate single-day domain (or an
-- entirely unparseable one) collapses to x=0 instead of dividing by zero,
-- same "everything starts at the left edge" fallback the old index-based
-- single-week case used.
local function DaysToX(days, minDays, maxDays)
    local span = maxDays - minDays
    if span <= 0 then return 0 end
    return ((days - minDays) / span) * 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

-- forecastSeries values folded in are every checked+ready forecast's
-- "projected" point plus its valid low/high (see GetVisibleForecast for
-- what "checked+ready" means, and CollectForecastSeries for when low/high
-- count as valid) — not just the one currently showing a range band, so
-- the axis never jumps when hover/focus moves the band between series.
-- Invalid (missing or non-numeric) ranges were already excluded at
-- collection time and simply aren't present on the point here.
local function CalculateYAxis(series, benchmarkSeries, forecastSeries)
    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 benchmarkSeries then
        for _, def in ipairs(BENCHMARK_DEFS) do
            local toggle = Trends.benchmarkToggles[def.key]
            if toggle and toggle.checked and toggle.available then
                local points = benchmarkSeries[def.key]
                if points then
                    for _, p in ipairs(points) do
                        if not lo or p.value < lo then lo = p.value end
                        if not hi or p.value > hi then hi = p.value end
                    end
                end

                if forecastSeries then
                    local forecast = GetVisibleForecast(def, benchmarkSeries, forecastSeries)
                    if forecast then
                        for _, p in ipairs(forecast.points) do
                            if not lo or p.value < lo then lo = p.value end
                            if not hi or p.value > hi then hi = p.value end
                            if p.low and (not lo or p.low < lo) then lo = p.low end
                            if p.high and (not hi or p.high > hi) then hi = p.high end
                        end
                    end
                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)
    HideAll(Trends.pools.benchmarkLines)
    HideAll(Trends.pools.forecastLines)
    HideAll(Trends.pools.forecastPoints)
    HideAll(Trends.pools.forecastBand)
    if Trends.forecastBoundary then
        Trends.forecastBoundary.line:Hide()
        Trends.forecastBoundary.label:Hide()
    end
    if Trends.forecastTooltip then
        Trends.forecastTooltip:Hide()
    end
    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

-- visibleDates is a sorted, deduplicated { {date=, days=}, ... } list (see
-- BuildVisibleDates) spanning crew snapshots AND any currently checked
-- benchmark history, so a benchmark line that extends past the crew's own
-- date range still gets its start/end represented on the axis. Always
-- shows the actual first and last visible date, thinned to ~7 labels
-- in between.
local function RenderAxes(visibleDates, minDays, maxDays)
    local pool = Trends.pools.xLabels
    local dateCount = #visibleDates
    local labelInterval = math.max(1, math.ceil(dateCount / 7))
    local idx = 0
    for i, entry in ipairs(visibleDates) do
        local isFirst = (i == 1)
        local isLast = (i == dateCount)
        if isFirst or isLast or (i - 1) % labelInterval == 0 then
            idx = idx + 1
            local x = DaysToX(entry.days, minDays, maxDays)

            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-date 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(entry.date))
            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, minDays, maxDays)
    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. A snapshot whose
        -- date fails to parse is skipped entirely rather than erroring.
        local pts = {}
        local runs = {}
        local currentRun
        for i, snap in ipairs(snaps) do
            local weekIdx = indexByWeek[snap.week]
            local days = DateStringToDays(snap.week)
            if weekIdx and days then
                local p = { x = DaysToX(days, minDays, maxDays), y = ValueToY(snap.rio, axisMin, axisMax), weekIdx = weekIdx }
                pts[i] = p
                if currentRun and pts[i - 1] and weekIdx == pts[i - 1].weekIdx + 1 then
                    tinsert(currentRun, p)
                else
                    currentRun = { p }
                    tinsert(runs, currentRun)
                end
            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]
            if p then
                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
    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 toggles ──────────────────────────────────────────────────
-- checked is a purely in-memory, session-only flag on each toggle table —
-- never persisted to SavedVariables. available reflects whether that
-- benchmark currently has at least 2 usable historical points to draw a
-- line through (see UpdateBenchmarkAvailability); an unavailable toggle
-- ignores clicks and stays dimmed regardless of checked.

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 UNAVAILABLE_BORDER_ALPHA = 0.08
local UNAVAILABLE_LABEL_ALPHA = 0.25

-- Checked state uses the toggle's own benchmark color (so the checkbox
-- visibly maps to its line color on the graph); label text itself always
-- stays neutral (only its alpha changes, never its color).
local function SetBenchmarkToggleVisual(toggle, checked)
    toggle.checked = checked
    if checked then
        local c = toggle.color or GOLD
        toggle.border:SetColorTexture(c[1], c[2], c[3], CHECKED_BORDER_ALPHA)
        toggle.fillBg:SetColorTexture(c[1], c[2], c[3], 0.12)
        toggle.checkMark:SetColorTexture(c[1], c[2], c[3], 1)
        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".
-- Never called while unavailable (see CreateBenchmarkToggle's handlers).
local function ApplyBenchmarkHoverVisual(toggle)
    if toggle.checked then
        local c = toggle.color or GOLD
        toggle.border:SetColorTexture(c[1], c[2], c[3], CHECKED_HOVER_BORDER_ALPHA)
    else
        toggle.border:SetColorTexture(1, 1, 1, HOVER_BORDER_ALPHA)
        toggle.labelFS:SetAlpha(HOVER_LABEL_ALPHA)
    end
end

-- A benchmark with no usable historical values (or fewer than 2 points,
-- since a single point can't draw a line) is visibly unavailable: dimmed,
-- forced unchecked, and its click/hover handlers no-op.
local function SetBenchmarkToggleAvailability(toggle, available)
    toggle.available = available
    if available then
        SetBenchmarkToggleVisual(toggle, toggle.checked)
    else
        toggle.checked = false
        toggle.border:SetColorTexture(1, 1, 1, UNAVAILABLE_BORDER_ALPHA)
        toggle.fillBg:SetColorTexture(0, 0, 0, 0.25)
        toggle.checkMark:Hide()
        toggle.labelFS:SetAlpha(UNAVAILABLE_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 an optional prefix icon (def.prefixIcon, e.g. the SMN
-- Discord-role badge — same 20x20 icon and 0px icon->label gap as every
-- other "prefix icon + prefix label" element in the addon) and then the
-- label, all inside one mouse-enabled hit frame. Takes the whole
-- BENCHMARK_DEFS entry (rather than separate label/tooltip/color params)
-- so its handlers can key focus/hover state off def.key and re-check
-- def's own eligibility via GetVisibleForecast. def.color is the
-- benchmark's own line color (see BENCHMARK_COLORS) — shown on the
-- checkbox when checked so it visibly maps to its line on the graph; the
-- label text itself always stays neutral.
local function CreateBenchmarkToggle(parent, def)
    local labelText = def.label
    local tooltipBody = def.tooltipBody
    local color = def.color

    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:SetText(labelText)

    -- box -> [prefix icon ->] label, using the standard prefix-icon gap
    -- (0px icon->label) when this toggle has one, otherwise box -> label
    -- directly at the usual BENCHMARK_LABEL_GAP.
    local totalWidth = BENCHMARK_BOX_SIZE + BENCHMARK_LABEL_GAP
    if def.prefixIcon then
        local prefixIconTex = hit:CreateTexture(nil, "OVERLAY")
        prefixIconTex:SetTexture(def.prefixIcon)
        prefixIconTex:SetSize(BENCHMARK_PREFIX_ICON_SIZE, BENCHMARK_PREFIX_ICON_SIZE)
        prefixIconTex:SetPoint("LEFT", box, "RIGHT", BENCHMARK_LABEL_GAP, 0)
        labelFS:SetPoint("LEFT", prefixIconTex, "RIGHT", 0, 0)
        totalWidth = totalWidth + BENCHMARK_PREFIX_ICON_SIZE
    else
        labelFS:SetPoint("LEFT", box, "RIGHT", BENCHMARK_LABEL_GAP, 0)
    end

    hit:SetWidth(totalWidth + labelFS:GetStringWidth())

    local toggle = {
        checked = false,
        available = true,  -- corrected by UpdateBenchmarkAvailability on first refresh
        color = color,
        label = labelText,
        button = hit,
        border = border,
        fillBg = fillBg,
        checkMark = checkMark,
        labelFS = labelFS,
    }
    SetBenchmarkToggleVisual(toggle, false)

    hit:SetScript("OnEnter", function()
        if toggle.available then
            ApplyBenchmarkHoverVisual(toggle)
        end
        -- Unavailable replaces the description outright (rather than
        -- appending to it) so the fixed-size tooltip never has to fit two
        -- stacked messages.
        local body = toggle.available and tooltipBody or "No historical data yet."
        if body then ShowBenchmarkTooltip(hit, labelText, body) end

        -- Range band hover preview. "Checked + ready" is exactly
        -- GetVisibleForecast succeeding — the same gate used everywhere
        -- else (NormalizeFocus, EffectiveBandKey, y-axis inclusion) — so
        -- hovering an unchecked, historical-only, or otherwise ineligible
        -- toggle never conjures a band. Never touches checked state or
        -- the persistent focus, and never rebuilds the chart — RenderBand
        -- only redraws band strips from already-cached axis data.
        if toggle.checked and GetVisibleForecast(def, Trends.benchmarkSeries or {}, Trends.forecastSeries or {}) then
            Trends.hoveredBenchmarkKey = def.key
            RenderBand()
        end
    end)
    hit:SetScript("OnLeave", function()
        if toggle.available then
            SetBenchmarkToggleVisual(toggle, toggle.checked)
        end
        HideBenchmarkTooltip()

        -- Always clears if this toggle set it, regardless of the toggle's
        -- current checked/available state — hover is transient UI state
        -- and must never survive the cursor actually leaving, even if
        -- checked/available changed out from under it while hovered.
        if Trends.hoveredBenchmarkKey == def.key then
            Trends.hoveredBenchmarkKey = nil
            RenderBand()
        end
    end)
    hit:SetScript("OnMouseUp", function(self, button)
        if button ~= "LeftButton" then return end
        if not toggle.available 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)

        local benchmarkSeries = Trends.benchmarkSeries or {}
        local forecastSeries = Trends.forecastSeries or {}
        local visible = GetVisibleForecast(def, benchmarkSeries, forecastSeries)

        -- Checking a ready benchmark makes it the persistent focus
        -- outright; unchecking never does, but may invalidate the current
        -- focus, so NormalizeFocus always runs afterward regardless.
        if toggle.checked and visible then
            Trends.focusedBenchmarkKey = def.key
        end

        -- This exact click can switch the toggle off, or otherwise make
        -- its series no longer band-eligible — either way, a hover
        -- preview pinned to it must not linger.
        if Trends.hoveredBenchmarkKey == def.key and not (toggle.checked and visible) then
            Trends.hoveredBenchmarkKey = nil
        end

        NormalizeFocus(benchmarkSeries, forecastSeries)

        RerenderChart()
    end)

    return toggle
end

-- Meaning of each benchmark, and the daily-row field it reads from
-- TOAST.benchmarks (data/benchmarks.lua, bot-generated):
--   average    - average RIO of the current TOAST leaderboard (leaderboard_avg).
--                Shown as the SMN prefix icon + "AVG" (same PREFIX_MAP
--                icon/spacing convention as leaderboard.lua) instead of
--                literal "LB AVG" text.
--   keyEnjoyer - current RIO of leaderboard rank 10, the "Key Enjoyer"
--                Top 10 threshold (key_enjoyer)
--   top1       - Raider.IO EU seasonal Top 1% title cutoff (eu_1pct)
--   top01      - Raider.IO EU seasonal Top 0.1% title cutoff (eu_0_1pct)
BENCHMARK_DEFS = {
    { key = "average", dataKey = "leaderboard_avg", label = "AVG", prefixIcon = "Interface\\AddOns\\TOAST\\media\\prefixes\\smn", tooltipBody = "Current average RIO of the SMN roster.", color = BENCHMARK_COLORS.leaderboard_avg },
    { key = "keyEnjoyer", dataKey = "key_enjoyer", label = "KEY ENJOYER", tooltipBody = "Current score required to enter the TOAST leaderboard Top 10 and earn the Key Enjoyer title.", color = BENCHMARK_COLORS.key_enjoyer },
    { key = "top1", dataKey = "eu_1pct", label = "1% CUTOFF", tooltipBody = "Raider.IO EU seasonal Top 1% title cutoff.", color = BENCHMARK_COLORS.eu_1pct },
    { key = "top01", dataKey = "eu_0_1pct", label = "0.1% CUTOFF", tooltipBody = "Raider.IO EU seasonal Top 0.1% title cutoff.", color = BENCHMARK_COLORS.eu_0_1pct },
}

-- ── Benchmark data collection + chart rerender orchestration ──────────

-- Row-based daily data (see CLAUDE.md data contract): TOAST.benchmarks is
-- an array of { date = "YYYY-MM-DD", leaderboard_avg = , key_enjoyer = ,
-- eu_1pct = , eu_0_1pct = , rio_updated_at = }. rio_updated_at is metadata,
-- never plotted. Every guard below exists because this is bot-generated
-- data this file must never trust blindly: disabled flag, non-table
-- payload, empty array, malformed dates, and missing/non-numeric
-- individual fields are all tolerated independently per row and per key.
local function CollectBenchmarkSeries()
    local result = {}
    for _, def in ipairs(BENCHMARK_DEFS) do result[def.key] = {} end

    if TOAST.benchmarks_enabled ~= true then return result end
    if type(TOAST.benchmarks) ~= "table" then return result end

    for _, row in ipairs(TOAST.benchmarks) do
        if type(row) == "table" then
            local days = DateStringToDays(row.date)
            if days then
                for _, def in ipairs(BENCHMARK_DEFS) do
                    local value = tonumber(row[def.dataKey])
                    if value then
                        tinsert(result[def.key], { days = days, value = value, date = row.date })
                    end
                end
            end
        end
    end

    for _, def in ipairs(BENCHMARK_DEFS) do
        table.sort(result[def.key], function(a, b) return a.days < b.days end)
    end

    return result
end

-- A benchmark needs at least 2 valid points to draw a line at all (no
-- point markers are rendered for daily values), otherwise its toggle goes
-- into the dimmed/unavailable state.
local function UpdateBenchmarkAvailability(benchmarkSeries)
    for _, def in ipairs(BENCHMARK_DEFS) do
        local toggle = Trends.benchmarkToggles[def.key]
        if toggle then
            local points = benchmarkSeries[def.key]
            SetBenchmarkToggleAvailability(toggle, points ~= nil and #points >= 2)
        end
    end
end

-- Row-based, per-benchmark data (see CLAUDE.md data contract):
-- TOAST.forecasts.series[def.dataKey] = { status = "ready"/other,
-- source_end_date = "YYYY-MM-DD", points = { {date=, horizon_day=,
-- projected=, low=, high=}, ... } }. Only status == "ready" is renderable;
-- slope_per_day/residual_standard_error are never read anywhere — the
-- addon must never calculate forecasts or statistics itself. low/high are
-- read (Pass 3's estimated-range band) but a point's low/high are only
-- ever stored together, and only when both parse and low <= high; an
-- otherwise-valid point with missing/invalid low or high still keeps its
-- date/projected value, it just carries no range. Every field is read
-- defensively, same tolerance as CollectBenchmarkSeries: disabled flag,
-- non-table payload, and malformed points (bad date or non-numeric
-- projected) are all tolerated independently. A point is only accepted
-- once sourceEndDays itself parsed successfully, and only if it's
-- strictly after that date — a malformed-but-parseable row on or before
-- source_end_date must never expand the domain backward or draw a
-- projection inside the historical period. Duplicate dates are
-- deterministically deduplicated (first one in array order wins) so they
-- can't produce duplicate hover targets.
local function CollectForecastSeries()
    local result = {}
    for _, def in ipairs(BENCHMARK_DEFS) do result[def.key] = nil end

    if TOAST.forecasts_enabled ~= true then return result end
    if type(TOAST.forecasts) ~= "table" then return result end
    if type(TOAST.forecasts.series) ~= "table" then return result end

    for _, def in ipairs(BENCHMARK_DEFS) do
        local raw = TOAST.forecasts.series[def.dataKey]
        if type(raw) == "table" and raw.status == "ready" then
            local sourceEndDays = DateStringToDays(raw.source_end_date)
            local points = {}
            if sourceEndDays and type(raw.points) == "table" then
                local seenDays = {}
                for _, p in ipairs(raw.points) do
                    if type(p) == "table" then
                        local days = DateStringToDays(p.date)
                        local projected = tonumber(p.projected)
                        if days and projected and days > sourceEndDays and not seenDays[days] then
                            seenDays[days] = true
                            local point = { days = days, value = projected, date = p.date }
                            local low = tonumber(p.low)
                            local high = tonumber(p.high)
                            if low ~= nil and high ~= nil and low <= high then
                                point.low = low
                                point.high = high
                            end
                            tinsert(points, point)
                        end
                    end
                end
            end
            table.sort(points, function(a, b) return a.days < b.days end)
            if sourceEndDays and #points > 0 then
                result[def.key] = { sourceEndDays = sourceEndDays, points = points }
            end
        end
    end

    return result
end

-- Returns (forecastData, lastActualPoint) if def's forecast should
-- currently be drawn: its toggle is checked+available, its forecast is
-- "ready" with usable points, and there's a historical actual observation
-- at or before its source_end_date to anchor the dashed transition segment
-- to (actualPoints is sorted ascending, so the last one <= sourceEndDays
-- is exactly the "latest valid actual observation at or before
-- source_end_date"). Returns nil for anything not currently visible.
GetVisibleForecast = function(def, benchmarkSeries, forecastSeries)
    local toggle = Trends.benchmarkToggles[def.key]
    if not (toggle and toggle.checked and toggle.available) then return nil end

    local forecast = forecastSeries[def.key]
    if not forecast then return nil end

    local actualPoints = benchmarkSeries[def.key]
    if not actualPoints then return nil end

    local lastActual
    for _, p in ipairs(actualPoints) do
        if p.days <= forecast.sourceEndDays then
            lastActual = p
        else
            break
        end
    end
    if not lastActual then return nil end

    return forecast, lastActual
end

-- Sorted, deduplicated { {date=, days=}, ... } spanning crew snapshot
-- dates, currently checked-and-available benchmark dates, and — when that
-- benchmark also has a currently visible ready forecast (see
-- GetVisibleForecast) — its projected dates too. This is the single
-- shared source for both the horizontal domain (see ComputeDateDomain)
-- and the x-axis labels (see RenderAxes), so the two can never drift
-- apart. Deliberately excludes unchecked/not-ready forecasts: with
-- nothing checked, or a checked benchmark with no ready forecast, the
-- chart's date range is unaffected by forecasts at all; checking a ready
-- forecast may expand/shift the domain, unchecking contracts it back.
local function BuildVisibleDates(weeks, benchmarkSeries, forecastSeries)
    local seen = {}
    local dates = {}

    local function addDate(dateStr)
        local days = DateStringToDays(dateStr)
        if days and not seen[days] then
            seen[days] = true
            tinsert(dates, { date = dateStr, days = days })
        end
    end

    for _, week in ipairs(weeks) do
        addDate(week)
    end
    for _, def in ipairs(BENCHMARK_DEFS) do
        local toggle = Trends.benchmarkToggles[def.key]
        if toggle and toggle.checked and toggle.available then
            local points = benchmarkSeries[def.key]
            if points then
                for _, p in ipairs(points) do
                    addDate(p.date)
                end
            end

            local forecast = GetVisibleForecast(def, benchmarkSeries, forecastSeries)
            if forecast then
                for _, p in ipairs(forecast.points) do
                    addDate(p.date)
                end
            end
        end
    end

    table.sort(dates, function(a, b) return a.days < b.days end)
    return dates
end

-- visibleDates is already sorted, so the domain is just its first/last
-- entry; an empty list (no parseable dates at all) falls back to a
-- degenerate single-point domain — DaysToX's span<=0 guard then places
-- everything at x=0 instead of dividing by zero.
local function ComputeDateDomain(visibleDates)
    if #visibleDates == 0 then return 0, 0 end
    return visibleDates[1].days, visibleDates[#visibleDates].days
end

local BENCHMARK_LINE_THICKNESS = 1.5  -- thinner than crew lines (2-2.75) to read as a background reference

-- Historical benchmark lines: straight segments only (no Catmull-Rom —
-- these are measurements, smoothing could imply values that were never
-- observed), solid, no point markers. Uses its own region pool, entirely
-- separate from Trends.lineGroups/pointGroups, so crew hover/selection
-- emphasis (ApplyEmphasis/ClearChartEmphasis) never touches these lines.
-- points is daily data sorted by days, but a missing/malformed row for a
-- given day simply isn't in the array — so a segment is only drawn between
-- two array-adjacent points that are also calendar-adjacent (exactly one
-- day apart). Anything else (a gap from a missing day, or a duplicate
-- date) is skipped rather than bridged, so the line never implies a
-- measurement that was never observed.
local function RenderBenchmarks(benchmarkSeries, minDays, maxDays, axisMin, axisMax)
    local linePool = Trends.pools.benchmarkLines
    local lineIdx = 0

    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 _, def in ipairs(BENCHMARK_DEFS) do
        local toggle = Trends.benchmarkToggles[def.key]
        if toggle and toggle.checked and toggle.available then
            local points = benchmarkSeries[def.key]
            if points then
                local color = def.color
                for i = 1, #points - 1 do
                    local p1, p2 = points[i], points[i + 1]
                    if p2.days == p1.days + 1 then
                        local line = AcquireLine()
                        line:ClearAllPoints()
                        line:SetThickness(BENCHMARK_LINE_THICKNESS)
                        line:SetColorTexture(color[1], color[2], color[3], 1)
                        line:SetStartPoint("TOPLEFT", Trends.plot, DaysToX(p1.days, minDays, maxDays), ValueToY(p1.value, axisMin, axisMax))
                        line:SetEndPoint("TOPLEFT", Trends.plot, DaysToX(p2.days, minDays, maxDays), ValueToY(p2.value, axisMin, axisMax))
                        line:Show()
                    end
                end
            end
        end
    end

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

local FORECAST_DASH_LEN = 5
local FORECAST_DASH_GAP = 4

-- WoW's Line API has no native dash pattern, so one logical segment is
-- drawn as several short on/off Line pieces along its length.
local function DrawDashedSegment(acquireLine, x1, y1, x2, y2, color, thickness)
    local dx, dy = x2 - x1, y2 - y1
    local len = math.sqrt(dx * dx + dy * dy)
    if len <= 0 then return end
    local ux, uy = dx / len, dy / len
    local pos = 0
    while pos < len do
        local dashEnd = math.min(pos + FORECAST_DASH_LEN, len)
        local line = acquireLine()
        line:ClearAllPoints()
        line:SetThickness(thickness)
        line:SetColorTexture(color[1], color[2], color[3], 1)
        line:SetStartPoint("TOPLEFT", Trends.plot, x1 + ux * pos, y1 + uy * pos)
        line:SetEndPoint("TOPLEFT", Trends.plot, x1 + ux * dashEnd, y1 + uy * dashEnd)
        line:Show()
        pos = pos + FORECAST_DASH_LEN + FORECAST_DASH_GAP
    end
end

-- Dashed continuation of a checked, ready forecast: one transition segment
-- from the latest actual observation at/before source_end_date to horizon
-- day 1 (always drawn once both endpoints exist — never place all
-- projections at source_end_date, each point uses its own date), then
-- through consecutive daily projected points using the same day-adjacency
-- discipline as RenderBenchmarks (a missing/malformed day breaks the line
-- rather than bridging it). No point markers; same color as the
-- benchmark's solid historical line, so a checked series reads as one
-- continuous thread that changes from solid to dashed. Its own region
-- pool, entirely separate from the historical/crew pools.
local function RenderForecasts(benchmarkSeries, forecastSeries, minDays, maxDays, axisMin, axisMax)
    local linePool = Trends.pools.forecastLines
    local lineIdx = 0

    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 _, def in ipairs(BENCHMARK_DEFS) do
        local forecast, lastActual = GetVisibleForecast(def, benchmarkSeries, forecastSeries)
        if forecast then
            local color = def.color
            local points = forecast.points

            DrawDashedSegment(AcquireLine,
                DaysToX(lastActual.days, minDays, maxDays), ValueToY(lastActual.value, axisMin, axisMax),
                DaysToX(points[1].days, minDays, maxDays), ValueToY(points[1].value, axisMin, axisMax),
                color, BENCHMARK_LINE_THICKNESS)

            for i = 1, #points - 1 do
                local p1, p2 = points[i], points[i + 1]
                if p2.days == p1.days + 1 then
                    DrawDashedSegment(AcquireLine,
                        DaysToX(p1.days, minDays, maxDays), ValueToY(p1.value, axisMin, axisMax),
                        DaysToX(p2.days, minDays, maxDays), ValueToY(p2.value, axisMin, axisMax),
                        color, BENCHMARK_LINE_THICKNESS)
                end
            end
        end
    end

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

local FORECAST_TOOLTIP_HEIGHT_NO_RANGE = 90
local FORECAST_TOOLTIP_HEIGHT_WITH_RANGE = 108  -- tall enough for the extra rangeFS line

-- Dedicated restrained tooltip for hovering a forecast point (never
-- GameTooltip, never the crew ShowPointTooltip — separate frame, separate
-- pool, so neither can interfere with the other; a forecast point never
-- shares plot space with a crew point since it's always past the actual/
-- forecast boundary). Content is benchmark label / formatted date /
-- "Projected: <value>", plus rangeFS ("Estimated range: <low>-<high>")
-- shown only when that point has a valid range (see ShowForecastTooltip).
local function EnsureForecastTooltip()
    if Trends.forecastTooltip then return end

    local tt = CreateFrame("Frame", "TOASTTrendsForecastTooltip", UIParent, "BackdropTemplate")
    Mixin(tt, BackdropTemplateMixin)
    tt:SetSize(200, FORECAST_TOOLTIP_HEIGHT_NO_RANGE)
    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 dateFS = tt:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    dateFS:SetPoint("TOPLEFT", nameFS, "BOTTOMLEFT", 0, -2)
    dateFS:SetTextColor(1, 1, 1, 0.5)
    tt.dateFS = dateFS

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

    local rangeFS = tt:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    rangeFS:SetPoint("TOPLEFT", valueFS, "BOTTOMLEFT", 0, -4)
    rangeFS:SetTextColor(1, 1, 1, 0.5)
    rangeFS:Hide()
    tt.rangeFS = rangeFS

    Trends.forecastTooltip = tt
end

local function HideForecastTooltip()
    if Trends.forecastTooltip then Trends.forecastTooltip:Hide() end
end

local function ShowForecastTooltip(pointBtn)
    EnsureForecastTooltip()
    local tt = Trends.forecastTooltip
    local def = pointBtn.defRef
    local point = pointBtn.pointRef
    local color = def.color

    tt.accent:SetColorTexture(color[1], color[2], color[3], 1)
    tt.nameFS:SetText(def.label)
    tt.nameFS:SetTextColor(color[1], color[2], color[3])
    tt.dateFS:SetText(FormatWeekLabel(point.date))
    tt.valueFS:SetText("Projected: " .. FormatNumber(point.value))
    tt.valueFS:SetTextColor(1, 1, 1, 1)

    if point.low and point.high then
        tt.rangeFS:SetText("Estimated range: " .. FormatNumber(point.low) .. " - " .. FormatNumber(point.high))
        tt.rangeFS:Show()
        tt:SetHeight(FORECAST_TOOLTIP_HEIGHT_WITH_RANGE)
    else
        tt.rangeFS:Hide()
        tt:SetHeight(FORECAST_TOOLTIP_HEIGHT_NO_RANGE)
    end

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

-- Invisible ~12x12 hover hit targets, one per currently visible forecast
-- point — no visible dot/ring/marker of any kind (the "no forecast point
-- markers" rule still holds; only the separate tooltip frame above ever
-- becomes visible, and only on hover). Pooled the same reuse/hide pattern
-- as every other region pool in this file.
local function RenderForecastPoints(benchmarkSeries, forecastSeries, minDays, maxDays, axisMin, axisMax)
    local pool = Trends.pools.forecastPoints
    local idx = 0

    for _, def in ipairs(BENCHMARK_DEFS) do
        local forecast = GetVisibleForecast(def, benchmarkSeries, forecastSeries)
        if forecast then
            for _, p in ipairs(forecast.points) do
                idx = idx + 1
                local btn = pool[idx]
                if not btn then
                    btn = CreateFrame("Button", nil, Trends.plot)
                    btn:SetSize(12, 12)
                    btn:SetScript("OnEnter", function(self) ShowForecastTooltip(self) end)
                    btn:SetScript("OnLeave", function() HideForecastTooltip() end)
                    pool[idx] = btn
                end

                btn:ClearAllPoints()
                btn:SetPoint("CENTER", Trends.plot, "TOPLEFT",
                    DaysToX(p.days, minDays, maxDays), ValueToY(p.value, axisMin, axisMax))
                btn.defRef = def
                btn.pointRef = p
                btn:Show()
            end
        end
    end

    for i = idx + 1, #pool do pool[i]:Hide() end
end

-- Re-validates Trends.focusedBenchmarkKey (the persistent range-band
-- selection) and Trends.hoveredBenchmarkKey (the transient hover
-- preview) against current checked/available/ready state. "Checked +
-- ready" is exactly GetVisibleForecast succeeding — the same gate used
-- everywhere else (EffectiveBandKey, hover eligibility in
-- CreateBenchmarkToggle, y-axis inclusion in CalculateYAxis) — so a raw
-- status=="ready" series without usable historical anchoring or valid
-- forecast points is never focusable or previewable either. The stale
-- hover key is discarded first, before resolving focus, so a toggle that
-- lost eligibility while merely hovered (never clicked) can't keep
-- pinning the band via EffectiveBandKey. If the current focus is still
-- valid it's left alone; otherwise focus moves to the first other
-- checked+ready benchmark in BENCHMARK_DEFS order, or clears if none
-- remain. Called on every refresh (data/availability may have changed)
-- and after every toggle click (checked state may have changed).
NormalizeFocus = function(benchmarkSeries, forecastSeries)
    local function eligible(key)
        for _, def in ipairs(BENCHMARK_DEFS) do
            if def.key == key then
                return GetVisibleForecast(def, benchmarkSeries, forecastSeries) ~= nil
            end
        end
        return false
    end

    if Trends.hoveredBenchmarkKey and not eligible(Trends.hoveredBenchmarkKey) then
        Trends.hoveredBenchmarkKey = nil
    end

    if Trends.focusedBenchmarkKey and eligible(Trends.focusedBenchmarkKey) then
        return
    end

    Trends.focusedBenchmarkKey = nil
    for _, def in ipairs(BENCHMARK_DEFS) do
        if GetVisibleForecast(def, benchmarkSeries, forecastSeries) then
            Trends.focusedBenchmarkKey = def.key
            break
        end
    end
end

-- The one benchmark whose range band is currently shown: hovered (if
-- still checked+ready) takes priority over the persistent focus (if still
-- checked+ready); nil if neither applies. Same GetVisibleForecast gate as
-- NormalizeFocus, so this can never resolve to a series lacking usable
-- historical anchoring or valid forecast points.
local function EffectiveBandKey(benchmarkSeries, forecastSeries)
    local function findDef(key)
        for _, d in ipairs(BENCHMARK_DEFS) do
            if d.key == key then return d end
        end
        return nil
    end

    if Trends.hoveredBenchmarkKey then
        local def = findDef(Trends.hoveredBenchmarkKey)
        if def and GetVisibleForecast(def, benchmarkSeries, forecastSeries) then
            return Trends.hoveredBenchmarkKey
        end
    end
    if Trends.focusedBenchmarkKey then
        local def = findDef(Trends.focusedBenchmarkKey)
        if def and GetVisibleForecast(def, benchmarkSeries, forecastSeries) then
            return Trends.focusedBenchmarkKey
        end
    end
    return nil
end

local function ClampX(x)
    if x < 0 then return 0 end
    if x > PLOT_WIDTH then return PLOT_WIDTH end
    return x
end

local FORECAST_BAND_ALPHA = 0.08
local FORECAST_BAND_STRIP_WIDTH = 2  -- px, approximate sampling width per vertical strip

-- One logical band segment (between two calendar-adjacent forecast points
-- that both have a valid range) is drawn as several narrow vertical
-- Texture strips, each linearly interpolating high(top)/low(bottom)
-- across its own slice of the x-interval — this is plain two-point
-- texture anchoring, not an invented/unverified polygon API. Strip
-- boundaries are exact shared fractions of the segment (x1 + width*k/n),
-- never independently rounded, so adjacent strips butt together with no
-- overlap and no gap — overlapping low-alpha strips would otherwise
-- stack into visibly darker seams. A strip with non-positive width or
-- non-positive height (a zero-width range at both ends) is skipped
-- outright rather than creating a degenerate texture.
local function DrawBandSegment(acquireStrip, x1, highY1, lowY1, x2, highY2, lowY2, color)
    local width = x2 - x1
    if width <= 0 then return end
    local stripCount = math.max(1, math.floor(width / FORECAST_BAND_STRIP_WIDTH + 0.5))
    for i = 0, stripCount - 1 do
        local sx1 = x1 + (width * i) / stripCount
        local sx2 = x1 + (width * (i + 1)) / stripCount
        if sx2 > sx1 then
            local tMid = (i + 0.5) / stripCount
            local topY = highY1 + (highY2 - highY1) * tMid
            local botY = lowY1 + (lowY2 - lowY1) * tMid
            if topY > botY then
                local strip = acquireStrip()
                strip:ClearAllPoints()
                strip:SetPoint("TOPLEFT", Trends.plot, "TOPLEFT", sx1, topY)
                strip:SetPoint("BOTTOMRIGHT", Trends.plot, "TOPLEFT", sx2, botY)
                strip:SetColorTexture(color[1], color[2], color[3], FORECAST_BAND_ALPHA)
                strip:Show()
            end
        end
    end
end

-- Lightweight band-only refresh: reads minDays/maxDays/axisMin/axisMax/
-- benchmarkSeries/forecastSeries entirely from cached Trends state and
-- never recomputes the chart. Called once at the end of a normal
-- RerenderChart, and directly from CreateBenchmarkToggle's hover
-- handlers, so a hover preview never triggers a full rebuild. Safely
-- no-ops (hiding the pool) unless every piece of required cached state
-- exists — covers hovering a toggle during initialization, an empty
-- state, or right after data/availability changed but before the next
-- full rerender has run.
RenderBand = function()
    local pool = Trends.pools.forecastBand

    if not Trends.plot or not Trends.minDays or not Trends.maxDays or not Trends.axisMin or not Trends.axisMax then
        HideAll(pool)
        return
    end

    local benchmarkSeries = Trends.benchmarkSeries or {}
    local forecastSeries = Trends.forecastSeries or {}

    local idx = 0
    local function AcquireStrip()
        idx = idx + 1
        local strip = pool[idx]
        if not strip then
            strip = Trends.plot:CreateTexture(nil, "BACKGROUND")
            pool[idx] = strip
        end
        return strip
    end

    local bandKey = EffectiveBandKey(benchmarkSeries, forecastSeries)
    if bandKey then
        local def
        for _, d in ipairs(BENCHMARK_DEFS) do
            if d.key == bandKey then
                def = d
                break
            end
        end
        if def then
            local forecast = GetVisibleForecast(def, benchmarkSeries, forecastSeries)
            if forecast then
                local color = def.color
                local points = forecast.points
                for i = 1, #points - 1 do
                    local p1, p2 = points[i], points[i + 1]
                    if p2.days == p1.days + 1 and p1.low and p1.high and p2.low and p2.high then
                        local x1 = ClampX(DaysToX(p1.days, Trends.minDays, Trends.maxDays))
                        local x2 = ClampX(DaysToX(p2.days, Trends.minDays, Trends.maxDays))
                        DrawBandSegment(AcquireStrip,
                            x1, ValueToY(p1.high, Trends.axisMin, Trends.axisMax), ValueToY(p1.low, Trends.axisMin, Trends.axisMax),
                            x2, ValueToY(p2.high, Trends.axisMin, Trends.axisMax), ValueToY(p2.low, Trends.axisMin, Trends.axisMax),
                            color)
                    end
                end
            end
        end
    end

    for i = idx + 1, #pool do pool[i]:Hide() end
end

local FORECAST_BOUNDARY_COLOR = GOLD  -- muted gold, no teal
local FORECAST_BOUNDARY_LINE_ALPHA = 0.28
local FORECAST_BOUNDARY_LABEL_ALPHA = 0.55

local function EnsureForecastBoundary()
    if Trends.forecastBoundary then return end

    local line = Trends.plot:CreateTexture(nil, "OVERLAY")
    line:SetColorTexture(FORECAST_BOUNDARY_COLOR[1], FORECAST_BOUNDARY_COLOR[2], FORECAST_BOUNDARY_COLOR[3], FORECAST_BOUNDARY_LINE_ALPHA)
    line:Hide()

    local label = Trends.frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    label:SetText("FORECAST")  -- ASCII only: WoW's font renders the Unicode arrow glyph as a missing-glyph box
    label:SetTextColor(FORECAST_BOUNDARY_COLOR[1], FORECAST_BOUNDARY_COLOR[2], FORECAST_BOUNDARY_COLOR[3], FORECAST_BOUNDARY_LABEL_ALPHA)
    label:Hide()

    Trends.forecastBoundary = { line = line, label = label }
end

-- One shared vertical marker at the visible forecasts' boundary date (the
-- latest source_end_date among every currently checked+ready forecast) —
-- never one per benchmark, so checking several ready forecasts still
-- shows a single line/label, not overlapping duplicates. Hidden entirely
-- when nothing checked currently has a visible forecast.
local function RenderForecastBoundary(boundaryDays, minDays, maxDays)
    EnsureForecastBoundary()
    local marker = Trends.forecastBoundary
    if not boundaryDays then
        marker.line:Hide()
        marker.label:Hide()
        return
    end

    local x = DaysToX(boundaryDays, minDays, maxDays)

    marker.line:ClearAllPoints()
    marker.line:SetPoint("TOP", Trends.plot, "TOPLEFT", x, 0)
    marker.line:SetSize(1, PLOT_HEIGHT)
    marker.line:Show()

    marker.label:ClearAllPoints()
    marker.label:SetPoint("BOTTOMLEFT", Trends.plot, "TOPLEFT", x + 3, 4)
    marker.label:Show()
end

-- Lightweight rerender for a toggle click: recomputes the visible-date set
-- and domain (may have changed — see BuildVisibleDates), x-axis labels,
-- y-axis/grid, crew line/point geometry, benchmark lines, forecast lines
-- and hover targets, the shared forecast boundary marker, the range band,
-- and current selection emphasis. Never touches the legend rows, header,
-- or frame — that's the "no full window rebuild" requirement.
-- Unconditionally hides any open forecast tooltip first: a toggle click
-- (checking, unchecking, or a benchmark going unavailable) can move or
-- remove the point the cursor was over, so a lingering tooltip must not
-- survive a rerender. No-ops (after that) if there's no crew series
-- (empty-state view, where the toolbar is still reachable).
RerenderChart = function()
    if Trends.forecastTooltip then Trends.forecastTooltip:Hide() end
    if not Trends.series then return end

    local benchmarkSeries = Trends.benchmarkSeries or {}
    local forecastSeries = Trends.forecastSeries or {}

    local visibleDates = BuildVisibleDates(Trends.weeks, benchmarkSeries, forecastSeries)
    Trends.minDays, Trends.maxDays = ComputeDateDomain(visibleDates)

    local axisMin, axisMax, step = CalculateYAxis(Trends.series, benchmarkSeries, forecastSeries)
    Trends.axisMin, Trends.axisMax = axisMin, axisMax

    RenderGrid(axisMin, axisMax, step)
    RenderAxes(visibleDates, Trends.minDays, Trends.maxDays)
    RenderSeries(Trends.series, Trends.weeks, Trends.indexByWeek, axisMin, axisMax, Trends.minDays, Trends.maxDays)
    RenderBenchmarks(benchmarkSeries, Trends.minDays, Trends.maxDays, axisMin, axisMax)
    RenderForecasts(benchmarkSeries, forecastSeries, Trends.minDays, Trends.maxDays, axisMin, axisMax)
    RenderForecastPoints(benchmarkSeries, forecastSeries, Trends.minDays, Trends.maxDays, axisMin, axisMax)
    RenderBand()

    local boundaryDays
    for _, def in ipairs(BENCHMARK_DEFS) do
        local forecast = GetVisibleForecast(def, benchmarkSeries, forecastSeries)
        if forecast and (not boundaryDays or forecast.sourceEndDays > boundaryDays) then
            boundaryDays = forecast.sourceEndDays
        end
    end
    RenderForecastBoundary(boundaryDays, Trends.minDays, Trends.maxDays)

    ApplySeriesVisualState()
end

-- ── 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)
        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. Hover is transient UI state and must never
    -- survive closing/reopening or a data refresh either — the persistent
    -- Trends.focusedBenchmarkKey is left alone here and instead
    -- normalized below, once current data is known.
    Trends.selectedSeriesIndex = nil
    Trends.hoveredBenchmarkKey = 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)
    Trends.weeks, Trends.indexByWeek = BuildSharedWeeks(series)

    Trends.benchmarkSeries = CollectBenchmarkSeries()
    UpdateBenchmarkAvailability(Trends.benchmarkSeries)

    Trends.forecastSeries = CollectForecastSeries()

    NormalizeFocus(Trends.benchmarkSeries, Trends.forecastSeries)

    RerenderChart()
    RenderLegend(series)
    ApplySeriesVisualState()

    if #Trends.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
