contents/ui/CostPresentation.js

.pragma library
.import "Guards.js" as Guards

// Cost and spend presentation: number formatting, chart geometry, and the row
// and summary builders the Usage & Spend tab and the provider cost sections
// bind to.
//
// Like ProviderNormalizer.js this module must not reach for `Qt`, `Kirigami`,
// or `i18n`. Two consequences shape the interface:
//
//   * The caller supplies the words. Every function that would otherwise call
//     `i18n` returns figures rather than sentences, so the plural rules and the
//     placeholder order stay in QML where `i18np` lives.
//   * The caller supplies the locale separators through `numberFormat`, because
//     `Qt.locale()` is not reachable from a `.pragma library` module.
//
// Everything here is side-effect free apart from `paintRoundedTopBar`, which
// draws into a caller-supplied canvas context.

var maximumCostHistoryPoints = 365
var maximumProjectRows = 128
var maximumCostTrustNoticeScopes = 128

// Locale separators, resolved once by the caller from `Qt.locale()`.
function numberFormat(groupSeparator, decimalPoint) {
    return {
        group: typeof groupSeparator === "string" ? groupSeparator : ",",
        decimal: typeof decimalPoint === "string" ? decimalPoint : "."
    }
}

function hasOwnKey(item, key) {
    return Guards.hasOwnKey(item, key)
}

function isUnsafeObjectKey(key) {
    return Guards.isUnsafeObjectKey(key)
}

function boundedText(value, limit) {
    if (value === null || value === undefined) {
        return ""
    }
    var text = String(value)
    return text.length > limit ? text.slice(0, limit) : text
}

// The metric the charts currently plot. Cost and tokens share one payload, so
// every scale, peak, and summary has to read the same switch.
function hasMetricValue(point, showsTokens) {
    if (!point) {
        return false
    }
    var value = showsTokens ? point.tokens : point.cost
    return typeof value === "number" && isFinite(value)
}

function metricValue(point, showsTokens) {
    if (!hasMetricValue(point, showsTokens)) {
        return 0
    }
    return showsTokens ? point.tokens : point.cost
}

function groupedDecimalString(fmt, value, digits) {
    var numeric = Number(value)
    if (!isFinite(numeric)) {
        return "-"
    }
    // Number.toFixed returns exponential notation past 1e21, which has no
    // fixed-point digits to group ("1e+21" would gain a bogus separator).
    if (Math.abs(numeric) >= 1e21) {
        return String(Math.abs(numeric))
    }
    var f = fmt && typeof fmt === "object" ? fmt : numberFormat()
    var groupSep = typeof f.group === "string" ? f.group : ","
    var decPoint = typeof f.decimal === "string" ? f.decimal : "."
    var parts = Math.abs(numeric).toFixed(digits).split(".")
    var whole = parts[0]
    var len = whole.length
    var grouped = whole
    if (len > 3) {
        var firstLen = len % 3 || 3
        var chunks = [whole.slice(0, firstLen)]
        for (var i = firstLen; i < len; i += 3) {
            chunks.push(whole.slice(i, i + 3))
        }
        grouped = chunks.join(groupSep)
    }
    return parts.length > 1 ? grouped + decPoint + parts[1] : grouped
}

// "Quota" is a credit balance, not money, so it prints as a bare rounded count.
// Like the money path it degrades to "-" for non-numeric input instead of
// printing "NaN".
function amountString(fmt, value, currency) {
    var code = typeof currency === "string" ? currency.trim() : ""
    if (typeof value !== "number" || !isFinite(value)) {
        return "-"
    }
    var numeric = value
    if (code === "Quota") {
        return String(Math.round(numeric))
    }
    var negative = numeric < 0
    var amount = groupedDecimalString(fmt, Math.abs(numeric), 2)
    if (code === "USD") {
        return negative ? "-$" + amount : "$" + amount
    }
    if (code.length === 0) {
        return (negative ? "-" : "") + amount
    }
    return (negative ? "-" : "") + code + " " + amount
}

function scaledTokenCount(value) {
    if (value >= 10) {
        return Number(value).toFixed(0)
    }
    return Number(value).toFixed(1).replace(/\.0$/, "")
}

// Credit balances are plain counts: a whole balance keeps no fractional part,
// while a fractional one keeps a single digit. Rounding can carry a fractional
// value across the whole boundary ("99.95" -> "100.0"), so the fractional digit
// drops when the rounded figure is whole instead of choosing the precision
// from the unrounded value.
function formatCount(fmt, value) {
    var numeric = Number(value)
    if (!isFinite(numeric)) {
        return "-"
    }
    var magnitude = Math.abs(numeric)
    var digits = magnitude >= 100 || magnitude === Math.round(magnitude) ? 0 : 1
    var text = groupedDecimalString(fmt, magnitude, digits)
    if (digits === 1) {
        var f = fmt && typeof fmt === "object" ? fmt : numberFormat()
        var decPoint = typeof f.decimal === "string" ? f.decimal : "."
        if (text.slice(-(decPoint.length + 1)) === decPoint + "0") {
            text = text.slice(0, -(decPoint.length + 1))
        }
    }
    return (numeric < 0 ? "-" : "") + text
}

function tokenCountString(tokens) {
    var value = Number(tokens)
    if (!isFinite(value)) {
        return "-"
    }
    var absValue = Math.abs(value)
    var sign = value < 0 ? "-" : ""
    // Round inside each unit before choosing it, so a value that rounds up
    // across a threshold promotes ("1M") instead of printing an overflowing
    // unit ("1000K").
    if (Math.round(absValue / 1000000) >= 1000) {
        return sign + scaledTokenCount(absValue / 1000000000) + "B"
    }
    if (Math.round(absValue / 1000) >= 1000) {
        return sign + scaledTokenCount(absValue / 1000000) + "M"
    }
    if (Math.round(absValue) >= 1000) {
        return sign + scaledTokenCount(absValue / 1000) + "K"
    }
    return Math.round(value).toString()
}

// Dense charts keep a one-pixel bar, so their drawing step must account for the
// bar width instead of the nominal point slot. Otherwise the newest bar extends
// beyond the right edge of the canvas.
function chartBarGeometry(width, count) {
    var numericWidth = Number(width)
    var safeWidth = isFinite(numericWidth) ? Math.max(0, numericWidth) : 0
    var numericCount = Math.floor(Number(count))
    var points = isFinite(numericCount) ? Math.max(1, numericCount) : 1
    var slotStep = safeWidth / points
    var gap = Math.max(0, Math.min(4, slotStep / 4))
    var barWidth = Math.max(1, slotStep - gap)

    if (barWidth > slotStep) {
        return {
            step: points > 1 ? Math.max(0, safeWidth - barWidth) / (points - 1) : 0,
            gap: gap,
            barWidth: barWidth,
            offset: 0
        }
    }

    return {
        step: slotStep,
        gap: gap,
        barWidth: barWidth,
        offset: Math.max(0, slotStep - barWidth) / 2
    }
}

// Keep the largest interactive marker fully inside the canvas at both ends.
function chartLineX(width, count, index, inset) {
    var numericWidth = Number(width)
    var safeWidth = isFinite(numericWidth) ? Math.max(0, numericWidth) : 0
    var numericCount = Math.floor(Number(count))
    var points = isFinite(numericCount) ? Math.max(1, numericCount) : 1
    var numericInset = Number(inset)
    var padding = Math.min(safeWidth / 2,
        isFinite(numericInset) ? Math.max(0, numericInset) : 0)

    if (points === 1) {
        return safeWidth / 2
    }

    var numericIndex = Math.floor(Number(index))
    var boundedIndex = isFinite(numericIndex)
        ? Math.max(0, Math.min(points - 1, numericIndex))
        : 0
    return padding + (safeWidth - padding * 2) * boundedIndex / (points - 1)
}

function chartLineIndexAt(width, count, positionX, inset) {
    var numericWidth = Number(width)
    var safeWidth = isFinite(numericWidth) ? Math.max(0, numericWidth) : 0
    var numericCount = Math.floor(Number(count))
    var points = isFinite(numericCount) ? Math.max(0, numericCount) : 0
    if (points === 0 || safeWidth === 0) {
        return -1
    }
    if (points === 1) {
        return 0
    }

    var numericInset = Number(inset)
    var padding = Math.min(safeWidth / 2,
        isFinite(numericInset) ? Math.max(0, numericInset) : 0)
    var numericPosition = Number(positionX)
    var boundedPosition = isFinite(numericPosition)
        ? Math.max(0, Math.min(safeWidth, numericPosition))
        : 0
    var drawableWidth = safeWidth - padding * 2
    var normalizedPosition = drawableWidth > 0
        ? (boundedPosition - padding) / drawableWidth
        : boundedPosition / safeWidth
    return Math.max(0, Math.min(points - 1,
        Math.round(normalizedPosition * (points - 1))))
}

function chartLineY(height, fraction, inset) {
    var numericHeight = Number(height)
    var safeHeight = isFinite(numericHeight) ? Math.max(0, numericHeight) : 0
    var numericInset = Number(inset)
    var padding = Math.min(safeHeight / 2,
        isFinite(numericInset) ? Math.max(0, numericInset) : 0)
    var numericFraction = Number(fraction)
    var boundedFraction = isFinite(numericFraction)
        ? Math.max(0, Math.min(1, numericFraction))
        : 0
    return safeHeight - padding - (safeHeight - padding * 2) * boundedFraction
}

function paintRoundedTopBar(context, x, baseline, width, height, radius) {
    var safeWidth = Math.max(0, width)
    var safeHeight = Math.max(0, height)
    var top = baseline - safeHeight
    var corner = Math.max(0, Math.min(radius, safeWidth / 2, safeHeight))

    context.beginPath()
    context.moveTo(x, baseline)
    context.lineTo(x, top + corner)
    context.quadraticCurveTo(x, top, x + corner, top)
    context.lineTo(x + safeWidth - corner, top)
    context.quadraticCurveTo(x + safeWidth, top, x + safeWidth, top + corner)
    context.lineTo(x + safeWidth, baseline)
    context.closePath()
    context.fill()
}

// Returns the peak of the currently plotted metric, so the bars keep their
// scale when the Usage & Spend tab switches between cost and tokens.
function sparklineMax(points, showsTokens) {
    var maximum = 0
    if (!Array.isArray(points)) {
        return maximum
    }
    for (var i = 0; i < points.length; i++) {
        maximum = Math.max(maximum, metricValue(points[i], showsTokens))
    }
    return maximum
}

function metricText(fmt, magnitude, currency, showsTokens) {
    return showsTokens
        ? tokenCountString(magnitude)
        : amountString(fmt, magnitude, currency || "USD")
}

function chartPoints(fmt, points, showsTokens) {
    var result = []
    if (!Array.isArray(points)) {
        return result
    }
    for (var i = 0; i < points.length; i++) {
        var point = points[i]
        if (!hasMetricValue(point, showsTokens)) {
            continue
        }
        var value = Math.max(0, metricValue(point, showsTokens))
        result.push({
            label: boundedText(point.label, 120),
            sourceIndex: i,
            value: value,
            displayValue: metricText(fmt, value, point.currency, showsTokens)
        })
    }
    return result
}

// Chart points omit unavailable amounts. Their index is not a daily-row index.
function selectedCostDay(daily, points, activeIndex) {
    if (!Array.isArray(daily) || !Array.isArray(points)
            || typeof activeIndex !== "number" || activeIndex !== Math.floor(activeIndex)
            || activeIndex < 0 || activeIndex >= points.length) {
        return null
    }
    var point = points[activeIndex]
    var sourceIndex = point && point.sourceIndex
    if (typeof sourceIndex !== "number" || sourceIndex !== Math.floor(sourceIndex)
            || sourceIndex < 0 || sourceIndex >= daily.length) {
        return null
    }
    return daily[sourceIndex] || null
}

function uniqueCostDayIndex(points, label) {
    var found = -1
    for (var i = 0; i < points.length; i++) {
        if (points[i] && points[i].label === label) {
            if (found !== -1) {
                return -1
            }
            found = i
        }
    }
    return found
}

function costDayIndexAfterRefresh(previousPoints, selectedIndex, points) {
    if (!Array.isArray(previousPoints) || !Array.isArray(points)
            || typeof selectedIndex !== "number" || selectedIndex !== Math.floor(selectedIndex)
            || selectedIndex < 0 || selectedIndex >= previousPoints.length) {
        return -1
    }
    var previous = previousPoints[selectedIndex]
    if (!previous || typeof previous.label !== "string" || previous.label.length === 0
            || uniqueCostDayIndex(previousPoints, previous.label) !== selectedIndex) {
        return -1
    }
    return uniqueCostDayIndex(points, previous.label)
}

// The newest plotted point, as a label and an already-formatted value. Returns
// null when there is nothing to summarise; the caller joins the two words.
function sparklineSummary(fmt, points, showsTokens) {
    if (!Array.isArray(points) || points.length === 0) {
        return null
    }
    var last = null
    for (var i = points.length - 1; i >= 0; i--) {
        if (hasMetricValue(points[i], showsTokens)) {
            last = points[i]
            break
        }
    }
    if (!last) {
        return null
    }
    return {
        label: last.label && last.label.length > 0 ? last.label : "",
        value: metricText(fmt, metricValue(last, showsTokens), last.currency, showsTokens)
    }
}

// Joins a money figure and a token figure with the separator the cost sections
// use. `tokensText` is the caller's already-worded token phrase, or "" to print
// the bare count.
function tokenSummary(fmt, cost, tokens, currency, tokensText) {
    var parts = []
    var costValue = Number(cost)
    var tokenValue = Number(tokens)
    if (isFinite(costValue) && costValue > 0) {
        parts.push(amountString(fmt, costValue, currency || "USD"))
    }
    if (isFinite(tokenValue) && tokenValue > 0) {
        parts.push(typeof tokensText === "string" && tokensText.length > 0
            ? tokensText
            : tokenCountString(tokenValue))
    }
    return parts.join(" · ")
}

// `entries` is an ordered list of `{ label, tokens }`. Rows with no positive
// token count are dropped rather than printed as zero.
function breakdownRows(entries) {
    var rows = []
    if (!Array.isArray(entries)) {
        return rows
    }
    for (var i = 0; i < entries.length; i++) {
        var value = Number(entries[i].tokens)
        if (!isFinite(value) || value <= 0) {
            continue
        }
        rows.push({
            label: entries[i].label,
            value: tokenCountString(value)
        })
    }
    return rows
}

// Missing amounts stay absent; measured zeroes remain visible.
function amountSummary(fmt, amounts, tokensTextFor) {
    var values = []
    if (hasMetricValue(amounts, false)) {
        values.push(amountString(fmt, amounts.cost, amounts.currency))
    }
    if (hasMetricValue(amounts, true)) {
        values.push(tokensTextFor ? tokensTextFor(amounts.tokens) : tokenCountString(amounts.tokens))
    }
    return values.length > 0 ? values.join(" · ") : "-"
}

// `tokensTextFor(tokens)` lets the caller word the token half of each summary.
function modelRows(fmt, tokenCost, tokensTextFor) {
    var rows = []
    if (!tokenCost || !Array.isArray(tokenCost.models)) {
        return rows
    }
    for (var i = 0; i < tokenCost.models.length; i++) {
        var item = tokenCost.models[i]
        rows.push({
            label: item.label,
            value: amountSummary(fmt, item, tokensTextFor)
        })
    }
    return rows
}

// The last seven days, newest first, scaled against the peak of the selected
// metric so the bar lengths match the figures beside them. A point whose label
// the payload omitted gets `fallbackLabel`.
function historyRows(fmt, tokenCost, showsTokens, fallbackLabel) {
    if (!tokenCost || !Array.isArray(tokenCost.daily) || tokenCost.daily.length === 0) {
        return []
    }

    var recentDaily = tokenCost.daily.slice(Math.max(0, tokenCost.daily.length - 7))
    var visibleDaily = []
    for (var day = 0; day < recentDaily.length; day++) {
        if (hasMetricValue(recentDaily[day], showsTokens)) {
            visibleDaily.push(recentDaily[day])
        }
    }
    var rows = []
    var maximum = sparklineMax(visibleDaily, showsTokens)
    for (var i = visibleDaily.length - 1; i >= 0; i--) {
        var item = visibleDaily[i]
        var magnitude = Math.max(0, metricValue(item, showsTokens))
        var value = tokenSummary(fmt, item.cost, item.tokens, item.currency, "")
        rows.push({
            label: item.label && item.label.length > 0 ? item.label : fallbackLabel,
            value: value.length > 0
                ? value
                : metricText(fmt, 0, item.currency, showsTokens),
            percent: maximum > 0 && magnitude > 0 ? Math.max(3, magnitude * 100 / maximum) : 0,
            isPeak: maximum > 0 && magnitude === maximum
        })
    }
    return rows
}

// The bars highlight the peak of the selected metric, so this has to name the
// same day, not the most expensive one. Returns null when nothing was spent.
function peakPoint(points, showsTokens) {
    if (!Array.isArray(points) || points.length === 0) {
        return null
    }
    var peak = null
    for (var i = 0; i < points.length; i++) {
        var magnitude = metricValue(points[i], showsTokens)
        if (!peak || magnitude > peak.magnitude) {
            peak = {
                label: points[i].label && points[i].label.length > 0 ? points[i].label : "",
                magnitude: magnitude,
                currency: points[i].currency || "USD"
            }
        }
    }
    return peak && peak.magnitude > 0 ? peak : null
}

function averageDailyValue(points, showsTokens) {
    if (!Array.isArray(points) || points.length === 0) {
        return null
    }
    var total = 0
    var count = 0
    var currency = "USD"
    for (var i = 0; i < points.length; i++) {
        if (!hasMetricValue(points[i], showsTokens)) {
            continue
        }
        total += Math.max(0, metricValue(points[i], showsTokens))
        count += 1
        if (points[i].currency) {
            currency = points[i].currency
        }
    }
    if (total <= 0) {
        return null
    }
    return { value: total / count, currency: currency }
}

function perMillionAmount(tokenCost) {
    if (!tokenCost || !tokenCost.totals) {
        return null
    }
    var cost = Number(tokenCost.totals.cost)
    var tokens = Number(tokenCost.totals.tokens)
    if (!isFinite(cost) || !isFinite(tokens) || cost <= 0 || tokens <= 0) {
        return null
    }
    return {
        value: cost * 1000000 / tokens,
        currency: tokenCost.totals.currency || "USD"
    }
}

// A cached cost snapshot belongs to the selected range only when the CLI
// answered for the same window; a stale snapshot must not be summed into a
// range the user has since changed.
function snapshotMatchesRange(tokenCost, historyDays) {
    if (!tokenCost) {
        return false
    }
    var snapshotDays = Number(tokenCost.historyDays)
    return isFinite(snapshotDays)
        && Math.floor(snapshotDays) === Math.floor(Number(historyDays))
}

// `titleFor` maps a provider ID to its display title, so the sort order matches
// what the Usage & Spend tab prints.
function spendSnapshots(tokenCosts, historyDays, titleFor) {
    var result = []
    for (var providerID in tokenCosts) {
        if (!hasOwnKey(tokenCosts, providerID)) {
            continue
        }
        var tokenCost = tokenCosts[providerID]
        if (!snapshotMatchesRange(tokenCost, historyDays)) {
            continue
        }
        result.push(tokenCost)
    }
    result.sort(function(a, b) {
        var left = titleFor ? titleFor(a.provider) : String(a.provider)
        var right = titleFor ? titleFor(b.provider) : String(b.provider)
        return left.localeCompare(right)
    })
    return result
}

// Callers supply range-matched snapshots in their displayed provider order.
// Rank within each provider, so different currencies are never compared.
function projectRows(costs, showsTokens) {
    var result = { rows: [], truncated: false }
    var items = Array.isArray(costs) ? costs : []
    for (var i = 0; i < items.length; i++) {
        var snapshot = items[i]
        var projects = snapshot && typeof snapshot === "object" ? snapshot.projects : null
        if (!projects || !Array.isArray(projects.rows)) {
            continue
        }
        result.truncated = result.truncated || projects.truncated === true
        if (result.rows.length === maximumProjectRows) {
            result.truncated = result.truncated || projects.rows.length > 0
            continue
        }
        // The slice bounds the inspected payload before filtering, so the
        // truncation check below can tell limit-driven omissions apart from
        // malformed rows that were never displayable.
        var inspectedRows = projects.rows.slice(0, maximumProjectRows)
        var rows = inspectedRows
            .filter(function(row) { return row && typeof row === "object" && !Array.isArray(row) })
        rows.sort(function(a, b) {
            var aHasValue = hasMetricValue(a, showsTokens)
            var bHasValue = hasMetricValue(b, showsTokens)
            if (aHasValue !== bHasValue) {
                return aHasValue ? -1 : 1
            }
            var difference = metricValue(b, showsTokens) - metricValue(a, showsTokens)
            var left = boundedText(a.label, 120)
            var right = boundedText(b.label, 120)
            return difference || left.localeCompare(right)
        })
        var trust = costTrustSummary([snapshot])
        for (var j = 0; j < rows.length && result.rows.length < maximumProjectRows; j++) {
            result.rows.push({
                provider: snapshot.provider,
                label: boundedText(rows[j].label, 120),
                cost: rows[j].cost,
                tokens: rows[j].tokens,
                currency: rows[j].currency,
                valueMode: trust ? trust.valueMode : "plain"
            })
        }
        result.truncated = result.truncated
            || projects.rows.length > inspectedRows.length
            || j < rows.length
    }
    return result
}

function spendCurrency(costs) {
    var items = Array.isArray(costs) ? costs : []
    // A token-only snapshot still carries a fallback currency, but it must not
    // choose which priced providers participate in the money aggregate.
    for (var i = 0; i < items.length; i++) {
        var snapshot = items[i]
        var totals = snapshot && typeof snapshot === "object" && snapshot.totals
            ? snapshot.totals : ({})
        if (hasMetricValue(totals, false)) {
            var totalsCurrency = boundedText(totals.currency || "", 12)
            if (totalsCurrency.length > 0) {
                return totalsCurrency
            }
        }
        var daily = snapshot && Array.isArray(snapshot.daily) ? snapshot.daily : []
        for (var j = 0; j < daily.length; j++) {
            if (!hasMetricValue(daily[j], false)) {
                continue
            }
            var dailyCurrency = boundedText(daily[j].currency || "", 12)
            if (dailyCurrency.length > 0) {
                return dailyCurrency
            }
        }
    }
    for (var fallbackIndex = 0; fallbackIndex < items.length; fallbackIndex++) {
        var fallbackSnapshot = items[fallbackIndex]
        var fallbackTotals = fallbackSnapshot && typeof fallbackSnapshot === "object"
            && fallbackSnapshot.totals ? fallbackSnapshot.totals : ({})
        var fallbackCurrency = boundedText(fallbackTotals.currency || "", 12)
        if (fallbackCurrency.length > 0) {
            return fallbackCurrency
        }
        var fallbackDaily = fallbackSnapshot && Array.isArray(fallbackSnapshot.daily) ? fallbackSnapshot.daily : []
        if (fallbackDaily.length > 0) {
            var firstDaily = fallbackDaily[0]
            var firstCurrency = firstDaily && typeof firstDaily === "object"
                ? firstDaily.currency : null
            return boundedText(firstCurrency || "USD", 12)
        }
    }
    return "USD"
}

function costMatchesSpendCurrency(cost, currency) {
    var totals = cost && cost.totals ? cost.totals : ({})
    return boundedText(totals.currency || currency, 12) === currency
}

// Cost figures from different currencies cannot share one aggregate. The
// caller uses this to label the selected-currency amount as a subtotal and to
// explain why token totals can cover more providers than money totals.
function spendHasMixedCostCurrencies(costs) {
    var items = Array.isArray(costs) ? costs : []
    var currency = spendCurrency(items)
    for (var i = 0; i < items.length; i++) {
        var snapshot = items[i]
        var totals = snapshot && typeof snapshot === "object" && snapshot.totals
            ? snapshot.totals : ({})
        if (hasMetricValue(totals, false)
                && boundedText(totals.currency || currency, 12) !== currency) {
            return true
        }
        var daily = snapshot && Array.isArray(snapshot.daily) ? snapshot.daily : []
        for (var j = 0; j < daily.length; j++) {
            if (hasMetricValue(daily[j], false)
                    && boundedText(daily[j].currency || "USD", 12) !== currency) {
                return true
            }
        }
    }
    return false
}

function acceptedCostCoverage(value) {
    if (value === null || typeof value !== "object" || Array.isArray(value)) {
        return null
    }
    var fields = ["priced", "unpriced", "unmetered", "estimated"]
    for (var i = 0; i < fields.length; i++) {
        var field = fields[i]
        var count = value[field]
        if (!hasOwnKey(value, field)
                || typeof count !== "number"
                || !isFinite(count)
                || Math.floor(count) !== count
                || count < 0) {
            return null
        }
    }
    return value
}

function acceptedCostSourceKind(value) {
    return typeof value === "string"
            && ["listPrice", "vendor", "mixed", "unknown"]
                .indexOf(value) !== -1
        ? value
        : ""
}

// Provenance without observed cost, token, daily, or coverage data cannot
// qualify a displayed total. Missing token totals normalize to zero, so only a
// positive token count is evidence; explicit finite cost values matter at zero.
function costTrustSnapshotHasEvidence(item) {
    if (!item) {
        return false
    }

    var totals = item.totals || ({})
    if (hasMetricValue(totals, false)
            || (hasMetricValue(totals, true) && totals.tokens > 0)) {
        return true
    }

    var trust = item.trust
    var coverage = trust !== null
            && typeof trust === "object"
            && !Array.isArray(trust)
            && hasOwnKey(trust, "coverage")
        ? acceptedCostCoverage(trust.coverage)
        : null
    if (coverage && (coverage.priced > 0
            || coverage.unpriced > 0
            || coverage.unmetered > 0
            || coverage.estimated > 0)) {
        return true
    }

    var daily = Array.isArray(item.daily) ? item.daily : []
    for (var i = 0; i < daily.length; i++) {
        if (hasMetricValue(daily[i], false)
                || (hasMetricValue(daily[i], true) && daily[i].tokens > 0)) {
            return true
        }
    }
    return false
}

// Fold normalized pricing coverage and provenance into one presentation
// decision. The caller localizes it; wire enum values never cross this seam.
// For global spend, currency eligibility deliberately matches `spendTotals()`.
// Token-only snapshots still qualify a mixed total, but cannot create a notice
// when there is no finite cost to qualify.
function costTrustSummary(costs) {
    var items = Array.isArray(costs) ? costs : []
    if (items.length === 0) {
        return null
    }

    var currency = spendCurrency(items)
    var hasEstimated = false
    var hasUnpriced = false
    var hasUnmetered = false
    var hasListPrice = false
    var hasVendor = false
    var hasMixed = false
    var hasUnknown = false
    var hasUnclassifiedSource = false
    var hasDisplayedCost = false

    for (var i = 0; i < items.length; i++) {
        var item = items[i]
        if (!costTrustSnapshotHasEvidence(item)) {
            continue
        }
        var itemHasCost = item && hasMetricValue(item.totals, false)
        if (!item || (itemHasCost && !costMatchesSpendCurrency(item, currency))) {
            continue
        }
        hasDisplayedCost = hasDisplayedCost
            || itemHasCost
        if (!hasOwnKey(item, "trust")
                || item.trust === null
                || typeof item.trust !== "object"
                || Array.isArray(item.trust)) {
            hasUnclassifiedSource = true
            continue
        }

        var coverage = hasOwnKey(item.trust, "coverage")
            ? acceptedCostCoverage(item.trust.coverage)
            : null
        if (coverage) {
            hasEstimated = hasEstimated || coverage.estimated > 0
            hasUnpriced = hasUnpriced || coverage.unpriced > 0
            hasUnmetered = hasUnmetered || coverage.unmetered > 0
        }

        var sourceKind = hasOwnKey(item.trust, "sourceKind")
            ? acceptedCostSourceKind(item.trust.sourceKind)
            : ""
        switch (sourceKind) {
        case "listPrice":
            hasListPrice = true
            break
        case "vendor":
            hasVendor = true
            break
        case "mixed":
            hasMixed = true
            break
        case "unknown":
            hasUnknown = true
            break
        default:
            hasUnclassifiedSource = true
            break
        }
    }

    var sourceKind = ""
    if (hasUnknown) {
        sourceKind = "unknown"
    } else if (hasMixed || (hasListPrice && hasVendor)) {
        sourceKind = "mixed"
    } else if (hasListPrice) {
        sourceKind = "listPrice"
    } else if (hasVendor) {
        sourceKind = "vendor"
    }

    if (hasListPrice || hasMixed) {
        hasEstimated = true
    }
    // A source reported by only part of the displayed total cannot describe
    // the whole amount. Keep all-legacy totals quiet, but make a mixed
    // classified/unclassified aggregate explicitly conservative.
    if (hasUnclassifiedSource && sourceKind.length > 0) {
        sourceKind = "unknown"
    }

    var isPartial = hasUnpriced || hasUnmetered
    if (!hasDisplayedCost
            || (!hasEstimated && !isPartial && sourceKind.length === 0)) {
        return null
    }

    return {
        level: isPartial || sourceKind === "unknown" ? "warning" : "information",
        valueMode: isPartial ? "partial"
            : (sourceKind === "unknown" ? "approximate"
            : (hasEstimated ? "estimated" : "plain")),
        sourceKind: sourceKind,
        hasEstimated: hasEstimated,
        hasUnpriced: hasUnpriced,
        hasUnmetered: hasUnmetered
    }
}

function costTrustNoticeKey(summary) {
    if (summary === null
            || typeof summary !== "object"
            || Array.isArray(summary)) {
        return ""
    }

    var sourceKind = hasOwnKey(summary, "sourceKind")
        ? acceptedCostSourceKind(summary.sourceKind)
        : ""
    var hasEstimated = hasOwnKey(summary, "hasEstimated")
        && summary.hasEstimated === true
    var hasUnpriced = hasOwnKey(summary, "hasUnpriced")
        && summary.hasUnpriced === true
    var hasUnmetered = hasOwnKey(summary, "hasUnmetered")
        && summary.hasUnmetered === true
    if (sourceKind.length === 0
            && !hasEstimated
            && !hasUnpriced
            && !hasUnmetered) {
        return ""
    }

    return [
        hasOwnKey(summary, "level") && summary.level === "warning"
            ? "warning" : "information",
        sourceKind,
        hasEstimated ? "estimated" : "exact",
        hasUnpriced ? "unpriced" : "priced",
        hasUnmetered ? "unmetered" : "metered"
    ].join("|")
}

// Keep dismissal state semantic rather than tying it to localized text or a
// freshly allocated summary object. Refreshes preserve a dismissal; a changed
// warning meaning becomes visible again, including a return to an older state.
function costTrustNoticeTransition(summary, previousState, shouldDismiss) {
    var key = costTrustNoticeKey(summary)
    var previousKey = ""
    var wasDismissed = false
    if (previousState !== null
            && typeof previousState === "object"
            && !Array.isArray(previousState)) {
        previousKey = hasOwnKey(previousState, "key")
                && typeof previousState.key === "string"
            ? previousState.key
            : ""
        wasDismissed = hasOwnKey(previousState, "dismissed")
            && previousState.dismissed === true
    }

    var dismissed = key.length > 0
        && key === previousKey
        && wasDismissed
    if (shouldDismiss === true && key.length > 0) {
        dismissed = true
    }
    return {
        key: key,
        dismissed: dismissed,
        shouldShow: key.length > 0 && !dismissed
    }
}

// The popup representation may be destroyed while the compact applet remains
// alive. Keep each surface's semantic notice state in the applet owner so a
// recreated notice does not forget that the user closed it.
function costTrustNoticeStoreTransition(summary, states, scope, shouldDismiss) {
    var safeScope = boundedText(scope, 120)
    if (safeScope.length === 0 || isUnsafeObjectKey(safeScope)) {
        safeScope = ""
    }
    var source = states !== null
            && typeof states === "object"
            && !Array.isArray(states)
        ? states
        : ({})
    var previousState = safeScope.length > 0 && hasOwnKey(source, safeScope)
        ? source[safeScope]
        : null
    var state = costTrustNoticeTransition(summary, previousState, shouldDismiss)
    var nextStates = ({})
    var copiedScopes = 0
    for (var existingScope in source) {
        if (!hasOwnKey(source, existingScope)
                || isUnsafeObjectKey(existingScope)
                || existingScope === safeScope
                || copiedScopes >= maximumCostTrustNoticeScopes - 1) {
            continue
        }
        nextStates[existingScope] = source[existingScope]
        copiedScopes += 1
    }
    var stateToStore = state
    if (state.key.length === 0
            && previousState !== null
            && typeof previousState === "object"
            && !Array.isArray(previousState)
            && hasOwnKey(previousState, "key")
            && typeof previousState.key === "string"
            && previousState.key.length > 0
            && hasOwnKey(previousState, "dismissed")
            && typeof previousState.dismissed === "boolean") {
        stateToStore = previousState
    }
    if (safeScope.length > 0 && stateToStore.key.length > 0) {
        nextStates[safeScope] = stateToStore
    }
    return { states: nextStates, state: state }
}

function spendDailyPoints(fmt, costs, showsTokens) {
    var items = Array.isArray(costs) ? costs : []
    var byDate = ({})
    var currency = spendCurrency(items)
    for (var i = 0; i < items.length; i++) {
        var snapshot = items[i]
        var daily = snapshot && Array.isArray(snapshot.daily) ? snapshot.daily : []
        for (var j = 0; j < daily.length; j++) {
            var point = daily[j]
            if (!point || typeof point !== "object" || Array.isArray(point)) {
                continue
            }
            var label = boundedText(point.label, 120)
            var pointCurrency = boundedText(point.currency || "USD", 12)
            // Mixed currencies cannot be summed as money, but token counts are
            // currency-free: filtering them would silently drop whole providers
            // from the chart.
            if (label.length === 0
                    || isUnsafeObjectKey(label)
                    || !hasMetricValue(point, showsTokens)
                    || (!showsTokens && pointCurrency !== currency)) {
                continue
            }
            if (!hasOwnKey(byDate, label)) {
                byDate[label] = { label: label, value: 0, currency: currency }
            }
            byDate[label].value += Math.max(0, metricValue(point, showsTokens))
        }
    }

    var result = []
    for (var day in byDate) {
        if (!hasOwnKey(byDate, day)) {
            continue
        }
        var item = byDate[day]
        item.displayValue = metricText(fmt, item.value, item.currency, showsTokens)
        result.push(item)
    }
    result.sort(function(a, b) { return a.label.localeCompare(b.label) })
    return result.slice(Math.max(0, result.length - maximumCostHistoryPoints))
}

// Money is summed only in the range currency. Tokens have no currency, so the
// total keeps every provider just like the token chart does.
function spendTotals(costs) {
    var items = Array.isArray(costs) ? costs : []
    if (items.length === 0) {
        return null
    }
    var currency = spendCurrency(items)
    var totalCost = 0
    var totalTokens = 0
    var hasCost = false
    var hasTokens = false
    for (var i = 0; i < items.length; i++) {
        var snapshot = items[i]
        var totals = snapshot && typeof snapshot === "object" && snapshot.totals
            ? snapshot.totals : ({})
        if (hasMetricValue(totals, true)) {
            totalTokens += Math.max(0, totals.tokens)
            hasTokens = true
        }
        if (!costMatchesSpendCurrency(items[i], currency)
                || !hasMetricValue(totals, false)) {
            continue
        }
        totalCost += Math.max(0, totals.cost)
        hasCost = true
    }
    return {
        cost: hasCost ? totalCost : null,
        tokens: hasTokens && isFinite(totalTokens) ? totalTokens : null,
        currency: currency,
        hasMixedCostCurrencies: spendHasMixedCostCurrencies(items)
    }
}

// The CLI reports whether its local log scan already covers the requested
// window; until it does, the earliest bars are short for a scan reason rather
// than a spend reason. A missing flag counts as established.
function historyStillBuilding(costs) {
    var items = Array.isArray(costs) ? costs : []
    for (var i = 0; i < items.length; i++) {
        var snapshot = items[i]
        if (snapshot && typeof snapshot === "object"
                && snapshot.historyCoverageEstablished === false) {
            return true
        }
    }
    return false
}