//! # Color Module
//!
//! This module provides a unified and comprehensive interface for color manipulation, conversion,
//! and management within the Zignal library. It supports a wide range of color spaces, from standard
//! display formats to perceptually uniform and scientific models.
//!
//! ## Key Features
//!
//! - **Generic Component Types**: All color structures are generic over their component type `T`
//!   (e.g., `Rgb(u8)`, `Lab(f32)`), handling value scaling automatically (0-255 for `u8`, 0-1 for floats).
//! - **Unified Conversion Architecture**: A "Hub & Spoke" system ensures accurate conversion between
//!   any two color spaces, using CIE XYZ and sRGB as central hubs.
//! - **Extensive Color Space Support**:
//!   - **Display**: sRGB (`Rgb`), sRGB with Alpha (`Rgba`), Grayscale (`Gray`)
//!   - **Cylindrical**: Hue-Saturation-Value (`Hsv`), Hue-Saturation-Lightness (`Hsl`)
//!   - **Scientific (CIE)**: CIE 1931 XYZ (`Xyz`), CIELAB (`Lab`), CIELCh (`Lch`)
//!   - **Perceptual (Next-Gen)**: Oklab (`Oklab`), Oklch (`Oklch`)
//!   - **Specialized**: LMS Cone Response (`Lms`), JPEG XL XYB (`Xyb`), YCbCr BT.601 (`Ycbcr`)
//! - **Utilities**:
//!   - Hexadecimal parsing and formatting (`initHex`, `hex`).
//!   - Color blending operations (`blendColors`).
//!   - Terminal output formatting for easy debugging.
//!   - Runtime dynamic color handling via the `Color(T)` union.
//!
//! ## Usage Examples
//!
//! ```zig
//! const zignal = @import("zignal");
//! const Rgb = zignal.color.Rgb;
//! const Lab = zignal.color.Lab;
//!
//! // Create a color from hex
//! const red = Rgb(u8).initHex(0xFF0000);
//!
//! // Convert to Lab (automatically handles 0-255 to 0-1 scaling if needed)
//! const red_lab = red.to(.lab);
//!
//! // Create a float-based RGB color
//! const blue = Rgb(f32){ .r = 0.0, .g = 0.0, .b = 1.0 };
//!
//! // Convert to HSV
//! const blue_hsv = blue.to(.hsv);
//! ```

const std = @import("std");
const Io = std.Io;
const assert = std.debug.assert;
const expect = std.testing.expect;
const expectEqual = std.testing.expectEqual;
const expectEqualDeep = std.testing.expectEqualDeep;
const expectApproxEqAbs = std.testing.expectApproxEqAbs;
const expectEqualStrings = std.testing.expectEqualStrings;
const clamp = std.math.clamp;
const lerp = std.math.lerp;
const pow = std.math.pow;

const blending = @import("blending.zig");
pub const Blending = blending.Blending;
pub const blendColors = blending.blendColors;
const getSimpleTypeName = @import("meta.zig").getSimpleTypeName;
const structFields = @import("meta.zig").structFields;

// Rec.709 Luma coefficients (used for Grayscale/Luma)
const luma_r = 0.2126;
const luma_g = 0.7152;
const luma_b = 0.0722;

// XYB Biases (JPEG XL)
const xyb_bias = 0.00379307325527544933;
const xyb_cbrt_bias_encode = 0.15595420054924863;
const xyb_cbrt_bias_decode = 0.15594113236791331;

// CIE D65 White Point
const d65_x = 95.047;
const d65_y = 100.000;
const d65_z = 108.883;

// CIELAB Constants
const lab_epsilon = 0.008856;
const lab_kappa_div_116 = 7.787;
const lab_delta = 16.0 / 116.0;

// sRGB gamma transfer (IEC 61966-2-1)
const srgb_linear_threshold = 0.0031308;
const srgb_gamma_threshold = 0.04045;
const srgb_gamma_offset = 0.055;
const srgb_gamma_scale = 1.055;
const srgb_linear_slope = 12.92;
const srgb_gamma_exponent = 2.4;

/// Returns true if `T` can be interpreted as a color by Zignal APIs.
///
/// This includes:
/// - Color structs that declare `pub const space: ColorSpace`
/// - Scalar grayscale values (`u8` in [0,255] and any float in [0,1])
pub fn isColor(comptime T: type) bool {
    if (T == u8) return true;
    if (@typeInfo(T) == .float) return true;
    if (@typeInfo(T) != .@"struct") return false;
    if (!@hasDecl(T, "space")) return false;
    return @TypeOf(T.space) == ColorSpace;
}

/// Converts a color from one type and/or space to another.
///
/// When converting from a scalar to a color struct, the scalar is interpreted
/// as a grayscale luminance value.
pub fn convertColor(comptime DestType: type, source: anytype) DestType {
    const SrcType = @TypeOf(source);
    if (DestType == SrcType) return source;

    // Scalar <-> Scalar
    if (SrcType == u8 and @typeInfo(DestType) == .float) return @as(DestType, source) / @as(DestType, 255.0);
    if (@typeInfo(SrcType) == .float and DestType == u8) {
        // Use f64 for precision with smaller floats, but SrcType if it's larger.
        const P = if (@typeInfo(SrcType).float.bits < 64) f64 else SrcType;
        return @round(clamp(@as(P, source), 0.0, 1.0) * @as(P, 255.0));
    }
    if (@typeInfo(SrcType) == .float and @typeInfo(DestType) == .float) return @floatCast(source);

    // Scalar -> Color
    if (SrcType == u8 or @typeInfo(SrcType) == .float) {
        const DestT = switch (@typeInfo(DestType)) {
            .@"struct" => |info| info.field_types[0],
            else => @compileError("Destination type must be a color struct"),
        };
        // Interpret scalar as grayscale
        const gray = Gray(SrcType){ .y = source };
        if (DestType.space == .gray) return gray.as(DestT);
        return gray.as(DestT).to(DestType.space).as(DestT);
    }

    // Color -> Scalar (Luminance)
    if (DestType == u8 or @typeInfo(DestType) == .float) {
        return source.to(.gray).as(DestType).y;
    }

    // Color -> Color
    const DestT = switch (@typeInfo(DestType)) {
        .@"struct" => |info| info.field_types[0],
        else => @compileError("Destination type must be a color struct"),
    };

    // If destination expects floats and the source supports casting, coerce before converting.
    if (@hasDecl(SrcType, "as") and @typeInfo(DestT) == .float) {
        const coerced = source.as(DestT);
        return coerced.to(DestType.space).as(DestT);
    }

    return source.to(DestType.space).as(DestT);
}

/// Internal helper to format color structs with ANSI colors for terminal output.
fn formatColor(comptime T: type, self: T, writer: *Io.Writer) !void {
    // Get the short type name
    const type_name = comptime getSimpleTypeName(T);

    // Convert to RGB for terminal display
    const rgb = convertColor(Rgb(u8), self);

    // Determine text color based on background darkness
    const fg: u8 = if (rgb.as(f32).to(.oklab).l < 0.5) 255 else 0;

    // Start with the SGR sequence
    try writer.print(
        "\x1b[1m\x1b[38;2;{d};{d};{d}m\x1b[48;2;{d};{d};{d}m{s}{{ ",
        .{ fg, fg, fg, rgb.r, rgb.g, rgb.b, type_name },
    );

    // Print each field
    const fields = comptime structFields(T);
    inline for (fields, 0..) |field, i| {
        try writer.print(".{s} = ", .{field.name});

        // Format the field value appropriately
        const value = @field(self, field.name);
        switch (field.type) {
            u8 => try writer.print("{d}", .{value}),
            f64, f32 => try writer.print("{d:.2}", .{value}),
            else => try writer.print("{any}", .{value}),
        }

        if (i < fields.len - 1) {
            try writer.print(", ", .{});
        }
    }

    // Close and reset
    try writer.print(" }}\x1b[0m", .{});
}

/// Defines the available color spaces and their conversion relationships.
///
/// # Conversion Graph
/// The system uses a "Hub & Spoke" architecture for conversions to ensure consistency
/// and maintainability.
///
/// **Display Hub (RGB)**             **Scientific Hub (XYZ)**
///        Gray                             Lms
///         |                                |
///        Rgb <-------------------------> Xyz <----> Xyb
///     / / | \ \                         /   \
/// Rgba Hsl Hsv Ycbcr                 Oklab  Lab
///                                      |     |
///                                    Oklch  Lch
///
/// - **Direct conversions** exist between a space and its parent hub.
/// - **Cross-conversions** (e.g., Hsl -> Lab) travel through the bridge:
///   `Hsl -> Rgb -> Xyz -> Lab`
pub const ColorSpace = enum {
    gray,
    hsl,
    hsv,
    lab,
    lch,
    lms,
    oklab,
    oklch,
    rgb,
    rgba,
    xyb,
    xyz,
    ycbcr,

    /// Returns the ColorSpace tag for a given color type.
    pub fn tag(comptime S: type) ColorSpace {
        if (@hasDecl(S, "space")) return S.space;
        @compileError("Type " ++ @typeName(S) ++ " is not a ColorSpace type");
    }

    /// Returns the color type for a given space and component type.
    pub fn Type(self: ColorSpace, comptime T: type) type {
        return switch (self) {
            .gray => Gray(T),
            .hsl => Hsl(T),
            .hsv => Hsv(T),
            .lab => Lab(T),
            .lch => Lch(T),
            .lms => Lms(T),
            .oklab => Oklab(T),
            .oklch => Oklch(T),
            .rgba => Rgba(T),
            .rgb => Rgb(T),
            .xyb => Xyb(T),
            .xyz => Xyz(T),
            .ycbcr => Ycbcr(T),
        };
    }
};

/// A tagged union capable of holding any color in the library with component type T.
/// Useful for APIs that need to accept dynamic color types at runtime.
pub fn Color(comptime T: type) type {
    return union(ColorSpace) {
        gray: Gray(T),
        hsl: Hsl(T),
        hsv: Hsv(T),
        lab: Lab(T),
        lch: Lch(T),
        lms: Lms(T),
        oklab: Oklab(T),
        oklch: Oklch(T),
        rgb: Rgb(T),
        rgba: Rgba(T),
        xyb: Xyb(T),
        xyz: Xyz(T),
        ycbcr: Ycbcr(T),

        /// Converts this dynamic color to a specific target color space.
        pub fn to(self: @This(), comptime target_space: ColorSpace) target_space.Type(T) {
            return switch (self) {
                inline else => |c| c.to(target_space),
            };
        }
    };
}

/// A color in the [sRGB](https://en.wikipedia.org/wiki/SRGB) colorspace, with all components
/// within the range 0-255 when `T` is `u8` and within 0-1 when `T` is float.
pub fn Rgb(comptime T: type) type {
    switch (@typeInfo(T)) {
        .float => {},
        .int => if (T != u8) @compileError("Unsupported backing type " ++ @typeName(T) ++ " for color space"),
        else => @compileError("Unsupported backing type " ++ @typeName(T) ++ " for color space"),
    }
    return struct {
        pub const space: ColorSpace = .rgb;
        r: T,
        g: T,
        b: T,
        pub const black = Rgb(T).initHex(0x000000);
        pub const white = Rgb(T).initHex(0xffffff);
        pub const red = Rgb(T).initHex(0xff0000);
        pub const green = Rgb(T).initHex(0x00ff00);
        pub const blue = Rgb(T).initHex(0x0000ff);

        /// Creates RGB from 24-bit hexadecimal value (0xRRGGBB format).
        pub fn initHex(hex_code: u24) Rgb(T) {
            const r: u8 = @intCast((hex_code >> 16) & 0xFF);
            const g: u8 = @intCast((hex_code >> 8) & 0xFF);
            const b: u8 = @intCast(hex_code & 0xFF);

            if (T == u8) {
                return .{ .r = r, .g = g, .b = b };
            } else {
                return .{
                    .r = @as(T, r) / 255.0,
                    .g = @as(T, g) / 255.0,
                    .b = @as(T, b) / 255.0,
                };
            }
        }

        /// Converts RGB to 24-bit hexadecimal representation (0xRRGGBB format).
        pub fn hex(self: Rgb(T)) u24 {
            const r: u8 = if (T == u8) self.r else @round(clamp(self.r, 0, 1) * 255);
            const g: u8 = if (T == u8) self.g else @round(clamp(self.g, 0, 1) * 255);
            const b: u8 = if (T == u8) self.b else @round(clamp(self.b, 0, 1) * 255);
            return (@as(u24, r) << 16) | (@as(u24, g) << 8) | @as(u24, b);
        }

        /// Returns the color with an added alpha channel.
        pub fn withAlpha(self: Rgb(T), alpha: T) Rgba(T) {
            return .{ .r = self.r, .g = self.g, .b = self.b, .a = alpha };
        }

        /// Inverts the color.
        pub fn invert(self: Rgb(T)) Rgb(T) {
            const max = if (T == u8) 255 else 1.0;
            return .{ .r = max - self.r, .g = max - self.g, .b = max - self.b };
        }

        /// Calculates the perceptual luminance using ITU-R BT.709 coefficients.
        pub fn luma(self: Rgb(T)) f64 {
            return rgbLuma(self.r, self.g, self.b);
        }

        /// Blends the color with an overlay.
        pub fn blend(self: Rgb(T), overlay: Rgba(T), mode: Blending) Rgb(T) {
            const blended = blendColors(T, self.withAlpha(if (T == u8) 255 else 1.0), overlay, mode);
            return .{ .r = blended.r, .g = blended.g, .b = blended.b };
        }

        /// Formats the color for terminal output.
        pub fn format(self: Rgb(T), writer: *Io.Writer) !void {
            return formatColor(Rgb(T), self, writer);
        }

        /// Converts the color to another color space.
        pub fn to(self: Rgb(T), comptime color_space: ColorSpace) color_space.Type(T) {
            return switch (color_space) {
                .gray => rgbToGray(T, self),
                .hsl => rgbToHsl(T, self),
                .hsv => rgbToHsv(T, self),
                .rgb => self,
                .rgba => .{ .r = self.r, .g = self.g, .b = self.b, .a = if (T == u8) 255 else 1.0 },
                .xyb => rgbToXyb(T, self),
                .xyz => rgbToXyz(T, self),
                .ycbcr => rgbToYcbcr(T, self),
                else => self.to(.xyz).to(color_space),
            };
        }

        /// Converts the backing component type.
        pub fn as(self: Rgb(T), comptime U: type) Rgb(U) {
            return switch (T) {
                u8 => switch (U) {
                    u8 => self,
                    else => .{
                        .r = @as(U, self.r) / 255,
                        .g = @as(U, self.g) / 255,
                        .b = @as(U, self.b) / 255,
                    },
                },
                else => switch (U) {
                    u8 => .{
                        .r = @round(255 * clamp(self.r, 0, 1)),
                        .g = @round(255 * clamp(self.g, 0, 1)),
                        .b = @round(255 * clamp(self.b, 0, 1)),
                    },
                    else => .{
                        .r = @floatCast(self.r),
                        .g = @floatCast(self.g),
                        .b = @floatCast(self.b),
                    },
                },
            };
        }
    };
}

/// A color in the [sRGB](https://en.wikipedia.org/wiki/SRGB) colorspace, with all components
/// within the range 0-255 when `T` is `u8` and within 0-1 when `T` is float.
pub fn Rgba(comptime T: type) type {
    switch (@typeInfo(T)) {
        .float => {},
        .int => if (T != u8) @compileError("Unsupported backing type " ++ @typeName(T) ++ " for color space"),
        else => @compileError("Unsupported backing type " ++ @typeName(T) ++ " for color space"),
    }
    return packed struct {
        pub const space: ColorSpace = .rgba;
        r: T,
        g: T,
        b: T,
        a: T = 0,
        pub const transparent = Rgba(T).initHex(0x00000000);
        pub const white = Rgba(T).initHex(0xffffffff);
        pub const black = Rgba(T).initHex(0x000000ff);
        pub const red = Rgba(T).initHex(0xff0000ff);
        pub const green = Rgba(T).initHex(0x00ff00ff);
        pub const blue = Rgba(T).initHex(0x0000ffff);

        /// Creates RGBA from 32-bit hexadecimal value (0xRRGGBBAA format).
        pub fn initHex(hex_code: u32) Rgba(T) {
            const r: u8 = @intCast((hex_code >> 24) & 0xFF);
            const g: u8 = @intCast((hex_code >> 16) & 0xFF);
            const b: u8 = @intCast((hex_code >> 8) & 0xFF);
            const a: u8 = @intCast(hex_code & 0xFF);
            if (T == u8) {
                return .{ .r = r, .g = g, .b = b, .a = a };
            } else {
                return .{
                    .r = @as(T, r) / 255.0,
                    .g = @as(T, g) / 255.0,
                    .b = @as(T, b) / 255.0,
                    .a = @as(T, a) / 255.0,
                };
            }
        }

        /// Converts RGBA to 32-bit hexadecimal representation (0xRRGGBBAA format).
        pub fn hex(self: Rgba(T)) u32 {
            const r: u8 = if (T == u8) self.r else @round(clamp(self.r, 0, 1) * 255);
            const g: u8 = if (T == u8) self.g else @round(clamp(self.g, 0, 1) * 255);
            const b: u8 = if (T == u8) self.b else @round(clamp(self.b, 0, 1) * 255);
            const a: u8 = if (T == u8) self.a else @round(clamp(self.a, 0, 1) * 255);
            return (@as(u32, r) << 24) | (@as(u32, g) << 16) | (@as(u32, b) << 8) | @as(u32, a);
        }

        /// Inverts the color (alpha is preserved).
        pub fn invert(self: Rgba(T)) Rgba(T) {
            const max = if (T == u8) 255 else 1.0;
            return .{ .r = max - self.r, .g = max - self.g, .b = max - self.b, .a = self.a };
        }

        /// Returns a copy with alpha scaled by `alpha` in [0,1].
        pub fn fade(self: Rgba(T), alpha: f32) Rgba(T) {
            const scale = clamp(alpha, 0, 1);
            if (T == u8) {
                const new_a: u8 = @trunc(@as(f32, self.a) * scale);
                return .{ .r = self.r, .g = self.g, .b = self.b, .a = new_a };
            } else {
                const s: T = @as(T, scale);
                return .{ .r = self.r, .g = self.g, .b = self.b, .a = self.a * s };
            }
        }

        /// Calculates the perceptual luminance using ITU-R BT.709 coefficients (ignores alpha).
        pub fn luma(self: Rgba(T)) f64 {
            return rgbLuma(self.r, self.g, self.b);
        }

        /// Blends the color with an overlay.
        pub fn blend(self: Rgba(T), overlay: Rgba(T), mode: Blending) Rgba(T) {
            return blendColors(T, self, overlay, mode);
        }

        /// Formats the color for terminal output.
        pub fn format(self: Rgba(T), writer: *Io.Writer) !void {
            return formatColor(Rgba(T), self, writer);
        }

        /// Converts the color to another color space.
        /// Note: The alpha channel is discarded during conversion to non-alpha spaces.
        pub fn to(self: Rgba(T), comptime color_space: ColorSpace) color_space.Type(T) {
            return switch (color_space) {
                .rgba => self,
                .rgb => .{ .r = self.r, .g = self.g, .b = self.b },
                else => self.to(.rgb).to(color_space),
            };
        }

        /// Converts the backing component type.
        pub fn as(self: Rgba(T), comptime U: type) Rgba(U) {
            return switch (T) {
                u8 => switch (U) {
                    u8 => self,
                    else => .{
                        .r = @as(U, self.r) / 255,
                        .g = @as(U, self.g) / 255,
                        .b = @as(U, self.b) / 255,
                        .a = @as(U, self.a) / 255,
                    },
                },
                else => switch (U) {
                    u8 => .{
                        .r = @round(255 * clamp(self.r, 0, 1)),
                        .g = @round(255 * clamp(self.g, 0, 1)),
                        .b = @round(255 * clamp(self.b, 0, 1)),
                        .a = @round(255 * clamp(self.a, 0, 1)),
                    },
                    else => .{
                        .r = @floatCast(self.r),
                        .g = @floatCast(self.g),
                        .b = @floatCast(self.b),
                        .a = @floatCast(self.a),
                    },
                },
            };
        }
    };
}

/// A grayscale color using sRGB Luminance (BT.709):
/// Y = 0.2126 R + 0.7152 G + 0.0722 B. Supports `u8` and float backings.
pub fn Gray(comptime T: type) type {
    switch (@typeInfo(T)) {
        .float => {},
        .int => if (T != u8) @compileError("Unsupported backing type " ++ @typeName(T) ++ " for color space"),
        else => @compileError("Unsupported backing type " ++ @typeName(T) ++ " for color space"),
    }

    return struct {
        pub const space: ColorSpace = .gray;
        y: T,

        /// Formats the color for terminal output.
        pub fn format(self: Gray(T), writer: *Io.Writer) !void {
            return formatColor(Gray(T), self, writer);
        }

        /// Converts the color to another color space.
        pub fn to(self: Gray(T), comptime color_space: ColorSpace) color_space.Type(T) {
            return switch (color_space) {
                .gray => self,
                else => grayToRgb(T, self).to(color_space),
            };
        }

        /// Converts the backing component type.
        pub fn as(self: Gray(T), comptime U: type) Gray(U) {
            switch (@typeInfo(U)) {
                .float => {},
                .int => |info| if (info.bits != 8 or info.signedness != .unsigned) @compileError("Unsupported backing type " ++ @typeName(U) ++ " for color space"),
                else => @compileError("Unsupported backing type " ++ @typeName(U) ++ " for color space"),
            }

            return switch (T) {
                u8 => switch (U) {
                    u8 => .{ .y = self.y },
                    else => .{ .y = @as(U, self.y) / 255 },
                },
                else => switch (U) {
                    u8 => .{ .y = @round(255 * clamp(self.y, 0, 1)) },
                    else => .{ .y = @floatCast(self.y) },
                },
            };
        }

        /// Inverts the color.
        pub fn invert(self: Gray(T)) Gray(T) {
            const max = if (T == u8) 255 else 1.0;
            return .{ .y = max - self.y };
        }
    };
}

/// A color in the [HSV](https://en.wikipedia.org/wiki/HSL_and_HSV) colorspace.
pub fn Hsv(comptime T: type) type {
    if (@typeInfo(T) != .float) @compileError("Unsupported backing type " ++ @typeName(T) ++ " for color space");
    return struct {
        pub const space: ColorSpace = .hsv;
        /// Hue, in degrees (0-360, though often normalized to 0-359).
        h: T,
        /// Saturation, as a percentage (0-100).
        s: T,
        /// Value, as a percentage (0-100).
        v: T,

        /// Formats the color for terminal output.
        pub fn format(self: Hsv(T), writer: *Io.Writer) !void {
            return formatColor(Hsv(T), self, writer);
        }

        /// Converts the color to another color space.
        pub fn to(self: Hsv(T), comptime color_space: ColorSpace) color_space.Type(T) {
            return switch (color_space) {
                .hsv => self,
                .hsl => hsvToHsl(T, self),
                .rgb => hsvToRgb(T, self),
                else => self.to(.rgb).to(color_space),
            };
        }

        /// Converts the backing component type.
        pub fn as(self: Hsv(T), comptime U: type) Hsv(U) {
            return .{
                .h = @floatCast(self.h),
                .s = @floatCast(self.s),
                .v = @floatCast(self.v),
            };
        }
    };
}

/// A color in the [HSL](https://en.wikipedia.org/wiki/HSL_and_HSV) colorspace.
pub fn Hsl(comptime T: type) type {
    if (@typeInfo(T) != .float) @compileError("Unsupported backing type " ++ @typeName(T) ++ " for color space");
    return struct {
        pub const space: ColorSpace = .hsl;
        /// Hue, in degrees (0-360, though often normalized to 0-359).
        h: T,
        /// Saturation, as a percentage (0-100).
        s: T,
        /// Lightness, as a percentage (0-100).
        l: T,

        /// Formats the color for terminal output.
        pub fn format(self: Hsl(T), writer: *Io.Writer) !void {
            return formatColor(Hsl(T), self, writer);
        }

        /// Converts the color to another color space.
        pub fn to(self: Hsl(T), comptime color_space: ColorSpace) color_space.Type(T) {
            return switch (color_space) {
                .hsl => self,
                .hsv => hslToHsv(T, self),
                .rgb => hslToRgb(T, self),
                else => self.to(.rgb).to(color_space),
            };
        }

        /// Converts the backing component type.
        pub fn as(self: Hsl(T), comptime U: type) Hsl(U) {
            return .{
                .h = @floatCast(self.h),
                .s = @floatCast(self.s),
                .l = @floatCast(self.l),
            };
        }
    };
}

/// A color in the [CIE 1931 XYZ color space](https://en.wikipedia.org/wiki/CIE_1931_color_space).
/// This is a device-independent space that covers the full gamut of human-perceptible colors
/// visible to the CIE 2° standard observer.
///
/// Tristimulus values are typically non-negative; their range varies with the
/// reference white point (e.g. D65), with Y often normalized to 100 for white.
pub fn Xyz(comptime T: type) type {
    if (@typeInfo(T) != .float) @compileError("Unsupported backing type " ++ @typeName(T) ++ " for color space");
    return struct {
        pub const space: ColorSpace = .xyz;
        /// X tristimulus value.
        x: T,
        /// Y tristimulus value, representing luminance.
        y: T,
        /// Z tristimulus value.
        z: T,

        /// Formats the color for terminal output.
        pub fn format(self: Xyz(T), writer: *Io.Writer) !void {
            return formatColor(Xyz(T), self, writer);
        }

        /// Converts the color to another color space.
        pub fn to(self: Xyz(T), comptime color_space: ColorSpace) color_space.Type(T) {
            return switch (color_space) {
                .lab => xyzToLab(T, self),
                .lch => self.to(.lab).to(.lch),
                .lms => xyzToLms(T, self),
                .oklab => xyzToOklab(T, self),
                .oklch => self.to(.oklab).to(.oklch),
                .rgb => xyzToRgb(T, self),
                .xyb => xyzToXyb(T, self),
                .xyz => self,
                else => self.to(.rgb).to(color_space),
            };
        }

        /// Converts the backing component type.
        pub fn as(self: Xyz(T), comptime U: type) Xyz(U) {
            return .{
                .x = @floatCast(self.x),
                .y = @floatCast(self.y),
                .z = @floatCast(self.z),
            };
        }
    };
}

/// A color in the [CIELAB color space](https://en.wikipedia.org/wiki/CIELAB_color_space) (also known as L*a*b*).
/// It expresses color as three values.
pub fn Lab(comptime T: type) type {
    if (@typeInfo(T) != .float) @compileError("Unsupported backing type " ++ @typeName(T) ++ " for color space");
    return struct {
        pub const space: ColorSpace = .lab;
        /// Lightness (0 for black to 100 for white).
        l: T,
        /// Green-red axis (-128 for green to +127 for red).
        a: T,
        /// Blue-yellow axis (-128 for blue to +127 for yellow).
        b: T,

        /// Formats the color for terminal output.
        pub fn format(self: Lab(T), writer: *Io.Writer) !void {
            return formatColor(Lab(T), self, writer);
        }

        /// Converts the color to another color space.
        pub fn to(self: Lab(T), comptime color_space: ColorSpace) color_space.Type(T) {
            return switch (color_space) {
                .lab => self,
                .lch => labToLch(T, self),
                .xyz => labToXyz(T, self),
                else => self.to(.xyz).to(color_space),
            };
        }

        /// Converts the backing component type.
        pub fn as(self: Lab(T), comptime U: type) Lab(U) {
            return .{
                .l = @floatCast(self.l),
                .a = @floatCast(self.a),
                .b = @floatCast(self.b),
            };
        }
    };
}

/// A color in the [CIELCh color space](https://en.wikipedia.org/wiki/CIELAB_color_space#Cylindrical_model).
/// LCh is the cylindrical representation of the CIELAB color space.
pub fn Lch(comptime T: type) type {
    if (@typeInfo(T) != .float) @compileError("Unsupported backing type " ++ @typeName(T) ++ " for color space");
    return struct {
        pub const space: ColorSpace = .lch;
        /// Lightness (0 for black to 100 for white).
        l: T,
        /// Chroma (chromatic intensity) (0 for achromatic, no upper bound).
        c: T,
        /// Hue angle in degrees (0-360).
        h: T,

        /// Formats the color for terminal output.
        pub fn format(self: Lch(T), writer: *Io.Writer) !void {
            return formatColor(Lch(T), self, writer);
        }

        /// Converts the color to another color space.
        pub fn to(self: Lch(T), comptime color_space: ColorSpace) color_space.Type(T) {
            return switch (color_space) {
                .lch => self,
                .lab => lchToLab(T, self),
                else => self.to(.lab).to(color_space),
            };
        }

        /// Converts the backing component type.
        pub fn as(self: Lch(T), comptime U: type) Lch(U) {
            return .{
                .l = @floatCast(self.l),
                .c = @floatCast(self.c),
                .h = @floatCast(self.h),
            };
        }
    };
}

/// A color in the [LMS color space](https://en.wikipedia.org/wiki/LMS_color_space).
/// Represents the response of the three types of cones (Long, Medium, Short wavelength) in the human eye.
/// Values are typically positive and represent the stimulus for each cone type.
pub fn Lms(comptime T: type) type {
    if (@typeInfo(T) != .float) @compileError("Unsupported backing type " ++ @typeName(T) ++ " for color space");
    return struct {
        pub const space: ColorSpace = .lms;
        l: T,
        m: T,
        s: T,

        /// Formats the color for terminal output.
        pub fn format(self: Lms(T), writer: *Io.Writer) !void {
            return formatColor(Lms(T), self, writer);
        }

        /// Converts the color to another color space.
        pub fn to(self: Lms(T), comptime color_space: ColorSpace) color_space.Type(T) {
            return switch (color_space) {
                .lms => self,
                .xyz => lmsToXyz(T, self),
                else => self.to(.xyz).to(color_space),
            };
        }

        /// Converts the backing component type.
        pub fn as(self: Lms(T), comptime U: type) Lms(U) {
            return .{
                .l = @floatCast(self.l),
                .m = @floatCast(self.m),
                .s = @floatCast(self.s),
            };
        }
    };
}

/// A color in the [Oklab color space](https://bottosson.github.io/posts/oklab/).
/// Oklab is designed to be a perceptually uniform color space.
pub fn Oklab(comptime T: type) type {
    if (@typeInfo(T) != .float) @compileError("Unsupported backing type " ++ @typeName(T) ++ " for color space");
    return struct {
        pub const space: ColorSpace = .oklab;
        /// Perceived lightness (0 for black to approximately 1 for white).
        l: T,
        /// Green-red axis (negative towards green, positive towards red, typically around -0.4 to 0.4).
        a: T,
        /// Blue-yellow axis (negative towards blue, positive towards yellow, typically around -0.4 to 0.4).
        b: T,

        /// Formats the color for terminal output.
        pub fn format(self: Oklab(T), writer: *Io.Writer) !void {
            return formatColor(Oklab(T), self, writer);
        }

        /// Converts the color to another color space.
        pub fn to(self: Oklab(T), comptime color_space: ColorSpace) color_space.Type(T) {
            return switch (color_space) {
                .oklab => self,
                .oklch => oklabToOklch(T, self),
                .xyz => oklabToXyz(T, self),
                else => self.to(.xyz).to(color_space),
            };
        }

        /// Converts the backing component type.
        pub fn as(self: Oklab(T), comptime U: type) Oklab(U) {
            return .{
                .l = @floatCast(self.l),
                .a = @floatCast(self.a),
                .b = @floatCast(self.b),
            };
        }
    };
}

/// A color in the [Oklch color space](https://en.wikipedia.org/wiki/Oklab_color_space).
/// Oklch is the cylindrical representation of the Oklab color space.
pub fn Oklch(comptime T: type) type {
    if (@typeInfo(T) != .float) @compileError("Unsupported backing type " ++ @typeName(T) ++ " for color space");
    return struct {
        pub const space: ColorSpace = .oklch;
        /// Perceived lightness (0 for black to approximately 1 for white).
        l: T,
        /// Chroma (chromatic intensity) (0 for achromatic to approximately 0.5 for pure colors).
        c: T,
        /// Hue angle in degrees (0-360).
        h: T,

        /// Formats the color for terminal output.
        pub fn format(self: Oklch(T), writer: *Io.Writer) !void {
            return formatColor(Oklch(T), self, writer);
        }

        /// Converts the color to another color space.
        pub fn to(self: Oklch(T), comptime color_space: ColorSpace) color_space.Type(T) {
            return switch (color_space) {
                .oklch => self,
                .oklab => oklchToOklab(T, self),
                else => self.to(.oklab).to(color_space),
            };
        }

        /// Converts the backing component type.
        pub fn as(self: Oklch(T), comptime U: type) Oklch(U) {
            return .{
                .l = @floatCast(self.l),
                .c = @floatCast(self.c),
                .h = @floatCast(self.h),
            };
        }
    };
}

/// A color in the [XYB color space](https://jpeg.org/jpegxl/documentation/xl-color-management.html#xyb)
/// used in JPEG XL. It's derived from LMS and designed for efficient image compression.
/// Ranges can vary based on transformations, but often centered around 0 for x and b, and positive for y.
pub fn Xyb(comptime T: type) type {
    if (@typeInfo(T) != .float) @compileError("Unsupported backing type " ++ @typeName(T) ++ " for color space");
    return struct {
        pub const space: ColorSpace = .xyb;
        /// X component (L-M, red-green opponent channel).
        x: T,
        /// Y component (L+M, luminance-like channel).
        y: T,
        /// B component (S, blue-yellow like channel, but often scaled S cone response).
        b: T,

        /// Formats the color for terminal output.
        pub fn format(self: Xyb(T), writer: *Io.Writer) !void {
            return formatColor(Xyb(T), self, writer);
        }

        /// Converts the color to another color space.
        pub fn to(self: Xyb(T), comptime color_space: ColorSpace) color_space.Type(T) {
            return switch (color_space) {
                .xyb => self,
                .rgb => xybToRgb(T, self),
                .xyz => xybToXyz(T, self),
                else => self.to(.xyz).to(color_space),
            };
        }

        /// Converts the backing component type.
        pub fn as(self: Xyb(T), comptime U: type) Xyb(U) {
            return .{
                .x = @floatCast(self.x),
                .y = @floatCast(self.y),
                .b = @floatCast(self.b),
            };
        }
    };
}

/// Ycbcr (Y'CbCr) colorspace used in JPEG and video encoding.
/// Y is luma (brightness), Cb is blue-difference chroma, Cr is red-difference chroma.
/// Uses ITU-R BT.601 coefficients for conversion to/from RGB.
pub fn Ycbcr(comptime T: type) type {
    switch (@typeInfo(T)) {
        .float => {},
        .int => |info| if (info.bits != 8 or info.signedness != .unsigned) @compileError("Unsupported backing type " ++ @typeName(T) ++ " for color space"),
        else => @compileError("Unsupported backing type " ++ @typeName(T) ++ " for color space"),
    }
    return struct {
        pub const space: ColorSpace = .ycbcr;
        y: T,
        cb: T,
        cr: T,

        /// Formats the color for terminal output.
        pub fn format(self: Ycbcr(T), writer: *Io.Writer) !void {
            return formatColor(Ycbcr(T), self, writer);
        }

        /// Converts the color to another color space.
        pub fn to(self: Ycbcr(T), comptime color_space: ColorSpace) color_space.Type(T) {
            return switch (color_space) {
                .ycbcr => self,
                .rgb => ycbcrToRgb(T, self),
                else => self.to(.rgb).to(color_space),
            };
        }

        /// Converts the backing component type.
        pub fn as(self: Ycbcr(T), comptime U: type) Ycbcr(U) {
            switch (@typeInfo(U)) {
                .float => {},
                .int => |info| if (info.bits != 8 or info.signedness != .unsigned) @compileError("Unsupported backing type " ++ @typeName(U) ++ " for color space"),
                else => @compileError("Unsupported backing type " ++ @typeName(U) ++ " for color space"),
            }
            return switch (T) {
                u8 => switch (U) {
                    u8 => self,
                    else => .{
                        .y = @as(U, self.y) / 255,
                        .cb = (@as(U, self.cb) - 128) / 255,
                        .cr = (@as(U, self.cr) - 128) / 255,
                    },
                },
                else => switch (U) {
                    u8 => .{
                        .y = @round(255 * clamp(self.y, 0, 1)),
                        .cb = @round(255 * clamp(self.cb + 0.5, 0, 1)),
                        .cr = @round(255 * clamp(self.cr + 0.5, 0, 1)),
                    },
                    else => .{
                        .y = @floatCast(self.y),
                        .cb = @floatCast(self.cb),
                        .cr = @floatCast(self.cr),
                    },
                },
            };
        }
    };
}

/// Converts RGB to Ycbcr using ITU-R BT.601 coefficients.
/// All components in [0, 255] range for u8, with Cb/Cr having 128 as neutral.
/// Uses 16-bit fixed-point arithmetic for precision when T is u8.
fn rgbToYcbcr(comptime T: type, rgb: Rgb(T)) Ycbcr(T) {
    if (T == u8) {
        // Integer (fixed-point) BT.601: 16-bit fractional precision, rounding at +32768.
        const r: i32 = rgb.r;
        const g: i32 = rgb.g;
        const b: i32 = rgb.b;

        const y_r: i64 = 19595;
        const y_g: i64 = 38470;
        const y_b: i64 = 7471;
        const cb_r: i64 = -11059;
        const cb_g: i64 = -21710;
        const cb_b: i64 = 32768;
        const cr_r: i64 = 32768;
        const cr_g: i64 = -27439;
        const cr_b: i64 = -5329;

        return .{
            .y = @intCast(clamp((y_r * r + y_g * g + y_b * b + 32768) >> 16, 0, 255)),
            .cb = @intCast(clamp(((cb_r * r + cb_g * g + cb_b * b + 32768) >> 16) + 128, 0, 255)),
            .cr = @intCast(clamp(((cr_r * r + cr_g * g + cr_b * b + 32768) >> 16) + 128, 0, 255)),
        };
    } else {
        const y = clamp(0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b, 0, 1);
        return .{
            .y = y,
            .cb = clamp((rgb.b - y) / 1.772, -0.5, 0.5),
            .cr = clamp((rgb.r - y) / 1.402, -0.5, 0.5),
        };
    }
}

/// Calculates the perceptual luminance using ITU-R BT.709 coefficients.
/// Returns a value in the range [0.0, 1.0].
pub fn rgbLuma(r: anytype, g: anytype, b: anytype) f64 {
    const T = @TypeOf(r);
    const r_f: f64 = if (T == u8) @as(f64, r) / 255.0 else @floatCast(r);
    const g_f: f64 = if (T == u8) @as(f64, g) / 255.0 else @floatCast(g);
    const b_f: f64 = if (T == u8) @as(f64, b) / 255.0 else @floatCast(b);
    return luma_r * r_f + luma_g * g_f + luma_b * b_f;
}

/// Converts RGB to grayscale using BT.709 luminance coefficients.
/// Y = 0.2126*R + 0.7152*G + 0.0722*B
fn rgbToGray(comptime T: type, rgb: Rgb(T)) Gray(T) {
    if (T == u8) {
        const r: i32 = rgb.r;
        const g: i32 = rgb.g;
        const b: i32 = rgb.b;
        const scale = 1 << 16;
        const offset = 1 << 15;
        const yr: i32 = @round(luma_r * scale);
        const yg: i32 = @round(luma_g * scale);
        const yb: i32 = @round(luma_b * scale);
        return .{ .y = @intCast(clamp((yr * r + yg * g + yb * b + offset) >> 16, 0, 255)) };
    } else {
        comptime assert(@typeInfo(T) == .float);
        const y = @as(T, luma_r) * rgb.r + @as(T, luma_g) * rgb.g + @as(T, luma_b) * rgb.b;
        return .{ .y = clamp(y, 0, 1) };
    }
}

/// Converts grayscale to RGB by replicating the Y component across channels.
fn grayToRgb(comptime T: type, gray: Gray(T)) Rgb(T) {
    return .{ .r = gray.y, .g = gray.y, .b = gray.y };
}

/// Converts Ycbcr to RGB using ITU-R BT.601 coefficients.
/// Expects all components in [0, 255] range for u8, with Cb/Cr having 128 as neutral.
/// Uses 16-bit fixed-point arithmetic for precision when T is u8.
fn ycbcrToRgb(comptime T: type, ycbcr: Ycbcr(T)) Rgb(T) {
    if (T == u8) {
        const y: i64 = ycbcr.y;
        const cb: i64 = @as(i64, ycbcr.cb) - 128;
        const cr: i64 = @as(i64, ycbcr.cr) - 128;

        const r: u8 = @intCast(clamp((65536 * y + 91881 * cr + 32768) >> 16, 0, 255));
        const g: u8 = @intCast(clamp((65536 * y - 22554 * cb - 46802 * cr + 32768) >> 16, 0, 255));
        const b: u8 = @intCast(clamp((65536 * y + 116130 * cb + 32768) >> 16, 0, 255));

        return .{ .r = r, .g = g, .b = b };
    } else {
        comptime assert(@typeInfo(T) == .float);
        const y = ycbcr.y;
        const cb = ycbcr.cb;
        const cr = ycbcr.cr;

        const r = y + 1.402 * cr;
        const g = y - 0.344136 * cb - 0.714136 * cr;
        const b = y + 1.772 * cb;

        return .{
            .r = clamp(r, 0, 1),
            .g = clamp(g, 0, 1),
            .b = clamp(b, 0, 1),
        };
    }
}

/// Converts RGB to HSV.
fn rgbToHsv(comptime T: type, rgb: Rgb(T)) Hsv(T) {
    comptime assert(@typeInfo(T) == .float);
    const min = @min(rgb.r, @min(rgb.g, rgb.b));
    const max = @max(rgb.r, @max(rgb.g, rgb.b));
    const delta = max - min;

    const h: T = if (delta == 0) 0 else blk: {
        if (max == rgb.r) {
            break :blk (rgb.g - rgb.b) / delta * 60;
        } else if (max == rgb.g) {
            break :blk 120 + (rgb.b - rgb.r) / delta * 60;
        } else {
            break :blk 240 + (rgb.r - rgb.g) / delta * 60;
        }
    };

    return .{
        .h = @mod(h, 360.0),
        .s = if (max == 0) 0 else (delta / max) * 100,
        .v = max * 100,
    };
}

/// Converts HSL to RGB.
fn hslToRgb(comptime T: type, hsl: Hsl(T)) Rgb(T) {
    comptime assert(@typeInfo(T) == .float);
    const h = @mod(hsl.h, 360);
    const s = clamp(hsl.s / 100, 0, 1);
    const l = clamp(hsl.l / 100, 0, 1);

    const hue_sector: T = h / 60.0;
    const sector: usize = @trunc(hue_sector);
    const fractional: T = hue_sector - @as(T, @floatFromInt(sector));

    const hue_factors = [_][3]T{
        .{ 1, fractional, 0 },
        .{ 1 - fractional, 1, 0 },
        .{ 0, 1, fractional },
        .{ 0, 1 - fractional, 1 },
        .{ fractional, 0, 1 },
        .{ 1, 0, 1 - fractional },
    };

    const index = @mod(sector, 6);
    const r = lerp(1, 2 * hue_factors[index][0], s);
    const g = lerp(1, 2 * hue_factors[index][1], s);
    const b = lerp(1, 2 * hue_factors[index][2], s);

    return if (l < 0.5)
        .{
            .r = r * l,
            .g = g * l,
            .b = b * l,
        }
    else
        .{
            .r = lerp(r, 2, l) - 1,
            .g = lerp(g, 2, l) - 1,
            .b = lerp(b, 2, l) - 1,
        };
}

/// Converts RGB to HSL.
fn rgbToHsl(comptime T: type, rgb: Rgb(T)) Hsl(T) {
    comptime assert(@typeInfo(T) == .float);
    const min = @min(rgb.r, @min(rgb.g, rgb.b));
    const max = @max(rgb.r, @max(rgb.g, rgb.b));
    const delta = max - min;

    const hue = if (delta == 0) 0 else blk: {
        if (max == rgb.r) {
            break :blk (rgb.g - rgb.b) / delta;
        } else if (max == rgb.g) {
            break :blk 2 + (rgb.b - rgb.r) / delta;
        } else {
            break :blk 4 + (rgb.r - rgb.g) / delta;
        }
    };

    const l = (max + min) / 2.0;
    const s = if (delta == 0) 0 else if (l < 0.5) delta / (2 * l) else delta / (2 - 2 * l);

    return .{
        .h = @mod(hue * 60.0, 360.0),
        .s = clamp(s, 0, 1) * 100.0,
        .l = clamp(l, 0, 1) * 100.0,
    };
}

/// Converts HSV to RGB.
fn hsvToRgb(comptime T: type, hsv: Hsv(T)) Rgb(T) {
    comptime assert(@typeInfo(T) == .float);
    const hue = clamp(hsv.h / 360, 0, 1);
    const sat = clamp(hsv.s / 100, 0, 1);
    const val = clamp(hsv.v / 100, 0, 1);

    if (sat == 0.0) {
        return .{ .r = val, .g = val, .b = val };
    }

    const sector = hue * 6;
    const index: i32 = @trunc(sector);
    const fractional = sector - @as(T, @floatFromInt(index));
    const p = val * (1 - sat);
    const q = val * (1 - (sat * fractional));
    const t = val * (1 - sat * (1 - fractional));
    const colors = [_][3]T{
        .{ val, t, p },
        .{ q, val, p },
        .{ p, val, t },
        .{ p, q, val },
        .{ t, p, val },
        .{ val, p, q },
    };
    const idx: usize = @intCast(@mod(index, 6));

    return .{
        .r = colors[idx][0],
        .g = colors[idx][1],
        .b = colors[idx][2],
    };
}

/// Converts HSV to HSL.
fn hsvToHsl(comptime T: type, hsv: Hsv(T)) Hsl(T) {
    comptime assert(@typeInfo(T) == .float);
    const s_v = hsv.s / 100.0;
    const v = hsv.v / 100.0;

    const l = v * (1.0 - s_v / 2.0);
    const s_l = if (l == 0 or l == 1) 0 else (v - l) / @min(l, 1 - l);

    return .{
        .h = hsv.h,
        .s = s_l * 100.0,
        .l = l * 100.0,
    };
}

/// Converts HSL to HSV.
fn hslToHsv(comptime T: type, hsl: Hsl(T)) Hsv(T) {
    comptime assert(@typeInfo(T) == .float);
    const s_l = hsl.s / 100.0;
    const l = hsl.l / 100.0;

    const v = l + s_l * @min(l, 1 - l);
    const s_v = if (v == 0) 0 else 2.0 * (1.0 - l / v);

    return .{
        .h = hsl.h,
        .s = s_v * 100.0,
        .v = v * 100.0,
    };
}

/// Converts linear RGB component to sRGB gamma.
fn linearToGamma(comptime T: type, c: T) T {
    comptime assert(@typeInfo(T) == .float);
    return if (c > srgb_linear_threshold)
        srgb_gamma_scale * pow(T, c, 1.0 / srgb_gamma_exponent) - srgb_gamma_offset
    else
        c * srgb_linear_slope;
}

/// Converts sRGB gamma component to linear RGB.
fn gammaToLinear(comptime T: type, c: T) T {
    comptime assert(@typeInfo(T) == .float);
    return if (c > srgb_gamma_threshold)
        pow(T, (c + srgb_gamma_offset) / srgb_gamma_scale, srgb_gamma_exponent)
    else
        c / srgb_linear_slope;
}

/// Converts RGB to XYZ.
fn rgbToXyz(comptime T: type, rgb: Rgb(T)) Xyz(T) {
    comptime assert(@typeInfo(T) == .float);
    const r = gammaToLinear(T, rgb.r);
    const g = gammaToLinear(T, rgb.g);
    const b = gammaToLinear(T, rgb.b);

    return .{
        .x = (r * 0.4124 + g * 0.3576 + b * 0.1805) * 100,
        .y = (r * 0.2126 + g * 0.7152 + b * 0.0722) * 100,
        .z = (r * 0.0193 + g * 0.1192 + b * 0.9505) * 100,
    };
}

/// Converts XYZ to RGB.
fn xyzToRgb(comptime T: type, xyz: Xyz(T)) Rgb(T) {
    comptime assert(@typeInfo(T) == .float);
    const r = (xyz.x * 3.2406 + xyz.y * -1.5372 + xyz.z * -0.4986) / 100;
    const g = (xyz.x * -0.9689 + xyz.y * 1.8758 + xyz.z * 0.0415) / 100;
    const b = (xyz.x * 0.0557 + xyz.y * -0.2040 + xyz.z * 1.0570) / 100;

    return .{
        .r = clamp(linearToGamma(T, r), 0, 1),
        .g = clamp(linearToGamma(T, g), 0, 1),
        .b = clamp(linearToGamma(T, b), 0, 1),
    };
}

/// CIELAB forward nonlinearity f(t) used by xyzToLab.
fn labForward(comptime T: type, t: T) T {
    return if (t > lab_epsilon) pow(T, t, 1.0 / 3.0) else lab_kappa_div_116 * t + lab_delta;
}

/// Converts XYZ to Lab.
fn xyzToLab(comptime T: type, xyz: Xyz(T)) Lab(T) {
    comptime assert(@typeInfo(T) == .float);
    const fx = labForward(T, xyz.x / d65_x);
    const fy = labForward(T, xyz.y / d65_y);
    const fz = labForward(T, xyz.z / d65_z);

    return .{
        // L* is typically 0-100, but we only clamp to positive.
        .l = @max(0, 116.0 * fy - 16.0),
        // a* and b* are theoretically unbounded, though often within [-128, 127].
        // We remove the hard clamping to support wide-gamut colors.
        .a = 500.0 * (fx - fy),
        .b = 200.0 * (fy - fz),
    };
}

/// Converts Lab to XYZ.
fn labToXyz(comptime T: type, lab: Lab(T)) Xyz(T) {
    comptime assert(@typeInfo(T) == .float);
    const fy: f64 = (lab.l + 16.0) / 116.0;
    const fx: f64 = (lab.a / 500.0) + fy;
    const fz: f64 = fy - (lab.b / 200.0);

    const y3 = fy * fy * fy;
    const x3 = fx * fx * fx;
    const z3 = fz * fz * fz;

    const y = if (y3 > lab_epsilon) y3 else (fy - lab_delta) / lab_kappa_div_116;
    const x = if (x3 > lab_epsilon) x3 else (fx - lab_delta) / lab_kappa_div_116;
    const z = if (z3 > lab_epsilon) z3 else (fz - lab_delta) / lab_kappa_div_116;

    return .{
        .x = @floatCast(x * d65_x),
        .y = @floatCast(y * d65_y),
        .z = @floatCast(z * d65_z),
    };
}

/// Shared (a,b) -> (chroma, hue°) conversion for Lab→Lch and Oklab→Oklch.
fn cartesianToCylindrical(comptime T: type, a: T, b: T) struct { c: T, h: T } {
    return .{
        .c = @sqrt(a * a + b * b),
        .h = @mod(std.math.radiansToDegrees(std.math.atan2(b, a)), 360.0),
    };
}

/// Shared (chroma, hue°) -> (a, b) conversion for Lch→Lab and Oklch→Oklab.
fn cylindricalToCartesian(comptime T: type, c: T, h: T) struct { a: T, b: T } {
    const h_rad = std.math.degreesToRadians(h);
    return .{ .a = c * @cos(h_rad), .b = c * @sin(h_rad) };
}

/// Converts Lab to LCh.
fn labToLch(comptime T: type, lab: Lab(T)) Lch(T) {
    comptime assert(@typeInfo(T) == .float);
    const cyl = cartesianToCylindrical(T, lab.a, lab.b);
    return .{ .l = lab.l, .c = cyl.c, .h = cyl.h };
}

/// Converts LCh to Lab.
fn lchToLab(comptime T: type, lch: Lch(T)) Lab(T) {
    comptime assert(@typeInfo(T) == .float);
    const cart = cylindricalToCartesian(T, lch.c, lch.h);
    return .{ .l = lch.l, .a = cart.a, .b = cart.b };
}

/// Converts XYZ to LMS.
fn xyzToLms(comptime T: type, xyz: Xyz(T)) Lms(T) {
    comptime assert(@typeInfo(T) == .float);
    return .{
        .l = (0.8951 * xyz.x + 0.2664 * xyz.y - 0.1614 * xyz.z) / 100,
        .m = (-0.7502 * xyz.x + 1.7135 * xyz.y + 0.0367 * xyz.z) / 100,
        .s = (0.0389 * xyz.x - 0.0685 * xyz.y + 1.0296 * xyz.z) / 100,
    };
}

/// Converts LMS to XYZ.
fn lmsToXyz(comptime T: type, lms: Lms(T)) Xyz(T) {
    comptime assert(@typeInfo(T) == .float);
    return .{
        .x = 100 * (0.9869929 * lms.l - 0.1470543 * lms.m + 0.1599627 * lms.s),
        .y = 100 * (0.4323053 * lms.l + 0.5183603 * lms.m + 0.0492912 * lms.s),
        .z = 100 * (-0.0085287 * lms.l + 0.0400428 * lms.m + 0.9684867 * lms.s),
    };
}

/// Converts XYZ to Oklab.
fn xyzToOklab(comptime T: type, xyz: Xyz(T)) Oklab(T) {
    comptime assert(@typeInfo(T) == .float);
    const x = xyz.x / 100.0;
    const y = xyz.y / 100.0;
    const z = xyz.z / 100.0;

    const l_linear = 0.8189330101 * x + 0.3618667424 * y - 0.1288597137 * z;
    const m_linear = 0.0329845436 * x + 0.9293118715 * y + 0.0361456387 * z;
    const s_linear = 0.0482003018 * x + 0.2643662691 * y + 0.6338517070 * z;

    const l_dash = std.math.cbrt(l_linear);
    const m_dash = std.math.cbrt(m_linear);
    const s_dash = std.math.cbrt(s_linear);

    return .{
        .l = 0.2104542553 * l_dash + 0.7936177850 * m_dash - 0.0040720468 * s_dash,
        .a = 1.9779984951 * l_dash - 2.4285922050 * m_dash + 0.4505937099 * s_dash,
        .b = 0.0259040371 * l_dash + 0.7827717662 * m_dash - 0.8086757660 * s_dash,
    };
}

/// Converts Oklab to XYZ.
fn oklabToXyz(comptime T: type, oklab: Oklab(T)) Xyz(T) {
    comptime assert(@typeInfo(T) == .float);
    const l_dash = oklab.l + 0.3963377774 * oklab.a + 0.2158037573 * oklab.b;
    const m_dash = oklab.l - 0.1055613458 * oklab.a - 0.0638541728 * oklab.b;
    const s_dash = oklab.l - 0.0894841775 * oklab.a - 1.2914855480 * oklab.b;

    const l_linear = l_dash * l_dash * l_dash;
    const m_linear = m_dash * m_dash * m_dash;
    const s_linear = s_dash * s_dash * s_dash;

    return .{
        .x = 100.0 * (1.2270138511 * l_linear - 0.5577999807 * m_linear + 0.2812561490 * s_linear),
        .y = 100.0 * (-0.0405801784 * l_linear + 1.1122568696 * m_linear - 0.0716766787 * s_linear),
        .z = 100.0 * (-0.0763812845 * l_linear - 0.4214819784 * m_linear + 1.5861632204 * s_linear),
    };
}

/// Converts Oklab to Oklch.
fn oklabToOklch(comptime T: type, oklab: Oklab(T)) Oklch(T) {
    comptime assert(@typeInfo(T) == .float);
    const cyl = cartesianToCylindrical(T, oklab.a, oklab.b);
    return .{ .l = oklab.l, .c = cyl.c, .h = cyl.h };
}

/// Converts Oklch to Oklab.
fn oklchToOklab(comptime T: type, oklch: Oklch(T)) Oklab(T) {
    comptime assert(@typeInfo(T) == .float);
    const cart = cylindricalToCartesian(T, oklch.c, oklch.h);
    return .{ .l = oklch.l, .a = cart.a, .b = cart.b };
}

/// Converts XYZ to XYB.
fn xyzToXyb(comptime T: type, xyz: Xyz(T)) Xyb(T) {
    comptime assert(@typeInfo(T) == .float);
    const r = (xyz.x * 3.2406 + xyz.y * -1.5372 + xyz.z * -0.4986) / 100;
    const g = (xyz.x * -0.9689 + xyz.y * 1.8758 + xyz.z * 0.0415) / 100;
    const b = (xyz.x * 0.0557 + xyz.y * -0.2040 + xyz.z * 1.0570) / 100;

    const l = @max(0, 0.30 * r + 0.622 * g + 0.078 * b + xyb_bias);
    const m = @max(0, 0.23 * r + 0.692 * g + 0.078 * b + xyb_bias);
    const s = @max(0, 0.24342268924547819 * r + 0.20476744424496821 * g + 0.5518098665095536 * b + xyb_bias);

    const l_dash = std.math.cbrt(l) - xyb_cbrt_bias_encode;
    const m_dash = std.math.cbrt(m) - xyb_cbrt_bias_encode;
    const s_dash = std.math.cbrt(s) - xyb_cbrt_bias_encode;

    return .{
        .x = 0.5 * (l_dash - m_dash),
        .y = 0.5 * (l_dash + m_dash),
        .b = s_dash,
    };
}

/// Converts XYB to XYZ.
fn xybToXyz(comptime T: type, xyb: Xyb(T)) Xyz(T) {
    comptime assert(@typeInfo(T) == .float);
    const l_dash = xyb.y + xyb.x;
    const m_dash = xyb.y - xyb.x;
    const s_dash = xyb.b;

    const l_cbrt = l_dash + xyb_cbrt_bias_decode;
    const m_cbrt = m_dash + xyb_cbrt_bias_decode;
    const s_cbrt = s_dash + xyb_cbrt_bias_decode;

    const l = (l_cbrt * l_cbrt * l_cbrt) - xyb_bias;
    const m = (m_cbrt * m_cbrt * m_cbrt) - xyb_bias;
    const s = (s_cbrt * s_cbrt * s_cbrt) - xyb_bias;

    const r = 11.031566901960783 * l - 9.866943921568629 * m - 0.16462299647058826 * s;
    const g = -3.254147380392157 * l + 4.418770392156863 * m - 0.16462299647058826 * s;
    const b = -3.6588512862745097 * l + 2.7129230470588235 * m + 1.9459282392156863 * s;

    return .{
        .x = (r * 0.4124 + g * 0.3576 + b * 0.1805) * 100,
        .y = (r * 0.2126 + g * 0.7152 + b * 0.0722) * 100,
        .z = (r * 0.0193 + g * 0.1192 + b * 0.9505) * 100,
    };
}

/// Converts RGB to XYB directly, skipping the XYZ round-trip.
fn rgbToXyb(comptime T: type, rgb: Rgb(T)) Xyb(T) {
    comptime assert(@typeInfo(T) == .float);
    const r = gammaToLinear(T, rgb.r);
    const g = gammaToLinear(T, rgb.g);
    const b = gammaToLinear(T, rgb.b);

    const l = @max(0, 0.30 * r + 0.622 * g + 0.078 * b + xyb_bias);
    const m = @max(0, 0.23 * r + 0.692 * g + 0.078 * b + xyb_bias);
    const s = @max(0, 0.24342268924547819 * r + 0.20476744424496821 * g + 0.5518098665095536 * b + xyb_bias);

    const l_dash = std.math.cbrt(l) - xyb_cbrt_bias_encode;
    const m_dash = std.math.cbrt(m) - xyb_cbrt_bias_encode;
    const s_dash = std.math.cbrt(s) - xyb_cbrt_bias_encode;

    return .{
        .x = 0.5 * (l_dash - m_dash),
        .y = 0.5 * (l_dash + m_dash),
        .b = s_dash,
    };
}

/// Converts XYB to RGB directly, skipping the XYZ round-trip.
fn xybToRgb(comptime T: type, xyb: Xyb(T)) Rgb(T) {
    comptime assert(@typeInfo(T) == .float);
    const l_dash = xyb.y + xyb.x;
    const m_dash = xyb.y - xyb.x;
    const s_dash = xyb.b;

    const l_cbrt = l_dash + xyb_cbrt_bias_decode;
    const m_cbrt = m_dash + xyb_cbrt_bias_decode;
    const s_cbrt = s_dash + xyb_cbrt_bias_decode;

    const l = (l_cbrt * l_cbrt * l_cbrt) - xyb_bias;
    const m = (m_cbrt * m_cbrt * m_cbrt) - xyb_bias;
    const s = (s_cbrt * s_cbrt * s_cbrt) - xyb_bias;

    const r = 11.031566901960783 * l - 9.866943921568629 * m - 0.16462299647058826 * s;
    const g = -3.254147380392157 * l + 4.418770392156863 * m - 0.16462299647058826 * s;
    const b = -3.6588512862745097 * l + 2.7129230470588235 * m + 1.9459282392156863 * s;

    return .{
        .r = clamp(linearToGamma(T, r), 0, 1),
        .g = clamp(linearToGamma(T, g), 0, 1),
        .b = clamp(linearToGamma(T, b), 0, 1),
    };
}

// ============================================================================
// TESTS
// ============================================================================

/// Internal test helper for round-trip color conversion.
fn testRoundTripConversion(from: Rgb(u8), to: anytype) !void {
    const Dest = @TypeOf(to);
    const T = switch (@typeInfo(Dest)) {
        .@"struct" => |info| info.field_types[0], // Assumes first field is component type, consistent with rest of file
        else => @compileError("Invalid test destination type"),
    };
    const target_space = comptime ColorSpace.tag(Dest);

    const converted = from.as(f64).to(target_space).as(T);
    try expectEqualDeep(converted, to);

    const Source = @TypeOf(from);
    const U = switch (@typeInfo(Source)) {
        .@"struct" => |info| info.field_types[0],
        else => @compileError("Invalid test source type"),
    };

    const recovered = converted.as(f64).to(ColorSpace.tag(Source)).as(U);
    try expectEqualDeep(recovered, from);
}

test "convert grayscale" {
    try expectEqual((Rgb(u8){ .r = 128, .g = 128, .b = 128 }).to(.gray), Gray(u8){ .y = 128 });
    try expectEqual((Rgb(u8){ .r = 255, .g = 0, .b = 0 }).to(.gray), Gray(u8){ .y = 54 });
    try expectEqual((Hsl(f64){ .h = 0, .s = 100, .l = 50 }).to(.gray).as(u8), Gray(u8){ .y = 54 });
    try expectEqual((Hsv(f64){ .h = 0, .s = 100, .v = 50 }).to(.gray).as(u8), Gray(u8){ .y = 27 });
    try expectEqual((Lab(f64){ .l = 50, .a = 0, .b = 0 }).to(.gray).as(u8), Gray(u8){ .y = 119 });
}

test "Gray invert" {
    const gray = Gray(u8){ .y = 100 };
    try expectEqual(gray.invert(), Gray(u8){ .y = 155 });
    const gray_f = Gray(f32){ .y = 0.2 };
    try expectApproxEqAbs(gray_f.invert().y, 0.8, 0.00001);
}

test "scalar colors" {
    try expect(isColor(u8));
    try expect(isColor(f32));
    try expect(isColor(f64));
    try expect(!isColor(u16));

    try expectEqualDeep(convertColor(Rgb(u8), @as(u8, 128)), Rgb(u8){ .r = 128, .g = 128, .b = 128 });
    try expectEqualDeep(convertColor(Rgb(u8), @as(f64, 0.5)), Rgb(u8){ .r = 128, .g = 128, .b = 128 });

    try expectApproxEqAbs(convertColor(f64, @as(u8, 128)), 128.0 / 255.0, 0.0000001);
    try expectEqual(convertColor(u8, @as(f64, 0.5)), @as(u8, 128));
    try expectApproxEqAbs(convertColor(f64, @as(f32, 0.25)), 0.25, 0.0000001);
}

test "Rgb fromHex and toHex" {
    try expectEqualDeep(Rgb(u8).initHex(0x4e008e), Rgb(u8){ .r = 78, .g = 0, .b = 142 });
    try expectEqualDeep(Rgb(u8).initHex(0x000000), Rgb(u8){ .r = 0, .g = 0, .b = 0 });
    try expectEqualDeep(Rgb(u8).initHex(0xffffff), Rgb(u8){ .r = 255, .g = 255, .b = 255 });
    try expectEqualDeep(Rgb(u8).initHex(0xff0000), Rgb(u8){ .r = 255, .g = 0, .b = 0 });
    try expectEqualDeep(Rgb(u8).initHex(0x00ff00), Rgb(u8){ .r = 0, .g = 255, .b = 0 });
    try expectEqualDeep(Rgb(u8).initHex(0x0000ff), Rgb(u8){ .r = 0, .g = 0, .b = 255 });
    try expectEqualDeep(Rgb(u8).initHex(0x808080), Rgb(u8){ .r = 128, .g = 128, .b = 128 });

    const black: Rgb(u8) = .{ .r = 0, .g = 0, .b = 0 };
    try expectEqual(black.hex(), 0x000000);
    const white: Rgb(u8) = .{ .r = 255, .g = 255, .b = 255 };
    try expectEqual(white.hex(), 0xffffff);
    const red: Rgb(u8) = .{ .r = 255, .g = 0, .b = 0 };
    try expectEqual(red.hex(), 0xff0000);
    const purple: Rgb(u8) = .{ .r = 78, .g = 0, .b = 142 };
    try expectEqual(purple.hex(), 0x4e008e);

    const test_colors = [_]u24{ 0x123456, 0xabcdef, 0x987654, 0xfedcba, 0x111111, 0xeeeeee };
    for (test_colors) |hex_color| {
        const rgb: Rgb(u8) = .initHex(hex_color);
        try expectEqual(rgb.hex(), hex_color);
    }

    try expectEqualDeep(Rgb(u8).initHex(0x000000), Rgb(u8).black);
    try expectEqualDeep(Rgb(u8).initHex(0xffffff), Rgb(u8).white);
}

test "Rgba fromHex and toHex" {
    try expectEqualDeep(Rgba(u8).initHex(0x4e008eff), Rgba(u8){ .r = 78, .g = 0, .b = 142, .a = 255 });
    try expectEqualDeep(Rgba(u8).initHex(0x000000ff), Rgba(u8){ .r = 0, .g = 0, .b = 0, .a = 255 });
    try expectEqualDeep(Rgba(u8).initHex(0xffffff00), Rgba(u8){ .r = 255, .g = 255, .b = 255, .a = 0 });
    try expectEqualDeep(Rgba(u8).initHex(0xff000080), Rgba(u8){ .r = 255, .g = 0, .b = 0, .a = 128 });
    try expectEqualDeep(Rgba(u8).initHex(0x00ff00c0), Rgba(u8){ .r = 0, .g = 255, .b = 0, .a = 192 });
    try expectEqualDeep(Rgba(u8).initHex(0x0000ff40), Rgba(u8){ .r = 0, .g = 0, .b = 255, .a = 64 });

    const purple_alpha: Rgba(u8) = .{ .r = 78, .g = 0, .b = 142, .a = 255 };
    try expectEqual(purple_alpha.hex(), 0x4e008eff);

    const transparent_white: Rgba(u8) = .{ .r = 255, .g = 255, .b = 255, .a = 0 };
    try expectEqual(transparent_white.hex(), 0xffffff00);

    const semi_red: Rgba(u8) = .{ .r = 255, .g = 0, .b = 0, .a = 128 };
    try expectEqual(semi_red.hex(), 0xff000080);

    const test_colors = [_]u32{ 0x12345678, 0xabcdef90, 0x98765432, 0xfedcba01, 0x11111111, 0xeeeeeeee };
    for (test_colors) |hex_color| {
        const rgba: Rgba(u8) = .initHex(hex_color);
        try expectEqual(rgba.hex(), hex_color);
    }

    try expectEqualDeep(Rgba(u8).initHex(0x00000000), Rgba(u8).transparent);
    try expectEqualDeep(Rgba(u8).initHex(0x000000ff), Rgba(u8).black);
    try expectEqualDeep(Rgba(u8).initHex(0xffffffff), Rgba(u8).white);
}

test "primary colors" {
    // red: 0xff0000
    try testRoundTripConversion(.{ .r = 255, .g = 0, .b = 0 }, Hsl(f64){ .h = 0, .s = 100, .l = 50 });
    try testRoundTripConversion(.{ .r = 255, .g = 0, .b = 0 }, Hsv(f64){ .h = 0, .s = 100, .v = 100 });
    try testRoundTripConversion(.{ .r = 255, .g = 0, .b = 0 }, Lab(f64){ .l = 53.23288178584245, .a = 80.10930952982204, .b = 67.22006831026425 });
    // green: 0x00ff00
    try testRoundTripConversion(.{ .r = 0, .g = 255, .b = 0 }, Hsl(f64){ .h = 120, .s = 100, .l = 50 });
    try testRoundTripConversion(.{ .r = 0, .g = 255, .b = 0 }, Hsv(f64){ .h = 120, .s = 100, .v = 100 });
    try testRoundTripConversion(.{ .r = 0, .g = 255, .b = 0 }, Lab(f64){ .l = 87.73703347354422, .a = -86.1846364976253, .b = 83.18116474777855 });
    // blue: 0x0000ff
    try testRoundTripConversion(.{ .r = 0, .g = 0, .b = 255 }, Hsl(f64){ .h = 240, .s = 100, .l = 50 });
    try testRoundTripConversion(.{ .r = 0, .g = 0, .b = 255 }, Hsv(f64){ .h = 240, .s = 100, .v = 100 });
    try testRoundTripConversion(.{ .r = 0, .g = 0, .b = 255 }, Lab(f64){ .l = 32.302586667249486, .a = 79.19666178930935, .b = -107.86368104495168 });
}

test "secondary colors" {
    // cyan: 0x00ffff
    try testRoundTripConversion(.{ .r = 0, .g = 255, .b = 255 }, Hsl(f64){ .h = 180, .s = 100, .l = 50 });
    try testRoundTripConversion(.{ .r = 0, .g = 255, .b = 255 }, Hsv(f64){ .h = 180, .s = 100, .v = 100 });
    try testRoundTripConversion(.{ .r = 0, .g = 255, .b = 255 }, Lab(f64){ .l = 91.11652110946342, .a = -48.079618466228716, .b = -14.138127754846131 });
    // magenta: 0xff00ff
    try testRoundTripConversion(.{ .r = 255, .g = 0, .b = 255 }, Hsl(f64){ .h = 300, .s = 100, .l = 50 });
    try testRoundTripConversion(.{ .r = 255, .g = 0, .b = 255 }, Hsv(f64){ .h = 300, .s = 100, .v = 100 });
    try testRoundTripConversion(.{ .r = 255, .g = 0, .b = 255 }, Lab(f64){ .l = 60.319933664076004, .a = 98.25421868616108, .b = -60.84298422386232 });
    // yellow: 0xffff00
    try testRoundTripConversion(.{ .r = 255, .g = 255, .b = 0 }, Hsl(f64){ .h = 60, .s = 100, .l = 50 });
    try testRoundTripConversion(.{ .r = 255, .g = 255, .b = 0 }, Hsv(f64){ .h = 60, .s = 100, .v = 100 });
    try testRoundTripConversion(.{ .r = 255, .g = 255, .b = 0 }, Lab(f64){ .l = 97.13824698129729, .a = -21.555908334832285, .b = 94.48248544644461 });
}

test "complementary colors" {
    // orange: 0xff8800
    try testRoundTripConversion(.{ .r = 255, .g = 136, .b = 0 }, Hsl(f64){ .h = 32, .s = 100, .l = 50 });
    try testRoundTripConversion(.{ .r = 255, .g = 136, .b = 0 }, Hsv(f64){ .h = 32, .s = 100, .v = 100 });
    try testRoundTripConversion(.{ .r = 255, .g = 136, .b = 0 }, Lab(f64){ .l = 68.65577208167872, .a = 38.85052375564019, .b = 74.99022544139406 });
    // purple: 0x800080
    try testRoundTripConversion(.{ .r = 128, .g = 0, .b = 128 }, Hsl(f64){ .h = 300, .s = 100, .l = 25.098039215686274 });
    try testRoundTripConversion(.{ .r = 128, .g = 0, .b = 128 }, Hsv(f64){ .h = 300, .s = 100, .v = 50.19607843137255 });
    try testRoundTripConversion(.{ .r = 128, .g = 0, .b = 128 }, Lab(f64){ .l = 29.782100092098077, .a = 58.93983731904206, .b = -36.49792996282386 });
}

test "neutral colors" {
    // white: 0xffffff
    try testRoundTripConversion(.{ .r = 255, .g = 255, .b = 255 }, Hsl(f64){ .h = 0, .s = 0, .l = 100 });
    try testRoundTripConversion(.{ .r = 255, .g = 255, .b = 255 }, Hsv(f64){ .h = 0, .s = 0, .v = 100 });
    try testRoundTripConversion(.{ .r = 255, .g = 255, .b = 255 }, Lab(f64){ .l = 100, .a = 0.00526049995830391, .b = -0.010408184525267927 });
    // gray: 0x808080
    try testRoundTripConversion(.{ .r = 128, .g = 128, .b = 128 }, Hsl(f64){ .h = 0, .s = 0, .l = 50.19607843137255 });
    try testRoundTripConversion(.{ .r = 128, .g = 128, .b = 128 }, Hsv(f64){ .h = 0, .s = 0, .v = 50.19607843137255 });
    try testRoundTripConversion(.{ .r = 128, .g = 128, .b = 128 }, Lab(f64){ .l = 53.58501345216902, .a = 0.003155620347972121, .b = -0.006243566036268078 });
    // black: 0x000000
    try testRoundTripConversion(.{ .r = 0, .g = 0, .b = 0 }, Hsl(f64){ .h = 0, .s = 0, .l = 0 });
    try testRoundTripConversion(.{ .r = 0, .g = 0, .b = 0 }, Hsv(f64){ .h = 0, .s = 0, .v = 0 });
    try testRoundTripConversion(.{ .r = 0, .g = 0, .b = 0 }, Lab(f64){ .l = 0, .a = 0, .b = 0 });
}

test "pastel colors" {
    // pale_pink: 0xffd3ba
    try testRoundTripConversion(.{ .r = 255, .g = 211, .b = 186 }, Hsl(f64){ .h = 21.739130434782602, .s = 100, .l = 86.47058823529412 });
    try testRoundTripConversion(.{ .r = 255, .g = 211, .b = 186 }, Hsv(f64){ .h = 21.739130434782602, .s = 27.058823529411768, .v = 100 });
    try testRoundTripConversion(.{ .r = 255, .g = 211, .b = 186 }, Lab(f64){ .l = 87.67593388241974, .a = 11.843797404960165, .b = 18.16236917854479 });
    // mint_green: 0x96fa96
    try testRoundTripConversion(.{ .r = 150, .g = 250, .b = 150 }, Hsl(f64){ .h = 120, .s = 90.90909090909089, .l = 78.43137254901961 });
    try testRoundTripConversion(.{ .r = 150, .g = 250, .b = 150 }, Hsv(f64){ .h = 120, .s = 40, .v = 98.0392156862745 });
    try testRoundTripConversion(.{ .r = 150, .g = 250, .b = 150 }, Lab(f64){ .l = 90.34795996024553, .a = -48.75545372512652, .b = 38.96689290268498 });
    // sky_blue: #8ad1ed
    try testRoundTripConversion(.{ .r = 138, .g = 209, .b = 237 }, Hsl(f64){ .h = 196.96969696969697, .s = 73.33333333333336, .l = 73.52941176470588 });
    try testRoundTripConversion(.{ .r = 138, .g = 209, .b = 237 }, Hsv(f64){ .h = 196.96969696969697, .s = 41.77215189873419, .v = 92.94117647058823 });
    try testRoundTripConversion(.{ .r = 138, .g = 209, .b = 237 }, Lab(f64){ .l = 80.24627015828005, .a = -15.11865203941365, .b = -20.767024460106565 });
}

test "vivid colors" {
    // hot_pink: #ff66b3
    try testRoundTripConversion(.{ .r = 255, .g = 102, .b = 179 }, Hsl(f64){ .h = 329.80392156862746, .s = 99.99999999999997, .l = 70 });
    try testRoundTripConversion(.{ .r = 255, .g = 102, .b = 179 }, Hsv(f64){ .h = 329.80392156862746, .s = 60, .v = 100 });
    try testRoundTripConversion(.{ .r = 255, .g = 102, .b = 179 }, Lab(f64){ .l = 64.9763931162809, .a = 65.40669278373645, .b = -10.847761988977656 });
    // lime_green:#31cc31
    try testRoundTripConversion(.{ .r = 49, .g = 204, .b = 49 }, Hsl(f64){ .h = 120, .s = 61.26482213438735, .l = 49.6078431372549 });
    try testRoundTripConversion(.{ .r = 49, .g = 204, .b = 49 }, Hsv(f64){ .h = 120, .s = 75.98039215686275, .v = 80 });
    try testRoundTripConversion(.{ .r = 49, .g = 204, .b = 49 }, Lab(f64){ .l = 72.26888334336961, .a = -67.03378336285304, .b = 61.425460443480894 });
    // electric_blue: #80dfff
    try testRoundTripConversion(.{ .r = 128, .g = 223, .b = 255 }, Hsl(f64){ .h = 195.11811023622047, .s = 100, .l = 75.09803921568627 });
    try testRoundTripConversion(.{ .r = 128, .g = 223, .b = 255 }, Hsv(f64){ .h = 195.11811023622047, .s = 49.80392156862745, .v = 100 });
    try testRoundTripConversion(.{ .r = 128, .g = 223, .b = 255 }, Lab(f64){ .l = 84.26919487615707, .a = -19.773688316136685, .b = -24.252061008370738 });
}

test "Color formatting" {
    const red: Rgb(u8) = .{ .r = 255, .g = 0, .b = 0 };
    var buffer: [512]u8 = undefined;
    var stream: Io.Writer = .fixed(&buffer);

    try red.format(&stream);
    const result_red = buffer[0..stream.end];
    const expected_red = "\x1b[1m\x1b[38;2;0;0;0m\x1b[48;2;255;0;0mRgb(u8){ .r = 255, .g = 0, .b = 0 }\x1b[0m";
    try expectEqualStrings(expected_red, result_red);
}

test "100 random colors" {
    const io = std.testing.io;
    const rng_impl: std.Random.IoSource = .{ .io = io };
    const seed = rng_impl.interface().int(u64);
    var prng: std.Random.DefaultPrng = .init(seed);
    var random = prng.random();
    for (0..100) |_| {
        const rgb: Rgb(u8) = .{ .r = random.int(u8), .g = random.int(u8), .b = random.int(u8) };
        const rgb_from_hsl = rgb.as(f64).to(.hsl).to(.rgb).as(u8);
        try expectEqualDeep(rgb, rgb_from_hsl);
        const rgb_from_hsv = rgb.as(f64).to(.hsv).to(.rgb).as(u8);
        try expectEqualDeep(rgb, rgb_from_hsv);
        const rgb_from_xyz = rgb.as(f64).to(.xyz).to(.rgb).as(u8);
        try expectEqualDeep(rgb, rgb_from_xyz);
        const rgb_from_lab = rgb.as(f64).to(.lab).to(.rgb).as(u8);
        try expectEqualDeep(rgb, rgb_from_lab);
        const rgb_from_lch = rgb.as(f64).to(.lch).to(.rgb).as(u8);
        try expectEqualDeep(rgb, rgb_from_lch);
        const rgb_from_oklab = rgb.as(f64).to(.oklab).to(.rgb).as(u8);
        try expectEqualDeep(rgb, rgb_from_oklab);
        const rgb_from_oklch = rgb.as(f64).to(.oklch).to(.rgb).as(u8);
        try expectEqualDeep(rgb, rgb_from_oklch);
        const rgb_from_xyb = rgb.as(f64).to(.xyb).to(.rgb).as(u8);
        try expectEqualDeep(rgb, rgb_from_xyb);
        const rgb_from_lms = rgb.as(f64).to(.lms).to(.rgb).as(u8);
        try expectEqualDeep(rgb, rgb_from_lms);
        const rgb_from_ycbcr = rgb.as(f64).to(.ycbcr).to(.rgb).as(u8);
        try expectEqualDeep(rgb, rgb_from_ycbcr);
        const rgb_from_ycbcr2 = rgb.to(.ycbcr).to(.rgb);
        try expectApproxEqAbs(@as(f32, rgb.r), @as(f32, rgb_from_ycbcr2.r), 1);
        try expectApproxEqAbs(@as(f32, rgb.g), @as(f32, rgb_from_ycbcr2.g), 1);
        try expectApproxEqAbs(@as(f32, rgb.b), @as(f32, rgb_from_ycbcr2.b), 1);
        const rgb_from_inv = rgb.invert().invert();
        try expectEqualDeep(rgb, rgb_from_inv);
    }
}

test "Xyz blend matches RGB blend" {
    const base_rgb = Rgb(f32){ .r = 0.47, .g = 0.39, .b = 0.31 };
    const overlay = Rgba(f32){ .r = 0.78, .g = 0.20, .b = 0.59, .a = 0.5 };

    const blended_xyz = base_rgb.to(.xyz).to(.rgba).blend(overlay, Blending.normal);
    const blended_rgb = base_rgb.blend(overlay, Blending.normal);

    try expectApproxEqAbs(blended_rgb.r, blended_xyz.to(.rgb).r, 0.001);
    try expectApproxEqAbs(blended_rgb.g, blended_xyz.to(.rgb).g, 0.001);
    try expectApproxEqAbs(blended_rgb.b, blended_xyz.to(.rgb).b, 0.001);
}

test "ColorSpace.convert" {
    const red_hsv: Hsv(f32) = .{ .h = 0, .s = 100, .v = 100 };
    const red_rgb_u8: Rgb(u8) = red_hsv.to(.rgb).as(u8);
    try expectEqualDeep(red_rgb_u8, Rgb(u8){ .r = 255, .g = 0, .b = 0 });

    const red_rgb_f32: Rgb(f32) = red_hsv.to(.rgb);
    try expectEqualDeep(red_rgb_f32, Rgb(f32){ .r = 1, .g = 0, .b = 0 });

    const red_u8 = Rgb(u8){ .r = 255, .g = 0, .b = 0 };
    const red_hsv_recovered: Hsv(f32) = red_u8.as(f32).to(.hsv);
    try expectEqualDeep(red_hsv_recovered, red_hsv);
}

test "Rgba fade" {
    const red = Rgba(u8){ .r = 255, .g = 0, .b = 0, .a = 255 };
    try expectEqualDeep(red.fade(0.5), Rgba(u8){ .r = 255, .g = 0, .b = 0, .a = 127 });
    try expectEqualDeep(red.fade(0), Rgba(u8){ .r = 255, .g = 0, .b = 0, .a = 0 });
    try expectEqualDeep(red.fade(1), red);

    const blue = Rgba(f32){ .r = 0, .g = 0, .b = 1, .a = 0.8 };
    const faded_blue = blue.fade(0.5);
    try expectApproxEqAbs(faded_blue.a, 0.4, 0.0001);
    try expectEqual(faded_blue.r, 0);
    try expectEqual(faded_blue.b, 1);
}

test "Luma calculation" {
    // Test known luma values
    const white = Rgb(u8).white;
    try expectApproxEqAbs(white.luma(), 1.0, 0.001);

    const black = Rgb(u8).black;
    try expectApproxEqAbs(black.luma(), 0.0, 0.001);

    const red = Rgb(u8).red; // 0.2126
    try expectApproxEqAbs(red.luma(), 0.2126, 0.001);

    const green = Rgb(u8).green; // 0.7152
    try expectApproxEqAbs(green.luma(), 0.7152, 0.001);

    const blue = Rgb(u8).blue; // 0.0722
    try expectApproxEqAbs(blue.luma(), 0.0722, 0.001);

    // Rgba should ignore alpha
    const red_alpha = Rgba(u8){ .r = 255, .g = 0, .b = 0, .a = 128 };
    try expectApproxEqAbs(red_alpha.luma(), 0.2126, 0.001);
}

test "Rgba invert" {
    const white_transparent = Rgba(u8){ .r = 255, .g = 255, .b = 255, .a = 0 };
    try expectEqualDeep(white_transparent.invert(), Rgba(u8){ .r = 0, .g = 0, .b = 0, .a = 0 });

    const mixed = Rgba(u8){ .r = 100, .g = 150, .b = 200, .a = 255 };
    try expectEqualDeep(mixed.invert(), Rgba(u8){ .r = 155, .g = 105, .b = 55, .a = 255 });
}

test "Color union float" {
    const red_rgb = Rgb(f32).red;
    const any_color = Color(f32){ .rgb = red_rgb };

    // Test conversion through union
    const as_hsv: Hsv(f32) = any_color.to(.hsv);
    try expectApproxEqAbs(as_hsv.h, 0, 0.001);
    try expectApproxEqAbs(as_hsv.s, 100, 0.001);
    try expectApproxEqAbs(as_hsv.v, 100, 0.001);
}

test "clamping out-of-range inputs" {
    // Negative float should clamp to 0
    try expectEqual(convertColor(u8, @as(f32, -0.5)), @as(u8, 0));
    // Float > 1 should clamp to 255
    try expectEqual(convertColor(u8, @as(f32, 1.5)), @as(u8, 255));

    const rgb_over = Rgb(f32){ .r = 1.2, .g = -0.2, .b = 0.5 };
    const rgb_u8 = rgb_over.as(u8);
    try expectEqual(rgb_u8.r, 255);
    try expectEqual(rgb_u8.g, 0);
    try expectEqual(rgb_u8.b, 128);
}
