local addonName, TOAST = ...

-- Hand-written UI for the normalized bot-generated TOAST.trends data
-- (data/trends.lua). Never edit that generated file from here — this module
-- consumes it through modules/trends_data.lua and renders it.

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 X_LABEL_W = 60
local X_LABEL_GAP = 4
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/SINCE LAST column headers, then a small gap
-- before the first character row.
local LEGEND_HEADER_TOGGLES_H = 28
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 -> latest 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 = 8  -- compact spacing between pill controls
local BENCHMARK_PILL_H = 24
local BENCHMARK_PILL_PAD_X = 8
local BENCHMARK_SWATCH_W = 12
local BENCHMARK_SWATCH_GAP = 6
local BENCHMARK_LABEL_GAP = 3
local BENCHMARK_SECTION_LABEL_GAP = 12
-- Prefix icons retain the established addon treatment, scaled slightly for
-- the compact pill height.
local BENCHMARK_PREFIX_ICON_SIZE = 16
local VIEWPORT_CONTROL_GAP = 12
local VIEWPORT_UNDERLINE_H = 2
local VIEWPORT_WHEEL_THROTTLE = 0.125

-- Fixed, deterministic zoom levels. A finite preset is available only
-- when it would crop the crew's actual historical domain; otherwise it
-- is shown disabled and navigation skips directly to ALL.
local VIEWPORT_PRESETS = {
    { key = "7d",  label = "7D",  days = 7 },
    { key = "4w",  label = "4W",  days = 28 },
    { key = "8w",  label = "8W",  days = 56 },
    { key = "16w", label = "16W", days = 112 },
    { key = "all", label = "ALL" },
}

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", "Snapshot data will appear here after the next TOAST update." },
    ["no-data"]     = { "No RIO history yet", "Snapshot 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,
    viewportButtons = {},
    viewportPresetKey = nil, -- session-only; nil receives the 8W-or-ALL default on first data refresh
    viewportAvailability = {},
    historyMinDays = nil,
    historyMaxDays = nil,
    lastViewportWheelAt = 0,
    tooltip = nil,
    benchmarkTooltip = nil,
    benchmarkValueTooltip = 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,             -- exact shared snapshot points, 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 = {},
        benchmarkPoints = {},
        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

local function FindViewportPreset(key)
    for i, preset in ipairs(VIEWPORT_PRESETS) do
        if preset.key == key then return preset, i end
    end
end

local function SetViewportButtonVisual(button, selected, available, hovered)
    if not available then
        button.labelFS:SetTextColor(1, 1, 1, 0.22)
        button.underline:Hide()
    elseif selected then
        button.labelFS:SetTextColor(GOLD[1], GOLD[2], GOLD[3], 1)
        button.underline:Show()
    elseif hovered then
        button.labelFS:SetTextColor(GOLD[1], GOLD[2], GOLD[3], 0.85)
        button.underline:Hide()
    else
        button.labelFS:SetTextColor(1, 1, 1, 0.48)
        button.underline:Hide()
    end
end

local function RefreshViewportControlVisuals()
    for _, preset in ipairs(VIEWPORT_PRESETS) do
        local button = Trends.viewportButtons[preset.key]
        if button then
            local available = Trends.viewportAvailability[preset.key] == true
            SetViewportButtonVisual(
                button,
                Trends.viewportPresetKey == preset.key,
                available,
                button.hovered == true
            )
        end
    end
end

local function UpdateViewportAvailability(weeks)
    local first = weeks and weeks[1]
    local last = weeks and weeks[#weeks]
    Trends.historyMinDays = first and first.days or nil
    Trends.historyMaxDays = last and last.days or nil

    local span = (Trends.historyMinDays and Trends.historyMaxDays)
        and (Trends.historyMaxDays - Trends.historyMinDays) or 0

    for _, preset in ipairs(VIEWPORT_PRESETS) do
        -- Strictly greater: if the complete history already fits inside a
        -- preset, selecting it would be indistinguishable from ALL.
        Trends.viewportAvailability[preset.key] = not preset.days or span > preset.days
    end

    if not Trends.viewportPresetKey then
        Trends.viewportPresetKey = Trends.viewportAvailability["8w"] and "8w" or "all"
    elseif not Trends.viewportAvailability[Trends.viewportPresetKey] then
        Trends.viewportPresetKey = "all"
    end

    RefreshViewportControlVisuals()

    local hasFinitePreset = false
    for _, preset in ipairs(VIEWPORT_PRESETS) do
        if preset.days and Trends.viewportAvailability[preset.key] then
            hasFinitePreset = true
            break
        end
    end
    for _, button in pairs(Trends.viewportButtons) do
        if hasFinitePreset then button:Show() else button:Hide() end
    end
end

local function SelectViewportPreset(key)
    if not Trends.viewportAvailability[key] then return false end
    if Trends.viewportPresetKey == key then return false end
    Trends.viewportPresetKey = key
    RefreshViewportControlVisuals()
    RerenderChart()
    return true
end

local function StepViewportPreset(delta)
    local _, currentIndex = FindViewportPreset(Trends.viewportPresetKey)
    if not currentIndex then return false end

    -- Wheel up zooms in (toward shorter presets); wheel down zooms out.
    local direction = delta > 0 and -1 or 1
    local i = currentIndex + direction
    while i >= 1 and i <= #VIEWPORT_PRESETS do
        local candidate = VIEWPORT_PRESETS[i]
        if Trends.viewportAvailability[candidate.key] then
            return SelectViewportPreset(candidate.key)
        end
        i = i + direction
    end
    return false
end

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

local function ResolvedPointDays(resolved)
    local observedAt = resolved and tonumber(resolved.observedAt)
    if observedAt and observedAt == observedAt and observedAt > 0 and observedAt ~= math.huge then
        return observedAt / 86400
    end
    return resolved and DateStringToDays(resolved.date) or nil
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

-- Clips a rendered straight segment to the plot rectangle. Coordinates at
-- every crossing are interpolated from the segment itself, so a focused
-- y-axis can safely let contextual lines leave the viewport without any
-- region drawing across the surrounding UI.
local function ClipSegmentToPlot(x1, y1, x2, y2)
    local function clipAxis(a1, b1, a2, b2, minimum, maximum)
        local delta = a2 - a1
        if delta == 0 then
            if a1 < minimum or a1 > maximum then return nil end
            return a1, b1, a2, b2
        end

        local edgeT1 = (minimum - a1) / delta
        local edgeT2 = (maximum - a1) / delta
        local visibleFrom = math.max(0, math.min(edgeT1, edgeT2))
        local visibleTo = math.min(1, math.max(edgeT1, edgeT2))
        if visibleFrom > visibleTo then return nil end

        local otherDelta = b2 - b1
        return
            a1 + delta * visibleFrom,
            b1 + otherDelta * visibleFrom,
            a1 + delta * visibleTo,
            b1 + otherDelta * visibleTo
    end

    x1, y1, x2, y2 = clipAxis(x1, y1, x2, y2, 0, PLOT_WIDTH)
    if not x1 then return nil end

    -- Swap axes to reuse the same exact clipping operation for the
    -- TOPLEFT-relative y range [-PLOT_HEIGHT, 0].
    y1, x1, y2, x2 = clipAxis(y1, x1, y2, x2, -PLOT_HEIGHT, 0)
    if not y1 then return nil end
    return x1, y1, x2, y2
end

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

local function Sign(v)
    if v > 0 then return 1 end
    if v < 0 then return -1 end
    return 0
end

-- Shape-preserving (Fritsch-Carlson) PCHIP tangents for a strictly
-- increasing data-space (days, value) sequence, given its precomputed
-- secant interval widths h[i] = x[i+1]-x[i] and slopes
-- delta[i] = (y[i+1]-y[i])/h[i], for i = 1..n-1. Returns one tangent m[i]
-- per point (i = 1..n). This is what guarantees the interpolated curve
-- never overshoots or reverses direction between two real measurements —
-- no downstream screen-space clamp is needed. n==2 collapses both
-- endpoints onto the single secant, which makes the Hermite result exactly
-- linear.
local function PchipTangents(h, delta, n)
    local m = {}
    if n == 2 then
        m[1] = delta[1]
        m[2] = delta[1]
        return m
    end

    for i = 2, n - 1 do
        local d1, d2 = delta[i - 1], delta[i]
        if d1 == 0 or d2 == 0 or Sign(d1) ~= Sign(d2) then
            m[i] = 0
        else
            local w1 = 2 * h[i] + h[i - 1]
            local w2 = h[i] + 2 * h[i - 1]
            m[i] = (w1 + w2) / (w1 / d1 + w2 / d2)
        end
    end

    m[1] = ((2 * h[1] + h[2]) * delta[1] - h[1] * delta[2]) / (h[1] + h[2])
    if Sign(m[1]) ~= Sign(delta[1]) then
        m[1] = 0
    elseif Sign(delta[1]) ~= Sign(delta[2]) and math.abs(m[1]) > 3 * math.abs(delta[1]) then
        m[1] = 3 * delta[1]
    end

    m[n] = ((2 * h[n - 1] + h[n - 2]) * delta[n - 1] - h[n - 1] * delta[n - 2]) / (h[n - 1] + h[n - 2])
    if Sign(m[n]) ~= Sign(delta[n - 1]) then
        m[n] = 0
    elseif Sign(delta[n - 1]) ~= Sign(delta[n - 2]) and math.abs(m[n]) > 3 * math.abs(delta[n - 1]) then
        m[n] = 3 * delta[n - 1]
    end

    return m
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 + normalized trends adapter) ──────

-- Shared score sanitizer for every value entering a series from any of the
-- three sources (trends.lua, members.lua, local observation): malformed
-- input is rejected independently of everything else about that entry, and
-- a negative value can never sneak into the y-axis range. A valid zero is
-- kept.
local function ValidScore(v)
    local n = tonumber(v)
    if not n then return nil end
    if n ~= n then return nil end   -- NaN
    if n < 0 then return nil end
    return n
end

-- Returns a sorted list of { name, class_, snapshots } for every crew
-- character with at least one plottable point from exact trend history or
-- TOAST.ResolveCharacterScore's best-known current score (members.lua/
-- leaderboard.lua/local observation), or nil + a reason string
-- ("no-identity" / "no-crew" / "no-data") for the empty state to use. A
-- character with no trends.lua history yet can still produce a one-point
-- series from resolver data alone.
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
        -- 1. Exact normalized trend history. The adapter already resolves
        -- snapshot references, validates values, and sorts chronologically.
        local trendSnaps = {}
        local rawTrend = TOAST.GetCharacterTrend and TOAST.GetCharacterTrend(m.name, m.realm) or {}
        if type(rawTrend) == "table" then
            for i, snap in ipairs(rawTrend) do
                if type(snap) == "table" and type(snap.date) == "string"
                    and type(snap.days) == "number" then
                    local rio = ValidScore(snap.rio)
                    if rio then
                        tinsert(trendSnaps, {
                            week = snap.date,
                            days = snap.days,
                            pointKey = "trend:" .. tostring(snap.snapshotID or snap.snapshot or i),
                            rio = rio,
                            source = "trends",
                            observedAt = snap.timestamp,
                            snapshotAt = snap.snapshotAt,
                        })
                    end
                end
            end
        end

        local latestTrendDays
        if #trendSnaps > 0 then
            latestTrendDays = trendSnaps[#trendSnaps].days
        end

        -- 2/3. The one best-known "current" score (members.lua/leaderboard.
        -- lua/local observation, already precedence-resolved) — see
        -- TOAST.ResolveCharacterScore in scores.lua, the single owner of
        -- that selection logic now.
        local resolved = TOAST.ResolveCharacterScore and TOAST.ResolveCharacterScore(m.name, m.realm) or nil

        local finalSnaps = {}
        for _, s in ipairs(trendSnaps) do tinsert(finalSnaps, s) end

        -- A resolved "trends" source means the freshest known score is
        -- already the latest historical point above - nothing supplemental
        -- to add. Otherwise: ignore it if it's older than that latest
        -- historical point, replace that point if the dates match, or
        -- append exactly one new point if it's later.
        if resolved and resolved.source ~= "trends" then
            local resolvedDays = ResolvedPointDays(resolved)
            local lastIdx = #finalSnaps
            local replacesSameDate = lastIdx > 0 and finalSnaps[lastIdx].week == resolved.date
            if resolvedDays and (replacesSameDate or not (latestTrendDays and resolvedDays < latestTrendDays)) then
                local point = {
                    week = resolved.date,
                    days = resolvedDays,
                    pointKey = "current:" .. tostring(m.name) .. ":" .. tostring(m.realm)
                        .. ":" .. tostring(resolved.source) .. ":" .. tostring(resolved.date),
                    rio = resolved.score,
                    source = resolved.source,
                    observedAt = resolved.observedAt,
                    isCurrent = true,
                }
                if replacesSameDate then
                    -- A date-only generated source has no better intraday
                    -- coordinate than the exact bot snapshot it supersedes.
                    if not resolved.observedAt then point.days = finalSnaps[lastIdx].days end
                    finalSnaps[lastIdx] = point
                else
                    tinsert(finalSnaps, point)
                end
                table.sort(finalSnaps, function(a, b)
                    if a.days ~= b.days then return a.days < b.days end
                    return a.pointKey < b.pointKey
                end)
            end
        end

        if #finalSnaps > 0 then
            tinsert(series, { name = m.name, class_ = m.class_, snapshots = finalSnaps })
        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 snap.pointKey and snap.days and not seen[snap.pointKey] then
                seen[snap.pointKey] = true
                tinsert(weeks, { key = snap.pointKey, date = snap.week, days = snap.days })
            end
        end
    end
    table.sort(weeks, function(a, b)
        if a.days ~= b.days then return a.days < b.days end
        return a.key < b.key
    end)

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

local function BuildSeriesDataPoints(series)
    local points = {}
    for _, snap in ipairs(series.snapshots) do
        if snap.days then
            local last = points[#points]
            if not last or snap.days > last.days then
                tinsert(points, { days = snap.days, value = snap.rio })
            elseif snap.days == last.days then
                last.value = snap.rio
            end
        end
    end
    return points
end

-- Evaluates the same shape-preserving curve RenderSeries draws, used only
-- at viewport edges so the y-axis includes the visible value where a line
-- enters/exits between two real snapshots.
local function PchipValueAt(points, targetDays)
    local n = #points
    if n == 0 or targetDays < points[1].days or targetDays > points[n].days then return nil end
    if n == 1 then return points[1].value end

    local h, delta = {}, {}
    for i = 1, n - 1 do
        h[i] = points[i + 1].days - points[i].days
        delta[i] = (points[i + 1].value - points[i].value) / h[i]
    end
    local m = PchipTangents(h, delta, n)

    for i = 1, n - 1 do
        local p1, p2 = points[i], points[i + 1]
        if targetDays >= p1.days and targetDays <= p2.days then
            if targetDays == p1.days then return p1.value end
            if targetDays == p2.days then return p2.value end
            local t = (targetDays - p1.days) / h[i]
            local t2, t3 = t * t, t * t * t
            local h00 =  2 * t3 - 3 * t2 + 1
            local h10 =      t3 - 2 * t2 + t
            local h01 = -2 * t3 + 3 * t2
            local h11 =      t3 -     t2
            return h00 * p1.value + h10 * h[i] * m[i]
                + h01 * p2.value + h11 * h[i] * m[i + 1]
        end
    end
end

-- Without a persistent character selection, the axis folds in every
-- visible crew value. With a selection, only that character's visible
-- measurements represent the crew. In both modes, every explicitly
-- checked benchmark/forecast (including valid low/high) also contributes
-- so a reference line the user turned on can never sit outside the scope.
local function CalculateYAxis(series, benchmarkSeries, forecastSeries, minDays, maxDays)
    local lo, hi

    local function include(value)
        if value == nil then return end
        if not lo or value < lo then lo = value end
        if not hi or value > hi then hi = value end
    end

    local function includeSeriesViewport(seriesEntry)
        for _, snap in ipairs(seriesEntry.snapshots) do
            if snap.days and snap.days >= minDays and snap.days <= maxDays then
                include(snap.rio)
            end
        end
        local points = BuildSeriesDataPoints(seriesEntry)
        include(PchipValueAt(points, minDays))
        include(PchipValueAt(points, maxDays))
    end

    local selected = Trends.selectedSeriesIndex and series[Trends.selectedSeriesIndex] or nil
    local firstSeries = selected and Trends.selectedSeriesIndex or 1
    local lastSeries = selected and Trends.selectedSeriesIndex or #series
    for si = firstSeries, lastSeries do
        includeSeriesViewport(series[si])
    end

    -- A preset can exclude an alt whose history stopped earlier. Keeping
    -- that now-invisible series selected would produce an empty 0-500
    -- axis, so fall back to the normal all-series view immediately.
    if selected and not lo then
        selected = nil
        Trends.selectedSeriesIndex = nil
        for _, s in ipairs(series) do
            includeSeriesViewport(s)
        end
    end

    -- Character focus excludes only the other crew series. Explicitly
    -- checked reference data remains part of the requested comparison and
    -- therefore always participates in the vertical domain.
    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 p.days >= minDays and p.days <= maxDays then
                            include(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 p.days >= minDays and p.days <= maxDays then
                                include(p.value)
                                include(p.low)
                                include(p.high)
                            end
                        end
                    end
                end
            end
        end
    end

    if not lo then return 0, 500, 100 end

    local dataRange = hi - lo
    local targetRange = math.max(50, dataRange * 1.3)
    local padding = (targetRange - dataRange) / 2
    lo = lo - padding
    hi = hi + padding

    local range = hi - lo
    local step
    if range <= 100 then step = 10
    elseif range <= 250 then step = 25
    elseif range <= 500 then step = 50
    elseif range <= 1000 then step = 100
    elseif range <= 2000 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, 106)
    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", "TOASTFontNormal")
    nameFS:SetPoint("TOPLEFT", tt, "TOPLEFT", 10, -8)
    tt.nameFS = nameFS

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

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

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

    local sourceFS = tt:CreateFontString(nil, "OVERLAY", "TOASTFontHighlightSmall")
    sourceFS:SetPoint("TOPLEFT", weeklyFS, "BOTTOMLEFT", 0, -4)
    sourceFS:SetTextColor(1, 1, 1, 0.5)
    tt.sourceFS = sourceFS

    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])

    if snap.isCurrent then
        tt.weekFS:SetText("Current \194\183 " .. FormatWeekLabel(snap.week))
    else
        tt.weekFS:SetText(string.format("Snapshot %d \194\183 %s",
            pointBtn.sharedWeekIdx, FormatWeekLabel(snap.week)))
    end

    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("Gain since previous: " .. FormatDelta(diff))
        SetDeltaColor(tt.weeklyFS, diff)
    else
        tt.weeklyFS:SetText("Gain since previous: \226\128\148")
        tt.weeklyFS:SetTextColor(1, 1, 1, 0.4)
    end

    local sourceText
    if snap.source == "self" then
        sourceText = "Seen in game"
        if snap.observedAt then
            sourceText = sourceText .. " \194\183 " .. date("%H:%M", snap.observedAt)
        end
    elseif snap.source == "members" then
        sourceText = "Latest roster update"
    elseif snap.source == "leaderboard" then
        sourceText = "Latest leaderboard update"
    else
        sourceText = "TOAST snapshot"
    end
    tt.sourceFS:SetText(sourceText)

    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

-- Shared character-line click hit-test (see HIT_THRESHOLD/DistanceToSegment
-- above): distance-checks the cursor against every rendered segment and
-- toggles selection on the nearest one within HIT_THRESHOLD. Always measures
-- against Trends.plot's own screen position, never the calling frame's, so
-- this reads identically whether it's invoked from the plot's own OnMouseUp
-- or delegated to from a child hit target (e.g. a historical benchmark hover
-- button) sitting on top of it — those buttons have no native click-through,
-- so they call this directly instead of the plot ever seeing the click.
local function HandlePlotClick(button)
    if button ~= "LeftButton" then return end
    local plotLeft, plotTop = Trends.plot:GetLeft(), Trends.plot:GetTop()
    if not plotLeft or not plotTop then return end

    local scale = Trends.plot: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
        RerenderChart()
    end
    -- Otherwise: clicked empty plot space, do nothing.
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.benchmarkPoints)
    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.benchmarkValueTooltip then
        Trends.benchmarkValueTooltip: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", "TOASTFontHighlightSmall")
            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 exact-time { {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
-- prefers the actual first and last visible date, thinned to ~7 labels
-- in between. A final screen-space pass removes collisions; when two labels
-- overlap, the newer/rightmost date wins so adjacent days never render on
-- top of one another.
local function RenderAxes(visibleDates, minDays, maxDays)
    local pool = Trends.pools.xLabels
    -- Several exact snapshots may share one calendar date. They remain
    -- distinct in the domain and line geometry, but one centered label per
    -- date is enough for a readable axis.
    local labelDates = {}
    for _, entry in ipairs(visibleDates) do
        local last = labelDates[#labelDates]
        if last and last.date == entry.date then
            last.lastDays = entry.days
            last.days = (last.firstDays + last.lastDays) / 2
        else
            labelDates[#labelDates + 1] = {
                date = entry.date,
                days = entry.days,
                firstDays = entry.days,
                lastDays = entry.days,
            }
        end
    end

    local dateCount = #labelDates
    local labelInterval = math.max(1, math.ceil(dateCount / 7))
    local candidates = {}
    for i, entry in ipairs(labelDates) do
        local isFirst = (i == 1)
        local isLast = (i == dateCount)
        if isFirst or isLast or (i - 1) % labelInterval == 0 then
            local x = DaysToX(entry.days, minDays, maxDays)
            local left, right
            if isFirst and not isLast then
                left, right = x, x + X_LABEL_W
            elseif isLast and not isFirst then
                left, right = x - X_LABEL_W, x
            else
                left, right = x - X_LABEL_W / 2, x + X_LABEL_W / 2
            end
            candidates[#candidates + 1] = {
                entry = entry,
                x = x,
                left = left,
                right = right,
                isFirst = isFirst,
                isLast = isLast,
            }
        end
    end

    -- Candidates are chronological and therefore left-to-right. Replace every
    -- colliding predecessor with the current candidate; this deterministically
    -- keeps the newer/rightmost label (e.g. 26 Jul instead of overlapping
    -- 25 Jul + 26 Jul). The while handles a wide edge label overlapping more
    -- than one earlier candidate.
    local visibleLabels = {}
    for _, candidate in ipairs(candidates) do
        while #visibleLabels > 0
            and candidate.left < visibleLabels[#visibleLabels].right + X_LABEL_GAP do
            table.remove(visibleLabels)
        end
        visibleLabels[#visibleLabels + 1] = candidate
    end

    local idx = 0
    for _, candidate in ipairs(visibleLabels) do
        idx = idx + 1
        local entry = candidate.entry
        local x = candidate.x
        local label = pool[idx]
        if not label then
            label = Trends.frame:CreateFontString(nil, "OVERLAY", "TOASTFontHighlightSmall")
            pool[idx] = label
        end
        label:ClearAllPoints()
        label:SetWidth(X_LABEL_W)
        -- 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 candidate.isFirst and not candidate.isLast then
            label:SetJustifyH("LEFT")
            label:SetPoint("TOPLEFT", Trends.plot, "TOPLEFT", x, -PLOT_HEIGHT - 6)
        elseif candidate.isLast and not candidate.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
    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

        -- Shared by both the single-straight-segment path (flat intervals,
        -- two-point paths) and the PCHIP sampling path below: acquires a
        -- pooled line, configures/shows it, and registers it for
        -- hover/selection emphasis (lineGroups) and click hit-testing
        -- (renderedSegments) — so neither path can drift out of sync with
        -- the other on what a "rendered segment" actually is.
        local function DrawSegment(x1, y1, x2, y2)
            x1, y1, x2, y2 = ClipSegmentToPlot(x1, y1, x2, y2)
            if not x1 then return end
            local line = AcquireLine()
            line:ClearAllPoints()
            line:SetThickness(2)
            line:SetColorTexture(color[1], color[2], color[3], 1)
            line:SetStartPoint("TOPLEFT", Trends.plot, x1, y1)
            line:SetEndPoint("TOPLEFT", Trends.plot, x2, y2)
            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 = x1, y1 = y1, x2 = x2, y2 = y2 })
        end

        -- Screen-space marker positions, one per exact snapshot.
        -- indexByWeek now indexes unique point keys, not calendar dates.
        local pts = {}
        for i, snap in ipairs(snaps) do
            local weekIdx = indexByWeek[snap.pointKey]
            local days = snap.days
            if weekIdx and days then
                pts[i] = { x = DaysToX(days, minDays, maxDays), y = ValueToY(snap.rio, axisMin, axisMax), weekIdx = weekIdx }
            end
        end

        -- Flat, chronological (exact timestamp, value) path in real
        -- data-space. Same-day snapshots remain separate. A duplicate exact
        -- timestamp replaces the earlier entry defensively so h[i] stays
        -- strictly positive and PCHIP never divides by zero.
        local dataPts = BuildSeriesDataPoints(s)

        local n = #dataPts
        if n >= 2 then
            local h, delta = {}, {}
            for i = 1, n - 1 do
                h[i] = dataPts[i + 1].days - dataPts[i].days
                delta[i] = (h[i] > 0) and ((dataPts[i + 1].value - dataPts[i].value) / h[i]) or 0
            end

            local m = PchipTangents(h, delta, n)

            for i = 1, n - 1 do
                local p1, p2 = dataPts[i], dataPts[i + 1]
                local sx1, sy1 = DaysToX(p1.days, minDays, maxDays), ValueToY(p1.value, axisMin, axisMax)
                local sx2, sy2 = DaysToX(p2.days, minDays, maxDays), ValueToY(p2.value, axisMin, axisMax)

                if n == 2 or delta[i] == 0 then
                    -- A two-point path is exactly linear by construction
                    -- (PchipTangents ties both tangents to the single
                    -- secant), and an exactly flat measurement pair has
                    -- nothing to curve — either way sampling would add
                    -- nothing but risk the rasterizer seam a flat/straight
                    -- line split across several short Line regions can
                    -- show, so draw the whole interval as one segment.
                    DrawSegment(sx1, sy1, sx2, sy2)
                else
                    local hi, m1, m2 = h[i], m[i], m[i + 1]
                    local sampleX, sampleY = sx1, sy1
                    -- Do not split a short on-screen interval into
                    -- subpixel Line regions: WoW can drop those fragments
                    -- and leave a visible gap even though both endpoints
                    -- are valid. The cap only affects sampling density;
                    -- longer intervals still use the full PCHIP curve and
                    -- every real endpoint remains exact.
                    local screenLength = math.sqrt((sx2 - sx1) * (sx2 - sx1) + (sy2 - sy1) * (sy2 - sy1))
                    local smoothSteps = math.min(
                        SMOOTH_STEPS,
                        math.max(1, math.floor(screenLength / MIN_SMOOTH_SEGMENT_PX))
                    )
                    for step = 1, smoothSteps do
                        local nx, ny
                        if step == smoothSteps then
                            -- Force the last sample of every interval onto
                            -- the exact next real point so adjoining
                            -- intervals share one identical coordinate
                            -- instead of drifting apart from float rounding.
                            nx, ny = sx2, sy2
                        else
                            local t = step / smoothSteps
                            local t2, t3 = t * t, t * t * t
                            local h00 =  2 * t3 - 3 * t2 + 1
                            local h10 =      t3 - 2 * t2 + t
                            local h01 = -2 * t3 + 3 * t2
                            local h11 =      t3 -     t2
                            local day = p1.days + t * hi
                            local value = h00 * p1.value + h10 * hi * m1 + h01 * p2.value + h11 * hi * m2
                            nx, ny = DaysToX(day, minDays, maxDays), ValueToY(value, axisMin, axisMax)
                        end
                        DrawSegment(sampleX, sampleY, nx, ny)
                        sampleX, sampleY = nx, ny
                    end
                end
            end
        end

        for i, snap in ipairs(snaps) do
            local p = pts[i]
            if p and snap.days >= minDays and snap.days <= maxDays
                and snap.rio >= axisMin and snap.rio <= axisMax then
                pointIdx = pointIdx + 1
                local pt = pointPool[pointIdx]
                if not pt then
                    pt = CreateFrame("Button", nil, Trends.plot)
                    pt:SetSize(12, 12)
                    -- Above the historical benchmark hover targets' frame
                    -- level (Trends.plot's own + 1 — see RenderBenchmarkPoints)
                    -- so an exact overlap still resolves to the crew point's
                    -- own tooltip, not the benchmark's.
                    pt:SetFrameLevel(Trends.plot:GetFrameLevel() + 2)
                    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", "TOASTFontHighlight")
            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", "TOASTFontHighlight")
            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", "TOASTFontHighlight")
            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
                RerenderChart()
            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", "TOASTFontNormalLarge")
    titleFS:SetPoint("CENTER", f, "CENTER", 0, 10)
    titleFS:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
    f.titleFS = titleFS

    local subFS = f:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    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.48
local HOVER_LABEL_ALPHA = 0.90
local CHECKED_BORDER_ALPHA = 0.78
local CHECKED_HOVER_BORDER_ALPHA = 0.90  -- checked resting -> hover: brighten, don't restyle
local UNAVAILABLE_BORDER_ALPHA = 0.10
local UNAVAILABLE_LABEL_ALPHA = 0.30

-- Checked state uses the toggle's own benchmark color so the pill visibly
-- maps to its graph line; label text itself stays neutral.
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] * 0.18, c[2] * 0.18, c[3] * 0.18, 0.98)
        toggle.swatch:SetColorTexture(c[1], c[2], c[3], 1)
        toggle.labelFS:SetAlpha(1)
    else
        local c = toggle.color or GOLD
        toggle.border:SetColorTexture(1, 1, 1, 0.20)
        toggle.fillBg:SetColorTexture(0.07, 0.08, 0.09, 0.98)
        toggle.swatch:SetColorTexture(c[1], c[2], c[3], 0.58)
        toggle.labelFS:SetAlpha(0.68)
    end
end

-- Hover only brightens the current resting state; it never changes whether
-- the benchmark is selected.
-- 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
        local c = toggle.color or GOLD
        toggle.border:SetColorTexture(1, 1, 1, HOVER_BORDER_ALPHA)
        toggle.fillBg:SetColorTexture(0.11, 0.12, 0.13, 0.98)
        toggle.swatch:SetColorTexture(c[1], c[2], c[3], 0.90)
        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
        local c = toggle.color or GOLD
        toggle.checked = false
        toggle.border:SetColorTexture(1, 1, 1, UNAVAILABLE_BORDER_ALPHA)
        toggle.fillBg:SetColorTexture(0.05, 0.06, 0.07, 0.96)
        toggle.swatch:SetColorTexture(c[1], c[2], c[3], 0.20)
        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", "TOASTFontNormal")
    titleFS:SetPoint("TOPLEFT", tt, "TOPLEFT", 10, -8)
    titleFS:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
    tt.titleFS = titleFS

    local bodyFS = tt:CreateFontString(nil, "OVERLAY", "TOASTFontHighlightSmall")
    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 compact pill with a benchmark-colored line swatch, optional prefix icon,
-- and readable label. The full pill is the hit target; selected state uses a
-- colored border/background instead of relying on a tiny checkbox. 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), visibly linking the
-- control to its graph line while the label itself 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(BENCHMARK_PILL_H)
    hit:EnableMouse(true)

    -- The full-size background is the border; the inset fill leaves a crisp
    -- one-pixel ring without depending on a Blizzard button template.
    local border = hit:CreateTexture(nil, "BACKGROUND")
    border:SetAllPoints()

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

    local swatch = hit:CreateTexture(nil, "ARTWORK")
    swatch:SetSize(BENCHMARK_SWATCH_W, 3)
    swatch:SetPoint("LEFT", hit, "LEFT", BENCHMARK_PILL_PAD_X, 0)

    local labelFS = hit:CreateFontString(nil, "OVERLAY", "TOASTFontHighlightSmall")
    labelFS:SetText(labelText)

    -- swatch -> [prefix icon ->] label, with enough horizontal padding that
    -- every control reads as a button even while unchecked.
    local totalWidth = BENCHMARK_PILL_PAD_X + BENCHMARK_SWATCH_W + BENCHMARK_SWATCH_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", swatch, "RIGHT", BENCHMARK_SWATCH_GAP, 0)
        labelFS:SetPoint("LEFT", prefixIconTex, "RIGHT", BENCHMARK_LABEL_GAP, 0)
        totalWidth = totalWidth + BENCHMARK_PREFIX_ICON_SIZE + BENCHMARK_LABEL_GAP
    else
        labelFS:SetPoint("LEFT", swatch, "RIGHT", BENCHMARK_SWATCH_GAP, 0)
    end

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

    local toggle = {
        checked = false,
        available = true,  -- corrected by UpdateBenchmarkAvailability on first refresh
        color = color,
        label = labelText,
        button = hit,
        border = border,
        fillBg = fillBg,
        swatch = swatch,
        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", tooltipLabel = "SMN 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 exact-time { {date=, days=}, ... } spanning crew snapshots,
-- 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 full horizontal data domain (before the
-- selected fixed viewport is applied) and the x-axis labels, 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, exactDays)
        local days = exactDays or DateStringToDays(dateStr)
        if days and not seen[days] then
            seen[days] = true
            tinsert(dates, { date = dateStr, days = days })
        end
    end

    for _, point in ipairs(weeks) do
        addDate(point.date, point.days)
    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 function ComputeViewportDomain(visibleDates)
    local fullMin, fullMax = ComputeDateDomain(visibleDates)
    local preset = FindViewportPreset(Trends.viewportPresetKey)
    if not preset or not preset.days or not Trends.viewportAvailability[preset.key]
        or not Trends.historyMaxDays then
        return fullMin, fullMax
    end

    -- Presets describe historical crew time ending at the latest real
    -- crew observation. A checked forecast may still extend fullMax to
    -- the right; it never counts toward preset availability or consumes
    -- days from the selected historical period.
    return Trends.historyMaxDays - preset.days, fullMax
end

local function FilterDatesToViewport(dates, minDays, maxDays)
    local visible = {}
    for _, entry in ipairs(dates) do
        if entry.days >= minDays and entry.days <= maxDays then
            tinsert(visible, entry)
        end
    end
    return visible
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 x1, y1, x2, y2 = ClipSegmentToPlot(
                            DaysToX(p1.days, minDays, maxDays), ValueToY(p1.value, axisMin, axisMax),
                            DaysToX(p2.days, minDays, maxDays), ValueToY(p2.value, axisMin, axisMax)
                        )
                        if x1 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, x1, y1)
                            line:SetEndPoint("TOPLEFT", Trends.plot, x2, y2)
                            line:Show()
                        end
                    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)
    x1, y1, x2, y2 = ClipSegmentToPlot(x1, y1, x2, y2)
    if not x1 then return end
    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 BENCHMARK_VALUE_TOOLTIP_HEIGHT_NO_RANGE = 90
local BENCHMARK_VALUE_TOOLTIP_HEIGHT_WITH_RANGE = 108  -- tall enough for the extra rangeFS line

-- Dedicated restrained tooltip shared by historical AND forecast benchmark
-- hover targets (never GameTooltip, never the crew ShowPointTooltip —
-- separate frame, separate pool, so none of the three can interfere with
-- one another). Title is def.tooltipLabel or def.label (e.g. "SMN AVG"
-- instead of the compact toggle's "AVG"), then formatted date, then either
-- "Actual: <value>" (historical, isForecast == false) or "Projected:
-- <value>" plus rangeFS ("Estimated range: <low>-<high>") shown only when
-- isForecast and the point has a valid range (see ShowBenchmarkValueTooltip).
-- Never surfaces model metadata (selected_model, validation_mae,
-- interval_method, ...) — only date/value/optional range.
local function EnsureBenchmarkValueTooltip()
    if Trends.benchmarkValueTooltip then return end

    local tt = CreateFrame("Frame", "TOASTTrendsBenchmarkValueTooltip", UIParent, "BackdropTemplate")
    Mixin(tt, BackdropTemplateMixin)
    tt:SetSize(200, BENCHMARK_VALUE_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", "TOASTFontNormal")
    nameFS:SetPoint("TOPLEFT", tt, "TOPLEFT", 10, -8)
    tt.nameFS = nameFS

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

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

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

    Trends.benchmarkValueTooltip = tt
end

local function HideBenchmarkValueTooltip()
    if Trends.benchmarkValueTooltip then Trends.benchmarkValueTooltip:Hide() end
end

-- anchorBtn carries defRef/pointRef set by its owning render function on
-- every render (never captured from a creation-time closure — pooled
-- buttons get new references on later renders). isForecast selects
-- "Actual" vs "Projected" wording and whether the range row can show.
local function ShowBenchmarkValueTooltip(anchorBtn, isForecast)
    EnsureBenchmarkValueTooltip()
    local tt = Trends.benchmarkValueTooltip
    local def = anchorBtn.defRef
    local point = anchorBtn.pointRef
    local color = def.color

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

    if isForecast then
        tt.valueFS:SetText("Projected: " .. FormatNumber(point.value))
    else
        tt.valueFS:SetText("Actual: " .. FormatNumber(point.value))
    end

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

    tt:ClearAllPoints()
    tt:SetPoint("BOTTOM", anchorBtn, "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 shared 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
                if p.days >= minDays and p.days <= maxDays
                    and p.value >= axisMin and p.value <= axisMax then
                    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) ShowBenchmarkValueTooltip(self, true) end)
                        btn:SetScript("OnLeave", function() HideBenchmarkValueTooltip() 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
    end

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

-- Invisible ~12x12 hover hit targets for HISTORICAL benchmark points, one
-- per valid point of every currently checked+available benchmark (no
-- visible marker — same "no point markers" rule as RenderForecastPoints).
-- Entirely separate pool/frame group from crew pointGroups and the
-- forecast target pool, so historical hover never touches character
-- selection or forecast-band focus (Trends.lineGroups/pointGroups,
-- Trends.hoveredBenchmarkKey are untouched here). Skips unchecked or
-- unavailable benchmarks outright, same eligibility check RenderBenchmarks
-- itself uses. Sits one frame level above Trends.plot but below crew
-- series points (see RenderSeries) so an exact overlap still resolves to
-- the crew point's own tooltip, and forwards clicks to HandlePlotClick —
-- these buttons have no native click-through, so a click landing on one
-- would otherwise never reach the plot's own line-selection hit-test.
local function RenderBenchmarkPoints(benchmarkSeries, minDays, maxDays, axisMin, axisMax)
    local pool = Trends.pools.benchmarkPoints
    local idx = 0

    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 p.days >= minDays and p.days <= maxDays
                        and p.value >= axisMin and p.value <= axisMax then
                        idx = idx + 1
                        local btn = pool[idx]
                        if not btn then
                            btn = CreateFrame("Button", nil, Trends.plot)
                            btn:SetSize(12, 12)
                            btn:SetFrameLevel(Trends.plot:GetFrameLevel() + 1)
                            btn:SetScript("OnEnter", function(self) ShowBenchmarkValueTooltip(self, false) end)
                            btn:SetScript("OnLeave", function() HideBenchmarkValueTooltip() end)
                            btn:SetScript("OnMouseUp", function(self, button) HandlePlotClick(button) 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
        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 FORECAST_BAND_ALPHA = 0.14
local FORECAST_BAND_STRIP_WIDTH = 2  -- px, approximate sampling width per vertical strip
-- Screen-space visibility floor only: a narrow-but-real empirical range
-- (e.g. 0.7-1.3px on the current plot) would otherwise render as an
-- invisible sliver. This never touches the low/high values themselves —
-- CalculateYAxis and tooltips still read the exact numbers — and it never
-- applies to a genuine zero-width interval (low == high), which stays 0px.
local FORECAST_BAND_MIN_HEIGHT = 3

-- 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 visibleFrom = math.max(0, (0 - x1) / width)
    local visibleTo = math.min(1, (PLOT_WIDTH - x1) / width)
    if visibleFrom > visibleTo then return end

    local originalX1 = x1
    local highDelta = highY2 - highY1
    local lowDelta = lowY2 - lowY1
    local originalHighY1, originalLowY1 = highY1, lowY1
    x1 = originalX1 + width * visibleFrom
    x2 = originalX1 + width * visibleTo
    highY1 = originalHighY1 + highDelta * visibleFrom
    highY2 = originalHighY1 + highDelta * visibleTo
    lowY1 = originalLowY1 + lowDelta * visibleFrom
    lowY2 = originalLowY1 + lowDelta * visibleTo
    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 and topY >= -PLOT_HEIGHT and botY <= 0 then
                topY = math.min(0, topY)
                botY = math.max(-PLOT_HEIGHT, botY)
                local realHeight = topY - botY
                if realHeight < FORECAST_BAND_MIN_HEIGHT then
                    local mid = (topY + botY) / 2
                    topY = mid + FORECAST_BAND_MIN_HEIGHT / 2
                    botY = mid - FORECAST_BAND_MIN_HEIGHT / 2
                    if topY > 0 then
                        botY = botY - topY
                        topY = 0
                    end
                    if botY < -PLOT_HEIGHT then
                        topY = topY + (-PLOT_HEIGHT - botY)
                        botY = -PLOT_HEIGHT
                    end
                end
                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 = DaysToX(p1.days, Trends.minDays, Trends.maxDays)
                        local x2 = 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", "TOASTFontHighlightSmall")
    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 or boundaryDays < minDays or boundaryDays > maxDays 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.benchmarkValueTooltip then Trends.benchmarkValueTooltip:Hide() end
    if not Trends.series then return end

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

    local domainDates = BuildVisibleDates(Trends.weeks, benchmarkSeries, forecastSeries)
    Trends.minDays, Trends.maxDays = ComputeViewportDomain(domainDates)
    local visibleDates = FilterDatesToViewport(domainDates, Trends.minDays, Trends.maxDays)

    local axisMin, axisMax, step = CalculateYAxis(
        Trends.series, benchmarkSeries, forecastSeries, Trends.minDays, Trends.maxDays
    )
    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)
    RenderBenchmarkPoints(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 BuildViewportControls(frame)
    local buttons = {}

    for _, preset in ipairs(VIEWPORT_PRESETS) do
        local presetKey = preset.key
        local button = CreateFrame("Button", nil, frame)
        button:SetHeight(18)

        local labelFS = button:CreateFontString(nil, "OVERLAY", "TOASTFontHighlightSmall")
        labelFS:SetPoint("TOP", button, "TOP", 0, 0)
        labelFS:SetText(preset.label)
        button.labelFS = labelFS

        local underline = button:CreateTexture(nil, "ARTWORK")
        underline:SetPoint("BOTTOMLEFT", button, "BOTTOMLEFT", 0, 0)
        underline:SetPoint("BOTTOMRIGHT", button, "BOTTOMRIGHT", 0, 0)
        underline:SetHeight(VIEWPORT_UNDERLINE_H)
        underline:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 1)
        underline:Hide()
        button.underline = underline

        local width = math.ceil(labelFS:GetStringWidth()) + 4
        button:SetWidth(width)

        button:SetScript("OnEnter", function(self)
            self.hovered = true
            RefreshViewportControlVisuals()
        end)
        button:SetScript("OnLeave", function(self)
            self.hovered = false
            RefreshViewportControlVisuals()
        end)
        button:SetScript("OnMouseUp", function(_, mouseButton)
            if mouseButton == "LeftButton" then
                SelectViewportPreset(presetKey)
            end
        end)

        Trends.viewportButtons[presetKey] = button
        tinsert(buttons, button)
    end

    local previous
    for i, button in ipairs(buttons) do
        if i == 1 then
            button:SetPoint("BOTTOMLEFT", Trends.plot, "TOPLEFT", 0, 5)
        else
            button:SetPoint("LEFT", previous, "RIGHT", VIEWPORT_CONTROL_GAP, 0)
        end
        previous = button
    end

    RefreshViewportControlVisuals()
end

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. See HandlePlotClick for the shared hit-test body —
    -- historical benchmark hover targets delegate to the same function.
    Trends.plot:EnableMouse(true)
    Trends.plot:EnableMouseWheel(true)
    Trends.plot:SetScript("OnMouseUp", function(self, button)
        HandlePlotClick(button)
    end)
    Trends.plot:SetScript("OnMouseWheel", function(_, delta)
        if not Trends.series or delta == 0 then return end
        local now = GetTime()
        if now - Trends.lastViewportWheelAt < VIEWPORT_WHEEL_THROTTLE then return end
        if StepViewportPreset(delta) then
            Trends.lastViewportWheelAt = now
        end
    end)

    BuildViewportControls(frame)

    BuildEmptyState(frame)

    Trends.helperFS = frame:CreateFontString(nil, "OVERLAY", "TOASTFontHighlightSmall")
    Trends.helperFS:SetPoint("BOTTOM", Trends.plot, "TOP", 0, 6)
    Trends.helperFS:SetTextColor(1, 1, 1, 0.4)
    Trends.helperFS:SetText("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/SINCE LAST 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 compareLabelFS = frame:CreateFontString(nil, "OVERLAY", "TOASTFontHighlightSmall")
    compareLabelFS:SetText("COMPARE WITH")
    compareLabelFS:SetTextColor(1, 1, 1, 0.45)

    local totalToggleWidth = compareLabelFS:GetStringWidth() + BENCHMARK_SECTION_LABEL_GAP
    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)

    compareLabelFS:SetPoint("LEFT", Trends.legendScroll, "TOPLEFT", toggleGroupStartX,
        TOGGLE_ROW_Y + BENCHMARK_PILL_H / 2)

    local previousControl
    for i, toggle in ipairs(toggles) do
        if i == 1 then
            toggle.button:SetPoint("BOTTOMLEFT", Trends.legendScroll, "TOPLEFT",
                toggleGroupStartX + compareLabelFS:GetStringWidth() + BENCHMARK_SECTION_LABEL_GAP,
                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", "TOASTFontHighlightSmall")
    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", "TOASTFontHighlightSmall")
    gainHeaderFS:SetPoint("BOTTOMRIGHT", Trends.legendScroll, "TOPRIGHT", -LEGEND_RIGHT_PAD, COLUMN_ROW_Y)
    gainHeaderFS:SetWidth(LEGEND_GAIN_COL_W)
    gainHeaderFS:SetJustifyH("RIGHT")
    gainHeaderFS:SetText("SINCE LAST")
    gainHeaderFS:SetTextColor(1, 1, 1, 0.4)

    local rioHeaderFS = frame:CreateFontString(nil, "OVERLAY", "TOASTFontHighlightSmall")
    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", "TOASTFontHighlightSmall")
    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", "TOASTFontNormalHuge")
    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", "TOASTFontNormalSmall")
    subFS:SetPoint("TOPLEFT", titleFS, "BOTTOMLEFT", 0, -4)
    subFS:SetJustifyH("LEFT")
    subFS:SetTextColor(1, 1, 1, 0.45)
    subFS:SetText("Totally Overengineered Aquatic Support Tech\nMy 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)
    -- Trends is the focused analysis view and must stay above both the
    -- HIGH-strata Leaderboard and DIALOG-strata Dashboard when windows
    -- are opened alongside each other.
    frame:SetFrameStrata("FULLSCREEN")
    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

    if TOAST.RefreshOwnScore then TOAST.RefreshOwnScore() end

    local series, reason = CollectCrewSeries()
    Trends.series = series
    if not series then
        UpdateDynamicLayout(0)  -- compact ~2-row default, same clamp as a real crew
        for _, button in pairs(Trends.viewportButtons) do button:Hide() end
        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)
    UpdateViewportAvailability(Trends.weeks)

    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

    local generatedAt = TOAST.GetTrendsGeneratedAt and TOAST.GetTrendsGeneratedAt() or nil
    Trends.footerFS:SetText("Data updated: " .. (generatedAt or "unknown"))
end

local function ShowTrends()
    if TOAST.CanOpenMajorWindow and not TOAST.CanOpenMajorWindow() then return end
    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
