//! Financial chart renderer using z2d.
//! Renders price + Bollinger Bands, volume bars, and RSI panel to raw RGB pixel data
//! suitable for Kitty graphics protocol transmission.

const std = @import("std");
const z2d = @import("z2d");
const zfin = @import("../root.zig");
const theme = @import("theme.zig");

const Surface = z2d.Surface;

/// Chart rendering mode.
pub const ChartMode = enum {
    /// Auto-detect: use Kitty graphics if terminal supports it, otherwise braille.
    auto,
    /// Force braille chart (no pixel graphics).
    braille,
    /// Kitty graphics with a custom resolution cap (width x height).
    kitty,
};

/// Chart graphics configuration.
pub const ChartConfig = struct {
    mode: ChartMode = .auto,
    max_width: u32 = 1920,
    max_height: u32 = 1080,

    /// Parse a --chart argument value.
    /// Accepted formats:
    ///   "auto"       — auto-detect (default)
    ///   "braille"    — force braille
    ///   "WxH"        — Kitty graphics with custom resolution (e.g. "1920x1080")
    pub fn parse(value: []const u8) ?ChartConfig {
        if (std.mem.eql(u8, value, "auto")) return .{ .mode = .auto };
        if (std.mem.eql(u8, value, "braille")) return .{ .mode = .braille };

        // Try WxH format
        if (std.mem.indexOfScalar(u8, value, 'x')) |sep| {
            const w = std.fmt.parseInt(u32, value[0..sep], 10) catch return null;
            const h = std.fmt.parseInt(u32, value[sep + 1 ..], 10) catch return null;
            if (w < 100 or h < 100) return null;
            return .{ .mode = .kitty, .max_width = w, .max_height = h };
        }
        return null;
    }
};
const Context = z2d.Context;
const Pixel = z2d.Pixel;

/// Chart timeframe selection.
pub const Timeframe = enum {
    @"6M",
    ytd,
    @"1Y",
    @"3Y",
    @"5Y",

    pub fn label(self: Timeframe) []const u8 {
        return switch (self) {
            .@"6M" => "6M",
            .ytd => "YTD",
            .@"1Y" => "1Y",
            .@"3Y" => "3Y",
            .@"5Y" => "5Y",
        };
    }

    pub fn tradingDays(self: Timeframe) usize {
        return switch (self) {
            .@"6M" => 126,
            .ytd => 252, // approximation, we'll clamp
            .@"1Y" => 252,
            .@"3Y" => 756,
            .@"5Y" => 1260,
        };
    }

    pub fn next(self: Timeframe) Timeframe {
        return switch (self) {
            .@"6M" => .ytd,
            .ytd => .@"1Y",
            .@"1Y" => .@"3Y",
            .@"3Y" => .@"5Y",
            .@"5Y" => .@"6M",
        };
    }

    pub fn prev(self: Timeframe) Timeframe {
        return switch (self) {
            .@"6M" => .@"5Y",
            .ytd => .@"6M",
            .@"1Y" => .ytd,
            .@"3Y" => .@"1Y",
            .@"5Y" => .@"3Y",
        };
    }
};

/// Layout constants (fractions of total height).
const price_frac: f64 = 0.72; // price panel takes 72%
const rsi_frac: f64 = 0.20; // RSI panel takes 20%
const gap_frac: f64 = 0.08; // gap between panels

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

/// Chart render result — raw RGB pixel data ready for Kitty graphics transmission.
pub const ChartResult = struct {
    /// Raw RGB pixel data (3 bytes per pixel, row-major).
    rgb_data: []const u8,
    width: u16,
    height: u16,
    /// Price range for external label rendering.
    price_min: f64,
    price_max: f64,
    /// Latest RSI value (or null if not enough data).
    rsi_latest: ?f64,
};

/// Cached indicator data to avoid recomputation across frames.
/// All slices are owned and must be freed with deinit().
pub const CachedIndicators = struct {
    closes: []f64,
    volumes: []f64,
    bb: []?zfin.indicators.BollingerBand,
    rsi_vals: []?f64,

    /// Free all allocated memory.
    pub fn deinit(self: *CachedIndicators, alloc: std.mem.Allocator) void {
        alloc.free(self.closes);
        alloc.free(self.volumes);
        alloc.free(self.bb);
        alloc.free(self.rsi_vals);
        self.* = undefined;
    }
};

/// Compute indicators for the given candle data.
/// Returns owned CachedIndicators that must be freed with deinit().
pub fn computeIndicators(
    alloc: std.mem.Allocator,
    candles: []const zfin.Candle,
    timeframe: Timeframe,
) !CachedIndicators {
    if (candles.len < 20) return error.InsufficientData;

    // Slice candles to timeframe
    const max_days = timeframe.tradingDays();
    const n = @min(candles.len, max_days);
    const data = candles[candles.len - n ..];

    // Extract data series
    const closes = try zfin.indicators.closePrices(alloc, data);
    errdefer alloc.free(closes);

    const vols = try zfin.indicators.volumes(alloc, data);
    errdefer alloc.free(vols);

    // Compute indicators
    const bb = try zfin.indicators.bollingerBands(alloc, closes, 20, 2.0);
    errdefer alloc.free(bb);

    const rsi_vals = try zfin.indicators.rsi(alloc, closes, 14);

    return .{
        .closes = closes,
        .volumes = vols,
        .bb = bb,
        .rsi_vals = rsi_vals,
    };
}

/// Render a complete financial chart to raw RGB pixel data.
/// The returned rgb_data is allocated with `alloc` and must be freed by caller.
/// If `cached` is provided, uses pre-computed indicators instead of recomputing.
/// 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 RenderedChart = struct {
    surface: Surface,
    width: u16,
    height: u16,
    price_min: f64,
    price_max: f64,
    rsi_latest: ?f64,

    pub fn deinit(self: *RenderedChart, 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 so
    /// the caller can still call `deinit`.
    pub fn extractRgb(self: *const RenderedChart, 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 the chart into a `Surface` and return both. Caller owns
/// the result and must call `deinit`.
///
/// Two consumers today:
///   - `renderChart` 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,
    candles: []const zfin.Candle,
    timeframe: Timeframe,
    width_px: u32,
    height_px: u32,
    th: theme.Theme,
    cached: ?*const CachedIndicators,
) !RenderedChart {
    if (candles.len < 20) return error.InsufficientData;

    // Slice candles to timeframe
    const max_days = timeframe.tradingDays();
    const n = @min(candles.len, max_days);
    const data = candles[candles.len - n ..];

    // Use cached indicators or compute fresh ones
    var local_closes: ?[]f64 = null;
    var local_vols: ?[]f64 = null;
    var local_bb: ?[]?zfin.indicators.BollingerBand = null;
    var local_rsi: ?[]?f64 = null;
    defer {
        if (local_closes) |c| alloc.free(c);
        if (local_vols) |v| alloc.free(v);
        if (local_bb) |b| alloc.free(b);
        if (local_rsi) |r| alloc.free(r);
    }

    const closes: []const f64 = if (cached) |c| c.closes else blk: {
        local_closes = try zfin.indicators.closePrices(alloc, data);
        break :blk local_closes.?;
    };
    const vols: []const f64 = if (cached) |c| c.volumes else blk: {
        local_vols = try zfin.indicators.volumes(alloc, data);
        break :blk local_vols.?;
    };
    const bb: []const ?zfin.indicators.BollingerBand = if (cached) |c| c.bb else blk: {
        local_bb = try zfin.indicators.bollingerBands(alloc, closes, 20, 2.0);
        break :blk local_bb.?;
    };
    const rsi_vals: []const ?f64 = if (cached) |c| c.rsi_vals else blk: {
        local_rsi = try zfin.indicators.rsi(alloc, closes, 14);
        break :blk local_rsi.?;
    };

    // Create z2d surface — use RGB (not RGBA) since we're rendering onto a solid
    // background. This avoids integer overflow in z2d's RGBA compositor when
    // compositing semi-transparent fills (alpha < 255).
    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);

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

    // Disable anti-aliasing and use direct pixel writes (.source operator)
    // to avoid integer overflow bugs in z2d's src_over compositor.
    // Semi-transparent colors are pre-blended against bg in blendColor().
    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();

    // Panel dimensions
    const chart_left = margin_left;
    const chart_right = fwidth - margin_right;
    const chart_w = chart_right - chart_left;
    const chart_top = margin_top;
    const total_h = fheight - margin_top - margin_bottom;

    const price_h = total_h * price_frac;
    const price_top = chart_top;
    const price_bottom = price_top + price_h;

    const gap_h = total_h * gap_frac;

    const rsi_h = total_h * rsi_frac;
    const rsi_top = price_bottom + gap_h;
    const rsi_bottom = rsi_top + rsi_h;

    // Price range (include Bollinger bands in range)
    var price_min: f64 = closes[0];
    var price_max: f64 = closes[0];
    for (closes) |c| {
        if (c < price_min) price_min = c;
        if (c > price_max) price_max = c;
    }
    for (bb) |b_opt| {
        if (b_opt) |b| {
            if (b.lower < price_min) price_min = b.lower;
            if (b.upper > price_max) price_max = b.upper;
        }
    }
    // Add 5% padding
    const price_pad = (price_max - price_min) * 0.05;
    price_min -= price_pad;
    price_max += price_pad;

    // Volume max
    var vol_max: f64 = 0;
    for (vols) |v| {
        if (v > vol_max) vol_max = v;
    }
    if (vol_max == 0) vol_max = 1;

    // Helper: map data index to x
    const x_step = chart_w / @as(f64, @floatFromInt(data.len - 1));

    // ── Grid lines ────────────────────────────────────────────────────
    const grid_color = blendColor(th.text_muted, 60, bg);
    try drawHorizontalGridLines(&ctx, chart_left, chart_right, price_top, price_bottom, 5, grid_color);
    try drawHorizontalGridLines(&ctx, chart_left, chart_right, rsi_top, rsi_bottom, 4, grid_color);

    // ── Volume bars (overlaid on price panel bottom 25%) ─────────────
    {
        const vol_panel_h = price_h * 0.25;
        const vol_bottom_y = price_bottom;
        const bar_w = @max(x_step * 0.7, 1.0);

        for (data, 0..) |candle, ci| {
            const x = chart_left + @as(f64, @floatFromInt(ci)) * x_step;
            const vol_h_px = (vols[ci] / vol_max) * vol_panel_h;
            const bar_top = vol_bottom_y - vol_h_px;

            // Up/down coloring: compare today's chart-close to
            // yesterday's chart-close (both split-adjusted when
            // available). Comparing close-vs-open here would render
            // a spurious "down" day on every split date because
            // `Candle.open` is not split-adjusted but `chartClose`
            // is — see `Candle.chartClose` for context.
            const cc = candle.chartClose();
            const prev_cc = if (ci > 0) data[ci - 1].chartClose() else cc;
            const is_up = cc >= prev_cc;
            const col = if (is_up) blendColor(th.positive, 50, bg) else blendColor(th.negative, 50, bg);
            ctx.setSourceToPixel(col);
            ctx.resetPath();
            try ctx.moveTo(x - bar_w / 2, bar_top);
            try ctx.lineTo(x + bar_w / 2, bar_top);
            try ctx.lineTo(x + bar_w / 2, vol_bottom_y);
            try ctx.lineTo(x - bar_w / 2, vol_bottom_y);
            try ctx.closePath();
            try ctx.fill();
        }
    }

    // ── Bollinger Bands fill (drawn FIRST so price fill paints over it) ──
    {
        const band_fill_color = blendColor(th.accent, 25, bg);
        ctx.setSourceToPixel(band_fill_color);
        ctx.resetPath();

        var started = false;
        for (bb, 0..) |b_opt, ci| {
            if (b_opt) |b| {
                const x = chart_left + @as(f64, @floatFromInt(ci)) * x_step;
                const y = mapY(b.upper, price_min, price_max, price_top, price_bottom);
                if (!started) {
                    try ctx.moveTo(x, y);
                    started = true;
                } else {
                    try ctx.lineTo(x, y);
                }
            }
        }
        if (started) {
            var ci: usize = data.len;
            while (ci > 0) {
                ci -= 1;
                if (bb[ci]) |b| {
                    const x = chart_left + @as(f64, @floatFromInt(ci)) * x_step;
                    const y = mapY(b.lower, price_min, price_max, price_top, price_bottom);
                    try ctx.lineTo(x, y);
                }
            }
            try ctx.closePath();
            try ctx.fill();
        }
    }

    // ── Price filled area (on top of BB fill) ──────────────────────────
    {
        const start_price = closes[0];
        const end_price = closes[closes.len - 1];
        const fill_color = if (end_price >= start_price) blendColor(th.positive, 30, bg) else blendColor(th.negative, 30, bg);
        ctx.setSourceToPixel(fill_color);
        ctx.resetPath();
        for (closes, 0..) |c, ci| {
            const x = chart_left + @as(f64, @floatFromInt(ci)) * x_step;
            const y = mapY(c, price_min, price_max, price_top, price_bottom);
            if (ci == 0) try ctx.moveTo(x, y) else try ctx.lineTo(x, y);
        }
        const last_x = chart_left + @as(f64, @floatFromInt(closes.len - 1)) * x_step;
        try ctx.lineTo(last_x, price_bottom);
        try ctx.lineTo(chart_left, price_bottom);
        try ctx.closePath();
        try ctx.fill();
    }

    // ── Bollinger Band boundary lines + SMA (on top of fills) ──────────
    {
        const band_line_color = blendColor(th.text_muted, 100, bg);
        try drawLineSeries(&ctx, bb, data.len, price_min, price_max, price_top, price_bottom, chart_left, x_step, band_line_color, 1.0, .upper);
        try drawLineSeries(&ctx, bb, data.len, price_min, price_max, price_top, price_bottom, chart_left, x_step, band_line_color, 1.0, .lower);
        // SMA (middle)
        try drawLineSeries(&ctx, bb, data.len, price_min, price_max, price_top, price_bottom, chart_left, x_step, blendColor(th.text_muted, 160, bg), 1.0, .middle);
    }

    // ── Price line (on top of everything) ─────────────────────────────
    {
        const start_price = closes[0];
        const end_price = closes[closes.len - 1];
        const price_color = if (end_price >= start_price) opaqueColor(th.positive) else opaqueColor(th.negative);

        ctx.setSourceToPixel(price_color);
        ctx.setLineWidth(2.0);
        ctx.resetPath();
        for (closes, 0..) |c, ci| {
            const x = chart_left + @as(f64, @floatFromInt(ci)) * x_step;
            const y = mapY(c, price_min, price_max, price_top, price_bottom);
            if (ci == 0) try ctx.moveTo(x, y) else try ctx.lineTo(x, y);
        }
        try ctx.stroke();
    }

    // ── RSI panel ─────────────────────────────────────────────────────
    {
        const ref_color = blendColor(th.text_muted, 100, bg);
        try drawHLine(&ctx, chart_left, chart_right, mapY(70, 0, 100, rsi_top, rsi_bottom), ref_color, 1.0);
        try drawHLine(&ctx, chart_left, chart_right, mapY(30, 0, 100, rsi_top, rsi_bottom), ref_color, 1.0);
        try drawHLine(&ctx, chart_left, chart_right, mapY(50, 0, 100, rsi_top, rsi_bottom), blendColor(th.text_muted, 50, bg), 1.0);

        const rsi_color = blendColor(th.info, 220, bg);
        ctx.setSourceToPixel(rsi_color);
        ctx.setLineWidth(1.5);
        ctx.resetPath();
        var rsi_started = false;
        for (rsi_vals, 0..) |r_opt, ci| {
            if (r_opt) |r| {
                const x = chart_left + @as(f64, @floatFromInt(ci)) * x_step;
                const y = mapY(r, 0, 100, rsi_top, rsi_bottom);
                if (!rsi_started) {
                    try ctx.moveTo(x, y);
                    rsi_started = true;
                } else {
                    try ctx.lineTo(x, y);
                }
            }
        }
        if (rsi_started) try ctx.stroke();
    }

    // ── Panel borders ─────────────────────────────────────────────────
    {
        const border_color = blendColor(th.border, 80, bg);
        try drawRect(&ctx, chart_left, price_top, chart_right, price_bottom, border_color, 1.0);
        try drawRect(&ctx, chart_left, rsi_top, chart_right, rsi_bottom, border_color, 1.0);
    }

    // Get latest RSI
    var rsi_latest: ?f64 = null;
    {
        var ri: usize = rsi_vals.len;
        while (ri > 0) {
            ri -= 1;
            if (rsi_vals[ri]) |r| {
                rsi_latest = r;
                break;
            }
        }
    }

    return .{
        .surface = sfc,
        .width = @intCast(width_px),
        .height = @intCast(height_px),
        .price_min = price_min,
        .price_max = price_max,
        .rsi_latest = rsi_latest,
    };
}

/// Render a complete financial chart to raw RGB pixel data.
/// The returned rgb_data is allocated with `alloc` and must be freed by caller.
/// If `cached` is provided, uses pre-computed indicators instead of recomputing.
pub fn renderChart(
    io: std.Io,
    alloc: std.mem.Allocator,
    candles: []const zfin.Candle,
    timeframe: Timeframe,
    width_px: u32,
    height_px: u32,
    th: theme.Theme,
    cached: ?*const CachedIndicators,
) !ChartResult {
    var rendered = try renderToSurface(io, alloc, candles, timeframe, width_px, height_px, th, cached);
    defer rendered.deinit(alloc);
    const raw = try rendered.extractRgb(alloc);
    return .{
        .rgb_data = raw,
        .width = rendered.width,
        .height = rendered.height,
        .price_min = rendered.price_min,
        .price_max = rendered.price_max,
        .rsi_latest = rendered.rsi_latest,
    };
}

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

/// Pre-blend a foreground color with alpha against a background color.
/// Returns a fully opaque pixel. This avoids z2d's broken src_over compositor.
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),
    } };
}

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

const BandField = enum { upper, middle, lower };

fn drawLineSeries(
    ctx: *Context,
    bb: []const ?zfin.indicators.BollingerBand,
    len: usize,
    price_min: f64,
    price_max: f64,
    price_top: f64,
    price_bottom: f64,
    chart_left: f64,
    x_step: f64,
    col: Pixel,
    line_w: f64,
    field: BandField,
) !void {
    ctx.setSourceToPixel(col);
    ctx.setLineWidth(line_w);
    ctx.resetPath();
    var started = false;
    for (0..len) |i| {
        if (bb[i]) |b| {
            const val = switch (field) {
                .upper => b.upper,
                .middle => b.middle,
                .lower => b.lower,
            };
            const x = chart_left + @as(f64, @floatFromInt(i)) * x_step;
            const y = mapY(val, price_min, price_max, price_top, price_bottom);
            if (!started) {
                try ctx.moveTo(x, y);
                started = true;
            } else {
                try ctx.lineTo(x, y);
            }
        }
    }
    if (started) try ctx.stroke();
    ctx.setLineWidth(2.0);
}

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, w: f64) !void {
    ctx.setSourceToPixel(col);
    ctx.setLineWidth(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, w: f64) !void {
    ctx.setSourceToPixel(col);
    ctx.setLineWidth(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" {
    // value at min → bottom
    try std.testing.expectEqual(@as(f64, 500.0), mapY(0, 0, 100, 100, 500));
    // value at max → top
    try std.testing.expectEqual(@as(f64, 100.0), mapY(100, 0, 100, 100, 500));
    // value at midpoint → midpoint
    try std.testing.expectEqual(@as(f64, 300.0), mapY(50, 0, 100, 100, 500));
    // flat range → midpoint
    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 };

    // Full alpha → foreground
    const full = blendColor(white, 255, black);
    try std.testing.expectEqual(@as(u8, 255), full.rgb.r);
    try std.testing.expectEqual(@as(u8, 255), full.rgb.g);

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

    // Half alpha → midpoint
    const half = blendColor(white, 128, black);
    // 255 * (128/255) + 0 * (127/255) ≈ 128
    try std.testing.expect(half.rgb.r >= 127 and half.rgb.r <= 129);
}

test "opaqueColor wraps theme color" {
    const px = opaqueColor(.{ 0x7f, 0xd8, 0x8f });
    try std.testing.expectEqual(@as(u8, 0x7f), px.rgb.r);
    try std.testing.expectEqual(@as(u8, 0xd8), px.rgb.g);
    try std.testing.expectEqual(@as(u8, 0x8f), px.rgb.b);
}

test "ChartConfig.parse" {
    // Named modes
    const auto = ChartConfig.parse("auto").?;
    try std.testing.expectEqual(ChartMode.auto, auto.mode);

    const braille = ChartConfig.parse("braille").?;
    try std.testing.expectEqual(ChartMode.braille, braille.mode);

    // WxH format
    const custom = ChartConfig.parse("800x600").?;
    try std.testing.expectEqual(ChartMode.kitty, custom.mode);
    try std.testing.expectEqual(@as(u32, 800), custom.max_width);
    try std.testing.expectEqual(@as(u32, 600), custom.max_height);

    // Too small
    try std.testing.expectEqual(@as(?ChartConfig, null), ChartConfig.parse("50x50"));

    // Invalid
    try std.testing.expectEqual(@as(?ChartConfig, null), ChartConfig.parse("garbage"));
}

test "Timeframe next/prev cycle" {
    // next cycles through all values
    try std.testing.expectEqual(Timeframe.ytd, Timeframe.@"6M".next());
    try std.testing.expectEqual(Timeframe.@"1Y", Timeframe.ytd.next());
    try std.testing.expectEqual(Timeframe.@"6M", Timeframe.@"5Y".next()); // wraps

    // prev is the reverse
    try std.testing.expectEqual(Timeframe.@"5Y", Timeframe.@"6M".prev()); // wraps
    try std.testing.expectEqual(Timeframe.@"6M", Timeframe.ytd.prev());
}

test "Timeframe tradingDays" {
    try std.testing.expectEqual(@as(usize, 126), Timeframe.@"6M".tradingDays());
    try std.testing.expectEqual(@as(usize, 252), Timeframe.@"1Y".tradingDays());
    try std.testing.expectEqual(@as(usize, 1260), Timeframe.@"5Y".tradingDays());
}

// ── renderToSurface tests ─────────────────────────────────────────────
//
// These exercise the actual chart rendering pipeline (z2d surface +
// indicator computation) rather than just the helpers. They live in
// this file so they share the testing harness with the existing
// helper tests.

const test_alloc = std.testing.allocator;
const Date = @import("../Date.zig");

/// Build a slice of synthetic candles with linearly-rising prices.
/// Used by multiple renderToSurface tests.
fn buildLinearCandles(arr: []zfin.Candle, start_price: f64) void {
    for (arr, 0..) |*c, i| {
        const price: f64 = start_price + @as(f64, @floatFromInt(i));
        c.* = .{
            .date = Date.fromYmd(2024, 1, 2).addDays(@intCast(i)),
            .open = price,
            .high = price + 1,
            .low = price - 1,
            .close = price,
            .adj_close = price,
            .volume = 1000,
        };
    }
}

test "renderToSurface returns InsufficientData with < 20 candles" {
    var candles: [10]zfin.Candle = undefined;
    buildLinearCandles(&candles, 100.0);
    const result = renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null);
    try std.testing.expectError(error.InsufficientData, result);
}

test "renderToSurface produces a populated surface at requested dimensions" {
    var candles: [30]zfin.Candle = undefined;
    buildLinearCandles(&candles, 100.0);

    var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null);
    defer rendered.deinit(test_alloc);

    try std.testing.expectEqual(@as(u16, 200), rendered.width);
    try std.testing.expectEqual(@as(u16, 100), rendered.height);
    // Surface must be the RGB variant the chart code commits to.
    switch (rendered.surface) {
        .image_surface_rgb => {},
        else => try std.testing.expect(false),
    }
}

test "renderToSurface price range covers the input close range" {
    var candles: [30]zfin.Candle = undefined;
    buildLinearCandles(&candles, 100.0); // closes: 100..129

    var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null);
    defer rendered.deinit(test_alloc);

    // 5% padding is applied inside renderToSurface, so the recorded
    // min must be <= the actual min close, and max >= actual max.
    // Bollinger bands can also expand the range, so we check the
    // weaker invariant rather than equality.
    try std.testing.expect(rendered.price_min <= 100.0);
    try std.testing.expect(rendered.price_max >= 129.0);
}

test "renderToSurface uses chartClose so split-day cliffs don't widen the price range" {
    // 30 candles: first 15 have raw close=300, adj_close=100; last 15
    // have raw close=100, adj_close=100. If renderToSurface used raw
    // `close`, price_max would be ~315 (300 + padding); using
    // `chartClose` it should stay near 100.
    var candles: [30]zfin.Candle = undefined;
    for (0..30) |i| {
        const raw: f64 = if (i < 15) 300 else 100;
        candles[i] = .{
            .date = Date.fromYmd(2024, 1, 2).addDays(@intCast(i)),
            .open = raw,
            .high = raw,
            .low = raw,
            .close = raw,
            .adj_close = 100, // adjusted is always 100
            .volume = 1000,
        };
    }

    var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null);
    defer rendered.deinit(test_alloc);

    // With chartClose, max should be near 100 — definitely not 250+.
    // (Bollinger bands of a flat 100 series stay near 100, so the
    // upper bound is tight.)
    try std.testing.expect(rendered.price_max < 200);
}

test "renderToSurface fills background with theme bg" {
    var candles: [30]zfin.Candle = undefined;
    buildLinearCandles(&candles, 100.0);

    // Use a custom theme with a distinctive bg color we can detect.
    var th = theme.default_theme;
    th.bg = .{ 0x12, 0x34, 0x56 };

    var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, th, null);
    defer rendered.deinit(test_alloc);

    // Pixel at (0, 0) is in the top-left margin — outside the chart
    // area where lines/fills render — so it should still be the
    // background color.
    const buf = switch (rendered.surface) {
        .image_surface_rgb => |s| s.buf,
        else => unreachable,
    };
    try std.testing.expect(buf.len > 0);
    try std.testing.expectEqual(@as(u8, 0x12), buf[0].r);
    try std.testing.expectEqual(@as(u8, 0x34), buf[0].g);
    try std.testing.expectEqual(@as(u8, 0x56), buf[0].b);
}

test "renderToSurface is deterministic across two calls with same input" {
    var candles: [30]zfin.Candle = undefined;
    buildLinearCandles(&candles, 100.0);

    var a = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null);
    defer a.deinit(test_alloc);
    var b = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null);
    defer b.deinit(test_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);
    // Compare a sample of pixels rather than the whole buffer to keep
    // the failure message readable when this regresses.
    var i: usize = 0;
    while (i < buf_a.len) : (i += 100) {
        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 "RenderedChart.extractRgb produces 3 bytes per pixel matching surface buffer" {
    var candles: [30]zfin.Candle = undefined;
    buildLinearCandles(&candles, 100.0);

    var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 50, 40, theme.default_theme, null);
    defer rendered.deinit(test_alloc);

    const raw = try rendered.extractRgb(test_alloc);
    defer test_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);
    // Spot-check first pixel: (R, G, B) at indices 0, 1, 2.
    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 "renderChart wraps renderToSurface and frees the surface" {
    // Smoke check that the legacy `renderChart` path still works
    // after the refactor — same input shape, RGB extraction succeeds.
    var candles: [30]zfin.Candle = undefined;
    buildLinearCandles(&candles, 100.0);

    const result = try renderChart(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null);
    defer test_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);
}
