//! Single-series line chart renderer using z2d.
//!
//! A deliberately slim sibling of `projection_chart.zig`: it draws one
//! value-over-time series (e.g. portfolio Liquid / Net Worth) as a
//! filled, trend-colored line - no percentile bands, no Bollinger, no
//! RSI, no volume. It exists so the `history` command can render its
//! portfolio-value timeline as a real bitmap chart (inline Kitty
//! graphics or a PNG via `--export-chart`) instead of only braille,
//! reusing the same `renderToSurface` -> RGB / PNG seam the other
//! charts use.
//!
//! Visual layers (bottom to top):
//!   - Background
//!   - Horizontal grid lines
//!   - Filled area under the line (faint, trend-colored)
//!   - Zero reference line (only when the y-range straddles zero)
//!   - The value line (solid, trend-colored: green if the series ended
//!     above where it started, red otherwise)
//!   - Panel border
//!
//! X positions are date-proportional: each point sits at
//! `(date - first_date) / (last_date - first_date)` across the chart
//! width, so irregular snapshot spacing (daily recent, weekly/monthly
//! for older imported history) renders to scale rather than evenly by
//! index. If every point shares a date (degenerate input) the renderer
//! falls back to even index spacing.

const std = @import("std");
const z2d = @import("z2d");
const theme = @import("../tui/theme.zig");
const Date = @import("../Date.zig");
const draw = @import("draw.zig");
const axis = @import("axis.zig");

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

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

/// How the y-axis lower bound is chosen.
pub const Baseline = enum {
    /// Fit the data: lower bound is the series minimum minus a small
    /// pad. Best for reading week-to-week variation in a large,
    /// slowly-moving balance.
    fit,
    /// Anchor the lower bound at zero (clamped to the data minimum if
    /// the series itself dips below zero). Shows absolute scale at the
    /// cost of flattening the visible variation.
    zero,
};

/// One (date, value) point on the series. Leaf-level: the chart module
/// deliberately does not depend on `analytics/timeline.zig`. Callers
/// convert their domain points (e.g. `timeline.MetricPoint`) into this
/// shape - same fields, distinct type - the way `projection_chart`
/// keeps its own `ActualsPoint`.
pub const LinePoint = struct {
    date: Date,
    value: f64,
};

/// Render options.
pub const Options = struct {
    baseline: Baseline = .fit,
    /// Draw in-image axis labels (y-axis dollar ticks + x-axis start/end
    /// dates) into reserved margins. Off by default: the TUI inline path
    /// draws its own terminal-text labels beside the image and wants the
    /// bare bitmap. The PNG export turns this on so the standalone file
    /// is self-describing.
    axis_labels: bool = false,
};

/// Line chart render result (raw RGB), produced by `renderLineChart`.
pub const LineChartResult = 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.
/// The shared mid-stage between RGB extraction (Kitty graphics) and PNG
/// export (`--export-chart`). See `renderToSurface`.
pub const RenderedLineChart = struct {
    surface: Surface,
    width: u16,
    height: u16,
    value_min: f64,
    value_max: f64,

    pub fn deinit(self: *RenderedLineChart, 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 RenderedLineChart, alloc: std.mem.Allocator) ![]u8 {
        return draw.extractRgb(alloc, &self.surface);
    }
};

/// Render a single-series line chart into a `Surface` and return both.
/// Caller owns the result and must call `deinit`.
///
/// Two consumers:
///   - `renderLineChart` wraps this for the CLI's inline Kitty graphics
///     path (extracts RGB, frees surface).
///   - `chart_export.exportTimelineChart` wraps this for PNG export via
///     `z2d.png_exporter.writeToPNGFile`.
pub fn renderToSurface(
    io: std.Io,
    alloc: std.mem.Allocator,
    points: []const LinePoint,
    width_px: u32,
    height_px: u32,
    th: theme.Theme,
    opts: Options,
) !RenderedLineChart {
    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);
    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
    try draw.fillBackground(&ctx, fwidth, fheight, bg);

    // Chart area. When axis labels are enabled we reserve a left margin
    // (y-axis dollar ticks) and a bottom margin (start/end dates), scaled
    // with the surface so labels stay legible on large exports.
    const label_scale: i32 = axis.labelScale(h);
    const label_char_h: f64 = axis.charHeight(label_scale);
    const m_left: f64 = if (opts.axis_labels) label_char_h else margin_left;
    const m_right: f64 = if (opts.axis_labels) axis.yAxisMargin(label_scale) else margin_right;
    const m_top: f64 = if (opts.axis_labels) (label_char_h / 2 + 4) else margin_top;
    const m_bottom: f64 = if (opts.axis_labels) axis.bottomMargin(label_scale) else margin_bottom;

    const chart_left = m_left;
    const chart_right = fwidth - m_right;
    const chart_w = chart_right - chart_left;
    const chart_top = m_top;
    const chart_bottom = fheight - m_bottom;

    // ── Value range ──────────────────────────────────────────────
    var data_min: f64 = points[0].value;
    var data_max: f64 = points[0].value;
    for (points) |p| {
        if (p.value < data_min) data_min = p.value;
        if (p.value > data_max) data_max = p.value;
    }
    const pad = (data_max - data_min) * 0.05;
    const value_max: f64 = data_max + pad;
    const value_min: f64 = switch (opts.baseline) {
        .fit => data_min - pad,
        // Anchor at zero, but never crop a series that genuinely dips
        // below zero (e.g. a negative net worth).
        .zero => @min(0, data_min),
    };

    // ── X mapping (date-proportional) ─────────────────────────────
    const first_days: f64 = @floatFromInt(points[0].date.days);
    const last_days: f64 = @floatFromInt(points[points.len - 1].date.days);
    const span_days: f64 = last_days - first_days;
    // Degenerate input (all points share a date): fall back to even
    // index spacing so we still draw something sensible.
    const use_dates = span_days > 0;
    const index_step = chart_w / @as(f64, @floatFromInt(points.len - 1));

    const mapX = struct {
        fn at(i: usize, p: LinePoint, left: f64, cw: f64, fd: f64, span: f64, by_date: bool, istep: f64) f64 {
            if (by_date) {
                const d: f64 = @floatFromInt(p.date.days);
                return left + ((d - fd) / span) * cw;
            }
            return left + @as(f64, @floatFromInt(i)) * istep;
        }
    }.at;

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

    // ── Filled area under the line (faint, trend-colored) ─────────
    const trend_up = points[points.len - 1].value >= points[0].value;
    const trend_color = if (trend_up) th.positive else th.negative;
    {
        const fill_color = blendColor(trend_color, 30, bg);
        ctx.setSourceToPixel(fill_color);
        ctx.resetPath();
        for (points, 0..) |p, i| {
            const x = mapX(i, p, chart_left, chart_w, first_days, span_days, use_dates, index_step);
            const y = mapY(p.value, value_min, value_max, chart_top, chart_bottom);
            if (i == 0) try ctx.moveTo(x, y) else try ctx.lineTo(x, y);
        }
        const last_x = mapX(points.len - 1, points[points.len - 1], chart_left, chart_w, first_days, span_days, use_dates, index_step);
        try ctx.lineTo(last_x, chart_bottom);
        try ctx.lineTo(chart_left, chart_bottom);
        try ctx.closePath();
        try ctx.fill();
    }

    // ── Zero reference line (only when the range straddles zero) ──
    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.text_muted, 100, bg);
        try drawHLine(&ctx, chart_left, chart_right, zero_y, zero_color, 1.0);
    }

    // ── Value line (solid, trend-colored, on top) ─────────────────
    {
        ctx.setSourceToPixel(opaqueColor(trend_color));
        ctx.setLineWidth(2.0);
        ctx.resetPath();
        for (points, 0..) |p, i| {
            const x = mapX(i, p, chart_left, chart_w, first_days, span_days, use_dates, index_step);
            const y = mapY(p.value, 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);
    }

    // ── Axis labels (export only; drawn directly into the buffer) ──
    if (opts.axis_labels) {
        axis.drawYDollarTicks(&sfc, label_scale, th.text_muted, chart_right, chart_top, chart_bottom, value_min, value_max, 5);
        var fbuf: [12]u8 = undefined;
        var lbuf: [12]u8 = undefined;
        const first_s = std.fmt.bufPrint(&fbuf, "{f}", .{points[0].date}) catch "";
        const last_s = std.fmt.bufPrint(&lbuf, "{f}", .{points[points.len - 1].date}) catch "";
        const date_y = chart_bottom + axis.labelGap(label_scale);
        axis.drawXEndpoints(&sfc, label_scale, th.text_muted, chart_left, chart_right, date_y, first_s, last_s);
    }

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

/// Render a single-series line chart to raw RGB pixel data. The returned
/// `rgb_data` is allocated with `alloc` and must be freed by the caller.
pub fn renderLineChart(
    io: std.Io,
    alloc: std.mem.Allocator,
    points: []const LinePoint,
    width_px: u32,
    height_px: u32,
    th: theme.Theme,
    opts: Options,
) !LineChartResult {
    var rendered = try renderToSurface(io, alloc, points, width_px, height_px, th, opts);
    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 ───────────────────────────────────────────────────
//
// The stateless primitives below are shared with the other chart
// renderers and live in `draw.zig`; aliased here so the call sites in
// this file stay unchanged.

const mapY = draw.mapY;
const blendColor = draw.blendColor;
const opaqueColor = draw.opaqueColor;
const drawHorizontalGridLines = draw.drawHorizontalGridLines;
const drawHLine = draw.drawHLine;
const drawRect = draw.drawRect;

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

const test_th = theme.default_theme;

fn pt(y: i16, m: u8, d: u8, v: f64) LinePoint {
    return .{ .date = Date.fromYmd(y, m, d), .value = v };
}

test "renderToSurface returns InsufficientData with < 2 points" {
    const alloc = std.testing.allocator;
    const one = [_]LinePoint{pt(2026, 1, 1, 100)};
    try std.testing.expectError(error.InsufficientData, renderToSurface(std.testing.io, alloc, &one, 200, 100, test_th, .{}));
    const none = [_]LinePoint{};
    try std.testing.expectError(error.InsufficientData, renderToSurface(std.testing.io, alloc, &none, 200, 100, test_th, .{}));
}

test "renderToSurface returns a populated RGB surface at requested dimensions" {
    const alloc = std.testing.allocator;
    const points = [_]LinePoint{ pt(2026, 1, 1, 100), pt(2026, 2, 1, 120), pt(2026, 3, 1, 110) };
    var rendered = try renderToSurface(std.testing.io, alloc, &points, 200, 100, test_th, .{});
    defer rendered.deinit(alloc);

    try std.testing.expectEqual(@as(u16, 200), rendered.width);
    try std.testing.expectEqual(@as(u16, 100), 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 points = [_]LinePoint{ pt(2026, 1, 1, 100), pt(2026, 2, 1, 120) };
    var th = theme.default_theme;
    th.bg = .{ 0xab, 0xcd, 0xef };

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

    const buf = switch (rendered.surface) {
        .image_surface_rgb => |s| s.buf,
        else => unreachable,
    };
    // Pixel (0,0) is in the top-left margin - outside the plotted area.
    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 fit baseline keeps value_min at or below the data minimum" {
    const alloc = std.testing.allocator;
    // All values well above zero; fit must NOT anchor at zero.
    const points = [_]LinePoint{ pt(2026, 1, 1, 1_000_000), pt(2026, 2, 1, 1_050_000), pt(2026, 3, 1, 1_020_000) };
    var rendered = try renderToSurface(std.testing.io, alloc, &points, 200, 100, test_th, .{ .baseline = .fit });
    defer rendered.deinit(alloc);

    try std.testing.expect(rendered.value_min <= 1_000_000);
    try std.testing.expect(rendered.value_min > 0); // far from zero
    try std.testing.expect(rendered.value_max >= 1_050_000);
}

test "renderToSurface zero baseline anchors value_min at zero for positive data" {
    const alloc = std.testing.allocator;
    const points = [_]LinePoint{ pt(2026, 1, 1, 1_000_000), pt(2026, 2, 1, 1_050_000) };
    var rendered = try renderToSurface(std.testing.io, alloc, &points, 200, 100, test_th, .{ .baseline = .zero });
    defer rendered.deinit(alloc);

    try std.testing.expectEqual(@as(f64, 0), rendered.value_min);
    try std.testing.expect(rendered.value_max > 1_000_000);
}

test "renderToSurface zero baseline still includes a negative data minimum" {
    const alloc = std.testing.allocator;
    // Net worth dips below zero - zero baseline must not crop it.
    const points = [_]LinePoint{ pt(2026, 1, 1, -50_000), pt(2026, 2, 1, 10_000) };
    var rendered = try renderToSurface(std.testing.io, alloc, &points, 200, 100, test_th, .{ .baseline = .zero });
    defer rendered.deinit(alloc);

    try std.testing.expect(rendered.value_min <= -50_000);
}

test "renderToSurface is deterministic across calls with same input" {
    const alloc = std.testing.allocator;
    const points = [_]LinePoint{ pt(2026, 1, 1, 100), pt(2026, 2, 1, 140), pt(2026, 3, 1, 90) };

    var a = try renderToSurface(std.testing.io, alloc, &points, 120, 60, test_th, .{});
    defer a.deinit(alloc);
    var b = try renderToSurface(std.testing.io, alloc, &points, 120, 60, test_th, .{});
    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 "renderToSurface handles degenerate single-date input without crashing" {
    const alloc = std.testing.allocator;
    // Both points share a date -> span is zero -> index-spacing fallback.
    const points = [_]LinePoint{ pt(2026, 1, 1, 100), pt(2026, 1, 1, 120) };
    var rendered = try renderToSurface(std.testing.io, alloc, &points, 100, 50, test_th, .{});
    defer rendered.deinit(alloc);
    try std.testing.expectEqual(@as(u16, 100), rendered.width);
}

test "renderLineChart wraps renderToSurface and produces RGB triplets" {
    const alloc = std.testing.allocator;
    const points = [_]LinePoint{ pt(2026, 1, 1, 100), pt(2026, 2, 1, 120), pt(2026, 3, 1, 110) };
    const result = try renderLineChart(std.testing.io, alloc, &points, 50, 40, test_th, .{});
    defer alloc.free(result.rgb_data);

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