/*
 * Portions of this file are based on code from react-spectrum.
 * Apache License Version 2.0, Copyright 2020 Adobe.
 *
 * Credits to the React Spectrum team:
 * https://github.com/adobe/react-spectrum/blob/68e305768cb829bab7b9836dded593bd731259f3/packages/%40react-stately/color/src/Color.ts
 *
 */

import { clamp } from "@kobalte/utils";
import { createNumberFormatter } from "../i18n";
import type { ColorIntlTranslations } from "./intl";
import type {
	ColorAxes,
	ColorChannel,
	ColorChannelRange,
	ColorFormat,
	ColorSpace,
	Color as IColor,
} from "./types";

/** Parses a color from a string value. Throws an error if the string could not be parsed. */
export function parseColor(value: string): IColor {
	const res =
		RGBColor.parse(value) || HSBColor.parse(value) || HSLColor.parse(value);
	if (res) {
		return res;
	}

	throw new Error(`Invalid color value: ${value}`);
}

export function normalizeColor(v: string | IColor) {
	if (typeof v === "string") {
		return parseColor(v);
	}
	return v;
}

/** Returns a list of color channels for a given color space. */
export function getColorChannels(colorSpace: ColorSpace) {
	switch (colorSpace) {
		case "rgb":
			return RGBColor.colorChannels;
		case "hsl":
			return HSLColor.colorChannels;
		case "hsb":
			return HSBColor.colorChannels;
	}
}

/**
 * Returns the hue value normalized to the range of 0 to 360.
 */
export function normalizeHue(hue: number) {
	if (hue === 360) {
		return hue;
	}

	return ((hue % 360) + 360) % 360;
}

// Lightness threshold between orange and brown.
const ORANGE_LIGHTNESS_THRESHOLD = 0.68;
// Lightness threshold between pure yellow and "yellow green".
const YELLOW_GREEN_LIGHTNESS_THRESHOLD = 0.85;
// The maximum lightness considered to be "dark".
const MAX_DARK_LIGHTNESS = 0.55;
// The chroma threshold between gray and color.
const GRAY_THRESHOLD = 0.001;
const OKLCH_HUES: [number, string][] = [
	[0, "pink"],
	[15, "red"],
	[48, "orange"],
	[94, "yellow"],
	[135, "green"],
	[175, "cyan"],
	[264, "blue"],
	[284, "purple"],
	[320, "magenta"],
	[349, "pink"],
];

abstract class Color implements IColor {
	abstract toFormat(format: ColorFormat): IColor;
	abstract toString(format: ColorFormat | "css"): string;
	abstract clone(): IColor;
	abstract getChannelRange(channel: ColorChannel): ColorChannelRange;
	abstract getChannelFormatOptions(
		channel: ColorChannel,
	): Intl.NumberFormatOptions;
	abstract formatChannelValue(channel: ColorChannel): string;

	toHexInt(): number {
		return this.toFormat("rgb").toHexInt();
	}

	getChannelValue(channel: ColorChannel): number {
		if (channel in this) {
			//@ts-expect-error
			return this[channel];
		}

		throw new Error(`Unsupported color channel: ${channel}`);
	}

	withChannelValue(channel: ColorChannel, value: number): IColor {
		if (channel in this) {
			const x = this.clone();
			//@ts-expect-error
			x[channel] = value;
			return x;
		}

		throw new Error(`Unsupported color channel: ${channel}`);
	}

	getChannelName(channel: ColorChannel, translations: ColorIntlTranslations) {
		return translations[channel];
	}

	abstract getColorSpace(): ColorSpace;

	getColorSpaceAxes(xyChannels: {
		xChannel?: ColorChannel;
		yChannel?: ColorChannel;
	}): ColorAxes {
		const { xChannel, yChannel } = xyChannels;
		const xCh =
			xChannel || this.getColorChannels().find((c) => c !== yChannel)!;
		const yCh = yChannel || this.getColorChannels().find((c) => c !== xCh)!;
		const zCh = this.getColorChannels().find((c) => c !== xCh && c !== yCh)!;

		return { xChannel: xCh, yChannel: yCh, zChannel: zCh };
	}

	abstract getColorChannels(): [ColorChannel, ColorChannel, ColorChannel];

	getColorName(translations: ColorIntlTranslations): string {
		// Convert to oklch color space, which has perceptually uniform lightness across all hues.
		let [l, c, h] = toOKLCH(this);

		if (l > 0.999) {
			return translations.white;
		}

		if (l < 0.001) {
			return translations.black;
		}

		let hue: string;
		[hue, l] = this.getOklchHue(l, c, h, translations);

		let lightness = "";
		let chroma = "";
		if (c <= 0.1 && c >= GRAY_THRESHOLD) {
			if (l >= 0.7) {
				chroma = "pale";
			} else {
				chroma = "grayish";
			}
		} else if (c >= 0.15) {
			chroma = "vibrant";
		}

		if (l < 0.3) {
			lightness = "very dark";
		} else if (l < MAX_DARK_LIGHTNESS) {
			lightness = "dark";
		} else if (l < 0.7) {
			// none
		} else if (l < 0.85) {
			lightness = "light";
		} else {
			lightness = "very light";
		}

		if (chroma) {
			//@ts-expect-error
			chroma = translations[chroma];
		}

		if (lightness) {
			//@ts-expect-error
			lightness = translations[lightness];
		}

		const alpha = this.getChannelValue("alpha");
		if (alpha < 1) {
			const percentTransparent = createNumberFormatter(() => ({
				style: "percent",
			}))().format(1 - alpha);
			return translations
				.transparentColorName(lightness, chroma, hue, percentTransparent)
				.replace(/\s+/g, " ")
				.trim();
		}
		return translations
			.colorName(lightness, chroma, hue)
			.replace(/\s+/g, " ")
			.trim();
	}

	private getOklchHue(
		l: number,
		c: number,
		h: number,
		translations: ColorIntlTranslations,
	): [string, number] {
		if (c < GRAY_THRESHOLD) {
			return [translations.gray, l];
		}

		for (let i = 0; i < OKLCH_HUES.length; i++) {
			let [hue, hueName] = OKLCH_HUES[i];
			const [nextHue, nextHueName] = OKLCH_HUES[i + 1] || [360, "pink"];
			if (h >= hue && h < nextHue) {
				// Split orange hue into brown/orange depending on lightness.
				if (hueName === "orange") {
					if (l < ORANGE_LIGHTNESS_THRESHOLD) {
						hueName = "brown";
					} else {
						// Adjust lightness.
						// biome-ignore lint/style/noParameterAssign:
						l = l - ORANGE_LIGHTNESS_THRESHOLD + MAX_DARK_LIGHTNESS;
					}
				}

				// If the hue is at least halfway to the next hue, add the next hue name as well.
				if (h > hue + (nextHue - hue) / 2 && hueName !== nextHueName) {
					hueName = `${hueName} ${nextHueName}`;
				} else if (
					hueName === "yellow" &&
					l < YELLOW_GREEN_LIGHTNESS_THRESHOLD
				) {
					// Yellow shifts toward green at lower lightnesses.
					hueName = "yellow green";
				}
				//@ts-expect-error
				const name = translations[hueName];
				return [name, l];
			}
		}

		throw new Error("Unexpected hue");
	}

	getHueName(translations: ColorIntlTranslations): string {
		const [l, c, h] = toOKLCH(this);
		const [name] = this.getOklchHue(l, c, h, translations);
		return name;
	}
}

class RGBColor extends Color {
	constructor(
		private red: number,
		private green: number,
		private blue: number,
		private alpha: number,
	) {
		super();
	}

	static parse(value: string) {
		let colors: Array<number | undefined> = [];
		// matching #rgb, #rgba, #rrggbb, #rrggbbaa
		if (/^#[\da-f]+$/i.test(value) && [4, 5, 7, 9].includes(value.length)) {
			const values = (
				value.length < 6 ? value.replace(/[^#]/gi, "$&$&") : value
			)
				.slice(1)
				.split("");
			while (values.length > 0) {
				colors.push(Number.parseInt(values.splice(0, 2).join(""), 16));
			}
			colors[3] = colors[3] !== undefined ? colors[3] / 255 : undefined;
		}

		// matching rgb(rrr, ggg, bbb), rgba(rrr, ggg, bbb, 0.a)
		const match = value.match(/^rgba?\((.*)\)$/);
		if (match?.[1]) {
			colors = match[1].split(",").map((value) => Number(value.trim()));
			colors = colors.map((num, i) => {
				return clamp(num ?? 0, 0, i < 3 ? 255 : 1);
			});
		}
		if (
			colors[0] === undefined ||
			colors[1] === undefined ||
			colors[2] === undefined
		) {
			return undefined;
		}

		return colors.length < 3
			? undefined
			: new RGBColor(colors[0], colors[1], colors[2], colors[3] ?? 1);
	}

	toString(format: ColorFormat | "css" = "css") {
		switch (format) {
			case "hex":
				return `#${(
					this.red.toString(16).padStart(2, "0") +
					this.green.toString(16).padStart(2, "0") +
					this.blue.toString(16).padStart(2, "0")
				).toUpperCase()}`;
			case "hexa":
				return `#${(
					this.red.toString(16).padStart(2, "0") +
					this.green.toString(16).padStart(2, "0") +
					this.blue.toString(16).padStart(2, "0") +
					Math.round(this.alpha * 255)
						.toString(16)
						.padStart(2, "0")
				).toUpperCase()}`;
			case "rgb":
				return `rgb(${this.red}, ${this.green}, ${this.blue})`;
			case "css":
			case "rgba":
				return `rgba(${this.red}, ${this.green}, ${this.blue}, ${this.alpha})`;
			default:
				return this.toFormat(format).toString(format);
		}
	}

	toFormat(format: ColorFormat): IColor {
		switch (format) {
			case "hex":
			case "hexa":
			case "rgb":
			case "rgba":
				return this;
			case "hsb":
			case "hsba":
				return this.toHSB();
			case "hsl":
			case "hsla":
				return this.toHSL();
			default:
				throw new Error(`Unsupported color conversion: rgb -> ${format}`);
		}
	}

	toHexInt(): number {
		return (this.red << 16) | (this.green << 8) | this.blue;
	}

	/**
	 * Converts an RGB color value to HSB.
	 * Conversion formula adapted from https://en.wikipedia.org/wiki/HSL_and_HSV#From_RGB.
	 * @returns An HSBColor object.
	 */
	private toHSB(): IColor {
		const red = this.red / 255;
		const green = this.green / 255;
		const blue = this.blue / 255;
		const min = Math.min(red, green, blue);
		const brightness = Math.max(red, green, blue);
		const chroma = brightness - min;
		const saturation = brightness === 0 ? 0 : chroma / brightness;
		let hue = 0; // achromatic

		if (chroma !== 0) {
			switch (brightness) {
				case red:
					hue = (green - blue) / chroma + (green < blue ? 6 : 0);
					break;
				case green:
					hue = (blue - red) / chroma + 2;
					break;
				case blue:
					hue = (red - green) / chroma + 4;
					break;
			}

			hue /= 6;
		}

		return new HSBColor(
			toFixedNumber(hue * 360, 2),
			toFixedNumber(saturation * 100, 2),
			toFixedNumber(brightness * 100, 2),
			this.alpha,
		);
	}

	/**
	 * Converts an RGB color value to HSL.
	 * Conversion formula adapted from https://en.wikipedia.org/wiki/HSL_and_HSV#From_RGB.
	 * @returns An HSLColor object.
	 */
	private toHSL(): IColor {
		const red = this.red / 255;
		const green = this.green / 255;
		const blue = this.blue / 255;
		const min = Math.min(red, green, blue);
		const max = Math.max(red, green, blue);
		const lightness = (max + min) / 2;
		const chroma = max - min;
		let hue: number;
		let saturation: number;

		if (chroma === 0) {
			hue = saturation = 0; // achromatic
		} else {
			saturation = chroma / (lightness < 0.5 ? max + min : 2 - max - min);

			switch (max) {
				case red:
					hue = (green - blue) / chroma + (green < blue ? 6 : 0);
					break;
				case green:
					hue = (blue - red) / chroma + 2;
					break;
				default:
					hue = (red - green) / chroma + 4;
					break;
			}

			hue /= 6;
		}

		return new HSLColor(
			toFixedNumber(hue * 360, 2),
			toFixedNumber(saturation * 100, 2),
			toFixedNumber(lightness * 100, 2),
			this.alpha,
		);
	}

	clone(): IColor {
		return new RGBColor(this.red, this.green, this.blue, this.alpha);
	}

	getChannelRange(channel: ColorChannel): ColorChannelRange {
		switch (channel) {
			case "red":
			case "green":
			case "blue":
				return { minValue: 0x0, maxValue: 0xff, step: 0x1, pageSize: 0x11 };
			case "alpha":
				return { minValue: 0, maxValue: 1, step: 0.01, pageSize: 0.1 };
			default:
				throw new Error(`Unknown color channel: ${channel}`);
		}
	}

	getChannelFormatOptions(channel: ColorChannel): Intl.NumberFormatOptions {
		switch (channel) {
			case "red":
			case "green":
			case "blue":
				return { style: "decimal" };
			case "alpha":
				return { style: "percent" };
			default:
				throw new Error(`Unknown color channel: ${channel}`);
		}
	}

	formatChannelValue(channel: ColorChannel) {
		const options = this.getChannelFormatOptions(channel);
		const value = this.getChannelValue(channel);
		return createNumberFormatter(() => options)().format(value);
	}

	getColorSpace(): ColorSpace {
		return "rgb";
	}

	static colorChannels: [ColorChannel, ColorChannel, ColorChannel] = [
		"red",
		"green",
		"blue",
	];
	getColorChannels(): [ColorChannel, ColorChannel, ColorChannel] {
		return RGBColor.colorChannels;
	}
}

// X = <negative/positive number with/without decimal places>
// before/after a comma, 0 or more whitespaces are allowed
// - hsb(X, X%, X%)
// - hsba(X, X%, X%, X)
const HSB_REGEX =
	/hsb\(([-+]?\d+(?:.\d+)?\s*,\s*[-+]?\d+(?:.\d+)?%\s*,\s*[-+]?\d+(?:.\d+)?%)\)|hsba\(([-+]?\d+(?:.\d+)?\s*,\s*[-+]?\d+(?:.\d+)?%\s*,\s*[-+]?\d+(?:.\d+)?%\s*,\s*[-+]?\d(.\d+)?)\)/;

class HSBColor extends Color {
	constructor(
		private hue: number,
		private saturation: number,
		private brightness: number,
		private alpha: number,
	) {
		super();
	}

	static parse(value: string): HSBColor | undefined {
		let m: RegExpMatchArray | null;
		if ((m = value.match(HSB_REGEX))) {
			const [h, s, b, a] = (m[1] ?? m[2])
				.split(",")
				.map((n) => Number(n.trim().replace("%", "")));
			return new HSBColor(
				normalizeHue(h),
				clamp(s, 0, 100),
				clamp(b, 0, 100),
				clamp(a ?? 1, 0, 1),
			);
		}
	}

	toString(format: ColorFormat | "css" = "css") {
		switch (format) {
			case "css":
				return this.toHSL().toString("css");
			case "hex":
				return this.toRGB().toString("hex");
			case "hexa":
				return this.toRGB().toString("hexa");
			case "hsb":
				return `hsb(${this.hue}, ${toFixedNumber(this.saturation, 2)}%, ${toFixedNumber(this.brightness, 2)}%)`;
			case "hsba":
				return `hsba(${this.hue}, ${toFixedNumber(this.saturation, 2)}%, ${toFixedNumber(this.brightness, 2)}%, ${this.alpha})`;
			default:
				return this.toFormat(format).toString(format);
		}
	}

	toFormat(format: ColorFormat): IColor {
		switch (format) {
			case "hsb":
			case "hsba":
				return this;
			case "hsl":
			case "hsla":
				return this.toHSL();
			case "rgb":
			case "rgba":
				return this.toRGB();
			default:
				throw new Error(`Unsupported color conversion: hsb -> ${format}`);
		}
	}

	/**
	 * Converts a HSB color to HSL.
	 * Conversion formula adapted from https://en.wikipedia.org/wiki/HSL_and_HSV#HSV_to_HSL.
	 * @returns An HSLColor object.
	 */
	private toHSL(): IColor {
		let saturation = this.saturation / 100;
		const brightness = this.brightness / 100;
		const lightness = brightness * (1 - saturation / 2);
		saturation =
			lightness === 0 || lightness === 1
				? 0
				: (brightness - lightness) / Math.min(lightness, 1 - lightness);

		return new HSLColor(
			toFixedNumber(this.hue, 2),
			toFixedNumber(saturation * 100, 2),
			toFixedNumber(lightness * 100, 2),
			this.alpha,
		);
	}

	/**
	 * Converts a HSV color value to RGB.
	 * Conversion formula adapted from https://en.wikipedia.org/wiki/HSL_and_HSV#HSV_to_RGB_alternative.
	 * @returns An RGBColor object.
	 */
	private toRGB(): IColor {
		const hue = this.hue;
		const saturation = this.saturation / 100;
		const brightness = this.brightness / 100;
		const fn = (n: number, k = (n + hue / 60) % 6) =>
			brightness - saturation * brightness * Math.max(Math.min(k, 4 - k, 1), 0);
		return new RGBColor(
			Math.round(fn(5) * 255),
			Math.round(fn(3) * 255),
			Math.round(fn(1) * 255),
			this.alpha,
		);
	}

	clone(): IColor {
		return new HSBColor(this.hue, this.saturation, this.brightness, this.alpha);
	}

	getChannelRange(channel: ColorChannel): ColorChannelRange {
		switch (channel) {
			case "hue":
				return { minValue: 0, maxValue: 360, step: 1, pageSize: 15 };
			case "saturation":
			case "brightness":
				return { minValue: 0, maxValue: 100, step: 1, pageSize: 10 };
			case "alpha":
				return { minValue: 0, maxValue: 1, step: 0.01, pageSize: 0.1 };
			default:
				throw new Error(`Unknown color channel: ${channel}`);
		}
	}

	getChannelFormatOptions(channel: ColorChannel): Intl.NumberFormatOptions {
		switch (channel) {
			case "hue":
				return { style: "unit", unit: "degree", unitDisplay: "narrow" };
			case "saturation":
			case "brightness":
			case "alpha":
				return { style: "percent" };
			default:
				throw new Error(`Unknown color channel: ${channel}`);
		}
	}

	formatChannelValue(channel: ColorChannel) {
		const options = this.getChannelFormatOptions(channel);
		let value = this.getChannelValue(channel);
		if (channel === "saturation" || channel === "brightness") {
			value /= 100;
		}
		return createNumberFormatter(() => options)().format(value);
	}

	getColorSpace(): ColorSpace {
		return "hsb";
	}

	static colorChannels: [ColorChannel, ColorChannel, ColorChannel] = [
		"hue",
		"saturation",
		"brightness",
	];
	getColorChannels(): [ColorChannel, ColorChannel, ColorChannel] {
		return HSBColor.colorChannels;
	}
}

// X = <negative/positive number with/without decimal places>
// before/after a comma, 0 or more whitespaces are allowed
// - hsl(X, X%, X%)
// - hsla(X, X%, X%, X)
const HSL_REGEX =
	/hsl\(([-+]?\d+(?:.\d+)?\s*,\s*[-+]?\d+(?:.\d+)?%\s*,\s*[-+]?\d+(?:.\d+)?%)\)|hsla\(([-+]?\d+(?:.\d+)?\s*,\s*[-+]?\d+(?:.\d+)?%\s*,\s*[-+]?\d+(?:.\d+)?%\s*,\s*[-+]?\d(.\d+)?)\)/;

class HSLColor extends Color {
	constructor(
		private hue: number,
		private saturation: number,
		private lightness: number,
		private alpha: number,
	) {
		super();
	}

	static parse(value: string): HSLColor | undefined {
		let m: RegExpMatchArray | null;
		if ((m = value.match(HSL_REGEX))) {
			const [h, s, l, a] = (m[1] ?? m[2])
				.split(",")
				.map((n) => Number(n.trim().replace("%", "")));
			return new HSLColor(
				normalizeHue(h),
				clamp(s, 0, 100),
				clamp(l, 0, 100),
				clamp(a ?? 1, 0, 1),
			);
		}
	}

	toString(format: ColorFormat | "css" = "css") {
		switch (format) {
			case "hex":
				return this.toRGB().toString("hex");
			case "hexa":
				return this.toRGB().toString("hexa");
			case "hsl":
				return `hsl(${this.hue}, ${toFixedNumber(this.saturation, 2)}%, ${toFixedNumber(this.lightness, 2)}%)`;
			case "css":
			case "hsla":
				return `hsla(${this.hue}, ${toFixedNumber(this.saturation, 2)}%, ${toFixedNumber(this.lightness, 2)}%, ${this.alpha})`;
			default:
				return this.toFormat(format).toString(format);
		}
	}
	toFormat(format: ColorFormat): IColor {
		switch (format) {
			case "hsl":
			case "hsla":
				return this;
			case "hsb":
			case "hsba":
				return this.toHSB();
			case "rgb":
			case "rgba":
				return this.toRGB();
			default:
				throw new Error(`Unsupported color conversion: hsl -> ${format}`);
		}
	}

	/**
	 * Converts a HSL color to HSB.
	 * Conversion formula adapted from https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_HSV.
	 * @returns An HSBColor object.
	 */
	private toHSB(): IColor {
		let saturation = this.saturation / 100;
		const lightness = this.lightness / 100;
		const brightness =
			lightness + saturation * Math.min(lightness, 1 - lightness);
		saturation = brightness === 0 ? 0 : 2 * (1 - lightness / brightness);
		return new HSBColor(
			toFixedNumber(this.hue, 2),
			toFixedNumber(saturation * 100, 2),
			toFixedNumber(brightness * 100, 2),
			this.alpha,
		);
	}

	/**
	 * Converts a HSL color to RGB.
	 * Conversion formula adapted from https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_RGB_alternative.
	 * @returns An RGBColor object.
	 */
	private toRGB(): IColor {
		const hue = this.hue;
		const saturation = this.saturation / 100;
		const lightness = this.lightness / 100;
		const a = saturation * Math.min(lightness, 1 - lightness);
		const fn = (n: number, k = (n + hue / 30) % 12) =>
			lightness - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
		return new RGBColor(
			Math.round(fn(0) * 255),
			Math.round(fn(8) * 255),
			Math.round(fn(4) * 255),
			this.alpha,
		);
	}

	clone(): IColor {
		return new HSLColor(this.hue, this.saturation, this.lightness, this.alpha);
	}

	getChannelRange(channel: ColorChannel): ColorChannelRange {
		switch (channel) {
			case "hue":
				return { minValue: 0, maxValue: 360, step: 1, pageSize: 15 };
			case "saturation":
			case "lightness":
				return { minValue: 0, maxValue: 100, step: 1, pageSize: 10 };
			case "alpha":
				return { minValue: 0, maxValue: 1, step: 0.01, pageSize: 0.1 };
			default:
				throw new Error(`Unknown color channel: ${channel}`);
		}
	}

	getChannelFormatOptions(channel: ColorChannel): Intl.NumberFormatOptions {
		switch (channel) {
			case "hue":
				return { style: "unit", unit: "degree", unitDisplay: "narrow" };
			case "saturation":
			case "lightness":
			case "alpha":
				return { style: "percent" };
			default:
				throw new Error(`Unknown color channel: ${channel}`);
		}
	}

	formatChannelValue(channel: ColorChannel) {
		const options = this.getChannelFormatOptions(channel);
		let value = this.getChannelValue(channel);
		if (channel === "saturation" || channel === "lightness") {
			value /= 100;
		}
		return createNumberFormatter(() => options)().format(value);
	}

	getColorSpace(): ColorSpace {
		return "hsl";
	}

	static colorChannels: [ColorChannel, ColorChannel, ColorChannel] = [
		"hue",
		"saturation",
		"lightness",
	];
	getColorChannels(): [ColorChannel, ColorChannel, ColorChannel] {
		return HSLColor.colorChannels;
	}
}

// https://www.w3.org/TR/css-color-4/#color-conversion-code
function toOKLCH(color: Color) {
	const rgb = color.toFormat("rgb");
	let red = rgb.getChannelValue("red") / 255;
	let green = rgb.getChannelValue("green") / 255;
	let blue = rgb.getChannelValue("blue") / 255;
	[red, green, blue] = lin_sRGB(red, green, blue);
	const [x, y, z] = lin_sRGB_to_XYZ(red, green, blue);
	const [l, a, b] = XYZ_to_OKLab(x, y, z);
	return OKLab_to_OKLCH(l, a, b);
}

function OKLab_to_OKLCH(
	l: number,
	a: number,
	b: number,
): [number, number, number] {
	const hue = (Math.atan2(b, a) * 180) / Math.PI;
	return [
		l,
		Math.sqrt(a ** 2 + b ** 2), // Chroma
		hue >= 0 ? hue : hue + 360, // Hue, in degrees [0 to 360)
	];
}

function lin_sRGB(r: number, g: number, b: number): [number, number, number] {
	// convert an array of sRGB values
	// where in-gamut values are in the range [0 - 1]
	// to linear light (un-companded) form.
	// https://en.wikipedia.org/wiki/SRGB
	// Extended transfer function:
	// for negative values,  linear portion is extended on reflection of axis,
	// then reflected power function is used.
	return [lin_sRGB_component(r), lin_sRGB_component(g), lin_sRGB_component(b)];
}

function lin_sRGB_component(val: number) {
	const sign = val < 0 ? -1 : 1;
	const abs = Math.abs(val);

	if (abs <= 0.04045) {
		return val / 12.92;
	}

	return sign * ((abs + 0.055) / 1.055) ** 2.4;
}

function lin_sRGB_to_XYZ(r: number, g: number, b: number) {
	// convert an array of linear-light sRGB values to CIE XYZ
	// using sRGB's own white, D65 (no chromatic adaptation)
	const M = [
		506752 / 1228815,
		87881 / 245763,
		12673 / 70218,
		87098 / 409605,
		175762 / 245763,
		12673 / 175545,
		7918 / 409605,
		87881 / 737289,
		1001167 / 1053270,
	];
	return multiplyMatrix(M, r, g, b);
}

function XYZ_to_OKLab(x: number, y: number, z: number) {
	// Given XYZ relative to D65, convert to OKLab
	const XYZtoLMS = [
		0.819022437996703, 0.3619062600528904, -0.1288737815209879,
		0.0329836539323885, 0.9292868615863434, 0.0361446663506424,
		0.0481771893596242, 0.2642395317527308, 0.6335478284694309,
	];
	const LMStoOKLab = [
		0.210454268309314, 0.7936177747023054, -0.0040720430116193,
		1.9779985324311684, -2.4285922420485799, 0.450593709617411,
		0.0259040424655478, 0.7827717124575296, -0.8086757549230774,
	];

	const [a, b, c] = multiplyMatrix(XYZtoLMS, x, y, z);
	return multiplyMatrix(LMStoOKLab, Math.cbrt(a), Math.cbrt(b), Math.cbrt(c));
}

function multiplyMatrix(
	m: number[],
	x: number,
	y: number,
	z: number,
): [number, number, number] {
	const a = m[0] * x + m[1] * y + m[2] * z;
	const b = m[3] * x + m[4] * y + m[5] * z;
	const c = m[6] * x + m[7] * y + m[8] * z;
	return [a, b, c];
}

function toFixedNumber(value: number, digits: number, base = 10): number {
	const pow = base ** digits;

	return Math.round(value * pow) / pow;
}
