/*
 * Copyright (C) 2009-2022 Apple Inc. All rights reserved.
 * Copyright (C) 2009 Joseph Pecoraro
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions
 * are met:
 *
 * 1.  Redistributions of source code must retain the above copyright
 *     notice, this list of conditions and the following disclaimer.
 * 2.  Redistributions in binary form must reproduce the above copyright
 *     notice, this list of conditions and the following disclaimer in the
 *     documentation and/or other materials provided with the distribution.
 * 3.  Neither the name of Apple Inc. ("Apple") nor the names of
 *     its contributors may be used to endorse or promote products derived
 *     from this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY APPLE AND ITS CONTRIBUTORS "AS IS" AND ANY
 * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
 * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED. IN NO EVENT SHALL APPLE OR ITS CONTRIBUTORS BE LIABLE FOR ANY
 * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
 * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
 * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
 * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
 * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

WI.Color = class Color
{
    constructor(format, components, gamut)
    {
        this.format = format;

        console.assert(gamut === undefined || Object.values(WI.Color.Gamut).includes(gamut));
        this._gamut = gamut || WI.Color.Gamut.SRGB;

        console.assert(components.length === 3 || components.length === 4, components);
        this.alpha = components.length === 4 ? components[3] : 1;

        this._rgb = null;
        this._normalizedRGB = null;
        this._hsl = null;

        if (format === WI.Color.Format.HSL || format === WI.Color.Format.HSLA)
            this._hsl = components.slice(0, 3);
        else if (format === WI.Color.Format.ColorFunction)
            this._normalizedRGB = components.slice(0, 3);
        else
            this._rgb = components.slice(0, 3);

        this.valid = !components.some(isNaN);
    }

    // Static

    static fromString(colorString)
    {
        const matchRegExp = /^(?:#(?<hex>[0-9a-f]{3,8})|rgba?\((?<rgb>[^)]+)\)|(?<keyword>\w+)|color\((?<color>[^)]+)\)|hsla?\((?<hsl>[^)]+)\))$/i;
        let match = colorString.match(matchRegExp);
        if (!match)
            return null;

        if (match.groups.hex) {
            let hex = match.groups.hex.toUpperCase();
            switch (hex.length) {
            case 3:
                return new WI.Color(WI.Color.Format.ShortHEX, [
                    parseInt(hex.charAt(0) + hex.charAt(0), 16),
                    parseInt(hex.charAt(1) + hex.charAt(1), 16),
                    parseInt(hex.charAt(2) + hex.charAt(2), 16),
                    1
                ]);

            case 6:
                return new WI.Color(WI.Color.Format.HEX, [
                    parseInt(hex.substring(0, 2), 16),
                    parseInt(hex.substring(2, 4), 16),
                    parseInt(hex.substring(4, 6), 16),
                    1
                ]);

            case 4:
                return new WI.Color(WI.Color.Format.ShortHEXAlpha, [
                    parseInt(hex.charAt(0) + hex.charAt(0), 16),
                    parseInt(hex.charAt(1) + hex.charAt(1), 16),
                    parseInt(hex.charAt(2) + hex.charAt(2), 16),
                    parseInt(hex.charAt(3) + hex.charAt(3), 16) / 255
                ]);

            case 8:
                return new WI.Color(WI.Color.Format.HEXAlpha, [
                    parseInt(hex.substring(0, 2), 16),
                    parseInt(hex.substring(2, 4), 16),
                    parseInt(hex.substring(4, 6), 16),
                    parseInt(hex.substring(6, 8), 16) / 255
                ]);
            }

            return null;
        }

        if (match.groups.keyword) {
            let keyword = match.groups.keyword.toLowerCase();
            if (!WI.Color.Keywords.hasOwnProperty(keyword))
                return null;
            let color = new WI.Color(WI.Color.Format.Keyword, WI.Color.Keywords[keyword].slice());
            color.keyword = keyword;
            color.original = colorString;
            return color;
        }

        function splitFunctionString(string) {
            return string.trim().replace(/(\s*(,|\/)\s*|\s+)/g, "|").split("|");
        }

        function parseFunctionAlpha(alpha) {
            let value = parseFloat(alpha);
            if (alpha.includes("%"))
                value /= 100;
            return Number.constrain(value, 0, 1);
        }

        if (match.groups.rgb) {
            let rgb = splitFunctionString(match.groups.rgb);
            if (rgb.length !== 3 && rgb.length !== 4)
                return null;

            function parseFunctionComponent(component) {
                let value = parseFloat(component);
                if (component.includes("%"))
                    value = value * 255 / 100;
                return Number.constrain(value, 0, 255);
            }

            let alpha = 1;
            if (rgb.length === 4)
                alpha = parseFunctionAlpha(rgb[3]);

            return new WI.Color(rgb.length === 4 ? WI.Color.Format.RGBA : WI.Color.Format.RGB, [
                parseFunctionComponent(rgb[0]),
                parseFunctionComponent(rgb[1]),
                parseFunctionComponent(rgb[2]),
                alpha,
            ]);
        }

        if (match.groups.hsl) {
            let hsl = splitFunctionString(match.groups.hsl);
            if (hsl.length !== 3 && hsl.length !== 4)
                return null;

            let alpha = 1;
            if (hsl.length === 4)
                alpha = parseFunctionAlpha(hsl[3]);

            function parseHueComponent(hue) {
                let value = parseFloat(hue);
                if (/(\b|\d)rad\b/.test(hue))
                    value = value * 180 / Math.PI;
                else if (/(\b|\d)grad\b/.test(hue))
                    value = value * 360 / 400;
                else if (/(\b|\d)turn\b/.test(hue))
                    value = value * 360;
                return Number.constrain(value, 0, 360);
            }

            function parsePercentageComponent(component) {
                let value = parseFloat(component);
                return Number.constrain(value, 0, 100);
            }

            return new WI.Color(hsl.length === 4 ? WI.Color.Format.HSLA : WI.Color.Format.HSL, [
                parseHueComponent(hsl[0]),
                parsePercentageComponent(hsl[1]),
                parsePercentageComponent(hsl[2]),
                alpha,
            ]);
        }

        if (match.groups.color) {
            let colorString = match.groups.color.trim();
            let components = splitFunctionString(colorString);
            if (components.length !== 4 && components.length !== 5)
                return null;

            let gamut = components[0].toLowerCase();
            if (!Object.values(WI.Color.Gamut).includes(gamut))
                return null;

            let alpha = 1;
            if (components.length === 5)
                alpha = parseFunctionAlpha(components[4]);

            function parseFunctionComponent(component) {
                let value = parseFloat(component);
                return Number.constrain(value, 0, 1);
            }

            return new WI.Color(WI.Color.Format.ColorFunction, [
                parseFunctionComponent(components[1]),
                parseFunctionComponent(components[2]),
                parseFunctionComponent(components[3]),
                alpha,
            ], gamut);
        }

        return null;
    }

    static fromStringBestMatchingSuggestedFormatAndGamut(colorString, {suggestedFormat, suggestedGamut, forceSuggestedFormatAndGamut} = {})
    {
        let newColor = WI.Color.fromString(colorString);

        if (forceSuggestedFormatAndGamut) {
            newColor.format = suggestedFormat;
            newColor.gamut = suggestedGamut;
            return newColor;
        }

        // Match the suggested gamut if we can do so losslessly.
        if (suggestedGamut === WI.Color.Gamut.DisplayP3 && newColor.gamut !== WI.Color.Gamut.DisplayP3)
            newColor.gamut = WI.Color.Gamut.DisplayP3;
        else if (suggestedGamut !== WI.Color.Gamut.DisplayP3 && newColor.gamut === WI.Color.Gamut.DisplayP3 && !newColor.isOutsideSRGB())
            newColor.gamut = WI.Color.Gamut.SRGB;

        // Non-sRGB gamuts can only be expressed in the Color Function format.
        if (newColor.gamut !== WI.Color.Gamut.SRGB)
            return newColor;

        // Match as closely as possible the suggested format, and progressively adjust the format (e.g. ShortHEX -> HEX
        // -> HEXAlpha) if an exact match would be lossy.
        switch (suggestedFormat) {
        case WI.Color.Format.Original:
            console.assert(false, "No color should have a format of 'Original'.");
            break;

        case WI.Color.Format.Keyword:
            // Use the format of the color string as-provided.
            break;

        case WI.Color.Format.HEX:
            newColor.format = newColor.simple ? WI.Color.Format.HEX : WI.Color.Format.HEXAlpha;
            break;

        case WI.Color.Format.ShortHEX:
            if (newColor.canBeSerializedAsShortHEX())
                newColor.format = newColor.simple ? WI.Color.Format.ShortHEX : WI.Color.Format.ShortHEXAlpha;
            else
                newColor.format = newColor.simple ? WI.Color.Format.HEX : WI.Color.Format.HEXAlpha;
            break;

        case WI.Color.Format.ShortHEXAlpha:
            newColor.format = newColor.canBeSerializedAsShortHEX() ? WI.Color.Format.ShortHEXAlpha : WI.Color.Format.HEXAlpha;
            break;

        case WI.Color.Format.RGB:
            newColor.format = newColor.simple ? WI.Color.Format.RGB : WI.Color.Format.RGBA;
            break;

        case WI.Color.Format.HSL:
            newColor.format = newColor.simple ? WI.Color.Format.HSL : WI.Color.Format.HSLA;
            break;

        case WI.Color.Format.HEXAlpha:
        case WI.Color.Format.RGBA:
        case WI.Color.Format.HSLA:
        case WI.Color.Format.ColorFunction:
            newColor.format = suggestedFormat;
            break;

        default:
            console.assert(false, "Should not be reached.", suggestedFormat);
            break;
        }

        return newColor;
    }

    static rgb2hsl(r, g, b)
    {
        r = WI.Color._eightBitChannel(r) / 255;
        g = WI.Color._eightBitChannel(g) / 255;
        b = WI.Color._eightBitChannel(b) / 255;

        let min = Math.min(r, g, b);
        let max = Math.max(r, g, b);
        let delta = max - min;

        let h = 0;
        let s = 0;
        let l = (max + min) / 2;

        if (delta === 0)
            h = 0;
        else if (max === r)
            h = (60 * ((g - b) / delta)) % 360;
        else if (max === g)
            h = 60 * ((b - r) / delta) + 120;
        else if (max === b)
            h = 60 * ((r - g) / delta) + 240;

        if (h < 0)
            h += 360;

        // Saturation
        if (delta === 0)
            s = 0;
        else
            s = delta / (1 - Math.abs((2 * l) - 1));

        return [
            h,
            s * 100,
            l * 100,
        ];
    }

    static hsl2rgb(h, s, l)
    {
        h = Number.constrain(h, 0, 360) % 360;
        s = Number.constrain(s, 0, 100) / 100;
        l = Number.constrain(l, 0, 100) / 100;

        let c = (1 - Math.abs((2 * l) - 1)) * s;
        let x = c * (1 - Math.abs(((h / 60) % 2) - 1));
        let m = l - (c / 2);

        let r = 0;
        let g = 0;
        let b = 0;

        if (h < 60) {
            r = c;
            g = x;
        } else if (h < 120) {
            r = x;
            g = c;
        } else if (h < 180) {
            g = c;
            b = x;
        } else if (h < 240) {
            g = x;
            b = c;
        } else if (h < 300) {
            r = x;
            b = c;
        } else if (h < 360) {
            r = c;
            b = x;
        }

        return [
            (r + m) * 255,
            (g + m) * 255,
            (b + m) * 255,
        ];
    }

    // https://en.wikipedia.org/wiki/HSL_and_HSV#HSV_to_HSL
    static hsv2hsl(h, s, v)
    {
        h = Number.constrain(h, 0, 360);
        s = Number.constrain(s, 0, 100) / 100;
        v = Number.constrain(v, 0, 100) / 100;

        let l = v - v * s / 2;
        let saturation;
        if (l === 0 || l === 1)
            saturation = 0;
        else
            saturation = (v - l) / Math.min(l, 1 - l);

        return [h, saturation * 100, l * 100];
    }

    // https://en.wikipedia.org/wiki/HSL_and_HSV#From_RGB
    static rgb2hsv(r, g, b)
    {
        r = Number.constrain(r, 0, 1);
        g = Number.constrain(g, 0, 1);
        b = Number.constrain(b, 0, 1);

        let max = Math.max(r, g, b);
        let min = Math.min(r, g, b);
        let h = 0;
        let delta = max - min;
        let s = max === 0 ? 0 : delta / max;
        let v = max;

        if (max === min)
            h = 0; // Grayscale.
        else {
            switch (max) {
            case r:
                h = ((g - b) / delta) + ((g < b) ? 6 : 0);
                break;
            case g:
                h = ((b - r) / delta) + 2;
                break;
            case b:
                h = ((r - g) / delta) + 4;
                break;
            }
            h /= 6;
        }

        return [h * 360, s * 100, v * 100];
    }

    // https://en.wikipedia.org/wiki/HSL_and_HSV#HSV_to_RGB_alternative
    static hsv2rgb(h, s, v)
    {
        h = Number.constrain(h, 0, 360);
        s = Number.constrain(s, 0, 100) / 100;
        v = Number.constrain(v, 0, 100) / 100;

        function fraction(n) {
            let k = (n + (h / 60)) % 6;
            return v - (v * s * Math.max(Math.min(k, 4 - k, 1), 0));
        }
        return [fraction(5), fraction(3), fraction(1)];
    }

    // https://www.w3.org/TR/css-color-4/#color-conversion-code
    static displayP3toSRGB(r, g, b)
    {
        r = Number.constrain(r, 0, 1);
        g = Number.constrain(g, 0, 1);
        b = Number.constrain(b, 0, 1);

        let linearP3 = WI.Color._toLinearLight([r, g, b]);

        // Convert an array of linear-light display-p3 values to CIE XYZ
        // using D65 (no chromatic adaptation).
        // http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html
        const rgbToXYZMatrix = [
            [0.4865709486482162, 0.26566769316909306, 0.1982172852343625],
            [0.2289745640697488, 0.6917385218365064,  0.079286914093745],
            [0.0000000000000000, 0.04511338185890264, 1.043944368900976],
        ];
        let xyz = Math.multiplyMatrixByVector(rgbToXYZMatrix, linearP3);

        // Convert XYZ to linear-light sRGB.
        const xyzToLinearSRGBMatrix = [
            [ 3.2404542, -1.5371385, -0.4985314],
            [-0.9692660,  1.8760108,  0.0415560],
            [ 0.0556434, -0.2040259,  1.0572252],
        ];
        let linearSRGB = Math.multiplyMatrixByVector(xyzToLinearSRGBMatrix, xyz);

        let srgb = WI.Color._gammaCorrect(linearSRGB);
        return srgb.map((x) => x.maxDecimals(4));
    }

    // https://www.w3.org/TR/css-color-4/#color-conversion-code
    static srgbToDisplayP3(r, g, b)
    {
        r = Number.constrain(r, 0, 1);
        g = Number.constrain(g, 0, 1);
        b = Number.constrain(b, 0, 1);

        let linearSRGB = WI.Color._toLinearLight([r, g, b]);

        // Convert an array of linear-light sRGB values to CIE XYZ
        // using sRGB's own white, D65 (no chromatic adaptation)
        // http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html
        const linearSRGBtoXYZMatrix = [
            [0.4124564,  0.3575761,  0.1804375],
            [0.2126729,  0.7151522,  0.0721750],
            [0.0193339,  0.1191920,  0.9503041],
        ];
        let xyz = Math.multiplyMatrixByVector(linearSRGBtoXYZMatrix, linearSRGB);

        const xyzToLinearP3Matrix = [
            [ 2.493496911941425,   -0.9313836179191239, -0.40271078445071684],
            [-0.8294889695615747,   1.7626640603183463,  0.023624685841943577],
            [ 0.03584583024378447, -0.07617238926804182, 0.9568845240076872],
        ];
        let linearP3 = Math.multiplyMatrixByVector(xyzToLinearP3Matrix, xyz);

        let p3 = WI.Color._gammaCorrect(linearP3);
        return p3.map((x) => x.maxDecimals(4));
    }

    // Convert gamma-corrected sRGB or Display-P3 to linear light form.
    // https://www.w3.org/TR/css-color-4/#color-conversion-code
    static _toLinearLight(rgb)
    {
        return rgb.map(function(value) {
            if (value < 0.04045)
                return value / 12.92;

            return Math.pow((value + 0.055) / 1.055, 2.4);
        });
    }

    // Convert linear-light sRGB or Display-P3 to gamma corrected form.
    // Inverse of `toLinearLight`.
    // https://www.w3.org/TR/css-color-4/#color-conversion-code
    static _gammaCorrect(rgb)
    {
        return rgb.map(function(value) {
            if (value > 0.0031308)
                return 1.055 * Math.pow(value, 1 / 2.4) - 0.055;

            return 12.92 * value;
        });
    }

    static cmyk2rgb(c, m, y, k)
    {
        c = Number.constrain(c, 0, 1);
        m = Number.constrain(m, 0, 1);
        y = Number.constrain(y, 0, 1);
        k = Number.constrain(k, 0, 1);
        return [
            255 * (1 - c) * (1 - k),
            255 * (1 - m) * (1 - k),
            255 * (1 - y) * (1 - k),
        ];
    }

    static normalized2rgb(r, g, b)
    {
        return [
            WI.Color._eightBitChannel(r * 255),
            WI.Color._eightBitChannel(g * 255),
            WI.Color._eightBitChannel(b * 255)
        ];
    }

    static _eightBitChannel(value)
    {
        return Number.constrain(Math.round(value), 0, 255);
    }

    static contrastComplianceForRatio(ratio, {isLargeText} = {})
    {
        let aaaThreshold = isLargeText ? WI.Color.ContrastThreshold.AA : WI.Color.ContrastThreshold.AAA;
        if (ratio >= aaaThreshold)
            return WI.Color.ContrastCompliance.AAA;

        let aaThreshold = isLargeText ? WI.Color.ContrastThreshold.AALargeText : WI.Color.ContrastThreshold.AA;
        if (ratio >= aaThreshold)
            return WI.Color.ContrastCompliance.AA;

        return WI.Color.ContrastCompliance.Fail;
    }

    // Public

    nextFormat(format)
    {
        format = format || this.format;

        switch (format) {
        case WI.Color.Format.Original:
        case WI.Color.Format.HEX:
        case WI.Color.Format.HEXAlpha:
            return this.simple ? WI.Color.Format.RGB : WI.Color.Format.RGBA;

        case WI.Color.Format.RGB:
        case WI.Color.Format.RGBA:
            return WI.Color.Format.ColorFunction;

        case WI.Color.Format.ColorFunction:
            if (this.simple)
                return WI.Color.Format.HSL;
            return WI.Color.Format.HSLA;

        case WI.Color.Format.HSL:
        case WI.Color.Format.HSLA:
            if (this.isKeyword())
                return WI.Color.Format.Keyword;
            if (this.simple)
                return this.canBeSerializedAsShortHEX() ? WI.Color.Format.ShortHEX : WI.Color.Format.HEX;
            return this.canBeSerializedAsShortHEX() ? WI.Color.Format.ShortHEXAlpha : WI.Color.Format.HEXAlpha;

        case WI.Color.Format.ShortHEX:
            return WI.Color.Format.HEX;

        case WI.Color.Format.ShortHEXAlpha:
            return WI.Color.Format.HEXAlpha;

        case WI.Color.Format.Keyword:
            if (this.simple)
                return this.canBeSerializedAsShortHEX() ? WI.Color.Format.ShortHEX : WI.Color.Format.HEX;
            return this.canBeSerializedAsShortHEX() ? WI.Color.Format.ShortHEXAlpha : WI.Color.Format.HEXAlpha;

        default:
            console.error("Unknown color format.");
            return null;
        }
    }

    get simple()
    {
        return this.alpha === 1;
    }

    get rgb()
    {
        if (!this._rgb) {
            if (this._hsl)
                this._rgb = WI.Color.hsl2rgb(...this._hsl);
            else if (this._normalizedRGB)
                this._rgb = this._normalizedRGB.map((component) => WI.Color._eightBitChannel(component * 255));
        }
        return this._rgb;
    }

    get hsl()
    {
        if (!this._hsl)
            this._hsl = WI.Color.rgb2hsl(...this.rgb);
        return this._hsl;
    }

    get normalizedRGB()
    {
        if (!this._normalizedRGB)
            this._normalizedRGB = this.rgb.map((component) => component / 255);
        return this._normalizedRGB;
    }

    get rgba()
    {
        return [...this.rgb, this.alpha];
    }

    get hsla()
    {
        return [...this.hsl, this.alpha];
    }

    get normalizedRGBA()
    {
        return [...this.normalizedRGB, this.alpha];
    }

    get gamut()
    {
        return this._gamut;
    }

    set gamut(gamut)
    {
        console.assert(gamut !== this._gamut);

        if (this._gamut === WI.Color.Gamut.DisplayP3 && gamut === WI.Color.Gamut.SRGB) {
            this._normalizedRGB = WI.Color.displayP3toSRGB(...this.normalizedRGB).map((x) => Number.constrain(x, 0, 1));
            this._hsl = null;
            this._rgb = null;
        } else if (this._gamut === WI.Color.Gamut.SRGB && gamut === WI.Color.Gamut.DisplayP3) {
            this._normalizedRGB = WI.Color.srgbToDisplayP3(...this.normalizedRGB);
            this._hsl = null;
            this._rgb = null;

            // Display-P3 is only available with the color function syntax.
            this.format = WI.Color.Format.ColorFunction;
        }

        this._gamut = gamut;
    }

    copy()
    {
        switch (this.format) {
        case WI.Color.Format.RGB:
        case WI.Color.Format.HEX:
        case WI.Color.Format.ShortHEX:
        case WI.Color.Format.HEXAlpha:
        case WI.Color.Format.ShortHEXAlpha:
        case WI.Color.Format.Keyword:
        case WI.Color.Format.RGBA:
            return new WI.Color(this.format, this.rgba, this._gamut);
        case WI.Color.Format.HSL:
        case WI.Color.Format.HSLA:
            return new WI.Color(this.format, this.hsla, this._gamut);
        case WI.Color.Format.ColorFunction:
            return new WI.Color(this.format, this.normalizedRGBA, this._gamut);
        }

        console.error("Invalid color format: " + this.format);
    }

    toString(format)
    {
        if (!format)
            format = this.format;

        switch (format) {
        case WI.Color.Format.Original:
            return this._toOriginalString();
        case WI.Color.Format.RGB:
            return this._toRGBString();
        case WI.Color.Format.RGBA:
            return this._toRGBAString();
        case WI.Color.Format.ColorFunction:
            return this._toFunctionString();
        case WI.Color.Format.HSL:
            return this._toHSLString();
        case WI.Color.Format.HSLA:
            return this._toHSLAString();
        case WI.Color.Format.HEX:
            return this._toHEXString();
        case WI.Color.Format.ShortHEX:
            return this._toShortHEXString();
        case WI.Color.Format.HEXAlpha:
            return this._toHEXAlphaString();
        case WI.Color.Format.ShortHEXAlpha:
            return this._toShortHEXAlphaString();
        case WI.Color.Format.Keyword:
            return this._toKeywordString();
        }

        console.error("Invalid color format: " + format);
        return "";
    }

    toProtocol()
    {
        let [r, g, b, a] = this.rgba;
        return {r, g, b, a};
    }

    isKeyword()
    {
        if (this.keyword)
            return true;

        if (this._gamut !== WI.Color.Gamut.SRGB)
            return false;

        if (!this.simple)
            return Array.shallowEqual(this.rgba, [0, 0, 0, 0]);

        return Object.keys(WI.Color.Keywords).some(key => Array.shallowEqual(WI.Color.Keywords[key], this.rgb));
    }

    isOutsideSRGB()
    {
        if (this._gamut !== WI.Color.Gamut.DisplayP3)
            return false;

        let rgb = WI.Color.displayP3toSRGB(...this.normalizedRGB);

        // displayP3toSRGB(1, 1, 1) produces [0.9999, 1, 1.0001], which aren't pure white color values.
        // However, `color(sRGB 0.9999 1 1.0001)` looks exactly the same as color `color(sRGB 1 1 1)`
        // because sRGB is only 8bit per channel. The values get rounded. For example,
        // `rgb(255, 254.51, 255)` looks exactly the same as `rgb(255, 255, 255)`.
        //
        // Consider a color to be within sRGB even if it's actually outside of sRGB by less than half a bit.
        const epsilon = (1 / 255) / 2;
        return rgb.some((x) => x <= -epsilon || x >= 1 + epsilon);
    }

    canBeSerializedAsShortHEX()
    {
        let rgb = this.rgb;

        let r = this._componentToHexValue(rgb[0]);
        if (r[0] !== r[1])
            return false;

        let g = this._componentToHexValue(rgb[1]);
        if (g[0] !== g[1])
            return false;

        let b = this._componentToHexValue(rgb[2]);
        if (b[0] !== b[1])
            return false;

        if (!this.simple) {
            let a = this._componentToHexValue(Math.round(this.alpha * 255));
            if (a[0] !== a[1])
                return false;
        }

        return true;
    }

    getNextValidHEXFormat()
    {
        const hexFormats = [
            {format: WI.Color.Format.ShortHEX, title: WI.UIString("Format: Short Hex")},
            {format: WI.Color.Format.ShortHEXAlpha, title: WI.UIString("Format: Short Hex with Alpha")},
            {format: WI.Color.Format.HEX, title: WI.UIString("Format: Hex")},
            {format: WI.Color.Format.HEXAlpha, title: WI.UIString("Format: Hex with Alpha")},
        ];

        let hexMatchesColor = (hexInfo) => {
            let nextIsSimple = hexInfo.format === WI.Color.Format.ShortHEX || hexInfo.format === WI.Color.Format.HEX;
            if (nextIsSimple && !this.simple)
                return false;

            let nextIsShort = hexInfo.format === WI.Color.Format.ShortHEX || hexInfo.format === WI.Color.Format.ShortHEXAlpha;
            if (nextIsShort && !this.canBeSerializedAsShortHEX())
                return false;

            return true;
        };

        let currentColorIsHEX = hexFormats.some((info) => info.format === this.format);

        for (let i = 0; i < hexFormats.length; ++i) {
            if (currentColorIsHEX && this.format !== hexFormats[i].format)
                continue;

            for (let j = ~~currentColorIsHEX; j < hexFormats.length; ++j) {
                let nextIndex = (i + j) % hexFormats.length;
                if (hexMatchesColor(hexFormats[nextIndex]))
                    return hexFormats[nextIndex];
            }
            return null;
        }
        return hexFormats[0];
    }

    // Private

    _toOriginalString()
    {
        return this.original || this._toKeywordString();
    }

    _toKeywordString()
    {
        if (this.keyword)
            return this.keyword;

        let rgba = this.rgba;
        if (!this.simple) {
            if (Array.shallowEqual(rgba, [0, 0, 0, 0]))
                return "transparent";
            return this._toRGBAString();
        }

        let keywords = WI.Color.Keywords;
        for (let keyword in keywords) {
            if (!keywords.hasOwnProperty(keyword))
                continue;

            let keywordRGB = keywords[keyword];
            if (keywordRGB[0] === rgba[0] && keywordRGB[1] === rgba[1] && keywordRGB[2] === rgba[2])
                return keyword;
        }

        return this._toRGBString();
    }

    _toShortHEXString()
    {
        if (!this.simple)
            return this._toRGBAString();

        let [r, g, b] = this.rgb.map(this._componentToHexValue);
        if (r[0] === r[1] && g[0] === g[1] && b[0] === b[1])
            return "#" + r[0] + g[0] + b[0];
        return "#" + r + g + b;
    }

    _toHEXString()
    {
        if (!this.simple)
            return this._toRGBAString();

        let [r, g, b] = this.rgb.map(this._componentToHexValue);
        return "#" + r + g + b;
    }

    _toShortHEXAlphaString()
    {
        let [r, g, b] = this.rgb.map(this._componentToHexValue);
        let a = this._componentToHexValue(Math.round(this.alpha * 255));
        if (r[0] === r[1] && g[0] === g[1] && b[0] === b[1] && a[0] === a[1])
            return "#" + r[0] + g[0] + b[0] + a[0];
        return "#" + r + g + b + a;
    }

    _toHEXAlphaString()
    {
        let [r, g, b] = this.rgb.map(this._componentToHexValue);
        let a = this._componentToHexValue(Math.round(this.alpha * 255));
        return "#" + r + g + b + a;
    }

    _toRGBString()
    {
        if (!this.simple)
            return this._toRGBAString();

        let [r, g, b] = this.rgb.map(WI.Color._eightBitChannel);
        return `rgb(${r}, ${g}, ${b})`;
    }

    _toRGBAString()
    {
        let [r, g, b] = this.rgb.map(WI.Color._eightBitChannel);
        return `rgba(${r}, ${g}, ${b}, ${this.alpha})`;
    }

    _toFunctionString()
    {
        let [r, g, b] = this.normalizedRGB.map((x) => x.maxDecimals(4));
        if (this.alpha === 1)
            return `color(${this._gamut} ${r} ${g} ${b})`;
        return `color(${this._gamut} ${r} ${g} ${b} / ${this.alpha})`;
    }

    _toHSLString()
    {
        if (!this.simple)
            return this._toHSLAString();

        let [h, s, l] = this.hsl.map((x) => x.maxDecimals(2));
        return `hsl(${h}, ${s}%, ${l}%)`;
    }

    _toHSLAString()
    {
        let [h, s, l] = this.hsl.map((x) => x.maxDecimals(2));
        return `hsla(${h}, ${s}%, ${l}%, ${this.alpha})`;
    }

    _componentToHexValue(value)
    {
        let hex = WI.Color._eightBitChannel(value).toString(16);
        if (hex.length === 1)
            hex = "0" + hex;
        return hex;
    }

    // https://www.w3.org/TR/WCAG20/#relativeluminancedef
    relativeLuminance()
    {
        let [r, g, b] = WI.Color._toLinearLight(this.normalizedRGB);
        return (0.2126729 * r) + (0.7151522 * g) + (0.0721750 * b);
    }

    contrastRatio(other)
    {
        console.assert(other instanceof WI.Color, other);

        let l1 = this.relativeLuminance();
        let l2 = other.relativeLuminance();

        if (l1 < l2)
            [l1, l2] = [l2, l1];

        return (l1 + 0.05) / (l2 + 0.05);
    }

    contrastCompliance(other, {isLargeText} = {})
    {
        let ratio = this.contrastRatio(other);
        return WI.Color.contrastComplianceForRatio(ratio, {isLargeText});
    }

    blendOverBackground(backgroundColor)
    {
        console.assert(backgroundColor instanceof WI.Color, backgroundColor);

        if (this.alpha === 1)
            return this.copy();

        let foregroundRGB = this.normalizedRGB;
        let backgroundRGB = backgroundColor.normalizedRGB;
        let alpha = this.alpha;

        let blendedRGB = [
            (foregroundRGB[0] * alpha) + (backgroundRGB[0] * (1 - alpha)),
            (foregroundRGB[1] * alpha) + (backgroundRGB[1] * (1 - alpha)),
            (foregroundRGB[2] * alpha) + (backgroundRGB[2] * (1 - alpha)),
        ];

        return new WI.Color(WI.Color.Format.ColorFunction, blendedRGB, this.gamut);
    }

};

WI.Color.Format = {
    Original: "color-format-original",
    Keyword: "color-format-keyword",
    HEX: "color-format-hex",
    ShortHEX: "color-format-short-hex",
    HEXAlpha: "color-format-hex-alpha",
    ShortHEXAlpha: "color-format-short-hex-alpha",
    RGB: "color-format-rgb",
    RGBA: "color-format-rgba",
    HSL: "color-format-hsl",
    HSLA: "color-format-hsla",
    ColorFunction: "color-format-color-function",
};

WI.Color.Gamut = {
    SRGB: "srgb",
    DisplayP3: "display-p3",
};

WI.Color.FunctionNames = new Set([
    "rgb",
    "rgba",
    "hsl",
    "hsla",
    "color",
    "color-mix",
]);

WI.Color.ContrastThreshold = {
    AAA: 7,
    AA: 4.5,
    AALargeText: 3,
};

WI.Color.ContrastCompliance = {
    AAA: "AAA",
    AA: "AA",
    Fail: "Fail",
};

WI.Color.Keywords = {
    "aliceblue": [240, 248, 255, 1],
    "antiquewhite": [250, 235, 215, 1],
    "aqua": [0, 255, 255, 1],
    "aquamarine": [127, 255, 212, 1],
    "azure": [240, 255, 255, 1],
    "beige": [245, 245, 220, 1],
    "bisque": [255, 228, 196, 1],
    "black": [0, 0, 0, 1],
    "blanchedalmond": [255, 235, 205, 1],
    "blue": [0, 0, 255, 1],
    "blueviolet": [138, 43, 226, 1],
    "brown": [165, 42, 42, 1],
    "burlywood": [222, 184, 135, 1],
    "cadetblue": [95, 158, 160, 1],
    "chartreuse": [127, 255, 0, 1],
    "chocolate": [210, 105, 30, 1],
    "coral": [255, 127, 80, 1],
    "cornflowerblue": [100, 149, 237, 1],
    "cornsilk": [255, 248, 220, 1],
    "crimson": [220, 20, 60, 1],
    "cyan": [0, 255, 255, 1],
    "darkblue": [0, 0, 139, 1],
    "darkcyan": [0, 139, 139, 1],
    "darkgoldenrod": [184, 134, 11, 1],
    "darkgray": [169, 169, 169, 1],
    "darkgreen": [0, 100, 0, 1],
    "darkgrey": [169, 169, 169, 1],
    "darkkhaki": [189, 183, 107, 1],
    "darkmagenta": [139, 0, 139, 1],
    "darkolivegreen": [85, 107, 47, 1],
    "darkorange": [255, 140, 0, 1],
    "darkorchid": [153, 50, 204, 1],
    "darkred": [139, 0, 0, 1],
    "darksalmon": [233, 150, 122, 1],
    "darkseagreen": [143, 188, 143, 1],
    "darkslateblue": [72, 61, 139, 1],
    "darkslategray": [47, 79, 79, 1],
    "darkslategrey": [47, 79, 79, 1],
    "darkturquoise": [0, 206, 209, 1],
    "darkviolet": [148, 0, 211, 1],
    "deeppink": [255, 20, 147, 1],
    "deepskyblue": [0, 191, 255, 1],
    "dimgray": [105, 105, 105, 1],
    "dimgrey": [105, 105, 105, 1],
    "dodgerblue": [30, 144, 255, 1],
    "firebrick": [178, 34, 34, 1],
    "floralwhite": [255, 250, 240, 1],
    "forestgreen": [34, 139, 34, 1],
    "fuchsia": [255, 0, 255, 1],
    "gainsboro": [220, 220, 220, 1],
    "ghostwhite": [248, 248, 255, 1],
    "gold": [255, 215, 0, 1],
    "goldenrod": [218, 165, 32, 1],
    "gray": [128, 128, 128, 1],
    "green": [0, 128, 0, 1],
    "greenyellow": [173, 255, 47, 1],
    "grey": [128, 128, 128, 1],
    "honeydew": [240, 255, 240, 1],
    "hotpink": [255, 105, 180, 1],
    "indianred": [205, 92, 92, 1],
    "indigo": [75, 0, 130, 1],
    "ivory": [255, 255, 240, 1],
    "khaki": [240, 230, 140, 1],
    "lavender": [230, 230, 250, 1],
    "lavenderblush": [255, 240, 245, 1],
    "lawngreen": [124, 252, 0, 1],
    "lemonchiffon": [255, 250, 205, 1],
    "lightblue": [173, 216, 230, 1],
    "lightcoral": [240, 128, 128, 1],
    "lightcyan": [224, 255, 255, 1],
    "lightgoldenrodyellow": [250, 250, 210, 1],
    "lightgray": [211, 211, 211, 1],
    "lightgreen": [144, 238, 144, 1],
    "lightgrey": [211, 211, 211, 1],
    "lightpink": [255, 182, 193, 1],
    "lightsalmon": [255, 160, 122, 1],
    "lightseagreen": [32, 178, 170, 1],
    "lightskyblue": [135, 206, 250, 1],
    "lightslategray": [119, 136, 153, 1],
    "lightslategrey": [119, 136, 153, 1],
    "lightsteelblue": [176, 196, 222, 1],
    "lightyellow": [255, 255, 224, 1],
    "lime": [0, 255, 0, 1],
    "limegreen": [50, 205, 50, 1],
    "linen": [250, 240, 230, 1],
    "magenta": [255, 0, 255, 1],
    "maroon": [128, 0, 0, 1],
    "mediumaquamarine": [102, 205, 170, 1],
    "mediumblue": [0, 0, 205, 1],
    "mediumorchid": [186, 85, 211, 1],
    "mediumpurple": [147, 112, 219, 1],
    "mediumseagreen": [60, 179, 113, 1],
    "mediumslateblue": [123, 104, 238, 1],
    "mediumspringgreen": [0, 250, 154, 1],
    "mediumturquoise": [72, 209, 204, 1],
    "mediumvioletred": [199, 21, 133, 1],
    "midnightblue": [25, 25, 112, 1],
    "mintcream": [245, 255, 250, 1],
    "mistyrose": [255, 228, 225, 1],
    "moccasin": [255, 228, 181, 1],
    "navajowhite": [255, 222, 173, 1],
    "navy": [0, 0, 128, 1],
    "oldlace": [253, 245, 230, 1],
    "olive": [128, 128, 0, 1],
    "olivedrab": [107, 142, 35, 1],
    "orange": [255, 165, 0, 1],
    "orangered": [255, 69, 0, 1],
    "orchid": [218, 112, 214, 1],
    "palegoldenrod": [238, 232, 170, 1],
    "palegreen": [152, 251, 152, 1],
    "paleturquoise": [175, 238, 238, 1],
    "palevioletred": [219, 112, 147, 1],
    "papayawhip": [255, 239, 213, 1],
    "peachpuff": [255, 218, 185, 1],
    "peru": [205, 133, 63, 1],
    "pink": [255, 192, 203, 1],
    "plum": [221, 160, 221, 1],
    "powderblue": [176, 224, 230, 1],
    "purple": [128, 0, 128, 1],
    "rebeccapurple": [102, 51, 153, 1],
    "red": [255, 0, 0, 1],
    "rosybrown": [188, 143, 143, 1],
    "royalblue": [65, 105, 225, 1],
    "saddlebrown": [139, 69, 19, 1],
    "salmon": [250, 128, 114, 1],
    "sandybrown": [244, 164, 96, 1],
    "seagreen": [46, 139, 87, 1],
    "seashell": [255, 245, 238, 1],
    "sienna": [160, 82, 45, 1],
    "silver": [192, 192, 192, 1],
    "skyblue": [135, 206, 235, 1],
    "slateblue": [106, 90, 205, 1],
    "slategray": [112, 128, 144, 1],
    "slategrey": [112, 128, 144, 1],
    "snow": [255, 250, 250, 1],
    "springgreen": [0, 255, 127, 1],
    "steelblue": [70, 130, 180, 1],
    "tan": [210, 180, 140, 1],
    "teal": [0, 128, 128, 1],
    "thistle": [216, 191, 216, 1],
    "tomato": [255, 99, 71, 1],
    "transparent": [0, 0, 0, 0],
    "turquoise": [64, 224, 208, 1],
    "violet": [238, 130, 238, 1],
    "wheat": [245, 222, 179, 1],
    "white": [255, 255, 255, 1],
    "whitesmoke": [245, 245, 245, 1],
    "yellow": [255, 255, 0, 1],
    "yellowgreen": [154, 205, 50, 1],
};
