//! Kitty graphics protocol emission for the plain CLI commands.
//!
//! The TUI renders bitmap charts through vaxis, which speaks the kitty
//! graphics protocol for us. The non-interactive CLI (`zfin history`,
//! `quote`, `projections`) has no vaxis loop, so it emits the protocol
//! directly: this module turns a raw RGB buffer into the APC escape
//! sequence that a kitty-capable terminal renders inline at the cursor.
//!
//! It also centralizes the inline-chart sizing: the on-screen size is
//! expressed in terminal *columns* (the headline projections/quote
//! charts are wider than the history timeline), and the pixel render
//! size is derived from the terminal's cell pixel dimensions.

const std = @import("std");

/// Default cell pixel size assumed when the terminal hasn't reported one.
/// Kitty scales the transmitted image to the requested `c`=cols,`r`=rows
/// footprint, so an imperfect assumption only affects render crispness,
/// not on-screen layout.
pub const default_cell_w: u32 = 8;
pub const default_cell_h: u32 = 16;

/// Inline-chart target widths in terminal columns. The headline charts
/// (projections, quote) are wider than the history timeline; tuned to
/// roughly match-and-exceed the braille chart's on-screen footprint.
pub const history_cols: u16 = 80;
pub const projection_cols: u16 = 120;
pub const quote_cols: u16 = 120;

/// Maximum base64 payload bytes per APC escape, per the kitty protocol.
const chunk_max: usize = 4096;

pub const PixelDims = struct { width: u32, height: u32 };

/// Pixel dimensions for a chart occupying `cols` x `rows` cells.
pub fn pixelDims(cols: u16, rows: u16, cell_w: u32, cell_h: u32) PixelDims {
    return .{ .width = @as(u32, cols) * cell_w, .height = @as(u32, rows) * cell_h };
}

/// Rows for a chart `cols` wide that preserve a ~3:1 width:height pixel
/// aspect (matching the braille chart's proportions), given the cell
/// pixel size. Always at least 1.
pub fn rowsForWidth(cols: u16, cell_w: u32, cell_h: u32) u16 {
    const width_px = @as(u32, cols) * cell_w;
    const height_px = width_px / 3;
    const rows = (height_px + cell_h - 1) / cell_h; // round up
    return @intCast(@max(rows, @as(u32, 1)));
}

/// Emit `rgb` (length must be `width_px * height_px * 3`) as a kitty
/// graphics image displayed at the cursor, occupying `cols` x `rows`
/// terminal cells. The payload is base64-encoded and split across as
/// many APC escapes as needed (<= 4096 base64 bytes each). `q=2`
/// suppresses the terminal's OK/error replies since the CLI isn't
/// reading them back; `C=1` keeps the terminal from moving the cursor
/// when the image is placed.
///
/// Because of `C=1` the cursor stays at the image's top-left, so callers
/// MUST print `rows` newlines afterward to advance below the chart. This
/// makes the vertical advance deterministic across terminals (rather
/// than relying on each terminal's default cursor-movement policy).
pub fn emitKittyRGB(
    writer: *std.Io.Writer,
    alloc: std.mem.Allocator,
    rgb: []const u8,
    width_px: u32,
    height_px: u32,
    cols: u16,
    rows: u16,
) !void {
    const Encoder = std.base64.standard.Encoder;
    const b64 = try alloc.alloc(u8, Encoder.calcSize(rgb.len));
    defer alloc.free(b64);
    _ = Encoder.encode(b64, rgb);

    var off: usize = 0;
    var first = true;
    while (true) {
        const end = @min(off + chunk_max, b64.len);
        const more = end < b64.len;
        try writer.writeAll("\x1b_G");
        if (first) {
            // First escape carries the image metadata + control keys.
            try writer.print(
                "a=T,q=2,C=1,f=24,s={d},v={d},c={d},r={d},m={d}",
                .{ width_px, height_px, cols, rows, @intFromBool(more) },
            );
            first = false;
        } else {
            try writer.print("m={d}", .{@intFromBool(more)});
        }
        try writer.writeAll(";");
        try writer.writeAll(b64[off..end]);
        try writer.writeAll("\x1b\\");
        off = end;
        if (!more) break;
    }
}

/// Emit `rgb` as a kitty image (see `emitKittyRGB`), then advance the
/// cursor `rows` lines so following output lands directly below it.
/// Centralizes the pairing of the `C=1` (no cursor move) emission with
/// its matching vertical advance - callers should use this rather than
/// emitting + advancing by hand.
pub fn placeInline(
    writer: *std.Io.Writer,
    alloc: std.mem.Allocator,
    rgb: []const u8,
    width_px: u32,
    height_px: u32,
    cols: u16,
    rows: u16,
) !void {
    try emitKittyRGB(writer, alloc, rgb, width_px, height_px, cols, rows);
    var i: u16 = 0;
    while (i < rows) : (i += 1) try writer.writeByte('\n');
}

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

const testing = std.testing;

test "pixelDims multiplies cells by cell size" {
    const d = pixelDims(80, 14, 8, 16);
    try testing.expectEqual(@as(u32, 640), d.width);
    try testing.expectEqual(@as(u32, 224), d.height);
}

test "rowsForWidth keeps a ~3:1 pixel aspect" {
    // 8x16 cells: rows ~= cols/6, rounded up.
    try testing.expectEqual(@as(u16, 14), rowsForWidth(80, 8, 16)); // 640/3/16 = 13.3 -> 14
    try testing.expectEqual(@as(u16, 20), rowsForWidth(120, 8, 16)); // 960/3/16 = 20
    // Never zero, even for a tiny width.
    try testing.expect(rowsForWidth(1, 8, 16) >= 1);
}

test "emitKittyRGB: single chunk carries control keys + decodable payload" {
    const alloc = testing.allocator;
    var aw: std.Io.Writer.Allocating = .init(alloc);
    defer aw.deinit();

    const rgb = [_]u8{ 0xAA, 0xBB, 0xCC, 0x11, 0x22, 0x33 }; // 2x1 RGB
    try emitKittyRGB(&aw.writer, alloc, &rgb, 2, 1, 4, 2);
    const out = aw.written();

    try testing.expect(std.mem.startsWith(u8, out, "\x1b_G"));
    try testing.expect(std.mem.endsWith(u8, out, "\x1b\\"));
    try testing.expect(std.mem.indexOf(u8, out, "a=T,q=2,C=1,f=24,s=2,v=1,c=4,r=2,m=0") != null);
    // Exactly one APC escape (one terminator).
    try testing.expectEqual(@as(usize, 1), std.mem.count(u8, out, "\x1b\\"));

    // Payload (between ';' and the ST) round-trips back to the input.
    const semi = std.mem.indexOfScalar(u8, out, ';').?;
    const payload = out[semi + 1 .. out.len - 2];
    var decoded: [6]u8 = undefined;
    try std.base64.standard.Decoder.decode(&decoded, payload);
    try testing.expectEqualSlices(u8, &rgb, &decoded);
}

test "emitKittyRGB: large payload is chunked with m=1 then a final m=0" {
    const alloc = testing.allocator;
    var aw: std.Io.Writer.Allocating = .init(alloc);
    defer aw.deinit();

    // 64x64 RGB = 12288 bytes -> base64 16384 bytes -> exactly 4 chunks.
    const rgb = try alloc.alloc(u8, 64 * 64 * 3);
    defer alloc.free(rgb);
    @memset(rgb, 0x7F);

    try emitKittyRGB(&aw.writer, alloc, rgb, 64, 64, 10, 5);
    const out = aw.written();

    try testing.expectEqual(@as(usize, 4), std.mem.count(u8, out, "\x1b_G"));
    // First escape: control keys + m=1 (more chunks follow).
    try testing.expect(std.mem.indexOf(u8, out, "a=T,q=2,C=1,f=24,s=64,v=64,c=10,r=5,m=1") != null);
    // A continuation escape with no control keys, and the final m=0.
    try testing.expect(std.mem.indexOf(u8, out, "\x1b_Gm=1;") != null);
    try testing.expect(std.mem.indexOf(u8, out, "\x1b_Gm=0;") != null);
}

test "placeInline appends exactly `rows` newlines after the image" {
    const alloc = testing.allocator;
    var aw: std.Io.Writer.Allocating = .init(alloc);
    defer aw.deinit();
    const rgb = [_]u8{ 0, 0, 0, 0, 0, 0 }; // 2x1, base64 has no newlines
    try placeInline(&aw.writer, alloc, &rgb, 2, 1, 4, 3);
    const out = aw.written();
    try testing.expect(std.mem.endsWith(u8, out, "\x1b\\\n\n\n"));
    try testing.expectEqual(@as(usize, 3), std.mem.count(u8, out, "\n"));
}
