const colorpkg = @This();

const std = @import("std");
const assert = @import("../quirks.zig").inlineAssert;
const x11_color = @import("x11_color.zig");

/// The default palette.
pub const default: Palette = default: {
    var result: Palette = undefined;

    // Named values
    var i: u8 = 0;
    while (i < 16) : (i += 1) {
        result[i] = Name.default(@enumFromInt(i)) catch unreachable;
    }

    // Cube
    assert(i == 16);
    var r: u8 = 0;
    while (r < 6) : (r += 1) {
        var g: u8 = 0;
        while (g < 6) : (g += 1) {
            var b: u8 = 0;
            while (b < 6) : (b += 1) {
                result[i] = .{
                    .r = if (r == 0) 0 else (r * 40 + 55),
                    .g = if (g == 0) 0 else (g * 40 + 55),
                    .b = if (b == 0) 0 else (b * 40 + 55),
                };

                i += 1;
            }
        }
    }

    // Gray ramp
    assert(i == 232);
    assert(@TypeOf(i) == u8);
    while (i > 0) : (i +%= 1) {
        const value = ((i - 232) * 10) + 8;
        result[i] = .{ .r = value, .g = value, .b = value };
    }

    break :default result;
};

/// Palette is the 256 color palette.
pub const Palette = [256]RGB;

/// A parsed palette entry from Ghostty's config "N=COLOR" syntax.
pub const PaletteEntry = struct {
    index: u8,
    color: RGB,
};

/// Parse a palette entry in Ghostty config syntax: "N=COLOR" where N is
/// a palette index 0-255 (decimal, or 0x/0o/0b-prefixed per Zig's
/// parseInt base-0 rules) and COLOR is anything RGB.parse accepts.
/// Whitespace (spaces/tabs) around N and COLOR is ignored.
pub fn parsePaletteEntry(value: []const u8) error{ InvalidFormat, Overflow }!PaletteEntry {
    const eql_idx = std.mem.indexOfScalar(u8, value, '=') orelse
        return error.InvalidFormat;
    const index = std.fmt.parseInt(
        u8,
        std.mem.trim(u8, value[0..eql_idx], " \t"),
        0,
    ) catch |err| switch (err) {
        error.Overflow => return error.Overflow,
        error.InvalidCharacter => return error.InvalidFormat,
    };
    const rgb = try RGB.parse(value[eql_idx + 1 ..]);
    return .{ .index = index, .color = rgb };
}

test "parsePaletteEntry" {
    const testing = std.testing;

    {
        const entry = try parsePaletteEntry("0=#AABBCC");
        try testing.expectEqual(@as(u8, 0), entry.index);
        try testing.expectEqual(RGB{ .r = 170, .g = 187, .b = 204 }, entry.color);
    }
    {
        const entry = try parsePaletteEntry("0b1=#014589");
        try testing.expectEqual(@as(u8, 1), entry.index);
        try testing.expectEqual(RGB{ .r = 1, .g = 69, .b = 137 }, entry.color);
    }
    {
        const entry = try parsePaletteEntry("0o7=#234567");
        try testing.expectEqual(@as(u8, 7), entry.index);
        try testing.expectEqual(RGB{ .r = 35, .g = 69, .b = 103 }, entry.color);
    }
    {
        const entry = try parsePaletteEntry("0xF=#ABCDEF");
        try testing.expectEqual(@as(u8, 15), entry.index);
        try testing.expectEqual(RGB{ .r = 171, .g = 205, .b = 239 }, entry.color);
    }
    {
        const entry = try parsePaletteEntry("0 =  #AABBCC");
        try testing.expectEqual(@as(u8, 0), entry.index);
        try testing.expectEqual(RGB{ .r = 170, .g = 187, .b = 204 }, entry.color);
    }
    {
        const entry = try parsePaletteEntry(" 1= #DDEEFF    ");
        try testing.expectEqual(@as(u8, 1), entry.index);
        try testing.expectEqual(RGB{ .r = 221, .g = 238, .b = 255 }, entry.color);
    }
    {
        const entry = try parsePaletteEntry("  2  =  #123456 ");
        try testing.expectEqual(@as(u8, 2), entry.index);
        try testing.expectEqual(RGB{ .r = 18, .g = 52, .b = 86 }, entry.color);
    }
    {
        const entry = try parsePaletteEntry("1=black");
        try testing.expectEqual(@as(u8, 1), entry.index);
        try testing.expectEqual(RGB{ .r = 0, .g = 0, .b = 0 }, entry.color);
    }

    try testing.expectError(error.InvalidFormat, parsePaletteEntry(" "));
    try testing.expectError(error.InvalidFormat, parsePaletteEntry("a"));
    try testing.expectError(error.Overflow, parsePaletteEntry("256=#AABBCC"));
    try testing.expectError(error.InvalidFormat, parsePaletteEntry("1=notacolor"));
}

/// C-compatible palette type using the extern RGB struct.
pub const PaletteC = [256]RGB.C;

/// Convert a Palette to a PaletteC.
pub fn paletteCval(palette: *const Palette) PaletteC {
    var result: PaletteC = undefined;
    for (&result, palette) |*dst, src| dst.* = src.cval();
    return result;
}

/// Convert a PaletteC to a Palette.
pub fn paletteZval(palette: *const PaletteC) Palette {
    var result: Palette = undefined;
    for (&result, palette) |*dst, src| dst.* = .fromC(src);
    return result;
}

/// Mask that can be used to set which palette indexes were set.
pub const PaletteMask = std.StaticBitSet(@typeInfo(Palette).array.len);

/// Generate the 256-color palette from the user's base16 theme colors,
/// terminal background, and terminal foreground.
///
/// Motivation: The default 256-color palette uses fixed, fully-saturated
/// colors that clash with custom base16 themes, have poor readability in
/// dark shades (the first non-black shade jumps to 37% intensity instead
/// of the expected 20%), and exhibit inconsistent perceived brightness
/// across hues of the same shade (e.g., blue appears darker than green).
/// By generating the extended palette from the user's chosen colors,
/// programs can use the richer 256-color range without requiring their
/// own theme configuration, and light/dark switching works automatically.
///
/// The 216-color cube (indices 16–231) is built via trilinear
/// interpolation in CIELAB space over the 8 base colors. The base16
/// palette maps to the 8 corners of a 6×6×6 RGB cube as follows:
///
///   R=0 edge: bg      → base[1] (red)
///   R=5 edge: base[6] → fg
///   G=0 edge: bg/base[6] (via R) → base[2]/base[4] (green/blue via R)
///   G=5 edge: base[1]/fg (via R) → base[3]/base[5] (yellow/magenta via R)
///
/// For each R slice, four corner colors (c0–c3) are interpolated along
/// the R axis, then for each G row two edge colors (c4–c5) are
/// interpolated along G, and finally each B cell is interpolated along B
/// to produce the final color. CIELAB interpolation ensures perceptually
/// uniform brightness transitions across different hues.
///
/// The 24-step grayscale ramp (indices 232–255) is a simple linear
/// interpolation in CIELAB from the background to the foreground,
/// excluding pure black and white (available in the cube at (0,0,0)
/// and (5,5,5)). The interpolation parameter runs from 1/25 to 24/25.
///
/// Fill `skip` with user-defined color indexes to avoid replacing them.
///
/// Reference: https://gist.github.com/jake-stewart/0a8ea46159a7da2c808e5be2177e1783
pub fn generate256Color(
    base: Palette,
    skip: PaletteMask,
    bg: RGB,
    fg: RGB,
    harmonious: bool,
) Palette {
    // Convert the background, foreground, and 8 base theme colors into
    // CIELAB space so that all interpolation is perceptually uniform.
    const base8_lab: [8]LAB = base8: {
        var base8: [8]LAB = .{
            .fromRgb(bg),
            LAB.fromRgb(base[1]),
            LAB.fromRgb(base[2]),
            LAB.fromRgb(base[3]),
            LAB.fromRgb(base[4]),
            LAB.fromRgb(base[5]),
            LAB.fromRgb(base[6]),
            .fromRgb(fg),
        };

        // For light themes (where the foreground is darker than the
        // background), the cube's dark-to-light orientation is inverted
        // relative to the base color mapping. When `harmonious` is false,
        // swap bg and fg so the cube still runs from black (16) to
        // white (231).
        const is_light_theme = base8[7].l < base8[0].l;
        const invert = is_light_theme and !harmonious;
        if (invert) std.mem.swap(LAB, &base8[0], &base8[7]);

        break :base8 base8;
    };

    // Start from the base palette so indices 0–15 are preserved as-is.
    var result = base;

    // Build the 216-color cube (indices 16–231) via trilinear interpolation
    // in CIELAB. The three nested loops correspond to the R, G, and B axes
    // of a 6×6×6 cube. For each R slice, four corner colors (c0–c3) are
    // interpolated along R from the 8 base colors, mapping the cube corners
    // to theme-aware anchors (see doc comment for the mapping). Then for
    // each G row, two edge colors (c4–c5) blend along G, and finally each
    // B cell interpolates along B to produce the final color.
    var idx: usize = 16;
    for (0..6) |ri| {
        // R-axis corners: blend base colors along the red dimension.
        const tr = @as(f32, @floatFromInt(ri)) / 5.0;
        const c0: LAB = .lerp(tr, base8_lab[0], base8_lab[1]);
        const c1: LAB = .lerp(tr, base8_lab[2], base8_lab[3]);
        const c2: LAB = .lerp(tr, base8_lab[4], base8_lab[5]);
        const c3: LAB = .lerp(tr, base8_lab[6], base8_lab[7]);
        for (0..6) |gi| {
            // G-axis edges: blend the R-interpolated corners along green.
            const tg = @as(f32, @floatFromInt(gi)) / 5.0;
            const c4: LAB = .lerp(tg, c0, c1);
            const c5: LAB = .lerp(tg, c2, c3);
            for (0..6) |bi| {
                // B-axis: final interpolation along blue, then convert back to RGB.
                if (!skip.isSet(idx)) {
                    const c6: LAB = .lerp(
                        @as(f32, @floatFromInt(bi)) / 5.0,
                        c4,
                        c5,
                    );
                    result[idx] = c6.toRgb();
                }

                idx += 1;
            }
        }
    }

    // Build the 24-step grayscale ramp (indices 232–255) by linearly
    // interpolating in CIELAB from background to foreground. The parameter
    // runs from 1/25 to 24/25, excluding the endpoints which are already
    // available in the cube at (0,0,0) and (5,5,5).
    for (0..24) |i| {
        const t = @as(f32, @floatFromInt(i + 1)) / 25.0;
        if (!skip.isSet(idx)) {
            const c: LAB = .lerp(t, base8_lab[0], base8_lab[7]);
            result[idx] = c.toRgb();
        }
        idx += 1;
    }

    return result;
}

/// A palette that can have its colors changed and reset. Purposely built
/// for terminal color operations.
pub const DynamicPalette = struct {
    /// The current palette including any user modifications.
    current: Palette,

    /// The original/default palette values.
    original: Palette,

    /// A bitset where each bit represents whether the corresponding
    /// palette index has been modified from its default value.
    mask: PaletteMask,

    pub const default: DynamicPalette = .init(colorpkg.default);

    /// Initialize a dynamic palette with a default palette.
    pub fn init(def: Palette) DynamicPalette {
        return .{
            .current = def,
            .original = def,
            .mask = .initEmpty(),
        };
    }

    /// Set a custom color at the given palette index.
    pub fn set(self: *DynamicPalette, idx: u8, color: RGB) void {
        self.current[idx] = color;
        self.mask.set(idx);
    }

    /// Reset the color at the given palette index to its original value.
    pub fn reset(self: *DynamicPalette, idx: u8) void {
        self.current[idx] = self.original[idx];
        self.mask.unset(idx);
    }

    /// Reset all colors to their original values.
    pub fn resetAll(self: *DynamicPalette) void {
        self.* = .init(self.original);
    }

    /// Change the default palette, but preserve the changed values.
    pub fn changeDefault(self: *DynamicPalette, def: Palette) void {
        self.original = def;

        // Fast path, the palette is usually not changed.
        if (self.mask.count() == 0) {
            self.current = self.original;
            return;
        }

        // There are usually less set than unset, so iterate over the changed
        // values and override them.
        var current = def;
        var it = self.mask.iterator(.{});
        while (it.next()) |idx| current[idx] = self.current[idx];
        self.current = current;
    }
};

/// RGB value that can be changed and reset. This can also be totally unset
/// in every way, in which case the caller can determine their own ultimate
/// default.
pub const DynamicRGB = struct {
    override: ?RGB,
    default: ?RGB,

    pub const unset: DynamicRGB = .{ .override = null, .default = null };

    pub fn init(def: RGB) DynamicRGB {
        return .{
            .override = null,
            .default = def,
        };
    }

    pub fn get(self: *const DynamicRGB) ?RGB {
        return self.override orelse self.default;
    }

    pub fn set(self: *DynamicRGB, color: RGB) void {
        self.override = color;
    }

    pub fn reset(self: *DynamicRGB) void {
        self.override = self.default;
    }
};

/// Color names in the standard 8 or 16 color palette.
pub const Name = enum(u8) {
    black = 0,
    red = 1,
    green = 2,
    yellow = 3,
    blue = 4,
    magenta = 5,
    cyan = 6,
    white = 7,

    bright_black = 8,
    bright_red = 9,
    bright_green = 10,
    bright_yellow = 11,
    bright_blue = 12,
    bright_magenta = 13,
    bright_cyan = 14,
    bright_white = 15,

    // Remainders are valid unnamed values in the 256 color palette.
    _,

    pub const C = u8;

    pub fn cval(self: Name) C {
        return @intFromEnum(self);
    }

    /// Default colors for tagged values.
    pub fn default(self: Name) error{NoDefaultValue}!RGB {
        return switch (self) {
            .black => RGB{ .r = 0x1D, .g = 0x1F, .b = 0x21 },
            .red => RGB{ .r = 0xCC, .g = 0x66, .b = 0x66 },
            .green => RGB{ .r = 0xB5, .g = 0xBD, .b = 0x68 },
            .yellow => RGB{ .r = 0xF0, .g = 0xC6, .b = 0x74 },
            .blue => RGB{ .r = 0x81, .g = 0xA2, .b = 0xBE },
            .magenta => RGB{ .r = 0xB2, .g = 0x94, .b = 0xBB },
            .cyan => RGB{ .r = 0x8A, .g = 0xBE, .b = 0xB7 },
            .white => RGB{ .r = 0xC5, .g = 0xC8, .b = 0xC6 },

            .bright_black => RGB{ .r = 0x66, .g = 0x66, .b = 0x66 },
            .bright_red => RGB{ .r = 0xD5, .g = 0x4E, .b = 0x53 },
            .bright_green => RGB{ .r = 0xB9, .g = 0xCA, .b = 0x4A },
            .bright_yellow => RGB{ .r = 0xE7, .g = 0xC5, .b = 0x47 },
            .bright_blue => RGB{ .r = 0x7A, .g = 0xA6, .b = 0xDA },
            .bright_magenta => RGB{ .r = 0xC3, .g = 0x97, .b = 0xD8 },
            .bright_cyan => RGB{ .r = 0x70, .g = 0xC0, .b = 0xB1 },
            .bright_white => RGB{ .r = 0xEA, .g = 0xEA, .b = 0xEA },

            else => error.NoDefaultValue,
        };
    }
};

/// The "special colors" as denoted by xterm. These can be set via
/// OSC 5 or via OSC 4 by adding the palette length to it.
///
/// https://invisible-island.net/xterm/ctlseqs/ctlseqs.html
pub const Special = enum(u3) {
    bold = 0,
    underline = 1,
    blink = 2,
    reverse = 3,
    italic = 4,

    pub fn osc4(self: Special) u16 {
        // "The special colors can also be set by adding the maximum
        // number of colors (e.g., 88 or 256) to these codes in an
        // OSC 4  control" - xterm ctlseqs
        const max = @typeInfo(Palette).array.len;
        return @as(u16, @intCast(@intFromEnum(self))) + max;
    }

    test "osc4" {
        const testing = std.testing;
        try testing.expectEqual(256, Special.bold.osc4());
        try testing.expectEqual(257, Special.underline.osc4());
        try testing.expectEqual(258, Special.blink.osc4());
        try testing.expectEqual(259, Special.reverse.osc4());
        try testing.expectEqual(260, Special.italic.osc4());
    }
};

test Special {
    _ = Special;
}

/// The "dynamic colors" as denoted by xterm. These can be set via
/// OSC 10 through 19.
pub const Dynamic = enum(u5) {
    foreground = 10,
    background = 11,
    cursor = 12,
    pointer_foreground = 13,
    pointer_background = 14,
    tektronix_foreground = 15,
    tektronix_background = 16,
    highlight_background = 17,
    tektronix_cursor = 18,
    highlight_foreground = 19,

    /// The next dynamic color sequentially. This is required because
    /// specifying colors sequentially without their index will automatically
    /// use the next dynamic color.
    ///
    /// "Each successive parameter changes the next color in the list.  The
    /// value of Ps tells the starting point in the list."
    pub fn next(self: Dynamic) ?Dynamic {
        return std.meta.intToEnum(
            Dynamic,
            @intFromEnum(self) + 1,
        ) catch null;
    }

    test "next" {
        const testing = std.testing;
        try testing.expectEqual(.background, Dynamic.foreground.next());
        try testing.expectEqual(.cursor, Dynamic.background.next());
        try testing.expectEqual(.pointer_foreground, Dynamic.cursor.next());
        try testing.expectEqual(.pointer_background, Dynamic.pointer_foreground.next());
        try testing.expectEqual(.tektronix_foreground, Dynamic.pointer_background.next());
        try testing.expectEqual(.tektronix_background, Dynamic.tektronix_foreground.next());
        try testing.expectEqual(.highlight_background, Dynamic.tektronix_background.next());
        try testing.expectEqual(.tektronix_cursor, Dynamic.highlight_background.next());
        try testing.expectEqual(.highlight_foreground, Dynamic.tektronix_cursor.next());
        try testing.expectEqual(null, Dynamic.highlight_foreground.next());
    }
};

test Dynamic {
    _ = Dynamic;
}

/// RGB
pub const RGB = packed struct(u24) {
    r: u8 = 0,
    g: u8 = 0,
    b: u8 = 0,

    pub const C = extern struct {
        r: u8,
        g: u8,
        b: u8,
    };

    pub fn fromC(c: C) RGB {
        return .{ .r = c.r, .g = c.g, .b = c.b };
    }

    pub fn cval(self: RGB) C {
        return .{
            .r = self.r,
            .g = self.g,
            .b = self.b,
        };
    }

    pub fn eql(self: RGB, other: RGB) bool {
        return self.r == other.r and self.g == other.g and self.b == other.b;
    }

    pub fn encodeRgb8(self: RGB, writer: *std.Io.Writer) !void {
        try writer.print(
            "rgb:{x:0>2}/{x:0>2}/{x:0>2}",
            .{ self.r, self.g, self.b },
        );
    }

    pub fn encodeRgb16(self: RGB, writer: *std.Io.Writer) !void {
        try writer.print(
            "rgb:{x:0>4}/{x:0>4}/{x:0>4}",
            .{
                @as(u16, self.r) * 257,
                @as(u16, self.g) * 257,
                @as(u16, self.b) * 257,
            },
        );
    }

    /// Calculates the contrast ratio between two colors. The contrast
    /// ration is a value between 1 and 21 where 1 is the lowest contrast
    /// and 21 is the highest contrast.
    ///
    /// https://www.w3.org/TR/WCAG20/#contrast-ratiodef
    pub fn contrast(self: RGB, other: RGB) f64 {
        // pair[0] = lighter, pair[1] = darker
        const pair: [2]f64 = pair: {
            const self_lum = self.luminance();
            const other_lum = other.luminance();
            if (self_lum > other_lum) break :pair .{ self_lum, other_lum };
            break :pair .{ other_lum, self_lum };
        };

        return (pair[0] + 0.05) / (pair[1] + 0.05);
    }

    /// Calculates luminance based on the W3C formula. This returns a
    /// normalized value between 0 and 1 where 0 is black and 1 is white.
    ///
    /// https://www.w3.org/TR/WCAG20/#relativeluminancedef
    pub fn luminance(self: RGB) f64 {
        const r_lum = componentLuminance(self.r);
        const g_lum = componentLuminance(self.g);
        const b_lum = componentLuminance(self.b);
        return 0.2126 * r_lum + 0.7152 * g_lum + 0.0722 * b_lum;
    }

    /// Calculates single-component luminance based on the W3C formula.
    ///
    /// Expects sRGB color space which at the time of writing we don't
    /// generally use but it's a good enough approximation until we fix that.
    /// https://www.w3.org/TR/WCAG20/#relativeluminancedef
    fn componentLuminance(c: u8) f64 {
        const c_f64: f64 = @floatFromInt(c);
        const normalized: f64 = c_f64 / 255;
        if (normalized <= 0.03928) return normalized / 12.92;
        return std.math.pow(f64, (normalized + 0.055) / 1.055, 2.4);
    }

    /// Calculates "perceived luminance" which is better for determining
    /// light vs dark.
    ///
    /// Source: https://www.w3.org/TR/AERT/#color-contrast
    pub fn perceivedLuminance(self: RGB) f64 {
        const r_f64: f64 = @floatFromInt(self.r);
        const g_f64: f64 = @floatFromInt(self.g);
        const b_f64: f64 = @floatFromInt(self.b);
        return 0.299 * (r_f64 / 255) + 0.587 * (g_f64 / 255) + 0.114 * (b_f64 / 255);
    }

    comptime {
        assert(@bitSizeOf(RGB) == 24);
        assert(@sizeOf(RGB) == 4);
    }

    /// Parse a color from a floating point intensity value.
    ///
    /// The value should be between 0.0 and 1.0, inclusive.
    fn fromIntensity(value: []const u8) error{InvalidFormat}!u8 {
        const i = std.fmt.parseFloat(f64, value) catch {
            @branchHint(.cold);
            return error.InvalidFormat;
        };
        if (i < 0.0 or i > 1.0) {
            @branchHint(.cold);
            return error.InvalidFormat;
        }

        return @intFromFloat(i * std.math.maxInt(u8));
    }

    /// Parse a color from a string of hexadecimal digits
    ///
    /// The string can contain 1, 2, 3, or 4 characters and represents the color
    /// value scaled in 4, 8, 12, or 16 bits, respectively.
    fn fromHex(value: []const u8) error{InvalidFormat}!u8 {
        if (value.len == 0 or value.len > 4) {
            @branchHint(.cold);
            return error.InvalidFormat;
        }

        const color = std.fmt.parseUnsigned(u16, value, 16) catch {
            @branchHint(.cold);
            return error.InvalidFormat;
        };

        const divisor: usize = switch (value.len) {
            1 => std.math.maxInt(u4),
            2 => std.math.maxInt(u8),
            3 => std.math.maxInt(u12),
            4 => std.math.maxInt(u16),
            else => unreachable,
        };

        return @intCast(@as(usize, color) * std.math.maxInt(u8) / divisor);
    }

    /// Parse a color specification.
    ///
    /// Leading and trailing spaces and tabs are ignored.
    ///
    /// Any of the following forms are accepted:
    ///
    /// 1. rgb:<red>/<green>/<blue>
    ///
    ///    <red>, <green>, <blue> := h | hh | hhh | hhhh
    ///
    ///    where `h` is a single hexadecimal digit.
    ///
    /// 2. rgbi:<red>/<green>/<blue>
    ///
    ///    where <red>, <green>, and <blue> are floating point values between
    ///    0.0 and 1.0 (inclusive).
    ///
    /// 3. #rgb, #rrggbb, rgb, rrggbb, #rrrgggbbb, #rrrrggggbbbb
    ///
    ///    where `r`, `g`, and `b` are hexadecimal digits. The forms with
    ///    a leading # specify a color with 4, 8, 12, and 16 bits of
    ///    precision per color channel. The forms without a leading # are
    ///    accepted for compatibility with Ghostty config/theme color values.
    ///
    /// 4. X11 color names
    pub fn parse(value: []const u8) error{InvalidFormat}!RGB {
        const input = std.mem.trim(u8, value, " \t");
        if (input.len == 0) {
            @branchHint(.cold);
            return error.InvalidFormat;
        }

        if (input[0] == '#') {
            switch (input.len) {
                4 => return RGB{
                    .r = try RGB.fromHex(input[1..2]),
                    .g = try RGB.fromHex(input[2..3]),
                    .b = try RGB.fromHex(input[3..4]),
                },
                7 => return RGB{
                    .r = try RGB.fromHex(input[1..3]),
                    .g = try RGB.fromHex(input[3..5]),
                    .b = try RGB.fromHex(input[5..7]),
                },
                10 => return RGB{
                    .r = try RGB.fromHex(input[1..4]),
                    .g = try RGB.fromHex(input[4..7]),
                    .b = try RGB.fromHex(input[7..10]),
                },
                13 => return RGB{
                    .r = try RGB.fromHex(input[1..5]),
                    .g = try RGB.fromHex(input[5..9]),
                    .b = try RGB.fromHex(input[9..13]),
                },

                else => {
                    @branchHint(.cold);
                    return error.InvalidFormat;
                },
            }
        }

        // Check for X11 named colors. We allow whitespace around the edges.
        if (x11_color.map.get(input)) |rgb| return rgb;

        switch (input.len) {
            3 => return RGB{
                .r = try RGB.fromHex(input[0..1]),
                .g = try RGB.fromHex(input[1..2]),
                .b = try RGB.fromHex(input[2..3]),
            },
            6 => return RGB{
                .r = try RGB.fromHex(input[0..2]),
                .g = try RGB.fromHex(input[2..4]),
                .b = try RGB.fromHex(input[4..6]),
            },
            else => {},
        }

        if (input.len < "rgb:a/a/a".len or !std.mem.eql(u8, input[0..3], "rgb")) {
            @branchHint(.cold);
            return error.InvalidFormat;
        }

        var i: usize = 3;

        const use_intensity = if (input[i] == 'i') blk: {
            i += 1;
            break :blk true;
        } else false;

        if (input[i] != ':') {
            @branchHint(.cold);
            return error.InvalidFormat;
        }

        i += 1;

        const r = r: {
            const slice = if (std.mem.indexOfScalarPos(u8, input, i, '/')) |end|
                input[i..end]
            else {
                @branchHint(.cold);
                return error.InvalidFormat;
            };

            i += slice.len + 1;

            break :r if (use_intensity)
                try RGB.fromIntensity(slice)
            else
                try RGB.fromHex(slice);
        };

        const g = g: {
            const slice = if (std.mem.indexOfScalarPos(u8, input, i, '/')) |end|
                input[i..end]
            else {
                @branchHint(.cold);
                return error.InvalidFormat;
            };

            i += slice.len + 1;

            break :g if (use_intensity)
                try RGB.fromIntensity(slice)
            else
                try RGB.fromHex(slice);
        };

        const b = if (use_intensity)
            try RGB.fromIntensity(input[i..])
        else
            try RGB.fromHex(input[i..]);

        return RGB{
            .r = r,
            .g = g,
            .b = b,
        };
    }
};

/// LAB color space
const LAB = struct {
    l: f32,
    a: f32,
    b: f32,

    /// RGB to LAB
    pub fn fromRgb(rgb: RGB) LAB {
        // Step 1: Normalize sRGB channels from [0, 255] to [0.0, 1.0].
        var r: f32 = @as(f32, @floatFromInt(rgb.r)) / 255.0;
        var g: f32 = @as(f32, @floatFromInt(rgb.g)) / 255.0;
        var b: f32 = @as(f32, @floatFromInt(rgb.b)) / 255.0;

        // Step 2: Apply the inverse sRGB companding (gamma correction) to
        // convert from sRGB to linear RGB. The sRGB transfer function has
        // two segments: a linear portion for small values and a power curve
        // for the rest.
        r = if (r > 0.04045) std.math.pow(f32, (r + 0.055) / 1.055, 2.4) else r / 12.92;
        g = if (g > 0.04045) std.math.pow(f32, (g + 0.055) / 1.055, 2.4) else g / 12.92;
        b = if (b > 0.04045) std.math.pow(f32, (b + 0.055) / 1.055, 2.4) else b / 12.92;

        // Step 3: Convert linear RGB to CIE XYZ using the sRGB to XYZ
        // transformation matrix (D65 illuminant). The X and Z values are
        // normalized by the D65 white point reference values (Xn=0.95047,
        // Zn=1.08883; Yn=1.0 is implicit).
        var x = (r * 0.4124564 + g * 0.3575761 + b * 0.1804375) / 0.95047;
        var y = r * 0.2126729 + g * 0.7151522 + b * 0.0721750;
        var z = (r * 0.0193339 + g * 0.1191920 + b * 0.9503041) / 1.08883;

        // Step 4: Apply the CIE f(t) nonlinear transform to each XYZ
        // component. Above the threshold (epsilon ≈ 0.008856) the cube
        // root is used; below it, a linear approximation avoids numerical
        // instability near zero.
        x = if (x > 0.008856) std.math.cbrt(x) else 7.787 * x + 16.0 / 116.0;
        y = if (y > 0.008856) std.math.cbrt(y) else 7.787 * y + 16.0 / 116.0;
        z = if (z > 0.008856) std.math.cbrt(z) else 7.787 * z + 16.0 / 116.0;

        // Step 5: Compute the final CIELAB values from the transformed XYZ.
        // L* is lightness (0–100), a* is green–red, b* is blue–yellow.
        return .{ .l = 116.0 * y - 16.0, .a = 500.0 * (x - y), .b = 200.0 * (y - z) };
    }

    /// LAB to RGB
    pub fn toRgb(self: LAB) RGB {
        // Step 1: Recover the intermediate f(Y), f(X), f(Z) values from
        // L*a*b* by inverting the CIELAB formulas.
        const y = (self.l + 16.0) / 116.0;
        const x = self.a / 500.0 + y;
        const z = y - self.b / 200.0;

        // Step 2: Apply the inverse CIE f(t) transform to get back to
        // XYZ. Above epsilon (≈0.008856) the cube is used; below it the
        // linear segment is inverted. Results are then scaled by the D65
        // white point reference values (Xn=0.95047, Zn=1.08883; Yn=1.0).
        const x3 = x * x * x;
        const y3 = y * y * y;
        const z3 = z * z * z;
        const xf = (if (x3 > 0.008856) x3 else (x - 16.0 / 116.0) / 7.787) * 0.95047;
        const yf = if (y3 > 0.008856) y3 else (y - 16.0 / 116.0) / 7.787;
        const zf = (if (z3 > 0.008856) z3 else (z - 16.0 / 116.0) / 7.787) * 1.08883;

        // Step 3: Convert CIE XYZ back to linear RGB using the XYZ to sRGB
        // matrix (inverse of the sRGB to XYZ matrix, D65 illuminant).
        var r = xf * 3.2404542 - yf * 1.5371385 - zf * 0.4985314;
        var g = -xf * 0.9692660 + yf * 1.8760108 + zf * 0.0415560;
        var b = xf * 0.0556434 - yf * 0.2040259 + zf * 1.0572252;

        // Step 4: Apply sRGB companding (gamma correction) to convert from
        // linear RGB back to sRGB. This is the forward sRGB transfer
        // function with the same two-segment split as the inverse.
        r = if (r > 0.0031308) 1.055 * std.math.pow(f32, r, 1.0 / 2.4) - 0.055 else 12.92 * r;
        g = if (g > 0.0031308) 1.055 * std.math.pow(f32, g, 1.0 / 2.4) - 0.055 else 12.92 * g;
        b = if (b > 0.0031308) 1.055 * std.math.pow(f32, b, 1.0 / 2.4) - 0.055 else 12.92 * b;

        // Step 5: Clamp to [0.0, 1.0], scale to [0, 255], and round to
        // the nearest integer to produce the final 8-bit sRGB values.
        return .{
            .r = @intFromFloat(@min(@max(r, 0.0), 1.0) * 255.0 + 0.5),
            .g = @intFromFloat(@min(@max(g, 0.0), 1.0) * 255.0 + 0.5),
            .b = @intFromFloat(@min(@max(b, 0.0), 1.0) * 255.0 + 0.5),
        };
    }

    /// Linearly interpolate between two LAB colors component-wise.
    /// `t` is the interpolation factor in [0, 1]: t=0 returns `a`,
    /// t=1 returns `b`, and values in between blend proportionally.
    pub fn lerp(t: f32, a: LAB, b: LAB) LAB {
        return .{
            .l = a.l + t * (b.l - a.l),
            .a = a.a + t * (b.a - a.a),
            .b = a.b + t * (b.b - a.b),
        };
    }
};

test "palette: default" {
    const testing = std.testing;

    // Safety check
    var i: u8 = 0;
    while (i < 16) : (i += 1) {
        try testing.expectEqual(Name.default(@as(Name, @enumFromInt(i))), default[i]);
    }
}

test "RGB.parse" {
    const testing = std.testing;

    try testing.expectEqual(RGB{ .r = 255, .g = 0, .b = 0 }, try RGB.parse("rgbi:1.0/0/0"));
    try testing.expectEqual(RGB{ .r = 127, .g = 160, .b = 0 }, try RGB.parse("rgb:7f/a0a0/0"));
    try testing.expectEqual(RGB{ .r = 255, .g = 255, .b = 255 }, try RGB.parse("rgb:f/ff/fff"));
    try testing.expectEqual(RGB{ .r = 255, .g = 255, .b = 255 }, try RGB.parse("#ffffff"));
    try testing.expectEqual(RGB{ .r = 255, .g = 255, .b = 255 }, try RGB.parse("#fff"));
    try testing.expectEqual(RGB{ .r = 255, .g = 255, .b = 255 }, try RGB.parse("#fffffffff"));
    try testing.expectEqual(RGB{ .r = 255, .g = 255, .b = 255 }, try RGB.parse("#ffffffffffff"));
    try testing.expectEqual(RGB{ .r = 255, .g = 0, .b = 16 }, try RGB.parse("#ff0010"));
    try testing.expectEqual(RGB{ .r = 10, .g = 11, .b = 12 }, try RGB.parse("0A0B0C"));
    try testing.expectEqual(RGB{ .r = 255, .g = 255, .b = 255 }, try RGB.parse("FFFFFF"));
    try testing.expectEqual(RGB{ .r = 255, .g = 255, .b = 255 }, try RGB.parse("FFF"));
    try testing.expectEqual(RGB{ .r = 51, .g = 68, .b = 85 }, try RGB.parse("#345"));
    try testing.expectEqual(RGB{ .r = 170, .g = 187, .b = 204 }, try RGB.parse(" #AABBCC   "));

    try testing.expectEqual(RGB{ .r = 0, .g = 0, .b = 0 }, try RGB.parse("black"));
    try testing.expectEqual(RGB{ .r = 255, .g = 0, .b = 0 }, try RGB.parse("red"));
    try testing.expectEqual(RGB{ .r = 0, .g = 255, .b = 0 }, try RGB.parse("green"));
    try testing.expectEqual(RGB{ .r = 0, .g = 0, .b = 255 }, try RGB.parse("blue"));
    try testing.expectEqual(RGB{ .r = 255, .g = 255, .b = 255 }, try RGB.parse("white"));

    try testing.expectEqual(RGB{ .r = 124, .g = 252, .b = 0 }, try RGB.parse("LawnGreen"));
    try testing.expectEqual(RGB{ .r = 0, .g = 250, .b = 154 }, try RGB.parse("medium spring green"));
    try testing.expectEqual(RGB{ .r = 34, .g = 139, .b = 34 }, try RGB.parse(" Forest Green "));
    try testing.expectEqual(RGB{ .r = 34, .g = 139, .b = 34 }, try RGB.parse("\tForestGreen\t"));

    // Invalid format
    try testing.expectError(error.InvalidFormat, RGB.parse(""));
    try testing.expectError(error.InvalidFormat, RGB.parse("  "));
    try testing.expectError(error.InvalidFormat, RGB.parse("rgb;"));
    try testing.expectError(error.InvalidFormat, RGB.parse("rgb:"));
    try testing.expectError(error.InvalidFormat, RGB.parse(":a/a/a"));
    try testing.expectError(error.InvalidFormat, RGB.parse("a/a/a"));
    try testing.expectError(error.InvalidFormat, RGB.parse("rgb:a/a/a/"));
    try testing.expectError(error.InvalidFormat, RGB.parse("rgb:00000///"));
    try testing.expectError(error.InvalidFormat, RGB.parse("rgb:000/"));
    try testing.expectError(error.InvalidFormat, RGB.parse("rgbi:a/a/a"));
    try testing.expectError(error.InvalidFormat, RGB.parse("rgb:0.5/0.0/1.0"));
    try testing.expectError(error.InvalidFormat, RGB.parse("rgb:not/hex/zz"));
    try testing.expectError(error.InvalidFormat, RGB.parse("#"));
    try testing.expectError(error.InvalidFormat, RGB.parse("#ff"));
    try testing.expectError(error.InvalidFormat, RGB.parse("#ffff"));
    try testing.expectError(error.InvalidFormat, RGB.parse("#fffff"));
    try testing.expectError(error.InvalidFormat, RGB.parse("#gggggg"));
    try testing.expectError(error.InvalidFormat, RGB.parse("#12345"));
    try testing.expectError(error.InvalidFormat, RGB.parse("12345"));
    try testing.expectError(error.InvalidFormat, RGB.parse("nosuchcolor"));
}

test "RGB: encode" {
    const rgb: RGB = .{ .r = 0x01, .g = 0x23, .b = 0xff };

    var buf: [64]u8 = undefined;
    var writer: std.Io.Writer = .fixed(&buf);
    try rgb.encodeRgb8(&writer);
    try std.testing.expectEqualStrings("rgb:01/23/ff", writer.buffered());

    writer = .fixed(&buf);
    try rgb.encodeRgb16(&writer);
    try std.testing.expectEqualStrings("rgb:0101/2323/ffff", writer.buffered());
}

test "DynamicPalette: init" {
    const testing = std.testing;

    var p: DynamicPalette = .init(default);
    try testing.expectEqual(default, p.current);
    try testing.expectEqual(default, p.original);
    try testing.expectEqual(@as(usize, 0), p.mask.count());
}

test "DynamicPalette: set" {
    const testing = std.testing;

    var p: DynamicPalette = .init(default);
    const new_color = RGB{ .r = 255, .g = 0, .b = 0 };

    p.set(0, new_color);
    try testing.expectEqual(new_color, p.current[0]);
    try testing.expect(p.mask.isSet(0));
    try testing.expectEqual(@as(usize, 1), p.mask.count());

    try testing.expectEqual(default[0], p.original[0]);
}

test "DynamicPalette: reset" {
    const testing = std.testing;

    var p: DynamicPalette = .init(default);
    const new_color = RGB{ .r = 255, .g = 0, .b = 0 };

    p.set(0, new_color);
    try testing.expect(p.mask.isSet(0));

    p.reset(0);
    try testing.expectEqual(default[0], p.current[0]);
    try testing.expect(!p.mask.isSet(0));
    try testing.expectEqual(@as(usize, 0), p.mask.count());
}

test "DynamicPalette: resetAll" {
    const testing = std.testing;

    var p: DynamicPalette = .init(default);
    const new_color = RGB{ .r = 255, .g = 0, .b = 0 };

    p.set(0, new_color);
    p.set(5, new_color);
    p.set(10, new_color);
    try testing.expectEqual(@as(usize, 3), p.mask.count());

    p.resetAll();
    try testing.expectEqual(default, p.current);
    try testing.expectEqual(default, p.original);
    try testing.expectEqual(@as(usize, 0), p.mask.count());
}

test "DynamicPalette: changeDefault with no changes" {
    const testing = std.testing;

    var p: DynamicPalette = .init(default);
    var new_palette = default;
    new_palette[0] = RGB{ .r = 100, .g = 100, .b = 100 };

    p.changeDefault(new_palette);
    try testing.expectEqual(new_palette, p.original);
    try testing.expectEqual(new_palette, p.current);
    try testing.expectEqual(@as(usize, 0), p.mask.count());
}

test "DynamicPalette: changeDefault preserves changes" {
    const testing = std.testing;

    var p: DynamicPalette = .init(default);
    const custom_color = RGB{ .r = 255, .g = 0, .b = 0 };

    p.set(5, custom_color);
    try testing.expect(p.mask.isSet(5));

    var new_palette = default;
    new_palette[0] = RGB{ .r = 100, .g = 100, .b = 100 };
    new_palette[5] = RGB{ .r = 50, .g = 50, .b = 50 };

    p.changeDefault(new_palette);

    try testing.expectEqual(new_palette, p.original);
    try testing.expectEqual(new_palette[0], p.current[0]);
    try testing.expectEqual(custom_color, p.current[5]);
    try testing.expect(p.mask.isSet(5));
    try testing.expectEqual(@as(usize, 1), p.mask.count());
}

test "DynamicPalette: changeDefault with multiple changes" {
    const testing = std.testing;

    var p: DynamicPalette = .init(default);
    const red = RGB{ .r = 255, .g = 0, .b = 0 };
    const green = RGB{ .r = 0, .g = 255, .b = 0 };
    const blue = RGB{ .r = 0, .g = 0, .b = 255 };

    p.set(1, red);
    p.set(2, green);
    p.set(3, blue);

    var new_palette = default;
    new_palette[0] = RGB{ .r = 50, .g = 50, .b = 50 };
    new_palette[1] = RGB{ .r = 60, .g = 60, .b = 60 };

    p.changeDefault(new_palette);

    try testing.expectEqual(new_palette[0], p.current[0]);
    try testing.expectEqual(red, p.current[1]);
    try testing.expectEqual(green, p.current[2]);
    try testing.expectEqual(blue, p.current[3]);
    try testing.expectEqual(@as(usize, 3), p.mask.count());
}

test "LAB.fromRgb" {
    const testing = std.testing;
    const epsilon = 0.5;

    // White (255, 255, 255) -> L*=100, a*=0, b*=0
    const white = LAB.fromRgb(.{ .r = 255, .g = 255, .b = 255 });
    try testing.expectApproxEqAbs(@as(f32, 100.0), white.l, epsilon);
    try testing.expectApproxEqAbs(@as(f32, 0.0), white.a, epsilon);
    try testing.expectApproxEqAbs(@as(f32, 0.0), white.b, epsilon);

    // Black (0, 0, 0) -> L*=0, a*=0, b*=0
    const black = LAB.fromRgb(.{ .r = 0, .g = 0, .b = 0 });
    try testing.expectApproxEqAbs(@as(f32, 0.0), black.l, epsilon);
    try testing.expectApproxEqAbs(@as(f32, 0.0), black.a, epsilon);
    try testing.expectApproxEqAbs(@as(f32, 0.0), black.b, epsilon);

    // Pure red (255, 0, 0) -> L*≈53.23, a*≈80.11, b*≈67.22
    const red = LAB.fromRgb(.{ .r = 255, .g = 0, .b = 0 });
    try testing.expectApproxEqAbs(@as(f32, 53.23), red.l, epsilon);
    try testing.expectApproxEqAbs(@as(f32, 80.11), red.a, epsilon);
    try testing.expectApproxEqAbs(@as(f32, 67.22), red.b, epsilon);

    // Pure green (0, 128, 0) -> L*≈46.23, a*≈-51.70, b*≈49.90
    const green = LAB.fromRgb(.{ .r = 0, .g = 128, .b = 0 });
    try testing.expectApproxEqAbs(@as(f32, 46.23), green.l, epsilon);
    try testing.expectApproxEqAbs(@as(f32, -51.70), green.a, epsilon);
    try testing.expectApproxEqAbs(@as(f32, 49.90), green.b, epsilon);

    // Pure blue (0, 0, 255) -> L*≈32.30, a*≈79.20, b*≈-107.86
    const blue = LAB.fromRgb(.{ .r = 0, .g = 0, .b = 255 });
    try testing.expectApproxEqAbs(@as(f32, 32.30), blue.l, epsilon);
    try testing.expectApproxEqAbs(@as(f32, 79.20), blue.a, epsilon);
    try testing.expectApproxEqAbs(@as(f32, -107.86), blue.b, epsilon);
}

test "generate256Color: base16 preserved" {
    const testing = std.testing;

    const bg = RGB{ .r = 0, .g = 0, .b = 0 };
    const fg = RGB{ .r = 255, .g = 255, .b = 255 };
    const palette = generate256Color(default, .initEmpty(), bg, fg, false);

    // The first 16 colors (base16) must remain unchanged.
    for (0..16) |i| {
        try testing.expectEqual(default[i], palette[i]);
    }
}

test "generate256Color: cube corners match base colors" {
    const testing = std.testing;

    const bg = RGB{ .r = 0, .g = 0, .b = 0 };
    const fg = RGB{ .r = 255, .g = 255, .b = 255 };
    const palette = generate256Color(default, .initEmpty(), bg, fg, false);

    // Index 16 is cube (0,0,0) which should equal bg.
    try testing.expectEqual(bg, palette[16]);

    // Index 231 is cube (5,5,5) which should equal fg.
    try testing.expectEqual(fg, palette[231]);
}

test "generate256Color: cube corners black/white with harmonious=false" {
    const testing = std.testing;

    const black = RGB{ .r = 0, .g = 0, .b = 0 };
    const white = RGB{ .r = 255, .g = 255, .b = 255 };

    // Dark theme: bg=black, fg=white.
    const dark = generate256Color(default, .initEmpty(), black, white, false);
    try testing.expectEqual(black, dark[16]);
    try testing.expectEqual(white, dark[231]);

    // Light theme: bg=white, fg=black. The bg/red swap ensures
    // the cube still runs from black (16) to white (231).
    const light = generate256Color(default, .initEmpty(), white, black, false);
    try testing.expectEqual(black, light[16]);
    try testing.expectEqual(white, light[231]);
}

test "generate256Color: light theme cube corners with harmonious=true" {
    const testing = std.testing;

    const white = RGB{ .r = 255, .g = 255, .b = 255 };
    const black = RGB{ .r = 0, .g = 0, .b = 0 };

    // harmonious=true skips the bg/fg swap, so the cube preserves the
    // original orientation: (0,0,0)=bg=white, (5,5,5)=fg=black.
    const palette = generate256Color(default, .initEmpty(), white, black, true);
    try testing.expectEqual(white, palette[16]);
    try testing.expectEqual(black, palette[231]);
}

test "generate256Color: grayscale ramp monotonic luminance" {
    const testing = std.testing;

    const bg = RGB{ .r = 0, .g = 0, .b = 0 };
    const fg = RGB{ .r = 255, .g = 255, .b = 255 };
    const palette = generate256Color(default, .initEmpty(), bg, fg, false);

    // The grayscale ramp (232–255) should have monotonically increasing
    // luminance from near-black to near-white.
    var prev_lum: f64 = 0.0;
    for (232..256) |i| {
        const lum = palette[i].luminance();
        try testing.expect(lum >= prev_lum);
        prev_lum = lum;
    }
}

test "generate256Color: skip mask preserves original colors" {
    const testing = std.testing;

    const bg = RGB{ .r = 0, .g = 0, .b = 0 };
    const fg = RGB{ .r = 255, .g = 255, .b = 255 };

    // Mark a few indices as skipped; they should keep their base value.
    var skip: PaletteMask = .initEmpty();
    skip.set(20);
    skip.set(100);
    skip.set(240);

    const palette = generate256Color(default, skip, bg, fg, false);
    try testing.expectEqual(default[20], palette[20]);
    try testing.expectEqual(default[100], palette[100]);
    try testing.expectEqual(default[240], palette[240]);

    // A non-skipped index in the cube should differ from the default.
    try testing.expect(!palette[21].eql(default[21]));
}

test "generate256Color: dark theme harmonious has no effect" {
    const testing = std.testing;

    // For a dark theme (fg lighter than bg), harmonious should not change
    // the output because the inversion is only relevant for light themes.
    const bg = RGB{ .r = 0, .g = 0, .b = 0 };
    const fg = RGB{ .r = 255, .g = 255, .b = 255 };
    const normal = generate256Color(default, .initEmpty(), bg, fg, false);
    const harmonious = generate256Color(default, .initEmpty(), bg, fg, true);

    for (16..256) |i| {
        try testing.expectEqual(normal[i], harmonious[i]);
    }
}

test "generate256Color: light theme harmonious skips inversion" {
    const testing = std.testing;

    // For a light theme (fg darker than bg), harmonious=true skips the
    // bg/red swap, producing different cube colors than harmonious=false.
    const bg = RGB{ .r = 255, .g = 255, .b = 255 };
    const fg = RGB{ .r = 0, .g = 0, .b = 0 };
    const inverted = generate256Color(default, .initEmpty(), bg, fg, false);
    const harmonious = generate256Color(default, .initEmpty(), bg, fg, true);

    // Cube origin (0,0,0) at index 16: without harmonious, bg and red are
    // swapped so it becomes the red base; with harmonious it stays as bg.
    try testing.expectEqual(bg, harmonious[16]);
    try testing.expect(!inverted[16].eql(bg));

    // At least some cube colors should differ between the two modes.
    var differ: usize = 0;
    for (16..232) |i| {
        if (!inverted[i].eql(harmonious[i])) differ += 1;
    }
    try testing.expect(differ > 0);
}

test "generate256Color: light theme harmonious grayscale ramp" {
    const testing = std.testing;

    const bg = RGB{ .r = 255, .g = 255, .b = 255 };
    const fg = RGB{ .r = 0, .g = 0, .b = 0 };

    // harmonious=false swaps bg/fg, so the ramp runs black→white (increasing).
    {
        const palette = generate256Color(default, .initEmpty(), bg, fg, false);
        var prev_lum: f64 = 0.0;
        for (232..256) |i| {
            const lum = palette[i].luminance();
            try testing.expect(lum >= prev_lum);
            prev_lum = lum;
        }
    }

    // harmonious=true keeps original order, so the ramp runs white→black (decreasing).
    {
        const palette = generate256Color(default, .initEmpty(), bg, fg, true);
        var prev_lum: f64 = 1.0;
        for (232..256) |i| {
            const lum = palette[i].luminance();
            try testing.expect(lum <= prev_lum);
            prev_lum = lum;
        }
    }
}

test "LAB.toRgb" {
    const testing = std.testing;

    // Round-trip: RGB -> LAB -> RGB should recover the original values.
    const cases = [_]RGB{
        .{ .r = 255, .g = 255, .b = 255 },
        .{ .r = 0, .g = 0, .b = 0 },
        .{ .r = 255, .g = 0, .b = 0 },
        .{ .r = 0, .g = 128, .b = 0 },
        .{ .r = 0, .g = 0, .b = 255 },
        .{ .r = 128, .g = 128, .b = 128 },
        .{ .r = 64, .g = 224, .b = 208 },
    };

    for (cases) |expected| {
        const lab = LAB.fromRgb(expected);
        const actual = lab.toRgb();
        try testing.expectEqual(expected.r, actual.r);
        try testing.expectEqual(expected.g, actual.g);
        try testing.expectEqual(expected.b, actual.b);
    }
}
