//! https://devblogs.microsoft.com/oldnewthing/20120720-00/?p=7083
//! https://learn.microsoft.com/en-us/previous-versions/ms997538(v=msdn.10)
//! https://learn.microsoft.com/en-us/windows/win32/menurc/newheader
//! https://learn.microsoft.com/en-us/windows/win32/menurc/resdir
//! https://learn.microsoft.com/en-us/windows/win32/menurc/localheader

const std = @import("std");
const builtin = @import("builtin");
const native_endian = builtin.cpu.arch.endian();

pub const ReadError = std.mem.Allocator.Error || error{ InvalidHeader, InvalidImageType, ImpossibleDataSize, UnexpectedEOF, ReadFailed };

pub fn read(allocator: std.mem.Allocator, reader: *std.Io.Reader, max_size: u64) ReadError!IconDir {
    return readInner(allocator, reader, max_size) catch |err| switch (err) {
        error.OutOfMemory,
        error.InvalidHeader,
        error.InvalidImageType,
        error.ImpossibleDataSize,
        error.ReadFailed,
        => |e| return e,
        error.EndOfStream => error.UnexpectedEOF,
    };
}

// TODO: This seems like a somewhat strange pattern, could be a better way
//       to do this. Maybe it makes more sense to handle the translation
//       at the call site instead of having a helper function here.
fn readInner(allocator: std.mem.Allocator, reader: *std.Io.Reader, max_size: u64) !IconDir {
    const reserved = try reader.takeInt(u16, .little);
    if (reserved != 0) {
        return error.InvalidHeader;
    }

    const image_type = reader.takeEnum(ImageType, .little) catch |err| switch (err) {
        error.InvalidEnumTag => return error.InvalidImageType,
        else => |e| return e,
    };

    const num_images = try reader.takeInt(u16, .little);

    // To avoid over-allocation in the case of a file that says it has way more
    // entries than it actually does, we use an ArrayList with a conservatively
    // limited initial capacity instead of allocating the entire slice at once.
    const initial_capacity = @min(num_images, 8);
    var entries = try std.ArrayList(Entry).initCapacity(allocator, initial_capacity);
    errdefer entries.deinit(allocator);

    var i: usize = 0;
    while (i < num_images) : (i += 1) {
        var entry: Entry = undefined;
        entry.width = try reader.takeByte();
        entry.height = try reader.takeByte();
        entry.num_colors = try reader.takeByte();
        entry.reserved = try reader.takeByte();
        switch (image_type) {
            .icon => {
                entry.type_specific_data = .{ .icon = .{
                    .color_planes = try reader.takeInt(u16, .little),
                    .bits_per_pixel = try reader.takeInt(u16, .little),
                } };
            },
            .cursor => {
                entry.type_specific_data = .{ .cursor = .{
                    .hotspot_x = try reader.takeInt(u16, .little),
                    .hotspot_y = try reader.takeInt(u16, .little),
                } };
            },
        }
        entry.data_size_in_bytes = try reader.takeInt(u32, .little);
        entry.data_offset_from_start_of_file = try reader.takeInt(u32, .little);
        // Validate that the offset/data size is feasible
        if (@as(u64, entry.data_offset_from_start_of_file) + entry.data_size_in_bytes > max_size) {
            return error.ImpossibleDataSize;
        }
        // and that the data size is large enough for at least the header of an image
        // Note: This avoids needing to deal with a miscompilation from the Win32 RC
        //       compiler when the data size of an image is specified as zero but there
        //       is data to-be-read at the offset. The Win32 RC compiler will output
        //       an ICON/CURSOR resource with a bogus size in its header but with no actual
        //       data bytes in it, leading to an invalid .res. Similarly, if, for example,
        //       there is valid PNG data at the image's offset, but the size is specified
        //       as fewer bytes than the PNG header, then the Win32 RC compiler will still
        //       treat it as a PNG (e.g. unconditionally set num_planes to 1) but the data
        //       of the resource will only be 1 byte so treating it as a PNG doesn't make
        //       sense (especially not when you have to read past the data size to determine
        //       that it's a PNG).
        if (entry.data_size_in_bytes < 16) {
            return error.ImpossibleDataSize;
        }
        try entries.append(allocator, entry);
    }

    return .{
        .image_type = image_type,
        .entries = try entries.toOwnedSlice(allocator),
        .allocator = allocator,
    };
}

pub const ImageType = enum(u16) {
    icon = 1,
    cursor = 2,
};

pub const IconDir = struct {
    image_type: ImageType,
    /// Note: entries.len will always fit into a u16, since the field containing the
    /// number of images in an ico file is a u16.
    entries: []Entry,
    allocator: std.mem.Allocator,

    pub fn deinit(self: IconDir) void {
        self.allocator.free(self.entries);
    }

    pub const res_header_byte_len = 6;

    pub fn getResDataSize(self: IconDir) u32 {
        // maxInt(u16) * Entry.res_byte_len = 917,490 which is well within the u32 range.
        // Note: self.entries.len is limited to maxInt(u16)
        return @intCast(IconDir.res_header_byte_len + self.entries.len * Entry.res_byte_len);
    }

    pub fn writeResData(self: IconDir, writer: *std.Io.Writer, first_image_id: u16) !void {
        try writer.writeInt(u16, 0, .little);
        try writer.writeInt(u16, @intFromEnum(self.image_type), .little);
        // We know that entries.len must fit into a u16
        try writer.writeInt(u16, @as(u16, @intCast(self.entries.len)), .little);

        var image_id = first_image_id;
        for (self.entries) |entry| {
            try entry.writeResData(writer, image_id);
            image_id += 1;
        }
    }
};

pub const Entry = struct {
    // Icons are limited to u8 sizes, cursors can have u16,
    // so we store as u16 and truncate when needed.
    width: u16,
    height: u16,
    num_colors: u8,
    /// This should always be zero, but whatever value it is gets
    /// carried over so we need to store it
    reserved: u8,
    type_specific_data: union(ImageType) {
        icon: struct {
            color_planes: u16,
            bits_per_pixel: u16,
        },
        cursor: struct {
            hotspot_x: u16,
            hotspot_y: u16,
        },
    },
    data_size_in_bytes: u32,
    data_offset_from_start_of_file: u32,

    pub const res_byte_len = 14;

    pub fn writeResData(self: Entry, writer: *std.Io.Writer, id: u16) !void {
        switch (self.type_specific_data) {
            .icon => |icon_data| {
                try writer.writeInt(u8, @as(u8, @truncate(self.width)), .little);
                try writer.writeInt(u8, @as(u8, @truncate(self.height)), .little);
                try writer.writeInt(u8, self.num_colors, .little);
                try writer.writeInt(u8, self.reserved, .little);
                try writer.writeInt(u16, icon_data.color_planes, .little);
                try writer.writeInt(u16, icon_data.bits_per_pixel, .little);
                try writer.writeInt(u32, self.data_size_in_bytes, .little);
            },
            .cursor => |cursor_data| {
                try writer.writeInt(u16, self.width, .little);
                try writer.writeInt(u16, self.height, .little);
                try writer.writeInt(u16, cursor_data.hotspot_x, .little);
                try writer.writeInt(u16, cursor_data.hotspot_y, .little);
                try writer.writeInt(u32, self.data_size_in_bytes + 4, .little);
            },
        }
        try writer.writeInt(u16, id, .little);
    }
};

test "icon" {
    const data = "\x00\x00\x01\x00\x01\x00\x10\x10\x00\x00\x01\x00\x10\x00\x10\x00\x00\x00\x16\x00\x00\x00" ++ @as([16]u8, @splat(0));
    var fbs: std.Io.Reader = .fixed(data);
    const icon = try read(std.testing.allocator, &fbs, data.len);
    defer icon.deinit();

    try std.testing.expectEqual(ImageType.icon, icon.image_type);
    try std.testing.expectEqual(@as(usize, 1), icon.entries.len);
}

test "icon too many images" {
    // Note that with verifying that all data sizes are within the file bounds and >= 16,
    // it's not possible to hit EOF when looking for more RESDIR structures, since they are
    // themselves 16 bytes long, so we'll always hit ImpossibleDataSize instead.
    const data = "\x00\x00\x01\x00\x02\x00\x10\x10\x00\x00\x01\x00\x10\x00\x10\x00\x00\x00\x16\x00\x00\x00" ++ @as([16]u8, @splat(0));
    var fbs: std.Io.Reader = .fixed(data);
    try std.testing.expectError(error.ImpossibleDataSize, read(std.testing.allocator, &fbs, data.len));
}

test "icon data size past EOF" {
    const data = "\x00\x00\x01\x00\x01\x00\x10\x10\x00\x00\x01\x00\x10\x00\x10\x01\x00\x00\x16\x00\x00\x00" ++ @as([16]u8, @splat(0));
    var fbs: std.Io.Reader = .fixed(data);
    try std.testing.expectError(error.ImpossibleDataSize, read(std.testing.allocator, &fbs, data.len));
}

test "icon data offset past EOF" {
    const data = "\x00\x00\x01\x00\x01\x00\x10\x10\x00\x00\x01\x00\x10\x00\x10\x00\x00\x00\x17\x00\x00\x00" ++ @as([16]u8, @splat(0));
    var fbs: std.Io.Reader = .fixed(data);
    try std.testing.expectError(error.ImpossibleDataSize, read(std.testing.allocator, &fbs, data.len));
}

test "icon data size too small" {
    const data = "\x00\x00\x01\x00\x01\x00\x10\x10\x00\x00\x01\x00\x10\x00\x0F\x00\x00\x00\x16\x00\x00\x00";
    var fbs: std.Io.Reader = .fixed(data);
    try std.testing.expectError(error.ImpossibleDataSize, read(std.testing.allocator, &fbs, data.len));
}

pub const ImageFormat = enum(u2) {
    dib,
    png,
    riff,

    const riff_header = std.mem.readInt(u32, "RIFF", native_endian);
    const png_signature = std.mem.readInt(u64, "\x89PNG\r\n\x1a\n", native_endian);
    const ihdr_code = std.mem.readInt(u32, "IHDR", native_endian);
    const acon_form_type = std.mem.readInt(u32, "ACON", native_endian);

    pub fn detect(header_bytes: *const [16]u8) ImageFormat {
        if (std.mem.readInt(u32, header_bytes[0..4], native_endian) == riff_header) return .riff;
        if (std.mem.readInt(u64, header_bytes[0..8], native_endian) == png_signature) return .png;
        return .dib;
    }

    pub fn validate(format: ImageFormat, header_bytes: *const [16]u8) bool {
        return switch (format) {
            .png => std.mem.readInt(u32, header_bytes[12..16], native_endian) == ihdr_code,
            .riff => std.mem.readInt(u32, header_bytes[8..12], native_endian) == acon_form_type,
            .dib => true,
        };
    }
};

/// Contains only the fields of BITMAPINFOHEADER (WinGDI.h) that are both:
/// - relevant to what we need, and
/// - are shared between all versions of BITMAPINFOHEADER (V4, V5).
pub const BitmapHeader = extern struct {
    bcSize: u32,
    bcWidth: i32,
    bcHeight: i32,
    bcPlanes: u16,
    bcBitCount: u16,

    pub fn version(self: *const BitmapHeader) Version {
        return Version.get(self.bcSize);
    }

    /// https://en.wikipedia.org/wiki/BMP_file_format#DIB_header_(bitmap_information_header)
    pub const Version = enum(u3) {
        unknown,
        @"win2.0", // Windows 2.0 or later
        @"nt3.1", // Windows NT, 3.1x or later
        @"nt4.0", // Windows NT 4.0, 95 or later
        @"nt5.0", // Windows NT 5.0, 98 or later

        pub fn get(header_size: u32) Version {
            return switch (header_size) {
                len(.@"win2.0") => .@"win2.0",
                len(.@"nt3.1") => .@"nt3.1",
                len(.@"nt4.0") => .@"nt4.0",
                len(.@"nt5.0") => .@"nt5.0",
                else => .unknown,
            };
        }

        pub fn len(comptime v: Version) comptime_int {
            return switch (v) {
                .@"win2.0" => 12,
                .@"nt3.1" => 40,
                .@"nt4.0" => 108,
                .@"nt5.0" => 124,
                .unknown => unreachable,
            };
        }

        pub fn nameForErrorDisplay(v: Version) []const u8 {
            return switch (v) {
                .unknown => "unknown",
                .@"win2.0" => "Windows 2.0 (BITMAPCOREHEADER)",
                .@"nt3.1" => "Windows NT, 3.1x (BITMAPINFOHEADER)",
                .@"nt4.0" => "Windows NT 4.0, 95 (BITMAPV4HEADER)",
                .@"nt5.0" => "Windows NT 5.0, 98 (BITMAPV5HEADER)",
            };
        }
    };
};
