//! Braille sparkline chart engine.
//!
//! Renders compact price/value sparklines using Unicode braille
//! characters (U+2800..U+28FF) for a 2-wide x 4-tall dot matrix per
//! terminal cell. `computeBrailleChart` produces a renderer-agnostic
//! `BrailleChart` (pattern grid + per-column colors); `writeBrailleAnsi`
//! renders it to a writer with ANSI color for the CLI, while the TUI
//! consumes the `BrailleChart` directly to emit vaxis cells.
//!
//! Extracted from `format.zig`; CLI and TUI both reach for it via
//! `@import("charts/braille.zig")`.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    return result;
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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