//! Forecast-evaluation chart renderer using z2d.
//!
//! Sibling to `projection_chart.zig` for plain line-shaped charts
//! (no percentile bands). Used by the projections tab's
//! convergence and return-back-test sub-views.
//!
//! Two render entry points:
//!   - `renderConvergenceChart`: single-series line of
//!     years-until-retirement vs. observation date, with a dashed
//!     `slope=-1` reference line for "perfect convergence" and
//!     small markers on `reached` rows.
//!   - `renderBacktestChart`: multi-series line chart showing
//!     `expected_return` (primary, solid) alongside realized 1y/3y/5y
//!     forward CAGR (faint, line styles vary by horizon). Y=0
//!     reference line for sanity.
//!
//! Both produce raw RGB pixel data for Kitty graphics protocol
//! transmission, mirroring `projection_chart.zig`'s output shape.
//!
//! The two functions share substantial scaffolding (margins,
//! axes, grid lines, value-range expansion). Helpers are
//! file-private; `renderProjectionChart`'s helpers are re-derived
//! locally to avoid leaking implementation details across the
//! module boundary. Sibling rather than shared because the chart
//! shapes are different enough that a shared core would be
//! awkwardly parameterized.

const std = @import("std");
const z2d = @import("z2d");
const theme = @import("theme.zig");
const forecast = @import("../analytics/forecast_evaluation.zig");
const Date = @import("../Date.zig");

const Surface = z2d.Surface;
const Context = z2d.Context;
const Pixel = z2d.Pixel;

const margin_left: f64 = 4;
const margin_right: f64 = 4;
const margin_top: f64 = 4;
const margin_bottom: f64 = 4;

pub const ChartResult = struct {
    rgb_data: []const u8,
    width: u16,
    height: u16,
    /// Y-range used; renderers may want this for label rendering.
    value_min: f64,
    value_max: f64,
};

// ── View 1: Convergence chart ────────────────────────────────

/// Render the convergence chart. X-axis spans
/// `points[0].observation_date` to
/// `points[points.len-1].observation_date`. Y-axis is
/// years-until-retirement (Encoding B per the spec).
///
/// Visual layers (bottom to top):
///   - Background
///   - Horizontal grid lines (at y values 0, 5, 10, ...)
///   - Dashed `slope=-1` reference line: at the leftmost x it
///     starts at `points[0].years_until_retirement` and decreases
///     by 1 year per year of x progression. This is "what the
///     line would look like if the model converged perfectly."
///   - Solid line through the convergence points
///   - Distinct markers on `reached` rows (small filled dots,
///     theme accent color)
pub fn renderConvergenceChart(
    io: std.Io,
    alloc: std.mem.Allocator,
    points: []const forecast.ConvergencePoint,
    width_px: u32,
    height_px: u32,
    th: theme.Theme,
) !ChartResult {
    if (points.len < 2) return error.InsufficientData;

    const w: i32 = @intCast(width_px);
    const h: i32 = @intCast(height_px);
    var sfc = try Surface.init(.image_surface_rgb, alloc, w, h);
    defer sfc.deinit(alloc);

    var ctx = Context.init(io, alloc, &sfc);
    defer ctx.deinit();

    ctx.setAntiAliasingMode(.none);
    ctx.setOperator(.src);

    const bg = th.bg;
    const fwidth: f64 = @floatFromInt(width_px);
    const fheight: f64 = @floatFromInt(height_px);

    // Background
    ctx.setSourceToPixel(opaqueColor(bg));
    ctx.resetPath();
    try ctx.moveTo(0, 0);
    try ctx.lineTo(fwidth, 0);
    try ctx.lineTo(fwidth, fheight);
    try ctx.lineTo(0, fheight);
    try ctx.closePath();
    try ctx.fill();

    const chart_left = margin_left;
    const chart_right = fwidth - margin_right;
    const chart_w = chart_right - chart_left;
    const chart_top = margin_top;
    const chart_bottom = fheight - margin_bottom;

    // X-range: observation_date span
    const x0_days: f64 = @floatFromInt(points[0].observation_date.days);
    const x1_days: f64 = @floatFromInt(points[points.len - 1].observation_date.days);
    const x_span: f64 = if (x1_days > x0_days) x1_days - x0_days else 1.0;

    // Y-range: years_until_retirement, padded
    const y_min: f64 = 0;
    var y_max: f64 = 0;
    for (points) |p| {
        if (p.years_until_retirement > y_max) y_max = p.years_until_retirement;
    }
    // The reference line ends at `points[0].years_until_retirement -
    // (x1 - x0) / 365.25`, which can be negative. Clamp the y-range
    // floor at 0 — negative years-until-retirement isn't a
    // meaningful display value.
    if (y_max < 1) y_max = 1; // ensure at least a 1-year scale
    const y_pad = y_max * 0.1;
    y_max += y_pad;

    // Grid lines
    const grid_color = blendColor(th.text_muted, 40, bg);
    try drawHorizontalGridLines(&ctx, chart_left, chart_right, chart_top, chart_bottom, 5, grid_color);

    // Reference line: slope = -1 year/year, starting at the leftmost
    // anchor's years_until_retirement value. If a point converges
    // perfectly it'd lie on this reference.
    {
        const ref_start_y = points[0].years_until_retirement;
        const x_years_span = x_span / 365.25;
        const ref_end_y = ref_start_y - x_years_span;
        const ref_color = blendColor(th.text_muted, 100, bg);
        ctx.setSourceToPixel(ref_color);
        ctx.setLineWidth(1.0);
        // Dashed: emit segment-pairs.
        const dash_len: f64 = 6.0;
        const gap_len: f64 = 4.0;
        var dx: f64 = 0;
        const total_pixels = chart_w;
        while (dx < total_pixels) {
            const dx_end = @min(dx + dash_len, total_pixels);
            const f0 = dx / total_pixels;
            const f1 = dx_end / total_pixels;
            const y0 = mapY(ref_start_y + (ref_end_y - ref_start_y) * f0, y_min, y_max, chart_top, chart_bottom);
            const y1 = mapY(ref_start_y + (ref_end_y - ref_start_y) * f1, y_min, y_max, chart_top, chart_bottom);
            ctx.resetPath();
            try ctx.moveTo(chart_left + dx, y0);
            try ctx.lineTo(chart_left + dx_end, y1);
            try ctx.stroke();
            dx = dx_end + gap_len;
        }
        ctx.setLineWidth(2.0);
    }

    // Main series: solid line through all points, theme accent.
    {
        ctx.setSourceToPixel(opaqueColor(th.accent));
        ctx.setLineWidth(2.0);
        ctx.resetPath();
        for (points, 0..) |p, i| {
            const dx_days: f64 = @floatFromInt(p.observation_date.days);
            const x_frac = (dx_days - x0_days) / x_span;
            const x = chart_left + x_frac * chart_w;
            const y = mapY(p.years_until_retirement, y_min, y_max, chart_top, chart_bottom);
            if (i == 0) try ctx.moveTo(x, y) else try ctx.lineTo(x, y);
        }
        try ctx.stroke();
    }

    // Reached markers (small filled dots).
    {
        ctx.setSourceToPixel(opaqueColor(th.positive));
        const dot_radius: f64 = 2.5;
        for (points) |p| {
            if (!p.reached) continue;
            const dx_days: f64 = @floatFromInt(p.observation_date.days);
            const x_frac = (dx_days - x0_days) / x_span;
            const x = chart_left + x_frac * chart_w;
            const y = mapY(p.years_until_retirement, y_min, y_max, chart_top, chart_bottom);
            try fillCircle(&ctx, x, y, dot_radius);
        }
    }

    // Border
    try drawRect(&ctx, chart_left, chart_top, chart_right, chart_bottom, blendColor(th.text_muted, 60, bg), 1.0);

    return .{
        .rgb_data = try extractRgb(alloc, &sfc),
        .width = @intCast(width_px),
        .height = @intCast(height_px),
        .value_min = y_min,
        .value_max = y_max,
    };
}

// ── View 2: Return back-test chart ───────────────────────────

/// Pivot of `forecast.BacktestPoint` rows into a single anchor's
/// realized-by-horizon view. One per anchor; passed to
/// `renderBacktestChart` as the renderer-friendly shape.
pub const BacktestAnchor = forecast.BacktestAnchor;

/// Render the return back-test chart. X-axis spans the anchor
/// dates; y-axis is decimal return rate. Renders four lines with
/// distinct hues so the legend is unambiguous; line styles
/// (dotted/dashed/solid) reinforce it for color-blind users:
///   - `expected` (solid, theme accent — purple)
///   - `realized_1y` (dotted, theme info — cyan)
///   - `realized_3y` (dashed, theme warning — yellow)
///   - `realized_5y` (solid, theme positive — green)
///
/// Plus a y=0 reference line.
pub fn renderBacktestChart(
    io: std.Io,
    alloc: std.mem.Allocator,
    anchors: []const BacktestAnchor,
    width_px: u32,
    height_px: u32,
    th: theme.Theme,
) !ChartResult {
    if (anchors.len < 2) return error.InsufficientData;

    const w: i32 = @intCast(width_px);
    const h: i32 = @intCast(height_px);
    var sfc = try Surface.init(.image_surface_rgb, alloc, w, h);
    defer sfc.deinit(alloc);

    var ctx = Context.init(io, alloc, &sfc);
    defer ctx.deinit();

    ctx.setAntiAliasingMode(.none);
    ctx.setOperator(.src);

    const bg = th.bg;
    const fwidth: f64 = @floatFromInt(width_px);
    const fheight: f64 = @floatFromInt(height_px);

    // Background
    ctx.setSourceToPixel(opaqueColor(bg));
    ctx.resetPath();
    try ctx.moveTo(0, 0);
    try ctx.lineTo(fwidth, 0);
    try ctx.lineTo(fwidth, fheight);
    try ctx.lineTo(0, fheight);
    try ctx.closePath();
    try ctx.fill();

    const chart_left = margin_left;
    const chart_right = fwidth - margin_right;
    const chart_w = chart_right - chart_left;
    const chart_top = margin_top;
    const chart_bottom = fheight - margin_bottom;

    // X-range
    const x0_days: f64 = @floatFromInt(anchors[0].anchor_date.days);
    const x1_days: f64 = @floatFromInt(anchors[anchors.len - 1].anchor_date.days);
    const x_span: f64 = if (x1_days > x0_days) x1_days - x0_days else 1.0;

    // Y-range across all four series — include realized_* even
    // when null (skip nulls without contributing).
    var y_min: f64 = 0;
    var y_max: f64 = 0;
    for (anchors) |a| {
        if (a.expected < y_min) y_min = a.expected;
        if (a.expected > y_max) y_max = a.expected;
        if (a.realized_1y) |v| {
            if (v < y_min) y_min = v;
            if (v > y_max) y_max = v;
        }
        if (a.realized_3y) |v| {
            if (v < y_min) y_min = v;
            if (v > y_max) y_max = v;
        }
        if (a.realized_5y) |v| {
            if (v < y_min) y_min = v;
            if (v > y_max) y_max = v;
        }
    }
    const y_range = y_max - y_min;
    const y_pad = if (y_range > 0) y_range * 0.10 else 0.05;
    y_min -= y_pad;
    y_max += y_pad;
    if (y_min > 0) y_min = 0; // ensure y=0 is in view for the reference line

    // Grid lines + y=0 reference (subtle but distinct from the grid).
    const grid_color = blendColor(th.text_muted, 40, bg);
    try drawHorizontalGridLines(&ctx, chart_left, chart_right, chart_top, chart_bottom, 5, grid_color);
    if (y_min < 0 and y_max > 0) {
        const zero_y = mapY(0, y_min, y_max, chart_top, chart_bottom);
        try drawHLine(&ctx, chart_left, chart_right, zero_y, blendColor(th.text_muted, 100, bg), 1.0);
    }

    // Realized series first (so they're below the expected line in z-order).
    // Distinct hues per horizon (cyan/yellow/green) so the legend
    // is unambiguous; line styles (dotted/dashed/solid) reinforce
    // it for users who are color-blind or running a low-contrast
    // theme. Keep these aligned with the legend lines emitted by
    // `drawBacktestWithKitty` in `projections_tab.zig`.
    try drawSeries(&ctx, anchors, .realized_1y, x0_days, x_span, chart_left, chart_w, y_min, y_max, chart_top, chart_bottom, opaqueColor(th.info), 1.5, .dotted);

    try drawSeries(&ctx, anchors, .realized_3y, x0_days, x_span, chart_left, chart_w, y_min, y_max, chart_top, chart_bottom, opaqueColor(th.warning), 1.5, .dashed);

    try drawSeries(&ctx, anchors, .realized_5y, x0_days, x_span, chart_left, chart_w, y_min, y_max, chart_top, chart_bottom, opaqueColor(th.positive), 2.0, .solid);

    // Expected series last (on top): solid, accent, full opacity, bold width.
    try drawSeries(&ctx, anchors, .expected, x0_days, x_span, chart_left, chart_w, y_min, y_max, chart_top, chart_bottom, opaqueColor(th.accent), 2.0, .solid);

    // Border
    try drawRect(&ctx, chart_left, chart_top, chart_right, chart_bottom, blendColor(th.text_muted, 60, bg), 1.0);

    return .{
        .rgb_data = try extractRgb(alloc, &sfc),
        .width = @intCast(width_px),
        .height = @intCast(height_px),
        .value_min = y_min,
        .value_max = y_max,
    };
}

const SeriesKey = enum { expected, realized_1y, realized_3y, realized_5y };
const LineStyle = enum { solid, dashed, dotted };
const DashPattern = struct { on: f64, off: f64 };

fn anchorValue(a: BacktestAnchor, key: SeriesKey) ?f64 {
    return switch (key) {
        .expected => a.expected,
        .realized_1y => a.realized_1y,
        .realized_3y => a.realized_3y,
        .realized_5y => a.realized_5y,
    };
}

/// Draw one series across the anchor list, skipping null values.
/// Disconnected (null-bridging) segments are emitted as separate
/// strokes — the line "lifts" over missing data rather than
/// drawing a phantom horizontal segment.
fn drawSeries(
    ctx: *Context,
    anchors: []const BacktestAnchor,
    key: SeriesKey,
    x0_days: f64,
    x_span: f64,
    chart_left: f64,
    chart_w: f64,
    y_min: f64,
    y_max: f64,
    chart_top: f64,
    chart_bottom: f64,
    color: Pixel,
    line_w: f64,
    style: LineStyle,
) !void {
    ctx.setSourceToPixel(color);
    ctx.setLineWidth(line_w);

    const dash_pattern: ?DashPattern = switch (style) {
        .solid => null,
        .dashed => .{ .on = 6.0, .off = 4.0 },
        .dotted => .{ .on = 2.0, .off = 3.0 },
    };

    var have_segment = false;

    // Emit one stroke per contiguous run of non-null values.
    // A null value breaks the run.
    for (anchors, 0..) |a, i| {
        const v_opt = anchorValue(a, key);
        if (v_opt) |_| {
            have_segment = true;
            // If this is the last anchor, flush the segment.
            if (i == anchors.len - 1) {
                try strokeSegment(ctx, anchors, key, x0_days, x_span, chart_left, chart_w, y_min, y_max, chart_top, chart_bottom, dash_pattern);
                have_segment = false;
            }
        } else if (have_segment) {
            // Run broke. Stroke from segment start to last-known endpoint.
            try strokeSegment(ctx, anchors[0..i], key, x0_days, x_span, chart_left, chart_w, y_min, y_max, chart_top, chart_bottom, dash_pattern);
            have_segment = false;
        }
    }

    ctx.setLineWidth(2.0);
}

/// Stroke the contiguous non-null segment of `anchors` for `key`.
/// For dashed/dotted styles, the segment is rasterized as
/// independent dash-length strokes rather than one continuous
/// path with z2d's dash array (which we don't use for cross-version
/// stability). Solid styles emit one continuous stroke.
fn strokeSegment(
    ctx: *Context,
    anchors: []const BacktestAnchor,
    key: SeriesKey,
    x0_days: f64,
    x_span: f64,
    chart_left: f64,
    chart_w: f64,
    y_min: f64,
    y_max: f64,
    chart_top: f64,
    chart_bottom: f64,
    dash: ?DashPattern,
) !void {
    if (dash) |d| {
        // Segment-by-segment with manual dashing along each
        // pixel-length straight line between consecutive points.
        var prev_x: ?f64 = null;
        var prev_y: ?f64 = null;
        for (anchors) |a| {
            const v_opt = anchorValue(a, key);
            if (v_opt) |v| {
                const dx_days: f64 = @floatFromInt(a.anchor_date.days);
                const x = chart_left + ((dx_days - x0_days) / x_span) * chart_w;
                const y = mapY(v, y_min, y_max, chart_top, chart_bottom);
                if (prev_x) |px| {
                    const py = prev_y.?;
                    try drawDashedLine(ctx, px, py, x, y, d.on, d.off);
                }
                prev_x = x;
                prev_y = y;
            } else {
                prev_x = null;
                prev_y = null;
            }
        }
    } else {
        // Solid: one path, then stroke.
        var first = true;
        ctx.resetPath();
        for (anchors) |a| {
            const v_opt = anchorValue(a, key);
            if (v_opt) |v| {
                const dx_days: f64 = @floatFromInt(a.anchor_date.days);
                const x = chart_left + ((dx_days - x0_days) / x_span) * chart_w;
                const y = mapY(v, y_min, y_max, chart_top, chart_bottom);
                if (first) {
                    try ctx.moveTo(x, y);
                    first = false;
                } else {
                    try ctx.lineTo(x, y);
                }
            }
        }
        if (!first) try ctx.stroke();
    }
}

fn drawDashedLine(ctx: *Context, x1: f64, y1: f64, x2: f64, y2: f64, dash_on: f64, dash_off: f64) !void {
    const dx = x2 - x1;
    const dy = y2 - y1;
    const len = std.math.sqrt(dx * dx + dy * dy);
    if (len <= 0) return;

    const ux = dx / len;
    const uy = dy / len;
    var t: f64 = 0;
    while (t < len) {
        const t_end = @min(t + dash_on, len);
        const sx = x1 + t * ux;
        const sy = y1 + t * uy;
        const ex = x1 + t_end * ux;
        const ey = y1 + t_end * uy;
        ctx.resetPath();
        try ctx.moveTo(sx, sy);
        try ctx.lineTo(ex, ey);
        try ctx.stroke();
        t = t_end + dash_off;
    }
}

fn fillCircle(ctx: *Context, cx: f64, cy: f64, r: f64) !void {
    // z2d doesn't expose `arc` here at present; approximate with
    // an N-sided polygon. 12 sides is plenty for a 2-3 px dot.
    const n: usize = 12;
    ctx.resetPath();
    var i: usize = 0;
    while (i < n) : (i += 1) {
        const ang = @as(f64, @floatFromInt(i)) * 2.0 * std.math.pi / @as(f64, @floatFromInt(n));
        const x = cx + r * @cos(ang);
        const y = cy + r * @sin(ang);
        if (i == 0) try ctx.moveTo(x, y) else try ctx.lineTo(x, y);
    }
    try ctx.closePath();
    try ctx.fill();
}

// ── Shared helpers (mirrors of projection_chart's privates) ───

fn mapY(value: f64, min_val: f64, max_val: f64, top_px: f64, bottom_px: f64) f64 {
    if (max_val == min_val) return (top_px + bottom_px) / 2;
    const norm = (value - min_val) / (max_val - min_val);
    return bottom_px - norm * (bottom_px - top_px);
}

fn blendColor(fg: [3]u8, alpha: u8, bg_color: [3]u8) Pixel {
    const a = @as(f64, @floatFromInt(alpha)) / 255.0;
    const inv_a = 1.0 - a;
    return .{ .rgb = .{
        .r = @intFromFloat(@as(f64, @floatFromInt(fg[0])) * a + @as(f64, @floatFromInt(bg_color[0])) * inv_a),
        .g = @intFromFloat(@as(f64, @floatFromInt(fg[1])) * a + @as(f64, @floatFromInt(bg_color[1])) * inv_a),
        .b = @intFromFloat(@as(f64, @floatFromInt(fg[2])) * a + @as(f64, @floatFromInt(bg_color[2])) * inv_a),
    } };
}

fn opaqueColor(c: [3]u8) Pixel {
    return .{ .rgb = .{ .r = c[0], .g = c[1], .b = c[2] } };
}

fn drawHorizontalGridLines(
    ctx: *Context,
    left: f64,
    right: f64,
    top: f64,
    bottom: f64,
    n_lines: usize,
    col: Pixel,
) !void {
    ctx.setSourceToPixel(col);
    ctx.setLineWidth(0.5);
    for (1..n_lines) |i| {
        const frac = @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(n_lines));
        const y = top + frac * (bottom - top);
        ctx.resetPath();
        try ctx.moveTo(left, y);
        try ctx.lineTo(right, y);
        try ctx.stroke();
    }
    ctx.setLineWidth(2.0);
}

fn drawHLine(ctx: *Context, x1: f64, x2: f64, y: f64, col: Pixel, line_w: f64) !void {
    ctx.setSourceToPixel(col);
    ctx.setLineWidth(line_w);
    ctx.resetPath();
    try ctx.moveTo(x1, y);
    try ctx.lineTo(x2, y);
    try ctx.stroke();
    ctx.setLineWidth(2.0);
}

fn drawRect(ctx: *Context, x1: f64, y1: f64, x2: f64, y2: f64, col: Pixel, line_w: f64) !void {
    ctx.setSourceToPixel(col);
    ctx.setLineWidth(line_w);
    ctx.resetPath();
    try ctx.moveTo(x1, y1);
    try ctx.lineTo(x2, y1);
    try ctx.lineTo(x2, y2);
    try ctx.lineTo(x1, y2);
    try ctx.closePath();
    try ctx.stroke();
    ctx.setLineWidth(2.0);
}

/// Extract raw RGB bytes from an `image_surface_rgb`. Mirrors the
/// inline pattern in `projection_chart.zig` so both renderers
/// produce the same on-the-wire shape for Kitty graphics
/// transmission. Caller owns the returned slice.
fn extractRgb(alloc: std.mem.Allocator, sfc: *const Surface) ![]u8 {
    const rgb_buf = switch (sfc.*) {
        .image_surface_rgb => |s| s.buf,
        else => unreachable,
    };
    const out = try alloc.alloc(u8, rgb_buf.len * 3);
    for (rgb_buf, 0..) |px, i| {
        out[i * 3 + 0] = px.r;
        out[i * 3 + 1] = px.g;
        out[i * 3 + 2] = px.b;
    }
    return out;
}

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

const testing = std.testing;

test "renderConvergenceChart produces RGB output" {
    const points = [_]forecast.ConvergencePoint{
        .{ .observation_date = Date.fromYmd(2020, 1, 1), .projected_date = Date.fromYmd(2030, 1, 1), .years_until_retirement = 10.0, .reached = false },
        .{ .observation_date = Date.fromYmd(2022, 1, 1), .projected_date = Date.fromYmd(2030, 1, 1), .years_until_retirement = 8.0, .reached = false },
        .{ .observation_date = Date.fromYmd(2025, 1, 1), .projected_date = Date.fromYmd(2025, 1, 1), .years_until_retirement = 0.0, .reached = true },
    };
    const th = theme.default_theme;
    const result = try renderConvergenceChart(testing.io, testing.allocator, &points, 200, 100, th);
    defer testing.allocator.free(result.rgb_data);
    try testing.expectEqual(@as(u16, 200), result.width);
    try testing.expectEqual(@as(u16, 100), result.height);
    try testing.expectEqual(@as(usize, 200 * 100 * 3), result.rgb_data.len);
}

test "renderConvergenceChart insufficient data" {
    const points = [_]forecast.ConvergencePoint{
        .{ .observation_date = Date.fromYmd(2020, 1, 1), .projected_date = Date.fromYmd(2030, 1, 1), .years_until_retirement = 10.0, .reached = false },
    };
    const th = theme.default_theme;
    const result = renderConvergenceChart(testing.io, testing.allocator, &points, 200, 100, th);
    try testing.expectError(error.InsufficientData, result);
}

test "renderBacktestChart produces RGB output with all four series" {
    const anchors = [_]BacktestAnchor{
        .{ .anchor_date = Date.fromYmd(2018, 1, 1), .expected = 0.10, .realized_1y = 0.12, .realized_3y = 0.09, .realized_5y = 0.08 },
        .{ .anchor_date = Date.fromYmd(2020, 1, 1), .expected = 0.08, .realized_1y = 0.18, .realized_3y = 0.10, .realized_5y = null },
        .{ .anchor_date = Date.fromYmd(2022, 1, 1), .expected = 0.12, .realized_1y = -0.05, .realized_3y = null, .realized_5y = null },
        .{ .anchor_date = Date.fromYmd(2024, 1, 1), .expected = 0.07, .realized_1y = null, .realized_3y = null, .realized_5y = null },
    };
    const th = theme.default_theme;
    const result = try renderBacktestChart(testing.io, testing.allocator, &anchors, 200, 100, th);
    defer testing.allocator.free(result.rgb_data);
    try testing.expectEqual(@as(u16, 200), result.width);
    try testing.expect(result.value_max > result.value_min);
    // Y range should include at least y=0 (we force it in)
    try testing.expect(result.value_min <= 0);
}

test "renderBacktestChart insufficient data" {
    const anchors = [_]BacktestAnchor{
        .{ .anchor_date = Date.fromYmd(2020, 1, 1), .expected = 0.10, .realized_1y = null, .realized_3y = null, .realized_5y = null },
    };
    const th = theme.default_theme;
    const result = renderBacktestChart(testing.io, testing.allocator, &anchors, 200, 100, th);
    try testing.expectError(error.InsufficientData, result);
}
