//! This implements the built-in "sprite face". This font renders
//! the built-in glyphs for the terminal, such as box drawing fonts, as well
//! as specific sprites that are part of our rendering model such as
//! text decorations (underlines).
//!
//! This isn't really a "font face" so much as it is quacks like a font
//! face with regards to how it works with font.Group. We don't use any
//! dynamic dispatch so it isn't truly an interface but the functions
//! and behaviors are close enough to a system face that it makes it easy
//! to integrate with font.Group. This is desirable so that higher level
//! processes such as GroupCache, Shaper, etc. don't need to be aware of
//! special sprite handling and just treat it like a normal font face.
const Face = @This();

const std = @import("std");
const assert = @import("../../quirks.zig").inlineAssert;
const Allocator = std.mem.Allocator;
const wuffs = @import("wuffs");
const z2d = @import("z2d");
const font = @import("../main.zig");
const Sprite = font.sprite.Sprite;

const special = @import("draw/special.zig");

const log = std.log.scoped(.font_sprite);

/// Grid metrics for rendering sprites.
metrics: font.Metrics,

pub const DrawFnError =
    Allocator.Error ||
    z2d.Path.Error ||
    z2d.painter.FillError ||
    z2d.painter.StrokeError ||
    error{
        /// Something went wrong while doing math.
        MathError,
    };

/// A function that draws a glyph on the provided canvas.
pub const DrawFn = fn (
    cp: u32,
    canvas: *font.sprite.Canvas,
    width: u32,
    height: u32,
    metrics: font.Metrics,
) DrawFnError!void;

const Range = struct {
    min: u32,
    max: u32,
    draw: DrawFn,
};

/// Automatically collect ranges for functions with names
/// in the format `draw<CP>` or `draw<MIN>_<MAX>`.
const ranges: []const Range = ranges: {
    @setEvalBranchQuota(1_000_000);

    // Structs containing drawing functions for codepoint ranges.
    const structs = [_]type{
        @import("draw/block.zig"),
        @import("draw/box.zig"),
        @import("draw/braille.zig"),
        @import("draw/branch.zig"),
        @import("draw/geometric_shapes.zig"),
        @import("draw/powerline.zig"),
        @import("draw/symbols_for_legacy_computing.zig"),
        @import("draw/symbols_for_legacy_computing_supplement.zig"),
    };

    // Count how many draw fns we have
    var range_count = 0;
    for (structs) |s| {
        for (@typeInfo(s).@"struct".decls) |decl| {
            if (!@hasDecl(s, decl.name)) continue;
            if (!std.mem.startsWith(u8, decl.name, "draw")) continue;
            range_count += 1;
        }
    }

    // Make an array and collect ranges for each function.
    var r: [range_count]Range = undefined;
    var names: [range_count][:0]const u8 = undefined;
    var i = 0;
    for (structs) |s| {
        for (@typeInfo(s).@"struct".decls) |decl| {
            if (!@hasDecl(s, decl.name)) continue;
            if (!std.mem.startsWith(u8, decl.name, "draw")) continue;

            const sep = std.mem.indexOfScalar(u8, decl.name, '_') orelse decl.name.len;

            const min = std.fmt.parseInt(u21, decl.name[4..sep], 16) catch unreachable;

            const max = if (sep == decl.name.len)
                min
            else
                std.fmt.parseInt(u21, decl.name[sep + 1 ..], 16) catch unreachable;

            r[i] = .{
                .min = min,
                .max = max,
                .draw = @field(s, decl.name),
            };
            names[i] = decl.name;
            i += 1;
        }
    }

    // Sort ranges in ascending order
    std.mem.sortUnstableContext(0, r.len, struct {
        r: []Range,
        names: [][:0]const u8,
        pub fn lessThan(self: @This(), a: usize, b: usize) bool {
            return self.r[a].min < self.r[b].min;
        }
        pub fn swap(self: @This(), a: usize, b: usize) void {
            std.mem.swap(Range, &self.r[a], &self.r[b]);
            std.mem.swap([:0]const u8, &self.names[a], &self.names[b]);
        }
    }{
        .r = &r,
        .names = &names,
    });

    // Ensure there's no overlapping ranges
    i = 0;
    for (r, 0..) |n, k| {
        if (n.min <= i) {
            @compileError(
                std.fmt.comptimePrint(
                    "Codepoint range for {s}(...) overlaps range for {s}(...), {X} <= {X} <= {X}",
                    .{ names[k], names[k - 1], r[k - 1].min, n.min, r[k - 1].max },
                ),
            );
        }
        i = n.max;
    }

    // We need to copy in to a const rather than a var in order to take
    // the reference at comptime so that we can break with a slice here.
    const fixed = r;

    break :ranges &fixed;
};

fn getDrawFn(cp: u32) ?*const DrawFn {
    // For special sprites (cursors, underlines, etc.) all sprites are drawn
    // by functions from `Special` that share the name of the enum field.
    if (cp >= Sprite.start) switch (@as(Sprite, @enumFromInt(cp))) {
        inline else => |sprite| {
            return @field(special, @tagName(sprite));
        },
    };

    // Pray that the compiler is smart enough to
    // turn this in to a jump table or something...
    inline for (ranges) |range| {
        if (cp >= range.min and cp <= range.max) return range.draw;
    }
    return null;
}

/// Returns true if the codepoint exists in our sprite font.
pub fn hasCodepoint(self: Face, cp: u32, p: ?font.Presentation) bool {
    // We ignore presentation. No matter what presentation is
    // requested we always provide glyphs for our codepoints.
    _ = p;
    _ = self;
    return getDrawFn(cp) != null;
}

/// Render the glyph.
pub fn renderGlyph(
    self: Face,
    alloc: Allocator,
    atlas: *font.Atlas,
    cp: u32,
    opts: font.Glyph.RenderOptions,
) !font.Glyph {
    if (std.debug.runtime_safety) {
        if (!self.hasCodepoint(cp, null)) {
            log.err("invalid codepoint cp={x}", .{cp});
            unreachable; // crash
        }
    }

    // It should be impossible for this to be null and we assert that
    // in runtime safety modes but in case it is its not worth memory
    // corruption so we return a valid, blank glyph.
    const draw = getDrawFn(cp) orelse return .{
        .width = 0,
        .height = 0,
        .offset_x = 0,
        .offset_y = 0,
        .atlas_x = 0,
        .atlas_y = 0,
    };

    const metrics = self.metrics;

    // We adjust our sprite width based on the cell width.
    const width = switch (opts.cell_width orelse 1) {
        0, 1 => metrics.cell_width,
        else => |width| metrics.cell_width * width,
    };

    // Sprite glyphs generally get the full cell height, but cursor glyphs need
    // to be affected by `adjust-cursor-height`, so we use `cursor_height` for
    // the height if it's one of the full-height cursors.
    const height = switch (cp) {
        @intFromEnum(Sprite.cursor_rect),
        @intFromEnum(Sprite.cursor_hollow_rect),
        @intFromEnum(Sprite.cursor_bar),
        => metrics.cursor_height,
        else => metrics.cell_height,
    };

    const padding_x = width / 4;
    const padding_y = height / 4;

    // Make a canvas of the desired size
    var canvas = try font.sprite.Canvas.init(alloc, width, height, padding_x, padding_y);
    defer canvas.deinit();

    try draw(cp, &canvas, width, height, metrics);

    // Write the drawing to the atlas
    const region = try canvas.writeAtlas(alloc, atlas);

    // The X offset is the displacement from the left edge of the cell that our
    // drawn sprite will be drawn in the grid. That's the same as the distance
    // we have calculated from the left of the canvas, minus the padding, since
    // the padding represents extra pixels to the left of the cell.
    const offset_x: i32 =
        @as(i32, @intCast(canvas.clip_left)) -
        @as(i32, @intCast(padding_x));
    // Similar logic applies for the Y offset, but with the additional factor
    // that we want to re-center cursor glyphs in the cell if they were drawn
    // taller or shorter than a normal cell is; this only applies to cursors
    // currently, but could conceivably apply to other things in the future.
    const offset_y: i32 =
        @as(i32, @intCast(region.height +| canvas.clip_bottom)) -
        @as(i32, @intCast(padding_y)) +
        @divTrunc(
            // By adding half the difference between the cell height and the
            // height we passed to the draw function, we center it in the cell.
            @as(i32, @intCast(metrics.cell_height)) - @as(i32, @intCast(height)),
            2,
        );

    return .{
        .width = region.width,
        .height = region.height,
        .offset_x = offset_x,
        .offset_y = offset_y,
        .atlas_x = region.x,
        .atlas_y = region.y,
    };
}

/// Used in `testDrawRanges`, checks for diff between the provided atlas
/// and the reference file for the range, returns true if there is a diff.
fn testDiffAtlas(
    alloc: Allocator,
    atlas: *z2d.Surface,
    path: []const u8,
    i: u32,
    width: u32,
    height: u32,
    thickness: u32,
) !bool {
    // Get the file contents, we compare the PNG data first in
    // order to ensure that no one smuggles arbitrary binary
    // data in to the reference PNGs.
    const test_file = try std.Io.Dir.openFileAbsolute(std.testing.io, path, .{ .mode = .read_only });
    defer test_file.close(std.testing.io);
    var test_file_reader = test_file.reader(std.testing.io, &.{});
    const test_bytes = try test_file_reader.interface.readAlloc(
        alloc,
        (try test_file.stat(std.testing.io)).size,
    );
    defer alloc.free(test_bytes);

    const cwd_absolute = try std.Io.Dir.cwd().realPathFileAlloc(std.testing.io, ".", alloc);
    defer alloc.free(cwd_absolute);

    // Get the reference file contents to compare.
    const ref_path = try std.fmt.allocPrint(
        alloc,
        "./src/font/sprite/testdata/U+{X}...U+{X}-{d}x{d}+{d}.png",
        .{ i, i + 0xFF, width, height, thickness },
    );
    defer alloc.free(ref_path);
    const ref_file =
        std.Io.Dir.cwd().openFile(std.testing.io, ref_path, .{ .mode = .read_only }) catch |err| {
            log.err("Can't open reference file {s}: {}\n", .{
                ref_path,
                err,
            });

            // Copy the test PNG in to the CWD so it isn't
            // cleaned up with the rest of the tmp dir files.
            const test_path = try std.fmt.allocPrint(
                alloc,
                "{s}/sprite_face_test-U+{X}...U+{X}-{d}x{d}+{d}.png",
                .{ cwd_absolute, i, i + 0xFF, width, height, thickness },
            );
            defer alloc.free(test_path);
            try std.Io.Dir.copyFileAbsolute(path, test_path, std.testing.io, .{});

            return true;
        };
    defer ref_file.close(std.testing.io);
    var ref_file_reader = ref_file.reader(std.testing.io, &.{});
    const ref_bytes = try ref_file_reader.interface.readAlloc(
        alloc,
        (try ref_file.stat(std.testing.io)).size,
    );
    defer alloc.free(ref_bytes);

    // Do our PNG bytes comparison, if it's the same then we can
    // move on, otherwise we'll decode the reference file and do
    // a pixel-for-pixel diff.
    if (std.mem.eql(u8, test_bytes, ref_bytes)) return false;

    // Copy the test PNG in to the CWD so it isn't
    // cleaned up with the rest of the tmp dir files.
    const test_path = try std.fmt.allocPrint(
        alloc,
        "{s}/sprite_face_test-U+{X}...U+{X}-{d}x{d}+{d}.png",
        .{ cwd_absolute, i, i + 0xFF, width, height, thickness },
    );
    defer alloc.free(test_path);
    try std.Io.Dir.copyFileAbsolute(path, test_path, std.testing.io, .{});

    // Use wuffs to decode the reference PNG to raw pixels.
    // These will be RGBA, so when diffing we can just compare
    // every fourth byte.
    const ref_rgba = try wuffs.png.decode(alloc, ref_bytes);
    defer alloc.free(ref_rgba.data);

    assert(ref_rgba.width == atlas.getWidth());
    assert(ref_rgba.height == atlas.getHeight());

    // We'll make a visual representation of the diff using
    // red for removed pixels and green for added. We make
    // a z2d surface for that here.
    var diff = try z2d.Surface.init(
        .image_surface_rgb,
        alloc,
        atlas.getWidth(),
        atlas.getHeight(),
    );
    defer diff.deinit(alloc);
    const diff_pix = diff.image_surface_rgb.buf;

    const test_gray = std.mem.sliceAsBytes(atlas.image_surface_alpha8.buf);

    var differs: bool = false;
    for (0..test_gray.len) |j| {
        const t = test_gray[j];
        const r = ref_rgba.data[j * 4];
        if (t == r) {
            // If the pixels match, write it as a faded gray.
            diff_pix[j].r = t / 3;
            diff_pix[j].g = t / 3;
            diff_pix[j].b = t / 3;
        } else {
            differs = true;
            // Otherwise put the reference value in the red
            // channel and the new value in the green channel.
            diff_pix[j].r = r;
            diff_pix[j].g = t;
        }
    }

    // If the PNG data differs but not the raw pixels, that's
    // a big red flag, since it could mean someone is trying to
    // smuggle binary data in to the test files.
    if (!differs) {
        log.err(
            "!!! Test PNG data does not match reference, but pixels do match! " ++
                "Either z2d's PNG exporter changed or someone is " ++
                "trying to smuggle binary data in the test files!\n" ++
                "test={s}, reference={s}",
            .{ test_path, ref_path },
        );
        return true;
    }

    // Drop the diff image as a PNG in the cwd.
    const diff_path = try std.fmt.allocPrint(
        alloc,
        "./sprite_face_diff-U+{X}...U+{X}-{d}x{d}+{d}.png",
        .{ i, i + 0xFF, width, height, thickness },
    );
    defer alloc.free(diff_path);
    try z2d.png_exporter.writeToPNGFile(std.testing.io, diff, diff_path, .{});
    log.err(
        "One or more glyphs differ from reference file in range U+{X}...U+{X}! " ++
            "test={s}, reference={s}, diff={s}",
        .{ i, i + 0xFF, test_path, ref_path, diff_path },
    );

    return true;
}

/// Draws all ranges in to a set of 16x16 glyph atlases, checks for regressions
/// against reference files, logs errors and exposes a diff for any difference
/// between the reference and test atlas.
///
/// Returns true if there was a diff.
fn testDrawRanges(
    width: u32,
    ascent: u32,
    descent: u32,
    thickness: u32,
) !bool {
    const testing = std.testing;
    const alloc = testing.allocator;

    const metrics: font.Metrics = .calc(.{
        // Fudged number, not used in anything we care about here.
        .px_per_em = 16,

        .cell_width = @floatFromInt(width),
        .ascent = @floatFromInt(ascent),
        .descent = -@as(f64, @floatFromInt(descent)),
        .line_gap = 0.0,
        .underline_thickness = @floatFromInt(thickness),
        .strikethrough_thickness = @floatFromInt(thickness),
    });

    const height = ascent + descent;

    const padding_x = width / 4;
    const padding_y = height / 4;

    // Canvas to draw glyphs on, we'll reuse this for all glyphs.
    var canvas = try font.sprite.Canvas.init(
        alloc,
        width,
        height,
        padding_x,
        padding_y,
    );
    defer canvas.deinit();

    // We render glyphs in batches of 256, which we copy (including padding) to
    // a 16 by 16 surface to be compared with the reference file for that range.
    const stride_x = width + 2 * padding_x;
    const stride_y = height + 2 * padding_y;
    var atlas = try z2d.Surface.init(
        .image_surface_alpha8,
        alloc,
        @intCast(stride_x * 16),
        @intCast(stride_y * 16),
    );
    defer atlas.deinit(alloc);

    var i: u32 = std.mem.alignBackward(u32, ranges[0].min, 0x100);

    // Try to make the sprite_face_test folder if it doesn't already exist.
    var dir = testing.tmpDir(.{});
    defer dir.cleanup();
    const tmp_dir = try dir.dir.realPathFileAlloc(testing.io, ".", alloc);
    defer alloc.free(tmp_dir);

    // We set this to true if we have any fails so we can
    // return an error after we're done comparing all glyphs.
    var fail: bool = false;

    inline for (ranges) |range| {
        for (range.min..range.max + 1) |cp| {
            // If we've moved to a new batch of 256, check the
            // current one and clear the surface for the next one.
            if (cp - i >= 0x100) {
                // Export to our tmp dir.
                const path = try std.fmt.allocPrint(
                    alloc,
                    "{s}/U+{X}...U+{X}-{d}x{d}+{d}.png",
                    .{ tmp_dir, i, i + 0xFF, width, height, thickness },
                );
                defer alloc.free(path);
                try z2d.png_exporter.writeToPNGFile(testing.io, atlas, path, .{});

                if (try testDiffAtlas(
                    alloc,
                    &atlas,
                    path,
                    i,
                    width,
                    height,
                    thickness,
                )) fail = true;

                i = std.mem.alignBackward(u32, @intCast(cp), 0x100);
                @memset(std.mem.sliceAsBytes(atlas.image_surface_alpha8.buf), 0);
            }

            try getDrawFn(@intCast(cp)).?(
                @intCast(cp),
                &canvas,
                width,
                height,
                metrics,
            );
            canvas.clearClippingRegions();
            atlas.composite(
                &canvas.sfc,
                .src,
                @intCast(stride_x * ((cp - i) % 16)),
                @intCast(stride_y * ((cp - i) / 16)),
                .{},
            );
            @memset(std.mem.sliceAsBytes(canvas.sfc.image_surface_alpha8.buf), 0);
            canvas.clip_top = 0;
            canvas.clip_left = 0;
            canvas.clip_right = 0;
            canvas.clip_bottom = 0;
        }
    }

    const path = try std.fmt.allocPrint(
        alloc,
        "{s}/U+{X}...U+{X}-{d}x{d}+{d}.png",
        .{ tmp_dir, i, i + 0xFF, width, height, thickness },
    );
    defer alloc.free(path);
    try z2d.png_exporter.writeToPNGFile(std.testing.io, atlas, path, .{});
    if (try testDiffAtlas(
        alloc,
        &atlas,
        path,
        i,
        width,
        height,
        thickness,
    )) fail = true;

    return fail;
}

test "sprite face render all sprites" {
    // This test is way too slow to run under Valgrind, unfortunately.
    if (std.valgrind.runningOnValgrind() > 0) return error.SkipZigTest;

    // Renders all sprites to an atlas and compares
    // it to a ground truth for regression testing.

    var diff: bool = false;

    // testDrawRanges(width, ascent, descent, thickness):
    //
    // We compare 4 different sets of metrics;
    // - even cell size / even thickness
    // - even cell size / odd thickness
    // - odd cell size / even thickness
    // - odd cell size / odd thickness
    // (Also a decreasing range of sizes.)
    if (try testDrawRanges(18, 30, 6, 4)) diff = true;
    if (try testDrawRanges(12, 20, 4, 3)) diff = true;
    if (try testDrawRanges(11, 19, 2, 2)) diff = true;
    if (try testDrawRanges(9, 15, 2, 1)) diff = true;

    try std.testing.expect(!diff); // There should be no diffs from reference.
}

test "full height cursor sprites respect cursor height metric" {
    const testing = std.testing;
    const alloc = testing.allocator;

    var atlas: font.Atlas = try .init(alloc, 128, .grayscale);
    defer atlas.deinit(alloc);

    // face with a 8x16 cell dimension.
    var face: Face = .{
        .metrics = .calc(.{
            // Fudged number, not used in anything we care about here.
            .px_per_em = 16,

            .cell_width = 8.0,
            .ascent = 12.0,
            .descent = -4.0,
            .line_gap = 0.0,
        }),
    };

    try testing.expectEqual(16, face.metrics.cell_height);

    // --- smaller than cell height ---
    face.metrics.cursor_height = 12;
    // bar
    {
        const glyph = try face.renderGlyph(alloc, &atlas, @intFromEnum(Sprite.cursor_bar), .{ .grid_metrics = face.metrics });
        try testing.expectEqual(12, glyph.height);
        try testing.expectEqual(14, glyph.offset_y);
    }
    // rect
    {
        const glyph = try face.renderGlyph(alloc, &atlas, @intFromEnum(Sprite.cursor_rect), .{ .grid_metrics = face.metrics });
        try testing.expectEqual(12, glyph.height);
        try testing.expectEqual(14, glyph.offset_y);
    }
    // hollow rect
    {
        const glyph = try face.renderGlyph(alloc, &atlas, @intFromEnum(Sprite.cursor_hollow_rect), .{ .grid_metrics = face.metrics });
        try testing.expectEqual(12, glyph.height);
        try testing.expectEqual(14, glyph.offset_y);
    }

    // --- equal to the cell height ---
    face.metrics.cursor_height = 16;
    // bar
    {
        const glyph = try face.renderGlyph(alloc, &atlas, @intFromEnum(Sprite.cursor_bar), .{ .grid_metrics = face.metrics });
        try testing.expectEqual(16, glyph.height);
        try testing.expectEqual(16, glyph.offset_y);
    }
    // rect
    {
        const glyph = try face.renderGlyph(alloc, &atlas, @intFromEnum(Sprite.cursor_rect), .{ .grid_metrics = face.metrics });
        try testing.expectEqual(16, glyph.height);
        try testing.expectEqual(16, glyph.offset_y);
    }
    // hollow rect
    {
        const glyph = try face.renderGlyph(alloc, &atlas, @intFromEnum(Sprite.cursor_hollow_rect), .{ .grid_metrics = face.metrics });
        try testing.expectEqual(16, glyph.height);
        try testing.expectEqual(16, glyph.offset_y);
    }

    // --- greater than the cell height ---
    face.metrics.cursor_height = 20;
    // bar
    {
        const glyph = try face.renderGlyph(alloc, &atlas, @intFromEnum(Sprite.cursor_bar), .{ .grid_metrics = face.metrics });
        try testing.expectEqual(20, glyph.height);
        try testing.expectEqual(18, glyph.offset_y);
    }
    // rect
    {
        const glyph = try face.renderGlyph(alloc, &atlas, @intFromEnum(Sprite.cursor_rect), .{ .grid_metrics = face.metrics });
        try testing.expectEqual(20, glyph.height);
        try testing.expectEqual(18, glyph.offset_y);
    }
    // hollow rect
    {
        const glyph = try face.renderGlyph(alloc, &atlas, @intFromEnum(Sprite.cursor_hollow_rect), .{ .grid_metrics = face.metrics });
        try testing.expectEqual(20, glyph.height);
        try testing.expectEqual(18, glyph.offset_y);
    }
}

// test "sprite face print all sprites" {
//     std.debug.print("\n\n", .{});
//     inline for (ranges) |range| {
//         for (range.min..range.max + 1) |cp| {
//             std.debug.print("{u}", .{ @as(u21, @intCast(cp)) });
//         }
//     }
//     std.debug.print("\n\n", .{});
// }

test {
    std.testing.refAllDecls(@This());
}
