//! Projection chart renderer using z2d.
//! Renders percentile bands (p10-p90, p25-p75) with a median line to raw RGB
//! pixel data suitable for Kitty graphics protocol transmission.
//!
//! Visual layers (bottom to top):
//!   - Background
//!   - Horizontal grid lines
//!   - "Today" vertical reference line (when overlay is active)
//!   - p10-p90 band fill (faint)
//!   - p25-p75 band fill (medium)
//!   - p10/p90 boundary lines
//!   - Median (p50) line (solid)
//!   - Zero line (if visible)
//!   - Actuals overlay line (when present)
//!   - Panel border

const std = @import("std");
const z2d = @import("z2d");
const theme = @import("theme.zig");
const projections = @import("../analytics/projections.zig");

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

/// Margins in pixels.
const margin_left: f64 = 4;
const margin_right: f64 = 4;
const margin_top: f64 = 4;
const margin_bottom: f64 = 4;

/// Single (year-offset, liquid-value) point on the actuals overlay.
/// Mirrors `views/projections.ActualsPoint` but kept duplicated here
/// so the chart module doesn't depend on the views layer.
pub const ActualsPoint = struct {
    years_from_as_of: f64,
    liquid: f64,
};

/// Optional actuals-overlay input. When non-null, the chart draws:
///   - A thin vertical "today" reference line at `today_years`.
///   - A connected line through `points` (cyan), wider than 1px so
///     it reads against the bands.
/// Y-range is expanded to include any actuals values that exceed
/// the band envelope, so the line never clips off-chart.
pub const ActualsOverlay = struct {
    points: []const ActualsPoint,
    today_years: f64,
};

/// Projection chart render result.
pub const ProjectionChartResult = struct {
    /// Raw RGB pixel data (3 bytes per pixel, row-major).
    rgb_data: []const u8,
    width: u16,
    height: u16,
    /// Value range for external label rendering.
    value_min: f64,
    value_max: f64,
};

/// Owned by the caller — call `result.deinit(alloc)` after using it.
/// Used as the shared mid-stage between RGB extraction (kitty) and
/// PNG export (`--export-chart`). See `renderToSurface`.
pub const RenderedProjection = struct {
    surface: Surface,
    width: u16,
    height: u16,
    value_min: f64,
    value_max: f64,

    pub fn deinit(self: *RenderedProjection, alloc: std.mem.Allocator) void {
        self.surface.deinit(alloc);
        self.* = undefined;
    }

    /// Extract a flat []u8 of R,G,B triplets from the surface buffer.
    /// Caller owns the returned slice. The surface is left intact.
    pub fn extractRgb(self: *const RenderedProjection, alloc: std.mem.Allocator) ![]u8 {
        const rgb_buf = switch (self.surface) {
            .image_surface_rgb => |s| s.buf,
            else => unreachable,
        };
        const raw = try alloc.alloc(u8, rgb_buf.len * 3);
        for (rgb_buf, 0..) |px, i| {
            raw[i * 3 + 0] = px.r;
            raw[i * 3 + 1] = px.g;
            raw[i * 3 + 2] = px.b;
        }
        return raw;
    }
};

/// Render a projection percentile band chart into a `Surface` and
/// return both. Caller owns the result and must call `deinit`.
///
/// Two consumers today:
///   - `renderProjectionChart` wraps this for the TUI's kitty
///     graphics path (extracts RGB, frees surface).
///   - `--export-chart` (CLI) wraps this for PNG export via
///     `z2d.png_exporter.writeToPNGFile`.
pub fn renderToSurface(
    io: std.Io,
    alloc: std.mem.Allocator,
    bands: []const projections.YearPercentiles,
    width_px: u32,
    height_px: u32,
    th: theme.Theme,
    actuals: ?ActualsOverlay,
) !RenderedProjection {
    if (bands.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);
    errdefer 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();

    // Chart area
    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;

    // Compute value range from all bands
    var value_min: f64 = bands[0].p10;
    var value_max: f64 = bands[0].p90;
    for (bands) |bp| {
        if (bp.p10 < value_min) value_min = bp.p10;
        if (bp.p90 > value_max) value_max = bp.p90;
    }
    // Expand to include any actuals that punch through the band
    // envelope. Without this, a portfolio that out- or under-performed
    // the model would clip off-chart.
    if (actuals) |ov| {
        for (ov.points) |p| {
            if (p.liquid < value_min) value_min = p.liquid;
            if (p.liquid > value_max) value_max = p.liquid;
        }
    }
    // Add 5% padding
    const pad = (value_max - value_min) * 0.05;
    value_min -= pad;
    value_max += pad;
    if (value_min < 0) value_min = 0;

    // X step (one point per year)
    const x_step = chart_w / @as(f64, @floatFromInt(bands.len - 1));

    // ── 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);

    // ── "Today" vertical reference line (overlay only) ───────────
    //
    // When the actuals overlay is active, draw a thin vertical line
    // at `today_years` along the x-axis. This visually separates the
    // realized past (left of the line) from the projected future
    // (right of the line). Drawn before bands so it sits behind the
    // data — a quiet reference, not a focal point.
    if (actuals) |ov| {
        const horizon_years: f64 = @floatFromInt(bands.len - 1);
        if (horizon_years > 0 and ov.today_years >= 0 and ov.today_years <= horizon_years) {
            const today_x = chart_left + (ov.today_years / horizon_years) * chart_w;
            const today_color = blendColor(th.text_muted, 100, bg);
            try drawVLine(&ctx, today_x, chart_top, chart_bottom, today_color, 1.0);
        }
    }

    // ── p10-p90 outer band fill ──────────────────────────────────
    {
        const band_color = blendColor(th.accent, 20, bg);
        ctx.setSourceToPixel(band_color);
        ctx.resetPath();

        // Forward along p90 (upper)
        for (bands, 0..) |bp, i| {
            const x = chart_left + @as(f64, @floatFromInt(i)) * x_step;
            const y = mapY(bp.p90, value_min, value_max, chart_top, chart_bottom);
            if (i == 0) try ctx.moveTo(x, y) else try ctx.lineTo(x, y);
        }
        // Backward along p10 (lower)
        var i: usize = bands.len;
        while (i > 0) {
            i -= 1;
            const x = chart_left + @as(f64, @floatFromInt(i)) * x_step;
            const y = mapY(bands[i].p10, value_min, value_max, chart_top, chart_bottom);
            try ctx.lineTo(x, y);
        }
        try ctx.closePath();
        try ctx.fill();
    }

    // ── p25-p75 inner band fill ──────────────────────────────────
    {
        const band_color = blendColor(th.accent, 40, bg);
        ctx.setSourceToPixel(band_color);
        ctx.resetPath();

        // Forward along p75 (upper)
        for (bands, 0..) |bp, i| {
            const x = chart_left + @as(f64, @floatFromInt(i)) * x_step;
            const y = mapY(bp.p75, value_min, value_max, chart_top, chart_bottom);
            if (i == 0) try ctx.moveTo(x, y) else try ctx.lineTo(x, y);
        }
        // Backward along p25 (lower)
        var i: usize = bands.len;
        while (i > 0) {
            i -= 1;
            const x = chart_left + @as(f64, @floatFromInt(i)) * x_step;
            const y = mapY(bands[i].p25, value_min, value_max, chart_top, chart_bottom);
            try ctx.lineTo(x, y);
        }
        try ctx.closePath();
        try ctx.fill();
    }

    // ── Median (p50) line ────────────────────────────────────────
    {
        const line_color = opaqueColor(th.accent);
        ctx.setSourceToPixel(line_color);
        ctx.setLineWidth(2.0);
        ctx.resetPath();
        for (bands, 0..) |bp, i| {
            const x = chart_left + @as(f64, @floatFromInt(i)) * x_step;
            const y = mapY(bp.p50, value_min, value_max, chart_top, chart_bottom);
            if (i == 0) try ctx.moveTo(x, y) else try ctx.lineTo(x, y);
        }
        try ctx.stroke();
    }

    // ── p10 and p90 boundary lines (thin, muted) ─────────────────
    {
        const line_color = blendColor(th.text_muted, 80, bg);
        ctx.setSourceToPixel(line_color);
        ctx.setLineWidth(1.0);
        ctx.resetPath();
        for (bands, 0..) |bp, i| {
            const x = chart_left + @as(f64, @floatFromInt(i)) * x_step;
            const y = mapY(bp.p90, value_min, value_max, chart_top, chart_bottom);
            if (i == 0) try ctx.moveTo(x, y) else try ctx.lineTo(x, y);
        }
        try ctx.stroke();

        ctx.resetPath();
        for (bands, 0..) |bp, i| {
            const x = chart_left + @as(f64, @floatFromInt(i)) * x_step;
            const y = mapY(bp.p10, value_min, value_max, chart_top, chart_bottom);
            if (i == 0) try ctx.moveTo(x, y) else try ctx.lineTo(x, y);
        }
        try ctx.stroke();
    }

    // ── Zero line (if visible) ───────────────────────────────────
    if (value_min <= 0 and value_max > 0) {
        const zero_y = mapY(0, value_min, value_max, chart_top, chart_bottom);
        const zero_color = blendColor(th.negative, 120, bg);
        try drawHLine(&ctx, chart_left, chart_right, zero_y, zero_color, 1.0);
    }

    // ── Actuals overlay line ─────────────────────────────────────
    //
    // Drawn after all band-related layers (and the zero line) so the
    // realized trajectory sits on top of the projection envelope.
    // Cyan (`th.info`) keeps it visually distinct from the purple
    // band/median palette. Slightly thinner than the median (1.5 vs
    // 2.0) so the median stays the anchor of the projection.
    if (actuals) |ov| {
        if (ov.points.len >= 2) {
            const horizon_years: f64 = @floatFromInt(bands.len - 1);
            const line_color = opaqueColor(th.info);
            ctx.setSourceToPixel(line_color);
            ctx.setLineWidth(1.5);
            ctx.resetPath();
            for (ov.points, 0..) |p, i| {
                // Clamp to chart bounds — overlay points should
                // always be in [0, today_years] which is <= horizon,
                // but defending against bad input is cheap.
                const yr = if (horizon_years > 0)
                    @max(0.0, @min(p.years_from_as_of, horizon_years))
                else
                    0.0;
                const x = chart_left + if (horizon_years > 0)
                    (yr / horizon_years) * chart_w
                else
                    0.0;
                const y = mapY(p.liquid, value_min, value_max, chart_top, chart_bottom);
                if (i == 0) try ctx.moveTo(x, y) else try ctx.lineTo(x, y);
            }
            try ctx.stroke();
        }
    }

    // ── Panel border ─────────────────────────────────────────────
    {
        const border_color = blendColor(th.border, 80, bg);
        try drawRect(&ctx, chart_left, chart_top, chart_right, chart_bottom, border_color, 1.0);
    }

    return .{
        .surface = sfc,
        .width = @intCast(width_px),
        .height = @intCast(height_px),
        .value_min = value_min,
        .value_max = value_max,
    };
}

/// Render a projection percentile band chart to raw RGB pixel data.
/// Draws p10-p90 outer band, p25-p75 inner band, and p50 median line.
///
/// `bands` is the array of YearPercentiles (year 0 through horizon).
/// `actuals` is an optional realized-trajectory overlay.
/// The returned rgb_data is allocated with `alloc` and must be freed by caller.
pub fn renderProjectionChart(
    io: std.Io,
    alloc: std.mem.Allocator,
    bands: []const projections.YearPercentiles,
    width_px: u32,
    height_px: u32,
    th: theme.Theme,
    actuals: ?ActualsOverlay,
) !ProjectionChartResult {
    var rendered = try renderToSurface(io, alloc, bands, width_px, height_px, th, actuals);
    defer rendered.deinit(alloc);
    const raw = try rendered.extractRgb(alloc);
    return .{
        .rgb_data = raw,
        .width = rendered.width,
        .height = rendered.height,
        .value_min = rendered.value_min,
        .value_max = rendered.value_max,
    };
}

// ── Drawing helpers ───────────────────────────────────────────────────

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 drawVLine(ctx: *Context, x: f64, y1: f64, y2: f64, col: Pixel, line_w: f64) !void {
    ctx.setSourceToPixel(col);
    ctx.setLineWidth(line_w);
    ctx.resetPath();
    try ctx.moveTo(x, y1);
    try ctx.lineTo(x, y2);
    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);
}

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

test "mapY maps value to pixel coordinate" {
    try std.testing.expectEqual(@as(f64, 500.0), mapY(0, 0, 100, 100, 500));
    try std.testing.expectEqual(@as(f64, 100.0), mapY(100, 0, 100, 100, 500));
    try std.testing.expectEqual(@as(f64, 300.0), mapY(50, 0, 100, 100, 500));
    try std.testing.expectEqual(@as(f64, 300.0), mapY(42, 42, 42, 100, 500));
}

test "blendColor alpha blending" {
    const white = [3]u8{ 255, 255, 255 };
    const black = [3]u8{ 0, 0, 0 };

    const full = blendColor(white, 255, black);
    try std.testing.expectEqual(@as(u8, 255), full.rgb.r);

    const zero = blendColor(white, 0, black);
    try std.testing.expectEqual(@as(u8, 0), zero.rgb.r);

    const half = blendColor(white, 128, black);
    try std.testing.expect(half.rgb.r >= 127 and half.rgb.r <= 129);
}

test "renderProjectionChart produces valid output" {
    const alloc = std.testing.allocator;
    const bands = [_]projections.YearPercentiles{
        .{ .year = 0, .p10 = 8000000, .p25 = 8000000, .p50 = 8000000, .p75 = 8000000, .p90 = 8000000 },
        .{ .year = 10, .p10 = 5000000, .p25 = 8000000, .p50 = 12000000, .p75 = 18000000, .p90 = 25000000 },
        .{ .year = 20, .p10 = 3000000, .p25 = 9000000, .p50 = 18000000, .p75 = 30000000, .p90 = 50000000 },
    };

    const th = @import("theme.zig").default_theme;
    const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, null);
    defer alloc.free(result.rgb_data);

    try std.testing.expectEqual(@as(u16, 200), result.width);
    try std.testing.expectEqual(@as(u16, 100), result.height);
    try std.testing.expectEqual(@as(usize, 200 * 100 * 3), result.rgb_data.len);
    try std.testing.expect(result.value_max > result.value_min);
}

test "renderProjectionChart insufficient data" {
    const alloc = std.testing.allocator;
    const bands = [_]projections.YearPercentiles{
        .{ .year = 0, .p10 = 8000000, .p25 = 8000000, .p50 = 8000000, .p75 = 8000000, .p90 = 8000000 },
    };

    const th = @import("theme.zig").default_theme;
    const result = renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, null);
    try std.testing.expectError(error.InsufficientData, result);
}

test "renderProjectionChart with overlay produces valid output" {
    const alloc = std.testing.allocator;
    const bands = [_]projections.YearPercentiles{
        .{ .year = 0, .p10 = 8000000, .p25 = 8000000, .p50 = 8000000, .p75 = 8000000, .p90 = 8000000 },
        .{ .year = 10, .p10 = 5000000, .p25 = 8000000, .p50 = 12000000, .p75 = 18000000, .p90 = 25000000 },
        .{ .year = 20, .p10 = 3000000, .p25 = 9000000, .p50 = 18000000, .p75 = 30000000, .p90 = 50000000 },
    };
    const points = [_]ActualsPoint{
        .{ .years_from_as_of = 0.0, .liquid = 8000000 },
        .{ .years_from_as_of = 0.5, .liquid = 8500000 },
        .{ .years_from_as_of = 1.0, .liquid = 9200000 },
    };
    const overlay: ActualsOverlay = .{ .points = &points, .today_years = 1.0 };

    const th = @import("theme.zig").default_theme;
    const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay);
    defer alloc.free(result.rgb_data);

    try std.testing.expectEqual(@as(u16, 200), result.width);
    try std.testing.expectEqual(@as(u16, 100), result.height);
    try std.testing.expect(result.value_max > result.value_min);
}

test "renderProjectionChart overlay expands y-range when actuals exceed bands" {
    const alloc = std.testing.allocator;
    // Bands top out at 25M
    const bands = [_]projections.YearPercentiles{
        .{ .year = 0, .p10 = 8000000, .p25 = 8000000, .p50 = 8000000, .p75 = 8000000, .p90 = 8000000 },
        .{ .year = 10, .p10 = 5000000, .p25 = 8000000, .p50 = 12000000, .p75 = 18000000, .p90 = 25000000 },
    };
    // Actuals punch through the top band at 50M
    const points = [_]ActualsPoint{
        .{ .years_from_as_of = 0.0, .liquid = 8000000 },
        .{ .years_from_as_of = 1.0, .liquid = 50000000 },
    };
    const overlay: ActualsOverlay = .{ .points = &points, .today_years = 1.0 };

    const th = @import("theme.zig").default_theme;
    const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay);
    defer alloc.free(result.rgb_data);

    // Without expansion, value_max would be ~25M (band p90 + 5%).
    // With expansion to include actuals, it must be >= 50M.
    try std.testing.expect(result.value_max >= 50_000_000);
}

test "renderProjectionChart overlay with no points renders without crash" {
    const alloc = std.testing.allocator;
    const bands = [_]projections.YearPercentiles{
        .{ .year = 0, .p10 = 8000000, .p25 = 8000000, .p50 = 8000000, .p75 = 8000000, .p90 = 8000000 },
        .{ .year = 10, .p10 = 5000000, .p25 = 8000000, .p50 = 12000000, .p75 = 18000000, .p90 = 25000000 },
    };
    const overlay: ActualsOverlay = .{ .points = &.{}, .today_years = 0.5 };

    const th = @import("theme.zig").default_theme;
    const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay);
    defer alloc.free(result.rgb_data);
    try std.testing.expect(result.rgb_data.len > 0);
}

// ── renderToSurface direct tests ──────────────────────────────────────
//
// Exercise the surface-builder seam introduced for --export-chart.
// Verifies the surface is the right type/dimensions and that
// extractRgb round-trips correctly.

test "renderToSurface returns a populated RGB surface at requested dimensions" {
    const alloc = std.testing.allocator;
    const bands = [_]projections.YearPercentiles{
        .{ .year = 0, .p10 = 1, .p25 = 2, .p50 = 3, .p75 = 4, .p90 = 5 },
        .{ .year = 1, .p10 = 2, .p25 = 3, .p50 = 4, .p75 = 5, .p90 = 6 },
    };
    const th = @import("theme.zig").default_theme;
    var rendered = try renderToSurface(std.testing.io, alloc, &bands, 150, 80, th, null);
    defer rendered.deinit(alloc);

    try std.testing.expectEqual(@as(u16, 150), rendered.width);
    try std.testing.expectEqual(@as(u16, 80), rendered.height);
    switch (rendered.surface) {
        .image_surface_rgb => {},
        else => try std.testing.expect(false),
    }
}

test "renderToSurface fills background with theme bg" {
    const alloc = std.testing.allocator;
    const bands = [_]projections.YearPercentiles{
        .{ .year = 0, .p10 = 100, .p25 = 110, .p50 = 120, .p75 = 130, .p90 = 140 },
        .{ .year = 1, .p10 = 100, .p25 = 110, .p50 = 120, .p75 = 130, .p90 = 140 },
    };
    var th = @import("theme.zig").default_theme;
    th.bg = .{ 0xab, 0xcd, 0xef };

    var rendered = try renderToSurface(std.testing.io, alloc, &bands, 100, 50, th, null);
    defer rendered.deinit(alloc);

    const buf = switch (rendered.surface) {
        .image_surface_rgb => |s| s.buf,
        else => unreachable,
    };
    try std.testing.expectEqual(@as(u8, 0xab), buf[0].r);
    try std.testing.expectEqual(@as(u8, 0xcd), buf[0].g);
    try std.testing.expectEqual(@as(u8, 0xef), buf[0].b);
}

test "renderToSurface is deterministic across calls with same input" {
    const alloc = std.testing.allocator;
    const bands = [_]projections.YearPercentiles{
        .{ .year = 0, .p10 = 100, .p25 = 110, .p50 = 120, .p75 = 130, .p90 = 140 },
        .{ .year = 5, .p10 = 90, .p25 = 110, .p50 = 130, .p75 = 160, .p90 = 200 },
        .{ .year = 10, .p10 = 80, .p25 = 120, .p50 = 160, .p75 = 220, .p90 = 300 },
    };
    const th = @import("theme.zig").default_theme;

    var a = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null);
    defer a.deinit(alloc);
    var b = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null);
    defer b.deinit(alloc);

    const buf_a = switch (a.surface) {
        .image_surface_rgb => |s| s.buf,
        else => unreachable,
    };
    const buf_b = switch (b.surface) {
        .image_surface_rgb => |s| s.buf,
        else => unreachable,
    };
    try std.testing.expectEqual(buf_a.len, buf_b.len);
    var i: usize = 0;
    while (i < buf_a.len) : (i += 50) {
        try std.testing.expectEqual(buf_a[i].r, buf_b[i].r);
        try std.testing.expectEqual(buf_a[i].g, buf_b[i].g);
        try std.testing.expectEqual(buf_a[i].b, buf_b[i].b);
    }
}

test "RenderedProjection.extractRgb produces 3 bytes per pixel" {
    const alloc = std.testing.allocator;
    const bands = [_]projections.YearPercentiles{
        .{ .year = 0, .p10 = 1, .p25 = 2, .p50 = 3, .p75 = 4, .p90 = 5 },
        .{ .year = 1, .p10 = 2, .p25 = 3, .p50 = 4, .p75 = 5, .p90 = 6 },
    };
    const th = @import("theme.zig").default_theme;
    var rendered = try renderToSurface(std.testing.io, alloc, &bands, 50, 40, th, null);
    defer rendered.deinit(alloc);

    const raw = try rendered.extractRgb(alloc);
    defer alloc.free(raw);

    const buf = switch (rendered.surface) {
        .image_surface_rgb => |s| s.buf,
        else => unreachable,
    };
    try std.testing.expectEqual(buf.len * 3, raw.len);
    try std.testing.expectEqual(buf[0].r, raw[0]);
    try std.testing.expectEqual(buf[0].g, raw[1]);
    try std.testing.expectEqual(buf[0].b, raw[2]);
}

test "renderToSurface clamps value_min to zero when bands include negatives" {
    const alloc = std.testing.allocator;
    // p10 dipping below zero with no padding clamping would make
    // the chart's negative region uselessly large.
    const bands = [_]projections.YearPercentiles{
        .{ .year = 0, .p10 = -100, .p25 = -50, .p50 = 0, .p75 = 50, .p90 = 100 },
        .{ .year = 1, .p10 = -200, .p25 = -100, .p50 = 0, .p75 = 100, .p90 = 200 },
    };
    const th = @import("theme.zig").default_theme;
    var rendered = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null);
    defer rendered.deinit(alloc);

    // After 5% padding and the `if (value_min < 0) value_min = 0`
    // clamp inside renderToSurface, the recorded min should be 0
    // even though the raw band min is negative.
    try std.testing.expectEqual(@as(f64, 0), rendered.value_min);
    try std.testing.expect(rendered.value_max > 0);
}
