//! Shared formatting utilities used by both CLI and TUI.
//!
//! Number formatting (fmtIntCommas, etc.), financial helpers
//! (capitalGainsIndicator, filterNearMoney), and braille chart computation.

const std = @import("std");
const Date = @import("Date.zig");
const Money = @import("Money.zig");
const Candle = @import("models/candle.zig").Candle;
const Lot = @import("models/portfolio.zig").Lot;
const OptionContract = @import("models/option.zig").OptionContract;

const EarningsEvent = @import("models/earnings.zig").EarningsEvent;
const PerformanceResult = @import("analytics/performance.zig").PerformanceResult;

// ── Layout constants ─────────────────────────────────────────

/// Width of the symbol column in portfolio view (CLI + TUI).
pub const sym_col_width = 7;
/// Comptime format spec for left-aligned symbol column, e.g. "{s:<7}".
pub const sym_col_spec = std.fmt.comptimePrint("{{s:<{d}}}", .{sym_col_width});

/// Width of the account name column in cash section (CLI + TUI).
pub const cash_acct_width = 30;

/// Format the cash section column header: "  Account                        Balance  Note"
pub fn fmtCashHeader(buf: []u8) []const u8 {
    const w = cash_acct_width;
    var pos: usize = 0;
    @memcpy(buf[0..2], "  ");
    pos += 2;
    const acct_label = "Account";
    @memcpy(buf[pos..][0..acct_label.len], acct_label);
    @memset(buf[pos + acct_label.len ..][0 .. w - acct_label.len], ' ');
    pos += w;
    buf[pos] = ' ';
    pos += 1;
    const bal_label = "Balance";
    const bal_pad = if (bal_label.len < 14) 14 - bal_label.len else 0;
    @memset(buf[pos..][0..bal_pad], ' ');
    pos += bal_pad;
    @memcpy(buf[pos..][0..bal_label.len], bal_label);
    pos += bal_label.len;
    @memcpy(buf[pos..][0..2], "  ");
    pos += 2;
    const note_label = "Note";
    @memcpy(buf[pos..][0..note_label.len], note_label);
    pos += note_label.len;
    return buf[0..pos];
}

/// Format a cash row: "  account_name                   $1,234.56  some note"
/// Returns a slice of `buf`.
pub fn fmtCashRow(buf: []u8, account: []const u8, amount: f64, note: ?[]const u8) []const u8 {
    var money_buf: [24]u8 = undefined;
    const money = std.fmt.bufPrint(&money_buf, "{f}", .{Money.from(amount)}) catch "$?";
    const w = cash_acct_width;
    // "  {name:<w} {money:>14}  {note}"
    const prefix = "  ";
    var pos: usize = 0;
    @memcpy(buf[pos..][0..prefix.len], prefix);
    pos += prefix.len;
    const name_len = @min(account.len, w);
    @memcpy(buf[pos..][0..name_len], account[0..name_len]);
    if (name_len < w) @memset(buf[pos + name_len ..][0 .. w - name_len], ' ');
    pos += w;
    buf[pos] = ' ';
    pos += 1;
    // Right-align money in 14 chars
    const money_pad = if (money.len < 14) 14 - money.len else 0;
    @memset(buf[pos..][0..money_pad], ' ');
    pos += money_pad;
    @memcpy(buf[pos..][0..money.len], money);
    pos += money.len;
    // Append note if present
    if (note) |n| {
        if (n.len > 0) {
            @memcpy(buf[pos..][0..2], "  ");
            pos += 2;
            const note_len = @min(n.len, buf.len - pos);
            @memcpy(buf[pos..][0..note_len], n[0..note_len]);
            pos += note_len;
        }
    }
    return buf[0..pos];
}

/// Format the cash total separator line.
pub fn fmtCashSep(buf: []u8) []const u8 {
    const w = cash_acct_width;
    var pos: usize = 0;
    @memcpy(buf[0..2], "  ");
    pos += 2;
    @memset(buf[pos..][0..w], '-');
    pos += w;
    buf[pos] = ' ';
    pos += 1;
    @memset(buf[pos..][0..14], '-');
    pos += 14;
    return buf[0..pos];
}

/// Format the cash total row.
pub fn fmtCashTotal(buf: []u8, total: f64) []const u8 {
    var money_buf: [24]u8 = undefined;
    const money = std.fmt.bufPrint(&money_buf, "{f}", .{Money.from(total)}) catch "$?";
    const w = cash_acct_width;
    var pos: usize = 0;
    @memcpy(buf[0..2], "  ");
    pos += 2;
    // Right-align "TOTAL" in w chars
    const label = "TOTAL";
    const label_pad = w - label.len;
    @memset(buf[pos..][0..label_pad], ' ');
    pos += label_pad;
    @memcpy(buf[pos..][0..label.len], label);
    pos += label.len;
    buf[pos] = ' ';
    pos += 1;
    const money_pad = if (money.len < 14) 14 - money.len else 0;
    @memset(buf[pos..][0..money_pad], ' ');
    pos += money_pad;
    @memcpy(buf[pos..][0..money.len], money);
    pos += money.len;
    return buf[0..pos];
}

/// Format the illiquid section column header: "  Asset                          Value    Note"
pub fn fmtIlliquidHeader(buf: []u8) []const u8 {
    const w = cash_acct_width;
    var pos: usize = 0;
    @memcpy(buf[0..2], "  ");
    pos += 2;
    const asset_label = "Asset";
    @memcpy(buf[pos..][0..asset_label.len], asset_label);
    @memset(buf[pos + asset_label.len ..][0 .. w - asset_label.len], ' ');
    pos += w;
    buf[pos] = ' ';
    pos += 1;
    const val_label = "Value";
    const val_pad = if (val_label.len < 14) 14 - val_label.len else 0;
    @memset(buf[pos..][0..val_pad], ' ');
    pos += val_pad;
    @memcpy(buf[pos..][0..val_label.len], val_label);
    pos += val_label.len;
    @memcpy(buf[pos..][0..2], "  ");
    pos += 2;
    const note_label = "Note";
    @memcpy(buf[pos..][0..note_label.len], note_label);
    pos += note_label.len;
    return buf[0..pos];
}

/// Format an illiquid asset row: "  House                          $1,200,000.00  Primary residence"
/// Returns a slice of `buf`.
pub fn fmtIlliquidRow(buf: []u8, name: []const u8, value: f64, note: ?[]const u8) []const u8 {
    return fmtCashRow(buf, name, value, note);
}

/// Format the illiquid total separator line.
pub const fmtIlliquidSep = fmtCashSep;

/// Format the illiquid total row.
pub const fmtIlliquidTotal = fmtCashTotal;

// ── Number formatters ────────────────────────────────────────

// Money formatters live in `src/Money.zig`. Use
// `Money.from(amount)` with `{f}` for the default `$1,234.56`
// rendering, or call `.whole()` / `.trim()` / `.signed()` /
// `.padRight(N)` for variants. See `Money.zig` for the API.

/// Format an integer with commas (e.g. 1234567 -> "1,234,567").
pub fn fmtIntCommas(buf: []u8, value: u64) []const u8 {
    var tmp: [32]u8 = undefined;
    var pos: usize = tmp.len;
    var v = value;
    var digit_count: usize = 0;
    if (v == 0) {
        pos -= 1;
        tmp[pos] = '0';
    } else {
        while (v > 0) {
            if (digit_count > 0 and digit_count % 3 == 0) {
                pos -= 1;
                tmp[pos] = ',';
            }
            pos -= 1;
            tmp[pos] = '0' + @as(u8, @intCast(v % 10));
            v /= 10;
            digit_count += 1;
        }
    }
    const len = tmp.len - pos;
    if (len > buf.len) return "?";
    @memcpy(buf[0..len], tmp[pos..]);
    return buf[0..len];
}

/// Format an earlier timestamp as relative time measured against a
/// reference point ("just now", "5m ago", "2h ago", "3d ago").
///
/// Pure: takes two unix-epoch-seconds values — `before_s` (the earlier
/// event being aged) and `after_s` (the reference "now"). Caller
/// captures `after_s` via `std.Io.Timestamp.now(io, .real).toSeconds()`
/// once per frame/command and passes it in.
///
/// Returns `""` when `before_s == 0` (caller-convention for "unset").
/// Returns `"just now"` when `before_s > after_s` (clock skew or unset)
/// or when the delta is under a minute.
pub fn fmtTimeAgo(buf: []u8, before_s: i64, after_s: i64) []const u8 {
    if (before_s == 0) return "";
    const delta = after_s - before_s;
    if (delta < 0) return "just now";
    if (delta < 60) return "just now";
    if (delta < std.time.s_per_hour) {
        return std.fmt.bufPrint(buf, "{d}m ago", .{@as(u64, @intCast(@divFloor(delta, std.time.s_per_min)))}) catch "?";
    }
    if (delta < std.time.s_per_day) {
        return std.fmt.bufPrint(buf, "{d}h ago", .{@as(u64, @intCast(@divFloor(delta, std.time.s_per_hour)))}) catch "?";
    }
    return std.fmt.bufPrint(buf, "{d}d ago", .{@as(u64, @intCast(@divFloor(delta, std.time.s_per_day)))}) catch "?";
}

/// Format large numbers with T/B/M suffixes (e.g. "1.5B", "45.6M").
pub fn fmtLargeNum(val: f64) [15]u8 {
    var result: [15]u8 = @splat(' ');
    // bufPrint can only fail with NoSpaceLeft, which is impossible
    // here: a 15-byte buffer comfortably holds any "{d:.1}<X>" value
    // with X in {T,B,M} or "{d:.0}" for under-million values.
    if (val >= 1_000_000_000_000) {
        _ = std.fmt.bufPrint(&result, "{d:.1}T", .{val / 1_000_000_000_000}) catch |err| std.debug.panic("fmtLargeNum buffer too small: {t}", .{err});
    } else if (val >= 1_000_000_000) {
        _ = std.fmt.bufPrint(&result, "{d:.1}B", .{val / 1_000_000_000}) catch |err| std.debug.panic("fmtLargeNum buffer too small: {t}", .{err});
    } else if (val >= 1_000_000) {
        _ = std.fmt.bufPrint(&result, "{d:.1}M", .{val / 1_000_000}) catch |err| std.debug.panic("fmtLargeNum buffer too small: {t}", .{err});
    } else {
        _ = std.fmt.bufPrint(&result, "{d:.0}", .{val}) catch |err| std.debug.panic("fmtLargeNum buffer too small: {t}", .{err});
    }
    return result;
}

// ── Display-width helpers ────────────────────────────────────

/// Count the number of terminal display columns occupied by a
/// UTF-8 string. ASCII bytes count as 1 column; multibyte UTF-8
/// sequences (em-dash `—`, chevrons `▶`/`▼`, etc.) also count
/// as 1 column. Continuation bytes count as 0.
///
/// This is a pragmatic helper for table-layout code, not a
/// full Unicode width database — it doesn't attempt to handle
/// East-Asian wide chars, combining marks, or zero-width
/// joiners. The strings that flow through table cells in this
/// codebase are short and use only single-column glyphs.
pub fn displayCols(s: []const u8) usize {
    var cols: usize = 0;
    var i: usize = 0;
    while (i < s.len) {
        const b = s[i];
        if (b < 0x80) {
            cols += 1;
            i += 1;
        } else if (b < 0xC0) {
            // Continuation byte (shouldn't lead a sequence; skip)
            i += 1;
        } else if (b < 0xE0) {
            cols += 1;
            i += 2;
        } else if (b < 0xF0) {
            cols += 1;
            i += 3;
        } else {
            cols += 1;
            i += 4;
        }
    }
    return cols;
}

/// Right-pad `content` to `target_cols` *display columns* by
/// appending spaces in-place. `content` must already live at
/// the start of `buf`; the function appends spaces directly
/// after it and returns the padded slice. If `content` is
/// already at or beyond `target_cols`, it's returned unchanged.
/// If `buf` is too small for the padded result, returns the
/// original content unmodified.
///
/// Use when you've written a cell into a stack buffer and need
/// to pad it to a fixed-column table layout. Display-column
/// aware (so multibyte content like `—` doesn't get under-padded
/// the way Zig's byte-padding `{s: <N}` does).
pub fn padRightToCols(buf: []u8, content: []const u8, target_cols: usize) []const u8 {
    const have = displayCols(content);
    if (have >= target_cols) return content;
    const pad = target_cols - have;
    if (content.len + pad > buf.len) return content;
    @memset(buf[content.len .. content.len + pad], ' ');
    return buf[0 .. content.len + pad];
}

/// Left-pad `content` to `target_cols` display columns by writing
/// spaces *before* the content. The buffer must be large enough
/// to fit `pad + content.len` bytes; if not, returns content
/// unchanged.
///
/// Useful for right-aligning numeric cells that may contain
/// multibyte sentinels like `—`. The byte-padding `{s:>N}` form
/// under-pads multibyte content (3 bytes for `—` = 1 display
/// column, so `{s:>8}` produces a 6-col-wide cell instead of 8).
///
/// `content` is COPIED into `buf` after the leading spaces, so
/// the caller can pass any slice (it doesn't have to live in
/// `buf`). Returns the slice covering the full padded result.
pub fn padLeftToCols(buf: []u8, content: []const u8, target_cols: usize) []const u8 {
    const have = displayCols(content);
    if (have >= target_cols) return content;
    const pad = target_cols - have;
    if (pad + content.len > buf.len) return content;
    @memset(buf[0..pad], ' ');
    @memcpy(buf[pad..][0..content.len], content);
    return buf[0 .. pad + content.len];
}

/// Truncate `content` to at most `max_cols` display columns,
/// returning a borrowed sub-slice of the input. Display-column
/// aware: walks UTF-8 sequences so a multibyte glyph isn't sliced
/// mid-codepoint and so column accounting matches what the user
/// sees on screen.
///
/// When `content` already fits, returns it unchanged. When it
/// doesn't, returns the longest prefix that fits in `max_cols`
/// columns. No ellipsis or marker is appended — callers that
/// want one should append it themselves to the returned slice
/// before padding to width.
///
/// Used by both the analysis tab's sector breakdown rows and the
/// review tab's per-holding sector cells, so a sector name that
/// overflows in one place overflows the same way in the other.
pub fn truncateToCols(content: []const u8, max_cols: usize) []const u8 {
    if (max_cols == 0) return content[0..0];
    var byte_idx: usize = 0;
    var col_idx: usize = 0;
    while (byte_idx < content.len and col_idx < max_cols) {
        const b = content[byte_idx];
        const seq_len: usize = if (b < 0x80)
            1
        else if (b < 0xC0)
            // Continuation byte at the head of a sequence is malformed;
            // skip one byte to avoid an infinite loop.
            1
        else if (b < 0xE0)
            2
        else if (b < 0xF0)
            3
        else
            4;
        if (byte_idx + seq_len > content.len) break;
        byte_idx += seq_len;
        col_idx += 1;
    }
    return content[0..byte_idx];
}

// ── Percent / Sharpe / "no-data" cell formatters ─────────────
//
// Shared formatters used by the review tab + view, and any other
// surface that needs to render decimals as 1.5%-style percent
// strings, optionally with a `+` for positives, optionally with
// a trailing reweight asterisk. Multiple TUI tabs and CLI
// commands had near-identical inline copies of this logic before
// these helpers existed; keep callers using these so a future
// "render percent like X" decision lands in one place.

/// Sentinel rendered for null inputs in `fmtPctOpt`/`fmtSharpeOpt`.
/// Chosen to match the rest of the codebase's "no data" convention
/// (`—`, the em-dash). Public so layout code can compute its
/// display width consistently.
pub const no_data_sentinel: []const u8 = "—";

/// Options for `fmtPctOpt`.
pub const PctOpts = struct {
    /// Number of decimal places. Most surfaces use 1 (review table,
    /// analysis bars); a few use 2 (perf command's "ann." rows via
    /// `performance.formatReturn`). Clamped at 6 to keep the
    /// stack buffer math sane.
    decimals: u8 = 1,
    /// Prefix `+` on positive values. Used by trailing-return cells
    /// where the user reads the sign as a win/loss; not used by
    /// vol or magnitude-only fields where everything is positive
    /// by definition.
    signed: bool = false,
    /// Append a trailing `*` asterisk. Review tab uses this to mark
    /// portfolio-totals cells whose math required dropping a
    /// holding from the window.
    asterisk: bool = false,
};

/// Format an optional decimal as a percent string (e.g. `0.1234`
/// → `"12.3%"`). Returns the `no_data_sentinel` when the input
/// is null. Buffer must be at least ~16 bytes for typical inputs.
///
/// One formatter for every percent-shaped cell — review tab,
/// review CLI command, and (future) any other surface. Avoids
/// the proliferation of near-duplicate `formatPctOpt` /
/// `printSignedPct` / `fmtPercent` helpers each tab used to
/// carry around.
pub fn fmtPctOpt(buf: []u8, v: ?f64, opts: PctOpts) []const u8 {
    const val = v orelse return no_data_sentinel;
    return fmtPct(buf, val, opts);
}

/// Same as `fmtPctOpt` but takes a non-optional value.
pub fn fmtPct(buf: []u8, v: f64, opts: PctOpts) []const u8 {
    const sign: []const u8 = if (opts.signed and v >= 0) "+" else "";
    const star: []const u8 = if (opts.asterisk) "*" else "";
    return switch (opts.decimals) {
        0 => std.fmt.bufPrint(buf, "{s}{d:.0}%{s}", .{ sign, v * 100.0, star }),
        1 => std.fmt.bufPrint(buf, "{s}{d:.1}%{s}", .{ sign, v * 100.0, star }),
        2 => std.fmt.bufPrint(buf, "{s}{d:.2}%{s}", .{ sign, v * 100.0, star }),
        3 => std.fmt.bufPrint(buf, "{s}{d:.3}%{s}", .{ sign, v * 100.0, star }),
        else => std.fmt.bufPrint(buf, "{s}{d:.4}%{s}", .{ sign, v * 100.0, star }),
    } catch "?";
}

/// Options for `fmtSharpeOpt`. Sharpe is a unitless ratio so it
/// renders as a plain decimal (no `%`), typically two places.
pub const SharpeOpts = struct {
    decimals: u8 = 2,
    asterisk: bool = false,
};

/// Format an optional Sharpe ratio (or any unitless ratio) as a
/// fixed-decimal string. Returns the `no_data_sentinel` when the
/// input is null.
pub fn fmtSharpeOpt(buf: []u8, v: ?f64, opts: SharpeOpts) []const u8 {
    const val = v orelse return no_data_sentinel;
    const star: []const u8 = if (opts.asterisk) "*" else "";
    return switch (opts.decimals) {
        0 => std.fmt.bufPrint(buf, "{d:.0}{s}", .{ val, star }),
        1 => std.fmt.bufPrint(buf, "{d:.1}{s}", .{ val, star }),
        2 => std.fmt.bufPrint(buf, "{d:.2}{s}", .{ val, star }),
        else => std.fmt.bufPrint(buf, "{d:.3}{s}", .{ val, star }),
    } catch "?";
}

/// Render an em-dash (`—`) horizontally centered inside a cell
/// of `width` display columns, padded with spaces on both
/// sides. Used for table cells where the value is unavailable
/// (e.g. illiquid totals on imported-only history rows).
///
/// Writes into `buf` and returns a slice of it. `buf` should be
/// at least `width + 2` bytes — the em-dash itself is 3 bytes /
/// 1 column, so the returned byte length is `width + 2` (one
/// 3-byte multibyte sequence in a width-col cell).
pub fn centerDash(buf: []u8, width: usize) []const u8 {
    const dash = "—";
    const pad = (width -| 1) / 2;
    var pos: usize = 0;
    // Left padding (ASCII spaces — 1 byte = 1 col).
    while (pos < pad and pos < buf.len) : (pos += 1) buf[pos] = ' ';
    // Dash glyph (3 bytes, 1 col).
    if (pos + dash.len <= buf.len) {
        @memcpy(buf[pos .. pos + dash.len], dash);
        pos += dash.len;
    }
    // Trailing padding: keep filling spaces until total *display
    // columns* hits `width`. We can't reuse the `pos < width`
    // shortcut from the left-pad loop because `pos` is now in
    // bytes (post-dash) while `width` is in columns.
    return padRightToCols(buf, buf[0..pos], width);
}

// ── Date / financial helpers ─────────────────────────────────

/// Get today's date from the system clock.
///
/// Takes `io` because reading wall-clock time is a side-effecting
/// operation in Zig 0.16+. For pure date math in tests, construct
/// dates directly via `Date.fromYmd` instead.
pub fn todayDate(io: std.Io) Date {
    const ts = std.Io.Timestamp.now(io, .real).toSeconds();
    const days: i32 = @intCast(@divFloor(ts, std.time.s_per_day));
    return .{ .days = days };
}

/// English pluralization for "(N day[s])" display suffixes. Returns
/// `""` for `n == 1`, `"s"` otherwise. Used across the CLI and TUI
/// anywhere a day-count is printed inline.
pub fn dayPlural(n: i32) []const u8 {
    return if (n == 1) "" else "s";
}

/// Return "LT" if held > 1 year from `open_date` to `as_of`, "ST" otherwise.
/// Caller passes today (or a backfill date) as `as_of`.
pub fn capitalGainsIndicator(as_of: Date, open_date: Date) []const u8 {
    return if (as_of.days - open_date.days > 365) "LT" else "ST";
}

/// Return a slice view of candles on or after the given date (no allocation).
pub fn filterCandlesFrom(candles: []const Candle, from: Date) []const Candle {
    var lo: usize = 0;
    var hi: usize = candles.len;
    while (lo < hi) {
        const mid = lo + (hi - lo) / 2;
        if (candles[mid].date.lessThan(from)) {
            lo = mid + 1;
        } else {
            hi = mid;
        }
    }
    if (lo >= candles.len) return candles[0..0];
    return candles[lo..];
}

// ── Options helpers ──────────────────────────────────────────

/// Filter options contracts to +/- N strikes from ATM.
pub fn filterNearMoney(contracts: []const OptionContract, atm: f64, n: usize) []const OptionContract {
    if (atm <= 0 or contracts.len == 0) return contracts;

    var best_idx: usize = 0;
    var best_dist: f64 = @abs(contracts[0].strike - atm);
    for (contracts, 0..) |c, i| {
        const dist = @abs(c.strike - atm);
        if (dist < best_dist) {
            best_dist = dist;
            best_idx = i;
        }
    }

    const start = if (best_idx >= n) best_idx - n else 0;
    const end = @min(best_idx + n + 1, contracts.len);
    return contracts[start..end];
}

/// Check if an expiration date is a standard monthly (3rd Friday of the month).
pub fn isMonthlyExpiration(date: Date) bool {
    const dow = date.dayOfWeek(); // 0=Mon..4=Fri
    if (dow != 4) return false; // Must be Friday
    const d = date.day();
    return d >= 15 and d <= 21; // 3rd Friday is between 15th and 21st
}

/// Convert a string to title case ("TECHNOLOGY" -> "Technology", "CONSUMER CYCLICAL" -> "Consumer Cyclical").
/// Writes into a caller-provided buffer and returns the slice.
pub fn toTitleCase(buf: []u8, s: []const u8) []const u8 {
    const len = @min(s.len, buf.len);
    var capitalize_next = true;
    for (s[0..len], 0..) |c, i| {
        if (c == ' ') {
            buf[i] = ' ';
            capitalize_next = true;
        } else if (capitalize_next) {
            buf[i] = std.ascii.toUpper(c);
            capitalize_next = false;
        } else {
            buf[i] = std.ascii.toLower(c);
        }
    }
    return buf[0..len];
}

/// Format an options contract line: strike + last + bid + ask + volume + OI + IV.
pub fn fmtContractLine(buf: []u8, prefix: []const u8, c: OptionContract) []const u8 {
    var last_buf: [12]u8 = undefined;
    const last_str = if (c.last_price) |p| std.fmt.bufPrint(&last_buf, "{d:>10.2}", .{p}) catch "--" else "--";
    var bid_buf: [12]u8 = undefined;
    const bid_str = if (c.bid) |b| std.fmt.bufPrint(&bid_buf, "{d:>10.2}", .{b}) catch "--" else "--";
    var ask_buf: [12]u8 = undefined;
    const ask_str = if (c.ask) |a| std.fmt.bufPrint(&ask_buf, "{d:>10.2}", .{a}) catch "--" else "--";
    var vol_buf: [12]u8 = undefined;
    const vol_str = if (c.volume) |v| std.fmt.bufPrint(&vol_buf, "{d:>10}", .{v}) catch "--" else "--";
    var oi_buf: [10]u8 = undefined;
    const oi_str = if (c.open_interest) |oi| std.fmt.bufPrint(&oi_buf, "{d:>8}", .{oi}) catch "--" else "--";
    var iv_buf: [10]u8 = undefined;
    const iv_str = if (c.implied_volatility) |iv| std.fmt.bufPrint(&iv_buf, "{d:>6.1}%", .{iv * 100.0}) catch "--" else "--";

    return std.fmt.bufPrint(buf, "{s}{d:>10.2} {s:>10} {s:>10} {s:>10} {s:>10} {s:>8} {s:>8}", .{
        prefix, c.strike, last_str, bid_str, ask_str, vol_str, oi_str, iv_str,
    }) catch "";
}

// ── Portfolio helpers ────────────────────────────────────────

/// Sort lots: open lots first (date descending), closed lots last (date descending).
/// Pass `as_of` as the open/closed reference point; avoids needing an
/// Io in the sort callback.
pub fn lotSortFn(as_of: Date, a: Lot, b: Lot) bool {
    const a_open = a.isOpen(as_of);
    const b_open = b.isOpen(as_of);
    if (a_open and !b_open) return true; // open before closed
    if (!a_open and b_open) return false;
    return a.open_date.days > b.open_date.days; // newest first
}

/// Sort lots by maturity date (earliest first). Lots without maturity sort last.
pub fn lotMaturitySortFn(_: void, a: Lot, b: Lot) bool {
    const ad = if (a.maturity_date) |d| d.days else std.math.maxInt(i32);
    const bd = if (b.maturity_date) |d| d.days else std.math.maxInt(i32);
    return ad < bd;
}

/// Sort lots by maturity date (earliest first), then by symbol name.
/// Lots without maturity sort last.
pub fn lotMaturityThenSymbolSortFn(_: void, a: Lot, b: Lot) bool {
    const ad = if (a.maturity_date) |d| d.days else std.math.maxInt(i32);
    const bd = if (b.maturity_date) |d| d.days else std.math.maxInt(i32);
    if (ad != bd) return ad < bd;
    return std.mem.lessThan(u8, a.symbol, b.symbol);
}

// ── Shared style intent ──────────────────────────────────────

/// Semantic style intent — renderers map this to platform-specific styles.
/// Used by view models (e.g. views/portfolio_sections.zig) and renderers.
pub const StyleIntent = enum {
    normal, // default text
    muted, // dim/secondary (expired items)
    positive, // green (gains, premium received)
    negative, // red (losses, premium paid)
    warning, // yellow (stale data, drift)
    accent, // purple — section headers, primary series in legends
    info, // cyan — informational/overlay content, secondary legend items
};

/// Summary of DRIP (dividend reinvestment) lots for a single ST or LT bucket.
pub const DripSummary = struct {
    lot_count: usize = 0,
    shares: f64 = 0,
    cost: f64 = 0,
    first_date: ?Date = null,
    last_date: ?Date = null,

    pub fn avgCost(self: DripSummary) f64 {
        return if (self.shares > 0) self.cost / self.shares else 0;
    }

    pub fn isEmpty(self: DripSummary) bool {
        return self.lot_count == 0;
    }
};

/// Aggregated ST and LT DRIP summaries.
pub const DripAggregation = struct {
    st: DripSummary = .{},
    lt: DripSummary = .{},
};

/// Aggregate DRIP lots into short-term and long-term buckets.
/// Classifies using `capitalGainsIndicator` (LT if held > 1 year).
pub fn aggregateDripLots(as_of: Date, lots: []const Lot) DripAggregation {
    var result: DripAggregation = .{};
    for (lots) |lot| {
        if (!lot.drip) continue;
        const is_lt = std.mem.eql(u8, capitalGainsIndicator(as_of, lot.open_date), "LT");
        const bucket: *DripSummary = if (is_lt) &result.lt else &result.st;
        bucket.lot_count += 1;
        bucket.shares += lot.shares;
        bucket.cost += lot.costBasis();
        if (bucket.first_date == null or lot.open_date.days < bucket.first_date.?.days)
            bucket.first_date = lot.open_date;
        if (bucket.last_date == null or lot.open_date.days > bucket.last_date.?.days)
            bucket.last_date = lot.open_date;
    }
    return result;
}

/// Format a DRIP summary line: "ST: 12 DRIP lots, 3.5 shares, avg $45.67  (2023-01 to 2024-06)"
pub fn fmtDripSummary(buf: []u8, label: []const u8, summary: DripSummary) []const u8 {
    var d1_buf: [10]u8 = undefined;
    var d2_buf: [10]u8 = undefined;
    const d1: []const u8 = if (summary.first_date) |d| blk: {
        const s = std.fmt.bufPrint(&d1_buf, "{f}", .{d}) catch break :blk "?";
        break :blk s[0..7];
    } else "?";
    const d2: []const u8 = if (summary.last_date) |d| blk: {
        const s = std.fmt.bufPrint(&d2_buf, "{f}", .{d}) catch break :blk "?";
        break :blk s[0..7];
    } else "?";
    return std.fmt.bufPrint(buf, "{s}: {d} DRIP lots, {d:.1} shares, avg {f}  ({s} to {s})", .{
        label,
        summary.lot_count,
        summary.shares,
        Money.from(summary.avgCost()),
        d1,
        d2,
    }) catch "?";
}

// ── Shared rendering helpers (CLI + TUI) ─────────────────────

/// Layout constants for analysis breakdown views.
pub const analysis_label_width: usize = 24;
pub const analysis_bar_width: usize = 30;

/// Format a signed gain/loss amount: "+$1,234.56" or "-$1,234.56".
/// Returns the formatted string and whether the value is non-negative.
pub const GainLossResult = struct { text: []const u8, positive: bool };

pub fn fmtGainLoss(buf: []u8, pnl: f64) GainLossResult {
    const positive = pnl >= 0;
    const abs_val = if (positive) pnl else -pnl;
    var money_buf: [24]u8 = undefined;
    const money = std.fmt.bufPrint(&money_buf, "{f}", .{Money.from(abs_val)}) catch "$?";
    const sign: []const u8 = if (positive) "+" else "-";
    const text = std.fmt.bufPrint(buf, "{s}{s}", .{ sign, money }) catch "?";
    return .{ .text = text, .positive = positive };
}

/// Format a single earnings event row (without color/style).
/// Returns the formatted text and color hint.
pub const EarningsRowResult = struct {
    text: []const u8,
    is_future: bool,
    is_positive: bool,
};

pub fn fmtEarningsRow(buf: []u8, e: EarningsEvent) EarningsRowResult {
    const is_future = e.isFuture();
    const surprise_positive = if (e.surpriseAmount()) |s| s >= 0 else true;

    var db: [10]u8 = undefined;
    const date_str = std.fmt.bufPrint(&db, "{f}", .{e.date}) catch "????-??-??";

    var q_buf: [4]u8 = undefined;
    const q_str = if (e.quarter) |q| std.fmt.bufPrint(&q_buf, "Q{d}", .{q}) catch "--" else "--";

    var est_buf: [12]u8 = undefined;
    const est_str = if (e.estimate) |est| std.fmt.bufPrint(&est_buf, "${d:.2}", .{est}) catch "--" else "--";

    var act_buf: [12]u8 = undefined;
    const act_str = if (e.actual) |act| std.fmt.bufPrint(&act_buf, "${d:.2}", .{act}) catch "--" else "--";

    var surp_buf: [12]u8 = undefined;
    const surp_str: []const u8 = if (e.surpriseAmount()) |s|
        (if (s >= 0) std.fmt.bufPrint(&surp_buf, "+${d:.4}", .{s}) catch "?" else std.fmt.bufPrint(&surp_buf, "-${d:.4}", .{-s}) catch "?")
    else
        "--";

    var surp_pct_buf: [12]u8 = undefined;
    const surp_pct_str: []const u8 = if (e.surprisePct()) |sp|
        (if (sp >= 0) std.fmt.bufPrint(&surp_pct_buf, "+{d:.1}%", .{sp}) catch "?" else std.fmt.bufPrint(&surp_pct_buf, "{d:.1}%", .{sp}) catch "?")
    else
        "--";

    const text = std.fmt.bufPrint(buf, "{s:>12} {s:>4} {s:>12} {s:>12} {s:>12} {s:>10}", .{
        date_str, q_str, est_str, act_str, surp_str, surp_pct_str,
    }) catch "";

    return .{ .text = text, .is_future = is_future, .is_positive = surprise_positive };
}

/// Format a single returns table row: "  label            +15.0%  ann."
/// Returns null if the period result is null (N/A).
pub const ReturnsRowResult = struct {
    price_str: []const u8,
    total_str: ?[]const u8,
    price_positive: bool,
    suffix: []const u8,
};

pub fn fmtReturnsRow(
    price_buf: []u8,
    total_buf: []u8,
    price_result: ?PerformanceResult,
    total_result: ?PerformanceResult,
    annualize: bool,
) ReturnsRowResult {
    const performance = @import("analytics/performance.zig");

    const ps: []const u8 = if (price_result) |r| blk: {
        const val = if (annualize) r.annualized_return orelse r.total_return else r.total_return;
        break :blk performance.formatReturn(price_buf, val);
    } else "N/A";

    const price_positive = if (price_result) |r| blk: {
        const val = if (annualize) r.annualized_return orelse r.total_return else r.total_return;
        break :blk val >= 0;
    } else true;

    const ts: ?[]const u8 = if (total_result) |r| blk: {
        const val = if (annualize) r.annualized_return orelse r.total_return else r.total_return;
        break :blk performance.formatReturn(total_buf, val);
    } else null;

    return .{
        .price_str = ps,
        .total_str = ts,
        .price_positive = price_positive,
        .suffix = if (annualize) "  ann." else "",
    };
}

/// Build a block-element bar using Unicode eighth-blocks for sub-character precision.
/// Returns a slice from the provided buffer.
/// U+2588 █ full, U+2589..U+258F partials (7/8..1/8).
pub fn buildBlockBar(buf: []u8, weight: f64, total_chars: usize) []const u8 {
    const total_eighths: f64 = @as(f64, @floatFromInt(total_chars)) * 8.0;
    const filled_eighths_f = weight * total_eighths;
    const filled_eighths: usize = @intFromFloat(@min(@max(filled_eighths_f, 0), total_eighths));
    const full_blocks = filled_eighths / 8;
    const partial = filled_eighths % 8;

    var pos: usize = 0;

    // Full blocks: U+2588 = E2 96 88
    for (0..full_blocks) |_| {
        buf[pos] = 0xE2;
        buf[pos + 1] = 0x96;
        buf[pos + 2] = 0x88;
        pos += 3;
    }

    // Partial block
    if (partial > 0) {
        const code: u8 = 0x88 + @as(u8, @intCast(8 - partial));
        buf[pos] = 0xE2;
        buf[pos + 1] = 0x96;
        buf[pos + 2] = code;
        pos += 3;
    }

    // Empty spaces
    const used = full_blocks + @as(usize, if (partial > 0) 1 else 0);
    if (used < total_chars) {
        @memset(buf[pos..][0 .. total_chars - used], ' ');
        pos += total_chars - used;
    }

    return buf[0..pos];
}

/// Format a historical snapshot as "+1.5%" or "--" for display.
pub fn fmtHistoricalChange(buf: []u8, snap_count: usize, pct: f64) []const u8 {
    if (snap_count == 0) return "--";
    if (pct >= 0) {
        return std.fmt.bufPrint(buf, "+{d:.1}%", .{pct}) catch "?";
    } else {
        return std.fmt.bufPrint(buf, "{d:.1}%", .{pct}) catch "?";
    }
}

/// Format a candle as a fixed-width row: "  YYYY-MM-DD     150.00     155.00     149.00     153.00   50,000,000"
pub fn fmtCandleRow(buf: []u8, candle: Candle) []const u8 {
    var vb: [32]u8 = undefined;
    return std.fmt.bufPrint(buf, "  {f} {d:>10.2} {d:>10.2} {d:>10.2} {d:>10.2} {s:>12}", .{
        candle.date.padLeft(12), candle.open, candle.high, candle.low, candle.close, fmtIntCommas(&vb, candle.volume),
    }) catch "";
}

/// Format a price change with sign: "+$3.50 (+2.04%)" or "-$3.50 (-2.04%)".
pub fn fmtPriceChange(buf: []u8, change: f64, pct: f64) []const u8 {
    if (change >= 0) {
        return std.fmt.bufPrint(buf, "+${d:.2} (+{d:.2}%)", .{ change, pct }) catch "?";
    } else {
        return std.fmt.bufPrint(buf, "-${d:.2} ({d:.2}%)", .{ -change, pct }) catch "?";
    }
}

/// Interpolate color between two RGB values. t in [0.0, 1.0].
pub fn lerpColor(a: [3]u8, b: [3]u8, t: f64) [3]u8 {
    return .{
        @intFromFloat(@as(f64, @floatFromInt(a[0])) * (1.0 - t) + @as(f64, @floatFromInt(b[0])) * t),
        @intFromFloat(@as(f64, @floatFromInt(a[1])) * (1.0 - t) + @as(f64, @floatFromInt(b[1])) * t),
        @intFromFloat(@as(f64, @floatFromInt(a[2])) * (1.0 - t) + @as(f64, @floatFromInt(b[2])) * t),
    };
}

// ── Braille chart ────────────────────────────────────────────

/// Braille dot patterns for the 2x4 matrix within each character cell.
/// Layout:  [0][3]    Bit mapping: dot0=0x01, dot3=0x08
///          [1][4]                 dot1=0x02, dot4=0x10
///          [2][5]                 dot2=0x04, dot5=0x20
///          [6][7]                 dot6=0x40, dot7=0x80
pub const braille_dots = [4][2]u8{
    .{ 0x01, 0x08 }, // row 0 (top)
    .{ 0x02, 0x10 }, // row 1
    .{ 0x04, 0x20 }, // row 2
    .{ 0x40, 0x80 }, // row 3 (bottom)
};

/// Comptime table of braille character UTF-8 encodings (U+2800..U+28FF).
/// Each braille codepoint is 3 bytes in UTF-8: 0xE2 0xA0+hi 0x80+lo.
pub const braille_utf8 = blk: {
    var table: [256][3]u8 = undefined;
    for (0..256) |i| {
        const cp: u21 = 0x2800 + @as(u21, @intCast(i));
        table[i] = .{
            @as(u8, 0xE0 | @as(u8, @truncate(cp >> 12))),
            @as(u8, 0x80 | @as(u8, @truncate((cp >> 6) & 0x3F))),
            @as(u8, 0x80 | @as(u8, @truncate(cp & 0x3F))),
        };
    }
    break :blk table;
};

/// Return a static-lifetime grapheme slice for a braille pattern byte.
pub fn brailleGlyph(pattern: u8) []const u8 {
    return &braille_utf8[pattern];
}

/// Maximum byte length for a `Money.from(v).{f}` rendering used as a
/// chart axis label. Sized to fit `$999,999,999,999.99` (19 chars,
/// up to a trillion-plus) with slack. Renderers that pre-allocate
/// buffer cells for these labels should use this constant rather
/// than hard-coding a smaller width and silently truncating
/// portfolios over $1M.
pub const money_label_max_bytes: usize = 24;

/// Computed braille chart data, ready for rendering by CLI (ANSI) or TUI (vaxis).
pub const BrailleChart = struct {
    /// Braille pattern bytes: patterns[row * n_cols + col]
    patterns: []u8,
    /// RGB color per data column
    col_colors: [][3]u8,
    n_cols: usize,
    chart_height: usize,
    /// Money labels formatted via `Money.from(v).{f}`. Sized to fit
    /// up to `$999,999,999,999.99` (19 chars) with slack so we don't
    /// silently drop the label when portfolios cross into ten figures.
    /// Renderers that need to budget cells for the label should use
    /// `money_label_max_bytes` rather than guessing.
    max_label: [money_label_max_bytes]u8,
    max_label_len: usize,
    min_label: [money_label_max_bytes]u8,
    min_label_len: usize,
    /// Date of first candle in the chart data
    start_date: Date,
    /// Date of last candle in the chart data
    end_date: Date,

    pub fn maxLabel(self: *const BrailleChart) []const u8 {
        return self.max_label[0..self.max_label_len];
    }

    pub fn minLabel(self: *const BrailleChart) []const u8 {
        return self.min_label[0..self.min_label_len];
    }

    pub fn pattern(self: *const BrailleChart, row: usize, col: usize) u8 {
        return self.patterns[row * self.n_cols + col];
    }

    /// Format a date as "MMM DD" for the braille chart x-axis.
    /// The year context is already visible in the surrounding CLI/TUI interface.
    /// Returns the number of bytes written.
    pub fn fmtShortDate(date: Date, buf: *[7]u8) []const u8 {
        const mon = Date.monthShort(date.month());
        const d = date.day();
        buf[0] = mon[0];
        buf[1] = mon[1];
        buf[2] = mon[2];
        buf[3] = ' ';
        if (d >= 10) {
            buf[4] = '0' + d / 10;
        } else {
            buf[4] = '0';
        }
        buf[5] = '0' + d % 10;
        return buf[0..6];
    }

    /// Format a date for the chart's x-axis at a granularity
    /// appropriate for that individual date's recency. Two tiers,
    /// per-date (driven by how far back the date is from the
    /// chart's `end_date` reference, not by the chart's overall
    /// span):
    ///   - within 720 days of `end_date` → "DD MMM"   (e.g., "08 May")
    ///   - older than 720 days           → "MMM YYYY" (e.g., "Jul 2014")
    ///
    /// On a 12-year chart, this typically yields a long-format
    /// start label (`Jul 2014`) paired with a short-format end
    /// label (`08 May`) — the start is far enough back that
    /// year context is what matters; the end is recent enough
    /// that day-of-month resolution is useful.
    ///
    /// The day-first ordering for the short form is intentional:
    /// when a chart pairs `"08 May"` with `"Jul 2014"`, the first
    /// character of each label cleanly disambiguates the format
    /// at a glance — digit-first is a recent date, letter-first is
    /// a distant date. Saves the eye from re-parsing every label.
    ///
    /// `buf` must be at least 8 bytes; the returned slice borrows
    /// from it.
    pub fn fmtAxisDate(self: *const BrailleChart, date: Date, buf: *[8]u8) []const u8 {
        const age_days = self.end_date.days - date.days;
        const mon = Date.monthShort(date.month());

        if (age_days <= 720) {
            // "DD MMM" — day-first so the leading character is a
            // digit (visually distinct from the letter-first
            // long form below).
            return std.fmt.bufPrint(buf, "{d:0>2} {s}", .{ date.day(), mon }) catch buf[0..0];
        }
        // "MMM YYYY" — for dates more than ~2 years before
        // `end_date`. Day-of-month resolution stops being useful
        // at this scale; full 4-digit year keeps the label
        // unambiguous regardless of how far back the chart goes.
        return std.fmt.bufPrint(buf, "{s} {d:0>4}", .{ mon, @as(u16, @intCast(date.year())) }) catch buf[0..0];
    }

    pub fn deinit(self: *BrailleChart, alloc: std.mem.Allocator) void {
        alloc.free(self.patterns);
        alloc.free(self.col_colors);
    }
};

/// Compute braille sparkline chart data from candle close prices.
/// Uses Unicode braille characters (U+2800..U+28FF) for 2-wide x 4-tall dot matrix per cell.
/// Each terminal row provides 4 sub-rows of resolution; each column maps to one data point.
///
/// Returns a BrailleChart with the pattern grid and per-column colors.
/// Caller must call deinit() when done (unless using an arena allocator).
pub fn computeBrailleChart(
    alloc: std.mem.Allocator,
    data: []const Candle,
    chart_width: usize,
    chart_height: usize,
    positive_color: [3]u8,
    negative_color: [3]u8,
) !BrailleChart {
    if (data.len < 2) return error.InsufficientData;

    const dot_rows: usize = chart_height * 4; // vertical dot resolution

    // Find min/max chart-close prices (split-adjusted when available).
    // See `Candle.chartClose` — using raw `close` here would render
    // false cliffs at split dates.
    var min_price: f64 = data[0].chartClose();
    var max_price: f64 = data[0].chartClose();
    for (data) |d| {
        const cc = d.chartClose();
        if (cc < min_price) min_price = cc;
        if (cc > max_price) max_price = cc;
    }
    if (max_price == min_price) max_price = min_price + 1.0;
    const price_range = max_price - min_price;

    // Price labels
    // SAFETY: every field of `result` is initialized below before
    // it is read or returned. Treating it as `undefined` here is
    // a deliberate "stack-allocate, then write each field"
    // pattern — Zig requires the variable to exist before
    // bufPrint can take a slice of one of its fields.
    var result: BrailleChart = undefined;
    const max_str = std.fmt.bufPrint(&result.max_label, "{f}", .{Money.from(max_price)}) catch "";
    result.max_label_len = max_str.len;
    const min_str = std.fmt.bufPrint(&result.min_label, "{f}", .{Money.from(min_price)}) catch "";
    result.min_label_len = min_str.len;

    const n_cols = @min(data.len, chart_width);
    result.n_cols = n_cols;
    result.chart_height = chart_height;
    result.start_date = data[0].date;
    result.end_date = data[data.len - 1].date;

    // Map each data column to a dot-row position and color
    const dot_y = try alloc.alloc(usize, n_cols);
    defer alloc.free(dot_y);

    result.col_colors = try alloc.alloc([3]u8, n_cols);
    errdefer alloc.free(result.col_colors);

    for (0..n_cols) |col| {
        const data_idx_f: f64 = @as(f64, @floatFromInt(col)) * @as(f64, @floatFromInt(data.len - 1)) / @as(f64, @floatFromInt(n_cols - 1));
        const data_idx: usize = @min(@as(usize, @intFromFloat(data_idx_f)), data.len - 1);
        const close = data[data_idx].chartClose();
        const norm = (close - min_price) / price_range; // 0 = min, 1 = max
        // Inverted: 0 = top dot row, dot_rows-1 = bottom
        const y_f = (1.0 - norm) * @as(f64, @floatFromInt(dot_rows - 1));
        dot_y[col] = @min(@as(usize, @intFromFloat(y_f)), dot_rows - 1);
        // Color: gradient from negative (bottom) to positive (top)
        result.col_colors[col] = lerpColor(negative_color, positive_color, norm);
    }

    // Build the braille pattern grid
    result.patterns = try alloc.alloc(u8, chart_height * n_cols);
    @memset(result.patterns, 0);

    for (0..n_cols) |col| {
        const target_y = dot_y[col];
        // Fill from target_y down to the bottom
        for (target_y..dot_rows) |dy| {
            const term_row = dy / 4;
            const sub_row = dy % 4;
            result.patterns[term_row * n_cols + col] |= braille_dots[sub_row][0];
        }

        // Interpolate between this point and the next for smooth contour
        if (col + 1 < n_cols) {
            const y0 = dot_y[col];
            const y1 = dot_y[col + 1];
            const min_y = @min(y0, y1);
            const max_y = @max(y0, y1);
            for (min_y..max_y + 1) |dy| {
                const term_row = dy / 4;
                const sub_row = dy % 4;
                result.patterns[term_row * n_cols + col] |= braille_dots[sub_row][0];
            }
        }
    }

    return result;
}

/// Write a braille chart to a writer with ANSI color escapes.
/// Used by the CLI for terminal output. Set `skip_date_axis` to
/// provide a custom x-axis (e.g. year labels instead of dates).
pub fn writeBrailleAnsi(
    out: *std.Io.Writer,
    chart: *const BrailleChart,
    use_color: bool,
    muted_color: [3]u8,
    skip_date_axis: bool,
) !void {
    var last_r: u8 = 0;
    var last_g: u8 = 0;
    var last_b: u8 = 0;
    var color_active = false;

    for (0..chart.chart_height) |row| {
        try out.writeAll("  "); // 2 leading spaces

        for (0..chart.n_cols) |col| {
            const pat = chart.pattern(row, col);
            if (use_color and pat != 0) {
                const c = chart.col_colors[col];
                // Only emit color escape if color changed
                if (!color_active or c[0] != last_r or c[1] != last_g or c[2] != last_b) {
                    try out.print("\x1b[38;2;{d};{d};{d}m", .{ c[0], c[1], c[2] });
                    last_r = c[0];
                    last_g = c[1];
                    last_b = c[2];
                    color_active = true;
                }
            } else if (color_active and pat == 0) {
                try out.writeAll("\x1b[0m");
                color_active = false;
            }
            try out.writeAll(brailleGlyph(pat));
        }

        if (color_active) {
            try out.writeAll("\x1b[0m");
            color_active = false;
        }

        // Price label on first/last row
        if (row == 0) {
            if (use_color) try out.print("\x1b[38;2;{d};{d};{d}m", .{ muted_color[0], muted_color[1], muted_color[2] });
            try out.print(" {s}", .{chart.maxLabel()});
            if (use_color) try out.writeAll("\x1b[0m");
        } else if (row == chart.chart_height - 1) {
            if (use_color) try out.print("\x1b[38;2;{d};{d};{d}m", .{ muted_color[0], muted_color[1], muted_color[2] });
            try out.print(" {s}", .{chart.minLabel()});
            if (use_color) try out.writeAll("\x1b[0m");
        }
        try out.writeAll("\n");
    }

    // Date axis below chart
    if (!skip_date_axis) {
        var start_buf: [8]u8 = undefined;
        var end_buf: [8]u8 = undefined;
        const start_label = chart.fmtAxisDate(chart.start_date, &start_buf);
        const end_label = chart.fmtAxisDate(chart.end_date, &end_buf);

        if (use_color) try out.print("\x1b[38;2;{d};{d};{d}m", .{ muted_color[0], muted_color[1], muted_color[2] });
        try out.writeAll("  "); // match leading indent
        try out.writeAll(start_label);
        const total_width = chart.n_cols;
        if (total_width > start_label.len + end_label.len) {
            const gap = total_width - start_label.len - end_label.len;
            for (0..gap) |_| try out.writeAll(" ");
        }
        try out.writeAll(end_label);
        if (use_color) try out.writeAll("\x1b[0m");
        try out.writeAll("\n");
    }
}

// ── ANSI color helpers (for CLI) ─────────────────────────────

/// Determine whether to use ANSI color output.
/// Uses std.Io.Terminal.Mode.detect which handles TTY detection, NO_COLOR,
/// CLICOLOR_FORCE, and Windows console API cross-platform.
pub fn shouldUseColor(io: std.Io, environ_map: *const std.process.Environ.Map, no_color_flag: bool) bool {
    if (no_color_flag) return false;
    const NO_COLOR = if (environ_map.get("NO_COLOR")) |v| v.len > 0 else false;
    const CLICOLOR_FORCE = if (environ_map.get("CLICOLOR_FORCE")) |v| v.len > 0 else false;
    const mode = std.Io.Terminal.Mode.detect(io, std.Io.File.stdout(), NO_COLOR, CLICOLOR_FORCE) catch return false;
    return mode != .no_color;
}

/// Write an ANSI 24-bit foreground color escape.
pub fn ansiSetFg(out: *std.Io.Writer, r: u8, g: u8, b: u8) !void {
    try out.print("\x1b[38;2;{d};{d};{d}m", .{ r, g, b });
}

/// Write ANSI bold.
pub fn ansiBold(out: *std.Io.Writer) !void {
    try out.writeAll("\x1b[1m");
}

/// Reset all ANSI attributes.
pub fn ansiReset(out: *std.Io.Writer) !void {
    try out.writeAll("\x1b[0m");
}

// ── Tests ────────────────────────────────────────────────────

test "fmtIntCommas" {
    var buf: [32]u8 = undefined;
    try std.testing.expectEqualStrings("0", fmtIntCommas(&buf, 0));
    try std.testing.expectEqualStrings("999", fmtIntCommas(&buf, 999));
    try std.testing.expectEqualStrings("1,000", fmtIntCommas(&buf, 1000));
    try std.testing.expectEqualStrings("1,234,567", fmtIntCommas(&buf, 1234567));
}

test "fmtLargeNum" {
    // Sub-million: formatted as raw number
    const small = fmtLargeNum(12345.0);
    try std.testing.expect(std.mem.startsWith(u8, &small, "12345"));
    // Millions
    const mil = fmtLargeNum(45_600_000.0);
    try std.testing.expect(std.mem.startsWith(u8, &mil, "45.6M"));
    // Billions
    const bil = fmtLargeNum(1_500_000_000.0);
    try std.testing.expect(std.mem.startsWith(u8, &bil, "1.5B"));
    // Trillions
    const tril = fmtLargeNum(2_300_000_000_000.0);
    try std.testing.expect(std.mem.startsWith(u8, &tril, "2.3T"));
}

test "fmtCashHeader" {
    var buf: [128]u8 = undefined;
    const header = fmtCashHeader(&buf);
    try std.testing.expect(std.mem.startsWith(u8, header, "  Account"));
    try std.testing.expect(std.mem.indexOf(u8, header, "Balance") != null);
    try std.testing.expect(std.mem.indexOf(u8, header, "Note") != null);
}

test "fmtCashRow" {
    var buf: [128]u8 = undefined;
    const row = fmtCashRow(&buf, "Savings Account", 5000.00, "Emergency fund");
    try std.testing.expect(std.mem.indexOf(u8, row, "Savings Account") != null);
    try std.testing.expect(std.mem.indexOf(u8, row, "$5,000.00") != null);
    try std.testing.expect(std.mem.indexOf(u8, row, "Emergency fund") != null);
}

test "fmtCashRow no note" {
    var buf: [128]u8 = undefined;
    const row = fmtCashRow(&buf, "Checking", 1234.56, null);
    try std.testing.expect(std.mem.indexOf(u8, row, "Checking") != null);
    try std.testing.expect(std.mem.indexOf(u8, row, "$1,234.56") != null);
}

test "fmtCashSep" {
    var buf: [128]u8 = undefined;
    const sep = fmtCashSep(&buf);
    // Should contain dashes
    try std.testing.expect(std.mem.indexOf(u8, sep, "---") != null);
    try std.testing.expect(std.mem.startsWith(u8, sep, "  "));
}

test "fmtCashTotal" {
    var buf: [128]u8 = undefined;
    const total = fmtCashTotal(&buf, 25000.00);
    try std.testing.expect(std.mem.indexOf(u8, total, "TOTAL") != null);
    try std.testing.expect(std.mem.indexOf(u8, total, "$25,000.00") != null);
}

test "fmtIlliquidHeader" {
    var buf: [128]u8 = undefined;
    const header = fmtIlliquidHeader(&buf);
    try std.testing.expect(std.mem.startsWith(u8, header, "  Asset"));
    try std.testing.expect(std.mem.indexOf(u8, header, "Value") != null);
    try std.testing.expect(std.mem.indexOf(u8, header, "Note") != null);
}

test "filterCandlesFrom" {
    const candles = [_]Candle{
        .{ .date = Date.fromYmd(2024, 1, 2), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 1000 },
        .{ .date = Date.fromYmd(2024, 1, 3), .open = 101, .high = 101, .low = 101, .close = 101, .adj_close = 101, .volume = 1000 },
        .{ .date = Date.fromYmd(2024, 1, 4), .open = 102, .high = 102, .low = 102, .close = 102, .adj_close = 102, .volume = 1000 },
        .{ .date = Date.fromYmd(2024, 1, 5), .open = 103, .high = 103, .low = 103, .close = 103, .adj_close = 103, .volume = 1000 },
    };
    // Date before all candles: returns all
    const all = filterCandlesFrom(&candles, Date.fromYmd(2024, 1, 1));
    try std.testing.expectEqual(@as(usize, 4), all.len);
    // Date in the middle: returns subset
    const mid = filterCandlesFrom(&candles, Date.fromYmd(2024, 1, 3));
    try std.testing.expectEqual(@as(usize, 3), mid.len);
    try std.testing.expect(mid[0].date.eql(Date.fromYmd(2024, 1, 3)));
    // Date after all candles: returns empty
    const none = filterCandlesFrom(&candles, Date.fromYmd(2025, 1, 1));
    try std.testing.expectEqual(@as(usize, 0), none.len);
    // Exact match on first date
    const exact = filterCandlesFrom(&candles, Date.fromYmd(2024, 1, 2));
    try std.testing.expectEqual(@as(usize, 4), exact.len);
    // Empty slice
    const empty: []const Candle = &.{};
    const from_empty = filterCandlesFrom(empty, Date.fromYmd(2024, 1, 1));
    try std.testing.expectEqual(@as(usize, 0), from_empty.len);
}

test "filterNearMoney" {
    const exp = Date.fromYmd(2024, 3, 15);
    const contracts = [_]OptionContract{
        .{ .strike = 90, .contract_type = .call, .expiration = exp },
        .{ .strike = 95, .contract_type = .call, .expiration = exp },
        .{ .strike = 100, .contract_type = .call, .expiration = exp },
        .{ .strike = 105, .contract_type = .call, .expiration = exp },
        .{ .strike = 110, .contract_type = .call, .expiration = exp },
        .{ .strike = 115, .contract_type = .call, .expiration = exp },
    };
    // ATM at 100, filter to +/- 2 strikes
    const near = filterNearMoney(&contracts, 100, 2);
    try std.testing.expectEqual(@as(usize, 5), near.len);
    try std.testing.expectApproxEqAbs(@as(f64, 90), near[0].strike, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 110), near[near.len - 1].strike, 0.01);
    // ATM <= 0: returns full slice
    const full = filterNearMoney(&contracts, 0, 2);
    try std.testing.expectEqual(@as(usize, 6), full.len);
    // Empty contracts
    const empty: []const OptionContract = &.{};
    const from_empty = filterNearMoney(empty, 100, 2);
    try std.testing.expectEqual(@as(usize, 0), from_empty.len);
}

test "isMonthlyExpiration" {
    // 2024-01-19 is a Friday and the 3rd Friday of January
    try std.testing.expect(isMonthlyExpiration(Date.fromYmd(2024, 1, 19)));
    // 2024-02-16 is a Friday and the 3rd Friday of February
    try std.testing.expect(isMonthlyExpiration(Date.fromYmd(2024, 2, 16)));
    // 2024-01-12 is a Friday but the 2nd Friday (day 12 < 15)
    try std.testing.expect(!isMonthlyExpiration(Date.fromYmd(2024, 1, 12)));
    // 2024-01-26 is a Friday but the 4th Friday (day 26 > 21)
    try std.testing.expect(!isMonthlyExpiration(Date.fromYmd(2024, 1, 26)));
    // 2024-01-17 is a Wednesday (not a Friday)
    try std.testing.expect(!isMonthlyExpiration(Date.fromYmd(2024, 1, 17)));
}

test "toTitleCase" {
    var buf: [64]u8 = undefined;
    try std.testing.expectEqualStrings("Technology", toTitleCase(&buf, "TECHNOLOGY"));
    try std.testing.expectEqualStrings("Consumer Cyclical", toTitleCase(&buf, "CONSUMER CYCLICAL"));
    try std.testing.expectEqualStrings("Healthcare", toTitleCase(&buf, "HEALTHCARE"));
    try std.testing.expectEqualStrings("Technology", toTitleCase(&buf, "Technology"));
    try std.testing.expectEqualStrings("Unknown", toTitleCase(&buf, "Unknown"));
}

test "lotSortFn" {
    const open_new = Lot{
        .symbol = "A",
        .shares = 1,
        .open_date = Date.fromYmd(2024, 6, 1),
        .open_price = 100,
    };
    const open_old = Lot{
        .symbol = "B",
        .shares = 1,
        .open_date = Date.fromYmd(2024, 1, 1),
        .open_price = 100,
    };
    const closed = Lot{
        .symbol = "C",
        .shares = 1,
        .open_date = Date.fromYmd(2024, 3, 1),
        .open_price = 100,
        .close_date = Date.fromYmd(2024, 6, 1),
        .close_price = 110,
    };
    // Open before closed
    try std.testing.expect(lotSortFn(Date.fromYmd(2026, 5, 8), open_new, closed));
    try std.testing.expect(!lotSortFn(Date.fromYmd(2026, 5, 8), closed, open_new));
    // Among open lots: newest first
    try std.testing.expect(lotSortFn(Date.fromYmd(2026, 5, 8), open_new, open_old));
    try std.testing.expect(!lotSortFn(Date.fromYmd(2026, 5, 8), open_old, open_new));
}

test "lotMaturitySortFn" {
    const with_maturity = Lot{
        .symbol = "CD1",
        .shares = 10000,
        .open_date = Date.fromYmd(2024, 1, 1),
        .open_price = 100,
        .security_type = .cd,
        .maturity_date = Date.fromYmd(2025, 6, 15),
    };
    const later_maturity = Lot{
        .symbol = "CD2",
        .shares = 10000,
        .open_date = Date.fromYmd(2024, 1, 1),
        .open_price = 100,
        .security_type = .cd,
        .maturity_date = Date.fromYmd(2026, 1, 1),
    };
    const no_maturity = Lot{
        .symbol = "CD3",
        .shares = 10000,
        .open_date = Date.fromYmd(2024, 1, 1),
        .open_price = 100,
        .security_type = .cd,
    };
    // Earlier maturity sorts first
    try std.testing.expect(lotMaturitySortFn({}, with_maturity, later_maturity));
    try std.testing.expect(!lotMaturitySortFn({}, later_maturity, with_maturity));
    // No maturity sorts last
    try std.testing.expect(lotMaturitySortFn({}, with_maturity, no_maturity));
    try std.testing.expect(!lotMaturitySortFn({}, no_maturity, with_maturity));
}

test "aggregateDripLots" {
    // Two ST drip lots + one LT drip lot + one non-drip lot (should be ignored)
    const lots = [_]Lot{
        .{ .symbol = "VTI", .shares = 0.5, .open_date = Date.fromYmd(2025, 6, 1), .open_price = 220, .drip = true },
        .{ .symbol = "VTI", .shares = 0.3, .open_date = Date.fromYmd(2025, 8, 1), .open_price = 230, .drip = true },
        .{ .symbol = "VTI", .shares = 0.2, .open_date = Date.fromYmd(2023, 1, 1), .open_price = 200, .drip = true },
        .{ .symbol = "VTI", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 210, .drip = false },
    };
    // as_of pinned: 2023 lot is >1 year old (LT), 2025 lots are <1 year (ST).
    const as_of = Date.fromYmd(2026, 1, 1);
    const agg = aggregateDripLots(as_of, &lots);
    // The 2023 lot is >1 year old -> LT, the 2025 lots are ST
    try std.testing.expect(!agg.lt.isEmpty());
    try std.testing.expectEqual(@as(usize, 1), agg.lt.lot_count);
    try std.testing.expectApproxEqAbs(@as(f64, 0.2), agg.lt.shares, 0.001);
    try std.testing.expectEqual(@as(usize, 2), agg.st.lot_count);
    try std.testing.expectApproxEqAbs(@as(f64, 0.8), agg.st.shares, 0.001);
    // Avg cost
    try std.testing.expectApproxEqAbs(@as(f64, 200.0), agg.lt.avgCost(), 0.01);
}

test "aggregateDripLots empty" {
    const lots = [_]Lot{
        .{ .symbol = "VTI", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 210, .drip = false },
    };
    const agg = aggregateDripLots(Date.fromYmd(2026, 1, 1), &lots);
    try std.testing.expect(agg.st.isEmpty());
    try std.testing.expect(agg.lt.isEmpty());
}

test "lerpColor" {
    // t=0 returns first color
    const c0 = lerpColor(.{ 0, 0, 0 }, .{ 255, 255, 255 }, 0.0);
    try std.testing.expectEqual(@as(u8, 0), c0[0]);
    try std.testing.expectEqual(@as(u8, 0), c0[1]);
    // t=1 returns second color
    const c1 = lerpColor(.{ 0, 0, 0 }, .{ 255, 255, 255 }, 1.0);
    try std.testing.expectEqual(@as(u8, 255), c1[0]);
    // t=0.5 returns midpoint
    const c_mid = lerpColor(.{ 0, 0, 0 }, .{ 200, 100, 50 }, 0.5);
    try std.testing.expectEqual(@as(u8, 100), c_mid[0]);
    try std.testing.expectEqual(@as(u8, 50), c_mid[1]);
    try std.testing.expectEqual(@as(u8, 25), c_mid[2]);
}

test "brailleGlyph" {
    // Pattern 0 = U+2800 (blank braille)
    const blank = brailleGlyph(0);
    try std.testing.expectEqual(@as(usize, 3), blank.len);
    try std.testing.expectEqual(@as(u8, 0xE2), blank[0]);
    try std.testing.expectEqual(@as(u8, 0xA0), blank[1]);
    try std.testing.expectEqual(@as(u8, 0x80), blank[2]);
    // Pattern 0xFF = U+28FF (full braille)
    const full = brailleGlyph(0xFF);
    try std.testing.expectEqual(@as(usize, 3), full.len);
    try std.testing.expectEqual(@as(u8, 0xE2), full[0]);
    try std.testing.expectEqual(@as(u8, 0xA3), full[1]);
    try std.testing.expectEqual(@as(u8, 0xBF), full[2]);
}

test "fmtShortDate" {
    var buf: [7]u8 = undefined;
    const jan15 = BrailleChart.fmtShortDate(Date.fromYmd(2024, 1, 15), &buf);
    try std.testing.expectEqualStrings("Jan 15", jan15);
    const dec01 = BrailleChart.fmtShortDate(Date.fromYmd(2024, 12, 1), &buf);
    try std.testing.expectEqualStrings("Dec 01", dec01);
    const jun09 = BrailleChart.fmtShortDate(Date.fromYmd(2026, 6, 9), &buf);
    try std.testing.expectEqualStrings("Jun 09", jun09);
}

test "computeBrailleChart" {
    const alloc = std.testing.allocator;
    // Build synthetic candle data: 20 candles, prices rising from 100 to 119
    var candles: [20]Candle = undefined;
    for (0..20) |i| {
        const price: f64 = 100.0 + @as(f64, @floatFromInt(i));
        candles[i] = .{
            .date = Date.fromYmd(2024, 1, 2).addDays(@intCast(i)),
            .open = price,
            .high = price,
            .low = price,
            .close = price,
            .adj_close = price,
            .volume = 1000,
        };
    }
    var chart = try computeBrailleChart(alloc, &candles, 20, 4, .{ 0x7f, 0xd8, 0x8f }, .{ 0xe0, 0x6c, 0x75 });
    defer chart.deinit(alloc);
    try std.testing.expectEqual(@as(usize, 20), chart.n_cols);
    try std.testing.expectEqual(@as(usize, 4), chart.chart_height);
    try std.testing.expectEqual(@as(usize, 80), chart.patterns.len); // 4 * 20
    try std.testing.expectEqual(@as(usize, 20), chart.col_colors.len);
    // Max/min labels should contain price info
    try std.testing.expect(chart.maxLabel().len > 0);
    try std.testing.expect(chart.minLabel().len > 0);
}

test "computeBrailleChart insufficient data" {
    const alloc = std.testing.allocator;
    const candles = [_]Candle{
        .{ .date = Date.fromYmd(2024, 1, 2), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 1000 },
    };
    const result = computeBrailleChart(alloc, &candles, 10, 4, .{ 0, 0, 0 }, .{ 255, 255, 255 });
    try std.testing.expectError(error.InsufficientData, result);
}

test "computeBrailleChart preserves full label for prices over $1M" {
    // Regression test for a bug where the max/min label buffers were
    // sized at 16 bytes - too small for `Money.from(v).{f}` of values
    // with 13+ chars (anything $1,000,000+). Result was a silently
    // empty label string in the BrailleChart, then the TUI renderer
    // truncated even further to 10 cells, dropping the label entirely
    // for portfolios over $1M. See `money_label_max_bytes`.
    const alloc = std.testing.allocator;
    var candles: [20]Candle = undefined;
    for (0..20) |i| {
        // Arbitrary placeholder range starting at $1,234,567.89 with
        // a $500,000 step. The point of this test is the rendered
        // shape - 13+ char `$X,XXX,XXX.XX` strings that would have
        // overflowed the old 16-byte label buffer or the renderer's
        // 10-cell budget. The exact numbers are irrelevant.
        const price: f64 = 1_234_567.89 + @as(f64, @floatFromInt(i)) * 500_000.0;
        candles[i] = .{
            .date = Date.fromYmd(2024, 1, 2).addDays(@intCast(i)),
            .open = price,
            .high = price,
            .low = price,
            .close = price,
            .adj_close = price,
            .volume = 1000,
        };
    }
    var chart = try computeBrailleChart(alloc, &candles, 20, 4, .{ 0x7f, 0xd8, 0x8f }, .{ 0xe0, 0x6c, 0x75 });
    defer chart.deinit(alloc);

    // Both labels must contain the dollar sign and a comma (the
    // thousands separator) - that confirms `Money.from` produced
    // a multi-million-dollar string and didn't fall through to the
    // empty-string fallback when bufPrint hit NoSpaceLeft.
    const max_lbl = chart.maxLabel();
    const min_lbl = chart.minLabel();
    try std.testing.expect(std.mem.indexOfScalar(u8, max_lbl, '$') != null);
    try std.testing.expect(std.mem.indexOfScalar(u8, max_lbl, ',') != null);
    try std.testing.expect(std.mem.indexOfScalar(u8, min_lbl, '$') != null);
    try std.testing.expect(std.mem.indexOfScalar(u8, min_lbl, ',') != null);
    // Both labels should match the `$X,XXX,XXX.XX` shape (at
    // least 13 chars). Any silent-truncation bug would leave them
    // empty or much shorter.
    try std.testing.expect(max_lbl.len >= 13);
    try std.testing.expect(min_lbl.len >= 13);
}

test "computeBrailleChart uses adj_close to avoid split cliff" {
    // Regression: SOXX 3:1 on 2024-03-07 used to render a sharp drop
    // because the chart consumed raw `close` instead of `adj_close`.
    // Build a synthetic 4-candle slice that mimics a 3:1 split: raw
    // close drops 300 → 100, but adj_close is constant at 100. The
    // chart should see a flat line, not a cliff.
    const alloc = std.testing.allocator;
    const candles = [_]Candle{
        .{ .date = Date.fromYmd(2024, 3, 5), .open = 300, .high = 300, .low = 300, .close = 300, .adj_close = 100, .volume = 1000 },
        .{ .date = Date.fromYmd(2024, 3, 6), .open = 300, .high = 300, .low = 300, .close = 300, .adj_close = 100, .volume = 1000 },
        .{ .date = Date.fromYmd(2024, 3, 7), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 1000 },
        .{ .date = Date.fromYmd(2024, 3, 8), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 1000 },
    };
    var chart = try computeBrailleChart(alloc, &candles, 20, 4, .{ 0x7f, 0xd8, 0x8f }, .{ 0xe0, 0x6c, 0x75 });
    defer chart.deinit(alloc);
    // Min and max labels should reflect the adjusted price (~$100),
    // not the raw close range (300 → 100). The exact values vary
    // because computeBrailleChart bumps max by $1 internally when
    // min == max, but neither label should mention $300.
    try std.testing.expect(std.mem.indexOf(u8, chart.maxLabel(), "300") == null);
    try std.testing.expect(std.mem.indexOf(u8, chart.minLabel(), "300") == null);
}

test "fmtContractLine" {
    var buf: [128]u8 = undefined;
    const contract = OptionContract{
        .strike = 150.0,
        .contract_type = .call,
        .expiration = Date.fromYmd(2024, 3, 15),
        .last_price = 5.25,
        .bid = 5.10,
        .ask = 5.40,
        .volume = 1234,
        .open_interest = 5678,
        .implied_volatility = 0.25,
    };
    const line = fmtContractLine(&buf, "C ", contract);
    try std.testing.expect(std.mem.indexOf(u8, line, "150.00") != null);
    try std.testing.expect(std.mem.indexOf(u8, line, "5.25") != null);
    try std.testing.expect(std.mem.indexOf(u8, line, "1234") != null);
}

test "fmtContractLine null fields" {
    var buf: [128]u8 = undefined;
    const contract = OptionContract{
        .strike = 200.0,
        .contract_type = .put,
        .expiration = Date.fromYmd(2024, 6, 21),
    };
    const line = fmtContractLine(&buf, "P ", contract);
    try std.testing.expect(std.mem.indexOf(u8, line, "200.00") != null);
    // Null fields should show "--"
    try std.testing.expect(std.mem.indexOf(u8, line, "--") != null);
}

test "fmtGainLoss positive" {
    var buf: [32]u8 = undefined;
    const result = fmtGainLoss(&buf, 1234.56);
    try std.testing.expect(result.positive);
    try std.testing.expect(std.mem.startsWith(u8, result.text, "+$"));
}

test "fmtGainLoss negative" {
    var buf: [32]u8 = undefined;
    const result = fmtGainLoss(&buf, -500.00);
    try std.testing.expect(!result.positive);
    try std.testing.expect(std.mem.startsWith(u8, result.text, "-$"));
}

test "fmtEarningsRow with data" {
    var buf: [128]u8 = undefined;
    const e = EarningsEvent{
        .symbol = "AAPL",
        .date = Date.fromYmd(2024, 1, 25),
        .quarter = 1,
        .estimate = 2.10,
        .actual = 2.18,
        .surprise = 0.08,
        .surprise_percent = 3.8,
    };
    const result = fmtEarningsRow(&buf, e);
    try std.testing.expect(!result.is_future);
    try std.testing.expect(result.is_positive);
    try std.testing.expect(std.mem.indexOf(u8, result.text, "Q1") != null);
    try std.testing.expect(std.mem.indexOf(u8, result.text, "$2.10") != null);
    try std.testing.expect(std.mem.indexOf(u8, result.text, "$2.18") != null);
}

test "fmtEarningsRow future event" {
    var buf: [128]u8 = undefined;
    const e = EarningsEvent{
        .symbol = "AAPL",
        .date = Date.fromYmd(2026, 7, 1),
        .estimate = 2.50,
    };
    const result = fmtEarningsRow(&buf, e);
    try std.testing.expect(result.is_future);
    try std.testing.expect(std.mem.indexOf(u8, result.text, "--") != null); // no actual
}

test "buildBlockBar" {
    var buf: [256]u8 = undefined;
    // Full bar
    const full = buildBlockBar(&buf, 1.0, 10);
    try std.testing.expectEqual(@as(usize, 30), full.len); // 10 chars * 3 bytes each
    // Empty bar
    const empty = buildBlockBar(&buf, 0.0, 10);
    try std.testing.expectEqual(@as(usize, 10), empty.len); // 10 spaces
    // Half bar: 5 full blocks + 5 spaces = 5*3 + 5 = 20
    const half = buildBlockBar(&buf, 0.5, 10);
    try std.testing.expectEqual(@as(usize, 20), half.len);
}

test "buildBlockBar: negative weight clamps to empty bar (no crash)" {
    // NPORT-P emits negative pct values for leveraged-fund
    // liability sleeves (e.g. PTY's repurchase agreement at
    // -29.72%). After portfolio-wide aggregation and dilution
    // these tend to produce small-magnitude negative weights in
    // the Sector breakdown. The renderer must handle them
    // safely — render as a 0-width (all-spaces) bar with no
    // panic on @intFromFloat.
    var buf: [256]u8 = undefined;
    const small_neg = buildBlockBar(&buf, -0.003, 10);
    try std.testing.expectEqual(@as(usize, 10), small_neg.len);
    try std.testing.expectEqualStrings("          ", small_neg);

    const large_neg = buildBlockBar(&buf, -1.5, 10);
    try std.testing.expectEqual(@as(usize, 10), large_neg.len);
    try std.testing.expectEqualStrings("          ", large_neg);
}

test "buildBlockBar: weight > 1.0 clamps to full bar (no overflow)" {
    // Symmetric defensive case: if for any reason the caller
    // hands us a weight above 1.0 (e.g. the per-fund rather than
    // per-portfolio side of the math), the bar should clamp
    // rather than write past `total_chars`.
    var buf: [256]u8 = undefined;
    const overshoot = buildBlockBar(&buf, 1.5, 10);
    try std.testing.expectEqual(@as(usize, 30), overshoot.len);
}

test "fmtHistoricalChange" {
    var buf: [16]u8 = undefined;
    try std.testing.expectEqualStrings("--", fmtHistoricalChange(&buf, 0, 0));
    const pos = fmtHistoricalChange(&buf, 5, 12.3);
    try std.testing.expect(std.mem.startsWith(u8, pos, "+"));
    try std.testing.expect(std.mem.indexOf(u8, pos, "12.3%") != null);
    const neg = fmtHistoricalChange(&buf, 5, -5.2);
    try std.testing.expect(std.mem.indexOf(u8, neg, "-5.2%") != null);
}

test "fmtCandleRow" {
    var buf: [128]u8 = undefined;
    const candle = Candle{
        .date = Date.fromYmd(2024, 6, 15),
        .open = 150.00,
        .high = 155.00,
        .low = 149.00,
        .close = 153.00,
        .adj_close = 153.00,
        .volume = 50_000_000,
    };
    const row = fmtCandleRow(&buf, candle);
    try std.testing.expect(std.mem.indexOf(u8, row, "150.00") != null);
    try std.testing.expect(std.mem.indexOf(u8, row, "155.00") != null);
    try std.testing.expect(std.mem.indexOf(u8, row, "50,000,000") != null);
}

test "fmtPriceChange" {
    var buf: [32]u8 = undefined;
    const pos = fmtPriceChange(&buf, 3.50, 2.04);
    try std.testing.expect(std.mem.startsWith(u8, pos, "+$3.50"));
    const neg = fmtPriceChange(&buf, -2.00, -1.5);
    try std.testing.expect(std.mem.startsWith(u8, neg, "-$2.00"));
}

test "fmtTimeAgo: zero before_s returns empty string" {
    var buf: [24]u8 = undefined;
    try std.testing.expectEqualStrings("", fmtTimeAgo(&buf, 0, 1_700_000_000));
}

test "fmtTimeAgo: before > after renders 'just now'" {
    var buf: [24]u8 = undefined;
    try std.testing.expectEqualStrings("just now", fmtTimeAgo(&buf, 1_700_000_050, 1_700_000_000));
}

test "fmtTimeAgo: sub-minute delta renders 'just now'" {
    var buf: [24]u8 = undefined;
    try std.testing.expectEqualStrings("just now", fmtTimeAgo(&buf, 1_700_000_000, 1_700_000_059));
}

test "fmtTimeAgo: minutes" {
    var buf: [24]u8 = undefined;
    // 5m = 300s
    try std.testing.expectEqualStrings("5m ago", fmtTimeAgo(&buf, 1_700_000_000, 1_700_000_000 + 300));
    // 59m = 3540s (still minutes)
    try std.testing.expectEqualStrings("59m ago", fmtTimeAgo(&buf, 1_700_000_000, 1_700_000_000 + 3540));
}

test "fmtTimeAgo: hours" {
    var buf: [24]u8 = undefined;
    // 1h exactly
    try std.testing.expectEqualStrings("1h ago", fmtTimeAgo(&buf, 1_700_000_000, 1_700_000_000 + 3600));
    // 5h
    try std.testing.expectEqualStrings("5h ago", fmtTimeAgo(&buf, 1_700_000_000, 1_700_000_000 + 5 * 3600));
}

test "fmtTimeAgo: days" {
    var buf: [24]u8 = undefined;
    // 1d = 86_400s
    try std.testing.expectEqualStrings("1d ago", fmtTimeAgo(&buf, 1_700_000_000, 1_700_000_000 + 86_400));
    // 7d
    try std.testing.expectEqualStrings("7d ago", fmtTimeAgo(&buf, 1_700_000_000, 1_700_000_000 + 7 * 86_400));
}

test "fmtAxisDate: span <=720d produces DD MMM" {
    var br: BrailleChart = undefined;
    br.start_date = Date.fromYmd(2026, 1, 1);
    br.end_date = Date.fromYmd(2026, 5, 11);
    var buf: [8]u8 = undefined;
    const lbl = br.fmtAxisDate(Date.fromYmd(2026, 4, 27), &buf);
    try std.testing.expectEqualStrings("27 Apr", lbl);
}

test "fmtAxisDate: ~2y span (around the threshold) produces DD MMM" {
    // 700 days from 2024-01-01 — still inside the threshold.
    var br: BrailleChart = undefined;
    br.start_date = Date.fromYmd(2024, 1, 1);
    br.end_date = Date.fromYmd(2025, 12, 1); // 700 days
    var buf: [8]u8 = undefined;
    const lbl = br.fmtAxisDate(Date.fromYmd(2024, 1, 1), &buf);
    try std.testing.expectEqualStrings("01 Jan", lbl);
}

test "fmtAxisDate: long-history chart shows MMM YYYY for old start, DD MMM for recent end" {
    // 12-year chart: start is way more than 720 days from end,
    // so the start gets MMM YYYY. End is `end_date` itself
    // (age 0), so it gets DD MMM.
    var br: BrailleChart = undefined;
    br.start_date = Date.fromYmd(2014, 7, 3);
    br.end_date = Date.fromYmd(2026, 5, 11);
    var buf: [8]u8 = undefined;
    const start_lbl = br.fmtAxisDate(br.start_date, &buf);
    try std.testing.expectEqualStrings("Jul 2014", start_lbl);
    var buf2: [8]u8 = undefined;
    const end_lbl = br.fmtAxisDate(br.end_date, &buf2);
    try std.testing.expectEqualStrings("11 May", end_lbl);
}

test "fmtAxisDate: boundary at exactly 720 days uses DD MMM" {
    var br: BrailleChart = undefined;
    br.start_date = Date.fromYmd(2025, 1, 1);
    // 720 days later = 2026-12-22.
    br.end_date = Date.fromYmd(2026, 12, 22);
    var buf: [8]u8 = undefined;
    // Date 720 days before end_date: still boundary-inclusive → DD MMM.
    const lbl = br.fmtAxisDate(Date.fromYmd(2025, 1, 1), &buf);
    try std.testing.expectEqualStrings("01 Jan", lbl);
}

test "fmtAxisDate: 721 days before end flips to MMM YYYY" {
    var br: BrailleChart = undefined;
    br.start_date = Date.fromYmd(2024, 12, 31);
    // 721 days after 2024-12-31 = 2026-12-22.
    br.end_date = Date.fromYmd(2026, 12, 22);
    var buf: [8]u8 = undefined;
    // Format the start (which is 721 days before end_date).
    const lbl = br.fmtAxisDate(br.start_date, &buf);
    try std.testing.expectEqualStrings("Dec 2024", lbl);
}

test "displayCols: ASCII bytes count as 1 col each" {
    try std.testing.expectEqual(@as(usize, 0), displayCols(""));
    try std.testing.expectEqual(@as(usize, 5), displayCols("hello"));
    try std.testing.expectEqual(@as(usize, 10), displayCols("2026-05-08"));
}

test "displayCols: multibyte UTF-8 chars count as 1 col" {
    // U+25B6 BLACK RIGHT-POINTING TRIANGLE = 3 bytes / 1 col.
    try std.testing.expectEqual(@as(usize, 1), displayCols("▶"));
    try std.testing.expectEqual(@as(usize, 1), displayCols("▼"));
    // U+2014 EM DASH = 3 bytes / 1 col.
    try std.testing.expectEqual(@as(usize, 1), displayCols("—"));
    // Mixed.
    try std.testing.expectEqual(@as(usize, 17), displayCols("▶ W of 2026-04-22"));
    try std.testing.expectEqual(@as(usize, 10), displayCols("▶ Jan 2026"));
    try std.testing.expectEqual(@as(usize, 6), displayCols("▶ 2024"));
}

test "padRightToCols: ASCII content pads to target" {
    var buf: [16]u8 = undefined;
    @memcpy(buf[0..2], "hi");
    const out = padRightToCols(&buf, buf[0..2], 5);
    try std.testing.expectEqualStrings("hi   ", out);
}

test "padRightToCols: multibyte content pads to display width" {
    var buf: [16]u8 = undefined;
    const dash = "—";
    @memcpy(buf[0..dash.len], dash);
    // Em-dash is 1 col / 3 bytes. Target 5 cols → 4 trailing spaces.
    // Total bytes: 3 + 4 = 7.
    const out = padRightToCols(&buf, buf[0..dash.len], 5);
    try std.testing.expectEqual(@as(usize, 7), out.len);
    try std.testing.expectEqualStrings("—    ", out);
}

test "padRightToCols: content already at-or-beyond target returns as-is" {
    var buf: [16]u8 = undefined;
    @memcpy(buf[0..5], "hello");
    const out = padRightToCols(&buf, buf[0..5], 5);
    try std.testing.expectEqualStrings("hello", out);
    const out2 = padRightToCols(&buf, buf[0..5], 3);
    try std.testing.expectEqualStrings("hello", out2);
}

test "centerDash: even width centers dash with equal padding" {
    var buf: [16]u8 = undefined;
    const out = centerDash(&buf, 6);
    // 6 cols: pad = (6-1)/2 = 2 left spaces, then dash (1 col / 3
    // bytes), then 3 right spaces. Total: 6 cols = 8 bytes.
    try std.testing.expectEqual(@as(usize, 6), displayCols(out));
    try std.testing.expectEqual(@as(usize, 8), out.len);
    try std.testing.expectEqualStrings("  —   ", out);
}

test "centerDash: odd width biases dash one col left of center" {
    var buf: [16]u8 = undefined;
    const out = centerDash(&buf, 5);
    // 5 cols: pad = (5-1)/2 = 2 left spaces, dash, 2 right spaces.
    // Total: 5 cols = 7 bytes.
    try std.testing.expectEqual(@as(usize, 5), displayCols(out));
    try std.testing.expectEqual(@as(usize, 7), out.len);
    try std.testing.expectEqualStrings("  —  ", out);
}

test "centerDash: width 1 emits dash with no padding" {
    var buf: [8]u8 = undefined;
    const out = centerDash(&buf, 1);
    // pad = (1-1)/2 = 0: no left spaces, dash, no right spaces.
    try std.testing.expectEqual(@as(usize, 1), displayCols(out));
    try std.testing.expectEqualStrings("—", out);
}

test "centerDash: width 0 emits empty slice" {
    var buf: [8]u8 = undefined;
    const out = centerDash(&buf, 0);
    // (0 -| 1) / 2 = 0: no left spaces. The `if pos + dash.len
    // <= buf.len` does write the dash though, but then
    // padRightToCols sees content with display-width 1 against
    // a target of 0 and returns the content as-is. So we get a
    // bare dash even at width 0. Degenerate case the renderer
    // never hits in practice (cell widths are always > 0); lock
    // in current behavior.
    try std.testing.expectEqualStrings("—", out);
}

test "centerDash: typical history-table cell width (31 cols)" {
    // This is the actual table_cell_width used in the History
    // tab — em-dash centered in 31 columns.
    var buf: [40]u8 = undefined;
    const out = centerDash(&buf, 31);
    // pad = 15, dash (1 col), 15 right spaces. 31 cols = 33 bytes.
    try std.testing.expectEqual(@as(usize, 31), displayCols(out));
    try std.testing.expectEqual(@as(usize, 33), out.len);
    try std.testing.expectEqualStrings("               —               ", out);
}

test "centerDash: undersized buffer returns less than `width` cols" {
    // Defensive: buffer too small to hold the full padded cell.
    // Function falls back to whatever fits without overflowing.
    var buf: [4]u8 = undefined;
    const out = centerDash(&buf, 10);
    // pad = 4 spaces wanted but only 4-byte buf — left-loop fills
    // to pos=4, then `pos + dash.len <= buf.len` is `4+3<=4` =
    // false, so dash isn't written. Trailing-pad helper sees
    // content with 4 cols against target 10, and 4+pad>buf.len
    // so it returns content unchanged. Output is 4 spaces.
    try std.testing.expectEqualStrings("    ", out);
}

test "truncateToCols: shorter than max returns unchanged" {
    try std.testing.expectEqualStrings("Foo", truncateToCols("Foo", 5));
    try std.testing.expectEqualStrings("", truncateToCols("", 5));
}

test "truncateToCols: ASCII at boundary" {
    try std.testing.expectEqualStrings("Hello", truncateToCols("Hello, world", 5));
    try std.testing.expectEqualStrings("Hello", truncateToCols("Hello", 5));
    try std.testing.expectEqualStrings("Hell", truncateToCols("Hello", 4));
}

test "truncateToCols: zero max returns empty slice" {
    try std.testing.expectEqualStrings("", truncateToCols("Anything", 0));
}

test "truncateToCols: multibyte glyphs counted as one column each" {
    // Em-dash is 3 bytes / 1 column. Three em-dashes in 9 bytes,
    // counted as 3 columns. Truncating to 2 columns should yield
    // the first two em-dashes (6 bytes).
    const dashes = "———xyz"; // 3 dashes (9 bytes) + 3 ASCII = 6 cols
    try std.testing.expectEqualStrings("——", truncateToCols(dashes, 2));
    try std.testing.expectEqualStrings("———", truncateToCols(dashes, 3));
    try std.testing.expectEqualStrings("———x", truncateToCols(dashes, 4));
}

test "truncateToCols: never slices a multibyte sequence in half" {
    const s = "a—b"; // 'a' (1B/1col), em-dash (3B/1col), 'b' (1B/1col) = 5B/3cols
    // Truncating to 1 col gets just 'a'.
    try std.testing.expectEqualStrings("a", truncateToCols(s, 1));
    // 2 cols gets 'a' + em-dash.
    try std.testing.expectEqualStrings("a—", truncateToCols(s, 2));
    // 3 cols (full string fits).
    try std.testing.expectEqualStrings("a—b", truncateToCols(s, 3));
}

test "fmtPct: default decimals is 1" {
    var buf: [16]u8 = undefined;
    try std.testing.expectEqualStrings("12.3%", fmtPct(&buf, 0.1234, .{}));
}

test "fmtPct: signed positive shows + prefix" {
    var buf: [16]u8 = undefined;
    try std.testing.expectEqualStrings("+12.3%", fmtPct(&buf, 0.1234, .{ .signed = true }));
}

test "fmtPct: signed negative does not double-sign" {
    var buf: [16]u8 = undefined;
    // {d} already produces the leading `-`; opts.signed only adds `+`.
    try std.testing.expectEqualStrings("-5.0%", fmtPct(&buf, -0.05, .{ .signed = true }));
}

test "fmtPct: asterisk appended after percent" {
    var buf: [16]u8 = undefined;
    try std.testing.expectEqualStrings("12.3%*", fmtPct(&buf, 0.1234, .{ .asterisk = true }));
    try std.testing.expectEqualStrings("+12.3%*", fmtPct(&buf, 0.1234, .{ .signed = true, .asterisk = true }));
}

test "fmtPct: decimals selector" {
    var buf: [16]u8 = undefined;
    try std.testing.expectEqualStrings("12%", fmtPct(&buf, 0.1234, .{ .decimals = 0 }));
    try std.testing.expectEqualStrings("12.34%", fmtPct(&buf, 0.1234, .{ .decimals = 2 }));
    try std.testing.expectEqualStrings("12.340%", fmtPct(&buf, 0.1234, .{ .decimals = 3 }));
}

test "fmtPctOpt: null returns sentinel" {
    var buf: [16]u8 = undefined;
    try std.testing.expectEqualStrings(no_data_sentinel, fmtPctOpt(&buf, null, .{}));
}

test "fmtPctOpt: value forwards to fmtPct with the same opts" {
    var buf: [16]u8 = undefined;
    try std.testing.expectEqualStrings("+12.3%", fmtPctOpt(&buf, 0.1234, .{ .signed = true }));
}

test "fmtSharpeOpt: default 2 decimals" {
    var buf: [16]u8 = undefined;
    try std.testing.expectEqualStrings("1.25", fmtSharpeOpt(&buf, 1.25, .{}));
    try std.testing.expectEqualStrings("-0.30", fmtSharpeOpt(&buf, -0.3, .{}));
}

test "fmtSharpeOpt: null returns sentinel" {
    var buf: [16]u8 = undefined;
    try std.testing.expectEqualStrings(no_data_sentinel, fmtSharpeOpt(&buf, null, .{}));
}

test "fmtSharpeOpt: asterisk and decimals" {
    var buf: [16]u8 = undefined;
    try std.testing.expectEqualStrings("1.25*", fmtSharpeOpt(&buf, 1.25, .{ .asterisk = true }));
    try std.testing.expectEqualStrings("1.3*", fmtSharpeOpt(&buf, 1.25, .{ .asterisk = true, .decimals = 1 }));
}
