<?php
/**
 * This file is part of Jdenticon for PHP.
 * https://github.com/dmester/jdenticon-php/
 * 
 * Copyright (c) 2025 Daniel Mester Pirttijärvi
 * 
 * For full license information, please see the LICENSE file that was 
 * distributed with this source code.
 */

namespace Jdenticon;

/**
 * Represents a 24-bit color with a 8-bit alpha channel.
 */
class Color
{
    /** @var array<float> */
    private static array $lightnessCompensations = [0.55, 0.5, 0.5, 0.46, 0.6, 0.55, 0.55];
    
    /**
     * The red component of the color in the range [0, 255].
     */
    public int $r;
    
    /**
     * The green component of the color in the range [0, 255].
     */
    public int $g;
    
    /**
     * The blue component of the color in the range [0, 255].
     */
    public int $b;
    
    /**
     * The alpha component of the color in the range [0, 255].
     */
    public int $a;

    // Users of the struct should use the static factory methods 
    // to create Color value.
    private function __construct()
    {
    }

    /**
     * Creates a Color from an RGB value.
     *
     * @param int $red Red component in the range [0, 255].
     * @param int $green Green component in the range [0, 255].
     * @param int $blue Blue component in the range [0, 255].
     * @param int $alpha Alpha channel value in the range [0, 255].
     * @return \Jdenticon\Color
     */
    public static function fromRgb(int $red, int $green, int $blue, int $alpha = 255): Color
    {
        $color = new Color();
        $color->r = $red;
        $color->g = $green;
        $color->b = $blue;
        $color->a = $alpha;
        return $color;
    }

    /**
     * Creates a Color instance from HSL color parameters.
     *
     * @param float $hue Hue in the range [0, 1].
     * @param float $saturation Saturation in the range [0, 1].
     * @param float $lightness Lightness in the range [0, 1].
     * @param float $alpha Alpha channel value in the range [0, 1].
     * @return \Jdenticon\Color
     */
    public static function fromHsl(float $hue, float $saturation, float $lightness, float $alpha = 1.0): Color
    {
        if ($hue < 0) $hue = 0;
        if ($hue > 1) $hue = 1;
        
        if ($saturation < 0) $saturation = 0;
        if ($saturation > 1) $saturation = 1;
        
        if ($lightness < 0) $lightness = 0;
        if ($lightness > 1) $lightness = 1;
        
        if ($alpha < 0) $alpha = 0;
        if ($alpha > 1) $alpha = 1;

        // Based on http://www.w3.org/TR/2011/REC-css3-color-20110607/#hsl-color
        if ($saturation == 0) {
            $value = (int)($lightness * 255);
            return self::fromRgb($value, $value, $value, (int)($alpha * 255));
        } else {
            if ($lightness <= 0.5) {
                $m2 = $lightness * ($saturation + 1);
            } else {
                $m2 = $lightness + $saturation - $lightness * $saturation;
            }
            
            $m1 = $lightness * 2 - $m2;

            return self::fromRgb(
                self::hueToRgb($m1, $m2, $hue * 6 + 2),
                self::hueToRgb($m1, $m2, $hue * 6),
                self::hueToRgb($m1, $m2, $hue * 6 - 2),
                (int)($alpha * 255));
        }
    }
    
    /**
     * Creates a Color instance from HSL color parameters and compensates 
     * the lightness for hues that appear to be darker than others.
     *
     * @param float $hue Hue in the range [0, 1].
     * @param float $saturation Saturation in the range [0, 1].
     * @param float $lightness Lightness in the range [0, 1].
     * @param float $alpha Alpha channel value in the range [0, 1].
     * @return \Jdenticon\Color
     */
    public static function fromHslCompensated(float $hue, float $saturation, float $lightness, float $alpha = 1.0): Color
    {
        if ($hue < 0) $hue = 0;
        if ($hue > 1) $hue = 1;
        
        $lightnessCompensation = self::$lightnessCompensations[(int)($hue * 6 + 0.5)];
        
        // Adjust the input lightness relative to the compensation
        $lightness = $lightness < 0.5 ?
            $lightness * $lightnessCompensation * 2 : 
            $lightnessCompensation + ($lightness - 0.5) * (1 - $lightnessCompensation) * 2;

        return self::fromHsl($hue, $saturation, $lightness, $alpha);
    }

    // Helper method for FromHsl
    private static function hueToRgb(float $m1, float $m2, float $h): int
    {
        if ($h < 0) {
            $h = $h + 6;
        } elseif ($h > 6) {
            $h = $h - 6;
        }
        
        if ($h < 1) {
            $r = $m1 + ($m2 - $m1) * $h;
        } elseif ($h < 3) {
            $r = $m2;
        } elseif ($h < 4) {
            $r = $m1 + ($m2 - $m1) * (4 - $h);
        } else {
            $r = $m1;
        }
        
        return (int)(255 * $r);
    }

    /**
     * Gets the argb value of this color.
     *
     * @return int
     */
    public function toRgba(): int
    {
        return 
            ($this->r << 24) |
            ($this->g << 16) |
            ($this->b << 8) |
            ($this->a);
    }

    /**
     * Gets a hexadecimal representation of this color on the format #rrggbbaa.
     *
     * @return string
     */
    public function __toString(): string
    {
        return '#' . bin2hex(pack('N', $this->toRgba()));
    }
    
    /**
     * Gets a hexadecimal representation of this color on the format #rrggbbaa.
     *
     * @param int $length Length of the hex string (6 or 8).
     * @return string
     */
    public function toHexString(int $length = 8): string
    {
        if ($length === 8) {
            return $this->__toString();
        }
        return '#' . substr(bin2hex(pack('N', $this->toRgba())), 0, 6);
    }
    
    /**
     * Tries to parse a value as a Color.
     * 
     * @param mixed $value Value to parse.
     * @throws \InvalidArgumentException
     * @return \Jdenticon\Color
     */
    public static function parse($value): Color
    {
        if ($value instanceof Color) {
            return $value;
        }
        
        $value = strtolower("$value");
        
        if (preg_match('/^#?[0-9a-f]{3,8}$/', $value) &&
            self::parseHexColor($value, $result)
        ) {
            return $result;
        }
        
        if (preg_match(
                '/^rgba?\\(([^,]+),([^,]+),([^,]+)(?:,([^,]+))?\\)$/', 
                $value, $matches) &&
            self::parseRgbComponent($matches[1], $r) &&
            self::parseRgbComponent($matches[2], $g) &&
            self::parseRgbComponent($matches[3], $b) &&
            self::parseAlpha(isset($matches[4]) ? $matches[4] : null, $a)
        ) {
            return self::fromRgb($r, $g, $b, (int)(255 * $a));
        }
        
        if (preg_match(
                '/^hsla?\\(([^,]+),([^,]+),([^,]+)(?:,([^,]+))?\\)$/', 
                $value, $matches) &&
            self::parseHue($matches[1], $h) &&
            self::parsePercent($matches[2], $s) &&
            self::parsePercent($matches[3], $l) &&
            self::parseAlpha(isset($matches[4]) ? $matches[4] : null, $a)
        ) {
            return self::fromHsl($h, $s, $l, $a);
        }
        
        $result = self::parseNamedColor($value);
        if ($result !== null) {
            return $result;
        }
        
        throw new \InvalidArgumentException(
            "Cannot parse '$value' as a color.");
    }
    
    /**
     * Parses a percent value.
     * 
     * @param string $input Input string.
     * @param float $result Resulting value in range [0, 1].
     * 
     * @return boolean
     */
    private static function parsePercent(string $input, ?float &$result): bool
    {
        // Detect and remove percent sign
        if (preg_match('/^\\s*(\\d*(?:\\.\\d*)?)%\\s*$/', $input, $matches)) {
            $result = floatval($matches[1]) / 100;
            
            if ($result < 0) $result = 0;
            if ($result > 1) $result = 1;
            
            return true;
        }
        return false;
    }

    /**
     * Parses an alpha value.
     * 
     * @param string $input Input string.
     * @param float $result Resulting alpha in range [0, 1].
     * 
     * @return boolean
     */
    private static function parseAlpha(?string $input, ?float &$result): bool
    {
        if ($input === null ||
            $input === ''
        ) {
            $result = 1;
            return true;
        }
        
        if (preg_match('/^\\s*(\\d*(?:\\.\\d*)?)(%?)\\s*$/', $input, $matches)) {
            $result = floatval($matches[1]);
            
            // Percentage
            if ($matches[2] !== '') {
                $result = $result / 100;
            }
            
            if ($result < 0) $result = 0;
            if ($result > 1) $result = 1;
            
            return true;
        }
        
        return false;
    }

    /**
     * Parses an RGB component.
     * 
     * @param string $input Input string.
     * @param int $result RGB component in range [0, 255].
     * 
     * @return boolean
     */
    private static function parseRgbComponent(string $input, ?int &$result): bool
    {
        if (preg_match('/^\\s*(\\d*(?:\\.\\d*)?)(%?)\\s*$/', $input, $matches)) {
            $float = floatval($matches[1]);
            
            if ($matches[2] === '%') {
                $float = 255 * $float / 100;
            }
            
            $result = (int)$float;
            
            if ($result < 0) $result = 0;
            if ($result > 255) $result = 255;
            
            return true;
        }
        return false;
    }

     /**
     * Parses a hue component.
     * 
     * @param string $input Input string.
     * @param float $result Hue in range [0, 1].
     * 
     * @return boolean
     */
    private static function parseHue(string $input, ?float &$result): bool
    {
        if (preg_match(
            '/^\s*(\d*(?:\.\d*)?)(deg|grad|rad|turn|)\s*$/', 
            $input, $matches)
        ) {
            $result = floatval($matches[1]);
            
            // Percentage
            switch ($matches[2]) {
                case "grad":
                    // Gradians: range 0 - 400
                    $result = $result / 400;
                    break;
                case "rad":
                    // Radians: range 0 - 2pi
                    $result = $result / M_PI / 2;
                    break;
                case "turn":
                    // Turns: range 0 - 1
                    // No change
                    break;
                default:
                    // Degree: range 0 - 360
                    $result = $result / 360;
                    break;
            }
            
            $result = fmod($result, 1);
            
            if ($result < 0) {
                $result += 1;
            }
            
            return true;
        }
        return false;
    }

    /**
     * Parses a hex color string.
     * 
     * @param string $input Input string.
     * @param \Jdenticon\Color $result Parsed color.
     * 
     * @return boolean
     */
    private static function parseHexColor(string $input, ?Color &$result): bool
    {
        if ($input[0] === '#') {
            $input = substr($input, 1);
        }

        // intval does not support unsigned 32-bit integers
        // so we need to parse large numbers stepwise
        $numeric24bit = intval(substr($input, 0, 6), 16);
        $alpha8bit = intval(substr($input, 6, 2), 16);

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